mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat: add auto update support (#65)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -217,12 +217,13 @@ Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasud
|
||||
Also download the `subminer` launcher (recommended):
|
||||
|
||||
```bash
|
||||
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \
|
||||
&& sudo chmod +x /usr/local/bin/subminer
|
||||
mkdir -p ~/.local/bin
|
||||
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
|
||||
&& chmod +x ~/.local/bin/subminer
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory.
|
||||
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type: added
|
||||
area: updater
|
||||
|
||||
- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher/support asset updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing.
|
||||
- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher updates, Linux rofi theme updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: updater
|
||||
|
||||
- Made Linux `subminer -u` perform release updates from the launcher, independent of any running tray app instance, while reporting `up to date` without downloading assets when the latest release is not newer.
|
||||
- Limited support asset updates to the Linux rofi theme.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Fixed Linux first-run launcher installs by building the packaged launcher with a valid Bun shebang.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: updater
|
||||
|
||||
- Stopped Linux tray update checks from invoking the native Electron updater, using GitHub release metadata/assets instead so checks do not crash the tray app.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: setup
|
||||
|
||||
- First-run setup now recognizes installed macOS launchers in Homebrew or user PATH dirs, while manual setup installs avoid Homebrew-owned directories.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: updater
|
||||
|
||||
- Bring macOS update dialogs to the front when `subminer --update` is run from the launcher.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: build
|
||||
|
||||
- Fixed one-shot `make clean build install` flows so install picks up the AppImage built earlier in the same make invocation.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: updates
|
||||
|
||||
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Fixed `subminer app --setup` so it opens the setup flow when SubMiner is already running in the background.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: setup
|
||||
|
||||
- Quit standalone setup app launches after first-run setup finishes, returning the terminal instead of leaving the app process open.
|
||||
@@ -3,9 +3,8 @@ area: tray
|
||||
|
||||
- Kept the tray app running when closing tray-launched Yomitan settings.
|
||||
- Kept tray-launched Yomitan settings loading from blocking other tray actions.
|
||||
- Removed the default native app menu from Yomitan settings so File > Quit cannot put the tray app into a stuck quit state.
|
||||
- Replaced the default native Yomitan settings menu with a close-only menu so closing settings does not quit the tray app.
|
||||
- Added an in-page close button for Yomitan settings on Hyprland, where native window controls are not available.
|
||||
- Disabled Yomitan's embedded popup preview in the tray-launched settings window to avoid renderer hangs during normal sidebar navigation.
|
||||
- Skipped heavy Yomitan settings startup preview, storage, dictionary, and Anki controllers when launched from SubMiner to avoid renderer hangs with large dictionary databases.
|
||||
- Cached Yomitan settings dictionary metadata after explicit loads to avoid repeated large IndexedDB reads.
|
||||
- Serialized copied Yomitan extension refreshes so startup cannot race itself and leave extension loading in an error state.
|
||||
- Fixed tray-launched session help focus handling so the modal can close without mpv running.
|
||||
|
||||
@@ -155,11 +155,11 @@ chmod +x ~/.local/bin/SubMiner.AppImage
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
||||
chmod +x ~/.local/bin/subminer
|
||||
|
||||
# Download launcher support assets used for bundled runtime plugin injection
|
||||
# Download the optional Linux rofi theme
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||
mkdir -p ~/.local/share/SubMiner/plugin/subminer
|
||||
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
|
||||
mkdir -p ~/.local/share/SubMiner/themes
|
||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||
```
|
||||
|
||||
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
|
||||
@@ -174,7 +174,9 @@ subminer -u
|
||||
subminer --update
|
||||
```
|
||||
|
||||
SubMiner verifies launcher/support asset downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
|
||||
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
|
||||
|
||||
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
|
||||
|
||||
### From Source
|
||||
|
||||
@@ -240,7 +242,7 @@ subminer -u
|
||||
subminer --update
|
||||
```
|
||||
|
||||
SubMiner verifies launcher/support asset downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
|
||||
SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
|
||||
|
||||
::: warning Bun required for the launcher
|
||||
The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`.
|
||||
@@ -269,7 +271,7 @@ Build and install the launcher alongside the app:
|
||||
make install-macos
|
||||
```
|
||||
|
||||
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle and rofi theme. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
|
||||
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
|
||||
|
||||
```bash
|
||||
sudo make install-macos PREFIX=/usr/local
|
||||
|
||||
@@ -109,6 +109,8 @@ 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
|
||||
|
||||
@@ -241,36 +241,36 @@ test('dictionary command returns after app handoff starts', () => {
|
||||
assert.equal(handled, true);
|
||||
});
|
||||
|
||||
test('update command forwards launcher path and waits for response', async () => {
|
||||
test('update command runs direct Linux release update without launching Electron', async () => {
|
||||
const context = createContext();
|
||||
context.args.update = true;
|
||||
const forwarded: string[][] = [];
|
||||
const responses: string[] = [];
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = await runUpdateCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-update-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandCaptureOutput: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
runAppCommandCaptureOutput: () => {
|
||||
throw new Error('unexpected Electron launch');
|
||||
},
|
||||
waitForUpdateResponse: async (responsePath) => {
|
||||
responses.push(responsePath);
|
||||
return { ok: true, status: 'up-to-date', version: '0.15.0' };
|
||||
runDirectReleaseUpdate: async (request) => {
|
||||
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
|
||||
return {
|
||||
appImage: { status: 'not-found' },
|
||||
launcher: { status: 'updated' },
|
||||
supportAssets: [{ status: 'skipped' }],
|
||||
};
|
||||
},
|
||||
readMainConfig: () => null,
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--update',
|
||||
'--update-launcher-path',
|
||||
'/tmp/subminer',
|
||||
'--update-response-path',
|
||||
'/tmp/subminer-update-test/response.json',
|
||||
],
|
||||
assert.deepEqual(calls, [
|
||||
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
||||
'info:AppImage update: not-found',
|
||||
'info:Launcher update: updated',
|
||||
'info:Rofi theme update: skipped',
|
||||
]);
|
||||
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
|
||||
});
|
||||
|
||||
test('stats command launches attached app command with response path', async () => {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runUpdateCommand } from './update-command';
|
||||
import type { LauncherCommandContext } from './context';
|
||||
|
||||
function makeContext(overrides: Partial<LauncherCommandContext> = {}): 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',
|
||||
]);
|
||||
});
|
||||
@@ -1,10 +1,27 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import packageJson from '../../package.json';
|
||||
import { runAppCommandCaptureOutput } from '../mpv.js';
|
||||
import { log as launcherLog } from '../log.js';
|
||||
import { nowMs } from '../time.js';
|
||||
import { sleep } from '../util.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { readLauncherMainConfigObject } from '../config/shared-config-reader.js';
|
||||
import type { UpdateChannel } from '../../src/types/config.js';
|
||||
import { updateAppImageFromRelease } from '../../src/main/runtime/update/appimage-updater.js';
|
||||
import { updateLauncherFromRelease } from '../../src/main/runtime/update/launcher-updater.js';
|
||||
import {
|
||||
compareSemverLike,
|
||||
fetchLatestStableRelease,
|
||||
fetchReleaseAssetBuffer,
|
||||
fetchReleaseAssetText,
|
||||
findReleaseAsset,
|
||||
parseReleaseVersion,
|
||||
parseSha256Sums,
|
||||
type FetchLike,
|
||||
} from '../../src/main/runtime/update/release-assets.js';
|
||||
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
|
||||
|
||||
type UpdateCommandResponse = {
|
||||
ok: boolean;
|
||||
@@ -13,6 +30,18 @@ type UpdateCommandResponse = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type DirectReleaseUpdateRequest = {
|
||||
appPath: string;
|
||||
launcherPath: string;
|
||||
channel: UpdateChannel;
|
||||
};
|
||||
|
||||
type DirectReleaseUpdateResult = {
|
||||
appImage: { status: string; command?: string; message?: string };
|
||||
launcher: { status: string; command?: string; message?: string };
|
||||
supportAssets: Array<{ status: string; command?: string; message?: string }>;
|
||||
};
|
||||
|
||||
type UpdateCommandDeps = {
|
||||
createTempDir: (prefix: string) => string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
@@ -22,9 +51,95 @@ type UpdateCommandDeps = {
|
||||
) => { status: number; stdout: string; stderr: string; error?: Error };
|
||||
waitForUpdateResponse: (responsePath: string) => Promise<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)),
|
||||
@@ -47,6 +162,9 @@ const defaultDeps: UpdateCommandDeps = {
|
||||
removeDir: (targetPath) => {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
},
|
||||
runDirectReleaseUpdate,
|
||||
readMainConfig: readLauncherMainConfigObject,
|
||||
log: launcherLog,
|
||||
};
|
||||
|
||||
export async function runUpdateCommand(
|
||||
@@ -59,6 +177,21 @@ export async function runUpdateCommand(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.processAdapter.platform() === 'linux') {
|
||||
const result = await resolvedDeps.runDirectReleaseUpdate({
|
||||
appPath,
|
||||
launcherPath: scriptPath,
|
||||
channel: readUpdateChannel(resolvedDeps.readMainConfig()),
|
||||
});
|
||||
const logLevel = args.logLevel ?? 'warn';
|
||||
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
|
||||
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
|
||||
for (const supportResult of result.supportAssets) {
|
||||
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const tempDir = resolvedDeps.createTempDir('subminer-update-');
|
||||
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
|
||||
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
|
||||
@@ -47,8 +47,8 @@
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
|
||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||
|
||||
@@ -130,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openFirstRunSetup: () => {
|
||||
calls.push('openFirstRunSetup');
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
calls.push(`setVisibleOverlayVisible:${visible}`);
|
||||
@@ -247,6 +247,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
log: (message) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
logDebug: (message) => {
|
||||
calls.push(`debug:${message}`);
|
||||
},
|
||||
warn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
@@ -358,13 +361,23 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand forces setup open for second-instance setup command', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ setup: true }), 'second-instance', deps);
|
||||
|
||||
assert.ok(calls.includes('openFirstRunSetup:force'));
|
||||
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
|
||||
});
|
||||
|
||||
test('handleCliCommand opens first-run setup window for --setup', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
|
||||
|
||||
assert.ok(calls.includes('openFirstRunSetup'));
|
||||
assert.ok(calls.includes('log:Opened first-run setup flow.'));
|
||||
assert.ok(calls.includes('openFirstRunSetup:force'));
|
||||
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
|
||||
assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
showMpvOsd: (text: string) => void;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, err: unknown) => void;
|
||||
}
|
||||
@@ -157,7 +158,7 @@ interface MiningCliRuntime {
|
||||
}
|
||||
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -211,6 +212,7 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => unknown;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, err: unknown) => void;
|
||||
}
|
||||
@@ -286,6 +288,7 @@ export function createCliCommandDepsRuntime(
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
showMpvOsd: options.mpv.showOsd,
|
||||
log: options.log,
|
||||
logDebug: options.logDebug,
|
||||
warn: options.warn,
|
||||
error: options.error,
|
||||
};
|
||||
@@ -378,8 +381,8 @@ export function handleCliCommand(
|
||||
} else if (args.togglePrimarySubtitleBar) {
|
||||
deps.togglePrimarySubtitleBar();
|
||||
} else if (args.setup) {
|
||||
deps.openFirstRunSetup();
|
||||
deps.log('Opened first-run setup flow.');
|
||||
deps.openFirstRunSetup(true);
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.settings) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
|
||||
@@ -358,3 +358,89 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
|
||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('bootstrap');
|
||||
},
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('load-subtitle-position');
|
||||
},
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolve-keybindings');
|
||||
},
|
||||
createMpvClient: () => {
|
||||
calls.push('create-mpv');
|
||||
},
|
||||
reloadConfig: () => {
|
||||
calls.push('reload-config');
|
||||
},
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: false },
|
||||
annotationWebsocket: { enabled: false },
|
||||
texthooker: { launchAtStartup: false },
|
||||
}),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {
|
||||
calls.push('set-log-level');
|
||||
},
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('init-runtime-options');
|
||||
},
|
||||
setSecondarySubMode: () => {
|
||||
calls.push('set-secondary-sub-mode');
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 0,
|
||||
defaultAnnotationWebsocketPort: 0,
|
||||
defaultTexthookerPort: 0,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('subtitle-timing');
|
||||
},
|
||||
createImmersionTracker: () => {
|
||||
calls.push('immersion');
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan-direct');
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('load-yomitan-guarded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('first-run');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {
|
||||
calls.push('visible-overlay');
|
||||
},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('init-overlay');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handle-initial-args');
|
||||
},
|
||||
shouldUseMinimalStartup: () => false,
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.equal(calls.includes('load-yomitan-direct'), false);
|
||||
assert.equal(calls.includes('load-yomitan-guarded'), true);
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface AppReadyRuntimeDeps {
|
||||
createImmersionTracker?: () => void;
|
||||
startJellyfinRemoteSession?: () => Promise<void>;
|
||||
loadYomitanExtension: () => Promise<void>;
|
||||
ensureYomitanExtensionLoaded?: () => Promise<void>;
|
||||
handleFirstRunSetup: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||
startBackgroundWarmups: () => void;
|
||||
@@ -215,6 +216,8 @@ 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();
|
||||
@@ -224,7 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
} else {
|
||||
deps.createMpvClient();
|
||||
deps.createSubtitleTimingTracker();
|
||||
await deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.initializeOverlayRuntime();
|
||||
deps.handleInitialArgs();
|
||||
}
|
||||
@@ -237,18 +240,10 @@ 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 deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
@@ -319,12 +314,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 deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.initializeOverlayRuntime();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
await deps.loadYomitanExtension();
|
||||
await ensureYomitanExtensionReady();
|
||||
}
|
||||
|
||||
await deps.handleFirstRunSetup();
|
||||
|
||||
@@ -9,6 +9,7 @@ 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));
|
||||
@@ -19,6 +20,66 @@ 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');
|
||||
@@ -185,10 +246,7 @@ test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied e
|
||||
assert.equal(results[0].copied, true);
|
||||
assert.equal(results[1].copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
|
||||
'utf8',
|
||||
),
|
||||
fs.readFileSync(path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'), 'utf8'),
|
||||
'new settings code',
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -29,6 +29,85 @@ 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> {
|
||||
@@ -79,9 +158,20 @@ export async function loadYomitanExtension(
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
|
||||
let extensionCopy: { copied: boolean; targetDir: string };
|
||||
try {
|
||||
extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy Yomitan extension:', {
|
||||
error,
|
||||
extensionPath: extPath,
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
clearRuntimeState();
|
||||
return null;
|
||||
}
|
||||
if (extensionCopy.copied) {
|
||||
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
}
|
||||
extPath = extensionCopy.targetDir;
|
||||
}
|
||||
@@ -91,13 +181,15 @@ export async function loadYomitanExtension(
|
||||
|
||||
try {
|
||||
const extensions = targetSession.extensions;
|
||||
const extension = extensions
|
||||
? await extensions.loadExtension(extPath, {
|
||||
const extension = await withSuppressedYomitanExtensionWarnings(() =>
|
||||
extensions
|
||||
? extensions.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
})
|
||||
: await targetSession.loadExtension(extPath, {
|
||||
: targetSession.loadExtension(extPath, {
|
||||
allowFileAccess: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
deps.setYomitanExtension(extension);
|
||||
return extension;
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,27 +2,101 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildYomitanSettingsCloseButtonScript,
|
||||
buildYomitanSettingsWindowMenuTemplate,
|
||||
buildYomitanSettingsUrl,
|
||||
configureYomitanSettingsWindowChrome,
|
||||
destroyYomitanSettingsWindow,
|
||||
installYomitanSettingsCloseButton,
|
||||
showYomitanSettingsWindow,
|
||||
shouldInstallYomitanSettingsCloseButton,
|
||||
} from './yomitan-settings';
|
||||
|
||||
test('yomitan settings window removes default app menu quit action', () => {
|
||||
test('yomitan settings window uses a close-only menu without app quit', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
configureYomitanSettingsWindowChrome({
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close'),
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never);
|
||||
} as never, (template) => {
|
||||
calls.push(`menu-label:${template[0]?.label ?? ''}`);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
const closeItem = submenu[0];
|
||||
assert.equal(closeItem?.label, 'Close');
|
||||
assert.notEqual(closeItem?.role, 'quit');
|
||||
closeItem?.click?.({} as never, {} as never, {} as never);
|
||||
return { id: 'settings-menu' } as never;
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
|
||||
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
|
||||
});
|
||||
|
||||
test('yomitan settings close menu skips destroyed windows', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildYomitanSettingsWindowMenuTemplate({
|
||||
isDestroyed: () => true,
|
||||
close: () => calls.push('close'),
|
||||
} as never);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
submenu[0]?.click?.({} as never, {} as never, {} as never);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('yomitan settings close button script installs an idempotent in-page close control', () => {
|
||||
const script = buildYomitanSettingsCloseButtonScript();
|
||||
|
||||
assert.match(script, /subminer-yomitan-settings-close/);
|
||||
assert.match(script, /aria-label', 'Close Yomitan settings'/);
|
||||
assert.match(script, /window\.close\(\)/);
|
||||
assert.match(script, /getElementById\(buttonId\)/);
|
||||
});
|
||||
|
||||
test('yomitan settings close button only installs for Hyprland sessions', () => {
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: '' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInstallYomitanSettingsCloseButton('win32', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('yomitan settings close button injection skips non-Hyprland windows', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
installYomitanSettingsCloseButton(
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: () => {
|
||||
calls.push('execute');
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{ platform: 'darwin', env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' } },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('yomitan settings URL disables the embedded popup preview', () => {
|
||||
assert.equal(
|
||||
buildYomitanSettingsUrl('abc123'),
|
||||
'chrome-extension://abc123/settings.html?popup-preview=false&subminer-settings-safe=true',
|
||||
'chrome-extension://abc123/settings.html?popup-preview=false',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
|
||||
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
|
||||
const logger = createLogger('main:yomitan-settings');
|
||||
|
||||
export interface OpenYomitanSettingsWindowOptions {
|
||||
@@ -13,15 +13,127 @@ export interface OpenYomitanSettingsWindowOptions {
|
||||
onWindowClosed?: () => void;
|
||||
}
|
||||
|
||||
export function configureYomitanSettingsWindowChrome(
|
||||
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
|
||||
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 {
|
||||
settingsWindow.setAutoHideMenuBar(true);
|
||||
settingsWindow.setMenu(null);
|
||||
if (settingsWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldInstallYomitanSettingsCloseButton(options.platform, options.env)) {
|
||||
return;
|
||||
}
|
||||
settingsWindow.webContents
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
|
||||
.catch((error: Error) => {
|
||||
logger.warn('Failed to install Yomitan settings close button:', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
export function configureYomitanSettingsWindowChrome(
|
||||
settingsWindow: Pick<BrowserWindow, 'close' | 'isDestroyed' | 'setAutoHideMenuBar' | 'setMenu'>,
|
||||
buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) =>
|
||||
ElectronMenu.buildFromTemplate(template),
|
||||
): void {
|
||||
settingsWindow.setAutoHideMenuBar(false);
|
||||
settingsWindow.setMenu(buildMenu(buildYomitanSettingsWindowMenuTemplate(settingsWindow)));
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsUrl(extensionId: string): string {
|
||||
return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
|
||||
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
|
||||
}
|
||||
|
||||
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
|
||||
@@ -108,6 +220,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
|
||||
settingsWindow.webContents.on('did-finish-load', () => {
|
||||
logger.info('Settings page loaded successfully');
|
||||
installYomitanSettingsCloseButton(settingsWindow);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
+43
-20
@@ -82,6 +82,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
initialArgs?.update ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
@@ -90,6 +91,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.update ||
|
||||
initialArgs.setup),
|
||||
),
|
||||
};
|
||||
@@ -365,6 +367,7 @@ import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
getFirstRunSetupCompletionMessage,
|
||||
isStandaloneFirstRunSetupCommand,
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
} from './main/runtime/first-run-setup-service';
|
||||
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||
@@ -508,22 +511,21 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import { createElectronAppUpdater } from './main/runtime/update/app-updater';
|
||||
import {
|
||||
createElectronAppUpdater,
|
||||
isNativeUpdaterSupported,
|
||||
} from './main/runtime/update/app-updater';
|
||||
import {
|
||||
fetchLatestStableRelease,
|
||||
fetchReleaseAssetBuffer,
|
||||
fetchReleaseAssetText,
|
||||
findReleaseAsset,
|
||||
parseSha256Sums,
|
||||
type GitHubRelease,
|
||||
} from './main/runtime/update/release-assets';
|
||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||
import {
|
||||
showNoUpdateDialog,
|
||||
showRestartDialog,
|
||||
showUpdateAvailableDialog,
|
||||
showUpdateFailedDialog,
|
||||
} from './main/runtime/update/update-dialogs';
|
||||
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
||||
import {
|
||||
runUpdateCliCommand,
|
||||
writeUpdateCliCommandResponse,
|
||||
@@ -847,6 +849,9 @@ const appLogger = {
|
||||
logInfo: (message: string) => {
|
||||
logger.info(message);
|
||||
},
|
||||
logDebug: (message: string) => {
|
||||
logger.debug(message);
|
||||
},
|
||||
logWarning: (message: string) => {
|
||||
logger.warn(message);
|
||||
},
|
||||
@@ -2902,6 +2907,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
},
|
||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
||||
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
|
||||
shouldQuitWhenClosedCompleted: () =>
|
||||
Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)),
|
||||
quitApp: () => requestAppQuit(),
|
||||
clearSetupWindow: () => {
|
||||
appState.firstRunSetupWindow = null;
|
||||
@@ -3733,6 +3740,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
reloadConfigMainDeps: {
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
logInfo: (message) => appLogger.logInfo(message),
|
||||
logDebug: (message) => appLogger.logDebug(message),
|
||||
logWarning: (message) => appLogger.logWarning(message),
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => configHotReloadRuntime.start(),
|
||||
@@ -3856,6 +3864,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
loadYomitanExtension: async () => {
|
||||
await loadYomitanExtension();
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
|
||||
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
|
||||
@@ -4613,12 +4624,12 @@ function getFetchForUpdater() {
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
async function updateLauncherFromLatestRelease(
|
||||
async function updateLauncherFromSelectedRelease(
|
||||
launcherPath?: string,
|
||||
channel: UpdateChannel = getResolvedConfig().updates.channel,
|
||||
release: GitHubRelease | null = null,
|
||||
) {
|
||||
const fetchForUpdater = getFetchForUpdater();
|
||||
const release = await fetchLatestStableRelease({ fetch: fetchForUpdater, channel });
|
||||
if (!release) {
|
||||
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
|
||||
}
|
||||
@@ -4642,9 +4653,9 @@ async function updateLauncherFromLatestRelease(
|
||||
});
|
||||
for (const result of supportResults) {
|
||||
if (result.status === 'protected' && result.command) {
|
||||
logger.warn(`Support assets update requires manual command: ${result.command}`);
|
||||
logger.warn(`Rofi theme update requires manual command: ${result.command}`);
|
||||
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
|
||||
logger.warn(`Support assets update skipped: ${result.message ?? result.status}`);
|
||||
logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`);
|
||||
}
|
||||
}
|
||||
return launcherResult;
|
||||
@@ -4657,6 +4668,19 @@ function getUpdateService() {
|
||||
isPackaged: app.isPackaged,
|
||||
log: (message) => logger.info(message),
|
||||
getChannel: () => getResolvedConfig().updates.channel,
|
||||
isNativeUpdaterSupported: () =>
|
||||
isNativeUpdaterSupported({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
execPath: process.execPath,
|
||||
env: process.env,
|
||||
log: (message) => logger.warn(message),
|
||||
}),
|
||||
});
|
||||
const updateDialogPresenter = createUpdateDialogPresenter({
|
||||
platform: process.platform,
|
||||
focusApp: () => app.focus({ steal: true }),
|
||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
getConfig: () => getResolvedConfig().updates,
|
||||
@@ -4667,16 +4691,14 @@ function getUpdateService() {
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
fetchLatestStableRelease: (channel) =>
|
||||
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||
updateLauncher: (launcherPath, channel) =>
|
||||
updateLauncherFromLatestRelease(launcherPath, channel),
|
||||
showNoUpdateDialog: (version) =>
|
||||
showNoUpdateDialog((options) => dialog.showMessageBox(options), version),
|
||||
updateLauncher: (launcherPath, channel, release) =>
|
||||
updateLauncherFromSelectedRelease(launcherPath, channel, release),
|
||||
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
|
||||
showUpdateAvailableDialog: (version) =>
|
||||
showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version),
|
||||
showUpdateFailedDialog: (message) =>
|
||||
showUpdateFailedDialog((options) => dialog.showMessageBox(options), message),
|
||||
updateDialogPresenter.showUpdateAvailableDialog(version),
|
||||
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
||||
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||
showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)),
|
||||
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
||||
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||
notifyUpdateAvailable: (version) =>
|
||||
notifyUpdateAvailable(
|
||||
@@ -5309,7 +5331,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
|
||||
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
|
||||
@@ -5367,6 +5389,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
||||
logInfo: (message: string) => logger.info(message),
|
||||
logDebug: (message: string) => logger.debug(message),
|
||||
logWarn: (message: string) => logger.warn(message),
|
||||
logError: (message: string, err: unknown) => logger.error(message, err),
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
|
||||
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
|
||||
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
|
||||
ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded'];
|
||||
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
|
||||
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
|
||||
@@ -109,6 +110,7 @@ export function createAppReadyRuntimeDeps(
|
||||
createImmersionTracker: params.createImmersionTracker,
|
||||
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
|
||||
loadYomitanExtension: params.loadYomitanExtension,
|
||||
ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded,
|
||||
handleFirstRunSetup: params.handleFirstRunSetup,
|
||||
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: params.startBackgroundWarmups,
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -54,6 +54,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, err: unknown) => void;
|
||||
}
|
||||
@@ -133,6 +134,7 @@ function createCliCommandDepsFromContext(
|
||||
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
|
||||
schedule: context.schedule,
|
||||
log: context.log,
|
||||
logDebug: context.logDebug,
|
||||
warn: context.warn,
|
||||
error: context.error,
|
||||
};
|
||||
|
||||
@@ -198,6 +198,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
|
||||
schedule: CliCommandDepsRuntimeOptions['schedule'];
|
||||
log: CliCommandDepsRuntimeOptions['log'];
|
||||
logDebug: CliCommandDepsRuntimeOptions['logDebug'];
|
||||
warn: CliCommandDepsRuntimeOptions['warn'];
|
||||
error: CliCommandDepsRuntimeOptions['error'];
|
||||
}
|
||||
@@ -377,6 +378,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
|
||||
schedule: params.schedule,
|
||||
log: params.log,
|
||||
logDebug: params.logDebug,
|
||||
warn: params.warn,
|
||||
error: params.error,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensure-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handle-first-run-setup');
|
||||
},
|
||||
@@ -67,6 +70,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
onReady.createMpvClient();
|
||||
await onReady.createMecabTokenizerAndCheck();
|
||||
await onReady.loadYomitanExtension();
|
||||
await onReady.ensureYomitanExtensionLoaded?.();
|
||||
await onReady.handleFirstRunSetup();
|
||||
await onReady.prewarmSubtitleDictionaries?.();
|
||||
onReady.startBackgroundWarmups();
|
||||
@@ -79,6 +83,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
'create-mpv-client',
|
||||
'create-mecab',
|
||||
'load-yomitan',
|
||||
'ensure-yomitan',
|
||||
'handle-first-run-setup',
|
||||
'prewarm-dicts',
|
||||
'start-warmups',
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
createImmersionTracker: deps.createImmersionTracker,
|
||||
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
|
||||
loadYomitanExtension: deps.loadYomitanExtension,
|
||||
ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded,
|
||||
handleFirstRunSetup: deps.handleFirstRunSetup,
|
||||
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: deps.startBackgroundWarmups,
|
||||
|
||||
@@ -81,6 +81,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
return setTimeout(() => {}, 0);
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -52,6 +52,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
}) {
|
||||
@@ -106,6 +107,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||
schedule: deps.schedule,
|
||||
logInfo: deps.logInfo,
|
||||
logDebug: deps.logDebug,
|
||||
logWarn: deps.logWarn,
|
||||
logError: deps.logError,
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
getMultiCopyTimeoutMs: () => 5000,
|
||||
schedule: (fn) => setTimeout(fn, 0),
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetupWindow: () => calls.push('open-setup'),
|
||||
openFirstRunSetupWindow: (force?: boolean) =>
|
||||
calls.push(`open-setup:${force === true ? 'force' : 'default'}`),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
@@ -110,6 +111,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
return setTimeout(() => {}, 0);
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
@@ -125,11 +127,19 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
assert.equal(deps.shouldOpenBrowser(), true);
|
||||
deps.showOsd('hello');
|
||||
deps.initializeOverlay();
|
||||
deps.openFirstRunSetup();
|
||||
deps.openFirstRunSetup(true);
|
||||
deps.setVisibleOverlay(true);
|
||||
deps.printHelp();
|
||||
await deps.runUpdateCommand({ update: true } as never, 'initial');
|
||||
|
||||
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
|
||||
assert.deepEqual(calls, [
|
||||
'osd:hello',
|
||||
'init-overlay',
|
||||
'open-setup:force',
|
||||
'set-visible:true',
|
||||
'help',
|
||||
'run-update',
|
||||
]);
|
||||
|
||||
const retry = await deps.retryAnilistQueueNow();
|
||||
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
}) {
|
||||
@@ -97,7 +98,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
|
||||
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
|
||||
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
||||
@@ -134,6 +135,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
|
||||
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logDebug: (message: string) => deps.logDebug(message),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||
});
|
||||
|
||||
@@ -66,6 +66,9 @@ function createDeps() {
|
||||
logInfo: (message: string) => {
|
||||
logs.push(`i:${message}`);
|
||||
},
|
||||
logDebug: (message: string) => {
|
||||
logs.push(`d:${message}`);
|
||||
},
|
||||
logWarn: (message: string) => {
|
||||
logs.push(`w:${message}`);
|
||||
},
|
||||
@@ -102,7 +105,8 @@ test('cli command context log methods map to deps loggers', () => {
|
||||
const { deps, getLogs } = createDeps();
|
||||
const context = createCliCommandContext(deps);
|
||||
context.log('info');
|
||||
context.logDebug('debug');
|
||||
context.warn('warn');
|
||||
context.error('error', new Error('x'));
|
||||
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
|
||||
assert.deepEqual(getLogs(), ['i:info', 'd:debug', 'w:warn', 'e:error']);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -57,6 +57,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
};
|
||||
@@ -133,6 +134,7 @@ export function createCliCommandContext(
|
||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||
schedule: deps.schedule,
|
||||
log: deps.logInfo,
|
||||
logDebug: deps.logDebug,
|
||||
warn: deps.logWarn,
|
||||
error: deps.logError,
|
||||
};
|
||||
|
||||
@@ -110,6 +110,21 @@ test('resolveBunInstallCommand uses Homebrew on macOS when available', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('detectBun reports homebrew install method from POSIX brew path', async () => {
|
||||
const snapshot = await detectBun({
|
||||
platform: 'darwin',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
|
||||
existsSync: (candidate) => candidate === '/opt/homebrew/bin/brew',
|
||||
accessSync: (candidate) => {
|
||||
if (candidate !== '/opt/homebrew/bin/brew') throw new Error('not executable');
|
||||
},
|
||||
runCommand: async () => ({ exitCode: 127, stdout: '', stderr: 'missing' }),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'missing');
|
||||
assert.equal(snapshot.installMethod, 'homebrew');
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'linux',
|
||||
@@ -144,6 +159,53 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
|
||||
assert.equal(target.installPath, null);
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/Users/tester/.local/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/opt/homebrew/bin' ||
|
||||
candidate === '/usr/local/bin' ||
|
||||
candidate === '/Users/tester/.local/bin' ||
|
||||
candidate === '/usr/bin',
|
||||
accessSync: (candidate) => {
|
||||
if (
|
||||
candidate !== '/opt/homebrew/bin' &&
|
||||
candidate !== '/usr/local/bin' &&
|
||||
candidate !== '/Users/tester/.local/bin'
|
||||
) {
|
||||
throw new Error('not writable');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(target.status, 'not_installed');
|
||||
assert.equal(target.pathDir, '/Users/tester/.local/bin');
|
||||
assert.equal(target.installPath, '/Users/tester/.local/bin/subminer');
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget uses usr local bin for macOS manual install when user bin is absent', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/opt/homebrew/bin' ||
|
||||
candidate === '/usr/local/bin' ||
|
||||
candidate === '/usr/bin',
|
||||
accessSync: (candidate) => {
|
||||
if (candidate !== '/opt/homebrew/bin' && candidate !== '/usr/local/bin') {
|
||||
throw new Error('not writable');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(target.status, 'not_installed');
|
||||
assert.equal(target.pathDir, '/usr/local/bin');
|
||||
assert.equal(target.installPath, '/usr/local/bin/subminer');
|
||||
});
|
||||
|
||||
test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
|
||||
const files = new Map<string, string>();
|
||||
const dirs = new Set<string>();
|
||||
@@ -209,6 +271,54 @@ test('detectLauncher reports shadowed when another subminer appears earlier on P
|
||||
assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer');
|
||||
});
|
||||
|
||||
test('detectLauncher accepts installed macOS launcher from user local bin before Homebrew target', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/Users/tester/.local/bin:/opt/homebrew/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/Users/tester/.local/bin' ||
|
||||
candidate === '/opt/homebrew/bin' ||
|
||||
candidate === '/Users/tester/.local/bin/subminer',
|
||||
accessSync: () => undefined,
|
||||
runCommand: async (command, args) => {
|
||||
assert.equal(command, '/Users/tester/.local/bin/subminer');
|
||||
assert.deepEqual(args, ['--help']);
|
||||
return { exitCode: 0, stdout: 'help', stderr: '' };
|
||||
},
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'ready');
|
||||
assert.equal(snapshot.commandPath, '/Users/tester/.local/bin/subminer');
|
||||
assert.equal(snapshot.installPath, '/Users/tester/.local/bin/subminer');
|
||||
assert.equal(snapshot.pathDir, '/Users/tester/.local/bin');
|
||||
assert.equal(snapshot.shadowedBy, null);
|
||||
});
|
||||
|
||||
test('detectLauncher accepts installed macOS launcher from Homebrew bin', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/opt/homebrew/bin' || candidate === '/opt/homebrew/bin/subminer',
|
||||
accessSync: () => undefined,
|
||||
runCommand: async (command, args) => {
|
||||
assert.equal(command, '/opt/homebrew/bin/subminer');
|
||||
assert.deepEqual(args, ['--help']);
|
||||
return { exitCode: 0, stdout: 'help', stderr: '' };
|
||||
},
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'ready');
|
||||
assert.equal(snapshot.commandPath, '/opt/homebrew/bin/subminer');
|
||||
assert.equal(snapshot.installPath, '/opt/homebrew/bin/subminer');
|
||||
assert.equal(snapshot.pathDir, '/opt/homebrew/bin');
|
||||
assert.equal(snapshot.shadowedBy, null);
|
||||
});
|
||||
|
||||
test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'linux',
|
||||
|
||||
@@ -72,21 +72,23 @@ const BUN_OFFICIAL_WINDOWS_COMMAND = [
|
||||
];
|
||||
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const COMMAND_TIMEOUT_MS = 15 * 1000;
|
||||
const MACOS_HOMEBREW_PATH_DIRS = ['/opt/homebrew/bin'];
|
||||
|
||||
function installMethodForCommand(
|
||||
command: string[] | null,
|
||||
): BunSnapshot['installMethod'] {
|
||||
function installMethodForCommand(command: string[] | null): BunSnapshot['installMethod'] {
|
||||
if (!command) return null;
|
||||
const executablePath = command[0];
|
||||
if (!executablePath) return null;
|
||||
const executable = path.win32.basename(executablePath).toLowerCase();
|
||||
if (executable === 'winget.exe') return 'winget';
|
||||
if (executable === 'scoop.cmd') return 'scoop';
|
||||
if (executable === 'brew') return 'homebrew';
|
||||
const executable = path.basename(executablePath).toLowerCase();
|
||||
const windowsExecutable = path.win32.basename(executablePath).toLowerCase();
|
||||
if (windowsExecutable === 'winget.exe') return 'winget';
|
||||
if (windowsExecutable === 'scoop.cmd') return 'scoop';
|
||||
if (executable === 'brew' || windowsExecutable === 'brew') return 'homebrew';
|
||||
return 'official-script';
|
||||
}
|
||||
|
||||
export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
|
||||
export function resolveBunInstallCommand(
|
||||
options: CommonOptions = {},
|
||||
): BunSnapshot['installCommand'] {
|
||||
const platform = platformOf(options);
|
||||
if (platform === 'win32') {
|
||||
const winget = findCommand('winget.exe', options);
|
||||
@@ -154,7 +156,8 @@ export async function detectBun(options: CommonOptions = {}): Promise<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');
|
||||
@@ -206,11 +209,47 @@ 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 candidates = [...preferred, ...pathDirs].filter((dir, index, all) =>
|
||||
all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
|
||||
: [
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
'/usr/local/bin',
|
||||
];
|
||||
const manualPreferred =
|
||||
platform === 'darwin'
|
||||
? [
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
'/usr/local/bin',
|
||||
]
|
||||
: preferred;
|
||||
const installCandidates = [...manualPreferred, ...pathDirs].filter(
|
||||
(dir, index, all) =>
|
||||
all.findIndex(
|
||||
(other) =>
|
||||
normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform),
|
||||
) === index,
|
||||
);
|
||||
const installedPreferred = pathDirs.find((dir) => {
|
||||
if (!pathEntriesContain(preferred, dir, platform)) return false;
|
||||
return existsSyncOf(options)(path.posix.join(dir, 'subminer'));
|
||||
});
|
||||
if (installedPreferred) {
|
||||
const installPath = path.posix.join(installedPreferred, 'subminer');
|
||||
return {
|
||||
status: 'ready',
|
||||
commandPath: installPath,
|
||||
installPath,
|
||||
pathDir: installedPreferred,
|
||||
shadowedBy: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
const selected = installCandidates.find(
|
||||
(dir) =>
|
||||
(platform !== 'darwin' || !pathEntriesContain(MACOS_HOMEBREW_PATH_DIRS, dir, platform)) &&
|
||||
pathEntriesContain(pathDirs, dir, platform) &&
|
||||
isWritableDir(dir, options),
|
||||
);
|
||||
const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options));
|
||||
if (!selected) {
|
||||
return {
|
||||
status: 'not_installable',
|
||||
@@ -258,10 +297,14 @@ export async function detectLauncher(
|
||||
|
||||
const commandPath = findCommand('subminer', options);
|
||||
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
|
||||
if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) {
|
||||
if (
|
||||
commandPath &&
|
||||
normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized
|
||||
) {
|
||||
return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath };
|
||||
}
|
||||
if (!existsSyncOf(options)(expectedPath)) return { ...target, status: 'not_installed', commandPath: null };
|
||||
if (!existsSyncOf(options)(expectedPath))
|
||||
return { ...target, status: 'not_installed', commandPath: null };
|
||||
if (!commandPath) {
|
||||
return {
|
||||
...target,
|
||||
|
||||
@@ -11,6 +11,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
|
||||
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
|
||||
},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
|
||||
@@ -58,6 +58,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
getMultiCopyTimeoutMs: () => 0,
|
||||
schedule: () => 0 as never,
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
update: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
@@ -124,6 +125,7 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
||||
});
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
|
||||
|
||||
@@ -119,6 +119,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
args.texthooker ||
|
||||
args.update ||
|
||||
args.help,
|
||||
);
|
||||
}
|
||||
@@ -129,6 +130,10 @@ export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean {
|
||||
return !hasAnyStartupCommandBeyondSetup(args);
|
||||
}
|
||||
|
||||
export function isStandaloneFirstRunSetupCommand(args: CliArgs): boolean {
|
||||
return args.setup && !args.start && !hasAnyStartupCommandBeyondSetup(args);
|
||||
}
|
||||
|
||||
function getPluginStatus(
|
||||
state: SetupState,
|
||||
pluginInstalled: boolean,
|
||||
|
||||
@@ -65,6 +65,9 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
assert.match(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Finish setup/);
|
||||
assert.match(html, /disabled/);
|
||||
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
|
||||
assert.match(html, /min-height:\s*100vh;/);
|
||||
assert.match(html, /box-sizing:\s*border-box;/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
|
||||
@@ -305,19 +308,60 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
|
||||
assert.match(html, /Installed, Bun missing/);
|
||||
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
|
||||
assert.match(html, /action=install-command-line-launcher/);
|
||||
assert.match(html, /<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/);
|
||||
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>/,
|
||||
);
|
||||
});
|
||||
|
||||
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, ['focus']);
|
||||
assert.deepEqual(calls, ['show', 'focus']);
|
||||
});
|
||||
|
||||
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
|
||||
@@ -366,6 +410,138 @@ 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;
|
||||
@@ -437,3 +613,76 @@ 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']);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
|
||||
type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
show?: () => void;
|
||||
};
|
||||
|
||||
type FirstRunSetupWebContentsLike = {
|
||||
@@ -124,7 +125,9 @@ function getLauncherTone(
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
|
||||
function renderCommandLineLauncherSection(
|
||||
commandLineLauncher: CommandLineLauncherSnapshot,
|
||||
): string {
|
||||
if (!commandLineLauncher.supported) {
|
||||
return '';
|
||||
}
|
||||
@@ -154,7 +157,7 @@ function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLaunch
|
||||
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 === 'failed' ? '' : '';
|
||||
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
|
||||
|
||||
return `
|
||||
<section class="setup-section">
|
||||
@@ -345,13 +348,20 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
--yellow: #eed49f;
|
||||
--red: #ed8796;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, var(--mantle), var(--base));
|
||||
color: var(--text);
|
||||
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
h1 {
|
||||
@@ -583,6 +593,7 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
|
||||
return (): boolean => {
|
||||
const window = deps.getSetupWindow();
|
||||
if (!window) return false;
|
||||
window.show?.();
|
||||
window.focus();
|
||||
return true;
|
||||
};
|
||||
@@ -626,6 +637,7 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
markSetupCancelled: () => Promise<unknown>;
|
||||
isSetupCompleted: () => boolean;
|
||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
||||
shouldQuitWhenClosedCompleted?: () => boolean;
|
||||
quitApp: () => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
@@ -639,11 +651,23 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
|
||||
const setupWindow = deps.createSetupWindow();
|
||||
deps.setSetupWindow(setupWindow);
|
||||
setupWindow.show?.();
|
||||
setupWindow.focus();
|
||||
|
||||
const render = async (): Promise<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({
|
||||
@@ -682,7 +706,10 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
});
|
||||
}
|
||||
deps.clearSetupWindow();
|
||||
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
|
||||
if (
|
||||
(setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) ||
|
||||
(!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: 560,
|
||||
height: 640,
|
||||
width: 720,
|
||||
height: 860,
|
||||
title: 'SubMiner Setup',
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
|
||||
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
}) {
|
||||
return createSetupWindowHandler(deps, {
|
||||
width: 560,
|
||||
height: 640,
|
||||
width: 720,
|
||||
height: 860,
|
||||
title: 'SubMiner Setup',
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
||||
const deps = createBuildReloadConfigMainDepsHandler({
|
||||
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('start-hot-reload'),
|
||||
@@ -30,6 +31,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
||||
warnings: [],
|
||||
});
|
||||
deps.logInfo('x');
|
||||
deps.logDebug('debug');
|
||||
deps.logWarning('y');
|
||||
deps.showDesktopNotification('SubMiner', { body: 'warn' });
|
||||
deps.startConfigHotReload();
|
||||
@@ -39,6 +41,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
||||
deps.failHandlers.quit();
|
||||
assert.deepEqual(calls, [
|
||||
'info:x',
|
||||
'debug:debug',
|
||||
'warn:y',
|
||||
'notify:SubMiner:warn',
|
||||
'start-hot-reload',
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
|
||||
return (): ReloadConfigMainDeps => ({
|
||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logDebug: (message: string) => deps.logDebug(message),
|
||||
logWarning: (message: string) => deps.logWarning(message),
|
||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||
deps.showDesktopNotification(title, options),
|
||||
|
||||
@@ -20,6 +20,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
||||
],
|
||||
}),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||
@@ -36,7 +37,11 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
||||
reloadConfig();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('debug:Using config file: /tmp/config.jsonc')));
|
||||
assert.equal(
|
||||
calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')),
|
||||
false,
|
||||
);
|
||||
assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)')));
|
||||
assert.ok(
|
||||
calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')),
|
||||
@@ -64,6 +69,7 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
|
||||
error: 'unexpected token',
|
||||
}),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||
@@ -102,6 +108,7 @@ test('createReloadConfigHandler can skip AniList refresh for headless commands',
|
||||
warnings: [],
|
||||
}),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||
|
||||
@@ -24,6 +24,7 @@ type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess;
|
||||
export type ReloadConfigRuntimeDeps = {
|
||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarning: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
startConfigHotReload: () => void;
|
||||
@@ -61,7 +62,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
|
||||
);
|
||||
}
|
||||
|
||||
deps.logInfo(`Using config file: ${result.path}`);
|
||||
deps.logDebug(`Using config file: ${result.path}`);
|
||||
if (result.warnings.length > 0) {
|
||||
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
|
||||
deps.showDesktopNotification('SubMiner', {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
|
||||
import {
|
||||
configureAutoUpdater,
|
||||
createElectronAppUpdater,
|
||||
isKnownLinuxPackageManagedAppImage,
|
||||
isNativeUpdaterSupported,
|
||||
resolveMacAppBundlePath,
|
||||
type ElectronAutoUpdaterLike,
|
||||
} from './app-updater';
|
||||
|
||||
type UpdaterLogger = {
|
||||
info: (message: string) => void;
|
||||
@@ -53,3 +60,222 @@ test('configureAutoUpdater allows prereleases only for the prerelease channel',
|
||||
configureAutoUpdater(updater, () => {}, 'stable');
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
});
|
||||
|
||||
test('configureAutoUpdater handles late updater error events', () => {
|
||||
const logged: string[] = [];
|
||||
const errorListeners: Array<(error: unknown) => void> = [];
|
||||
const updater: ElectronAutoUpdaterLike & {
|
||||
on: (event: string, listener: (error: unknown) => void) => typeof updater;
|
||||
} = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
on: (event, listener) => {
|
||||
if (event === 'error') errorListeners.push(listener);
|
||||
return updater;
|
||||
},
|
||||
};
|
||||
|
||||
configureAutoUpdater(updater, (message) => logged.push(message));
|
||||
|
||||
const [errorListener] = errorListeners;
|
||||
assert.ok(errorListener);
|
||||
errorListener(new Error('APPIMAGE env is not defined'));
|
||||
assert.deepEqual(logged, ['Updater error event: APPIMAGE env is not defined']);
|
||||
});
|
||||
|
||||
test('app updater skips native update checks when native updater is unsupported', async () => {
|
||||
let checked = false;
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => {
|
||||
checked = true;
|
||||
return {
|
||||
updateInfo: {
|
||||
version: '0.15.0',
|
||||
},
|
||||
};
|
||||
},
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const logged: string[] = [];
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: (message) => logged.push(message),
|
||||
isNativeUpdaterSupported: () => false,
|
||||
});
|
||||
|
||||
const result = await appUpdater.checkForUpdates('stable');
|
||||
|
||||
assert.equal(checked, false);
|
||||
assert.deepEqual(result, {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
});
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native app update check because native updater is unsupported.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('app updater skips native downloads when native updater is unsupported', async () => {
|
||||
let downloaded = false;
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => {
|
||||
downloaded = true;
|
||||
return [];
|
||||
},
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const logged: string[] = [];
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: (message) => logged.push(message),
|
||||
isNativeUpdaterSupported: () => false,
|
||||
});
|
||||
|
||||
await appUpdater.downloadUpdate();
|
||||
|
||||
assert.equal(downloaded, false);
|
||||
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
|
||||
});
|
||||
|
||||
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
|
||||
assert.equal(
|
||||
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||
'/Applications/SubMiner.app',
|
||||
);
|
||||
assert.equal(resolveMacAppBundlePath('/usr/local/bin/SubMiner'), null);
|
||||
});
|
||||
|
||||
test('mac native updater is unsupported for ad-hoc signed app bundles', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
readCodeSignature: () =>
|
||||
['Signature=adhoc', 'TeamIdentifier=not set', 'Runtime Version=26.0.0'].join('\n'),
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
||||
});
|
||||
|
||||
test('mac native updater is supported for Developer ID signed app bundles', async () => {
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
readCodeSignature: () =>
|
||||
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
|
||||
});
|
||||
|
||||
assert.equal(supported, true);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for package-managed AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/opt/SubMiner/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('known Linux package-managed AppImage detection follows the canonical AUR path', () => {
|
||||
assert.equal(isKnownLinuxPackageManagedAppImage('/opt/SubMiner/SubMiner.AppImage'), true);
|
||||
assert.equal(
|
||||
isKnownLinuxPackageManagedAppImage('/home/tester/.local/bin/SubMiner.AppImage'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('native updater is unsupported on Windows by default', async () => {
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from './release-assets';
|
||||
@@ -20,6 +23,9 @@ export interface ElectronAutoUpdaterLike {
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: boolean;
|
||||
logger?: ElectronUpdaterLoggerLike | null;
|
||||
on?: (event: 'error', listener: (error: unknown) => void) => unknown;
|
||||
off?: (event: 'error', listener: (error: unknown) => void) => unknown;
|
||||
removeListener?: (event: 'error', listener: (error: unknown) => void) => unknown;
|
||||
checkForUpdates: () => Promise<{
|
||||
updateInfo?: {
|
||||
version?: string;
|
||||
@@ -29,6 +35,85 @@ export interface ElectronAutoUpdaterLike {
|
||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
||||
}
|
||||
|
||||
const updaterErrorListeners = new WeakMap<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 = () => {},
|
||||
@@ -43,6 +128,22 @@ export function configureAutoUpdater(
|
||||
warn: (message) => log(message),
|
||||
error: (message) => log(message),
|
||||
};
|
||||
const previousErrorListener = updaterErrorListeners.get(updater);
|
||||
if (previousErrorListener) {
|
||||
if (updater.off) {
|
||||
updater.off('error', previousErrorListener);
|
||||
} else {
|
||||
updater.removeListener?.('error', previousErrorListener);
|
||||
}
|
||||
}
|
||||
if (updater.on) {
|
||||
const errorListener = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(`Updater error event: ${message}`);
|
||||
};
|
||||
updater.on('error', errorListener);
|
||||
updaterErrorListeners.set(updater, errorListener);
|
||||
}
|
||||
return updater;
|
||||
}
|
||||
|
||||
@@ -52,6 +153,7 @@ export function createElectronAppUpdater(options: {
|
||||
updater?: ElectronAutoUpdaterLike;
|
||||
log: (message: string) => void;
|
||||
getChannel?: () => UpdateChannel;
|
||||
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||
const updater = configureAutoUpdater(
|
||||
@@ -59,6 +161,15 @@ 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> {
|
||||
@@ -69,6 +180,14 @@ export function createElectronAppUpdater(options: {
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping native app update check because native updater is unsupported.');
|
||||
return {
|
||||
available: false,
|
||||
version: options.currentVersion,
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
configureAutoUpdater(updater, options.log, channel ?? getChannel());
|
||||
const result = await updater.checkForUpdates();
|
||||
const version = result?.updateInfo?.version ?? options.currentVersion;
|
||||
@@ -83,9 +202,21 @@ export function createElectronAppUpdater(options: {
|
||||
options.log('Skipping app update download because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping app update download because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
await updater.downloadUpdate();
|
||||
},
|
||||
quitAndInstall(): void {
|
||||
async quitAndInstall(): Promise<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;
|
||||
}
|
||||
updater.quitAndInstall(false, true);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { buildProtectedAppImageUpdateCommand, updateAppImageFromRelease } from './appimage-updater';
|
||||
|
||||
const appImageBytes = Buffer.from('appimage');
|
||||
const appImageHash = createHash('sha256').update(appImageBytes).digest('hex');
|
||||
|
||||
test('updateAppImageFromRelease verifies hash and atomically replaces writable AppImage', async () => {
|
||||
const writes: Array<{ path: string; data: Buffer }> = [];
|
||||
const chmods: Array<{ path: string; mode: number }> = [];
|
||||
const renames: Array<{ from: string; to: string }> = [];
|
||||
|
||||
const result = await updateAppImageFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
|
||||
},
|
||||
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
|
||||
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
downloadAsset: async () => appImageBytes,
|
||||
fs: {
|
||||
stat: async () => ({
|
||||
isFile: () => true,
|
||||
mode: 0o755,
|
||||
}),
|
||||
access: async () => {},
|
||||
writeFile: async (targetPath, data) => {
|
||||
writes.push({ path: targetPath, data });
|
||||
},
|
||||
chmod: async (targetPath, mode) => {
|
||||
chmods.push({ path: targetPath, mode });
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
renames.push({ from, to });
|
||||
},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: 'updated',
|
||||
path: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
});
|
||||
assert.deepEqual(writes, [
|
||||
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', data: appImageBytes },
|
||||
]);
|
||||
assert.deepEqual(chmods, [
|
||||
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', mode: 0o755 },
|
||||
]);
|
||||
assert.deepEqual(renames, [
|
||||
{
|
||||
from: '/home/kyle/.local/bin/.SubMiner.AppImage.update',
|
||||
to: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => {
|
||||
const result = await updateAppImageFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
|
||||
},
|
||||
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
|
||||
appImagePath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
downloadAsset: async () => appImageBytes,
|
||||
fs: {
|
||||
stat: async () => ({
|
||||
isFile: () => true,
|
||||
mode: 0o755,
|
||||
}),
|
||||
access: async () => {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'protected');
|
||||
assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/);
|
||||
assert.match(result.command ?? '', /sha256sum -c -/);
|
||||
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/);
|
||||
});
|
||||
|
||||
test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => {
|
||||
const command = buildProtectedAppImageUpdateCommand(
|
||||
"https://example.test/Sub Miner.AppImage?sig='abc'",
|
||||
"/opt/Sub Miner/SubMiner's.AppImage",
|
||||
'ABCDEF',
|
||||
);
|
||||
|
||||
assert.match(command, /trap 'rm -f "\$tmp"' EXIT/);
|
||||
assert.match(
|
||||
command,
|
||||
/curl -fSL 'https:\/\/example\.test\/Sub Miner\.AppImage\?sig='\\''abc'\\''' -o "\$tmp"/,
|
||||
);
|
||||
assert.match(command, /printf '%s %s\\n' 'abcdef' "\$tmp" \| sha256sum -c -/);
|
||||
assert.match(command, /sudo mv "\$tmp" '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
|
||||
assert.match(command, /sudo chmod \+x '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
|
||||
});
|
||||
|
||||
test('updateAppImageFromRelease aborts on hash mismatch', async () => {
|
||||
const result = await updateAppImageFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
|
||||
},
|
||||
sha256Sums: new Map([['SubMiner.AppImage', '0'.repeat(64)]]),
|
||||
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
downloadAsset: async () => appImageBytes,
|
||||
fs: {
|
||||
stat: async () => ({
|
||||
isFile: () => true,
|
||||
mode: 0o755,
|
||||
}),
|
||||
access: async () => {},
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'hash-mismatch');
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
|
||||
type StatLike = {
|
||||
isFile: () => boolean;
|
||||
mode?: number;
|
||||
};
|
||||
|
||||
export type AppImageUpdateStatus =
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'protected'
|
||||
| 'hash-mismatch'
|
||||
| 'not-found'
|
||||
| 'missing-asset';
|
||||
|
||||
export interface AppImageUpdateResult {
|
||||
status: AppImageUpdateStatus;
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AppImageUpdateFileSystem {
|
||||
stat: (targetPath: string) => Promise<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 uses sudo curl and chmod for protected paths', () => {
|
||||
test('buildProtectedLauncherUpdateCommand quotes sudo curl and chmod paths', () => {
|
||||
assert.equal(
|
||||
buildProtectedLauncherUpdateCommand(
|
||||
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
|
||||
'/usr/local/bin/subminer',
|
||||
"https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='abc'",
|
||||
"/usr/local/bin/subminer's launcher",
|
||||
),
|
||||
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
|
||||
"sudo curl -fSL 'https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='\\''abc'\\''' -o '/usr/local/bin/subminer'\\''s launcher' && sudo chmod +x '/usr/local/bin/subminer'\\''s launcher'",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ test('updateLauncherAtPath reports protected command without replacing non-writa
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'protected');
|
||||
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
|
||||
assert.match(result.command ?? '', /^sudo curl -fSL 'https:\/\/example\.test\/subminer'/);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
|
||||
|
||||
@@ -50,13 +50,17 @@ export function buildProtectedLauncherUpdateCommand(
|
||||
assetUrl: string,
|
||||
launcherPath: string,
|
||||
): string {
|
||||
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
|
||||
return `sudo curl -fSL ${shellQuote(assetUrl)} -o ${shellQuote(launcherPath)} && sudo chmod +x ${shellQuote(launcherPath)}`;
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function defaultFs(): LauncherUpdateFileSystem {
|
||||
return {
|
||||
readFile: (targetPath) => fs.promises.readFile(targetPath),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildProtectedSupportAssetsCommand,
|
||||
detectSupportAssetDataDirs,
|
||||
updateSupportAssetsFromRelease,
|
||||
} from './support-assets';
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), 'new theme\n');
|
||||
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n');
|
||||
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
|
||||
return {
|
||||
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
|
||||
tempDir,
|
||||
};
|
||||
}
|
||||
|
||||
test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => {
|
||||
assert.deepEqual(
|
||||
detectSupportAssetDataDirs({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/kyle',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
detectSupportAssetDataDirs({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/kyle',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
}),
|
||||
['/tmp/xdg-data/SubMiner', '/usr/local/share/SubMiner', '/usr/share/SubMiner'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => {
|
||||
const command = buildProtectedSupportAssetsCommand(
|
||||
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
||||
"/usr/local/share/SubMiner's data",
|
||||
);
|
||||
|
||||
assert.match(command, /tmp=\$\(mktemp -d\)/);
|
||||
assert.match(command, /trap 'rm -rf "\$tmp"' EXIT/);
|
||||
assert.match(
|
||||
command,
|
||||
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
|
||||
);
|
||||
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||
|
||||
try {
|
||||
const results = await updateSupportAssetsFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
assets: [
|
||||
{
|
||||
name: 'subminer-assets.tar.gz',
|
||||
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
|
||||
},
|
||||
],
|
||||
},
|
||||
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(archive)]]),
|
||||
downloadAsset: async () => archive,
|
||||
platform: 'linux',
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ status: 'updated', path: dataDir }]);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'old plugin\n',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -29,12 +29,6 @@ export function detectSupportAssetDataDirs(options: {
|
||||
homeDir: string;
|
||||
xdgDataHome?: string;
|
||||
}): string[] {
|
||||
if (options.platform === 'darwin') {
|
||||
return [
|
||||
path.join(options.homeDir, 'Library/Application Support/SubMiner'),
|
||||
'/usr/local/share/SubMiner',
|
||||
];
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
|
||||
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
|
||||
@@ -46,10 +40,10 @@ export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: st
|
||||
const quotedDir = shellQuote(dataDir);
|
||||
return [
|
||||
'tmp=$(mktemp -d)',
|
||||
'trap \'rm -rf "$tmp"\' EXIT',
|
||||
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
||||
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
||||
`sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`,
|
||||
`sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`,
|
||||
`sudo mkdir -p ${quotedDir}/themes`,
|
||||
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||
].join(' && ');
|
||||
}
|
||||
@@ -76,12 +70,15 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
homeDir?: string;
|
||||
xdgDataHome?: string;
|
||||
}): Promise<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 support assets.' }];
|
||||
if (!asset) return [{ status: 'missing-asset', message: 'Release has no rofi theme asset.' }];
|
||||
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
||||
if (!expectedSha256) {
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }];
|
||||
}
|
||||
|
||||
const dataDirs = detectSupportAssetDataDirs({
|
||||
@@ -91,12 +88,11 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
});
|
||||
const existingDataDirs: string[] = [];
|
||||
for (const dataDir of dataDirs) {
|
||||
const hasPlugin = await pathExists(path.join(dataDir, 'plugin/subminer'));
|
||||
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
|
||||
if (hasPlugin || hasTheme) existingDataDirs.push(dataDir);
|
||||
if (hasTheme) existingDataDirs.push(dataDir);
|
||||
}
|
||||
if (existingDataDirs.length === 0) {
|
||||
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
|
||||
return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }];
|
||||
}
|
||||
|
||||
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
|
||||
@@ -139,17 +135,8 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
|
||||
const results: SupportAssetsUpdateResult[] = [...protectedResults];
|
||||
for (const dataDir of writableDataDirs) {
|
||||
const targetPluginDir = path.join(dataDir, 'plugin/subminer');
|
||||
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
|
||||
if (await pathExists(targetPluginDir)) {
|
||||
await fs.promises.mkdir(targetPluginDir, { recursive: true });
|
||||
await fs.promises.cp(path.join(tempDir, 'plugin/subminer'), targetPluginDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
if (await pathExists(targetThemePath)) {
|
||||
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
|
||||
await fs.promises.copyFile(
|
||||
path.join(tempDir, 'assets/themes/subminer.rasi'),
|
||||
targetThemePath,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createUpdateDialogPresenter, type ShowMessageBox } from './update-dialogs';
|
||||
|
||||
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: () => calls.push('focus'),
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
focusApp: () => calls.push('focus'),
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
@@ -15,6 +15,12 @@ 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,
|
||||
@@ -27,6 +33,27 @@ export async function showNoUpdateDialog(
|
||||
});
|
||||
}
|
||||
|
||||
function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void {
|
||||
if ((deps.platform ?? process.platform) !== 'darwin') return;
|
||||
deps.focusApp?.();
|
||||
}
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||
maybeFocusAppForDialog(deps);
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
return {
|
||||
showNoUpdateDialog: (version: string) => showNoUpdateDialog(showFocusedMessageBox, version),
|
||||
showUpdateAvailableDialog: (version: string) =>
|
||||
showUpdateAvailableDialog(showFocusedMessageBox, version),
|
||||
showUpdateFailedDialog: (message: string) =>
|
||||
showUpdateFailedDialog(showFocusedMessageBox, message),
|
||||
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
|
||||
};
|
||||
}
|
||||
|
||||
export async function showUpdateAvailableDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
version: string,
|
||||
|
||||
@@ -47,3 +47,24 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails',
|
||||
|
||||
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable logs non-error osd failures with thrown value', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
{ notificationType: 'osd', version: '0.15.0' },
|
||||
{
|
||||
showSystemNotification: () => {
|
||||
calls.push('system');
|
||||
},
|
||||
showOsdNotification: async () => {
|
||||
throw 'mpv disconnected';
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@ export async function notifyUpdateAvailable(
|
||||
try {
|
||||
await deps.showOsdNotification(message);
|
||||
} catch (error) {
|
||||
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
deps.log(`Update OSD notification failed: ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ 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}`);
|
||||
},
|
||||
@@ -90,6 +92,32 @@ test('manual update check falls back to GitHub release when app metadata is unav
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update check reports available when no update asset was applied', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
updateLauncher: async (_launcherPath, channel) => {
|
||||
calls.push(`launcher:${channel}`);
|
||||
return { status: 'skipped' };
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable']);
|
||||
});
|
||||
|
||||
test('automatic update check skips inside configured interval', async () => {
|
||||
const { deps, calls, setState } = createDeps();
|
||||
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
|
||||
@@ -141,6 +169,57 @@ test('concurrent update checks share one in-flight check', async () => {
|
||||
assert.equal(checkCount, 1);
|
||||
});
|
||||
|
||||
test('manual update check does not reuse in-flight automatic check', async () => {
|
||||
let checkCount = 0;
|
||||
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
|
||||
const { deps } = createDeps({
|
||||
checkAppUpdate: () =>
|
||||
new Promise((resolve) => {
|
||||
checkCount += 1;
|
||||
resolveChecks.push(resolve);
|
||||
}),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
const automatic = service.checkForUpdates({ source: 'automatic', force: true });
|
||||
const manual = service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(checkCount, 2);
|
||||
for (const resolve of resolveChecks) {
|
||||
resolve({ available: false, version: '0.14.0' });
|
||||
}
|
||||
await Promise.all([automatic, manual]);
|
||||
});
|
||||
|
||||
test('manual update check passes selected GitHub release to launcher update', async () => {
|
||||
const selectedRelease = {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
let forwardedRelease: unknown;
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
fetchLatestStableRelease: async () => selectedRelease,
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
updateLauncher: (async (...args: unknown[]) => {
|
||||
calls.push(`launcher:${args[1]}`);
|
||||
forwardedRelease = args[2];
|
||||
return { status: 'updated' };
|
||||
}) as UpdateServiceDeps['updateLauncher'],
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.equal(forwardedRelease, selectedRelease);
|
||||
});
|
||||
|
||||
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getConfig: () => ({
|
||||
|
||||
@@ -43,13 +43,14 @@ 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>;
|
||||
downloadAppUpdate: () => Promise<void>;
|
||||
showRestartDialog: () => Promise<'restart' | 'later'>;
|
||||
quitAndInstall: () => void;
|
||||
quitAndInstall: () => void | Promise<void>;
|
||||
notifyUpdateAvailable: (version: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
@@ -96,7 +97,7 @@ function summarizeError(error: unknown): string {
|
||||
}
|
||||
|
||||
export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
let inFlight: Promise<UpdateCheckResult> | null = null;
|
||||
const inFlightBySource = new Map<UpdateCheckSource, Promise<UpdateCheckResult>>();
|
||||
|
||||
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const now = deps.now();
|
||||
@@ -157,17 +158,24 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
let appUpdateApplied = false;
|
||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
||||
await deps.downloadAppUpdate();
|
||||
appUpdateApplied = true;
|
||||
}
|
||||
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
|
||||
const launcherResult = await deps.updateLauncher(request.launcherPath, channel, release);
|
||||
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||
}
|
||||
|
||||
const launcherUpdateApplied = launcherResult.status === 'updated';
|
||||
if (!appUpdateApplied && !launcherUpdateApplied) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
const restartChoice = await deps.showRestartDialog();
|
||||
if (restartChoice === 'restart') {
|
||||
deps.quitAndInstall();
|
||||
await deps.quitAndInstall();
|
||||
}
|
||||
return { status: 'updated', version: latest.version };
|
||||
} catch (error) {
|
||||
@@ -183,11 +191,13 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
|
||||
return {
|
||||
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const inFlight = inFlightBySource.get(request.source);
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = runCheck(request).finally(() => {
|
||||
inFlight = null;
|
||||
const nextInFlight = runCheck(request).finally(() => {
|
||||
inFlightBySource.delete(request.source);
|
||||
});
|
||||
return inFlight;
|
||||
inFlightBySource.set(request.source, nextInFlight);
|
||||
return nextInFlight;
|
||||
},
|
||||
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
||||
const setTimeoutFn = deps.setTimeout ?? setTimeout;
|
||||
|
||||
@@ -119,9 +119,9 @@ test('yomitan opener uses loaded extension from app state without calling loader
|
||||
assert.equal(forwardedExtension, appStateExtension);
|
||||
});
|
||||
|
||||
test('yomitan opener warns instead of starting a settings-triggered load when extension is not ready', async () => {
|
||||
test('yomitan opener lazy-loads extension when app state is empty and no load is in flight', async () => {
|
||||
let ensureCalled = false;
|
||||
const logs: string[] = [];
|
||||
let forwardedExtension: { id: string } | null = null;
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
ensureCalled = true;
|
||||
@@ -129,19 +129,19 @@ test('yomitan opener warns instead of starting a settings-triggered load when ex
|
||||
},
|
||||
getYomitanExtension: () => null,
|
||||
getYomitanExtensionLoadInFlight: () => null,
|
||||
openYomitanSettingsWindow: () => {
|
||||
throw new Error('should not open before extension is ready');
|
||||
openYomitanSettingsWindow: ({ yomitanExt }) => {
|
||||
forwardedExtension = yomitanExt as { id: string };
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: (message) => logs.push(message),
|
||||
logError: () => logs.push('error'),
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(ensureCalled, false);
|
||||
assert.deepEqual(logs, ['Unable to open Yomitan settings: extension is not loaded yet.']);
|
||||
assert.equal(ensureCalled, true);
|
||||
assert.deepEqual(forwardedExtension, { id: 'ext' });
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
return (): void => {
|
||||
void (async () => {
|
||||
if (deps.getYomitanExtension) {
|
||||
const loadedExtension = deps.getYomitanExtension();
|
||||
let loadedExtension = deps.getYomitanExtension();
|
||||
if (!loadedExtension) {
|
||||
if (deps.getYomitanExtensionLoadInFlight?.()) {
|
||||
deps.logWarn(
|
||||
@@ -30,9 +30,12 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
);
|
||||
return;
|
||||
}
|
||||
deps.logWarn('Unable to open Yomitan settings: extension is not loaded yet.');
|
||||
loadedExtension = await deps.ensureYomitanExtensionLoaded();
|
||||
if (!loadedExtension) {
|
||||
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||
deps.openYomitanSettingsWindow({
|
||||
|
||||
@@ -164,6 +164,7 @@ test('release packaging stages generated launcher as an app resource', () => {
|
||||
),
|
||||
);
|
||||
assert.match(packageJson.scripts.build ?? '', /bun run build:launcher/);
|
||||
assert.match(packageJson.scripts['build:launcher'] ?? '', /--banner='#!\/usr\/bin\/env bun'/);
|
||||
});
|
||||
|
||||
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
||||
|
||||
Reference in New Issue
Block a user