mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Harden stats APIs and fix Electron Yomitan debug runtime
- Validate stats session IDs/limits and add AnkiConnect request timeouts - Stabilize stats window/runtime lifecycle and tighten window security defaults - Fix Electron CLI debug startup by unsetting `ELECTRON_RUN_AS_NODE` and wiring Yomitan session state - Expand regression coverage for tracker queries/events ordering and session aggregates - Update docs for stats dashboard usage and Yomitan lookup troubleshooting
This commit is contained in:
@@ -618,8 +618,8 @@ test('monthly rollups are grouped by calendar month', async () => {
|
||||
runRollupMaintenance: () => void;
|
||||
};
|
||||
|
||||
const januaryStartedAtMs = Date.UTC(2026, 0, 31, 23, 59, 59, 0);
|
||||
const februaryStartedAtMs = Date.UTC(2026, 1, 1, 0, 0, 1, 0);
|
||||
const januaryStartedAtMs = Date.UTC(2026, 0, 15, 12, 0, 0, 0);
|
||||
const februaryStartedAtMs = Date.UTC(2026, 1, 15, 12, 0, 0, 0);
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_videos (
|
||||
|
||||
@@ -109,7 +109,13 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
||||
assert.equal(row.sessionId, sessionId);
|
||||
assert.equal(row.canonicalTitle, 'Query Test Episode');
|
||||
assert.equal(row.videoId, videoId);
|
||||
assert.ok(row.linesSeen >= 5);
|
||||
assert.equal(row.linesSeen, 5);
|
||||
assert.equal(row.totalWatchedMs, 3_000);
|
||||
assert.equal(row.activeWatchedMs, 2_500);
|
||||
assert.equal(row.wordsSeen, 10);
|
||||
assert.equal(row.tokensSeen, 10);
|
||||
assert.equal(row.lookupCount, 2);
|
||||
assert.equal(row.lookupHits, 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
@@ -137,7 +143,12 @@ test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
||||
assert.ok(row, 'expected to find the session with no telemetry');
|
||||
assert.equal(row.canonicalTitle, 'No Telemetry');
|
||||
assert.equal(row.totalWatchedMs, 0);
|
||||
assert.equal(row.activeWatchedMs, 0);
|
||||
assert.equal(row.linesSeen, 0);
|
||||
assert.equal(row.wordsSeen, 0);
|
||||
assert.equal(row.tokensSeen, 0);
|
||||
assert.equal(row.lookupCount, 0);
|
||||
assert.equal(row.lookupHits, 0);
|
||||
assert.equal(row.cardsMined, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -153,13 +164,19 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
|
||||
// Insert words: 猫 twice, 犬 once
|
||||
// Insert words with the highest-frequency entry inserted after another word
|
||||
stmts.wordUpsertStmt.run('犬', '犬', 'いぬ', 'noun', '名詞', '一般', '', 1_500, 1_500);
|
||||
stmts.wordUpsertStmt.run('猫', '猫', 'ねこ', 'noun', '名詞', '一般', '', 1_000, 2_000);
|
||||
stmts.wordUpsertStmt.run('猫', '猫', 'ねこ', 'noun', '名詞', '一般', '', 1_000, 3_000);
|
||||
stmts.wordUpsertStmt.run('犬', '犬', 'いぬ', 'noun', '名詞', '一般', '', 1_500, 1_500);
|
||||
|
||||
const rows = getVocabularyStats(db, 10);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0]?.headword, '猫');
|
||||
assert.equal(rows[1]?.headword, '犬');
|
||||
assert.equal(rows[0]?.frequency, 2);
|
||||
assert.equal(rows[1]?.frequency, 1);
|
||||
|
||||
assert.ok(rows.length >= 2);
|
||||
// First row should be 猫 (frequency 2)
|
||||
const nekRow = rows.find((r) => r.headword === '猫');
|
||||
@@ -432,13 +449,17 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
|
||||
// Insert kanji: 日 twice, 月 once
|
||||
// Insert kanji with highest-frequency entry inserted after another character
|
||||
stmts.kanjiUpsertStmt.run('月', 1_500, 1_500);
|
||||
stmts.kanjiUpsertStmt.run('日', 1_000, 2_000);
|
||||
stmts.kanjiUpsertStmt.run('日', 1_000, 3_000);
|
||||
stmts.kanjiUpsertStmt.run('月', 1_500, 1_500);
|
||||
|
||||
const rows = getKanjiStats(db, 10);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0]?.kanji, '日');
|
||||
assert.equal(rows[1]?.kanji, '月');
|
||||
|
||||
assert.ok(rows.length >= 2);
|
||||
const nichiRow = rows.find((r) => r.kanji === '日');
|
||||
const tsukiRow = rows.find((r) => r.kanji === '月');
|
||||
@@ -539,7 +560,16 @@ test('getSessionEvents returns empty array for session with no events', () => {
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const events = getSessionEvents(db, 9999, 50);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', {
|
||||
canonicalTitle: 'Events Empty',
|
||||
sourcePath: '/tmp/events-empty.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 6_000_000);
|
||||
|
||||
const events = getSessionEvents(db, sessionId, 50);
|
||||
assert.deepEqual(events, []);
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -547,6 +577,72 @@ test('getSessionEvents returns empty array for session with no events', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents filters events to the requested session id', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
|
||||
const decoyVideoId = getOrCreateVideoRecord(db, 'local:/tmp/events-filter-decoy.mkv', {
|
||||
canonicalTitle: 'Events Filter Decoy',
|
||||
sourcePath: '/tmp/events-filter-decoy.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const targetVideoId = getOrCreateVideoRecord(db, 'local:/tmp/events-filter-target.mkv', {
|
||||
canonicalTitle: 'Events Filter Target',
|
||||
sourcePath: '/tmp/events-filter-target.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const decoySession = startSessionRecord(db, decoyVideoId, 8_000_000);
|
||||
const targetSession = startSessionRecord(db, targetVideoId, 8_100_000);
|
||||
|
||||
// Decoy session event
|
||||
stmts.eventInsertStmt.run(
|
||||
decoySession.sessionId,
|
||||
8_100_000 + 1,
|
||||
EVENT_SUBTITLE_LINE,
|
||||
1,
|
||||
0,
|
||||
500,
|
||||
1,
|
||||
0,
|
||||
'{"line":"decoy"}',
|
||||
8_100_000 + 1,
|
||||
8_100_000 + 1,
|
||||
);
|
||||
|
||||
// Target session event
|
||||
stmts.eventInsertStmt.run(
|
||||
targetSession.sessionId,
|
||||
8_100_000 + 2,
|
||||
EVENT_SUBTITLE_LINE,
|
||||
2,
|
||||
0,
|
||||
600,
|
||||
1,
|
||||
0,
|
||||
'{"line":"target"}',
|
||||
8_100_000 + 2,
|
||||
8_100_000 + 2,
|
||||
);
|
||||
|
||||
const events = getSessionEvents(db, targetSession.sessionId, 50);
|
||||
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0]?.payload, '{"line":"target"}');
|
||||
assert.equal(events[0]?.eventType, EVENT_SUBTITLE_LINE);
|
||||
assert.equal(events[0]?.tsMs, 8100002);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getSessionEvents respects limit parameter', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -229,6 +229,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return Math.min(value as number, maxValue);
|
||||
};
|
||||
|
||||
const parsePositiveInteger = (value: unknown): number | null => {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
ipc.on(
|
||||
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
||||
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||
@@ -474,18 +481,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionTimeline,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
if (typeof sessionId !== 'number') return [];
|
||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
||||
if (parsedSessionId === null) return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 200, 1000);
|
||||
return deps.immersionTracker?.getSessionTimeline(sessionId, parsedLimit) ?? [];
|
||||
return deps.immersionTracker?.getSessionTimeline(parsedSessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionEvents,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
if (typeof sessionId !== 'number') return [];
|
||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
||||
if (parsedSessionId === null) return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 500, 1000);
|
||||
return deps.immersionTracker?.getSessionEvents(sessionId, parsedLimit) ?? [];
|
||||
return deps.immersionTracker?.getSessionEvents(parsedSessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||
|
||||
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
|
||||
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
|
||||
@@ -130,7 +131,7 @@ export function createStatsApp(
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
||||
const id = parseIntQuery(c.req.query('id') ?? c.req.param('id'), 0);
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 200, 1000);
|
||||
const timeline = await tracker.getSessionTimeline(id, limit);
|
||||
@@ -138,7 +139,7 @@ export function createStatsApp(
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/events', async (c) => {
|
||||
const id = parseIntQuery(c.req.query('id') ?? c.req.param('id'), 0);
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
||||
const events = await tracker.getSessionEvents(id, limit);
|
||||
@@ -304,6 +305,7 @@ export function createStatsApp(
|
||||
const response = await fetch('http://127.0.0.1:8765', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||
body: JSON.stringify({ action: 'guiBrowse', version: 6, params: { query: `nid:${noteId}` } }),
|
||||
});
|
||||
const result = await response.json();
|
||||
@@ -315,12 +317,17 @@ export function createStatsApp(
|
||||
|
||||
app.post('/api/stats/anki/notesInfo', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const noteIds = Array.isArray(body?.noteIds) ? body.noteIds.filter((id: unknown) => typeof id === 'number') : [];
|
||||
const noteIds = Array.isArray(body?.noteIds)
|
||||
? body.noteIds.filter(
|
||||
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
||||
)
|
||||
: [];
|
||||
if (noteIds.length === 0) return c.json([]);
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:8765', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||
body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }),
|
||||
});
|
||||
const result = await response.json() as { result?: Array<{ noteId: number; fields: Record<string, { value: string }> }> };
|
||||
|
||||
@@ -36,7 +36,7 @@ export function buildStatsWindowOptions(options: {
|
||||
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
||||
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
@@ -50,7 +50,7 @@ export function buildStatsWindowOptions(options: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: options.preloadPath,
|
||||
sandbox: false,
|
||||
sandbox: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
|
||||
assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js');
|
||||
assert.equal(options.webPreferences?.contextIsolation, true);
|
||||
assert.equal(options.webPreferences?.nodeIntegration, false);
|
||||
assert.equal(options.webPreferences?.sandbox, false);
|
||||
assert.equal(options.webPreferences?.sandbox, true);
|
||||
});
|
||||
|
||||
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
|
||||
@@ -55,6 +55,58 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
{
|
||||
type: 'keyDown',
|
||||
key: '`',
|
||||
code: 'Backquote',
|
||||
control: true,
|
||||
} as Electron.Input,
|
||||
'Backquote',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
{
|
||||
type: 'keyDown',
|
||||
key: '`',
|
||||
code: 'Backquote',
|
||||
alt: true,
|
||||
} as Electron.Input,
|
||||
'Backquote',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
{
|
||||
type: 'keyDown',
|
||||
key: '`',
|
||||
code: 'Backquote',
|
||||
meta: true,
|
||||
} as Electron.Input,
|
||||
'Backquote',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
{
|
||||
type: 'keyDown',
|
||||
key: '`',
|
||||
code: 'Backquote',
|
||||
isAutoRepeat: true,
|
||||
} as Electron.Input,
|
||||
'Backquote',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
{
|
||||
|
||||
31
src/main.ts
31
src/main.ts
@@ -622,13 +622,19 @@ if (!fs.existsSync(USER_DATA_PATH)) {
|
||||
app.setPath('userData', USER_DATA_PATH);
|
||||
|
||||
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
||||
|
||||
function stopStatsServer(): void {
|
||||
if (!statsServer) {
|
||||
return;
|
||||
}
|
||||
statsServer.close();
|
||||
statsServer = null;
|
||||
}
|
||||
|
||||
function requestAppQuit(): void {
|
||||
destroyStatsWindow();
|
||||
if (appState.statsServer) {
|
||||
appState.statsServer.close();
|
||||
appState.statsServer = null;
|
||||
}
|
||||
stopStatsServer();
|
||||
if (!forceQuitTimer) {
|
||||
forceQuitTimer = setTimeout(() => {
|
||||
logger.warn('App quit timed out; forcing process exit.');
|
||||
@@ -2376,6 +2382,8 @@ const {
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
clearImmersionTracker: () => {
|
||||
stopStatsServer();
|
||||
appState.statsServer = null;
|
||||
appState.immersionTracker = null;
|
||||
},
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
@@ -2427,13 +2435,15 @@ const ensureStatsServerStarted = (): string => {
|
||||
if (!tracker) {
|
||||
throw new Error('Immersion tracker failed to initialize.');
|
||||
}
|
||||
if (!appState.statsServer) {
|
||||
appState.statsServer = startStatsServer({
|
||||
if (!statsServer) {
|
||||
statsServer = startStatsServer({
|
||||
port: getResolvedConfig().stats.serverPort,
|
||||
staticDir: statsDistPath,
|
||||
tracker,
|
||||
});
|
||||
appState.statsServer = statsServer;
|
||||
}
|
||||
appState.statsServer = statsServer;
|
||||
return `http://127.0.0.1:${getResolvedConfig().stats.serverPort}`;
|
||||
};
|
||||
|
||||
@@ -2473,10 +2483,17 @@ const immersionTrackerStartupMainDeps: Parameters<
|
||||
resolveLegacyVocabularyPos,
|
||||
}),
|
||||
setTracker: (tracker) => {
|
||||
const trackerHasChanged =
|
||||
appState.immersionTracker !== null && appState.immersionTracker !== tracker;
|
||||
if (trackerHasChanged && appState.statsServer) {
|
||||
stopStatsServer();
|
||||
appState.statsServer = null;
|
||||
}
|
||||
|
||||
appState.immersionTracker = tracker as ImmersionTrackerService | null;
|
||||
appState.immersionTracker?.setCoverArtFetcher(statsCoverArtFetcher);
|
||||
if (tracker) {
|
||||
// Start HTTP stats server (once)
|
||||
// Start HTTP stats server
|
||||
if (!appState.statsServer) {
|
||||
const config = getResolvedConfig();
|
||||
if (config.stats.autoStartServer) {
|
||||
|
||||
Reference in New Issue
Block a user