mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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:
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
+123
-8
@@ -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)
|
||||
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,17 +624,20 @@ 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 })
|
||||
|
||||
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
|
||||
local command = build_subprocess_command(start_args)
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = start_args,
|
||||
args = command.args,
|
||||
env = command.env,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
@@ -545,13 +651,22 @@ function M.create(ctx)
|
||||
)
|
||||
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)
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user