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: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:cutter": "npm run build && node scripts/smoke-test-cutter.js",
|
||||||
"test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.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: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",
|
"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",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"dist": "npm run build && electron-builder",
|
"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 path = require('path');
|
||||||
const { pathToFileURL } = require('url');
|
const { pathToFileURL } = require('url');
|
||||||
const { spawnSync } = require('child_process');
|
const { spawnSync } = require('child_process');
|
||||||
|
const { requireFileCapability } = require('./file-capability-contract');
|
||||||
const {
|
const {
|
||||||
createE2eEnvironment,
|
createE2eEnvironment,
|
||||||
getElectronLaunchOptions,
|
getElectronLaunchOptions,
|
||||||
@@ -48,6 +49,32 @@ function getProcessTreeMemoryMb(rootPid) {
|
|||||||
return Number.isFinite(value) ? value : 0;
|
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) {
|
function createTestVideo(environment) {
|
||||||
const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4');
|
const filePath = path.join(environment.mediaDir, 'Cutter Test #ä 01.mp4');
|
||||||
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
||||||
@@ -173,6 +200,11 @@ async function run() {
|
|||||||
await win.setViewportSize({ width: 1440, height: 900 });
|
await win.setViewportSize({ width: 1440, height: 900 });
|
||||||
await win.emulateMedia({ reducedMotion: 'reduce' });
|
await win.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
await win.evaluate(() => window.showTab('cutter'));
|
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 additionalContainerSupport = await win.evaluate(async (entries) => {
|
||||||
const probe = (sourceUrl) => new Promise((resolve) => {
|
const probe = (sourceUrl) => new Promise((resolve) => {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
@@ -197,7 +229,7 @@ async function run() {
|
|||||||
const results = [];
|
const results = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const nativePlayable = await probe(entry.sourceUrl);
|
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({
|
results.push({
|
||||||
extension: entry.extension,
|
extension: entry.extension,
|
||||||
nativePlayable,
|
nativePlayable,
|
||||||
@@ -206,7 +238,7 @@ async function run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return results;
|
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)}`);
|
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');
|
const cutterArtifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'cutter');
|
||||||
fs.mkdirSync(cutterArtifactDir, { recursive: true });
|
fs.mkdirSync(cutterArtifactDir, { recursive: true });
|
||||||
@@ -289,7 +321,8 @@ async function run() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const firstAssetsStartedAt = Date.now();
|
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;
|
const originalDevicePixelRatio = window.devicePixelRatio;
|
||||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 });
|
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 });
|
||||||
const workspace = document.getElementById('cutterWorkspace');
|
const workspace = document.getElementById('cutterWorkspace');
|
||||||
@@ -347,9 +380,9 @@ async function run() {
|
|||||||
if (window.__cutterAssetAudit.waveformLoads.at(-1)?.signature === signature) return;
|
if (window.__cutterAssetAudit.waveformLoads.at(-1)?.signature === signature) return;
|
||||||
window.__cutterAssetAudit.waveformLoads.push({ signature, width: image.naturalWidth, height: image.naturalHeight });
|
window.__cutterAssetAudit.waveformLoads.push({ signature, width: image.naturalWidth, height: image.naturalHeight });
|
||||||
});
|
});
|
||||||
window.__cutterLoadPromise = window.loadCutterFromPath(filePath);
|
window.__cutterLoadPromise = window.loadCutterFromPath(fileCapability);
|
||||||
return originalDevicePixelRatio;
|
return originalDevicePixelRatio;
|
||||||
}, inputFile);
|
}, inputCapability);
|
||||||
await win.waitForFunction(() => document.getElementById('cutterWorkspace').classList.contains('shown'), null, { timeout: 15000 });
|
await win.waitForFunction(() => document.getElementById('cutterWorkspace').classList.contains('shown'), null, { timeout: 15000 });
|
||||||
await win.evaluate(() => window.__cutterLoadPromise);
|
await win.evaluate(() => window.__cutterLoadPromise);
|
||||||
await win.waitForFunction(() => {
|
await win.waitForFunction(() => {
|
||||||
@@ -640,10 +673,11 @@ async function run() {
|
|||||||
&& cutterInfoAlignment.valueLineHeights.every(Number.isFinite),
|
&& cutterInfoAlignment.valueLineHeights.every(Number.isFinite),
|
||||||
`Cutter information labels and values are not aligned to common rows: ${JSON.stringify(cutterInfoAlignment)}`
|
`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) => {
|
await app.evaluate(({ ipcMain }, nextFile) => {
|
||||||
ipcMain.removeHandler('select-video-file');
|
ipcMain.removeHandler('select-video-file');
|
||||||
ipcMain.handle('select-video-file', () => nextFile);
|
ipcMain.handle('select-video-file', () => nextFile);
|
||||||
}, scrubStressInputFile);
|
}, replacementCapability);
|
||||||
await win.evaluate(() => {
|
await win.evaluate(() => {
|
||||||
cutterEditorState.trimStart = 1;
|
cutterEditorState.trimStart = 1;
|
||||||
renderCutterEditor();
|
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)}`);
|
check(replacementPlaybackState.paused && !replacementPlaybackState.playingClass && replacementPlaybackState.playIconVisible && !replacementPlaybackState.pauseIconVisible, `Replacing a playing video leaves stale playback UI: ${JSON.stringify(replacementPlaybackState)}`);
|
||||||
}
|
}
|
||||||
const scrubFirstAssetsStartedAt = Date.now();
|
const scrubFirstAssetsStartedAt = Date.now();
|
||||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), scrubStressInputFile);
|
await loadCutterCapability(win, scrubStressInputFile);
|
||||||
await win.waitForFunction(() => {
|
await win.waitForFunction(() => {
|
||||||
const video = document.getElementById('cutterVideo');
|
const video = document.getElementById('cutterVideo');
|
||||||
return video.readyState >= HTMLMediaElement.HAVE_METADATA && video.duration >= 8 && video.duration <= 10;
|
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)}`
|
`Trim handles are not as immediate and frame-synchronized as the playhead: ${JSON.stringify(trimScrubProbe)}`
|
||||||
);
|
);
|
||||||
const mediumLoadStarted = Date.now();
|
const mediumLoadStarted = Date.now();
|
||||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), mediumInputFile);
|
await loadCutterCapability(win, mediumInputFile);
|
||||||
await win.waitForFunction(() => {
|
await win.waitForFunction(() => {
|
||||||
const video = document.getElementById('cutterVideo');
|
const video = document.getElementById('cutterVideo');
|
||||||
const waveform = document.getElementById('cutterWaveform');
|
const waveform = document.getElementById('cutterWaveform');
|
||||||
@@ -1010,7 +1044,7 @@ async function run() {
|
|||||||
);
|
);
|
||||||
const memoryBeforeStressMb = getProcessTreeMemoryMb(app.process().pid);
|
const memoryBeforeStressMb = getProcessTreeMemoryMb(app.process().pid);
|
||||||
const longLoadStarted = Date.now();
|
const longLoadStarted = Date.now();
|
||||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), longInputFile);
|
const longCapability = await loadCutterCapability(win, longInputFile);
|
||||||
await win.waitForFunction(() => {
|
await win.waitForFunction(() => {
|
||||||
const video = document.getElementById('cutterVideo');
|
const video = document.getElementById('cutterVideo');
|
||||||
return video.readyState >= HTMLMediaElement.HAVE_METADATA && Math.abs(video.duration - 1800) < 1;
|
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.evaluate(() => window.showTab('vods'));
|
||||||
await win.waitForTimeout(80);
|
await win.waitForTimeout(80);
|
||||||
await win.evaluate(() => window.showTab('cutter'));
|
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'));
|
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');
|
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 });
|
await win.waitForFunction(() => Number(document.getElementById('cutterThumbnailStrip').dataset.thumbnailCount || document.querySelectorAll('#cutterThumbnailStrip img').length) >= 30, null, { timeout: 90000 });
|
||||||
const longAssetsReadyMs = Date.now() - longLoadStarted;
|
const longAssetsReadyMs = Date.now() - longLoadStarted;
|
||||||
@@ -1082,28 +1117,30 @@ async function run() {
|
|||||||
longScrubPresentation.frames >= 12 && longScrubPresentation.maximumGap !== null && longScrubPresentation.maximumGap <= 0.75,
|
longScrubPresentation.frames >= 12 && longScrubPresentation.maximumGap !== null && longScrubPresentation.maximumGap <= 0.75,
|
||||||
`Long-video scrubbing skips too much visible media: ${JSON.stringify(longScrubPresentation)}`
|
`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) => {
|
const rapidSwitch = await win.evaluate(async (files) => {
|
||||||
await Promise.allSettled(files.map((filePath) => window.loadCutterFromPath(filePath)));
|
await Promise.allSettled(files.map((file) => window.loadCutterFromPath(file)));
|
||||||
return cutterFile;
|
return cutterFile?.token || null;
|
||||||
}, [inputFile, longInputFile, silentInputFile, longInputFile, inputFile, silentInputFile, longInputFile, silentInputFile]);
|
}, rapidSwitchCapabilities);
|
||||||
await win.waitForFunction((expectedFile) => cutterFile === expectedFile && document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, silentInputFile, { timeout: 15000 });
|
const expectedRapidSwitchToken = rapidSwitchCapabilities.at(-1).token;
|
||||||
check(rapidSwitch === silentInputFile, `Rapid file switching committed stale media: ${rapidSwitch}`);
|
await win.waitForFunction((expectedToken) => cutterFile?.token === expectedToken && document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, expectedRapidSwitchToken, { timeout: 15000 });
|
||||||
await win.evaluate((filePath) => window.loadCutterFromPath(filePath), inputFile);
|
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 });
|
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 memoryAfterStressMb = getProcessTreeMemoryMb(app.process().pid);
|
||||||
const stressMemoryDeltaMb = memoryBeforeStressMb && memoryAfterStressMb ? memoryAfterStressMb - memoryBeforeStressMb : 0;
|
const stressMemoryDeltaMb = memoryBeforeStressMb && memoryAfterStressMb ? memoryAfterStressMb - memoryBeforeStressMb : 0;
|
||||||
check(stressMemoryDeltaMb < 350, `Rapid media switching retained too much process memory: ${stressMemoryDeltaMb.toFixed(1)} MB`);
|
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)}`);
|
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'));
|
await win.waitForFunction(() => !document.getElementById('cutterWorkspace').classList.contains('loading'));
|
||||||
const preservedAfterUnsupported = await win.evaluate((expectedFile) => ({
|
const preservedAfterUnsupported = await win.evaluate((expectedFile) => ({
|
||||||
filePreserved: cutterFile === expectedFile,
|
filePreserved: cutterFile?.token === expectedFile,
|
||||||
statePreserved: Boolean(cutterEditorState) && cutterEditorState.duration === 10,
|
statePreserved: Boolean(cutterEditorState) && cutterEditorState.duration === 10,
|
||||||
playerPreserved: document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA,
|
playerPreserved: document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA,
|
||||||
waveformPreserved: !document.getElementById('cutterWaveform').hidden && document.getElementById('cutterWaveform').naturalWidth === 32000,
|
waveformPreserved: !document.getElementById('cutterWaveform').hidden && document.getElementById('cutterWaveform').naturalWidth === 32000,
|
||||||
exportEnabled: !document.getElementById('btnCut').disabled
|
exportEnabled: !document.getElementById('btnCut').disabled
|
||||||
}), inputFile);
|
}), inputCapability.token);
|
||||||
check(Object.values(preservedAfterUnsupported).every(Boolean), `Unsupported replacement corrupted the loaded editor: ${JSON.stringify(preservedAfterUnsupported)}`);
|
check(Object.values(preservedAfterUnsupported).every(Boolean), `Unsupported replacement corrupted the loaded editor: ${JSON.stringify(preservedAfterUnsupported)}`);
|
||||||
await win.locator('#timelineContainer').scrollIntoViewIfNeeded();
|
await win.locator('#timelineContainer').scrollIntoViewIfNeeded();
|
||||||
const timeline = await win.locator('#timeline').boundingBox();
|
const timeline = await win.locator('#timeline').boundingBox();
|
||||||
@@ -1815,28 +1852,28 @@ async function run() {
|
|||||||
trimEnd: cutterEditorState.trimEnd,
|
trimEnd: cutterEditorState.trimEnd,
|
||||||
cuts: cutterEditorState.cuts.map((cut) => ({ ...cut }))
|
cuts: cutterEditorState.cuts.map((cut) => ({ ...cut }))
|
||||||
}));
|
}));
|
||||||
const sourceProtection = await win.evaluate(({ inputFile, editorState }) => window.api.exportVideoEdit({
|
const sourceProtection = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({
|
||||||
inputFile,
|
inputCapability: cutterFile.token,
|
||||||
outputFile: inputFile.toUpperCase(),
|
outputName,
|
||||||
trimStart: editorState.trimStart,
|
trimStart: editorState.trimStart,
|
||||||
trimEnd: editorState.trimEnd,
|
trimEnd: editorState.trimEnd,
|
||||||
cuts: editorState.cuts
|
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)}`);
|
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 invalidOutputFile = path.join(environment.mediaDir, 'Invalid request.mp4');
|
||||||
const invalidRequest = await win.evaluate(({ inputFile, outputFile, editorState }) => window.api.exportVideoEdit({
|
const invalidRequest = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({
|
||||||
inputFile,
|
inputCapability: cutterFile.token,
|
||||||
outputFile,
|
outputName,
|
||||||
trimStart: Number.NaN,
|
trimStart: Number.NaN,
|
||||||
trimEnd: editorState.trimEnd,
|
trimEnd: editorState.trimEnd,
|
||||||
cuts: editorState.cuts
|
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)}`);
|
check(!invalidRequest.success && !fs.existsSync(invalidOutputFile), `Invalid numeric request was accepted: ${JSON.stringify(invalidRequest)}`);
|
||||||
const cancelledOutputFile = path.join(environment.mediaDir, 'Cancelled export.mp4');
|
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({
|
const exportPromise = window.api.exportVideoEdit({
|
||||||
inputFile,
|
inputCapability: cutterFile.token,
|
||||||
outputFile,
|
outputName,
|
||||||
trimStart: editorState.trimStart,
|
trimStart: editorState.trimStart,
|
||||||
trimEnd: editorState.trimEnd,
|
trimEnd: editorState.trimEnd,
|
||||||
cuts: editorState.cuts
|
cuts: editorState.cuts
|
||||||
@@ -1844,16 +1881,16 @@ async function run() {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
const cancelAccepted = await window.api.cancelVideoEdit();
|
const cancelAccepted = await window.api.cancelVideoEdit();
|
||||||
return { cancelAccepted, result: await exportPromise };
|
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)}`);
|
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');
|
fs.writeFileSync(outputFile, 'previous-output', 'utf8');
|
||||||
const exportResult = await win.evaluate(({ inputFile, outputFile, editorState }) => window.api.exportVideoEdit({
|
const exportResult = await win.evaluate(({ outputName, editorState }) => window.api.exportVideoEdit({
|
||||||
inputFile,
|
inputCapability: cutterFile.token,
|
||||||
outputFile,
|
outputName,
|
||||||
trimStart: editorState.trimStart,
|
trimStart: editorState.trimStart,
|
||||||
trimEnd: editorState.trimEnd,
|
trimEnd: editorState.trimEnd,
|
||||||
cuts: editorState.cuts
|
cuts: editorState.cuts
|
||||||
}), { inputFile, outputFile, editorState });
|
}), { outputName: path.basename(outputFile), editorState });
|
||||||
check(exportResult.success && fs.existsSync(outputFile), `Export failed: ${JSON.stringify(exportResult)}`);
|
check(exportResult.success && fs.existsSync(outputFile), `Export failed: ${JSON.stringify(exportResult)}`);
|
||||||
if (fs.existsSync(outputFile)) {
|
if (fs.existsSync(outputFile)) {
|
||||||
const probe = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', 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,
|
start: 1.2 + index * 0.2,
|
||||||
end: 1.28 + index * 0.2
|
end: 1.28 + index * 0.2
|
||||||
}));
|
}));
|
||||||
const manyCutsExport = await win.evaluate(({ inputFile, outputFile, cuts }) => window.api.exportVideoEdit({
|
const manyCutsExport = await win.evaluate(({ outputName, cuts }) => window.api.exportVideoEdit({
|
||||||
inputFile,
|
inputCapability: cutterFile.token,
|
||||||
outputFile,
|
outputName,
|
||||||
trimStart: 1,
|
trimStart: 1,
|
||||||
trimEnd: 9.5,
|
trimEnd: 9.5,
|
||||||
cuts
|
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)}`);
|
check(manyCutsExport.success && fs.existsSync(manyCutsOutputFile) && fs.statSync(manyCutsOutputFile).size > 256, `Many-cut export failed: ${JSON.stringify(manyCutsExport)}`);
|
||||||
const sourceStatBeforeMutation = fs.statSync(inputFile);
|
const sourceStatBeforeMutation = fs.statSync(inputFile);
|
||||||
fs.utimesSync(inputFile, sourceStatBeforeMutation.atime, new Date(sourceStatBeforeMutation.mtimeMs + 5000));
|
fs.utimesSync(inputFile, sourceStatBeforeMutation.atime, new Date(sourceStatBeforeMutation.mtimeMs + 5000));
|
||||||
const changedSourceOutputFile = path.join(environment.mediaDir, 'Changed source rejected.mp4');
|
const changedSourceOutputFile = path.join(environment.mediaDir, 'Changed source rejected.mp4');
|
||||||
const changedSourceExport = await win.evaluate(({ inputFile, outputFile }) => window.api.exportVideoEdit({
|
const changedSourceExport = await win.evaluate(({ outputName }) => window.api.exportVideoEdit({
|
||||||
inputFile,
|
inputCapability: cutterFile.token,
|
||||||
outputFile,
|
outputName,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 10,
|
trimEnd: 10,
|
||||||
cuts: []
|
cuts: []
|
||||||
}), { inputFile, outputFile: changedSourceOutputFile });
|
}), { outputName: path.basename(changedSourceOutputFile) });
|
||||||
check(!changedSourceExport.success && !fs.existsSync(changedSourceOutputFile), `Changed source identity was accepted: ${JSON.stringify(changedSourceExport)}`);
|
check(!changedSourceExport.success && !fs.existsSync(changedSourceOutputFile), `Changed source identity was accepted: ${JSON.stringify(changedSourceExport)}`);
|
||||||
await win.evaluate(() => {
|
await win.evaluate(() => {
|
||||||
window.updateCutterZoom(1);
|
window.updateCutterZoom(1);
|
||||||
@@ -1894,7 +1931,7 @@ async function run() {
|
|||||||
});
|
});
|
||||||
await win.waitForTimeout(250);
|
await win.waitForTimeout(250);
|
||||||
await win.screenshot({ path: path.join(cutterArtifactDir, 'editor.png'), fullPage: true });
|
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(() => {
|
await win.waitForFunction(() => {
|
||||||
const video = document.getElementById('cutterVideo');
|
const video = document.getElementById('cutterVideo');
|
||||||
return video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth === 360;
|
return video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth === 360;
|
||||||
@@ -1907,13 +1944,13 @@ async function run() {
|
|||||||
emptyVisible: !document.getElementById('cutterAudioEmpty').hidden
|
emptyVisible: !document.getElementById('cutterAudioEmpty').hidden
|
||||||
}));
|
}));
|
||||||
check(silentState.waveformHidden && silentState.emptyVisible, `Silent video does not show the no-audio state: ${JSON.stringify(silentState)}`);
|
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({
|
const silentExportResult = await win.evaluate(({ outputName, state }) => window.api.exportVideoEdit({
|
||||||
inputFile,
|
inputCapability: cutterFile.token,
|
||||||
outputFile,
|
outputName,
|
||||||
trimStart: state.trimStart,
|
trimStart: state.trimStart,
|
||||||
trimEnd: state.trimEnd,
|
trimEnd: state.trimEnd,
|
||||||
cuts: state.cuts
|
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)}`);
|
check(silentExportResult.success && fs.existsSync(silentOutputFile), `Silent export failed: ${JSON.stringify(silentExportResult)}`);
|
||||||
if (fs.existsSync(silentOutputFile)) {
|
if (fs.existsSync(silentOutputFile)) {
|
||||||
const silentProbe = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', 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'));
|
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 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');
|
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.waitForFunction(() => document.getElementById('cutterVideo').readyState >= HTMLMediaElement.HAVE_METADATA, null, { timeout: 15000 });
|
||||||
await win.evaluate(({ inputFile, outputFile }) => {
|
await win.evaluate(({ outputName }) => {
|
||||||
void window.api.exportVideoEdit({ inputFile, outputFile, trimStart: 0, trimEnd: 1800, cuts: [] });
|
void window.api.exportVideoEdit({ inputCapability: cutterFile.token, outputName, trimStart: 0, trimEnd: 1800, cuts: [] });
|
||||||
}, { inputFile: longInputFile, outputFile: shutdownOutputFile });
|
}, { outputName: path.basename(shutdownOutputFile) });
|
||||||
await win.waitForTimeout(150);
|
await win.waitForTimeout(150);
|
||||||
const appClosed = app.waitForEvent('close', { timeout: 15000 });
|
const appClosed = app.waitForEvent('close', { timeout: 15000 });
|
||||||
try { await app.evaluate(({ app: electronApp }) => electronApp.quit()); } catch { }
|
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 isolation = await verifyE2eIsolation(app, win, environment);
|
||||||
const fixtures = await installOfflineFixtures(app);
|
const fixtures = await installOfflineFixtures(app);
|
||||||
const issues = [];
|
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) => {
|
win.on('pageerror', (err) => {
|
||||||
issues.push(`pageerror: ${String(err)}`);
|
issues.push(`pageerror: ${String(err)}`);
|
||||||
@@ -206,7 +214,7 @@ async function run() {
|
|||||||
assert(deState.deActive, 'German language button did not activate');
|
assert(deState.deActive, 'German language button did not activate');
|
||||||
assert(enState.enActive, 'English 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');
|
window.showTab('vods');
|
||||||
await window.selectStreamer('fixture_streamer');
|
await window.selectStreamer('fixture_streamer');
|
||||||
|
|
||||||
@@ -310,16 +318,27 @@ async function run() {
|
|||||||
|
|
||||||
await clearQueue();
|
await clearQueue();
|
||||||
|
|
||||||
const info = await window.api.getVideoInfo(mediaA);
|
const cutterInput = await window.api.selectVideoFile();
|
||||||
const frame = await window.api.extractFrame(mediaA, 1);
|
const mergeInputs = await window.api.selectMultipleVideos();
|
||||||
const cut = await window.api.cutVideo(mediaA, 0.5, 1.7);
|
const mergeOutput = await window.api.saveVideoDialog('merged_full.mp4');
|
||||||
const merge = await window.api.mergeVideos([mediaA, mediaB], `${tmpDir.replace(/\\/g, '/')}/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 = {
|
checks.media = {
|
||||||
|
capabilityContract,
|
||||||
infoOk: !!info && info.duration > 0,
|
infoOk: !!info && info.duration > 0,
|
||||||
frameOk: typeof frame === 'string' && frame.length > 100,
|
frameOk: typeof frame === 'string' && frame.length > 100,
|
||||||
cutOk: cut.success,
|
cutOk: cut.success,
|
||||||
mergeOk: merge.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.infoOk, 'getVideoInfo failed for test media');
|
||||||
assert(checks.media.frameOk, 'extractFrame failed for test media');
|
assert(checks.media.frameOk, 'extractFrame failed for test media');
|
||||||
assert(checks.media.cutOk, 'cutVideo 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');
|
const cutterDropFixturePath = path.join(environment.mediaDir, 'electron-43-cutter-drop.mp4');
|
||||||
fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8');
|
fs.writeFileSync(cutterDropFixturePath, 'electron-43-cutter-drop-fixture', 'utf8');
|
||||||
await app.evaluate(({ ipcMain }) => {
|
await app.evaluate(({ ipcMain }, expectedPath) => {
|
||||||
globalThis.__workspaceCutterDropPaths = { media: '' };
|
globalThis.__workspaceCutterDropPaths = { mediaCapability: '', expectedPath };
|
||||||
ipcMain.removeHandler('prepare-video-editor-media');
|
ipcMain.removeHandler('prepare-video-editor-media');
|
||||||
ipcMain.handle('prepare-video-editor-media', (_, filePath) => {
|
ipcMain.handle('prepare-video-editor-media', (_, capability) => {
|
||||||
globalThis.__workspaceCutterDropPaths.media = filePath;
|
globalThis.__workspaceCutterDropPaths.mediaCapability = capability;
|
||||||
return {
|
return {
|
||||||
sourceUrl: encodeURI(`file:///${filePath.replace(/\\/g, '/')}`),
|
sourceUrl: encodeURI(`file:///${expectedPath.replace(/\\/g, '/')}`),
|
||||||
info: { duration: 120, width: 1920, height: 1080, fps: 60, hasAudio: false },
|
info: { duration: 120, width: 1920, height: 1080, fps: 60, hasAudio: false },
|
||||||
thumbnails: ['data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='],
|
thumbnails: ['data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='],
|
||||||
waveform: null
|
waveform: null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
}, cutterDropFixturePath);
|
||||||
await win.evaluate(() => {
|
await win.evaluate(() => {
|
||||||
window.showTab('cutter');
|
window.showTab('cutter');
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -200,7 +200,7 @@ async function run() {
|
|||||||
const cutterFileObject = await win.evaluate(() => {
|
const cutterFileObject = await win.evaluate(() => {
|
||||||
const input = document.getElementById('workspaceCutterDropInput');
|
const input = document.getElementById('workspaceCutterDropInput');
|
||||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : undefined;
|
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();
|
const transfer = new DataTransfer();
|
||||||
transfer.items.add(file);
|
transfer.items.add(file);
|
||||||
document.getElementById('cutterTab')?.dispatchEvent(new DragEvent('drop', {
|
document.getElementById('cutterTab')?.dispatchEvent(new DragEvent('drop', {
|
||||||
@@ -211,7 +211,7 @@ async function run() {
|
|||||||
return {
|
return {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
legacyPathType: typeof file.path,
|
legacyPathType: typeof file.path,
|
||||||
apiType: typeof window.api.getPathForFile
|
apiType: typeof window.api.selectDroppedVideo
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await win.waitForTimeout(250);
|
await win.waitForTimeout(250);
|
||||||
@@ -229,8 +229,9 @@ async function run() {
|
|||||||
ui: cutterDropUi
|
ui: cutterDropUi
|
||||||
};
|
};
|
||||||
check(cutterFileObject.legacyPathType === 'undefined', `Electron File.path is unexpectedly ${cutterFileObject.legacyPathType}`);
|
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(cutterFileObject.apiType === 'function', `Cutter capability API is ${cutterFileObject.apiType}`);
|
||||||
check(cutterDropPaths.media === cutterDropFixturePath, `Cutter drop sent "${cutterDropPaths.media}" to media preparation instead of the Electron file path`);
|
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');
|
check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls');
|
||||||
|
|
||||||
const queueEmptyActions = await win.evaluate(() => ({
|
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 { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor';
|
||||||
import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export';
|
import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export';
|
||||||
import {
|
import {
|
||||||
|
CUTTER_SESSION_CAPABILITY_TTL_MS,
|
||||||
FileCapabilityStore,
|
FileCapabilityStore,
|
||||||
isTrustedFileIpcSender,
|
isTrustedFileIpcSender,
|
||||||
publishCapabilityOutput,
|
publishCapabilityOutput,
|
||||||
type FileCapabilityPurpose,
|
type FileCapabilityPurpose,
|
||||||
type FileCapabilityReference,
|
type FileCapabilityReference,
|
||||||
} from './main/domain/file-capability';
|
} from './main/domain/file-capability';
|
||||||
|
import { registerTrustedIpcHandler } from './main/domain/privileged-ipc';
|
||||||
|
import { createRendererQueueItem, getMergeGroupCleanupPaths } from './main/domain/renderer-queue-input';
|
||||||
import {
|
import {
|
||||||
setDebugLogFn, initToolDirs,
|
setDebugLogFn, initToolDirs,
|
||||||
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
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();
|
const triggered = await runAutoRecordPoll();
|
||||||
return { triggered };
|
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();
|
const queuedCount = await runAutoVodPoll();
|
||||||
return { queuedCount };
|
return { queuedCount };
|
||||||
});
|
});
|
||||||
@@ -7317,7 +7322,8 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('login', async () => {
|
ipcMain.handle('login', async (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event)) return false;
|
||||||
return await twitchLogin();
|
return await twitchLogin();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7335,7 +7341,8 @@ ipcMain.handle('get-queue', (event) => {
|
|||||||
return downloadQueue;
|
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) {
|
if (typeof streamerName !== 'string' || !streamerName) {
|
||||||
return { success: false, error: 'Invalid streamer name' };
|
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 };
|
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)) {
|
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
|
||||||
runtimeMetrics.duplicateSkips += 1;
|
runtimeMetrics.duplicateSkips += 1;
|
||||||
mainWindow?.webContents.send('queue-duplicate-skipped', {
|
mainWindow?.webContents.send('queue-duplicate-skipped', {
|
||||||
@@ -7395,19 +7404,14 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
|
|||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueItem: QueueItem = {
|
downloadQueue.push(item);
|
||||||
...item,
|
|
||||||
id: generateQueueItemId(),
|
|
||||||
status: 'pending',
|
|
||||||
progress: 0
|
|
||||||
};
|
|
||||||
downloadQueue.push(queueItem);
|
|
||||||
saveQueue(downloadQueue);
|
saveQueue(downloadQueue);
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return downloadQueue;
|
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);
|
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id) || queueProcessRegistry.activeItemIds().includes(id);
|
||||||
|
|
||||||
if (wasActiveItem) {
|
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)
|
// Clean up merge-group temp files (must run for any merge group, not just active)
|
||||||
const removedItem = downloadQueue.find(item => item.id === id);
|
const removedItem = downloadQueue.find(item => item.id === id);
|
||||||
if (removedItem?.mergeGroup) {
|
for (const cleanupPath of getMergeGroupCleanupPaths(removedItem)) {
|
||||||
const mg = removedItem.mergeGroup;
|
try { if (fs.existsSync(cleanupPath)) fs.unlinkSync(cleanupPath); } catch { }
|
||||||
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 { }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueue = downloadQueue.filter(item => item.id !== id);
|
downloadQueue = downloadQueue.filter(item => item.id !== id);
|
||||||
@@ -7439,14 +7437,16 @@ ipcMain.handle('remove-from-queue', async (_, id: string) => {
|
|||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('clear-completed', () => {
|
ipcMain.handle('clear-completed', (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
||||||
downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
|
downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
|
||||||
saveQueue(downloadQueue);
|
saveQueue(downloadQueue);
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
return downloadQueue;
|
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 order = new Map(orderIds.map((id, idx) => [id, idx]));
|
||||||
const withOrder = [...downloadQueue].sort((a, b) => {
|
const withOrder = [...downloadQueue].sort((a, b) => {
|
||||||
const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER;
|
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;
|
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);
|
const failedIds = downloadQueue.filter((item) => item.status === 'error').map((item) => item.id);
|
||||||
await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id)));
|
await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id)));
|
||||||
for (const id of failedIds) queueProcessRegistry.resetItem(id);
|
for (const id of failedIds) queueProcessRegistry.resetItem(id);
|
||||||
@@ -7485,7 +7486,8 @@ ipcMain.handle('retry-failed-downloads', async () => {
|
|||||||
return downloadQueue;
|
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;
|
if (typeof id !== 'string' || !id) return downloadQueue;
|
||||||
const idx = downloadQueue.findIndex((it) => it.id === id);
|
const idx = downloadQueue.findIndex((it) => it.id === id);
|
||||||
if (idx < 0) return downloadQueue;
|
if (idx < 0) return downloadQueue;
|
||||||
@@ -7513,7 +7515,8 @@ ipcMain.handle('retry-queue-item', async (_, id: string) => {
|
|||||||
return downloadQueue;
|
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));
|
const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id));
|
||||||
|
|
||||||
if (selectedItems.length < 2) {
|
if (selectedItems.length < 2) {
|
||||||
@@ -7590,7 +7593,8 @@ ipcMain.handle('create-merge-group', (_, itemIds: string[]) => {
|
|||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('start-download', async () => {
|
ipcMain.handle('start-download', async (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event)) return false;
|
||||||
if (isDownloading && queuePaused) {
|
if (isDownloading && queuePaused) {
|
||||||
queuePaused = false;
|
queuePaused = false;
|
||||||
for (const item of downloadQueue) {
|
for (const item of downloadQueue) {
|
||||||
@@ -7620,7 +7624,8 @@ ipcMain.handle('start-download', async () => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('pause-download', async () => {
|
ipcMain.handle('pause-download', async (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event)) return false;
|
||||||
if (!isDownloading || queuePaused) return false;
|
if (!isDownloading || queuePaused) return false;
|
||||||
|
|
||||||
queuePaused = true;
|
queuePaused = true;
|
||||||
@@ -7639,7 +7644,8 @@ ipcMain.handle('pause-download', async () => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('cancel-download', async () => {
|
ipcMain.handle('cancel-download', async (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event)) return false;
|
||||||
isDownloading = false;
|
isDownloading = false;
|
||||||
queuePaused = false;
|
queuePaused = false;
|
||||||
const activeItemIds = queueProcessRegistry.activeItemIds();
|
const activeItemIds = queueProcessRegistry.activeItemIds();
|
||||||
@@ -7652,8 +7658,8 @@ const fileCapabilities = new FileCapabilityStore();
|
|||||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'];
|
const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'];
|
||||||
const knownRendererPaths = new Map<FileCapabilityPurpose, Set<string>>();
|
const knownRendererPaths = new Map<FileCapabilityPurpose, Set<string>>();
|
||||||
|
|
||||||
function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = []): FileCapabilityReference {
|
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 });
|
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 {
|
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]
|
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;
|
: null;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('grant-dropped-video', (event, filePath: string): FileCapabilityReference | null => {
|
ipcMain.handle('grant-dropped-video', (event, filePath: string): FileCapabilityReference | null => {
|
||||||
if (!isTrustedRendererEvent(event)) return null;
|
if (!isTrustedRendererEvent(event)) return null;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -7780,7 +7786,8 @@ ipcMain.handle('show-in-folder', (event, capability: string): boolean => {
|
|||||||
|
|
||||||
ipcMain.handle('get-version', () => APP_VERSION);
|
ipcMain.handle('get-version', () => APP_VERSION);
|
||||||
|
|
||||||
ipcMain.handle('check-update', async () => {
|
ipcMain.handle('check-update', async (event) => {
|
||||||
|
if (!isTrustedRendererEvent(event)) return { error: true };
|
||||||
try {
|
try {
|
||||||
setupAutoUpdater();
|
setupAutoUpdater();
|
||||||
const result = await requestUpdateCheck('manual', true);
|
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 {
|
try {
|
||||||
setupAutoUpdater();
|
setupAutoUpdater();
|
||||||
const result = await requestUpdateDownload('manual');
|
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);
|
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
|
// Only allow https / http URLs — never let the renderer push a
|
||||||
// file://, javascript:, or shell:-style URL through to the OS
|
// file://, javascript:, or shell:-style URL through to the OS
|
||||||
// shell.openExternal handler. The renderer is contextIsolated +
|
// shell.openExternal handler. The renderer is contextIsolated +
|
||||||
@@ -7845,7 +7855,7 @@ interface ActiveClipDownloadTracking {
|
|||||||
}
|
}
|
||||||
const activeClipProcesses = new Map<string, 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 = '';
|
let clipId = '';
|
||||||
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
||||||
const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([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);
|
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
|
// Cap so a misbehaving renderer (or future feature) cannot ask the
|
||||||
// main process to slice millions of lines from a multi-MB log.
|
// 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;
|
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 (typeof vodId !== 'string' || !vodId) return { success: false };
|
||||||
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
||||||
const has = config.downloaded_vod_ids.includes(vodId);
|
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 };
|
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;
|
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
||||||
config.downloaded_vod_ids = [];
|
config.downloaded_vod_ids = [];
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
CUTTER_SESSION_CAPABILITY_TTL_MS,
|
||||||
FileCapabilityStore,
|
FileCapabilityStore,
|
||||||
isTrustedFileIpcSender,
|
isTrustedFileIpcSender,
|
||||||
publishCapabilityOutput,
|
publishCapabilityOutput,
|
||||||
@@ -50,6 +51,31 @@ describe('file capability boundary', () => {
|
|||||||
expect(() => store.resolve(expired.token, 7, 'chat-input')).toThrow('Expired file capability');
|
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', () => {
|
it('binds canonical input and output paths to the allowed extension and semantics', () => {
|
||||||
const fixture = createFixture();
|
const fixture = createFixture();
|
||||||
const store = new FileCapabilityStore();
|
const store = new FileCapabilityStore();
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type FileCapabilityPurpose =
|
|||||||
|
|
||||||
export type FileCapabilityKind = 'input-file' | 'output-file' | 'directory';
|
export type FileCapabilityKind = 'input-file' | 'output-file' | 'directory';
|
||||||
|
|
||||||
|
export const CUTTER_SESSION_CAPABILITY_TTL_MS = 8 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export interface FileCapabilityReference {
|
export interface FileCapabilityReference {
|
||||||
token: string;
|
token: string;
|
||||||
name: 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
|
// Queue
|
||||||
getQueue: () => ipcRenderer.invoke('get-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),
|
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
|
||||||
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
|
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
|
||||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||||
|
|||||||
Vendored
+1
-1
@@ -374,7 +374,7 @@ interface ApiBridge {
|
|||||||
getUserId(username: string): Promise<string | null>;
|
getUserId(username: string): Promise<string | null>;
|
||||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||||
getQueue(): Promise<QueueItem[]>;
|
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 }>;
|
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
|
||||||
removeFromQueue(id: string): Promise<QueueItem[]>;
|
removeFromQueue(id: string): Promise<QueueItem[]>;
|
||||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user