fix(security): harden privileged renderer boundaries
Reject renderer-owned queue internals before persistence and bind privileged handlers to trusted renderer events. Extend cutter session capabilities without weakening owner, purpose, path identity, or expiry checks. Migrate release harness contracts to opaque capabilities and add an invisible Node gate.
This commit is contained in:
+2
-1
@@ -22,8 +22,9 @@
|
||||
"test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js",
|
||||
"test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js",
|
||||
"test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js",
|
||||
"test:capability-contract": "node scripts/smoke-test-file-capability-contract.js",
|
||||
"test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js",
|
||||
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave",
|
||||
"test:e2e:release": "npm run build && npm run test:unit && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave",
|
||||
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
function fileName(value) {
|
||||
return String(value).split(/[/\\]/).pop() || '';
|
||||
}
|
||||
|
||||
function requireFileCapability(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('File capability reference is required');
|
||||
if (typeof value.token !== 'string' || value.token.length < 32) throw new Error('Opaque file capability token is required');
|
||||
if (typeof value.name !== 'string' || !value.name || fileName(value.name) !== value.name) throw new Error('Safe file display name is required');
|
||||
return { token: value.token, name: value.name };
|
||||
}
|
||||
|
||||
function createCutterExportRequest(capability, outputFile, state) {
|
||||
const file = requireFileCapability(capability);
|
||||
const outputName = fileName(outputFile);
|
||||
if (!outputName.toLowerCase().endsWith('.mp4')) throw new Error('MP4 output name is required');
|
||||
return {
|
||||
inputCapability: file.token,
|
||||
outputName,
|
||||
trimStart: state.trimStart,
|
||||
trimEnd: state.trimEnd,
|
||||
cuts: state.cuts,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { requireFileCapability, createCutterExportRequest };
|
||||
@@ -4,6 +4,7 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { requireFileCapability } = require('./file-capability-contract');
|
||||
const {
|
||||
createE2eEnvironment,
|
||||
getElectronLaunchOptions,
|
||||
@@ -48,6 +49,32 @@ function getProcessTreeMemoryMb(rootPid) {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
async function createCutterCapability(win, filePath) {
|
||||
const inputId = `cutter-capability-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
await win.evaluate((id) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.id = id;
|
||||
document.body.appendChild(input);
|
||||
}, inputId);
|
||||
await win.locator(`#${inputId}`).setInputFiles(filePath);
|
||||
const capability = await win.evaluate(async (id) => {
|
||||
const input = document.getElementById(id);
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
||||
const capability = file ? await window.api.selectDroppedVideo(file) : null;
|
||||
input?.remove();
|
||||
if (!capability || typeof capability.token !== 'string' || !capability.token) throw new Error('Cutter capability was not issued');
|
||||
return capability;
|
||||
}, inputId);
|
||||
return requireFileCapability(capability);
|
||||
}
|
||||
|
||||
async function loadCutterCapability(win, filePath) {
|
||||
const capability = await createCutterCapability(win, filePath);
|
||||
await win.evaluate((selection) => window.loadCutterFromPath(selection), capability);
|
||||
return capability;
|
||||
}
|
||||
|
||||
function createTestVideo(environment) {
|
||||
const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4');
|
||||
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
||||
@@ -173,6 +200,11 @@ async function run() {
|
||||
await win.setViewportSize({ width: 1440, height: 900 });
|
||||
await win.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await win.evaluate(() => window.showTab('cutter'));
|
||||
const additionalContainerCapabilities = await Promise.all(Object.entries(additionalContainerFiles).map(async ([extension, filePath]) => ({
|
||||
extension,
|
||||
capability: await createCutterCapability(win, filePath),
|
||||
sourceUrl: pathToFileURL(filePath).href
|
||||
})));
|
||||
const additionalContainerSupport = await win.evaluate(async (entries) => {
|
||||
const probe = (sourceUrl) => new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
@@ -197,7 +229,7 @@ async function run() {
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
const nativePlayable = await probe(entry.sourceUrl);
|
||||
const media = await window.api.prepareVideoEditorMedia(entry.filePath);
|
||||
const media = await window.api.prepareVideoEditorMedia(entry.capability.token);
|
||||
results.push({
|
||||
extension: entry.extension,
|
||||
nativePlayable,
|
||||
@@ -206,7 +238,7 @@ async function run() {
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, Object.entries(additionalContainerFiles).map(([extension, filePath]) => ({ extension, filePath, sourceUrl: pathToFileURL(filePath).href })));
|
||||
}, additionalContainerCapabilities);
|
||||
check(additionalContainerSupport.every((entry) => entry.prepared && entry.editorPlayable), `Additional video containers are not usable in the editor: ${JSON.stringify(additionalContainerSupport)}`);
|
||||
const cutterArtifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'cutter');
|
||||
fs.mkdirSync(cutterArtifactDir, { recursive: true });
|
||||
@@ -289,7 +321,8 @@ async function run() {
|
||||
return;
|
||||
}
|
||||
const firstAssetsStartedAt = Date.now();
|
||||
const initialOriginalDevicePixelRatio = await win.evaluate((filePath) => {
|
||||
const inputCapability = await createCutterCapability(win, inputFile);
|
||||
const initialOriginalDevicePixelRatio = await win.evaluate((fileCapability) => {
|
||||
const originalDevicePixelRatio = window.devicePixelRatio;
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 });
|
||||
const workspace = document.getElementById('cutterWorkspace');
|
||||
@@ -347,9 +380,9 @@ async function run() {
|
||||
if (window.__cutterAssetAudit.waveformLoads.at(-1)?.signature === signature) return;
|
||||
window.__cutterAssetAudit.waveformLoads.push({ signature, width: image.naturalWidth, height: image.naturalHeight });
|
||||
});
|
||||
window.__cutterLoadPromise = window.loadCutterFromPath(filePath);
|
||||
window.__cutterLoadPromise = window.loadCutterFromPath(fileCapability);
|
||||
return originalDevicePixelRatio;
|
||||
}, inputFile);
|
||||
}, inputCapability);
|
||||
await win.waitForFunction(() => document.getElementById('cutterWorkspace').classList.contains('shown'), null, { timeout: 15000 });
|
||||
await win.evaluate(() => window.__cutterLoadPromise);
|
||||
await win.waitForFunction(() => {
|
||||
@@ -640,10 +673,11 @@ async function run() {
|
||||
&& cutterInfoAlignment.valueLineHeights.every(Number.isFinite),
|
||||
`Cutter information labels and values are not aligned to common rows: ${JSON.stringify(cutterInfoAlignment)}`
|
||||
);
|
||||
const replacementCapability = await createCutterCapability(win, scrubStressInputFile);
|
||||
await app.evaluate(({ ipcMain }, nextFile) => {
|
||||
ipcMain.removeHandler('select-video-file');
|
||||
ipcMain.handle('select-video-file', () => nextFile);
|
||||
}, scrubStressInputFile);
|
||||
}, replacementCapability);
|
||||
await win.evaluate(() => {
|
||||
cutterEditorState.trimStart = 1;
|
||||
renderCutterEditor();
|
||||
@@ -718,7 +752,7 @@ async function run() {
|
||||
check(replacementPlaybackState.paused && !replacementPlaybackState.playingClass && replacementPlaybackState.playIconVisible && !replacementPlaybackState.pauseIconVisible, `Replacing a playing video leaves stale playback UI: ${JSON.stringify(replacementPlaybackState)}`);
|
||||
}
|
||||
const scrubFirstAssetsStartedAt = Date.now();
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), scrubStressInputFile);
|
||||
await loadCutterCapability(win, scrubStressInputFile);
|
||||
await win.waitForFunction(() => {
|
||||
const video = document.getElementById('cutterVideo');
|
||||
return video.readyState >= HTMLMediaElement.HAVE_METADATA && video.duration >= 8 && video.duration <= 10;
|
||||
@@ -945,7 +979,7 @@ async function run() {
|
||||
`Trim handles are not as immediate and frame-synchronized as the playhead: ${JSON.stringify(trimScrubProbe)}`
|
||||
);
|
||||
const mediumLoadStarted = Date.now();
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), mediumInputFile);
|
||||
await loadCutterCapability(win, mediumInputFile);
|
||||
await win.waitForFunction(() => {
|
||||
const video = document.getElementById('cutterVideo');
|
||||
const waveform = document.getElementById('cutterWaveform');
|
||||
@@ -1010,7 +1044,7 @@ async function run() {
|
||||
);
|
||||
const memoryBeforeStressMb = getProcessTreeMemoryMb(app.process().pid);
|
||||
const longLoadStarted = Date.now();
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), longInputFile);
|
||||
const longCapability = await loadCutterCapability(win, longInputFile);
|
||||
await win.waitForFunction(() => {
|
||||
const video = document.getElementById('cutterVideo');
|
||||
return video.readyState >= HTMLMediaElement.HAVE_METADATA && Math.abs(video.duration - 1800) < 1;
|
||||
@@ -1020,9 +1054,10 @@ async function run() {
|
||||
await win.evaluate(() => window.showTab('vods'));
|
||||
await win.waitForTimeout(80);
|
||||
await win.evaluate(() => window.showTab('cutter'));
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), unsupportedInputFile);
|
||||
const unsupportedCapability = { token: 'forged-unsupported-capability', name: path.basename(unsupportedInputFile) };
|
||||
await win.evaluate((capability) => window.loadCutterFromPath(capability), unsupportedCapability);
|
||||
await win.waitForFunction(() => !document.getElementById('cutterWorkspace').classList.contains('loading'));
|
||||
const longPreservedAfterAssetInterruptions = await win.evaluate((expectedFile) => cutterFile === expectedFile, longInputFile);
|
||||
const longPreservedAfterAssetInterruptions = await win.evaluate((expectedToken) => cutterFile?.token === expectedToken, longCapability.token);
|
||||
check(longPreservedAfterAssetInterruptions, 'Long editor was replaced after an interrupted asset load and unsupported file selection');
|
||||
await win.waitForFunction(() => Number(document.getElementById('cutterThumbnailStrip').dataset.thumbnailCount || document.querySelectorAll('#cutterThumbnailStrip img').length) >= 30, null, { timeout: 90000 });
|
||||
const longAssetsReadyMs = Date.now() - longLoadStarted;
|
||||
@@ -1082,28 +1117,30 @@ async function run() {
|
||||
longScrubPresentation.frames >= 12 && longScrubPresentation.maximumGap !== null && longScrubPresentation.maximumGap <= 0.75,
|
||||
`Long-video scrubbing skips too much visible media: ${JSON.stringify(longScrubPresentation)}`
|
||||
);
|
||||
const rapidSwitchCapabilities = await Promise.all([inputFile, longInputFile, silentInputFile, longInputFile, inputFile, silentInputFile, longInputFile, silentInputFile].map((filePath) => createCutterCapability(win, filePath)));
|
||||
const rapidSwitch = await win.evaluate(async (files) => {
|
||||
await Promise.allSettled(files.map((filePath) => window.loadCutterFromPath(filePath)));
|
||||
return cutterFile;
|
||||
}, [inputFile, longInputFile, silentInputFile, longInputFile, inputFile, silentInputFile, longInputFile, silentInputFile]);
|
||||
await win.waitForFunction((expectedFile) => cutterFile === expectedFile && document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, silentInputFile, { timeout: 15000 });
|
||||
check(rapidSwitch === silentInputFile, `Rapid file switching committed stale media: ${rapidSwitch}`);
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), inputFile);
|
||||
await Promise.allSettled(files.map((file) => window.loadCutterFromPath(file)));
|
||||
return cutterFile?.token || null;
|
||||
}, rapidSwitchCapabilities);
|
||||
const expectedRapidSwitchToken = rapidSwitchCapabilities.at(-1).token;
|
||||
await win.waitForFunction((expectedToken) => cutterFile?.token === expectedToken && document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, expectedRapidSwitchToken, { timeout: 15000 });
|
||||
check(rapidSwitch === expectedRapidSwitchToken, `Rapid file switching committed stale media: ${rapidSwitch}`);
|
||||
await win.evaluate((capability) => window.loadCutterFromPath(capability), inputCapability);
|
||||
await win.waitForFunction(() => document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA && Number(document.getElementById('cutterThumbnailStrip').dataset.thumbnailCount || document.querySelectorAll('#cutterThumbnailStrip img').length) >= 30, null, { timeout: 90000 });
|
||||
const memoryAfterStressMb = getProcessTreeMemoryMb(app.process().pid);
|
||||
const stressMemoryDeltaMb = memoryBeforeStressMb && memoryAfterStressMb ? memoryAfterStressMb - memoryBeforeStressMb : 0;
|
||||
check(stressMemoryDeltaMb < 350, `Rapid media switching retained too much process memory: ${stressMemoryDeltaMb.toFixed(1)} MB`);
|
||||
const probedMedia = await win.evaluate((filePath) => window.api.getVideoInfo(filePath), inputFile);
|
||||
const probedMedia = await win.evaluate((capability) => window.api.getVideoInfo(capability.token), inputCapability);
|
||||
check(probedMedia?.videoCodec === 'h264' && probedMedia?.audioCodec === 'aac' && probedMedia?.previewCompatible && !probedMedia?.variableFrameRate, `Media capability probe is inconsistent: ${JSON.stringify(probedMedia)}`);
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), unsupportedInputFile);
|
||||
await win.evaluate((capability) => window.loadCutterFromPath(capability), unsupportedCapability);
|
||||
await win.waitForFunction(() => !document.getElementById('cutterWorkspace').classList.contains('loading'));
|
||||
const preservedAfterUnsupported = await win.evaluate((expectedFile) => ({
|
||||
filePreserved: cutterFile === expectedFile,
|
||||
filePreserved: cutterFile?.token === expectedFile,
|
||||
statePreserved: Boolean(cutterEditorState) && cutterEditorState.duration === 10,
|
||||
playerPreserved: document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA,
|
||||
waveformPreserved: !document.getElementById('cutterWaveform').hidden && document.getElementById('cutterWaveform').naturalWidth === 32000,
|
||||
exportEnabled: !document.getElementById('btnCut').disabled
|
||||
}), inputFile);
|
||||
}), inputCapability.token);
|
||||
check(Object.values(preservedAfterUnsupported).every(Boolean), `Unsupported replacement corrupted the loaded editor: ${JSON.stringify(preservedAfterUnsupported)}`);
|
||||
await win.locator('#timelineContainer').scrollIntoViewIfNeeded();
|
||||
const timeline = await win.locator('#timeline').boundingBox();
|
||||
@@ -1815,28 +1852,28 @@ async function run() {
|
||||
trimEnd: cutterEditorState.trimEnd,
|
||||
cuts: cutterEditorState.cuts.map((cut) => ({ ...cut }))
|
||||
}));
|
||||
const sourceProtection = await win.evaluate(({ inputFile, editorState }) => window.api.exportVideoEdit({
|
||||
inputFile,
|
||||
outputFile: inputFile.toUpperCase(),
|
||||
const sourceProtection = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: editorState.trimStart,
|
||||
trimEnd: editorState.trimEnd,
|
||||
cuts: editorState.cuts
|
||||
}), { inputFile, editorState });
|
||||
}), { outputName: path.basename(inputFile).toUpperCase(), editorState });
|
||||
check(!sourceProtection.success && fs.existsSync(inputFile) && fs.statSync(inputFile).size > 256, `Source overwrite protection failed: ${JSON.stringify(sourceProtection)}`);
|
||||
const invalidOutputFile = path.join(environment.mediaDir, 'Invalid request.mp4');
|
||||
const invalidRequest = await win.evaluate(({ inputFile, outputFile, editorState }) => window.api.exportVideoEdit({
|
||||
inputFile,
|
||||
outputFile,
|
||||
const invalidRequest = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: Number.NaN,
|
||||
trimEnd: editorState.trimEnd,
|
||||
cuts: editorState.cuts
|
||||
}), { inputFile, outputFile: invalidOutputFile, editorState });
|
||||
}), { outputName: path.basename(invalidOutputFile), editorState });
|
||||
check(!invalidRequest.success && !fs.existsSync(invalidOutputFile), `Invalid numeric request was accepted: ${JSON.stringify(invalidRequest)}`);
|
||||
const cancelledOutputFile = path.join(environment.mediaDir, 'Cancelled export.mp4');
|
||||
const cancelledExport = await win.evaluate(async ({ inputFile, outputFile, editorState }) => {
|
||||
const cancelledExport = await win.evaluate(async ({ outputName, editorState }) => {
|
||||
const exportPromise = window.api.exportVideoEdit({
|
||||
inputFile,
|
||||
outputFile,
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: editorState.trimStart,
|
||||
trimEnd: editorState.trimEnd,
|
||||
cuts: editorState.cuts
|
||||
@@ -1844,16 +1881,16 @@ async function run() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const cancelAccepted = await window.api.cancelVideoEdit();
|
||||
return { cancelAccepted, result: await exportPromise };
|
||||
}, { inputFile, outputFile: cancelledOutputFile, editorState });
|
||||
}, { outputName: path.basename(cancelledOutputFile), editorState });
|
||||
check(cancelledExport.cancelAccepted && !cancelledExport.result.success && cancelledExport.result.cancelled === true && !fs.existsSync(cancelledOutputFile), `Export cancellation did not return a clean cancelled result or published a file: ${JSON.stringify(cancelledExport)}`);
|
||||
fs.writeFileSync(outputFile, 'previous-output', 'utf8');
|
||||
const exportResult = await win.evaluate(({ inputFile, outputFile, editorState }) => window.api.exportVideoEdit({
|
||||
inputFile,
|
||||
outputFile,
|
||||
const exportResult = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: editorState.trimStart,
|
||||
trimEnd: editorState.trimEnd,
|
||||
cuts: editorState.cuts
|
||||
}), { inputFile, outputFile, editorState });
|
||||
}), { outputName: path.basename(outputFile), editorState });
|
||||
check(exportResult.success && fs.existsSync(outputFile), `Export failed: ${JSON.stringify(exportResult)}`);
|
||||
if (fs.existsSync(outputFile)) {
|
||||
const probe = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', outputFile]));
|
||||
@@ -1869,24 +1906,24 @@ async function run() {
|
||||
start: 1.2 + index * 0.2,
|
||||
end: 1.28 + index * 0.2
|
||||
}));
|
||||
const manyCutsExport = await win.evaluate(({ inputFile, outputFile, cuts }) => window.api.exportVideoEdit({
|
||||
inputFile,
|
||||
outputFile,
|
||||
const manyCutsExport = await win.evaluate(({ outputName, cuts }) => window.api.exportVideoEdit({
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: 1,
|
||||
trimEnd: 9.5,
|
||||
cuts
|
||||
}), { inputFile, outputFile: manyCutsOutputFile, cuts: manyCuts });
|
||||
}), { outputName: path.basename(manyCutsOutputFile), cuts: manyCuts });
|
||||
check(manyCutsExport.success && fs.existsSync(manyCutsOutputFile) && fs.statSync(manyCutsOutputFile).size > 256, `Many-cut export failed: ${JSON.stringify(manyCutsExport)}`);
|
||||
const sourceStatBeforeMutation = fs.statSync(inputFile);
|
||||
fs.utimesSync(inputFile, sourceStatBeforeMutation.atime, new Date(sourceStatBeforeMutation.mtimeMs + 5000));
|
||||
const changedSourceOutputFile = path.join(environment.mediaDir, 'Changed source rejected.mp4');
|
||||
const changedSourceExport = await win.evaluate(({ inputFile, outputFile }) => window.api.exportVideoEdit({
|
||||
inputFile,
|
||||
outputFile,
|
||||
const changedSourceExport = await win.evaluate(({ outputName }) => window.api.exportVideoEdit({
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: 0,
|
||||
trimEnd: 10,
|
||||
cuts: []
|
||||
}), { inputFile, outputFile: changedSourceOutputFile });
|
||||
}), { outputName: path.basename(changedSourceOutputFile) });
|
||||
check(!changedSourceExport.success && !fs.existsSync(changedSourceOutputFile), `Changed source identity was accepted: ${JSON.stringify(changedSourceExport)}`);
|
||||
await win.evaluate(() => {
|
||||
window.updateCutterZoom(1);
|
||||
@@ -1894,7 +1931,7 @@ async function run() {
|
||||
});
|
||||
await win.waitForTimeout(250);
|
||||
await win.screenshot({ path: path.join(cutterArtifactDir, 'editor.png'), fullPage: true });
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), silentInputFile);
|
||||
await loadCutterCapability(win, silentInputFile);
|
||||
await win.waitForFunction(() => {
|
||||
const video = document.getElementById('cutterVideo');
|
||||
return video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth === 360;
|
||||
@@ -1907,13 +1944,13 @@ async function run() {
|
||||
emptyVisible: !document.getElementById('cutterAudioEmpty').hidden
|
||||
}));
|
||||
check(silentState.waveformHidden && silentState.emptyVisible, `Silent video does not show the no-audio state: ${JSON.stringify(silentState)}`);
|
||||
const silentExportResult = await win.evaluate(({ inputFile, outputFile, state }) => window.api.exportVideoEdit({
|
||||
inputFile,
|
||||
outputFile,
|
||||
const silentExportResult = await win.evaluate(({ outputName, state }) => window.api.exportVideoEdit({
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: state.trimStart,
|
||||
trimEnd: state.trimEnd,
|
||||
cuts: state.cuts
|
||||
}), { inputFile: silentInputFile, outputFile: silentOutputFile, state: silentState });
|
||||
}), { outputName: path.basename(silentOutputFile), state: silentState });
|
||||
check(silentExportResult.success && fs.existsSync(silentOutputFile), `Silent export failed: ${JSON.stringify(silentExportResult)}`);
|
||||
if (fs.existsSync(silentOutputFile)) {
|
||||
const silentProbe = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', silentOutputFile]));
|
||||
@@ -1925,11 +1962,11 @@ async function run() {
|
||||
check(runtimeIssues.length === 0, runtimeIssues.join('\n'));
|
||||
const cutterTempDirectoriesBeforeShutdown = new Set(fs.readdirSync(os.tmpdir()).filter((name) => /^tvm-editor-(?:media|waveform|preview)-/.test(name)));
|
||||
const shutdownOutputFile = path.join(environment.mediaDir, 'Shutdown export.mp4');
|
||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), longInputFile);
|
||||
await loadCutterCapability(win, longInputFile);
|
||||
await win.waitForFunction(() => document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, null, { timeout: 15000 });
|
||||
await win.evaluate(({ inputFile, outputFile }) => {
|
||||
void window.api.exportVideoEdit({ inputFile, outputFile, trimStart: 0, trimEnd: 1800, cuts: [] });
|
||||
}, { inputFile: longInputFile, outputFile: shutdownOutputFile });
|
||||
await win.evaluate(({ outputName }) => {
|
||||
void window.api.exportVideoEdit({ inputCapability: cutterFile.token, outputName, trimStart: 0, trimEnd: 1800, cuts: [] });
|
||||
}, { outputName: path.basename(shutdownOutputFile) });
|
||||
await win.waitForTimeout(150);
|
||||
const appClosed = app.waitForEvent('close', { timeout: 15000 });
|
||||
try { await app.evaluate(({ app: electronApp }) => electronApp.quit()); } catch { }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const assert = require('assert');
|
||||
const { createCutterExportRequest, requireFileCapability } = require('./file-capability-contract');
|
||||
|
||||
assert.throws(() => requireFileCapability('C:\\forged\\video.mp4'));
|
||||
assert.throws(() => requireFileCapability({ token: 'short', name: 'video.mp4' }));
|
||||
assert.throws(() => requireFileCapability({ token: 'x'.repeat(43), name: 'C:\\forged\\video.mp4' }));
|
||||
|
||||
const capability = { token: 'x'.repeat(43), name: 'video.mp4', displayPath: 'C:\\private\\video.mp4' };
|
||||
assert.deepStrictEqual(requireFileCapability(capability), { token: 'x'.repeat(43), name: 'video.mp4' });
|
||||
|
||||
const request = createCutterExportRequest(capability, 'C:\\exports\\result.mp4', { trimStart: 1, trimEnd: 5, cuts: [] });
|
||||
assert.deepStrictEqual(request, {
|
||||
inputCapability: 'x'.repeat(43),
|
||||
outputName: 'result.mp4',
|
||||
trimStart: 1,
|
||||
trimEnd: 5,
|
||||
cuts: [],
|
||||
});
|
||||
assert.strictEqual('inputFile' in request, false);
|
||||
assert.strictEqual('outputFile' in request, false);
|
||||
|
||||
console.log('File capability contract tests passed.');
|
||||
@@ -86,6 +86,14 @@ async function run() {
|
||||
const isolation = await verifyE2eIsolation(app, win, environment);
|
||||
const fixtures = await installOfflineFixtures(app);
|
||||
const issues = [];
|
||||
const mergedOutputFile = path.join(environment.mediaDir, 'merged_full.mp4');
|
||||
await app.evaluate(({ dialog }, files) => {
|
||||
dialog.showOpenDialog = async (_window, options) => ({
|
||||
canceled: false,
|
||||
filePaths: options?.properties?.includes('multiSelections') ? [files.mediaA, files.mediaB] : [files.mediaA]
|
||||
});
|
||||
dialog.showSaveDialog = async () => ({ canceled: false, filePath: files.mergedOutputFile });
|
||||
}, { mediaA, mediaB, mergedOutputFile });
|
||||
|
||||
win.on('pageerror', (err) => {
|
||||
issues.push(`pageerror: ${String(err)}`);
|
||||
@@ -206,7 +214,7 @@ async function run() {
|
||||
assert(deState.deActive, 'German language button did not activate');
|
||||
assert(enState.enActive, 'English language button did not activate');
|
||||
|
||||
await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir });
|
||||
await window.api.saveConfig({ client_id: '', client_secret: '' });
|
||||
window.showTab('vods');
|
||||
await window.selectStreamer('fixture_streamer');
|
||||
|
||||
@@ -310,16 +318,27 @@ async function run() {
|
||||
|
||||
await clearQueue();
|
||||
|
||||
const info = await window.api.getVideoInfo(mediaA);
|
||||
const frame = await window.api.extractFrame(mediaA, 1);
|
||||
const cut = await window.api.cutVideo(mediaA, 0.5, 1.7);
|
||||
const merge = await window.api.mergeVideos([mediaA, mediaB], `${tmpDir.replace(/\\/g, '/')}/merged_full.mp4`);
|
||||
const cutterInput = await window.api.selectVideoFile();
|
||||
const mergeInputs = await window.api.selectMultipleVideos();
|
||||
const mergeOutput = await window.api.saveVideoDialog('merged_full.mp4');
|
||||
const capabilityContract = Boolean(
|
||||
cutterInput?.token
|
||||
&& mergeInputs?.length === 2
|
||||
&& mergeInputs.every((file) => typeof file.token === 'string' && file.token)
|
||||
&& mergeOutput?.token
|
||||
);
|
||||
const info = capabilityContract ? await window.api.getVideoInfo(cutterInput.token) : null;
|
||||
const frame = capabilityContract ? await window.api.extractFrame(cutterInput.token, 1) : null;
|
||||
const cut = capabilityContract ? await window.api.cutVideo(cutterInput.token, 0.5, 1.7) : { success: false };
|
||||
const merge = capabilityContract ? await window.api.mergeVideos(mergeInputs.map((file) => file.token), mergeOutput.token) : { success: false };
|
||||
checks.media = {
|
||||
capabilityContract,
|
||||
infoOk: !!info && info.duration > 0,
|
||||
frameOk: typeof frame === 'string' && frame.length > 100,
|
||||
cutOk: cut.success,
|
||||
mergeOk: merge.success
|
||||
};
|
||||
assert(checks.media.capabilityContract, 'File dialogs did not issue capability references');
|
||||
assert(checks.media.infoOk, 'getVideoInfo failed for test media');
|
||||
assert(checks.media.frameOk, 'extractFrame failed for test media');
|
||||
assert(checks.media.cutOk, 'cutVideo failed for test media');
|
||||
|
||||
@@ -176,19 +176,19 @@ async function run() {
|
||||
|
||||
const cutterDropFixturePath = path.join(environment.mediaDir, 'electron-43-cutter-drop.mp4');
|
||||
fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8');
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
globalThis.__workspaceCutterDropPaths = { media: '' };
|
||||
await app.evaluate(({ ipcMain }, expectedPath) => {
|
||||
globalThis.__workspaceCutterDropPaths = { mediaCapability: '', expectedPath };
|
||||
ipcMain.removeHandler('prepare-video-editor-media');
|
||||
ipcMain.handle('prepare-video-editor-media', (_, filePath) => {
|
||||
globalThis.__workspaceCutterDropPaths.media = filePath;
|
||||
ipcMain.handle('prepare-video-editor-media', (_, capability) => {
|
||||
globalThis.__workspaceCutterDropPaths.mediaCapability = capability;
|
||||
return {
|
||||
sourceUrl: encodeURI(`file:///${filePath.replace(/\\/g, '/')}`),
|
||||
sourceUrl: encodeURI(`file:///${expectedPath.replace(/\\/g, '/')}`),
|
||||
info: { duration: 120, width: 1920, height: 1080, fps: 60, hasAudio: false },
|
||||
thumbnails: ['data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='],
|
||||
waveform: null
|
||||
};
|
||||
});
|
||||
});
|
||||
}, cutterDropFixturePath);
|
||||
await win.evaluate(() => {
|
||||
window.showTab('cutter');
|
||||
const input = document.createElement('input');
|
||||
@@ -200,7 +200,7 @@ async function run() {
|
||||
const cutterFileObject = await win.evaluate(() => {
|
||||
const input = document.getElementById('workspaceCutterDropInput');
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : undefined;
|
||||
if (!file) return { name: '', legacyPathType: 'missing', apiType: typeof window.api.getPathForFile };
|
||||
if (!file) return { name: '', legacyPathType: 'missing', apiType: typeof window.api.selectDroppedVideo };
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
document.getElementById('cutterTab')?.dispatchEvent(new DragEvent('drop', {
|
||||
@@ -211,7 +211,7 @@ async function run() {
|
||||
return {
|
||||
name: file.name,
|
||||
legacyPathType: typeof file.path,
|
||||
apiType: typeof window.api.getPathForFile
|
||||
apiType: typeof window.api.selectDroppedVideo
|
||||
};
|
||||
});
|
||||
await win.waitForTimeout(250);
|
||||
@@ -229,8 +229,9 @@ async function run() {
|
||||
ui: cutterDropUi
|
||||
};
|
||||
check(cutterFileObject.legacyPathType === 'undefined', `Electron File.path is unexpectedly ${cutterFileObject.legacyPathType}`);
|
||||
check(cutterDropUi.filePath === cutterDropFixturePath, `Cutter drop resolved "${cutterDropUi.filePath}" instead of the Electron file path`);
|
||||
check(cutterDropPaths.media === cutterDropFixturePath, `Cutter drop sent "${cutterDropPaths.media}" to media preparation instead of the Electron file path`);
|
||||
check(cutterFileObject.apiType === 'function', `Cutter capability API is ${cutterFileObject.apiType}`);
|
||||
check(cutterDropUi.filePath === path.basename(cutterDropFixturePath), `Cutter drop displayed "${cutterDropUi.filePath}" instead of the safe file name`);
|
||||
check(typeof cutterDropPaths.mediaCapability === 'string' && cutterDropPaths.mediaCapability.length >= 32 && cutterDropPaths.mediaCapability !== cutterDropFixturePath, `Cutter drop did not send an opaque capability to media preparation: ${JSON.stringify(cutterDropPaths)}`);
|
||||
check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls');
|
||||
|
||||
const queueEmptyActions = await win.evaluate(() => ({
|
||||
|
||||
+54
-42
@@ -41,12 +41,15 @@ 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 {
|
||||
CUTTER_SESSION_CAPABILITY_TTL_MS,
|
||||
FileCapabilityStore,
|
||||
isTrustedFileIpcSender,
|
||||
publishCapabilityOutput,
|
||||
type FileCapabilityPurpose,
|
||||
type FileCapabilityReference,
|
||||
} from './main/domain/file-capability';
|
||||
import { registerTrustedIpcHandler } from './main/domain/privileged-ipc';
|
||||
import { createRendererQueueItem, getMergeGroupCleanupPaths } from './main/domain/renderer-queue-input';
|
||||
import {
|
||||
setDebugLogFn, initToolDirs,
|
||||
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
||||
@@ -7222,12 +7225,14 @@ ipcMain.handle('get-automation-status', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
ipcMain.handle('trigger-auto-record-scan', async () => {
|
||||
ipcMain.handle('trigger-auto-record-scan', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return { triggered: 0 };
|
||||
const triggered = await runAutoRecordPoll();
|
||||
return { triggered };
|
||||
});
|
||||
|
||||
ipcMain.handle('trigger-auto-vod-scan', async () => {
|
||||
ipcMain.handle('trigger-auto-vod-scan', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return { queuedCount: 0 };
|
||||
const queuedCount = await runAutoVodPoll();
|
||||
return { queuedCount };
|
||||
});
|
||||
@@ -7317,7 +7322,8 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
||||
return config;
|
||||
});
|
||||
|
||||
ipcMain.handle('login', async () => {
|
||||
ipcMain.handle('login', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return false;
|
||||
return await twitchLogin();
|
||||
});
|
||||
|
||||
@@ -7335,7 +7341,8 @@ ipcMain.handle('get-queue', (event) => {
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('start-live-recording', async (_, streamerName: string) => {
|
||||
ipcMain.handle('start-live-recording', async (event, streamerName: string) => {
|
||||
if (!isTrustedRendererEvent(event)) return { success: false, error: 'Access denied' };
|
||||
if (typeof streamerName !== 'string' || !streamerName) {
|
||||
return { success: false, error: 'Invalid streamer name' };
|
||||
}
|
||||
@@ -7379,7 +7386,9 @@ ipcMain.handle('start-live-recording', async (_, streamerName: string) => {
|
||||
return { success: true, streamer: login, title: liveInfo.title || login };
|
||||
});
|
||||
|
||||
ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => {
|
||||
registerTrustedIpcHandler(ipcMain, 'add-to-queue', isTrustedRendererEvent, () => downloadQueue, (_, input: unknown) => {
|
||||
const item = createRendererQueueItem(input, generateQueueItemId());
|
||||
if (!item) return downloadQueue;
|
||||
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
|
||||
runtimeMetrics.duplicateSkips += 1;
|
||||
mainWindow?.webContents.send('queue-duplicate-skipped', {
|
||||
@@ -7395,19 +7404,14 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
|
||||
return downloadQueue;
|
||||
}
|
||||
|
||||
const queueItem: QueueItem = {
|
||||
...item,
|
||||
id: generateQueueItemId(),
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
};
|
||||
downloadQueue.push(queueItem);
|
||||
downloadQueue.push(item);
|
||||
saveQueue(downloadQueue);
|
||||
emitQueueUpdated();
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('remove-from-queue', async (_, id: string) => {
|
||||
registerTrustedIpcHandler(ipcMain, 'remove-from-queue', isTrustedRendererEvent, () => Promise.resolve(downloadQueue), async (_, id: string) => {
|
||||
if (typeof id !== 'string' || !id) return downloadQueue;
|
||||
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id) || queueProcessRegistry.activeItemIds().includes(id);
|
||||
|
||||
if (wasActiveItem) {
|
||||
@@ -7423,14 +7427,8 @@ ipcMain.handle('remove-from-queue', async (_, id: string) => {
|
||||
|
||||
// Clean up merge-group temp files (must run for any merge group, not just active)
|
||||
const removedItem = downloadQueue.find(item => item.id === id);
|
||||
if (removedItem?.mergeGroup) {
|
||||
const mg = removedItem.mergeGroup;
|
||||
for (const key of Object.keys(mg.downloadedFiles)) {
|
||||
try { if (fs.existsSync(mg.downloadedFiles[Number(key)])) fs.unlinkSync(mg.downloadedFiles[Number(key)]); } catch { }
|
||||
}
|
||||
if (mg.mergedFile) {
|
||||
try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); } catch { }
|
||||
}
|
||||
for (const cleanupPath of getMergeGroupCleanupPaths(removedItem)) {
|
||||
try { if (fs.existsSync(cleanupPath)) fs.unlinkSync(cleanupPath); } catch { }
|
||||
}
|
||||
|
||||
downloadQueue = downloadQueue.filter(item => item.id !== id);
|
||||
@@ -7439,14 +7437,16 @@ ipcMain.handle('remove-from-queue', async (_, id: string) => {
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-completed', () => {
|
||||
ipcMain.handle('clear-completed', (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
||||
downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
|
||||
saveQueue(downloadQueue);
|
||||
emitQueueUpdated();
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('reorder-queue', (_, orderIds: string[]) => {
|
||||
ipcMain.handle('reorder-queue', (event, orderIds: string[]) => {
|
||||
if (!isTrustedRendererEvent(event) || !Array.isArray(orderIds)) return downloadQueue;
|
||||
const order = new Map(orderIds.map((id, idx) => [id, idx]));
|
||||
const withOrder = [...downloadQueue].sort((a, b) => {
|
||||
const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER;
|
||||
@@ -7460,7 +7460,8 @@ ipcMain.handle('reorder-queue', (_, orderIds: string[]) => {
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('retry-failed-downloads', async () => {
|
||||
ipcMain.handle('retry-failed-downloads', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
||||
const failedIds = downloadQueue.filter((item) => item.status === 'error').map((item) => item.id);
|
||||
await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id)));
|
||||
for (const id of failedIds) queueProcessRegistry.resetItem(id);
|
||||
@@ -7485,7 +7486,8 @@ ipcMain.handle('retry-failed-downloads', async () => {
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('retry-queue-item', async (_, id: string) => {
|
||||
ipcMain.handle('retry-queue-item', async (event, id: string) => {
|
||||
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
||||
if (typeof id !== 'string' || !id) return downloadQueue;
|
||||
const idx = downloadQueue.findIndex((it) => it.id === id);
|
||||
if (idx < 0) return downloadQueue;
|
||||
@@ -7513,7 +7515,8 @@ ipcMain.handle('retry-queue-item', async (_, id: string) => {
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('create-merge-group', (_, itemIds: string[]) => {
|
||||
ipcMain.handle('create-merge-group', (event, itemIds: string[]) => {
|
||||
if (!isTrustedRendererEvent(event) || !Array.isArray(itemIds)) return downloadQueue;
|
||||
const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id));
|
||||
|
||||
if (selectedItems.length < 2) {
|
||||
@@ -7590,7 +7593,8 @@ ipcMain.handle('create-merge-group', (_, itemIds: string[]) => {
|
||||
return downloadQueue;
|
||||
});
|
||||
|
||||
ipcMain.handle('start-download', async () => {
|
||||
ipcMain.handle('start-download', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return false;
|
||||
if (isDownloading && queuePaused) {
|
||||
queuePaused = false;
|
||||
for (const item of downloadQueue) {
|
||||
@@ -7620,7 +7624,8 @@ ipcMain.handle('start-download', async () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('pause-download', async () => {
|
||||
ipcMain.handle('pause-download', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return false;
|
||||
if (!isDownloading || queuePaused) return false;
|
||||
|
||||
queuePaused = true;
|
||||
@@ -7639,7 +7644,8 @@ ipcMain.handle('pause-download', async () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('cancel-download', async () => {
|
||||
ipcMain.handle('cancel-download', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return false;
|
||||
isDownloading = false;
|
||||
queuePaused = false;
|
||||
const activeItemIds = queueProcessRegistry.activeItemIds();
|
||||
@@ -7652,8 +7658,8 @@ 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 issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = [], ttlMs?: number): FileCapabilityReference {
|
||||
return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions, ttlMs });
|
||||
}
|
||||
|
||||
function resolveFileCapability(event: IpcMainInvokeEvent, token: string, purpose: FileCapabilityPurpose, consume = false, protectedPaths: string[] = []): string | null {
|
||||
@@ -7715,14 +7721,14 @@ ipcMain.handle('select-video-file', async (event) => {
|
||||
]
|
||||
});
|
||||
return result.filePaths[0]
|
||||
? issueFileCapability(event, 'cutter-input', result.filePaths[0], 'input-file', VIDEO_FILE_EXTENSIONS)
|
||||
? issueFileCapability(event, 'cutter-input', result.filePaths[0], 'input-file', VIDEO_FILE_EXTENSIONS, CUTTER_SESSION_CAPABILITY_TTL_MS)
|
||||
: null;
|
||||
});
|
||||
|
||||
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);
|
||||
return issueFileCapability(event, 'cutter-input', filePath, 'input-file', VIDEO_FILE_EXTENSIONS, CUTTER_SESSION_CAPABILITY_TTL_MS);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -7780,7 +7786,8 @@ ipcMain.handle('show-in-folder', (event, capability: string): boolean => {
|
||||
|
||||
ipcMain.handle('get-version', () => APP_VERSION);
|
||||
|
||||
ipcMain.handle('check-update', async () => {
|
||||
ipcMain.handle('check-update', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return { error: true };
|
||||
try {
|
||||
setupAutoUpdater();
|
||||
const result = await requestUpdateCheck('manual', true);
|
||||
@@ -7797,7 +7804,8 @@ ipcMain.handle('check-update', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('download-update', async () => {
|
||||
ipcMain.handle('download-update', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return { error: true };
|
||||
try {
|
||||
setupAutoUpdater();
|
||||
const result = await requestUpdateDownload('manual');
|
||||
@@ -7814,11 +7822,13 @@ ipcMain.handle('download-update', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('install-update', () => {
|
||||
ipcMain.handle('install-update', (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return;
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', async (_, url: string) => {
|
||||
ipcMain.handle('open-external', async (event, url: string) => {
|
||||
if (!isTrustedRendererEvent(event)) return;
|
||||
// Only allow https / http URLs — never let the renderer push a
|
||||
// file://, javascript:, or shell:-style URL through to the OS
|
||||
// shell.openExternal handler. The renderer is contextIsolated +
|
||||
@@ -7845,7 +7855,7 @@ interface ActiveClipDownloadTracking {
|
||||
}
|
||||
const activeClipProcesses = new Map<string, ActiveClipDownloadTracking>();
|
||||
|
||||
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () => Promise.resolve({ success: false, error: 'File access denied' }), async (_, clipUrl: string) => {
|
||||
let clipId = '';
|
||||
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
||||
const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/);
|
||||
@@ -7952,11 +7962,11 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => {
|
||||
registerTrustedIpcHandler(ipcMain, 'run-preflight', isTrustedRendererEvent, () => Promise.resolve(null), async (_, autoFix: boolean = false) => {
|
||||
return await runPreflight(autoFix);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-debug-log', async (_, lines: number = 200) => {
|
||||
registerTrustedIpcHandler(ipcMain, 'get-debug-log', isTrustedRendererEvent, () => Promise.resolve(''), async (_, lines: number = 200) => {
|
||||
// Cap so a misbehaving renderer (or future feature) cannot ask the
|
||||
// main process to slice millions of lines from a multi-MB log.
|
||||
const safeLines = Number.isFinite(lines) ? Math.max(1, Math.min(5000, Math.floor(lines))) : 200;
|
||||
@@ -8127,7 +8137,8 @@ ipcMain.handle('export-runtime-metrics', async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => {
|
||||
ipcMain.handle('mark-vod-downloaded', (event, vodId: string, mark: boolean): { success: boolean } => {
|
||||
if (!isTrustedRendererEvent(event)) return { success: false };
|
||||
if (typeof vodId !== 'string' || !vodId) return { success: false };
|
||||
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
||||
const has = config.downloaded_vod_ids.includes(vodId);
|
||||
@@ -8143,7 +8154,8 @@ ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { succe
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('reset-downloaded-vod-ids', () => {
|
||||
ipcMain.handle('reset-downloaded-vod-ids', (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return { success: false, removedCount: 0 };
|
||||
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
||||
config.downloaded_vod_ids = [];
|
||||
saveConfig(config);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CUTTER_SESSION_CAPABILITY_TTL_MS,
|
||||
FileCapabilityStore,
|
||||
isTrustedFileIpcSender,
|
||||
publishCapabilityOutput,
|
||||
@@ -50,6 +51,31 @@ describe('file capability boundary', () => {
|
||||
expect(() => store.resolve(expired.token, 7, 'chat-input')).toThrow('Expired file capability');
|
||||
});
|
||||
|
||||
it('keeps a purpose-bound cutter session usable after fifteen minutes without weakening owner or purpose checks', () => {
|
||||
const fixture = createFixture();
|
||||
let now = 10_000;
|
||||
const store = new FileCapabilityStore({ now: () => now, defaultTtlMs: 500 });
|
||||
const cutter = store.issue({
|
||||
ownerId: 7,
|
||||
purpose: 'cutter-input',
|
||||
path: fixture.video,
|
||||
kind: 'input-file',
|
||||
extensions: ['mp4'],
|
||||
ttlMs: CUTTER_SESSION_CAPABILITY_TTL_MS,
|
||||
});
|
||||
|
||||
now += 16 * 60 * 1000;
|
||||
const assetInput = store.resolve(cutter.token, 7, 'cutter-input');
|
||||
const exportInput = store.resolve(cutter.token, 7, 'cutter-input');
|
||||
expect(assetInput).toBe(realpathSync.native(fixture.video));
|
||||
expect(exportInput).toBe(realpathSync.native(fixture.video));
|
||||
expect(() => store.resolve(cutter.token, 8, 'cutter-input')).toThrow('Invalid file capability owner');
|
||||
expect(() => store.resolve(cutter.token, 7, 'merge-input')).toThrow('Invalid file capability purpose');
|
||||
|
||||
now = 10_000 + 8 * 60 * 60 * 1000;
|
||||
expect(() => store.resolve(cutter.token, 7, 'cutter-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();
|
||||
|
||||
@@ -17,6 +17,8 @@ export type FileCapabilityPurpose =
|
||||
|
||||
export type FileCapabilityKind = 'input-file' | 'output-file' | 'directory';
|
||||
|
||||
export const CUTTER_SESSION_CAPABILITY_TTL_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
export interface FileCapabilityReference {
|
||||
token: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { registerTrustedIpcHandler } from './privileged-ipc';
|
||||
|
||||
describe('privileged IPC behavior', () => {
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(['add-to-queue', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])(
|
||||
'registers %s so an untrusted renderer event cannot execute it',
|
||||
async (channel) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'tvm-privileged-ipc-'));
|
||||
directories.push(directory);
|
||||
const marker = join(directory, `${channel}.txt`);
|
||||
const handlers = new Map<string, (event: { trusted: boolean }) => unknown>();
|
||||
registerTrustedIpcHandler(
|
||||
{ handle: (registeredChannel, handler) => handlers.set(registeredChannel, handler) },
|
||||
channel,
|
||||
(event: { trusted: boolean }) => event.trusted,
|
||||
() => ({ denied: true }),
|
||||
async () => {
|
||||
writeFileSync(marker, channel);
|
||||
return { denied: false };
|
||||
},
|
||||
);
|
||||
const handler = handlers.get(channel);
|
||||
|
||||
expect(handler).toBeTypeOf('function');
|
||||
expect(await handler?.({ trusted: false })).toEqual({ denied: true });
|
||||
expect(() => readFileSync(marker, 'utf8')).toThrow();
|
||||
expect(await handler?.({ trusted: true })).toEqual({ denied: false });
|
||||
expect(readFileSync(marker, 'utf8')).toBe(channel);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function createTrustedIpcHandler<TEvent, TArgs extends unknown[], TResult, TDenied>(
|
||||
isTrusted: (event: TEvent) => boolean,
|
||||
deniedResult: () => TDenied,
|
||||
handler: (event: TEvent, ...args: TArgs) => TResult,
|
||||
): (event: TEvent, ...args: TArgs) => TResult | TDenied {
|
||||
return (event, ...args) => isTrusted(event) ? handler(event, ...args) : deniedResult();
|
||||
}
|
||||
|
||||
export function registerTrustedIpcHandler<TEvent, TArgs extends unknown[], TResult, TDenied>(
|
||||
registrar: { handle(channel: string, handler: (event: TEvent, ...args: TArgs) => TResult | TDenied): void },
|
||||
channel: string,
|
||||
isTrusted: (event: TEvent) => boolean,
|
||||
deniedResult: () => TDenied,
|
||||
handler: (event: TEvent, ...args: TArgs) => TResult,
|
||||
): void {
|
||||
registrar.handle(channel, createTrustedIpcHandler(isTrusted, deniedResult, handler));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createRendererQueueItem, getMergeGroupCleanupPaths, normalizeRendererQueueInput } from './renderer-queue-input';
|
||||
|
||||
describe('renderer queue input', () => {
|
||||
it('keeps only validated renderer-owned queue fields', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'tvm-renderer-queue-'));
|
||||
const victim = join(directory, 'important.txt');
|
||||
writeFileSync(victim, 'keep');
|
||||
const normalized = normalizeRendererQueueInput({
|
||||
id: 'forged-id',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
url: 'https://www.twitch.tv/videos/123456789',
|
||||
title: 'Fixture title',
|
||||
date: '2026-08-11T20:00:00.000Z',
|
||||
streamer: 'fixture_streamer',
|
||||
duration_str: '1h2m3s',
|
||||
outputFiles: ['C:\\Users\\victim\\important.txt'],
|
||||
mergeGroup: {
|
||||
items: [],
|
||||
mergePhase: 'done',
|
||||
currentItemIndex: 0,
|
||||
downloadedFiles: { 0: 'C:\\Users\\victim\\important.txt' },
|
||||
mergedFile: 'C:\\Users\\victim\\another-important.txt',
|
||||
},
|
||||
isLive: true,
|
||||
recordingHealth: 'ok',
|
||||
last_error: 'forged',
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
url: 'https://www.twitch.tv/videos/123456789',
|
||||
title: 'Fixture title',
|
||||
date: '2026-08-11T20:00:00.000Z',
|
||||
streamer: 'fixture_streamer',
|
||||
duration_str: '1h2m3s',
|
||||
});
|
||||
expect(JSON.stringify(normalized)).not.toContain('important.txt');
|
||||
const queueItem = createRendererQueueItem({
|
||||
...normalized,
|
||||
mergeGroup: { downloadedFiles: { 0: victim }, mergedFile: victim },
|
||||
}, 'main-owned-id');
|
||||
for (const cleanupPath of getMergeGroupCleanupPaths(queueItem ?? undefined)) rmSync(cleanupPath, { force: true });
|
||||
expect(existsSync(victim)).toBe(true);
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('normalizes a valid custom clip without accepting extra fields', () => {
|
||||
const normalized = normalizeRendererQueueInput({
|
||||
url: 'https://www.twitch.tv/videos/123456789',
|
||||
title: 'Fixture title',
|
||||
date: '2026-08-11T20:00:00.000Z',
|
||||
streamer: 'fixture_streamer',
|
||||
duration_str: '1h2m3s',
|
||||
customClip: {
|
||||
startSec: 12.5,
|
||||
durationSec: 30,
|
||||
startPart: 1,
|
||||
filenameFormat: 'template',
|
||||
filenameTemplate: '{date}_{title}',
|
||||
mergedFile: 'C:\\forged.mp4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized?.customClip).toEqual({
|
||||
startSec: 12.5,
|
||||
durationSec: 30,
|
||||
startPart: 1,
|
||||
filenameFormat: 'template',
|
||||
filenameTemplate: '{date}_{title}',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed queue requests before they can enter persistent state', () => {
|
||||
expect(normalizeRendererQueueInput(null)).toBeNull();
|
||||
expect(normalizeRendererQueueInput({ url: 'file:///C:/victim.txt', title: 'x', date: 'x', streamer: 'x', duration_str: '1s' })).toBeNull();
|
||||
expect(normalizeRendererQueueInput({ url: 'https://www.twitch.tv/videos/1', title: '', date: 'x', streamer: 'x', duration_str: '1s' })).toBeNull();
|
||||
expect(normalizeRendererQueueInput({
|
||||
url: 'https://www.twitch.tv/videos/1',
|
||||
title: 'x',
|
||||
date: 'x',
|
||||
streamer: 'x',
|
||||
duration_str: '1s',
|
||||
customClip: { startSec: -1, durationSec: 10, startPart: 1, filenameFormat: 'simple' },
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CustomClip, QueueItem } from '../../types';
|
||||
|
||||
export type RendererQueueInput = Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str'> & { customClip?: CustomClip };
|
||||
|
||||
function normalizedString(value: unknown, maximumLength: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized && normalized.length <= maximumLength ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeCustomClip(value: unknown): CustomClip | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (!Number.isFinite(raw.startSec) || Number(raw.startSec) < 0) return null;
|
||||
if (!Number.isFinite(raw.durationSec) || Number(raw.durationSec) <= 0 || Number(raw.durationSec) > 24 * 60 * 60) return null;
|
||||
if (!Number.isInteger(raw.startPart) || Number(raw.startPart) < 1 || Number(raw.startPart) > 100000) return null;
|
||||
if (!['simple', 'timestamp', 'template', 'parts'].includes(String(raw.filenameFormat))) return null;
|
||||
const filenameFormat = raw.filenameFormat as CustomClip['filenameFormat'];
|
||||
const filenameTemplate = raw.filenameTemplate === undefined
|
||||
? undefined
|
||||
: normalizedString(raw.filenameTemplate, 500);
|
||||
if (raw.filenameTemplate !== undefined && !filenameTemplate) return null;
|
||||
if (filenameFormat === 'template' && !filenameTemplate) return null;
|
||||
return {
|
||||
startSec: Number(raw.startSec),
|
||||
durationSec: Number(raw.durationSec),
|
||||
startPart: Number(raw.startPart),
|
||||
filenameFormat,
|
||||
...(filenameTemplate ? { filenameTemplate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRendererQueueInput(value: unknown): RendererQueueInput | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const url = normalizedString(raw.url, 2048);
|
||||
const title = normalizedString(raw.title, 500);
|
||||
const date = normalizedString(raw.date, 100);
|
||||
const streamer = normalizedString(raw.streamer, 25);
|
||||
const duration = normalizedString(raw.duration_str, 64);
|
||||
if (!url || !/^https:\/\/(?:www\.)?twitch\.tv\/videos\/\d+(?:[/?#].*)?$/i.test(url)) return null;
|
||||
if (!title || !date || !streamer || !/^[a-z0-9_]+$/i.test(streamer) || !duration) return null;
|
||||
const customClip = raw.customClip === undefined ? undefined : normalizeCustomClip(raw.customClip);
|
||||
if (raw.customClip !== undefined && !customClip) return null;
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
date,
|
||||
streamer: streamer.toLowerCase(),
|
||||
duration_str: duration,
|
||||
...(customClip ? { customClip } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRendererQueueItem(value: unknown, id: string): QueueItem | null {
|
||||
const input = normalizeRendererQueueInput(value);
|
||||
if (!input || !id) return null;
|
||||
return { ...input, id, status: 'pending', progress: 0 };
|
||||
}
|
||||
|
||||
export function getMergeGroupCleanupPaths(item: QueueItem | undefined): string[] {
|
||||
if (!item?.mergeGroup) return [];
|
||||
return [
|
||||
...Object.values(item.mergeGroup.downloadedFiles),
|
||||
...(item.mergeGroup.mergedFile ? [item.mergeGroup.mergedFile] : []),
|
||||
];
|
||||
}
|
||||
+1
-1
@@ -112,7 +112,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
// Queue
|
||||
getQueue: () => ipcRenderer.invoke('get-queue'),
|
||||
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
|
||||
addToQueue: (item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>) => ipcRenderer.invoke('add-to-queue', item),
|
||||
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
|
||||
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
|
||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||
|
||||
Vendored
+1
-1
@@ -374,7 +374,7 @@ interface ApiBridge {
|
||||
getUserId(username: string): Promise<string | null>;
|
||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||
getQueue(): Promise<QueueItem[]>;
|
||||
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
|
||||
addToQueue(item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueItem[]>;
|
||||
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
|
||||
removeFromQueue(id: string): Promise<QueueItem[]>;
|
||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||
|
||||
Reference in New Issue
Block a user