mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat: add auto update support (#65)
This commit is contained in:
@@ -130,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openFirstRunSetup: () => {
|
||||
calls.push('openFirstRunSetup');
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
calls.push(`setVisibleOverlayVisible:${visible}`);
|
||||
@@ -247,6 +247,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
log: (message) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
logDebug: (message) => {
|
||||
calls.push(`debug:${message}`);
|
||||
},
|
||||
warn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
@@ -358,13 +361,23 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand forces setup open for second-instance setup command', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ setup: true }), 'second-instance', deps);
|
||||
|
||||
assert.ok(calls.includes('openFirstRunSetup:force'));
|
||||
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
|
||||
});
|
||||
|
||||
test('handleCliCommand opens first-run setup window for --setup', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
|
||||
|
||||
assert.ok(calls.includes('openFirstRunSetup'));
|
||||
assert.ok(calls.includes('log:Opened first-run setup flow.'));
|
||||
assert.ok(calls.includes('openFirstRunSetup:force'));
|
||||
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
|
||||
assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
showMpvOsd: (text: string) => void;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, err: unknown) => void;
|
||||
}
|
||||
@@ -157,7 +158,7 @@ interface MiningCliRuntime {
|
||||
}
|
||||
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -211,6 +212,7 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => unknown;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, err: unknown) => void;
|
||||
}
|
||||
@@ -286,6 +288,7 @@ export function createCliCommandDepsRuntime(
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
showMpvOsd: options.mpv.showOsd,
|
||||
log: options.log,
|
||||
logDebug: options.logDebug,
|
||||
warn: options.warn,
|
||||
error: options.error,
|
||||
};
|
||||
@@ -378,8 +381,8 @@ export function handleCliCommand(
|
||||
} else if (args.togglePrimarySubtitleBar) {
|
||||
deps.togglePrimarySubtitleBar();
|
||||
} else if (args.setup) {
|
||||
deps.openFirstRunSetup();
|
||||
deps.log('Opened first-run setup flow.');
|
||||
deps.openFirstRunSetup(true);
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.settings) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
|
||||
@@ -358,3 +358,89 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
|
||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
texthooker: { launchAtStartup: false },
|
||||
}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan-direct');
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('load-yomitan-guarded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.equal(calls.includes('load-yomitan-direct'), false);
|
||||
assert.equal(calls.includes('load-yomitan-guarded'), true);
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface AppReadyRuntimeDeps {
|
||||
createImmersionTracker?: () => void;
|
||||
startJellyfinRemoteSession?: () => Promise<void>;
|
||||
loadYomitanExtension: () => Promise<void>;
|
||||
ensureYomitanExtensionLoaded?: () => Promise<void>;
|
||||
handleFirstRunSetup: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||
startBackgroundWarmups: () => void;
|
||||
@@ -215,6 +216,8 @@ export function isAutoUpdateEnabledRuntime(
|
||||
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const startupStartedAtMs = now();
|
||||
const ensureYomitanExtensionReady =
|
||||
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||
deps.reloadConfig();
|
||||
@@ -224,7 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
} else {
|
||||
deps.createMpvClient();
|
||||
deps.createSubtitleTimingTracker();
|
||||
await deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.initializeOverlayRuntime();
|
||||
deps.handleInitialArgs();
|
||||
}
|
||||
@@ -237,18 +240,10 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.logDebug?.('App-ready critical path started.');
|
||||
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
@@ -319,12 +314,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
if (deps.texthookerOnlyMode) {
|
||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||
await deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.initializeOverlayRuntime();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
await deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
}
|
||||
|
||||
await deps.handleFirstRunSetup();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ensureExtensionCopyAsync,
|
||||
shouldCopyYomitanExtension,
|
||||
} from './yomitan-extension-copy';
|
||||
import { withSuppressedYomitanExtensionWarnings } from './yomitan-extension-loader';
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
@@ -19,6 +20,66 @@ function writeFile(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
test('suppresses Yomitan contextMenus extension load warnings only while loading', async () => {
|
||||
const emitted: string[] = [];
|
||||
const warningProcess = {
|
||||
emitWarning: (warning: string | Error, options?: { type?: string }) => {
|
||||
const message = warning instanceof Error ? warning.message : warning;
|
||||
emitted.push(`${options?.type ?? ''}:${message}`);
|
||||
},
|
||||
} as Pick<NodeJS.Process, 'emitWarning'>;
|
||||
|
||||
await withSuppressedYomitanExtensionWarnings(async () => {
|
||||
warningProcess.emitWarning(
|
||||
"Warnings loading extension:\nPermission 'contextMenus' is unknown.",
|
||||
{
|
||||
type: 'ExtensionLoadWarning',
|
||||
},
|
||||
);
|
||||
warningProcess.emitWarning('Other extension warning', { type: 'ExtensionLoadWarning' });
|
||||
return null;
|
||||
}, warningProcess);
|
||||
|
||||
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
|
||||
type: 'ExtensionLoadWarning',
|
||||
});
|
||||
|
||||
assert.deepEqual(emitted, [
|
||||
'ExtensionLoadWarning:Other extension warning',
|
||||
"ExtensionLoadWarning:Permission 'contextMenus' is unknown.",
|
||||
]);
|
||||
});
|
||||
|
||||
test('suppressed Yomitan warning wrapper is re-entrant safe', async () => {
|
||||
const emitted: string[] = [];
|
||||
const warningProcess = {
|
||||
emitWarning: (warning: string | Error, options?: { type?: string }) => {
|
||||
const message = warning instanceof Error ? warning.message : warning;
|
||||
emitted.push(`${options?.type ?? ''}:${message}`);
|
||||
},
|
||||
} as Pick<NodeJS.Process, 'emitWarning'>;
|
||||
const originalEmitWarning = warningProcess.emitWarning;
|
||||
|
||||
await withSuppressedYomitanExtensionWarnings(async () => {
|
||||
await withSuppressedYomitanExtensionWarnings(async () => {
|
||||
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
|
||||
type: 'ExtensionLoadWarning',
|
||||
});
|
||||
warningProcess.emitWarning('Nested warning', { type: 'ExtensionLoadWarning' });
|
||||
}, warningProcess);
|
||||
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
|
||||
type: 'ExtensionLoadWarning',
|
||||
});
|
||||
warningProcess.emitWarning('Outer warning', { type: 'ExtensionLoadWarning' });
|
||||
}, warningProcess);
|
||||
|
||||
assert.equal(warningProcess.emitWarning, originalEmitWarning);
|
||||
assert.deepEqual(emitted, [
|
||||
'ExtensionLoadWarning:Nested warning',
|
||||
'ExtensionLoadWarning:Outer warning',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
|
||||
const tempRoot = makeTempDir('subminer-yomitan-copy-');
|
||||
const sourceDir = path.join(tempRoot, 'source');
|
||||
@@ -185,10 +246,7 @@ test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied e
|
||||
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',
|
||||
),
|
||||
fs.readFileSync(path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'), 'utf8'),
|
||||
'new settings code',
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -29,6 +29,85 @@ export interface YomitanExtensionLoaderDeps {
|
||||
setYomitanSession: (session: Session | null) => void;
|
||||
}
|
||||
|
||||
type WarningProcess = Pick<NodeJS.Process, 'emitWarning'>;
|
||||
|
||||
const suppressedWarningState = new WeakMap<
|
||||
WarningProcess,
|
||||
{
|
||||
count: number;
|
||||
originalEmitWarning: WarningProcess['emitWarning'];
|
||||
}
|
||||
>();
|
||||
|
||||
function getWarningType(warning: string | Error, args: unknown[]): string | undefined {
|
||||
if (typeof warning !== 'string') {
|
||||
return warning.name;
|
||||
}
|
||||
const firstArg = args[0];
|
||||
if (typeof firstArg === 'string') {
|
||||
return firstArg;
|
||||
}
|
||||
if (firstArg && typeof firstArg === 'object' && 'type' in firstArg) {
|
||||
const type = (firstArg as { type?: unknown }).type;
|
||||
return typeof type === 'string' ? type : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldSuppressYomitanExtensionWarning(warning: string | Error, args: unknown[]): boolean {
|
||||
const message = warning instanceof Error ? warning.message : warning;
|
||||
return (
|
||||
getWarningType(warning, args) === 'ExtensionLoadWarning' &&
|
||||
message.includes("Permission 'contextMenus' is unknown.")
|
||||
);
|
||||
}
|
||||
|
||||
export async function withSuppressedYomitanExtensionWarnings<T>(
|
||||
run: () => Promise<T>,
|
||||
warningProcess: WarningProcess = process,
|
||||
): Promise<T> {
|
||||
const existingState = suppressedWarningState.get(warningProcess);
|
||||
if (existingState) {
|
||||
existingState.count++;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
existingState.count--;
|
||||
if (existingState.count === 0) {
|
||||
warningProcess.emitWarning = existingState.originalEmitWarning;
|
||||
suppressedWarningState.delete(warningProcess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const originalEmitWarning = warningProcess.emitWarning;
|
||||
const state = {
|
||||
count: 1,
|
||||
originalEmitWarning,
|
||||
};
|
||||
suppressedWarningState.set(warningProcess, state);
|
||||
warningProcess.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
|
||||
if (shouldSuppressYomitanExtensionWarning(warning, args)) {
|
||||
return;
|
||||
}
|
||||
return (originalEmitWarning as (...emitArgs: unknown[]) => void).call(
|
||||
warningProcess,
|
||||
warning,
|
||||
...args,
|
||||
);
|
||||
}) as typeof process.emitWarning;
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
state.count--;
|
||||
if (state.count === 0) {
|
||||
warningProcess.emitWarning = originalEmitWarning;
|
||||
suppressedWarningState.delete(warningProcess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadYomitanExtension(
|
||||
deps: YomitanExtensionLoaderDeps,
|
||||
): Promise<Extension | null> {
|
||||
@@ -79,9 +158,20 @@ export async function loadYomitanExtension(
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
|
||||
let extensionCopy: { copied: boolean; targetDir: string };
|
||||
try {
|
||||
extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy Yomitan extension:', {
|
||||
error,
|
||||
extensionPath: extPath,
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
clearRuntimeState();
|
||||
return null;
|
||||
}
|
||||
if (extensionCopy.copied) {
|
||||
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
}
|
||||
extPath = extensionCopy.targetDir;
|
||||
}
|
||||
@@ -91,13 +181,15 @@ export async function loadYomitanExtension(
|
||||
|
||||
try {
|
||||
const extensions = targetSession.extensions;
|
||||
const extension = extensions
|
||||
? await extensions.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
})
|
||||
: await targetSession.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
});
|
||||
const extension = await withSuppressedYomitanExtensionWarnings(() =>
|
||||
extensions
|
||||
? extensions.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
})
|
||||
: targetSession.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
}),
|
||||
);
|
||||
deps.setYomitanExtension(extension);
|
||||
return extension;
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,27 +2,101 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildYomitanSettingsCloseButtonScript,
|
||||
buildYomitanSettingsWindowMenuTemplate,
|
||||
buildYomitanSettingsUrl,
|
||||
configureYomitanSettingsWindowChrome,
|
||||
destroyYomitanSettingsWindow,
|
||||
installYomitanSettingsCloseButton,
|
||||
showYomitanSettingsWindow,
|
||||
shouldInstallYomitanSettingsCloseButton,
|
||||
} from './yomitan-settings';
|
||||
|
||||
test('yomitan settings window removes default app menu quit action', () => {
|
||||
test('yomitan settings window uses a close-only menu without app quit', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
configureYomitanSettingsWindowChrome({
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close'),
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never);
|
||||
} as never, (template) => {
|
||||
calls.push(`menu-label:${template[0]?.label ?? ''}`);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
const closeItem = submenu[0];
|
||||
assert.equal(closeItem?.label, 'Close');
|
||||
assert.notEqual(closeItem?.role, 'quit');
|
||||
closeItem?.click?.({} as never, {} as never, {} as never);
|
||||
return { id: 'settings-menu' } as never;
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
|
||||
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
|
||||
});
|
||||
|
||||
test('yomitan settings close menu skips destroyed windows', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildYomitanSettingsWindowMenuTemplate({
|
||||
isDestroyed: () => true,
|
||||
close: () => calls.push('close'),
|
||||
} as never);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
submenu[0]?.click?.({} as never, {} as never, {} as never);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('yomitan settings close button script installs an idempotent in-page close control', () => {
|
||||
const script = buildYomitanSettingsCloseButtonScript();
|
||||
|
||||
assert.match(script, /subminer-yomitan-settings-close/);
|
||||
assert.match(script, /aria-label', 'Close Yomitan settings'/);
|
||||
assert.match(script, /window\.close\(\)/);
|
||||
assert.match(script, /getElementById\(buttonId\)/);
|
||||
});
|
||||
|
||||
test('yomitan settings close button only installs for Hyprland sessions', () => {
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: '' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('win32', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('yomitan settings close button injection skips non-Hyprland windows', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
installYomitanSettingsCloseButton(
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: () => {
|
||||
calls.push('execute');
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{ platform: 'darwin', env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' } },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
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',
|
||||
'chrome-extension://abc123/settings.html?popup-preview=false',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
|
||||
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
|
||||
const logger = createLogger('main:yomitan-settings');
|
||||
|
||||
export interface OpenYomitanSettingsWindowOptions {
|
||||
@@ -13,15 +13,127 @@ export interface OpenYomitanSettingsWindowOptions {
|
||||
onWindowClosed?: () => void;
|
||||
}
|
||||
|
||||
export function configureYomitanSettingsWindowChrome(
|
||||
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
|
||||
type YomitanSettingsWindowMenuOwner = Pick<BrowserWindow, 'close' | 'isDestroyed'>;
|
||||
|
||||
type HyprlandSessionEnv = {
|
||||
HYPRLAND_INSTANCE_SIGNATURE?: string;
|
||||
};
|
||||
|
||||
export interface InstallYomitanSettingsCloseButtonOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: HyprlandSessionEnv;
|
||||
}
|
||||
|
||||
export function shouldInstallYomitanSettingsCloseButton(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: HyprlandSessionEnv = process.env,
|
||||
): boolean {
|
||||
return platform === 'linux' && Boolean(env.HYPRLAND_INSTANCE_SIGNATURE);
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsWindowMenuTemplate(
|
||||
settingsWindow: YomitanSettingsWindowMenuOwner,
|
||||
): MenuItemConstructorOptions[] {
|
||||
return [
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Close',
|
||||
accelerator: process.platform === 'darwin' ? 'Command+W' : 'Ctrl+W',
|
||||
click: () => {
|
||||
if (!settingsWindow.isDestroyed()) {
|
||||
settingsWindow.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsCloseButtonScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const buttonId = 'subminer-yomitan-settings-close';
|
||||
const styleId = 'subminer-yomitan-settings-close-style';
|
||||
if (document.getElementById(buttonId)) {
|
||||
return;
|
||||
}
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = \`
|
||||
#\${buttonId} {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 2147483647;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 4px;
|
||||
background: rgba(24, 24, 24, 0.92);
|
||||
color: #f2f2f2;
|
||||
font: 22px/1 system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
#\${buttonId}:hover {
|
||||
background: rgba(54, 54, 54, 0.96);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
#\${buttonId}:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
\`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.id = buttonId;
|
||||
button.type = 'button';
|
||||
button.title = 'Close';
|
||||
button.setAttribute('aria-label', 'Close Yomitan settings');
|
||||
button.textContent = '\\u00d7';
|
||||
button.addEventListener('click', () => {
|
||||
window.close();
|
||||
});
|
||||
document.body.appendChild(button);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
export function installYomitanSettingsCloseButton(
|
||||
settingsWindow: Pick<BrowserWindow, 'isDestroyed' | 'webContents'>,
|
||||
options: InstallYomitanSettingsCloseButtonOptions = {},
|
||||
): void {
|
||||
settingsWindow.setAutoHideMenuBar(true);
|
||||
settingsWindow.setMenu(null);
|
||||
if (settingsWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldInstallYomitanSettingsCloseButton(options.platform, options.env)) {
|
||||
return;
|
||||
}
|
||||
settingsWindow.webContents
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
|
||||
.catch((error: Error) => {
|
||||
logger.warn('Failed to install Yomitan settings close button:', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
export function configureYomitanSettingsWindowChrome(
|
||||
settingsWindow: Pick<BrowserWindow, 'close' | 'isDestroyed' | 'setAutoHideMenuBar' | 'setMenu'>,
|
||||
buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) =>
|
||||
ElectronMenu.buildFromTemplate(template),
|
||||
): void {
|
||||
settingsWindow.setAutoHideMenuBar(false);
|
||||
settingsWindow.setMenu(buildMenu(buildYomitanSettingsWindowMenuTemplate(settingsWindow)));
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsUrl(extensionId: string): string {
|
||||
return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
|
||||
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
|
||||
}
|
||||
|
||||
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
|
||||
@@ -108,6 +220,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
|
||||
settingsWindow.webContents.on('did-finish-load', () => {
|
||||
logger.info('Settings page loaded successfully');
|
||||
installYomitanSettingsCloseButton(settingsWindow);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user