Compare commits

..

3 Commits

Author SHA1 Message Date
sudacode 44609f3da0 fix(macos): validate PID and socket before reporting window as minimized
- Move PID extraction and app PID match before minimized check in windowStateFromAccessibilityAPI
- Add test asserting correct validation order in get-mpv-window-macos.swift
- Include new test in test:fast suite
2026-05-15 18:58:42 -07:00
sudacode b9ca198039 fix(macos): default overlay to click-through before subtitle hover
- Set shouldUseMacOSMousePassthrough so overlay starts in forward passthrough mode
- Renderer hover tracking re-enables interaction only over subtitle/popup areas
- Update tests to expect mouse-ignore:true:forward instead of mouse-ignore:false:plain
- Add test covering click-through behavior when overlay already had focus
2026-05-15 18:36:19 -07:00
sudacode 00811922fc fix(macos): preserve overlay on transient tracker loss and fix subsync m
- macOS tracker now reports minimized vs not-found so transient helper misses no longer hide the overlay; minimizing mpv still triggers hide
- overlay-runtime-init skips hide on non-minimized window-lost and calls updateVisibleOverlayVisibility instead
- overlay-visibility preserves window level and passthrough state during transient tracker loss
- subsync modal open uses dedicated modal window with retry logic to fix first-attempt flash and stale modal state on macOS
2026-05-15 18:36:07 -07:00
77 changed files with 194 additions and 2516 deletions
+2 -13
View File
@@ -47,13 +47,6 @@ jobs:
- name: Build (TypeScript check)
run: bun run typecheck
- name: Install Lua
run: |
sudo apt-get update
sudo apt-get install -y lua5.4
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
@@ -369,12 +362,8 @@ jobs:
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Verify committed prerelease notes
run: |
if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1
fi
- name: Generate prerelease notes from pending fragments
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
- name: Publish Prerelease
env:
-1
View File
@@ -10,7 +10,6 @@ dist/
release/*
!release/
!release/release-notes.md
!release/prerelease-notes.md
build/yomitan/
coverage/
+3 -3
View File
@@ -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)
+3 -4
View File
@@ -217,13 +217,12 @@ Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasud
Also download the `subminer` launcher (recommended):
```bash
mkdir -p ~/.local/bin
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \
&& sudo chmod +x /usr/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. Make sure `~/.local/bin` is on your PATH before installing there.
> 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.
</details>
+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: updater
- 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.
- 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.
-5
View File
@@ -1,5 +0,0 @@
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.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: launcher
- Fixed Linux first-run launcher installs by building the packaged launcher with a valid Bun shebang.
-4
View File
@@ -1,4 +0,0 @@
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.
-4
View File
@@ -1,4 +0,0 @@
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.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: updater
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: updater
- Bring macOS update dialogs to the front when `subminer --update` is run from the launcher.
-4
View File
@@ -1,4 +0,0 @@
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.
-4
View File
@@ -1,4 +0,0 @@
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.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: launcher
- Fixed `subminer app --setup` so it opens the setup flow when SubMiner is already running in the background.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: setup
- Quit standalone setup app launches after first-run setup finishes, returning the terminal instead of leaving the app process open.
+3 -2
View File
@@ -3,8 +3,9 @@ area: tray
- Kept the tray app running when closing tray-launched Yomitan settings.
- Kept tray-launched Yomitan settings loading from blocking other tray actions.
- 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.
- Removed the default native app menu from Yomitan settings so File > Quit cannot put the tray app into a stuck quit state.
- 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.
+6 -8
View File
@@ -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 the optional Linux rofi theme
# Download launcher support assets used for bundled runtime plugin injection
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/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
mkdir -p ~/.local/share/SubMiner/plugin/subminer
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
```
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,9 +174,7 @@ subminer -u
subminer --update
```
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.
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.
### From Source
@@ -242,7 +240,7 @@ subminer -u
subminer --update
```
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.
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.
::: 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`.
@@ -271,7 +269,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. 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 and rofi theme. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
```bash
sudo make install-macos PREFIX=/usr/local
-2
View File
@@ -109,8 +109,6 @@ Use `subminer <subcommand> -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
+1 -4
View File
@@ -55,10 +55,7 @@
`bun run build`
When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. Do not run
`bun run changelog:build`.
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
+20 -20
View File
@@ -241,36 +241,36 @@ test('dictionary command returns after app handoff starts', () => {
assert.equal(handled, true);
});
test('update command runs direct Linux release update without launching Electron', async () => {
test('update command forwards launcher path and waits for response', async () => {
const context = createContext();
context.args.update = true;
const calls: string[] = [];
const forwarded: string[][] = [];
const responses: string[] = [];
const handled = await runUpdateCommand(context, {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (_appPath, appArgs) => {
forwarded.push(appArgs);
return { status: 0, stdout: '', stderr: '' };
},
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}`);
waitForUpdateResponse: async (responsePath) => {
responses.push(responsePath);
return { ok: true, status: 'up-to-date', version: '0.15.0' };
},
});
assert.equal(handled, true);
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(forwarded, [
[
'--update',
'--update-launcher-path',
'/tmp/subminer',
'--update-response-path',
'/tmp/subminer-update-test/response.json',
],
]);
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
});
test('stats command launches attached app command with response path', async () => {
-140
View File
@@ -1,140 +0,0 @@
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> = {}): 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',
]);
});
-133
View File
@@ -1,27 +1,10 @@
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;
@@ -30,18 +13,6 @@ 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;
@@ -51,95 +22,9 @@ type UpdateCommandDeps = {
) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void;
runDirectReleaseUpdate: (
request: DirectReleaseUpdateRequest,
) => Promise<DirectReleaseUpdateResult>;
readMainConfig: () => Record<string, unknown> | 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<DirectReleaseUpdateResult> {
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<string, string>();
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<string, unknown> | null): UpdateChannel {
const updates =
root?.updates && typeof root.updates === 'object' && !Array.isArray(root.updates)
? (root.updates as Record<string, unknown>)
: null;
return updates?.channel === 'prerelease' ? 'prerelease' : 'stable';
}
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): 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)),
@@ -162,9 +47,6 @@ const defaultDeps: UpdateCommandDeps = {
removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
runDirectReleaseUpdate,
readMainConfig: readLauncherMainConfigObject,
log: launcherLog,
};
export async function runUpdateCommand(
@@ -177,21 +59,6 @@ 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');
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.2",
"version": "0.14.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -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 --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --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/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: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: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",
-36
View File
@@ -1,36 +0,0 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
## Highlights
### Added
**Auto-Update:** The tray and `subminer -u` now check for SubMiner releases and prompt you to install updates, covering the app itself, the command-line launcher, and Linux rofi themes. Update notifications are configurable, downloads are checksum-verified, and an opt-in prerelease channel lets you receive beta and RC builds.
**First-Run Setup:** The app can now install Bun and the `subminer` command-line launcher during first run on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim lets you type `subminer` in any terminal without manually adding `SubMiner.exe` to PATH.
### Fixed
**macOS Overlay:** Controls on the mpv window are now clickable before you hover the subtitle bars. Transient mpv window focus misses no longer hide the overlay; minimizing mpv still hides it as expected.
**Subtitle Sync:** The subtitle sync modal on macOS now opens reliably: no more flash-and-hide on the first attempt or stale modal state left after syncing.
**Updater:** Update checks on Linux now use GitHub release metadata instead of the native Electron updater, preventing tray app crashes. macOS builds that cannot auto-install updates show a manual install prompt instead of a non-functional restart prompt. macOS update dialogs are brought to the front when `subminer --update` is run from the launcher.
**Linux Command-Line Updater:** `subminer -u` now performs release updates directly from the launcher without requiring the tray app, and reports "up to date" without downloading assets when already on the latest version.
**Launcher Setup:** Linux first-run launcher installs now build with a valid Bun shebang. `subminer app --setup` correctly opens the setup flow even when SubMiner is already running in the background. On macOS, first-run setup recognizes existing launchers in Homebrew or user PATH directories, and manual installs avoid Homebrew-managed locations. The setup window now quits automatically after first-run setup completes, returning control to the terminal.
**Tray App:** Fixed several issues with tray-launched Yomitan settings: closing the window no longer quits the tray app, a close-only menu replaces the default native menu, and settings loading no longer blocks other tray actions. An in-page close button is now available on Hyprland, where native window controls may be absent. Disabled the embedded popup preview in the settings window to prevent renderer hangs. Fixed a startup race condition that could leave extension loading in an error state, and corrected focus handling for the session help modal when mpv is not running.
**Build:** One-shot `make clean build install` flows now correctly pick up the AppImage built in the same invocation.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+4 -17
View File
@@ -130,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
openFirstRunSetup: () => {
calls.push('openFirstRunSetup');
},
setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`);
@@ -247,9 +247,6 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
log: (message) => {
calls.push(`log:${message}`);
},
logDebug: (message) => {
calls.push(`debug:${message}`);
},
warn: (message) => {
calls.push(`warn:${message}`);
},
@@ -361,23 +358,13 @@ 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:force'));
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
assert.ok(calls.includes('openFirstRunSetup'));
assert.ok(calls.includes('log:Opened first-run setup flow.'));
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
});
+4 -7
View File
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
openFirstRunSetup: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -106,7 +106,6 @@ 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;
}
@@ -158,7 +157,7 @@ interface MiningCliRuntime {
}
interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void;
openFirstRunSetup: () => void;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -212,7 +211,6 @@ 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;
}
@@ -288,7 +286,6 @@ export function createCliCommandDepsRuntime(
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd,
log: options.log,
logDebug: options.logDebug,
warn: options.warn,
error: options.error,
};
@@ -381,8 +378,8 @@ export function handleCliCommand(
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
} else if (args.setup) {
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.');
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) {
-86
View File
@@ -358,89 +358,3 @@ 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);
});
+12 -7
View File
@@ -131,7 +131,6 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
ensureYomitanExtensionLoaded?: () => Promise<void>;
handleFirstRunSetup: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
@@ -216,8 +215,6 @@ export function isAutoUpdateEnabledRuntime(
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig();
@@ -227,7 +224,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} else {
deps.createMpvClient();
deps.createSubtitleTimingTracker();
await ensureYomitanExtensionReady();
await deps.loadYomitanExtension();
deps.initializeOverlayRuntime();
deps.handleInitialArgs();
}
@@ -240,10 +237,18 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return;
}
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
}
deps.logDebug?.('App-ready critical path started.');
if (deps.shouldSkipHeavyStartup?.()) {
await ensureYomitanExtensionReady();
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
@@ -314,12 +319,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await ensureYomitanExtensionReady();
await deps.loadYomitanExtension();
deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
await ensureYomitanExtensionReady();
await deps.loadYomitanExtension();
}
await deps.handleFirstRunSetup();
@@ -9,7 +9,6 @@ import {
ensureExtensionCopyAsync,
shouldCopyYomitanExtension,
} from './yomitan-extension-copy';
import { withSuppressedYomitanExtensionWarnings } from './yomitan-extension-loader';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -20,66 +19,6 @@ function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, 'utf-8');
}
test('suppresses Yomitan contextMenus extension load warnings only while loading', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning(
"Warnings loading extension:\nPermission 'contextMenus' is unknown.",
{
type: 'ExtensionLoadWarning',
},
);
warningProcess.emitWarning('Other extension warning', { type: 'ExtensionLoadWarning' });
return null;
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Other extension warning',
"ExtensionLoadWarning:Permission 'contextMenus' is unknown.",
]);
});
test('suppressed Yomitan warning wrapper is re-entrant safe', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
const originalEmitWarning = warningProcess.emitWarning;
await withSuppressedYomitanExtensionWarnings(async () => {
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Nested warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Outer warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
assert.equal(warningProcess.emitWarning, originalEmitWarning);
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Nested warning',
'ExtensionLoadWarning:Outer warning',
]);
});
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
@@ -246,7 +185,10 @@ 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 {
+9 -101
View File
@@ -29,85 +29,6 @@ export interface YomitanExtensionLoaderDeps {
setYomitanSession: (session: Session | null) => void;
}
type WarningProcess = Pick<NodeJS.Process, 'emitWarning'>;
const suppressedWarningState = new WeakMap<
WarningProcess,
{
count: number;
originalEmitWarning: WarningProcess['emitWarning'];
}
>();
function getWarningType(warning: string | Error, args: unknown[]): string | undefined {
if (typeof warning !== 'string') {
return warning.name;
}
const firstArg = args[0];
if (typeof firstArg === 'string') {
return firstArg;
}
if (firstArg && typeof firstArg === 'object' && 'type' in firstArg) {
const type = (firstArg as { type?: unknown }).type;
return typeof type === 'string' ? type : undefined;
}
return undefined;
}
function shouldSuppressYomitanExtensionWarning(warning: string | Error, args: unknown[]): boolean {
const message = warning instanceof Error ? warning.message : warning;
return (
getWarningType(warning, args) === 'ExtensionLoadWarning' &&
message.includes("Permission 'contextMenus' is unknown.")
);
}
export async function withSuppressedYomitanExtensionWarnings<T>(
run: () => Promise<T>,
warningProcess: WarningProcess = process,
): Promise<T> {
const existingState = suppressedWarningState.get(warningProcess);
if (existingState) {
existingState.count++;
try {
return await run();
} finally {
existingState.count--;
if (existingState.count === 0) {
warningProcess.emitWarning = existingState.originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
const originalEmitWarning = warningProcess.emitWarning;
const state = {
count: 1,
originalEmitWarning,
};
suppressedWarningState.set(warningProcess, state);
warningProcess.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
if (shouldSuppressYomitanExtensionWarning(warning, args)) {
return;
}
return (originalEmitWarning as (...emitArgs: unknown[]) => void).call(
warningProcess,
warning,
...args,
);
}) as typeof process.emitWarning;
try {
return await run();
} finally {
state.count--;
if (state.count === 0) {
warningProcess.emitWarning = originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
@@ -158,20 +79,9 @@ export async function loadYomitanExtension(
return null;
}
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;
}
const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
if (extensionCopy.copied) {
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
}
@@ -181,15 +91,13 @@ export async function loadYomitanExtension(
try {
const extensions = targetSession.extensions;
const extension = await withSuppressedYomitanExtensionWarnings(() =>
extensions
? extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: targetSession.loadExtension(extPath, {
allowFileAccess: true,
}),
);
const extension = extensions
? await extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: await targetSession.loadExtension(extPath, {
allowFileAccess: true,
});
deps.setYomitanExtension(extension);
return extension;
} catch (err) {
+3 -77
View File
@@ -2,101 +2,27 @@ 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 uses a close-only menu without app quit', () => {
test('yomitan settings window removes default app menu quit action', () => {
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, (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: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, []);
assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
});
test('yomitan settings URL disables the embedded popup preview', () => {
assert.equal(
buildYomitanSettingsUrl('abc123'),
'chrome-extension://abc123/settings.html?popup-preview=false',
'chrome-extension://abc123/settings.html?popup-preview=false&subminer-settings-safe=true',
);
});
+6 -119
View File
@@ -1,8 +1,8 @@
import electron from 'electron';
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import { createLogger } from '../../logger';
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
@@ -13,127 +13,15 @@ export interface OpenYomitanSettingsWindowOptions {
onWindowClosed?: () => void;
}
type YomitanSettingsWindowMenuOwner = Pick<BrowserWindow, 'close' | 'isDestroyed'>;
type HyprlandSessionEnv = {
HYPRLAND_INSTANCE_SIGNATURE?: string;
};
export interface InstallYomitanSettingsCloseButtonOptions {
platform?: NodeJS.Platform;
env?: HyprlandSessionEnv;
}
export function shouldInstallYomitanSettingsCloseButton(
platform: NodeJS.Platform = process.platform,
env: HyprlandSessionEnv = process.env,
): boolean {
return platform === 'linux' && Boolean(env.HYPRLAND_INSTANCE_SIGNATURE);
}
export function buildYomitanSettingsWindowMenuTemplate(
settingsWindow: YomitanSettingsWindowMenuOwner,
): MenuItemConstructorOptions[] {
return [
{
label: 'File',
submenu: [
{
label: 'Close',
accelerator: process.platform === 'darwin' ? 'Command+W' : 'Ctrl+W',
click: () => {
if (!settingsWindow.isDestroyed()) {
settingsWindow.close();
}
},
},
],
},
];
}
export function buildYomitanSettingsCloseButtonScript(): string {
return `
(() => {
const buttonId = 'subminer-yomitan-settings-close';
const styleId = 'subminer-yomitan-settings-close-style';
if (document.getElementById(buttonId)) {
return;
}
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = \`
#\${buttonId} {
position: fixed;
top: 10px;
left: 10px;
z-index: 2147483647;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(24, 24, 24, 0.92);
color: #f2f2f2;
font: 22px/1 system-ui, sans-serif;
cursor: pointer;
}
#\${buttonId}:hover {
background: rgba(54, 54, 54, 0.96);
border-color: rgba(255, 255, 255, 0.5);
}
#\${buttonId}:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
\`;
document.head.appendChild(style);
}
const button = document.createElement('button');
button.id = buttonId;
button.type = 'button';
button.title = 'Close';
button.setAttribute('aria-label', 'Close Yomitan settings');
button.textContent = '\\u00d7';
button.addEventListener('click', () => {
window.close();
});
document.body.appendChild(button);
})();
`;
}
export function installYomitanSettingsCloseButton(
settingsWindow: Pick<BrowserWindow, 'isDestroyed' | 'webContents'>,
options: InstallYomitanSettingsCloseButtonOptions = {},
): void {
if (settingsWindow.isDestroyed()) {
return;
}
if (!shouldInstallYomitanSettingsCloseButton(options.platform, options.env)) {
return;
}
settingsWindow.webContents
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
.catch((error: Error) => {
logger.warn('Failed to install Yomitan settings close button:', error.message);
});
}
export function configureYomitanSettingsWindowChrome(
settingsWindow: Pick<BrowserWindow, 'close' | 'isDestroyed' | 'setAutoHideMenuBar' | 'setMenu'>,
buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) =>
ElectronMenu.buildFromTemplate(template),
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
): void {
settingsWindow.setAutoHideMenuBar(false);
settingsWindow.setMenu(buildMenu(buildYomitanSettingsWindowMenuTemplate(settingsWindow)));
settingsWindow.setAutoHideMenuBar(true);
settingsWindow.setMenu(null);
}
export function buildYomitanSettingsUrl(extensionId: string): string {
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
}
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
@@ -220,7 +108,6 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
});
setTimeout(() => {
+20 -45
View File
@@ -82,7 +82,6 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.update ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
@@ -91,7 +90,6 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.update ||
initialArgs.setup),
),
};
@@ -367,7 +365,6 @@ 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';
@@ -511,21 +508,22 @@ 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,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import { createElectronAppUpdater } 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 { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
import {
showNoUpdateDialog,
showRestartDialog,
showUpdateAvailableDialog,
showUpdateFailedDialog,
} from './main/runtime/update/update-dialogs';
import {
runUpdateCliCommand,
writeUpdateCliCommandResponse,
@@ -849,9 +847,6 @@ const appLogger = {
logInfo: (message: string) => {
logger.info(message);
},
logDebug: (message: string) => {
logger.debug(message);
},
logWarning: (message: string) => {
logger.warn(message);
},
@@ -2907,8 +2902,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
},
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
shouldQuitWhenClosedCompleted: () =>
Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)),
quitApp: () => requestAppQuit(),
clearSetupWindow: () => {
appState.firstRunSetupWindow = null;
@@ -3740,7 +3733,6 @@ 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(),
@@ -3864,9 +3856,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
ensureYomitanExtensionLoaded: async () => {
await ensureYomitanExtensionLoaded();
},
handleFirstRunSetup: async () => {
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
@@ -4624,12 +4613,12 @@ function getFetchForUpdater() {
return globalThis.fetch.bind(globalThis);
}
async function updateLauncherFromSelectedRelease(
async function updateLauncherFromLatestRelease(
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.` };
}
@@ -4653,9 +4642,9 @@ async function updateLauncherFromSelectedRelease(
});
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
logger.warn(`Rofi theme update requires manual command: ${result.command}`);
logger.warn(`Support assets update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`);
logger.warn(`Support assets update skipped: ${result.message ?? result.status}`);
}
}
return launcherResult;
@@ -4668,19 +4657,6 @@ 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,
@@ -4691,16 +4667,16 @@ function getUpdateService() {
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
fetchLatestStableRelease: (channel) =>
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel, release) =>
updateLauncherFromSelectedRelease(launcherPath, channel, release),
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
updateLauncher: (launcherPath, channel) =>
updateLauncherFromLatestRelease(launcherPath, channel),
showNoUpdateDialog: (version) =>
showNoUpdateDialog((options) => dialog.showMessageBox(options), version),
showUpdateAvailableDialog: (version) =>
updateDialogPresenter.showUpdateAvailableDialog(version),
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
showManualUpdateRequiredDialog: (version) =>
updateDialogPresenter.showManualUpdateRequiredDialog(version),
showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version),
showUpdateFailedDialog: (message) =>
showUpdateFailedDialog((options) => dialog.showMessageBox(options), message),
downloadAppUpdate: () => appUpdater.downloadUpdate(),
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)),
quitAndInstall: () => appUpdater.quitAndInstall(),
notifyUpdateAvailable: (version) =>
notifyUpdateAvailable(
@@ -5333,7 +5309,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
@@ -5391,7 +5367,6 @@ 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),
},
-2
View File
@@ -44,7 +44,6 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded'];
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
@@ -110,7 +109,6 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded,
handleFirstRunSetup: params.handleFirstRunSetup,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
+1 -3
View File
@@ -20,7 +20,7 @@ export interface CliCommandRuntimeServiceContext {
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -54,7 +54,6 @@ export interface CliCommandRuntimeServiceContext {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -134,7 +133,6 @@ function createCliCommandDepsFromContext(
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
schedule: context.schedule,
log: context.log,
logDebug: context.logDebug,
warn: context.warn,
error: context.error,
};
-2
View File
@@ -198,7 +198,6 @@ export interface CliCommandRuntimeServiceDepsParams {
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
schedule: CliCommandDepsRuntimeOptions['schedule'];
log: CliCommandDepsRuntimeOptions['log'];
logDebug: CliCommandDepsRuntimeOptions['logDebug'];
warn: CliCommandDepsRuntimeOptions['warn'];
error: CliCommandDepsRuntimeOptions['error'];
}
@@ -378,7 +377,6 @@ export function createCliCommandRuntimeServiceDeps(
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
schedule: params.schedule,
log: params.log,
logDebug: params.logDebug,
warn: params.warn,
error: params.error,
};
@@ -36,9 +36,6 @@ 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');
},
@@ -70,7 +67,6 @@ 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();
@@ -83,7 +79,6 @@ 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',
-1
View File
@@ -27,7 +27,6 @@ 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,
@@ -81,7 +81,6 @@ 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}`),
});
+1 -3
View File
@@ -18,7 +18,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -52,7 +52,6 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
@@ -107,7 +106,6 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
logInfo: deps.logInfo,
logDebug: deps.logDebug,
logWarn: deps.logWarn,
logError: deps.logError,
});
@@ -82,7 +82,6 @@ test('cli command context factory composes main deps and context handlers', () =
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => setTimeout(fn, 0),
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
logError: () => {},
});
@@ -30,8 +30,7 @@ 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: (force?: boolean) =>
calls.push(`open-setup:${force === true ? 'force' : 'default'}`),
openFirstRunSetupWindow: () => calls.push('open-setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
@@ -111,7 +110,6 @@ 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}`),
});
@@ -127,19 +125,11 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();
deps.openFirstRunSetup(true);
deps.openFirstRunSetup();
deps.setVisibleOverlay(true);
deps.printHelp();
await deps.runUpdateCommand({ update: true } as never, 'initial');
assert.deepEqual(calls, [
'osd:hello',
'init-overlay',
'open-setup:force',
'set-visible:true',
'help',
'run-update',
]);
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });
@@ -28,7 +28,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
openFirstRunSetupWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -65,7 +65,6 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
@@ -98,7 +97,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force),
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
@@ -135,7 +134,6 @@ 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),
});
+1 -5
View File
@@ -66,9 +66,6 @@ function createDeps() {
logInfo: (message: string) => {
logs.push(`i:${message}`);
},
logDebug: (message: string) => {
logs.push(`d:${message}`);
},
logWarn: (message: string) => {
logs.push(`w:${message}`);
},
@@ -105,8 +102,7 @@ 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', 'd:debug', 'w:warn', 'e:error']);
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
});
+1 -3
View File
@@ -23,7 +23,7 @@ export type CliCommandContextFactoryDeps = {
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -57,7 +57,6 @@ export type CliCommandContextFactoryDeps = {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
};
@@ -134,7 +133,6 @@ export function createCliCommandContext(
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
log: deps.logInfo,
logDebug: deps.logDebug,
warn: deps.logWarn,
error: deps.logError,
};
@@ -110,21 +110,6 @@ 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',
@@ -159,53 +144,6 @@ 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<string, string>();
const dirs = new Set<string>();
@@ -271,54 +209,6 @@ 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',
+15 -58
View File
@@ -72,23 +72,21 @@ 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.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';
const executable = path.win32.basename(executablePath).toLowerCase();
if (executable === 'winget.exe') return 'winget';
if (executable === 'scoop.cmd') return 'scoop';
if (executable === '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);
@@ -156,8 +154,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options));
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath =
options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const resourcesPath = options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
@@ -209,47 +206,11 @@ export async function resolveLauncherInstallTarget(
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [
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),
: [path.posix.join(homeDir, '.local', 'bin'), path.posix.join(homeDir, 'bin'), '/usr/local/bin'];
const candidates = [...preferred, ...pathDirs].filter((dir, index, all) =>
all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
);
const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options));
if (!selected) {
return {
status: 'not_installable',
@@ -297,14 +258,10 @@ 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,
@@ -11,7 +11,6 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
},
logInfo: () => {},
logDebug: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
@@ -58,7 +58,6 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
getMultiCopyTimeoutMs: () => 0,
schedule: () => 0 as never,
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
logError: () => {},
},
@@ -82,7 +82,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinPreviewAuth: false,
texthooker: false,
texthookerOpenBrowser: false,
update: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
@@ -125,7 +124,6 @@ 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', () => {
@@ -119,7 +119,6 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.update ||
args.help,
);
}
@@ -130,10 +129,6 @@ 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,
+2 -251
View File
@@ -65,9 +65,6 @@ 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', () => {
@@ -308,60 +305,19 @@ 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,
/<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
);
});
test('buildFirstRunSetupHtml disables launcher install when no target is installable', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot({
launcher: {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'No writable PATH directory found.',
},
}),
message: null,
});
assert.match(
html,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install launcher<\/button>/,
);
assert.match(html, /<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/);
});
test('first-run setup window handler focuses existing window', () => {
const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => ({
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}),
});
assert.equal(maybeFocus(), true);
assert.deepEqual(calls, ['show', 'focus']);
assert.deepEqual(calls, ['focus']);
});
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
@@ -410,138 +366,6 @@ test('first-run setup navigation handler swallows stale custom-scheme actions',
assert.deepEqual(calls, ['preventDefault']);
});
test('opening first-run setup shows and focuses window after content loads', async () => {
const calls: string[] = [];
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => false,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'load', 'show', 'focus']);
});
test('opening first-run setup skips rendering if window is destroyed after snapshot', async () => {
const calls: string[] = [];
let destroyed = false;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => destroyed,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => {
calls.push('snapshot');
destroyed = true;
return {
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
};
},
buildSetupHtml: () => {
calls.push('build');
return '<html></html>';
},
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
@@ -613,76 +437,3 @@ test('closing incomplete first-run setup quits app outside background mode', asy
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
});
test('closing completed first-run setup quits app when completion policy allows it', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => undefined,
on: (event: 'closed', callback: () => void) => {
if (event === 'closed') {
closedHandler = callback;
}
},
isDestroyed: () => false,
close: () => calls.push('close-window'),
focus: () => {},
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => undefined,
markSetupCancelled: async () => {
calls.push('cancelled');
},
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => true,
shouldQuitWhenClosedCompleted: () => true,
quitApp: () => {
calls.push('quit');
},
clearSetupWindow: () => {
calls.push('clear');
},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
if (typeof closedHandler !== 'function') {
throw new Error('expected closed handler');
}
closedHandler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'clear', 'quit']);
});
+3 -30
View File
@@ -7,7 +7,6 @@ import type {
type FocusableWindowLike = {
focus: () => void;
show?: () => void;
};
type FirstRunSetupWebContentsLike = {
@@ -125,9 +124,7 @@ function getLauncherTone(
return 'muted';
}
function renderCommandLineLauncherSection(
commandLineLauncher: CommandLineLauncherSnapshot,
): string {
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
if (!commandLineLauncher.supported) {
return '';
}
@@ -157,7 +154,7 @@ function renderCommandLineLauncherSection(
bun.status === 'missing' || bun.status === 'failed'
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
: '';
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
const launcherButtonDisabled = launcher.status === 'failed' ? '' : '';
return `
<section class="setup-section">
@@ -348,20 +345,13 @@ 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 {
@@ -593,7 +583,6 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
return (): boolean => {
const window = deps.getSetupWindow();
if (!window) return false;
window.show?.();
window.focus();
return true;
};
@@ -637,7 +626,6 @@ export function createOpenFirstRunSetupWindowHandler<
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean;
shouldQuitWhenClosedCompleted?: () => boolean;
quitApp: () => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
@@ -651,23 +639,11 @@ export function createOpenFirstRunSetupWindowHandler<
const setupWindow = deps.createSetupWindow();
deps.setSetupWindow(setupWindow);
setupWindow.show?.();
setupWindow.focus();
const render = async (): Promise<void> => {
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({
@@ -706,10 +682,7 @@ export function createOpenFirstRunSetupWindowHandler<
});
}
deps.clearSetupWindow();
if (
(setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) ||
(!setupCompleted && deps.shouldQuitWhenClosedIncomplete())
) {
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
deps.quitApp();
}
});
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, {
width: 720,
height: 860,
width: 560,
height: 640,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
+2 -2
View File
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 720,
height: 860,
width: 560,
height: 640,
title: 'SubMiner Setup',
resizable: false,
minimizable: false,
@@ -10,7 +10,6 @@ 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'),
@@ -31,7 +30,6 @@ 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();
@@ -41,7 +39,6 @@ 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',
@@ -7,7 +7,6 @@ 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),
+1 -8
View File
@@ -20,7 +20,6 @@ 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'),
@@ -37,11 +36,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
reloadConfig();
await Promise.resolve();
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('info:Using config file: /tmp/config.jsonc')));
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.')),
@@ -69,7 +64,6 @@ 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'),
@@ -108,7 +102,6 @@ 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'),
+1 -2
View File
@@ -24,7 +24,6 @@ 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;
@@ -62,7 +61,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
);
}
deps.logDebug(`Using config file: ${result.path}`);
deps.logInfo(`Using config file: ${result.path}`);
if (result.warnings.length > 0) {
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
deps.showDesktopNotification('SubMiner', {
+1 -227
View File
@@ -1,13 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
configureAutoUpdater,
createElectronAppUpdater,
isKnownLinuxPackageManagedAppImage,
isNativeUpdaterSupported,
resolveMacAppBundlePath,
type ElectronAutoUpdaterLike,
} from './app-updater';
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
type UpdaterLogger = {
info: (message: string) => void;
@@ -60,222 +53,3 @@ 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);
});
+1 -132
View File
@@ -1,6 +1,3 @@
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';
@@ -23,9 +20,6 @@ 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;
@@ -35,85 +29,6 @@ export interface ElectronAutoUpdaterLike {
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
}
const updaterErrorListeners = new WeakMap<object, (error: unknown) => 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<string | null> {
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<string | null>;
log?: (message: string) => void;
}): Promise<boolean> {
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 = () => {},
@@ -128,22 +43,6 @@ 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;
}
@@ -153,7 +52,6 @@ export function createElectronAppUpdater(options: {
updater?: ElectronAutoUpdaterLike;
log: (message: string) => void;
getChannel?: () => UpdateChannel;
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
}) {
const getChannel = options.getChannel ?? (() => 'stable' as const);
const updater = configureAutoUpdater(
@@ -161,15 +59,6 @@ export function createElectronAppUpdater(options: {
options.log,
getChannel(),
);
let nativeUpdaterSupported: Promise<boolean> | null = null;
async function getNativeUpdaterSupported(): Promise<boolean> {
if (!options.isNativeUpdaterSupported) return true;
if (nativeUpdaterSupported === null) {
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
}
return nativeUpdaterSupported;
}
return {
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
@@ -180,14 +69,6 @@ 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;
@@ -202,21 +83,9 @@ 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();
},
async quitAndInstall(): Promise<void> {
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;
}
quitAndInstall(): void {
updater.quitAndInstall(false, true);
},
};
@@ -1,140 +0,0 @@
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');
});
-155
View File
@@ -1,155 +0,0 @@
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<StatLike>;
access: (targetPath: string) => Promise<void>;
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
chmod: (targetPath: string, mode: number) => Promise<void>;
rename: (fromPath: string, toPath: string) => Promise<void>;
unlink: (targetPath: string) => Promise<void>;
}
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<string, string>;
appImagePath?: string;
downloadAsset: (url: string) => Promise<Buffer>;
fs?: AppImageUpdateFileSystem;
}): Promise<AppImageUpdateResult> {
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;
}
}
@@ -15,13 +15,13 @@ test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
});
test('buildProtectedLauncherUpdateCommand quotes sudo curl and chmod paths', () => {
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
assert.equal(
buildProtectedLauncherUpdateCommand(
"https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='abc'",
"/usr/local/bin/subminer's launcher",
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
'/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'",
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
);
});
@@ -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 () => {
+1 -5
View File
@@ -50,17 +50,13 @@ export function buildProtectedLauncherUpdateCommand(
assetUrl: string,
launcherPath: string,
): string {
return `sudo curl -fSL ${shellQuote(assetUrl)} -o ${shellQuote(launcherPath)} && sudo chmod +x ${shellQuote(launcherPath)}`;
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${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),
@@ -1,103 +0,0 @@
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 });
}
});
+22 -9
View File
@@ -29,6 +29,12 @@ 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'];
@@ -40,10 +46,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}/themes`,
`sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`,
`sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`,
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
].join(' && ');
}
@@ -70,15 +76,12 @@ export async function updateSupportAssetsFromRelease(options: {
homeDir?: string;
xdgDataHome?: string;
}): Promise<SupportAssetsUpdateResult[]> {
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 rofi theme asset.' }];
if (!asset) return [{ status: 'missing-asset', message: 'Release has no support assets.' }];
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
if (!expectedSha256) {
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }];
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
}
const dataDirs = detectSupportAssetDataDirs({
@@ -88,11 +91,12 @@ 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 (hasTheme) existingDataDirs.push(dataDir);
if (hasPlugin || hasTheme) existingDataDirs.push(dataDir);
}
if (existingDataDirs.length === 0) {
return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }];
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
}
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
@@ -135,8 +139,17 @@ 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,
@@ -1,64 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createUpdateDialogPresenter,
showManualUpdateRequiredDialog,
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)']);
});
test('manual update required dialog explains that automatic install is unavailable', async () => {
let shown:
| {
type?: string;
title?: string;
message: string;
detail?: string;
buttons?: string[];
}
| undefined;
const showMessageBox: ShowMessageBox = async (options) => {
shown = options;
return { response: 0 };
};
await showManualUpdateRequiredDialog(showMessageBox, '0.15.0-beta.1');
assert.equal(shown?.type, 'warning');
assert.equal(shown?.message, 'Manual install required');
assert.match(shown?.detail ?? '', /SubMiner v0\.15\.0-beta\.1 is available/);
assert.match(shown?.detail ?? '', /cannot install app updates automatically/);
});
-42
View File
@@ -15,12 +15,6 @@ export type ShowMessageBox = (options: {
cancelId?: number;
}) => Promise<MessageBoxResultLike>;
export interface UpdateDialogPresenterDeps {
showMessageBox: ShowMessageBox;
focusApp?: () => void;
platform?: NodeJS.Platform;
}
export async function showNoUpdateDialog(
showMessageBox: ShowMessageBox,
version: string,
@@ -33,29 +27,6 @@ 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),
showManualUpdateRequiredDialog: (version: string) =>
showManualUpdateRequiredDialog(showFocusedMessageBox, version),
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
};
}
export async function showUpdateAvailableDialog(
showMessageBox: ShowMessageBox,
version: string,
@@ -83,19 +54,6 @@ export async function showRestartDialog(showMessageBox: ShowMessageBox): Promise
return result.response === 0 ? 'restart' : 'later';
}
export async function showManualUpdateRequiredDialog(
showMessageBox: ShowMessageBox,
version: string,
): Promise<void> {
await showMessageBox({
type: 'warning',
title: 'SubMiner Updates',
message: 'Manual install required',
detail: `SubMiner v${version} is available, but this build cannot install app updates automatically. Download and install the latest release, then reopen SubMiner.`,
buttons: ['Close'],
});
}
export async function showUpdateFailedDialog(
showMessageBox: ShowMessageBox,
message: string,
@@ -47,24 +47,3 @@ 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']);
});
@@ -20,8 +20,7 @@ export async function notifyUpdateAvailable(
try {
await deps.showOsdNotification(message);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
deps.log(`Update OSD notification failed: ${reason}`);
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
}
}
}
+1 -120
View File
@@ -37,9 +37,6 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
showUpdateFailedDialog: async (message) => {
calls.push(`failed:${message}`);
},
showManualUpdateRequiredDialog: async (version) => {
calls.push(`manual-install:${version}`);
},
downloadAppUpdate: async () => {
calls.push('download');
},
@@ -47,9 +44,7 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
calls.push('restart-dialog');
return 'later';
},
quitAndInstall: () => {
calls.push('quit-install');
},
quitAndInstall: () => calls.push('quit-install'),
notifyUpdateAvailable: async (version) => {
calls.push(`notify:${version}`);
},
@@ -95,69 +90,6 @@ 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', 'manual-install:0.15.0']);
});
test('manual update check does not prompt restart when only launcher updates', 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: 'updated' };
},
showRestartDialog: async () => {
calls.push('restart-dialog');
return 'restart';
},
quitAndInstall: () => {
calls.push('quit-install');
},
});
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',
'manual-install:0.15.0',
]);
});
test('automatic update check skips inside configured interval', async () => {
const { deps, calls, setState } = createDeps();
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
@@ -209,57 +141,6 @@ 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: () => ({
+8 -20
View File
@@ -43,15 +43,13 @@ export interface UpdateServiceDeps {
updateLauncher: (
launcherPath?: string,
channel?: UpdateChannel,
release?: GitHubRelease | null,
) => Promise<{ status: string; command?: string }>;
showNoUpdateDialog: (version: string) => Promise<void>;
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
showUpdateFailedDialog: (message: string) => Promise<void>;
showManualUpdateRequiredDialog: (version: string) => Promise<void>;
downloadAppUpdate: () => Promise<void>;
showRestartDialog: () => Promise<'restart' | 'later'>;
quitAndInstall: () => void | Promise<void>;
quitAndInstall: () => void;
notifyUpdateAvailable: (version: string) => Promise<void>;
log: (message: string) => void;
setTimeout?: (callback: () => void, delayMs: number) => unknown;
@@ -98,7 +96,7 @@ function summarizeError(error: unknown): string {
}
export function createUpdateService(deps: UpdateServiceDeps) {
const inFlightBySource = new Map<UpdateCheckSource, Promise<UpdateCheckResult>>();
let inFlight: Promise<UpdateCheckResult> | null = null;
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const now = deps.now();
@@ -159,25 +157,17 @@ export function createUpdateService(deps: UpdateServiceDeps) {
return { status: 'update-available', version: latest.version };
}
const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false;
let appUpdateApplied = false;
if (canInstallAppUpdate) {
if (appUpdate.available && appUpdate.canUpdate !== false) {
await deps.downloadAppUpdate();
appUpdateApplied = true;
}
const launcherResult = await deps.updateLauncher(request.launcherPath, channel, release);
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
if (launcherResult.status === 'protected' && launcherResult.command) {
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
}
if (!appUpdateApplied) {
await deps.showManualUpdateRequiredDialog(latest.version);
return { status: 'update-available', version: latest.version };
}
const restartChoice = await deps.showRestartDialog();
if (restartChoice === 'restart') {
await deps.quitAndInstall();
deps.quitAndInstall();
}
return { status: 'updated', version: latest.version };
} catch (error) {
@@ -193,13 +183,11 @@ export function createUpdateService(deps: UpdateServiceDeps) {
return {
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const inFlight = inFlightBySource.get(request.source);
if (inFlight) return inFlight;
const nextInFlight = runCheck(request).finally(() => {
inFlightBySource.delete(request.source);
inFlight = runCheck(request).finally(() => {
inFlight = null;
});
inFlightBySource.set(request.source, nextInFlight);
return nextInFlight;
return inFlight;
},
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
const setTimeoutFn = deps.setTimeout ?? setTimeout;
@@ -119,9 +119,9 @@ test('yomitan opener uses loaded extension from app state without calling loader
assert.equal(forwardedExtension, appStateExtension);
});
test('yomitan opener lazy-loads extension when app state is empty and no load is in flight', async () => {
test('yomitan opener warns instead of starting a settings-triggered load when extension is not ready', async () => {
let ensureCalled = false;
let forwardedExtension: { id: string } | null = null;
const logs: string[] = [];
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => {
ensureCalled = true;
@@ -129,19 +129,19 @@ test('yomitan opener lazy-loads extension when app state is empty and no load is
},
getYomitanExtension: () => null,
getYomitanExtensionLoadInFlight: () => null,
openYomitanSettingsWindow: ({ yomitanExt }) => {
forwardedExtension = yomitanExt as { id: string };
openYomitanSettingsWindow: () => {
throw new Error('should not open before extension is ready');
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
logWarn: (message) => logs.push(message),
logError: () => logs.push('error'),
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.equal(ensureCalled, true);
assert.deepEqual(forwardedExtension, { id: 'ext' });
assert.equal(ensureCalled, false);
assert.deepEqual(logs, ['Unable to open Yomitan settings: extension is not loaded yet.']);
});
+3 -6
View File
@@ -22,7 +22,7 @@ export function createOpenYomitanSettingsHandler(deps: {
return (): void => {
void (async () => {
if (deps.getYomitanExtension) {
let loadedExtension = deps.getYomitanExtension();
const loadedExtension = deps.getYomitanExtension();
if (!loadedExtension) {
if (deps.getYomitanExtensionLoadInFlight?.()) {
deps.logWarn(
@@ -30,11 +30,8 @@ export function createOpenYomitanSettingsHandler(deps: {
);
return;
}
loadedExtension = await deps.ensureYomitanExtensionLoaded();
if (!loadedExtension) {
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
return;
}
deps.logWarn('Unable to open Yomitan settings: extension is not loaded yet.');
return;
}
const yomitanSession = deps.getYomitanSession?.() ?? null;
+3 -4
View File
@@ -27,10 +27,9 @@ test('package scripts expose prerelease notes generation separately from stable
);
});
test('prerelease workflow uses committed prerelease notes and never calls claude in CI', () => {
assert.match(prereleaseWorkflow, /--notes-file release\/prerelease-notes\.md/);
assert.doesNotMatch(prereleaseWorkflow, /run: bun run changelog:prerelease-notes/);
assert.doesNotMatch(prereleaseWorkflow, /run: bun run changelog:build/);
test('prerelease workflow generates prerelease notes from pending fragments', () => {
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
});
test('prerelease workflow includes the environment suite in the gate sequence', () => {
-1
View File
@@ -164,7 +164,6 @@ 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', () => {