diff --git a/backlog/tasks/task-162 - Normalize-packaged-Linux-paths-to-canonical-SubMiner-directories.md b/backlog/tasks/task-162 - Normalize-packaged-Linux-paths-to-canonical-SubMiner-directories.md index 9d25cfb..41bf868 100644 --- a/backlog/tasks/task-162 - Normalize-packaged-Linux-paths-to-canonical-SubMiner-directories.md +++ b/backlog/tasks/task-162 - Normalize-packaged-Linux-paths-to-canonical-SubMiner-directories.md @@ -1,11 +1,11 @@ --- id: TASK-162 title: Normalize packaged Linux paths to canonical SubMiner directories -status: In Progress +status: Done assignee: - codex created_date: '2026-03-11 08:28' -updated_date: '2026-03-11 08:29' +updated_date: '2026-03-16 06:25' labels: - linux - packaging @@ -30,10 +30,10 @@ Align packaged Linux path conventions so system-installed assets use canonical ` ## Acceptance Criteria -- [ ] #1 Launcher/runtime path discovery prefers canonical packaged Linux locations that use `SubMiner` casing for shared data and config directories. -- [ ] #2 Tests cover the expected packaged Linux discovery paths for the AppImage and rofi theme search behavior. -- [ ] #3 User-facing docs reference the canonical packaged Linux locations consistently. -- [ ] #4 Lowercase names remain only where intentionally required for the launcher wrapper, rofi theme filename, and mpv Lua plugin/conf. +- [x] #1 Launcher/runtime path discovery prefers canonical packaged Linux locations that use `SubMiner` casing for shared data and config directories. +- [x] #2 Tests cover the expected packaged Linux discovery paths for the AppImage and rofi theme search behavior. +- [x] #3 User-facing docs reference the canonical packaged Linux locations consistently. +- [x] #4 Lowercase names remain only where intentionally required for the launcher wrapper, rofi theme filename, and mpv Lua plugin/conf. ## Implementation Plan @@ -44,4 +44,22 @@ Align packaged Linux path conventions so system-installed assets use canonical ` 3. Update plugin auto-detection comments and binary search defaults so packaged Linux paths stay consistent with launcher/runtime expectations. 4. Update user-facing docs to reference canonical SubMiner-cased config/share paths while keeping lowercase names only for the launcher wrapper, rofi theme filename, and mpv Lua plugin/conf. 5. Run targeted launcher tests plus docs checks. + +Remaining work (2026-03-15): +- binary.lua: add lowercase fallback candidates /usr/bin/subminer and /usr/local/bin/subminer after existing title-case entries +- launcher tests: add findAppBinary Linux candidates and findRofiTheme /usr/share + /usr/local/share tests + +## Implementation Notes + + +2026-03-15: Adding launcher tests for Linux packaged path discovery (findAppBinary + findRofiTheme). Implementing in mpv.test.ts and new picker.test.ts following node:test / assert/strict patterns from mpv.test.ts. + +2026-03-15: AC#2 complete. Added findAppBinary tests (3) to launcher/mpv.test.ts and findRofiTheme tests (4) to new launcher/picker.test.ts. All 76 launcher tests pass. Added picker.test.ts to test:launcher:src script. + + +## Final Summary + + +## Completed changes\n\n### `plugin/subminer/binary.lua`\nAdded lowercase fallback candidates after existing title-case entries in the non-Windows `find_binary()` search list:\n- `/usr/local/bin/subminer` (after `/usr/local/bin/SubMiner`)\n- `/usr/bin/subminer` (after `/usr/bin/SubMiner`)\n\n### `plugin/subminer.conf`\nUpdated the comment documenting the Linux binary search list to include the two new lowercase candidates.\n\n### `launcher/mpv.test.ts`\nAdded 3 new tests for `findAppBinary` Linux candidates:\n- Resolves `~/.local/bin/SubMiner.AppImage` when it exists\n- Resolves `/opt/SubMiner/SubMiner.AppImage` when `~/.local/bin` candidate absent\n- Finds `subminer` on PATH when AppImage candidates absent\n\n### `launcher/picker.test.ts` (new file)\nAdded 4 tests for `findRofiTheme` Linux packaged paths:\n- Resolves `/usr/local/share/SubMiner/themes/subminer.rasi`\n- Resolves `/usr/share/SubMiner/themes/subminer.rasi` when `/usr/local/share` absent\n- Resolves `$XDG_DATA_HOME/SubMiner/themes/subminer.rasi` when set\n- Resolves `~/.local/share/SubMiner/themes/subminer.rasi` when `XDG_DATA_HOME` unset\n\n### `package.json`\nAdded `launcher/picker.test.ts` to `test:launcher:src` file list.\n\n## Verification\n- `launcher-plugin` lane: passed (76 launcher tests, 524 fast tests — all green)\n\n## Policy checks\n- Docs update required? No — docs already reflected canonical paths.\n- Changelog fragment required? Yes — user-visible fix to plugin binary auto-detection. Fragment should be added under `changes/`. + diff --git a/changes/2026-03-15-linux-plugin-binary-lowercase-fallback.md b/changes/2026-03-15-linux-plugin-binary-lowercase-fallback.md new file mode 100644 index 0000000..271523b --- /dev/null +++ b/changes/2026-03-15-linux-plugin-binary-lowercase-fallback.md @@ -0,0 +1,4 @@ +type: fixed +area: launcher + +- Fixed mpv Lua plugin binary auto-detection on Linux to also search `/usr/bin/subminer` and `/usr/local/bin/subminer` (lowercase), matching the conventional Unix wrapper name used by packaged installs such as the AUR package. diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 5f56efb..f8d1271 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -2,11 +2,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import os from 'node:os'; import net from 'node:net'; import { EventEmitter } from 'node:events'; import type { Args } from './types'; import { cleanupPlaybackSession, + findAppBinary, runAppCommandCaptureOutput, shouldResolveAniSkipMetadata, startOverlay, @@ -233,3 +235,72 @@ test('cleanupPlaybackSession preserves background app while stopping mpv-owned c fs.rmSync(dir, { recursive: true, force: true }); } }); + +// ── findAppBinary: Linux packaged path discovery ────────────────────────────── + +function makeExecutable(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(filePath, 0o755); +} + +test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); + const originalHomedir = os.homedir; + try { + os.homedir = () => baseDir; + const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage'); + makeExecutable(appImage); + + const result = findAppBinary('/some/other/path/subminer'); + assert.equal(result, appImage); + } finally { + os.homedir = originalHomedir; + fs.rmSync(baseDir, { recursive: true, force: true }); + } +}); + +test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); + const originalHomedir = os.homedir; + const originalAccessSync = fs.accessSync; + try { + os.homedir = () => baseDir; + // No ~/.local/bin/SubMiner.AppImage; patch accessSync so only /opt path is executable + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).accessSync = (filePath: string, mode?: number): void => { + if (filePath === '/opt/SubMiner/SubMiner.AppImage') return; + throw Object.assign(new Error(`EACCES: ${filePath}`), { code: 'EACCES' }); + }; + + const result = findAppBinary('/some/other/path/subminer'); + assert.equal(result, '/opt/SubMiner/SubMiner.AppImage'); + } finally { + os.homedir = originalHomedir; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).accessSync = originalAccessSync; + fs.rmSync(baseDir, { recursive: true, force: true }); + } +}); + +test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-')); + const originalHomedir = os.homedir; + const originalPath = process.env.PATH; + try { + os.homedir = () => baseDir; + // No AppImage candidates in empty home dir; place subminer wrapper on PATH + const binDir = path.join(baseDir, 'bin'); + const wrapperPath = path.join(binDir, 'subminer'); + makeExecutable(wrapperPath); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; + + // selfPath must differ from wrapperPath so the self-check does not exclude it + const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer')); + assert.equal(result, wrapperPath); + } finally { + os.homedir = originalHomedir; + process.env.PATH = originalPath; + fs.rmSync(baseDir, { recursive: true, force: true }); + } +}); diff --git a/launcher/picker.test.ts b/launcher/picker.test.ts new file mode 100644 index 0000000..dc4e1ee --- /dev/null +++ b/launcher/picker.test.ts @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { findRofiTheme } from './picker'; + +// ── findRofiTheme: Linux packaged path discovery ────────────────────────────── + +const ROFI_THEME_FILE = 'subminer.rasi'; + +function makeFile(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '/* theme */'); +} + +test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when it exists', () => { + const originalExistsSync = fs.existsSync; + const targetPath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).existsSync = (filePath: unknown): boolean => { + if (filePath === targetPath) return true; + return false; + }; + + const result = findRofiTheme('/usr/local/bin/subminer'); + assert.equal(result, targetPath); + } finally { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).existsSync = originalExistsSync; + } +}); + +test('findRofiTheme resolves /usr/share/SubMiner/themes/subminer.rasi when /usr/local/share one does not exist', () => { + const originalExistsSync = fs.existsSync; + const localSharePath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`; + const sharePath = `/usr/share/SubMiner/themes/${ROFI_THEME_FILE}`; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).existsSync = (filePath: unknown): boolean => { + if (filePath === sharePath) return true; + if (filePath === localSharePath) return false; + return false; + }; + + const result = findRofiTheme('/usr/bin/subminer'); + assert.equal(result, sharePath); + } finally { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fs as any).existsSync = originalExistsSync; + } +}); + +test('findRofiTheme resolves XDG_DATA_HOME/SubMiner/themes/subminer.rasi when set and file exists', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-')); + const originalXdgDataHome = process.env.XDG_DATA_HOME; + try { + process.env.XDG_DATA_HOME = baseDir; + const themePath = path.join(baseDir, `SubMiner/themes/${ROFI_THEME_FILE}`); + makeFile(themePath); + + const result = findRofiTheme('/usr/bin/subminer'); + assert.equal(result, themePath); + } finally { + process.env.XDG_DATA_HOME = originalXdgDataHome; + fs.rmSync(baseDir, { recursive: true, force: true }); + } +}); + +test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when XDG_DATA_HOME unset', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); + const originalHomedir = os.homedir; + const originalXdgDataHome = process.env.XDG_DATA_HOME; + try { + os.homedir = () => baseDir; + delete process.env.XDG_DATA_HOME; + const themePath = path.join(baseDir, `.local/share/SubMiner/themes/${ROFI_THEME_FILE}`); + makeFile(themePath); + + const result = findRofiTheme('/usr/bin/subminer'); + assert.equal(result, themePath); + } finally { + os.homedir = originalHomedir; + if (originalXdgDataHome !== undefined) { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } + fs.rmSync(baseDir, { recursive: true, force: true }); + } +}); diff --git a/package.json b/package.json index 6411744..9b962c6 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", - "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", + "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", diff --git a/plugin/subminer.conf b/plugin/subminer.conf index e39fe21..78c7ef6 100644 --- a/plugin/subminer.conf +++ b/plugin/subminer.conf @@ -5,7 +5,7 @@ # Auto-detection searches common locations, including: # - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner # - Windows: %LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe, %ProgramFiles%\SubMiner\SubMiner.exe -# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/bin/SubMiner +# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/local/bin/subminer, /usr/bin/SubMiner, /usr/bin/subminer binary_path= # Path to mpv IPC socket (must match input-ipc-server in mpv.conf) diff --git a/plugin/subminer/binary.lua b/plugin/subminer/binary.lua index 9a3519f..9b231eb 100644 --- a/plugin/subminer/binary.lua +++ b/plugin/subminer/binary.lua @@ -257,7 +257,9 @@ try { add_search_path(search_paths, utils.join_path(home, ".local", "bin", "SubMiner.AppImage")) add_search_path(search_paths, "/opt/SubMiner/SubMiner.AppImage") add_search_path(search_paths, "/usr/local/bin/SubMiner") + add_search_path(search_paths, "/usr/local/bin/subminer") add_search_path(search_paths, "/usr/bin/SubMiner") + add_search_path(search_paths, "/usr/bin/subminer") end for _, path in ipairs(search_paths) do