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
This commit is contained in:
2026-05-18 01:29:35 -07:00
parent 48447c2f1a
commit 1bb7b26641
33 changed files with 923 additions and 66 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners.
+1 -1
View File
@@ -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.
+30
View File
@@ -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',
+45 -8
View File
@@ -39,6 +39,7 @@ export const state = {
type SpawnTarget = {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
};
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
@@ -46,6 +47,8 @@ type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | '
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
export interface LauncherRuntimePluginPlan {
scriptPath: string | null;
@@ -1009,7 +1012,7 @@ export async function startOverlay(
const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath);
@@ -1146,7 +1149,7 @@ function stopManagedOverlayApp(args: Args): void {
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
const result = spawnSync(target.command, target.args, {
stdio: 'ignore',
env: buildAppEnv(),
env: buildAppEnv(process.env, target.env),
});
if (result.error) {
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
@@ -1163,13 +1166,40 @@ function stopManagedOverlayApp(args: Args): void {
}
}
function buildAppEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
function clearTransportedAppArgs(env: Record<string, string | undefined>): 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<string, string | undefined> = {
...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}`);
+149 -34
View File
@@ -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
+1
View File
@@ -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,
+198 -2
View File
@@ -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 = "",
+5
View File
@@ -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);
+6
View File
@@ -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 &&
+31
View File
@@ -69,6 +69,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
appPing: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -91,6 +92,9 @@ function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
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']);
});
+15
View File
@@ -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<void>;
}
@@ -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;
+35
View File
@@ -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());
+1 -1
View File
@@ -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;
@@ -13,15 +13,26 @@ type WindowTrackerStub = {
function createMainWindowRecorder() {
const calls: string[] = [];
const listeners = new Map<string, Array<() => 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 = {
+18
View File
@@ -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();
@@ -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');
+2 -1
View File
@@ -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,
@@ -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 })];
@@ -188,6 +188,35 @@ function shouldAllowHonorificPrefixNounFrequency(token: MergedToken): boolean {
);
}
function shouldAllowDeterminerLedNounFrequency(
normalizedPos1: string,
normalizedPos2: string,
pos1Exclusions: ReadonlySet<string>,
pos2Exclusions: ReadonlySet<string>,
): 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<string>,
@@ -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
) {
+17
View File
@@ -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);
+34
View File
@@ -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') {
+3 -1
View File
@@ -13,6 +13,7 @@ import {
sanitizeBackgroundEnv,
sanitizeHelpEnv,
sanitizeLaunchMpvEnv,
hasTransportedStartupArgs,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
@@ -184,7 +185,8 @@ registerFatalErrorHandlers({
});
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),
+2 -1
View File
@@ -732,6 +732,7 @@ type BootServices = MainBootServicesResult<
{
requestSingleInstanceLock: () => boolean;
quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
@@ -3435,7 +3436,7 @@ const {
stopConfigHotReload: () => configHotReloadRuntime.stop(),
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles();
restoreOverlayMpvSubtitles({ force: true });
},
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => {
+6 -1
View File
@@ -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<void>;
};
@@ -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');
});
+3
View File
@@ -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<void>;
}
@@ -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<void>;
@@ -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(
+40 -5
View File
@@ -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 & {
+35 -6
View File
@@ -1,8 +1,18 @@
interface ElectronSecondInstanceAppLike {
requestSingleInstanceLock: () => boolean;
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
requestSingleInstanceLock: (additionalData?: Record<string, unknown>) => 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;
}
@@ -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,
@@ -3,6 +3,10 @@ type MpvVisibilityClient = {
requestProperty: (name: string) => Promise<unknown>;
};
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;
}
@@ -101,6 +101,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',
@@ -108,4 +110,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);
});
+11 -1
View File
@@ -67,7 +67,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<Buffer>((resolve, reject) => {
@@ -77,6 +86,7 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
{
encoding: 'buffer',
maxBuffer: 600 * 1024 * 1024,
timeout: 65_000,
},
(error, stdout, stderr) => {
if (error) {
@@ -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);
});
+19 -2
View File
@@ -50,12 +50,29 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
);
let lastNotifiedExtension: Extension | null = null;
let notifyingExtension: Extension | null = null;
let notificationPromise: Promise<void> | null = null;
async function notifyYomitanExtensionLoaded(extension: Extension | null): Promise<void> {
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 {