Compare commits

..

11 Commits

Author SHA1 Message Date
sudacode 05ac3a0382 test: address runtime nitpick coverage 2026-06-12 01:46:48 -07:00
sudacode 2c5a803839 fix(main): remove obsolete subtitle delay handler wiring 2026-06-12 01:25:26 -07:00
sudacode 572bdd1cf7 fix: address CodeRabbit review findings across runtime modules
- Extract filterLegacyMpvPluginFileCandidates, buildYomitanAnkiSettingsKey, setMpvCurrentSecondarySubText, runSupportAssetUpdatesForLauncherResult helpers
- Include forceOverride in yomitan anki settings cache key (was missing, causing incorrect cache hits)
- Detect same-PID stale stats daemon state to avoid self-connect
- Validate non-empty extension in buildFfmpegSubtitleExtractionArgs
- Drop unused message param from showOverlayLoadingStatusNotification
- Log and rethrow on session bindings artifact write failure
- Add unit tests for all extracted helpers
2026-06-12 01:22:20 -07:00
sudacode b9fe555b94 refactor(main): extract visible-overlay platform interaction runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 8f362063dd refactor(main): extract autoplay subtitle priming runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode eb1af727bb refactor(main): extract overlay geometry runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 1fc83a842d refactor(main): extract overlay notifications runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode a4edf53d21 refactor(main): extract stats server runtime from main.ts 2026-06-12 01:22:20 -07:00
sudacode 1a3944aa4f refactor(main): extract password-store args, mpv plugin detection, yomitan anki sync, session bindings, log export from main.ts 2026-06-12 01:22:20 -07:00
sudacode 2d1b6cb78e refactor(main): extract internal subtitle extraction from main.ts 2026-06-12 01:22:20 -07:00
sudacode 0ef95cde09 refactor(main): extract update service runtime from main.ts 2026-06-12 01:18:40 -07:00
26 changed files with 128 additions and 612 deletions
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle.
-5
View File
@@ -1,5 +0,0 @@
type: internal
area: runtime
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
+2 -2
View File
File diff suppressed because one or more lines are too long
-9
View File
@@ -5307,15 +5307,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
focusMainWindow: () => { focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
if (process.platform === 'darwin') {
focusMacOSOverlayWindow({
platform: process.platform,
getOverlayWindow: () => mainWindow,
stealAppFocus: () => app.focus({ steal: true }),
warn: (message, details) => logger.warn(message, details),
});
return;
}
if (!mainWindow.isFocused()) { if (!mainWindow.isFocused()) {
mainWindow.focus(); mainWindow.focus();
} }
-7
View File
@@ -1,7 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getPasswordStoreArg } from './password-store-args';
test('getPasswordStoreArg ignores split-form whitespace-only values', () => {
assert.equal(getPasswordStoreArg(['SubMiner.AppImage', '--password-store', ' ']), null);
});
+2 -3
View File
@@ -11,9 +11,8 @@ export function getPasswordStoreArg(argv: string[]): string | null {
if (arg === PASSWORD_STORE_ARG) { if (arg === PASSWORD_STORE_ARG) {
const value = argv[i + 1]; const value = argv[i + 1];
const trimmed = value?.trim(); if (value && !value.startsWith('--')) {
if (trimmed && !trimmed.startsWith('--')) { resolved = value.trim();
resolved = trimmed;
i += 1; i += 1;
} }
continue; continue;
@@ -1,9 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import { setMpvCurrentSecondarySubText } from './autoplay-subtitle-priming-runtime';
createAutoplaySubtitlePrimingRuntime,
setMpvCurrentSecondarySubText,
} from './autoplay-subtitle-priming-runtime';
test('setMpvCurrentSecondarySubText uses client setter when available', () => { test('setMpvCurrentSecondarySubText uses client setter when available', () => {
const calls: string[] = []; const calls: string[] = [];
@@ -29,37 +26,3 @@ test('setMpvCurrentSecondarySubText updates client property when setter is unava
assert.equal(client.currentSecondarySubText, 'secondary'); assert.equal(client.currentSecondarySubText, 'secondary');
}); });
test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback', async () => {
const logs: string[] = [];
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => null,
getMpvClient: () => null,
setCurrentSubText: () => {},
getCurrentSubText: () => '',
getCurrentSubtitleData: () => null,
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: () => {},
refreshCurrentSubtitle: () => {},
},
emitSubtitlePayload: () => {},
getSubtitlePrefetchService: () => null,
getLastObservedTimePos: () => 0,
getVisibleOverlayVisible: () => false,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {
throw new Error('refresh failed');
},
logDebug: (message) => logs.push(message),
});
runtime.scheduleSubtitlePrefetchRefresh(0);
await new Promise((resolve) => setTimeout(resolve, 5));
assert.deepEqual(logs, [
'[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed',
]);
});
@@ -253,13 +253,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
clearScheduledSubtitlePrefetchRefresh(); clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchRefreshTimer = setTimeout(() => { subtitlePrefetchRefreshTimer = setTimeout(() => {
subtitlePrefetchRefreshTimer = null; subtitlePrefetchRefreshTimer = null;
void deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => { void deps.refreshSubtitlePrefetchFromActiveTrack();
deps.logDebug(
`[autoplay-subtitle-prime] subtitle prefetch refresh failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}, delayMs); }, delayMs);
} }
@@ -7,6 +7,7 @@ import {
detectInstalledFirstRunPlugin, detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
filterLegacyMpvPluginFileCandidates,
removeLegacyMpvPluginCandidates, removeLegacyMpvPluginCandidates,
resolvePackagedFirstRunPluginAssets, resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath, resolvePackagedRuntimePluginPath,
@@ -220,6 +221,20 @@ test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without v
}); });
}); });
test('filterLegacyMpvPluginFileCandidates keeps only legacy file candidates', () => {
assert.deepEqual(
filterLegacyMpvPluginFileCandidates([
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
]),
[
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
],
);
});
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => { test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
const calls: string[] = []; const calls: string[] = [];
const result = await removeLegacyMpvPluginCandidates({ const result = await removeLegacyMpvPluginCandidates({
+4 -13
View File
@@ -180,19 +180,10 @@ export function detectInstalledFirstRunPluginCandidates(options: {
return candidates; return candidates;
} }
export function detectWindowsMpvPluginRemovalCandidates(options: { export function filterLegacyMpvPluginFileCandidates(
homeDir: string; candidates: InstalledFirstRunPluginCandidate[],
appDataDir: string; ): InstalledFirstRunPluginCandidate[] {
mpvExecutablePath: string; return candidates.filter((candidate) => candidate.kind === 'file');
existsSync?: (candidate: string) => boolean;
}): InstalledFirstRunPluginCandidate[] {
return detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: options.homeDir,
appDataDir: options.appDataDir,
mpvExecutablePath: options.mpvExecutablePath,
existsSync: options.existsSync,
});
} }
function parseInstalledPluginVersion(content: string): string | null { function parseInstalledPluginVersion(content: string): string | null {
@@ -1,14 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import test from 'node:test'; import test from 'node:test';
import { import { buildFfmpegSubtitleExtractionArgs } from './internal-subtitle-extraction';
buildFfmpegSubtitleExtractionArgs,
extractInternalSubtitleTrackToTempFile,
parseTrackId,
} from './internal-subtitle-extraction';
test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => { test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => {
assert.throws( assert.throws(
@@ -16,32 +8,3 @@ test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extensio
/outputPath.*file extension/, /outputPath.*file extension/,
); );
}); });
test('parseTrackId rejects negative track ids', () => {
assert.equal(parseTrackId(-1), null);
assert.equal(parseTrackId(' -2 '), null);
});
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
const videoPath = path.join(root, 'video.mkv');
fs.writeFileSync(videoPath, '');
try {
await assert.rejects(
() =>
extractInternalSubtitleTrackToTempFile(
process.execPath,
videoPath,
{ 'ff-index': 0, codec: 'ass' },
{
extractionTimeoutMs: 20,
spawnArgsOverride: ['-e', 'setTimeout(() => {}, 1000);'],
},
),
/ffmpeg extraction timed out/,
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
@@ -35,15 +35,13 @@ export type MpvSubtitleTrackLike = {
'external-filename'?: unknown; 'external-filename'?: unknown;
}; };
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
export function parseTrackId(value: unknown): number | null { export function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { if (typeof value === 'number' && Number.isInteger(value)) {
return value; return value;
} }
if (typeof value === 'string') { if (typeof value === 'string') {
const parsed = Number(value.trim()); const parsed = Number(value.trim());
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; return Number.isInteger(parsed) ? parsed : null;
} }
return null; return null;
} }
@@ -79,7 +77,6 @@ export async function extractInternalSubtitleTrackToTempFile(
ffmpegPath: string, ffmpegPath: string,
videoPath: string, videoPath: string,
track: MpvSubtitleTrackLike, track: MpvSubtitleTrackLike,
options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {},
): Promise<{ path: string; cleanup: () => Promise<void> } | null> { ): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
const ffIndex = parseTrackId(track['ff-index']); const ffIndex = parseTrackId(track['ff-index']);
const codec = typeof track.codec === 'string' ? track.codec : null; const codec = typeof track.codec === 'string' ? track.codec : null;
@@ -93,38 +90,18 @@ export async function extractInternalSubtitleTrackToTempFile(
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let settled = false;
const child = spawn( const child = spawn(
ffmpegPath, ffmpegPath,
options.spawnArgsOverride ??
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath), buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
); );
const extractionTimeoutMs = options.extractionTimeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS;
const timeoutId = setTimeout(() => {
if (settled) {
return;
}
settled = true;
child.kill('SIGKILL');
reject(new Error(`ffmpeg extraction timed out after ${extractionTimeoutMs}ms`));
}, extractionTimeoutMs);
const settle = (callback: () => void): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
callback();
};
let stderr = ''; let stderr = '';
child.stderr.on('data', (chunk: Buffer) => { child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString(); stderr += chunk.toString();
}); });
child.on('error', (error) => { child.on('error', (error) => {
settle(() => reject(error)); reject(error);
}); });
child.on('close', (code) => { child.on('close', (code) => {
settle(() => {
if (code === 0) { if (code === 0) {
resolve(); resolve();
return; return;
@@ -132,7 +109,6 @@ export async function extractInternalSubtitleTrackToTempFile(
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
}); });
}); });
});
} catch (error) { } catch (error) {
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
throw error; throw error;
@@ -1,34 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs';
test('showLogExportSuccessDialog handles dialog rejection', async () => {
const warnings: string[] = [];
await showLogExportSuccessDialog({
zipPath: '/tmp/subminer-logs.zip',
showMessageBox: async () => {
throw new Error('dialog failed');
},
showItemInFolder: () => {
throw new Error('unexpected shell call');
},
logWarn: (message) => warnings.push(message),
});
assert.deepEqual(warnings, ['Failed to show log export success dialog.']);
});
test('showLogExportErrorDialog handles dialog rejection', async () => {
const warnings: string[] = [];
await showLogExportErrorDialog({
message: 'export failed',
showMessageBox: async () => {
throw new Error('dialog failed');
},
logWarn: (message) => warnings.push(message),
});
assert.deepEqual(warnings, ['Failed to show log export error dialog.']);
});
-46
View File
@@ -1,46 +0,0 @@
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron';
type ShowMessageBox = (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>;
export async function showLogExportSuccessDialog(options: {
zipPath: string;
showMessageBox: ShowMessageBox;
showItemInFolder: (path: string) => void;
logWarn: (message: string, details?: unknown) => void;
}): Promise<void> {
const successDialog = await options
.showMessageBox({
type: 'info',
title: 'SubMiner logs exported',
message: 'SubMiner log export created.',
detail: options.zipPath,
buttons: ['OK', 'Show in Folder'],
defaultId: 0,
cancelId: 0,
})
.catch((dialogError) => {
options.logWarn('Failed to show log export success dialog.', dialogError);
return undefined;
});
if (successDialog?.response === 1) {
options.showItemInFolder(options.zipPath);
}
}
export async function showLogExportErrorDialog(options: {
message: string;
showMessageBox: ShowMessageBox;
logWarn: (message: string, details?: unknown) => void;
}): Promise<void> {
await options
.showMessageBox({
type: 'error',
title: 'SubMiner log export failed',
message: 'Could not export SubMiner logs.',
detail: options.message,
})
.catch((dialogError) => {
options.logWarn('Failed to show log export error dialog.', dialogError);
});
}
+19 -10
View File
@@ -1,6 +1,5 @@
import { app, dialog, shell } from 'electron'; import { app, dialog, shell } from 'electron';
import * as os from 'os'; import * as os from 'os';
import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs';
import { exportLogsArchive } from './log-export'; import { exportLogsArchive } from './log-export';
export interface LogExportTrayRuntimeDeps { export interface LogExportTrayRuntimeDeps {
@@ -32,19 +31,29 @@ export function createLogExportTrayRuntime(deps: LogExportTrayRuntimeDeps): {
deps.logInfo( deps.logInfo(
`Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`, `Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`,
); );
await showLogExportSuccessDialog({ void dialog
zipPath: result.zipPath, .showMessageBox({
showMessageBox: (options) => dialog.showMessageBox(options), type: 'info',
showItemInFolder: (zipPath) => shell.showItemInFolder(zipPath), title: 'SubMiner logs exported',
logWarn: deps.logWarn, message: 'SubMiner log export created.',
detail: result.zipPath,
buttons: ['OK', 'Show in Folder'],
defaultId: 0,
cancelId: 0,
})
.then((response) => {
if (response.response === 1) {
shell.showItemInFolder(result.zipPath);
}
}); });
} catch (error) { } catch (error) {
const message = describeUnknownError(error); const message = describeUnknownError(error);
deps.logWarn('Failed to export logs from tray.', error); deps.logWarn('Failed to export logs from tray.', error);
await showLogExportErrorDialog({ void dialog.showMessageBox({
message, type: 'error',
showMessageBox: (options) => dialog.showMessageBox(options), title: 'SubMiner log export failed',
logWarn: deps.logWarn, message: 'Could not export SubMiner logs.',
detail: message,
}); });
} }
} }
@@ -35,38 +35,3 @@ test('persistSessionBindings logs and does not publish bindings when artifact wr
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }
}); });
test('persistSessionBindings keeps saved bindings when mpv reload notification fails', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
const calls: string[] = [];
const runtime = createSessionBindingsRuntime({
configDir: root,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
getResolvedConfig: () =>
({
stats: { toggleKey: 's', markWatchedKey: 'w' },
}) as ResolvedConfig,
getMpvClient: () =>
({
connected: true,
send: () => {
throw new Error('mpv unavailable');
},
}) as never,
setSessionBindings: () => calls.push('setSessionBindings'),
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
logWarn: (message) => calls.push(`warn:${message}`),
});
try {
assert.doesNotThrow(() => runtime.persistSessionBindings([] as CompiledSessionBinding[]));
assert.deepEqual(calls, [
'setSessionBindings',
'setSessionBindingsInitialized',
'warn:[session-bindings] Failed to notify mpv to reload session bindings',
]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+1 -5
View File
@@ -15,7 +15,7 @@ export interface SessionBindingsRuntimeDeps {
getMpvClient: () => MpvRuntimeClientLike | null; getMpvClient: () => MpvRuntimeClientLike | null;
setSessionBindings: (bindings: CompiledSessionBinding[]) => void; setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
setSessionBindingsInitialized: (initialized: boolean) => void; setSessionBindingsInitialized: (initialized: boolean) => void;
logWarn: (message: string, details?: unknown) => void; logWarn: (message: string) => void;
} }
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): { export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
@@ -64,11 +64,7 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
deps.setSessionBindingsInitialized(true); deps.setSessionBindingsInitialized(true);
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) { if (mpvClient?.connected) {
try {
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']); sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
} catch (error) {
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
}
} }
} }
-38
View File
@@ -1,5 +1,4 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
export type BackgroundStatsServerState = { export type BackgroundStatsServerState = {
@@ -66,43 +65,6 @@ export function isBackgroundStatsServerProcessAlive(pid: number): boolean {
} }
} }
function readProcessStartedAtMs(pid: number): number | null {
try {
if (process.platform === 'win32') {
const output = execFileSync(
'powershell.exe',
[
'-NoProfile',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().ToString("o")`,
],
{ encoding: 'utf8', timeout: 1000 },
).trim();
const parsed = Date.parse(output);
return Number.isFinite(parsed) ? parsed : null;
}
const output = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
encoding: 'utf8',
timeout: 1000,
}).trim();
const parsed = Date.parse(output);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
export function verifyBackgroundStatsServerIdentity(pid: number, startedAtMs: number): boolean {
const processStartedAtMs = readProcessStartedAtMs(pid);
if (processStartedAtMs === null) {
return false;
}
const earliestAllowedStateWriteMs = processStartedAtMs;
const latestAllowedStateWriteMs = processStartedAtMs + 60_000;
return startedAtMs >= earliestAllowedStateWriteMs && startedAtMs <= latestAllowedStateWriteMs;
}
export function resolveBackgroundStatsServerUrl( export function resolveBackgroundStatsServerUrl(
state: Pick<BackgroundStatsServerState, 'port'>, state: Pick<BackgroundStatsServerState, 'port'>,
): string { ): string {
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
createStatsServerRuntime,
isSelfOwnedBackgroundStatsDaemonState, isSelfOwnedBackgroundStatsDaemonState,
shouldClearAppStateStatsServerOnStop, shouldClearAppStateStatsServerOnStop,
} from './stats-server-runtime'; } from './stats-server-runtime';
@@ -16,44 +15,3 @@ test('detects self-owned background stats daemon state', () => {
test('stats server app-state reference should be cleared after private server stop', () => { test('stats server app-state reference should be cleared after private server stop', () => {
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true); assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
}); });
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
const calls: string[] = [];
const runtime = createStatsServerRuntime({
userDataPath: '/tmp/subminer-stats-runtime-test',
statsDistPath: '/tmp/stats-dist',
getResolvedConfig: () => ({ stats: { serverPort: 5175 } }) as never,
getImmersionTracker: () => null,
setAppStateStatsServer: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
getYomitanExt: () => null,
getYomitanSession: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
getYomitanAnkiDeckName: async () => 'Mining',
getAnilistRateLimiter: () => ({}) as never,
resolveAnkiNoteId: (noteId) => noteId,
trackDuplicateNoteIdsForNote: () => {},
resolveSentenceSearchHeadwords: async () => [],
ensureImmersionTrackerStarted: () => {},
setStatsStartupInProgress: () => {},
readBackgroundStatsServerState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }),
removeBackgroundStatsServerState: () => {
calls.push('removeBackgroundStatsServerState');
},
isBackgroundStatsServerProcessAlive: () => true,
verifyBackgroundStatsServerIdentity: () => false,
killProcess: () => {
calls.push('killProcess');
},
});
const result = await runtime.stopBackgroundStatsServer();
assert.deepEqual(result, { ok: true, stale: true });
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
});
+22 -46
View File
@@ -9,11 +9,10 @@ import { createLogger } from '../../logger';
import type { ResolvedConfig } from '../../types/config'; import type { ResolvedConfig } from '../../types/config';
import type { AppState } from '../state'; import type { AppState } from '../state';
import { import {
isBackgroundStatsServerProcessAlive as defaultIsBackgroundStatsServerProcessAlive, isBackgroundStatsServerProcessAlive,
readBackgroundStatsServerState as defaultReadBackgroundStatsServerState, readBackgroundStatsServerState,
removeBackgroundStatsServerState as defaultRemoveBackgroundStatsServerState, removeBackgroundStatsServerState,
resolveBackgroundStatsServerUrl, resolveBackgroundStatsServerUrl,
verifyBackgroundStatsServerIdentity as defaultVerifyBackgroundStatsServerIdentity,
writeBackgroundStatsServerState, writeBackgroundStatsServerState,
} from './stats-daemon'; } from './stats-daemon';
import { createEnsureStatsServerUrlHandler } from './stats-server-routing'; import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
@@ -57,11 +56,6 @@ export interface StatsServerRuntimeDeps {
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>; resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
ensureImmersionTrackerStarted: () => void; ensureImmersionTrackerStarted: () => void;
setStatsStartupInProgress: (inProgress: boolean) => void; setStatsStartupInProgress: (inProgress: boolean) => void;
readBackgroundStatsServerState?: typeof defaultReadBackgroundStatsServerState;
removeBackgroundStatsServerState?: typeof defaultRemoveBackgroundStatsServerState;
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
} }
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
@@ -75,46 +69,32 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
} { } {
let statsServer: ReturnType<typeof startStatsServer> | null = null; let statsServer: ReturnType<typeof startStatsServer> | null = null;
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json'); const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
const readDaemonState =
deps.readBackgroundStatsServerState ??
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
const removeDaemonState =
deps.removeBackgroundStatsServerState ??
((statePath: string) => defaultRemoveBackgroundStatsServerState(statePath));
const isDaemonAlive =
deps.isBackgroundStatsServerProcessAlive ??
((pid: number) => defaultIsBackgroundStatsServerProcessAlive(pid));
const verifyDaemonIdentity =
deps.verifyBackgroundStatsServerIdentity ??
((pid: number, startedAtMs: number) =>
defaultVerifyBackgroundStatsServerIdentity(pid, startedAtMs));
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
function readLiveBackgroundStatsDaemonState(): { function readLiveBackgroundStatsDaemonState(): {
pid: number; pid: number;
port: number; port: number;
startedAtMs: number; startedAtMs: number;
} | null { } | null {
const state = readDaemonState(statsDaemonStatePath); const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (!state) { if (!state) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return null; return null;
} }
if (state.pid === process.pid && !statsServer) { if (state.pid === process.pid && !statsServer) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return null; return null;
} }
if (!isDaemonAlive(state.pid)) { if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return null; return null;
} }
return state; return state;
} }
function clearOwnedBackgroundStatsDaemonState(): void { function clearOwnedBackgroundStatsDaemonState(): void {
const state = readDaemonState(statsDaemonStatePath); const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (state?.pid === process.pid) { if (state?.pid === process.pid) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
} }
} }
@@ -188,11 +168,11 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({ const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
currentPid: process.pid, currentPid: process.pid,
readBackgroundState: () => readDaemonState(statsDaemonStatePath), readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath),
removeBackgroundState: () => { removeBackgroundState: () => {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
}, },
isProcessAlive: (pid) => isDaemonAlive(pid), isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid),
hasLocalStatsServer: () => statsServer !== null, hasLocalStatsServer: () => statsServer !== null,
startLocalStatsServer, startLocalStatsServer,
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort, getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
@@ -230,29 +210,25 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
}; };
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
const state = readDaemonState(statsDaemonStatePath); const state = readBackgroundStatsServerState(statsDaemonStatePath);
if (!state) { if (!state) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true }; return { ok: true, stale: true };
} }
if (isSelfOwnedBackgroundStatsDaemonState(state)) { if (isSelfOwnedBackgroundStatsDaemonState(state)) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true }; return { ok: true, stale: true };
} }
if (!isDaemonAlive(state.pid)) { if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!verifyDaemonIdentity(state.pid, state.startedAtMs)) {
removeDaemonState(statsDaemonStatePath);
return { ok: true, stale: true }; return { ok: true, stale: true };
} }
try { try {
killProcess(state.pid, 'SIGTERM'); process.kill(state.pid, 'SIGTERM');
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: true }; return { ok: true, stale: true };
} }
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') { if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
@@ -265,8 +241,8 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
const deadline = Date.now() + 2_000; const deadline = Date.now() + 2_000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (!isDaemonAlive(state.pid)) { if (!isBackgroundStatsServerProcessAlive(state.pid)) {
removeDaemonState(statsDaemonStatePath); removeBackgroundStatsServerState(statsDaemonStatePath);
return { ok: true, stale: false }; return { ok: true, stale: false };
} }
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
@@ -1,18 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectWindowsMpvPluginRemovalCandidates } from './first-run-setup-plugin';
test('Windows plugin removal candidates include portable directory installs', () => {
const mpvPath = 'C:\\tools\\mpv\\mpv.exe';
const portablePluginDir = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer';
const existing = new Set([portablePluginDir]);
const candidates = detectWindowsMpvPluginRemovalCandidates({
homeDir: 'C:\\Users\\tester',
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
mpvExecutablePath: mpvPath,
existsSync: (candidate) => existing.has(candidate),
});
assert.deepEqual(candidates, [{ path: portablePluginDir, kind: 'directory' }]);
});
@@ -1,8 +1,9 @@
import { app, dialog, shell } from 'electron'; import { app, dialog, shell } from 'electron';
import * as os from 'os'; import * as os from 'os';
import { import {
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
detectWindowsMpvPluginRemovalCandidates, filterLegacyMpvPluginFileCandidates,
removeLegacyMpvPluginCandidates, removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath, resolvePackagedRuntimePluginPath,
} from './first-run-setup-plugin'; } from './first-run-setup-plugin';
@@ -86,11 +87,14 @@ export function createWindowsMpvPluginDetectionRuntime(
} }
const result = await removeLegacyMpvPluginCandidates({ const result = await removeLegacyMpvPluginCandidates({
candidates: detectWindowsMpvPluginRemovalCandidates({ candidates: filterLegacyMpvPluginFileCandidates(
detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(), homeDir: os.homedir(),
appDataDir: app.getPath('appData'), appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath, mpvExecutablePath: mpvPath,
}), }),
),
trashItem: (candidatePath) => shell.trashItem(candidatePath), trashItem: (candidatePath) => shell.trashItem(candidatePath),
}); });
if (result.ok) { if (result.ok) {
+23 -94
View File
@@ -842,12 +842,7 @@ test('nested popup close reasserts interactive state and focus when another popu
} }
}); });
function setupYomitanPopupFocusHarness( test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
options: {
isMacOSPlatform?: boolean;
visiblePopupHost?: boolean;
} = {},
) {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const previousWindow = (globalThis as { window?: unknown }).window; const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document; const previousDocument = (globalThis as { document?: unknown }).document;
@@ -860,7 +855,6 @@ function setupYomitanPopupFocusHarness(
let overlayFocusCalls = 0; let overlayFocusCalls = 0;
ctx.platform.shouldToggleMouseIgnore = true; ctx.platform.shouldToggleMouseIgnore = true;
ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true;
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => { (ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
overlayFocusCalls += 1; overlayFocusCalls += 1;
}; };
@@ -908,8 +902,8 @@ function setupYomitanPopupFocusHarness(
querySelector: () => null, querySelector: () => null,
querySelectorAll: (selector: string) => { querySelectorAll: (selector: string) => {
if ( if (
(options.visiblePopupHost === true && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) || selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
(options.visiblePopupHost === true && selector === YOMITAN_POPUP_HOST_SELECTOR) selector === YOMITAN_POPUP_HOST_SELECTOR
) { ) {
return [visiblePopupHost]; return [visiblePopupHost];
} }
@@ -933,6 +927,7 @@ function setupYomitanPopupFocusHarness(
}, },
}); });
try {
const handlers = createMouseHandlers(ctx as never, { const handlers = createMouseHandlers(ctx as never, {
modalStateReader: { modalStateReader: {
isAnySettingsModalOpen: () => false, isAnySettingsModalOpen: () => false,
@@ -946,98 +941,32 @@ function setupYomitanPopupFocusHarness(
getPlaybackPaused: async () => false, getPlaybackPaused: async () => false,
sendMpvCommand: () => {}, sendMpvCommand: () => {},
}); });
handlers.setupYomitanObserver();
return { handlers.setupYomitanObserver();
ctx, assert.equal(ctx.state.yomitanPopupVisible, true);
windowListeners, assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
ignoreCalls, assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
focusMainWindowCalls: () => focusMainWindowCalls, ignoreCalls.length = 0;
windowFocusCalls: () => windowFocusCalls,
overlayFocusCalls: () => overlayFocusCalls, for (const listener of windowListeners.get('blur') ?? []) {
restore: () => { listener();
}
await Promise.resolve();
assert.equal(ctx.state.yomitanPopupVisible, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(focusMainWindowCalls, 1);
assert.equal(windowFocusCalls, 1);
assert.equal(overlayFocusCalls, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
configurable: true,
value: previousDocument,
});
Object.defineProperty(globalThis, 'MutationObserver', { Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true, configurable: true,
value: previousMutationObserver, value: previousMutationObserver,
}); });
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
},
};
}
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
const harness = setupYomitanPopupFocusHarness({ visiblePopupHost: true });
try {
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
harness.ignoreCalls.length = 0;
for (const listener of harness.windowListeners.get('blur') ?? []) {
listener();
}
await Promise.resolve();
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(harness.focusMainWindowCalls(), 1);
assert.equal(harness.windowFocusCalls(), 1);
assert.equal(harness.overlayFocusCalls(), 1);
} finally {
harness.restore();
}
});
test('window blur on macOS keeps yomitan popup interactive without stealing click-away focus', async () => {
const harness = setupYomitanPopupFocusHarness({
isMacOSPlatform: true,
visiblePopupHost: true,
});
try {
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
harness.ignoreCalls.length = 0;
for (const listener of harness.windowListeners.get('blur') ?? []) {
listener();
}
await Promise.resolve();
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(harness.focusMainWindowCalls(), 0);
assert.equal(harness.windowFocusCalls(), 0);
assert.equal(harness.overlayFocusCalls(), 0);
} finally {
harness.restore();
}
});
test('popup shown reclaims overlay focus on macOS', () => {
const harness = setupYomitanPopupFocusHarness({ isMacOSPlatform: true });
try {
harness.ignoreCalls.length = 0;
for (const listener of harness.windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
assert.equal(harness.focusMainWindowCalls(), 1);
assert.equal(harness.windowFocusCalls(), 1);
assert.equal(harness.overlayFocusCalls(), 1);
} finally {
harness.restore();
} }
}); });
+3 -7
View File
@@ -67,7 +67,7 @@ export function createMouseHandlers(
if (!ctx.platform.shouldToggleMouseIgnore) { if (!ctx.platform.shouldToggleMouseIgnore) {
return; return;
} }
if (ctx.platform.isLinuxPlatform) { if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
return; return;
} }
@@ -467,11 +467,7 @@ export function createMouseHandlers(
reconcilePopupInteraction({ allowPause: true }); reconcilePopupInteraction({ allowPause: true });
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
reconcilePopupInteraction({ reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
assumeVisible: true,
allowPause: true,
reclaimFocus: ctx.platform.isMacOSPlatform,
});
}); });
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
@@ -495,7 +491,7 @@ export function createMouseHandlers(
if (typeof document === 'undefined' || document.visibilityState !== 'visible') { if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
return; return;
} }
reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform }); reconcilePopupInteraction({ reclaimFocus: true });
}); });
}); });
-49
View File
@@ -123,10 +123,6 @@ test('stats daemon control stops live daemon and treats stale state as success',
calls.push(`isProcessAlive:${pid}:${aliveChecks}`); calls.push(`isProcessAlive:${pid}:${aliveChecks}`);
return aliveChecks === 1; return aliveChecks === 1;
}, },
verifyProcessIdentity: (pid, startedAtMs) => {
calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`);
return true;
},
resolveUrl: (state) => `http://127.0.0.1:${state.port}`, resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
spawnDaemon: async () => 1, spawnDaemon: async () => 1,
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }), waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
@@ -151,7 +147,6 @@ test('stats daemon control stops live daemon and treats stale state as success',
assert.equal(exitCode, 0); assert.equal(exitCode, 0);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'isProcessAlive:4242:1', 'isProcessAlive:4242:1',
'verifyProcessIdentity:4242:1',
'killProcess:4242:SIGTERM', 'killProcess:4242:SIGTERM',
'isProcessAlive:4242:2', 'isProcessAlive:4242:2',
'removeState', 'removeState',
@@ -163,47 +158,3 @@ test('stats daemon control stops live daemon and treats stale state as success',
}, },
]); ]);
}); });
test('stats daemon control clears stale state when daemon identity mismatches', async () => {
const calls: string[] = [];
const responses: Array<{ path: string; payload: { ok: boolean; url?: string; error?: string } }> =
[];
const handler = createRunStatsDaemonControlHandler({
statePath: '/tmp/stats-daemon.json',
readState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }),
removeState: () => {
calls.push('removeState');
},
isProcessAlive: (pid) => {
calls.push(`isProcessAlive:${pid}`);
return true;
},
verifyProcessIdentity: (pid, startedAtMs) => {
calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`);
return false;
},
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
spawnDaemon: async () => 1,
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
openExternal: async () => {},
writeResponse: (responsePath, payload) => {
responses.push({ path: responsePath, payload });
},
killProcess: () => {
calls.push('killProcess');
},
sleep: async () => {},
});
const exitCode = await handler({
action: 'stop',
responsePath: '/tmp/response.json',
openBrowser: false,
daemonScriptPath: '/tmp/stats-daemon-runner.js',
userDataPath: '/tmp/SubMiner',
});
assert.equal(exitCode, 0);
assert.deepEqual(calls, ['isProcessAlive:4242', 'verifyProcessIdentity:4242:1', 'removeState']);
assert.deepEqual(responses, [{ path: '/tmp/response.json', payload: { ok: true } }]);
});
-8
View File
@@ -1,5 +1,4 @@
import type { BackgroundStatsServerState } from './main/runtime/stats-daemon'; import type { BackgroundStatsServerState } from './main/runtime/stats-daemon';
import { verifyBackgroundStatsServerIdentity } from './main/runtime/stats-daemon';
import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command'; import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command';
export type StatsDaemonControlAction = 'start' | 'stop'; export type StatsDaemonControlAction = 'start' | 'stop';
@@ -23,7 +22,6 @@ export function createRunStatsDaemonControlHandler(deps: {
readState: () => BackgroundStatsServerState | null; readState: () => BackgroundStatsServerState | null;
removeState: () => void; removeState: () => void;
isProcessAlive: (pid: number) => boolean; isProcessAlive: (pid: number) => boolean;
verifyProcessIdentity?: (pid: number, startedAtMs: number) => boolean;
resolveUrl: (state: Pick<BackgroundStatsServerState, 'port'>) => string; resolveUrl: (state: Pick<BackgroundStatsServerState, 'port'>) => string;
spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise<number> | number; spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise<number> | number;
waitForDaemonResponse: (responsePath: string) => Promise<StatsCliCommandResponse>; waitForDaemonResponse: (responsePath: string) => Promise<StatsCliCommandResponse>;
@@ -83,12 +81,6 @@ export function createRunStatsDaemonControlHandler(deps: {
writeResponseSafe(args.responsePath, { ok: true }); writeResponseSafe(args.responsePath, { ok: true });
return 0; return 0;
} }
const verifyProcessIdentity = deps.verifyProcessIdentity ?? verifyBackgroundStatsServerIdentity;
if (!verifyProcessIdentity(state.pid, state.startedAtMs)) {
deps.removeState();
writeResponseSafe(args.responsePath, { ok: true });
return 0;
}
deps.killProcess(state.pid, 'SIGTERM'); deps.killProcess(state.pid, 'SIGTERM');
const deadline = Date.now() + 2_000; const deadline = Date.now() + 2_000;