From a79af3f3fee00f248231bd9d13ccf8684a91def7 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:51:47 +0200 Subject: [PATCH] fix(security): gate privileged file IPC with capabilities --- src/main.ts | 298 ++++++++++++++++++------ src/main/domain/file-capability.test.ts | 120 ++++++++++ src/main/domain/file-capability.ts | 206 ++++++++++++++++ src/preload.ts | 58 +++-- src/renderer-cutter.ts | 46 ++-- src/renderer-globals.d.ts | 46 ++-- src/renderer-settings.ts | 6 +- src/renderer-shared.ts | 4 +- src/renderer-streamers.ts | 8 +- src/renderer.ts | 14 +- 10 files changed, 662 insertions(+), 144 deletions(-) create mode 100644 src/main/domain/file-capability.test.ts create mode 100644 src/main/domain/file-capability.ts diff --git a/src/main.ts b/src/main.ts index a4c9231..f6286cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -40,6 +40,13 @@ import { buildVodPreviewFrameUrls } from './main/domain/vod-preview'; import { getWindowsAppIdentity } from './main/domain/app-identity'; import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor'; import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export'; +import { + FileCapabilityStore, + isTrustedFileIpcSender, + publishCapabilityOutput, + type FileCapabilityPurpose, + type FileCapabilityReference, +} from './main/domain/file-capability'; import { setDebugLogFn, initToolDirs, getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, @@ -293,7 +300,15 @@ interface VideoEditorAssetProfile { interface VideoEditExportRequest { inputFile: string; - outputFile?: string; + outputFile: string; + trimStart: number; + trimEnd: number; + cuts: EditorCut[]; +} + +interface RendererVideoEditExportRequest { + inputCapability: string; + outputName?: string; trimStart: number; trimEnd: number; cuts: EditorCut[]; @@ -1560,6 +1575,7 @@ function emitQueueUpdated(force = false): void { } lastQueueBroadcastFingerprint = nextFingerprint; + rememberQueueFilePaths(downloadQueue); mainWindow?.webContents.send('queue-updated', downloadQueue); updateTaskbarProgress(); } @@ -7216,7 +7232,8 @@ ipcMain.handle('trigger-auto-vod-scan', async () => { return { queuedCount }; }); -ipcMain.handle('save-config', (_, newConfig: Partial) => { +ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability?: string) => { + if (!isTrustedRendererEvent(event)) return config; const previousClientId = config.client_id; const previousClientSecret = config.client_secret; const previousCacheMinutes = config.metadata_cache_minutes; @@ -7228,7 +7245,16 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => { const previousAutoVodMinutes = config.auto_vod_download_poll_minutes; const previousStreamerList = JSON.stringify(config.streamers || []); - config = normalizeConfigTemplates({ ...config, ...newConfig }); + const acceptedConfig = { ...newConfig }; + if (typeof acceptedConfig.download_path === 'string' && acceptedConfig.download_path !== config.download_path) { + const selectedPath = typeof fileCapability === 'string' + ? resolveFileCapability(event, fileCapability, 'selected-folder') + : null; + if (!selectedPath || normalizeComparablePath(selectedPath) !== normalizeComparablePath(acceptedConfig.download_path)) { + delete acceptedConfig.download_path; + } + } + config = normalizeConfigTemplates({ ...config, ...acceptedConfig }); if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) { accessToken = null; @@ -7303,7 +7329,11 @@ ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = fal return await getVODs(userId, forceRefresh); }); -ipcMain.handle('get-queue', () => downloadQueue); +ipcMain.handle('get-queue', (event) => { + if (!isTrustedRendererEvent(event)) return []; + rememberQueueFilePaths(downloadQueue); + return downloadQueue; +}); ipcMain.handle('start-live-recording', async (_, streamerName: string) => { if (typeof streamerName !== 'string' || !streamerName) { @@ -7618,29 +7648,106 @@ ipcMain.handle('cancel-download', async () => { return true; }); -ipcMain.handle('select-folder', async () => { +const fileCapabilities = new FileCapabilityStore(); +const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi']; +const knownRendererPaths = new Map>(); + +function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = []): FileCapabilityReference { + return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions }); +} + +function resolveFileCapability(event: IpcMainInvokeEvent, token: string, purpose: FileCapabilityPurpose, consume = false, protectedPaths: string[] = []): string | null { + if (!isTrustedRendererEvent(event)) return null; + try { + return consume + ? fileCapabilities.consume(token, event.sender.id, purpose, protectedPaths) + : fileCapabilities.resolve(token, event.sender.id, purpose); + } catch (error) { + appendDebugLog('file-capability-rejected', { purpose, error: String(error) }); + return null; + } +} + +function rememberRendererPath(purpose: FileCapabilityPurpose, filePath: string): void { + if (typeof filePath !== 'string' || !filePath || !path.isAbsolute(filePath) || !fs.existsSync(filePath)) return; + const normalized = normalizeComparablePath(filePath); + const paths = knownRendererPaths.get(purpose) ?? new Set(); + paths.delete(normalized); + paths.add(normalized); + while (paths.size > 4096) paths.delete(paths.values().next().value as string); + knownRendererPaths.set(purpose, paths); +} + +function rememberQueueFilePaths(queueItems: QueueItem[]): void { + for (const item of queueItems) { + for (const filePath of item.outputFiles ?? []) { + rememberRendererPath('open-file', filePath); + rememberRendererPath('show-in-folder', filePath); + if (/\.(?:chat\.json|chat\.jsonl|events\.jsonl)$/i.test(filePath)) rememberRendererPath('chat-input', filePath); + } + } +} + +function isKnownRendererPath(purpose: FileCapabilityPurpose, candidate: string): boolean { + if (typeof candidate !== 'string' || !candidate || !path.isAbsolute(candidate) || !fs.existsSync(candidate)) return false; + const normalized = normalizeComparablePath(candidate); + if (purpose === 'selected-folder' && normalized === normalizeComparablePath(config.download_path)) return true; + return knownRendererPaths.get(purpose)?.has(normalized) === true; +} + +ipcMain.handle('select-folder', async (event) => { + if (!isTrustedRendererEvent(event)) return null; const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openDirectory'] }); - return result.filePaths[0] || null; + const selectedPath = result.filePaths[0]; + if (!selectedPath) return null; + const capability = issueFileCapability(event, 'selected-folder', selectedPath, 'directory'); + return { ...capability, displayPath: selectedPath }; }); -ipcMain.handle('select-video-file', async () => { +ipcMain.handle('select-video-file', async (event) => { + if (!isTrustedRendererEvent(event)) return null; const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openFile'], filters: [ - { name: 'Video Files', extensions: ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'] } + { name: 'Video Files', extensions: VIDEO_FILE_EXTENSIONS } ] }); - return result.filePaths[0] || null; + return result.filePaths[0] + ? issueFileCapability(event, 'cutter-input', result.filePaths[0], 'input-file', VIDEO_FILE_EXTENSIONS) + : null; }); -ipcMain.handle('open-folder', (_, folderPath: string) => { - if (fs.existsSync(folderPath)) { - shell.openPath(folderPath); +ipcMain.handle('grant-dropped-video', (event, filePath: string): FileCapabilityReference | null => { + if (!isTrustedRendererEvent(event)) return null; + try { + return issueFileCapability(event, 'cutter-input', filePath, 'input-file', VIDEO_FILE_EXTENSIONS); + } catch { + return null; } }); +ipcMain.handle('authorize-managed-path', (event, purpose: FileCapabilityPurpose, pathOrCapability: string): FileCapabilityReference | null => { + if (!isTrustedRendererEvent(event) || !['selected-folder', 'chat-input', 'open-file', 'show-in-folder'].includes(purpose)) return null; + try { + const existingPath = fileCapabilities.resolve(pathOrCapability, event.sender.id, purpose); + return { token: pathOrCapability, name: path.basename(existingPath) }; + } catch { } + if (!isKnownRendererPath(purpose, pathOrCapability)) return null; + if (purpose === 'selected-folder') { + if (!fs.statSync(pathOrCapability).isDirectory()) return null; + return issueFileCapability(event, purpose, pathOrCapability, 'directory'); + } + const extensions = purpose === 'chat-input' ? ['.chat.json', '.chat.jsonl', '.events.jsonl'] : []; + return issueFileCapability(event, purpose, pathOrCapability, 'input-file', extensions); +}); + +ipcMain.handle('open-folder', async (event, capability: string) => { + const folderPath = resolveFileCapability(event, capability, 'selected-folder', true); + if (folderPath) await shell.openPath(folderPath); +}); + // Extensions that shell.openPath would happily execute via the system // default. Calc.exe via XSS smuggling is the canonical example; this // list blocks the obvious vectors. Media/text/image extensions are @@ -7651,9 +7758,9 @@ const OPEN_FILE_BLOCKED_EXTENSIONS = new Set([ '.lnk', '.cpl', '.reg', '.hta', '.jar', '.application' ]); -ipcMain.handle('open-file', async (_, filePath: string): Promise => { - if (typeof filePath !== 'string' || !filePath) return false; - if (!fs.existsSync(filePath)) return false; +ipcMain.handle('open-file', async (event, capability: string): Promise => { + const filePath = resolveFileCapability(event, capability, 'open-file', true); + if (!filePath) return false; const ext = path.extname(filePath).toLowerCase(); if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) { appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) }); @@ -7664,9 +7771,9 @@ ipcMain.handle('open-file', async (_, filePath: string): Promise => { return result === ''; }); -ipcMain.handle('show-in-folder', (_, filePath: string): boolean => { - if (typeof filePath !== 'string' || !filePath) return false; - if (!fs.existsSync(filePath)) return false; +ipcMain.handle('show-in-folder', (event, capability: string): boolean => { + const filePath = resolveFileCapability(event, capability, 'show-in-folder', true); + if (!filePath) return false; shell.showItemInFolder(filePath); return true; }); @@ -7856,13 +7963,15 @@ ipcMain.handle('get-debug-log', async (_, lines: number = 200) => { return readDebugLog(safeLines); }); -ipcMain.handle('open-debug-log-file', (): boolean => { +ipcMain.handle('open-debug-log-file', (event): boolean => { + if (!isTrustedRendererEvent(event)) return false; if (!fs.existsSync(DEBUG_LOG_FILE)) return false; shell.showItemInFolder(DEBUG_LOG_FILE); return true; }); -ipcMain.handle('get-archive-stats', (): ArchiveStats => { +ipcMain.handle('get-archive-stats', (event): ArchiveStats => { + if (!isTrustedRendererEvent(event)) throw new Error('File access denied'); return computeArchiveStats(); }); @@ -7884,7 +7993,8 @@ ipcMain.handle('get-live-status-snapshot', (): Record => { return snap; }); -ipcMain.handle('search-archive', (_, filter: Partial): ArchiveSearchResult => { +ipcMain.handle('search-archive', (event, filter: Partial): ArchiveSearchResult => { + if (!isTrustedRendererEvent(event)) throw new Error('File access denied'); const normalized: ArchiveSearchFilter = { query: typeof filter?.query === 'string' ? filter.query.trim() : '', type: (['all', 'live', 'vod', 'chat', 'events'] as const).includes(filter?.type as 'all' | 'live' | 'vod' | 'chat' | 'events') @@ -7898,23 +8008,38 @@ ipcMain.handle('search-archive', (_, filter: Partial): Arch : 'date_desc', limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200 }; - return searchArchive(normalized); + const result = searchArchive(normalized); + for (const hit of result.hits) { + rememberRendererPath('open-file', hit.fullPath); + rememberRendererPath('show-in-folder', hit.fullPath); + for (const sidecar of [hit.chatPath, hit.eventsPath]) { + if (!sidecar) continue; + rememberRendererPath('open-file', sidecar); + rememberRendererPath('show-in-folder', sidecar); + rememberRendererPath('chat-input', sidecar); + } + } + return result; }); -ipcMain.handle('get-storage-stats', (): StorageStatsResult => { - return computeStorageStats(); +ipcMain.handle('get-storage-stats', (event): StorageStatsResult => { + if (!isTrustedRendererEvent(event)) throw new Error('File access denied'); + const result = computeStorageStats(); + for (const row of [...result.streamers, ...result.extras]) rememberRendererPath('selected-folder', row.folderPath); + return result; }); -ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): CleanupReport => { +ipcMain.handle('run-storage-cleanup', (event, options?: { dryRun?: boolean }): CleanupReport => { + if (!isTrustedRendererEvent(event)) throw new Error('File access denied'); return runStorageCleanup({ dryRun: options?.dryRun === true }); }); // Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and // return a normalized message list the renderer can display directly. // Caps at 50k messages to stop a runaway file from killing the renderer. -ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number } => { - if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' }; - if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' }; +ipcMain.handle('read-chat-file', (event, capability: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number } => { + const filePath = resolveFileCapability(event, capability, 'chat-input', true); + if (!filePath) return { success: false, error: 'File access denied' }; const MAX_MESSAGES = 50000; try { @@ -7961,8 +8086,9 @@ ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; erro } }); -ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => { - if (typeof folderPath !== 'string' || !folderPath) return false; +ipcMain.handle('check-folder-writable', (event, capability: string): boolean => { + const folderPath = resolveFileCapability(event, capability, 'selected-folder', true); + if (!folderPath) return false; return isDownloadPathWritable(folderPath); }); @@ -7970,7 +8096,8 @@ ipcMain.handle('is-downloading', () => isDownloading && !queuePaused); ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot()); -ipcMain.handle('export-runtime-metrics', async () => { +ipcMain.handle('export-runtime-metrics', async (event) => { + if (!isTrustedRendererEvent(event)) return { success: false, error: 'File access denied' }; try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const defaultName = `runtime-metrics-${timestamp}.json`; @@ -7985,12 +8112,15 @@ ipcMain.handle('export-runtime-metrics', async () => { return { success: false, cancelled: true }; } + const outputCapability = issueFileCapability(event, 'runtime-export', dialogResult.filePath, 'output-file', ['json']); + const outputFile = resolveFileCapability(event, outputCapability.token, 'runtime-export', true); + if (!outputFile) return { success: false, error: 'File access denied' }; const snapshot = getRuntimeMetricsSnapshot(); // Atomic write: same fsync+rename pattern used for config/queue // (cycle 1) so a power loss mid-export can't leave a half-written // metrics file at the user's chosen path. - writeFileAtomicSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2)); - return { success: true, filePath: dialogResult.filePath }; + writeFileAtomicSync(outputFile, JSON.stringify(snapshot, null, 2)); + return { success: true, filePath: outputFile }; } catch (e) { appendDebugLog('runtime-metrics-export-failed', String(e)); return { success: false, error: String(e) }; @@ -8021,7 +8151,8 @@ ipcMain.handle('reset-downloaded-vod-ids', () => { return { success: true, removedCount: count }; }); -ipcMain.handle('export-config', async () => { +ipcMain.handle('export-config', async (event) => { + if (!isTrustedRendererEvent(event)) return { success: false, error: 'File access denied' }; try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const defaultName = `twitch-vod-manager-config-${timestamp}.json`; @@ -8039,21 +8170,25 @@ ipcMain.handle('export-config', async () => { // Strip the secrets from the export — Client Secret should not // travel as plain text across machines / cloud sync. The user // re-enters it on the new machine after import. + const outputCapability = issueFileCapability(event, 'config-export', dialogResult.filePath, 'output-file', ['json']); + const outputFile = resolveFileCapability(event, outputCapability.token, 'config-export', true); + if (!outputFile) return { success: false, error: 'File access denied' }; const exportable = { ...config, client_secret: '', __exportVersion: 1, __exportedAt: new Date().toISOString() }; - writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2)); - return { success: true, filePath: dialogResult.filePath }; + writeFileAtomicSync(outputFile, JSON.stringify(exportable, null, 2)); + return { success: true, filePath: outputFile }; } catch (e) { appendDebugLog('config-export-failed', String(e)); return { success: false, error: String(e) }; } }); -ipcMain.handle('import-config', async () => { +ipcMain.handle('import-config', async (event) => { + if (!isTrustedRendererEvent(event)) return { success: false, error: 'File access denied' }; try { const dialogResult = await dialog.showOpenDialog(mainWindow!, { properties: ['openFile'], @@ -8063,7 +8198,9 @@ ipcMain.handle('import-config', async () => { return { success: false, cancelled: true }; } - const importPath = dialogResult.filePaths[0]; + const importCapability = issueFileCapability(event, 'config-import', dialogResult.filePaths[0], 'input-file', ['json']); + const importPath = resolveFileCapability(event, importCapability.token, 'config-import', true); + if (!importPath) return { success: false, error: 'File access denied' }; const raw = fs.readFileSync(importPath, 'utf-8'); const parsed = JSON.parse(raw); if (!isPlainObject(parsed)) { @@ -8092,10 +8229,10 @@ ipcMain.handle('import-config', async () => { }); function isTrustedRendererEvent(event: IpcMainInvokeEvent): boolean { - if (!mainWindow || event.sender.id !== mainWindow.webContents.id) return false; + if (!mainWindow) return false; const rendererUrl = pathToFileURL(path.join(__dirname, '../src/index.html')).href; const senderUrl = event.senderFrame?.url || event.sender.getURL(); - return senderUrl.split(/[?#]/, 1)[0] === rendererUrl; + return isTrustedFileIpcSender(mainWindow.webContents.id, rendererUrl, event.sender.id, senderUrl); } function isPathInsideDirectory(rootDirectory: string, candidate: string): boolean { @@ -8106,30 +8243,40 @@ function isPathInsideDirectory(rootDirectory: string, candidate: string): boolea } // Video Cutter IPC -ipcMain.handle('get-video-info', async (event, filePath: string) => { +ipcMain.handle('get-video-info', async (event, capability: string) => { if (!isTrustedRendererEvent(event) || appShutdownStarted) return null; + const filePath = resolveFileCapability(event, capability, 'cutter-input'); + if (!filePath) return null; return await getVideoInfo(filePath, currentCutterInfoProcesses); }); -ipcMain.handle('extract-frame', async (event, filePath: string, timeSeconds: number) => { +ipcMain.handle('extract-frame', async (event, capability: string, timeSeconds: number) => { if (!isTrustedRendererEvent(event) || appShutdownStarted) return null; + const filePath = resolveFileCapability(event, capability, 'cutter-input'); + if (!filePath) return null; return await extractFrame(filePath, timeSeconds); }); -ipcMain.handle('prepare-video-editor-media', async (event, filePath: string) => { +ipcMain.handle('prepare-video-editor-media', async (event, capability: string) => { if (!isTrustedRendererEvent(event) || appShutdownStarted) return null; + const filePath = resolveFileCapability(event, capability, 'cutter-input'); + if (!filePath) return null; const media = await prepareVideoEditorMedia(filePath); if (media && cutterMediaJob?.jobId === media.jobId) cutterPreparedInput = cutterMediaJob.identity; return media; }); -ipcMain.handle('prepare-video-editor-waveform', async (event, filePath: string, jobId: number) => { +ipcMain.handle('prepare-video-editor-waveform', async (event, capability: string, jobId: number) => { if (!isTrustedRendererEvent(event) || appShutdownStarted) return null; + const filePath = resolveFileCapability(event, capability, 'cutter-input'); + if (!filePath) return null; return await prepareVideoEditorWaveform(filePath, jobId); }); -ipcMain.handle('prepare-video-editor-assets', async (event, filePath: string, jobId: number, profile: VideoEditorAssetProfile) => { +ipcMain.handle('prepare-video-editor-assets', async (event, capability: string, jobId: number, profile: VideoEditorAssetProfile) => { if (!isTrustedRendererEvent(event) || appShutdownStarted) return null; + const filePath = resolveFileCapability(event, capability, 'cutter-input'); + if (!filePath) return null; return await prepareVideoEditorAssets(filePath, jobId, profile); }); @@ -8139,26 +8286,34 @@ ipcMain.handle('cancel-video-editor-assets', (event, jobId: number) => { return true; }); -ipcMain.handle('export-video-edit', async (event, request: VideoEditExportRequest) => { - if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputFile !== 'string') return { success: false, outputFile: null }; - if (!cutterInputIdentityMatches(request.inputFile)) return { success: false, outputFile: null }; +ipcMain.handle('export-video-edit', async (event, request: RendererVideoEditExportRequest) => { + if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputCapability !== 'string') return { success: false, outputName: null }; + const inputFile = resolveFileCapability(event, request.inputCapability, 'cutter-input'); + if (!inputFile || !cutterInputIdentityMatches(inputFile)) return { success: false, outputName: null }; let outputFile: string | null = null; const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT; - if (testRoot && typeof request.outputFile === 'string' && isPathInsideDirectory(testRoot, request.outputFile)) { - outputFile = request.outputFile; + if (testRoot && typeof request.outputName === 'string' && path.basename(request.outputName) === request.outputName) { + const candidate = path.join(testRoot, request.outputName); + if (isPathInsideDirectory(testRoot, candidate)) { + const outputCapability = issueFileCapability(event, 'cutter-output', candidate, 'output-file', ['mp4']); + outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]); + } } else { - const defaultName = path.join(path.dirname(request.inputFile), `${path.basename(request.inputFile, path.extname(request.inputFile))}_edited.mp4`); + const defaultName = path.join(path.dirname(inputFile), `${path.basename(inputFile, path.extname(inputFile))}_edited.mp4`); const result = await dialog.showSaveDialog(mainWindow!, { defaultPath: defaultName, filters: [{ name: 'MP4 Video', extensions: ['mp4'] }], }); - if (result.canceled || !result.filePath) return { success: false, outputFile: null, cancelled: true }; - outputFile = result.filePath; + if (result.canceled || !result.filePath) return { success: false, outputName: null, cancelled: true }; + const outputCapability = issueFileCapability(event, 'cutter-output', result.filePath, 'output-file', ['mp4']); + outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]); } - const outcome = await exportVideoEdit({ ...request, outputFile }, (percent) => { + if (!outputFile) return { success: false, outputName: null }; + const outcome = await exportVideoEdit({ inputFile, outputFile, trimStart: request.trimStart, trimEnd: request.trimEnd, cuts: request.cuts }, (percent) => { mainWindow?.webContents.send('cut-progress', percent); }); - return { success: outcome.success, outputFile: outcome.success ? outputFile : null, cancelled: outcome.cancelled || undefined }; + const outputCapability = outcome.success ? issueFileCapability(event, 'show-in-folder', outputFile, 'input-file', ['mp4']) : null; + return { success: outcome.success, outputCapability: outputCapability?.token, outputName: outcome.success ? path.basename(outputFile) : null, cancelled: outcome.cancelled || undefined }; }); ipcMain.handle('cancel-video-edit', (event) => { @@ -8170,7 +8325,9 @@ ipcMain.handle('cancel-video-edit', (event) => { return true; }); -ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => { +ipcMain.handle('cut-video', async (event, inputCapability: string, startTime: number, endTime: number) => { + const inputFile = resolveFileCapability(event, inputCapability, 'cutter-input', true); + if (!inputFile) return { success: false, outputName: null }; const dir = path.dirname(inputFile); const baseName = path.basename(inputFile, path.extname(inputFile)); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19); @@ -8182,36 +8339,45 @@ ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endT mainWindow?.webContents.send('cut-progress', percent); }); - return { success, outputFile: success ? outputFile : null }; + return { success, outputName: success ? path.basename(outputFile) : null }; }); // Merge IPC -ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => { - const success = await mergeVideos(inputFiles, outputFile, (percent) => { +ipcMain.handle('merge-videos', async (event, inputCapabilities: string[], outputCapability: string) => { + if (!isTrustedRendererEvent(event) || !Array.isArray(inputCapabilities) || inputCapabilities.length < 2) return { success: false, outputName: null }; + const inputFiles = inputCapabilities.map((capability) => resolveFileCapability(event, capability, 'merge-input')); + const outputFile = resolveFileCapability(event, outputCapability, 'merge-output'); + if (inputFiles.some((file): file is null => !file) || !outputFile) return { success: false, outputName: null }; + for (const capability of inputCapabilities) { + if (!resolveFileCapability(event, capability, 'merge-input', true)) return { success: false, outputName: null }; + } + if (!resolveFileCapability(event, outputCapability, 'merge-output', true, inputFiles as string[])) return { success: false, outputName: null }; + const success = await publishCapabilityOutput(outputFile, async (partialFile) => await mergeVideos(inputFiles as string[], partialFile, (percent) => { mainWindow?.webContents.send('merge-progress', percent); - }); - - return { success, outputFile: success ? outputFile : null }; + })); + return { success, outputName: success ? path.basename(outputFile) : null }; }); -ipcMain.handle('select-multiple-videos', async () => { +ipcMain.handle('select-multiple-videos', async (event) => { + if (!isTrustedRendererEvent(event)) return null; const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openFile', 'multiSelections'], filters: [ { name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } ] }); - return result.filePaths; + return result.filePaths.map((filePath) => issueFileCapability(event, 'merge-input', filePath, 'input-file', VIDEO_FILE_EXTENSIONS)); }); -ipcMain.handle('save-video-dialog', async (_, defaultName: string) => { +ipcMain.handle('save-video-dialog', async (event, defaultName: string) => { + if (!isTrustedRendererEvent(event)) return null; const result = await dialog.showSaveDialog(mainWindow!, { defaultPath: defaultName, filters: [ { name: 'MP4 Video', extensions: ['mp4'] } ] }); - return result.filePath || null; + return result.filePath ? issueFileCapability(event, 'merge-output', result.filePath, 'output-file', ['mp4']) : null; }); // ========================================== diff --git a/src/main/domain/file-capability.test.ts b/src/main/domain/file-capability.test.ts new file mode 100644 index 0000000..5a49c12 --- /dev/null +++ b/src/main/domain/file-capability.test.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + FileCapabilityStore, + isTrustedFileIpcSender, + publishCapabilityOutput, +} from './file-capability'; + +describe('file capability boundary', () => { + const directories: string[] = []; + + afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); + }); + + function createFixture(): { directory: string; video: string; chat: string; output: string } { + const directory = mkdtempSync(join(tmpdir(), 'tvm-capability-')); + directories.push(directory); + const video = join(directory, 'source.mp4'); + const chat = join(directory, 'source.chat.jsonl'); + writeFileSync(video, 'video'); + writeFileSync(chat, '{"message":"hello"}\n'); + return { directory, video, chat, output: join(directory, 'result.mp4') }; + } + + it('accepts only the expected renderer owner and document URL', () => { + const expectedUrl = 'file:///C:/app/src/index.html'; + expect(isTrustedFileIpcSender(17, expectedUrl, 17, `${expectedUrl}?language=de#cutter`)).toBe(true); + expect(isTrustedFileIpcSender(17, expectedUrl, 18, expectedUrl)).toBe(false); + expect(isTrustedFileIpcSender(17, expectedUrl, 17, 'file:///C:/app/src/forged.html')).toBe(false); + expect(isTrustedFileIpcSender(17, expectedUrl, 17, 'https://attacker.invalid/')).toBe(false); + }); + + it('rejects forged, wrong-owner, wrong-purpose, expired, and reused tokens', () => { + const fixture = createFixture(); + let now = 1_000; + const store = new FileCapabilityStore({ now: () => now, defaultTtlMs: 500 }); + const mergeInput = store.issue({ ownerId: 7, purpose: 'merge-input', path: fixture.video, kind: 'input-file', extensions: ['.mp4'] }); + + expect(() => store.consume('forged', 7, 'merge-input')).toThrow('Invalid file capability'); + expect(() => store.consume(mergeInput.token, 8, 'merge-input')).toThrow('Invalid file capability owner'); + expect(() => store.consume(mergeInput.token, 7, 'cutter-input')).toThrow('Invalid file capability purpose'); + expect(store.consume(mergeInput.token, 7, 'merge-input')).toBe(realpathSync.native(fixture.video)); + expect(() => store.consume(mergeInput.token, 7, 'merge-input')).toThrow('Invalid file capability'); + + const expired = store.issue({ ownerId: 7, purpose: 'chat-input', path: fixture.chat, kind: 'input-file', extensions: ['.chat.jsonl'] }); + now = 1_500; + expect(() => store.resolve(expired.token, 7, 'chat-input')).toThrow('Expired file capability'); + }); + + it('binds canonical input and output paths to the allowed extension and semantics', () => { + const fixture = createFixture(); + const store = new FileCapabilityStore(); + const aliasedInput = join(fixture.directory, 'nested', '..', 'source.mp4'); + mkdirSync(dirname(aliasedInput), { recursive: true }); + const input = store.issue({ ownerId: 3, purpose: 'cutter-input', path: aliasedInput, kind: 'input-file', extensions: ['mp4'] }); + expect(store.resolve(input.token, 3, 'cutter-input')).toBe(realpathSync.native(fixture.video)); + + expect(() => store.issue({ ownerId: 3, purpose: 'cutter-input', path: fixture.chat, kind: 'input-file', extensions: ['mp4'] })).toThrow('File extension is not allowed'); + expect(() => store.issue({ ownerId: 3, purpose: 'merge-output', path: join(fixture.directory, 'result.exe'), kind: 'output-file', extensions: ['mp4'] })).toThrow('File extension is not allowed'); + expect(() => store.issue({ ownerId: 3, purpose: 'merge-output', path: join(fixture.directory, 'missing', 'result.mp4'), kind: 'output-file', extensions: ['mp4'] })).toThrow('Output directory does not exist'); + const directoryTarget = join(fixture.directory, 'directory.mp4'); + mkdirSync(directoryTarget); + expect(() => store.issue({ ownerId: 3, purpose: 'merge-output', path: directoryTarget, kind: 'output-file', extensions: ['mp4'] })).toThrow('Output target is not a file'); + + const output = store.issue({ ownerId: 3, purpose: 'merge-output', path: fixture.output, kind: 'output-file', extensions: ['mp4'] }); + expect(store.consume(output.token, 3, 'merge-output')).toBe(fixture.output); + }); + + it('rejects an output capability that aliases a protected input', () => { + const fixture = createFixture(); + const store = new FileCapabilityStore(); + const output = store.issue({ ownerId: 3, purpose: 'merge-output', path: fixture.video, kind: 'output-file', extensions: ['mp4'] }); + + expect(() => store.consume(output.token, 3, 'merge-output', [fixture.video])).toThrow('Output path conflicts with a protected input'); + }); + + it('detects canonical-path replacement after a capability is issued', () => { + const fixture = createFixture(); + const store = new FileCapabilityStore(); + const input = store.issue({ ownerId: 3, purpose: 'cutter-input', path: fixture.video, kind: 'input-file', extensions: ['mp4'] }); + const replacement = join(fixture.directory, 'replacement.mp4'); + writeFileSync(replacement, 'replacement'); + rmSync(fixture.video); + try { + symlinkSync(replacement, fixture.video, 'file'); + } catch { + writeFileSync(fixture.video, 'changed'); + } + expect(() => store.resolve(input.token, 3, 'cutter-input')).toThrow('File capability path changed'); + }); + + it('never removes or replaces an existing destination when output production fails', async () => { + const fixture = createFixture(); + writeFileSync(fixture.output, 'existing-user-file'); + + const success = await publishCapabilityOutput(fixture.output, async (partialPath) => { + writeFileSync(partialPath, 'incomplete-merge'); + return false; + }); + + expect(success).toBe(false); + expect(readFileSync(fixture.output, 'utf8')).toBe('existing-user-file'); + }); + + it('atomically replaces the selected destination only after successful production', async () => { + const fixture = createFixture(); + writeFileSync(fixture.output, 'existing-user-file'); + + const success = await publishCapabilityOutput(fixture.output, async (partialPath) => { + writeFileSync(partialPath, 'complete-merge'); + return true; + }); + + expect(success).toBe(true); + expect(readFileSync(fixture.output, 'utf8')).toBe('complete-merge'); + }); +}); diff --git a/src/main/domain/file-capability.ts b/src/main/domain/file-capability.ts new file mode 100644 index 0000000..a49aff4 --- /dev/null +++ b/src/main/domain/file-capability.ts @@ -0,0 +1,206 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs'; +import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'; + +export type FileCapabilityPurpose = + | 'cutter-input' + | 'cutter-output' + | 'merge-input' + | 'merge-output' + | 'chat-input' + | 'config-import' + | 'config-export' + | 'runtime-export' + | 'selected-folder' + | 'open-file' + | 'show-in-folder'; + +export type FileCapabilityKind = 'input-file' | 'output-file' | 'directory'; + +export interface FileCapabilityReference { + token: string; + name: string; +} + +interface FileIdentity { + dev: number; + ino: number; + size: number; + mtimeMs: number; +} + +interface FileCapabilityGrant { + ownerId: number; + purpose: FileCapabilityPurpose; + path: string; + kind: FileCapabilityKind; + extensions: Set; + expiresAt: number; + identity: FileIdentity | null; +} + +interface IssueFileCapabilityOptions { + ownerId: number; + purpose: FileCapabilityPurpose; + path: string; + kind: FileCapabilityKind; + extensions?: string[]; + ttlMs?: number; +} + +interface FileCapabilityStoreOptions { + now?: () => number; + defaultTtlMs?: number; +} + +function normalizeExtension(extension: string): string { + const normalized = extension.trim().toLowerCase(); + return normalized.startsWith('.') ? normalized : `.${normalized}`; +} + +function comparablePath(filePath: string): string { + return process.platform === 'win32' ? filePath.toLocaleLowerCase('en-US') : filePath; +} + +function canonicalInputPath(filePath: string, kind: FileCapabilityKind): string { + if (typeof filePath !== 'string' || !filePath || !isAbsolute(filePath)) throw new Error('File path must be absolute'); + if (!existsSync(filePath)) throw new Error(kind === 'directory' ? 'Directory does not exist' : 'Input file does not exist'); + const canonical = realpathSync.native(resolve(filePath)); + const stats = statSync(canonical); + if (kind === 'directory' && !stats.isDirectory()) throw new Error('Capability path is not a directory'); + if (kind === 'input-file' && !stats.isFile()) throw new Error('Capability path is not a file'); + return canonical; +} + +function canonicalOutputPath(filePath: string): string { + if (typeof filePath !== 'string' || !filePath || !isAbsolute(filePath)) throw new Error('File path must be absolute'); + const resolved = resolve(filePath); + const parent = dirname(resolved); + if (!existsSync(parent) || !statSync(parent).isDirectory()) throw new Error('Output directory does not exist'); + if (existsSync(resolved) && !statSync(resolved).isFile()) throw new Error('Output target is not a file'); + return join(realpathSync.native(parent), basename(resolved)); +} + +function validateExtension(filePath: string, extensions: Set): void { + const lowerPath = filePath.toLowerCase(); + if (extensions.size > 0 && !Array.from(extensions).some((extension) => lowerPath.endsWith(extension))) throw new Error('File extension is not allowed'); +} + +function getIdentity(filePath: string): FileIdentity { + const stats = statSync(filePath); + return { dev: stats.dev, ino: stats.ino, size: stats.size, mtimeMs: stats.mtimeMs }; +} + +function identitiesMatch(left: FileIdentity, right: FileIdentity): boolean { + if (left.dev !== right.dev || left.size !== right.size || left.mtimeMs !== right.mtimeMs) return false; + return left.ino === 0 || right.ino === 0 || left.ino === right.ino; +} + +export function isTrustedFileIpcSender(expectedOwnerId: number, expectedUrl: string, actualOwnerId: number, actualUrl: string): boolean { + if (actualOwnerId !== expectedOwnerId || typeof actualUrl !== 'string') return false; + return actualUrl.split(/[?#]/, 1)[0] === expectedUrl; +} + +export class FileCapabilityStore { + private readonly grants = new Map(); + private readonly now: () => number; + private readonly defaultTtlMs: number; + + constructor(options: FileCapabilityStoreOptions = {}) { + this.now = options.now ?? Date.now; + this.defaultTtlMs = options.defaultTtlMs ?? 15 * 60 * 1000; + } + + issue(options: IssueFileCapabilityOptions): FileCapabilityReference { + const extensions = new Set((options.extensions ?? []).map(normalizeExtension)); + const canonical = options.kind === 'output-file' + ? canonicalOutputPath(options.path) + : canonicalInputPath(options.path, options.kind); + validateExtension(canonical, extensions); + const ttlMs = options.ttlMs ?? this.defaultTtlMs; + if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('File capability lifetime is invalid'); + const token = randomBytes(32).toString('base64url'); + this.grants.set(token, { + ownerId: options.ownerId, + purpose: options.purpose, + path: canonical, + kind: options.kind, + extensions, + expiresAt: this.now() + ttlMs, + identity: options.kind === 'input-file' ? getIdentity(canonical) : null, + }); + return { token, name: basename(canonical) }; + } + + resolve(token: string, ownerId: number, purpose: FileCapabilityPurpose): string { + return this.resolveGrant(token, ownerId, purpose, false); + } + + consume(token: string, ownerId: number, purpose: FileCapabilityPurpose, protectedPaths: string[] = []): string { + const resolved = this.resolveGrant(token, ownerId, purpose, false); + for (const protectedPath of protectedPaths) { + const canonicalProtected = existsSync(protectedPath) + ? realpathSync.native(resolve(protectedPath)) + : canonicalOutputPath(protectedPath); + const samePath = comparablePath(resolved) === comparablePath(canonicalProtected); + const sameFile = existsSync(resolved) + && existsSync(canonicalProtected) + && identitiesMatch(getIdentity(resolved), getIdentity(canonicalProtected)); + if (samePath || sameFile) throw new Error('Output path conflicts with a protected input'); + } + this.grants.delete(token); + return resolved; + } + + revoke(token: string): void { + this.grants.delete(token); + } + + private resolveGrant(token: string, ownerId: number, purpose: FileCapabilityPurpose, consume: boolean): string { + if (typeof token !== 'string' || !token) throw new Error('Invalid file capability'); + const grant = this.grants.get(token); + if (!grant) throw new Error('Invalid file capability'); + if (grant.ownerId !== ownerId) throw new Error('Invalid file capability owner'); + if (grant.purpose !== purpose) throw new Error('Invalid file capability purpose'); + if (this.now() >= grant.expiresAt) { + this.grants.delete(token); + throw new Error('Expired file capability'); + } + const currentPath = grant.kind === 'output-file' + ? canonicalOutputPath(grant.path) + : canonicalInputPath(grant.path, grant.kind); + validateExtension(currentPath, grant.extensions); + if (comparablePath(currentPath) !== comparablePath(grant.path)) throw new Error('File capability path changed'); + if (grant.identity && !identitiesMatch(grant.identity, getIdentity(currentPath))) throw new Error('File capability path changed'); + if (consume) this.grants.delete(token); + return currentPath; + } +} + +export async function publishCapabilityOutput(outputPath: string, produce: (partialPath: string) => Promise): Promise { + const canonicalOutput = canonicalOutputPath(outputPath); + const extension = extname(canonicalOutput); + const stem = basename(canonicalOutput, extension); + const partialPath = join(dirname(canonicalOutput), `.${stem}.${process.pid}.${randomBytes(8).toString('hex')}.partial${extension}`); + const backupPath = `${canonicalOutput}.${process.pid}.${randomBytes(8).toString('hex')}.backup`; + let backupCreated = false; + try { + const produced = await produce(partialPath); + if (!produced || !existsSync(partialPath) || !statSync(partialPath).isFile()) return false; + if (existsSync(canonicalOutput)) { + renameSync(canonicalOutput, backupPath); + backupCreated = true; + } + try { + renameSync(partialPath, canonicalOutput); + } catch (error) { + if (backupCreated && !existsSync(canonicalOutput)) renameSync(backupPath, canonicalOutput); + throw error; + } + if (backupCreated) rmSync(backupPath, { force: true }); + return true; + } finally { + rmSync(partialPath, { force: true }); + if (backupCreated && existsSync(backupPath) && !existsSync(canonicalOutput)) renameSync(backupPath, canonicalOutput); + } +} diff --git a/src/preload.ts b/src/preload.ts index f002bde..6c3168c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -84,18 +84,24 @@ interface VideoEditorAssetProfile { } interface VideoEditExportRequest { - inputFile: string; - outputFile?: string; + inputCapability: string; + outputName?: string; trimStart: number; trimEnd: number; cuts: Array<{ id: string; start: number; end: number }>; } +interface FileCapabilityReference { + token: string; + name: string; + displayPath?: string; +} + // Expose protected methods to renderer contextBridge.exposeInMainWorld('api', { // Config getConfig: () => ipcRenderer.invoke('get-config'), - saveConfig: (config: any) => ipcRenderer.invoke('save-config', config), + saveConfig: (config: any, fileCapability?: string) => ipcRenderer.invoke('save-config', config, fileCapability), // Auth login: () => ipcRenderer.invoke('login'), @@ -126,13 +132,22 @@ contextBridge.exposeInMainWorld('api', { selectFolder: () => ipcRenderer.invoke('select-folder'), selectVideoFile: () => ipcRenderer.invoke('select-video-file'), selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'), - getPathForFile: (file: File): string => webUtils.getPathForFile(file), + selectDroppedVideo: (file: File) => ipcRenderer.invoke('grant-dropped-video', webUtils.getPathForFile(file)), saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName), - openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), - openFile: (path: string) => ipcRenderer.invoke('open-file', path), - showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path), + openFolder: async (pathOrCapability: string) => { + const capability = await ipcRenderer.invoke('authorize-managed-path', 'selected-folder', pathOrCapability); + if (capability) return ipcRenderer.invoke('open-folder', capability.token); + }, + openFile: async (pathOrCapability: string) => { + const capability = await ipcRenderer.invoke('authorize-managed-path', 'open-file', pathOrCapability); + return capability ? ipcRenderer.invoke('open-file', capability.token) : false; + }, + showInFolder: async (pathOrCapability: string) => { + const capability = await ipcRenderer.invoke('authorize-managed-path', 'show-in-folder', pathOrCapability); + return capability ? ipcRenderer.invoke('show-in-folder', capability.token) : false; + }, openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'), - checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path), + checkFolderWritable: (capability: string) => ipcRenderer.invoke('check-folder-writable', capability), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), @@ -144,7 +159,12 @@ contextBridge.exposeInMainWorld('api', { }, searchArchive: (filter: Record) => ipcRenderer.invoke('search-archive', filter), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), - readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), + readChatFile: async (filePath: string) => { + const capability = await ipcRenderer.invoke('authorize-managed-path', 'chat-input', filePath); + return capability + ? ipcRenderer.invoke('read-chat-file', capability.token) + : { success: false, error: 'File access denied' }; + }, getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'), triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'), triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'), @@ -153,20 +173,20 @@ contextBridge.exposeInMainWorld('api', { }, // Video Cutter - getVideoInfo: (filePath: string): Promise => ipcRenderer.invoke('get-video-info', filePath), - extractFrame: (filePath: string, timeSeconds: number): Promise => ipcRenderer.invoke('extract-frame', filePath, timeSeconds), - prepareVideoEditorMedia: (filePath: string): Promise => ipcRenderer.invoke('prepare-video-editor-media', filePath), - prepareVideoEditorWaveform: (filePath: string, jobId: number): Promise => ipcRenderer.invoke('prepare-video-editor-waveform', filePath, jobId), - prepareVideoEditorAssets: (filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise => ipcRenderer.invoke('prepare-video-editor-assets', filePath, jobId, profile), + getVideoInfo: (capability: string): Promise => ipcRenderer.invoke('get-video-info', capability), + extractFrame: (capability: string, timeSeconds: number): Promise => ipcRenderer.invoke('extract-frame', capability, timeSeconds), + prepareVideoEditorMedia: (capability: string): Promise => ipcRenderer.invoke('prepare-video-editor-media', capability), + prepareVideoEditorWaveform: (capability: string, jobId: number): Promise => ipcRenderer.invoke('prepare-video-editor-waveform', capability, jobId), + prepareVideoEditorAssets: (capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise => ipcRenderer.invoke('prepare-video-editor-assets', capability, jobId, profile), cancelVideoEditorAssets: (jobId: number): Promise => ipcRenderer.invoke('cancel-video-editor-assets', jobId), - exportVideoEdit: (request: VideoEditExportRequest): Promise<{ success: boolean; outputFile: string | null; cancelled?: boolean }> => ipcRenderer.invoke('export-video-edit', request), + exportVideoEdit: (request: VideoEditExportRequest): Promise<{ success: boolean; outputCapability?: string; outputName: string | null; cancelled?: boolean }> => ipcRenderer.invoke('export-video-edit', request), cancelVideoEdit: (): Promise => ipcRenderer.invoke('cancel-video-edit'), - cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> => - ipcRenderer.invoke('cut-video', inputFile, startTime, endTime), + cutVideo: (inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }> => + ipcRenderer.invoke('cut-video', inputCapability, startTime, endTime), // Merge Videos - mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> => - ipcRenderer.invoke('merge-videos', inputFiles, outputFile), + mergeVideos: (inputCapabilities: string[], outputCapability: string): Promise<{ success: boolean; outputName: string | null }> => + ipcRenderer.invoke('merge-videos', inputCapabilities, outputCapability), // App getVersion: () => ipcRenderer.invoke('get-version'), diff --git a/src/renderer-cutter.ts b/src/renderer-cutter.ts index a711e0f..eb1a50e 100644 --- a/src/renderer-cutter.ts +++ b/src/renderer-cutter.ts @@ -758,7 +758,7 @@ function animateCutterWorkspaceReveal(previousPreviewRect: DOMRect): void { async function requestCutterAssets(): Promise { if (!cutterFile || cutterMediaJobId === null || !byId('cutterTab').classList.contains('active')) return; - const filePath = cutterFile; + const file = cutterFile; const jobId = cutterMediaJobId; const profile = getCutterAssetProfile(); const requestedPixelWidth = getCutterAssetPixelWidth(profile); @@ -771,7 +771,7 @@ async function requestCutterAssets(): Promise { cutterAssetsInFlightPixelWidth = 0; cutterAssetsInFlightPixelHeight = 0; await window.api.cancelVideoEditorAssets(jobId); - if (cutterFile !== filePath || cutterMediaJobId !== jobId || !byId('cutterTab').classList.contains('active')) return; + if (cutterFile !== file || cutterMediaJobId !== jobId || !byId('cutterTab').classList.contains('active')) return; } const requestGeneration = ++cutterAssetsRequestGeneration; cutterAssetsInFlightJobId = jobId; @@ -779,14 +779,14 @@ async function requestCutterAssets(): Promise { cutterAssetsInFlightPixelHeight = requestedPixelHeight; let assets: VideoEditorAssets | null = null; try { - assets = await window.api.prepareVideoEditorAssets(filePath, jobId, profile); + assets = await window.api.prepareVideoEditorAssets(file.token, jobId, profile); } catch { } if (requestGeneration === cutterAssetsRequestGeneration && cutterAssetsInFlightJobId === jobId) { cutterAssetsInFlightJobId = null; cutterAssetsInFlightPixelWidth = 0; cutterAssetsInFlightPixelHeight = 0; } - if (!assets || requestGeneration !== cutterAssetsRequestGeneration || assets.jobId !== jobId || cutterFile !== filePath || cutterMediaJobId !== jobId) return; + if (!assets || requestGeneration !== cutterAssetsRequestGeneration || assets.jobId !== jobId || cutterFile !== file || cutterMediaJobId !== jobId) return; const currentPixelWidth = getCutterAssetPixelWidth(); const currentPixelHeight = getCutterAssetPixelHeight(); if (assets.pixelWidth < currentPixelWidth * 0.95 || assets.pixelHeight < currentPixelHeight) { @@ -799,12 +799,12 @@ async function requestCutterAssets(): Promise { if (getCutterAssetPixelWidth() > cutterAssetsPixelWidth * 1.05 || getCutterAssetPixelHeight() > cutterAssetsPixelHeight) scheduleCutterAssetRefresh(); } -async function requestCutterWaveform(filePath: string, jobId: number, loadGeneration: number): Promise { +async function requestCutterWaveform(file: FileCapabilityReference, jobId: number, loadGeneration: number): Promise { let result: VideoEditorWaveform | null = null; try { - result = await window.api.prepareVideoEditorWaveform(filePath, jobId); + result = await window.api.prepareVideoEditorWaveform(file.token, jobId); } catch { } - if (loadGeneration !== cutterLoadGeneration || cutterFile !== filePath || cutterMediaJobId !== jobId || !result || result.jobId !== jobId) return; + if (loadGeneration !== cutterLoadGeneration || cutterFile !== file || cutterMediaJobId !== jobId || !result || result.jobId !== jobId) return; const waveform = byId('cutterWaveform'); waveform.hidden = !result.waveform; if (result.waveform) { @@ -815,8 +815,8 @@ async function requestCutterWaveform(filePath: string, jobId: number, loadGenera byId('cutterAudioEmpty').hidden = Boolean(result.waveform); } -async function loadCutterFromPath(filePath: string): Promise { - if (!filePath || isCutting) return; +async function loadCutterFromPath(file: FileCapabilityReference): Promise { + if (!file || isCutting) return; const generation = ++cutterLoadGeneration; const video = getCutterVideo(); const hadEditor = Boolean(cutterEditorState && cutterFile); @@ -832,7 +832,7 @@ async function loadCutterFromPath(filePath: string): Promise { byId('btnCut').disabled = true; let media: VideoEditorMedia | null = null; try { - media = await window.api.prepareVideoEditorMedia(filePath); + media = await window.api.prepareVideoEditorMedia(file.token); } catch { } if (generation !== cutterLoadGeneration) return; byId('cutterPlayerLoading').hidden = true; @@ -851,7 +851,7 @@ async function loadCutterFromPath(filePath: string): Promise { } video.removeAttribute('src'); video.load(); - cutterFile = filePath; + cutterFile = file; cutterMediaJobId = media.jobId; cutterAssetsPixelWidth = 0; cutterAssetsPixelHeight = 0; @@ -875,7 +875,7 @@ async function loadCutterFromPath(filePath: string): Promise { cutterActiveCutId = null; cutterZoom = getInitialCutterZoom(media.info.duration); byId('cutterZoom').value = String(cutterZoom); - byId('cutterFilePath').value = filePath; + byId('cutterFilePath').value = file.name; const previousPreviewRect = hadEditor ? null : byId('cutterPreview').getBoundingClientRect(); byId('cutterWorkspace').classList.add('shown'); byId('cutterInfo').classList.add('shown'); @@ -900,7 +900,7 @@ async function loadCutterFromPath(filePath: string): Promise { updateCutterZoom(cutterZoom); renderCutterEditor(); updateCutterPlayhead(0); - void requestCutterWaveform(filePath, media.jobId, generation); + void requestCutterWaveform(file, media.jobId, generation); void requestCutterAssets(); } @@ -935,8 +935,8 @@ function trapCutterDiscardFocus(event: KeyboardEvent): void { } } -function confirmCutterReplacement(filePath: string): Promise { - if (!cutterFile || !cutterEditorState || cutterFile === filePath) return Promise.resolve(true); +function confirmCutterReplacement(file: FileCapabilityReference): Promise { + if (!cutterFile || !cutterEditorState || cutterFile.token === file.token) return Promise.resolve(true); if (cutterDiscardResolver) resolveCutterDiscard(false); const modal = byId('cutterDiscardModal'); cutterDiscardReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== document.body @@ -950,15 +950,15 @@ function confirmCutterReplacement(filePath: string): Promise { return new Promise((resolve) => { cutterDiscardResolver = resolve; }); } -async function requestCutterVideoReplacement(filePath: string): Promise { - if (!filePath || isCutting) return; - if (!await confirmCutterReplacement(filePath)) return; - await loadCutterFromPath(filePath); +async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise { + if (!file || isCutting) return; + if (!await confirmCutterReplacement(file)) return; + await loadCutterFromPath(file); } async function selectCutterVideo(): Promise { - const filePath = await window.api.selectVideoFile(); - if (filePath) await requestCutterVideoReplacement(filePath); + const file = await window.api.selectVideoFile(); + if (file) await requestCutterVideoReplacement(file); } function updateTimeFromInput(): void { @@ -1406,14 +1406,14 @@ async function startCutting(): Promise { byId('cutProgress').classList.add('show'); try { const result = await window.api.exportVideoEdit({ - inputFile: cutterFile, + inputCapability: cutterFile.token, trimStart: cutterEditorState.trimStart, trimEnd: cutterEditorState.trimEnd, cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })), }); if (result.success) { showAppToast(UI_TEXT.cutter.exportSuccess, 'info'); - if (result.outputFile) await window.api.showInFolder(result.outputFile); + if (result.outputCapability) await window.api.showInFolder(result.outputCapability); } else if (!result.cancelled) { showAppToast(UI_TEXT.cutter.exportFailed, 'warn'); } diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index d476993..71b7635 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -203,13 +203,19 @@ interface VideoEditorAssetProfile { } interface VideoEditExportRequest { - inputFile: string; - outputFile?: string; + inputCapability: string; + outputName?: string; trimStart: number; trimEnd: number; cuts: Array<{ id: string; start: number; end: number }>; } +interface FileCapabilityReference { + token: string; + name: string; + displayPath?: string; +} + interface ClipDialogData { url: string; title: string; @@ -363,7 +369,7 @@ interface ArchiveStats { interface ApiBridge { getConfig(): Promise; - saveConfig(config: Partial): Promise; + saveConfig(config: Partial, fileCapability?: string): Promise; login(): Promise; getUserId(username: string): Promise; getVODs(userId: string, forceRefresh?: boolean): Promise; @@ -381,16 +387,16 @@ interface ApiBridge { cancelDownload(): Promise; isDownloading(): Promise; downloadClip(url: string): Promise<{ success: boolean; error?: string }>; - selectFolder(): Promise; - selectVideoFile(): Promise; - selectMultipleVideos(): Promise; - getPathForFile(file: File): string; - saveVideoDialog(defaultName: string): Promise; - openFolder(path: string): Promise; - openFile(path: string): Promise; - showInFolder(path: string): Promise; + selectFolder(): Promise<(FileCapabilityReference & { displayPath: string }) | null>; + selectVideoFile(): Promise; + selectMultipleVideos(): Promise; + selectDroppedVideo(file: File): Promise; + saveVideoDialog(defaultName: string): Promise; + openFolder(pathOrCapability: string): Promise; + openFile(pathOrCapability: string): Promise; + showInFolder(pathOrCapability: string): Promise; openDebugLogFile(): Promise; - checkFolderWritable(path: string): Promise; + checkFolderWritable(capability: string): Promise; getStorageStats(): Promise; getArchiveStats(): Promise; getStreamerProfile(login: string, forceRefresh?: boolean): Promise; @@ -416,16 +422,16 @@ interface ApiBridge { triggerAutoVodScan(): Promise<{ queuedCount: number }>; triggerAutoRecordScan(): Promise<{ triggered: number }>; onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void; - getVideoInfo(filePath: string): Promise; - extractFrame(filePath: string, timeSeconds: number): Promise; - prepareVideoEditorMedia(filePath: string): Promise; - prepareVideoEditorWaveform(filePath: string, jobId: number): Promise; - prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise; + getVideoInfo(capability: string): Promise; + extractFrame(capability: string, timeSeconds: number): Promise; + prepareVideoEditorMedia(capability: string): Promise; + prepareVideoEditorWaveform(capability: string, jobId: number): Promise; + prepareVideoEditorAssets(capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise; cancelVideoEditorAssets(jobId: number): Promise; - exportVideoEdit(request: VideoEditExportRequest): Promise<{ success: boolean; outputFile: string | null; cancelled?: boolean }>; + exportVideoEdit(request: VideoEditExportRequest): Promise<{ success: boolean; outputCapability?: string; outputName: string | null; cancelled?: boolean }>; cancelVideoEdit(): Promise; - cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; - mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>; + cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>; + mergeVideos(inputCapabilities: string[], outputCapability: string): Promise<{ success: boolean; outputName: string | null }>; getVersion(): Promise; checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>; downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>; diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 2775fab..d9cef3d 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -936,15 +936,15 @@ async function selectFolder(): Promise { return; } - byId('downloadPath').value = folder; - config = await window.api.saveConfig({ download_path: folder }); + byId('downloadPath').value = folder.displayPath; + config = await window.api.saveConfig({ download_path: folder.displayPath }, folder.token); // Warn-only validation — the user explicitly chose this folder, so don't // refuse to save (they might be picking a path on a USB stick that's // currently disconnected). Just surface the writability problem early // instead of letting the next download fail with a cryptic error. try { - const writable = await window.api.checkFolderWritable(folder); + const writable = await window.api.checkFolderWritable(folder.token); if (!writable) { const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn'); diff --git a/src/renderer-shared.ts b/src/renderer-shared.ts index 0f7f2c9..108d44c 100644 --- a/src/renderer-shared.ts +++ b/src/renderer-shared.ts @@ -68,11 +68,11 @@ let selectedQueueIds: string[] = []; let expandedQueueIds: Set = new Set(); let queueDragDropInitialized = false; -let cutterFile: string | null = null; +let cutterFile: FileCapabilityReference | null = null; let cutterVideoInfo: VideoInfo | null = null; let isCutting = false; -let mergeFiles: string[] = []; +let mergeFiles: FileCapabilityReference[] = []; let isMerging = false; let clipDialogData: ClipDialogData | null = null; diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index c770a6a..0e7f0ac 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -491,12 +491,12 @@ function initCutterDragDrop(): void { showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn'); return; } - const filePath = window.api.getPathForFile(file); - if (!filePath) return; + const selection = await window.api.selectDroppedVideo(file); + if (!selection) return; - const loader = (window as unknown as { requestCutterVideoReplacement?: (p: string) => Promise }).requestCutterVideoReplacement; + const loader = (window as unknown as { requestCutterVideoReplacement?: (selection: FileCapabilityReference) => Promise }).requestCutterVideoReplacement; if (typeof loader === 'function') { - await loader(filePath); + await loader(selection); } }); } diff --git a/src/renderer.ts b/src/renderer.ts index c0d829f..fc9340c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1715,12 +1715,12 @@ function renderMergeFiles(): void { return; } - list.innerHTML = mergeFiles.map((file: string, index: number) => { - const name = file.split(/[/\\]/).pop(); + list.innerHTML = mergeFiles.map((file: FileCapabilityReference, index: number) => { + const name = file.name; return `
${index + 1}
-
${name}
+
${escapeHtml(name)}
@@ -1753,8 +1753,8 @@ async function startMerging(): Promise { return; } - const outputFile = await window.api.saveVideoDialog('merged_video.mp4'); - if (!outputFile) { + const output = await window.api.saveVideoDialog('merged_video.mp4'); + if (!output) { return; } @@ -1763,7 +1763,7 @@ async function startMerging(): Promise { byId('btnMerge').textContent = UI_TEXT.merge.merging; byId('mergeProgress').classList.add('show'); - const result = await window.api.mergeVideos(mergeFiles, outputFile); + const result = await window.api.mergeVideos(mergeFiles.map((file) => file.token), output.token); isMerging = false; byId('btnMerge').disabled = false; @@ -1771,7 +1771,7 @@ async function startMerging(): Promise { byId('mergeProgress').classList.remove('show'); if (result.success) { - alert(`${UI_TEXT.merge.success}\n\n${result.outputFile}`); + alert(`${UI_TEXT.merge.success}\n\n${result.outputName || output.name}`); mergeFiles = []; renderMergeFiles(); return;