From fa89c43f748d59a6e38b35d50074b5720c1b91d7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 5 Apr 2026 10:36:28 -0700 Subject: [PATCH] fix: stage libffmpeg for Linux AppImage child processes --- ...mage-child-process-libffmpeg-resolution.md | 58 +++++++++++++ changes/fix-appimage-libffmpeg-path.md | 4 + package.json | 3 +- scripts/electron-builder-after-pack.cjs | 41 +++++++++ scripts/electron-builder-after-pack.test.ts | 84 +++++++++++++++++++ src/release-workflow.test.ts | 5 ++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 backlog/tasks/task-279 - Fix-Linux-AppImage-child-process-libffmpeg-resolution.md create mode 100644 changes/fix-appimage-libffmpeg-path.md create mode 100644 scripts/electron-builder-after-pack.cjs create mode 100644 scripts/electron-builder-after-pack.test.ts diff --git a/backlog/tasks/task-279 - Fix-Linux-AppImage-child-process-libffmpeg-resolution.md b/backlog/tasks/task-279 - Fix-Linux-AppImage-child-process-libffmpeg-resolution.md new file mode 100644 index 00000000..e57a07c1 --- /dev/null +++ b/backlog/tasks/task-279 - Fix-Linux-AppImage-child-process-libffmpeg-resolution.md @@ -0,0 +1,58 @@ +--- +id: TASK-279 +title: Fix Linux AppImage child-process libffmpeg resolution +status: Done +assignee: + - '@codex' +created_date: '2026-04-05 17:17' +updated_date: '2026-04-05 17:21' +labels: [] +dependencies: [] +references: + - 'https://github.com/ksyasuda/SubMiner/issues/41' +documentation: + - docs/workflow/verification.md +priority: high +--- + +## Description + + +Fix the Linux AppImage packaging so Chromium child processes relaunched from the bundled binary can resolve the packaged libffmpeg shared library and SubMiner starts cleanly instead of crash-looping on network-service restarts. + + +## Acceptance Criteria + +- [x] #1 Linux AppImage packaging ensures bundled Chromium child processes can resolve the packaged libffmpeg shared library during relaunch. +- [x] #2 Regression coverage exercises the Linux packaging/build configuration that provides the AppImage shared-library path. +- [x] #3 Release notes/changelog reflect the Linux AppImage startup fix. + + +## Implementation Plan + + +1. Add focused regression tests for Linux release packaging that assert the build config invokes an `afterPack` hook and that the hook stages bundled `libffmpeg.so` into `usr/lib` for AppImage runtime lookup. +2. Implement a small electron-builder `afterPack` hook that runs only for Linux, copies `libffmpeg.so` from the packaged app root into `usr/lib`, and no-ops when the source library is absent. +3. Wire the hook into `package.json` build config and add a changelog fragment for the Linux AppImage startup fix. +4. Run the focused test lane first, then the default handoff gate because the change touches release-sensitive packaging behavior. + + +## Implementation Notes + + +Chose a repo-local electron-builder `afterPack` hook instead of patching/forking `electron-builder`. The hook copies bundled `libffmpeg.so` from the packaged Linux app root into `usr/lib`, matching the AppImage runtime's existing `LD_LIBRARY_PATH` search path. + +Added regression coverage for both config wiring (`src/release-workflow.test.ts`) and the hook behavior (`scripts/electron-builder-after-pack.test.ts`), then wired the new script test into `test:fast` so the maintained lane keeps exercising the fix. + +Verification passed: `bun test scripts/electron-builder-after-pack.test.ts src/release-workflow.test.ts`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`. + + +## Final Summary + + +Added a shared electron-builder `afterPack` hook at `scripts/electron-builder-after-pack.cjs` and wired it into `package.json` so Linux packaging stages the bundled `libffmpeg.so` into `usr/lib` inside the packaged app. This keeps Chromium child relaunches compatible with the AppImage runtime's existing `LD_LIBRARY_PATH` layout without forking or patching upstream `electron-builder`. + +Regression coverage now checks both the packaging config and the hook behavior: `src/release-workflow.test.ts` asserts the hook stays wired into release config, and `scripts/electron-builder-after-pack.test.ts` verifies Linux copies `libffmpeg.so` into `usr/lib` while non-Linux and missing-library cases no-op safely. The new script test is included in `test:fast`, and a changelog fragment was added under `changes/fix-appimage-libffmpeg-path.md`. + +Verification passed with `bun test scripts/electron-builder-after-pack.test.ts src/release-workflow.test.ts`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, and `bun run test:smoke:dist`. + diff --git a/changes/fix-appimage-libffmpeg-path.md b/changes/fix-appimage-libffmpeg-path.md new file mode 100644 index 00000000..02113fe9 --- /dev/null +++ b/changes/fix-appimage-libffmpeg-path.md @@ -0,0 +1,4 @@ +type: fixed +area: release + +- Fixed Linux AppImage startup packaging so Chromium child relaunches can resolve the bundled `libffmpeg.so` instead of crash-looping on startup. diff --git a/package.json b/package.json index b04310eb..6a075784 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:launcher": "bun run test:launcher:src", "test:core": "bun run test:core:src", "test:subtitle": "bun run test:subtitle:src", - "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", + "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", "generate:config-example": "bun run src/generate-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", @@ -128,6 +128,7 @@ "productName": "SubMiner", "executableName": "SubMiner", "artifactName": "SubMiner-${version}.${ext}", + "afterPack": "scripts/electron-builder-after-pack.cjs", "icon": "assets/SubMiner-square.png", "directories": { "output": "release" diff --git a/scripts/electron-builder-after-pack.cjs b/scripts/electron-builder-after-pack.cjs new file mode 100644 index 00000000..e2e776e8 --- /dev/null +++ b/scripts/electron-builder-after-pack.cjs @@ -0,0 +1,41 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const LINUX_FFMPEG_LIBRARY = 'libffmpeg.so'; + +async function stageLinuxAppImageSharedLibrary( + context, + deps = { + access: (filePath) => fs.access(filePath), + mkdir: (dirPath) => fs.mkdir(dirPath, { recursive: true }), + copyFile: (from, to) => fs.copyFile(from, to), + }, +) { + if (context.electronPlatformName !== 'linux') return false; + + const sourceLibraryPath = path.join(context.appOutDir, LINUX_FFMPEG_LIBRARY); + + try { + await deps.access(sourceLibraryPath); + } catch { + return false; + } + + const targetLibraryDir = path.join(context.appOutDir, 'usr', 'lib'); + const targetLibraryPath = path.join(targetLibraryDir, LINUX_FFMPEG_LIBRARY); + + await deps.mkdir(targetLibraryDir); + await deps.copyFile(sourceLibraryPath, targetLibraryPath); + + return true; +} + +async function afterPack(context) { + await stageLinuxAppImageSharedLibrary(context); +} + +module.exports = { + LINUX_FFMPEG_LIBRARY, + stageLinuxAppImageSharedLibrary, + default: afterPack, +}; diff --git a/scripts/electron-builder-after-pack.test.ts b/scripts/electron-builder-after-pack.test.ts new file mode 100644 index 00000000..6921ab9e --- /dev/null +++ b/scripts/electron-builder-after-pack.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +const { + LINUX_FFMPEG_LIBRARY, + stageLinuxAppImageSharedLibrary, +} = require('./electron-builder-after-pack.cjs') as { + LINUX_FFMPEG_LIBRARY: string; + stageLinuxAppImageSharedLibrary: (context: { + appOutDir: string; + electronPlatformName: string; + }) => Promise; +}; + +function createWorkspace(name: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); +} + +test('stageLinuxAppImageSharedLibrary copies libffmpeg.so into usr/lib for Linux packaging', async () => { + const workspace = createWorkspace('subminer-after-pack-linux'); + const appOutDir = path.join(workspace, 'SubMiner-linux-x64'); + const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY); + const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY); + + fs.mkdirSync(appOutDir, { recursive: true }); + fs.writeFileSync(sourceLibraryPath, 'bundled ffmpeg', 'utf8'); + + try { + const staged = await stageLinuxAppImageSharedLibrary({ + appOutDir, + electronPlatformName: 'linux', + }); + + assert.equal(staged, true); + assert.equal(fs.readFileSync(targetLibraryPath, 'utf8'), 'bundled ffmpeg'); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('stageLinuxAppImageSharedLibrary skips non-Linux packaging contexts', async () => { + const workspace = createWorkspace('subminer-after-pack-non-linux'); + const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64'); + const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY); + const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY); + + fs.mkdirSync(appOutDir, { recursive: true }); + fs.writeFileSync(sourceLibraryPath, 'bundled ffmpeg', 'utf8'); + + try { + const staged = await stageLinuxAppImageSharedLibrary({ + appOutDir, + electronPlatformName: 'darwin', + }); + + assert.equal(staged, false); + assert.equal(fs.existsSync(targetLibraryPath), false); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('stageLinuxAppImageSharedLibrary no-ops when libffmpeg.so is absent', async () => { + const workspace = createWorkspace('subminer-after-pack-missing-library'); + const appOutDir = path.join(workspace, 'SubMiner-linux-x64'); + const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY); + + fs.mkdirSync(appOutDir, { recursive: true }); + + try { + const staged = await stageLinuxAppImageSharedLibrary({ + appOutDir, + electronPlatformName: 'linux', + }); + + assert.equal(staged, false); + assert.equal(fs.existsSync(targetLibraryPath), false); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index aefcab86..cfe5a82d 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -13,6 +13,7 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { productName?: string; scripts: Record; build?: { + afterPack?: string; files?: string[]; }; }; @@ -77,6 +78,10 @@ test('release package scripts disable implicit electron-builder publishing', () assert.match(packageJson.scripts['build:win:unsigned'] ?? '', /build-win-unsigned\.mjs/); }); +test('release packaging wires a shared afterPack hook for Linux AppImage library staging', () => { + assert.equal(packageJson.build?.afterPack, 'scripts/electron-builder-after-pack.cjs'); +}); + test('top-level package metadata keeps Linux Electron runtime app identity canonical', () => { assert.equal(packageJson.productName, 'SubMiner'); assert.equal(packageJson.desktopName, 'SubMiner.desktop');