mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
feat(notifications): add notification history panel and overlay UX fixes
- New toggleNotificationHistory (Ctrl+N) session-scoped history panel; slides in from same edge as notification stack - Overlay error/recovery toast follows notifications.overlayPosition; stack and history side seeded at startup - Cold managed background startup initializes tray and visible overlay shell before tokenization warmups finish - Add Update button to overlay update-available notifications - Fix Ctrl+S sentence-card flow: only Anki progress notification, no duplicate status toast - Fix overlay notification close/actions clickability above subtitle bars on Linux - Increase pause-until-ready default timeout from 15s to 30s
This commit is contained in:
@@ -2,6 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function waitTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
@@ -277,6 +281,71 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
||||
releaseWarmup();
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => {
|
||||
const calls: string[] = [];
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handleFirstRunSetup');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
try {
|
||||
assert.ok(calls.includes('handleFirstRunSetup'));
|
||||
assert.ok(calls.includes('handleInitialArgs'));
|
||||
assert.equal(calls.includes('loadYomitanExtension:done'), false);
|
||||
} finally {
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
|
||||
const calls: string[] = [];
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
assert.equal(calls.includes('handleInitialArgs'), false);
|
||||
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
|
||||
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
||||
const calls: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
|
||||
@@ -137,6 +137,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
@@ -242,6 +243,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -552,6 +554,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -977,6 +980,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: (update) => {
|
||||
@@ -1058,6 +1062,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async (update) => {
|
||||
@@ -1262,6 +1267,31 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards valid overlay notification actions', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const actions: Array<{ notificationId: string; actionId: string }> = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
handleOverlayNotificationAction: (notificationId: string, actionId: string) => {
|
||||
actions.push({ notificationId, actionId });
|
||||
},
|
||||
} as Partial<IpcServiceDeps>),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction);
|
||||
assert.ok(actionHandler);
|
||||
|
||||
actionHandler({}, null);
|
||||
actionHandler({}, { notificationId: '', actionId: 'install-update' });
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' });
|
||||
|
||||
assert.deepEqual(actions, [
|
||||
{ notificationId: 'subminer-update-available', actionId: 'install-update' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(
|
||||
@@ -1289,6 +1319,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface IpcServiceDeps {
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -80,6 +84,7 @@ export interface IpcServiceDeps {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -223,6 +228,18 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOverlayNotificationActionPayload(
|
||||
payload: unknown,
|
||||
): { notificationId: string; actionId: string } | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const notificationId = record.notificationId;
|
||||
const actionId = record.actionId;
|
||||
if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null;
|
||||
if (typeof actionId !== 'string' || actionId.trim().length === 0) return null;
|
||||
return { notificationId, actionId };
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
@@ -242,6 +259,10 @@ export interface IpcDepsRuntimeOptions {
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -262,6 +283,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -312,6 +334,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
|
||||
handleOverlayNotificationAction: options.handleOverlayNotificationAction,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
|
||||
quitApp: options.quitApp,
|
||||
@@ -349,6 +372,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
|
||||
getStatsToggleKey: options.getStatsToggleKey,
|
||||
getMarkWatchedKey: options.getMarkWatchedKey,
|
||||
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerConfig: options.saveControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
@@ -473,6 +497,18 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
deps.onOverlayModalOpened(parsedModal, senderWindow);
|
||||
});
|
||||
ipc.on(IPC_CHANNELS.command.overlayNotificationAction, (_event: unknown, payload: unknown) => {
|
||||
const parsedPayload = parseOverlayNotificationActionPayload(payload);
|
||||
if (!parsedPayload) return;
|
||||
void Promise.resolve(
|
||||
deps.handleOverlayNotificationAction?.(parsedPayload.notificationId, parsedPayload.actionId),
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
'Failed to handle overlay notification action:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.youtubePickerResolve,
|
||||
@@ -641,6 +677,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getMarkWatchedKey();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getOverlayNotificationPosition, () => {
|
||||
return deps.getOverlayNotificationPosition();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
mineSentenceCount: (count) => calls.push(`mine:${count}`),
|
||||
toggleSecondarySub: () => calls.push('secondary'),
|
||||
toggleSubtitleSidebar: () => calls.push('sidebar'),
|
||||
toggleNotificationHistory: () => calls.push('notification-history'),
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('audio');
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SessionActionExecutorDeps {
|
||||
mineSentenceCount: (count: number) => void;
|
||||
toggleSecondarySub: () => void;
|
||||
toggleSubtitleSidebar: () => void;
|
||||
toggleNotificationHistory: () => void;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -79,6 +80,9 @@ export async function dispatchSessionAction(
|
||||
case 'toggleSubtitleSidebar':
|
||||
deps.toggleSubtitleSidebar();
|
||||
return;
|
||||
case 'toggleNotificationHistory':
|
||||
deps.toggleNotificationHistory();
|
||||
return;
|
||||
case 'markAudioCard':
|
||||
await deps.markLastCardAsAudioCard();
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -195,7 +196,10 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
|
||||
assert.deepEqual(
|
||||
result.bindings.map((binding) => binding.sourcePath),
|
||||
['keybindings[0].key'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
|
||||
['unsupported:shortcuts.openJimaku'],
|
||||
|
||||
@@ -59,6 +59,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
||||
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
|
||||
{ key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' },
|
||||
];
|
||||
|
||||
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface AppReadyRuntimeDeps {
|
||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: () => boolean;
|
||||
}
|
||||
|
||||
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
|
||||
@@ -229,6 +230,23 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const startupStartedAtMs = now();
|
||||
const ensureYomitanExtensionReady =
|
||||
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
|
||||
let firstRunSetupHandled = false;
|
||||
let initialArgsHandled = false;
|
||||
const handleFirstRunSetupOnce = async (): Promise<void> => {
|
||||
if (firstRunSetupHandled) {
|
||||
return;
|
||||
}
|
||||
firstRunSetupHandled = true;
|
||||
await deps.handleFirstRunSetup();
|
||||
};
|
||||
const handleInitialArgsOnce = (): void => {
|
||||
if (initialArgsHandled) {
|
||||
return;
|
||||
}
|
||||
initialArgsHandled = true;
|
||||
deps.handleInitialArgs();
|
||||
};
|
||||
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||
deps.reloadConfig();
|
||||
@@ -247,7 +265,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.shouldUseMinimalStartup?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.handleInitialArgs();
|
||||
handleInitialArgsOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,8 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
return;
|
||||
}
|
||||
@@ -332,10 +350,15 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.initializeOverlayRuntime();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
await ensureYomitanExtensionReady();
|
||||
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
} else {
|
||||
await ensureYomitanExtensionReady();
|
||||
}
|
||||
}
|
||||
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ConfiguredShortcuts {
|
||||
openControllerSelect: string | null | undefined;
|
||||
openControllerDebug: string | null | undefined;
|
||||
toggleSubtitleSidebar: string | null | undefined;
|
||||
toggleNotificationHistory: string | null | undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredShortcuts(
|
||||
@@ -67,5 +68,6 @@ export function resolveConfiguredShortcuts(
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
|
||||
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
|
||||
toggleNotificationHistory: normalizeShortcut(shortcutValue('toggleNotificationHistory')),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user