From 920cbab1bc51abae824987550019f939683962ab Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 25 May 2026 01:34:01 -0700 Subject: [PATCH] Fix Windows mpv handoff and tray setup (#82) --- changes/fix-windows-background-mpv-overlay.md | 4 + changes/windows-mpv-launcher-tray.md | 4 + launcher/mpv.test.ts | 1 + launcher/mpv.ts | 17 +- launcher/picker.ts | 4 +- launcher/smoke.e2e.test.ts | 36 ++++- launcher/types.ts | 1 + plugin/subminer/environment.lua | 10 ++ plugin/subminer/session_bindings.lua | 81 ++++++++-- scripts/test-plugin-session-bindings.lua | 2 + src/config/config.test.ts | 2 +- src/core/services/index.ts | 6 +- .../services/overlay-runtime-init.test.ts | 61 ++++++- src/core/services/overlay-runtime-init.ts | 150 +++++++++-------- src/main-entry-launch-config.ts | 43 +++++ src/main-entry-runtime.test.ts | 108 ++++++++++++- src/main-entry-runtime.ts | 3 +- src/main-entry.ts | 110 ++++++------- src/main.ts | 152 ++++++++++++------ .../cli-command-context-main-deps.test.ts | 5 + .../runtime/cli-command-context-main-deps.ts | 5 + .../runtime/command-line-launcher-deps.ts | 33 +++- .../runtime/command-line-launcher.test.ts | 45 ++++++ src/main/runtime/tray-main-actions.test.ts | 41 ++++- src/main/runtime/tray-main-actions.ts | 4 +- src/main/runtime/tray-main-deps.test.ts | 3 +- src/main/runtime/tray-main-deps.ts | 2 +- src/main/runtime/tray-runtime.test.ts | 3 +- src/main/runtime/tray-runtime.ts | 2 +- src/main/runtime/windows-mpv-launch.test.ts | 32 +++- src/main/runtime/windows-mpv-launch.ts | 1 + 31 files changed, 751 insertions(+), 220 deletions(-) create mode 100644 changes/fix-windows-background-mpv-overlay.md create mode 100644 changes/windows-mpv-launcher-tray.md create mode 100644 src/main-entry-launch-config.ts diff --git a/changes/fix-windows-background-mpv-overlay.md b/changes/fix-windows-background-mpv-overlay.md new file mode 100644 index 00000000..3376c474 --- /dev/null +++ b/changes/fix-windows-background-mpv-overlay.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Windows managed mpv launches from a background SubMiner instance so the existing warm app receives the start command, retargets the new mpv socket, binds to the player window, and receives startup overlay options. diff --git a/changes/windows-mpv-launcher-tray.md b/changes/windows-mpv-launcher-tray.md new file mode 100644 index 00000000..8061bb1f --- /dev/null +++ b/changes/windows-mpv-launcher-tray.md @@ -0,0 +1,4 @@ +type: fixed +area: tray + +- Fixed the Windows tray "Open SubMiner Setup" action so it opens the setup window after first-run setup is already complete. diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index f070efc1..325b51ab 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -256,6 +256,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured '--sub-file-paths=.;subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 643b9019..06e0cb26 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -47,7 +47,10 @@ type SpawnTarget = { env?: NodeJS.ProcessEnv; }; -type PathModule = Pick; +type PathModule = Pick< + typeof path, + 'dirname' | 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve' | 'isAbsolute' | 'normalize' +>; const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid'); const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900; @@ -62,6 +65,12 @@ export interface LauncherRuntimePluginPlan { errorMessage: string | null; } +function resolvePluginCandidatePath(candidate: string, pathModule: PathModule): string { + return pathModule.isAbsolute(candidate) + ? pathModule.normalize(candidate) + : pathModule.resolve(candidate); +} + export function parseMpvArgString(input: string): string[] { const chars = input; const args: string[] = []; @@ -291,12 +300,12 @@ export function resolveLauncherRuntimePluginPath(options: { pathModule?: typeof path; existsSync?: (candidate: string) => boolean; }): string | null { + const platform = options.platform ?? process.platform; const pathModule = options.pathModule ?? path; const existsSync = options.existsSync ?? fs.existsSync; const env = options.env ?? process.env; const dirname = options.dirname ?? __dirname; const cwd = options.cwd ?? process.cwd(); - const platform = options.platform ?? process.platform; const homeDir = options.homeDir ?? os.homedir(); const candidates: string[] = []; @@ -344,7 +353,7 @@ export function resolveLauncherRuntimePluginPath(options: { const seen = new Set(); for (const candidate of candidates) { - const resolved = pathModule.resolve(candidate); + const resolved = resolvePluginCandidatePath(candidate, pathModule); if (seen.has(resolved)) continue; seen.add(resolved); const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync }); @@ -1704,7 +1713,7 @@ export async function waitForUnixSocketReady( const deadline = nowMs() + timeoutMs; while (nowMs() < deadline) { try { - if (fs.existsSync(socketPath)) { + if (process.platform === 'win32' || fs.existsSync(socketPath)) { const ready = await canConnectUnixSocket(socketPath); if (ready) return true; } diff --git a/launcher/picker.ts b/launcher/picker.ts index 38701dfd..2a506fc0 100644 --- a/launcher/picker.ts +++ b/launcher/picker.ts @@ -365,8 +365,8 @@ export function findRofiTheme(scriptPath: string): string | null { } else { const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share'); candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE)); - candidates.push(path.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE)); - candidates.push(path.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE)); + candidates.push(path.posix.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE)); + candidates.push(path.posix.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE)); } candidates.push(path.join(scriptDir, 'assets', 'themes', ROFI_THEME_FILE)); diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 54cf133b..16254673 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -40,6 +40,19 @@ function writeExecutable(filePath: string, body: string): void { fs.chmodSync(filePath, 0o755); } +function writeFixtureExecutable(basePath: string, body: string): string { + if (process.platform !== 'win32') { + writeExecutable(basePath, body); + return basePath; + } + + const scriptPath = `${basePath}.js`; + const commandPath = `${basePath}.cmd`; + fs.writeFileSync(scriptPath, body); + fs.writeFileSync(commandPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`); + return commandPath; +} + function createSmokeCase(name: string): SmokeCase { const baseDir = path.join(process.cwd(), '.tmp', 'launcher-smoke'); fs.mkdirSync(baseDir, { recursive: true }); @@ -52,8 +65,8 @@ function createSmokeCase(name: string): SmokeCase { const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-')); const socketPath = path.join(socketDir, 'subminer.sock'); const videoPath = path.join(root, 'video.mkv'); - const fakeAppPath = path.join(binDir, 'fake-subminer'); - const fakeMpvPath = path.join(binDir, 'mpv'); + const fakeAppBasePath = path.join(binDir, 'fake-subminer'); + const fakeMpvBasePath = path.join(binDir, 'mpv'); const mpvOverlayLogPath = path.join(artifactsDir, 'mpv-overlay.log'); fs.mkdirSync(artifactsDir, { recursive: true }); @@ -74,8 +87,8 @@ function createSmokeCase(name: string): SmokeCase { const fakeAppStartLogPath = path.join(artifactsDir, 'fake-app-start.log'); const fakeAppStopLogPath = path.join(artifactsDir, 'fake-app-stop.log'); - writeExecutable( - fakeMpvPath, + const fakeMpvPath = writeFixtureExecutable( + fakeMpvBasePath, `#!/usr/bin/env bun const fs = require('node:fs'); const net = require('node:net'); @@ -113,8 +126,8 @@ process.on('SIGTERM', closeAndExit); `, ); - writeExecutable( - fakeAppPath, + const fakeAppPath = writeFixtureExecutable( + fakeAppBasePath, `#!/usr/bin/env bun const fs = require('node:fs'); @@ -157,14 +170,21 @@ process.exit(0); } function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { ...process.env, HOME: smokeCase.homeDir, XDG_CONFIG_HOME: smokeCase.xdgConfigHome, SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath, SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath, - PATH: `${smokeCase.binDir}${path.delimiter}${process.env.PATH || ''}`, }; + const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; + env[pathKey] = `${smokeCase.binDir}${path.delimiter}${env[pathKey] || ''}`; + for (const key of Object.keys(env)) { + if (key !== pathKey && key.toLowerCase() === 'path') { + delete env[key]; + } + } + return env; } function runLauncher( diff --git a/launcher/types.ts b/launcher/types.ts index 28741aaf..400030da 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -60,6 +60,7 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [ '--sub-file-paths=.;subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', diff --git a/plugin/subminer/environment.lua b/plugin/subminer/environment.lua index 3b6b338d..f71c52cd 100644 --- a/plugin/subminer/environment.lua +++ b/plugin/subminer/environment.lua @@ -13,6 +13,16 @@ function M.create(ctx) local APP_RUNNING_CACHE_TTL_SECONDS = 2 local function is_windows() + local platform = mp.get_property("platform") or "" + if platform ~= "" then + local normalized = platform:lower() + if normalized == "windows" or normalized == "win32" then + return true + end + if normalized == "macos" or normalized == "darwin" or normalized == "osx" or normalized == "linux" then + return false + end + end return package.config:sub(1, 1) == "\\" end diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index d7fa2c5a..242899f6 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -33,6 +33,30 @@ local MODIFIER_MAP = { meta = "Meta", } +local SHIFTED_KEY_NAME_MAP = { + Digit1 = "!", + Digit2 = "@", + Digit3 = "SHARP", + Digit4 = "$", + Digit5 = "%", + Digit6 = "^", + Digit7 = "&", + Digit8 = "*", + Digit9 = "(", + Digit0 = ")", + Minus = "_", + Equal = "+", + BracketLeft = "{", + BracketRight = "}", + Backslash = "|", + Semicolon = ":", + Quote = '"', + Comma = "<", + Period = ">", + Slash = "?", + Backquote = "~", +} + function M.create(ctx) local mp = ctx.mp local utils = ctx.utils @@ -84,7 +108,22 @@ function M.create(ctx) return nil end - local function key_spec_to_mpv_binding(key) + local function contains_value(values, target) + for _, value in ipairs(values) do + if value == target then + return true + end + end + return false + end + + local function append_unique(values, value) + if not contains_value(values, value) then + values[#values + 1] = value + end + end + + local function key_spec_to_mpv_bindings(key) if type(key) ~= "table" then return nil end @@ -123,7 +162,24 @@ function M.create(ctx) end end parts[#parts + 1] = key_name - return table.concat(parts, "+") + local bindings = { table.concat(parts, "+") } + + local shifted_key_name = SHIFTED_KEY_NAME_MAP[key.code] + if has_shift and shifted_key_name then + local shifted_parts = {} + for _, modifier in ipairs(key.modifiers) do + if modifier ~= "shift" then + local mapped = MODIFIER_MAP[modifier] + if mapped then + shifted_parts[#shifted_parts + 1] = mapped + end + end + end + shifted_parts[#shifted_parts + 1] = shifted_key_name + append_unique(bindings, table.concat(shifted_parts, "+")) + end + + return bindings end local function build_cli_args(action_id, payload) @@ -294,13 +350,20 @@ function M.create(ctx) local generation = state.session_binding_generation for index, binding in ipairs(artifact.bindings) do - local key_name = key_spec_to_mpv_binding(binding.key) - if key_name then - local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index) - next_binding_names[#next_binding_names + 1] = name - mp.add_forced_key_binding(key_name, name, function() - handle_binding(binding) - end) + local key_names = key_spec_to_mpv_bindings(binding.key) + if key_names then + for key_index, key_name in ipairs(key_names) do + local name = "subminer-session-binding-" + .. tostring(generation) + .. "-" + .. tostring(index) + .. "-" + .. tostring(key_index) + next_binding_names[#next_binding_names + 1] = name + mp.add_forced_key_binding(key_name, name, function() + handle_binding(binding) + end) + end else subminer_log( "warn", diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 88b81e21..c48ea689 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -322,7 +322,9 @@ end local expected_cli_bindings = { { keys = "Shift+]", flag = "--shift-sub-delay-next-line" }, + { keys = "}", flag = "--shift-sub-delay-next-line" }, { keys = "Shift+[", flag = "--shift-sub-delay-prev-line" }, + { keys = "{", flag = "--shift-sub-delay-prev-line" }, { keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" }, { keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" }, { keys = "Ctrl+H", flag = "--replay-current-subtitle" }, diff --git a/src/config/config.test.ts b/src/config/config.test.ts index bb4fa7c0..35fef738 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -152,7 +152,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.updates.checkIntervalHours, 24); assert.equal(config.updates.notificationType, 'system'); assert.equal(config.updates.channel, 'stable'); - assert.equal(config.mpv.socketPath, '/tmp/subminer-socket'); + assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath); assert.equal(config.mpv.backend, 'auto'); assert.equal(config.mpv.profile, ''); assert.equal(config.mpv.autoStartSubMiner, true); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 61da4011..3b485e12 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -80,7 +80,11 @@ export { handleOverlayWindowBeforeInputEvent, isTabInputForMpvForwarding, } from './overlay-window-input'; -export { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init'; +export { + initializeOverlayAnkiIntegration, + initializeOverlayRuntime, + startOverlayWindowTracker, +} from './overlay-runtime-init'; export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index 76172c2b..56d5d6c0 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -1,6 +1,65 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init'; +import { + initializeOverlayAnkiIntegration, + initializeOverlayRuntime, + startOverlayWindowTracker, +} from './overlay-runtime-init'; + +test('startOverlayWindowTracker starts tracker for the current mpv socket', () => { + const calls: string[] = []; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + isTargetWindowMinimized: () => false, + start: () => { + calls.push('start'); + }, + }; + + const result = startOverlayWindowTracker({ + backendOverride: 'windows', + getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket', + createWindowTracker: (override, socketPath) => { + calls.push(`create:${override}:${socketPath}`); + return tracker as never; + }, + setWindowTracker: (nextTracker) => { + calls.push(nextTracker === tracker ? 'set-tracker' : 'clear-tracker'); + }, + updateVisibleOverlayBounds: () => { + calls.push('bounds'); + }, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => { + calls.push('visibility'); + }, + refreshCurrentSubtitle: () => { + calls.push('refresh-subtitle'); + }, + getOverlayWindows: () => [], + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + }); + + assert.equal(result, tracker); + tracker.onWindowFound?.({ x: 10, y: 20, width: 300, height: 200 }); + tracker.onWindowFocusChange?.(true); + + assert.deepEqual(calls, [ + 'create:windows:\\\\.\\pipe\\subminer-socket', + 'set-tracker', + 'start', + 'bounds', + 'visibility', + 'refresh-subtitle', + 'visibility', + 'sync-shortcuts', + ]); +}); test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => { let createdIntegrations = 0; diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index e56afb9e..9648d794 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -25,6 +25,24 @@ type CreateAnkiIntegrationArgs = { knownWordCacheStatePath: string; }; +export type OverlayWindowTrackerOptions = { + backendOverride: string | null; + getMpvSocketPath: () => string; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + isVisibleOverlayVisible: () => boolean; + updateVisibleOverlayVisibility: () => void; + refreshCurrentSubtitle?: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + setWindowTracker: (tracker: BaseWindowTracker | null) => void; + createWindowTracker?: ( + override?: string | null, + targetMpvSocketPath?: string | null, + ) => BaseWindowTracker | null; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; +}; + function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiIntegrationLike { const { AnkiIntegration } = require('../../anki-integration') as typeof import('../../anki-integration'); @@ -46,82 +64,80 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte ); } -export function initializeOverlayRuntime(options: { - getMpvSocketPath: () => string; - getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig }; - getSubtitleTimingTracker: () => unknown | null; - getMpvClient: () => { - send?: (payload: { command: string[] }) => void; - } | null; - getRuntimeOptionsManager: () => { - getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; - } | null; - getAnkiIntegration?: () => unknown | null; - setAnkiIntegration: (integration: unknown | null) => void; - showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; - createFieldGroupingCallback: () => ( - data: KikuFieldGroupingRequestData, - ) => Promise; - getKnownWordCacheStatePath: () => string; - shouldStartAnkiIntegration?: () => boolean; - createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; - backendOverride: string | null; - createMainWindow: () => void; - registerGlobalShortcuts: () => void; - updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; - isVisibleOverlayVisible: () => boolean; - updateVisibleOverlayVisibility: () => void; - refreshCurrentSubtitle?: () => void; - getOverlayWindows: () => BrowserWindow[]; - syncOverlayShortcuts: () => void; - setWindowTracker: (tracker: BaseWindowTracker | null) => void; - createWindowTracker?: ( - override?: string | null, - targetMpvSocketPath?: string | null, - ) => BaseWindowTracker | null; - bindOverlayOwner?: () => void; - releaseOverlayOwner?: () => void; -}): void { - options.createMainWindow(); - options.registerGlobalShortcuts(); - +export function startOverlayWindowTracker( + options: OverlayWindowTrackerOptions, +): BaseWindowTracker | null { const createWindowTrackerHandler = options.createWindowTracker ?? createWindowTracker; const windowTracker = createWindowTrackerHandler( options.backendOverride, options.getMpvSocketPath(), ); options.setWindowTracker(windowTracker); - if (windowTracker) { - windowTracker.onGeometryChange = (geometry: WindowGeometry) => { - options.updateVisibleOverlayBounds(geometry); - }; - windowTracker.onWindowFound = (geometry: WindowGeometry) => { - options.updateVisibleOverlayBounds(geometry); - options.bindOverlayOwner?.(); - if (options.isVisibleOverlayVisible()) { - options.updateVisibleOverlayVisibility(); - options.refreshCurrentSubtitle?.(); - } - }; - windowTracker.onWindowLost = () => { - options.releaseOverlayOwner?.(); - if (windowTracker.isTargetWindowMinimized()) { - for (const window of options.getOverlayWindows()) { - window.hide(); - } - options.syncOverlayShortcuts(); - return; - } + if (!windowTracker) { + return null; + } + + windowTracker.onGeometryChange = (geometry: WindowGeometry) => { + options.updateVisibleOverlayBounds(geometry); + }; + windowTracker.onWindowFound = (geometry: WindowGeometry) => { + options.updateVisibleOverlayBounds(geometry); + options.bindOverlayOwner?.(); + if (options.isVisibleOverlayVisible()) { options.updateVisibleOverlayVisibility(); - }; - windowTracker.onWindowFocusChange = () => { - if (options.isVisibleOverlayVisible()) { - options.updateVisibleOverlayVisibility(); + options.refreshCurrentSubtitle?.(); + } + }; + windowTracker.onWindowLost = () => { + options.releaseOverlayOwner?.(); + if (windowTracker.isTargetWindowMinimized()) { + for (const window of options.getOverlayWindows()) { + window.hide(); } options.syncOverlayShortcuts(); - }; - windowTracker.start(); - } + return; + } + options.updateVisibleOverlayVisibility(); + }; + windowTracker.onWindowFocusChange = () => { + if (options.isVisibleOverlayVisible()) { + options.updateVisibleOverlayVisibility(); + } + options.syncOverlayShortcuts(); + }; + windowTracker.start(); + return windowTracker; +} + +export function initializeOverlayRuntime( + options: OverlayWindowTrackerOptions & { + getMpvSocketPath: () => string; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig }; + getSubtitleTimingTracker: () => unknown | null; + getMpvClient: () => { + send?: (payload: { command: string[] }) => void; + } | null; + getRuntimeOptionsManager: () => { + getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; + } | null; + getAnkiIntegration?: () => unknown | null; + setAnkiIntegration: (integration: unknown | null) => void; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getKnownWordCacheStatePath: () => string; + shouldStartAnkiIntegration?: () => boolean; + createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; + backendOverride: string | null; + createMainWindow: () => void; + registerGlobalShortcuts: () => void; + }, +): void { + options.createMainWindow(); + options.registerGlobalShortcuts(); + + startOverlayWindowTracker(options); initializeOverlayAnkiIntegration(options); diff --git a/src/main-entry-launch-config.ts b/src/main-entry-launch-config.ts new file mode 100644 index 00000000..3a7a66db --- /dev/null +++ b/src/main-entry-launch-config.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { loadRawConfigStrict } from './config/load'; +import { resolveConfig } from './config/resolve'; +import type { MpvLaunchMode, ResolvedConfig } from './types/config'; +import type { SubminerPluginRuntimeScriptOptConfig } from './shared/subminer-plugin-script-opts'; + +export interface ConfiguredWindowsMpvLaunch { + executablePath: string; + launchMode: MpvLaunchMode; + pluginRuntimeConfig: SubminerPluginRuntimeScriptOptConfig; +} + +export function buildWindowsMpvPluginRuntimeConfig( + config: Pick, +): SubminerPluginRuntimeScriptOptConfig { + return { + socketPath: config.mpv.socketPath, + binaryPath: config.mpv.subminerBinaryPath, + backend: config.mpv.backend, + autoStart: config.mpv.autoStartSubMiner, + autoStartVisibleOverlay: config.auto_start_overlay, + autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady, + texthookerEnabled: config.texthooker.launchAtStartup, + aniskipEnabled: config.mpv.aniskipEnabled, + aniskipButtonKey: config.mpv.aniskipButtonKey, + }; +} + +export function readConfiguredWindowsMpvLaunch(configDir: string): ConfiguredWindowsMpvLaunch { + const loadResult = loadRawConfigStrict({ + configDir, + configFileJsonc: path.join(configDir, 'config.jsonc'), + configFileJson: path.join(configDir, 'config.json'), + }); + const rawConfig = loadResult.ok ? loadResult.config : {}; + const { resolved } = resolveConfig(rawConfig); + + return { + executablePath: resolved.mpv.executablePath, + launchMode: resolved.mpv.launchMode, + pluginRuntimeConfig: buildWindowsMpvPluginRuntimeConfig(resolved), + }; +} diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 760de82f..e2c51939 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -1,5 +1,10 @@ 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'; +import { DEFAULT_CONFIG } from './config/definitions'; +import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { configureEarlyAppPaths, normalizeLaunchMpvExtraArgs, @@ -146,7 +151,7 @@ test('applyEarlyLinuxCommandLineSwitches appends password store before main star ]); }); -test('transported AppImage visibility commands should forward through app control', () => { +test('transported AppImage visibility commands forward through app control', () => { assert.equal( shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], { SUBMINER_APP_ARGC: '1', @@ -156,9 +161,35 @@ test('transported AppImage visibility commands should forward through app contro ); }); -test('app control forwarding is only for transported runtime commands', () => { +test('direct runtime commands forward through app control', () => { assert.equal( shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}), + true, + ); + assert.equal( + shouldForwardStartupArgvViaAppControl( + ['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer-socket'], + {}, + ), + true, + ); + assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--settings'], {}), true); + assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stop'], {}), true); +}); + +test('entry-only and internal commands do not forward through app control', () => { + assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false); + assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}), + false, + ); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stats-daemon-start'], {}), + false, + ); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stats', '--stats-background'], {}), false, ); assert.equal( @@ -175,6 +206,12 @@ test('app control forwarding is only for transported runtime commands', () => { }), false, ); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--start'], { + ELECTRON_RUN_AS_NODE: '1', + }), + false, + ); }); test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => { @@ -269,6 +306,73 @@ test('launch-mpv entry helpers detect and normalize targets', () => { ); }); +test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script opts', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-config-')); + try { + const launch = readConfiguredWindowsMpvLaunch(tempDir); + + assert.equal(launch.executablePath, DEFAULT_CONFIG.mpv.executablePath); + assert.equal(launch.launchMode, DEFAULT_CONFIG.mpv.launchMode); + assert.deepEqual(launch.pluginRuntimeConfig, { + socketPath: DEFAULT_CONFIG.mpv.socketPath, + binaryPath: DEFAULT_CONFIG.mpv.subminerBinaryPath, + backend: DEFAULT_CONFIG.mpv.backend, + autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner, + autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay, + autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady, + texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup, + aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled, + aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script opts', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-config-')); + try { + fs.writeFileSync( + path.join(tempDir, 'config.jsonc'), + JSON.stringify({ + auto_start_overlay: false, + texthooker: { + launchAtStartup: true, + }, + mpv: { + executablePath: ' C:\\tools\\mpv.exe ', + launchMode: 'maximized', + socketPath: '\\\\.\\pipe\\custom-subminer-socket', + backend: 'windows', + autoStartSubMiner: false, + pauseUntilOverlayReady: false, + subminerBinaryPath: 'C:\\SubMiner\\Custom.exe', + aniskipEnabled: false, + aniskipButtonKey: 'F8', + }, + }), + ); + + const launch = readConfiguredWindowsMpvLaunch(tempDir); + + assert.equal(launch.executablePath, 'C:\\tools\\mpv.exe'); + assert.equal(launch.launchMode, 'maximized'); + assert.deepEqual(launch.pluginRuntimeConfig, { + socketPath: '\\\\.\\pipe\\custom-subminer-socket', + binaryPath: 'C:\\SubMiner\\Custom.exe', + backend: 'windows', + autoStart: false, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: false, + texthookerEnabled: true, + aniskipEnabled: false, + aniskipButtonKey: 'F8', + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('stats-daemon entry helper detects internal daemon commands', () => { assert.equal( shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {}), diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index de4e2447..56264230 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -154,10 +154,9 @@ export function shouldForwardStartupArgvViaAppControl( env: NodeJS.ProcessEnv, ): boolean { if (env.ELECTRON_RUN_AS_NODE === '1') return false; - if (!hasTransportedStartupArgs(env)) return false; const args = parseCliArgs(argv); - if (args.help || args.appPing || args.launchMpv) return false; + if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false; if (resolveStatsDaemonCommandAction(argv) !== null) return false; return hasExplicitCommand(args); diff --git a/src/main-entry.ts b/src/main-entry.ts index 76d1f28b..cead3f46 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -1,9 +1,7 @@ -import path from 'node:path'; import os from 'node:os'; import { spawn } from 'node:child_process'; import { app, dialog, shell } from 'electron'; import { printHelp } from './cli/help'; -import { loadRawConfigStrict } from './config/load'; import { configureEarlyAppPaths, normalizeLaunchMpvExtraArgs, @@ -22,6 +20,7 @@ import { shouldHandleStatsDaemonCommandAtEntry, } from './main-entry-runtime'; import { requestSingleInstanceLockEarly } from './main/early-single-instance'; +import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { sendAppControlCommand } from './shared/app-control-client'; import { detectInstalledFirstRunPluginCandidates, @@ -30,7 +29,6 @@ import { resolvePackagedRuntimePluginPath, } from './main/runtime/first-run-setup-plugin'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; -import { parseMpvLaunchMode } from './shared/mpv-launch-mode'; import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error'; @@ -150,31 +148,6 @@ function createWindowsRuntimePluginPolicy() { }; } -function readConfiguredWindowsMpvLaunch(configDir: string): { - executablePath: string; - launchMode: 'normal' | 'maximized' | 'fullscreen'; -} { - const loadResult = loadRawConfigStrict({ - configDir, - configFileJsonc: path.join(configDir, 'config.jsonc'), - configFileJson: path.join(configDir, 'config.json'), - }); - if (!loadResult.ok) { - return { - executablePath: '', - launchMode: 'normal', - }; - } - - return { - executablePath: - typeof loadResult.config.mpv?.executablePath === 'string' - ? loadResult.config.mpv.executablePath.trim() - : '', - launchMode: parseMpvLaunchMode(loadResult.config.mpv?.launchMode) ?? 'normal', - }; -} - process.argv = normalizeStartupArgv(process.argv, process.env); applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv); applySanitizedEnv(sanitizeStartupEnv(process.env)); @@ -226,31 +199,22 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise { return false; } -if (shouldDetachBackgroundLaunch(process.argv, process.env)) { - const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); - const child = spawn(process.execPath, childArgs, { - detached: true, - stdio: 'ignore', - env: sanitizeBackgroundEnv(process.env), - }); - child.unref(); - process.exit(0); -} - -if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) { - const sanitizedEnv = sanitizeHelpEnv(process.env); - process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS; - if (!sanitizedEnv.VK_INSTANCE_LAYERS) { - delete process.env.VK_INSTANCE_LAYERS; +async function runEntryProcess(): Promise { + if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) { + const sanitizedEnv = sanitizeHelpEnv(process.env); + process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS; + if (!sanitizedEnv.VK_INSTANCE_LAYERS) { + delete process.env.VK_INSTANCE_LAYERS; + } + printHelp(DEFAULT_TEXTHOOKER_PORT); + process.exit(0); + return; } - printHelp(DEFAULT_TEXTHOOKER_PORT); - process.exit(0); -} -if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { - const sanitizedEnv = sanitizeLaunchMpvEnv(process.env); - applySanitizedEnv(sanitizedEnv); - void app.whenReady().then(async () => { + if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { + const sanitizedEnv = sanitizeLaunchMpvEnv(process.env); + applySanitizedEnv(sanitizedEnv); + await app.whenReady(); const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath); const result = await launchWindowsMpv( normalizeLaunchMpvTargets(process.argv), @@ -266,23 +230,39 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), + configuredMpvLaunch.pluginRuntimeConfig, ); app.exit(result.ok ? 0 : 1); - }); -} else if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) { - void app.whenReady().then(async () => { + return; + } + + if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) { + await app.whenReady(); const exitCode = await runStatsDaemonControlFromProcess(app.getPath('userData')); app.exit(exitCode); - }); -} else { - void forwardStartupArgvViaAppControlIfAvailable() - .then((forwarded) => { - if (!forwarded) { - startMainProcess(); - } - }) - .catch((error) => { - console.error('SubMiner app-control handoff failed:', error); - startMainProcess(); + return; + } + + if (await forwardStartupArgvViaAppControlIfAvailable()) { + return; + } + + if (shouldDetachBackgroundLaunch(process.argv, process.env)) { + const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); + const child = spawn(process.execPath, childArgs, { + detached: true, + stdio: 'ignore', + env: sanitizeBackgroundEnv(process.env), }); + child.unref(); + process.exit(0); + return; + } + + startMainProcess(); } + +void runEntryProcess().catch((error) => { + console.error('SubMiner app-control handoff failed:', error); + startMainProcess(); +}); diff --git a/src/main.ts b/src/main.ts index 416bbf54..f484970f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -347,6 +347,7 @@ import { syncOverlayWindowLayer, setVisibleOverlayVisible as setVisibleOverlayVisibleCore, showMpvOsdRuntime, + startOverlayWindowTracker as startOverlayWindowTrackerCore, tokenizeSubtitle as tokenizeSubtitleCore, triggerFieldGrouping as triggerFieldGroupingCore, upsertYomitanDictionarySettings, @@ -2460,6 +2461,7 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) { return trackedHandle; } + return null; } return findWindowsMpvTargetWindowHandle(); } catch { @@ -2467,6 +2469,104 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu } } +function createOverlayWindowTracker(override?: string | null, targetMpvSocketPath?: string | null) { + if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { + return null; + } + return createWindowTrackerCore(override, targetMpvSocketPath); +} + +function bindVisibleOverlayOwner(): void { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + const targetSocketPath = appState.mpvSocketPath; + const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath); + if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) { + return; + } + if (targetSocketPath) { + return; + } + const tracker = appState.windowTracker; + const mpvResult = tracker + ? (() => { + try { + const win32 = + require('./window-trackers/win32') as typeof import('./window-trackers/win32'); + const poll = win32.findMpvWindows(); + const focused = poll.matches.find((m) => m.isForeground); + return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null; + } catch { + return null; + } + })() + : null; + if (!mpvResult) return; + if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) { + logger.warn('Failed to set overlay owner via koffi'); + } +} + +function releaseVisibleOverlayOwner(): void { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (!clearWindowsOverlayOwner(overlayHwnd)) { + logger.warn('Failed to clear overlay owner via koffi'); + } +} + +function startOverlayWindowTrackerForCurrentSocket(): void { + startOverlayWindowTrackerCore({ + backendOverride: appState.backendOverride, + getMpvSocketPath: () => appState.mpvSocketPath, + createWindowTracker: createOverlayWindowTracker, + setWindowTracker: (tracker) => { + appState.windowTracker = tracker; + }, + updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, + getOverlayWindows: () => getOverlayWindows(), + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + bindOverlayOwner: () => bindVisibleOverlayOwner(), + releaseOverlayOwner: () => releaseVisibleOverlayOwner(), + }); +} + +function retargetOverlayWindowTrackerForMpvSocket( + nextSocketPath: string, + previousSocketPath: string, +): void { + if (nextSocketPath === previousSocketPath || !appState.overlayRuntimeInitialized) { + return; + } + + const previousTracker = appState.windowTracker; + if (previousTracker) { + try { + previousTracker.stop(); + } catch (error) { + logger.warn('Failed to stop previous overlay window tracker before retargeting', error); + } + } + + releaseVisibleOverlayOwner(); + appState.windowTracker = null; + appState.trackerNotReadyWarningShown = false; + lastOverlayWindowGeometry = null; + startOverlayWindowTrackerForCurrentSocket(); + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + overlayShortcutsRuntime.syncOverlayShortcuts(); + logger.info( + `Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`, + ); +} + async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { if (process.platform !== 'win32') { return false; @@ -5925,6 +6025,8 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ cliCommandContextMainDeps: { appState, setLogLevel: (level) => setLogLevel(level, 'cli'), + onMpvSocketPathChanged: (nextSocketPath, previousSocketPath) => + retargetOverlayWindowTrackerForMpvSocket(nextSocketPath, previousSocketPath), texthookerService, getResolvedConfig: () => getResolvedConfig(), defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, @@ -6213,7 +6315,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()), showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), - openFirstRunSetupWindow: () => openFirstRunSetupWindow(), + openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), showWindowsMpvLauncherSetup: () => process.platform === 'win32', openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => openConfigSettingsWindow(), @@ -6323,52 +6425,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = } registerGlobalShortcuts(); }, - createWindowTracker: (override, targetMpvSocketPath) => { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { - return null; - } - return createWindowTrackerCore(override, targetMpvSocketPath); - }, + createWindowTracker: (override, targetMpvSocketPath) => + createOverlayWindowTracker(override, targetMpvSocketPath), updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), - bindOverlayOwner: () => { - const mainWindow = overlayManager.getMainWindow(); - if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; - const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); - const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath); - if ( - targetWindowHwnd !== null && - bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd) - ) { - return; - } - const tracker = appState.windowTracker; - const mpvResult = tracker - ? (() => { - try { - const win32 = - require('./window-trackers/win32') as typeof import('./window-trackers/win32'); - const poll = win32.findMpvWindows(); - const focused = poll.matches.find((m) => m.isForeground); - return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null; - } catch { - return null; - } - })() - : null; - if (!mpvResult) return; - if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) { - logger.warn('Failed to set overlay owner via koffi'); - } - }, - releaseOverlayOwner: () => { - const mainWindow = overlayManager.getMainWindow(); - if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; - const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); - if (!clearWindowsOverlayOwner(overlayHwnd)) { - logger.warn('Failed to clear overlay owner via koffi'); - } - }, + bindOverlayOwner: () => bindVisibleOverlayOwner(), + releaseOverlayOwner: () => releaseVisibleOverlayOwner(), getOverlayWindows: () => getOverlayWindows(), getResolvedConfig: () => getResolvedConfig(), showDesktopNotification, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 26c3a56e..1c55d405 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -13,6 +13,7 @@ test('cli command context main deps builder maps state and callbacks', async () const build = createBuildCliCommandContextMainDepsHandler({ appState, + onMpvSocketPathChanged: (next, previous) => calls.push(`socket:${previous}->${next}`), texthookerService: { isRunning: () => false, start: () => null }, getResolvedConfig: () => ({ texthooker: { openBrowser: true }, @@ -121,6 +122,10 @@ test('cli command context main deps builder maps state and callbacks', async () assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); deps.setSocketPath('/tmp/next.sock'); assert.equal(appState.mpvSocketPath, '/tmp/next.sock'); + assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']); + deps.setSocketPath('/tmp/next.sock'); + assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']); + calls.length = 0; assert.equal(deps.getTexthookerPort(), 5174); deps.setTexthookerPort(5175); assert.equal(appState.texthookerPort, 5175); diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 9835ea4c..d50bf87f 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -12,6 +12,7 @@ type CliCommandContextMainState = { export function createBuildCliCommandContextMainDepsHandler(deps: { appState: CliCommandContextMainState; setLogLevel?: (level: NonNullable) => void; + onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void; texthookerService: CliCommandContextFactoryDeps['texthookerService']; getResolvedConfig: () => { texthooker?: { openBrowser?: boolean }; @@ -74,7 +75,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { setLogLevel: deps.setLogLevel, getSocketPath: () => deps.appState.mpvSocketPath, setSocketPath: (socketPath: string) => { + const previousSocketPath = deps.appState.mpvSocketPath; deps.appState.mpvSocketPath = socketPath; + if (socketPath !== previousSocketPath) { + deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath); + } }, getMpvClient: () => deps.appState.mpvClient, showOsd: (text: string) => deps.showMpvOsd(text), diff --git a/src/main/runtime/command-line-launcher-deps.ts b/src/main/runtime/command-line-launcher-deps.ts index f859e265..69b654af 100644 --- a/src/main/runtime/command-line-launcher-deps.ts +++ b/src/main/runtime/command-line-launcher-deps.ts @@ -139,13 +139,38 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri return detail ? `${fallback}: ${detail}` : fallback; } +function needsWindowsShell(command: string): boolean { + return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command); +} + +function quoteForWindowsShell(value: string): string { + return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`; +} + function createDefaultRunCommand(): RunCommand { return (command, args, options = {}) => new Promise((resolve) => { - const child = spawn(command, args, { - env: options.env ?? process.env, - windowsHide: false, - }); + const useShell = needsWindowsShell(command); + let child: ReturnType; + try { + child = useShell + ? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), { + env: options.env ?? process.env, + windowsHide: false, + shell: true, + }) + : spawn(command, args, { + env: options.env ?? process.env, + windowsHide: false, + }); + } catch (error) { + resolve({ + exitCode: 1, + stdout: '', + stderr: error instanceof Error ? error.message : String(error), + }); + return; + } let stdout = ''; let stderr = ''; const timeout = setTimeout(() => { diff --git a/src/main/runtime/command-line-launcher.test.ts b/src/main/runtime/command-line-launcher.test.ts index dbfd0294..68e516b4 100644 --- a/src/main/runtime/command-line-launcher.test.ts +++ b/src/main/runtime/command-line-launcher.test.ts @@ -1,5 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { detectBun, @@ -9,6 +11,7 @@ import { resolveLauncherInstallTarget, type BunSnapshot, } from './command-line-launcher'; +import { getRunCommand } from './command-line-launcher-deps'; function createBunSnapshot(status: BunSnapshot['status']): BunSnapshot { return { @@ -85,6 +88,48 @@ test('resolveBunInstallCommand prefers winget on Windows', () => { ); }); +test('default runCommand preserves Windows cmd metacharacter args', async (t) => { + if (process.platform !== 'win32') return; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-')); + const scriptPath = path.join(tempDir, 'argv.cmd'); + const outputPath = path.join(tempDir, 'argv.txt'); + t.after(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + fs.writeFileSync( + scriptPath, + [ + '@echo off', + 'setlocal DisableDelayedExpansion', + '> "%SUBMINER_ARGV_OUT%" (', + ' echo 1=%~1', + ' echo 2=%~2', + ' echo 3=%~3', + ' echo 4=%~4', + ' echo 5=%~5', + ' echo 6=%~6', + ')', + '', + ].join('\r\n'), + 'utf8', + ); + + const result = await getRunCommand({})( + scriptPath, + ['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'], + { + env: { ...process.env, SUBMINER_ARGV_OUT: outputPath }, + }, + ); + + assert.equal(result.exitCode, 0, result.stderr); + assert.equal( + fs.readFileSync(outputPath, 'utf8'), + ['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'), + ); +}); + test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => { assert.deepEqual( resolveBunInstallCommand({ diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index e36d0113..6933d16d 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -66,7 +66,8 @@ test('build tray template handler wires actions and init guards', () => { openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => true, - openFirstRunSetupWindow: () => calls.push('setup'), + openFirstRunSetupWindow: (force?: boolean) => + calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), @@ -91,7 +92,7 @@ test('build tray template handler wires actions and init guards', () => { 'texthooker', 'show-texthooker:true', 'setup', - 'setup', + 'setup-forced', 'yomitan', 'configuration', 'jellyfin', @@ -102,6 +103,42 @@ test('build tray template handler wires actions and init guards', () => { ]); }); +test('windows mpv launcher tray action force-opens completed setup', () => { + const calls: string[] = []; + const buildTemplate = createBuildTrayMenuTemplateHandler({ + buildTrayMenuTemplateRuntime: (handlers) => { + assert.equal(handlers.showFirstRunSetup, false); + assert.equal(handlers.showWindowsMpvLauncherSetup, true); + handlers.openWindowsMpvLauncherSetup(); + return [{ label: 'ok' }] as never; + }, + initializeOverlayRuntime: () => calls.push('init'), + isOverlayRuntimeInitialized: () => true, + openSessionHelpModal: () => calls.push('help'), + openTexthookerInBrowser: () => calls.push('texthooker'), + showTexthookerPage: () => true, + showFirstRunSetup: () => false, + openFirstRunSetupWindow: (force?: boolean) => + calls.push(force ? 'setup-forced' : 'setup'), + showWindowsMpvLauncherSetup: () => true, + openYomitanSettings: () => calls.push('yomitan'), + openConfigSettingsWindow: () => calls.push('configuration'), + openJellyfinSetupWindow: () => calls.push('jellyfin'), + isJellyfinConfigured: () => false, + isJellyfinDiscoveryActive: () => false, + toggleJellyfinDiscovery: () => { + calls.push('jellyfin-discovery'); + }, + platform: 'win32', + openAnilistSetupWindow: () => calls.push('anilist'), + checkForUpdates: () => calls.push('updates'), + quitApp: () => calls.push('quit'), + }); + + assert.deepEqual(buildTemplate(), [{ label: 'ok' }]); + assert.deepEqual(calls, ['setup-forced']); +}); + test('texthooker tray visibility follows websocket server enabled state', () => { assert.equal( shouldShowTexthookerTrayEntry({ diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index e60d4db3..5f45a88f 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -61,7 +61,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { openTexthookerInBrowser: () => void; showTexthookerPage: () => boolean; showFirstRunSetup: () => boolean; - openFirstRunSetupWindow: () => void; + openFirstRunSetupWindow: (force?: boolean) => void; showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; @@ -92,7 +92,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { }, showFirstRunSetup: deps.showFirstRunSetup(), openWindowsMpvLauncherSetup: () => { - deps.openFirstRunSetupWindow(); + deps.openFirstRunSetupWindow(true); }, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(), openYomitanSettings: () => { diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 8188018f..f8273d34 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -28,7 +28,8 @@ test('tray main deps builders return mapped handlers', () => { openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => true, - openFirstRunSetupWindow: () => calls.push('setup'), + openFirstRunSetupWindow: (force?: boolean) => + calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index f318843b..4d4c43c8 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -51,7 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openTexthookerInBrowser: () => void; showTexthookerPage: () => boolean; showFirstRunSetup: () => boolean; - openFirstRunSetupWindow: () => void; + openFirstRunSetupWindow: (force?: boolean) => void; showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 74182f6c..42099088 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -57,6 +57,7 @@ test('tray menu template contains expected entries and handlers', () => { false, ); assert.equal(template[0]!.label, 'Open Help'); + assert.equal(template[3]!.label, 'Open SubMiner Setup'); const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery'); assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.checked, false); @@ -102,7 +103,7 @@ test('tray menu template omits first-run setup entry when setup is complete', () .filter(Boolean); assert.equal(labels.includes('Complete Setup'), false); - assert.equal(labels.includes('Manage Windows mpv launcher'), false); + assert.equal(labels.includes('Open SubMiner Setup'), false); assert.equal(labels.includes('Jellyfin Discovery'), false); }); diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index ba71c5a0..0ed98056 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -89,7 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): ...(handlers.showWindowsMpvLauncherSetup ? [ { - label: 'Manage Windows mpv launcher', + label: 'Open SubMiner Setup', click: handlers.openWindowsMpvLauncherSetup, }, ] diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index 49c70470..c2998411 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -72,6 +72,7 @@ test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket', 'C:\\a.mkv', @@ -100,6 +101,7 @@ test('buildWindowsMpvLaunchArgs inserts maximized launch mode before explicit ex '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket', '--window-maximized=yes', @@ -129,6 +131,7 @@ test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () = '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket', ], @@ -154,6 +157,7 @@ test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script op '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket', '--input-ipc-server', @@ -182,6 +186,7 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', '--script-opts=subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket', '--input-ipc-server', @@ -223,6 +228,31 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => { assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/); }); +test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly overridden', () => { + const args = buildWindowsMpvLaunchArgs( + ['C:\\video.mkv'], + [], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', + 'normal', + { + socketPath: 'C:\\Users\\tester\\AppData\\Local\\Temp\\subminer-smoke-sock\\subminer.sock', + binaryPath: '', + backend: 'windows', + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'F7', + }, + ); + + assert.ok(args.includes('--input-ipc-server=\\\\.\\pipe\\subminer-socket')); + const scriptOpts = args.find((arg) => arg.startsWith('--script-opts=')); + assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\subminer-socket/); +}); + test('launchWindowsMpv reports missing mpv path', async () => { const errors: string[] = []; const result = await launchWindowsMpv( @@ -258,7 +288,7 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => { assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe'); assert.deepEqual(calls, [ 'C:\\mpv\\mpv.exe', - '--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv', + '--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--sub-visibility=no|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv', ]); }); diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 42898f31..95552008 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -143,6 +143,7 @@ export function buildWindowsMpvLaunchArgs( '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', + '--sub-visibility=no', '--secondary-sub-visibility=no', ...(scriptOpts ? [scriptOpts] : []), ...buildMpvLaunchModeArgs(launchMode),