mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-13 15:13:32 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ff77eebc3
|
|||
| 33e767458f |
@@ -0,0 +1,4 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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
File diff suppressed because one or more lines are too long
@@ -5307,6 +5307,15 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -11,8 +11,9 @@ 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];
|
||||||
if (value && !value.startsWith('--')) {
|
const trimmed = value?.trim();
|
||||||
resolved = value.trim();
|
if (trimmed && !trimmed.startsWith('--')) {
|
||||||
|
resolved = trimmed;
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { setMpvCurrentSecondarySubText } from './autoplay-subtitle-priming-runtime';
|
import {
|
||||||
|
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[] = [];
|
||||||
@@ -26,3 +29,37 @@ 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,7 +253,13 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
clearScheduledSubtitlePrefetchRefresh();
|
clearScheduledSubtitlePrefetchRefresh();
|
||||||
subtitlePrefetchRefreshTimer = setTimeout(() => {
|
subtitlePrefetchRefreshTimer = setTimeout(() => {
|
||||||
subtitlePrefetchRefreshTimer = null;
|
subtitlePrefetchRefreshTimer = null;
|
||||||
void deps.refreshSubtitlePrefetchFromActiveTrack();
|
void deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] subtitle prefetch refresh failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
detectInstalledFirstRunPlugin,
|
detectInstalledFirstRunPlugin,
|
||||||
detectInstalledFirstRunPluginCandidates,
|
detectInstalledFirstRunPluginCandidates,
|
||||||
detectInstalledMpvPlugin,
|
detectInstalledMpvPlugin,
|
||||||
filterLegacyMpvPluginFileCandidates,
|
|
||||||
removeLegacyMpvPluginCandidates,
|
removeLegacyMpvPluginCandidates,
|
||||||
resolvePackagedFirstRunPluginAssets,
|
resolvePackagedFirstRunPluginAssets,
|
||||||
resolvePackagedRuntimePluginPath,
|
resolvePackagedRuntimePluginPath,
|
||||||
@@ -221,20 +220,6 @@ 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({
|
||||||
|
|||||||
@@ -180,10 +180,19 @@ export function detectInstalledFirstRunPluginCandidates(options: {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterLegacyMpvPluginFileCandidates(
|
export function detectWindowsMpvPluginRemovalCandidates(options: {
|
||||||
candidates: InstalledFirstRunPluginCandidate[],
|
homeDir: string;
|
||||||
): InstalledFirstRunPluginCandidate[] {
|
appDataDir: string;
|
||||||
return candidates.filter((candidate) => candidate.kind === 'file');
|
mpvExecutablePath: string;
|
||||||
|
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,6 +1,14 @@
|
|||||||
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 { buildFfmpegSubtitleExtractionArgs } from './internal-subtitle-extraction';
|
import {
|
||||||
|
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(
|
||||||
@@ -8,3 +16,32 @@ 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,13 +35,15 @@ 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)) {
|
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
||||||
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 : null;
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,7 @@ 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;
|
||||||
@@ -90,23 +93,44 @@ 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,
|
||||||
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
options.spawnArgsOverride ??
|
||||||
|
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) => {
|
||||||
reject(error);
|
settle(() => reject(error));
|
||||||
});
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code === 0) {
|
settle(() => {
|
||||||
resolve();
|
if (code === 0) {
|
||||||
return;
|
resolve();
|
||||||
}
|
return;
|
||||||
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) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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.']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -31,29 +32,19 @@ 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}`,
|
||||||
);
|
);
|
||||||
void dialog
|
await showLogExportSuccessDialog({
|
||||||
.showMessageBox({
|
zipPath: result.zipPath,
|
||||||
type: 'info',
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
title: 'SubMiner logs exported',
|
showItemInFolder: (zipPath) => shell.showItemInFolder(zipPath),
|
||||||
message: 'SubMiner log export created.',
|
logWarn: deps.logWarn,
|
||||||
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);
|
||||||
void dialog.showMessageBox({
|
await showLogExportErrorDialog({
|
||||||
type: 'error',
|
message,
|
||||||
title: 'SubMiner log export failed',
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
message: 'Could not export SubMiner logs.',
|
logWarn: deps.logWarn,
|
||||||
detail: message,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,38 @@ 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) => void;
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||||
@@ -64,7 +64,11 @@ 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) {
|
||||||
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
|
try {
|
||||||
|
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 = {
|
||||||
@@ -65,6 +66,43 @@ 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,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -15,3 +16,44 @@ 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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ 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,
|
isBackgroundStatsServerProcessAlive as defaultIsBackgroundStatsServerProcessAlive,
|
||||||
readBackgroundStatsServerState,
|
readBackgroundStatsServerState as defaultReadBackgroundStatsServerState,
|
||||||
removeBackgroundStatsServerState,
|
removeBackgroundStatsServerState as defaultRemoveBackgroundStatsServerState,
|
||||||
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';
|
||||||
@@ -56,6 +57,11 @@ 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): {
|
||||||
@@ -69,32 +75,46 @@ 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 = readBackgroundStatsServerState(statsDaemonStatePath);
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (state.pid === process.pid && !statsServer) {
|
if (state.pid === process.pid && !statsServer) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
if (!isDaemonAlive(state.pid)) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOwnedBackgroundStatsDaemonState(): void {
|
function clearOwnedBackgroundStatsDaemonState(): void {
|
||||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
if (state?.pid === process.pid) {
|
if (state?.pid === process.pid) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +188,11 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
|
|
||||||
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
||||||
currentPid: process.pid,
|
currentPid: process.pid,
|
||||||
readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath),
|
readBackgroundState: () => readDaemonState(statsDaemonStatePath),
|
||||||
removeBackgroundState: () => {
|
removeBackgroundState: () => {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
},
|
},
|
||||||
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid),
|
isProcessAlive: (pid) => isDaemonAlive(pid),
|
||||||
hasLocalStatsServer: () => statsServer !== null,
|
hasLocalStatsServer: () => statsServer !== null,
|
||||||
startLocalStatsServer,
|
startLocalStatsServer,
|
||||||
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
||||||
@@ -210,25 +230,29 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return { ok: true, stale: true };
|
return { ok: true, stale: true };
|
||||||
}
|
}
|
||||||
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(statsDaemonStatePath);
|
||||||
return { ok: true, stale: true };
|
return { ok: true, stale: true };
|
||||||
}
|
}
|
||||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
if (!isDaemonAlive(state.pid)) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(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 {
|
||||||
process.kill(state.pid, 'SIGTERM');
|
killProcess(state.pid, 'SIGTERM');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(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') {
|
||||||
@@ -241,8 +265,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 (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
if (!isDaemonAlive(state.pid)) {
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
removeDaemonState(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));
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,9 +1,8 @@
|
|||||||
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,
|
||||||
filterLegacyMpvPluginFileCandidates,
|
detectWindowsMpvPluginRemovalCandidates,
|
||||||
removeLegacyMpvPluginCandidates,
|
removeLegacyMpvPluginCandidates,
|
||||||
resolvePackagedRuntimePluginPath,
|
resolvePackagedRuntimePluginPath,
|
||||||
} from './first-run-setup-plugin';
|
} from './first-run-setup-plugin';
|
||||||
@@ -87,14 +86,11 @@ export function createWindowsMpvPluginDetectionRuntime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await removeLegacyMpvPluginCandidates({
|
const result = await removeLegacyMpvPluginCandidates({
|
||||||
candidates: filterLegacyMpvPluginFileCandidates(
|
candidates: detectWindowsMpvPluginRemovalCandidates({
|
||||||
detectInstalledFirstRunPluginCandidates({
|
homeDir: os.homedir(),
|
||||||
platform: 'win32',
|
appDataDir: app.getPath('appData'),
|
||||||
homeDir: os.homedir(),
|
mpvExecutablePath: mpvPath,
|
||||||
appDataDir: app.getPath('appData'),
|
}),
|
||||||
mpvExecutablePath: mpvPath,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
trashItem: (candidatePath) => shell.trashItem(candidatePath),
|
trashItem: (candidatePath) => shell.trashItem(candidatePath),
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
|||||||
@@ -842,7 +842,12 @@ test('nested popup close reasserts interactive state and focus when another popu
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
function setupYomitanPopupFocusHarness(
|
||||||
|
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;
|
||||||
@@ -855,6 +860,7 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
|||||||
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;
|
||||||
};
|
};
|
||||||
@@ -902,8 +908,8 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
|||||||
querySelector: () => null,
|
querySelector: () => null,
|
||||||
querySelectorAll: (selector: string) => {
|
querySelectorAll: (selector: string) => {
|
||||||
if (
|
if (
|
||||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
(options.visiblePopupHost === true && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) ||
|
||||||
selector === YOMITAN_POPUP_HOST_SELECTOR
|
(options.visiblePopupHost === true && selector === YOMITAN_POPUP_HOST_SELECTOR)
|
||||||
) {
|
) {
|
||||||
return [visiblePopupHost];
|
return [visiblePopupHost];
|
||||||
}
|
}
|
||||||
@@ -927,46 +933,111 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
windowListeners,
|
||||||
|
ignoreCalls,
|
||||||
|
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||||
|
windowFocusCalls: () => windowFocusCalls,
|
||||||
|
overlayFocusCalls: () => overlayFocusCalls,
|
||||||
|
restore: () => {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousDocument,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
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 {
|
try {
|
||||||
const handlers = createMouseHandlers(ctx as never, {
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
modalStateReader: {
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
isAnySettingsModalOpen: () => false,
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
isAnyModalOpen: () => false,
|
harness.ignoreCalls.length = 0;
|
||||||
},
|
|
||||||
applyYPercent: () => {},
|
|
||||||
getCurrentYPercent: () => 10,
|
|
||||||
persistSubtitlePositionPatch: () => {},
|
|
||||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
|
||||||
getYomitanPopupAutoPauseEnabled: () => false,
|
|
||||||
getPlaybackPaused: async () => false,
|
|
||||||
sendMpvCommand: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
handlers.setupYomitanObserver();
|
for (const listener of harness.windowListeners.get('blur') ?? []) {
|
||||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
|
||||||
ignoreCalls.length = 0;
|
|
||||||
|
|
||||||
for (const listener of windowListeners.get('blur') ?? []) {
|
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
assert.equal(focusMainWindowCalls, 1);
|
assert.equal(harness.focusMainWindowCalls(), 1);
|
||||||
assert.equal(windowFocusCalls, 1);
|
assert.equal(harness.windowFocusCalls(), 1);
|
||||||
assert.equal(overlayFocusCalls, 1);
|
assert.equal(harness.overlayFocusCalls(), 1);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
harness.restore();
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
}
|
||||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
});
|
||||||
configurable: true,
|
|
||||||
value: previousMutationObserver,
|
test('window blur on macOS keeps yomitan popup interactive without stealing click-away focus', async () => {
|
||||||
});
|
const harness = setupYomitanPopupFocusHarness({
|
||||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function createMouseHandlers(
|
|||||||
if (!ctx.platform.shouldToggleMouseIgnore) {
|
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
if (ctx.platform.isLinuxPlatform) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +467,11 @@ export function createMouseHandlers(
|
|||||||
reconcilePopupInteraction({ allowPause: true });
|
reconcilePopupInteraction({ allowPause: true });
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
reconcilePopupInteraction({
|
||||||
|
assumeVisible: true,
|
||||||
|
allowPause: true,
|
||||||
|
reclaimFocus: ctx.platform.isMacOSPlatform,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
@@ -491,7 +495,7 @@ export function createMouseHandlers(
|
|||||||
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconcilePopupInteraction({ reclaimFocus: true });
|
reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ 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' }),
|
||||||
@@ -147,6 +151,7 @@ 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',
|
||||||
@@ -158,3 +163,47 @@ 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 } }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -22,6 +23,7 @@ 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>;
|
||||||
@@ -81,6 +83,12 @@ 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user