Files
SubMiner/src/main/assemblies/settings.ts
T
sudacode 2924f04316 refactor(main): move domains into per-domain assemblies (#309)
* refactor(main): move the YouTube domain into an assembly

createYoutubeAssembly owns the YouTube flow, playback, media cache,
whisper subtitles, primary-subtitle notifications, and the in-app
browser, plus the browser quit-on-close state. main.ts builds it once
where the first YouTube runtime used to be and calls through the result.
Siblings built later are passed as getters or callbacks; tests pin that
construction calls none of them.

* docs(architecture): describe per-domain assemblies

* test(e2e): blank the line before mining the sub-delay cue

The previous test leaves the same cue on screen, so the overlay check
passed before the seek landed and --mine-sentence could read mpv's empty
mid-seek sub-text and skip the card.

* refactor(main): move the Jellyfin domain into an assembly

createJellyfinAssembly owns the Jellyfin composer call, its playback and
remote-progress state, the subtitle cache IO, mpv defaults, and the tray
discovery deps. main.ts builds it where the composer call used to be and
reads state through getActivePlayback/getQuitOnDisconnectArmed.

* refactor(main): move the AniList domain into an assembly

createAnilistAssembly owns the AniList setup and tracking composer calls,
the setup window, the state runtime, and the token, media-guess, in-flight
and attempted-update state they share. main.ts builds it where the setup
composer call used to be and keeps the rate limiter, which the stats
cover-art fetcher also uses.

* refactor(main): move config hot-reload, settings, and sync UI into assemblies

createSettingsAssembly owns config hot-reload and the settings window,
which share the hot-reload-applied handler; main.ts still supplies that
handler's cross-domain deps and the Yomitan deck-name helper.
createSyncAssembly owns the sync window, sync IPC runtime, and auto-sync
scheduler. Both are built where their blocks were; main.ts keeps the
registerHandlers()/start() calls at the same points.

* refactor(main): move dictionary runtimes into assemblies

createDictionariesAssembly owns the JLPT and frequency lookups and their
dictionary-root builders; main.ts still resolves the root paths at the
same point. createCharacterDictionaryAssembly owns the character
dictionary runtime, Yomitan auto-sync, and the portrait and name-candidate
lookups. Both are built where their blocks were; later siblings (Yomitan
profile policy, notifications, OSD, subtitle refresh) come in as getters
or callbacks.

* refactor(main): move overlay modal openers into an assembly

createOverlayModalsAssembly owns the shared open-with-notification helper
and the runtime options, Jimaku, TsukiHime, subtitle generation, session
help, changelog, character dictionary manager, controller, and playlist
browser openers. main.ts builds it where the openers used to live.

* refactor(logger): export the Logger type for assembly deps

The build emits declarations, and the Jellyfin assembly returns the logger
in its tray discovery deps, so tsc needs a nameable Logger type. Assemblies
now Pick from it directly.
2026-10-31 19:39:27 -07:00

142 lines
6.3 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
import { AnkiConnectClient } from '../../anki-connect';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ConfigService } from '../../config/service';
import { buildConfigSettingsRegistry } from '../../config/settings/registry';
import {
createConfigHotReloadRuntime,
promoteSettingsWindowAboveOverlay,
} from '../../core/services';
import type { Logger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
buildConfigWarningDialogDetails,
buildConfigWarningNotificationBody,
} from '../config-validation';
import {
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
createWatchConfigPathHandler,
} from '../runtime/config-hot-reload-handlers';
import {
createConfigSettingsRuntime,
type ConfigSettingsIpcMainLike,
} from '../runtime/config-settings-runtime';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createCreateConfigSettingsWindowHandler } from '../runtime/setup-window-factory';
import type { AppState } from '../state';
type ConfigHotReloadMessageDeps = Parameters<typeof createConfigHotReloadMessageHandler>[0];
/**
* What config hot-reload and the settings window need from the rest of the app. Siblings built
* after this assembly in `main.ts` are reached through getters or callbacks.
*/
export interface SettingsAssemblyDeps {
appState: Pick<AppState, 'configSettingsWindow'>;
configService: Pick<
ConfigService,
'getConfig' | 'getConfigPath' | 'getRawConfig' | 'getWarnings' | 'reloadConfigStrict'
>;
logger: Pick<Logger, 'error'>;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'getConfiguredStatusNotificationType' | 'showOverlayNotification'
>;
showMpvOsd: (message: string) => void;
showDesktopNotification: ConfigHotReloadMessageDeps['showDesktopNotification'];
/** Applies a reloaded config to the running app; shared by file hot-reload and the settings window. */
hotReloadApplied: Parameters<typeof createConfigHotReloadAppliedHandler>[0];
getYomitanAnkiDeckName: () => Promise<string>;
showErrorBox: (title: string, content: string) => void;
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
/** Directory of the main bundle; the settings preload and page live next to it. */
mainDirname: string;
openPath: (targetPath: string) => Promise<string>;
ipcMain: ConfigSettingsIpcMainLike;
}
export type SettingsAssembly = ReturnType<typeof createSettingsAssembly>;
/**
* Owns config file hot-reload and the settings window, which both apply reloaded config through
* the same handler. `main.ts` starts/stops hot-reload and registers the settings IPC handlers.
*/
export function createSettingsAssembly(deps: SettingsAssemblyDeps) {
const { appState, configService, logger } = deps;
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler({
getNotificationType: () => deps.getOverlayNotifications().getConfiguredStatusNotificationType(),
showMpvOsd: (message) => deps.showMpvOsd(message),
showOverlayNotification: (payload) =>
deps.getOverlayNotifications().showOverlayNotification(payload),
showDesktopNotification: (title, options) => deps.showDesktopNotification(title, options),
});
const watchConfigPathHandler = createWatchConfigPathHandler({
fileExists: (targetPath) => fs.existsSync(targetPath),
dirname: (targetPath) => path.dirname(targetPath),
watchPath: (targetPath, listener) => fs.watch(targetPath, listener),
});
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(deps.hotReloadApplied);
const configHotReloadRuntime = createConfigHotReloadRuntime({
getCurrentConfig: () => configService.getConfig(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange),
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timeout) => clearTimeout(timeout),
debounceMs: 250,
onHotReloadApplied: applyConfigHotReloadDiff,
onRestartRequired: (fields) =>
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
onInvalidConfig: notifyConfigHotReloadMessage,
onValidationWarnings: (configPath, warnings) => {
deps.showDesktopNotification('SubMiner', {
body: buildConfigWarningNotificationBody(configPath, warnings),
});
if (process.platform === 'darwin') {
deps.showErrorBox(
'SubMiner config validation warning',
buildConfigWarningDialogDetails(configPath, warnings),
);
}
},
});
const configSettingsRuntime = createConfigSettingsRuntime({
fields: configSettingsFields,
getConfigPath: () => configService.getConfigPath(),
getRawConfig: () => configService.getRawConfig(),
getConfig: () => configService.getConfig(),
getWarnings: () => configService.getWarnings(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(url),
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => {
appState.configSettingsWindow = window as BrowserWindow | null;
},
createSettingsWindow: createCreateConfigSettingsWindowHandler({
createBrowserWindow: (options) => deps.createBrowserWindow(options),
preloadPath: path.join(deps.mainDirname, 'preload-settings.js'),
}),
settingsHtmlPath: path.join(deps.mainDirname, 'settings', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
openPath: (targetPath) => deps.openPath(targetPath),
ipcMain: deps.ipcMain,
ipcChannels: IPC_CHANNELS.request,
log: (message) => logger.error(message),
});
return {
hotReload: configHotReloadRuntime,
window: configSettingsRuntime,
};
}