rename config window to settings and update CLI entry points

- Replace `--config`/`subminer config` (no action) with `--settings`/`subminer settings`
- `subminer config` now requires an explicit action (`path` or `show`)
- `--settings` previously opened Yomitan; replaced by `--yomitan`
- Linux tray update installs AppImage via electron-updater instead of manual flow
- macOS update dialog activation and curl-fetch routing fixes
- Delete stale compiled artifacts (main.js, app-updater.js)
This commit is contained in:
2026-05-20 20:31:02 -07:00
parent fcd6511aa1
commit 166015897d
63 changed files with 500 additions and 5281 deletions
+18 -20
View File
@@ -7,7 +7,7 @@ import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldRunYomitanOnlyStartup,
shouldStartApp,
} from './args';
@@ -66,7 +66,7 @@ test('parseArgs captures update command and internal launcher paths', () => {
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunSettingsOnlyStartup(args), false);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
@@ -208,35 +208,33 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(update), true);
assert.equal(isHeadlessInitialCommand(update), true);
const yomitan = parseArgs(['--yomitan']);
assert.equal(yomitan.yomitan, true);
assert.equal(hasExplicitCommand(yomitan), true);
assert.equal(shouldStartApp(yomitan), true);
assert.equal(shouldRunYomitanOnlyStartup(yomitan), true);
const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true);
assert.equal(hasExplicitCommand(settings), true);
assert.equal(shouldStartApp(settings), true);
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
assert.equal(shouldRunYomitanOnlyStartup(settings), false);
assert.equal(commandNeedsOverlayRuntime(settings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
const configSettings = parseArgs(['--config']);
assert.equal(configSettings.configSettings, true);
assert.equal(hasExplicitCommand(configSettings), true);
assert.equal(shouldStartApp(configSettings), true);
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
assert.equal(yomitanWithOverlay.yomitan, true);
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), false);
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
assert.equal(settingsWithOverlay.settings, true);
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
const yomitanAlias = parseArgs(['--yomitan']);
assert.equal(yomitanAlias.settings, true);
assert.equal(hasExplicitCommand(yomitanAlias), true);
assert.equal(shouldStartApp(yomitanAlias), true);
const settingsDoesNotEnableYomitan = parseArgs(['--settings']);
assert.equal(settingsDoesNotEnableYomitan.yomitan, false);
const help = parseArgs(['--help']);
assert.equal(help.help, true);
assert.equal(hasExplicitCommand(help), true);
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
assert.equal(shouldRunYomitanOnlyStartup(help), false);
const appPing = parseArgs(['--app-ping']);
assert.equal(appPing.appPing, true);
+10 -10
View File
@@ -10,8 +10,8 @@ export interface CliArgs {
toggle: boolean;
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
settings: boolean;
configSettings: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -117,8 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -239,8 +239,8 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--config') args.configSettings = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -494,8 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.show ||
args.hide ||
@@ -569,8 +569,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.settings &&
!args.configSettings &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -639,8 +639,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -687,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
return false;
}
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
return (
args.settings &&
args.yomitan &&
!args.background &&
!args.start &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.configSettings &&
!args.settings &&
!args.show &&
!args.hide &&
!args.setup &&
+2 -1
View File
@@ -22,7 +22,8 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--config\s+Open configuration window/);
assert.match(output, /--settings\s+Open SubMiner settings window/);
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
+2 -2
View File
@@ -24,8 +24,8 @@ ${B}Overlay${R}
--toggle-primary-subtitle-bar Toggle primary subtitle bar
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
--config Open configuration window
--yomitan Open Yomitan settings window
--settings Open SubMiner settings window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
+63 -2
View File
@@ -19,17 +19,77 @@ test('settings registry splits viewing into appearance and behavior categories',
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
assert.equal(field('auto_start_overlay').category, 'behavior');
assert.equal(field('auto_start_overlay').section, 'Visible Overlay Auto-Start');
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
assert.equal(field('mpv.launchMode').category, 'behavior');
assert.equal(field('mpv.launchMode').section, 'MPV Launcher');
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
assert.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
);
});
test('settings registry groups playback startup controls under playback behavior', () => {
for (const path of [
'subtitleStyle.autoPauseVideoOnHover',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'subtitleSidebar.pauseVideoOnHover',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
]) {
assert.equal(field(path).category, 'behavior', path);
assert.equal(field(path).section, 'Playback Behavior', path);
}
});
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
});
test('settings registry hides removed modal-only fields', () => {
for (const path of [
'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) {
assert.equal(
fields.some((candidate) => candidate.configPath === path),
false,
path,
);
}
});
test('settings registry orders websocket server immediately after annotation websocket', () => {
const integrationSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'integrations')
.map((candidate) => candidate.section),
),
];
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
});
test('settings registry places immersion tracking after other tracking and app sections', () => {
const trackingSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'tracking-app')
.map((candidate) => candidate.section),
),
];
assert.equal(trackingSections.at(-1), 'Immersion tracking');
});
test('settings registry groups annotation display fields by config group', () => {
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
@@ -190,6 +250,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
test('settings registry marks safe live config paths as hot-reloadable', () => {
for (const path of [
'mpv.aniskipButtonKey',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
+38 -11
View File
@@ -65,13 +65,17 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope',
'jellyfin.accessToken',
'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const;
@@ -123,12 +127,11 @@ const SECTION_ORDER = new Map<string, number>(
'Primary Subtitle Appearance',
'Secondary Subtitle Appearance',
'Subtitle Sidebar Appearance',
'Playback Pause Behavior',
'Playback Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'Visible Overlay Auto-Start',
'YouTube Playback Settings',
'MPV Launcher',
'mpv Playback',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
@@ -140,7 +143,19 @@ const SECTION_ORDER = new Map<string, number>(
'MPV Keybindings',
'Overlay Shortcuts',
'Controller',
'Annotation WebSocket',
'WebSocket server',
'AniList',
'Character Dictionary',
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Yomitan',
'Stats dashboard',
'Startup warmups',
'Logging',
'Updates',
'Immersion tracking',
].map((section, index) => [section, index]),
);
@@ -169,9 +184,9 @@ const PATH_ORDER = new Map<string, number>(
'mpv.backend',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'mpv.launchMode',
'mpv.executablePath',
'mpv.aniskipButtonKey',
].map((path, index) => [path, index]),
);
@@ -186,7 +201,6 @@ const SUBSECTION_ORDER = new Map<string, number>(
'Toggle & Visibility',
'Open Panels',
'Playback',
'Timing',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
);
@@ -215,6 +229,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
'mpv.aniskipEnabled': 'Enable AniSkip',
'mpv.aniskipButtonKey': 'AniSkip Button Key',
'discordPresence.updateIntervalMs': 'Update Interval Seconds',
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
@@ -232,6 +247,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in seconds.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -295,7 +312,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'behavior', section: 'Playback Pause Behavior' };
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
@@ -373,8 +390,15 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (path === 'auto_start_overlay') {
return { category: 'behavior', section: topSection(path) };
if (
path === 'auto_start_overlay' ||
path === 'mpv.autoStartSubMiner' ||
path === 'mpv.pauseUntilOverlayReady'
) {
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
@@ -437,7 +461,7 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'MPV Launcher',
mpv: 'mpv Playback',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Subtitle Sync',
@@ -447,7 +471,7 @@ function topSection(path: string): string {
yomitan: 'Yomitan',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Visible Overlay Auto-Start',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
}
@@ -515,9 +539,11 @@ function subsectionForPath(path: string): string | undefined {
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return 'Toggle & Visibility';
}
if (path === 'mpv.aniskipButtonKey') {
return 'Playback';
}
if (path.startsWith('shortcuts.')) {
const leaf = path.split('.').at(-1) ?? '';
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
if (
leaf === 'copySubtitle' ||
leaf === 'copySubtitleMultiple' ||
@@ -632,6 +658,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
+20 -1
View File
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -223,3 +223,22 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ settings: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
+4 -1
View File
@@ -164,7 +164,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
});
deps.onWindowAllClosed(() => {
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings)
) {
deps.quitApp();
}
});
+3 -3
View File
@@ -15,8 +15,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -586,8 +586,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
args: Partial<CliArgs>;
expected: string;
}> = [
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
+2 -2
View File
@@ -386,9 +386,9 @@ export function handleCliCommand(
} else if (args.setup) {
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
} else if (args.settings) {
} else if (args.yomitan) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.configSettings) {
} else if (args.settings) {
deps.openConfigSettingsWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
@@ -21,6 +21,7 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.mpv.aniskipButtonKey = 'F8';
next.stats.toggleKey = 'F8';
next.stats.markWatchedKey = 'F9';
next.logging.level = 'debug';
@@ -52,6 +53,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
new Set(diff.hotReloadFields),
new Set([
'stats.toggleKey',
'mpv.aniskipButtonKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
+1
View File
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
+1 -1
View File
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
+39 -25
View File
@@ -21,7 +21,6 @@ import {
clipboard,
globalShortcut,
ipcMain,
net,
shell,
protocol,
Extension,
@@ -91,7 +90,7 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -505,11 +504,7 @@ import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import {
createCurlFetch,
createElectronNetFetch,
createGlobalFetch,
} from './main/runtime/update/fetch-adapter';
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
import {
@@ -618,6 +613,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const TRAY_TOOLTIP = 'SubMiner';
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
@@ -4894,28 +4890,19 @@ flushPendingMpvLogWrites = () => {
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
let updateService: ReturnType<typeof createUpdateService> | null = null;
const electronNetFetch = createElectronNetFetch({
fetch: (url, init) => net.fetch(url, init as RequestInit),
});
const globalFetchForUpdater = createGlobalFetch();
const curlFetch = createCurlFetch();
function createNativeUpdaterHttpExecutor() {
if (process.platform === 'darwin') {
return createCurlHttpExecutor();
}
if (process.platform === 'win32') {
return createFetchHttpExecutor();
}
return undefined;
return createCurlHttpExecutor();
}
function getFetchForUpdater() {
if (process.platform === 'win32') {
return globalFetchForUpdater;
}
if (process.platform === 'linux') return curlFetch;
return electronNetFetch;
if (process.platform === 'win32') return globalFetchForUpdater;
return curlFetch;
}
async function updateLauncherFromSelectedRelease(
@@ -4962,11 +4949,8 @@ function getUpdateService() {
isPackaged: app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
configureHttpExecutor:
process.platform === 'darwin' || process.platform === 'win32'
? createNativeUpdaterHttpExecutor
: undefined,
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
configureHttpExecutor: createNativeUpdaterHttpExecutor,
disableDifferentialDownload: true,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
@@ -4978,7 +4962,37 @@ function getUpdateService() {
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: () => app.focus({ steal: true }),
focusApp: async () => {
if (process.platform !== 'darwin') {
app.focus({ steal: true });
return;
}
try {
await app.dock?.show();
} catch (error) {
logger.warn('Failed to show macOS dock before update dialog', error);
}
// app.focus({ steal: true }) alone does not reliably activate the process
// when SubMiner was reached via `subminer -u` (single-instance forwarding
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
// which is the only path that reliably brings the running app forward.
await new Promise<void>((resolve) => {
execFile(
'/usr/bin/osascript',
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
{ timeout: 2000 },
(error) => {
if (error) {
logger.warn(
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
);
}
resolve();
},
);
});
app.focus({ steal: true });
},
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
+1 -1
View File
@@ -10,7 +10,7 @@ const fields: ConfigSettingsField[] = [
description: 'Launch mode setting.',
configPath: 'mpv.launchMode',
category: 'behavior',
section: 'MPV Launcher',
section: 'mpv Playback',
control: 'select',
defaultValue: 'windowed',
restartBehavior: 'restart',
+1 -1
View File
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
const window = deps.createSettingsWindow();
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
deps.log?.(`Failed to load configuration settings window: ${message}`);
deps.log?.(`Failed to load settings window: ${message}`);
deps.setSettingsWindow(null);
window.destroy?.();
});
@@ -29,8 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -122,12 +122,12 @@ function createCommandLineLauncherSnapshot(
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, settings: true })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
});
+1 -1
View File
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.yomitan ||
args.settings ||
args.configSettings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
@@ -110,7 +110,7 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
assert.deepEqual(options, {
width: 1040,
height: 760,
title: 'SubMiner Configuration',
title: 'SubMiner Settings',
show: true,
autoHideMenuBar: true,
resizable: true,
+1 -1
View File
@@ -76,7 +76,7 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
return createSetupWindowHandler(deps, {
width: 1040,
height: 760,
title: 'SubMiner Configuration',
title: 'SubMiner Settings',
resizable: true,
preloadPath: deps.preloadPath,
backgroundColor: '#24273a',
+2 -2
View File
@@ -7,8 +7,8 @@ import {
shouldStartAutomaticUpdateChecks,
} from './startup-mode-flags';
test('config settings startup uses minimal startup and skips background integrations', () => {
const args = parseArgs(['--config']);
test('settings window startup uses minimal startup and skips background integrations', () => {
const args = parseArgs(['--settings']);
const flags = getStartupModeFlags(args);
assert.equal(flags.shouldUseMinimalStartup, true);
+6 -6
View File
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
shouldRunSettingsOnlyStartup,
shouldRunYomitanOnlyStartup,
} from '../../cli/args';
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
@@ -12,15 +12,15 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.configSettings ||
initialArgs?.settings ||
initialArgs?.update ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.configSettings ||
(shouldRunYomitanOnlyStartup(initialArgs) ||
initialArgs.settings ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.update ||
@@ -32,9 +32,9 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
export function shouldRefreshAnilistOnConfigReload(
initialArgs: CliArgs | null | undefined,
): boolean {
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.settings));
}
export function shouldStartAutomaticUpdateChecks(initialArgs: CliArgs | null | undefined): boolean {
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.settings));
}
+1 -1
View File
@@ -94,7 +94,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
click: handlers.openRuntimeOptions,
},
{
label: 'Open Configuration',
label: 'Open Settings',
click: handlers.openConfigSettings,
},
{
+5 -25
View File
@@ -258,7 +258,7 @@ test('mac native updater supports Developer ID signed packaged app bundles', asy
assert.deepEqual(logged, []);
});
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
test('linux native updater is supported for direct AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
@@ -270,10 +270,8 @@ test('linux native updater is unsupported even for writable direct AppImage inst
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
assert.equal(supported, true);
assert.deepEqual(logged, []);
});
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
@@ -288,25 +286,7 @@ test('linux native updater is unsupported when APPIMAGE is missing', async () =>
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
]);
});
@@ -324,7 +304,7 @@ test('linux native updater is unsupported for package-managed AppImage installs'
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
'Skipping native Linux updater because the AppImage is managed by a system package.',
]);
});
+16 -6
View File
@@ -108,15 +108,25 @@ export async function isNativeUpdaterSupported(options: {
options.log?.('Skipping native updater because this build is not packaged.');
return false;
}
if (options.platform === 'linux') {
options.log?.(
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
);
return false;
}
if (options.platform === 'win32') {
return true;
}
if (options.platform === 'linux') {
const appImagePath = options.env?.APPIMAGE;
if (!appImagePath) {
options.log?.(
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
);
return false;
}
if (isKnownLinuxPackageManagedAppImage(appImagePath)) {
options.log?.(
'Skipping native Linux updater because the AppImage is managed by a system package.',
);
return false;
}
return true;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
+38 -5
View File
@@ -6,7 +6,7 @@ import {
type ShowMessageBox,
} from './update-dialogs';
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
test('update dialog presenter focuses app and yields the run loop before showing macOS dialogs', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
@@ -14,16 +14,44 @@ test('update dialog presenter focuses app before showing macOS dialogs', async (
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: () => calls.push('focus'),
focusApp: () => {
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => {
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: async () => {
await new Promise<void>((resolve) => setImmediate(resolve));
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter does not focus app or yield before showing non-macOS dialogs', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
@@ -31,7 +59,12 @@ test('update dialog presenter does not focus app before showing non-macOS dialog
};
const presenter = createUpdateDialogPresenter({
platform: 'linux',
focusApp: () => calls.push('focus'),
focusApp: () => {
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
+10 -4
View File
@@ -17,7 +17,8 @@ export type ShowMessageBox = (options: {
export interface UpdateDialogPresenterDeps {
showMessageBox: ShowMessageBox;
focusApp?: () => void;
focusApp?: () => void | Promise<void>;
yieldToRunLoop?: () => Promise<void>;
platform?: NodeJS.Platform;
}
@@ -33,14 +34,19 @@ export async function showNoUpdateDialog(
});
}
function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void {
async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<void> {
if ((deps.platform ?? process.platform) !== 'darwin') return;
deps.focusApp?.();
await deps.focusApp?.();
// Yield to the macOS run loop so the activation request is processed before the
// modal alert blocks JS execution; without this, the alert often appears behind
// other apps when SubMiner is not the active app at dialog-show time.
const yieldToRunLoop = deps.yieldToRunLoop ?? (() => new Promise((r) => setTimeout(r, 0)));
await yieldToRunLoop();
}
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
const showFocusedMessageBox: ShowMessageBox = async (options) => {
maybeFocusAppForDialog(deps);
await maybeFocusAppForDialog(deps);
return deps.showMessageBox(options);
};
+4 -4
View File
@@ -7,22 +7,22 @@
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self';"
/>
<title>SubMiner Configuration</title>
<title>SubMiner Settings</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main id="app" class="settings-shell">
<aside class="settings-nav" aria-label="Configuration categories">
<aside class="settings-nav" aria-label="Settings categories">
<div class="brand-block">
<div class="brand-title">SubMiner</div>
<div class="brand-subtitle">Configuration</div>
<div class="brand-subtitle">Settings</div>
</div>
<nav id="categoryNav" class="category-nav"></nav>
</aside>
<section class="settings-main">
<header class="settings-toolbar">
<div class="toolbar-title-block">
<h1 id="categoryTitle">Configuration</h1>
<h1 id="categoryTitle">Settings</h1>
<div id="categoryMeta" class="toolbar-meta"></div>
</div>
<div class="toolbar-actions">
+7 -4
View File
@@ -511,8 +511,12 @@ export function renderKnownWordsDecksInput(
void loadAnkiDeckFieldNames(deckName, draftUrl);
}
const row = createElement('div', 'deck-field-row');
const header = createElement('div', 'deck-field-row-header');
const usedDeckNames = new Set(Object.keys(currentDecks));
const deckSelect = createElement('select', 'config-input') as HTMLSelectElement;
const deckSelect = createElement(
'select',
'config-input deck-field-row-name',
) as HTMLSelectElement;
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
if (candidateDeck !== deckName && usedDeckNames.has(candidateDeck)) continue;
addOption(deckSelect, candidateDeck);
@@ -534,7 +538,6 @@ export function renderKnownWordsDecksInput(
const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : [];
const fieldNames = uniqueSorted([...availableFields, ...selectedFields]);
const fieldsWrap = createElement('div', 'deck-field-fields');
const fieldActions = createElement('div', 'deck-field-actions');
const checkboxList = createElement('div', 'field-checkbox-list');
@@ -569,7 +572,6 @@ export function renderKnownWordsDecksInput(
});
fieldActions.append(selectAllButton, clearButton);
fieldsWrap.append(fieldActions, checkboxList);
if (state.deckFieldNamesLoading.has(deckName)) {
const hint = createElement('div', 'control-hint');
@@ -609,7 +611,8 @@ export function renderKnownWordsDecksInput(
requestRender();
});
row.append(deckSelect, fieldsWrap, removeButton);
header.append(deckSelect, removeButton);
row.append(header, fieldActions, checkboxList);
const error = state.deckFieldNamesErrors.get(deckName);
if (error) {
const hint = createElement('div', 'control-hint error');
+3 -2
View File
@@ -1,4 +1,5 @@
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
import { parseOptionalNumberInputValue } from './input-values';
import {
configureAnkiControls,
@@ -143,7 +144,7 @@ export function renderControl(
field: ConfigSettingsField,
context: SettingsControlContext,
): HTMLElement {
const value = context.valueForField(field);
const value = toSettingsDisplayValue(field.configPath, context.valueForField(field));
if (field.control === 'keyboard-shortcut') {
return renderKeyboardInput(context, field, 'accelerator');
@@ -199,7 +200,7 @@ export function renderControl(
if (next.ok) {
input.classList.remove('invalid');
context.setFieldError(field.configPath, null);
context.updateDraft(field.configPath, next.value);
context.updateDraft(field.configPath, toConfigDraftValue(field.configPath, next.value));
} else {
input.classList.add('invalid');
context.setFieldError(field.configPath, 'Invalid number');
+10 -1
View File
@@ -6,6 +6,8 @@ import {
setDraftValue,
resetDraftPath,
getDirtyOperations,
toConfigDraftValue,
toSettingsDisplayValue,
} from './settings-model';
import type { ConfigSettingsField } from '../types/settings';
@@ -16,7 +18,7 @@ const fields: ConfigSettingsField[] = [
description: 'Pause while hovering subtitles.',
configPath: 'subtitleStyle.autoPauseVideoOnHover',
category: 'behavior',
section: 'Playback Pause Behavior',
section: 'Playback Behavior',
control: 'boolean',
defaultValue: true,
restartBehavior: 'hot-reload',
@@ -147,3 +149,10 @@ test('settings draft emits reset operations for css-editor-owned legacy style pa
},
]);
});
test('discord presence update interval displays seconds while saving milliseconds', () => {
const path = 'discordPresence.updateIntervalMs';
assert.equal(toSettingsDisplayValue(path, 3000), 3);
assert.equal(toConfigDraftValue(path, 2.5), 2500);
});
+20
View File
@@ -71,6 +71,26 @@ export function createSettingsDraft(
};
}
export function toSettingsDisplayValue(
path: string,
value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue {
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return value / 1000;
}
return value;
}
export function toConfigDraftValue(
path: string,
value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue {
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return Math.round(value * 1000);
}
return value;
}
export function setDraftValue(
draft: SettingsDraft,
path: string,
+16 -9
View File
@@ -615,17 +615,25 @@ code {
}
.deck-field-row {
display: grid;
grid-template-columns: minmax(140px, 0.75fr) minmax(220px, 1.25fr) auto;
gap: 8px;
align-items: start;
}
.deck-field-fields {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
gap: 8px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(24, 25, 38, 0.4);
}
.deck-field-row-header {
display: flex;
align-items: center;
gap: 8px;
}
.deck-field-row-name {
min-width: 0;
flex: 1 1 auto;
}
.deck-field-actions {
@@ -823,7 +831,6 @@ code {
.settings-toolbar,
.field-row,
.field-control,
.deck-field-row,
.keybinding-row {
display: flex;
flex-direction: column;