feat: improve background startup and launcher control

Detach --background launches from terminals with quieter runtime output, make wrapper/plugin overlay start explicit, and allow trailing commas in JSONC configs for safer hot-reload edits. Includes pending Anki/docs/backlog updates in this unreleased batch.
This commit is contained in:
2026-02-18 02:22:01 -08:00
parent 4703b995da
commit ebaed49f76
34 changed files with 515 additions and 48 deletions

View File

@@ -192,13 +192,35 @@ export class AnkiConnectClient {
deckName: string,
modelName: string,
fields: Record<string, string>,
tags: string[] = [],
): Promise<number> {
const note: {
deckName: string;
modelName: string;
fields: Record<string, string>;
tags?: string[];
} = { deckName, modelName, fields };
if (tags.length > 0) {
note.tags = tags;
}
const result = await this.invoke('addNote', {
note: { deckName, modelName, fields },
note,
});
return result as number;
}
async addTags(noteIds: number[], tags: string[]): Promise<void> {
if (noteIds.length === 0 || tags.length === 0) {
return;
}
await this.invoke('addTags', {
notes: noteIds,
tags: tags.join(' '),
});
}
async deleteNotes(noteIds: number[]): Promise<void> {
await this.invoke('deleteNotes', { notes: noteIds });
}

View File

