fix(security): gate privileged file IPC with capabilities
This commit is contained in:
+232
-66
@@ -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<Config>) => {
|
||||
ipcMain.handle('save-config', (event, newConfig: Partial<Config>, 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<Config>) => {
|
||||
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<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!, {
|
||||
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<boolean> => {
|
||||
if (typeof filePath !== 'string' || !filePath) return false;
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
ipcMain.handle('open-file', async (event, capability: string): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
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<string, boolean> => {
|
||||
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 = {
|
||||
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<ArchiveSearchFilter>): 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<Record<string, unknown>>; 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<Record<string, unknown>>; 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;
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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<string, unknown>) => 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<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds),
|
||||
prepareVideoEditorMedia: (filePath: string): Promise<VideoEditorMedia | null> => ipcRenderer.invoke('prepare-video-editor-media', filePath),
|
||||
prepareVideoEditorWaveform: (filePath: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', filePath, jobId),
|
||||
prepareVideoEditorAssets: (filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> => ipcRenderer.invoke('prepare-video-editor-assets', filePath, jobId, profile),
|
||||
getVideoInfo: (capability: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', capability),
|
||||
extractFrame: (capability: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', capability, timeSeconds),
|
||||
prepareVideoEditorMedia: (capability: string): Promise<VideoEditorMedia | null> => ipcRenderer.invoke('prepare-video-editor-media', capability),
|
||||
prepareVideoEditorWaveform: (capability: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', capability, jobId),
|
||||
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),
|
||||
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'),
|
||||
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'),
|
||||
|
||||
+23
-23
@@ -758,7 +758,7 @@ function animateCutterWorkspaceReveal(previousPreviewRect: DOMRect): void {
|
||||
|
||||
async function requestCutterAssets(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
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<HTMLImageElement>('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<void> {
|
||||
if (!filePath || isCutting) return;
|
||||
async function loadCutterFromPath(file: FileCapabilityReference): Promise<void> {
|
||||
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<void> {
|
||||
byId<HTMLButtonElement>('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<void> {
|
||||
}
|
||||
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<void> {
|
||||
cutterActiveCutId = null;
|
||||
cutterZoom = getInitialCutterZoom(media.info.duration);
|
||||
byId<HTMLInputElement>('cutterZoom').value = String(cutterZoom);
|
||||
byId<HTMLInputElement>('cutterFilePath').value = filePath;
|
||||
byId<HTMLInputElement>('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<void> {
|
||||
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<boolean> {
|
||||
if (!cutterFile || !cutterEditorState || cutterFile === filePath) return Promise.resolve(true);
|
||||
function confirmCutterReplacement(file: FileCapabilityReference): Promise<boolean> {
|
||||
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<boolean> {
|
||||
return new Promise((resolve) => { cutterDiscardResolver = resolve; });
|
||||
}
|
||||
|
||||
async function requestCutterVideoReplacement(filePath: string): Promise<void> {
|
||||
if (!filePath || isCutting) return;
|
||||
if (!await confirmCutterReplacement(filePath)) return;
|
||||
await loadCutterFromPath(filePath);
|
||||
async function requestCutterVideoReplacement(file: FileCapabilityReference): Promise<void> {
|
||||
if (!file || isCutting) return;
|
||||
if (!await confirmCutterReplacement(file)) return;
|
||||
await loadCutterFromPath(file);
|
||||
}
|
||||
|
||||
async function selectCutterVideo(): Promise<void> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
Vendored
+26
-20
@@ -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<AppConfig>;
|
||||
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
|
||||
saveConfig(config: Partial<AppConfig>, fileCapability?: string): Promise<AppConfig>;
|
||||
login(): Promise<boolean>;
|
||||
getUserId(username: string): Promise<string | null>;
|
||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||
@@ -381,16 +387,16 @@ interface ApiBridge {
|
||||
cancelDownload(): Promise<boolean>;
|
||||
isDownloading(): Promise<boolean>;
|
||||
downloadClip(url: string): Promise<{ success: boolean; error?: string }>;
|
||||
selectFolder(): Promise<string | null>;
|
||||
selectVideoFile(): Promise<string | null>;
|
||||
selectMultipleVideos(): Promise<string[] | null>;
|
||||
getPathForFile(file: File): string;
|
||||
saveVideoDialog(defaultName: string): Promise<string | null>;
|
||||
openFolder(path: string): Promise<void>;
|
||||
openFile(path: string): Promise<boolean>;
|
||||
showInFolder(path: string): Promise<boolean>;
|
||||
selectFolder(): Promise<(FileCapabilityReference & { displayPath: string }) | null>;
|
||||
selectVideoFile(): Promise<FileCapabilityReference | null>;
|
||||
selectMultipleVideos(): Promise<FileCapabilityReference[] | null>;
|
||||
selectDroppedVideo(file: File): Promise<FileCapabilityReference | null>;
|
||||
saveVideoDialog(defaultName: string): Promise<FileCapabilityReference | null>;
|
||||
openFolder(pathOrCapability: string): Promise<void>;
|
||||
openFile(pathOrCapability: string): Promise<boolean>;
|
||||
showInFolder(pathOrCapability: string): Promise<boolean>;
|
||||
openDebugLogFile(): Promise<boolean>;
|
||||
checkFolderWritable(path: string): Promise<boolean>;
|
||||
checkFolderWritable(capability: string): Promise<boolean>;
|
||||
getStorageStats(): Promise<StorageStatsResult>;
|
||||
getArchiveStats(): Promise<ArchiveStats>;
|
||||
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
||||
@@ -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<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
prepareVideoEditorMedia(filePath: string): Promise<VideoEditorMedia | null>;
|
||||
prepareVideoEditorWaveform(filePath: string, jobId: number): Promise<VideoEditorWaveform | null>;
|
||||
prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
|
||||
getVideoInfo(capability: string): Promise<VideoInfo | null>;
|
||||
extractFrame(capability: string, timeSeconds: number): Promise<string | null>;
|
||||
prepareVideoEditorMedia(capability: string): Promise<VideoEditorMedia | null>;
|
||||
prepareVideoEditorWaveform(capability: string, jobId: number): Promise<VideoEditorWaveform | null>;
|
||||
prepareVideoEditorAssets(capability: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
|
||||
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>;
|
||||
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<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 }>;
|
||||
|
||||
@@ -936,15 +936,15 @@ async function selectFolder(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
byId<HTMLInputElement>('downloadPath').value = folder;
|
||||
config = await window.api.saveConfig({ download_path: folder });
|
||||
byId<HTMLInputElement>('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');
|
||||
|
||||
@@ -68,11 +68,11 @@ let selectedQueueIds: string[] = [];
|
||||
let expandedQueueIds: Set<string> = 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;
|
||||
|
||||
@@ -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<void> }).requestCutterVideoReplacement;
|
||||
const loader = (window as unknown as { requestCutterVideoReplacement?: (selection: FileCapabilityReference) => Promise<void> }).requestCutterVideoReplacement;
|
||||
if (typeof loader === 'function') {
|
||||
await loader(filePath);
|
||||
await loader(selection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-7
@@ -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 `
|
||||
<div class="file-item" draggable="true" data-index="${index}">
|
||||
<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">
|
||||
<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' : ''}>▲</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' : ''}>▼</button>
|
||||
@@ -1753,8 +1753,8 @@ async function startMerging(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user