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
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = "",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user