@@ -175,7 +175,9 @@ export class AnkiIntegration {
getMpvClient: () => this.mpvClient,
getDeck: () => this.config.deck,
client: {
addNote: (deck, modelName, fields) => this.client.addNote(deck, modelName, fields),
addNote: (deck, modelName, fields, tags) =>
this.client.addNote(deck, modelName, fields, tags),
addTags: (noteIds, tags) => this.client.addTags(noteIds, tags),
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) =>
this.client.updateNoteFields(noteId, fields) as Promise<void>,
@@ -295,6 +297,25 @@ export class AnkiIntegration {
this.knownWordCache.stopLifecycle();
}
private getConfiguredAnkiTags(): string[] {
if (!Array.isArray(this.config.tags)) {
return [];
}
return [...new Set(this.config.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))];
}
private async addConfiguredTagsToNote(noteId: number): Promise<void> {
const tags = this.getConfiguredAnkiTags();
if (tags.length === 0) {
return;
}
try {
await this.client.addTags([noteId], tags);
} catch (error) {
log.warn('Failed to add tags to card:', (error as Error).message);
}
}
async refreshKnownWordCache(): Promise<void> {
return this.knownWordCache.refresh(true);
}
@@ -512,6 +533,7 @@ export class AnkiIntegration {
if (updatePerformed) {
await this.client.updateNoteFields(noteId, updatedFields);
await this.addConfiguredTagsToNote(noteId);
log.info('Updated card fields for:', expressionText);
await this.showNotification(noteId, expressionText);
}
@@ -1465,6 +1487,7 @@ export class AnkiIntegration {
if (Object.keys(mergedFields).length > 0) {
await this.client.updateNoteFields(keepNoteId, mergedFields);
await this.addConfiguredTagsToNote(keepNoteId);
}
if (deleteDuplicate) {
@@ -1621,6 +1644,7 @@ export class AnkiIntegration {
await this.client.updateNoteFields(noteId, {
[resolvedMiscField]: nextValue,
});
await this.addConfiguredTagsToNote(noteId);
}
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {

View File

@@ -16,7 +16,13 @@ export interface CardCreationNoteInfo {
type CardKind = 'sentence' | 'audio';
interface CardCreationClient {
addNote(deck: string, modelName: string, fields: Record<string, string>): Promise<number>;
addNote(
deck: string,
modelName: string,
fields: Record<string, string>,
tags?: string[],
): Promise<number>;
addTags(noteIds: number[], tags: string[]): Promise<void>;
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
@@ -101,6 +107,26 @@ interface CardCreationDeps {
export class CardCreationService {
constructor(private readonly deps: CardCreationDeps) {}
private getConfiguredAnkiTags(): string[] {
const tags = this.deps.getConfig().tags;
if (!Array.isArray(tags)) {
return [];
}
return [...new Set(tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))];
}
private async addConfiguredTagsToNote(noteId: number): Promise<void> {
const tags = this.getConfiguredAnkiTags();
if (tags.length === 0) {
return;
}
try {
await this.deps.client.addTags([noteId], tags);
} catch (error) {
log.warn('Failed to add tags to card:', (error as Error).message);
}
}
async updateLastAddedFromClipboard(clipboardText: string): Promise<void> {
try {
if (!clipboardText || !clipboardText.trim()) {
@@ -272,6 +298,7 @@ export class CardCreationService {
if (updatePerformed) {
await this.deps.client.updateNoteFields(noteId, updatedFields);
await this.addConfiguredTagsToNote(noteId);
const label = expressionText || noteId;
log.info('Updated card from clipboard:', label);
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
@@ -408,6 +435,7 @@ export class CardCreationService {
}
await this.deps.client.updateNoteFields(noteId, updatedFields);
await this.addConfiguredTagsToNote(noteId);
const label = expressionText || noteId;
log.info('Marked card as audio card:', label);
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
@@ -490,7 +518,12 @@ export class CardCreationService {
const deck = this.deps.getConfig().deck || 'Default';
let noteId: number;
try {
noteId = await this.deps.client.addNote(deck, sentenceCardModel, fields);
noteId = await this.deps.client.addNote(
deck,
sentenceCardModel,
fields,
this.getConfiguredAnkiTags(),
);
log.info('Created sentence card:', noteId);
this.deps.trackLastAddedNoteId?.(noteId);
} catch (error) {

View File

@@ -4,6 +4,7 @@ import { hasExplicitCommand, parseArgs, shouldStartApp } from './args';
test('parseArgs parses booleans and value flags', () => {
const args = parseArgs([
'--background',
'--start',
'--socket',
'/tmp/mpv.sock',
@@ -22,6 +23,7 @@ test('parseArgs parses booleans and value flags', () => {
'2',
]);
assert.equal(args.background, true);
assert.equal(args.start, true);
assert.equal(args.socketPath, '/tmp/mpv.sock');
assert.equal(args.backend, 'hyprland');
@@ -93,4 +95,9 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(jellyfinRemoteAnnounce.jellyfinRemoteAnnounce, true);
assert.equal(hasExplicitCommand(jellyfinRemoteAnnounce), true);
assert.equal(shouldStartApp(jellyfinRemoteAnnounce), false);
const background = parseArgs(['--background']);
assert.equal(background.background, true);
assert.equal(hasExplicitCommand(background), true);
assert.equal(shouldStartApp(background), true);
});

View File

@@ -1,4 +1,5 @@
export interface CliArgs {
background: boolean;
start: boolean;
stop: boolean;
toggle: boolean;
@@ -61,6 +62,7 @@ export type CliCommandSource = 'initial' | 'second-instance';
export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
background: false,
start: false,
stop: false,
toggle: false,
@@ -115,7 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
if (arg === '--start') args.start = true;
if (arg === '--background') args.background = true;
else if (arg === '--start') args.start = true;
else if (arg === '--stop') args.stop = true;
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
@@ -255,6 +258,7 @@ export function parseArgs(argv: string[]): CliArgs {
export function hasExplicitCommand(args: CliArgs): boolean {
return (
args.background ||
args.start ||
args.stop ||
args.toggle ||
@@ -299,6 +303,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
export function shouldStartApp(args: CliArgs): boolean {
if (args.stop && !args.start) return false;
if (
args.background ||
args.start ||
args.toggle ||
args.toggleVisibleOverlay ||

View File

@@ -10,6 +10,7 @@ ${B}SubMiner${R} — Japanese sentence mining with mpv + Yomitan
${B}Usage:${R} subminer ${D}[command] [options]${R}
${B}Session${R}
--background Start in tray/background mode
--start Connect to mpv and launch overlay
--stop Stop the running instance
--texthooker Start texthooker server only ${D}(no overlay)${R}

View File

@@ -17,6 +17,7 @@ test('loads defaults when config is missing', () => {
const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.anilist.enabled, false);
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
@@ -258,6 +259,28 @@ test('parses jsonc and warns/falls back on invalid value', () => {
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
});
test('accepts trailing commas in jsonc', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"websocket": {
"enabled": "auto",
"port": 7788,
},
"youtubeSubgen": {
"primarySubLanguages": ["ja", "en",],
},
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, 7788);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'en']);
});
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
@@ -631,6 +654,44 @@ test('accepts valid ankiConnect n+1 deck list', () => {
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']);
});
test('accepts valid ankiConnect tags list', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"tags": ["SubMiner", "Mining"]
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']);
});
test('falls back to default when ankiConnect tags list is invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"tags": ["SubMiner", 123]
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags'));
});
test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(

View File

@@ -79,6 +79,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
enabled: false,
url: 'http://127.0.0.1:8765',
pollingRate: 3000,
tags: ['SubMiner'],
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
@@ -397,6 +398,13 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
defaultValue: DEFAULT_CONFIG.ankiConnect.pollingRate,
description: 'Polling interval in milliseconds.',
},
{
path: 'ankiConnect.tags',
kind: 'array',
defaultValue: DEFAULT_CONFIG.ankiConnect.tags,
description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
},
{
path: 'ankiConnect.behavior.autoUpdateNewCards',
kind: 'boolean',

View File

@@ -174,7 +174,10 @@ export class ConfigService {
const parsed = configPath.endsWith('.jsonc')
? (() => {
const errors: ParseError[] = [];
const result = parseJsonc(data, errors);
const result = parseJsonc(data, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
@@ -889,6 +892,32 @@ export class ConfigService {
},
};
if (Array.isArray(ac.tags)) {
const normalizedTags = ac.tags
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedTags.length === ac.tags.length) {
resolved.ankiConnect.tags = [...new Set(normalizedTags)];
} else {
resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
warn(
'ankiConnect.tags',
ac.tags,
resolved.ankiConnect.tags,
'Expected an array of non-empty strings.',
);
}
} else if (ac.tags !== undefined) {
resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
warn(
'ankiConnect.tags',
ac.tags,
resolved.ankiConnect.tags,
'Expected an array of strings.',
);
}
const legacy = ac as Record<string, unknown>;
const mapLegacy = (key: string, apply: (value: unknown) => void): void => {
if (legacy[key] !== undefined) apply(legacy[key]);

View File

@@ -21,6 +21,7 @@ export interface AppLifecycleServiceDeps {
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
}
interface AppLike {
@@ -42,6 +43,7 @@ export interface AppLifecycleDepsRuntimeOptions {
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
}
export function createAppLifecycleDepsRuntime(
@@ -80,6 +82,7 @@ export function createAppLifecycleDepsRuntime(
onWillQuitCleanup: options.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: options.restoreWindowsOnActivate,
shouldQuitOnWindowAllClosed: options.shouldQuitOnWindowAllClosed,
};
}
@@ -119,7 +122,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
});
deps.onWindowAllClosed(() => {
if (!deps.isDarwinPlatform()) {
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
deps.quitApp();
}
});

View File

@@ -5,6 +5,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
stop: false,
toggle: false,

View File

@@ -5,6 +5,7 @@ import { CliArgs } from '../../cli/args';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
stop: false,
toggle: false,
@@ -80,6 +81,7 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle',
assert.equal(result.backendOverride, 'x11');
assert.equal(result.autoStartOverlay, true);
assert.equal(result.texthookerOnlyMode, true);
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
@@ -130,6 +132,7 @@ test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flag
assert.equal(result.backendOverride, null);
assert.equal(result.autoStartOverlay, false);
assert.equal(result.texthookerOnlyMode, false);
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
});
@@ -173,5 +176,26 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
assert.equal(result.mpvSocketPath, '/tmp/default.sock');
assert.equal(result.texthookerPort, 5174);
assert.equal(result.backendOverride, null);
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
});
test('runStartupBootstrapRuntime enables quiet background mode by default', () => {
const calls: string[] = [];
const args = makeArgs({ background: true });
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--background'],
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, true);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});

View File

@@ -9,6 +9,7 @@ export interface StartupBootstrapRuntimeState {
backendOverride: string | null;
autoStartOverlay: boolean;
texthookerOnlyMode: boolean;
backgroundMode: boolean;
}
interface RuntimeAutoUpdateOptionManagerLike {
@@ -47,6 +48,8 @@ export function runStartupBootstrapRuntime(
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background) {
deps.setLogLevel('warn', 'cli');
}
deps.forceX11Backend(initialArgs);
@@ -59,6 +62,7 @@ export function runStartupBootstrapRuntime(
backendOverride: initialArgs.backend ?? null,
autoStartOverlay: initialArgs.autoStartOverlay,
texthookerOnlyMode: initialArgs.texthooker,
backgroundMode: initialArgs.background,
};
if (!deps.runGenerateConfigFlow(initialArgs)) {

35
src/main-entry.ts Normal file
View File

@@ -0,0 +1,35 @@
import { spawn } from 'node:child_process';
const BACKGROUND_ARG = '--background';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!argv.includes(BACKGROUND_ARG)) return false;
if (env[BACKGROUND_CHILD_ENV] === '1') return false;
return true;
}
function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
env[BACKGROUND_CHILD_ENV] = '1';
if (!env.NODE_NO_WARNINGS) {
env.NODE_NO_WARNINGS = '1';
}
if (typeof env.VK_INSTANCE_LAYERS === 'string' && /lsfg/i.test(env.VK_INSTANCE_LAYERS)) {
delete env.VK_INSTANCE_LAYERS;
}
return env;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const child = spawn(process.execPath, process.argv.slice(1), {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
});
child.unref();
process.exit(0);
}
require('./main.js');

View File

@@ -15,6 +15,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
}
export interface AppReadyRuntimeDepsFactoryInput {
@@ -59,6 +60,7 @@ export function createAppLifecycleRuntimeDeps(
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: params.restoreWindowsOnActivate,
shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed,
};
}

View File

@@ -16,6 +16,7 @@ export interface AppLifecycleRuntimeRunnerParams {
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
}
export function createAppLifecycleRuntimeRunner(
@@ -37,6 +38,7 @@ export function createAppLifecycleRuntimeRunner(
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: params.restoreWindowsOnActivate,
shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed,
}),
),
);

View File

@@ -78,6 +78,7 @@ export interface AppState {
backendOverride: string | null;
autoStartOverlay: boolean;
texthookerOnlyMode: boolean;
backgroundMode: boolean;
jlptLevelLookup: (term: string) => JlptLevel | null;
frequencyRankLookup: FrequencyDictionaryLookup;
anilistSetupPageOpened: boolean;
@@ -90,6 +91,7 @@ export interface AppStateInitialValues {
backendOverride?: string | null;
autoStartOverlay?: boolean;
texthookerOnlyMode?: boolean;
backgroundMode?: boolean;
}
export interface StartupState {
@@ -99,6 +101,7 @@ export interface StartupState {
backendOverride: AppState['backendOverride'];
autoStartOverlay: AppState['autoStartOverlay'];
texthookerOnlyMode: AppState['texthookerOnlyMode'];
backgroundMode: AppState['backgroundMode'];
}
export function createAppState(values: AppStateInitialValues): AppState {
@@ -152,6 +155,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
backendOverride: values.backendOverride ?? null,
autoStartOverlay: values.autoStartOverlay ?? false,
texthookerOnlyMode: values.texthookerOnlyMode ?? false,
backgroundMode: values.backgroundMode ?? false,
jlptLevelLookup: () => null,
frequencyRankLookup: () => null,
anilistSetupPageOpened: false,
@@ -172,4 +176,5 @@ export function applyStartupState(appState: AppState, startupState: StartupState
appState.backendOverride = startupState.backendOverride;
appState.autoStartOverlay = startupState.autoStartOverlay;
appState.texthookerOnlyMode = startupState.texthookerOnlyMode;
appState.backgroundMode = startupState.backgroundMode;
}

View File

@@ -194,6 +194,7 @@ export interface AnkiConnectConfig {
enabled?: boolean;
url?: string;
pollingRate?: number;
tags?: string[];
fields?: {
audio?: string;
image?: string;
@@ -423,6 +424,7 @@ export interface ResolvedConfig {
enabled: boolean;
url: string;
pollingRate: number;
tags: string[];
fields: {
audio: string;
image: string;