fix(tools): verify pinned archives before atomic install
Pin Streamlink and FFmpeg archives to verified manifests, stage replacements before promotion, and expose managed tool status with repair and reset controls. Keep updater checks exclusive until their underlying operation settles and restore default electron-builder certificate environment support.
This commit is contained in:
@@ -72,7 +72,6 @@
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "build/icon.ico",
|
||||
"signExecutable": false,
|
||||
"artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
|
||||
@@ -914,6 +914,13 @@
|
||||
<button type="button" class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
|
||||
</div>
|
||||
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
|
||||
<h4 id="managedToolsTitle">Verwaltete Tools</h4>
|
||||
<div class="form-row" style="margin-bottom: 10px;">
|
||||
<button type="button" class="btn-secondary" id="btnRefreshManagedTools" onclick="refreshManagedToolStatus()">Tool-Status aktualisieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnRepairManagedTools" onclick="repairManagedTools()">Tools reparieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnResetManagedTools" onclick="resetManagedTools()">Tools zurücksetzen</button>
|
||||
</div>
|
||||
<pre id="managedToolStatus" class="log-panel">Noch kein Tool-Status geladen.</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" data-settings-pane="debug" hidden>
|
||||
|
||||
+56
-45
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url';
|
||||
import axios from 'axios';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
||||
import { createUpdateCheckCoordinator } from './main/domain/update-check-operation';
|
||||
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
||||
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
||||
import {
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
setDebugLogFn, initToolDirs,
|
||||
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
||||
refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled,
|
||||
getManagedToolStatuses, repairManagedTools, resetManagedTools,
|
||||
canExecute, canExecuteCommand,
|
||||
cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand,
|
||||
cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands,
|
||||
@@ -827,6 +829,7 @@ let autoUpdaterInitialized = false;
|
||||
let autoUpdateCheckTimer: NodeJS.Timeout | null = null;
|
||||
let autoUpdateStartupTimer: NodeJS.Timeout | null = null;
|
||||
let autoUpdateCheckInProgress = false;
|
||||
const autoUpdateCheckCoordinator = createUpdateCheckCoordinator();
|
||||
let autoUpdateReadyToInstall = false;
|
||||
let autoUpdateDownloadInProgress = false;
|
||||
let lastAutoUpdateCheckAt = 0;
|
||||
@@ -6951,7 +6954,7 @@ function buildUpdateInfoPayload(version: string, releaseDate?: string): {
|
||||
}
|
||||
|
||||
async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> {
|
||||
if (autoUpdateCheckInProgress) {
|
||||
if (autoUpdateCheckCoordinator.inProgress) {
|
||||
return { started: false, reason: 'in-progress' };
|
||||
}
|
||||
|
||||
@@ -6960,57 +6963,51 @@ async function requestUpdateCheck(source: UpdateCheckSource, force = false): Pro
|
||||
return { started: false, reason: 'throttled' };
|
||||
}
|
||||
|
||||
autoUpdateCheckInProgress = true;
|
||||
lastAutoUpdateCheckAt = now;
|
||||
appendDebugLog('update-check-start', { source });
|
||||
|
||||
try {
|
||||
const result = await autoUpdateCheckCoordinator.run(async () => {
|
||||
autoUpdateCheckInProgress = true;
|
||||
lastAutoUpdateCheckAt = now;
|
||||
appendDebugLog('update-check-start', { source });
|
||||
try {
|
||||
const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Twitch-VOD-Manager'
|
||||
}
|
||||
});
|
||||
cacheLatestReleaseUpdateInfo(githubReleaseResponse.data);
|
||||
const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name;
|
||||
if (tagName) {
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
|
||||
try {
|
||||
const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Twitch-VOD-Manager'
|
||||
}
|
||||
});
|
||||
appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME });
|
||||
cacheLatestReleaseUpdateInfo(githubReleaseResponse.data);
|
||||
const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name;
|
||||
if (tagName) {
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
|
||||
});
|
||||
appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME });
|
||||
}
|
||||
} catch (apiErr) {
|
||||
appendDebugLog('github-api-failed', String(apiErr));
|
||||
}
|
||||
} catch (apiErr) {
|
||||
appendDebugLog('github-api-failed', String(apiErr));
|
||||
}
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
autoUpdater.checkForUpdates(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`Update check timed out after ${AUTO_UPDATE_CHECK_TIMEOUT_MS}ms`));
|
||||
}, AUTO_UPDATE_CHECK_TIMEOUT_MS);
|
||||
})
|
||||
]);
|
||||
await autoUpdater.checkForUpdates();
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
autoUpdateCheckInProgress = false;
|
||||
}
|
||||
}, AUTO_UPDATE_CHECK_TIMEOUT_MS);
|
||||
|
||||
if (result.state === 'completed') {
|
||||
return { started: true };
|
||||
} catch (err) {
|
||||
appendDebugLog('update-check-failed', { source, error: String(err) });
|
||||
console.error('Update check failed:', err);
|
||||
return { started: false, reason: 'error' };
|
||||
} finally {
|
||||
autoUpdateCheckInProgress = false;
|
||||
}
|
||||
if (result.state === 'timed-out') {
|
||||
appendDebugLog('update-check-ui-timeout', { source, timeoutMs: AUTO_UPDATE_CHECK_TIMEOUT_MS });
|
||||
return { started: false, reason: 'timed-out' };
|
||||
}
|
||||
if (result.state === 'in-progress') {
|
||||
return { started: false, reason: 'in-progress' };
|
||||
}
|
||||
|
||||
appendDebugLog('update-check-failed', { source, error: String(result.error) });
|
||||
console.error('Update check failed:', result.error);
|
||||
return { started: false, reason: 'error' };
|
||||
}
|
||||
|
||||
async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> {
|
||||
@@ -7162,7 +7159,6 @@ function setupAutoUpdater() {
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
autoUpdateCheckInProgress = false;
|
||||
autoUpdateDownloadInProgress = false;
|
||||
const message = String(err);
|
||||
appendDebugLog('auto-updater-error', message);
|
||||
@@ -7981,6 +7977,21 @@ registerTrustedIpcHandler(ipcMain, 'run-preflight', isTrustedRendererEvent, () =
|
||||
return await runPreflight(autoFix);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-managed-tool-status', (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return null;
|
||||
return getManagedToolStatuses();
|
||||
});
|
||||
|
||||
ipcMain.handle('repair-managed-tools', async (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return null;
|
||||
return await repairManagedTools();
|
||||
});
|
||||
|
||||
ipcMain.handle('reset-managed-tools', (event) => {
|
||||
if (!isTrustedRendererEvent(event)) return null;
|
||||
return resetManagedTools();
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createManagedToolInstaller, type ExternalToolManifest } from './managed-tools';
|
||||
|
||||
let directory: string;
|
||||
|
||||
function sha256(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function manifest(): ExternalToolManifest {
|
||||
return {
|
||||
id: 'streamlink',
|
||||
version: '8.4.0',
|
||||
sourceUrl: 'https://example.invalid/streamlink-8.4.0.zip',
|
||||
archiveName: 'streamlink-8.4.0.zip',
|
||||
sha256: sha256('verified archive'),
|
||||
executables: ['streamlink.exe']
|
||||
};
|
||||
}
|
||||
|
||||
function writeExistingInstallation(installPath: string, contents = 'old executable'): void {
|
||||
fs.mkdirSync(path.join(installPath, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), contents);
|
||||
}
|
||||
|
||||
function createInstaller(options: {
|
||||
archiveContents?: string;
|
||||
extract?: (archivePath: string, destinationPath: string) => Promise<void>;
|
||||
rename?: (sourcePath: string, destinationPath: string) => void;
|
||||
} = {}) {
|
||||
const installPath = path.join(directory, 'installed');
|
||||
const tempPath = path.join(directory, 'temporary');
|
||||
const download = vi.fn(async (_sourceUrl: string, archivePath: string) => {
|
||||
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
||||
fs.writeFileSync(archivePath, options.archiveContents ?? 'verified archive');
|
||||
});
|
||||
const extract = options.extract ?? (async (_archivePath: string, destinationPath: string) => {
|
||||
fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), 'new executable');
|
||||
});
|
||||
|
||||
return {
|
||||
installPath,
|
||||
installer: createManagedToolInstaller({
|
||||
installationDirectory: installPath,
|
||||
temporaryDirectory: tempPath,
|
||||
download,
|
||||
extract,
|
||||
rename: options.rename
|
||||
}),
|
||||
download
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'twitch-vod-manager-managed-tools-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('managed tool installer', () => {
|
||||
it('retains the working installation when the downloaded archive hash is corrupted', async () => {
|
||||
const { installer, installPath, download } = createInstaller({ archiveContents: 'corrupted archive' });
|
||||
writeExistingInstallation(installPath);
|
||||
|
||||
const result = await installer.repair(manifest());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('archive-hash-mismatch');
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
||||
});
|
||||
|
||||
it('retains the working installation when extraction is interrupted', async () => {
|
||||
const { installer, installPath } = createInstaller({
|
||||
extract: async () => { throw new Error('interrupted extraction'); }
|
||||
});
|
||||
writeExistingInstallation(installPath);
|
||||
|
||||
const result = await installer.repair(manifest());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('extract-failed');
|
||||
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
||||
});
|
||||
|
||||
it('retains the working installation when a staged archive lacks the required executable', async () => {
|
||||
const { installer, installPath } = createInstaller({
|
||||
extract: async (_archivePath, destinationPath) => {
|
||||
fs.mkdirSync(destinationPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(destinationPath, 'readme.txt'), 'missing executable');
|
||||
}
|
||||
});
|
||||
writeExistingInstallation(installPath);
|
||||
|
||||
const result = await installer.repair(manifest());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('required-executable-missing');
|
||||
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
||||
});
|
||||
|
||||
it('restores the previous tool when promotion of the staged installation fails', async () => {
|
||||
const installPath = path.join(directory, 'installed');
|
||||
let failedPromotion = false;
|
||||
const { installer } = createInstaller({
|
||||
rename: (sourcePath, destinationPath) => {
|
||||
if (!failedPromotion && sourcePath.includes('.stage-') && destinationPath === installPath) {
|
||||
failedPromotion = true;
|
||||
throw new Error('promotion interrupted');
|
||||
}
|
||||
fs.renameSync(sourcePath, destinationPath);
|
||||
}
|
||||
});
|
||||
writeExistingInstallation(installPath);
|
||||
|
||||
const result = await installer.repair(manifest());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('promotion-failed');
|
||||
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
||||
});
|
||||
|
||||
it('reports promotion failure when the previous installation cannot be restored', async () => {
|
||||
const installPath = path.join(directory, 'installed');
|
||||
const { installer } = createInstaller({
|
||||
rename: (sourcePath, destinationPath) => {
|
||||
if (sourcePath.includes('.stage-') && destinationPath === installPath) {
|
||||
throw new Error('promotion interrupted');
|
||||
}
|
||||
if (sourcePath.includes('.backup-') && destinationPath === installPath) {
|
||||
throw new Error('rollback interrupted');
|
||||
}
|
||||
fs.renameSync(sourcePath, destinationPath);
|
||||
}
|
||||
});
|
||||
writeExistingInstallation(installPath);
|
||||
|
||||
const result = await installer.repair(manifest());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('promotion-failed');
|
||||
});
|
||||
|
||||
it('reports a verified manifest version only after a staged install is promoted', async () => {
|
||||
const { installer, installPath } = createInstaller();
|
||||
|
||||
const result = await installer.repair(manifest());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
|
||||
expect(result.status).toMatchObject({
|
||||
id: 'streamlink',
|
||||
version: '8.4.0',
|
||||
state: 'verified',
|
||||
verified: true
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a mismatched installed version as unverified', () => {
|
||||
const { installer, installPath } = createInstaller();
|
||||
writeExistingInstallation(installPath);
|
||||
fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({
|
||||
id: 'streamlink',
|
||||
version: '8.3.0',
|
||||
sourceUrl: 'https://example.invalid/streamlink-8.3.0.zip',
|
||||
archiveName: 'streamlink-8.3.0.zip',
|
||||
sha256: sha256('old archive')
|
||||
}));
|
||||
|
||||
expect(installer.status(manifest())).toMatchObject({
|
||||
version: '8.3.0',
|
||||
state: 'unverified',
|
||||
verified: false
|
||||
});
|
||||
});
|
||||
|
||||
it('shares one repair operation for concurrent requests', async () => {
|
||||
let releaseDownload: (() => void) | undefined;
|
||||
const downloadGate = new Promise<void>((resolve) => { releaseDownload = resolve; });
|
||||
const { installer, download } = createInstaller({
|
||||
extract: async (_archivePath, destinationPath) => {
|
||||
fs.mkdirSync(path.join(destinationPath, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(destinationPath, 'bin', 'streamlink.exe'), 'new executable');
|
||||
}
|
||||
});
|
||||
download.mockImplementationOnce(async (_sourceUrl: string, archivePath: string) => {
|
||||
await downloadGate;
|
||||
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
||||
fs.writeFileSync(archivePath, 'verified archive');
|
||||
});
|
||||
|
||||
const first = installer.repair(manifest());
|
||||
const second = installer.repair(manifest());
|
||||
await Promise.resolve();
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseDownload?.();
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(secondResult.success).toBe(true);
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export type ManagedToolId = 'streamlink' | 'ffmpeg';
|
||||
|
||||
export interface ExternalToolManifest {
|
||||
id: ManagedToolId;
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
archiveName: string;
|
||||
sha256: string;
|
||||
executables: string[];
|
||||
}
|
||||
|
||||
export interface ManagedToolStatus {
|
||||
id: ManagedToolId;
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
archiveName: string;
|
||||
state: 'missing' | 'installing' | 'verified' | 'unverified' | 'corrupt';
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedToolInstallResult {
|
||||
success: boolean;
|
||||
status: ManagedToolStatus;
|
||||
error?: 'download-failed' | 'archive-hash-mismatch' | 'extract-failed' | 'required-executable-missing' | 'promotion-failed';
|
||||
}
|
||||
|
||||
interface ManagedToolRecord {
|
||||
id: ManagedToolId;
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
archiveName: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ManagedToolInstallerOptions {
|
||||
installationDirectory: string;
|
||||
temporaryDirectory: string;
|
||||
download(sourceUrl: string, archivePath: string): Promise<void>;
|
||||
extract(archivePath: string, destinationPath: string): Promise<void>;
|
||||
rename?(sourcePath: string, destinationPath: string): void;
|
||||
}
|
||||
|
||||
const RECORD_FILE_NAME = '.tool-manifest.json';
|
||||
|
||||
function findFileRecursive(rootDir: string, fileName: string): string | null {
|
||||
try {
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(rootDir, entry.name);
|
||||
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
|
||||
return entryPath;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFileRecursive(entryPath, fileName);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sha256File(filePath: string): string {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is ManagedToolRecord {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record.id === 'string'
|
||||
&& typeof record.version === 'string'
|
||||
&& typeof record.sourceUrl === 'string'
|
||||
&& typeof record.archiveName === 'string'
|
||||
&& typeof record.sha256 === 'string';
|
||||
}
|
||||
|
||||
function matchesManifest(record: ManagedToolRecord, manifest: ExternalToolManifest): boolean {
|
||||
return record.id === manifest.id
|
||||
&& record.version === manifest.version
|
||||
&& record.sourceUrl === manifest.sourceUrl
|
||||
&& record.archiveName === manifest.archiveName
|
||||
&& record.sha256 === manifest.sha256;
|
||||
}
|
||||
|
||||
export class ManagedToolInstaller {
|
||||
private readonly inFlight = new Map<ManagedToolId, Promise<ManagedToolInstallResult>>();
|
||||
|
||||
constructor(private readonly options: ManagedToolInstallerOptions) {}
|
||||
|
||||
status(manifest: ExternalToolManifest, includeInstallState = true): ManagedToolStatus {
|
||||
const base: Omit<ManagedToolStatus, 'state' | 'verified'> = {
|
||||
id: manifest.id,
|
||||
version: manifest.version,
|
||||
sourceUrl: manifest.sourceUrl,
|
||||
archiveName: manifest.archiveName
|
||||
};
|
||||
|
||||
if (includeInstallState && this.inFlight.has(manifest.id)) {
|
||||
return { ...base, state: 'installing', verified: false };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.options.installationDirectory)) {
|
||||
return { ...base, state: 'missing', verified: false };
|
||||
}
|
||||
|
||||
let record: ManagedToolRecord | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(path.join(this.options.installationDirectory, RECORD_FILE_NAME), 'utf8'));
|
||||
if (!isRecord(parsed)) {
|
||||
return { ...base, state: 'corrupt', verified: false };
|
||||
}
|
||||
record = parsed;
|
||||
} catch {
|
||||
const hasAnyExecutable = manifest.executables.some((name) => findFileRecursive(this.options.installationDirectory, name));
|
||||
return { ...base, state: hasAnyExecutable ? 'unverified' : 'corrupt', verified: false };
|
||||
}
|
||||
|
||||
const recordBase = { ...base, version: record.version };
|
||||
if (!matchesManifest(record, manifest)) {
|
||||
return { ...recordBase, state: 'unverified', verified: false };
|
||||
}
|
||||
|
||||
const allExecutablesPresent = manifest.executables.every((name) => findFileRecursive(this.options.installationDirectory, name));
|
||||
return allExecutablesPresent
|
||||
? { ...recordBase, state: 'verified', verified: true }
|
||||
: { ...recordBase, state: 'corrupt', verified: false };
|
||||
}
|
||||
|
||||
install(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
|
||||
const currentStatus = this.status(manifest);
|
||||
if (currentStatus.verified) {
|
||||
return Promise.resolve({ success: true, status: currentStatus });
|
||||
}
|
||||
return this.start(manifest);
|
||||
}
|
||||
|
||||
repair(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
|
||||
return this.start(manifest);
|
||||
}
|
||||
|
||||
reset(manifest: ExternalToolManifest): ManagedToolStatus {
|
||||
if (this.inFlight.has(manifest.id)) {
|
||||
return this.status(manifest);
|
||||
}
|
||||
fs.rmSync(this.options.installationDirectory, { recursive: true, force: true });
|
||||
return this.status(manifest);
|
||||
}
|
||||
|
||||
private start(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
|
||||
const existing = this.inFlight.get(manifest.id);
|
||||
if (existing) return existing;
|
||||
|
||||
const operation = this.installOnce(manifest);
|
||||
this.inFlight.set(manifest.id, operation);
|
||||
void operation.then(() => {
|
||||
if (this.inFlight.get(manifest.id) === operation) {
|
||||
this.inFlight.delete(manifest.id);
|
||||
}
|
||||
});
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async installOnce(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
|
||||
const uniqueSuffix = crypto.randomUUID();
|
||||
const archivePath = path.join(this.options.temporaryDirectory, `${manifest.archiveName}.${uniqueSuffix}`);
|
||||
const stagingDirectory = `${this.options.installationDirectory}.stage-${uniqueSuffix}`;
|
||||
let error: ManagedToolInstallResult['error'] | undefined;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(this.options.temporaryDirectory, { recursive: true });
|
||||
await this.options.download(manifest.sourceUrl, archivePath);
|
||||
if (sha256File(archivePath).toLowerCase() !== manifest.sha256.toLowerCase()) {
|
||||
error = 'archive-hash-mismatch';
|
||||
return { success: false, error, status: this.status(manifest, false) };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.extract(archivePath, stagingDirectory);
|
||||
} catch {
|
||||
error = 'extract-failed';
|
||||
return { success: false, error, status: this.status(manifest, false) };
|
||||
}
|
||||
|
||||
if (!manifest.executables.every((name) => findFileRecursive(stagingDirectory, name))) {
|
||||
error = 'required-executable-missing';
|
||||
return { success: false, error, status: this.status(manifest, false) };
|
||||
}
|
||||
|
||||
const record: ManagedToolRecord = {
|
||||
id: manifest.id,
|
||||
version: manifest.version,
|
||||
sourceUrl: manifest.sourceUrl,
|
||||
archiveName: manifest.archiveName,
|
||||
sha256: manifest.sha256
|
||||
};
|
||||
fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record));
|
||||
|
||||
try {
|
||||
this.promote(stagingDirectory);
|
||||
} catch {
|
||||
error = 'promotion-failed';
|
||||
return { success: false, error, status: this.status(manifest, false) };
|
||||
}
|
||||
|
||||
return { success: true, status: this.status(manifest, false) };
|
||||
} catch {
|
||||
error = 'download-failed';
|
||||
return { success: false, error, status: this.status(manifest, false) };
|
||||
} finally {
|
||||
fs.rmSync(archivePath, { force: true });
|
||||
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private promote(stagingDirectory: string): void {
|
||||
const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`;
|
||||
const rename = this.options.rename ?? fs.renameSync;
|
||||
let previousMoved = false;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(this.options.installationDirectory)) {
|
||||
rename(this.options.installationDirectory, backupDirectory);
|
||||
previousMoved = true;
|
||||
}
|
||||
rename(stagingDirectory, this.options.installationDirectory);
|
||||
if (previousMoved) {
|
||||
try {
|
||||
fs.rmSync(backupDirectory, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
if (previousMoved && fs.existsSync(backupDirectory) && !fs.existsSync(this.options.installationDirectory)) {
|
||||
try {
|
||||
rename(backupDirectory, this.options.installationDirectory);
|
||||
} catch {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createManagedToolInstaller(options: ManagedToolInstallerOptions): ManagedToolInstaller {
|
||||
return new ManagedToolInstaller(options);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ExternalToolManifest } from './managed-tools';
|
||||
|
||||
export const APPLICATION_TOOL_MANIFEST: Record<'streamlink' | 'ffmpeg', ExternalToolManifest> = {
|
||||
streamlink: {
|
||||
id: 'streamlink',
|
||||
version: '8.4.0',
|
||||
sourceUrl: 'https://github.com/streamlink/windows-builds/releases/download/8.4.0-1/streamlink-8.4.0-1-py314-x86_64.zip',
|
||||
archiveName: 'streamlink-8.4.0-1-py314-x86_64.zip',
|
||||
sha256: 'a8d3bd2b409e6d1b1f7a0e2a5c0cbfba619775e475da3f31285af08d680fb71c',
|
||||
executables: ['streamlink.exe']
|
||||
},
|
||||
ffmpeg: {
|
||||
id: 'ffmpeg',
|
||||
version: '8.1.2',
|
||||
sourceUrl: 'https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip',
|
||||
archiveName: 'ffmpeg-8.1.2-essentials_build.zip',
|
||||
sha256: 'db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec',
|
||||
executables: ['ffmpeg.exe', 'ffprobe.exe']
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createUpdateCheckCoordinator } from './update-check-operation';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
let reject: (reason?: unknown) => void = () => {};
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('update check coordinator', () => {
|
||||
it('keeps a timed-out check exclusive until the underlying operation settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
const coordinator = createUpdateCheckCoordinator();
|
||||
const firstCheck = deferred<void>();
|
||||
const firstFactory = vi.fn(() => firstCheck.promise);
|
||||
const secondFactory = vi.fn(() => Promise.resolve());
|
||||
|
||||
const first = coordinator.run(firstFactory, 100);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(await first).toEqual({ state: 'timed-out' });
|
||||
expect(coordinator.inProgress).toBe(true);
|
||||
expect(await coordinator.run(secondFactory, 100)).toEqual({ state: 'in-progress' });
|
||||
expect(secondFactory).not.toHaveBeenCalled();
|
||||
|
||||
firstCheck.resolve();
|
||||
await firstCheck.promise;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(coordinator.inProgress).toBe(false);
|
||||
|
||||
expect(await coordinator.run(secondFactory, 100)).toEqual({ state: 'completed' });
|
||||
expect(secondFactory).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('releases the operation only after its rejection settles', async () => {
|
||||
const coordinator = createUpdateCheckCoordinator();
|
||||
const failedCheck = deferred<void>();
|
||||
|
||||
const attempt = coordinator.run(() => failedCheck.promise, 1000);
|
||||
failedCheck.reject(new Error('network failed'));
|
||||
|
||||
await expect(attempt).resolves.toMatchObject({ state: 'failed' });
|
||||
expect(coordinator.inProgress).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
export type UpdateCheckOperationResult =
|
||||
| { state: 'completed' }
|
||||
| { state: 'failed'; error: unknown }
|
||||
| { state: 'timed-out' }
|
||||
| { state: 'in-progress' };
|
||||
|
||||
export class UpdateCheckCoordinator {
|
||||
private activeOperation: Promise<{ state: 'completed' } | { state: 'failed'; error: unknown }> | null = null;
|
||||
|
||||
get inProgress(): boolean {
|
||||
return this.activeOperation !== null;
|
||||
}
|
||||
|
||||
run(operation: () => Promise<void>, timeoutMs: number): Promise<UpdateCheckOperationResult> {
|
||||
if (this.activeOperation) {
|
||||
return Promise.resolve({ state: 'in-progress' });
|
||||
}
|
||||
|
||||
const tracked = Promise.resolve()
|
||||
.then(operation)
|
||||
.then(
|
||||
() => ({ state: 'completed' as const }),
|
||||
(error) => ({ state: 'failed' as const, error })
|
||||
);
|
||||
this.activeOperation = tracked;
|
||||
void tracked.then(() => {
|
||||
if (this.activeOperation === tracked) {
|
||||
this.activeOperation = null;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<UpdateCheckOperationResult>((resolve) => {
|
||||
let pending = true;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!pending) return;
|
||||
pending = false;
|
||||
resolve({ state: 'timed-out' });
|
||||
}, timeoutMs);
|
||||
void tracked.then((result) => {
|
||||
if (!pending) return;
|
||||
pending = false;
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createUpdateCheckCoordinator(): UpdateCheckCoordinator {
|
||||
return new UpdateCheckCoordinator();
|
||||
}
|
||||
@@ -200,6 +200,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
installUpdate: () => ipcRenderer.invoke('install-update'),
|
||||
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
|
||||
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
|
||||
getManagedToolStatus: () => ipcRenderer.invoke('get-managed-tool-status'),
|
||||
repairManagedTools: () => ipcRenderer.invoke('repair-managed-tools'),
|
||||
resetManagedTools: () => ipcRenderer.invoke('reset-managed-tools'),
|
||||
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
|
||||
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
|
||||
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
|
||||
Vendored
+18
-1
@@ -258,6 +258,20 @@ interface PreflightResult {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ManagedToolStatus {
|
||||
id: 'streamlink' | 'ffmpeg';
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
archiveName: string;
|
||||
state: 'missing' | 'installing' | 'verified' | 'unverified' | 'corrupt';
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
interface ManagedToolStatuses {
|
||||
streamlink: ManagedToolStatus;
|
||||
ffmpeg: ManagedToolStatus;
|
||||
}
|
||||
|
||||
interface StreamerStorageEntry {
|
||||
name: string;
|
||||
fileCount: number;
|
||||
@@ -442,11 +456,14 @@ interface ApiBridge {
|
||||
cutVideo(inputCapability: string, startTime: number, endTime: number): Promise<{ success: boolean; outputName: string | null }>;
|
||||
mergeVideos(inputCapabilities: string[], outputCapability: string): Promise<{ success: boolean; outputName: string | null }>;
|
||||
getVersion(): Promise<string>;
|
||||
checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>;
|
||||
checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'timed-out' | 'error' | string }>;
|
||||
downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>;
|
||||
installUpdate(): Promise<void>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
runPreflight(autoFix: boolean): Promise<PreflightResult>;
|
||||
getManagedToolStatus(): Promise<ManagedToolStatuses | null>;
|
||||
repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
|
||||
resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
|
||||
getDebugLog(lines: number): Promise<string>;
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
|
||||
@@ -287,6 +287,18 @@ const UI_TEXT_DE = {
|
||||
preflightFfmpeg: 'FFmpeg',
|
||||
preflightFfprobe: 'FFprobe',
|
||||
preflightPath: 'Download-Pfad',
|
||||
managedToolsTitle: 'Verwaltete Tools',
|
||||
managedToolsRefresh: 'Tool-Status aktualisieren',
|
||||
managedToolsRepair: 'Tools reparieren',
|
||||
managedToolsReset: 'Tools zurücksetzen',
|
||||
managedToolsEmpty: 'Noch kein Tool-Status geladen.',
|
||||
managedToolsRepairing: 'Repariere...',
|
||||
managedToolsResetConfirm: 'Verwaltete Tool-Installationen zurücksetzen?',
|
||||
managedToolsMissing: 'Fehlt',
|
||||
managedToolsInstalling: 'Wird installiert',
|
||||
managedToolsVerified: 'Verifiziert',
|
||||
managedToolsUnverified: 'Nicht verifiziert',
|
||||
managedToolsCorrupt: 'Beschädigt',
|
||||
debugLogTitle: 'Live Debug-Log',
|
||||
refreshLog: 'Aktualisieren',
|
||||
autoRefresh: 'Auto-Refresh',
|
||||
|
||||
@@ -287,6 +287,18 @@ const UI_TEXT_EN = {
|
||||
preflightFfmpeg: 'FFmpeg',
|
||||
preflightFfprobe: 'FFprobe',
|
||||
preflightPath: 'Download path',
|
||||
managedToolsTitle: 'Managed tools',
|
||||
managedToolsRefresh: 'Refresh tool status',
|
||||
managedToolsRepair: 'Repair tools',
|
||||
managedToolsReset: 'Reset tools',
|
||||
managedToolsEmpty: 'No tool status loaded yet.',
|
||||
managedToolsRepairing: 'Repairing...',
|
||||
managedToolsResetConfirm: 'Reset managed tool installations?',
|
||||
managedToolsMissing: 'Missing',
|
||||
managedToolsInstalling: 'Installing',
|
||||
managedToolsVerified: 'Verified',
|
||||
managedToolsUnverified: 'Unverified',
|
||||
managedToolsCorrupt: 'Corrupt',
|
||||
debugLogTitle: 'Live Debug Log',
|
||||
refreshLog: 'Refresh',
|
||||
autoRefresh: 'Auto refresh',
|
||||
|
||||
@@ -344,6 +344,52 @@ async function runPreflight(autoFix = false): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function getManagedToolStateLabel(state: ManagedToolStatus['state']): string {
|
||||
const labels: Record<ManagedToolStatus['state'], string> = {
|
||||
missing: UI_TEXT.static.managedToolsMissing,
|
||||
installing: UI_TEXT.static.managedToolsInstalling,
|
||||
verified: UI_TEXT.static.managedToolsVerified,
|
||||
unverified: UI_TEXT.static.managedToolsUnverified,
|
||||
corrupt: UI_TEXT.static.managedToolsCorrupt
|
||||
};
|
||||
return labels[state];
|
||||
}
|
||||
|
||||
function renderManagedToolStatus(statuses: ManagedToolStatuses): void {
|
||||
const lines = [statuses.streamlink, statuses.ffmpeg].map((status) => {
|
||||
const verification = status.verified ? UI_TEXT.static.managedToolsVerified : UI_TEXT.static.managedToolsUnverified;
|
||||
return `${status.id} ${status.version}: ${getManagedToolStateLabel(status.state)} · ${verification}`;
|
||||
});
|
||||
byId('managedToolStatus').textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
async function refreshManagedToolStatus(): Promise<void> {
|
||||
const statuses = await window.api.getManagedToolStatus();
|
||||
if (statuses) renderManagedToolStatus(statuses);
|
||||
}
|
||||
|
||||
async function repairManagedTools(): Promise<void> {
|
||||
const buttons = [
|
||||
byId<HTMLButtonElement>('btnRefreshManagedTools'),
|
||||
byId<HTMLButtonElement>('btnRepairManagedTools'),
|
||||
byId<HTMLButtonElement>('btnResetManagedTools')
|
||||
];
|
||||
for (const button of buttons) button.disabled = true;
|
||||
byId('managedToolStatus').textContent = UI_TEXT.static.managedToolsRepairing;
|
||||
try {
|
||||
const result = await window.api.repairManagedTools();
|
||||
if (result) renderManagedToolStatus(result.statuses);
|
||||
} finally {
|
||||
for (const button of buttons) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetManagedTools(): Promise<void> {
|
||||
if (!confirm(UI_TEXT.static.managedToolsResetConfirm)) return;
|
||||
const result = await window.api.resetManagedTools();
|
||||
if (result) renderManagedToolStatus(result.statuses);
|
||||
}
|
||||
|
||||
async function runCleanupDryRun(): Promise<void> {
|
||||
await runCleanupOnce(true);
|
||||
}
|
||||
|
||||
@@ -318,6 +318,11 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('btnPreflightRun', UI_TEXT.static.preflightRun);
|
||||
setText('btnPreflightFix', UI_TEXT.static.preflightFix);
|
||||
setText('preflightResult', UI_TEXT.static.preflightEmpty);
|
||||
setText('managedToolsTitle', UI_TEXT.static.managedToolsTitle);
|
||||
setText('btnRefreshManagedTools', UI_TEXT.static.managedToolsRefresh);
|
||||
setText('btnRepairManagedTools', UI_TEXT.static.managedToolsRepair);
|
||||
setText('btnResetManagedTools', UI_TEXT.static.managedToolsReset);
|
||||
setText('managedToolStatus', UI_TEXT.static.managedToolsEmpty);
|
||||
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
|
||||
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
|
||||
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
|
||||
|
||||
@@ -548,7 +548,7 @@ async function checkUpdate(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (skippedReason === 'in-progress' || skippedReason === 'throttled') {
|
||||
if (skippedReason === 'in-progress' || skippedReason === 'throttled' || skippedReason === 'timed-out') {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
manualUpdateCheckPending = false;
|
||||
|
||||
@@ -204,6 +204,7 @@ async function init(): Promise<void> {
|
||||
}, 3000);
|
||||
|
||||
void runPreflight(false);
|
||||
void refreshManagedToolStatus();
|
||||
void refreshDebugLog();
|
||||
validateFilenameTemplates();
|
||||
void refreshRuntimeMetrics();
|
||||
|
||||
+79
-41
@@ -2,6 +2,8 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { spawn, execSync, spawnSync } from 'child_process';
|
||||
import axios from 'axios';
|
||||
import { createManagedToolInstaller, type ManagedToolInstaller, type ManagedToolStatus } from './main/domain/managed-tools';
|
||||
import { APPLICATION_TOOL_MANIFEST } from './main/domain/tool-manifest';
|
||||
|
||||
// ==========================================
|
||||
// CONSTANTS
|
||||
@@ -28,6 +30,7 @@ export function initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPa
|
||||
TOOLS_STREAMLINK_DIR = streamlinkDir;
|
||||
TOOLS_FFMPEG_DIR = ffmpegDir;
|
||||
_getTempPath = getTempPath;
|
||||
managedToolInstallers.clear();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -44,6 +47,7 @@ let verifiedStreamlinkCommandKey: string | null = null;
|
||||
let verifiedFfmpegCommandKey: string | null = null;
|
||||
let bundledToolPathSignature = '';
|
||||
let bundledToolPathRefreshedAt = 0;
|
||||
const managedToolInstallers = new Map<'streamlink' | 'ffmpeg', ManagedToolInstaller>();
|
||||
|
||||
// ==========================================
|
||||
// INTERNAL HELPERS
|
||||
@@ -376,19 +380,79 @@ async function extractZip(zipPath: string, destinationDir: string): Promise<bool
|
||||
}
|
||||
}
|
||||
|
||||
function getManagedToolInstaller(toolId: 'streamlink' | 'ffmpeg'): ManagedToolInstaller {
|
||||
const existing = managedToolInstallers.get(toolId);
|
||||
if (existing) return existing;
|
||||
|
||||
const installationDirectory = toolId === 'streamlink' ? TOOLS_STREAMLINK_DIR : TOOLS_FFMPEG_DIR;
|
||||
const installer = createManagedToolInstaller({
|
||||
installationDirectory,
|
||||
temporaryDirectory: _getTempPath(),
|
||||
download: async (sourceUrl, archivePath) => {
|
||||
if (!await downloadFile(sourceUrl, archivePath)) {
|
||||
throw new Error('tool archive download failed');
|
||||
}
|
||||
},
|
||||
extract: async (archivePath, destinationPath) => {
|
||||
if (!await extractZip(archivePath, destinationPath)) {
|
||||
throw new Error('tool archive extraction failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
managedToolInstallers.set(toolId, installer);
|
||||
return installer;
|
||||
}
|
||||
|
||||
export interface ManagedToolStatuses {
|
||||
streamlink: ManagedToolStatus;
|
||||
ffmpeg: ManagedToolStatus;
|
||||
}
|
||||
|
||||
export function getManagedToolStatuses(): ManagedToolStatuses {
|
||||
return {
|
||||
streamlink: getManagedToolInstaller('streamlink').status(APPLICATION_TOOL_MANIFEST.streamlink),
|
||||
ffmpeg: getManagedToolInstaller('ffmpeg').status(APPLICATION_TOOL_MANIFEST.ffmpeg)
|
||||
};
|
||||
}
|
||||
|
||||
export async function repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses }> {
|
||||
const [streamlink, ffmpeg] = await Promise.all([
|
||||
getManagedToolInstaller('streamlink').repair(APPLICATION_TOOL_MANIFEST.streamlink),
|
||||
getManagedToolInstaller('ffmpeg').repair(APPLICATION_TOOL_MANIFEST.ffmpeg)
|
||||
]);
|
||||
refreshBundledToolPaths(true);
|
||||
return {
|
||||
success: streamlink.success && ffmpeg.success,
|
||||
statuses: getManagedToolStatuses()
|
||||
};
|
||||
}
|
||||
|
||||
export function resetManagedTools(): { success: boolean; statuses: ManagedToolStatuses } {
|
||||
const streamlink = getManagedToolInstaller('streamlink').reset(APPLICATION_TOOL_MANIFEST.streamlink);
|
||||
const ffmpeg = getManagedToolInstaller('ffmpeg').reset(APPLICATION_TOOL_MANIFEST.ffmpeg);
|
||||
refreshBundledToolPaths(true);
|
||||
return {
|
||||
success: streamlink.state !== 'installing' && ffmpeg.state !== 'installing',
|
||||
statuses: getManagedToolStatuses()
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUTO-INSTALL TOOLS
|
||||
// ==========================================
|
||||
export async function ensureStreamlinkInstalled(): Promise<boolean> {
|
||||
refreshBundledToolPaths();
|
||||
|
||||
const manifest = APPLICATION_TOOL_MANIFEST.streamlink;
|
||||
const managedStatus = getManagedToolInstaller('streamlink').status(manifest);
|
||||
const requiresManagedRepair = Boolean(bundledStreamlinkPath) && !managedStatus.verified;
|
||||
const current = getStreamlinkCommand();
|
||||
const versionArgs = [...current.prefixArgs, '--version'];
|
||||
if (isVerifiedStreamlinkCommand(current.command, versionArgs)) {
|
||||
if (!requiresManagedRepair && isVerifiedStreamlinkCommand(current.command, versionArgs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canExecuteCommand(current.command, versionArgs)) {
|
||||
if (!requiresManagedRepair && canExecuteCommand(current.command, versionArgs)) {
|
||||
cacheVerifiedStreamlinkCommand(current.command, versionArgs);
|
||||
return true;
|
||||
}
|
||||
@@ -399,34 +463,12 @@ export async function ensureStreamlinkInstalled(): Promise<boolean> {
|
||||
|
||||
_appendDebugLog('streamlink-install-start');
|
||||
try {
|
||||
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
|
||||
|
||||
const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', {
|
||||
timeout: 120000,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'Twitch-VOD-Manager'
|
||||
}
|
||||
});
|
||||
|
||||
const assets = release.data?.assets || [];
|
||||
const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name));
|
||||
if (!zipAsset?.browser_download_url) {
|
||||
_appendDebugLog('streamlink-install-no-asset-found');
|
||||
const result = await getManagedToolInstaller('streamlink').install(manifest);
|
||||
if (!result.success) {
|
||||
_appendDebugLog('streamlink-install-failed', { error: result.error, status: result.status });
|
||||
return false;
|
||||
}
|
||||
|
||||
const zipPath = path.join(_getTempPath(), `streamlink_portable_${Date.now()}.zip`);
|
||||
const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath);
|
||||
if (!downloadOk) return false;
|
||||
|
||||
fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
|
||||
|
||||
const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR);
|
||||
try { fs.unlinkSync(zipPath); } catch { }
|
||||
if (!extractOk) return false;
|
||||
|
||||
refreshBundledToolPaths(true);
|
||||
streamlinkCommandCache = null;
|
||||
|
||||
@@ -447,13 +489,16 @@ export async function ensureStreamlinkInstalled(): Promise<boolean> {
|
||||
export async function ensureFfmpegInstalled(): Promise<boolean> {
|
||||
refreshBundledToolPaths();
|
||||
|
||||
const manifest = APPLICATION_TOOL_MANIFEST.ffmpeg;
|
||||
const managedStatus = getManagedToolInstaller('ffmpeg').status(manifest);
|
||||
const requiresManagedRepair = Boolean(bundledFFmpegPath || bundledFFprobePath) && !managedStatus.verified;
|
||||
const ffmpegPath = getFFmpegPath();
|
||||
const ffprobePath = getFFprobePath();
|
||||
if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
|
||||
if (!requiresManagedRepair && isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
|
||||
if (!requiresManagedRepair && canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
|
||||
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
|
||||
return true;
|
||||
}
|
||||
@@ -464,18 +509,11 @@ export async function ensureFfmpegInstalled(): Promise<boolean> {
|
||||
|
||||
_appendDebugLog('ffmpeg-install-start');
|
||||
try {
|
||||
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
|
||||
|
||||
const zipPath = path.join(_getTempPath(), `ffmpeg_essentials_${Date.now()}.zip`);
|
||||
const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath);
|
||||
if (!downloadOk) return false;
|
||||
|
||||
fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
|
||||
|
||||
const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR);
|
||||
try { fs.unlinkSync(zipPath); } catch { }
|
||||
if (!extractOk) return false;
|
||||
const result = await getManagedToolInstaller('ffmpeg').install(manifest);
|
||||
if (!result.success) {
|
||||
_appendDebugLog('ffmpeg-install-failed', { error: result.error, status: result.status });
|
||||
return false;
|
||||
}
|
||||
|
||||
refreshBundledToolPaths(true);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user