返回 oh-my-ppt
assets-handlers.ts
根目录 / src / main / io / assets-handlers.ts
1 import { is } from '@electron-toolkit/utils'
2 import { BrowserWindow, dialog, ipcMain, protocol } from 'electron'
3 import fs from 'fs'
4 import path from 'path'
5 import type { IpcContext } from '../ipc/context'
6 import { getUserFontsRoot } from '../presentation/fonts/font-registry'
7 import {
8 allowLocalAssetRoot,
9 getDynamicAllowedLocalAssetRoots,
10 normalizeExistingPath
11 } from './local-asset-roots'
12
13 export { allowLocalAssetRoot } from './local-asset-roots'
14
15 const ASSET_MIME_MAP: Record<string, string> = {
16 png: 'image/png',
17 jpg: 'image/jpeg',
18 jpeg: 'image/jpeg',
19 webp: 'image/webp',
20 gif: 'image/gif',
21 svg: 'image/svg+xml',
22 mp4: 'video/mp4',
23 webm: 'video/webm',
24 ogg: 'video/ogg',
25 ogv: 'video/ogg',
26 js: 'text/javascript',
27 css: 'text/css',
28 woff2: 'font/woff2',
29 woff: 'font/woff',
30 ttf: 'font/ttf',
31 html: 'text/html'
32 }
33
34 const getResourcesRoot = (): string =>
35 is.dev
36 ? path.join(process.cwd(), 'resources')
37 : path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
38
39 const isPathInside = (candidate: string, root: string): boolean => {
40 const relative = path.relative(root, candidate)
41 return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
42 }
43
44 const getStaticAllowedRoots = (): string[] => [getResourcesRoot(), getUserFontsRoot()]
45
46 export const resolveAllowedLocalAssetPath = (filePath: string): string | null => {
47 const normalizedFile = normalizeExistingPath(filePath)
48 const roots = [...getStaticAllowedRoots(), ...getDynamicAllowedLocalAssetRoots()]
49 .map(normalizeExistingPath)
50 .filter((root) => root.length > 0)
51 return roots.some((root) => isPathInside(normalizedFile, root)) ? normalizedFile : null
52 }
53
54 export function registerLocalAssetProtocol(): void {
55 protocol.handle('local-asset', (request) => {
56 const requestedPath = decodeURIComponent(
57 request.url.replace('local-asset://', '').split(/[?#]/, 1)[0]
58 )
59 const filePath = resolveAllowedLocalAssetPath(requestedPath)
60 if (!filePath) return new Response('Forbidden', { status: 403 })
61 try {
62 const stat = fs.statSync(filePath)
63 if (!stat.isFile()) return new Response('Not found', { status: 404 })
64 const ext = filePath.split('.').pop()?.toLowerCase() || ''
65 const mime = ASSET_MIME_MAP[ext] || 'application/octet-stream'
66 const fileSize = stat.size
67
68 const range = request.headers.get('range')
69 if (range) {
70 const m = /bytes=(\d*)-(\d*)/.exec(range)
71 if (!m) return new Response('Invalid range', { status: 416 })
72 const start = m[1] ? parseInt(m[1], 10) : 0
73 const end = m[2] ? Math.min(parseInt(m[2], 10), fileSize - 1) : fileSize - 1
74 if (start > end || start >= fileSize) {
75 return new Response('Range not satisfiable', { status: 416 })
76 }
77 const len = end - start + 1
78 const fd = fs.openSync(filePath, 'r')
79 const buf = Buffer.alloc(len)
80 fs.readSync(fd, buf, 0, len, start)
81 fs.closeSync(fd)
82 return new Response(buf, {
83 status: 206,
84 headers: {
85 'content-type': mime,
86 'content-range': `bytes ${start}-${end}/${fileSize}`,
87 'content-length': String(len),
88 'accept-ranges': 'bytes'
89 }
90 })
91 }
92
93 const data = fs.readFileSync(filePath)
94 return new Response(data, {
95 headers: {
96 'content-type': mime,
97 'accept-ranges': 'bytes',
98 'content-length': String(fileSize)
99 }
100 })
101 } catch {
102 return new Response('Not found', { status: 404 })
103 }
104 })
105 }
106
107 export function registerAssetHandlers(ctx: IpcContext): void {
108 const { mainWindow, uploadMediaAssets, resolveSessionProjectDir } = ctx
109
110 ipcMain.handle('assets:upload', async (_event, payload: unknown) => {
111 const record =
112 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
113 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
114 const files = Array.isArray(record.files)
115 ? (record.files as Array<Record<string, unknown>>)
116 : []
117 return { assets: await uploadMediaAssets(sessionId, files) }
118 })
119
120 ipcMain.handle('assets:chooseAndUpload', async (event, payload: unknown) => {
121 const record =
122 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
123 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
124 const assetType =
125 record.assetType === 'video' ? 'video' : record.assetType === 'image' ? 'image' : 'image'
126 if (!sessionId) throw new Error('sessionId 不能为空')
127
128 const win = BrowserWindow.fromWebContents(event.sender) || mainWindow
129 const result = await dialog.showOpenDialog(win, {
130 title: assetType === 'video' ? '选择视频素材' : '选择图片素材',
131 properties: ['openFile', 'multiSelections'],
132 filters:
133 assetType === 'video'
134 ? [{ name: 'Videos', extensions: ['mp4', 'webm', 'ogg'] }]
135 : [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'] }]
136 })
137 if (result.canceled || result.filePaths.length === 0) {
138 return { assets: [], cancelled: true }
139 }
140 const assets = await uploadMediaAssets(
141 sessionId,
142 result.filePaths.map((filePath) => ({
143 path: filePath,
144 name: path.basename(filePath)
145 }))
146 )
147 return { assets, cancelled: false }
148 })
149
150 ipcMain.handle('assets:list', async (_event, payload: unknown) => {
151 const record =
152 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
153 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
154 const assetType =
155 record.assetType === 'video' ? 'video' : record.assetType === 'image' ? 'image' : 'image'
156 if (!sessionId) throw new Error('sessionId 不能为空')
157
158 const dirName = assetType === 'video' ? 'videos' : 'images'
159 const projectDir = await resolveSessionProjectDir(sessionId)
160 const targetDir = path.join(projectDir, dirName)
161 allowLocalAssetRoot(targetDir)
162 if (!fs.existsSync(targetDir)) return { assets: [] }
163
164 const files = await fs.promises.readdir(targetDir)
165 const assets = files
166 .filter((f) => !f.startsWith('.'))
167 .map((f) => ({
168 fileName: f,
169 relativePath: `./${dirName}/${f}`,
170 absolutePath: path.join(targetDir, f)
171 }))
172 return { assets }
173 })
174 }
175
175 lines TYPESCRIPT