diff --git a/Makefile b/Makefile index 822a1939..525df635 100644 --- a/Makefile +++ b/Makefile @@ -20,9 +20,9 @@ MACOS_APP_DIR ?= $(HOME)/Applications MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app # If building from source, the AppImage will typically land in release/. -APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage)) -MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app)) -MACOS_ZIP_SRC := $(firstword $(wildcard release/SubMiner-*.zip)) +APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage)) +MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app)) +MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip)) UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown) ifeq ($(OS),Windows_NT) diff --git a/README.md b/README.md index d89aed3a..d2dfcfda 100644 --- a/README.md +++ b/README.md @@ -217,12 +217,13 @@ Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasud Also download the `subminer` launcher (recommended): ```bash -sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \ - && sudo chmod +x /usr/local/bin/subminer +mkdir -p ~/.local/bin +curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \ + && chmod +x ~/.local/bin/subminer ``` > [!NOTE] -> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. +> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there. diff --git a/changes/auto-update.md b/changes/auto-update.md index 5ad74dda..1a7c473c 100644 --- a/changes/auto-update.md +++ b/changes/auto-update.md @@ -1,4 +1,4 @@ type: added area: updater -- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher/support asset updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing. +- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher updates, Linux rofi theme updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing. diff --git a/changes/linux-direct-launcher-update.md b/changes/linux-direct-launcher-update.md new file mode 100644 index 00000000..5c99aec2 --- /dev/null +++ b/changes/linux-direct-launcher-update.md @@ -0,0 +1,5 @@ +type: fixed +area: updater + +- Made Linux `subminer -u` perform release updates from the launcher, independent of any running tray app instance, while reporting `up to date` without downloading assets when the latest release is not newer. +- Limited support asset updates to the Linux rofi theme. diff --git a/changes/linux-launcher-shim.md b/changes/linux-launcher-shim.md new file mode 100644 index 00000000..e69756c9 --- /dev/null +++ b/changes/linux-launcher-shim.md @@ -0,0 +1,4 @@ +type: fixed +area: launcher + +- Fixed Linux first-run launcher installs by building the packaged launcher with a valid Bun shebang. diff --git a/changes/linux-updater-error-event.md b/changes/linux-updater-error-event.md new file mode 100644 index 00000000..529c1d3c --- /dev/null +++ b/changes/linux-updater-error-event.md @@ -0,0 +1,4 @@ +type: fixed +area: updater + +- Stopped Linux tray update checks from invoking the native Electron updater, using GitHub release metadata/assets instead so checks do not crash the tray app. diff --git a/changes/macos-local-bin-launcher.md b/changes/macos-local-bin-launcher.md new file mode 100644 index 00000000..00a37f86 --- /dev/null +++ b/changes/macos-local-bin-launcher.md @@ -0,0 +1,4 @@ +type: fixed +area: setup + +- First-run setup now recognizes installed macOS launchers in Homebrew or user PATH dirs, while manual setup installs avoid Homebrew-owned directories. diff --git a/changes/macos-update-dialog-focus.md b/changes/macos-update-dialog-focus.md new file mode 100644 index 00000000..48729c23 --- /dev/null +++ b/changes/macos-update-dialog-focus.md @@ -0,0 +1,4 @@ +type: fixed +area: updater + +- Bring macOS update dialogs to the front when `subminer --update` is run from the launcher. diff --git a/changes/make-install-fresh-appimage.md b/changes/make-install-fresh-appimage.md new file mode 100644 index 00000000..99765e63 --- /dev/null +++ b/changes/make-install-fresh-appimage.md @@ -0,0 +1,4 @@ +type: fixed +area: build + +- Fixed one-shot `make clean build install` flows so install picks up the AppImage built earlier in the same make invocation. diff --git a/changes/native-updater-crash.md b/changes/native-updater-crash.md new file mode 100644 index 00000000..417ef56f --- /dev/null +++ b/changes/native-updater-crash.md @@ -0,0 +1,4 @@ +type: fixed +area: updates + +- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app. diff --git a/changes/setup-background-command.md b/changes/setup-background-command.md new file mode 100644 index 00000000..0b522420 --- /dev/null +++ b/changes/setup-background-command.md @@ -0,0 +1,4 @@ +type: fixed +area: launcher + +- Fixed `subminer app --setup` so it opens the setup flow when SubMiner is already running in the background. diff --git a/changes/setup-finish-quit.md b/changes/setup-finish-quit.md new file mode 100644 index 00000000..11ef1bcc --- /dev/null +++ b/changes/setup-finish-quit.md @@ -0,0 +1,4 @@ +type: fixed +area: setup + +- Quit standalone setup app launches after first-run setup finishes, returning the terminal instead of leaving the app process open. diff --git a/changes/tray-modal-lifecycle.md b/changes/tray-modal-lifecycle.md index 4ae89d06..f4218694 100644 --- a/changes/tray-modal-lifecycle.md +++ b/changes/tray-modal-lifecycle.md @@ -3,9 +3,8 @@ area: tray - Kept the tray app running when closing tray-launched Yomitan settings. - Kept tray-launched Yomitan settings loading from blocking other tray actions. -- Removed the default native app menu from Yomitan settings so File > Quit cannot put the tray app into a stuck quit state. +- Replaced the default native Yomitan settings menu with a close-only menu so closing settings does not quit the tray app. +- Added an in-page close button for Yomitan settings on Hyprland, where native window controls are not available. - Disabled Yomitan's embedded popup preview in the tray-launched settings window to avoid renderer hangs during normal sidebar navigation. -- Skipped heavy Yomitan settings startup preview, storage, dictionary, and Anki controllers when launched from SubMiner to avoid renderer hangs with large dictionary databases. -- Cached Yomitan settings dictionary metadata after explicit loads to avoid repeated large IndexedDB reads. - Serialized copied Yomitan extension refreshes so startup cannot race itself and leave extension loading in an error state. - Fixed tray-launched session help focus handling so the modal can close without mpv running. diff --git a/docs-site/installation.md b/docs-site/installation.md index 5b4c4ecf..3f7f03a4 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -155,11 +155,11 @@ chmod +x ~/.local/bin/SubMiner.AppImage wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer chmod +x ~/.local/bin/subminer -# Download launcher support assets used for bundled runtime plugin injection +# Download the optional Linux rofi theme wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz tar -xzf /tmp/subminer-assets.tar.gz -C /tmp -mkdir -p ~/.local/share/SubMiner/plugin/subminer -cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/ +mkdir -p ~/.local/share/SubMiner/themes +cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi ``` The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin. @@ -174,7 +174,9 @@ subminer -u subminer --update ``` -SubMiner verifies launcher/support asset downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead. +SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead. + +On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app. ### From Source @@ -240,7 +242,7 @@ subminer -u subminer --update ``` -SubMiner verifies launcher/support asset downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself. +SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself. ::: warning Bun required for the launcher The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`. @@ -269,7 +271,7 @@ Build and install the launcher alongside the app: make install-macos ``` -This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle and rofi theme. To install to `/usr/local/bin` instead (already on the default macOS `PATH`): +This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`): ```bash sudo make install-macos PREFIX=/usr/local diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 2b194cdb..01576ec7 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -109,6 +109,8 @@ Use `subminer -h` for command-specific help. | `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | | `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | +On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray. + With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary. ## Logging diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 8ab75531..a7b3cfbc 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -241,36 +241,36 @@ test('dictionary command returns after app handoff starts', () => { assert.equal(handled, true); }); -test('update command forwards launcher path and waits for response', async () => { +test('update command runs direct Linux release update without launching Electron', async () => { const context = createContext(); context.args.update = true; - const forwarded: string[][] = []; - const responses: string[] = []; + const calls: string[] = []; const handled = await runUpdateCommand(context, { - createTempDir: () => '/tmp/subminer-update-test', - joinPath: (...parts) => parts.join('/'), - runAppCommandCaptureOutput: (_appPath, appArgs) => { - forwarded.push(appArgs); - return { status: 0, stdout: '', stderr: '' }; + runAppCommandCaptureOutput: () => { + throw new Error('unexpected Electron launch'); }, - waitForUpdateResponse: async (responsePath) => { - responses.push(responsePath); - return { ok: true, status: 'up-to-date', version: '0.15.0' }; + runDirectReleaseUpdate: async (request) => { + calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`); + return { + appImage: { status: 'not-found' }, + launcher: { status: 'updated' }, + supportAssets: [{ status: 'skipped' }], + }; + }, + readMainConfig: () => null, + log: (level, _configured, message) => { + calls.push(`${level}:${message}`); }, }); assert.equal(handled, true); - assert.deepEqual(forwarded, [ - [ - '--update', - '--update-launcher-path', - '/tmp/subminer', - '--update-response-path', - '/tmp/subminer-update-test/response.json', - ], + assert.deepEqual(calls, [ + 'direct:/tmp/subminer.app:/tmp/subminer:stable', + 'info:AppImage update: not-found', + 'info:Launcher update: updated', + 'info:Rofi theme update: skipped', ]); - assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']); }); test('stats command launches attached app command with response path', async () => { diff --git a/launcher/commands/update-command.test.ts b/launcher/commands/update-command.test.ts new file mode 100644 index 00000000..f126ac22 --- /dev/null +++ b/launcher/commands/update-command.test.ts @@ -0,0 +1,140 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runUpdateCommand } from './update-command'; +import type { LauncherCommandContext } from './context'; + +function makeContext(overrides: Partial = {}): LauncherCommandContext { + return { + args: { + update: true, + logLevel: 'warn', + } as LauncherCommandContext['args'], + scriptPath: '/home/kyle/.local/bin/subminer', + scriptName: 'subminer', + mpvSocketPath: '/tmp/subminer.sock', + pluginRuntimeConfig: {} as LauncherCommandContext['pluginRuntimeConfig'], + appPath: '/home/kyle/.local/bin/SubMiner.AppImage', + launcherJellyfinConfig: {} as LauncherCommandContext['launcherJellyfinConfig'], + processAdapter: { + platform: () => 'linux', + } as LauncherCommandContext['processAdapter'], + ...overrides, + }; +} + +test('runUpdateCommand updates directly on Linux without launching Electron', async () => { + const calls: string[] = []; + + const handled = await runUpdateCommand(makeContext(), { + runAppCommandCaptureOutput: () => { + throw new Error('unexpected Electron launch'); + }, + runDirectReleaseUpdate: async (request) => { + calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`); + return { + appImage: { status: 'updated' }, + launcher: { status: 'updated' }, + supportAssets: [{ status: 'skipped' }], + }; + }, + readMainConfig: () => ({ updates: { channel: 'prerelease' } }), + log: (level, _configured, message) => { + calls.push(`${level}:${message}`); + }, + }); + + assert.equal(handled, true); + assert.deepEqual(calls, [ + 'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease', + 'info:AppImage update: updated', + 'info:Launcher update: updated', + 'info:Rofi theme update: skipped', + ]); +}); + +test('runUpdateCommand skips Linux asset replacement when release is not newer', async () => { + const calls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string) => { + calls.push(`fetch:${url}`); + if (!url.endsWith('/releases')) { + throw new Error(`unexpected asset fetch: ${url}`); + } + return { + ok: true, + status: 200, + json: async () => [ + { + tag_name: 'v0.14.0', + prerelease: false, + draft: false, + assets: [ + { + name: 'SHA256SUMS.txt', + browser_download_url: 'https://example.test/SHA256SUMS.txt', + }, + { + name: 'SubMiner.AppImage', + browser_download_url: 'https://example.test/SubMiner.AppImage', + }, + ], + }, + ], + text: async () => '', + arrayBuffer: async () => new ArrayBuffer(0), + }; + }) as typeof fetch; + + try { + const handled = await runUpdateCommand(makeContext(), { + runAppCommandCaptureOutput: () => { + throw new Error('unexpected Electron launch'); + }, + readMainConfig: () => null, + log: (level, _configured, message) => { + calls.push(`${level}:${message}`); + }, + }); + + assert.equal(handled, true); + assert.deepEqual(calls, [ + 'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases', + 'info:AppImage update: up to date', + 'info:Launcher update: up to date', + 'info:Rofi theme update: up to date', + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('runUpdateCommand keeps app-mediated update path on non-Linux', async () => { + const calls: string[] = []; + + const handled = await runUpdateCommand( + makeContext({ + processAdapter: { + platform: () => 'darwin', + } as LauncherCommandContext['processAdapter'], + appPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + }), + { + createTempDir: () => '/tmp/subminer-update-test', + joinPath: (...parts) => parts.join('/'), + runAppCommandCaptureOutput: (appPath, appArgs) => { + calls.push(`app:${appPath}:${appArgs.join(' ')}`); + return { status: 0, stdout: '', stderr: '' }; + }, + waitForUpdateResponse: async () => ({ ok: true, status: 'up-to-date' }), + removeDir: (targetPath) => { + calls.push(`remove:${targetPath}`); + }, + }, + ); + + assert.equal(handled, true); + assert.deepEqual(calls, [ + 'app:/Applications/SubMiner.app/Contents/MacOS/SubMiner:--update --update-launcher-path /home/kyle/.local/bin/subminer --update-response-path /tmp/subminer-update-test/response.json', + 'remove:/tmp/subminer-update-test', + ]); +}); diff --git a/launcher/commands/update-command.ts b/launcher/commands/update-command.ts index 18f29554..5aac40bb 100644 --- a/launcher/commands/update-command.ts +++ b/launcher/commands/update-command.ts @@ -1,10 +1,27 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import packageJson from '../../package.json'; import { runAppCommandCaptureOutput } from '../mpv.js'; +import { log as launcherLog } from '../log.js'; import { nowMs } from '../time.js'; import { sleep } from '../util.js'; import type { LauncherCommandContext } from './context.js'; +import { readLauncherMainConfigObject } from '../config/shared-config-reader.js'; +import type { UpdateChannel } from '../../src/types/config.js'; +import { updateAppImageFromRelease } from '../../src/main/runtime/update/appimage-updater.js'; +import { updateLauncherFromRelease } from '../../src/main/runtime/update/launcher-updater.js'; +import { + compareSemverLike, + fetchLatestStableRelease, + fetchReleaseAssetBuffer, + fetchReleaseAssetText, + findReleaseAsset, + parseReleaseVersion, + parseSha256Sums, + type FetchLike, +} from '../../src/main/runtime/update/release-assets.js'; +import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js'; type UpdateCommandResponse = { ok: boolean; @@ -13,6 +30,18 @@ type UpdateCommandResponse = { error?: string; }; +type DirectReleaseUpdateRequest = { + appPath: string; + launcherPath: string; + channel: UpdateChannel; +}; + +type DirectReleaseUpdateResult = { + appImage: { status: string; command?: string; message?: string }; + launcher: { status: string; command?: string; message?: string }; + supportAssets: Array<{ status: string; command?: string; message?: string }>; +}; + type UpdateCommandDeps = { createTempDir: (prefix: string) => string; joinPath: (...parts: string[]) => string; @@ -22,9 +51,95 @@ type UpdateCommandDeps = { ) => { status: number; stdout: string; stderr: string; error?: Error }; waitForUpdateResponse: (responsePath: string) => Promise; removeDir: (targetPath: string) => void; + runDirectReleaseUpdate: ( + request: DirectReleaseUpdateRequest, + ) => Promise; + readMainConfig: () => Record | null; + log: typeof launcherLog; }; const UPDATE_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000; +const CURRENT_VERSION = packageJson.version; + +function getFetchForLauncherUpdater(): FetchLike { + return globalThis.fetch.bind(globalThis) as FetchLike; +} + +async function runDirectReleaseUpdate( + request: DirectReleaseUpdateRequest, +): Promise { + const fetchForUpdater = getFetchForLauncherUpdater(); + const release = await fetchLatestStableRelease({ + fetch: fetchForUpdater, + channel: request.channel, + }); + const releaseVersion = parseReleaseVersion(release); + if (releaseVersion && compareSemverLike(releaseVersion, CURRENT_VERSION) <= 0) { + return { + appImage: { status: 'up-to-date' }, + launcher: { status: 'up-to-date' }, + supportAssets: [{ status: 'up-to-date' }], + }; + } + + const sumsAsset = release ? findReleaseAsset(release, 'SHA256SUMS.txt') : null; + const sha256Sums = + sumsAsset && release + ? parseSha256Sums( + await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url), + ) + : new Map(); + const downloadAsset = (url: string) => fetchReleaseAssetBuffer(fetchForUpdater, url); + + const [appImage, launcher, supportAssets] = await Promise.all([ + updateAppImageFromRelease({ + release, + sha256Sums, + appImagePath: request.appPath, + downloadAsset, + }), + updateLauncherFromRelease({ + release, + sha256Sums, + launcherPath: request.launcherPath, + downloadAsset, + }), + updateSupportAssetsFromRelease({ + release, + sha256Sums, + downloadAsset, + }), + ]); + + return { appImage, launcher, supportAssets }; +} + +function readUpdateChannel(root: Record | null): UpdateChannel { + const updates = + root?.updates && typeof root.updates === 'object' && !Array.isArray(root.updates) + ? (root.updates as Record) + : null; + return updates?.channel === 'prerelease' ? 'prerelease' : 'stable'; +} + +function logUpdateResult( + label: string, + result: { status: string; command?: string; message?: string }, + configuredLogLevel: NonNullable, + deps: Pick, +): void { + const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status; + deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`); + if (result.command) { + deps.log( + 'warn', + configuredLogLevel, + `${label} update requires manual command: ${result.command}`, + ); + } else if (result.message) { + deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`); + } +} const defaultDeps: UpdateCommandDeps = { createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), @@ -47,6 +162,9 @@ const defaultDeps: UpdateCommandDeps = { removeDir: (targetPath) => { fs.rmSync(targetPath, { recursive: true, force: true }); }, + runDirectReleaseUpdate, + readMainConfig: readLauncherMainConfigObject, + log: launcherLog, }; export async function runUpdateCommand( @@ -59,6 +177,21 @@ export async function runUpdateCommand( return false; } + if (context.processAdapter.platform() === 'linux') { + const result = await resolvedDeps.runDirectReleaseUpdate({ + appPath, + launcherPath: scriptPath, + channel: readUpdateChannel(resolvedDeps.readMainConfig()), + }); + const logLevel = args.logLevel ?? 'warn'; + logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps); + logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps); + for (const supportResult of result.supportAssets) { + logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps); + } + return true; + } + const tempDir = resolvedDeps.createTempDir('subminer-update-'); const responsePath = resolvedDeps.joinPath(tempDir, 'response.json'); diff --git a/package.json b/package.json index eec5d91c..509c4a3b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js", "build:yomitan": "bun scripts/build-yomitan.mjs", "build:assets": "bun scripts/prepare-build-assets.mjs", - "build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer", + "build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer", "build:stats": "cd stats && bun run build", "dev:stats": "cd stats && bun run dev", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets", @@ -47,8 +47,8 @@ "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", - "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts", + "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index ff6131b3..eef49437 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -130,8 +130,8 @@ function createDeps(overrides: Partial = {}) { 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 = {}) { 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); }); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 909f88d4..635fde11 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -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) { diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index 156c9947..a349d60f 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -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); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index cbd2d815..f4e26adc 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -131,6 +131,7 @@ export interface AppReadyRuntimeDeps { createImmersionTracker?: () => void; startJellyfinRemoteSession?: () => Promise; loadYomitanExtension: () => Promise; + ensureYomitanExtensionLoaded?: () => Promise; handleFirstRunSetup: () => Promise; prewarmSubtitleDictionaries?: () => Promise; startBackgroundWarmups: () => void; @@ -215,6 +216,8 @@ export function isAutoUpdateEnabledRuntime( export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { 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 { + 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; + + 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; + 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 { diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts index 8b3853d7..dc364e74 100644 --- a/src/core/services/yomitan-extension-loader.ts +++ b/src/core/services/yomitan-extension-loader.ts @@ -29,6 +29,85 @@ export interface YomitanExtensionLoaderDeps { setYomitanSession: (session: Session | null) => void; } +type WarningProcess = Pick; + +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( + run: () => Promise, + warningProcess: WarningProcess = process, +): Promise { + 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 { @@ -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) { diff --git a/src/core/services/yomitan-settings.test.ts b/src/core/services/yomitan-settings.test.ts index a438939b..f6489395 100644 --- a/src/core/services/yomitan-settings.test.ts +++ b/src/core/services/yomitan-settings.test.ts @@ -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', ); }); diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index 6e67c8a0..c5552182 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -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, +type YomitanSettingsWindowMenuOwner = Pick; + +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, + 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, + 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(() => { diff --git a/src/main.ts b/src/main.ts index b8141961..a0c9ce8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -82,6 +82,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { return { shouldUseMinimalStartup: Boolean( (initialArgs && isStandaloneTexthookerCommand(initialArgs)) || + initialArgs?.update || (initialArgs?.stats && (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), ), @@ -90,6 +91,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { (shouldRunSettingsOnlyStartup(initialArgs) || initialArgs.stats || initialArgs.dictionary || + initialArgs.update || initialArgs.setup), ), }; @@ -365,6 +367,7 @@ import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/ import { createFirstRunSetupService, getFirstRunSetupCompletionMessage, + isStandaloneFirstRunSetupCommand, shouldAutoOpenFirstRunSetup, } from './main/runtime/first-run-setup-service'; import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; @@ -508,22 +511,21 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; -import { createElectronAppUpdater } from './main/runtime/update/app-updater'; +import { + createElectronAppUpdater, + isNativeUpdaterSupported, +} from './main/runtime/update/app-updater'; import { fetchLatestStableRelease, fetchReleaseAssetBuffer, fetchReleaseAssetText, findReleaseAsset, parseSha256Sums, + type GitHubRelease, } from './main/runtime/update/release-assets'; import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater'; import { notifyUpdateAvailable } from './main/runtime/update/update-notifications'; -import { - showNoUpdateDialog, - showRestartDialog, - showUpdateAvailableDialog, - showUpdateFailedDialog, -} from './main/runtime/update/update-dialogs'; +import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs'; import { runUpdateCliCommand, writeUpdateCliCommandResponse, @@ -847,6 +849,9 @@ const appLogger = { logInfo: (message: string) => { logger.info(message); }, + logDebug: (message: string) => { + logger.debug(message); + }, logWarning: (message: string) => { logger.warn(message); }, @@ -2902,6 +2907,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ }, isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode, + shouldQuitWhenClosedCompleted: () => + Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)), quitApp: () => requestAppQuit(), clearSetupWindow: () => { appState.firstRunSetupWindow = null; @@ -3733,6 +3740,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ reloadConfigMainDeps: { reloadConfigStrict: () => configService.reloadConfigStrict(), logInfo: (message) => appLogger.logInfo(message), + logDebug: (message) => appLogger.logDebug(message), logWarning: (message) => appLogger.logWarning(message), showDesktopNotification: (title, options) => showDesktopNotification(title, options), startConfigHotReload: () => configHotReloadRuntime.start(), @@ -3856,6 +3864,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ loadYomitanExtension: async () => { await loadYomitanExtension(); }, + ensureYomitanExtensionLoaded: async () => { + await ensureYomitanExtensionLoaded(); + }, handleFirstRunSetup: async () => { const snapshot = await firstRunSetupService.ensureSetupStateInitialized(); appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; @@ -4613,12 +4624,12 @@ function getFetchForUpdater() { return globalThis.fetch.bind(globalThis); } -async function updateLauncherFromLatestRelease( +async function updateLauncherFromSelectedRelease( launcherPath?: string, channel: UpdateChannel = getResolvedConfig().updates.channel, + release: GitHubRelease | null = null, ) { const fetchForUpdater = getFetchForUpdater(); - const release = await fetchLatestStableRelease({ fetch: fetchForUpdater, channel }); if (!release) { return { status: 'missing-asset', message: `No ${channel} GitHub release found.` }; } @@ -4642,9 +4653,9 @@ async function updateLauncherFromLatestRelease( }); for (const result of supportResults) { if (result.status === 'protected' && result.command) { - logger.warn(`Support assets update requires manual command: ${result.command}`); + logger.warn(`Rofi theme update requires manual command: ${result.command}`); } else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') { - logger.warn(`Support assets update skipped: ${result.message ?? result.status}`); + logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`); } } return launcherResult; @@ -4657,6 +4668,19 @@ function getUpdateService() { isPackaged: app.isPackaged, log: (message) => logger.info(message), getChannel: () => getResolvedConfig().updates.channel, + isNativeUpdaterSupported: () => + isNativeUpdaterSupported({ + platform: process.platform, + isPackaged: app.isPackaged, + execPath: process.execPath, + env: process.env, + log: (message) => logger.warn(message), + }), + }); + const updateDialogPresenter = createUpdateDialogPresenter({ + platform: process.platform, + focusApp: () => app.focus({ steal: true }), + showMessageBox: (options) => dialog.showMessageBox(options), }); updateService = createUpdateService({ getConfig: () => getResolvedConfig().updates, @@ -4667,16 +4691,14 @@ function getUpdateService() { checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel), fetchLatestStableRelease: (channel) => fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }), - updateLauncher: (launcherPath, channel) => - updateLauncherFromLatestRelease(launcherPath, channel), - showNoUpdateDialog: (version) => - showNoUpdateDialog((options) => dialog.showMessageBox(options), version), + updateLauncher: (launcherPath, channel, release) => + updateLauncherFromSelectedRelease(launcherPath, channel, release), + showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version), showUpdateAvailableDialog: (version) => - showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version), - showUpdateFailedDialog: (message) => - showUpdateFailedDialog((options) => dialog.showMessageBox(options), message), + updateDialogPresenter.showUpdateAvailableDialog(version), + showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message), downloadAppUpdate: () => appUpdater.downloadUpdate(), - showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)), + showRestartDialog: () => updateDialogPresenter.showRestartDialog(), quitAndInstall: () => appUpdater.quitAndInstall(), notifyUpdateAvailable: (version) => notifyUpdateAvailable( @@ -5309,7 +5331,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ initializeOverlayRuntime: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), - openFirstRunSetupWindow: () => openFirstRunSetupWindow(), + openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), copyCurrentSubtitle: () => copyCurrentSubtitle(), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), @@ -5367,6 +5389,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), logInfo: (message: string) => logger.info(message), + logDebug: (message: string) => logger.debug(message), logWarn: (message: string) => logger.warn(message), logError: (message: string, err: unknown) => logger.error(message, err), }, diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index d0274bf0..dc907a72 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -44,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput { createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker']; startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession']; loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension']; + ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded']; handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup']; prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries']; startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; @@ -109,6 +110,7 @@ export function createAppReadyRuntimeDeps( createImmersionTracker: params.createImmersionTracker, startJellyfinRemoteSession: params.startJellyfinRemoteSession, loadYomitanExtension: params.loadYomitanExtension, + ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded, handleFirstRunSetup: params.handleFirstRunSetup, prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries, startBackgroundWarmups: params.startBackgroundWarmups, diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 93203382..2454d07a 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -20,7 +20,7 @@ export interface CliCommandRuntimeServiceContext { initializeOverlay: () => void; toggleVisibleOverlay: () => void; togglePrimarySubtitleBar: () => void; - openFirstRunSetup: () => void; + openFirstRunSetup: (force?: boolean) => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -54,6 +54,7 @@ export interface CliCommandRuntimeServiceContext { getMultiCopyTimeoutMs: () => number; schedule: (fn: () => void, delayMs: number) => ReturnType; log: (message: string) => void; + logDebug: (message: string) => void; warn: (message: string) => void; error: (message: string, err: unknown) => void; } @@ -133,6 +134,7 @@ function createCliCommandDepsFromContext( getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs, schedule: context.schedule, log: context.log, + logDebug: context.logDebug, warn: context.warn, error: context.error, }; diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index cda317f3..10420956 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -198,6 +198,7 @@ export interface CliCommandRuntimeServiceDepsParams { getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs']; schedule: CliCommandDepsRuntimeOptions['schedule']; log: CliCommandDepsRuntimeOptions['log']; + logDebug: CliCommandDepsRuntimeOptions['logDebug']; warn: CliCommandDepsRuntimeOptions['warn']; error: CliCommandDepsRuntimeOptions['error']; } @@ -377,6 +378,7 @@ export function createCliCommandRuntimeServiceDeps( getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs, schedule: params.schedule, log: params.log, + logDebug: params.logDebug, warn: params.warn, error: params.error, }; diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts index c3fd98c9..36438e88 100644 --- a/src/main/runtime/app-ready-main-deps.test.ts +++ b/src/main/runtime/app-ready-main-deps.test.ts @@ -36,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async loadYomitanExtension: async () => { calls.push('load-yomitan'); }, + ensureYomitanExtensionLoaded: async () => { + calls.push('ensure-yomitan'); + }, handleFirstRunSetup: async () => { calls.push('handle-first-run-setup'); }, @@ -67,6 +70,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async onReady.createMpvClient(); await onReady.createMecabTokenizerAndCheck(); await onReady.loadYomitanExtension(); + await onReady.ensureYomitanExtensionLoaded?.(); await onReady.handleFirstRunSetup(); await onReady.prewarmSubtitleDictionaries?.(); onReady.startBackgroundWarmups(); @@ -79,6 +83,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async 'create-mpv-client', 'create-mecab', 'load-yomitan', + 'ensure-yomitan', 'handle-first-run-setup', 'prewarm-dicts', 'start-warmups', diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index be13fce0..5fd1ab66 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -27,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD createImmersionTracker: deps.createImmersionTracker, startJellyfinRemoteSession: deps.startJellyfinRemoteSession, loadYomitanExtension: deps.loadYomitanExtension, + ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded, handleFirstRunSetup: deps.handleFirstRunSetup, prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries, startBackgroundWarmups: deps.startBackgroundWarmups, diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 5c32ac49..d50113f6 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -81,6 +81,7 @@ test('build cli command context deps maps handlers and values', () => { return setTimeout(() => {}, 0); }, logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), logWarn: (message) => calls.push(`warn:${message}`), logError: (message) => calls.push(`error:${message}`), }); diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index ff87a9c7..c2ba07f4 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -18,7 +18,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { initializeOverlay: () => void; toggleVisibleOverlay: () => void; togglePrimarySubtitleBar: () => void; - openFirstRunSetup: () => void; + openFirstRunSetup: (force?: boolean) => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -52,6 +52,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { getMultiCopyTimeoutMs: () => number; schedule: (fn: () => void, delayMs: number) => ReturnType; logInfo: (message: string) => void; + logDebug: (message: string) => void; logWarn: (message: string) => void; logError: (message: string, err: unknown) => void; }) { @@ -106,6 +107,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, schedule: deps.schedule, logInfo: deps.logInfo, + logDebug: deps.logDebug, logWarn: deps.logWarn, logError: deps.logError, }); diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 2598f36c..4fcb925e 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -82,6 +82,7 @@ test('cli command context factory composes main deps and context handlers', () = getMultiCopyTimeoutMs: () => 5000, schedule: (fn) => setTimeout(fn, 0), logInfo: () => {}, + logDebug: () => {}, logWarn: () => {}, logError: () => {}, }); diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 0d65a551..ff7cbba6 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -30,7 +30,8 @@ test('cli command context main deps builder maps state and callbacks', async () initializeOverlayRuntime: () => calls.push('init-overlay'), toggleVisibleOverlay: () => calls.push('toggle-visible'), togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), - openFirstRunSetupWindow: () => calls.push('open-setup'), + openFirstRunSetupWindow: (force?: boolean) => + calls.push(`open-setup:${force === true ? 'force' : 'default'}`), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), copyCurrentSubtitle: () => calls.push('copy-sub'), @@ -110,6 +111,7 @@ test('cli command context main deps builder maps state and callbacks', async () return setTimeout(() => {}, 0); }, logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), logWarn: (message) => calls.push(`warn:${message}`), logError: (message) => calls.push(`error:${message}`), }); @@ -125,11 +127,19 @@ test('cli command context main deps builder maps state and callbacks', async () assert.equal(deps.shouldOpenBrowser(), true); deps.showOsd('hello'); deps.initializeOverlay(); - deps.openFirstRunSetup(); + deps.openFirstRunSetup(true); deps.setVisibleOverlay(true); deps.printHelp(); + await deps.runUpdateCommand({ update: true } as never, 'initial'); - assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']); + assert.deepEqual(calls, [ + 'osd:hello', + 'init-overlay', + 'open-setup:force', + 'set-visible:true', + 'help', + 'run-update', + ]); const retry = await deps.retryAnilistQueueNow(); assert.deepEqual(retry, { ok: true, message: 'ok' }); diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 66ffeaea..c3b2e65c 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -28,7 +28,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; togglePrimarySubtitleBar: () => void; - openFirstRunSetupWindow: () => void; + openFirstRunSetupWindow: (force?: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { getMultiCopyTimeoutMs: () => number; schedule: (fn: () => void, delayMs: number) => ReturnType; logInfo: (message: string) => void; + logDebug: (message: string) => void; logWarn: (message: string) => void; logError: (message: string, err: unknown) => void; }) { @@ -97,7 +98,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { initializeOverlay: () => deps.initializeOverlayRuntime(), toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(), - openFirstRunSetup: () => deps.openFirstRunSetupWindow(), + openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force), setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), @@ -134,6 +135,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(), schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs), logInfo: (message: string) => deps.logInfo(message), + logDebug: (message: string) => deps.logDebug(message), logWarn: (message: string) => deps.logWarn(message), logError: (message: string, err: unknown) => deps.logError(message, err), }); diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index a8b3c674..110018d5 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -66,6 +66,9 @@ function createDeps() { logInfo: (message: string) => { logs.push(`i:${message}`); }, + logDebug: (message: string) => { + logs.push(`d:${message}`); + }, logWarn: (message: string) => { logs.push(`w:${message}`); }, @@ -102,7 +105,8 @@ test('cli command context log methods map to deps loggers', () => { const { deps, getLogs } = createDeps(); const context = createCliCommandContext(deps); context.log('info'); + context.logDebug('debug'); context.warn('warn'); context.error('error', new Error('x')); - assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']); + assert.deepEqual(getLogs(), ['i:info', 'd:debug', 'w:warn', 'e:error']); }); diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index c988a684..93cad14b 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -23,7 +23,7 @@ export type CliCommandContextFactoryDeps = { initializeOverlay: () => void; toggleVisibleOverlay: () => void; togglePrimarySubtitleBar: () => void; - openFirstRunSetup: () => void; + openFirstRunSetup: (force?: boolean) => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; startPendingMultiCopy: (timeoutMs: number) => void; @@ -57,6 +57,7 @@ export type CliCommandContextFactoryDeps = { getMultiCopyTimeoutMs: () => number; schedule: (fn: () => void, delayMs: number) => ReturnType; logInfo: (message: string) => void; + logDebug: (message: string) => void; logWarn: (message: string) => void; logError: (message: string, err: unknown) => void; }; @@ -133,6 +134,7 @@ export function createCliCommandContext( getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, schedule: deps.schedule, log: deps.logInfo, + logDebug: deps.logDebug, warn: deps.logWarn, error: deps.logError, }; diff --git a/src/main/runtime/command-line-launcher.test.ts b/src/main/runtime/command-line-launcher.test.ts index 54a7f28e..dbfd0294 100644 --- a/src/main/runtime/command-line-launcher.test.ts +++ b/src/main/runtime/command-line-launcher.test.ts @@ -110,6 +110,21 @@ test('resolveBunInstallCommand uses Homebrew on macOS when available', () => { ); }); +test('detectBun reports homebrew install method from POSIX brew path', async () => { + const snapshot = await detectBun({ + platform: 'darwin', + env: { PATH: '/opt/homebrew/bin:/usr/bin' }, + existsSync: (candidate) => candidate === '/opt/homebrew/bin/brew', + accessSync: (candidate) => { + if (candidate !== '/opt/homebrew/bin/brew') throw new Error('not executable'); + }, + runCommand: async () => ({ exitCode: 127, stdout: '', stderr: 'missing' }), + }); + + assert.equal(snapshot.status, 'missing'); + assert.equal(snapshot.installMethod, 'homebrew'); +}); + test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => { const target = await resolveLauncherInstallTarget({ platform: 'linux', @@ -144,6 +159,53 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH assert.equal(target.installPath, null); }); +test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => { + const target = await resolveLauncherInstallTarget({ + platform: 'darwin', + homeDir: '/Users/tester', + env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/Users/tester/.local/bin:/usr/bin' }, + existsSync: (candidate) => + candidate === '/opt/homebrew/bin' || + candidate === '/usr/local/bin' || + candidate === '/Users/tester/.local/bin' || + candidate === '/usr/bin', + accessSync: (candidate) => { + if ( + candidate !== '/opt/homebrew/bin' && + candidate !== '/usr/local/bin' && + candidate !== '/Users/tester/.local/bin' + ) { + throw new Error('not writable'); + } + }, + }); + + assert.equal(target.status, 'not_installed'); + assert.equal(target.pathDir, '/Users/tester/.local/bin'); + assert.equal(target.installPath, '/Users/tester/.local/bin/subminer'); +}); + +test('resolveLauncherInstallTarget uses usr local bin for macOS manual install when user bin is absent', async () => { + const target = await resolveLauncherInstallTarget({ + platform: 'darwin', + homeDir: '/Users/tester', + env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin' }, + existsSync: (candidate) => + candidate === '/opt/homebrew/bin' || + candidate === '/usr/local/bin' || + candidate === '/usr/bin', + accessSync: (candidate) => { + if (candidate !== '/opt/homebrew/bin' && candidate !== '/usr/local/bin') { + throw new Error('not writable'); + } + }, + }); + + assert.equal(target.status, 'not_installed'); + assert.equal(target.pathDir, '/usr/local/bin'); + assert.equal(target.installPath, '/usr/local/bin/subminer'); +}); + test('installLauncher writes Windows cmd shim and appends user PATH once', async () => { const files = new Map(); const dirs = new Set(); @@ -209,6 +271,54 @@ test('detectLauncher reports shadowed when another subminer appears earlier on P assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer'); }); +test('detectLauncher accepts installed macOS launcher from user local bin before Homebrew target', async () => { + const snapshot = await detectLauncher({ + platform: 'darwin', + homeDir: '/Users/tester', + env: { PATH: '/Users/tester/.local/bin:/opt/homebrew/bin:/usr/bin' }, + existsSync: (candidate) => + candidate === '/Users/tester/.local/bin' || + candidate === '/opt/homebrew/bin' || + candidate === '/Users/tester/.local/bin/subminer', + accessSync: () => undefined, + runCommand: async (command, args) => { + assert.equal(command, '/Users/tester/.local/bin/subminer'); + assert.deepEqual(args, ['--help']); + return { exitCode: 0, stdout: 'help', stderr: '' }; + }, + bunSnapshot: createBunSnapshot('ready'), + }); + + assert.equal(snapshot.status, 'ready'); + assert.equal(snapshot.commandPath, '/Users/tester/.local/bin/subminer'); + assert.equal(snapshot.installPath, '/Users/tester/.local/bin/subminer'); + assert.equal(snapshot.pathDir, '/Users/tester/.local/bin'); + assert.equal(snapshot.shadowedBy, null); +}); + +test('detectLauncher accepts installed macOS launcher from Homebrew bin', async () => { + const snapshot = await detectLauncher({ + platform: 'darwin', + homeDir: '/Users/tester', + env: { PATH: '/opt/homebrew/bin:/usr/bin' }, + existsSync: (candidate) => + candidate === '/opt/homebrew/bin' || candidate === '/opt/homebrew/bin/subminer', + accessSync: () => undefined, + runCommand: async (command, args) => { + assert.equal(command, '/opt/homebrew/bin/subminer'); + assert.deepEqual(args, ['--help']); + return { exitCode: 0, stdout: 'help', stderr: '' }; + }, + bunSnapshot: createBunSnapshot('ready'), + }); + + assert.equal(snapshot.status, 'ready'); + assert.equal(snapshot.commandPath, '/opt/homebrew/bin/subminer'); + assert.equal(snapshot.installPath, '/opt/homebrew/bin/subminer'); + assert.equal(snapshot.pathDir, '/opt/homebrew/bin'); + assert.equal(snapshot.shadowedBy, null); +}); + test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => { const snapshot = await detectLauncher({ platform: 'linux', diff --git a/src/main/runtime/command-line-launcher.ts b/src/main/runtime/command-line-launcher.ts index f5334ac9..6dd770fd 100644 --- a/src/main/runtime/command-line-launcher.ts +++ b/src/main/runtime/command-line-launcher.ts @@ -72,21 +72,23 @@ const BUN_OFFICIAL_WINDOWS_COMMAND = [ ]; const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; const COMMAND_TIMEOUT_MS = 15 * 1000; +const MACOS_HOMEBREW_PATH_DIRS = ['/opt/homebrew/bin']; -function installMethodForCommand( - command: string[] | null, -): BunSnapshot['installMethod'] { +function installMethodForCommand(command: string[] | null): BunSnapshot['installMethod'] { if (!command) return null; const executablePath = command[0]; if (!executablePath) return null; - const executable = path.win32.basename(executablePath).toLowerCase(); - if (executable === 'winget.exe') return 'winget'; - if (executable === 'scoop.cmd') return 'scoop'; - if (executable === 'brew') return 'homebrew'; + const executable = path.basename(executablePath).toLowerCase(); + const windowsExecutable = path.win32.basename(executablePath).toLowerCase(); + if (windowsExecutable === 'winget.exe') return 'winget'; + if (windowsExecutable === 'scoop.cmd') return 'scoop'; + if (executable === 'brew' || windowsExecutable === 'brew') return 'homebrew'; return 'official-script'; } -export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] { +export function resolveBunInstallCommand( + options: CommonOptions = {}, +): BunSnapshot['installCommand'] { const platform = platformOf(options); if (platform === 'win32') { const winget = findCommand('winget.exe', options); @@ -154,7 +156,8 @@ export async function detectBun(options: CommonOptions = {}): Promise - all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index, + : [ + path.posix.join(homeDir, '.local', 'bin'), + path.posix.join(homeDir, 'bin'), + '/usr/local/bin', + ]; + const manualPreferred = + platform === 'darwin' + ? [ + path.posix.join(homeDir, '.local', 'bin'), + path.posix.join(homeDir, 'bin'), + '/usr/local/bin', + ] + : preferred; + const installCandidates = [...manualPreferred, ...pathDirs].filter( + (dir, index, all) => + all.findIndex( + (other) => + normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform), + ) === index, + ); + const installedPreferred = pathDirs.find((dir) => { + if (!pathEntriesContain(preferred, dir, platform)) return false; + return existsSyncOf(options)(path.posix.join(dir, 'subminer')); + }); + if (installedPreferred) { + const installPath = path.posix.join(installedPreferred, 'subminer'); + return { + status: 'ready', + commandPath: installPath, + installPath, + pathDir: installedPreferred, + shadowedBy: null, + message: null, + }; + } + const selected = installCandidates.find( + (dir) => + (platform !== 'darwin' || !pathEntriesContain(MACOS_HOMEBREW_PATH_DIRS, dir, platform)) && + pathEntriesContain(pathDirs, dir, platform) && + isWritableDir(dir, options), ); - const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options)); if (!selected) { return { status: 'not_installable', @@ -258,10 +297,14 @@ export async function detectLauncher( const commandPath = findCommand('subminer', options); const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath); - if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) { + if ( + commandPath && + normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized + ) { return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath }; } - if (!existsSyncOf(options)(expectedPath)) return { ...target, status: 'not_installed', commandPath: null }; + if (!existsSyncOf(options)(expectedPath)) + return { ...target, status: 'not_installed', commandPath: null }; if (!commandPath) { return { ...target, diff --git a/src/main/runtime/composers/app-ready-composer.test.ts b/src/main/runtime/composers/app-ready-composer.test.ts index d4e98eb6..e53294b3 100644 --- a/src/main/runtime/composers/app-ready-composer.test.ts +++ b/src/main/runtime/composers/app-ready-composer.test.ts @@ -11,6 +11,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => return { ok: true, path: '/tmp/config.jsonc', warnings: [] }; }, logInfo: () => {}, + logDebug: () => {}, logWarning: () => {}, showDesktopNotification: () => {}, startConfigHotReload: () => {}, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 6c0e247d..4853222b 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -58,6 +58,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { getMultiCopyTimeoutMs: () => 0, schedule: () => 0 as never, logInfo: () => {}, + logDebug: () => {}, logWarn: () => {}, logError: () => {}, }, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 029aa46b..22f62ee3 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -82,6 +82,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { jellyfinPreviewAuth: false, texthooker: false, texthookerOpenBrowser: false, + update: false, help: false, autoStartOverlay: false, generateConfig: false, @@ -124,6 +125,7 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => { false, ); assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false); + assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false); }); test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => { diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 25906119..a2f1b9f8 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -119,6 +119,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { args.jellyfinRemoteAnnounce || args.jellyfinPreviewAuth || args.texthooker || + args.update || args.help, ); } @@ -129,6 +130,10 @@ export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean { return !hasAnyStartupCommandBeyondSetup(args); } +export function isStandaloneFirstRunSetupCommand(args: CliArgs): boolean { + return args.setup && !args.start && !hasAnyStartupCommandBeyondSetup(args); +} + function getPluginStatus( state: SetupState, pluginInstalled: boolean, diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index af2e2766..537e8689 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -65,6 +65,9 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish assert.match(html, /Open Yomitan Settings/); assert.match(html, /Finish setup/); assert.match(html, /disabled/); + assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/); + assert.match(html, /min-height:\s*100vh;/); + assert.match(html, /box-sizing:\s*border-box;/); }); test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => { @@ -305,19 +308,60 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions', assert.match(html, /Installed, Bun missing/); assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/); assert.match(html, /action=install-command-line-launcher/); - assert.match(html, /` : ''; - const launcherButtonDisabled = launcher.status === 'failed' ? '' : ''; + const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : ''; return `
@@ -345,13 +348,20 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { --yellow: #eed49f; --red: #ed8796; } + html, + body { + min-height: 100%; + } body { margin: 0; + min-height: 100vh; background: linear-gradient(180deg, var(--mantle), var(--base)); color: var(--text); font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } main { + box-sizing: border-box; + min-height: 100vh; padding: 18px; } h1 { @@ -583,6 +593,7 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: { return (): boolean => { const window = deps.getSetupWindow(); if (!window) return false; + window.show?.(); window.focus(); return true; }; @@ -626,6 +637,7 @@ export function createOpenFirstRunSetupWindowHandler< markSetupCancelled: () => Promise; isSetupCompleted: () => boolean; shouldQuitWhenClosedIncomplete: () => boolean; + shouldQuitWhenClosedCompleted?: () => boolean; quitApp: () => void; clearSetupWindow: () => void; setSetupWindow: (window: TWindow) => void; @@ -639,11 +651,23 @@ export function createOpenFirstRunSetupWindowHandler< const setupWindow = deps.createSetupWindow(); deps.setSetupWindow(setupWindow); + setupWindow.show?.(); + setupWindow.focus(); const render = async (): Promise => { const model = await deps.getSetupSnapshot(); + if (setupWindow.isDestroyed()) { + return; + } const html = deps.buildSetupHtml(model); + if (setupWindow.isDestroyed()) { + return; + } await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`); + if (!setupWindow.isDestroyed()) { + setupWindow.show?.(); + setupWindow.focus(); + } }; const handleNavigation = createHandleFirstRunSetupNavigationHandler({ @@ -682,7 +706,10 @@ export function createOpenFirstRunSetupWindowHandler< }); } deps.clearSetupWindow(); - if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) { + if ( + (setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) || + (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) + ) { deps.quitApp(); } }); diff --git a/src/main/runtime/setup-window-factory.test.ts b/src/main/runtime/setup-window-factory.test.ts index cb748823..1c2504f8 100644 --- a/src/main/runtime/setup-window-factory.test.ts +++ b/src/main/runtime/setup-window-factory.test.ts @@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', () assert.deepEqual(createSetupWindow(), { id: 'first-run' }); assert.deepEqual(options, { - width: 560, - height: 640, + width: 720, + height: 860, title: 'SubMiner Setup', show: true, autoHideMenuBar: true, diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts index 96d9adf8..730b9789 100644 --- a/src/main/runtime/setup-window-factory.ts +++ b/src/main/runtime/setup-window-factory.ts @@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler(deps: { createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; }) { return createSetupWindowHandler(deps, { - width: 560, - height: 640, + width: 720, + height: 860, title: 'SubMiner Setup', resizable: false, minimizable: false, diff --git a/src/main/runtime/startup-config-main-deps.test.ts b/src/main/runtime/startup-config-main-deps.test.ts index 229ff93d..daec97cb 100644 --- a/src/main/runtime/startup-config-main-deps.test.ts +++ b/src/main/runtime/startup-config-main-deps.test.ts @@ -10,6 +10,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async ( const deps = createBuildReloadConfigMainDepsHandler({ reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }), logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), logWarning: (message) => calls.push(`warn:${message}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), startConfigHotReload: () => calls.push('start-hot-reload'), @@ -30,6 +31,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async ( warnings: [], }); deps.logInfo('x'); + deps.logDebug('debug'); deps.logWarning('y'); deps.showDesktopNotification('SubMiner', { body: 'warn' }); deps.startConfigHotReload(); @@ -39,6 +41,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async ( deps.failHandlers.quit(); assert.deepEqual(calls, [ 'info:x', + 'debug:debug', 'warn:y', 'notify:SubMiner:warn', 'start-hot-reload', diff --git a/src/main/runtime/startup-config-main-deps.ts b/src/main/runtime/startup-config-main-deps.ts index 24162ea9..c4ac0837 100644 --- a/src/main/runtime/startup-config-main-deps.ts +++ b/src/main/runtime/startup-config-main-deps.ts @@ -7,6 +7,7 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep return (): ReloadConfigMainDeps => ({ reloadConfigStrict: () => deps.reloadConfigStrict(), logInfo: (message: string) => deps.logInfo(message), + logDebug: (message: string) => deps.logDebug(message), logWarning: (message: string) => deps.logWarning(message), showDesktopNotification: (title: string, options: { body: string }) => deps.showDesktopNotification(title, options), diff --git a/src/main/runtime/startup-config.test.ts b/src/main/runtime/startup-config.test.ts index 0d5aaab2..ea253e2c 100644 --- a/src/main/runtime/startup-config.test.ts +++ b/src/main/runtime/startup-config.test.ts @@ -20,6 +20,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => { ], }), logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), logWarning: (message) => calls.push(`warn:${message}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), startConfigHotReload: () => calls.push('hotReload:start'), @@ -36,7 +37,11 @@ test('createReloadConfigHandler runs success flow with warnings', async () => { reloadConfig(); await Promise.resolve(); - assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc'))); + assert.ok(calls.some((entry) => entry.startsWith('debug:Using config file: /tmp/config.jsonc'))); + assert.equal( + calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')), + false, + ); assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)'))); assert.ok( calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')), @@ -64,6 +69,7 @@ test('createReloadConfigHandler fails startup for parse errors', () => { error: 'unexpected token', }), logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), logWarning: (message) => calls.push(`warn:${message}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), startConfigHotReload: () => calls.push('hotReload:start'), @@ -102,6 +108,7 @@ test('createReloadConfigHandler can skip AniList refresh for headless commands', warnings: [], }), logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), logWarning: (message) => calls.push(`warn:${message}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), startConfigHotReload: () => calls.push('hotReload:start'), diff --git a/src/main/runtime/startup-config.ts b/src/main/runtime/startup-config.ts index 89619f78..24cfa5a1 100644 --- a/src/main/runtime/startup-config.ts +++ b/src/main/runtime/startup-config.ts @@ -24,6 +24,7 @@ type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess; export type ReloadConfigRuntimeDeps = { reloadConfigStrict: () => ReloadConfigStrictResult; logInfo: (message: string) => void; + logDebug: (message: string) => void; logWarning: (message: string) => void; showDesktopNotification: (title: string, options: { body: string }) => void; startConfigHotReload: () => void; @@ -61,7 +62,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () => ); } - deps.logInfo(`Using config file: ${result.path}`); + deps.logDebug(`Using config file: ${result.path}`); if (result.warnings.length > 0) { deps.logWarning(buildConfigWarningSummary(result.path, result.warnings)); deps.showDesktopNotification('SubMiner', { diff --git a/src/main/runtime/update/app-updater.test.ts b/src/main/runtime/update/app-updater.test.ts index 108ba83f..a2c9f9ba 100644 --- a/src/main/runtime/update/app-updater.test.ts +++ b/src/main/runtime/update/app-updater.test.ts @@ -1,6 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater'; +import { + configureAutoUpdater, + createElectronAppUpdater, + isKnownLinuxPackageManagedAppImage, + isNativeUpdaterSupported, + resolveMacAppBundlePath, + type ElectronAutoUpdaterLike, +} from './app-updater'; type UpdaterLogger = { info: (message: string) => void; @@ -53,3 +60,222 @@ test('configureAutoUpdater allows prereleases only for the prerelease channel', configureAutoUpdater(updater, () => {}, 'stable'); assert.equal(updater.allowPrerelease, false); }); + +test('configureAutoUpdater handles late updater error events', () => { + const logged: string[] = []; + const errorListeners: Array<(error: unknown) => void> = []; + const updater: ElectronAutoUpdaterLike & { + on: (event: string, listener: (error: unknown) => void) => typeof updater; + } = { + autoDownload: true, + allowPrerelease: false, + allowDowngrade: true, + logger: null, + checkForUpdates: async () => null, + downloadUpdate: async () => [], + quitAndInstall: () => {}, + on: (event, listener) => { + if (event === 'error') errorListeners.push(listener); + return updater; + }, + }; + + configureAutoUpdater(updater, (message) => logged.push(message)); + + const [errorListener] = errorListeners; + assert.ok(errorListener); + errorListener(new Error('APPIMAGE env is not defined')); + assert.deepEqual(logged, ['Updater error event: APPIMAGE env is not defined']); +}); + +test('app updater skips native update checks when native updater is unsupported', async () => { + let checked = false; + const updater: ElectronAutoUpdaterLike = { + autoDownload: true, + allowPrerelease: false, + allowDowngrade: true, + logger: null, + checkForUpdates: async () => { + checked = true; + return { + updateInfo: { + version: '0.15.0', + }, + }; + }, + downloadUpdate: async () => [], + quitAndInstall: () => {}, + }; + const logged: string[] = []; + const appUpdater = createElectronAppUpdater({ + currentVersion: '0.14.0', + isPackaged: true, + updater, + log: (message) => logged.push(message), + isNativeUpdaterSupported: () => false, + }); + + const result = await appUpdater.checkForUpdates('stable'); + + assert.equal(checked, false); + assert.deepEqual(result, { + available: false, + version: '0.14.0', + canUpdate: false, + }); + assert.deepEqual(logged, [ + 'Skipping native app update check because native updater is unsupported.', + ]); +}); + +test('app updater skips native downloads when native updater is unsupported', async () => { + let downloaded = false; + const updater: ElectronAutoUpdaterLike = { + autoDownload: true, + allowPrerelease: false, + allowDowngrade: true, + logger: null, + checkForUpdates: async () => null, + downloadUpdate: async () => { + downloaded = true; + return []; + }, + quitAndInstall: () => {}, + }; + const logged: string[] = []; + const appUpdater = createElectronAppUpdater({ + currentVersion: '0.14.0', + isPackaged: true, + updater, + log: (message) => logged.push(message), + isNativeUpdaterSupported: () => false, + }); + + await appUpdater.downloadUpdate(); + + assert.equal(downloaded, false); + assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']); +}); + +test('resolveMacAppBundlePath resolves packaged macOS executable path', () => { + assert.equal( + resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'), + '/Applications/SubMiner.app', + ); + assert.equal(resolveMacAppBundlePath('/usr/local/bin/SubMiner'), null); +}); + +test('mac native updater is unsupported for ad-hoc signed app bundles', async () => { + const logged: string[] = []; + const supported = await isNativeUpdaterSupported({ + platform: 'darwin', + isPackaged: true, + execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + readCodeSignature: () => + ['Signature=adhoc', 'TeamIdentifier=not set', 'Runtime Version=26.0.0'].join('\n'), + log: (message) => logged.push(message), + }); + + assert.equal(supported, false); + assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']); +}); + +test('mac native updater is supported for Developer ID signed app bundles', async () => { + const supported = await isNativeUpdaterSupported({ + platform: 'darwin', + isPackaged: true, + execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + readCodeSignature: () => + ['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'), + }); + + assert.equal(supported, true); +}); + +test('linux native updater is unsupported even for writable direct 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.', + ]); +}); + +test('linux native updater is unsupported when APPIMAGE is missing', async () => { + const logged: string[] = []; + const supported = await isNativeUpdaterSupported({ + platform: 'linux', + isPackaged: true, + execPath: '/tmp/.mount_SubMiner/SubMiner', + env: {}, + log: (message) => logged.push(message), + }); + + 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.', + ]); +}); + +test('linux native updater is unsupported for package-managed AppImage installs', async () => { + const logged: string[] = []; + const supported = await isNativeUpdaterSupported({ + platform: 'linux', + isPackaged: true, + execPath: '/tmp/.mount_SubMiner/SubMiner', + env: { + APPIMAGE: '/opt/SubMiner/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.', + ]); +}); + +test('known Linux package-managed AppImage detection follows the canonical AUR path', () => { + assert.equal(isKnownLinuxPackageManagedAppImage('/opt/SubMiner/SubMiner.AppImage'), true); + assert.equal( + isKnownLinuxPackageManagedAppImage('/home/tester/.local/bin/SubMiner.AppImage'), + false, + ); +}); + +test('native updater is unsupported on Windows by default', async () => { + const supported = await isNativeUpdaterSupported({ + platform: 'win32', + isPackaged: true, + execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe', + }); + + assert.equal(supported, false); +}); diff --git a/src/main/runtime/update/app-updater.ts b/src/main/runtime/update/app-updater.ts index 0f1a28b7..0076a6dc 100644 --- a/src/main/runtime/update/app-updater.ts +++ b/src/main/runtime/update/app-updater.ts @@ -1,3 +1,6 @@ +import { realpathSync } from 'node:fs'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { autoUpdater as electronAutoUpdater } from 'electron-updater'; import type { UpdateChannel } from '../../../types/config'; import { compareSemverLike } from './release-assets'; @@ -20,6 +23,9 @@ export interface ElectronAutoUpdaterLike { allowPrerelease: boolean; allowDowngrade: boolean; logger?: ElectronUpdaterLoggerLike | null; + on?: (event: 'error', listener: (error: unknown) => void) => unknown; + off?: (event: 'error', listener: (error: unknown) => void) => unknown; + removeListener?: (event: 'error', listener: (error: unknown) => void) => unknown; checkForUpdates: () => Promise<{ updateInfo?: { version?: string; @@ -29,6 +35,85 @@ export interface ElectronAutoUpdaterLike { quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void; } +const updaterErrorListeners = new WeakMap void>(); +const execFileAsync = promisify(execFile); + +export function resolveMacAppBundlePath(execPath: string): string | null { + const marker = '.app/Contents/MacOS/'; + const markerIndex = execPath.indexOf(marker); + if (markerIndex < 0) return null; + return execPath.slice(0, markerIndex + '.app'.length); +} + +async function readMacCodeSignature(appBundlePath: string): Promise { + try { + const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], { + encoding: 'utf8', + }); + return `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + } catch { + return null; + } +} + +function realpathOrOriginal(filePath: string): string { + try { + return realpathSync(filePath); + } catch { + return filePath; + } +} + +export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean { + return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage'; +} + +export async function isNativeUpdaterSupported(options: { + platform: NodeJS.Platform; + isPackaged: boolean; + execPath: string; + env?: NodeJS.ProcessEnv; + readCodeSignature?: (appBundlePath: string) => string | null | Promise; + log?: (message: string) => void; +}): Promise { + if (!options.isPackaged) { + 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 !== 'darwin') { + options.log?.('Skipping native updater because this platform uses GitHub metadata checks.'); + return false; + } + + const appBundlePath = resolveMacAppBundlePath(options.execPath); + if (!appBundlePath) { + options.log?.( + 'Skipping native macOS updater because the app bundle path could not be resolved.', + ); + return false; + } + + const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath); + if (!signature) { + options.log?.( + 'Skipping native macOS updater because the app code signature could not be read.', + ); + return false; + } + if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) { + options.log?.('Skipping native macOS updater because this build is ad-hoc signed.'); + return false; + } + + return true; +} + export function configureAutoUpdater( updater: ElectronAutoUpdaterLike, log: (message: string) => void = () => {}, @@ -43,6 +128,22 @@ export function configureAutoUpdater( warn: (message) => log(message), error: (message) => log(message), }; + const previousErrorListener = updaterErrorListeners.get(updater); + if (previousErrorListener) { + if (updater.off) { + updater.off('error', previousErrorListener); + } else { + updater.removeListener?.('error', previousErrorListener); + } + } + if (updater.on) { + const errorListener = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + log(`Updater error event: ${message}`); + }; + updater.on('error', errorListener); + updaterErrorListeners.set(updater, errorListener); + } return updater; } @@ -52,6 +153,7 @@ export function createElectronAppUpdater(options: { updater?: ElectronAutoUpdaterLike; log: (message: string) => void; getChannel?: () => UpdateChannel; + isNativeUpdaterSupported?: () => boolean | Promise; }) { const getChannel = options.getChannel ?? (() => 'stable' as const); const updater = configureAutoUpdater( @@ -59,6 +161,15 @@ export function createElectronAppUpdater(options: { options.log, getChannel(), ); + let nativeUpdaterSupported: Promise | null = null; + + async function getNativeUpdaterSupported(): Promise { + if (!options.isNativeUpdaterSupported) return true; + if (nativeUpdaterSupported === null) { + nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported()); + } + return nativeUpdaterSupported; + } return { async checkForUpdates(channel?: UpdateChannel): Promise { @@ -69,6 +180,14 @@ export function createElectronAppUpdater(options: { canUpdate: false, }; } + if (!(await getNativeUpdaterSupported())) { + options.log('Skipping native app update check because native updater is unsupported.'); + return { + available: false, + version: options.currentVersion, + canUpdate: false, + }; + } configureAutoUpdater(updater, options.log, channel ?? getChannel()); const result = await updater.checkForUpdates(); const version = result?.updateInfo?.version ?? options.currentVersion; @@ -83,9 +202,21 @@ export function createElectronAppUpdater(options: { options.log('Skipping app update download because this build is not packaged.'); return; } + if (!(await getNativeUpdaterSupported())) { + options.log('Skipping app update download because native updater is unsupported.'); + return; + } await updater.downloadUpdate(); }, - quitAndInstall(): void { + async quitAndInstall(): Promise { + if (!options.isPackaged) { + options.log('Skipping app update install because this build is not packaged.'); + return; + } + if (!(await getNativeUpdaterSupported())) { + options.log('Skipping app update install because native updater is unsupported.'); + return; + } updater.quitAndInstall(false, true); }, }; diff --git a/src/main/runtime/update/appimage-updater.test.ts b/src/main/runtime/update/appimage-updater.test.ts new file mode 100644 index 00000000..67a323ff --- /dev/null +++ b/src/main/runtime/update/appimage-updater.test.ts @@ -0,0 +1,140 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { buildProtectedAppImageUpdateCommand, updateAppImageFromRelease } from './appimage-updater'; + +const appImageBytes = Buffer.from('appimage'); +const appImageHash = createHash('sha256').update(appImageBytes).digest('hex'); + +test('updateAppImageFromRelease verifies hash and atomically replaces writable AppImage', async () => { + const writes: Array<{ path: string; data: Buffer }> = []; + const chmods: Array<{ path: string; mode: number }> = []; + const renames: Array<{ from: string; to: string }> = []; + + const result = await updateAppImageFromRelease({ + release: { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }], + }, + sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]), + appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage', + downloadAsset: async () => appImageBytes, + fs: { + stat: async () => ({ + isFile: () => true, + mode: 0o755, + }), + access: async () => {}, + writeFile: async (targetPath, data) => { + writes.push({ path: targetPath, data }); + }, + chmod: async (targetPath, mode) => { + chmods.push({ path: targetPath, mode }); + }, + rename: async (from, to) => { + renames.push({ from, to }); + }, + unlink: async () => {}, + }, + }); + + assert.deepEqual(result, { + status: 'updated', + path: '/home/kyle/.local/bin/SubMiner.AppImage', + }); + assert.deepEqual(writes, [ + { path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', data: appImageBytes }, + ]); + assert.deepEqual(chmods, [ + { path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', mode: 0o755 }, + ]); + assert.deepEqual(renames, [ + { + from: '/home/kyle/.local/bin/.SubMiner.AppImage.update', + to: '/home/kyle/.local/bin/SubMiner.AppImage', + }, + ]); +}); + +test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => { + const result = await updateAppImageFromRelease({ + release: { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }], + }, + sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]), + appImagePath: '/opt/SubMiner/SubMiner.AppImage', + downloadAsset: async () => appImageBytes, + fs: { + stat: async () => ({ + isFile: () => true, + mode: 0o755, + }), + access: async () => { + throw new Error('EACCES'); + }, + writeFile: async () => { + throw new Error('unexpected write'); + }, + chmod: async () => {}, + rename: async () => {}, + unlink: async () => {}, + }, + }); + + assert.equal(result.status, 'protected'); + assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage'); + assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/); + assert.match(result.command ?? '', /sha256sum -c -/); + assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/); +}); + +test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => { + const command = buildProtectedAppImageUpdateCommand( + "https://example.test/Sub Miner.AppImage?sig='abc'", + "/opt/Sub Miner/SubMiner's.AppImage", + 'ABCDEF', + ); + + assert.match(command, /trap 'rm -f "\$tmp"' EXIT/); + assert.match( + command, + /curl -fSL 'https:\/\/example\.test\/Sub Miner\.AppImage\?sig='\\''abc'\\''' -o "\$tmp"/, + ); + assert.match(command, /printf '%s %s\\n' 'abcdef' "\$tmp" \| sha256sum -c -/); + assert.match(command, /sudo mv "\$tmp" '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/); + assert.match(command, /sudo chmod \+x '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/); +}); + +test('updateAppImageFromRelease aborts on hash mismatch', async () => { + const result = await updateAppImageFromRelease({ + release: { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }], + }, + sha256Sums: new Map([['SubMiner.AppImage', '0'.repeat(64)]]), + appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage', + downloadAsset: async () => appImageBytes, + fs: { + stat: async () => ({ + isFile: () => true, + mode: 0o755, + }), + access: async () => {}, + writeFile: async () => { + throw new Error('unexpected write'); + }, + chmod: async () => {}, + rename: async () => {}, + unlink: async () => {}, + }, + }); + + assert.equal(result.status, 'hash-mismatch'); +}); diff --git a/src/main/runtime/update/appimage-updater.ts b/src/main/runtime/update/appimage-updater.ts new file mode 100644 index 00000000..60a43d17 --- /dev/null +++ b/src/main/runtime/update/appimage-updater.ts @@ -0,0 +1,155 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { GitHubRelease } from './release-assets'; +import { findReleaseAsset } from './release-assets'; + +type StatLike = { + isFile: () => boolean; + mode?: number; +}; + +export type AppImageUpdateStatus = + | 'updated' + | 'skipped' + | 'protected' + | 'hash-mismatch' + | 'not-found' + | 'missing-asset'; + +export interface AppImageUpdateResult { + status: AppImageUpdateStatus; + path?: string; + command?: string; + message?: string; +} + +export interface AppImageUpdateFileSystem { + stat: (targetPath: string) => Promise; + access: (targetPath: string) => Promise; + writeFile: (targetPath: string, data: Buffer) => Promise; + chmod: (targetPath: string, mode: number) => Promise; + rename: (fromPath: string, toPath: string) => Promise; + unlink: (targetPath: string) => Promise; +} + +function sha256(data: Buffer): string { + return createHash('sha256').update(data).digest('hex'); +} + +function defaultFs(): AppImageUpdateFileSystem { + return { + stat: (targetPath) => fs.promises.stat(targetPath), + access: async (targetPath) => { + await fs.promises.access(targetPath, fs.constants.W_OK); + }, + writeFile: (targetPath, data) => fs.promises.writeFile(targetPath, data), + chmod: (targetPath, mode) => fs.promises.chmod(targetPath, mode), + rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath), + unlink: async (targetPath) => { + await fs.promises.unlink(targetPath).catch(() => undefined); + }, + }; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function buildProtectedAppImageUpdateCommand( + assetUrl: string, + appImagePath: string, + expectedSha256: string, +): string { + const quotedUrl = shellQuote(assetUrl); + const quotedPath = shellQuote(appImagePath); + const quotedSha256 = shellQuote(expectedSha256.toLowerCase()); + return [ + 'tmp=$(mktemp)', + 'trap \'rm -f "$tmp"\' EXIT', + `curl -fSL ${quotedUrl} -o "$tmp"`, + `printf '%s %s\\n' ${quotedSha256} "$tmp" | sha256sum -c -`, + `sudo mv "$tmp" ${quotedPath}`, + `sudo chmod +x ${quotedPath}`, + ].join(' && '); +} + +function selectAppImageAsset(release: GitHubRelease, appImagePath: string) { + const basename = path.basename(appImagePath); + return ( + findReleaseAsset(release, basename) ?? + findReleaseAsset(release, 'SubMiner.AppImage') ?? + release.assets.find((asset) => asset.name.endsWith('.AppImage')) ?? + null + ); +} + +export async function updateAppImageFromRelease(options: { + release: GitHubRelease | null; + sha256Sums: Map; + appImagePath?: string; + downloadAsset: (url: string) => Promise; + fs?: AppImageUpdateFileSystem; +}): Promise { + if (!options.appImagePath) { + return { status: 'not-found', message: 'No AppImage path detected.' }; + } + if (!options.release) return { status: 'missing-asset', message: 'No release found.' }; + + const asset = selectAppImageAsset(options.release, options.appImagePath); + if (!asset) return { status: 'missing-asset', message: 'Release has no AppImage asset.' }; + + const expectedSha256 = options.sha256Sums.get(asset.name); + if (!expectedSha256) { + return { status: 'missing-asset', message: `SHA256SUMS.txt has no ${asset.name} entry.` }; + } + + const fsDeps = options.fs ?? defaultFs(); + let stat: StatLike; + try { + stat = await fsDeps.stat(options.appImagePath); + } catch { + return { status: 'not-found', path: options.appImagePath }; + } + if (!stat.isFile()) { + return { status: 'skipped', path: options.appImagePath, message: 'AppImage is not a file.' }; + } + + try { + await fsDeps.access(options.appImagePath); + } catch { + return { + status: 'protected', + path: options.appImagePath, + command: buildProtectedAppImageUpdateCommand( + asset.browser_download_url, + options.appImagePath, + expectedSha256, + ), + }; + } + + const data = await options.downloadAsset(asset.browser_download_url); + const actualSha256 = sha256(data); + if (actualSha256 !== expectedSha256.toLowerCase()) { + return { + status: 'hash-mismatch', + path: options.appImagePath, + message: `Expected ${expectedSha256}, got ${actualSha256}.`, + }; + } + + const tempPath = path.join( + path.dirname(options.appImagePath), + `.${path.basename(options.appImagePath)}.update`, + ); + try { + await fsDeps.writeFile(tempPath, data); + await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755); + await fsDeps.rename(tempPath, options.appImagePath); + return { status: 'updated', path: options.appImagePath }; + } catch (error) { + await fsDeps.unlink(tempPath); + throw error; + } +} diff --git a/src/main/runtime/update/launcher-updater.test.ts b/src/main/runtime/update/launcher-updater.test.ts index b2821e86..87ae05ff 100644 --- a/src/main/runtime/update/launcher-updater.test.ts +++ b/src/main/runtime/update/launcher-updater.test.ts @@ -15,13 +15,13 @@ test('looksLikeSubminerLauncher rejects unrelated executable content', () => { assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true); }); -test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => { +test('buildProtectedLauncherUpdateCommand quotes sudo curl and chmod paths', () => { assert.equal( buildProtectedLauncherUpdateCommand( - 'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer', - '/usr/local/bin/subminer', + "https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='abc'", + "/usr/local/bin/subminer's launcher", ), - 'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer', + "sudo curl -fSL 'https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='\\''abc'\\''' -o '/usr/local/bin/subminer'\\''s launcher' && sudo chmod +x '/usr/local/bin/subminer'\\''s launcher'", ); }); @@ -84,7 +84,7 @@ test('updateLauncherAtPath reports protected command without replacing non-writa }); assert.equal(result.status, 'protected'); - assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/); + assert.match(result.command ?? '', /^sudo curl -fSL 'https:\/\/example\.test\/subminer'/); }); test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => { diff --git a/src/main/runtime/update/launcher-updater.ts b/src/main/runtime/update/launcher-updater.ts index b1fefdad..7f69711b 100644 --- a/src/main/runtime/update/launcher-updater.ts +++ b/src/main/runtime/update/launcher-updater.ts @@ -50,13 +50,17 @@ export function buildProtectedLauncherUpdateCommand( assetUrl: string, launcherPath: string, ): string { - return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`; + return `sudo curl -fSL ${shellQuote(assetUrl)} -o ${shellQuote(launcherPath)} && sudo chmod +x ${shellQuote(launcherPath)}`; } function sha256(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + function defaultFs(): LauncherUpdateFileSystem { return { readFile: (targetPath) => fs.promises.readFile(targetPath), diff --git a/src/main/runtime/update/support-assets.test.ts b/src/main/runtime/update/support-assets.test.ts new file mode 100644 index 00000000..24a30492 --- /dev/null +++ b/src/main/runtime/update/support-assets.test.ts @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildProtectedSupportAssetsCommand, + detectSupportAssetDataDirs, + updateSupportAssetsFromRelease, +} from './support-assets'; + +function sha256(data: Buffer): string { + return createHash('sha256').update(data).digest('hex'); +} + +function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-')); + fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), 'new theme\n'); + fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n'); + execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir }); + return { + archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')), + tempDir, + }; +} + +test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => { + assert.deepEqual( + detectSupportAssetDataDirs({ + platform: 'darwin', + homeDir: '/Users/kyle', + }), + [], + ); + assert.deepEqual( + detectSupportAssetDataDirs({ + platform: 'linux', + homeDir: '/home/kyle', + xdgDataHome: '/tmp/xdg-data', + }), + ['/tmp/xdg-data/SubMiner', '/usr/local/share/SubMiner', '/usr/share/SubMiner'], + ); +}); + +test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => { + const command = buildProtectedSupportAssetsCommand( + "https://example.test/subminer assets.tar.gz?sig='abc'", + "/usr/local/share/SubMiner's data", + ); + + assert.match(command, /tmp=\$\(mktemp -d\)/); + assert.match(command, /trap 'rm -rf "\$tmp"' EXIT/); + assert.match( + command, + /curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/, + ); + assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/); +}); + +test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => { + const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-')); + const dataDir = path.join(xdgDataHome, 'SubMiner'); + fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true }); + fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true }); + fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n'); + fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n'); + const { archive, tempDir } = makeSupportAssetsArchive(); + + try { + const results = await updateSupportAssetsFromRelease({ + release: { + tag_name: 'v0.15.0', + assets: [ + { + name: 'subminer-assets.tar.gz', + browser_download_url: 'https://example.test/subminer-assets.tar.gz', + }, + ], + }, + sha256Sums: new Map([['subminer-assets.tar.gz', sha256(archive)]]), + downloadAsset: async () => archive, + platform: 'linux', + xdgDataHome, + }); + + assert.deepEqual(results, [{ status: 'updated', path: dataDir }]); + assert.equal( + fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'), + 'new theme\n', + ); + assert.equal( + fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'), + 'old plugin\n', + ); + } finally { + fs.rmSync(xdgDataHome, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/src/main/runtime/update/support-assets.ts b/src/main/runtime/update/support-assets.ts index 8f1161f9..da7dba1e 100644 --- a/src/main/runtime/update/support-assets.ts +++ b/src/main/runtime/update/support-assets.ts @@ -29,12 +29,6 @@ export function detectSupportAssetDataDirs(options: { homeDir: string; xdgDataHome?: string; }): string[] { - if (options.platform === 'darwin') { - return [ - path.join(options.homeDir, 'Library/Application Support/SubMiner'), - '/usr/local/share/SubMiner', - ]; - } if (options.platform === 'linux') { const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share'); return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner']; @@ -46,10 +40,10 @@ export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: st const quotedDir = shellQuote(dataDir); return [ 'tmp=$(mktemp -d)', + 'trap \'rm -rf "$tmp"\' EXIT', `curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`, 'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"', - `sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`, - `sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`, + `sudo mkdir -p ${quotedDir}/themes`, `sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`, ].join(' && '); } @@ -76,12 +70,15 @@ export async function updateSupportAssetsFromRelease(options: { homeDir?: string; xdgDataHome?: string; }): Promise { + if ((options.platform ?? process.platform) !== 'linux') { + return [{ status: 'skipped', message: 'Support assets are only installed on Linux.' }]; + } if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }]; const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz'); - if (!asset) return [{ status: 'missing-asset', message: 'Release has no support assets.' }]; + if (!asset) return [{ status: 'missing-asset', message: 'Release has no rofi theme asset.' }]; const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz'); if (!expectedSha256) { - return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }]; + return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }]; } const dataDirs = detectSupportAssetDataDirs({ @@ -91,12 +88,11 @@ export async function updateSupportAssetsFromRelease(options: { }); const existingDataDirs: string[] = []; for (const dataDir of dataDirs) { - const hasPlugin = await pathExists(path.join(dataDir, 'plugin/subminer')); const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi')); - if (hasPlugin || hasTheme) existingDataDirs.push(dataDir); + if (hasTheme) existingDataDirs.push(dataDir); } if (existingDataDirs.length === 0) { - return [{ status: 'skipped', message: 'No existing support asset install detected.' }]; + return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }]; } const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs @@ -139,17 +135,8 @@ export async function updateSupportAssetsFromRelease(options: { await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]); const results: SupportAssetsUpdateResult[] = [...protectedResults]; for (const dataDir of writableDataDirs) { - const targetPluginDir = path.join(dataDir, 'plugin/subminer'); const targetThemePath = path.join(dataDir, 'themes/subminer.rasi'); - if (await pathExists(targetPluginDir)) { - await fs.promises.mkdir(targetPluginDir, { recursive: true }); - await fs.promises.cp(path.join(tempDir, 'plugin/subminer'), targetPluginDir, { - recursive: true, - force: true, - }); - } if (await pathExists(targetThemePath)) { - await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true }); await fs.promises.copyFile( path.join(tempDir, 'assets/themes/subminer.rasi'), targetThemePath, diff --git a/src/main/runtime/update/update-dialogs.test.ts b/src/main/runtime/update/update-dialogs.test.ts new file mode 100644 index 00000000..d0d8bd7c --- /dev/null +++ b/src/main/runtime/update/update-dialogs.test.ts @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createUpdateDialogPresenter, type ShowMessageBox } from './update-dialogs'; + +test('update dialog presenter focuses app before showing macOS dialogs', async () => { + const calls: string[] = []; + const showMessageBox: ShowMessageBox = async (options) => { + calls.push(`dialog:${options.message}`); + return { response: 0 }; + }; + const presenter = createUpdateDialogPresenter({ + platform: 'darwin', + focusApp: () => calls.push('focus'), + showMessageBox, + }); + + await presenter.showNoUpdateDialog('0.14.0'); + + assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']); +}); + +test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => { + const calls: string[] = []; + const showMessageBox: ShowMessageBox = async (options) => { + calls.push(`dialog:${options.message}`); + return { response: 0 }; + }; + const presenter = createUpdateDialogPresenter({ + platform: 'linux', + focusApp: () => calls.push('focus'), + showMessageBox, + }); + + await presenter.showNoUpdateDialog('0.14.0'); + + assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']); +}); diff --git a/src/main/runtime/update/update-dialogs.ts b/src/main/runtime/update/update-dialogs.ts index a682b068..65e2fff6 100644 --- a/src/main/runtime/update/update-dialogs.ts +++ b/src/main/runtime/update/update-dialogs.ts @@ -15,6 +15,12 @@ export type ShowMessageBox = (options: { cancelId?: number; }) => Promise; +export interface UpdateDialogPresenterDeps { + showMessageBox: ShowMessageBox; + focusApp?: () => void; + platform?: NodeJS.Platform; +} + export async function showNoUpdateDialog( showMessageBox: ShowMessageBox, version: string, @@ -27,6 +33,27 @@ export async function showNoUpdateDialog( }); } +function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void { + if ((deps.platform ?? process.platform) !== 'darwin') return; + deps.focusApp?.(); +} + +export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) { + const showFocusedMessageBox: ShowMessageBox = async (options) => { + maybeFocusAppForDialog(deps); + return deps.showMessageBox(options); + }; + + return { + showNoUpdateDialog: (version: string) => showNoUpdateDialog(showFocusedMessageBox, version), + showUpdateAvailableDialog: (version: string) => + showUpdateAvailableDialog(showFocusedMessageBox, version), + showUpdateFailedDialog: (message: string) => + showUpdateFailedDialog(showFocusedMessageBox, message), + showRestartDialog: () => showRestartDialog(showFocusedMessageBox), + }; +} + export async function showUpdateAvailableDialog( showMessageBox: ShowMessageBox, version: string, diff --git a/src/main/runtime/update/update-notifications.test.ts b/src/main/runtime/update/update-notifications.test.ts index 8b83c9c6..c7d9659a 100644 --- a/src/main/runtime/update/update-notifications.test.ts +++ b/src/main/runtime/update/update-notifications.test.ts @@ -47,3 +47,24 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails', assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']); }); + +test('notifyUpdateAvailable logs non-error osd failures with thrown value', async () => { + const calls: string[] = []; + + await notifyUpdateAvailable( + { notificationType: 'osd', version: '0.15.0' }, + { + showSystemNotification: () => { + calls.push('system'); + }, + showOsdNotification: async () => { + throw 'mpv disconnected'; + }, + log: (message) => { + calls.push(message); + }, + }, + ); + + assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']); +}); diff --git a/src/main/runtime/update/update-notifications.ts b/src/main/runtime/update/update-notifications.ts index 07c8af05..13b7072c 100644 --- a/src/main/runtime/update/update-notifications.ts +++ b/src/main/runtime/update/update-notifications.ts @@ -20,7 +20,8 @@ export async function notifyUpdateAvailable( try { await deps.showOsdNotification(message); } catch (error) { - deps.log(`Update OSD notification failed: ${(error as Error).message}`); + const reason = error instanceof Error ? error.message : String(error); + deps.log(`Update OSD notification failed: ${reason}`); } } } diff --git a/src/main/runtime/update/update-service.test.ts b/src/main/runtime/update/update-service.test.ts index bb186fe8..b4a80b32 100644 --- a/src/main/runtime/update/update-service.test.ts +++ b/src/main/runtime/update/update-service.test.ts @@ -44,7 +44,9 @@ function createDeps(overrides: Partial = {}) { calls.push('restart-dialog'); return 'later'; }, - quitAndInstall: () => calls.push('quit-install'), + quitAndInstall: () => { + calls.push('quit-install'); + }, notifyUpdateAvailable: async (version) => { calls.push(`notify:${version}`); }, @@ -90,6 +92,32 @@ test('manual update check falls back to GitHub release when app metadata is unav assert.deepEqual(calls, ['available-dialog:0.15.0']); }); +test('manual update check reports available when no update asset was applied', async () => { + const { deps, calls } = createDeps({ + checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }), + fetchLatestStableRelease: async () => ({ + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [], + }), + showUpdateAvailableDialog: async (version) => { + calls.push(`available-dialog:${version}`); + return 'update'; + }, + updateLauncher: async (_launcherPath, channel) => { + calls.push(`launcher:${channel}`); + return { status: 'skipped' }; + }, + }); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ source: 'manual' }); + + assert.equal(result.status, 'update-available'); + assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable']); +}); + test('automatic update check skips inside configured interval', async () => { const { deps, calls, setState } = createDeps(); setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 }); @@ -141,6 +169,57 @@ test('concurrent update checks share one in-flight check', async () => { assert.equal(checkCount, 1); }); +test('manual update check does not reuse in-flight automatic check', async () => { + let checkCount = 0; + const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = []; + const { deps } = createDeps({ + checkAppUpdate: () => + new Promise((resolve) => { + checkCount += 1; + resolveChecks.push(resolve); + }), + }); + const service = createUpdateService(deps); + const automatic = service.checkForUpdates({ source: 'automatic', force: true }); + const manual = service.checkForUpdates({ source: 'manual' }); + + await Promise.resolve(); + assert.equal(checkCount, 2); + for (const resolve of resolveChecks) { + resolve({ available: false, version: '0.14.0' }); + } + await Promise.all([automatic, manual]); +}); + +test('manual update check passes selected GitHub release to launcher update', async () => { + const selectedRelease = { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [], + }; + let forwardedRelease: unknown; + const { deps, calls } = createDeps({ + checkAppUpdate: async () => ({ available: true, version: '0.15.0' }), + fetchLatestStableRelease: async () => selectedRelease, + showUpdateAvailableDialog: async (version) => { + calls.push(`available-dialog:${version}`); + return 'update'; + }, + updateLauncher: (async (...args: unknown[]) => { + calls.push(`launcher:${args[1]}`); + forwardedRelease = args[2]; + return { status: 'updated' }; + }) as UpdateServiceDeps['updateLauncher'], + }); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ source: 'manual' }); + + assert.equal(result.status, 'updated'); + assert.equal(forwardedRelease, selectedRelease); +}); + test('manual prerelease update check uses prerelease release and launcher channel', async () => { const { deps, calls } = createDeps({ getConfig: () => ({ diff --git a/src/main/runtime/update/update-service.ts b/src/main/runtime/update/update-service.ts index 7e8a8c10..42349907 100644 --- a/src/main/runtime/update/update-service.ts +++ b/src/main/runtime/update/update-service.ts @@ -43,13 +43,14 @@ export interface UpdateServiceDeps { updateLauncher: ( launcherPath?: string, channel?: UpdateChannel, + release?: GitHubRelease | null, ) => Promise<{ status: string; command?: string }>; showNoUpdateDialog: (version: string) => Promise; showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>; showUpdateFailedDialog: (message: string) => Promise; downloadAppUpdate: () => Promise; showRestartDialog: () => Promise<'restart' | 'later'>; - quitAndInstall: () => void; + quitAndInstall: () => void | Promise; notifyUpdateAvailable: (version: string) => Promise; log: (message: string) => void; setTimeout?: (callback: () => void, delayMs: number) => unknown; @@ -96,7 +97,7 @@ function summarizeError(error: unknown): string { } export function createUpdateService(deps: UpdateServiceDeps) { - let inFlight: Promise | null = null; + const inFlightBySource = new Map>(); async function runCheck(request: UpdateCheckRequest): Promise { const now = deps.now(); @@ -157,17 +158,24 @@ export function createUpdateService(deps: UpdateServiceDeps) { return { status: 'update-available', version: latest.version }; } + let appUpdateApplied = false; if (appUpdate.available && appUpdate.canUpdate !== false) { await deps.downloadAppUpdate(); + appUpdateApplied = true; } - const launcherResult = await deps.updateLauncher(request.launcherPath, channel); + const launcherResult = await deps.updateLauncher(request.launcherPath, channel, release); if (launcherResult.status === 'protected' && launcherResult.command) { deps.log(`Launcher update requires manual command: ${launcherResult.command}`); } + const launcherUpdateApplied = launcherResult.status === 'updated'; + if (!appUpdateApplied && !launcherUpdateApplied) { + return { status: 'update-available', version: latest.version }; + } + const restartChoice = await deps.showRestartDialog(); if (restartChoice === 'restart') { - deps.quitAndInstall(); + await deps.quitAndInstall(); } return { status: 'updated', version: latest.version }; } catch (error) { @@ -183,11 +191,13 @@ export function createUpdateService(deps: UpdateServiceDeps) { return { checkForUpdates(request: UpdateCheckRequest): Promise { + const inFlight = inFlightBySource.get(request.source); if (inFlight) return inFlight; - inFlight = runCheck(request).finally(() => { - inFlight = null; + const nextInFlight = runCheck(request).finally(() => { + inFlightBySource.delete(request.source); }); - return inFlight; + inFlightBySource.set(request.source, nextInFlight); + return nextInFlight; }, startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void { const setTimeoutFn = deps.setTimeout ?? setTimeout; diff --git a/src/main/runtime/yomitan-settings-opener.test.ts b/src/main/runtime/yomitan-settings-opener.test.ts index cee08b9d..cf399ab0 100644 --- a/src/main/runtime/yomitan-settings-opener.test.ts +++ b/src/main/runtime/yomitan-settings-opener.test.ts @@ -119,9 +119,9 @@ test('yomitan opener uses loaded extension from app state without calling loader assert.equal(forwardedExtension, appStateExtension); }); -test('yomitan opener warns instead of starting a settings-triggered load when extension is not ready', async () => { +test('yomitan opener lazy-loads extension when app state is empty and no load is in flight', async () => { let ensureCalled = false; - const logs: string[] = []; + let forwardedExtension: { id: string } | null = null; const openSettings = createOpenYomitanSettingsHandler({ ensureYomitanExtensionLoaded: async () => { ensureCalled = true; @@ -129,19 +129,19 @@ test('yomitan opener warns instead of starting a settings-triggered load when ex }, getYomitanExtension: () => null, getYomitanExtensionLoadInFlight: () => null, - openYomitanSettingsWindow: () => { - throw new Error('should not open before extension is ready'); + openYomitanSettingsWindow: ({ yomitanExt }) => { + forwardedExtension = yomitanExt as { id: string }; }, getExistingWindow: () => null, setWindow: () => {}, - logWarn: (message) => logs.push(message), - logError: () => logs.push('error'), + logWarn: () => {}, + logError: () => {}, }); openSettings(); await Promise.resolve(); await Promise.resolve(); - assert.equal(ensureCalled, false); - assert.deepEqual(logs, ['Unable to open Yomitan settings: extension is not loaded yet.']); + assert.equal(ensureCalled, true); + assert.deepEqual(forwardedExtension, { id: 'ext' }); }); diff --git a/src/main/runtime/yomitan-settings-opener.ts b/src/main/runtime/yomitan-settings-opener.ts index 7125fd12..08966996 100644 --- a/src/main/runtime/yomitan-settings-opener.ts +++ b/src/main/runtime/yomitan-settings-opener.ts @@ -22,7 +22,7 @@ export function createOpenYomitanSettingsHandler(deps: { return (): void => { void (async () => { if (deps.getYomitanExtension) { - const loadedExtension = deps.getYomitanExtension(); + let loadedExtension = deps.getYomitanExtension(); if (!loadedExtension) { if (deps.getYomitanExtensionLoadInFlight?.()) { deps.logWarn( @@ -30,8 +30,11 @@ export function createOpenYomitanSettingsHandler(deps: { ); return; } - deps.logWarn('Unable to open Yomitan settings: extension is not loaded yet.'); - return; + loadedExtension = await deps.ensureYomitanExtensionLoaded(); + if (!loadedExtension) { + deps.logWarn('Unable to open Yomitan settings: extension failed to load.'); + return; + } } const yomitanSession = deps.getYomitanSession?.() ?? null; diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index e2f30466..07d8c4bc 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -164,6 +164,7 @@ test('release packaging stages generated launcher as an app resource', () => { ), ); assert.match(packageJson.scripts.build ?? '', /bun run build:launcher/); + assert.match(packageJson.scripts['build:launcher'] ?? '', /--banner='#!\/usr\/bin\/env bun'/); }); test('config example generation runs directly from source without unrelated bundle prerequisites', () => {