add app control server for launcher-to-app attachment

- Launcher detects a running app via control socket and attaches without spawning a new process
- Own-lifecycle app launches now pass --background --managed-playback; borrowed apps skip --background
- Separate plain subtitle websocket (tokens: []) from annotation websocket
- Default pauseVideoOnHover to true; update docs and config.example.jsonc
- Setup: remove plugin readiness card, add Open SubMiner Settings button
This commit is contained in:
2026-05-21 01:32:58 -07:00
parent 47f92129af
commit 355d7d95b2
58 changed files with 1618 additions and 205 deletions
+1
View File
@@ -101,6 +101,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
+1 -1
View File
@@ -69,7 +69,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
pauseVideoOnHover: true,
autoScroll: true,
css: {},
maxWidth: 420,
+8
View File
@@ -79,6 +79,14 @@ test('settings registry orders websocket server immediately after annotation web
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
});
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
assert.equal(field('websocket.enabled').defaultValue, false);
assert.equal(
field('websocket.enabled').description,
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
);
});
test('settings registry places immersion tracking after other tracking and app sections', () => {
const trackingSections = [
...new Set(
+2
View File
@@ -247,6 +247,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in milliseconds.',
};
+56
View File
@@ -224,6 +224,62 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
test('startAppLifecycle routes control socket commands through the second-instance queue', async () => {
const handled: string[] = [];
let controlArgvHandler: ((argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
let releaseReady: (() => void) | null = null;
const readyFinished = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const { deps } = createDeps({
shouldStartApp: () => true,
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
startControlServer: (handler) => {
controlArgvHandler = handler;
return () => {
handled.push('control-close');
};
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
await readyFinished;
handled.push('ready');
},
});
let willQuitHandler: (() => void) | null = null;
deps.onWillQuit = (handler) => {
willQuitHandler = handler;
};
startAppLifecycle(makeArgs({ background: true }), deps);
assert.ok(controlArgvHandler);
(controlArgvHandler as (argv: string[]) => void)(['--start']);
assert.deepEqual(handled, []);
assert.ok(readyHandler);
const readyRun = (readyHandler as () => Promise<void>)();
await Promise.resolve();
assert.deepEqual(handled, []);
assert.ok(releaseReady);
(releaseReady as () => void)();
await readyRun;
assert.deepEqual(handled, ['ready', 'second-instance:start']);
assert.ok(willQuitHandler);
(willQuitHandler as () => void)();
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
});
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
+17 -1
View File
@@ -13,6 +13,7 @@ export interface AppLifecycleServiceDeps {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
@@ -41,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -70,6 +72,7 @@ export function createAppLifecycleDepsRuntime(
handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance,
startControlServer: options.startControlServer,
whenReady: (handler) => {
options.app
.whenReady()
@@ -116,6 +119,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = [];
let stopControlServer: (() => void) | null = null;
const handleSecondInstanceCommand = (args: CliArgs): void => {
try {
deps.handleCliCommand(args, 'second-instance');
@@ -133,7 +137,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}
};
deps.onSecondInstance((_event, argv) => {
const dispatchSecondInstanceArgv = (argv: string[]): void => {
try {
const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
@@ -145,6 +149,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
deps.onSecondInstance((_event, argv) => {
dispatchSecondInstanceArgv(argv);
});
if (!deps.shouldStartApp(initialArgs)) {
@@ -157,6 +165,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
return;
}
try {
stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
} catch (error) {
logger.error('Failed to start app control socket:', error);
}
deps.whenReady(async () => {
await deps.onReady();
appReadyRuntimeComplete = true;
@@ -173,6 +187,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
});
deps.onWillQuit(() => {
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup();
});
+131 -1
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
import {
CliCommandServiceDeps,
createCliCommandDepsRuntime,
handleCliCommand,
} from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
@@ -501,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
assert.ok(calls.includes('connectMpvClient'));
});
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
const calls: string[] = [];
const client = {
setSocketPath: (socketPath: string) => {
calls.push(`setSocketPath:${socketPath}`);
},
connect: () => {
calls.push('connect');
},
reconnect: () => {
calls.push('reconnect');
},
};
const deps = createCliCommandDepsRuntime({
mpv: {
getSocketPath: () => '/tmp/runtime.sock',
setSocketPath: () => {},
getClient: () => client,
showOsd: () => {},
},
texthooker: {
service: { isRunning: () => false, start: () => {} },
getPort: () => 5174,
setPort: () => {},
getWebsocketUrl: () => undefined,
shouldOpenBrowser: () => false,
openInBrowser: () => {},
},
overlay: {
isInitialized: () => true,
initialize: () => {},
toggleVisible: () => {},
togglePrimarySubtitleBar: () => {},
setVisible: () => {},
},
mining: {
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWords: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
},
anilist: {
getStatus: () => ({
tokenStatus: 'not_checked',
tokenSource: 'none',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearToken: () => {},
openSetup: () => {},
getQueueStatus: () => ({
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
},
dictionary: {
generate: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 0,
}),
getSelection: async () => ({
seriesKey: 'test',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setSelection: async () => ({
ok: true,
seriesKey: 'test',
selected: { id: 1, title: 'Test', episodes: null },
staleMediaIds: [],
}),
},
jellyfin: {
openSetup: () => {},
runStatsCommand: async () => {},
runCommand: async () => {},
},
ui: {
openFirstRunSetup: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
},
app: {
stop: () => {},
hasMainWindow: () => true,
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
},
dispatchSessionAction: async () => {},
getMultiCopyTimeoutMs: () => 2500,
schedule: () => undefined,
log: () => {},
logDebug: () => {},
warn: () => {},
error: () => {},
});
deps.setMpvClientSocketPath('/tmp/runtime.sock');
deps.connectMpvClient();
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
});
test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
+5
View File
@@ -115,6 +115,7 @@ export interface CliCommandServiceDeps {
interface MpvClientLike {
setSocketPath: (socketPath: string) => void;
connect: () => void;
reconnect?: () => void;
}
interface TexthookerServiceLike {
@@ -235,6 +236,10 @@ export function createCliCommandDepsRuntime(
connectMpvClient: () => {
const client = options.mpv.getClient();
if (!client) return;
if (client.reconnect) {
client.reconnect();
return;
}
client.connect();
},
isTexthookerRunning: () => options.texthooker.service.isRunning(),
+51 -1
View File
@@ -32,6 +32,12 @@ class FakeSocket extends EventEmitter {
}
}
class ManualCloseSocket extends FakeSocket {
override destroy(): void {
this.destroyed = true;
}
}
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
@@ -203,12 +209,15 @@ test('MpvSocketTransport ignores connect requests while already connecting or co
});
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {},
onData: () => {},
onError: () => {},
onClose: () => {},
onClose: () => {
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
@@ -220,4 +229,45 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
assert.equal(transport.isConnected, false);
assert.equal(transport.isConnecting, false);
assert.equal(transport.getSocket(), null);
assert.deepEqual(events, []);
});
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
const events: string[] = [];
const sockets: ManualCloseSocket[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push('connect');
},
onData: () => {
events.push('data');
},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => {
const socket = new ManualCloseSocket();
sockets.push(socket);
return socket as unknown as net.Socket;
},
});
transport.connect();
await wait();
transport.shutdown();
transport.connect();
await wait();
const eventsBeforeStaleSocket = [...events];
sockets[0]!.emit('data', Buffer.from('{}'));
sockets[0]!.emit('error', new Error('stale'));
sockets[0]!.emit('close');
assert.deepEqual(events, eventsBeforeStaleSocket);
assert.equal(transport.isConnected, true);
assert.equal(transport.getSocket(), sockets[1]);
});
+16 -10
View File
@@ -105,32 +105,37 @@ export class MpvSocketTransport {
}
this.connecting = true;
this.socketRef = this.socketFactory();
this.socket = this.socketRef;
const socket = this.socketFactory();
this.socketRef = socket;
this.socket = socket;
this.socketRef.on('connect', () => {
socket.on('connect', () => {
if (this.socketRef !== socket) return;
this.connected = true;
this.connecting = false;
this.callbacks.onConnect();
});
this.socketRef.on('data', (data: Buffer) => {
socket.on('data', (data: Buffer) => {
if (this.socketRef !== socket) return;
this.callbacks.onData(data);
});
this.socketRef.on('error', (error: Error) => {
socket.on('error', (error: Error) => {
if (this.socketRef !== socket) return;
this.connected = false;
this.connecting = false;
this.callbacks.onError(error);
});
this.socketRef.on('close', () => {
socket.on('close', () => {
if (this.socketRef !== socket) return;
this.connected = false;
this.connecting = false;
this.callbacks.onClose();
});
this.socketRef.connect(this.socketPath);
socket.connect(this.socketPath);
}
send(payload: MpvSocketMessagePayload): boolean {
@@ -144,13 +149,14 @@ export class MpvSocketTransport {
}
shutdown(): void {
if (this.socketRef) {
this.socketRef.destroy();
}
const socket = this.socketRef;
this.socketRef = null;
this.socket = null;
this.connected = false;
this.connecting = false;
if (socket) {
socket.destroy();
}
}
getSocket(): net.Socket | null {
+26
View File
@@ -168,6 +168,32 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
assert.equal(requestLogs.length, 1);
});
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const calls: string[] = [];
const resolved: unknown[] = [];
(client as any).connected = true;
(client as any).connecting = false;
(client as any).socket = {};
(client as any).pendingRequests.set(10, (message: unknown) => {
resolved.push(message);
});
(client as any).transport.shutdown = () => {
calls.push('shutdown');
};
(client as any).transport.connect = () => {
calls.push('connect');
};
client.reconnect();
assert.deepEqual(calls, ['shutdown', 'connect']);
assert.equal(client.connected, false);
assert.equal((client as any).connecting, true);
assert.equal((client as any).socket, null);
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
});
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const resolved: unknown[] = [];
+11
View File
@@ -275,6 +275,17 @@ export class MpvIpcClient implements MpvClient {
this.transport.connect();
}
reconnect(): void {
logger.debug('MPV IPC reconnect requested.');
this.transport.shutdown();
this.connected = false;
this.connecting = false;
this.socket = null;
this.playbackPaused = null;
this.failPendingRequests();
this.connect();
}
private scheduleReconnect(): void {
this.reconnectAttempt = scheduleMpvReconnect({
attempt: this.reconnectAttempt,
+32
View File
@@ -217,6 +217,38 @@ test('serializeSubtitleWebsocketMessage emits structured token api payload', ()
});
});
test('serializeSubtitleWebsocketMessage can force plain subtitle payloads', () => {
const payload: SubtitleData = {
text: '無事',
tokens: [
{
surface: '無事',
reading: 'ぶじ',
headword: '無事',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
jlptLevel: 'N2',
frequencyRank: 745,
},
],
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions, {
payloadMode: 'plain',
});
assert.deepEqual(JSON.parse(raw), {
version: 1,
text: '無事',
sentence: '無事',
tokens: [],
});
});
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
const payload: SubtitleData = {
text: 'ignored fallback',
+24 -2
View File
@@ -18,6 +18,12 @@ export type SubtitleWebsocketFrequencyOptions = {
mode: 'single' | 'banded';
};
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
type SubtitleWebsocketMessageOptions = {
payloadMode?: SubtitleWebsocketPayloadMode;
};
type SerializedSubtitleToken = Pick<
MergedToken,
| 'surface'
@@ -198,7 +204,17 @@ export function serializeSubtitleMarkup(
export function serializeSubtitleWebsocketMessage(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string {
if (messageOptions.payloadMode === 'plain') {
return JSON.stringify({
version: 1,
text: payload.text,
sentence: escapeHtml(payload.text).replaceAll('\n', '<br>'),
tokens: [],
});
}
return JSON.stringify({
version: 1,
text: payload.text,
@@ -210,18 +226,21 @@ export function serializeSubtitleWebsocketMessage(
export function serializeInitialSubtitleWebsocketMessage(
payload: SubtitleData | null,
options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string | null {
if (!payload || !payload.text.trim()) {
return null;
}
return serializeSubtitleWebsocketMessage(payload, options);
return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
}
export class SubtitleWebSocket {
private server: WebSocket.Server | null = null;
private latestMessage = '';
public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
public isRunning(): boolean {
return this.server !== null;
}
@@ -247,6 +266,7 @@ export class SubtitleWebSocket {
const currentMessage = serializeInitialSubtitleWebsocketMessage(
getCurrentSubtitleData(),
getFrequencyOptions(),
{ payloadMode: this.payloadMode },
);
if (currentMessage) {
ws.send(currentMessage);
@@ -262,7 +282,9 @@ export class SubtitleWebSocket {
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
if (!this.server) return;
const message = serializeSubtitleWebsocketMessage(payload, options);
const message = serializeSubtitleWebsocketMessage(payload, options, {
payloadMode: this.payloadMode,
});
this.latestMessage = message;
for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) {
+21 -3
View File
@@ -34,6 +34,8 @@ import {
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server';
import { getAppControlSocketPath } from './shared/app-control';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
@@ -166,6 +168,7 @@ import {
rememberAnilistAttemptedUpdateKey,
} from './main/runtime/domains/anilist';
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
import {
createApplyJellyfinMpvDefaultsHandler,
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
@@ -790,7 +793,7 @@ const bootServices = createMainBootServices({
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
}),
createSubtitleWebSocket: () => new SubtitleWebSocket(),
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
createLogger,
createMainRuntimeRegistry,
createOverlayManager,
@@ -3073,6 +3076,12 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
if (submission.action === 'open-config-settings') {
firstRunSetupMessage = openConfigSettingsWindow()
? 'Opened SubMiner settings.'
: 'SubMiner settings are unavailable.';
return { skipRender: true };
}
if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
@@ -5796,6 +5805,16 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
startControlServer: (handleArgv: (argv: string[]) => void) => {
const server = startAppControlServer({
socketPath: getAppControlSocketPath({ configDir: CONFIG_DIR }),
platform: process.platform,
handleArgv,
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
return () => server.close();
},
onReady: runAppReadyRuntimeWithFatalReporting,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
@@ -5943,12 +5962,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
isJellyfinConfigured: () =>
+2
View File
@@ -11,6 +11,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -73,6 +74,7 @@ export function createAppLifecycleRuntimeDeps(
handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance,
startControlServer: params.startControlServer,
onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
+7 -2
View File
@@ -21,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ targetPath: string },
{ targetPath: string },
{ targetPath: string },
{ kind: string },
{ kind: string; payloadMode: 'plain' | 'annotated' },
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getMainWindow: () => null; getModalWindow: () => null },
@@ -76,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: () => ({ kind: 'ws' }),
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
createLogger: (scope) =>
({
scope,
@@ -115,6 +115,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(services.anilistUpdateQueue, {
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
});
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
assert.deepEqual(services.annotationSubtitleWsService, {
kind: 'ws',
payloadMode: 'annotated',
});
assert.deepEqual(services.appState, {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
+3 -3
View File
@@ -64,7 +64,7 @@ export interface MainBootServicesParams<
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
createSubtitleWebSocket: () => TSubtitleWebSocket;
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
createLogger: (scope: string) => TLogger & {
warn: (message: string) => void;
info: (message: string) => void;
@@ -205,8 +205,8 @@ export function createMainBootServices<
const anilistUpdateQueue = params.createAnilistUpdateQueue(
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
);
const subtitleWsService = params.createSubtitleWebSocket();
const annotationSubtitleWsService = params.createSubtitleWebSocket();
const subtitleWsService = params.createSubtitleWebSocket('plain');
const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
const logger = params.createLogger('main');
const runtimeRegistry = params.createMainRuntimeRegistry();
const overlayManager = params.createOverlayManager();
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { sendAppControlCommand } from '../../shared/app-control-client';
import { startAppControlServer } from './app-control-server';
async function waitForSocketPath(socketPath: string): Promise<void> {
const deadline = Date.now() + 1000;
while (Date.now() < deadline) {
if (fs.existsSync(socketPath)) return;
await new Promise<void>((resolve) => setTimeout(resolve, 10));
}
}
test('app control server dispatches argv requests and replies ok', async () => {
if (process.platform === 'win32') return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
const socketPath = path.join(dir, 'control.sock');
const received: string[][] = [];
const server = startAppControlServer({
socketPath,
platform: 'linux',
handleArgv: (argv) => {
received.push(argv);
},
});
try {
await waitForSocketPath(socketPath);
const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
socketPath,
});
assert.deepEqual(result, { ok: true });
assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
+96
View File
@@ -0,0 +1,96 @@
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import {
encodeAppControlResponse,
parseAppControlRequestLine,
type AppControlResponse,
} from '../../shared/app-control';
export interface AppControlServerOptions {
socketPath: string;
platform?: NodeJS.Platform;
handleArgv: (argv: string[]) => void;
logDebug?: (message: string) => void;
logWarn?: (message: string, error?: unknown) => void;
}
export interface AppControlServerHandle {
close: () => void;
}
function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
fs.rmSync(socketPath, { force: true });
}
function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
try {
fs.rmSync(socketPath, { force: true });
} catch {
// ignore
}
}
function writeResponse(socket: net.Socket, response: AppControlResponse): void {
socket.end(encodeAppControlResponse(response));
}
export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
const platform = options.platform ?? process.platform;
prepareSocketPath(options.socketPath, platform);
const server = net.createServer((socket) => {
let buffer = '';
let handled = false;
socket.on('data', (chunk) => {
if (handled) return;
buffer += chunk.toString('utf8');
if (buffer.length > 65536) {
handled = true;
writeResponse(socket, { ok: false, error: 'App control request too large' });
return;
}
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex < 0) return;
handled = true;
try {
const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
options.handleArgv(request.argv);
writeResponse(socket, { ok: true });
} catch (error) {
options.logWarn?.('Failed to handle app control command.', error);
writeResponse(socket, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
});
server.on('error', (error) => {
options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
});
server.listen(options.socketPath, () => {
options.logDebug?.(`App control socket listening: ${options.socketPath}`);
});
let closed = false;
return {
close: () => {
if (closed) return;
closed = true;
try {
server.close();
} catch {
// ignore
}
cleanupSocketPath(options.socketPath, platform);
},
};
}
+101 -5
View File
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Ready/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.doesNotMatch(html, /Bundled ready/);
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Open SubMiner Settings/);
assert.match(
html,
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
@@ -70,7 +75,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /box-sizing:\s*border-box;/);
});
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
@@ -94,10 +99,11 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
});
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
@@ -124,7 +130,8 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
});
assert.match(html, /Legacy mpv plugin/);
assert.match(html, /Legacy detected/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /Found/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
assert.match(html, /Remove legacy mpv plugin/);
@@ -251,6 +258,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
action: 'remove-legacy-plugin',
},
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
{
action: 'open-config-settings',
},
);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
@@ -542,6 +555,89 @@ test('opening first-run setup skips rendering if window is destroyed after snaps
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
});
test('first-run setup action can skip rerender after launching another window', async () => {
const calls: string[] = [];
let navigateHandler: ((event: unknown, url: string) => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: (_event: 'will-navigate', callback: (event: unknown, url: string) => void) => {
navigateHandler = callback;
},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => false,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async () => {
calls.push('action');
return { skipRender: true };
},
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
navigateHandler?.(
{ preventDefault: () => calls.push('preventDefault') },
'subminer://first-run-setup?action=open-config-settings',
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
'set',
'show',
'focus',
'in-progress',
'load',
'show',
'focus',
'preventDefault',
'action',
]);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
+10 -18
View File
@@ -29,6 +29,7 @@ export type FirstRunSetupAction =
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'open-config-settings'
| 'refresh'
| 'finish';
@@ -200,14 +201,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
legacyMpvPluginPaths.length > 0 && model.canFinish
? 'Continue without removing'
: 'Finish setup';
const pluginLabel =
legacyMpvPluginPaths.length > 0
? 'Legacy detected'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Ready';
const pluginTone =
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
@@ -326,7 +319,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
return `<!doctype html>
@@ -522,14 +515,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
</div>
<div class="card">
<div>
<strong>mpv runtime plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
@@ -544,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
@@ -566,6 +552,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
action !== 'install-bun' &&
action !== 'install-command-line-launcher' &&
action !== 'open-yomitan-settings' &&
action !== 'open-config-settings' &&
action !== 'refresh' &&
action !== 'finish'
) {
@@ -632,7 +619,9 @@ export function createOpenFirstRunSetupWindowHandler<
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
handleAction: (
submission: FirstRunSetupSubmission,
) => Promise<{ closeWindow?: boolean; skipRender?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
@@ -680,6 +669,9 @@ export function createOpenFirstRunSetupWindowHandler<
}
return;
}
if (result?.skipRender) {
return;
}
if (!setupWindow.isDestroyed()) {
await render();
}
@@ -144,3 +144,34 @@ test('managed local subtitle selection runtime promotes a single unlabeled exter
['set_property', 'secondary-sid', 1],
]);
});
test('managed local subtitle selection keeps waiting for primary after early secondary-only track list', () => {
const commands: Array<Array<string | number>> = [];
const runtime = createManagedLocalSubtitleSelectionRuntime({
getCurrentMediaPath: () => '/videos/example.mkv',
getMpvClient: () => null,
getPrimarySubtitleLanguages: () => [],
getSecondarySubtitleLanguages: () => [],
sendMpvCommand: (command) => {
commands.push(command);
},
schedule: () => 1 as never,
clearScheduled: () => {},
});
runtime.handleMediaPathChange('/videos/example.mkv');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
]);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
]);
assert.deepEqual(commands, [
['set_property', 'secondary-sid', 2],
['set_property', 'sid', 3],
]);
});
+17 -8
View File
@@ -200,7 +200,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
}) {
const delayMs = deps.delayMs ?? 400;
let currentMediaPath: string | null = null;
let appliedMediaPath: string | null = null;
let appliedPrimaryMediaPath: string | null = null;
let appliedSecondaryMediaPath: string | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
const clearPendingTimer = (): void => {
@@ -212,7 +213,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
};
const maybeApplySelection = (trackList: unknown[] | null): void => {
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
if (
!currentMediaPath ||
(appliedPrimaryMediaPath === currentMediaPath &&
appliedSecondaryMediaPath === currentMediaPath)
) {
return;
}
const selection = resolveManagedLocalSubtitleSelection({
@@ -223,14 +228,17 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
return;
}
if (selection.primaryTrackId !== null) {
if (selection.primaryTrackId !== null && appliedPrimaryMediaPath !== currentMediaPath) {
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
appliedPrimaryMediaPath = currentMediaPath;
}
if (selection.secondaryTrackId !== null) {
if (selection.secondaryTrackId !== null && appliedSecondaryMediaPath !== currentMediaPath) {
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
appliedSecondaryMediaPath = currentMediaPath;
}
if (appliedPrimaryMediaPath === currentMediaPath) {
clearPendingTimer();
}
appliedMediaPath = currentMediaPath;
clearPendingTimer();
};
const refreshFromMpv = async (): Promise<void> => {
@@ -252,7 +260,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
const scheduleRefresh = (): void => {
clearPendingTimer();
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
return;
}
pendingTimer = deps.schedule(() => {
@@ -265,7 +273,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
const normalizedPath = normalizeLocalMediaPath(mediaPath);
if (normalizedPath !== currentMediaPath) {
appliedMediaPath = null;
appliedPrimaryMediaPath = null;
appliedSecondaryMediaPath = null;
}
currentMediaPath = normalizedPath;
if (!currentMediaPath) {
@@ -161,3 +161,67 @@ test('main mpv event binder runs mpv-connected callback on connection', () => {
assert.ok(calls.includes('mpv-connected'));
});
test('main mpv event binder clears media path on disconnect', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => true,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
isMpvConnected: () => false,
quitApp: () => {},
recordImmersionSubtitleLine: () => {},
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
setCurrentSubText: () => {},
broadcastSubtitle: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: () => {},
broadcastSubtitleAss: () => {},
broadcastSecondarySubtitle: () => {},
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
notifyImmersionTitleUpdate: () => {},
recordPlaybackPosition: () => {},
recordMediaDuration: () => {},
reportJellyfinRemoteProgress: () => {},
recordPauseState: () => {},
updateSubtitleRenderMetrics: () => {},
setPreviousSecondarySubVisibility: () => {},
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('connection-change')?.({ connected: false });
assert.ok(calls.includes('media-path:'));
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('presence-refresh'));
});
@@ -101,6 +101,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
}): void => {
if (connected) {
deps.resetSubtitleSidebarEmbeddedLayout();
} else {
deps.updateCurrentMediaPath('');
}
handleMpvConnectionChange({ connected });
};
@@ -11,6 +11,7 @@ export function createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
handleCliCommand: deps.handleCliCommand,
printHelp: deps.printHelp,
logNoRunningInstance: deps.logNoRunningInstance,
startControlServer: deps.startControlServer,
onReady: deps.onReady,
onWillQuitCleanup: deps.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate,
+13 -2
View File
@@ -44,14 +44,14 @@ test('window-all-closed keeps background app alive without tray', () => {
);
});
test('mpv shutdown keeps managed background tray app alive', () => {
test('mpv shutdown quits managed background playback despite tray residency', () => {
assert.equal(
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: true,
backgroundMode: true,
hasTray: true,
}),
false,
true,
);
});
@@ -65,3 +65,14 @@ test('mpv shutdown quits standalone managed playback without tray residency', ()
true,
);
});
test('mpv shutdown keeps unmanaged background tray app alive', () => {
assert.equal(
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: false,
backgroundMode: true,
hasTray: true,
}),
false,
);
});
+2 -4
View File
@@ -27,8 +27,6 @@ export function shouldQuitOnMpvShutdownForTrayState(options: {
backgroundMode: boolean;
hasTray: boolean;
}): boolean {
if (!options.managedPlayback) return false;
if (options.backgroundMode) return false;
if (options.hasTray) return false;
return true;
// managedPlayback marks process ownership; tray/background only affect window-close policy.
return options.managedPlayback;
}
+32 -3
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
createBuildTrayMenuTemplateHandler,
createResolveTrayIconPathHandler,
shouldShowTexthookerTrayEntry,
} from './tray-main-actions';
test('resolve tray icon path handler forwards runtime dependencies', () => {
@@ -47,7 +48,6 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openConfigSettings();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery();
@@ -68,7 +68,6 @@ test('build tray template handler wires actions and init guards', () => {
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -91,7 +90,6 @@ test('build tray template handler wires actions and init guards', () => {
'setup',
'setup',
'yomitan',
'runtime-options',
'configuration',
'jellyfin',
'jellyfin-discovery',
@@ -100,3 +98,34 @@ test('build tray template handler wires actions and init guards', () => {
'quit',
]);
});
test('texthooker tray visibility follows websocket server enabled state', () => {
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: false },
annotationWebsocket: { enabled: false },
}),
false,
);
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: true },
annotationWebsocket: { enabled: false },
}),
true,
);
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: 'auto' },
annotationWebsocket: { enabled: false },
}),
true,
);
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: false },
annotationWebsocket: { enabled: true },
}),
true,
);
});
+9 -8
View File
@@ -26,6 +26,15 @@ export function createResolveTrayIconPathHandler(deps: {
};
}
export function shouldShowTexthookerTrayEntry(config: {
websocket?: { enabled?: boolean | 'auto' };
annotationWebsocket?: { enabled?: boolean };
}): boolean {
const websocketEnabled = config.websocket?.enabled ?? false;
const annotationWebsocketEnabled = config.annotationWebsocket?.enabled ?? false;
return websocketEnabled !== false || annotationWebsocketEnabled !== false;
}
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void;
@@ -36,7 +45,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -55,7 +63,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openConfigSettingsWindow: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -88,12 +95,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => {
deps.openYomitanSettings();
},
openRuntimeOptions: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.openRuntimeOptionsPalette();
},
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
-2
View File
@@ -31,7 +31,6 @@ test('tray main deps builders return mapped handlers', () => {
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -53,7 +52,6 @@ test('tray main deps builders return mapped handlers', () => {
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openConfigSettings: () => calls.push('open-configuration'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
-3
View File
@@ -35,7 +35,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -54,7 +53,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openConfigSettingsWindow: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -75,7 +73,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -31,7 +31,6 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openConfigSettingsWindow: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+10 -9
View File
@@ -37,7 +37,6 @@ test('tray menu template contains expected entries and handlers', () => {
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openConfigSettings: () => calls.push('configuration'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
@@ -48,7 +47,11 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 13);
assert.equal(template.length, 12);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
);
assert.equal(
template.some((entry) => entry.label === 'Open Overlay'),
false,
@@ -61,10 +64,11 @@ test('tray menu template contains expected entries and handlers', () => {
template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
assert.equal(template[10]!.label, 'Check for Updates');
template[10]!.click?.();
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[12]!.click?.();
assert.equal(template[5]!.label, 'Open SubMiner Settings');
assert.equal(template[9]!.label, 'Check for Updates');
template[9]!.click?.();
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery',
'help',
@@ -85,7 +89,6 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -113,7 +116,6 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -139,7 +141,6 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
+1 -6
View File
@@ -38,7 +38,6 @@ export type TrayMenuActionHandlers = {
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -90,11 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
click: handlers.openYomitanSettings,
},
{
label: 'Open Runtime Options',
click: handlers.openRuntimeOptions,
},
{
label: 'Open Settings',
label: 'Open SubMiner Settings',
click: handlers.openConfigSettings,
},
{
+2
View File
@@ -12,6 +12,7 @@ export interface AppLifecycleRuntimeRunnerParams {
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -34,6 +35,7 @@ export function createAppLifecycleRuntimeRunner(
handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance,
startControlServer: params.startControlServer,
onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
+8
View File
@@ -156,3 +156,11 @@ test('discord presence update interval displays seconds while saving millisecond
assert.equal(toSettingsDisplayValue(path, 3000), 3);
assert.equal(toConfigDraftValue(path, 2.5), 2500);
});
test('websocket enabled select values save booleans instead of strings', () => {
assert.equal(toSettingsDisplayValue('websocket.enabled', true), 'true');
assert.equal(toSettingsDisplayValue('websocket.enabled', false), 'false');
assert.equal(toConfigDraftValue('websocket.enabled', 'true'), true);
assert.equal(toConfigDraftValue('websocket.enabled', 'false'), false);
assert.equal(toConfigDraftValue('websocket.enabled', 'auto'), 'auto');
});
+7
View File
@@ -75,6 +75,9 @@ export function toSettingsDisplayValue(
path: string,
value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue {
if (path === 'websocket.enabled' && typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return value / 1000;
}
@@ -85,6 +88,10 @@ export function toConfigDraftValue(
path: string,
value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue {
if (path === 'websocket.enabled') {
if (value === 'true') return true;
if (value === 'false') return false;
}
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return Math.round(value * 1000);
}
+103
View File
@@ -0,0 +1,103 @@
import net from 'node:net';
import {
encodeAppControlRequest,
getAppControlSocketPath,
parseAppControlResponseLine,
type AppControlSocketPathOptions,
} from './app-control';
export interface AppControlClientOptions extends AppControlSocketPathOptions {
socketPath?: string;
timeoutMs?: number;
}
export interface AppControlCommandResult {
ok: boolean;
unavailable?: boolean;
error?: string;
}
function resolveSocketPath(options: AppControlClientOptions): string {
return options.socketPath ?? getAppControlSocketPath(options);
}
export function isAppControlServerAvailable(
options: AppControlClientOptions = {},
): Promise<boolean> {
const socketPath = resolveSocketPath(options);
const timeoutMs = options.timeoutMs ?? 350;
return new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (available: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(available);
};
socket.once('connect', () => finish(typeof socket.write === 'function'));
socket.once('error', () => finish(false));
socket.setTimeout(timeoutMs, () => finish(false));
});
}
export function sendAppControlCommand(
argv: string[],
options: AppControlClientOptions = {},
): Promise<AppControlCommandResult> {
const socketPath = resolveSocketPath(options);
const timeoutMs = options.timeoutMs ?? 1000;
return new Promise<AppControlCommandResult>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
let connected = false;
let responseBuffer = '';
const finish = (result: AppControlCommandResult): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(result);
};
socket.once('connect', () => {
connected = true;
if (typeof socket.write !== 'function') {
finish({ ok: false, unavailable: true, error: 'App control socket is not writable' });
return;
}
socket.write(encodeAppControlRequest(argv));
});
socket.on('data', (chunk) => {
responseBuffer += chunk.toString('utf8');
const newlineIndex = responseBuffer.indexOf('\n');
if (newlineIndex < 0) return;
try {
finish(parseAppControlResponseLine(responseBuffer.slice(0, newlineIndex)));
} catch (error) {
finish({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
socket.once('error', (error) => {
finish({ ok: false, unavailable: !connected, error: error.message });
});
socket.once('close', () => {
finish({ ok: false, unavailable: !connected, error: 'App control socket closed' });
});
socket.setTimeout(timeoutMs, () => {
finish({ ok: false, unavailable: !connected, error: 'App control socket timed out' });
});
});
}
+98
View File
@@ -0,0 +1,98 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
export const SUBMINER_APP_CONTROL_SOCKET_ENV = 'SUBMINER_APP_CONTROL_SOCKET';
export interface AppControlSocketPathOptions {
configDir?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
tmpDir?: string;
}
export interface AppControlRequest {
argv: string[];
}
export interface AppControlResponse {
ok: boolean;
error?: string;
}
function getUserKey(): string {
if (typeof process.getuid === 'function') {
return String(process.getuid());
}
try {
const user = os.userInfo();
if (typeof user.uid === 'number') {
return String(user.uid);
}
if (user.username) {
return user.username.replace(/[^\w.-]/g, '_');
}
} catch {
// Fall back below.
}
return 'user';
}
export function getAppControlSocketPath(options: AppControlSocketPathOptions = {}): string {
const env = options.env ?? process.env;
const override = env[SUBMINER_APP_CONTROL_SOCKET_ENV]?.trim();
if (override) return override;
const platform = options.platform ?? process.platform;
const identity = options.configDir?.trim() || 'default';
const digest = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
if (platform === 'win32') {
return `\\\\.\\pipe\\subminer-control-${digest}`;
}
return path.join(
options.tmpDir ?? os.tmpdir(),
`subminer-control-${getUserKey()}-${digest}.sock`,
);
}
export function encodeAppControlRequest(argv: string[]): string {
return `${JSON.stringify({ argv })}\n`;
}
export function encodeAppControlResponse(response: AppControlResponse): string {
return `${JSON.stringify(response)}\n`;
}
function normalizeArgv(value: unknown): string[] | null {
if (!Array.isArray(value) || value.length > 128) return null;
const argv: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string' || entry.length > 8192) {
return null;
}
argv.push(entry);
}
return argv;
}
export function parseAppControlRequestLine(line: string): AppControlRequest {
const payload = JSON.parse(line) as { argv?: unknown };
const argv = normalizeArgv(payload.argv);
if (!argv) {
throw new Error('Invalid app-control argv payload');
}
return { argv };
}
export function parseAppControlResponseLine(line: string): AppControlResponse {
const payload = JSON.parse(line) as { ok?: unknown; error?: unknown };
if (payload.ok === true) {
return { ok: true };
}
return {
ok: false,
error: typeof payload.error === 'string' ? payload.error : 'App control command failed',
};
}