mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
a982debd2f
- add activateMacOSApp to steal app focus before window.focus(), since show()/focus() only reorder windows within an already-active app - wire activateApp through config-settings-window/runtime into the settings, sync, and anime browser window handlers - restore the anime browser's Dock icon before showing the window instead of after, since an accessory process cannot become frontmost
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
|
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
|
import type {
|
|
ConfigSettingsAnkiDeckResult,
|
|
ConfigSettingsAnkiListResult,
|
|
ConfigSettingsField,
|
|
ConfigSettingsSaveResult,
|
|
ConfigSettingsSnapshot,
|
|
} from '../../types/settings';
|
|
import type { ReloadConfigStrictResult } from '../../config';
|
|
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
|
import {
|
|
createSaveConfigSettingsPatchHandler,
|
|
type ConfigSettingsHotReloadDiff,
|
|
} from './config-settings-save';
|
|
import {
|
|
createOpenConfigSettingsWindowHandler,
|
|
type ConfigSettingsWindowLike,
|
|
} from './config-settings-window';
|
|
import { isConfigSettingsPatch } from './config-settings-ipc';
|
|
|
|
export interface ConfigSettingsIpcMainLike {
|
|
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
|
}
|
|
|
|
export interface ConfigSettingsIpcChannels {
|
|
getConfigSettingsSnapshot: string;
|
|
saveConfigSettingsPatch: string;
|
|
openConfigSettingsFile: string;
|
|
openConfigSettingsWindow: string;
|
|
getConfigSettingsAnkiDeckNames: string;
|
|
getConfigSettingsAnkiDeckFieldNames: string;
|
|
getConfigSettingsAnkiDeckModelNames: string;
|
|
getConfigSettingsAnkiModelNames: string;
|
|
getConfigSettingsAnkiModelFieldNames: string;
|
|
getConfigSettingsYomitanAnkiDeckName: string;
|
|
}
|
|
|
|
export interface ConfigSettingsAnkiClient {
|
|
deckNames(): Promise<string[]>;
|
|
fieldNamesForDeck(deckName: string): Promise<string[]>;
|
|
modelNamesForDeck(deckName: string): Promise<string[]>;
|
|
modelNames(): Promise<string[]>;
|
|
modelFieldNames(modelName: string): Promise<string[]>;
|
|
}
|
|
|
|
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
|
|
fields: ConfigSettingsField[];
|
|
getConfigPath(): string;
|
|
getRawConfig(): RawConfig;
|
|
getConfig(): ResolvedConfig;
|
|
getWarnings(): ConfigValidationWarning[];
|
|
reloadConfigStrict(): ReloadConfigStrictResult;
|
|
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
|
getSettingsWindow(): TWindow | null;
|
|
setSettingsWindow(window: TWindow | null): void;
|
|
createSettingsWindow(): TWindow;
|
|
settingsHtmlPath: string;
|
|
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
|
|
activateApp?: () => void;
|
|
openPath(path: string): Promise<string>;
|
|
defaultAnkiConnectUrl: string;
|
|
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
|
getYomitanAnkiDeckName?: () => Promise<string | null | undefined>;
|
|
ipcMain: ConfigSettingsIpcMainLike;
|
|
ipcChannels: ConfigSettingsIpcChannels;
|
|
log?: (message: string) => void;
|
|
}
|
|
|
|
export function writeTextFileAtomically(targetPath: string, content: string): void {
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
const tempPath = path.join(
|
|
path.dirname(targetPath),
|
|
`.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`,
|
|
);
|
|
try {
|
|
fs.writeFileSync(tempPath, content, 'utf-8');
|
|
fs.renameSync(tempPath, targetPath);
|
|
} catch (error) {
|
|
try {
|
|
fs.rmSync(tempPath, { force: true });
|
|
} catch {
|
|
// Best effort cleanup after a failed atomic write.
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function getRestartRequiredSettingsSections(
|
|
fields: readonly ConfigSettingsField[],
|
|
restartRequiredFields: string[],
|
|
): string[] {
|
|
const sections = new Set<string>();
|
|
for (const field of fields) {
|
|
if (
|
|
restartRequiredFields.some(
|
|
(restartField) =>
|
|
field.configPath === restartField ||
|
|
field.configPath.startsWith(`${restartField}.`) ||
|
|
restartField.startsWith(`${field.configPath}.`),
|
|
)
|
|
) {
|
|
sections.add(field.section);
|
|
}
|
|
}
|
|
return [...sections].sort();
|
|
}
|
|
|
|
export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindowLike>(
|
|
deps: ConfigSettingsRuntimeDeps<TWindow>,
|
|
) {
|
|
function getSnapshot(): ConfigSettingsSnapshot {
|
|
return buildConfigSettingsSnapshot({
|
|
configPath: deps.getConfigPath(),
|
|
rawConfig: deps.getRawConfig(),
|
|
resolvedConfig: deps.getConfig(),
|
|
warnings: deps.getWarnings(),
|
|
fields: deps.fields,
|
|
});
|
|
}
|
|
|
|
const savePatch = createSaveConfigSettingsPatchHandler({
|
|
getConfigPath: () => deps.getConfigPath(),
|
|
getCurrentConfig: () => deps.getConfig(),
|
|
getWarnings: () => deps.getWarnings(),
|
|
getSnapshot,
|
|
fileExists: (targetPath) => fs.existsSync(targetPath),
|
|
readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'),
|
|
writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content),
|
|
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
|
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
|
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
|
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
|
onHotReloadApplied: deps.onHotReloadApplied,
|
|
});
|
|
|
|
function ensureConfigFileExists(): string {
|
|
const configPath = deps.getConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
writeTextFileAtomically(configPath, '{}\n');
|
|
}
|
|
return configPath;
|
|
}
|
|
|
|
const openWindow = createOpenConfigSettingsWindowHandler({
|
|
getSettingsWindow: deps.getSettingsWindow,
|
|
setSettingsWindow: deps.setSettingsWindow,
|
|
createSettingsWindow: deps.createSettingsWindow,
|
|
settingsHtmlPath: deps.settingsHtmlPath,
|
|
promoteSettingsWindowAboveOverlay: deps.promoteSettingsWindowAboveOverlay,
|
|
activateApp: deps.activateApp,
|
|
log: deps.log,
|
|
});
|
|
|
|
function invalidPatchResult(): ConfigSettingsSaveResult {
|
|
return {
|
|
ok: false,
|
|
warnings: [],
|
|
error: 'Invalid config settings patch.',
|
|
hotReloadFields: [],
|
|
restartRequiredFields: [],
|
|
restartRequiredSections: [],
|
|
};
|
|
}
|
|
|
|
function getAnkiConnectUrl(draftUrl: unknown): string {
|
|
return typeof draftUrl === 'string' && draftUrl.trim().length > 0
|
|
? draftUrl.trim()
|
|
: deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl;
|
|
}
|
|
|
|
async function getAnkiList(
|
|
draftUrl: unknown,
|
|
lookup: (client: ConfigSettingsAnkiClient) => Promise<string[]>,
|
|
): Promise<ConfigSettingsAnkiListResult> {
|
|
try {
|
|
const client = deps.createAnkiClient(getAnkiConnectUrl(draftUrl));
|
|
return { ok: true, values: await lookup(client) };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
values: [],
|
|
error: error instanceof Error ? error.message : 'Failed to query AnkiConnect.',
|
|
};
|
|
}
|
|
}
|
|
|
|
function invalidAnkiListResult(error: string): ConfigSettingsAnkiListResult {
|
|
return {
|
|
ok: false,
|
|
values: [],
|
|
error,
|
|
};
|
|
}
|
|
|
|
function persistInferredYomitanDeckIfEmpty(deckName: string): void {
|
|
const normalizedDeckName = deckName.trim();
|
|
const configuredDeckName = deps.getConfig().ankiConnect?.deck?.trim() ?? '';
|
|
if (!normalizedDeckName || configuredDeckName) {
|
|
return;
|
|
}
|
|
|
|
const result = savePatch({
|
|
operations: [
|
|
{
|
|
op: 'set',
|
|
path: 'ankiConnect.deck',
|
|
value: normalizedDeckName,
|
|
},
|
|
],
|
|
});
|
|
if (!result.ok) {
|
|
deps.log?.(
|
|
`Failed to persist inferred Yomitan Anki deck: ${result.error ?? 'unknown error'}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
|
|
if (!deps.getYomitanAnkiDeckName) {
|
|
return { ok: true, value: '' };
|
|
}
|
|
try {
|
|
const value = await deps.getYomitanAnkiDeckName();
|
|
const deckName = typeof value === 'string' ? value.trim() : '';
|
|
persistInferredYomitanDeckIfEmpty(deckName);
|
|
return { ok: true, value: deckName };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
value: '',
|
|
error: error instanceof Error ? error.message : 'Failed to query Yomitan.',
|
|
};
|
|
}
|
|
}
|
|
|
|
function registerHandlers(): void {
|
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
|
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
|
if (!isConfigSettingsPatch(patch, deps.fields)) {
|
|
return invalidPatchResult();
|
|
}
|
|
return savePatch(patch);
|
|
});
|
|
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsFile, async () => {
|
|
const openError = await deps.openPath(ensureConfigFileExists());
|
|
return openError.length === 0;
|
|
});
|
|
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
|
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiDeckNames, (_event, draftUrl) =>
|
|
getAnkiList(draftUrl, (client) => client.deckNames()),
|
|
);
|
|
deps.ipcMain.handle(
|
|
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
|
|
(_event, deckName, draftUrl) => {
|
|
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
|
|
return normalizedDeckName
|
|
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(normalizedDeckName))
|
|
: invalidAnkiListResult('Deck name is required.');
|
|
},
|
|
);
|
|
deps.ipcMain.handle(
|
|
deps.ipcChannels.getConfigSettingsAnkiDeckModelNames,
|
|
(_event, deckName, draftUrl) => {
|
|
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
|
|
return normalizedDeckName
|
|
? getAnkiList(draftUrl, (client) => client.modelNamesForDeck(normalizedDeckName))
|
|
: invalidAnkiListResult('Deck name is required.');
|
|
},
|
|
);
|
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
|
|
getAnkiList(draftUrl, (client) => client.modelNames()),
|
|
);
|
|
deps.ipcMain.handle(
|
|
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
|
|
(_event, modelName, draftUrl) => {
|
|
const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : '';
|
|
return normalizedModelName
|
|
? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName))
|
|
: invalidAnkiListResult('Note type is required.');
|
|
},
|
|
);
|
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsYomitanAnkiDeckName, () =>
|
|
getYomitanAnkiDeckName(),
|
|
);
|
|
}
|
|
|
|
return {
|
|
getSnapshot,
|
|
savePatch,
|
|
openWindow,
|
|
registerHandlers,
|
|
};
|
|
}
|