From c7fc328194a09fe40196312f612ce6b18d8b804d Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 18 May 2026 01:29:35 -0700 Subject: [PATCH] fix: transport AppImage args via env and gate restart on app-ping - Transport Linux AppImage CLI args through SUBMINER_APP_ARGC/ARG_* env vars instead of argv - Add --app-ping command to probe single-instance lock ownership (exit 0 = running, 1 = not) - Gate manual restart: poll app-ping until old app releases lock, then until new app owns it - Preserve user-paused playback when disarming the auto-play-ready gate on restart - Snapshot subtitles before connection side effects (sub-visibility hide) can suppress them - Reapply overlay bounds after first show for Hyprland compatibility --- changes/fix-determiner-noun-frequency.md | 4 + changes/overlay-restart-visible.md | 2 +- launcher/mpv.test.ts | 30 +++ launcher/mpv.ts | 53 ++++- plugin/subminer/process.lua | 183 +++++++++++++--- plugin/subminer/state.lua | 1 + scripts/test-plugin-start-gate.lua | 200 +++++++++++++++++- src/cli/args.test.ts | 5 + src/cli/args.ts | 6 + src/core/services/app-lifecycle.test.ts | 31 +++ src/core/services/app-lifecycle.ts | 15 ++ src/core/services/mpv.test.ts | 35 +++ src/core/services/mpv.ts | 2 +- src/core/services/overlay-visibility.test.ts | 51 +++++ src/core/services/overlay-visibility.ts | 18 ++ .../services/overlay-window-config.test.ts | 27 +++ src/core/services/overlay-window-options.ts | 3 +- .../tokenizer/annotation-stage.test.ts | 29 +++ .../services/tokenizer/annotation-stage.ts | 37 ++++ src/main-entry-runtime.test.ts | 17 ++ src/main-entry-runtime.ts | 34 +++ src/main-entry.ts | 4 +- src/main.ts | 3 +- src/main/boot/services.test.ts | 7 +- src/main/boot/services.ts | 3 + src/main/early-single-instance.test.ts | 45 +++- src/main/early-single-instance.ts | 41 +++- .../overlay-mpv-sub-visibility.test.ts | 30 +++ .../runtime/overlay-mpv-sub-visibility.ts | 8 +- src/main/runtime/update/fetch-adapter.test.ts | 3 + src/main/runtime/update/fetch-adapter.ts | 12 +- .../runtime/yomitan-extension-runtime.test.ts | 29 +++ src/main/runtime/yomitan-extension-runtime.ts | 21 +- 33 files changed, 923 insertions(+), 66 deletions(-) create mode 100644 changes/fix-determiner-noun-frequency.md diff --git a/changes/fix-determiner-noun-frequency.md b/changes/fix-determiner-noun-frequency.md new file mode 100644 index 00000000..a075c01a --- /dev/null +++ b/changes/fix-determiner-noun-frequency.md @@ -0,0 +1,4 @@ +type: fixed +area: subtitles + +- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners. diff --git a/changes/overlay-restart-visible.md b/changes/overlay-restart-visible.md index c52746b8..e07629b6 100644 --- a/changes/overlay-restart-visible.md +++ b/changes/overlay-restart-visible.md @@ -1,4 +1,4 @@ type: fixed area: overlay -- Kept the visible overlay open after restarting SubMiner from the mpv `y-r` shortcut, including readiness-time restore when visible-overlay auto-start is disabled. +- Kept the visible overlay and subtitle stream alive after restarting SubMiner from the mpv `y-r` shortcut by transporting Linux AppImage control args safely, restoring mpv subtitle visibility during shutdown, snapshotting subtitles before overlay suppression resumes, reapplying Linux overlay bounds after the restarted window maps, allowing Hyprland to resize the visible overlay window, and preserving user-paused playback while readiness gates clear. diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 37db175f..1ee1892d 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -114,6 +114,36 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env' } }); +test('runAppCommandCaptureOutput transports Linux AppImage args through environment', () => { + if (process.platform !== 'linux') return; + const { dir } = createTempSocketPath(); + const appPath = path.join(dir, 'SubMiner.AppImage'); + fs.writeFileSync( + appPath, + [ + '#!/bin/sh', + 'printf "args:%s\\n" "$*"', + 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"', + 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"', + 'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"', + '', + ].join('\n'), + ); + fs.chmodSync(appPath, 0o755); + + try { + const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']); + + assert.equal(result.status, 0); + assert.match(result.stdout, /^args:\n/m); + assert.match(result.stdout, /^argc:2\n/m); + assert.match(result.stdout, /^arg0:--app-ping\n/m); + assert.match(result.stdout, /^arg1:--socket\n/m); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('parseMpvArgString preserves empty quoted tokens', () => { assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [ '--title', diff --git a/launcher/mpv.ts b/launcher/mpv.ts index af4b54f0..b146d59b 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -39,6 +39,7 @@ export const state = { type SpawnTarget = { command: string; args: string[]; + env?: NodeJS.ProcessEnv; }; type PathModule = Pick; @@ -46,6 +47,8 @@ type PathModule = Pick): void { + for (const key of Object.keys(env)) { + if (key === TRANSPORTED_APP_ARGC_ENV || /^SUBMINER_APP_ARG_\d+$/.test(key)) { + delete env[key]; + } + } +} + +function buildTransportedAppArgsEnv(appArgs: string[]): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + [TRANSPORTED_APP_ARGC_ENV]: String(appArgs.length), + }; + appArgs.forEach((arg, index) => { + env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`] = arg; + }); + return env; +} + +function shouldTransportAppArgsForAppImage(appPath: string): boolean { + return process.platform === 'linux' && /\.AppImage$/i.test(appPath); +} + +function buildAppEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + extraEnv: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { const env: Record = { ...baseEnv, SUBMINER_APP_LOG: getAppLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(), }; delete env.ELECTRON_RUN_AS_NODE; + clearTransportedAppArgs(env); + Object.assign(env, extraEnv); const layers = env.VK_INSTANCE_LAYERS; if (typeof layers === 'string' && layers.trim().length > 0) { const filtered = layers @@ -1274,7 +1304,7 @@ function runSyncAppCommand( } { const target = resolveAppSpawnTarget(appPath, appArgs); const result = spawnSync(target.command, target.args, { - env: buildAppEnv(), + env: buildAppEnv(process.env, target.env), encoding: 'utf8', }); if (result.stdout) { @@ -1307,6 +1337,13 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean { } function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget { + if (shouldTransportAppArgsForAppImage(appPath)) { + return { + command: appPath, + args: [], + env: buildTransportedAppArgsEnv(appArgs), + }; + } if (process.platform !== 'win32') { return { command: appPath, args: appArgs }; } @@ -1321,7 +1358,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo const target = resolveAppSpawnTarget(appPath, appArgs); const proc = spawn(target.command, target.args, { stdio: ['ignore', 'pipe', 'pipe'], - env: buildAppEnv(), + env: buildAppEnv(process.env, target.env), }); attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); proc.once('error', (error) => { @@ -1340,7 +1377,7 @@ export function runAppCommandSilently(appPath: string, appArgs: string[]): void const target = resolveAppSpawnTarget(appPath, appArgs); const proc = spawn(target.command, target.args, { stdio: ['ignore', 'pipe', 'pipe'], - env: buildAppEnv(), + env: buildAppEnv(process.env, target.env), }); attachAppProcessLogging(proc); proc.once('error', (error) => { @@ -1391,7 +1428,7 @@ export function runAppCommandAttached( return new Promise((resolve, reject) => { const proc = spawn(target.command, target.args, { stdio: ['ignore', 'pipe', 'pipe'], - env: buildAppEnv(), + env: buildAppEnv(process.env, target.env), }); attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); proc.once('error', (error) => { @@ -1462,7 +1499,7 @@ export function launchAppCommandDetached( const proc = spawn(target.command, target.args, { stdio: ['ignore', stdoutFd, stderrFd], detached: true, - env: buildAppEnv(), + env: buildAppEnv(process.env, target.env), }); proc.once('error', (error) => { log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index f5529e28..57b8bb08 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -2,12 +2,15 @@ local M = {} local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_MAX_ATTEMPTS = 6 +local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2 +local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 function M.create(ctx) local mp = ctx.mp + local utils = ctx.utils local opts = ctx.opts local state = ctx.state local binary = ctx.binary @@ -17,6 +20,8 @@ function M.create(ctx) local show_osd = ctx.log.show_osd local normalize_log_level = ctx.log.normalize_log_level local run_control_command_async + local APP_ARGC_ENV = "SUBMINER_APP_ARGC" + local APP_ARG_PREFIX = "SUBMINER_APP_ARG_" local function resolve_visible_overlay_startup() local raw_visible_overlay = opts.auto_start_visible_overlay @@ -112,10 +117,12 @@ function M.create(ctx) local function disarm_auto_play_ready_gate(options) local should_resume = options == nil or options.resume_playback ~= false local was_armed = state.auto_play_ready_gate_armed + local should_resume_playback = state.auto_play_ready_should_resume_playback == true clear_auto_play_ready_timeout() clear_auto_play_ready_osd_timer() state.auto_play_ready_gate_armed = false - if was_armed and should_resume then + state.auto_play_ready_should_resume_playback = false + if was_armed and should_resume and should_resume_playback then mp.set_property_native("pause", false) end end @@ -124,17 +131,26 @@ function M.create(ctx) if not state.auto_play_ready_gate_armed then return end + local should_resume_playback = state.auto_play_ready_should_resume_playback == true disarm_auto_play_ready_gate({ resume_playback = false }) - mp.set_property_native("pause", false) show_osd(AUTO_PLAY_READY_READY_OSD) - subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) + if should_resume_playback then + mp.set_property_native("pause", false) + subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) + else + subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready")) + end end local function arm_auto_play_ready_gate() - if state.auto_play_ready_gate_armed then + local was_armed = state.auto_play_ready_gate_armed + if was_armed then clear_auto_play_ready_timeout() clear_auto_play_ready_osd_timer() end + if not was_armed then + state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true + end state.auto_play_ready_gate_armed = true mp.set_property_native("pause", true) show_osd(AUTO_PLAY_READY_LOADING_OSD) @@ -223,12 +239,75 @@ function M.create(ctx) return args end + local function is_appimage_binary(path) + return environment.is_linux() and type(path) == "string" and path:lower():match("%.appimage$") ~= nil + end + + local function append_transport_env(env, args) + local count = math.max(#args - 1, 0) + env[#env + 1] = APP_ARGC_ENV .. "=" .. tostring(count) + for index = 2, #args do + env[#env + 1] = APP_ARG_PREFIX .. tostring(index - 2) .. "=" .. tostring(args[index]) + end + end + + local function env_has_name(env, name) + local prefix = name .. "=" + for _, value in ipairs(env) do + if type(value) == "string" and value:sub(1, #prefix) == prefix then + return true + end + end + return false + end + + local function append_default_app_log_env(env) + local log_dir = environment.join_path(environment.resolve_subminer_config_dir(), "logs") + local date = os.date("%Y-%m-%d") + if not env_has_name(env, "SUBMINER_APP_LOG") then + env[#env + 1] = "SUBMINER_APP_LOG=" .. environment.join_path(log_dir, "app-" .. date .. ".log") + end + if not env_has_name(env, "SUBMINER_MPV_LOG") then + env[#env + 1] = "SUBMINER_MPV_LOG=" .. environment.join_path(log_dir, "mpv-" .. date .. ".log") + end + end + + local function build_appimage_subprocess_env(args) + local env = {} + if utils and type(utils.get_env_list) == "function" then + for _, value in ipairs(utils.get_env_list()) do + if + type(value) == "string" + and not value:match("^" .. APP_ARGC_ENV .. "=") + and not value:match("^" .. APP_ARG_PREFIX .. "%d+=") + then + env[#env + 1] = value + end + end + end + append_default_app_log_env(env) + append_transport_env(env, args) + return env + end + + local function build_subprocess_command(args) + if is_appimage_binary(args[1]) then + return { + args = { args[1] }, + env = build_appimage_subprocess_env(args), + } + end + return { args = args } + end + run_control_command_async = function(action, overrides, callback) local args = build_command_args(action, overrides) + local command = build_subprocess_command(args) subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) mp.command_native_async({ name = "subprocess", - args = args, + args = command.args, + env = command.env, playback_only = false, capture_stdout = true, capture_stderr = true, @@ -240,11 +319,33 @@ function M.create(ctx) end) end + local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt) + attempt = attempt or 1 + run_control_command_async("app-ping", nil, function(ok) + if ok == expected_running then + on_ready() + return + end + if attempt >= OVERLAY_RESTART_PING_MAX_ATTEMPTS then + subminer_log("warn", "process", "Timed out waiting for SubMiner app to " .. label) + if on_timeout then + on_timeout() + end + return + end + mp.add_timeout(OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS, function() + wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt + 1) + end) + end) + end + local function run_binary_command_async(args, callback) + local command = build_subprocess_command(args) subminer_log("debug", "process", "Binary command: " .. table.concat(args, " ")) mp.command_native_async({ name = "subprocess", - args = args, + args = command.args, + env = command.env, playback_only = false, capture_stdout = true, capture_stderr = true, @@ -355,9 +456,11 @@ function M.create(ctx) end state.overlay_running = true + local command = build_subprocess_command(args) mp.command_native_async({ name = "subprocess", - args = args, + args = command.args, + env = command.env, playback_only = false, capture_stdout = true, capture_stderr = true, @@ -521,37 +624,49 @@ function M.create(ctx) state.texthooker_running = false state.suppress_ready_overlay_restore = false state.force_ready_overlay_restore = true - disarm_auto_play_ready_gate() + disarm_auto_play_ready_gate({ resume_playback = false }) - local start_args = build_command_args("start", { - show_visible_overlay = true, - }) - subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " ")) + wait_for_app_ping_state(false, "release the single-instance lock", function() + local start_args = build_command_args("start", { + show_visible_overlay = true, + }) + subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " ")) - state.overlay_running = true - mp.command_native_async({ - name = "subprocess", - args = start_args, - playback_only = false, - capture_stdout = true, - capture_stderr = true, - }, function(success, result, error) - if not success or (result and result.status ~= 0) then - state.overlay_running = false - subminer_log( - "error", - "process", - "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") - ) - show_osd("Restart failed") - else - show_osd("Restarted successfully") + state.overlay_running = true + local command = build_subprocess_command(start_args) + mp.command_native_async({ + name = "subprocess", + args = command.args, + env = command.env, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or (result and result.status ~= 0) then + state.overlay_running = false + subminer_log( + "error", + "process", + "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") + ) + show_osd("Restart failed") + else + wait_for_app_ping_state(true, "own the single-instance lock", function() + run_control_command_async("show-visible-overlay") + show_osd("Restarted successfully") + end, function() + run_control_command_async("show-visible-overlay") + show_osd("Restarted successfully") + end) + end + end) + + if resolve_texthooker_enabled(nil) then + ensure_texthooker_running(function() end) end + end, function() + show_osd("Restart failed") end) - - if resolve_texthooker_enabled(nil) then - ensure_texthooker_running(function() end) - end end) end diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index c27f60dd..76e4e607 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -30,6 +30,7 @@ function M.new() prompt_shown = false, }, auto_play_ready_gate_armed = false, + auto_play_ready_should_resume_playback = false, auto_play_ready_timeout = nil, auto_play_ready_osd_timer = nil, suppress_ready_overlay_restore = false, diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 964782f8..064c9ef4 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -12,6 +12,7 @@ local function run_plugin_scenario(config) logs = {}, property_sets = {}, periodic_timers = {}, + timeouts = {}, } local function make_mp_stub() @@ -40,6 +41,9 @@ local function run_plugin_scenario(config) end function mp.get_property_native(name) + if name == "pause" then + return config.paused == true + end if name == "osd-dimensions" then return config.osd_dimensions or { w = 1280, @@ -109,6 +113,13 @@ local function run_plugin_scenario(config) end end for _, value in ipairs(args) do + if value == "--app-ping" then + config.app_ping_index = (config.app_ping_index or 0) + 1 + local statuses = config.app_ping_statuses or { 1 } + local status = statuses[config.app_ping_index] or statuses[#statuses] or 1 + callback(status == 0, { status = status, stdout = "", stderr = "" }, nil) + return + end if value == "--stop" and config.stop_command_fails then local stderr = config.stop_command_stderr or "stop failed" callback(false, { status = 1, stdout = "", stderr = stderr }, stderr) @@ -120,6 +131,7 @@ local function run_plugin_scenario(config) end function mp.add_timeout(seconds, callback) + recorded.timeouts[#recorded.timeouts + 1] = seconds local timeout = { killed = false, } @@ -192,6 +204,9 @@ local function run_plugin_scenario(config) name = name, value = value, } + if name == "pause" then + config.paused = value == true + end end function mp.set_property(name, value) recorded.property_sets[#recorded.property_sets + 1] = { @@ -229,6 +244,10 @@ local function run_plugin_scenario(config) return table.concat(parts, "/") end + function utils.get_env_list() + return config.env_list or {} + end + function utils.parse_json(json) if json == '{"enabled":true,"amount":125}' then return { @@ -405,6 +424,29 @@ local function find_control_call(async_calls, flag) return nil end +local function find_nth_control_call(async_calls, flag, target_count) + local count = 0 + for _, call in ipairs(async_calls) do + local args = call.args or {} + local has_flag = false + local has_start = false + for _, value in ipairs(args) do + if value == flag then + has_flag = true + elseif value == "--start" then + has_start = true + end + end + if has_flag and not has_start then + count = count + 1 + if count == target_count then + return call + end + end + end + return nil +end + local function count_control_calls(async_calls, flag) local count = 0 for _, call in ipairs(async_calls) do @@ -510,6 +552,35 @@ local function count_osd_message(messages, target) return count end +local function has_timeout(timeouts, target) + for _, seconds in ipairs(timeouts) do + if math.abs(seconds - target) < 0.0001 then + return true + end + end + return false +end + +local function env_has(call, target) + local env = (call and call.env) or {} + for _, value in ipairs(env) do + if value == target then + return true + end + end + return false +end + +local function env_has_prefix(call, target) + local env = (call and call.env) or {} + for _, value in ipairs(env) do + if type(value) == "string" and value:sub(1, #target) == target then + return true + end + end + return false +end + local function count_property_set(property_sets, name, value) local count = 0 for _, call in ipairs(property_sets) do @@ -544,6 +615,7 @@ local function has_key_binding(recorded, keys, name) end local binary_path = "/tmp/subminer-binary" +local appimage_path = "/tmp/SubMiner.AppImage" do local recorded, err = run_plugin_scenario({ @@ -569,6 +641,42 @@ end do local recorded, err = run_plugin_scenario({ process_list = "", + option_overrides = { + binary_path = appimage_path, + auto_start = "no", + socket_path = "/tmp/subminer-socket", + }, + files = { + [appimage_path] = true, + }, + env_list = { + "PATH=/usr/bin", + "SUBMINER_APP_ARGC=stale", + "SUBMINER_APP_ARG_0=--stale", + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for AppImage env transport scenario: " .. tostring(err)) + recorded.script_messages["subminer-start"]("texthooker=no") + local call = recorded.async_calls[#recorded.async_calls] + assert_true(call ~= nil, "AppImage start should issue an async subprocess") + assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags") + assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment") + assert_true(env_has(call, "SUBMINER_APP_ARGC=8"), "AppImage subprocess should transport app arg count") + assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start") + assert_true(env_has(call, "SUBMINER_APP_ARG_1=--background"), "AppImage subprocess should transport --background") + assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag") + assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env") + assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env") + assert_true( + not env_has(call, "SUBMINER_APP_ARG_0=--stale"), + "AppImage subprocess should remove stale transported args" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + app_ping_statuses = { 0, 1, 0 }, option_overrides = { binary_path = binary_path, auto_start = "no", @@ -590,6 +698,25 @@ do restart_binding.fn() local start_call = find_start_call(recorded.async_calls) assert_true(start_call ~= nil, "manual restart should issue --start command") + local start_index = find_call_index(recorded.async_calls, start_call) or 0 + local old_app_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 1) + local old_app_stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2) + local new_app_started_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3) + assert_true(old_app_ping ~= nil, "manual restart should ping before waiting for old app shutdown") + assert_true(old_app_stopped_ping ~= nil, "manual restart should keep pinging until old app shutdown") + assert_true(new_app_started_ping ~= nil, "manual restart should ping after start until the new app is running") + assert_true( + (find_call_index(recorded.async_calls, old_app_ping) or 0) < start_index, + "manual restart should wait for old app ping before starting" + ) + assert_true( + (find_call_index(recorded.async_calls, old_app_stopped_ping) or 0) < start_index, + "manual restart should wait for old app stopped ping before starting" + ) + assert_true( + start_index < (find_call_index(recorded.async_calls, new_app_started_ping) or 0), + "manual restart should wait for new app running ping after starting" + ) assert_true( call_has_arg(start_call, "--show-visible-overlay"), "manual restart should bring the visible overlay back after process reload" @@ -598,11 +725,49 @@ do not call_has_arg(start_call, "--hide-visible-overlay"), "manual restart should not restart into hidden visible-overlay state" ) + assert_true( + not has_timeout(recorded.timeouts, 0.5), + "manual restart should use app-ping readiness instead of a fixed 0.5s start delay" + ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "manual restart should re-assert visible overlay after the restarted app is launched" + ) end do local recorded, err = run_plugin_scenario({ process_list = "", + app_ping_statuses = { 0, 1, 0 }, + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for gated restart pause scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true( + count_property_set(recorded.property_sets, "pause", true) == 1, + "gated restart should start from an armed pause gate" + ) + recorded.script_messages["subminer-restart"]() + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual restart should clear a startup gate without resuming playback" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + app_ping_statuses = { 1, 0 }, option_overrides = { binary_path = binary_path, auto_start = "no", @@ -629,8 +794,8 @@ do recorded.script_messages["subminer-restart"]() recorded.script_messages["subminer-autoplay-ready"]() assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, - "manual restart should re-assert visible overlay on readiness even when auto-start visibility is disabled" + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled" ) end @@ -1129,6 +1294,37 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + paused = true, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for pre-paused pause-until-ready scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true( + count_property_set(recorded.property_sets, "pause", true) == 1, + "pre-paused pause-until-ready should still arm the gate" + ) + assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered") + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "pre-paused pause-until-ready should leave playback paused when ready" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 90749a4e..840aed74 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -236,6 +236,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(shouldStartApp(help), false); assert.equal(shouldRunSettingsOnlyStartup(help), false); + const appPing = parseArgs(['--app-ping']); + assert.equal(appPing.appPing, true); + assert.equal(hasExplicitCommand(appPing), true); + assert.equal(shouldStartApp(appPing), false); + const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']); assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true); diff --git a/src/cli/args.ts b/src/cli/args.ts index a78b9795..d8c790c3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -74,6 +74,7 @@ export interface CliArgs { texthooker: boolean; texthookerOpenBrowser: boolean; help: boolean; + appPing?: boolean; update?: boolean; updateLauncherPath?: string; updateResponsePath?: string; @@ -172,6 +173,7 @@ export function parseArgs(argv: string[]): CliArgs { texthooker: false, texthookerOpenBrowser: false, help: false, + appPing: false, update: false, updateLauncherPath: undefined, updateResponsePath: undefined, @@ -339,6 +341,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true; else if (arg === '--texthooker') args.texthooker = true; else if (arg === '--open-browser') args.texthookerOpenBrowser = true; + else if (arg === '--app-ping') args.appPing = true; else if (arg === '--update') args.update = true; else if (arg.startsWith('--update-launcher-path=')) { const value = arg.split('=', 2)[1]; @@ -540,6 +543,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.jellyfinRemoteAnnounce || args.jellyfinPreviewAuth || args.texthooker || + args.appPing || args.update || args.generateConfig || args.help @@ -612,6 +616,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.jellyfinPlay && !args.jellyfinRemoteAnnounce && !args.jellyfinPreviewAuth && + !args.appPing && !args.update && !args.help && !args.autoStartOverlay && @@ -737,6 +742,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.jellyfinRemoteAnnounce && !args.jellyfinPreviewAuth && !args.texthooker && + !args.appPing && !args.update && !args.help && !args.autoStartOverlay && diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index b9561a17..f2e1c4d6 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -69,6 +69,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { texthooker: false, texthookerOpenBrowser: false, help: false, + appPing: false, autoStartOverlay: false, generateConfig: false, backupOverwrite: false, @@ -91,6 +92,9 @@ function createDeps(overrides: Partial = {}) { quitApp: () => { calls.push('quitApp'); }, + exitApp: (code) => { + calls.push(`exit:${code}`); + }, onSecondInstance: () => {}, handleCliCommand: () => {}, printHelp: () => { @@ -136,3 +140,30 @@ test('startAppLifecycle still acquires lock for startup commands', () => { assert.equal(getLockCalls(), 1); }); + +test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => { + const { deps, calls, getLockCalls } = createDeps({ + shouldStartApp: () => false, + }); + + startAppLifecycle(makeArgs({ appPing: true }), deps); + + assert.equal(getLockCalls(), 1); + assert.deepEqual(calls, ['exit:1']); +}); + +test('startAppLifecycle app ping exits zero immediately when another instance owns the lock', () => { + let lockCalls = 0; + const { deps, calls } = createDeps({ + shouldStartApp: () => false, + requestSingleInstanceLock: () => { + lockCalls += 1; + return false; + }, + }); + + startAppLifecycle(makeArgs({ appPing: true }), deps); + + assert.equal(lockCalls, 1); + assert.deepEqual(calls, ['exit:0']); +}); diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index 83dbdfe2..830ade75 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -8,6 +8,7 @@ export interface AppLifecycleServiceDeps { parseArgs: (argv: string[]) => CliArgs; requestSingleInstanceLock: () => boolean; quitApp: () => void; + exitApp: (code: number) => void; onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void; handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; printHelp: () => void; @@ -27,6 +28,7 @@ export interface AppLifecycleServiceDeps { interface AppLike { requestSingleInstanceLock: () => boolean; quit: () => void; + exit?: (exitCode?: number) => void; on: (...args: any[]) => unknown; whenReady: () => Promise; } @@ -54,6 +56,14 @@ export function createAppLifecycleDepsRuntime( parseArgs: options.parseArgs, requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), quitApp: () => options.app.quit(), + exitApp: (code) => { + if (options.app.exit) { + options.app.exit(code); + return; + } + process.exitCode = code; + options.app.quit(); + }, onSecondInstance: (handler) => { options.app.on('second-instance', handler as (...args: unknown[]) => void); }, @@ -94,6 +104,11 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic } const gotTheLock = deps.requestSingleInstanceLock(); + if (initialArgs.appPing) { + deps.exitApp(gotTheLock ? 1 : 0); + return; + } + if (!gotTheLock) { deps.quitApp(); return; diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 8b949a14..f243674f 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -385,6 +385,41 @@ test('MpvIpcClient connect does not force primary subtitle visibility from bindi assert.equal(hasPrimaryVisibilityMutation, false); }); +test('MpvIpcClient snapshots current subtitles before connection side effects can hide them', () => { + const commands: unknown[] = []; + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + (client as any).send = (command: unknown) => { + commands.push(command); + return true; + }; + client.on('connection-change', ({ connected }) => { + if (connected) { + client.setSubVisibility(false); + } + }); + + const callbacks = (client as any).transport.callbacks; + callbacks.onConnect(); + + const firstSubTextSnapshot = commands.findIndex((command) => { + const args = (command as { command?: unknown[] }).command; + return Array.isArray(args) && args[0] === 'get_property' && args[1] === 'sub-text'; + }); + const firstPrimaryHide = commands.findIndex((command) => { + const args = (command as { command?: unknown[] }).command; + return ( + Array.isArray(args) && + args[0] === 'set_property' && + args[1] === 'sub-visibility' && + (args[2] === false || args[2] === 'no') + ); + }); + + assert.notEqual(firstSubTextSnapshot, -1); + assert.notEqual(firstPrimaryHide, -1); + assert.ok(firstSubTextSnapshot < firstPrimaryHide); +}); + test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => { const commands: unknown[] = []; const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 9525c43a..19685ebe 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -186,12 +186,12 @@ export class MpvIpcClient implements MpvClient { this.connected = true; this.connecting = false; this.socket = this.transport.getSocket(); - this.emit('connection-change', { connected: true }); this.reconnectAttempt = 0; this.hasConnectedOnce = true; this.setSecondarySubVisibility(false); subscribeToMpvProperties(this.send.bind(this)); requestMpvInitialState(this.send.bind(this)); + this.emit('connection-change', { connected: true }); const shouldAutoStart = this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true; diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 564c98c6..6eca9e44 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -13,15 +13,26 @@ type WindowTrackerStub = { function createMainWindowRecorder() { const calls: string[] = []; + const listeners = new Map void>>(); let visible = false; let focused = false; let opacity = 1; let contentReady = true; + const emit = (event: string): void => { + const handlers = listeners.get(event) ?? []; + listeners.delete(event); + for (const handler of handlers) { + handler(); + } + }; const window = { webContents: {}, isDestroyed: () => false, isVisible: () => visible, isFocused: () => focused, + once: (event: string, handler: () => void) => { + listeners.set(event, [...(listeners.get(event) ?? []), handler]); + }, hide: () => { visible = false; focused = false; @@ -30,10 +41,12 @@ function createMainWindowRecorder() { show: () => { visible = true; calls.push('show'); + emit('show'); }, showInactive: () => { visible = true; calls.push('show-inactive'); + emit('show'); }, focus: () => { focused = true; @@ -216,6 +229,44 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke assert.ok(!calls.includes('osd')); }); +test('tracked non-macOS overlay reapplies bounds after first show', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: false, + } as never); + + assert.deepEqual( + calls.filter((call) => call === 'update-bounds' || call === 'show'), + ['update-bounds', 'show', 'update-bounds'], + ); +}); + test('Windows visible overlay stays click-through and binds to mpv while tracked', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 0924651b..d9f514c5 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -270,6 +270,23 @@ export function updateVisibleOverlayVisibility(args: { args.markOverlayLoadingOsdShown?.(); }; + const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => { + if ( + geometry === null || + args.isMacOSPlatform || + args.isWindowsPlatform || + mainWindow.isVisible() + ) { + return; + } + mainWindow.once('show', () => { + if (mainWindow.isDestroyed() || !mainWindow.isVisible()) { + return; + } + args.updateVisibleOverlayBounds(geometry); + }); + }; + if (!args.visibleOverlayVisible) { args.setTrackerNotReadyWarningShown(false); args.resetOverlayLoadingOsdSuppression?.(); @@ -298,6 +315,7 @@ export function updateVisibleOverlayVisibility(args: { const geometry = args.windowTracker.getGeometry(); if (geometry) { args.updateVisibleOverlayBounds(geometry); + refreshNonNativeOverlayBoundsAfterFirstShow(geometry); } args.syncPrimaryOverlayWindowLayer('visible'); const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index 33234bf9..03c9f564 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -14,6 +14,33 @@ test('overlay window config explicitly disables renderer sandbox for preload com assert.equal(options.webPreferences?.backgroundThrottling, false); }); +test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux', + }); + + try { + const visibleOptions = buildOverlayWindowOptions('visible', { + isDev: false, + yomitanSession: null, + }); + const modalOptions = buildOverlayWindowOptions('modal', { + isDev: false, + yomitanSession: null, + }); + + assert.equal(visibleOptions.resizable, true); + assert.equal(modalOptions.resizable, false); + } finally { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } + } +}); + test('Windows visible overlay window config does not start as always-on-top', () => { const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); diff --git a/src/core/services/overlay-window-options.ts b/src/core/services/overlay-window-options.ts index bd69a529..cae763ce 100644 --- a/src/core/services/overlay-window-options.ts +++ b/src/core/services/overlay-window-options.ts @@ -16,6 +16,7 @@ export function buildOverlayWindowOptions( ): BrowserWindowConstructorOptions { const showNativeDebugFrame = process.platform === 'win32' && options.isDev; const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible'); + const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible'; return { show: false, @@ -29,7 +30,7 @@ export function buildOverlayWindowOptions( frame: false, alwaysOnTop: shouldStartAlwaysOnTop, skipTaskbar: true, - resizable: false, + resizable: shouldAllowCompositorResize, hasShadow: false, focusable: true, acceptFirstMouse: true, diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts index d64a50d7..df394027 100644 --- a/src/core/services/tokenizer/annotation-stage.test.ts +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -122,6 +122,35 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex assert.equal(result[3]?.frequencyRank, 11); }); +test('annotateTokens keeps frequency for determiner-led content noun compounds', () => { + const tokens = [ + makeToken({ + surface: 'その場', + headword: 'その場', + reading: 'そのば', + partOfSpeech: PartOfSpeech.noun, + pos1: '連体詞|名詞', + pos2: '*|一般', + startPos: 0, + endPos: 3, + frequencyRank: 879, + }), + ]; + + const result = annotateTokens( + tokens, + makeDeps({ + isKnownWord: (text) => text === 'その場', + getJlptLevel: (text) => (text === 'その場' ? 'N4' : null), + }), + { minSentenceWordsForNPlusOne: 1 }, + ); + + assert.equal(result[0]?.isKnown, true); + assert.equal(result[0]?.frequencyRank, 879); + assert.equal(result[0]?.jlptLevel, 'N4'); +}); + test('annotateTokens preserves existing frequency rank when frequency is enabled', () => { const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })]; diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index f1cd6bb6..ea9c142f 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -188,6 +188,35 @@ function shouldAllowHonorificPrefixNounFrequency(token: MergedToken): boolean { ); } +function shouldAllowDeterminerLedNounFrequency( + normalizedPos1: string, + normalizedPos2: string, + pos1Exclusions: ReadonlySet, + pos2Exclusions: ReadonlySet, +): boolean { + const pos1Parts = splitNormalizedTagParts(normalizedPos1); + if (pos1Parts.length < 2 || pos1Parts[0] !== '連体詞') { + return false; + } + + const pos2Parts = splitNormalizedTagParts(normalizedPos2); + if (!isExcludedComponent(pos1Parts[0], pos2Parts[0], pos1Exclusions, pos2Exclusions)) { + return false; + } + + const componentCount = Math.max(pos1Parts.length, pos2Parts.length); + for (let index = 1; index < componentCount; index += 1) { + if ( + pos1Parts[index] === '名詞' && + !isExcludedComponent(pos1Parts[index], pos2Parts[index], pos1Exclusions, pos2Exclusions) + ) { + return true; + } + } + + return false; +} + function isFrequencyExcludedByPos( token: MergedToken, pos1Exclusions: ReadonlySet, @@ -207,12 +236,19 @@ function isFrequencyExcludedByPos( pos1Exclusions, pos2Exclusions, ); + const allowDeterminerLedNounToken = shouldAllowDeterminerLedNounFrequency( + normalizedPos1, + normalizedPos2, + pos1Exclusions, + pos2Exclusions, + ); const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token); const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token); if ( isExcludedByTagSet(normalizedPos1, pos1Exclusions) && !allowContentLedMergedToken && + !allowDeterminerLedNounToken && !allowOrdinalPrefixNounToken && !allowHonorificPrefixNounToken ) { @@ -222,6 +258,7 @@ function isFrequencyExcludedByPos( if ( isExcludedByTagSet(normalizedPos2, pos2Exclusions) && !allowContentLedMergedToken && + !allowDeterminerLedNounToken && !allowOrdinalPrefixNounToken && !allowHonorificPrefixNounToken ) { diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index b0f8bd54..2864355f 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -14,6 +14,7 @@ import { shouldHandleHelpOnlyAtEntry, shouldHandleLaunchMpvAtEntry, shouldHandleStatsDaemonCommandAtEntry, + hasTransportedStartupArgs, } from './main-entry-runtime'; test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { @@ -55,6 +56,22 @@ test('normalizeStartupArgv defaults no-arg Windows startup to --start only', () } }); +test('normalizeStartupArgv uses transported AppImage args instead of raw Electron args', () => { + assert.deepEqual( + normalizeStartupArgv(['SubMiner.AppImage', '--background'], { + SUBMINER_APP_ARGC: '2', + SUBMINER_APP_ARG_0: '--stop', + SUBMINER_APP_ARG_1: '--socket', + }), + ['SubMiner.AppImage', '--stop', '--socket'], + ); +}); + +test('hasTransportedStartupArgs detects env-carried app args', () => { + assert.equal(hasTransportedStartupArgs({ SUBMINER_APP_ARGC: '1' }), true); + assert.equal(hasTransportedStartupArgs({}), false); +}); + test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => { assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true); assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false); diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index e88b81a0..671bd3ff 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -7,6 +7,9 @@ const BACKGROUND_ARG = '--background'; const START_ARG = '--start'; const PASSWORD_STORE_ARG = '--password-store'; const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; +const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; +const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; +const MAX_TRANSPORTED_APP_ARGS = 256; const APP_NAME = 'SubMiner'; const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([ '--alang', @@ -83,9 +86,40 @@ function parseCliArgs(argv: string[]): CliArgs { return parseArgs(argv); } +export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean { + return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string'; +} + +function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { + const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; + if (rawCount === undefined) { + return null; + } + + const count = Number(rawCount); + if (!Number.isInteger(count) || count < 0 || count > MAX_TRANSPORTED_APP_ARGS) { + return null; + } + + const args: string[] = []; + for (let index = 0; index < count; index += 1) { + const value = env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`]; + if (typeof value !== 'string') { + return null; + } + args.push(value); + } + return args; +} + export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] { if (env.ELECTRON_RUN_AS_NODE === '1') return argv; + const transportedArgs = readTransportedStartupArgs(env); + if (transportedArgs) { + return [argv[0] ?? APP_NAME, ...transportedArgs]; + } + const effectiveArgs = removePassiveStartupArgs(argv.slice(1)); if (effectiveArgs.length === 0) { if (process.platform === 'win32') { diff --git a/src/main-entry.ts b/src/main-entry.ts index b58f438c..4fadb177 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -13,6 +13,7 @@ import { sanitizeBackgroundEnv, sanitizeHelpEnv, sanitizeLaunchMpvEnv, + hasTransportedStartupArgs, shouldDetachBackgroundLaunch, shouldHandleHelpOnlyAtEntry, shouldHandleLaunchMpvAtEntry, @@ -175,7 +176,8 @@ applySanitizedEnv(sanitizeStartupEnv(process.env)); const userDataPath = configureEarlyAppPaths(app); if (shouldDetachBackgroundLaunch(process.argv, process.env)) { - const child = spawn(process.execPath, process.argv.slice(1), { + const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); + const child = spawn(process.execPath, childArgs, { detached: true, stdio: 'ignore', env: sanitizeBackgroundEnv(process.env), diff --git a/src/main.ts b/src/main.ts index ec2149dd..121c9231 100644 --- a/src/main.ts +++ b/src/main.ts @@ -725,6 +725,7 @@ type BootServices = MainBootServicesResult< { requestSingleInstanceLock: () => boolean; quit: () => void; + exit: (code?: number) => void; on: (event: string, listener: (...args: unknown[]) => void) => unknown; whenReady: () => Promise; } @@ -3405,7 +3406,7 @@ const { stopConfigHotReload: () => configHotReloadRuntime.stop(), restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), restoreMpvSubVisibility: () => { - restoreOverlayMpvSubtitles(); + restoreOverlayMpvSubtitles({ force: true }); }, unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), stopSubtitleWebsocket: () => { diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts index f318dc0e..dad13311 100644 --- a/src/main/boot/services.test.ts +++ b/src/main/boot/services.test.ts @@ -6,6 +6,7 @@ test('createMainBootServices builds boot-phase service bundle', () => { type MockAppLifecycleApp = { requestSingleInstanceLock: () => boolean; quit: () => void; + exit: (code?: number) => void; on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp; whenReady: () => Promise; }; @@ -54,6 +55,9 @@ test('createMainBootServices builds boot-phase service bundle', () => { setPathValue = value; }, quit: () => {}, + exit: (code?: number) => { + calls.push(`exit:${code ?? 0}`); + }, on: (event: string) => { appOnCalls.push(event); return {}; @@ -123,8 +127,9 @@ test('createMainBootServices builds boot-phase service bundle', () => { services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp, ); + services.appLifecycleApp.exit(7); assert.deepEqual(appOnCalls, ['ready']); assert.equal(secondInstanceHandlerRegistered, true); - assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']); + assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']); assert.equal(setPathValue, '/tmp/subminer-config'); }); diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts index 24afbf5f..f4e796ae 100644 --- a/src/main/boot/services.ts +++ b/src/main/boot/services.ts @@ -4,6 +4,7 @@ import { ConfigStartupParseError } from '../../config'; export interface AppLifecycleShape { requestSingleInstanceLock: () => boolean; quit: () => void; + exit: (code?: number) => void; on: (event: string, listener: (...args: unknown[]) => void) => unknown; whenReady: () => Promise; } @@ -50,6 +51,7 @@ export interface MainBootServicesParams< app: { setPath: (name: string, value: string) => void; quit: () => void; + exit: (code?: number) => void; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures on: Function; whenReady: () => Promise; @@ -260,6 +262,7 @@ export function createMainBootServices< requestSingleInstanceLock: () => params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(), quit: () => params.app.quit(), + exit: (code?: number) => params.app.exit(code), on: (event: string, listener: (...args: unknown[]) => void) => { if (event === 'second-instance') { params.registerSecondInstanceHandlerEarly( diff --git a/src/main/early-single-instance.test.ts b/src/main/early-single-instance.test.ts index 0d0624e7..b3dbfac5 100644 --- a/src/main/early-single-instance.test.ts +++ b/src/main/early-single-instance.test.ts @@ -9,22 +9,40 @@ import * as earlySingleInstance from './early-single-instance'; function createFakeApp(lockValue = true) { let requestCalls = 0; - let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null; + let requestData: unknown = null; + let secondInstanceListener: + | (( + _event: unknown, + argv: string[], + workingDirectory?: string, + additionalData?: unknown, + ) => void) + | null = null; return { app: { - requestSingleInstanceLock: () => { + requestSingleInstanceLock: (additionalData?: unknown) => { requestCalls += 1; + requestData = additionalData ?? null; return lockValue; }, - on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => { + on: ( + _event: 'second-instance', + listener: ( + _event: unknown, + argv: string[], + workingDirectory?: string, + additionalData?: unknown, + ) => void, + ) => { secondInstanceListener = listener; }, }, - emitSecondInstance: (argv: string[]) => { - secondInstanceListener?.({}, argv); + emitSecondInstance: (argv: string[], additionalData?: unknown) => { + secondInstanceListener?.({}, argv, '/tmp', additionalData); }, getRequestCalls: () => requestCalls, + getRequestData: () => requestData, }; } @@ -56,6 +74,23 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev ]); }); +test('requestSingleInstanceLockEarly sends normalized argv through second-instance data', () => { + resetEarlySingleInstanceStateForTests(); + const fake = createFakeApp(true); + const primaryArgv = ['SubMiner.AppImage', '--start']; + const transportedArgv = ['SubMiner.AppImage', '--stop']; + const calls: string[][] = []; + + assert.equal(requestSingleInstanceLockEarly(fake.app, primaryArgv), true); + registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => { + calls.push(argv); + }); + fake.emitSecondInstance(['SubMiner.AppImage'], { subminerArgv: transportedArgv }); + + assert.deepEqual(fake.getRequestData(), { subminerArgv: primaryArgv }); + assert.deepEqual(calls, [transportedArgv]); +}); + test('stats daemon args bypass the normal single-instance lock path', () => { const shouldBypass = ( earlySingleInstance as typeof earlySingleInstance & { diff --git a/src/main/early-single-instance.ts b/src/main/early-single-instance.ts index 5c748a88..db70eb14 100644 --- a/src/main/early-single-instance.ts +++ b/src/main/early-single-instance.ts @@ -1,8 +1,18 @@ interface ElectronSecondInstanceAppLike { - requestSingleInstanceLock: () => boolean; - on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown; + requestSingleInstanceLock: (additionalData?: Record) => boolean; + on: ( + event: 'second-instance', + listener: ( + _event: unknown, + argv: string[], + workingDirectory?: string, + additionalData?: unknown, + ) => void, + ) => unknown; } +const SECOND_INSTANCE_ARGV_KEY = 'subminerArgv'; + export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean { return argv.includes('--stats-background') || argv.includes('--stats-stop'); } @@ -12,10 +22,24 @@ let secondInstanceListenerAttached = false; const secondInstanceArgvHistory: string[][] = []; const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>(); +function normalizeSecondInstanceArgv(argv: string[], additionalData: unknown): string[] { + if ( + additionalData && + typeof additionalData === 'object' && + Array.isArray((additionalData as { subminerArgv?: unknown }).subminerArgv) && + (additionalData as { subminerArgv: unknown[] }).subminerArgv.every( + (value) => typeof value === 'string', + ) + ) { + return [...(additionalData as { subminerArgv: string[] }).subminerArgv]; + } + return [...argv]; +} + function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void { if (secondInstanceListenerAttached) return; - app.on('second-instance', (event, argv) => { - const clonedArgv = [...argv]; + app.on('second-instance', (event, argv, _workingDirectory, additionalData) => { + const clonedArgv = normalizeSecondInstanceArgv(argv, additionalData); secondInstanceArgvHistory.push(clonedArgv); for (const handler of secondInstanceHandlers) { handler(event, [...clonedArgv]); @@ -24,12 +48,17 @@ function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void secondInstanceListenerAttached = true; } -export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean { +export function requestSingleInstanceLockEarly( + app: ElectronSecondInstanceAppLike, + argv: readonly string[] = process.argv, +): boolean { attachSecondInstanceListener(app); if (cachedSingleInstanceLock !== null) { return cachedSingleInstanceLock; } - cachedSingleInstanceLock = app.requestSingleInstanceLock(); + cachedSingleInstanceLock = app.requestSingleInstanceLock({ + [SECOND_INSTANCE_ARGV_KEY]: [...argv], + }); return cachedSingleInstanceLock; } diff --git a/src/main/runtime/overlay-mpv-sub-visibility.test.ts b/src/main/runtime/overlay-mpv-sub-visibility.test.ts index b7d4c624..cbdf15d6 100644 --- a/src/main/runtime/overlay-mpv-sub-visibility.test.ts +++ b/src/main/runtime/overlay-mpv-sub-visibility.test.ts @@ -104,6 +104,36 @@ test('restore keeps mpv subtitles hidden when visible-overlay binding still requ assert.deepEqual(calls, [false]); }); +test('forced restore ignores visible-overlay suppression during app shutdown', () => { + const state: VisibilityState = { + savedSubVisibility: true, + revision: 9, + }; + const calls: boolean[] = []; + + const restore = createRestoreOverlayMpvSubtitlesHandler({ + getSavedSubVisibility: () => state.savedSubVisibility, + setSavedSubVisibility: (visible) => { + state.savedSubVisibility = visible; + }, + getRevision: () => state.revision, + setRevision: (revision) => { + state.revision = revision; + }, + isMpvConnected: () => true, + shouldKeepSuppressedFromVisibleOverlayBinding: () => true, + setMpvSubVisibility: (visible) => { + calls.push(visible); + }, + }); + + restore({ force: true }); + + assert.equal(state.savedSubVisibility, null); + assert.equal(state.revision, 10); + assert.deepEqual(calls, [true]); +}); + test('restore defers mpv subtitle restore while mpv is disconnected', () => { const state: VisibilityState = { savedSubVisibility: true, diff --git a/src/main/runtime/overlay-mpv-sub-visibility.ts b/src/main/runtime/overlay-mpv-sub-visibility.ts index 16408347..d87b3bfe 100644 --- a/src/main/runtime/overlay-mpv-sub-visibility.ts +++ b/src/main/runtime/overlay-mpv-sub-visibility.ts @@ -3,6 +3,10 @@ type MpvVisibilityClient = { requestProperty: (name: string) => Promise; }; +type RestoreOverlayMpvSubtitlesOptions = { + force?: boolean; +}; + function parseSubVisibility(value: unknown): boolean { if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); @@ -81,11 +85,11 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: { shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean; setMpvSubVisibility: (visible: boolean) => void; }) { - return (): void => { + return (options: RestoreOverlayMpvSubtitlesOptions = {}): void => { deps.setRevision(deps.getRevision() + 1); const savedVisibility = deps.getSavedSubVisibility(); - if (deps.shouldKeepSuppressedFromVisibleOverlayBinding()) { + if (!options.force && deps.shouldKeepSuppressedFromVisibleOverlayBinding()) { deps.setMpvSubVisibility(false); return; } diff --git a/src/main/runtime/update/fetch-adapter.test.ts b/src/main/runtime/update/fetch-adapter.test.ts index 655a5982..74c6f0e6 100644 --- a/src/main/runtime/update/fetch-adapter.test.ts +++ b/src/main/runtime/update/fetch-adapter.test.ts @@ -72,6 +72,8 @@ test('createCurlFetch requests updater metadata without Electron networking', as '--show-error', '--connect-timeout', '30', + '--max-time', + '60', '--header', 'Accept: application/vnd.github+json', '--header', @@ -79,4 +81,5 @@ test('createCurlFetch requests updater metadata without Electron networking', as 'https://api.github.com/repos/ksyasuda/SubMiner/releases', ]); assert.equal(calls[0]?.options.encoding, 'buffer'); + assert.equal(calls[0]?.options.timeout, 65_000); }); diff --git a/src/main/runtime/update/fetch-adapter.ts b/src/main/runtime/update/fetch-adapter.ts index 23bb8cc8..5e25e040 100644 --- a/src/main/runtime/update/fetch-adapter.ts +++ b/src/main/runtime/update/fetch-adapter.ts @@ -54,7 +54,16 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike { const curlPath = options.curlPath ?? '/usr/bin/curl'; return async (url, init = {}) => { - const args = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30']; + const args = [ + '--fail', + '--location', + '--silent', + '--show-error', + '--connect-timeout', + '30', + '--max-time', + '60', + ]; addHeaderArgs(args, init.headers); args.push(url); const body = await new Promise((resolve, reject) => { @@ -64,6 +73,7 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike { { encoding: 'buffer', maxBuffer: 600 * 1024 * 1024, + timeout: 65_000, }, (error, stdout, stderr) => { if (error) { diff --git a/src/main/runtime/yomitan-extension-runtime.test.ts b/src/main/runtime/yomitan-extension-runtime.test.ts index d9e88639..d6bdc1b0 100644 --- a/src/main/runtime/yomitan-extension-runtime.test.ts +++ b/src/main/runtime/yomitan-extension-runtime.test.ts @@ -165,3 +165,32 @@ test('yomitan extension runtime notifies once after concurrent ensure load resol assert.equal(await second, fakeExtension); assert.deepEqual(notifications, [fakeExtension]); }); + +test('yomitan extension runtime retries notification after callback failure', async () => { + const fakeExtension = { id: 'yomitan' } as Extension; + let calls = 0; + + const runtime = createYomitanExtensionRuntime({ + loadYomitanExtensionCore: async () => fakeExtension, + userDataPath: '/tmp', + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + setYomitanParserReadyPromise: () => {}, + setYomitanParserInitPromise: () => {}, + setYomitanExtension: () => {}, + setYomitanSession: () => {}, + getYomitanExtension: () => fakeExtension, + getLoadInFlight: () => null, + setLoadInFlight: () => {}, + onYomitanExtensionLoaded: () => { + calls += 1; + if (calls === 1) { + throw new Error('overlay reload failed'); + } + }, + }); + + await assert.rejects(runtime.ensureYomitanExtensionLoaded(), /overlay reload failed/); + assert.equal(await runtime.ensureYomitanExtensionLoaded(), fakeExtension); + assert.equal(calls, 2); +}); diff --git a/src/main/runtime/yomitan-extension-runtime.ts b/src/main/runtime/yomitan-extension-runtime.ts index 1d225416..f3bda06c 100644 --- a/src/main/runtime/yomitan-extension-runtime.ts +++ b/src/main/runtime/yomitan-extension-runtime.ts @@ -50,12 +50,29 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) ); let lastNotifiedExtension: Extension | null = null; + let notifyingExtension: Extension | null = null; + let notificationPromise: Promise | null = null; async function notifyYomitanExtensionLoaded(extension: Extension | null): Promise { if (!extension || extension === lastNotifiedExtension) { return; } - lastNotifiedExtension = extension; - await deps.onYomitanExtensionLoaded?.(extension); + if (extension === notifyingExtension && notificationPromise) { + await notificationPromise; + return; + } + notifyingExtension = extension; + notificationPromise = (async () => { + await deps.onYomitanExtensionLoaded?.(extension); + lastNotifiedExtension = extension; + })(); + try { + await notificationPromise; + } finally { + if (notifyingExtension === extension) { + notifyingExtension = null; + notificationPromise = null; + } + } } return {