diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6c907..7827e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ | Windows | macOS | Linux | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **EXE:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Windows-x64.exe) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Windows-arm64.exe) | **[Universal DMG](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-MacOS-universal.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Linux-x86_64.AppImage) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Linux-arm64.AppImage) | -|
| **[Universal ZIP](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-MacOS-universal.zip)** | **DEB:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Linux-amd64.deb) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Linux-arm64.deb) | -| | | **RPM:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Linux-x86_64.rpm) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.1/ROSI-Linux-aarch64.rpm) | +| **EXE:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Windows-x64.exe) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Windows-arm64.exe) | **[Universal DMG](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-MacOS-universal.dmg)** | **AppImage:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Linux-x86_64.AppImage) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Linux-arm64.AppImage) | +|
| **[Universal ZIP](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-MacOS-universal.zip)** | **DEB:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Linux-amd64.deb) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Linux-arm64.deb) | +| | | **RPM:** [x64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Linux-x86_64.rpm) / [arm64](https://github.com/BurntToasters/ROSI/releases/download/v4.1.2/ROSI-Linux-aarch64.rpm) | > [!IMPORTANT] > The `.sig` files in this repo are NOT normal GPG signatures — they are for ROSI's built-in updater to verify the integrity of updates before downloading and installing. @@ -30,6 +30,10 @@ --- +## Changes in `v4.1.2:` + +- **macOS:** Addressed a codesigning issue with yt-dlp/ffmpeg on macOS builds of ROSI. + ## Changes in `v4.1.1:` _What's a new feature update without a major bugfix am I right ;)_ diff --git a/build-scripts/sign-mac-helpers.js b/build-scripts/sign-mac-helpers.js new file mode 100644 index 0000000..b06d33f --- /dev/null +++ b/build-scripts/sign-mac-helpers.js @@ -0,0 +1,59 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +/** + * Re-sign bundled helper binaries after electron-builder signs the app. + * PyInstaller sidecars (yt-dlp) extract a Python runtime at launch; without + * disable-library-validation they fail with Team ID mismatches on macOS. + */ +exports.default = async function signMacHelpers(context) { + if (context.electronPlatformName !== 'darwin') { + return; + } + + const entitlements = path.join(__dirname, '..', 'build', 'entitlements.helper.plist'); + if (!fs.existsSync(entitlements)) { + console.warn(`[sign-mac-helpers] Entitlements not found: ${entitlements}`); + return; + } + + const identity = process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY; + if (!identity) { + console.warn('[sign-mac-helpers] No signing identity found; skipping helper re-sign'); + return; + } + + const appPath = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`); + const resourcesPath = path.join(appPath, 'Contents', 'Resources'); + const helpers = [ + path.join(resourcesPath, 'assets', 'yt-dlp_macos'), + path.join(resourcesPath, 'ffmpeg', 'ffmpeg'), + path.join(resourcesPath, 'ffmpeg', 'ffprobe'), + ]; + + for (const target of helpers) { + if (!fs.existsSync(target)) { + console.log(`[sign-mac-helpers] Skipping missing helper: ${target}`); + continue; + } + + console.log(`[sign-mac-helpers] Signing ${target}`); + execFileSync( + 'codesign', + [ + '--force', + '--options', + 'runtime', + '--entitlements', + entitlements, + '--sign', + identity, + target, + ], + { stdio: 'inherit' } + ); + } +}; diff --git a/build/entitlements.helper.plist b/build/entitlements.helper.plist new file mode 100644 index 0000000..da02cff --- /dev/null +++ b/build/entitlements.helper.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/electron-builder.github.yml b/electron-builder.github.yml index 17d4cc3..a8d33c2 100644 --- a/electron-builder.github.yml +++ b/electron-builder.github.yml @@ -12,3 +12,4 @@ mac: entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist notarize: true +afterSign: build-scripts/sign-mac-helpers.js diff --git a/src/main/downloader.ts b/src/main/downloader.ts index ead654f..fb33807 100644 --- a/src/main/downloader.ts +++ b/src/main/downloader.ts @@ -583,6 +583,16 @@ export function startDownload( if (exitType === 'failed') { cleanupPathFile(); sendProgress(session, `❌ Download failed: yt-dlp process exited with code ${code}`); + if ( + downloadErrorData.includes('different Team IDs') || + downloadErrorData.includes('[PYI-') || + downloadErrorData.includes('Failed to load Python shared library') + ) { + sendProgress( + session, + ' macOS blocked the bundled yt-dlp runtime (code signing). Install yt-dlp via Homebrew as a workaround, or use a rebuilt ROSI release with signed helpers.' + ); + } sendProgress(session, ` Check console and stderr output above for details.`); completeSession(session, '❌ Download failed.', 'failed'); return; diff --git a/src/main/main.ts b/src/main/main.ts index 6f12ded..dc622e0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { randomUUID } from 'crypto'; import log from 'electron-log/main.js'; -import { isPackaged, resolveYtdlpPath, verifyBundledFfmpeg } from './platform'; +import { isPackaged, initializeYtdlpPath, verifyBundledFfmpeg } from './platform'; import { loadSettings, saveSettings, @@ -82,7 +82,7 @@ process.on('unhandledRejection', (reason) => { let ytdlpPath: string | null = null; function getYtdlpPath(): string { if (!ytdlpPath) { - ytdlpPath = resolveYtdlpPath(); + throw new Error('yt-dlp path has not been initialized.'); } return ytdlpPath; } @@ -435,13 +435,14 @@ if (isPrimaryInstance && !isSmokeRun) { }); } -void app.whenReady().then(() => { +void app.whenReady().then(async () => { if (!isPrimaryInstance) { app.quit(); return; } - const resolvedYtdlpPath = getYtdlpPath(); + ytdlpPath = await initializeYtdlpPath(); + const resolvedYtdlpPath = ytdlpPath; if (!fs.existsSync(resolvedYtdlpPath)) { dialog.showErrorBox( 'Missing Dependency', diff --git a/src/main/platform.ts b/src/main/platform.ts index 8950acc..4028a45 100644 --- a/src/main/platform.ts +++ b/src/main/platform.ts @@ -298,7 +298,65 @@ function getYtdlpBinaryName() { export const ytdlpBinary = getYtdlpBinaryName(); -export function resolveYtdlpPath(): string { +let effectiveYtdlpPath: string | null = null; + +const MAX_PROBE_BUFFER = 4096; + +function findMacSystemYtdlpPath(): string | null { + if (!isMac) return null; + + const homeDir = safeHomeDir(); + const candidates = [ + path.join(homeDir, '.local', 'bin', 'yt-dlp'), + '/opt/homebrew/bin/yt-dlp', + '/usr/local/bin/yt-dlp', + ]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + const stats = fs.statSync(candidate); + if (stats.isFile()) return candidate; + } + } catch { + // try next candidate + } + } + + return null; +} + +function probeYtdlpBinary(ytdlpPath: string): Promise<{ ok: boolean; detail: string }> { + return new Promise((resolve) => { + let stderr = ''; + let stdout = ''; + const proc = spawn(ytdlpPath, ['--version'], { + env: { ...process.env, PATH: buildEnhancedPath() }, + shell: false, + }); + + proc.stdout?.on('data', (data: Buffer) => { + if (stdout.length < 512) stdout += data.toString(); + }); + proc.stderr?.on('data', (data: Buffer) => { + if (stderr.length < MAX_PROBE_BUFFER) stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve({ ok: true, detail: stdout.trim() || stderr.trim() }); + return; + } + resolve({ ok: false, detail: stderr.trim() || stdout.trim() || `exit code ${code}` }); + }); + + proc.on('error', (err: Error) => { + resolve({ ok: false, detail: err.message }); + }); + }); +} + +function resolveBundledYtdlpPath(): string { let resolved = ''; if (isPackaged) { @@ -365,3 +423,58 @@ export function resolveYtdlpPath(): string { return resolved; } + +export function resolveYtdlpPath(): string { + return effectiveYtdlpPath ?? resolveBundledYtdlpPath(); +} + +export async function initializeYtdlpPath(): Promise { + if (effectiveYtdlpPath) return effectiveYtdlpPath; + + const bundled = resolveBundledYtdlpPath(); + if (!isMac || !isPackaged) { + effectiveYtdlpPath = bundled; + return bundled; + } + + const bundledProbe = await probeYtdlpBinary(bundled); + if (bundledProbe.ok) { + log.info( + `Bundled yt-dlp verified: ${bundledProbe.detail.split('\n')[0] ?? bundledProbe.detail}` + ); + effectiveYtdlpPath = bundled; + return bundled; + } + + log.warn(`Bundled yt-dlp failed startup check at ${bundled}: ${bundledProbe.detail}`); + + const systemPath = findMacSystemYtdlpPath(); + if (systemPath) { + const systemProbe = await probeYtdlpBinary(systemPath); + if (systemProbe.ok) { + log.info(`Using system yt-dlp fallback at ${systemPath}`); + effectiveYtdlpPath = systemPath; + return systemPath; + } + log.warn(`System yt-dlp failed startup check at ${systemPath}: ${systemProbe.detail}`); + } + + effectiveYtdlpPath = bundled; + return bundled; +} + +export function verifyBundledYtdlp(): void { + const bundled = resolveBundledYtdlpPath(); + if (!fs.existsSync(bundled)) { + log.info('No bundled yt-dlp found.'); + return; + } + + void probeYtdlpBinary(bundled).then((result) => { + if (result.ok) { + log.info(`Bundled yt-dlp verified: ${result.detail.split('\n')[0] ?? result.detail}`); + return; + } + log.warn(`Bundled yt-dlp at ${bundled} failed verification: ${result.detail}`); + }); +} diff --git a/src/tests/main.ipc.test.ts b/src/tests/main.ipc.test.ts index 22233d6..d4a3ecd 100644 --- a/src/tests/main.ipc.test.ts +++ b/src/tests/main.ipc.test.ts @@ -249,7 +249,7 @@ vi.mock('electron', () => ({ vi.mock('../main/platform', () => ({ isPackaged: true, - resolveYtdlpPath: vi.fn(() => ytdlpFixturePath), + initializeYtdlpPath: vi.fn(async () => ytdlpFixturePath), verifyBundledFfmpeg: vi.fn(), })); diff --git a/src/tests/queue.test.ts b/src/tests/queue.test.ts index 7e9a332..4bbe116 100644 --- a/src/tests/queue.test.ts +++ b/src/tests/queue.test.ts @@ -177,7 +177,7 @@ vi.mock('electron', () => ({ vi.mock('../main/platform', () => ({ isPackaged: true, - resolveYtdlpPath: vi.fn(() => getCurrentYtdlpFixturePath()), + initializeYtdlpPath: vi.fn(async () => getCurrentYtdlpFixturePath()), verifyBundledFfmpeg: vi.fn(), }));