fix(security): gate privileged file IPC with capabilities

This commit is contained in:
Sucukdeluxe
2026-08-11 22:51:47 +02:00
parent de05539422
commit a79af3f3fe
10 changed files with 662 additions and 144 deletions
+232 -66
View File
@@ -40,6 +40,13 @@ import { buildVodPreviewFrameUrls } from './main/domain/vod-preview';
import { getWindowsAppIdentity } from './main/domain/app-identity'; import { getWindowsAppIdentity } from './main/domain/app-identity';
import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor'; import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor';
import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export'; import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export';
import {
FileCapabilityStore,
isTrustedFileIpcSender,
publishCapabilityOutput,
type FileCapabilityPurpose,
type FileCapabilityReference,
} from './main/domain/file-capability';
import { import {
setDebugLogFn, initToolDirs, setDebugLogFn, initToolDirs,
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
@@ -293,7 +300,15 @@ interface VideoEditorAssetProfile {
interface VideoEditExportRequest { interface VideoEditExportRequest {
inputFile: string; inputFile: string;
outputFile?: string; outputFile: string;
trimStart: number;
trimEnd: number;
cuts: EditorCut[];
}
interface RendererVideoEditExportRequest {
inputCapability: string;
outputName?: string;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
cuts: EditorCut[]; cuts: EditorCut[];
@@ -1560,6 +1575,7 @@ function emitQueueUpdated(force = false): void {
} }
lastQueueBroadcastFingerprint = nextFingerprint; lastQueueBroadcastFingerprint = nextFingerprint;
rememberQueueFilePaths(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue); mainWindow?.webContents.send('queue-updated', downloadQueue);
updateTaskbarProgress(); updateTaskbarProgress();
} }
@@ -7216,7 +7232,8 @@ ipcMain.handle('trigger-auto-vod-scan', async () => {
return { queuedCount }; return { queuedCount };
}); });
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => { ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability?: string) => {
if (!isTrustedRendererEvent(event)) return config;
const previousClientId = config.client_id; const previousClientId = config.client_id;
const previousClientSecret = config.client_secret; const previousClientSecret = config.client_secret;
const previousCacheMinutes = config.metadata_cache_minutes; const previousCacheMinutes = config.metadata_cache_minutes;
@@ -7228,7 +7245,16 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes; const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
const previousStreamerList = JSON.stringify(config.streamers || []); 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) { if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
accessToken = null; accessToken = null;
@@ -7303,7 +7329,11 @@ ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = fal
return await getVODs(userId, forceRefresh); 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) => { ipcMain.handle('start-live-recording', async (_, streamerName: string) => {
if (typeof streamerName !== 'string' || !streamerName) { if (typeof streamerName !== 'string' || !streamerName) {
@@ -7618,29 +7648,106 @@ ipcMain.handle('cancel-download', async () => {
return true; 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<FileCapabilityPurpose, Set<string>>();
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<string>();
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!, { const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openDirectory'] 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!, { const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile'], properties: ['openFile'],
filters: [ 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) => { ipcMain.handle('grant-dropped-video', (event, filePath: string): FileCapabilityReference | null => {
if (fs.existsSync(folderPath)) { if (!isTrustedRendererEvent(event)) return null;
shell.openPath(folderPath); 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 // Extensions that shell.openPath would happily execute via the system
// default. Calc.exe via XSS smuggling is the canonical example; this // default. Calc.exe via XSS smuggling is the canonical example; this
// list blocks the obvious vectors. Media/text/image extensions are // 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' '.lnk', '.cpl', '.reg', '.hta', '.jar', '.application'
]); ]);
ipcMain.handle('open-file', async (_, filePath: string): Promise<boolean> => { ipcMain.handle('open-file', async (event, capability: string): Promise<boolean> => {
if (typeof filePath !== 'string' || !filePath) return false; const filePath = resolveFileCapability(event, capability, 'open-file', true);
if (!fs.existsSync(filePath)) return false; if (!filePath) return false;
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) { if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) {
appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) }); appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) });
@@ -7664,9 +7771,9 @@ ipcMain.handle('open-file', async (_, filePath: string): Promise<boolean> => {
return result === ''; return result === '';
}); });
ipcMain.handle('show-in-folder', (_, filePath: string): boolean => { ipcMain.handle('show-in-folder', (event, capability: string): boolean => {
if (typeof filePath !== 'string' || !filePath) return false; const filePath = resolveFileCapability(event, capability, 'show-in-folder', true);
if (!fs.existsSync(filePath)) return false; if (!filePath) return false;
shell.showItemInFolder(filePath); shell.showItemInFolder(filePath);
return true; return true;
}); });
@@ -7856,13 +7963,15 @@ ipcMain.handle('get-debug-log', async (_, lines: number = 200) => {
return readDebugLog(safeLines); 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; if (!fs.existsSync(DEBUG_LOG_FILE)) return false;
shell.showItemInFolder(DEBUG_LOG_FILE); shell.showItemInFolder(DEBUG_LOG_FILE);
return true; 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(); return computeArchiveStats();
}); });
@@ -7884,7 +7993,8 @@ ipcMain.handle('get-live-status-snapshot', (): Record<string, boolean> => {
return snap; return snap;
}); });
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => { ipcMain.handle('search-archive', (event, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
if (!isTrustedRendererEvent(event)) throw new Error('File access denied');
const normalized: ArchiveSearchFilter = { const normalized: ArchiveSearchFilter = {
query: typeof filter?.query === 'string' ? filter.query.trim() : '', 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') 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<ArchiveSearchFilter>): Arch
: 'date_desc', : 'date_desc',
limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200 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 => { ipcMain.handle('get-storage-stats', (event): StorageStatsResult => {
return computeStorageStats(); 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 }); return runStorageCleanup({ dryRun: options?.dryRun === true });
}); });
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and // Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
// return a normalized message list the renderer can display directly. // return a normalized message list the renderer can display directly.
// Caps at 50k messages to stop a runaway file from killing the renderer. // 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<Record<string, unknown>>; truncated?: boolean; total?: number } => { ipcMain.handle('read-chat-file', (event, capability: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number } => {
if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' }; const filePath = resolveFileCapability(event, capability, 'chat-input', true);
if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' }; if (!filePath) return { success: false, error: 'File access denied' };
const MAX_MESSAGES = 50000; const MAX_MESSAGES = 50000;
try { try {
@@ -7961,8 +8086,9 @@ ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; erro
} }
}); });
ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => { ipcMain.handle('check-folder-writable', (event, capability: string): boolean => {
if (typeof folderPath !== 'string' || !folderPath) return false; const folderPath = resolveFileCapability(event, capability, 'selected-folder', true);
if (!folderPath) return false;
return isDownloadPathWritable(folderPath); return isDownloadPathWritable(folderPath);
}); });
@@ -7970,7 +8096,8 @@ ipcMain.handle('is-downloading', () => isDownloading && !queuePaused);
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot()); 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 { try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const defaultName = `runtime-metrics-${timestamp}.json`; const defaultName = `runtime-metrics-${timestamp}.json`;
@@ -7985,12 +8112,15 @@ ipcMain.handle('export-runtime-metrics', async () => {
return { success: false, cancelled: true }; 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(); const snapshot = getRuntimeMetricsSnapshot();
// Atomic write: same fsync+rename pattern used for config/queue // Atomic write: same fsync+rename pattern used for config/queue
// (cycle 1) so a power loss mid-export can't leave a half-written // (cycle 1) so a power loss mid-export can't leave a half-written
// metrics file at the user's chosen path. // metrics file at the user's chosen path.
writeFileAtomicSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2)); writeFileAtomicSync(outputFile, JSON.stringify(snapshot, null, 2));
return { success: true, filePath: dialogResult.filePath }; return { success: true, filePath: outputFile };
} catch (e) { } catch (e) {
appendDebugLog('runtime-metrics-export-failed', String(e)); appendDebugLog('runtime-metrics-export-failed', String(e));
return { success: false, error: String(e) }; return { success: false, error: String(e) };
@@ -8021,7 +8151,8 @@ ipcMain.handle('reset-downloaded-vod-ids', () => {
return { success: true, removedCount: count }; 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 { try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const defaultName = `twitch-vod-manager-config-${timestamp}.json`; 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 // Strip the secrets from the export — Client Secret should not
// travel as plain text across machines / cloud sync. The user // travel as plain text across machines / cloud sync. The user
// re-enters it on the new machine after import. // 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 = { const exportable = {
...config, ...config,
client_secret: '', client_secret: '',
__exportVersion: 1, __exportVersion: 1,
__exportedAt: new Date().toISOString() __exportedAt: new Date().toISOString()
}; };
writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2)); writeFileAtomicSync(outputFile, JSON.stringify(exportable, null, 2));
return { success: true, filePath: dialogResult.filePath }; return { success: true, filePath: outputFile };
} catch (e) { } catch (e) {
appendDebugLog('config-export-failed', String(e)); appendDebugLog('config-export-failed', String(e));
return { success: false, error: 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 { try {
const dialogResult = await dialog.showOpenDialog(mainWindow!, { const dialogResult = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile'], properties: ['openFile'],
@@ -8063,7 +8198,9 @@ ipcMain.handle('import-config', async () => {
return { success: false, cancelled: true }; 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 raw = fs.readFileSync(importPath, 'utf-8');
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!isPlainObject(parsed)) { if (!isPlainObject(parsed)) {
@@ -8092,10 +8229,10 @@ ipcMain.handle('import-config', async () => {
}); });
function isTrustedRendererEvent(event: IpcMainInvokeEvent): boolean { 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 rendererUrl = pathToFileURL(path.join(__dirname, '../src/index.html')).href;
const senderUrl = event.senderFrame?.url || event.sender.getURL(); 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 { function isPathInsideDirectory(rootDirectory: string, candidate: string): boolean {
@@ -8106,30 +8243,40 @@ function isPathInsideDirectory(rootDirectory: string, candidate: string): boolea
} }
// Video Cutter IPC // 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; if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
const filePath = resolveFileCapability(event, capability, 'cutter-input');
if (!filePath) return null;
return await getVideoInfo(filePath, currentCutterInfoProcesses); 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; if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
const filePath = resolveFileCapability(event, capability, 'cutter-input');
if (!filePath) return null;
return await extractFrame(filePath, timeSeconds); 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; if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
const filePath = resolveFileCapability(event, capability, 'cutter-input');
if (!filePath) return null;
const media = await prepareVideoEditorMedia(filePath); const media = await prepareVideoEditorMedia(filePath);
if (media && cutterMediaJob?.jobId === media.jobId) cutterPreparedInput = cutterMediaJob.identity; if (media && cutterMediaJob?.jobId === media.jobId) cutterPreparedInput = cutterMediaJob.identity;
return media; 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; if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
const filePath = resolveFileCapability(event, capability, 'cutter-input');
if (!filePath) return null;
return await prepareVideoEditorWaveform(filePath, jobId); 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; if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
const filePath = resolveFileCapability(event, capability, 'cutter-input');
if (!filePath) return null;
return await prepareVideoEditorAssets(filePath, jobId, profile); return await prepareVideoEditorAssets(filePath, jobId, profile);
}); });
@@ -8139,26 +8286,34 @@ ipcMain.handle('cancel-video-editor-assets', (event, jobId: number) => {
return true; return true;
}); });
ipcMain.handle('export-video-edit', async (event, request: VideoEditExportRequest) => { ipcMain.handle('export-video-edit', async (event, request: RendererVideoEditExportRequest) => {
if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputFile !== 'string') return { success: false, outputFile: null }; if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputCapability !== 'string') return { success: false, outputName: null };
if (!cutterInputIdentityMatches(request.inputFile)) return { success: false, outputFile: null }; const inputFile = resolveFileCapability(event, request.inputCapability, 'cutter-input');
if (!inputFile || !cutterInputIdentityMatches(inputFile)) return { success: false, outputName: null };
let outputFile: string | null = null; let outputFile: string | null = null;
const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT; const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT;
if (testRoot && typeof request.outputFile === 'string' && isPathInsideDirectory(testRoot, request.outputFile)) { if (testRoot && typeof request.outputName === 'string' && path.basename(request.outputName) === request.outputName) {
outputFile = request.outputFile; 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 { } 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!, { const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: defaultName, defaultPath: defaultName,
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }], filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
}); });
if (result.canceled || !result.filePath) return { success: false, outputFile: null, cancelled: true }; if (result.canceled || !result.filePath) return { success: false, outputName: null, cancelled: true };
outputFile = result.filePath; 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); 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) => { ipcMain.handle('cancel-video-edit', (event) => {
@@ -8170,7 +8325,9 @@ ipcMain.handle('cancel-video-edit', (event) => {
return true; 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 dir = path.dirname(inputFile);
const baseName = path.basename(inputFile, path.extname(inputFile)); const baseName = path.basename(inputFile, path.extname(inputFile));
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19); 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); mainWindow?.webContents.send('cut-progress', percent);
}); });
return { success, outputFile: success ? outputFile : null }; return { success, outputName: success ? path.basename(outputFile) : null };
}); });
// Merge IPC // Merge IPC
ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => { ipcMain.handle('merge-videos', async (event, inputCapabilities: string[], outputCapability: string) => {
const success = await mergeVideos(inputFiles, outputFile, (percent) => { 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); mainWindow?.webContents.send('merge-progress', percent);
}));
return { success, outputName: success ? path.basename(outputFile) : null };
}); });
return { success, outputFile: success ? outputFile : null }; ipcMain.handle('select-multiple-videos', async (event) => {
}); if (!isTrustedRendererEvent(event)) return null;
ipcMain.handle('select-multiple-videos', async () => {
const result = await dialog.showOpenDialog(mainWindow!, { const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile', 'multiSelections'], properties: ['openFile', 'multiSelections'],
filters: [ filters: [
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } { 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!, { const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: defaultName, defaultPath: defaultName,
filters: [ filters: [
{ name: 'MP4 Video', extensions: ['mp4'] } { name: 'MP4 Video', extensions: ['mp4'] }
] ]
}); });
return result.filePath || null; return result.filePath ? issueFileCapability(event, 'merge-output', result.filePath, 'output-file', ['mp4']) : null;
}); });
// ========================================== // ==========================================
+120
View File
@@ -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');
});
});
+206
View File
@@ -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<string>;
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<string>): 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<string, FileCapabilityGrant>();
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<boolean>): Promise<boolean> {
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);
}
}
+39 -19
View File
@@ -84,18 +84,24 @@ interface VideoEditorAssetProfile {
} }
interface VideoEditExportRequest { interface VideoEditExportRequest {
inputFile: string; inputCapability: string;
outputFile?: string; outputName?: string;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
cuts: Array<{ id: string; start: number; end: number }>; cuts: Array<{ id: string; start: number; end: number }>;
} }
interface FileCapabilityReference {
token: string;
name: string;
displayPath?: string;
}
// Expose protected methods to renderer // Expose protected methods to renderer
contextBridge.exposeInMainWorld('api', { contextBridge.exposeInMainWorld('api', {
// Config // Config
getConfig: () => ipcRenderer.invoke('get-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 // Auth
login: () => ipcRenderer.invoke('login'), login: () => ipcRenderer.invoke('login'),
@@ -126,13 +132,22 @@ contextBridge.exposeInMainWorld('api', {
selectFolder: () => ipcRenderer.invoke('select-folder'), selectFolder: () => ipcRenderer.invoke('select-folder'),
selectVideoFile: () => ipcRenderer.invoke('select-video-file'), selectVideoFile: () => ipcRenderer.invoke('select-video-file'),
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'), 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), saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), openFolder: async (pathOrCapability: string) => {
openFile: (path: string) => ipcRenderer.invoke('open-file', path), const capability = await ipcRenderer.invoke('authorize-managed-path', 'selected-folder', pathOrCapability);
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path), 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'), 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'), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'), getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh), getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
@@ -144,7 +159,12 @@ contextBridge.exposeInMainWorld('api', {
}, },
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter), searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), 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'), getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'), triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'), triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'),
@@ -153,20 +173,20 @@ contextBridge.exposeInMainWorld('api', {
}, },
// Video Cutter // Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath), getVideoInfo: (capability: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', capability),
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds), extractFrame: (capability: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', capability, timeSeconds),
prepareVideoEditorMedia: (filePath: string): Promise<VideoEditorMedia | null> => ipcRenderer.invoke('prepare-video-editor-media', filePath), prepareVideoEditorMedia: (capability: string): Promise<VideoEditorMedia | null> => ipcRenderer.invoke('prepare-video-editor-media', capability),
prepareVideoEditorWaveform: (filePath: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', filePath, jobId), prepareVideoEditorWaveform: (capability: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', capability, jobId),
prepareVideoEditorAssets: (filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> => ipcRenderer.invoke('prepare-video-editor-assets', filePath, jobId, profile), prepareVideoEditorAssets: (capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> => ipcRenderer.invoke('prepare-video-editor-assets', capability, jobId, profile),
cancelVideoEditorAssets: (jobId: number): Promise<boolean> => ipcRenderer.invoke('cancel-video-editor-assets', jobId), cancelVideoEditorAssets: (jobId: number): Promise<boolean> => 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<boolean> => ipcRenderer.invoke('cancel-video-edit'), cancelVideoEdit: (): Promise<boolean> => ipcRenderer.invoke('cancel-video-edit'),
cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> => cutVideo: (inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }> =>
ipcRenderer.invoke('cut-video', inputFile, startTime, endTime), ipcRenderer.invoke('cut-video', inputCapability, startTime, endTime),
// Merge Videos // Merge Videos
mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> => mergeVideos: (inputCapabilities: string[], outputCapability: string): Promise<{ success: boolean; outputName: string | null }> =>
ipcRenderer.invoke('merge-videos', inputFiles, outputFile), ipcRenderer.invoke('merge-videos', inputCapabilities, outputCapability),
// App // App
getVersion: () => ipcRenderer.invoke('get-version'), getVersion: () => ipcRenderer.invoke('get-version'),
+23 -23
View File
@@ -758,7 +758,7 @@ function animateCutterWorkspaceReveal(previousPreviewRect: DOMRect): void {
async function requestCutterAssets(): Promise<void> { async function requestCutterAssets(): Promise<void> {
if (!cutterFile || cutterMediaJobId === null || !byId('cutterTab').classList.contains('active')) return; if (!cutterFile || cutterMediaJobId === null || !byId('cutterTab').classList.contains('active')) return;
const filePath = cutterFile; const file = cutterFile;
const jobId = cutterMediaJobId; const jobId = cutterMediaJobId;
const profile = getCutterAssetProfile(); const profile = getCutterAssetProfile();
const requestedPixelWidth = getCutterAssetPixelWidth(profile); const requestedPixelWidth = getCutterAssetPixelWidth(profile);
@@ -771,7 +771,7 @@ async function requestCutterAssets(): Promise<void> {
cutterAssetsInFlightPixelWidth = 0; cutterAssetsInFlightPixelWidth = 0;
cutterAssetsInFlightPixelHeight = 0; cutterAssetsInFlightPixelHeight = 0;
await window.api.cancelVideoEditorAssets(jobId); 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; const requestGeneration = ++cutterAssetsRequestGeneration;
cutterAssetsInFlightJobId = jobId; cutterAssetsInFlightJobId = jobId;
@@ -779,14 +779,14 @@ async function requestCutterAssets(): Promise<void> {
cutterAssetsInFlightPixelHeight = requestedPixelHeight; cutterAssetsInFlightPixelHeight = requestedPixelHeight;
let assets: VideoEditorAssets | null = null; let assets: VideoEditorAssets | null = null;
try { try {
assets = await window.api.prepareVideoEditorAssets(filePath, jobId, profile); assets = await window.api.prepareVideoEditorAssets(file.token, jobId, profile);
} catch { } } catch { }
if (requestGeneration === cutterAssetsRequestGeneration && cutterAssetsInFlightJobId === jobId) { if (requestGeneration === cutterAssetsRequestGeneration && cutterAssetsInFlightJobId === jobId) {
cutterAssetsInFlightJobId = null; cutterAssetsInFlightJobId = null;
cutterAssetsInFlightPixelWidth = 0; cutterAssetsInFlightPixelWidth = 0;
cutterAssetsInFlightPixelHeight = 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 currentPixelWidth = getCutterAssetPixelWidth();
const currentPixelHeight = getCutterAssetPixelHeight(); const currentPixelHeight = getCutterAssetPixelHeight();
if (assets.pixelWidth < currentPixelWidth * 0.95 || assets.pixelHeight < currentPixelHeight) { if (assets.pixelWidth < currentPixelWidth * 0.95 || assets.pixelHeight < currentPixelHeight) {
@@ -799,12 +799,12 @@ async function requestCutterAssets(): Promise<void> {
if (getCutterAssetPixelWidth() > cutterAssetsPixelWidth * 1.05 || getCutterAssetPixelHeight() > cutterAssetsPixelHeight) scheduleCutterAssetRefresh(); if (getCutterAssetPixelWidth() > cutterAssetsPixelWidth * 1.05 || getCutterAssetPixelHeight() > cutterAssetsPixelHeight) scheduleCutterAssetRefresh();
} }
async function requestCutterWaveform(filePath: string, jobId: number, loadGeneration: number): Promise<void> { async function requestCutterWaveform(file: FileCapabilityReference, jobId: number, loadGeneration: number): Promise<void> {
let result: VideoEditorWaveform | null = null; let result: VideoEditorWaveform | null = null;
try { try {
result = await window.api.prepareVideoEditorWaveform(filePath, jobId); result = await window.api.prepareVideoEditorWaveform(file.token, jobId);
} catch { } } 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<HTMLImageElement>('cutterWaveform'); const waveform = byId<HTMLImageElement>('cutterWaveform');
waveform.hidden = !result.waveform; waveform.hidden = !result.waveform;
if (result.waveform) { if (result.waveform) {
@@ -815,8 +815,8 @@ async function requestCutterWaveform(filePath: string, jobId: number, loadGenera
byId('cutterAudioEmpty').hidden = Boolean(result.waveform); byId('cutterAudioEmpty').hidden = Boolean(result.waveform);
} }
async function loadCutterFromPath(filePath: string): Promise<void> { async function loadCutterFromPath(file: FileCapabilityReference): Promise<void> {
if (!filePath || isCutting) return; if (!file || isCutting) return;
const generation = ++cutterLoadGeneration; const generation = ++cutterLoadGeneration;
const video = getCutterVideo(); const video = getCutterVideo();
const hadEditor = Boolean(cutterEditorState && cutterFile); const hadEditor = Boolean(cutterEditorState && cutterFile);
@@ -832,7 +832,7 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
byId<HTMLButtonElement>('btnCut').disabled = true; byId<HTMLButtonElement>('btnCut').disabled = true;
let media: VideoEditorMedia | null = null; let media: VideoEditorMedia | null = null;
try { try {
media = await window.api.prepareVideoEditorMedia(filePath); media = await window.api.prepareVideoEditorMedia(file.token);
} catch { } } catch { }
if (generation !== cutterLoadGeneration) return; if (generation !== cutterLoadGeneration) return;
byId('cutterPlayerLoading').hidden = true; byId('cutterPlayerLoading').hidden = true;
@@ -851,7 +851,7 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
} }
video.removeAttribute('src'); video.removeAttribute('src');
video.load(); video.load();
cutterFile = filePath; cutterFile = file;
cutterMediaJobId = media.jobId; cutterMediaJobId = media.jobId;
cutterAssetsPixelWidth = 0; cutterAssetsPixelWidth = 0;
cutterAssetsPixelHeight = 0; cutterAssetsPixelHeight = 0;
@@ -875,7 +875,7 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
cutterActiveCutId = null; cutterActiveCutId = null;
cutterZoom = getInitialCutterZoom(media.info.duration); cutterZoom = getInitialCutterZoom(media.info.duration);
byId<HTMLInputElement>('cutterZoom').value = String(cutterZoom); byId<HTMLInputElement>('cutterZoom').value = String(cutterZoom);
byId<HTMLInputElement>('cutterFilePath').value = filePath; byId<HTMLInputElement>('cutterFilePath').value = file.name;
const previousPreviewRect = hadEditor ? null : byId('cutterPreview').getBoundingClientRect(); const previousPreviewRect = hadEditor ? null : byId('cutterPreview').getBoundingClientRect();
byId('cutterWorkspace').classList.add('shown'); byId('cutterWorkspace').classList.add('shown');
byId('cutterInfo').classList.add('shown'); byId('cutterInfo').classList.add('shown');
@@ -900,7 +900,7 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
updateCutterZoom(cutterZoom); updateCutterZoom(cutterZoom);
renderCutterEditor(); renderCutterEditor();
updateCutterPlayhead(0); updateCutterPlayhead(0);
void requestCutterWaveform(filePath, media.jobId, generation); void requestCutterWaveform(file, media.jobId, generation);
void requestCutterAssets(); void requestCutterAssets();
} }
@@ -935,8 +935,8 @@ function trapCutterDiscardFocus(event: KeyboardEvent): void {
} }
} }
function confirmCutterReplacement(filePath: string): Promise<boolean> { function confirmCutterReplacement(file: FileCapabilityReference): Promise<boolean> {
if (!cutterFile || !cutterEditorState || cutterFile === filePath) return Promise.resolve(true); if (!cutterFile || !cutterEditorState || cutterFile.token === file.token) return Promise.resolve(true);
if (cutterDiscardResolver) resolveCutterDiscard(false); if (cutterDiscardResolver) resolveCutterDiscard(false);
const modal = byId('cutterDiscardModal'); const modal = byId('cutterDiscardModal');
cutterDiscardReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== document.body cutterDiscardReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== document.body
@@ -950,15 +950,15 @@ function confirmCutterReplacement(filePath: string): Promise<boolean> {
return new Promise((resolve) => { cutterDiscardResolver = resolve; }); return new Promise((resolve) => { cutterDiscardResolver = resolve; });
} }
async function requestCutterVideoReplacement(filePath: string): Promise<void> { async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise<void> {
if (!filePath || isCutting) return; if (!file || isCutting) return;
if (!await confirmCutterReplacement(filePath)) return; if (!await confirmCutterReplacement(file)) return;
await loadCutterFromPath(filePath); await loadCutterFromPath(file);
} }
async function selectCutterVideo(): Promise<void> { async function selectCutterVideo(): Promise<void> {
const filePath = await window.api.selectVideoFile(); const file = await window.api.selectVideoFile();
if (filePath) await requestCutterVideoReplacement(filePath); if (file) await requestCutterVideoReplacement(file);
} }
function updateTimeFromInput(): void { function updateTimeFromInput(): void {
@@ -1406,14 +1406,14 @@ async function startCutting(): Promise<void> {
byId('cutProgress').classList.add('show'); byId('cutProgress').classList.add('show');
try { try {
const result = await window.api.exportVideoEdit({ const result = await window.api.exportVideoEdit({
inputFile: cutterFile, inputCapability: cutterFile.token,
trimStart: cutterEditorState.trimStart, trimStart: cutterEditorState.trimStart,
trimEnd: cutterEditorState.trimEnd, trimEnd: cutterEditorState.trimEnd,
cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })), cuts: cutterEditorState.cuts.map((cut) => ({ ...cut })),
}); });
if (result.success) { if (result.success) {
showAppToast(UI_TEXT.cutter.exportSuccess, 'info'); 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) { } else if (!result.cancelled) {
showAppToast(UI_TEXT.cutter.exportFailed, 'warn'); showAppToast(UI_TEXT.cutter.exportFailed, 'warn');
} }
+26 -20
View File
@@ -203,13 +203,19 @@ interface VideoEditorAssetProfile {
} }
interface VideoEditExportRequest { interface VideoEditExportRequest {
inputFile: string; inputCapability: string;
outputFile?: string; outputName?: string;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
cuts: Array<{ id: string; start: number; end: number }>; cuts: Array<{ id: string; start: number; end: number }>;
} }
interface FileCapabilityReference {
token: string;
name: string;
displayPath?: string;
}
interface ClipDialogData { interface ClipDialogData {
url: string; url: string;
title: string; title: string;
@@ -363,7 +369,7 @@ interface ArchiveStats {
interface ApiBridge { interface ApiBridge {
getConfig(): Promise<AppConfig>; getConfig(): Promise<AppConfig>;
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>; saveConfig(config: Partial<AppConfig>, fileCapability?: string): Promise<AppConfig>;
login(): Promise<boolean>; login(): Promise<boolean>;
getUserId(username: string): Promise<string | null>; getUserId(username: string): Promise<string | null>;
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>; getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
@@ -381,16 +387,16 @@ interface ApiBridge {
cancelDownload(): Promise<boolean>; cancelDownload(): Promise<boolean>;
isDownloading(): Promise<boolean>; isDownloading(): Promise<boolean>;
downloadClip(url: string): Promise<{ success: boolean; error?: string }>; downloadClip(url: string): Promise<{ success: boolean; error?: string }>;
selectFolder(): Promise<string | null>; selectFolder(): Promise<(FileCapabilityReference & { displayPath: string }) | null>;
selectVideoFile(): Promise<string | null>; selectVideoFile(): Promise<FileCapabilityReference | null>;
selectMultipleVideos(): Promise<string[] | null>; selectMultipleVideos(): Promise<FileCapabilityReference[] | null>;
getPathForFile(file: File): string; selectDroppedVideo(file: File): Promise<FileCapabilityReference | null>;
saveVideoDialog(defaultName: string): Promise<string | null>; saveVideoDialog(defaultName: string): Promise<FileCapabilityReference | null>;
openFolder(path: string): Promise<void>; openFolder(pathOrCapability: string): Promise<void>;
openFile(path: string): Promise<boolean>; openFile(pathOrCapability: string): Promise<boolean>;
showInFolder(path: string): Promise<boolean>; showInFolder(pathOrCapability: string): Promise<boolean>;
openDebugLogFile(): Promise<boolean>; openDebugLogFile(): Promise<boolean>;
checkFolderWritable(path: string): Promise<boolean>; checkFolderWritable(capability: string): Promise<boolean>;
getStorageStats(): Promise<StorageStatsResult>; getStorageStats(): Promise<StorageStatsResult>;
getArchiveStats(): Promise<ArchiveStats>; getArchiveStats(): Promise<ArchiveStats>;
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>; getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
@@ -416,16 +422,16 @@ interface ApiBridge {
triggerAutoVodScan(): Promise<{ queuedCount: number }>; triggerAutoVodScan(): Promise<{ queuedCount: number }>;
triggerAutoRecordScan(): Promise<{ triggered: number }>; triggerAutoRecordScan(): Promise<{ triggered: number }>;
onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void; onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
getVideoInfo(filePath: string): Promise<VideoInfo | null>; getVideoInfo(capability: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>; extractFrame(capability: string, timeSeconds: number): Promise<string | null>;
prepareVideoEditorMedia(filePath: string): Promise<VideoEditorMedia | null>; prepareVideoEditorMedia(capability: string): Promise<VideoEditorMedia | null>;
prepareVideoEditorWaveform(filePath: string, jobId: number): Promise<VideoEditorWaveform | null>; prepareVideoEditorWaveform(capability: string, jobId: number): Promise<VideoEditorWaveform | null>;
prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>; prepareVideoEditorAssets(capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
cancelVideoEditorAssets(jobId: number): Promise<boolean>; cancelVideoEditorAssets(jobId: number): Promise<boolean>;
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<boolean>; cancelVideoEdit(): Promise<boolean>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>;
mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>; mergeVideos(inputCapabilities: string[], outputCapability: string): Promise<{ success: boolean; outputName: string | null }>;
getVersion(): Promise<string>; getVersion(): Promise<string>;
checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>; 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 }>; downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>;
+3 -3
View File
@@ -936,15 +936,15 @@ async function selectFolder(): Promise<void> {
return; return;
} }
byId<HTMLInputElement>('downloadPath').value = folder; byId<HTMLInputElement>('downloadPath').value = folder.displayPath;
config = await window.api.saveConfig({ download_path: folder }); config = await window.api.saveConfig({ download_path: folder.displayPath }, folder.token);
// Warn-only validation — the user explicitly chose this folder, so don't // 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 // refuse to save (they might be picking a path on a USB stick that's
// currently disconnected). Just surface the writability problem early // currently disconnected). Just surface the writability problem early
// instead of letting the next download fail with a cryptic error. // instead of letting the next download fail with a cryptic error.
try { try {
const writable = await window.api.checkFolderWritable(folder); const writable = await window.api.checkFolderWritable(folder.token);
if (!writable) { if (!writable) {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn'); if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn');
+2 -2
View File
@@ -68,11 +68,11 @@ let selectedQueueIds: string[] = [];
let expandedQueueIds: Set<string> = new Set(); let expandedQueueIds: Set<string> = new Set();
let queueDragDropInitialized = false; let queueDragDropInitialized = false;
let cutterFile: string | null = null; let cutterFile: FileCapabilityReference | null = null;
let cutterVideoInfo: VideoInfo | null = null; let cutterVideoInfo: VideoInfo | null = null;
let isCutting = false; let isCutting = false;
let mergeFiles: string[] = []; let mergeFiles: FileCapabilityReference[] = [];
let isMerging = false; let isMerging = false;
let clipDialogData: ClipDialogData | null = null; let clipDialogData: ClipDialogData | null = null;
+4 -4
View File
@@ -491,12 +491,12 @@ function initCutterDragDrop(): void {
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn'); showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
return; return;
} }
const filePath = window.api.getPathForFile(file); const selection = await window.api.selectDroppedVideo(file);
if (!filePath) return; if (!selection) return;
const loader = (window as unknown as { requestCutterVideoReplacement?: (p: string) => Promise<void> }).requestCutterVideoReplacement; const loader = (window as unknown as { requestCutterVideoReplacement?: (selection: FileCapabilityReference) => Promise<void> }).requestCutterVideoReplacement;
if (typeof loader === 'function') { if (typeof loader === 'function') {
await loader(filePath); await loader(selection);
} }
}); });
} }
+7 -7
View File
@@ -1715,12 +1715,12 @@ function renderMergeFiles(): void {
return; return;
} }
list.innerHTML = mergeFiles.map((file: string, index: number) => { list.innerHTML = mergeFiles.map((file: FileCapabilityReference, index: number) => {
const name = file.split(/[/\\]/).pop(); const name = file.name;
return ` return `
<div class="file-item" draggable="true" data-index="${index}"> <div class="file-item" draggable="true" data-index="${index}">
<div class="file-order">${index + 1}</div> <div class="file-order">${index + 1}</div>
<div class="file-name" title="${file}">${name}</div> <div class="file-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
<div class="file-actions"> <div class="file-actions">
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button> <button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>&#9650;</button>
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveDownAria)}" title="${escapeHtml(UI_TEXT.merge.moveDownAria)}" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button> <button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveDownAria)}" title="${escapeHtml(UI_TEXT.merge.moveDownAria)}" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>&#9660;</button>
@@ -1753,8 +1753,8 @@ async function startMerging(): Promise<void> {
return; return;
} }
const outputFile = await window.api.saveVideoDialog('merged_video.mp4'); const output = await window.api.saveVideoDialog('merged_video.mp4');
if (!outputFile) { if (!output) {
return; return;
} }
@@ -1763,7 +1763,7 @@ async function startMerging(): Promise<void> {
byId('btnMerge').textContent = UI_TEXT.merge.merging; byId('btnMerge').textContent = UI_TEXT.merge.merging;
byId('mergeProgress').classList.add('show'); 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; isMerging = false;
byId('btnMerge').disabled = false; byId('btnMerge').disabled = false;
@@ -1771,7 +1771,7 @@ async function startMerging(): Promise<void> {
byId('mergeProgress').classList.remove('show'); byId('mergeProgress').classList.remove('show');
if (result.success) { if (result.success) {
alert(`${UI_TEXT.merge.success}\n\n${result.outputFile}`); alert(`${UI_TEXT.merge.success}\n\n${result.outputName || output.name}`);
mergeFiles = []; mergeFiles = [];
renderMergeFiles(); renderMergeFiles();
return; return;