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 type: fixed
area: overlay 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', () => { test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [ assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title', '--title',
+45 -8
View File
@@ -39,6 +39,7 @@ export const state = {
type SpawnTarget = { type SpawnTarget = {
command: string; command: string;
args: string[]; args: string[];
env?: NodeJS.ProcessEnv;
}; };
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>; 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 DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900; const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700; 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 { export interface LauncherRuntimePluginPlan {
scriptPath: string | null; scriptPath: string | null;
@@ -1009,7 +1012,7 @@ export async function startOverlay(
const target = resolveAppSpawnTarget(appPath, overlayArgs); const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, { state.overlayProc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(), env: buildAppEnv(process.env, target.env),
}); });
attachAppProcessLogging(state.overlayProc); attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath); markOverlayManagedByLauncher(appPath);
@@ -1146,7 +1149,7 @@ function stopManagedOverlayApp(args: Args): void {
const target = resolveAppSpawnTarget(state.appPath, stopArgs); const target = resolveAppSpawnTarget(state.appPath, stopArgs);
const result = spawnSync(target.command, target.args, { const result = spawnSync(target.command, target.args, {
stdio: 'ignore', stdio: 'ignore',
env: buildAppEnv(), env: buildAppEnv(process.env, target.env),
}); });
if (result.error) { if (result.error) {
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`); 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> = { const env: Record<string, string | undefined> = {
...baseEnv, ...baseEnv,
SUBMINER_APP_LOG: getAppLogPath(), SUBMINER_APP_LOG: getAppLogPath(),
SUBMINER_MPV_LOG: getMpvLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(),
}; };
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RUN_AS_NODE;
clearTransportedAppArgs(env);
Object.assign(env, extraEnv);
const layers = env.VK_INSTANCE_LAYERS; const layers = env.VK_INSTANCE_LAYERS;
if (typeof layers === 'string' && layers.trim().length > 0) { if (typeof layers === 'string' && layers.trim().length > 0) {
const filtered = layers const filtered = layers
@@ -1274,7 +1304,7 @@ function runSyncAppCommand(
} { } {
const target = resolveAppSpawnTarget(appPath, appArgs); const target = resolveAppSpawnTarget(appPath, appArgs);
const result = spawnSync(target.command, target.args, { const result = spawnSync(target.command, target.args, {
env: buildAppEnv(), env: buildAppEnv(process.env, target.env),
encoding: 'utf8', encoding: 'utf8',
}); });
if (result.stdout) { if (result.stdout) {
@@ -1307,6 +1337,13 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
} }
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget { function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
if (shouldTransportAppArgsForAppImage(appPath)) {
return {
command: appPath,
args: [],
env: buildTransportedAppArgsEnv(appArgs),
};
}
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
return { command: appPath, args: appArgs }; return { command: appPath, args: appArgs };
} }
@@ -1321,7 +1358,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
const target = resolveAppSpawnTarget(appPath, appArgs); const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(), env: buildAppEnv(process.env, target.env),
}); });
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => { proc.once('error', (error) => {
@@ -1340,7 +1377,7 @@ export function runAppCommandSilently(appPath: string, appArgs: string[]): void
const target = resolveAppSpawnTarget(appPath, appArgs); const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(), env: buildAppEnv(process.env, target.env),
}); });
attachAppProcessLogging(proc); attachAppProcessLogging(proc);
proc.once('error', (error) => { proc.once('error', (error) => {
@@ -1391,7 +1428,7 @@ export function runAppCommandAttached(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(), env: buildAppEnv(process.env, target.env),
}); });
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => { proc.once('error', (error) => {
@@ -1462,7 +1499,7 @@ export function launchAppCommandDetached(
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: ['ignore', stdoutFd, stderrFd], stdio: ['ignore', stdoutFd, stderrFd],
detached: true, detached: true,
env: buildAppEnv(), env: buildAppEnv(process.env, target.env),
}); });
proc.once('error', (error) => { proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); 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_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 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_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local utils = ctx.utils
local opts = ctx.opts local opts = ctx.opts
local state = ctx.state local state = ctx.state
local binary = ctx.binary local binary = ctx.binary
@@ -17,6 +20,8 @@ function M.create(ctx)
local show_osd = ctx.log.show_osd local show_osd = ctx.log.show_osd
local normalize_log_level = ctx.log.normalize_log_level local normalize_log_level = ctx.log.normalize_log_level
local run_control_command_async 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 function resolve_visible_overlay_startup()
local raw_visible_overlay = opts.auto_start_visible_overlay 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 function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed 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_timeout()
clear_auto_play_ready_osd_timer() clear_auto_play_ready_osd_timer()
state.auto_play_ready_gate_armed = false 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) mp.set_property_native("pause", false)
end end
end end
@@ -124,17 +131,26 @@ function M.create(ctx)
if not state.auto_play_ready_gate_armed then if not state.auto_play_ready_gate_armed then
return return
end end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
mp.set_property_native("pause", false)
show_osd(AUTO_PLAY_READY_READY_OSD) 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 end
local function arm_auto_play_ready_gate() 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_timeout()
clear_auto_play_ready_osd_timer() clear_auto_play_ready_osd_timer()
end 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 state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true) mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_osd(AUTO_PLAY_READY_LOADING_OSD)
@@ -223,12 +239,75 @@ function M.create(ctx)
return args return args
end 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) run_control_command_async = function(action, overrides, callback)
local args = build_command_args(action, overrides) local args = build_command_args(action, overrides)
local command = build_subprocess_command(args)
subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
mp.command_native_async({ mp.command_native_async({
name = "subprocess", name = "subprocess",
args = args, args = command.args,
env = command.env,
playback_only = false, playback_only = false,
capture_stdout = true, capture_stdout = true,
capture_stderr = true, capture_stderr = true,
@@ -240,11 +319,33 @@ function M.create(ctx)
end) end)
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 function run_binary_command_async(args, callback)
local command = build_subprocess_command(args)
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " ")) subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
mp.command_native_async({ mp.command_native_async({
name = "subprocess", name = "subprocess",
args = args, args = command.args,
env = command.env,
playback_only = false, playback_only = false,
capture_stdout = true, capture_stdout = true,
capture_stderr = true, capture_stderr = true,
@@ -355,9 +456,11 @@ function M.create(ctx)
end end
state.overlay_running = true state.overlay_running = true
local command = build_subprocess_command(args)
mp.command_native_async({ mp.command_native_async({
name = "subprocess", name = "subprocess",
args = args, args = command.args,
env = command.env,
playback_only = false, playback_only = false,
capture_stdout = true, capture_stdout = true,
capture_stderr = true, capture_stderr = true,
@@ -521,37 +624,49 @@ function M.create(ctx)
state.texthooker_running = false state.texthooker_running = false
state.suppress_ready_overlay_restore = false state.suppress_ready_overlay_restore = false
state.force_ready_overlay_restore = true 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", { wait_for_app_ping_state(false, "release the single-instance lock", function()
show_visible_overlay = true, local start_args = build_command_args("start", {
}) show_visible_overlay = true,
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " ")) })
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
state.overlay_running = true state.overlay_running = true
mp.command_native_async({ local command = build_subprocess_command(start_args)
name = "subprocess", mp.command_native_async({
args = start_args, name = "subprocess",
playback_only = false, args = command.args,
capture_stdout = true, env = command.env,
capture_stderr = true, playback_only = false,
}, function(success, result, error) capture_stdout = true,
if not success or (result and result.status ~= 0) then capture_stderr = true,
state.overlay_running = false }, function(success, result, error)
subminer_log( if not success or (result and result.status ~= 0) then
"error", state.overlay_running = false
"process", subminer_log(
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") "error",
) "process",
show_osd("Restart failed") "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
else )
show_osd("Restarted successfully") 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
end, function()
show_osd("Restart failed")
end) end)
if resolve_texthooker_enabled(nil) then
ensure_texthooker_running(function() end)
end
end) end)
end end
+1
View File
@@ -30,6 +30,7 @@ function M.new()
prompt_shown = false, prompt_shown = false,
}, },
auto_play_ready_gate_armed = false, auto_play_ready_gate_armed = false,
auto_play_ready_should_resume_playback = false,
auto_play_ready_timeout = nil, auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
+198 -2
View File
@@ -12,6 +12,7 @@ local function run_plugin_scenario(config)
logs = {}, logs = {},
property_sets = {}, property_sets = {},
periodic_timers = {}, periodic_timers = {},
timeouts = {},
} }
local function make_mp_stub() local function make_mp_stub()
@@ -40,6 +41,9 @@ local function run_plugin_scenario(config)
end end
function mp.get_property_native(name) function mp.get_property_native(name)
if name == "pause" then
return config.paused == true
end
if name == "osd-dimensions" then if name == "osd-dimensions" then
return config.osd_dimensions or { return config.osd_dimensions or {
w = 1280, w = 1280,
@@ -109,6 +113,13 @@ local function run_plugin_scenario(config)
end end
end end
for _, value in ipairs(args) do 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 if value == "--stop" and config.stop_command_fails then
local stderr = config.stop_command_stderr or "stop failed" local stderr = config.stop_command_stderr or "stop failed"
callback(false, { status = 1, stdout = "", stderr = stderr }, stderr) callback(false, { status = 1, stdout = "", stderr = stderr }, stderr)
@@ -120,6 +131,7 @@ local function run_plugin_scenario(config)
end end
function mp.add_timeout(seconds, callback) function mp.add_timeout(seconds, callback)
recorded.timeouts[#recorded.timeouts + 1] = seconds
local timeout = { local timeout = {
killed = false, killed = false,
} }
@@ -192,6 +204,9 @@ local function run_plugin_scenario(config)
name = name, name = name,
value = value, value = value,
} }
if name == "pause" then
config.paused = value == true
end
end end
function mp.set_property(name, value) function mp.set_property(name, value)
recorded.property_sets[#recorded.property_sets + 1] = { recorded.property_sets[#recorded.property_sets + 1] = {
@@ -229,6 +244,10 @@ local function run_plugin_scenario(config)
return table.concat(parts, "/") return table.concat(parts, "/")
end end
function utils.get_env_list()
return config.env_list or {}
end
function utils.parse_json(json) function utils.parse_json(json)
if json == '{"enabled":true,"amount":125}' then if json == '{"enabled":true,"amount":125}' then
return { return {
@@ -405,6 +424,29 @@ local function find_control_call(async_calls, flag)
return nil return nil
end 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 function count_control_calls(async_calls, flag)
local count = 0 local count = 0
for _, call in ipairs(async_calls) do for _, call in ipairs(async_calls) do
@@ -510,6 +552,35 @@ local function count_osd_message(messages, target)
return count return count
end 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 function count_property_set(property_sets, name, value)
local count = 0 local count = 0
for _, call in ipairs(property_sets) do for _, call in ipairs(property_sets) do
@@ -544,6 +615,7 @@ local function has_key_binding(recorded, keys, name)
end end
local binary_path = "/tmp/subminer-binary" local binary_path = "/tmp/subminer-binary"
local appimage_path = "/tmp/SubMiner.AppImage"
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
@@ -569,6 +641,42 @@ end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", 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 = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
@@ -590,6 +698,25 @@ do
restart_binding.fn() restart_binding.fn()
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "manual restart should issue --start command") 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( assert_true(
call_has_arg(start_call, "--show-visible-overlay"), call_has_arg(start_call, "--show-visible-overlay"),
"manual restart should bring the visible overlay back after process reload" "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"), not call_has_arg(start_call, "--hide-visible-overlay"),
"manual restart should not restart into hidden visible-overlay state" "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 end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", 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 = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
@@ -629,8 +794,8 @@ do
recorded.script_messages["subminer-restart"]() recorded.script_messages["subminer-restart"]()
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"manual restart should re-assert visible overlay on readiness even when auto-start visibility is disabled" "manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled"
) )
end end
@@ -1129,6 +1294,37 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
+5
View File
@@ -236,6 +236,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(help), false); assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(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']); const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true); assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
+6
View File
@@ -74,6 +74,7 @@ export interface CliArgs {
texthooker: boolean; texthooker: boolean;
texthookerOpenBrowser: boolean; texthookerOpenBrowser: boolean;
help: boolean; help: boolean;
appPing?: boolean;
update?: boolean; update?: boolean;
updateLauncherPath?: string; updateLauncherPath?: string;
updateResponsePath?: string; updateResponsePath?: string;
@@ -172,6 +173,7 @@ export function parseArgs(argv: string[]): CliArgs {
texthooker: false, texthooker: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
help: false, help: false,
appPing: false,
update: false, update: false,
updateLauncherPath: undefined, updateLauncherPath: undefined,
updateResponsePath: 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 === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true; else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--open-browser') args.texthookerOpenBrowser = 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 === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) { else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1]; const value = arg.split('=', 2)[1];
@@ -540,6 +543,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce || args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth || args.jellyfinPreviewAuth ||
args.texthooker || args.texthooker ||
args.appPing ||
args.update || args.update ||
args.generateConfig || args.generateConfig ||
args.help args.help
@@ -612,6 +616,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPlay && !args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce && !args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth && !args.jellyfinPreviewAuth &&
!args.appPing &&
!args.update && !args.update &&
!args.help && !args.help &&
!args.autoStartOverlay && !args.autoStartOverlay &&
@@ -737,6 +742,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinRemoteAnnounce && !args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth && !args.jellyfinPreviewAuth &&
!args.texthooker && !args.texthooker &&
!args.appPing &&
!args.update && !args.update &&
!args.help && !args.help &&
!args.autoStartOverlay && !args.autoStartOverlay &&
+31
View File
@@ -69,6 +69,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false, texthooker: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
help: false, help: false,
appPing: false,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
backupOverwrite: false, backupOverwrite: false,
@@ -91,6 +92,9 @@ function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
quitApp: () => { quitApp: () => {
calls.push('quitApp'); calls.push('quitApp');
}, },
exitApp: (code) => {
calls.push(`exit:${code}`);
},
onSecondInstance: () => {}, onSecondInstance: () => {},
handleCliCommand: () => {}, handleCliCommand: () => {},
printHelp: () => { printHelp: () => {
@@ -136,3 +140,30 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
assert.equal(getLockCalls(), 1); 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; parseArgs: (argv: string[]) => CliArgs;
requestSingleInstanceLock: () => boolean; requestSingleInstanceLock: () => boolean;
quitApp: () => void; quitApp: () => void;
exitApp: (code: number) => void;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void; onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void; printHelp: () => void;
@@ -27,6 +28,7 @@ export interface AppLifecycleServiceDeps {
interface AppLike { interface AppLike {
requestSingleInstanceLock: () => boolean; requestSingleInstanceLock: () => boolean;
quit: () => void; quit: () => void;
exit?: (exitCode?: number) => void;
on: (...args: any[]) => unknown; on: (...args: any[]) => unknown;
whenReady: () => Promise<void>; whenReady: () => Promise<void>;
} }
@@ -54,6 +56,14 @@ export function createAppLifecycleDepsRuntime(
parseArgs: options.parseArgs, parseArgs: options.parseArgs,
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(), quitApp: () => options.app.quit(),
exitApp: (code) => {
if (options.app.exit) {
options.app.exit(code);
return;
}
process.exitCode = code;
options.app.quit();
},
onSecondInstance: (handler) => { onSecondInstance: (handler) => {
options.app.on('second-instance', handler as (...args: unknown[]) => void); 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(); const gotTheLock = deps.requestSingleInstanceLock();
if (initialArgs.appPing) {
deps.exitApp(gotTheLock ? 1 : 0);
return;
}
if (!gotTheLock) { if (!gotTheLock) {
deps.quitApp(); deps.quitApp();
return; return;
+35
View File
@@ -385,6 +385,41 @@ test('MpvIpcClient connect does not force primary subtitle visibility from bindi
assert.equal(hasPrimaryVisibilityMutation, false); 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', () => { test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => {
const commands: unknown[] = []; const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); 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.connected = true;
this.connecting = false; this.connecting = false;
this.socket = this.transport.getSocket(); this.socket = this.transport.getSocket();
this.emit('connection-change', { connected: true });
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.hasConnectedOnce = true; this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false); this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this)); subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this)); requestMpvInitialState(this.send.bind(this));
this.emit('connection-change', { connected: true });
const shouldAutoStart = const shouldAutoStart =
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true; this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
@@ -13,15 +13,26 @@ type WindowTrackerStub = {
function createMainWindowRecorder() { function createMainWindowRecorder() {
const calls: string[] = []; const calls: string[] = [];
const listeners = new Map<string, Array<() => void>>();
let visible = false; let visible = false;
let focused = false; let focused = false;
let opacity = 1; let opacity = 1;
let contentReady = true; let contentReady = true;
const emit = (event: string): void => {
const handlers = listeners.get(event) ?? [];
listeners.delete(event);
for (const handler of handlers) {
handler();
}
};
const window = { const window = {
webContents: {}, webContents: {},
isDestroyed: () => false, isDestroyed: () => false,
isVisible: () => visible, isVisible: () => visible,
isFocused: () => focused, isFocused: () => focused,
once: (event: string, handler: () => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), handler]);
},
hide: () => { hide: () => {
visible = false; visible = false;
focused = false; focused = false;
@@ -30,10 +41,12 @@ function createMainWindowRecorder() {
show: () => { show: () => {
visible = true; visible = true;
calls.push('show'); calls.push('show');
emit('show');
}, },
showInactive: () => { showInactive: () => {
visible = true; visible = true;
calls.push('show-inactive'); calls.push('show-inactive');
emit('show');
}, },
focus: () => { focus: () => {
focused = true; focused = true;
@@ -216,6 +229,44 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
assert.ok(!calls.includes('osd')); 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', () => { test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
+18
View File
@@ -270,6 +270,23 @@ export function updateVisibleOverlayVisibility(args: {
args.markOverlayLoadingOsdShown?.(); 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) { if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.(); args.resetOverlayLoadingOsdSuppression?.();
@@ -298,6 +315,7 @@ export function updateVisibleOverlayVisibility(args: {
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {
args.updateVisibleOverlayBounds(geometry); args.updateVisibleOverlayBounds(geometry);
refreshNonNativeOverlayBoundsAfterFirstShow(geometry);
} }
args.syncPrimaryOverlayWindowLayer('visible'); args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
@@ -14,6 +14,33 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.webPreferences?.backgroundThrottling, false); 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', () => { test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
+2 -1
View File
@@ -16,6 +16,7 @@ export function buildOverlayWindowOptions(
): BrowserWindowConstructorOptions { ): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev; const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible'); const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
return { return {
show: false, show: false,
@@ -29,7 +30,7 @@ export function buildOverlayWindowOptions(
frame: false, frame: false,
alwaysOnTop: shouldStartAlwaysOnTop, alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true, skipTaskbar: true,
resizable: false, resizable: shouldAllowCompositorResize,
hasShadow: false, hasShadow: false,
focusable: true, focusable: true,
acceptFirstMouse: true, acceptFirstMouse: true,
@@ -122,6 +122,35 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex
assert.equal(result[3]?.frequencyRank, 11); 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', () => { test('annotateTokens preserves existing frequency rank when frequency is enabled', () => {
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })]; 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( function isFrequencyExcludedByPos(
token: MergedToken, token: MergedToken,
pos1Exclusions: ReadonlySet<string>, pos1Exclusions: ReadonlySet<string>,
@@ -207,12 +236,19 @@ function isFrequencyExcludedByPos(
pos1Exclusions, pos1Exclusions,
pos2Exclusions, pos2Exclusions,
); );
const allowDeterminerLedNounToken = shouldAllowDeterminerLedNounFrequency(
normalizedPos1,
normalizedPos2,
pos1Exclusions,
pos2Exclusions,
);
const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token); const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token);
const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token); const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token);
if ( if (
isExcludedByTagSet(normalizedPos1, pos1Exclusions) && isExcludedByTagSet(normalizedPos1, pos1Exclusions) &&
!allowContentLedMergedToken && !allowContentLedMergedToken &&
!allowDeterminerLedNounToken &&
!allowOrdinalPrefixNounToken && !allowOrdinalPrefixNounToken &&
!allowHonorificPrefixNounToken !allowHonorificPrefixNounToken
) { ) {
@@ -222,6 +258,7 @@ function isFrequencyExcludedByPos(
if ( if (
isExcludedByTagSet(normalizedPos2, pos2Exclusions) && isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
!allowContentLedMergedToken && !allowContentLedMergedToken &&
!allowDeterminerLedNounToken &&
!allowOrdinalPrefixNounToken && !allowOrdinalPrefixNounToken &&
!allowHonorificPrefixNounToken !allowHonorificPrefixNounToken
) { ) {
+17
View File
@@ -14,6 +14,7 @@ import {
shouldHandleHelpOnlyAtEntry, shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs,
} from './main-entry-runtime'; } from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { 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', () => { test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true); assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false); assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
+34
View File
@@ -7,6 +7,9 @@ const BACKGROUND_ARG = '--background';
const START_ARG = '--start'; const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store'; const PASSWORD_STORE_ARG = '--password-store';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; 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 APP_NAME = 'SubMiner';
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([ const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
'--alang', '--alang',
@@ -83,9 +86,40 @@ function parseCliArgs(argv: string[]): CliArgs {
return parseArgs(argv); 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[] { export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
if (env.ELECTRON_RUN_AS_NODE === '1') return argv; 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)); const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
if (effectiveArgs.length === 0) { if (effectiveArgs.length === 0) {
if (process.platform === 'win32') { if (process.platform === 'win32') {
+3 -1
View File
@@ -13,6 +13,7 @@ import {
sanitizeBackgroundEnv, sanitizeBackgroundEnv,
sanitizeHelpEnv, sanitizeHelpEnv,
sanitizeLaunchMpvEnv, sanitizeLaunchMpvEnv,
hasTransportedStartupArgs,
shouldDetachBackgroundLaunch, shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry, shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
@@ -184,7 +185,8 @@ registerFatalErrorHandlers({
}); });
if (shouldDetachBackgroundLaunch(process.argv, process.env)) { 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, detached: true,
stdio: 'ignore', stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env), env: sanitizeBackgroundEnv(process.env),
+2 -1
View File
@@ -732,6 +732,7 @@ type BootServices = MainBootServicesResult<
{ {
requestSingleInstanceLock: () => boolean; requestSingleInstanceLock: () => boolean;
quit: () => void; quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown; on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>; whenReady: () => Promise<void>;
} }
@@ -3435,7 +3436,7 @@ const {
stopConfigHotReload: () => configHotReloadRuntime.stop(), stopConfigHotReload: () => configHotReloadRuntime.stop(),
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => { restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles(); restoreOverlayMpvSubtitles({ force: true });
}, },
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => { stopSubtitleWebsocket: () => {
+6 -1
View File
@@ -6,6 +6,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
type MockAppLifecycleApp = { type MockAppLifecycleApp = {
requestSingleInstanceLock: () => boolean; requestSingleInstanceLock: () => boolean;
quit: () => void; quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp; on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
whenReady: () => Promise<void>; whenReady: () => Promise<void>;
}; };
@@ -54,6 +55,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
setPathValue = value; setPathValue = value;
}, },
quit: () => {}, quit: () => {},
exit: (code?: number) => {
calls.push(`exit:${code ?? 0}`);
},
on: (event: string) => { on: (event: string) => {
appOnCalls.push(event); appOnCalls.push(event);
return {}; return {};
@@ -123,8 +127,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp.on('second-instance', () => {}),
services.appLifecycleApp, services.appLifecycleApp,
); );
services.appLifecycleApp.exit(7);
assert.deepEqual(appOnCalls, ['ready']); assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true); 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'); assert.equal(setPathValue, '/tmp/subminer-config');
}); });
+3
View File
@@ -4,6 +4,7 @@ import { ConfigStartupParseError } from '../../config';
export interface AppLifecycleShape { export interface AppLifecycleShape {
requestSingleInstanceLock: () => boolean; requestSingleInstanceLock: () => boolean;
quit: () => void; quit: () => void;
exit: (code?: number) => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown; on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>; whenReady: () => Promise<void>;
} }
@@ -50,6 +51,7 @@ export interface MainBootServicesParams<
app: { app: {
setPath: (name: string, value: string) => void; setPath: (name: string, value: string) => void;
quit: () => void; quit: () => void;
exit: (code?: number) => void;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
on: Function; on: Function;
whenReady: () => Promise<void>; whenReady: () => Promise<void>;
@@ -260,6 +262,7 @@ export function createMainBootServices<
requestSingleInstanceLock: () => requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(), params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(), quit: () => params.app.quit(),
exit: (code?: number) => params.app.exit(code),
on: (event: string, listener: (...args: unknown[]) => void) => { on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') { if (event === 'second-instance') {
params.registerSecondInstanceHandlerEarly( params.registerSecondInstanceHandlerEarly(
+40 -5
View File
@@ -9,22 +9,40 @@ import * as earlySingleInstance from './early-single-instance';
function createFakeApp(lockValue = true) { function createFakeApp(lockValue = true) {
let requestCalls = 0; 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 { return {
app: { app: {
requestSingleInstanceLock: () => { requestSingleInstanceLock: (additionalData?: unknown) => {
requestCalls += 1; requestCalls += 1;
requestData = additionalData ?? null;
return lockValue; 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; secondInstanceListener = listener;
}, },
}, },
emitSecondInstance: (argv: string[]) => { emitSecondInstance: (argv: string[], additionalData?: unknown) => {
secondInstanceListener?.({}, argv); secondInstanceListener?.({}, argv, '/tmp', additionalData);
}, },
getRequestCalls: () => requestCalls, 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', () => { test('stats daemon args bypass the normal single-instance lock path', () => {
const shouldBypass = ( const shouldBypass = (
earlySingleInstance as typeof earlySingleInstance & { earlySingleInstance as typeof earlySingleInstance & {
+35 -6
View File
@@ -1,8 +1,18 @@
interface ElectronSecondInstanceAppLike { interface ElectronSecondInstanceAppLike {
requestSingleInstanceLock: () => boolean; requestSingleInstanceLock: (additionalData?: Record<string, unknown>) => boolean;
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown; 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 { export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
return argv.includes('--stats-background') || argv.includes('--stats-stop'); return argv.includes('--stats-background') || argv.includes('--stats-stop');
} }
@@ -12,10 +22,24 @@ let secondInstanceListenerAttached = false;
const secondInstanceArgvHistory: string[][] = []; const secondInstanceArgvHistory: string[][] = [];
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>(); 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 { function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
if (secondInstanceListenerAttached) return; if (secondInstanceListenerAttached) return;
app.on('second-instance', (event, argv) => { app.on('second-instance', (event, argv, _workingDirectory, additionalData) => {
const clonedArgv = [...argv]; const clonedArgv = normalizeSecondInstanceArgv(argv, additionalData);
secondInstanceArgvHistory.push(clonedArgv); secondInstanceArgvHistory.push(clonedArgv);
for (const handler of secondInstanceHandlers) { for (const handler of secondInstanceHandlers) {
handler(event, [...clonedArgv]); handler(event, [...clonedArgv]);
@@ -24,12 +48,17 @@ function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void
secondInstanceListenerAttached = true; secondInstanceListenerAttached = true;
} }
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean { export function requestSingleInstanceLockEarly(
app: ElectronSecondInstanceAppLike,
argv: readonly string[] = process.argv,
): boolean {
attachSecondInstanceListener(app); attachSecondInstanceListener(app);
if (cachedSingleInstanceLock !== null) { if (cachedSingleInstanceLock !== null) {
return cachedSingleInstanceLock; return cachedSingleInstanceLock;
} }
cachedSingleInstanceLock = app.requestSingleInstanceLock(); cachedSingleInstanceLock = app.requestSingleInstanceLock({
[SECOND_INSTANCE_ARGV_KEY]: [...argv],
});
return cachedSingleInstanceLock; return cachedSingleInstanceLock;
} }
@@ -104,6 +104,36 @@ test('restore keeps mpv subtitles hidden when visible-overlay binding still requ
assert.deepEqual(calls, [false]); 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', () => { test('restore defers mpv subtitle restore while mpv is disconnected', () => {
const state: VisibilityState = { const state: VisibilityState = {
savedSubVisibility: true, savedSubVisibility: true,
@@ -3,6 +3,10 @@ type MpvVisibilityClient = {
requestProperty: (name: string) => Promise<unknown>; requestProperty: (name: string) => Promise<unknown>;
}; };
type RestoreOverlayMpvSubtitlesOptions = {
force?: boolean;
};
function parseSubVisibility(value: unknown): boolean { function parseSubVisibility(value: unknown): boolean {
if (typeof value === 'string') { if (typeof value === 'string') {
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
@@ -81,11 +85,11 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: {
shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean; shouldKeepSuppressedFromVisibleOverlayBinding: () => boolean;
setMpvSubVisibility: (visible: boolean) => void; setMpvSubVisibility: (visible: boolean) => void;
}) { }) {
return (): void => { return (options: RestoreOverlayMpvSubtitlesOptions = {}): void => {
deps.setRevision(deps.getRevision() + 1); deps.setRevision(deps.getRevision() + 1);
const savedVisibility = deps.getSavedSubVisibility(); const savedVisibility = deps.getSavedSubVisibility();
if (deps.shouldKeepSuppressedFromVisibleOverlayBinding()) { if (!options.force && deps.shouldKeepSuppressedFromVisibleOverlayBinding()) {
deps.setMpvSubVisibility(false); deps.setMpvSubVisibility(false);
return; return;
} }
@@ -101,6 +101,8 @@ test('createCurlFetch requests updater metadata without Electron networking', as
'--show-error', '--show-error',
'--connect-timeout', '--connect-timeout',
'30', '30',
'--max-time',
'60',
'--header', '--header',
'Accept: application/vnd.github+json', 'Accept: application/vnd.github+json',
'--header', '--header',
@@ -108,4 +110,5 @@ test('createCurlFetch requests updater metadata without Electron networking', as
'https://api.github.com/repos/ksyasuda/SubMiner/releases', 'https://api.github.com/repos/ksyasuda/SubMiner/releases',
]); ]);
assert.equal(calls[0]?.options.encoding, 'buffer'); 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'; const curlPath = options.curlPath ?? '/usr/bin/curl';
return async (url, init = {}) => { 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); addHeaderArgs(args, init.headers);
args.push(url); args.push(url);
const body = await new Promise<Buffer>((resolve, reject) => { const body = await new Promise<Buffer>((resolve, reject) => {
@@ -77,6 +86,7 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
{ {
encoding: 'buffer', encoding: 'buffer',
maxBuffer: 600 * 1024 * 1024, maxBuffer: 600 * 1024 * 1024,
timeout: 65_000,
}, },
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (error) { if (error) {
@@ -165,3 +165,32 @@ test('yomitan extension runtime notifies once after concurrent ensure load resol
assert.equal(await second, fakeExtension); assert.equal(await second, fakeExtension);
assert.deepEqual(notifications, [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 lastNotifiedExtension: Extension | null = null;
let notifyingExtension: Extension | null = null;
let notificationPromise: Promise<void> | null = null;
async function notifyYomitanExtensionLoaded(extension: Extension | null): Promise<void> { async function notifyYomitanExtensionLoaded(extension: Extension | null): Promise<void> {
if (!extension || extension === lastNotifiedExtension) { if (!extension || extension === lastNotifiedExtension) {
return; return;
} }
lastNotifiedExtension = extension; if (extension === notifyingExtension && notificationPromise) {
await deps.onYomitanExtensionLoaded?.(extension); 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 { return {