mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
Compare commits
3 Commits
da0087bba6
...
5f3c3871d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f3c3871d3 | ||
| 4d24e22bb5 | |||
| c47cfb52af |
@@ -0,0 +1,62 @@
|
||||
---
|
||||
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:56'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- 'https://github.com/ksyasuda/SubMiner/issues/41'
|
||||
documentation:
|
||||
- docs/workflow/verification.md
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [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.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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`.
|
||||
|
||||
Addressed PR #45 CodeRabbit review thread: Linux `afterPack` staging now hard-fails when `libffmpeg.so` is missing instead of silently no-oping. Updated focused hook tests to assert the new failure contract and that `afterPack` propagates Linux staging errors.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
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`.
|
||||
|
||||
Follow-up review fix on PR #45: Linux packaging now throws when `libffmpeg.so` is missing from the packaged app root, preventing silent shipment of a broken AppImage. Focused regression coverage was updated so the missing-library case rejects and `afterPack` propagates the failure.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: TASK-280
|
||||
title: >-
|
||||
Force launcher-spawned mpv onto X11 when backend resolves to x11 or no
|
||||
supported Wayland tracker is available
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-05 21:01'
|
||||
updated_date: '2026-04-05 21:05'
|
||||
labels:
|
||||
- bug
|
||||
- linux
|
||||
- launcher
|
||||
- overlay
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
On Linux Plasma Wayland and similar sessions, `subminer --backend=x11` currently only changes SubMiner's window-tracker override. The launcher still spawns mpv without forcing an X11/XWayland backend, so the X11 tracker cannot find the mpv window and the overlay remains hidden. Update launcher-side mpv spawn behavior so launcher-managed mpv runs under X11 when backend resolves to `x11`, and also when auto detection cannot resolve to a supported Wayland tracker. Preserve existing Hyprland/Sway behavior.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Launcher-managed mpv is spawned with X11/XWayland-forcing environment/config when backend resolves to `x11`.
|
||||
- [x] #2 Linux auto mode falls back to X11/XWayland-forced mpv when no supported Wayland tracker backend is detected.
|
||||
- [x] #3 Hyprland and Sway launcher flows do not regress to forced X11 mpv.
|
||||
- [x] #4 Regression tests cover launcher env/backend selection for these Linux cases.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add focused launcher tests that assert the mpv spawn environment forces X11 when backend resolves to `x11`, and when Linux auto mode cannot use a supported Wayland tracker.
|
||||
2. Refactor launcher mpv spawn code to compute an mpv-specific environment without changing existing Hyprland/Sway flows.
|
||||
3. Route all launcher-managed mpv spawns through the new environment helper.
|
||||
4. Run focused launcher tests, then summarize behavior and any remaining verification gaps.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
User approved scope: force launcher-managed mpv to X11 for explicit `--backend=x11` and for unsupported Linux Wayland auto-detect fallback; preserve Hyprland/Sway behavior.
|
||||
|
||||
Implemented launcher-side `buildMpvEnv` to strip Wayland hints and force X11/XWayland for launcher-managed mpv when `--backend=x11`, and for Linux auto mode on unsupported Wayland desktops with an X11 display available. Wired both normal mpv launches and idle detached mpv launches through the helper.
|
||||
|
||||
Verification: `bun test launcher/mpv.test.ts --test-name-pattern "buildMpvEnv"` passed; `bun run tsc --noEmit` passed. A broader `bun test launcher/mpv.test.ts` run still hits a pre-existing sandbox-specific failure in `launchAppCommandDetached handles child process spawn errors` because this environment cannot write the default app log path under `/home/sudacode/.config/SubMiner/logs`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Updated the launcher so mpv gets an X11/XWayland-oriented spawn environment whenever the user explicitly requests `--backend=x11`, and when Linux auto mode is running under an unsupported Wayland desktop that still exposes an X11 display. The new helper reuses the launcher child-process base environment, strips Wayland-specific hints (`WAYLAND_DISPLAY`, Hyprland/Sway markers), and flips `XDG_SESSION_TYPE` to `x11` only for those fallback cases. Both foreground mpv launches and detached idle mpv launches now use the same helper so overlay-tracked playback stays consistent.
|
||||
|
||||
Added focused regression coverage in `launcher/mpv.test.ts` for three cases: explicit `x11` forcing, unsupported Wayland auto fallback (for example KDE Plasma Wayland), and preserving native Wayland env for supported Hyprland/Sway auto backends. Verification completed with `bun test launcher/mpv.test.ts --test-name-pattern "buildMpvEnv"` and `bun run tsc --noEmit`. A broader launcher mpv test run still shows an unrelated sandbox write failure for the default app log path in this environment.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: TASK-281
|
||||
title: Prevent Windows launcher tests from leaking backslash temp files on POSIX
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-05 21:13'
|
||||
updated_date: '2026-04-05 21:20'
|
||||
labels:
|
||||
- tests
|
||||
- launcher
|
||||
- bug
|
||||
dependencies: []
|
||||
documentation:
|
||||
- /home/sudacode/github/SubMiner2/AGENTS.md
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Windows-specific launcher tests in launcher/mpv.test.ts currently create real filesystem entries using path.win32.join(...) with a POSIX mkdtemp base. On Linux/macOS this produces literal backslash-named paths like \\tmp\\subminer-test-win-dir-* inside the repo/worktree, and the existing cleanup only removes the POSIX /tmp base directory. Fix the tests so they still cover Windows path resolution behavior without leaking stray files into the working tree.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Running the Windows findAppBinary tests on a POSIX host does not create new untracked \\tmp\\subminer-test-win-* files in the repository root.
|
||||
- [x] #2 The Windows launcher tests still validate PATH and install-directory resolution behavior.
|
||||
- [x] #3 Relevant launcher tests pass after the change.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add a regression test in launcher/mpv.test.ts that exercises the Windows findAppBinary cases on a POSIX host and asserts they do not leave new backslash-named temp artifacts in the repository root.
|
||||
2. Refactor the Windows launcher tests to avoid creating real filesystem paths from path.win32.join(...) on POSIX; keep Windows path assertions via stubs and only create real files with native POSIX paths where needed.
|
||||
3. Run the targeted launcher tests and confirm no new \\tmp\\subminer-test-win-* artifacts appear in git status.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Investigation: reproduced the leak locally. The source is launcher/mpv.test.ts Windows findAppBinary tests that combine a POSIX mkdtemp base with path.win32.join(...), creating literal backslash-named entries like \\tmp\\subminer-test-win-dir-* in the repo root. Existing cleanup only removes the POSIX /tmp base directory.
|
||||
|
||||
User approved implementation plan on 2026-04-05.
|
||||
|
||||
Implemented in launcher/mpv.test.ts by replacing the leaky Windows PATH/install-directory helpers with pure fs stubs (access/exists/stat) and fixed Windows path strings instead of creating real path.win32 filesystem entries on POSIX. Added a regression test that snapshots repo-root \\tmp\\subminer-test-win-* artifacts before/after running the Windows cases and asserts no new entries are created.
|
||||
|
||||
Verification: `bun test launcher/mpv.test.ts --test-name-pattern 'findAppBinary Windows cases do not leak backslash temp artifacts on POSIX|findAppBinary resolves SubMiner.exe on PATH on Windows|findAppBinary resolves a Windows install directory to SubMiner.exe'` passed (3/3). `bun test launcher/mpv.test.ts` still has one unrelated pre-existing sandbox failure in `launchAppCommandDetached handles child process spawn errors` because the test opens `~/.config/SubMiner/logs/app-2026-04-05.log` and hits `EROFS` in this environment.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Reworked the Windows `findAppBinary` tests in `launcher/mpv.test.ts` so they no longer create real backslash-named temp files on POSIX hosts. The PATH and install-directory cases now use synthetic Windows path strings plus `fs.accessSync` / `fs.existsSync` / `fs.statSync` stubs to exercise the same resolver behavior without writing `\\tmp\\subminer-test-win-*` entries into the repository root.
|
||||
|
||||
Added a POSIX regression test that snapshots existing repo-root `\\tmp\\subminer-test-win-*` artifacts, runs the Windows path-resolution cases, and asserts the artifact set is unchanged. This catches future regressions where a Windows-path test accidentally writes literal backslash paths on Linux/macOS.
|
||||
|
||||
Tests run:
|
||||
- `bun test launcher/mpv.test.ts --test-name-pattern 'findAppBinary Windows cases do not leak backslash temp artifacts on POSIX|findAppBinary resolves SubMiner.exe on PATH on Windows|findAppBinary resolves a Windows install directory to SubMiner.exe'`
|
||||
- `bun test launcher/mpv.test.ts` (all relevant `findAppBinary` tests passed; one unrelated existing sandbox failure remains in `launchAppCommandDetached handles child process spawn errors` due `EROFS` opening `~/.config/SubMiner/logs/...`)
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id: TASK-282
|
||||
title: >-
|
||||
Force launcher-managed mpv to an explicit X11 GPU context when X11 fallback is
|
||||
active
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-05 21:14'
|
||||
updated_date: '2026-04-05 21:15'
|
||||
labels:
|
||||
- bug
|
||||
- linux
|
||||
- launcher
|
||||
- mpv
|
||||
- overlay
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Follow-up to the launcher X11 fallback work: on Plasma Wayland, stripping Wayland env vars alone is not sufficient for launcher-managed mpv. mpv can still fail GPU initialization under the default video output path (`vo/gpu-next`) unless an explicit X11 GPU context is selected. Update launcher-managed mpv startup so X11 fallback mode also appends explicit mpv options for an X11 GPU context, while preserving supported Hyprland/Sway Wayland flows.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Launcher-managed mpv appends explicit X11 GPU context args when explicit `--backend=x11` is used on Linux.
|
||||
- [x] #2 Launcher-managed mpv appends the same explicit X11 GPU context args for Linux auto-mode fallback on unsupported Wayland desktops.
|
||||
- [x] #3 Supported Hyprland/Sway Wayland flows do not receive the forced X11 GPU context args.
|
||||
- [x] #4 Regression tests cover the forced-X11 mpv arg selection.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add focused launcher tests for explicit X11 GPU-context arg selection, unsupported Wayland auto fallback, and Hyprland/Sway no-regression cases.
|
||||
2. Introduce a shared launcher helper that decides when mpv should be forced onto X11 fallback mode.
|
||||
3. Use that helper to append explicit mpv X11 GPU-context args in both normal and detached idle mpv launch paths.
|
||||
4. Run focused launcher tests plus TypeScript verification, then record remaining runtime follow-up guidance.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
User runtime log showed `vo/gpu-next` failing with `Failed initializing any suitable GPU context!` under forced-X11 playback, which indicates env forcing alone was insufficient. Selected `--gpu-context=x11egl,x11` as the explicit mpv fallback: prefer X11/EGL, with GLX as a compatibility fallback.
|
||||
|
||||
Verification: `bun test launcher/mpv.test.ts --test-name-pattern "buildMpv(Env|BackendArgs)"` passed. `bun run tsc --noEmit` passed.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Added explicit mpv backend args for launcher-managed X11 fallback mode. The launcher now uses a shared `shouldForceX11MpvBackend` decision for both env rewriting and mpv arg selection, so explicit `--backend=x11` and unsupported Linux Wayland auto fallback both append `--gpu-context=x11egl,x11` while still stripping Wayland env hints. This preserves supported Hyprland/Sway native Wayland flows and makes the X11 fallback more explicit for mpv's GPU initialization path.
|
||||
|
||||
Wired the new X11 GPU-context args into both the normal playback launch path and the detached idle mpv launch path. Added focused regression coverage for explicit `x11`, Plasma-style unsupported Wayland auto fallback, and Hyprland/Sway no-regression behavior. Verification completed with `bun test launcher/mpv.test.ts --test-name-pattern "buildMpv(Env|BackendArgs)"` and `bun run tsc --noEmit`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: TASK-283
|
||||
title: Force launcher-managed X11 fallback to mpv vo=gpu with OpenGL on Linux
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-05 21:19'
|
||||
updated_date: '2026-04-05 21:20'
|
||||
labels:
|
||||
- bug
|
||||
- linux
|
||||
- launcher
|
||||
- mpv
|
||||
- overlay
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Follow-up to explicit X11 gpu-context forcing: Plasma Wayland runtime logs still show mpv using `vo/gpu-next` and failing to initialize any suitable GPU context under launcher-managed X11 fallback. Update launcher-managed mpv X11 fallback mode to force a more compatible renderer stack: `--vo=gpu`, `--gpu-api=opengl`, and `--gpu-context=x11egl,x11`, while preserving supported native Wayland flows and allowing explicit user mpv args to override later on the command line.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Launcher-managed mpv appends `--vo=gpu`, `--gpu-api=opengl`, and `--gpu-context=x11egl,x11` when explicit `--backend=x11` is used on Linux.
|
||||
- [x] #2 Launcher-managed mpv appends the same renderer args for Linux auto-mode fallback on unsupported Wayland desktops.
|
||||
- [x] #3 Supported Hyprland/Sway Wayland flows do not receive the forced X11 renderer args.
|
||||
- [x] #4 Regression tests cover the forced-X11 renderer arg selection.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Tighten launcher tests so forced-X11 renderer args require `--vo=gpu`, `--gpu-api=opengl`, and `--gpu-context=x11egl,x11`.
|
||||
2. Update the shared launcher X11-fallback helper to return the full renderer arg stack for explicit `x11` and unsupported Wayland auto fallback.
|
||||
3. Re-run focused launcher env/backend tests and TypeScript verification.
|
||||
4. Hand back with retry instructions and next debugging branch if runtime still fails.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Runtime log still showed `[vo/gpu-next] Failed initializing any suitable GPU context!`, which meant forcing only the context was not enough. Updated the fallback to force the classic OpenGL renderer path too: `--vo=gpu --gpu-api=opengl --gpu-context=x11egl,x11`.
|
||||
|
||||
Verification: `bun test launcher/mpv.test.ts --test-name-pattern "buildMpv(Env|BackendArgs)"` passed. `bun run tsc --noEmit` passed.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Updated launcher-managed X11 fallback mode to force a more compatible mpv renderer stack on Linux: `--vo=gpu`, `--gpu-api=opengl`, and `--gpu-context=x11egl,x11`. This applies both to explicit `--backend=x11` and to unsupported Wayland auto fallback, while supported Hyprland/Sway Wayland sessions still keep their native path. The renderer args are still inserted before user-supplied `--args`, so an explicit user override can win later on the command line if needed.
|
||||
|
||||
Adjusted regression coverage to require the full renderer stack for forced-X11 mode and verified the helper behavior with focused launcher tests plus TypeScript compilation. Verification completed with `bun test launcher/mpv.test.ts --test-name-pattern "buildMpv(Env|BackendArgs)"` and `bun run tsc --noEmit`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: TASK-284
|
||||
title: Fix CI changelog fragment requirement for launcher X11 MPV fallback change
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-05 22:21'
|
||||
updated_date: '2026-04-05 22:22'
|
||||
labels:
|
||||
- ci
|
||||
- changelog
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Current CI is failing in `changelog:pr-check` because PR changes release-relevant files but does not include a required entry under `changes/` and lacks `skip-changelog` label. Add a release fragment describing the behavioral change and verify the CI gate passes.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Add a correctly formatted changelog fragment under `changes/` for the current change
|
||||
- [x] #2 Run the local changelog PR check (or equivalent) with passing result
|
||||
- [x] #3 Run required CI gate commands after change and confirm no regressions
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Added `changes/2026.04.05-mpv-x11-fallback.md` with launcher release-note metadata so `changelog:pr-check` can pass. Verified local CI gate commands: `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist` all passed. Ran manual PR changelog verification by invoking `verifyPullRequestChangelog` with current git diff plus the new fragment and confirmed it passes.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
4
changes/2026.04.05-mpv-x11-fallback.md
Normal file
4
changes/2026.04.05-mpv-x11-fallback.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Fixed launcher-managed mpv spawning to force an explicit X11 GPU path when Wayland trackers are unavailable.
|
||||
4
changes/43-local-subtitle-sidecar.md
Normal file
4
changes/43-local-subtitle-sidecar.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place.
|
||||
4
changes/fix-appimage-libffmpeg-path.md
Normal file
4
changes/fix-appimage-libffmpeg-path.md
Normal file
@@ -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.
|
||||
@@ -7,6 +7,8 @@ import net from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Args } from './types';
|
||||
import {
|
||||
buildMpvBackendArgs,
|
||||
buildMpvEnv,
|
||||
cleanupPlaybackSession,
|
||||
detectBackend,
|
||||
findAppBinary,
|
||||
@@ -125,6 +127,113 @@ test('detectBackend resolves windows on win32 auto mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvEnv forces X11 by dropping Wayland hints when backend resolves to x11', () => {
|
||||
withPlatform('linux', () => {
|
||||
const env = buildMpvEnv(makeArgs({ backend: 'x11' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
|
||||
SWAYSOCK: '/tmp/sway.sock',
|
||||
});
|
||||
|
||||
assert.equal(env.DISPLAY, ':1');
|
||||
assert.equal(env.WAYLAND_DISPLAY, undefined);
|
||||
assert.equal(env.XDG_SESSION_TYPE, 'x11');
|
||||
assert.equal(env.HYPRLAND_INSTANCE_SIGNATURE, undefined);
|
||||
assert.equal(env.SWAYSOCK, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvEnv auto mode falls back to X11 when no supported Wayland tracker backend is detected', () => {
|
||||
withPlatform('linux', () => {
|
||||
const env = buildMpvEnv(makeArgs({ backend: 'auto' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
XDG_CURRENT_DESKTOP: 'KDE',
|
||||
XDG_SESSION_DESKTOP: 'plasma',
|
||||
});
|
||||
|
||||
assert.equal(env.DISPLAY, ':1');
|
||||
assert.equal(env.WAYLAND_DISPLAY, undefined);
|
||||
assert.equal(env.XDG_SESSION_TYPE, 'x11');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvEnv preserves native Wayland env for supported Hyprland and Sway auto backends', () => {
|
||||
withPlatform('linux', () => {
|
||||
const hyprEnv = buildMpvEnv(makeArgs({ backend: 'auto' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
|
||||
});
|
||||
assert.equal(hyprEnv.WAYLAND_DISPLAY, 'wayland-0');
|
||||
assert.equal(hyprEnv.XDG_SESSION_TYPE, 'wayland');
|
||||
|
||||
const swayEnv = buildMpvEnv(makeArgs({ backend: 'auto' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
SWAYSOCK: '/tmp/sway.sock',
|
||||
});
|
||||
assert.equal(swayEnv.WAYLAND_DISPLAY, 'wayland-0');
|
||||
assert.equal(swayEnv.XDG_SESSION_TYPE, 'wayland');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend resolves to x11', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.deepEqual(
|
||||
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
}),
|
||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Wayland auto fallback', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.deepEqual(
|
||||
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
XDG_CURRENT_DESKTOP: 'KDE',
|
||||
XDG_SESSION_DESKTOP: 'plasma',
|
||||
}),
|
||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvBackendArgs keeps supported Hyprland and Sway auto backends unchanged', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.deepEqual(
|
||||
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
SWAYSOCK: '/tmp/sway.sock',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||
@@ -485,6 +594,40 @@ function withAccessSyncStub(
|
||||
}
|
||||
}
|
||||
|
||||
function withExistsAndStatSyncStubs(
|
||||
options: {
|
||||
existingPaths?: string[];
|
||||
directoryPaths?: string[];
|
||||
},
|
||||
run: () => void,
|
||||
): void {
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
const directoryPaths = new Set(options.directoryPaths ?? []);
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalStatSync = fs.statSync;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).existsSync = (filePath: string): boolean =>
|
||||
existingPaths.has(filePath) || directoryPaths.has(filePath);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).statSync = (filePath: string) => {
|
||||
if (directoryPaths.has(filePath)) {
|
||||
return { isDirectory: () => true };
|
||||
}
|
||||
if (existingPaths.has(filePath)) {
|
||||
return { isDirectory: () => false };
|
||||
}
|
||||
throw Object.assign(new Error(`ENOENT: ${filePath}`), { code: 'ENOENT' });
|
||||
};
|
||||
run();
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).existsSync = originalExistsSync;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).statSync = originalStatSync;
|
||||
}
|
||||
}
|
||||
|
||||
function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: () => void): void {
|
||||
const originalRealpathSync = fs.realpathSync;
|
||||
try {
|
||||
@@ -497,6 +640,75 @@ function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: ()
|
||||
}
|
||||
}
|
||||
|
||||
function listRepoRootWindowsTempArtifacts(): string[] {
|
||||
return fs
|
||||
.readdirSync(process.cwd())
|
||||
.filter((entry) => /^\\tmp\\subminer-test-win-/.test(entry))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function runFindAppBinaryWindowsPathCase(): void {
|
||||
const baseDir = 'C:\\Users\\tester\\subminer-test-win-path';
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const binDir = path.win32.join(baseDir, 'bin');
|
||||
const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
|
||||
process.env.PATH = `${binDir}${path.win32.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
const result = findAppBinary(
|
||||
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||
pathModule,
|
||||
);
|
||||
assert.equal(result, wrapperPath);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
|
||||
function runFindAppBinaryWindowsInstallDirCase(): void {
|
||||
const baseDir = 'C:\\Users\\tester\\subminer-test-win-dir';
|
||||
const originalHomedir = os.homedir;
|
||||
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
|
||||
const appExe = path.win32.join(installDir, 'SubMiner.exe');
|
||||
process.env.SUBMINER_BINARY_PATH = installDir;
|
||||
|
||||
withPlatform('win32', () => {
|
||||
withExistsAndStatSyncStubs(
|
||||
{ existingPaths: [appExe], directoryPaths: [installDir] },
|
||||
() => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === appExe,
|
||||
() => {
|
||||
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32);
|
||||
assert.equal(result, appExe);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalSubminerBinaryPath === undefined) {
|
||||
delete process.env.SUBMINER_BINARY_PATH;
|
||||
} else {
|
||||
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
|
||||
{ concurrency: false },
|
||||
@@ -657,71 +869,31 @@ test('findAppBinary resolves Windows install paths when present', { concurrency:
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-path-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const binDir = path.win32.join(baseDir, 'bin');
|
||||
const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.win32.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
test(
|
||||
'findAppBinary Windows cases do not leak backslash temp artifacts on POSIX',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const result = findAppBinary(
|
||||
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||
pathModule,
|
||||
);
|
||||
assert.equal(result, wrapperPath);
|
||||
if (path.sep === '\\') {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = listRepoRootWindowsTempArtifacts();
|
||||
runFindAppBinaryWindowsPathCase();
|
||||
runFindAppBinaryWindowsInstallDirCase();
|
||||
const after = listRepoRootWindowsTempArtifacts();
|
||||
|
||||
assert.deepEqual(after, before);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, () => {
|
||||
runFindAppBinaryWindowsPathCase();
|
||||
});
|
||||
|
||||
test(
|
||||
'findAppBinary resolves a Windows install directory to SubMiner.exe',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
|
||||
const appExe = path.win32.join(installDir, 'SubMiner.exe');
|
||||
process.env.SUBMINER_BINARY_PATH = installDir;
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appExe, 0o755);
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
const result = findAppBinary(
|
||||
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||
path.win32,
|
||||
);
|
||||
assert.equal(result, appExe);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalSubminerBinaryPath === undefined) {
|
||||
delete process.env.SUBMINER_BINARY_PATH;
|
||||
} else {
|
||||
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
|
||||
}
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
runFindAppBinaryWindowsInstallDirCase();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -225,27 +225,65 @@ export function makeTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
||||
export function detectBackend(
|
||||
backend: Backend,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Exclude<Backend, 'auto'> {
|
||||
if (backend !== 'auto') return backend;
|
||||
if (process.platform === 'win32') return 'windows';
|
||||
if (process.platform === 'darwin') return 'macos';
|
||||
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
||||
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
||||
const xdgSessionType = (process.env.XDG_SESSION_TYPE || '').toLowerCase();
|
||||
const hasWayland = Boolean(process.env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland';
|
||||
const linuxDesktopEnv = getLinuxDesktopEnv(env);
|
||||
|
||||
if (
|
||||
process.env.HYPRLAND_INSTANCE_SIGNATURE ||
|
||||
xdgCurrentDesktop.includes('hyprland') ||
|
||||
xdgSessionDesktop.includes('hyprland')
|
||||
env.HYPRLAND_INSTANCE_SIGNATURE ||
|
||||
linuxDesktopEnv.xdgCurrentDesktop.includes('hyprland') ||
|
||||
linuxDesktopEnv.xdgSessionDesktop.includes('hyprland')
|
||||
) {
|
||||
return 'hyprland';
|
||||
}
|
||||
if (hasWayland && commandExists('hyprctl')) return 'hyprland';
|
||||
if (process.env.DISPLAY) return 'x11';
|
||||
if (linuxDesktopEnv.hasWayland && commandExists('hyprctl')) return 'hyprland';
|
||||
if (env.DISPLAY) return 'x11';
|
||||
fail('Could not detect display backend');
|
||||
}
|
||||
|
||||
type LinuxDesktopEnv = {
|
||||
xdgCurrentDesktop: string;
|
||||
xdgSessionDesktop: string;
|
||||
hasWayland: boolean;
|
||||
};
|
||||
|
||||
function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv {
|
||||
const xdgCurrentDesktop = (env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
||||
const xdgSessionDesktop = (env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
||||
const xdgSessionType = (env.XDG_SESSION_TYPE || '').toLowerCase();
|
||||
return {
|
||||
xdgCurrentDesktop,
|
||||
xdgSessionDesktop,
|
||||
hasWayland: Boolean(env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland',
|
||||
};
|
||||
}
|
||||
|
||||
function shouldForceX11MpvBackend(
|
||||
args: Pick<Args, 'backend'>,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const linuxDesktopEnv = getLinuxDesktopEnv(env);
|
||||
const supportedWaylandBackend =
|
||||
Boolean(env.HYPRLAND_INSTANCE_SIGNATURE || env.SWAYSOCK) ||
|
||||
linuxDesktopEnv.xdgCurrentDesktop.includes('hyprland') ||
|
||||
linuxDesktopEnv.xdgCurrentDesktop.includes('sway') ||
|
||||
linuxDesktopEnv.xdgSessionDesktop.includes('hyprland') ||
|
||||
linuxDesktopEnv.xdgSessionDesktop.includes('sway');
|
||||
return (
|
||||
args.backend === 'x11' ||
|
||||
(args.backend === 'auto' && linuxDesktopEnv.hasWayland && !supportedWaylandBackend)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string {
|
||||
const direct = resolveBinaryPathCandidate(candidate);
|
||||
if (!direct) return '';
|
||||
@@ -637,6 +675,7 @@ export async function startMpv(
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
mpvArgs.push(...buildMpvBackendArgs(args));
|
||||
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
||||
log('info', args.logLevel, 'Applying URL playback options');
|
||||
mpvArgs.push('--ytdl=yes');
|
||||
@@ -712,7 +751,10 @@ export async function startMpv(
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||
normalizeWindowsShellArgs: false,
|
||||
});
|
||||
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
|
||||
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, {
|
||||
stdio: 'inherit',
|
||||
env: buildMpvEnv(args),
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOverlayStartCommandSettled(
|
||||
@@ -889,9 +931,9 @@ function stopManagedOverlayApp(args: Args): void {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAppEnv(): NodeJS.ProcessEnv {
|
||||
function buildAppEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...baseEnv,
|
||||
SUBMINER_APP_LOG: getAppLogPath(),
|
||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
||||
};
|
||||
@@ -911,6 +953,32 @@ function buildAppEnv(): NodeJS.ProcessEnv {
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildMpvEnv(
|
||||
args: Pick<Args, 'backend'>,
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env = buildAppEnv(baseEnv);
|
||||
if (!shouldForceX11MpvBackend(args, env)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
delete env.WAYLAND_DISPLAY;
|
||||
delete env.HYPRLAND_INSTANCE_SIGNATURE;
|
||||
delete env.SWAYSOCK;
|
||||
env.XDG_SESSION_TYPE = 'x11';
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildMpvBackendArgs(
|
||||
args: Pick<Args, 'backend'>,
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
if (!shouldForceX11MpvBackend(args, baseEnv)) {
|
||||
return [];
|
||||
}
|
||||
return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'];
|
||||
}
|
||||
|
||||
function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void {
|
||||
const normalized = chunk.replace(/\r\n/g, '\n');
|
||||
for (const line of normalized.split('\n')) {
|
||||
@@ -1144,6 +1212,7 @@ export function launchMpvIdleDetached(
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
mpvArgs.push(...buildMpvBackendArgs(args));
|
||||
if (args.mpvArgs) {
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
@@ -1159,6 +1228,7 @@ export function launchMpvIdleDetached(
|
||||
const proc = spawn(mpvTarget.command, mpvTarget.args, {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
env: buildMpvEnv(args),
|
||||
});
|
||||
if (typeof proc.pid === 'number' && proc.pid > 0) {
|
||||
trackDetachedMpvPid(proc.pid);
|
||||
|
||||
@@ -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"
|
||||
|
||||
46
scripts/electron-builder-after-pack.cjs
Normal file
46
scripts/electron-builder-after-pack.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
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 (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`Linux packaging requires ${LINUX_FFMPEG_LIBRARY} at ${sourceLibraryPath} so AppImage child processes can resolve it.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
104
scripts/electron-builder-after-pack.test.ts
Normal file
104
scripts/electron-builder-after-pack.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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,
|
||||
default: afterPack,
|
||||
stageLinuxAppImageSharedLibrary,
|
||||
} = require('./electron-builder-after-pack.cjs') as {
|
||||
LINUX_FFMPEG_LIBRARY: string;
|
||||
default: (context: { appOutDir: string; electronPlatformName: string }) => Promise<void>;
|
||||
stageLinuxAppImageSharedLibrary: (context: {
|
||||
appOutDir: string;
|
||||
electronPlatformName: string;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
|
||||
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 throws when Linux packaging is missing libffmpeg.so', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-missing-library');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-linux-x64');
|
||||
|
||||
fs.mkdirSync(appOutDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
stageLinuxAppImageSharedLibrary({
|
||||
appOutDir,
|
||||
electronPlatformName: 'linux',
|
||||
}),
|
||||
new RegExp(`Linux packaging requires ${LINUX_FFMPEG_LIBRARY} at .*${LINUX_FFMPEG_LIBRARY}`),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('afterPack propagates Linux staging failures', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-propagates-linux-failure');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-linux-x64');
|
||||
|
||||
fs.mkdirSync(appOutDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
afterPack({
|
||||
appOutDir,
|
||||
electronPlatformName: 'linux',
|
||||
}),
|
||||
/Linux packaging requires libffmpeg\.so/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,11 @@ const mixedLanguageTrackList = [
|
||||
{ type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true },
|
||||
];
|
||||
|
||||
const unlabeledExternalSidecarTrackList = [
|
||||
{ type: 'sub', id: 1, lang: 'eng', title: 'English ASS', external: false, selected: true },
|
||||
{ type: 'sub', id: 2, title: 'srt', external: true },
|
||||
];
|
||||
|
||||
test('resolveManagedLocalSubtitleSelection prefers default Japanese primary and English secondary tracks', () => {
|
||||
const result = resolveManagedLocalSubtitleSelection({
|
||||
trackList: mixedLanguageTrackList,
|
||||
@@ -37,6 +42,31 @@ test('resolveManagedLocalSubtitleSelection respects configured language override
|
||||
assert.equal(result.secondaryTrackId, 12);
|
||||
});
|
||||
|
||||
test('resolveManagedLocalSubtitleSelection promotes a single unlabeled external sidecar to primary', () => {
|
||||
const result = resolveManagedLocalSubtitleSelection({
|
||||
trackList: unlabeledExternalSidecarTrackList,
|
||||
primaryLanguages: [],
|
||||
secondaryLanguages: [],
|
||||
});
|
||||
|
||||
assert.equal(result.primaryTrackId, 2);
|
||||
assert.equal(result.secondaryTrackId, 1);
|
||||
});
|
||||
|
||||
test('resolveManagedLocalSubtitleSelection does not guess between multiple unlabeled external sidecars', () => {
|
||||
const result = resolveManagedLocalSubtitleSelection({
|
||||
trackList: [
|
||||
...unlabeledExternalSidecarTrackList,
|
||||
{ type: 'sub', id: 3, title: 'subrip', external: true },
|
||||
],
|
||||
primaryLanguages: [],
|
||||
secondaryLanguages: [],
|
||||
});
|
||||
|
||||
assert.equal(result.primaryTrackId, null);
|
||||
assert.equal(result.secondaryTrackId, 1);
|
||||
});
|
||||
|
||||
test('managed local subtitle selection runtime applies preferred tracks once for a local media path', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
@@ -75,3 +105,42 @@ test('managed local subtitle selection runtime applies preferred tracks once for
|
||||
['set_property', 'secondary-sid', 11],
|
||||
]);
|
||||
});
|
||||
|
||||
test('managed local subtitle selection runtime promotes a single unlabeled external sidecar over embedded english', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'track-list') {
|
||||
return unlabeledExternalSidecarTrackList;
|
||||
}
|
||||
throw new Error(`Unexpected property: ${name}`);
|
||||
},
|
||||
}) as never,
|
||||
getPrimarySubtitleLanguages: () => [],
|
||||
getSecondarySubtitleLanguages: () => [],
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
clearScheduled: () => {},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||
scheduled.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
runtime.handleSubtitleTrackListChange(unlabeledExternalSidecarTrackList);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'sid', 2],
|
||||
['set_property', 'secondary-sid', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -90,6 +90,10 @@ function isLikelyHearingImpaired(title: string): boolean {
|
||||
return HEARING_IMPAIRED_PATTERN.test(title);
|
||||
}
|
||||
|
||||
function isUnlabeledExternalTrack(track: NormalizedSubtitleTrack): boolean {
|
||||
return track.external && normalizeYoutubeLangCode(track.lang).length === 0;
|
||||
}
|
||||
|
||||
function pickBestTrackId(
|
||||
tracks: NormalizedSubtitleTrack[],
|
||||
preferredLanguages: string[],
|
||||
@@ -126,6 +130,19 @@ function pickBestTrackId(
|
||||
};
|
||||
}
|
||||
|
||||
function pickSingleUnlabeledExternalTrackId(
|
||||
tracks: NormalizedSubtitleTrack[],
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
const fallbackCandidates = tracks.filter(
|
||||
(track) => track.id !== excludeId && isUnlabeledExternalTrack(track),
|
||||
);
|
||||
if (fallbackCandidates.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return fallbackCandidates[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export function resolveManagedLocalSubtitleSelection(input: {
|
||||
trackList: unknown[] | null;
|
||||
primaryLanguages: string[];
|
||||
@@ -146,12 +163,13 @@ export function resolveManagedLocalSubtitleSelection(input: {
|
||||
);
|
||||
|
||||
const primary = pickBestTrackId(tracks, preferredPrimaryLanguages);
|
||||
const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primary.trackId);
|
||||
const primaryTrackId = primary.trackId ?? pickSingleUnlabeledExternalTrackId(tracks);
|
||||
const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primaryTrackId);
|
||||
|
||||
return {
|
||||
primaryTrackId: primary.trackId,
|
||||
primaryTrackId,
|
||||
secondaryTrackId: secondary.trackId,
|
||||
hasPrimaryMatch: primary.hasMatch,
|
||||
hasPrimaryMatch: primary.hasMatch || primaryTrackId !== null,
|
||||
hasSecondaryMatch: secondary.hasMatch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
productName?: string;
|
||||
scripts: Record<string, string>;
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user