mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user