diff --git a/backlog/tasks/task-317 - Add-browser-open-affordance-for-texthooker.md b/backlog/tasks/task-317 - Add-browser-open-affordance-for-texthooker.md new file mode 100644 index 00000000..0d7c6c50 --- /dev/null +++ b/backlog/tasks/task-317 - Add-browser-open-affordance-for-texthooker.md @@ -0,0 +1,35 @@ +--- +id: TASK-317 +title: Add browser open affordance for texthooker +status: Done +assignee: [] +created_date: '2026-05-03 02:02' +updated_date: '2026-05-03 02:21' +labels: + - feature + - texthooker +dependencies: [] +priority: medium +--- + +## Description + + +Add a `-o` flag to the texthooker subcommand to open the texthooker page in the user's default browser, and add a tray app option that triggers the same behavior. Implement with tests and existing launcher/tray patterns. + + +## Acceptance Criteria + +- [x] #1 `texthooker -o` starts/targets the texthooker page and opens it in the default browser. +- [x] #2 Tray app exposes a menu option to open the texthooker page in the default browser. +- [x] #3 Existing texthooker behavior without `-o` remains unchanged. +- [x] #4 Relevant CLI/tray behavior covered by tests. + + +## Final Summary + + +Implemented `subminer texthooker -o` by parsing the launcher subcommand flag, forwarding `--open-browser` to the app texthooker command, and allowing that app arg to force browser opening even when `texthooker.openBrowser` is false. Added an `Open Texthooker` tray menu item wired through the same CLI command path. Updated docs-site usage/launcher/API docs and added a changelog fragment. Verification: targeted CLI/tray tests passed; `bun run typecheck` passed; `bun run docs:test` passed; `bun run changelog:lint` passed; `bun run test:env` passed; `bun run build` passed; `bun run test:smoke:dist` passed; `bun run docs:build` passed after installing docs-site deps. `bun run test:fast` is blocked by an existing broader-suite failure in `runSubsyncManual writes deterministic _retimed filename when replace is false` (`window.electronAPI` undefined), followed by Bun nested-test cascade errors. + +Follow-up fix: `subminer texthooker -o` now opens `http://127.0.0.1:5174` from the launcher after a successful texthooker app handoff, so it works even when the installed SubMiner app binary does not yet understand the app-side `--open-browser` flag. Reproduced the reported behavior; confirmed the texthooker server was running at `127.0.0.1:5174`; added a launcher regression asserting the browser URL is opened. Verification: `bun test launcher/mpv.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts src/core/services/cli-command.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts` passed; `bun run typecheck` passed; `bun run build:launcher` passed. + diff --git a/changes/317-texthooker-open-browser.md b/changes/317-texthooker-open-browser.md new file mode 100644 index 00000000..fb613d12 --- /dev/null +++ b/changes/317-texthooker-open-browser.md @@ -0,0 +1,4 @@ +type: added +area: texthooker + +- Texthooker: Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser. diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 0b8cb7a8..7160e80c 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -85,6 +85,7 @@ subminer stats -b # start background stats daemon | `subminer dictionary --candidates ` | List AniList candidate matches for character dictionary correction | | `subminer dictionary --select ` | Pin an AniList media ID for that target series | | `subminer texthooker` | Launch texthooker-only mode | +| `subminer texthooker -o` | Launch texthooker and open it in the default browser | | `subminer app` | Pass arguments directly to SubMiner binary | Use `subminer -h` for command-specific help. diff --git a/docs-site/usage.md b/docs-site/usage.md index 6f8b9f9e..0b1bb68e 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -103,12 +103,14 @@ subminer dictionary /path/to/file-or-directory # Generate character dictionary subminer dictionary --candidates /path/to/file.mkv subminer dictionary --select 21355 /path/to/file.mkv subminer texthooker # Launch texthooker-only mode +subminer texthooker -o # Launch texthooker and open it in your browser subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) # Direct packaged app control SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs) SubMiner.AppImage --start --texthooker # Start overlay with texthooker SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window) +SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser SubMiner.AppImage --setup # Open first-run setup popup SubMiner.AppImage --stop # Stop overlay SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index bd51d4f0..44fb9066 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -164,6 +164,8 @@ Start it with either: ```bash subminer texthooker +# or open the page immediately +subminer texthooker -o ``` or by leaving `texthooker.launchAtStartup` enabled. @@ -273,7 +275,7 @@ Examples: Examples: - open a media picker, then call `subminer /path/to/file.mkv` -- launch browser-only subtitle tooling with `subminer texthooker` +- launch browser-only subtitle tooling with `subminer texthooker -o` - disable the helper UI for a session with `subminer --no-texthooker video.mkv` #### Build an overlay-adjacent client diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 2b703e23..b043e042 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -28,6 +28,7 @@ function createContext(): LauncherCommandContext { useTexthooker: false, autoStartOverlay: false, texthookerOnly: false, + texthookerOpenBrowser: false, useRofi: false, logLevel: 'info', passwordStore: '', diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 1cf6f801..a3d6b5f6 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -144,6 +144,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { doctorRefreshKnownWords: false, texthookerTriggered: false, texthookerLogLevel: null, + texthookerOpenBrowser: false, }); assert.equal(parsed.jellyfin, false); @@ -157,3 +158,36 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { assert.equal(parsed.configShow, true); assert.equal(parsed.logLevel, 'warn'); }); + +test('applyInvocationsToArgs maps texthooker browser-open request', () => { + const parsed = createDefaultArgs({}); + + applyInvocationsToArgs(parsed, { + jellyfinInvocation: null, + configInvocation: null, + mpvInvocation: null, + appInvocation: null, + dictionaryTriggered: false, + dictionaryTarget: null, + dictionaryLogLevel: null, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: null, + statsTriggered: false, + statsBackground: false, + statsStop: false, + statsCleanup: false, + statsCleanupVocab: false, + statsCleanupLifetime: false, + statsLogLevel: null, + doctorTriggered: false, + doctorLogLevel: null, + doctorRefreshKnownWords: false, + texthookerTriggered: true, + texthookerLogLevel: null, + texthookerOpenBrowser: true, + }); + + assert.equal(parsed.texthookerOnly, true); + assert.equal(parsed.texthookerOpenBrowser, true); +}); diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 27e24c18..88b8e2d2 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -184,6 +184,7 @@ export function createDefaultArgs( useTexthooker: true, autoStartOverlay: false, texthookerOnly: false, + texthookerOpenBrowser: false, useRofi: false, logLevel: 'info', passwordStore: '', @@ -247,6 +248,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; if (invocations.texthookerTriggered) parsed.texthookerOnly = true; + if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true; if (invocations.jellyfinInvocation) { if (invocations.jellyfinInvocation.logLevel) { diff --git a/launcher/config/cli-parser-builder.test.ts b/launcher/config/cli-parser-builder.test.ts index ada31119..b62d5f14 100644 --- a/launcher/config/cli-parser-builder.test.ts +++ b/launcher/config/cli-parser-builder.test.ts @@ -35,3 +35,10 @@ test('parseCliPrograms routes app alias arguments through passthrough mode', () appArgs: ['--anilist', '--log-level', 'debug'], }); }); + +test('parseCliPrograms captures texthooker browser-open flag', () => { + const result = parseCliPrograms(['texthooker', '-o'], 'subminer'); + + assert.equal(result.invocations.texthookerTriggered, true); + assert.equal(result.invocations.texthookerOpenBrowser, true); +}); diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index b92a31c5..babd68c2 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -42,6 +42,7 @@ export interface CliInvocations { doctorRefreshKnownWords: boolean; texthookerTriggered: boolean; texthookerLogLevel: string | null; + texthookerOpenBrowser: boolean; } function applyRootOptions(program: Command): void { @@ -152,6 +153,7 @@ export function parseCliPrograms( let doctorLogLevel: string | null = null; let doctorRefreshKnownWords = false; let texthookerLogLevel: string | null = null; + let texthookerOpenBrowser = false; let doctorTriggered = false; let texthookerTriggered = false; @@ -313,10 +315,12 @@ export function parseCliPrograms( commandProgram .command('texthooker') .description('Launch texthooker-only mode') + .option('-o, --open-browser', 'Open texthooker in the default browser') .option('--log-level ', 'Log level') .action((options: Record) => { texthookerTriggered = true; texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + texthookerOpenBrowser = options.openBrowser === true; }); commandProgram @@ -369,6 +373,7 @@ export function parseCliPrograms( doctorRefreshKnownWords, texthookerTriggered, texthookerLogLevel, + texthookerOpenBrowser, }, }; } diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 7d28f4c9..4345bc51 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -270,6 +270,29 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () assert.equal(error.code, 1); }); +test('launchTexthookerOnly forwards browser-open request to app command', () => { + const { dir } = createTempSocketPath(); + const appPath = path.join(dir, 'fake-subminer.sh'); + const argsPath = path.join(dir, 'args.txt'); + const openedUrls: string[] = []; + fs.writeFileSync(appPath, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsPath}"\nexit 0\n`); + fs.chmodSync(appPath, 0o755); + + const error = withProcessExitIntercept(() => { + launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), { + openBrowser: (url) => openedUrls.push(url), + }); + }); + + assert.equal(error.code, 0); + assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [ + '--texthooker', + '--open-browser', + ]); + assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']); + fs.rmSync(dir, { recursive: true, force: true }); +}); + test('launchAppCommandDetached handles child process spawn errors', async () => { let uncaughtError: Error | null = null; const onUncaughtException = (error: Error) => { @@ -399,6 +422,7 @@ function makeArgs(overrides: Partial = {}): Args { useTexthooker: false, autoStartOverlay: false, texthookerOnly: false, + texthookerOpenBrowser: false, useRofi: false, logLevel: 'error', passwordStore: '', diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 61cf3049..14773775 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -831,8 +831,30 @@ export async function startOverlay( } } -export function launchTexthookerOnly(appPath: string, args: Args): never { +export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void { + const target = + process.platform === 'darwin' + ? { command: 'open', args: [url] } + : process.platform === 'win32' + ? { command: 'cmd', args: ['/c', 'start', '', url] } + : { command: 'xdg-open', args: [url] }; + const result = spawnSync(target.command, target.args, { + stdio: 'ignore', + env: process.env, + windowsHide: true, + }); + if (result.error) { + log('warn', logLevel, `Failed to open browser for ${url}: ${result.error.message}`); + } +} + +export function launchTexthookerOnly( + appPath: string, + args: Args, + deps: { openBrowser?: (url: string) => void } = {}, +): never { const overlayArgs = ['--texthooker']; + if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser'); if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); log('info', args.logLevel, 'Launching texthooker mode...'); @@ -840,6 +862,13 @@ export function launchTexthookerOnly(appPath: string, args: Args): never { if (result.error) { fail(`Failed to launch texthooker mode: ${result.error.message}`); } + if (args.texthookerOpenBrowser && (result.status ?? 0) === 0) { + const url = 'http://127.0.0.1:5174'; + const openBrowser = + deps.openBrowser ?? + ((browserUrl: string) => openUrlInDefaultBrowser(browserUrl, args.logLevel)); + openBrowser(url); + } process.exit(result.status ?? 0); } diff --git a/launcher/types.ts b/launcher/types.ts index 588e12d7..37e21e61 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -105,6 +105,7 @@ export interface Args { useTexthooker: boolean; autoStartOverlay: boolean; texthookerOnly: boolean; + texthookerOpenBrowser: boolean; useRofi: boolean; logLevel: LogLevel; passwordStore: string; diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index ff2e457e..c07a031c 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -124,9 +124,12 @@ test('youtube playback does not use generic overlay-runtime bootstrap classifica test('standalone texthooker classification excludes integrated start flow', () => { const standalone = parseArgs(['--texthooker']); + const standaloneOpenBrowser = parseArgs(['--texthooker', '--open-browser']); const integrated = parseArgs(['--start', '--texthooker']); assert.equal(isStandaloneTexthookerCommand(standalone), true); + assert.equal(standaloneOpenBrowser.texthookerOpenBrowser, true); + assert.equal(isStandaloneTexthookerCommand(standaloneOpenBrowser), true); assert.equal(isStandaloneTexthookerCommand(integrated), false); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index aee216a9..bf5b2ed9 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -71,6 +71,7 @@ export interface CliArgs { jellyfinRemoteAnnounce: boolean; jellyfinPreviewAuth: boolean; texthooker: boolean; + texthookerOpenBrowser: boolean; help: boolean; autoStartOverlay: boolean; generateConfig: boolean; @@ -164,6 +165,7 @@ export function parseArgs(argv: string[]): CliArgs { jellyfinRemoteAnnounce: false, jellyfinPreviewAuth: false, texthooker: false, + texthookerOpenBrowser: false, help: false, autoStartOverlay: false, generateConfig: false, @@ -327,6 +329,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--jellyfin-remote-announce') args.jellyfinRemoteAnnounce = true; else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true; else if (arg === '--texthooker') args.texthooker = true; + else if (arg === '--open-browser') args.texthookerOpenBrowser = true; else if (arg === '--auto-start-overlay') args.autoStartOverlay = true; else if (arg === '--generate-config') args.generateConfig = true; else if (arg === '--backup-overwrite') args.backupOverwrite = true; diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index 3601c626..2fdcffa4 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -19,6 +19,7 @@ test('printHelp includes configured texthooker port', () => { assert.match(output, /default: 7777/); assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/); assert.match(output, /--stats\s+Open the stats dashboard in your browser/); + 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, /--anilist-status/); diff --git a/src/cli/help.ts b/src/cli/help.ts index 83e70992..972e87ee 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -16,6 +16,7 @@ ${B}Session${R} --stop Stop the running instance --stats Open the stats dashboard in your browser --texthooker Start texthooker server only ${D}(no overlay)${R} + --open-browser Open texthooker in your default browser ${B}Overlay${R} --toggle-visible-overlay Toggle subtitle overlay diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 7da4bf96..eb37add5 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -66,6 +66,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { jellyfinRemoteAnnounce: false, jellyfinPreviewAuth: false, texthooker: false, + texthookerOpenBrowser: false, help: false, autoStartOverlay: false, generateConfig: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 6aa62953..b231cbca 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -68,6 +68,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { jellyfinRemoteAnnounce: false, jellyfinPreviewAuth: false, texthooker: false, + texthookerOpenBrowser: false, help: false, autoStartOverlay: false, generateConfig: false, @@ -399,6 +400,21 @@ test('handleCliCommand runs texthooker flow with browser open', () => { assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174')); }); +test('handleCliCommand opens texthooker browser when requested even if config disables auto-open', () => { + const { deps, calls } = createDeps({ + shouldOpenTexthookerBrowser: () => false, + }); + const args = { + ...makeArgs({ texthooker: true }), + texthookerOpenBrowser: true, + } as CliArgs; + + handleCliCommand(args, 'initial', deps); + + assert.ok(calls.includes('ensureTexthookerRunning:5174:')); + assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174')); +}); + test('handleCliCommand forwards resolved websocket url to texthooker startup', () => { const { deps, calls } = createDeps({ getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678', diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index ae249d8a..002c3270 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -704,7 +704,7 @@ export function handleCliCommand( } else if (args.texthooker) { const texthookerPort = deps.getTexthookerPort(); deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl()); - if (deps.shouldOpenTexthookerBrowser()) { + if (args.texthookerOpenBrowser || deps.shouldOpenTexthookerBrowser()) { deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`); } deps.log(`Texthooker available at http://127.0.0.1:${texthookerPort}`); diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index ccf88848..8e2162d3 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -66,6 +66,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { jellyfinRemoteAnnounce: false, jellyfinPreviewAuth: false, texthooker: false, + texthookerOpenBrowser: false, help: false, autoStartOverlay: false, generateConfig: false, diff --git a/src/main.ts b/src/main.ts index a29a45f5..a18a46b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5150,6 +5150,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = initializeOverlayRuntime: () => initializeOverlayRuntime(), isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, openSessionHelpModal: () => openSessionHelpOverlay(), + openTexthookerInBrowser: () => + handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), openFirstRunSetupWindow: () => openFirstRunSetupWindow(), showWindowsMpvLauncherSetup: () => process.platform === 'win32', diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 0edc8b9a..f7071785 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -80,6 +80,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { jellyfinRemoteAnnounce: false, jellyfinPreviewAuth: false, texthooker: false, + texthookerOpenBrowser: false, help: false, autoStartOverlay: false, generateConfig: false, diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 45bd1139..809a54e4 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => { const buildTemplate = createBuildTrayMenuTemplateHandler({ buildTrayMenuTemplateRuntime: (handlers) => { handlers.openSessionHelp(); + handlers.openTexthookerInBrowser(); handlers.openFirstRunSetup(); handlers.openWindowsMpvLauncherSetup(); handlers.openYomitanSettings(); @@ -57,6 +58,7 @@ test('build tray template handler wires actions and init guards', () => { }, isOverlayRuntimeInitialized: () => initialized, openSessionHelpModal: () => calls.push('help'), + openTexthookerInBrowser: () => calls.push('texthooker'), showFirstRunSetup: () => true, openFirstRunSetupWindow: () => calls.push('setup'), showWindowsMpvLauncherSetup: () => true, @@ -72,6 +74,7 @@ test('build tray template handler wires actions and init guards', () => { assert.deepEqual(calls, [ 'init', 'help', + 'texthooker', 'setup', 'setup', 'yomitan', diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 4aa2e543..43c587d3 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -29,6 +29,7 @@ export function createResolveTrayIconPathHandler(deps: { export function createBuildTrayMenuTemplateHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { openSessionHelp: () => void; + openTexthookerInBrowser: () => void; openFirstRunSetup: () => void; showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; @@ -42,6 +43,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { initializeOverlayRuntime: () => void; isOverlayRuntimeInitialized: () => boolean; openSessionHelpModal: () => void; + openTexthookerInBrowser: () => void; showFirstRunSetup: () => boolean; openFirstRunSetupWindow: () => void; showWindowsMpvLauncherSetup: () => boolean; @@ -59,6 +61,9 @@ export function createBuildTrayMenuTemplateHandler(deps: { } deps.openSessionHelpModal(); }, + openTexthookerInBrowser: () => { + deps.openTexthookerInBrowser(); + }, openFirstRunSetup: () => { deps.openFirstRunSetupWindow(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 6baea9f6..d0a1b0e0 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -25,6 +25,7 @@ test('tray main deps builders return mapped handlers', () => { initializeOverlayRuntime: () => calls.push('init'), isOverlayRuntimeInitialized: () => false, openSessionHelpModal: () => calls.push('help'), + openTexthookerInBrowser: () => calls.push('texthooker'), showFirstRunSetup: () => true, openFirstRunSetupWindow: () => calls.push('setup'), showWindowsMpvLauncherSetup: () => true, @@ -37,6 +38,7 @@ test('tray main deps builders return mapped handlers', () => { const template = menuDeps.buildTrayMenuTemplateRuntime({ openSessionHelp: () => calls.push('open-help'), + openTexthookerInBrowser: () => calls.push('open-texthooker'), openFirstRunSetup: () => calls.push('open-setup'), showFirstRunSetup: true, openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 9ad9408b..2ec92b5f 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -28,6 +28,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: { export function createBuildTrayMenuTemplateMainDepsHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { openSessionHelp: () => void; + openTexthookerInBrowser: () => void; openFirstRunSetup: () => void; showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; @@ -41,6 +42,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { initializeOverlayRuntime: () => void; isOverlayRuntimeInitialized: () => boolean; openSessionHelpModal: () => void; + openTexthookerInBrowser: () => void; showFirstRunSetup: () => boolean; openFirstRunSetupWindow: () => void; showWindowsMpvLauncherSetup: () => boolean; @@ -55,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { initializeOverlayRuntime: deps.initializeOverlayRuntime, isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, openSessionHelpModal: deps.openSessionHelpModal, + openTexthookerInBrowser: deps.openTexthookerInBrowser, showFirstRunSetup: deps.showFirstRunSetup, openFirstRunSetupWindow: deps.openFirstRunSetupWindow, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index 407b6178..92e342e3 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -25,6 +25,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => }, isOverlayRuntimeInitialized: () => overlayInitialized, openSessionHelpModal: () => {}, + openTexthookerInBrowser: () => {}, showFirstRunSetup: () => true, openFirstRunSetupWindow: () => {}, showWindowsMpvLauncherSetup: () => true, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 5b972264..16c46dae 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => { const calls: string[] = []; const template = buildTrayMenuTemplateRuntime({ openSessionHelp: () => calls.push('help'), + openTexthookerInBrowser: () => calls.push('texthooker'), openFirstRunSetup: () => calls.push('setup'), showFirstRunSetup: true, openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'), @@ -41,18 +42,24 @@ test('tray menu template contains expected entries and handlers', () => { quitApp: () => calls.push('quit'), }); - assert.equal(template.length, 9); - assert.equal(template.some((entry) => entry.label === 'Open Overlay'), false); + assert.equal(template.length, 10); + assert.equal( + template.some((entry) => entry.label === 'Open Overlay'), + false, + ); assert.equal(template[0]!.label, 'Open Help'); template[0]!.click?.(); - template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); - template[8]!.click?.(); - assert.deepEqual(calls, ['help', 'separator', 'quit']); + assert.equal(template[1]!.label, 'Open Texthooker'); + template[1]!.click?.(); + template[8]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); + template[9]!.click?.(); + assert.deepEqual(calls, ['help', 'texthooker', 'separator', 'quit']); }); test('tray menu template omits first-run setup entry when setup is complete', () => { const labels = buildTrayMenuTemplateRuntime({ openSessionHelp: () => undefined, + openTexthookerInBrowser: () => undefined, openFirstRunSetup: () => undefined, showFirstRunSetup: false, openWindowsMpvLauncherSetup: () => undefined, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 083f3035..115cd48c 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -31,6 +31,7 @@ export function resolveTrayIconPathRuntime(deps: { export type TrayMenuActionHandlers = { openSessionHelp: () => void; + openTexthookerInBrowser: () => void; openFirstRunSetup: () => void; showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; @@ -52,6 +53,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): label: 'Open Help', click: handlers.openSessionHelp, }, + { + label: 'Open Texthooker', + click: handlers.openTexthookerInBrowser, + }, ...(handlers.showFirstRunSetup ? [ {