mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-28 00:55:16 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d05e2bd8ec
|
|||
|
7484d3c102
|
|||
|
f78a875ba3
|
|||
|
a025652542
|
|||
| 91a01b86a9 | |||
| 105713361e |
@@ -47,6 +47,13 @@ jobs:
|
|||||||
- name: Build (TypeScript check)
|
- name: Build (TypeScript check)
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Install Lua
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y lua5.4
|
||||||
|
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
|
||||||
|
lua -v
|
||||||
|
|
||||||
- name: Test suite (source)
|
- name: Test suite (source)
|
||||||
run: bun run test:fast
|
run: bun run test:fast
|
||||||
|
|
||||||
@@ -362,8 +369,12 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Generate prerelease notes from pending fragments
|
- name: Verify committed prerelease notes
|
||||||
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
|
run: |
|
||||||
|
if [ ! -s release/prerelease-notes.md ]; then
|
||||||
|
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Publish Prerelease
|
- name: Publish Prerelease
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dist/
|
|||||||
release/*
|
release/*
|
||||||
!release/
|
!release/
|
||||||
!release/release-notes.md
|
!release/release-notes.md
|
||||||
|
!release/prerelease-notes.md
|
||||||
build/yomitan/
|
build/yomitan/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ MACOS_APP_DIR ?= $(HOME)/Applications
|
|||||||
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||||
|
|
||||||
# If building from source, the AppImage will typically land in release/.
|
# If building from source, the AppImage will typically land in release/.
|
||||||
APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage))
|
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||||
MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app))
|
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
||||||
MACOS_ZIP_SRC := $(firstword $(wildcard release/SubMiner-*.zip))
|
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
||||||
|
|
||||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||||
ifeq ($(OS),Windows_NT)
|
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):
|
Also download the `subminer` launcher (recommended):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \
|
mkdir -p ~/.local/bin
|
||||||
&& sudo chmod +x /usr/local/bin/subminer
|
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
|
||||||
|
&& chmod +x ~/.local/bin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: updater
|
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,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed macOS overlay tracking so transient mpv window misses no longer hide the overlay; minimizing mpv still hides it.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed macOS overlay passthrough so mpv controls remain clickable before hovering subtitle bars.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed subtitle sync modal opens so macOS no longer flashes and hides the first modal attempt or leaves stale modal state after syncing.
|
||||||
@@ -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 the tray app running when closing tray-launched Yomitan settings.
|
||||||
- Kept tray-launched Yomitan settings loading from blocking other tray actions.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
||||||
chmod +x ~/.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
|
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
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.local/share/SubMiner/plugin/subminer
|
mkdir -p ~/.local/share/SubMiner/themes
|
||||||
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
|
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.
|
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 --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
|
### From Source
|
||||||
|
|
||||||
@@ -240,7 +242,7 @@ subminer -u
|
|||||||
subminer --update
|
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
|
::: 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`.
|
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
|
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
|
```bash
|
||||||
sudo make install-macos PREFIX=/usr/local
|
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`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
| `--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.
|
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
|
## Logging
|
||||||
|
|||||||
+4
-1
@@ -55,7 +55,10 @@
|
|||||||
`bun run build`
|
`bun run build`
|
||||||
When validating packaged updater output, confirm the platform build writes
|
When validating packaged updater output, confirm the platform build writes
|
||||||
`*.yml` and `*.blockmap` files under `release/`.
|
`*.yml` and `*.blockmap` files under `release/`.
|
||||||
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
|
5. Commit the prerelease prep (package.json version bump + the generated
|
||||||
|
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||||
|
committed file — so review it before committing. Do not run
|
||||||
|
`bun run changelog:build`.
|
||||||
6. Tag the commit: `git tag v<version>`.
|
6. Tag the commit: `git tag v<version>`.
|
||||||
7. Push commit + tag.
|
7. Push commit + tag.
|
||||||
|
|
||||||
|
|||||||
@@ -241,36 +241,36 @@ test('dictionary command returns after app handoff starts', () => {
|
|||||||
assert.equal(handled, true);
|
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();
|
const context = createContext();
|
||||||
context.args.update = true;
|
context.args.update = true;
|
||||||
const forwarded: string[][] = [];
|
const calls: string[] = [];
|
||||||
const responses: string[] = [];
|
|
||||||
|
|
||||||
const handled = await runUpdateCommand(context, {
|
const handled = await runUpdateCommand(context, {
|
||||||
createTempDir: () => '/tmp/subminer-update-test',
|
runAppCommandCaptureOutput: () => {
|
||||||
joinPath: (...parts) => parts.join('/'),
|
throw new Error('unexpected Electron launch');
|
||||||
runAppCommandCaptureOutput: (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return { status: 0, stdout: '', stderr: '' };
|
|
||||||
},
|
},
|
||||||
waitForUpdateResponse: async (responsePath) => {
|
runDirectReleaseUpdate: async (request) => {
|
||||||
responses.push(responsePath);
|
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
|
||||||
return { ok: true, status: 'up-to-date', version: '0.15.0' };
|
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.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(calls, [
|
||||||
[
|
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
||||||
'--update',
|
'info:AppImage update: not-found',
|
||||||
'--update-launcher-path',
|
'info:Launcher update: updated',
|
||||||
'/tmp/subminer',
|
'info:Rofi theme update: skipped',
|
||||||
'--update-response-path',
|
|
||||||
'/tmp/subminer-update-test/response.json',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command launches attached app command with response path', async () => {
|
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 fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import packageJson from '../../package.json';
|
||||||
import { runAppCommandCaptureOutput } from '../mpv.js';
|
import { runAppCommandCaptureOutput } from '../mpv.js';
|
||||||
|
import { log as launcherLog } from '../log.js';
|
||||||
import { nowMs } from '../time.js';
|
import { nowMs } from '../time.js';
|
||||||
import { sleep } from '../util.js';
|
import { sleep } from '../util.js';
|
||||||
import type { LauncherCommandContext } from './context.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 = {
|
type UpdateCommandResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -13,6 +30,18 @@ type UpdateCommandResponse = {
|
|||||||
error?: string;
|
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 = {
|
type UpdateCommandDeps = {
|
||||||
createTempDir: (prefix: string) => string;
|
createTempDir: (prefix: string) => string;
|
||||||
joinPath: (...parts: string[]) => string;
|
joinPath: (...parts: string[]) => string;
|
||||||
@@ -22,9 +51,95 @@ type UpdateCommandDeps = {
|
|||||||
) => { status: number; stdout: string; stderr: string; error?: Error };
|
) => { status: number; stdout: string; stderr: string; error?: Error };
|
||||||
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
|
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
|
||||||
removeDir: (targetPath: string) => void;
|
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 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 = {
|
const defaultDeps: UpdateCommandDeps = {
|
||||||
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||||
@@ -47,6 +162,9 @@ const defaultDeps: UpdateCommandDeps = {
|
|||||||
removeDir: (targetPath) => {
|
removeDir: (targetPath) => {
|
||||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
|
runDirectReleaseUpdate,
|
||||||
|
readMainConfig: readLauncherMainConfigObject,
|
||||||
|
log: launcherLog,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runUpdateCommand(
|
export async function runUpdateCommand(
|
||||||
@@ -59,6 +177,21 @@ export async function runUpdateCommand(
|
|||||||
return false;
|
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 tempDir = resolvedDeps.createTempDir('subminer-update-');
|
||||||
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
|
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0-beta.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
"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:yomitan": "bun scripts/build-yomitan.mjs",
|
||||||
"build:assets": "bun scripts/prepare-build-assets.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",
|
"build:stats": "cd stats && bun run build",
|
||||||
"dev:stats": "cd stats && bun run dev",
|
"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",
|
"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: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: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: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: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/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
|
"test:core: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: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: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",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Auto-Update:** Tray and `subminer -u` command-line update checks for new SubMiner releases, with app and launcher update prompts, checksum verification, configurable update notifications, and an opt-in prerelease channel for beta and RC builds.
|
||||||
|
- **First-Run Setup:** Guided setup flow to install Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim is installed so you can type `subminer` in any terminal without adding the main executable to PATH.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **macOS Overlay:** Transient mpv window appearances no longer incorrectly hide the subtitle overlay; minimizing mpv still hides it as expected. mpv controls are also now clickable before hovering a subtitle bar.
|
||||||
|
- **Subtitle Sync Modal:** Opening the subtitle sync panel on macOS no longer flashes and dismisses on the first attempt, and no longer leaves stale modal state after syncing.
|
||||||
|
- **Updater Stability:** Linux tray and background update checks now use GitHub release metadata instead of the native Electron updater, preventing crashes. Unsafe native updater paths are avoided on all platforms.
|
||||||
|
- **Linux Launcher Update:** `subminer -u` on Linux now performs release updates directly from the launcher without requiring the tray app to be running. When already on the latest version it reports up to date without downloading assets. Support asset updates are limited to the Linux rofi theme.
|
||||||
|
- **Linux Launcher Install:** First-run launcher installs on Linux now use a valid Bun shebang so the installed launcher executes correctly.
|
||||||
|
- **macOS Setup:** First-run setup now correctly recognizes launchers already installed via Homebrew or user PATH directories, and manual installs avoid writing to Homebrew-managed locations.
|
||||||
|
- **Update Dialog:** macOS update dialogs are brought to the front when `subminer --update` is run from the command line.
|
||||||
|
- **Setup Flow:** `subminer app --setup` now correctly opens the setup window when SubMiner is already running in the background. The standalone setup process also quits after first-run completes, returning the terminal prompt instead of leaving the app open.
|
||||||
|
- **Build:** One-shot `make clean build install` flows now correctly pick up the AppImage produced by the current build rather than a stale previous one.
|
||||||
|
- **Tray Settings:** Closing Yomitan settings launched from the tray no longer quits the tray app, and loading settings no longer blocks other tray actions. A close button is shown within the Yomitan settings page on Hyprland where native window controls are unavailable. The embedded Yomitan popup preview is disabled in the tray settings window to prevent renderer hangs. Extension refreshes are now serialized to prevent startup race conditions, and session help modals can close correctly without mpv running.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See the README and docs/installation guide for full setup steps.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- Linux: `SubMiner.AppImage`
|
||||||
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
|
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||||
|
|
||||||
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
## Highlights
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Character Dictionary:** Added AniList-based selection to resolve character dictionary mismatches, with series-scoped overrides that replace stale entries. Available via `subminer dictionary --candidates` / `--select` and a default `Ctrl+Alt+A` in-app shortcut.
|
|
||||||
- **Subtitle Bar Toggle:** Added a `V` shortcut and mpv binding to toggle the primary subtitle bar independently of mpv's native subtitle display.
|
|
||||||
- **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **mpv Plugin Setup:** Managed launches now inject the bundled plugin automatically. The setup flow can trash detected legacy global plugin files before launch, and legacy global install entrypoints have been removed so regular mpv playback is unaffected.
|
|
||||||
- **Tray Menu:** Replaced "Open Overlay" with "Open Help," which opens the session help modal.
|
|
||||||
- **Stats Exclusions:** Vocabulary exclusions now persist in the immersion database and migrate existing browser-local exclusions on first load.
|
|
||||||
- **Config Defaults:** Disabled texthooker startup, subtitle, and annotation websocket servers by default. Fresh installs now use a Japanese font stack, transparent subtitle backgrounds, stronger text shadows, and teal N4/fourth-band coloring for primary subtitles. Yomitan popup auto-pause remains enabled.
|
|
||||||
- **Config Example:** The generated example config now lists every built-in keybinding default.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Subtitle Annotations — Grammar Filtering:** Suppressed N+1, JLPT, frequency, and name styling on grammar-only tokens: standalone interjections (`あ`, katakana variants), kana grammar helpers (`ことに`), auxiliary inflection fragments (`れる`, `れた`), polite copula tails (`です`, `じゃないですか`), standalone particles matched by known-word decks, and existence verbs (`ある`/`有る`). Known-word highlighting is preserved where applicable.
|
|
||||||
- **Subtitle Annotations — Color Priority:** Fixed token color priority so typography settings are preserved, JLPT colors no longer override higher-priority known-word or frequency colors, JLPT underlines persist at their correct color after dictionary lookups and when a token also carries known-word or frequency annotations, and frequency highlighting works correctly for ordinal prefix-noun tokens like `第二`.
|
|
||||||
- **Subtitle Annotations — Other:** Stopped kana-only tokens from being selected as N+1 targets; preserved Yomitan compound tokens so known component words no longer color a larger unknown word green; kept annotation prefetch running after immediate cache-hit renders; added a brightness lift for annotated token hover states when hover backgrounds are transparent; accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`; refreshed the current subtitle after successful card mining so newly known words recolor immediately.
|
|
||||||
- **Subtitle Bar:** Changed `v` to cycle the primary subtitle bar through visible, hover, and hidden modes with OSD feedback. Added `subtitleStyle.primaryDefaultMode` to set the startup visibility default independently from secondary subtitles.
|
|
||||||
- **Tokenizer:** Now uses Yomitan `wordClasses` metadata for part-of-speech filtering, and backfills blank MeCab POS fields during parser enrichment.
|
|
||||||
- **Overlay (Linux):** Fixed multi-line subtitle copy timing out after the prompt; follow-up number-row digits are now accepted for multi-line mining even when the original shortcut modifiers are still held.
|
|
||||||
- **Overlay (Hyprland):** Fixed fullscreen transitions so overlay geometry refreshes on mpv fullscreen changes, topmost stacking is reasserted, and hover pause works correctly after resize/toggle cycles. Overlay windows now align precisely to mpv bounds with floating decoration disabled; the stats overlay is opaque to prevent mpv bleed-through at the top edge; overlay windows no longer pin across workspaces.
|
|
||||||
- **Overlay (macOS):** Kept the overlay visible and interactive during transient tracker refreshes while mpv is the active tracked window, and kept it behind unrelated foreground windows while remaining above mpv.
|
|
||||||
- **Overlay:** Keyboard-only Yomitan popup shortcuts now take precedence over overlay keybindings like `j`; the browser focus outline is hidden so focused overlays no longer show a yellow/orange viewport border.
|
|
||||||
- **Default Keybindings:** Fixed replay/next subtitle keybindings — session help moved to `Ctrl/Cmd+/`, freeing `Ctrl+Shift+H` and `Ctrl+Shift+L` for subtitle playback controls. `Ctrl+Shift+L` now correctly reaches play-next-subtitle, and play-next resumes from a paused state before pausing again at the subtitle end.
|
|
||||||
- **Anki:** Manual clipboard subtitle updates preserve existing word audio while replacing sentence audio, animated-image media, and expression fields — even when audio overwrite is configured off.
|
|
||||||
- **AniList:** Post-watch progress checks now run on time-position updates using the fresh mpv position; manual mark-watched forces a progress sync; missing episode metadata is filled from the filename parser. Duplicate writes during concurrent checks are prevented, and manual watched marks are preserved when sync fails.
|
|
||||||
- **AniList (Linux):** Retried safeStorage availability after transient keyring failures so tokens can load and save once the keyring becomes available. Prevented config reload from opening the setup window during playback when token storage cannot be resolved, and stopped the setup flow from reporting success when token persistence fails.
|
|
||||||
- **mpv:** Stopped mpv from holding SubMiner subprocesses during shutdown, preventing desktop crash notifications on video close. Kept the overlay alive across same-media buffering reloads to avoid duplicate startup gates and AniSkip lookups; playlist navigation now reuses the running overlay without repeating the pause-until-ready warmup gate.
|
|
||||||
- **Launcher:** Managed playback now exits the background SubMiner app when the video closes; explicit background launches remain persistent.
|
|
||||||
- **Stats:** Background mode routes through the isolated stats daemon; app startup defers to an already-running daemon instead of failing when the port is already in use. Fixed recent session detail pages showing "Media not found" before lifetime media summaries are available.
|
|
||||||
- **Jellyfin:** Improved setup with recent server selection and inline authentication feedback. Added a tray toggle for runtime-only cast discovery.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- Improved the docs homepage with canonical URLs and a cleaner sitemap.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
|
||||||
@@ -25,6 +25,11 @@ private struct WindowState {
|
|||||||
let focused: Bool
|
let focused: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum WindowLookupResult {
|
||||||
|
case visible(WindowState)
|
||||||
|
case minimized
|
||||||
|
}
|
||||||
|
|
||||||
private let targetMpvSocketPath: String? = {
|
private let targetMpvSocketPath: String? = {
|
||||||
guard CommandLine.arguments.count > 1 else {
|
guard CommandLine.arguments.count > 1 else {
|
||||||
return nil
|
return nil
|
||||||
@@ -145,7 +150,7 @@ private func frontmostApplicationPid() -> pid_t? {
|
|||||||
NSWorkspace.shared.frontmostApplication?.processIdentifier
|
NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
private func windowStateFromAccessibilityAPI() -> WindowState? {
|
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||||
let runningApps = NSWorkspace.shared.runningApplications.filter { app in
|
let runningApps = NSWorkspace.shared.runningApplications.filter { app in
|
||||||
guard let name = app.localizedName else {
|
guard let name = app.localizedName else {
|
||||||
return false
|
return false
|
||||||
@@ -154,6 +159,7 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let frontmostPid = frontmostApplicationPid()
|
let frontmostPid = frontmostApplicationPid()
|
||||||
|
var foundMinimizedTargetWindow = false
|
||||||
|
|
||||||
for app in runningApps {
|
for app in runningApps {
|
||||||
let appElement = AXUIElementCreateApplication(app.processIdentifier)
|
let appElement = AXUIElementCreateApplication(app.processIdentifier)
|
||||||
@@ -168,14 +174,12 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for window in windows {
|
for window in windows {
|
||||||
var minimizedRef: CFTypeRef?
|
var windowPid: pid_t = 0
|
||||||
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
|
if AXUIElementGetPid(window, &windowPid) != .success {
|
||||||
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var windowPid: pid_t = 0
|
if windowPid != app.processIdentifier {
|
||||||
if AXUIElementGetPid(window, &windowPid) != .success {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,15 +187,28 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var minimizedRef: CFTypeRef?
|
||||||
|
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
|
||||||
|
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
|
||||||
|
foundMinimizedTargetWindow = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if let geometry = geometryFromAXWindow(window) {
|
if let geometry = geometryFromAXWindow(window) {
|
||||||
return WindowState(
|
return .visible(
|
||||||
geometry: geometry,
|
WindowState(
|
||||||
focused: frontmostPid == windowPid
|
geometry: geometry,
|
||||||
|
focused: frontmostPid == windowPid
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if foundMinimizedTargetWindow {
|
||||||
|
return .minimized
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +267,25 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let window = windowStateFromAccessibilityAPI() ?? windowStateFromCoreGraphics() {
|
private let lookupResult: WindowLookupResult? = {
|
||||||
print(
|
if let axResult = windowStateFromAccessibilityAPI() {
|
||||||
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
|
return axResult
|
||||||
)
|
}
|
||||||
|
if let cgWindow = windowStateFromCoreGraphics() {
|
||||||
|
return .visible(cgWindow)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
if let result = lookupResult {
|
||||||
|
switch result {
|
||||||
|
case .visible(let window):
|
||||||
|
print(
|
||||||
|
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
|
||||||
|
)
|
||||||
|
case .minimized:
|
||||||
|
print("minimized")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
print("not-found")
|
print("not-found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
|
||||||
|
|
||||||
|
test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
|
||||||
|
const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
|
||||||
|
assert.notEqual(minimizedAssignmentIndex, -1);
|
||||||
|
|
||||||
|
const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
|
||||||
|
assert.notEqual(loopStartIndex, -1);
|
||||||
|
|
||||||
|
const pidExtractionIndex = source.indexOf(
|
||||||
|
'AXUIElementGetPid(window, &windowPid)',
|
||||||
|
loopStartIndex,
|
||||||
|
);
|
||||||
|
const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
|
||||||
|
const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
|
||||||
|
'window PID must be extracted before accepting a minimized window',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
|
||||||
|
'window PID must match the owning app before accepting a minimized window',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
|
||||||
|
'target socket must be validated before accepting a minimized window',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -130,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
openYomitanSettingsDelayed: (delayMs) => {
|
openYomitanSettingsDelayed: (delayMs) => {
|
||||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||||
},
|
},
|
||||||
openFirstRunSetup: () => {
|
openFirstRunSetup: (force?: boolean) => {
|
||||||
calls.push('openFirstRunSetup');
|
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||||
},
|
},
|
||||||
setVisibleOverlayVisible: (visible) => {
|
setVisibleOverlayVisible: (visible) => {
|
||||||
calls.push(`setVisibleOverlayVisible:${visible}`);
|
calls.push(`setVisibleOverlayVisible:${visible}`);
|
||||||
@@ -247,6 +247,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
log: (message) => {
|
log: (message) => {
|
||||||
calls.push(`log:${message}`);
|
calls.push(`log:${message}`);
|
||||||
},
|
},
|
||||||
|
logDebug: (message) => {
|
||||||
|
calls.push(`debug:${message}`);
|
||||||
|
},
|
||||||
warn: (message) => {
|
warn: (message) => {
|
||||||
calls.push(`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', () => {
|
test('handleCliCommand opens first-run setup window for --setup', () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
|
|
||||||
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
|
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
|
||||||
|
|
||||||
assert.ok(calls.includes('openFirstRunSetup'));
|
assert.ok(calls.includes('openFirstRunSetup:force'));
|
||||||
assert.ok(calls.includes('log:Opened first-run setup flow.'));
|
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);
|
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
|
|||||||
initializeOverlayRuntime: () => void;
|
initializeOverlayRuntime: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
togglePrimarySubtitleBar: () => void;
|
togglePrimarySubtitleBar: () => void;
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: (force?: boolean) => void;
|
||||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
|||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
error: (message: string, err: unknown) => void;
|
error: (message: string, err: unknown) => void;
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@ interface MiningCliRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UiCliRuntime {
|
interface UiCliRuntime {
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: (force?: boolean) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
@@ -211,6 +212,7 @@ export interface CliCommandDepsRuntimeOptions {
|
|||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
schedule: (fn: () => void, delayMs: number) => unknown;
|
schedule: (fn: () => void, delayMs: number) => unknown;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
error: (message: string, err: unknown) => void;
|
error: (message: string, err: unknown) => void;
|
||||||
}
|
}
|
||||||
@@ -286,6 +288,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||||
showMpvOsd: options.mpv.showOsd,
|
showMpvOsd: options.mpv.showOsd,
|
||||||
log: options.log,
|
log: options.log,
|
||||||
|
logDebug: options.logDebug,
|
||||||
warn: options.warn,
|
warn: options.warn,
|
||||||
error: options.error,
|
error: options.error,
|
||||||
};
|
};
|
||||||
@@ -378,8 +381,8 @@ export function handleCliCommand(
|
|||||||
} else if (args.togglePrimarySubtitleBar) {
|
} else if (args.togglePrimarySubtitleBar) {
|
||||||
deps.togglePrimarySubtitleBar();
|
deps.togglePrimarySubtitleBar();
|
||||||
} else if (args.setup) {
|
} else if (args.setup) {
|
||||||
deps.openFirstRunSetup();
|
deps.openFirstRunSetup(true);
|
||||||
deps.log('Opened first-run setup flow.');
|
deps.logDebug('Opened first-run setup flow.');
|
||||||
} else if (args.settings) {
|
} else if (args.settings) {
|
||||||
deps.openYomitanSettingsDelayed(1000);
|
deps.openYomitanSettingsDelayed(1000);
|
||||||
} else if (args.show || args.showVisibleOverlay) {
|
} else if (args.show || args.showVisibleOverlay) {
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ test('initializeOverlayRuntime hides overlay windows when tracker loses the targ
|
|||||||
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
|
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
|
test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const tracker = {
|
const tracker = {
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||||
@@ -600,7 +600,7 @@ test('initializeOverlayRuntime hides visible overlay on Windows tracker loss whe
|
|||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
tracker.onWindowLost?.();
|
tracker.onWindowLost?.();
|
||||||
|
|
||||||
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
|
assert.deepEqual(calls, ['update-visible']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
|
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
|
||||||
|
|||||||
@@ -105,10 +105,14 @@ export function initializeOverlayRuntime(options: {
|
|||||||
};
|
};
|
||||||
windowTracker.onWindowLost = () => {
|
windowTracker.onWindowLost = () => {
|
||||||
options.releaseOverlayOwner?.();
|
options.releaseOverlayOwner?.();
|
||||||
for (const window of options.getOverlayWindows()) {
|
if (windowTracker.isTargetWindowMinimized()) {
|
||||||
window.hide();
|
for (const window of options.getOverlayWindows()) {
|
||||||
|
window.hide();
|
||||||
|
}
|
||||||
|
options.syncOverlayShortcuts();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
options.syncOverlayShortcuts();
|
options.updateVisibleOverlayVisibility();
|
||||||
};
|
};
|
||||||
windowTracker.onWindowFocusChange = () => {
|
windowTracker.onWindowFocusChange = () => {
|
||||||
if (options.isVisibleOverlayVisible()) {
|
if (options.isVisibleOverlayVisible()) {
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
|
|||||||
assert.ok(!calls.includes('update-bounds'));
|
assert.ok(!calls.includes('update-bounds'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS tracked visible overlay stays interactive without passively stealing focus', () => {
|
test('macOS tracked visible overlay starts click-through without passively stealing focus', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
@@ -915,12 +915,52 @@ test('macOS tracked visible overlay stays interactive without passively stealing
|
|||||||
isWindowsPlatform: false,
|
isWindowsPlatform: false,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('show'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS keeps active mpv overlay visible and interactive during tracker refresh', () => {
|
test('macOS tracked visible overlay remains click-through even if the overlay had focus', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFocused(true);
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('focus'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS keeps active mpv overlay visible and click-through during tracker refresh', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
@@ -961,7 +1001,7 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
|
|||||||
|
|
||||||
assert.ok(calls.includes('update-bounds'));
|
assert.ok(calls.includes('update-bounds'));
|
||||||
assert.ok(calls.includes('sync-layer'));
|
assert.ok(calls.includes('sync-layer'));
|
||||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('ensure-level'));
|
assert.ok(calls.includes('ensure-level'));
|
||||||
assert.ok(calls.includes('enforce-order'));
|
assert.ok(calls.includes('enforce-order'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
@@ -1060,7 +1100,7 @@ test('macOS preserves an already visible active mpv overlay while tracker is tem
|
|||||||
|
|
||||||
assert.equal(trackerWarning, false);
|
assert.equal(trackerWarning, false);
|
||||||
assert.ok(calls.includes('sync-layer'));
|
assert.ok(calls.includes('sync-layer'));
|
||||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('ensure-level'));
|
assert.ok(calls.includes('ensure-level'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
assert.ok(!calls.includes('hide'));
|
assert.ok(!calls.includes('hide'));
|
||||||
@@ -1390,7 +1430,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
|
|||||||
assert.deepEqual(osdMessages, []);
|
assert.deepEqual(osdMessages, []);
|
||||||
assert.ok(calls.includes('update-bounds'));
|
assert.ok(calls.includes('update-bounds'));
|
||||||
assert.ok(calls.includes('sync-layer'));
|
assert.ok(calls.includes('sync-layer'));
|
||||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('ensure-level'));
|
assert.ok(calls.includes('ensure-level'));
|
||||||
assert.ok(calls.includes('enforce-order'));
|
assert.ok(calls.includes('enforce-order'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
@@ -1398,6 +1438,55 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
|
|||||||
assert.ok(!calls.includes('show'));
|
assert.ok(!calls.includes('show'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => false,
|
||||||
|
getGeometry: () => null,
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
showOverlayLoadingOsd: () => {
|
||||||
|
calls.push('loading-osd');
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('loading-osd'));
|
||||||
|
});
|
||||||
|
|
||||||
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
|
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
|
||||||
const { window } = createMainWindowRecorder();
|
const { window } = createMainWindowRecorder();
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
|
|||||||
@@ -94,15 +94,30 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const wasVisible = mainWindow.isVisible();
|
const wasVisible = mainWindow.isVisible();
|
||||||
const isVisibleOverlayFocused =
|
const isVisibleOverlayFocused =
|
||||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
||||||
|
const windowTracker = args.windowTracker;
|
||||||
|
const canReportMacOSTargetMinimized =
|
||||||
|
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
||||||
|
const isTrackedMacOSTargetMinimized =
|
||||||
|
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||||
|
const hasTransientMacOSTrackerLoss =
|
||||||
|
args.isMacOSPlatform &&
|
||||||
|
canReportMacOSTargetMinimized &&
|
||||||
|
!!windowTracker &&
|
||||||
|
!windowTracker.isTracking() &&
|
||||||
|
!isTrackedMacOSTargetMinimized &&
|
||||||
|
mainWindow.isVisible();
|
||||||
const isTrackedMacOSTargetFocused =
|
const isTrackedMacOSTargetFocused =
|
||||||
!args.isMacOSPlatform || !args.windowTracker
|
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||||
? true
|
? true
|
||||||
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
||||||
const shouldReleaseMacOSOverlayLevel =
|
const shouldReleaseMacOSOverlayLevel =
|
||||||
args.isMacOSPlatform &&
|
args.isMacOSPlatform &&
|
||||||
!!args.windowTracker &&
|
!!args.windowTracker &&
|
||||||
|
!hasTransientMacOSTrackerLoss &&
|
||||||
!isVisibleOverlayFocused &&
|
!isVisibleOverlayFocused &&
|
||||||
!isTrackedMacOSTargetFocused;
|
!isTrackedMacOSTargetFocused;
|
||||||
|
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
|
||||||
|
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
|
||||||
const shouldDefaultToPassthrough =
|
const shouldDefaultToPassthrough =
|
||||||
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
||||||
const windowsForegroundProcessName =
|
const windowsForegroundProcessName =
|
||||||
@@ -127,6 +142,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||||
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
||||||
const shouldIgnoreMouseEvents =
|
const shouldIgnoreMouseEvents =
|
||||||
|
shouldUseMacOSMousePassthrough ||
|
||||||
forceMousePassthrough ||
|
forceMousePassthrough ||
|
||||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||||
@@ -274,9 +290,16 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
||||||
const hasActiveMacOSTargetSignal =
|
const hasActiveMacOSTargetSignal =
|
||||||
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
||||||
|
const canReportMacOSTargetMinimized =
|
||||||
|
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
||||||
|
const isTrackedMacOSTargetMinimized =
|
||||||
|
canReportMacOSTargetMinimized && args.windowTracker.isTargetWindowMinimized();
|
||||||
const shouldPreserveTransientTrackedOverlay =
|
const shouldPreserveTransientTrackedOverlay =
|
||||||
(args.isMacOSPlatform &&
|
(args.isMacOSPlatform &&
|
||||||
(hasRetainedTrackedGeometry || (mainWindow.isVisible() && hasActiveMacOSTargetSignal))) ||
|
!isTrackedMacOSTargetMinimized &&
|
||||||
|
(hasRetainedTrackedGeometry ||
|
||||||
|
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
||||||
|
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
||||||
(args.isWindowsPlatform &&
|
(args.isWindowsPlatform &&
|
||||||
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
|
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
!args.windowTracker.isTargetWindowMinimized());
|
!args.windowTracker.isTargetWindowMinimized());
|
||||||
|
|||||||
@@ -358,3 +358,89 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
|
|||||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||||
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
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;
|
createImmersionTracker?: () => void;
|
||||||
startJellyfinRemoteSession?: () => Promise<void>;
|
startJellyfinRemoteSession?: () => Promise<void>;
|
||||||
loadYomitanExtension: () => Promise<void>;
|
loadYomitanExtension: () => Promise<void>;
|
||||||
|
ensureYomitanExtensionLoaded?: () => Promise<void>;
|
||||||
handleFirstRunSetup: () => Promise<void>;
|
handleFirstRunSetup: () => Promise<void>;
|
||||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||||
startBackgroundWarmups: () => void;
|
startBackgroundWarmups: () => void;
|
||||||
@@ -215,6 +216,8 @@ export function isAutoUpdateEnabledRuntime(
|
|||||||
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
|
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
|
||||||
const now = deps.now ?? (() => Date.now());
|
const now = deps.now ?? (() => Date.now());
|
||||||
const startupStartedAtMs = now();
|
const startupStartedAtMs = now();
|
||||||
|
const ensureYomitanExtensionReady =
|
||||||
|
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
|
||||||
deps.ensureDefaultConfigBootstrap();
|
deps.ensureDefaultConfigBootstrap();
|
||||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
@@ -224,7 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
} else {
|
} else {
|
||||||
deps.createMpvClient();
|
deps.createMpvClient();
|
||||||
deps.createSubtitleTimingTracker();
|
deps.createSubtitleTimingTracker();
|
||||||
await deps.loadYomitanExtension();
|
await ensureYomitanExtensionReady();
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
}
|
}
|
||||||
@@ -237,18 +240,10 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
|
||||||
await deps.loadYomitanExtension();
|
|
||||||
deps.reloadConfig();
|
|
||||||
await deps.handleFirstRunSetup();
|
|
||||||
deps.handleInitialArgs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.logDebug?.('App-ready critical path started.');
|
deps.logDebug?.('App-ready critical path started.');
|
||||||
|
|
||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
if (deps.shouldSkipHeavyStartup?.()) {
|
||||||
await deps.loadYomitanExtension();
|
await ensureYomitanExtensionReady();
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
@@ -319,12 +314,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
if (deps.texthookerOnlyMode) {
|
if (deps.texthookerOnlyMode) {
|
||||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||||
await deps.loadYomitanExtension();
|
await ensureYomitanExtensionReady();
|
||||||
deps.setVisibleOverlayVisible(true);
|
deps.setVisibleOverlayVisible(true);
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
} else {
|
} else {
|
||||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||||
await deps.loadYomitanExtension();
|
await ensureYomitanExtensionReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ensureExtensionCopyAsync,
|
ensureExtensionCopyAsync,
|
||||||
shouldCopyYomitanExtension,
|
shouldCopyYomitanExtension,
|
||||||
} from './yomitan-extension-copy';
|
} from './yomitan-extension-copy';
|
||||||
|
import { withSuppressedYomitanExtensionWarnings } from './yomitan-extension-loader';
|
||||||
|
|
||||||
function makeTempDir(prefix: string): string {
|
function makeTempDir(prefix: string): string {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
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');
|
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', () => {
|
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
|
||||||
const tempRoot = makeTempDir('subminer-yomitan-copy-');
|
const tempRoot = makeTempDir('subminer-yomitan-copy-');
|
||||||
const sourceDir = path.join(tempRoot, 'source');
|
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[0].copied, true);
|
||||||
assert.equal(results[1].copied, true);
|
assert.equal(results[1].copied, true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.readFileSync(
|
fs.readFileSync(path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'), 'utf8'),
|
||||||
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
|
|
||||||
'utf8',
|
|
||||||
),
|
|
||||||
'new settings code',
|
'new settings code',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -29,6 +29,85 @@ export interface YomitanExtensionLoaderDeps {
|
|||||||
setYomitanSession: (session: Session | null) => void;
|
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(
|
export async function loadYomitanExtension(
|
||||||
deps: YomitanExtensionLoaderDeps,
|
deps: YomitanExtensionLoaderDeps,
|
||||||
): Promise<Extension | null> {
|
): Promise<Extension | null> {
|
||||||
@@ -79,9 +158,20 @@ export async function loadYomitanExtension(
|
|||||||
return null;
|
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) {
|
if (extensionCopy.copied) {
|
||||||
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||||
}
|
}
|
||||||
extPath = extensionCopy.targetDir;
|
extPath = extensionCopy.targetDir;
|
||||||
}
|
}
|
||||||
@@ -91,13 +181,15 @@ export async function loadYomitanExtension(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const extensions = targetSession.extensions;
|
const extensions = targetSession.extensions;
|
||||||
const extension = extensions
|
const extension = await withSuppressedYomitanExtensionWarnings(() =>
|
||||||
? await extensions.loadExtension(extPath, {
|
extensions
|
||||||
allowFileAccess: true,
|
? extensions.loadExtension(extPath, {
|
||||||
})
|
allowFileAccess: true,
|
||||||
: await targetSession.loadExtension(extPath, {
|
})
|
||||||
allowFileAccess: true,
|
: targetSession.loadExtension(extPath, {
|
||||||
});
|
allowFileAccess: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
deps.setYomitanExtension(extension);
|
deps.setYomitanExtension(extension);
|
||||||
return extension;
|
return extension;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,27 +2,101 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildYomitanSettingsCloseButtonScript,
|
||||||
|
buildYomitanSettingsWindowMenuTemplate,
|
||||||
buildYomitanSettingsUrl,
|
buildYomitanSettingsUrl,
|
||||||
configureYomitanSettingsWindowChrome,
|
configureYomitanSettingsWindowChrome,
|
||||||
destroyYomitanSettingsWindow,
|
destroyYomitanSettingsWindow,
|
||||||
|
installYomitanSettingsCloseButton,
|
||||||
showYomitanSettingsWindow,
|
showYomitanSettingsWindow,
|
||||||
|
shouldInstallYomitanSettingsCloseButton,
|
||||||
} from './yomitan-settings';
|
} 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[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
configureYomitanSettingsWindowChrome({
|
configureYomitanSettingsWindowChrome({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
close: () => calls.push('close'),
|
||||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
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', () => {
|
test('yomitan settings URL disables the embedded popup preview', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildYomitanSettingsUrl('abc123'),
|
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 electron from 'electron';
|
||||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
|
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
|
||||||
const logger = createLogger('main:yomitan-settings');
|
const logger = createLogger('main:yomitan-settings');
|
||||||
|
|
||||||
export interface OpenYomitanSettingsWindowOptions {
|
export interface OpenYomitanSettingsWindowOptions {
|
||||||
@@ -13,15 +13,127 @@ export interface OpenYomitanSettingsWindowOptions {
|
|||||||
onWindowClosed?: () => void;
|
onWindowClosed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureYomitanSettingsWindowChrome(
|
type YomitanSettingsWindowMenuOwner = Pick<BrowserWindow, 'close' | 'isDestroyed'>;
|
||||||
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
|
|
||||||
|
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 {
|
): void {
|
||||||
settingsWindow.setAutoHideMenuBar(true);
|
if (settingsWindow.isDestroyed()) {
|
||||||
settingsWindow.setMenu(null);
|
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 {
|
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 {
|
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
|
||||||
@@ -108,6 +220,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
|||||||
|
|
||||||
settingsWindow.webContents.on('did-finish-load', () => {
|
settingsWindow.webContents.on('did-finish-load', () => {
|
||||||
logger.info('Settings page loaded successfully');
|
logger.info('Settings page loaded successfully');
|
||||||
|
installYomitanSettingsCloseButton(settingsWindow);
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
+49
-24
@@ -82,6 +82,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
|||||||
return {
|
return {
|
||||||
shouldUseMinimalStartup: Boolean(
|
shouldUseMinimalStartup: Boolean(
|
||||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||||
|
initialArgs?.update ||
|
||||||
(initialArgs?.stats &&
|
(initialArgs?.stats &&
|
||||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||||
),
|
),
|
||||||
@@ -90,6 +91,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
|||||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||||
initialArgs.stats ||
|
initialArgs.stats ||
|
||||||
initialArgs.dictionary ||
|
initialArgs.dictionary ||
|
||||||
|
initialArgs.update ||
|
||||||
initialArgs.setup),
|
initialArgs.setup),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -365,6 +367,7 @@ import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/
|
|||||||
import {
|
import {
|
||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
getFirstRunSetupCompletionMessage,
|
getFirstRunSetupCompletionMessage,
|
||||||
|
isStandaloneFirstRunSetupCommand,
|
||||||
shouldAutoOpenFirstRunSetup,
|
shouldAutoOpenFirstRunSetup,
|
||||||
} from './main/runtime/first-run-setup-service';
|
} from './main/runtime/first-run-setup-service';
|
||||||
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||||
@@ -483,13 +486,13 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
|
|||||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||||
|
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
||||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||||
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
|
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
|
||||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||||
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
|
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
|
||||||
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
|
|
||||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||||
import {
|
import {
|
||||||
createFrequencyDictionaryRuntimeService,
|
createFrequencyDictionaryRuntimeService,
|
||||||
@@ -508,22 +511,21 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
|||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
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 {
|
import {
|
||||||
fetchLatestStableRelease,
|
fetchLatestStableRelease,
|
||||||
fetchReleaseAssetBuffer,
|
fetchReleaseAssetBuffer,
|
||||||
fetchReleaseAssetText,
|
fetchReleaseAssetText,
|
||||||
findReleaseAsset,
|
findReleaseAsset,
|
||||||
parseSha256Sums,
|
parseSha256Sums,
|
||||||
|
type GitHubRelease,
|
||||||
} from './main/runtime/update/release-assets';
|
} from './main/runtime/update/release-assets';
|
||||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||||
import {
|
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
||||||
showNoUpdateDialog,
|
|
||||||
showRestartDialog,
|
|
||||||
showUpdateAvailableDialog,
|
|
||||||
showUpdateFailedDialog,
|
|
||||||
} from './main/runtime/update/update-dialogs';
|
|
||||||
import {
|
import {
|
||||||
runUpdateCliCommand,
|
runUpdateCliCommand,
|
||||||
writeUpdateCliCommandResponse,
|
writeUpdateCliCommandResponse,
|
||||||
@@ -847,6 +849,9 @@ const appLogger = {
|
|||||||
logInfo: (message: string) => {
|
logInfo: (message: string) => {
|
||||||
logger.info(message);
|
logger.info(message);
|
||||||
},
|
},
|
||||||
|
logDebug: (message: string) => {
|
||||||
|
logger.debug(message);
|
||||||
|
},
|
||||||
logWarning: (message: string) => {
|
logWarning: (message: string) => {
|
||||||
logger.warn(message);
|
logger.warn(message);
|
||||||
},
|
},
|
||||||
@@ -1479,9 +1484,11 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
|
|||||||
},
|
},
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showMpvOsd(text),
|
||||||
openManualPicker: (payload) => {
|
openManualPicker: (payload) => {
|
||||||
sendToActiveOverlayWindow('subsync:open-manual', payload, {
|
openOverlayHostedModalWithOsd(
|
||||||
restoreOnModalClose: 'subsync',
|
(deps) => openSubsyncManualModalRuntime(deps, payload),
|
||||||
});
|
'Subsync overlay unavailable.',
|
||||||
|
'Failed to open subsync overlay.',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const immersionMediaRuntime = createImmersionMediaRuntime(
|
const immersionMediaRuntime = createImmersionMediaRuntime(
|
||||||
@@ -2900,6 +2907,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
},
|
},
|
||||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
||||||
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
|
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
|
||||||
|
shouldQuitWhenClosedCompleted: () =>
|
||||||
|
Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
clearSetupWindow: () => {
|
clearSetupWindow: () => {
|
||||||
appState.firstRunSetupWindow = null;
|
appState.firstRunSetupWindow = null;
|
||||||
@@ -3731,6 +3740,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
reloadConfigMainDeps: {
|
reloadConfigMainDeps: {
|
||||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||||
logInfo: (message) => appLogger.logInfo(message),
|
logInfo: (message) => appLogger.logInfo(message),
|
||||||
|
logDebug: (message) => appLogger.logDebug(message),
|
||||||
logWarning: (message) => appLogger.logWarning(message),
|
logWarning: (message) => appLogger.logWarning(message),
|
||||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||||
startConfigHotReload: () => configHotReloadRuntime.start(),
|
startConfigHotReload: () => configHotReloadRuntime.start(),
|
||||||
@@ -3854,6 +3864,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
loadYomitanExtension: async () => {
|
loadYomitanExtension: async () => {
|
||||||
await loadYomitanExtension();
|
await loadYomitanExtension();
|
||||||
},
|
},
|
||||||
|
ensureYomitanExtensionLoaded: async () => {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
},
|
||||||
handleFirstRunSetup: async () => {
|
handleFirstRunSetup: async () => {
|
||||||
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
|
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
|
||||||
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
|
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
|
||||||
@@ -4611,12 +4624,12 @@ function getFetchForUpdater() {
|
|||||||
return globalThis.fetch.bind(globalThis);
|
return globalThis.fetch.bind(globalThis);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateLauncherFromLatestRelease(
|
async function updateLauncherFromSelectedRelease(
|
||||||
launcherPath?: string,
|
launcherPath?: string,
|
||||||
channel: UpdateChannel = getResolvedConfig().updates.channel,
|
channel: UpdateChannel = getResolvedConfig().updates.channel,
|
||||||
|
release: GitHubRelease | null = null,
|
||||||
) {
|
) {
|
||||||
const fetchForUpdater = getFetchForUpdater();
|
const fetchForUpdater = getFetchForUpdater();
|
||||||
const release = await fetchLatestStableRelease({ fetch: fetchForUpdater, channel });
|
|
||||||
if (!release) {
|
if (!release) {
|
||||||
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
|
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
|
||||||
}
|
}
|
||||||
@@ -4640,9 +4653,9 @@ async function updateLauncherFromLatestRelease(
|
|||||||
});
|
});
|
||||||
for (const result of supportResults) {
|
for (const result of supportResults) {
|
||||||
if (result.status === 'protected' && result.command) {
|
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') {
|
} 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;
|
return launcherResult;
|
||||||
@@ -4655,6 +4668,19 @@ function getUpdateService() {
|
|||||||
isPackaged: app.isPackaged,
|
isPackaged: app.isPackaged,
|
||||||
log: (message) => logger.info(message),
|
log: (message) => logger.info(message),
|
||||||
getChannel: () => getResolvedConfig().updates.channel,
|
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({
|
updateService = createUpdateService({
|
||||||
getConfig: () => getResolvedConfig().updates,
|
getConfig: () => getResolvedConfig().updates,
|
||||||
@@ -4665,16 +4691,14 @@ function getUpdateService() {
|
|||||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||||
fetchLatestStableRelease: (channel) =>
|
fetchLatestStableRelease: (channel) =>
|
||||||
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||||
updateLauncher: (launcherPath, channel) =>
|
updateLauncher: (launcherPath, channel, release) =>
|
||||||
updateLauncherFromLatestRelease(launcherPath, channel),
|
updateLauncherFromSelectedRelease(launcherPath, channel, release),
|
||||||
showNoUpdateDialog: (version) =>
|
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
|
||||||
showNoUpdateDialog((options) => dialog.showMessageBox(options), version),
|
|
||||||
showUpdateAvailableDialog: (version) =>
|
showUpdateAvailableDialog: (version) =>
|
||||||
showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version),
|
updateDialogPresenter.showUpdateAvailableDialog(version),
|
||||||
showUpdateFailedDialog: (message) =>
|
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
||||||
showUpdateFailedDialog((options) => dialog.showMessageBox(options), message),
|
|
||||||
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||||
showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)),
|
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
||||||
quitAndInstall: () => appUpdater.quitAndInstall(),
|
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||||
notifyUpdateAvailable: (version) =>
|
notifyUpdateAvailable: (version) =>
|
||||||
notifyUpdateAvailable(
|
notifyUpdateAvailable(
|
||||||
@@ -5307,7 +5331,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
||||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
|
||||||
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
||||||
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||||
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
|
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
|
||||||
@@ -5365,6 +5389,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|||||||
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
||||||
logInfo: (message: string) => logger.info(message),
|
logInfo: (message: string) => logger.info(message),
|
||||||
|
logDebug: (message: string) => logger.debug(message),
|
||||||
logWarn: (message: string) => logger.warn(message),
|
logWarn: (message: string) => logger.warn(message),
|
||||||
logError: (message: string, err: unknown) => logger.error(message, err),
|
logError: (message: string, err: unknown) => logger.error(message, err),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
|||||||
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
|
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
|
||||||
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
|
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
|
||||||
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
|
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
|
||||||
|
ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded'];
|
||||||
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
|
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
|
||||||
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
|
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
|
||||||
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
|
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
|
||||||
@@ -109,6 +110,7 @@ export function createAppReadyRuntimeDeps(
|
|||||||
createImmersionTracker: params.createImmersionTracker,
|
createImmersionTracker: params.createImmersionTracker,
|
||||||
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
|
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
|
||||||
loadYomitanExtension: params.loadYomitanExtension,
|
loadYomitanExtension: params.loadYomitanExtension,
|
||||||
|
ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded,
|
||||||
handleFirstRunSetup: params.handleFirstRunSetup,
|
handleFirstRunSetup: params.handleFirstRunSetup,
|
||||||
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
|
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
|
||||||
startBackgroundWarmups: params.startBackgroundWarmups,
|
startBackgroundWarmups: params.startBackgroundWarmups,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
initializeOverlay: () => void;
|
initializeOverlay: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
togglePrimarySubtitleBar: () => void;
|
togglePrimarySubtitleBar: () => void;
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: (force?: boolean) => void;
|
||||||
setVisibleOverlay: (visible: boolean) => void;
|
setVisibleOverlay: (visible: boolean) => void;
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||||
@@ -54,6 +54,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
error: (message: string, err: unknown) => void;
|
error: (message: string, err: unknown) => void;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,7 @@ function createCliCommandDepsFromContext(
|
|||||||
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
|
||||||
schedule: context.schedule,
|
schedule: context.schedule,
|
||||||
log: context.log,
|
log: context.log,
|
||||||
|
logDebug: context.logDebug,
|
||||||
warn: context.warn,
|
warn: context.warn,
|
||||||
error: context.error,
|
error: context.error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
|
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
|
||||||
schedule: CliCommandDepsRuntimeOptions['schedule'];
|
schedule: CliCommandDepsRuntimeOptions['schedule'];
|
||||||
log: CliCommandDepsRuntimeOptions['log'];
|
log: CliCommandDepsRuntimeOptions['log'];
|
||||||
|
logDebug: CliCommandDepsRuntimeOptions['logDebug'];
|
||||||
warn: CliCommandDepsRuntimeOptions['warn'];
|
warn: CliCommandDepsRuntimeOptions['warn'];
|
||||||
error: CliCommandDepsRuntimeOptions['error'];
|
error: CliCommandDepsRuntimeOptions['error'];
|
||||||
}
|
}
|
||||||
@@ -377,6 +378,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
|
||||||
schedule: params.schedule,
|
schedule: params.schedule,
|
||||||
log: params.log,
|
log: params.log,
|
||||||
|
logDebug: params.logDebug,
|
||||||
warn: params.warn,
|
warn: params.warn,
|
||||||
error: params.error,
|
error: params.error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
|||||||
loadYomitanExtension: async () => {
|
loadYomitanExtension: async () => {
|
||||||
calls.push('load-yomitan');
|
calls.push('load-yomitan');
|
||||||
},
|
},
|
||||||
|
ensureYomitanExtensionLoaded: async () => {
|
||||||
|
calls.push('ensure-yomitan');
|
||||||
|
},
|
||||||
handleFirstRunSetup: async () => {
|
handleFirstRunSetup: async () => {
|
||||||
calls.push('handle-first-run-setup');
|
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();
|
onReady.createMpvClient();
|
||||||
await onReady.createMecabTokenizerAndCheck();
|
await onReady.createMecabTokenizerAndCheck();
|
||||||
await onReady.loadYomitanExtension();
|
await onReady.loadYomitanExtension();
|
||||||
|
await onReady.ensureYomitanExtensionLoaded?.();
|
||||||
await onReady.handleFirstRunSetup();
|
await onReady.handleFirstRunSetup();
|
||||||
await onReady.prewarmSubtitleDictionaries?.();
|
await onReady.prewarmSubtitleDictionaries?.();
|
||||||
onReady.startBackgroundWarmups();
|
onReady.startBackgroundWarmups();
|
||||||
@@ -79,6 +83,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
|||||||
'create-mpv-client',
|
'create-mpv-client',
|
||||||
'create-mecab',
|
'create-mecab',
|
||||||
'load-yomitan',
|
'load-yomitan',
|
||||||
|
'ensure-yomitan',
|
||||||
'handle-first-run-setup',
|
'handle-first-run-setup',
|
||||||
'prewarm-dicts',
|
'prewarm-dicts',
|
||||||
'start-warmups',
|
'start-warmups',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
|||||||
createImmersionTracker: deps.createImmersionTracker,
|
createImmersionTracker: deps.createImmersionTracker,
|
||||||
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
|
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
|
||||||
loadYomitanExtension: deps.loadYomitanExtension,
|
loadYomitanExtension: deps.loadYomitanExtension,
|
||||||
|
ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded,
|
||||||
handleFirstRunSetup: deps.handleFirstRunSetup,
|
handleFirstRunSetup: deps.handleFirstRunSetup,
|
||||||
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
|
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
|
||||||
startBackgroundWarmups: deps.startBackgroundWarmups,
|
startBackgroundWarmups: deps.startBackgroundWarmups,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
return setTimeout(() => {}, 0);
|
return setTimeout(() => {}, 0);
|
||||||
},
|
},
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
logError: (message) => calls.push(`error:${message}`),
|
logError: (message) => calls.push(`error:${message}`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
initializeOverlay: () => void;
|
initializeOverlay: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
togglePrimarySubtitleBar: () => void;
|
togglePrimarySubtitleBar: () => void;
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: (force?: boolean) => void;
|
||||||
setVisibleOverlay: (visible: boolean) => void;
|
setVisibleOverlay: (visible: boolean) => void;
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||||
@@ -52,6 +52,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -106,6 +107,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||||
schedule: deps.schedule,
|
schedule: deps.schedule,
|
||||||
logInfo: deps.logInfo,
|
logInfo: deps.logInfo,
|
||||||
|
logDebug: deps.logDebug,
|
||||||
logWarn: deps.logWarn,
|
logWarn: deps.logWarn,
|
||||||
logError: deps.logError,
|
logError: deps.logError,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
getMultiCopyTimeoutMs: () => 5000,
|
getMultiCopyTimeoutMs: () => 5000,
|
||||||
schedule: (fn) => setTimeout(fn, 0),
|
schedule: (fn) => setTimeout(fn, 0),
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
|
logDebug: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
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}`),
|
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||||
|
|
||||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||||
@@ -110,6 +111,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
return setTimeout(() => {}, 0);
|
return setTimeout(() => {}, 0);
|
||||||
},
|
},
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
logWarn: (message) => calls.push(`warn:${message}`),
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
logError: (message) => calls.push(`error:${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);
|
assert.equal(deps.shouldOpenBrowser(), true);
|
||||||
deps.showOsd('hello');
|
deps.showOsd('hello');
|
||||||
deps.initializeOverlay();
|
deps.initializeOverlay();
|
||||||
deps.openFirstRunSetup();
|
deps.openFirstRunSetup(true);
|
||||||
deps.setVisibleOverlay(true);
|
deps.setVisibleOverlay(true);
|
||||||
deps.printHelp();
|
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();
|
const retry = await deps.retryAnilistQueueNow();
|
||||||
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
initializeOverlayRuntime: () => void;
|
initializeOverlayRuntime: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
togglePrimarySubtitleBar: () => void;
|
togglePrimarySubtitleBar: () => void;
|
||||||
openFirstRunSetupWindow: () => void;
|
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
|
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -97,7 +98,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||||
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
|
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
|
||||||
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
|
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force),
|
||||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||||
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
||||||
@@ -134,6 +135,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
|
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
|
||||||
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
|
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
|
logDebug: (message: string) => deps.logDebug(message),
|
||||||
logWarn: (message: string) => deps.logWarn(message),
|
logWarn: (message: string) => deps.logWarn(message),
|
||||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ function createDeps() {
|
|||||||
logInfo: (message: string) => {
|
logInfo: (message: string) => {
|
||||||
logs.push(`i:${message}`);
|
logs.push(`i:${message}`);
|
||||||
},
|
},
|
||||||
|
logDebug: (message: string) => {
|
||||||
|
logs.push(`d:${message}`);
|
||||||
|
},
|
||||||
logWarn: (message: string) => {
|
logWarn: (message: string) => {
|
||||||
logs.push(`w:${message}`);
|
logs.push(`w:${message}`);
|
||||||
},
|
},
|
||||||
@@ -102,7 +105,8 @@ test('cli command context log methods map to deps loggers', () => {
|
|||||||
const { deps, getLogs } = createDeps();
|
const { deps, getLogs } = createDeps();
|
||||||
const context = createCliCommandContext(deps);
|
const context = createCliCommandContext(deps);
|
||||||
context.log('info');
|
context.log('info');
|
||||||
|
context.logDebug('debug');
|
||||||
context.warn('warn');
|
context.warn('warn');
|
||||||
context.error('error', new Error('x'));
|
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;
|
initializeOverlay: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
togglePrimarySubtitleBar: () => void;
|
togglePrimarySubtitleBar: () => void;
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: (force?: boolean) => void;
|
||||||
setVisibleOverlay: (visible: boolean) => void;
|
setVisibleOverlay: (visible: boolean) => void;
|
||||||
copyCurrentSubtitle: () => void;
|
copyCurrentSubtitle: () => void;
|
||||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||||
@@ -57,6 +57,7 @@ export type CliCommandContextFactoryDeps = {
|
|||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
};
|
};
|
||||||
@@ -133,6 +134,7 @@ export function createCliCommandContext(
|
|||||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||||
schedule: deps.schedule,
|
schedule: deps.schedule,
|
||||||
log: deps.logInfo,
|
log: deps.logInfo,
|
||||||
|
logDebug: deps.logDebug,
|
||||||
warn: deps.logWarn,
|
warn: deps.logWarn,
|
||||||
error: deps.logError,
|
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 () => {
|
test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
|
||||||
const target = await resolveLauncherInstallTarget({
|
const target = await resolveLauncherInstallTarget({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
@@ -144,6 +159,53 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
|
|||||||
assert.equal(target.installPath, null);
|
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 () => {
|
test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
|
||||||
const files = new Map<string, string>();
|
const files = new Map<string, string>();
|
||||||
const dirs = new Set<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');
|
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 () => {
|
test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
|
||||||
const snapshot = await detectLauncher({
|
const snapshot = await detectLauncher({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
|
|||||||
@@ -72,21 +72,23 @@ const BUN_OFFICIAL_WINDOWS_COMMAND = [
|
|||||||
];
|
];
|
||||||
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const COMMAND_TIMEOUT_MS = 15 * 1000;
|
const COMMAND_TIMEOUT_MS = 15 * 1000;
|
||||||
|
const MACOS_HOMEBREW_PATH_DIRS = ['/opt/homebrew/bin'];
|
||||||
|
|
||||||
function installMethodForCommand(
|
function installMethodForCommand(command: string[] | null): BunSnapshot['installMethod'] {
|
||||||
command: string[] | null,
|
|
||||||
): BunSnapshot['installMethod'] {
|
|
||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
const executablePath = command[0];
|
const executablePath = command[0];
|
||||||
if (!executablePath) return null;
|
if (!executablePath) return null;
|
||||||
const executable = path.win32.basename(executablePath).toLowerCase();
|
const executable = path.basename(executablePath).toLowerCase();
|
||||||
if (executable === 'winget.exe') return 'winget';
|
const windowsExecutable = path.win32.basename(executablePath).toLowerCase();
|
||||||
if (executable === 'scoop.cmd') return 'scoop';
|
if (windowsExecutable === 'winget.exe') return 'winget';
|
||||||
if (executable === 'brew') return 'homebrew';
|
if (windowsExecutable === 'scoop.cmd') return 'scoop';
|
||||||
|
if (executable === 'brew' || windowsExecutable === 'brew') return 'homebrew';
|
||||||
return 'official-script';
|
return 'official-script';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
|
export function resolveBunInstallCommand(
|
||||||
|
options: CommonOptions = {},
|
||||||
|
): BunSnapshot['installCommand'] {
|
||||||
const platform = platformOf(options);
|
const platform = platformOf(options);
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
const winget = findCommand('winget.exe', options);
|
const winget = findCommand('winget.exe', options);
|
||||||
@@ -154,7 +156,8 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
|
|||||||
function resolveLauncherResourcePath(options: CommonOptions): string {
|
function resolveLauncherResourcePath(options: CommonOptions): string {
|
||||||
const platformPath = pathModuleFor(platformOf(options));
|
const platformPath = pathModuleFor(platformOf(options));
|
||||||
if (options.launcherResourcePath) return options.launcherResourcePath;
|
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;
|
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
|
||||||
if (packaged && existsSyncOf(options)(packaged)) return packaged;
|
if (packaged && existsSyncOf(options)(packaged)) return packaged;
|
||||||
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
|
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, '.local', 'bin'),
|
||||||
path.posix.join(homeDir, '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) =>
|
path.posix.join(homeDir, '.local', 'bin'),
|
||||||
all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
|
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) {
|
if (!selected) {
|
||||||
return {
|
return {
|
||||||
status: 'not_installable',
|
status: 'not_installable',
|
||||||
@@ -258,10 +297,14 @@ export async function detectLauncher(
|
|||||||
|
|
||||||
const commandPath = findCommand('subminer', options);
|
const commandPath = findCommand('subminer', options);
|
||||||
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
|
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 };
|
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) {
|
if (!commandPath) {
|
||||||
return {
|
return {
|
||||||
...target,
|
...target,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
|
|||||||
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
|
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
|
||||||
},
|
},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
|
logDebug: () => {},
|
||||||
logWarning: () => {},
|
logWarning: () => {},
|
||||||
showDesktopNotification: () => {},
|
showDesktopNotification: () => {},
|
||||||
startConfigHotReload: () => {},
|
startConfigHotReload: () => {},
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
|||||||
getMultiCopyTimeoutMs: () => 0,
|
getMultiCopyTimeoutMs: () => 0,
|
||||||
schedule: () => 0 as never,
|
schedule: () => 0 as never,
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
|
logDebug: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
jellyfinPreviewAuth: false,
|
jellyfinPreviewAuth: false,
|
||||||
texthooker: false,
|
texthooker: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
|
update: false,
|
||||||
help: false,
|
help: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
generateConfig: false,
|
generateConfig: false,
|
||||||
@@ -124,6 +125,7 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), 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', () => {
|
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
|||||||
args.jellyfinRemoteAnnounce ||
|
args.jellyfinRemoteAnnounce ||
|
||||||
args.jellyfinPreviewAuth ||
|
args.jellyfinPreviewAuth ||
|
||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
|
args.update ||
|
||||||
args.help,
|
args.help,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,6 +130,10 @@ export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean {
|
|||||||
return !hasAnyStartupCommandBeyondSetup(args);
|
return !hasAnyStartupCommandBeyondSetup(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isStandaloneFirstRunSetupCommand(args: CliArgs): boolean {
|
||||||
|
return args.setup && !args.start && !hasAnyStartupCommandBeyondSetup(args);
|
||||||
|
}
|
||||||
|
|
||||||
function getPluginStatus(
|
function getPluginStatus(
|
||||||
state: SetupState,
|
state: SetupState,
|
||||||
pluginInstalled: boolean,
|
pluginInstalled: boolean,
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
assert.match(html, /Open Yomitan Settings/);
|
assert.match(html, /Open Yomitan Settings/);
|
||||||
assert.match(html, /Finish setup/);
|
assert.match(html, /Finish setup/);
|
||||||
assert.match(html, /disabled/);
|
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', () => {
|
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, /Installed, Bun missing/);
|
||||||
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
|
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
|
||||||
assert.match(html, /action=install-command-line-launcher/);
|
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', () => {
|
test('first-run setup window handler focuses existing window', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||||
getSetupWindow: () => ({
|
getSetupWindow: () => ({
|
||||||
|
show: () => calls.push('show'),
|
||||||
focus: () => calls.push('focus'),
|
focus: () => calls.push('focus'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(maybeFocus(), true);
|
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 () => {
|
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']);
|
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 () => {
|
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let closedHandler: (() => void) | undefined;
|
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']);
|
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 = {
|
type FocusableWindowLike = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
|
show?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FirstRunSetupWebContentsLike = {
|
type FirstRunSetupWebContentsLike = {
|
||||||
@@ -124,7 +125,9 @@ function getLauncherTone(
|
|||||||
return 'muted';
|
return 'muted';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
|
function renderCommandLineLauncherSection(
|
||||||
|
commandLineLauncher: CommandLineLauncherSnapshot,
|
||||||
|
): string {
|
||||||
if (!commandLineLauncher.supported) {
|
if (!commandLineLauncher.supported) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -154,7 +157,7 @@ function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLaunch
|
|||||||
bun.status === 'missing' || bun.status === 'failed'
|
bun.status === 'missing' || bun.status === 'failed'
|
||||||
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
|
? `<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 `
|
return `
|
||||||
<section class="setup-section">
|
<section class="setup-section">
|
||||||
@@ -345,13 +348,20 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
--yellow: #eed49f;
|
--yellow: #eed49f;
|
||||||
--red: #ed8796;
|
--red: #ed8796;
|
||||||
}
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
background: linear-gradient(180deg, var(--mantle), var(--base));
|
background: linear-gradient(180deg, var(--mantle), var(--base));
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 100vh;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
@@ -583,6 +593,7 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
|
|||||||
return (): boolean => {
|
return (): boolean => {
|
||||||
const window = deps.getSetupWindow();
|
const window = deps.getSetupWindow();
|
||||||
if (!window) return false;
|
if (!window) return false;
|
||||||
|
window.show?.();
|
||||||
window.focus();
|
window.focus();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -626,6 +637,7 @@ export function createOpenFirstRunSetupWindowHandler<
|
|||||||
markSetupCancelled: () => Promise<unknown>;
|
markSetupCancelled: () => Promise<unknown>;
|
||||||
isSetupCompleted: () => boolean;
|
isSetupCompleted: () => boolean;
|
||||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
shouldQuitWhenClosedIncomplete: () => boolean;
|
||||||
|
shouldQuitWhenClosedCompleted?: () => boolean;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
clearSetupWindow: () => void;
|
clearSetupWindow: () => void;
|
||||||
setSetupWindow: (window: TWindow) => void;
|
setSetupWindow: (window: TWindow) => void;
|
||||||
@@ -639,11 +651,23 @@ export function createOpenFirstRunSetupWindowHandler<
|
|||||||
|
|
||||||
const setupWindow = deps.createSetupWindow();
|
const setupWindow = deps.createSetupWindow();
|
||||||
deps.setSetupWindow(setupWindow);
|
deps.setSetupWindow(setupWindow);
|
||||||
|
setupWindow.show?.();
|
||||||
|
setupWindow.focus();
|
||||||
|
|
||||||
const render = async (): Promise<void> => {
|
const render = async (): Promise<void> => {
|
||||||
const model = await deps.getSetupSnapshot();
|
const model = await deps.getSetupSnapshot();
|
||||||
|
if (setupWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const html = deps.buildSetupHtml(model);
|
const html = deps.buildSetupHtml(model);
|
||||||
|
if (setupWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`);
|
await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`);
|
||||||
|
if (!setupWindow.isDestroyed()) {
|
||||||
|
setupWindow.show?.();
|
||||||
|
setupWindow.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||||
@@ -682,7 +706,10 @@ export function createOpenFirstRunSetupWindowHandler<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
deps.clearSetupWindow();
|
deps.clearSetupWindow();
|
||||||
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
|
if (
|
||||||
|
(setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) ||
|
||||||
|
(!setupCompleted && deps.shouldQuitWhenClosedIncomplete())
|
||||||
|
) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
|
|||||||
|
|
||||||
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
|
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
|
||||||
assert.deepEqual(options, {
|
assert.deepEqual(options, {
|
||||||
width: 560,
|
width: 720,
|
||||||
height: 640,
|
height: 860,
|
||||||
title: 'SubMiner Setup',
|
title: 'SubMiner Setup',
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
|
|||||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||||
}) {
|
}) {
|
||||||
return createSetupWindowHandler(deps, {
|
return createSetupWindowHandler(deps, {
|
||||||
width: 560,
|
width: 720,
|
||||||
height: 640,
|
height: 860,
|
||||||
title: 'SubMiner Setup',
|
title: 'SubMiner Setup',
|
||||||
resizable: false,
|
resizable: false,
|
||||||
minimizable: false,
|
minimizable: false,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
|||||||
const deps = createBuildReloadConfigMainDepsHandler({
|
const deps = createBuildReloadConfigMainDepsHandler({
|
||||||
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
|
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
logWarning: (message) => calls.push(`warn:${message}`),
|
logWarning: (message) => calls.push(`warn:${message}`),
|
||||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||||
startConfigHotReload: () => calls.push('start-hot-reload'),
|
startConfigHotReload: () => calls.push('start-hot-reload'),
|
||||||
@@ -30,6 +31,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
|||||||
warnings: [],
|
warnings: [],
|
||||||
});
|
});
|
||||||
deps.logInfo('x');
|
deps.logInfo('x');
|
||||||
|
deps.logDebug('debug');
|
||||||
deps.logWarning('y');
|
deps.logWarning('y');
|
||||||
deps.showDesktopNotification('SubMiner', { body: 'warn' });
|
deps.showDesktopNotification('SubMiner', { body: 'warn' });
|
||||||
deps.startConfigHotReload();
|
deps.startConfigHotReload();
|
||||||
@@ -39,6 +41,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
|||||||
deps.failHandlers.quit();
|
deps.failHandlers.quit();
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'info:x',
|
'info:x',
|
||||||
|
'debug:debug',
|
||||||
'warn:y',
|
'warn:y',
|
||||||
'notify:SubMiner:warn',
|
'notify:SubMiner:warn',
|
||||||
'start-hot-reload',
|
'start-hot-reload',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
|
|||||||
return (): ReloadConfigMainDeps => ({
|
return (): ReloadConfigMainDeps => ({
|
||||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
|
logDebug: (message: string) => deps.logDebug(message),
|
||||||
logWarning: (message: string) => deps.logWarning(message),
|
logWarning: (message: string) => deps.logWarning(message),
|
||||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||||
deps.showDesktopNotification(title, options),
|
deps.showDesktopNotification(title, options),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
logWarning: (message) => calls.push(`warn:${message}`),
|
logWarning: (message) => calls.push(`warn:${message}`),
|
||||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||||
@@ -36,7 +37,11 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
|||||||
reloadConfig();
|
reloadConfig();
|
||||||
await Promise.resolve();
|
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.startsWith('warn:[config] Validation found 1 issue(s)')));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')),
|
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',
|
error: 'unexpected token',
|
||||||
}),
|
}),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
logWarning: (message) => calls.push(`warn:${message}`),
|
logWarning: (message) => calls.push(`warn:${message}`),
|
||||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||||
@@ -102,6 +108,7 @@ test('createReloadConfigHandler can skip AniList refresh for headless commands',
|
|||||||
warnings: [],
|
warnings: [],
|
||||||
}),
|
}),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
logWarning: (message) => calls.push(`warn:${message}`),
|
logWarning: (message) => calls.push(`warn:${message}`),
|
||||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess;
|
|||||||
export type ReloadConfigRuntimeDeps = {
|
export type ReloadConfigRuntimeDeps = {
|
||||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
reloadConfigStrict: () => ReloadConfigStrictResult;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
logWarning: (message: string) => void;
|
logWarning: (message: string) => void;
|
||||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||||
startConfigHotReload: () => 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) {
|
if (result.warnings.length > 0) {
|
||||||
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
|
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
|
||||||
deps.showDesktopNotification('SubMiner', {
|
deps.showDesktopNotification('SubMiner', {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { openSubsyncManualModal } from './subsync-open';
|
||||||
|
import type { SubsyncManualPayload } from '../../types';
|
||||||
|
|
||||||
|
const payload: SubsyncManualPayload = {
|
||||||
|
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('subsync manual open prefers dedicated modal window on first attempt', async () => {
|
||||||
|
const sends: Array<{
|
||||||
|
channel: string;
|
||||||
|
payload: SubsyncManualPayload;
|
||||||
|
options: {
|
||||||
|
restoreOnModalClose: 'subsync';
|
||||||
|
preferModalWindow: boolean;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const opened = await openSubsyncManualModal(
|
||||||
|
{
|
||||||
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||||
|
sendToActiveOverlayWindow: (channel, nextPayload, options) => {
|
||||||
|
sends.push({
|
||||||
|
channel,
|
||||||
|
payload: nextPayload as SubsyncManualPayload,
|
||||||
|
options: options as {
|
||||||
|
restoreOnModalClose: 'subsync';
|
||||||
|
preferModalWindow: boolean;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
waitForModalOpen: async (modal, timeoutMs) => {
|
||||||
|
assert.equal(modal, 'subsync');
|
||||||
|
assert.equal(timeoutMs, 1500);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
logWarn: () => {
|
||||||
|
throw new Error('should not warn on first-attempt success');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(opened, true);
|
||||||
|
assert.deepEqual(sends, [
|
||||||
|
{
|
||||||
|
channel: 'subsync:open-manual',
|
||||||
|
payload,
|
||||||
|
options: {
|
||||||
|
restoreOnModalClose: 'subsync',
|
||||||
|
preferModalWindow: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsync manual open retries on the dedicated modal window after open timeout', async () => {
|
||||||
|
const preferModalWindowValues: boolean[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let waitCalls = 0;
|
||||||
|
|
||||||
|
const opened = await openSubsyncManualModal(
|
||||||
|
{
|
||||||
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||||
|
sendToActiveOverlayWindow: (_channel, _payload, options) => {
|
||||||
|
preferModalWindowValues.push(Boolean(options?.preferModalWindow));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
waitForModalOpen: async () => {
|
||||||
|
waitCalls += 1;
|
||||||
|
return waitCalls === 2;
|
||||||
|
},
|
||||||
|
logWarn: (message) => {
|
||||||
|
warnings.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(opened, true);
|
||||||
|
assert.deepEqual(preferModalWindowValues, [true, true]);
|
||||||
|
assert.deepEqual(warnings, [
|
||||||
|
'Subsync modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsync manual open fails when the dedicated modal window cannot be targeted', async () => {
|
||||||
|
let waitCalls = 0;
|
||||||
|
|
||||||
|
const opened = await openSubsyncManualModal(
|
||||||
|
{
|
||||||
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||||
|
sendToActiveOverlayWindow: () => false,
|
||||||
|
waitForModalOpen: async () => {
|
||||||
|
waitCalls += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
logWarn: () => {},
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(opened, false);
|
||||||
|
assert.equal(waitCalls, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import type { SubsyncManualPayload } from '../../types';
|
||||||
|
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||||
|
|
||||||
|
const SUBSYNC_MODAL: OverlayHostedModal = 'subsync';
|
||||||
|
const SUBSYNC_OPEN_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
export async function openSubsyncManualModal(
|
||||||
|
deps: {
|
||||||
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||||
|
sendToActiveOverlayWindow: (
|
||||||
|
channel: string,
|
||||||
|
payload?: unknown,
|
||||||
|
runtimeOptions?: {
|
||||||
|
restoreOnModalClose?: OverlayHostedModal;
|
||||||
|
preferModalWindow?: boolean;
|
||||||
|
},
|
||||||
|
) => boolean;
|
||||||
|
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
},
|
||||||
|
payload: SubsyncManualPayload,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return await retryOverlayModalOpen(
|
||||||
|
{
|
||||||
|
waitForModalOpen: deps.waitForModalOpen,
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modal: SUBSYNC_MODAL,
|
||||||
|
timeoutMs: SUBSYNC_OPEN_TIMEOUT_MS,
|
||||||
|
retryWarning:
|
||||||
|
'Subsync modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||||
|
sendOpen: () =>
|
||||||
|
openOverlayHostedModal(
|
||||||
|
{
|
||||||
|
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions:
|
||||||
|
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.event.subsyncOpenManual,
|
||||||
|
modal: SUBSYNC_MODAL,
|
||||||
|
payload,
|
||||||
|
preferModalWindow: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 = {
|
type UpdaterLogger = {
|
||||||
info: (message: string) => void;
|
info: (message: string) => void;
|
||||||
@@ -53,3 +60,222 @@ test('configureAutoUpdater allows prereleases only for the prerelease channel',
|
|||||||
configureAutoUpdater(updater, () => {}, 'stable');
|
configureAutoUpdater(updater, () => {}, 'stable');
|
||||||
assert.equal(updater.allowPrerelease, false);
|
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 { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||||
import type { UpdateChannel } from '../../../types/config';
|
import type { UpdateChannel } from '../../../types/config';
|
||||||
import { compareSemverLike } from './release-assets';
|
import { compareSemverLike } from './release-assets';
|
||||||
@@ -20,6 +23,9 @@ export interface ElectronAutoUpdaterLike {
|
|||||||
allowPrerelease: boolean;
|
allowPrerelease: boolean;
|
||||||
allowDowngrade: boolean;
|
allowDowngrade: boolean;
|
||||||
logger?: ElectronUpdaterLoggerLike | null;
|
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<{
|
checkForUpdates: () => Promise<{
|
||||||
updateInfo?: {
|
updateInfo?: {
|
||||||
version?: string;
|
version?: string;
|
||||||
@@ -29,6 +35,85 @@ export interface ElectronAutoUpdaterLike {
|
|||||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
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(
|
export function configureAutoUpdater(
|
||||||
updater: ElectronAutoUpdaterLike,
|
updater: ElectronAutoUpdaterLike,
|
||||||
log: (message: string) => void = () => {},
|
log: (message: string) => void = () => {},
|
||||||
@@ -43,6 +128,22 @@ export function configureAutoUpdater(
|
|||||||
warn: (message) => log(message),
|
warn: (message) => log(message),
|
||||||
error: (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;
|
return updater;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +153,7 @@ export function createElectronAppUpdater(options: {
|
|||||||
updater?: ElectronAutoUpdaterLike;
|
updater?: ElectronAutoUpdaterLike;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
getChannel?: () => UpdateChannel;
|
getChannel?: () => UpdateChannel;
|
||||||
|
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||||
const updater = configureAutoUpdater(
|
const updater = configureAutoUpdater(
|
||||||
@@ -59,6 +161,15 @@ export function createElectronAppUpdater(options: {
|
|||||||
options.log,
|
options.log,
|
||||||
getChannel(),
|
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 {
|
return {
|
||||||
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
|
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
|
||||||
@@ -69,6 +180,14 @@ export function createElectronAppUpdater(options: {
|
|||||||
canUpdate: false,
|
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());
|
configureAutoUpdater(updater, options.log, channel ?? getChannel());
|
||||||
const result = await updater.checkForUpdates();
|
const result = await updater.checkForUpdates();
|
||||||
const version = result?.updateInfo?.version ?? options.currentVersion;
|
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.');
|
options.log('Skipping app update download because this build is not packaged.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(await getNativeUpdaterSupported())) {
|
||||||
|
options.log('Skipping app update download because native updater is unsupported.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await updater.downloadUpdate();
|
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);
|
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);
|
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(
|
assert.equal(
|
||||||
buildProtectedLauncherUpdateCommand(
|
buildProtectedLauncherUpdateCommand(
|
||||||
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
|
"https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='abc'",
|
||||||
'/usr/local/bin/subminer',
|
"/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.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 () => {
|
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
|
||||||
|
|||||||
@@ -50,13 +50,17 @@ export function buildProtectedLauncherUpdateCommand(
|
|||||||
assetUrl: string,
|
assetUrl: string,
|
||||||
launcherPath: string,
|
launcherPath: string,
|
||||||
): 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 {
|
function sha256(data: Buffer): string {
|
||||||
return createHash('sha256').update(data).digest('hex');
|
return createHash('sha256').update(data).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultFs(): LauncherUpdateFileSystem {
|
function defaultFs(): LauncherUpdateFileSystem {
|
||||||
return {
|
return {
|
||||||
readFile: (targetPath) => fs.promises.readFile(targetPath),
|
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;
|
homeDir: string;
|
||||||
xdgDataHome?: string;
|
xdgDataHome?: string;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
if (options.platform === 'darwin') {
|
|
||||||
return [
|
|
||||||
path.join(options.homeDir, 'Library/Application Support/SubMiner'),
|
|
||||||
'/usr/local/share/SubMiner',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (options.platform === 'linux') {
|
if (options.platform === 'linux') {
|
||||||
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
|
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
|
||||||
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
|
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);
|
const quotedDir = shellQuote(dataDir);
|
||||||
return [
|
return [
|
||||||
'tmp=$(mktemp -d)',
|
'tmp=$(mktemp -d)',
|
||||||
|
'trap \'rm -rf "$tmp"\' EXIT',
|
||||||
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
||||||
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
||||||
`sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`,
|
`sudo mkdir -p ${quotedDir}/themes`,
|
||||||
`sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`,
|
|
||||||
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||||
].join(' && ');
|
].join(' && ');
|
||||||
}
|
}
|
||||||
@@ -76,12 +70,15 @@ export async function updateSupportAssetsFromRelease(options: {
|
|||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
xdgDataHome?: string;
|
xdgDataHome?: string;
|
||||||
}): Promise<SupportAssetsUpdateResult[]> {
|
}): 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.' }];
|
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
|
||||||
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
|
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');
|
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
||||||
if (!expectedSha256) {
|
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({
|
const dataDirs = detectSupportAssetDataDirs({
|
||||||
@@ -91,12 +88,11 @@ export async function updateSupportAssetsFromRelease(options: {
|
|||||||
});
|
});
|
||||||
const existingDataDirs: string[] = [];
|
const existingDataDirs: string[] = [];
|
||||||
for (const dataDir of dataDirs) {
|
for (const dataDir of dataDirs) {
|
||||||
const hasPlugin = await pathExists(path.join(dataDir, 'plugin/subminer'));
|
|
||||||
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
|
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) {
|
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
|
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
|
||||||
@@ -139,17 +135,8 @@ export async function updateSupportAssetsFromRelease(options: {
|
|||||||
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
|
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
|
||||||
const results: SupportAssetsUpdateResult[] = [...protectedResults];
|
const results: SupportAssetsUpdateResult[] = [...protectedResults];
|
||||||
for (const dataDir of writableDataDirs) {
|
for (const dataDir of writableDataDirs) {
|
||||||
const targetPluginDir = path.join(dataDir, 'plugin/subminer');
|
|
||||||
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
|
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)) {
|
if (await pathExists(targetThemePath)) {
|
||||||
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
|
|
||||||
await fs.promises.copyFile(
|
await fs.promises.copyFile(
|
||||||
path.join(tempDir, 'assets/themes/subminer.rasi'),
|
path.join(tempDir, 'assets/themes/subminer.rasi'),
|
||||||
targetThemePath,
|
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;
|
cancelId?: number;
|
||||||
}) => Promise<MessageBoxResultLike>;
|
}) => Promise<MessageBoxResultLike>;
|
||||||
|
|
||||||
|
export interface UpdateDialogPresenterDeps {
|
||||||
|
showMessageBox: ShowMessageBox;
|
||||||
|
focusApp?: () => void;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
}
|
||||||
|
|
||||||
export async function showNoUpdateDialog(
|
export async function showNoUpdateDialog(
|
||||||
showMessageBox: ShowMessageBox,
|
showMessageBox: ShowMessageBox,
|
||||||
version: string,
|
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(
|
export async function showUpdateAvailableDialog(
|
||||||
showMessageBox: ShowMessageBox,
|
showMessageBox: ShowMessageBox,
|
||||||
version: string,
|
version: string,
|
||||||
|
|||||||
@@ -47,3 +47,24 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails',
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
|
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 {
|
try {
|
||||||
await deps.showOsdNotification(message);
|
await deps.showOsdNotification(message);
|
||||||
} catch (error) {
|
} 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');
|
calls.push('restart-dialog');
|
||||||
return 'later';
|
return 'later';
|
||||||
},
|
},
|
||||||
quitAndInstall: () => calls.push('quit-install'),
|
quitAndInstall: () => {
|
||||||
|
calls.push('quit-install');
|
||||||
|
},
|
||||||
notifyUpdateAvailable: async (version) => {
|
notifyUpdateAvailable: async (version) => {
|
||||||
calls.push(`notify:${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']);
|
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 () => {
|
test('automatic update check skips inside configured interval', async () => {
|
||||||
const { deps, calls, setState } = createDeps();
|
const { deps, calls, setState } = createDeps();
|
||||||
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
|
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);
|
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 () => {
|
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
getConfig: () => ({
|
getConfig: () => ({
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ export interface UpdateServiceDeps {
|
|||||||
updateLauncher: (
|
updateLauncher: (
|
||||||
launcherPath?: string,
|
launcherPath?: string,
|
||||||
channel?: UpdateChannel,
|
channel?: UpdateChannel,
|
||||||
|
release?: GitHubRelease | null,
|
||||||
) => Promise<{ status: string; command?: string }>;
|
) => Promise<{ status: string; command?: string }>;
|
||||||
showNoUpdateDialog: (version: string) => Promise<void>;
|
showNoUpdateDialog: (version: string) => Promise<void>;
|
||||||
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
||||||
showUpdateFailedDialog: (message: string) => Promise<void>;
|
showUpdateFailedDialog: (message: string) => Promise<void>;
|
||||||
downloadAppUpdate: () => Promise<void>;
|
downloadAppUpdate: () => Promise<void>;
|
||||||
showRestartDialog: () => Promise<'restart' | 'later'>;
|
showRestartDialog: () => Promise<'restart' | 'later'>;
|
||||||
quitAndInstall: () => void;
|
quitAndInstall: () => void | Promise<void>;
|
||||||
notifyUpdateAvailable: (version: string) => Promise<void>;
|
notifyUpdateAvailable: (version: string) => Promise<void>;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
setTimeout?: (callback: () => void, delayMs: number) => unknown;
|
setTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||||
@@ -96,7 +97,7 @@ function summarizeError(error: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createUpdateService(deps: UpdateServiceDeps) {
|
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> {
|
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||||
const now = deps.now();
|
const now = deps.now();
|
||||||
@@ -157,17 +158,24 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
|||||||
return { status: 'update-available', version: latest.version };
|
return { status: 'update-available', version: latest.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let appUpdateApplied = false;
|
||||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
||||||
await deps.downloadAppUpdate();
|
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) {
|
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||||
deps.log(`Launcher update requires manual command: ${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();
|
const restartChoice = await deps.showRestartDialog();
|
||||||
if (restartChoice === 'restart') {
|
if (restartChoice === 'restart') {
|
||||||
deps.quitAndInstall();
|
await deps.quitAndInstall();
|
||||||
}
|
}
|
||||||
return { status: 'updated', version: latest.version };
|
return { status: 'updated', version: latest.version };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -183,11 +191,13 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||||
|
const inFlight = inFlightBySource.get(request.source);
|
||||||
if (inFlight) return inFlight;
|
if (inFlight) return inFlight;
|
||||||
inFlight = runCheck(request).finally(() => {
|
const nextInFlight = runCheck(request).finally(() => {
|
||||||
inFlight = null;
|
inFlightBySource.delete(request.source);
|
||||||
});
|
});
|
||||||
return inFlight;
|
inFlightBySource.set(request.source, nextInFlight);
|
||||||
|
return nextInFlight;
|
||||||
},
|
},
|
||||||
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
||||||
const setTimeoutFn = deps.setTimeout ?? setTimeout;
|
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);
|
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;
|
let ensureCalled = false;
|
||||||
const logs: string[] = [];
|
let forwardedExtension: { id: string } | null = null;
|
||||||
const openSettings = createOpenYomitanSettingsHandler({
|
const openSettings = createOpenYomitanSettingsHandler({
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
ensureYomitanExtensionLoaded: async () => {
|
||||||
ensureCalled = true;
|
ensureCalled = true;
|
||||||
@@ -129,19 +129,19 @@ test('yomitan opener warns instead of starting a settings-triggered load when ex
|
|||||||
},
|
},
|
||||||
getYomitanExtension: () => null,
|
getYomitanExtension: () => null,
|
||||||
getYomitanExtensionLoadInFlight: () => null,
|
getYomitanExtensionLoadInFlight: () => null,
|
||||||
openYomitanSettingsWindow: () => {
|
openYomitanSettingsWindow: ({ yomitanExt }) => {
|
||||||
throw new Error('should not open before extension is ready');
|
forwardedExtension = yomitanExt as { id: string };
|
||||||
},
|
},
|
||||||
getExistingWindow: () => null,
|
getExistingWindow: () => null,
|
||||||
setWindow: () => {},
|
setWindow: () => {},
|
||||||
logWarn: (message) => logs.push(message),
|
logWarn: () => {},
|
||||||
logError: () => logs.push('error'),
|
logError: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
openSettings();
|
openSettings();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
assert.equal(ensureCalled, false);
|
assert.equal(ensureCalled, true);
|
||||||
assert.deepEqual(logs, ['Unable to open Yomitan settings: extension is not loaded yet.']);
|
assert.deepEqual(forwardedExtension, { id: 'ext' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function createOpenYomitanSettingsHandler(deps: {
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (deps.getYomitanExtension) {
|
if (deps.getYomitanExtension) {
|
||||||
const loadedExtension = deps.getYomitanExtension();
|
let loadedExtension = deps.getYomitanExtension();
|
||||||
if (!loadedExtension) {
|
if (!loadedExtension) {
|
||||||
if (deps.getYomitanExtensionLoadInFlight?.()) {
|
if (deps.getYomitanExtensionLoadInFlight?.()) {
|
||||||
deps.logWarn(
|
deps.logWarn(
|
||||||
@@ -30,8 +30,11 @@ export function createOpenYomitanSettingsHandler(deps: {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deps.logWarn('Unable to open Yomitan settings: extension is not loaded yet.');
|
loadedExtension = await deps.ensureYomitanExtensionLoaded();
|
||||||
return;
|
if (!loadedExtension) {
|
||||||
|
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ test('package scripts expose prerelease notes generation separately from stable
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prerelease workflow generates prerelease notes from pending fragments', () => {
|
test('prerelease workflow uses committed prerelease notes and never calls claude in CI', () => {
|
||||||
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
|
assert.match(prereleaseWorkflow, /--notes-file release\/prerelease-notes\.md/);
|
||||||
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
|
assert.doesNotMatch(prereleaseWorkflow, /run: bun run changelog:prerelease-notes/);
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /run: bun run changelog:build/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prerelease workflow includes the environment suite in the gate sequence', () => {
|
test('prerelease workflow includes the environment suite in the gate sequence', () => {
|
||||||
|
|||||||
@@ -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 ?? '', /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', () => {
|
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
||||||
|
|||||||
@@ -822,6 +822,7 @@ test('default keybindings dispatch through overlay keyboard handling', async ()
|
|||||||
testGlobals.sessionActions.map((action) => action.actionId).sort(),
|
testGlobals.sessionActions.map((action) => action.actionId).sort(),
|
||||||
expectedSessionActions.sort(),
|
expectedSessionActions.sort(),
|
||||||
);
|
);
|
||||||
|
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { MacOSWindowTracker } from './macos-tracker';
|
import { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker';
|
||||||
|
|
||||||
|
test('parseMacOSHelperOutput parses minimized state', () => {
|
||||||
|
assert.deepEqual(parseMacOSHelperOutput('minimized'), {
|
||||||
|
geometry: null,
|
||||||
|
focused: false,
|
||||||
|
minimized: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
@@ -170,3 +178,40 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
|
|||||||
assert.equal(tracker.isTracking(), false);
|
assert.equal(tracker.isTracking(), false);
|
||||||
assert.equal(tracker.getGeometry(), null);
|
assert.equal(tracker.getGeometry(), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker reports minimized target when helper reports minimized', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'minimized', stderr: '' },
|
||||||
|
{ stdout: 'minimized', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
now: () => now,
|
||||||
|
minimizedTrackingLossGraceMs: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowMinimized(), false);
|
||||||
|
|
||||||
|
now += 250;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTargetWindowMinimized(), true);
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
|
||||||
|
now += 250;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTargetWindowMinimized(), true);
|
||||||
|
assert.equal(tracker.isTracking(), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,13 +40,21 @@ type MacOSTrackerDeps = {
|
|||||||
) => Promise<MacOSTrackerRunnerResult>;
|
) => Promise<MacOSTrackerRunnerResult>;
|
||||||
maxConsecutiveMisses?: number;
|
maxConsecutiveMisses?: number;
|
||||||
trackingLossGraceMs?: number;
|
trackingLossGraceMs?: number;
|
||||||
|
minimizedTrackingLossGraceMs?: number;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MacOSHelperWindowState {
|
export type MacOSHelperWindowState =
|
||||||
geometry: WindowGeometry;
|
| {
|
||||||
focused: boolean;
|
geometry: WindowGeometry;
|
||||||
}
|
focused: boolean;
|
||||||
|
minimized?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
geometry: null;
|
||||||
|
focused: false;
|
||||||
|
minimized: true;
|
||||||
|
};
|
||||||
|
|
||||||
function runHelperWithExecFile(
|
function runHelperWithExecFile(
|
||||||
helperPath: string,
|
helperPath: string,
|
||||||
@@ -84,6 +92,13 @@ function runHelperWithExecFile(
|
|||||||
|
|
||||||
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
||||||
const trimmed = result.trim();
|
const trimmed = result.trim();
|
||||||
|
if (trimmed === 'minimized') {
|
||||||
|
return {
|
||||||
|
geometry: null,
|
||||||
|
focused: false,
|
||||||
|
minimized: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!trimmed || trimmed === 'not-found') {
|
if (!trimmed || trimmed === 'not-found') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -137,9 +152,11 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
) => Promise<MacOSTrackerRunnerResult>;
|
) => Promise<MacOSTrackerRunnerResult>;
|
||||||
private readonly maxConsecutiveMisses: number;
|
private readonly maxConsecutiveMisses: number;
|
||||||
private readonly trackingLossGraceMs: number;
|
private readonly trackingLossGraceMs: number;
|
||||||
|
private readonly minimizedTrackingLossGraceMs: number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
private consecutiveMisses = 0;
|
private consecutiveMisses = 0;
|
||||||
private trackingLossStartedAtMs: number | null = null;
|
private trackingLossStartedAtMs: number | null = null;
|
||||||
|
private targetWindowMinimized = false;
|
||||||
|
|
||||||
constructor(targetMpvSocketPath?: string, deps: MacOSTrackerDeps = {}) {
|
constructor(targetMpvSocketPath?: string, deps: MacOSTrackerDeps = {}) {
|
||||||
super();
|
super();
|
||||||
@@ -147,6 +164,10 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
|
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
|
||||||
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
|
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
|
||||||
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
|
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
|
||||||
|
this.minimizedTrackingLossGraceMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
||||||
|
);
|
||||||
this.now = deps.now ?? (() => Date.now());
|
this.now = deps.now ?? (() => Date.now());
|
||||||
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
||||||
if (resolvedHelper) {
|
if (resolvedHelper) {
|
||||||
@@ -259,28 +280,32 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override isTargetWindowMinimized(): boolean {
|
||||||
|
return this.targetWindowMinimized;
|
||||||
|
}
|
||||||
|
|
||||||
private resetTrackingLossState(): void {
|
private resetTrackingLossState(): void {
|
||||||
this.consecutiveMisses = 0;
|
this.consecutiveMisses = 0;
|
||||||
this.trackingLossStartedAtMs = null;
|
this.trackingLossStartedAtMs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldDropTracking(): boolean {
|
private shouldDropTracking(graceMs = this.trackingLossGraceMs): boolean {
|
||||||
if (!this.isTracking()) {
|
if (!this.isTracking()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (this.trackingLossGraceMs === 0) {
|
if (graceMs === 0) {
|
||||||
return this.consecutiveMisses >= this.maxConsecutiveMisses;
|
return this.consecutiveMisses >= this.maxConsecutiveMisses;
|
||||||
}
|
}
|
||||||
if (this.trackingLossStartedAtMs === null) {
|
if (this.trackingLossStartedAtMs === null) {
|
||||||
this.trackingLossStartedAtMs = this.now();
|
this.trackingLossStartedAtMs = this.now();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.now() - this.trackingLossStartedAtMs > this.trackingLossGraceMs;
|
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerTrackingMiss(): void {
|
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
||||||
this.consecutiveMisses += 1;
|
this.consecutiveMisses += 1;
|
||||||
if (this.shouldDropTracking()) {
|
if (this.shouldDropTracking(graceMs)) {
|
||||||
this.updateGeometry(null);
|
this.updateGeometry(null);
|
||||||
this.resetTrackingLossState();
|
this.resetTrackingLossState();
|
||||||
}
|
}
|
||||||
@@ -296,12 +321,20 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
.then(({ stdout }) => {
|
.then(({ stdout }) => {
|
||||||
const parsed = parseMacOSHelperOutput(stdout || '');
|
const parsed = parseMacOSHelperOutput(stdout || '');
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
|
if (parsed.minimized) {
|
||||||
|
this.targetWindowMinimized = true;
|
||||||
|
this.updateTargetWindowFocused(false);
|
||||||
|
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.resetTrackingLossState();
|
this.resetTrackingLossState();
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
this.updateFocus(parsed.focused);
|
this.updateFocus(parsed.focused);
|
||||||
this.updateGeometry(parsed.geometry);
|
this.updateGeometry(parsed.geometry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
this.registerTrackingMiss();
|
this.registerTrackingMiss();
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
@@ -314,6 +347,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
? (error as { stderr: string }).stderr
|
? (error as { stderr: string }).stderr
|
||||||
: '';
|
: '';
|
||||||
this.maybeLogExecError(err, stderr);
|
this.maybeLogExecError(err, stderr);
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
this.registerTrackingMiss();
|
this.registerTrackingMiss();
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user