feat: add auto update support

This commit is contained in:
2026-05-15 01:47:56 -07:00
parent d1ec678d7a
commit 094bcce0dc
101 changed files with 4978 additions and 163 deletions
+33
View File
@@ -70,6 +70,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
update: false,
updateLauncherPath: undefined,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -231,6 +233,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
},
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
},
printHelp: () => {
calls.push('printHelp');
},
@@ -363,6 +368,34 @@ test('handleCliCommand opens first-run setup window for --setup', () => {
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
});
test('handleCliCommand runs update command without overlay startup', async () => {
const { deps, calls } = createDeps();
handleCliCommand(
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer']);
});
test('handleCliCommand stops app after headless initial update completes', async () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
});
handleCliCommand(
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer', 'stopApp']);
});
test('handleCliCommand dispatches stats command without overlay startup', async () => {
const { deps, calls } = createDeps({
runStatsCommand: async () => {
+16
View File
@@ -95,6 +95,7 @@ export interface CliCommandServiceDeps {
}) => Promise<CharacterDictionarySelectionResult>;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
@@ -174,6 +175,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
}
@@ -277,6 +279,7 @@ export function createCliCommandDepsRuntime(
setCharacterDictionarySelection: options.dictionary.setSelection,
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runUpdateCommand: options.app.runUpdateCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
@@ -416,6 +419,19 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.update) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.runUpdateCommand(args, source)
.catch((err) => {
deps.error('runUpdateCommand failed:', err);
deps.showMpvOsd(`Update failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {
+1 -1
View File
@@ -27,7 +27,7 @@ export {
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
} from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings';
export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export {
addYomitanNoteViaSearch,
@@ -223,3 +223,23 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
assert.equal(result.backgroundMode, true);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
const calls: string[] = [];
const args = makeArgs({ update: true });
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--update'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
+1 -1
View File
@@ -44,7 +44,7 @@ export function runStartupBootstrapRuntime(
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background) {
} else if (initialArgs.background || initialArgs.update) {
deps.setLogLevel('warn', 'cli');
}
+103 -4
View File
@@ -9,6 +9,13 @@ const YOMITAN_SYNC_SCRIPT_PATHS = [
path.join('js', 'display', 'display-audio.js'),
];
type ExtensionCopyResult = {
targetDir: string;
copied: boolean;
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
function readManifestVersion(manifestPath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { version?: unknown };
@@ -18,6 +25,15 @@ function readManifestVersion(manifestPath: string): string | null {
}
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath);
return true;
} catch {
return false;
}
}
export function hashDirectoryContents(dirPath: string): string | null {
try {
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
@@ -53,6 +69,42 @@ export function hashDirectoryContents(dirPath: string): string | null {
}
}
export async function hashDirectoryContentsAsync(dirPath: string): Promise<string | null> {
try {
const dirStat = await fs.promises.stat(dirPath);
if (!dirStat.isDirectory()) {
return null;
}
const hash = createHash('sha256');
const queue = [''];
while (queue.length > 0) {
const relativeDir = queue.shift()!;
const absoluteDir = path.join(dirPath, relativeDir);
const entries = await fs.promises.readdir(absoluteDir, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
const normalizedRelativePath = relativePath.split(path.sep).join('/');
hash.update(normalizedRelativePath);
if (entry.isDirectory()) {
queue.push(relativePath);
continue;
}
if (!entry.isFile()) {
continue;
}
hash.update(await fs.promises.readFile(path.join(dirPath, relativePath)));
}
}
return hash.digest('hex');
} catch {
return null;
}
}
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
try {
@@ -93,10 +145,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
export function ensureExtensionCopy(
sourceDir: string,
userDataPath: string,
): {
targetDir: string;
copied: boolean;
} {
): ExtensionCopyResult {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
@@ -117,3 +166,53 @@ export function ensureExtensionCopy(
return { targetDir, copied: shouldCopy };
}
export async function ensureExtensionCopyAsync(
sourceDir: string,
userDataPath: string,
): Promise<ExtensionCopyResult> {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const inFlightKey = path.resolve(targetDir);
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
if (inFlight) {
return await inFlight;
}
const copyPromise = ensureExtensionCopyAsyncInternal(sourceDir, extensionsRoot, targetDir);
asyncExtensionCopyInFlight.set(inFlightKey, copyPromise);
try {
return await copyPromise;
} finally {
if (asyncExtensionCopyInFlight.get(inFlightKey) === copyPromise) {
asyncExtensionCopyInFlight.delete(inFlightKey);
}
}
}
async function ensureExtensionCopyAsyncInternal(
sourceDir: string,
extensionsRoot: string,
targetDir: string,
): Promise<ExtensionCopyResult> {
let shouldCopy = !(await pathExists(targetDir));
if (!shouldCopy) {
const [sourceHash, targetHash] = await Promise.all([
hashDirectoryContentsAsync(sourceDir),
hashDirectoryContentsAsync(targetDir),
]);
shouldCopy = sourceHash !== targetHash;
}
if (shouldCopy) {
await fs.promises.mkdir(extensionsRoot, { recursive: true });
await fs.promises.rm(targetDir, { recursive: true, force: true });
await fs.promises.cp(sourceDir, targetDir, { recursive: true });
}
return { targetDir, copied: shouldCopy };
}
@@ -4,7 +4,11 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
import {
ensureExtensionCopy,
ensureExtensionCopyAsync,
shouldCopyYomitanExtension,
} from './yomitan-extension-copy';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -82,3 +86,115 @@ test('ensureExtensionCopy refreshes copied extension when display files change',
'new display code',
);
});
test('ensureExtensionCopyAsync refreshes copied extension without completing synchronously', async () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
'new display code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'old display code',
);
let completed = false;
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot).then((result) => {
completed = true;
return result;
});
assert.equal(completed, false);
const result = await resultPromise;
assert.equal(result.targetDir, targetDir);
assert.equal(result.copied, true);
assert.equal(
fs.readFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'utf8',
),
'new display code',
);
});
test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied extension', async () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'pages', 'settings'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'pages', 'settings'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'pages', 'settings', 'settings-main.js'),
'new settings code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
'old settings code',
);
const originalCp = fs.promises.cp;
let cpCalls = 0;
let firstCopyStarted = false;
let releaseFirstCopy: () => void = () => {};
const firstCopyStartedPromise = new Promise<void>((resolve) => {
const fsPromises = fs.promises as typeof fs.promises & {
cp: typeof fs.promises.cp;
};
fsPromises.cp = async (...args: Parameters<typeof fs.promises.cp>) => {
cpCalls++;
if (!firstCopyStarted) {
firstCopyStarted = true;
resolve();
await new Promise<void>((release) => {
releaseFirstCopy = release;
});
}
return await originalCp(...args);
};
});
try {
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot);
await firstCopyStartedPromise;
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot);
releaseFirstCopy();
const results = await Promise.all([first, second]);
assert.equal(cpCalls, 1);
assert.equal(results[0].targetDir, targetDir);
assert.equal(results[1].targetDir, targetDir);
assert.equal(results[0].copied, true);
assert.equal(results[1].copied, true);
assert.equal(
fs.readFileSync(
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
'utf8',
),
'new settings code',
);
} finally {
const fsPromises = fs.promises as typeof fs.promises & {
cp: typeof fs.promises.cp;
};
fsPromises.cp = originalCp;
}
});
@@ -3,7 +3,7 @@ import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { ensureExtensionCopy } from './yomitan-extension-copy';
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
import {
getYomitanExtensionSearchPaths,
resolveExternalYomitanExtensionPath,
@@ -79,7 +79,7 @@ export async function loadYomitanExtension(
return null;
}
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
buildYomitanSettingsUrl,
configureYomitanSettingsWindowChrome,
destroyYomitanSettingsWindow,
showYomitanSettingsWindow,
} from './yomitan-settings';
function assertGuardedBySubminerSettingsSafe(source: string, call: string): void {
const callIndex = source.indexOf(call);
assert.notEqual(callIndex, -1, `missing call: ${call}`);
const beforeCall = source.slice(0, callIndex);
const guardIndex = beforeCall.lastIndexOf('if (!subminerSettingsSafe) {');
const blockCloseIndex = beforeCall.lastIndexOf('\n }');
assert.ok(
guardIndex > blockCloseIndex,
`${call} must be inside its own !subminerSettingsSafe startup guard`,
);
}
test('yomitan settings window removes default app menu quit action', () => {
const calls: string[] = [];
configureYomitanSettingsWindowChrome({
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
} as never);
assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
});
test('yomitan settings URL disables the embedded popup preview', () => {
assert.equal(
buildYomitanSettingsUrl('abc123'),
'chrome-extension://abc123/settings.html?popup-preview=false&subminer-settings-safe=true',
);
});
test('vendored Yomitan settings safe mode skips heavy startup controllers', () => {
const source = readFileSync(
'vendor/subminer-yomitan/ext/js/pages/settings/settings-main.js',
'utf8',
);
assert.match(source, /subminer-settings-safe/);
assertGuardedBySubminerSettingsSafe(source, 'popupPreviewController.prepare()');
assertGuardedBySubminerSettingsSafe(source, 'persistentStorageController.prepare()');
assertGuardedBySubminerSettingsSafe(source, 'storageController.prepare()');
assertGuardedBySubminerSettingsSafe(source, 'dictionaryController.prepare()');
assertGuardedBySubminerSettingsSafe(source, 'ankiController.prepare()');
assert.match(source, /if \(!subminerSettingsSafe\)[\s\S]*new AnkiDeckGeneratorController/);
assert.match(source, /if \(!subminerSettingsSafe\)[\s\S]*new SecondarySearchDictionaryController/);
assert.match(source, /if \(!subminerSettingsSafe\)[\s\S]*new SortFrequencyDictionaryController/);
});
test('vendored Yomitan settings caches dictionary metadata requests', () => {
const source = readFileSync(
'vendor/subminer-yomitan/ext/js/pages/settings/settings-controller.js',
'utf8',
);
assert.match(source, /_dictionaryInfoPromise/);
assert.match(source, /_dictionaryInfoCache/);
assert.match(source, /databaseUpdated/);
assert.match(
source,
/this\._dictionaryInfoPromise = this\._application\.api\.getDictionaryInfo\(\)/,
);
});
test('vendored Yomitan Anki settings reuses SettingsController dictionary metadata cache', () => {
const source = readFileSync(
'vendor/subminer-yomitan/ext/js/pages/settings/anki-controller.js',
'utf8',
);
assert.match(source, /this\._settingsController\.getDictionaryInfo\(\)/);
assert.doesNotMatch(source, /this\._application\.api\.getDictionaryInfo\(\)/);
});
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
const calls: string[] = [];
showYomitanSettingsWindow({
isDestroyed: () => false,
isMinimized: () => true,
restore: () => calls.push('restore'),
getSize: () => [1200, 800],
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
webContents: {
invalidate: () => calls.push('invalidate'),
},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
} as never);
assert.deepEqual(calls, ['restore', 'set-size:1200x800', 'invalidate', 'show', 'focus']);
});
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
const calls: string[] = [];
const destroyed = destroyYomitanSettingsWindow({
isDestroyed: () => false,
destroy: () => calls.push('destroy'),
} as never);
assert.equal(destroyed, true);
assert.deepEqual(calls, ['destroy']);
});
test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => {
assert.equal(destroyYomitanSettingsWindow(null), false);
assert.equal(
destroyYomitanSettingsWindow({
isDestroyed: () => true,
destroy: () => {
throw new Error('should not destroy twice');
},
} as never),
false,
);
});
+39 -9
View File
@@ -13,6 +13,39 @@ export interface OpenYomitanSettingsWindowOptions {
onWindowClosed?: () => void;
}
export function configureYomitanSettingsWindowChrome(
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
): void {
settingsWindow.setAutoHideMenuBar(true);
settingsWindow.setMenu(null);
}
export function buildYomitanSettingsUrl(extensionId: string): string {
return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
}
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
if (settingsWindow.isDestroyed()) {
return;
}
if (settingsWindow.isMinimized()) {
settingsWindow.restore();
}
const [width = 0, height = 0] = settingsWindow.getSize();
settingsWindow.setSize(width, height);
settingsWindow.webContents.invalidate();
settingsWindow.show();
settingsWindow.focus();
}
export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean {
if (!settingsWindow || settingsWindow.isDestroyed()) {
return false;
}
settingsWindow.destroy();
return true;
}
export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void {
logger.info('openYomitanSettings called');
@@ -24,8 +57,8 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
const existingWindow = options.getExistingWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
logger.info('Settings window already exists, focusing');
existingWindow.focus();
logger.info('Settings window already exists, showing and focusing');
showYomitanSettingsWindow(existingWindow);
return;
}
@@ -35,15 +68,17 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
width: 1200,
height: 800,
show: false,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: options.yomitanSession ?? session.defaultSession,
},
});
configureYomitanSettingsWindowChrome(settingsWindow);
options.setWindow(settingsWindow);
const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`;
const settingsUrl = buildYomitanSettingsUrl(options.yomitanExt.id);
logger.info('Loading settings URL:', settingsUrl);
let loadAttempts = 0;
@@ -76,12 +111,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
});
setTimeout(() => {
if (!settingsWindow.isDestroyed()) {
const [width = 0, height = 0] = settingsWindow.getSize();
settingsWindow.setSize(width, height);
settingsWindow.webContents.invalidate();
settingsWindow.show();
}
showYomitanSettingsWindow(settingsWindow);
}, 500);
settingsWindow.on('closed', () => {