Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)

This commit is contained in:
2026-07-13 18:56:51 -07:00
committed by GitHub
parent 66f8ca4f80
commit 49b926e08c
111 changed files with 6983 additions and 1130 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased
### Added
- Stats Sync Without the Launcher: The stats sync engine now runs only inside the app. The sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically.
## v0.18.0 (2026-07-10)
### Added
@@ -0,0 +1,4 @@
type: fixed
area: sync
- Fixed word/kanji frequencies double-counting across syncs when the remote snapshot contained a stale active session (e.g. after a crash): a word new to the local machine adopted the remote's full lifetime frequency, which already included the active session's partial occurrences, and those occurrences were added again when the session finalized and synced. Newly adopted words/kanji now exclude active-session counts, which arrive once the session completes.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: sync
- Fixed stats sync copying a remote daily rollup that should have been recomputed when the day was the 1st of a month and the machine was on a negative UTC offset (the Americas), which could leave that day's totals wrong after a sync. The rollup day is now read back at local noon instead of UTC midnight, so it always resolves to the correct civil month.
+6
View File
@@ -0,0 +1,6 @@
type: fixed
area: sync
- Fixed the app staying alive in the background after closing a standalone Sync window (`subminer sync --ui`) on macOS. The quit re-issued after async will-quit cleanup was dropped by Electron when the cleanup settled within the same tick as the will-quit dispatch; the re-quit now runs on a fresh macrotask, and the standalone Sync window close path uses the same forced-exit fallback as SIGTERM. Windows opened from the tray menu of a running app are unaffected: closing them still leaves the app running.
- Fixed a sync run hanging when the sync child exited without emitting a terminal result: the run waited on `close`, which a descendant holding the inherited stdio pipes can delay indefinitely, stalling the quit-time sync shutdown. Cancelling now settles the run as soon as the child has exited, and an exit with no result settles after a bounded stdout drain window.
- Fixed sync progress and error text being corrupted when a multibyte character (e.g. a Japanese media title) straddled a chunk boundary in the sync child's output.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: sync
- Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and separate merge summaries for this machine and the remote device, connection testing for first-time setup, cancellable live syncs while the app, stats server, or playback is active, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Live sync uses a consistent WAL snapshot and transactional merge, and excludes unfinished sessions until a later sync sees them finalized. Hosts with auto-sync enabled are synced in the background on a configurable interval, including during playback, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and show up in the window automatically. `subminer sync --ui` launches silently in the background and returns the shell immediately; closing that standalone Sync window shuts down the app.
- Added `subminer sync <host> --check` to test the SSH connection and remote launcher availability without syncing, using bounded noninteractive probes and settling when the check process exits even if an inherited output pipe remains open, without dropping terminal progress that races process exit. Linux AppImage sync commands clear inherited GUI startup argument transport before launching the bundled engine, then exit directly, so sync window checks and remote snapshots never initialize the GUI or require a display server. Added `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes).
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased
**Added**
- Stats Sync Without the Launcher: The stats sync engine now runs only inside the app. The sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically.
## v0.18.0 (2026-07-10)
**Added**
+66 -43
View File
@@ -81,21 +81,27 @@ Series whose directories are not currently accessible (e.g. an unmounted network
## Sync Between Machines
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version.
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The sync engine runs only inside the app (`SubMiner --sync-cli sync ...`): the sync window spawns it that way, `subminer sync` is a thin proxy that forwards to the installed app, and the remote side is found automatically whether it has the launcher or just the app. The command-line launcher is optional everywhere.
```bash
subminer sync macbook # two-way sync with the host "macbook"
subminer sync macbook --push # merge local data into macbook only
subminer sync macbook --pull # merge macbook data into local only
subminer sync user@192.168.1.20 # explicit user@host
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote SubMiner/launcher path
subminer sync macbook --check # test SSH + remote SubMiner without syncing
subminer sync --ui # open the sync window (also in the tray menu)
```
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time. Syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
For a one-way transfer, `--push` snapshots the local database and merges it into the host without changing the local database. `--pull` snapshots the host and merges it into the local database without changing the host. These modes add missing data; they do not delete destination-only data or make the destination an exact mirror.
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version the sync aborts on a stats schema mismatch. Remote sync checks standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`.
Command-line sync defaults to a cold-start safety check: close SubMiner (and stop the background stats daemon with `subminer stats -s`) on both machines before running it, or pass `--force`. Syncs started from the Sync window use live mode automatically, including scheduled auto-syncs while SubMiner or playback is active. SQLite WAL provides a consistent snapshot, the transactional merge serializes with live writes, and each machine's unfinished session is excluded from the transfer; that session syncs normally after it finishes. The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block command-line sync. Both machines must be on the same SubMiner version; otherwise, the sync aborts on a stats schema mismatch.
On the remote, sync looks for the `subminer` launcher first (PATH and `~/.local/bin`), then the app binary in `--sync-cli` mode (`SubMiner` on PATH, then the standard macOS `/Applications` and `~/Applications` installs), checking standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`. An AppImage in a custom location can be addressed with `--remote-cmd /path/to/SubMiner.AppImage` (or symlink it as `SubMiner` somewhere on the remote PATH).
Windows remotes are supported: enable Windows' built-in **OpenSSH Server** and sync detects the remote shell (cmd or PowerShell) automatically, finding SubMiner in its default install location (`%LOCALAPPDATA%\Programs\SubMiner`), the launcher shim (`%LOCALAPPDATA%\SubMiner\bin`), or on PATH. Temp files on the remote are created and removed by SubMiner itself (`sync --make-temp` / `--remove-temp`), so no POSIX tools are required on the remote side.
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
@@ -104,7 +110,22 @@ subminer sync --snapshot /tmp/stats.sqlite # write a consistent snapshot of th
subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the local database
```
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync — each machine derives it from its own Anki collection.
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync. Each machine derives it from its own Anki collection.
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp <dir>` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically and runs AppImages in Node-only mode, so remote sync does not require a graphical session.
### Sync window
`subminer sync --ui` opens a dedicated window for the same engine in a detached app process, returning the shell immediately. Closing that standalone-launched window exits its app instance. Opening **Sync Stats & History** from the tray keeps the resident app running when the window closes:
- **Devices:** saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
- **Add a device:** test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration.
- **Activity:** live stage-by-stage progress, remote output, and separate merge summaries (sessions, words, kanji, rollups) for each machine updated by the run. Runs can be cancelled and can proceed while the app, stats server, or playback is active.
- **Snapshots:** create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
Hosts with **Auto-sync** enabled are synced in the background on a configurable interval (default every 60 minutes), including during active playback; results surface as overlay notifications. The unfinished playback session is skipped until a later sync sees it finalized. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
## Common Commands
@@ -120,51 +141,53 @@ subminer stats -b # start background stats daemon
## Subcommands
| Subcommand | Purpose |
| ------------------------------------------ | ------------------------------------------------------------------ |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login, `--logout`, `--setup`) |
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
| Subcommand | Purpose |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login, `--logout`, `--setup`) |
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
| `subminer settings` | Open the SubMiner settings window |
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
| `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness |
| `subminer mpv socket` | Print active socket path |
| `subminer mpv idle` | Launch detached idle mpv instance |
| `subminer sync <host>` | Two-way stats/history sync with another machine over SSH |
| `subminer sync <host> --push` | Merge local stats/history into another machine only |
| `subminer sync <host> --pull` | Merge another machine's stats/history into the local database only |
| `subminer dictionary <path>` / `dict` | Generate character dictionary ZIP from file/dir target |
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
| `subminer texthooker` | Launch texthooker-only mode |
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
| `subminer app` / `bin` | Pass arguments directly to SubMiner binary (e.g. `subminer app --setup`) |
| `subminer settings` | Open the SubMiner settings window |
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
| `subminer config path` | Print active config file path |
| `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness |
| `subminer mpv socket` | Print active socket path |
| `subminer mpv idle` | Launch detached idle mpv instance |
| `subminer sync <host>` | Two-way stats/history sync with another machine over SSH |
| `subminer sync <host> --push` | Merge local stats/history into another machine only |
| `subminer sync <host> --pull` | Merge another machine's stats/history into the local database only |
| `subminer sync <host> --check` | Test SSH connection and remote launcher availability |
| `subminer sync --ui` | Open the sync window (saved devices, auto-sync, snapshots) |
| `subminer dictionary <path>` / `dict` | Generate character dictionary ZIP from file/dir target |
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
| `subminer texthooker` | Launch texthooker-only mode |
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
| `subminer app` / `bin` | Pass arguments directly to SubMiner binary (e.g. `subminer app --setup`) |
Use `subminer <subcommand> -h` for command-specific help.
## Options
| Flag | Description |
| --------------------- | -------------------------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| Flag | Description |
| --------------------- | --------------------------------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Force the visible overlay on start |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--settings` | Open the SubMiner settings window |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Force the visible overlay on start |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--settings` | Open the SubMiner settings window |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
+10 -1
View File
@@ -122,8 +122,13 @@ subminer mpv idle # Launch detached idle mpv with SubMiner defau
subminer sync media-box # Sync stats/watch history with an SSH host
subminer sync media-box --push # Merge this machine's stats into the host only
subminer sync media-box --pull # Merge the host's stats into this machine only
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
subminer sync media-box --json # Emit machine-readable NDJSON progress
subminer sync --ui # Open the Sync Stats & History window
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
subminer sync --make-temp # Create an internal sync temp directory
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv
@@ -159,12 +164,16 @@ SubMiner.AppImage --jellyfin-libraries
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
SubMiner.AppImage --help # Show all options
```
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
@@ -367,7 +376,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
| Keybind | Action | Scope |
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
+11
View File
@@ -1,4 +1,5 @@
import {
launchAppCommandDetached,
launchAppBackgroundDetached,
launchTexthookerOnly,
runAppCommandWithInherit,
@@ -7,6 +8,10 @@ import type { LauncherCommandContext } from './context.js';
type AppCommandDeps = {
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
launchSyncUiDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchAppBackgroundDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
@@ -15,6 +20,8 @@ type AppCommandDeps = {
const defaultAppCommandDeps: AppCommandDeps = {
runAppCommandWithInherit,
launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached,
};
@@ -30,6 +37,10 @@ export function runAppPassthroughCommand(
deps.runAppCommandWithInherit(appPath, ['--settings']);
return true;
}
if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel);
return true;
}
if (!args.appPassthrough) {
return false;
}
+18
View File
@@ -206,6 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -225,6 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -245,6 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
launchSyncUiDetached: () => detached.push('sync-ui'),
launchAppBackgroundDetached: () => {
detached.push('detached');
},
@@ -255,6 +258,21 @@ test('app command keeps explicit passthrough args attached', () => {
assert.deepEqual(detached, []);
});
test('sync UI command launches the app detached from the terminal', () => {
const context = createContext();
context.args.syncUi = true;
const calls: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAppBackgroundDetached: () => calls.push('detached'),
});
assert.equal(handled, true);
assert.deepEqual(calls, ['sync-ui:/tmp/subminer.app:warn']);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext();
context.args.mpvStatus = true;
+2 -7
View File
@@ -37,13 +37,8 @@ function createContext(): LauncherCommandContext {
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+65 -214
View File
@@ -1,24 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Args } from '../types.js';
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
import type { LauncherCommandContext } from './context.js';
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
function makeContext(
overrides: Partial<Args>,
appPath: string | null = '/opt/SubMiner/subminer-app',
): LauncherCommandContext {
return {
args: {
sync: true,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncCliTokens: [],
logLevel: 'warn',
...overrides,
} as Args,
@@ -26,232 +19,90 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
scriptName: 'subminer',
mpvSocketPath: '',
pluginRuntimeConfig: {},
appPath: null,
appPath,
launcherJellyfinConfig: {},
processAdapter: process,
processAdapter: { platform: () => 'linux' },
} as unknown as LauncherCommandContext;
}
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
let socketConnectable = false;
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
const deps: Partial<SyncCommandDeps> = {
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
findLiveStatsDaemonPid: () => null,
canConnectUnixSocket: async () => socketConnectable,
fail: (message: string): never => {
throw new Error(message);
},
};
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (dbPath: string, outPath: string) => {
calls.push(`snapshot:${dbPath}->${outPath}`);
},
mergeSnapshotIntoDb: (dbPath: string, snapshotPath: string) => {
calls.push(`merge:${dbPath}<-${snapshotPath}`);
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host: string) => {
calls.push(`host:${host}`);
},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
return command.startsWith('mktemp ') ? ok('/tmp/subminer-sync.remote\n') : ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
},
fail: (message: string): never => {
throw new Error(message);
runAppCommand: (appPath, appArgs) => {
spawned.push({ appPath, appArgs });
},
};
assert.equal(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
true,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
assert.deepEqual(spawned, [
{
appPath: '/opt/SubMiner/subminer-app',
appArgs: ['--sync-cli', 'sync', 'media-box', '--json', '--log-level', 'warn'],
},
]);
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
deps,
);
assert.ok(calls.includes('host:media-box'));
await assert.rejects(
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
assert.equal(await runSyncCommand(makeContext({ sync: false }), deps), false);
assert.equal(spawned.length, 1);
});
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
const spawned: string[][] = [];
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
mkdtempSync: ((prefix: string) => {
localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), path.basename(prefix)));
return localTmpDir;
}) as typeof fs.mkdtempSync,
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
if (command.includes(' sync --snapshot ')) return ok();
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
}
return ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
runAppCommand: (_appPath, appArgs) => {
spawned.push(appArgs);
},
};
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
);
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
assert.ok(calls.includes('local-merge'));
assert.ok(calls.some((call) => call.startsWith('ssh:rm -rf ')));
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
if (command.includes(' sync --snapshot ')) {
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
}
return ok();
},
runScp: () => {},
};
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
return {
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
return ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
};
}
test('runHostSync push only snapshots locally and merges remotely', async () => {
const calls: string[] = [];
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'push',
syncCliTokens: [
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
],
logLevel: 'debug',
}),
makeDirectionDeps(calls),
deps,
);
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(!calls.includes('local-merge'));
assert.deepEqual(spawned, [
[
'--sync-cli',
'sync',
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
'--log-level',
'debug',
],
]);
});
test('runHostSync pull only snapshots remotely and merges locally', async () => {
const calls: string[] = [];
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
const deps: Partial<SyncCommandDeps> = {
runAppCommand: () => {
throw new Error('should not spawn');
},
fail: (message: string): never => {
throw new Error(message);
},
};
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'pull',
}),
makeDirectionDeps(calls),
await assert.rejects(
() => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps),
/SubMiner app binary not found \(sync runs inside the app\)/,
);
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
assert.ok(calls.includes('local-merge'));
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
});
+29 -237
View File
@@ -1,253 +1,45 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fail, log } from '../log.js';
import { resolveImmersionDbPath } from '../history-db.js';
import {
createDbSnapshot,
findLiveStatsDaemonPid,
formatMergeSummary,
mergeSnapshotIntoDb,
} from '../sync/sync-db.js';
import {
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
shellQuote,
} from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js';
import { SYNC_CLI_FLAG } from '../../src/core/services/stats-sync/cli-args.js';
import { fail } from '../log.js';
import { runAppCommandInteractive } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import type { RemoteRunResult } from '../sync/ssh.js';
export interface SyncCommandDeps {
createDbSnapshot: typeof createDbSnapshot;
mergeSnapshotIntoDb: typeof mergeSnapshotIntoDb;
formatMergeSummary: typeof formatMergeSummary;
findLiveStatsDaemonPid: typeof findLiveStatsDaemonPid;
assertSafeSshHost: typeof assertSafeSshHost;
resolveRemoteSubminerCommand: typeof resolveRemoteSubminerCommand;
runScp: typeof runScp;
runSsh: typeof runSsh;
fail: typeof fail;
log: typeof log;
canConnectUnixSocket: typeof canConnectUnixSocket;
realpathSync: typeof fs.realpathSync;
mkdtempSync: typeof fs.mkdtempSync;
rmSync: typeof fs.rmSync;
consoleLog: typeof console.log;
writeStdout: typeof process.stdout.write;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
}
function resolveDbPath(context: LauncherCommandContext): string {
const override = context.args.syncDbPath.trim();
return override ? resolvePathMaybe(override) : resolveImmersionDbPath();
}
function isTrackerDb(dbPath: string, deps: SyncCommandDeps): boolean {
const trackerDbPath = resolveImmersionDbPath();
try {
return deps.realpathSync(dbPath) === deps.realpathSync(trackerDbPath);
} catch {
return dbPath === trackerDbPath;
}
}
export async function ensureTrackerQuiescent(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
// elsewhere needs no guard.
if (!isTrackerDb(dbPath, deps)) return;
const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) {
deps.fail(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
deps.fail(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
}
runAppCommand: (appPath: string, appArgs: string[]) => void;
fail: (message: string) => never;
}
const defaultSyncCommandDeps: SyncCommandDeps = {
createDbSnapshot,
mergeSnapshotIntoDb,
formatMergeSummary,
findLiveStatsDaemonPid,
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
runAppCommand: runAppCommandInteractive,
fail,
log,
canConnectUnixSocket,
realpathSync: fs.realpathSync,
mkdtempSync: fs.mkdtempSync,
rmSync: fs.rmSync,
consoleLog: console.log,
writeStdout: process.stdout.write.bind(process.stdout),
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
};
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
return { ...defaultSyncCommandDeps, ...inputDeps };
}
export function runSnapshotMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
const deps = resolveSyncCommandDeps(inputDeps);
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
deps.createDbSnapshot(dbPath, outPath);
deps.consoleLog(outPath);
}
export async function runMergeMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.consoleLog(deps.formatMergeSummary(summary));
}
function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncCommandDeps): void {
if (!remoteTmpDir.startsWith('/tmp/')) return;
deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
}
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
const stderr = run.stderr.trim();
return stderr ? `${message}\n${stderr}` : message;
}
export async function runHostSync(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
const host = args.syncHost;
const direction = args.syncDirection ?? 'both';
const shouldPull = direction !== 'push';
const shouldPush = direction !== 'pull';
deps.assertSafeSshHost(host);
await deps.ensureTrackerQuiescent(context, dbPath);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
let remoteTmpDir = '';
try {
// Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
// main().catch() reports the message the same way fail() would.
const mktemp = deps.runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
remoteTmpDir = mktemp.stdout.trim();
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
throw new Error(`Could not create a temporary directory on ${host}.`);
}
const forceFlag = args.syncForce ? ' --force' : '';
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.createDbSnapshot(dbPath, localSnapshot);
}
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
if (shouldPull) {
deps.consoleLog(`Snapshotting ${host}...`);
const snapshotRun = deps.runSsh(
host,
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
);
if (snapshotRun.status !== 0) {
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
}
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
if (shouldPull) deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
if (shouldPush) deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
if (shouldPull) {
deps.consoleLog(`\nMerging ${host} -> local:`);
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
deps.consoleLog(deps.formatMergeSummary(summary));
}
if (shouldPush) {
deps.consoleLog(`\nMerging local -> ${host}:`);
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
);
deps.writeStdout(mergeRun.stdout);
if (mergeRun.status !== 0) {
const retryCommand =
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
const localUpdate = shouldPull ? ' The local database was updated;' : '';
throw new Error(
formatRemoteRunError(
`Remote merge failed on ${host}.${localUpdate} re-run "${retryCommand}" once the remote issue is fixed.`,
mergeRun,
),
);
}
}
deps.consoleLog('\nSync complete.');
} finally {
deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteTmpDir, deps);
} catch {
// best effort
}
}
}
}
/**
* `subminer sync` is a thin proxy: the sync engine only executes inside the
* SubMiner app (--sync-cli mode, libsql). The launcher contributes its
* parser/help and app discovery; the app's parseSyncCliTokens owns validation,
* so its errors reach the terminal through the child's inherited stdio. The
* child owns the terminal and its exit code becomes the launcher's.
*/
export async function runSyncCommand(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<boolean> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
if (!args.sync) return false;
const dbPath = resolveDbPath(context);
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
const deps = { ...defaultSyncCommandDeps, ...inputDeps };
if (!context.args.sync) return false;
if (!context.appPath) {
deps.fail(
context.processAdapter.platform() === 'darwin'
? 'SubMiner app binary not found (sync runs inside the app). Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.'
: 'SubMiner app binary not found (sync runs inside the app). Install the SubMiner app, or set SUBMINER_APPIMAGE_PATH.',
);
return true; // fail() never returns; this only satisfies control-flow analysis
}
deps.runAppCommand(context.appPath, [
SYNC_CLI_FLAG,
'sync',
...context.args.syncCliTokens,
'--log-level',
context.args.logLevel,
]);
return true;
}
+12 -28
View File
@@ -136,14 +136,10 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -191,14 +187,10 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -239,14 +231,10 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -285,14 +273,10 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncCliTokens: [],
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
+7 -14
View File
@@ -200,13 +200,8 @@ export function createDefaultArgs(
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncCliTokens: [],
syncUi: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
@@ -274,15 +269,13 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
}
if (invocations.syncTriggered) {
parsed.sync = true;
parsed.syncHost = invocations.syncHost ?? '';
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
parsed.syncMergePath = invocations.syncMergePath ?? '';
parsed.syncDirection = invocations.syncDirection;
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce;
parsed.syncCliTokens = invocations.syncCliTokens;
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
}
if (invocations.syncUiTriggered) {
parsed.syncUi = true;
if (invocations.syncUiLogLevel) parsed.logLevel = parseLogLevel(invocations.syncUiLogLevel);
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
+77 -10
View File
@@ -43,21 +43,88 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
assert.equal(result.invocations.texthookerOpenBrowser, true);
});
test('parseCliPrograms captures one-way sync directions', () => {
test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => {
const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer');
assert.equal(push.invocations.syncTriggered, true);
assert.deepEqual(push.invocations.syncCliTokens, ['media-box', '--push']);
const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer');
assert.deepEqual(pull.invocations.syncCliTokens, ['media-box', '--pull']);
assert.equal(push.invocations.syncDirection, 'push');
assert.equal(pull.invocations.syncDirection, 'pull');
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
assert.deepEqual(check.invocations.syncCliTokens, ['media-box', '--check', '--json']);
const full = parseCliPrograms(
[
'sync',
'media-box',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--log-level',
'debug',
],
'subminer',
);
assert.deepEqual(full.invocations.syncCliTokens, [
'media-box',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
]);
assert.equal(full.invocations.syncLogLevel, 'debug');
const snapshot = parseCliPrograms(['sync', '--snapshot', '/tmp/out.sqlite'], 'subminer');
assert.deepEqual(snapshot.invocations.syncCliTokens, ['--snapshot', '/tmp/out.sqlite']);
const merge = parseCliPrograms(['sync', '--merge', '/tmp/in.sqlite'], 'subminer');
assert.deepEqual(merge.invocations.syncCliTokens, ['--merge', '/tmp/in.sqlite']);
const makeTemp = parseCliPrograms(['sync', '--make-temp'], 'subminer');
assert.deepEqual(makeTemp.invocations.syncCliTokens, ['--make-temp']);
const removeTemp = parseCliPrograms(
['sync', '--remove-temp', '/tmp/subminer-sync-x'],
'subminer',
);
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
});
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
test('parseCliPrograms leaves sync validation to the app parser', () => {
// Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them.
const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer');
assert.equal(invalid.invocations.syncTriggered, true);
assert.deepEqual(invalid.invocations.syncCliTokens, ['media-box', '--push', '--pull']);
const empty = parseCliPrograms(['sync'], 'subminer');
assert.equal(empty.invocations.syncTriggered, true);
assert.deepEqual(empty.invocations.syncCliTokens, []);
});
test('parseCliPrograms captures sync --ui', () => {
const result = parseCliPrograms(['sync', '--ui'], 'subminer');
assert.equal(result.invocations.syncUiTriggered, true);
assert.equal(result.invocations.syncTriggered, false);
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'),
/--push and --pull cannot be combined/,
);
assert.throws(
() => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'),
/--push and --pull require a host/,
() => parseCliPrograms(['sync', 'media-box', '--ui'], 'subminer'),
/--ui cannot be combined/,
);
});
test('parseCliPrograms rejects sync --ui with --remote-cmd', () => {
assert.throws(
() => parseCliPrograms(['sync', '--ui', '--remote-cmd', '/opt/SubMiner.AppImage'], 'subminer'),
{ message: 'Sync --ui cannot be combined with other sync options.' },
);
});
test('parseCliPrograms rejects sync --ui with --db', () => {
assert.throws(() => parseCliPrograms(['sync', '--ui', '--db', '/tmp/db.sqlite'], 'subminer'), {
message: 'Sync --ui cannot be combined with other sync options.',
});
});
+60 -42
View File
@@ -39,14 +39,10 @@ export interface CliInvocations {
statsCleanupLifetime: boolean;
statsLogLevel: string | null;
syncTriggered: boolean;
syncHost: string | null;
syncSnapshotPath: string | null;
syncMergePath: string | null;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string | null;
syncDbPath: string | null;
syncForce: boolean;
syncCliTokens: string[];
syncLogLevel: string | null;
syncUiTriggered: boolean;
syncUiLogLevel: string | null;
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
@@ -75,7 +71,11 @@ function applyRootOptions(program: Command): void {
.option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
.option('-T, --no-texthooker', 'Disable texthooker-ui server')
// The SubMiner app answers sync commands when invoked with --sync-cli.
// Remote-command resolution may address the launcher the same way, so
// accept the flag as a no-op to keep both invocation shapes equivalent.
.option('--sync-cli', 'Compatibility no-op (sync commands work with or without it)');
}
function buildSubcommandHelpText(program: Command): string {
@@ -171,14 +171,10 @@ export function parseCliPrograms(
let statsCleanupLifetime = false;
let statsLogLevel: string | null = null;
let syncTriggered = false;
let syncHost: string | null = null;
let syncSnapshotPath: string | null = null;
let syncMergePath: string | null = null;
let syncDirection: 'both' | 'push' | 'pull' = 'both';
let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null;
let syncForce = false;
let syncCliTokens: string[] = [];
let syncLogLevel: string | null = null;
let syncUiTriggered = false;
let syncUiLogLevel: string | null = null;
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let logsTriggered = false;
@@ -319,6 +315,11 @@ export function parseCliPrograms(
.option('--db <file>', 'Override the local stats database path')
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
.option('-f, --force', 'Skip the running-app safety check')
.option('--check', 'Test the SSH connection and remote subminer availability')
.option('--json', 'Emit machine-readable NDJSON progress output')
.option('--make-temp', 'Create a sync temp directory and print its path (used over SSH)')
.option('--remove-temp <dir>', 'Remove a sync temp directory created by --make-temp')
.option('--ui', 'Open the SubMiner sync window')
.option('--log-level <level>', 'Log level')
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
@@ -326,28 +327,49 @@ export function parseCliPrograms(
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
const push = options.push === true;
const pull = options.pull === true;
if (push && pull) {
throw new Error('Sync --push and --pull cannot be combined.');
}
if ((push || pull) && !host) {
throw new Error('Sync --push and --pull require a host.');
}
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
if (modes === 0) {
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
}
if (modes > 1) {
throw new Error('Sync host, --snapshot, and --merge cannot be combined.');
const check = options.check === true;
const makeTemp = options.makeTemp === true;
const removeTemp = typeof options.removeTemp === 'string' ? options.removeTemp.trim() : '';
if (options.ui === true) {
if (
host ||
snapshot ||
merge ||
push ||
pull ||
check ||
makeTemp ||
removeTemp ||
options.remoteCmd !== undefined ||
options.db !== undefined ||
options.json === true ||
options.force === true
) {
throw new Error('Sync --ui cannot be combined with other sync options.');
}
syncUiTriggered = true;
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
return;
}
// No validation here: the app's parseSyncCliTokens owns the sync rules
// and its error text reaches the terminal through the child's stdio.
const remoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() : '';
const dbPath = typeof options.db === 'string' ? options.db.trim() : '';
const tokens: string[] = [];
if (host) tokens.push(host);
if (snapshot) tokens.push('--snapshot', snapshot);
if (merge) tokens.push('--merge', merge);
if (makeTemp) tokens.push('--make-temp');
if (removeTemp) tokens.push('--remove-temp', removeTemp);
if (push) tokens.push('--push');
if (pull) tokens.push('--pull');
if (check) tokens.push('--check');
if (remoteCmd) tokens.push('--remote-cmd', remoteCmd);
if (dbPath) tokens.push('--db', dbPath);
if (options.force === true) tokens.push('--force');
if (options.json === true) tokens.push('--json');
syncTriggered = true;
syncHost = host || null;
syncSnapshotPath = snapshot || null;
syncMergePath = merge || null;
syncDirection = push ? 'push' : pull ? 'pull' : 'both';
syncRemoteCmd =
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true;
syncCliTokens = tokens;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
@@ -463,14 +485,10 @@ export function parseCliPrograms(
statsCleanupLifetime,
statsLogLevel,
syncTriggered,
syncHost,
syncSnapshotPath,
syncMergePath,
syncDirection,
syncRemoteCmd,
syncDbPath,
syncForce,
syncCliTokens,
syncLogLevel,
syncUiTriggered,
syncUiLogLevel,
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,
+6 -69
View File
@@ -1,32 +1,12 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.js';
import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js';
import {
isReadonlyWalRetryError,
withReadonlyWalRetry,
} from '../src/core/services/stats-sync/wal-retry.js';
export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject();
const tracking =
root?.immersionTracking &&
typeof root.immersionTracking === 'object' &&
!Array.isArray(root.immersionTracking)
? (root.immersionTracking as Record<string, unknown>)
: null;
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
if (configured) return resolvePathMaybe(configured);
const configDir = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
return path.join(configDir, 'immersion.sqlite');
}
export { isReadonlyWalRetryError, resolveImmersionDbPath, withReadonlyWalRetry };
interface RawHistoryRow {
video_id: number;
@@ -43,49 +23,6 @@ export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
}
export function withReadonlyWalRetry<T>(
dbPath: string,
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
): T {
try {
return query({ readonly: true });
} catch (error) {
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
return query({ readwrite: true, create: false });
}
}
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
if (!isWalModeSqliteDatabase(dbPath)) return false;
const code =
typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const message = error instanceof Error ? error.message : String(error);
const text = `${code} ${message}`.toLowerCase();
return (
text.includes('readonly') ||
text.includes('read-only') ||
text.includes('attempt to write a readonly database') ||
text.includes('sqlite_cantopen') ||
text.includes('unable to open database file')
);
}
function isWalModeSqliteDatabase(dbPath: string): boolean {
const header = Buffer.alloc(20);
let fd: number | null = null;
try {
fd = fs.openSync(dbPath, 'r');
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
}
function tableExists(db: Database, tableName: string): boolean {
return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
+2 -7
View File
@@ -31,13 +31,8 @@ function createArgs(): Args {
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+33 -7
View File
@@ -119,6 +119,37 @@ test('runAppCommandCaptureOutput transports Linux AppImage args through environm
}
});
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'SubMiner.AppImage');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
'printf "args:%s\\n" "$*"',
'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
try {
const result = withPlatform('linux', () =>
runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']),
);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:-e /m);
assert.match(result.stdout, /^electron-node:1$/m);
assert.match(result.stdout, /^argc:4$/m);
assert.match(result.stdout, /^arg0:--sync-cli$/m);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
@@ -572,13 +603,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'error',
logRotation: 7,
passwordStore: '',
+43 -2
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import type { StdioOptions } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import {
@@ -1276,6 +1277,15 @@ function shouldTransportAppArgsForAppImage(appPath: string): boolean {
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
}
const APPIMAGE_SYNC_NODE_RUNNER = [
'const root=process.env.APPDIR+"/resources/app.asar";',
'const {runSyncCliFromProcess}=require(root+"/dist/main/sync-cli.js");',
'const count=Number(process.env.SUBMINER_APP_ARGC);',
'const argv=[process.execPath,...Array.from({length:count},(_,i)=>process.env["SUBMINER_APP_ARG_"+i]??"")];',
'runSyncCliFromProcess(argv,require(root+"/package.json").version)',
'.then(code=>process.exit(code),error=>{console.error(error);process.exit(1)});',
].join('');
function buildAppEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
extraEnv: NodeJS.ProcessEnv = {},
@@ -1431,6 +1441,16 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
if (shouldTransportAppArgsForAppImage(appPath)) {
if (appArgs[0] === '--sync-cli') {
return {
command: appPath,
args: ['-e', APPIMAGE_SYNC_NODE_RUNNER],
env: {
...buildTransportedAppArgsEnv(appArgs),
ELECTRON_RUN_AS_NODE: '1',
},
};
}
return {
command: appPath,
args: [],
@@ -1444,16 +1464,37 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
}
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void {
runAppCommand(appPath, appArgs, ['ignore', 'pipe', 'pipe'], true);
}
/**
* Like runAppCommandWithInherit, but with the terminal fully attached: the
* child owns stdin (ssh password/host-key prompts must reach the user) and
* writes stdout/stderr directly (NDJSON --json output must stay unwrapped).
* Used for `subminer sync`, which proxies to the app's --sync-cli mode.
*/
export function runAppCommandInteractive(appPath: string, appArgs: string[]): void {
runAppCommand(appPath, appArgs, ['inherit', 'inherit', 'inherit'], false);
}
function runAppCommand(
appPath: string,
appArgs: string[],
stdio: StdioOptions,
attachLogging: boolean,
): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
stdio,
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
if (attachLogging) {
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
}
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
@@ -4,7 +4,11 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { createDbSnapshot, mergeSnapshotIntoDb } from './sync-db.js';
// The engine executes on libsql in production; these merge tests run through
// that same driver. bun:sqlite is used only to build fixtures and inspect
// results.
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js';
import {
createImmersionDbFixture,
insertFixtureSession,
@@ -596,3 +600,61 @@ test('createDbSnapshot produces a mergeable copy', () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('adopted word frequency excludes active-session counts that merge later', () => {
const { dir, localPath, remotePath } = makeDbPair();
try {
// Remote has an ended session and a stale ACTIVE one (e.g. app crashed).
// The remote tracker increments frequency live, so the word's frequency (5)
// already includes the active session's 4 occurrences even though that
// session's lines are skipped by the merge.
insertFixtureSession(remotePath, {
uuid: 'remote-ended',
videoKey: 'showb-e1',
animeTitleKey: 'showb',
startedAtMs: BASE_MS,
applyLifetime: true,
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 }],
});
insertFixtureSession(remotePath, {
uuid: 'remote-active',
videoKey: 'showb-e2',
animeTitleKey: 'showb',
startedAtMs: BASE_MS + DAY_MS,
endedAtMs: null,
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 4 }],
});
const first = mergeSnapshotIntoDb(localPath, remotePath);
assert.equal(first.sessionsMerged, 1);
assert.equal(first.activeSessionsSkipped, 1);
assert.equal(first.wordsAdded, 1);
// Only the ended session's count is adopted; the active session's slice
// is re-added when that session finalizes and syncs.
assert.equal(
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
?.frequency,
1,
);
// The remote app restarts and finalizes the stale session.
withWritableDb(remotePath, (db) => {
db.prepare(
`UPDATE imm_sessions SET ended_at_ms = ?, status = 2
WHERE session_uuid = 'remote-active'`,
).run(String(BASE_MS + DAY_MS + 1_500_000));
});
const second = mergeSnapshotIntoDb(localPath, remotePath);
assert.equal(second.sessionsMerged, 1);
assert.equal(second.sessionsAlreadyPresent, 1);
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
assert.equal(
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
?.frequency,
5,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
-51
View File
@@ -1,51 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertSafeSshHost, resolveRemoteSubminerCommand, runScp, shellQuote } from './ssh.js';
test('assertSafeSshHost rejects option-like hosts', () => {
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
assert.throws(() => assertSafeSshHost('-lroot'), /looks like an option/);
});
test('assertSafeSshHost accepts normal destinations', () => {
assert.doesNotThrow(() => assertSafeSshHost('macbook'));
assert.doesNotThrow(() => assertSafeSshHost('user@192.168.1.20'));
assert.doesNotThrow(() => assertSafeSshHost('ssh-alias'));
});
test('shellQuote escapes single quotes and wraps in quotes', () => {
assert.equal(shellQuote('subminer'), `'subminer'`);
assert.equal(shellQuote(`a'; rm -rf ~; '`), `'a'\\''; rm -rf ~; '\\'''`);
});
test('runScp rejects option-like local endpoints before spawning scp', () => {
assert.throws(() => runScp('-oProxyCommand=sh', '/tmp/out.sqlite'), /looks like an option/);
assert.throws(() => runScp('/tmp/in.sqlite', '-bad-destination'), /looks like an option/);
});
test('runScp rejects option-like remote host components', () => {
assert.throws(
() => runScp('-oProxyCommand=sh:/tmp/in.sqlite', '/tmp/out.sqlite'),
/SSH host that looks like an option/,
);
});
test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => {
const calls: Array<{ host: string; remoteCommand: string }> = [];
const command = resolveRemoteSubminerCommand('macbook', null, (host, remoteCommand) => {
calls.push({ host, remoteCommand });
return { status: 0, stdout: '', stderr: '' };
});
assert.equal(
command,
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer',
);
assert.deepEqual(calls, [
{
host: 'macbook',
remoteCommand:
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer --help >/dev/null 2>&1',
},
]);
});
-106
View File
@@ -1,106 +0,0 @@
import { spawnSync } from 'node:child_process';
export interface RemoteRunResult {
status: number;
stdout: string;
stderr: string;
}
/**
* ssh/scp have no `--` terminator for the destination, so a host that starts
* with `-` (e.g. `-oProxyCommand=...`) is parsed as an option. Reject those
* before spawning.
*/
export function assertSafeSshHost(host: string): void {
if (host.startsWith('-')) {
throw new Error(`Refusing to use SSH host that looks like an option: ${host}`);
}
}
/**
* Run a command on the SSH host. stdin stays attached so interactive prompts
* can still read from the terminal; stdout/stderr are captured for callers
* that need actionable remote failure messages.
*/
export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
assertSafeSshHost(host);
const result = spawnSync('ssh', [host, remoteCommand], {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'pipe'],
});
if (result.error) {
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
}
return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
}
function assertSafeScpEndpoint(endpoint: string): void {
const colon = endpoint.indexOf(':');
const slash = endpoint.indexOf('/');
if (colon <= 0 || (slash !== -1 && slash < colon)) {
if (endpoint.startsWith('-')) {
throw new Error(`Refusing to use scp endpoint that looks like an option: ${endpoint}`);
}
return;
}
const host = endpoint.slice(0, colon);
const remotePath = endpoint.slice(colon + 1);
assertSafeSshHost(host);
if (remotePath.startsWith('-')) {
throw new Error(`Refusing to use scp remote path that looks like an option: ${remotePath}`);
}
}
export function runScp(from: string, to: string): void {
assertSafeScpEndpoint(from);
assertSafeScpEndpoint(to);
const result = spawnSync('scp', ['-q', from, to], {
encoding: 'utf8',
stdio: ['inherit', 'inherit', 'inherit'],
});
if (result.error) {
throw new Error(`Failed to run scp: ${(result.error as Error).message}`);
}
if ((result.status ?? 1) !== 0) {
throw new Error(`scp failed copying ${from} -> ${to}`);
}
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
const REMOTE_RUNTIME_PATH =
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"';
/**
* Non-interactive SSH shells often miss user-installed launchers and Bun.
* Probe the launcher under the same deterministic PATH used by sync itself.
*/
export function resolveRemoteSubminerCommand(
host: string,
preferred: string | null,
runRemote: typeof runSsh = runSsh,
): string {
// Trusted defaults stay unquoted so the remote shell expands `~`; a
// user-supplied override is shell-quoted to prevent command injection.
const candidates: Array<{ value: string; invocation: string }> = preferred
? [{ value: preferred, invocation: shellQuote(preferred) }]
: [
{ value: 'subminer', invocation: 'subminer' },
{ value: '~/.local/bin/subminer', invocation: '~/.local/bin/subminer' },
];
for (const candidate of candidates) {
const command = `${REMOTE_RUNTIME_PATH} ${candidate.invocation}`;
const probe = runRemote(host, `${command} --help >/dev/null 2>&1`);
if (probe.status === 0) {
return command;
}
}
throw new Error(
preferred
? `Remote command not found on ${host}: ${preferred}`
: `subminer not found on ${host} (tried PATH and ~/.local/bin/subminer). Pass --remote-cmd <path>.`,
);
}
+3 -7
View File
@@ -114,13 +114,9 @@ export interface Args {
useRofi: boolean;
history: boolean;
sync: boolean;
syncHost: string;
syncSnapshotPath: string;
syncMergePath: string;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string;
syncDbPath: string;
syncForce: boolean;
/** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */
syncCliTokens: string[];
syncUi: boolean;
logLevel: LogLevel;
logRotation: LogRotation;
passwordStore: string;
+2 -1
View File
@@ -20,9 +20,10 @@
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && 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:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
) as { scripts: Record<string, string> };
const command = packageJson.scripts['build:syncui'] ?? '';
assert.match(command, /src\/preload-syncui\.ts/);
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
});
+7
View File
@@ -9,6 +9,8 @@ const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
@@ -41,6 +43,10 @@ function copySettingsAssets() {
copyAssets(settingsSourceDir, settingsOutputDir, 'settings');
}
function copySyncUiAssets() {
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
}
function fallbackToMacosSource() {
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
@@ -83,6 +89,7 @@ function buildMacosHelper() {
function main() {
copyRendererAssets();
copySettingsAssets();
copySyncUiAssets();
buildMacosHelper();
}
+7
View File
@@ -14,6 +14,7 @@ export interface CliArgs {
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
settings: boolean;
syncWindow: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -123,6 +124,7 @@ export function parseArgs(argv: string[]): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -271,6 +273,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--sync-window') args.syncWindow = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -544,6 +547,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.syncWindow ||
args.setup ||
args.show ||
args.hide ||
@@ -622,6 +626,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.settings &&
!args.syncWindow &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -693,6 +698,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.settings ||
args.syncWindow ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -750,6 +756,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.syncWindow &&
!args.show &&
!args.hide &&
!args.setup &&
+5
View File
@@ -79,6 +79,11 @@ ${B}Jellyfin${R}
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
${B}Stats sync${R}
--sync-window Open the stats sync window
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
${D}run SubMiner --sync-cli --help for details)${R}
${B}Options${R}
--socket ${D}PATH${R} mpv IPC socket path
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
+81 -2
View File
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -140,6 +141,65 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
assert.equal(getLockCalls(), 1);
});
test('startAppLifecycle defers quit until async cleanup settles', async () => {
let willQuit: ((event: { preventDefault(): void }) => void) | null = null;
let releaseCleanup: (() => void) | null = null;
const cleanupDone = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
let prevented = false;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
onWillQuit: (handler) => {
willQuit = handler;
},
onWillQuitCleanup: () => cleanupDone,
});
startAppLifecycle(makeArgs({ start: true }), deps);
assert.ok(willQuit);
(willQuit as (event: { preventDefault(): void }) => void)({
preventDefault: () => {
prevented = true;
},
});
assert.equal(prevented, true);
assert.deepEqual(calls, []);
assert.ok(releaseCleanup);
(releaseCleanup as () => void)();
await cleanupDone;
// The re-quit must not fire in the microtask turn of the will-quit
// dispatch: Electron drops a quit issued while the prevented quit is
// still unwinding, leaving a windowless process alive.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(calls, []);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['quitApp']);
});
test('startAppLifecycle contains synchronous quit cleanup failures', () => {
let willQuit: ((event: { preventDefault(): void }) => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
onWillQuit: (handler) => {
willQuit = handler;
},
onWillQuitCleanup: () => {
throw new Error('cleanup exploded');
},
});
startAppLifecycle(makeArgs({ start: true }), deps);
assert.ok(willQuit);
assert.doesNotThrow(() =>
(willQuit as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} }),
);
assert.deepEqual(calls, []);
});
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
const { deps, calls, getLockCalls } = createDeps({
shouldStartApp: () => false,
@@ -252,7 +312,7 @@ test('startAppLifecycle routes control socket commands through the second-instan
},
});
let willQuitHandler: (() => void) | null = null;
let willQuitHandler: ((event: { preventDefault(): void }) => void) | null = null;
deps.onWillQuit = (handler) => {
willQuitHandler = handler;
};
@@ -274,7 +334,7 @@ test('startAppLifecycle routes control socket commands through the second-instan
assert.deepEqual(handled, ['ready', 'second-instance:start']);
assert.ok(willQuitHandler);
(willQuitHandler as () => void)();
(willQuitHandler as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} });
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
});
@@ -353,3 +413,22 @@ test('startAppLifecycle quits macOS setup-only launch when all windows close', (
handler();
assert.deepEqual(calls, ['quitApp']);
});
test('startAppLifecycle quits macOS sync-window launch when its window closes', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ syncWindow: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
+38 -6
View File
@@ -16,11 +16,11 @@ export interface AppLifecycleServiceDeps {
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
onWillQuit: (handler: (event: { preventDefault(): void }) => void) => void;
onActivate: (handler: () => void) => void;
isDarwinPlatform: () => boolean;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -44,7 +44,7 @@ export interface AppLifecycleDepsRuntimeOptions {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
@@ -183,16 +183,48 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
deps.onWindowAllClosed(() => {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
(!deps.isDarwinPlatform() ||
initialArgs.settings ||
initialArgs.setup ||
initialArgs.syncWindow)
) {
deps.quitApp();
}
});
deps.onWillQuit(() => {
let quitCleanupPending = false;
let quitCleanupComplete = false;
deps.onWillQuit((event) => {
if (quitCleanupComplete) return;
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup();
if (quitCleanupPending) {
event.preventDefault();
return;
}
let cleanup: void | Promise<void>;
try {
cleanup = deps.onWillQuitCleanup();
} catch (error) {
logger.error('App quit cleanup failed:', error);
return;
}
if (!(cleanup instanceof Promise)) return;
quitCleanupPending = true;
event.preventDefault();
void cleanup
.catch((error) => {
logger.error('App quit cleanup failed:', error);
})
.finally(() => {
quitCleanupPending = false;
quitCleanupComplete = true;
// A cleanup promise that settles in a microtask would re-quit while
// Electron is still unwinding the prevented quit, and that quit call
// is silently dropped, leaving a windowless process alive. Re-issue
// the quit from a fresh macrotask instead.
setImmediate(() => deps.quitApp());
});
});
deps.onActivate(() => {
+5
View File
@@ -21,6 +21,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -138,6 +139,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openConfigSettingsWindow: () => {
calls.push('openConfigSettingsWindow');
},
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -660,6 +664,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openFirstRunSetup: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+5
View File
@@ -44,6 +44,7 @@ export interface CliCommandServiceDeps {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -170,6 +171,7 @@ interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -274,6 +276,7 @@ export function createCliCommandDepsRuntime(
}, delayMs);
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -417,6 +420,8 @@ export function handleCliCommand(
deps.openYomitanSettingsDelayed(1000);
} else if (args.settings) {
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
deps.openSyncUiWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { extractSyncCliTokens, parseSyncCliTokens } from './cli-args';
test('extractSyncCliTokens returns tokens after --sync-cli', () => {
assert.equal(extractSyncCliTokens(['/bin/electron', '/app']), null);
assert.deepEqual(extractSyncCliTokens(['/bin/electron', '/app', '--sync-cli', 'sync', 'host']), [
'sync',
'host',
]);
// A repeated flag (e.g. resolved remote command + forwarded argv) is ignored.
assert.deepEqual(extractSyncCliTokens(['/app', '--sync-cli', '--sync-cli', 'sync', 'h']), [
'sync',
'h',
]);
});
test('parseSyncCliTokens handles help, version, and run modes', () => {
assert.deepEqual(parseSyncCliTokens(['--help']), { kind: 'help' });
assert.deepEqual(parseSyncCliTokens(['--version']), { kind: 'version' });
const run = parseSyncCliTokens(['sync', 'media-box', '--pull', '--force', '--json']);
assert.equal(run.kind, 'run');
if (run.kind === 'run') {
assert.equal(run.args.syncHost, 'media-box');
assert.equal(run.args.syncDirection, 'pull');
assert.equal(run.args.syncForce, true);
assert.equal(run.args.syncJson, true);
}
const snapshot = parseSyncCliTokens(['sync', '--snapshot', '/tmp/x.sqlite', '--db', '/tmp/db']);
assert.equal(snapshot.kind, 'run');
if (snapshot.kind === 'run') {
assert.equal(snapshot.args.syncSnapshotPath, '/tmp/x.sqlite');
assert.equal(snapshot.args.syncDbPath, '/tmp/db');
}
});
test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
const makeTemp = parseSyncCliTokens(['sync', '--make-temp']);
assert.equal(makeTemp.kind, 'run');
if (makeTemp.kind === 'run') assert.equal(makeTemp.args.syncMakeTemp, true);
const removeTemp = parseSyncCliTokens(['sync', '--remove-temp', '/tmp/subminer-sync-x']);
assert.equal(removeTemp.kind, 'run');
if (removeTemp.kind === 'run') {
assert.equal(removeTemp.args.syncRemoveTempPath, '/tmp/subminer-sync-x');
}
assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
});
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
assert.equal(parseSyncCliTokens([]).kind, 'error');
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--snapshot', '/tmp/x', '--push']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--check']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--snapshot', '/tmp/x']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', '--bogus']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error');
});
test('parseSyncCliTokens rejects an option-like token where a value is required', () => {
// Without this guard `--snapshot --force` writes a snapshot to a file literally
// named "--force" and silently drops the flag.
assert.deepEqual(parseSyncCliTokens(['sync', '--snapshot', '--force']), {
kind: 'error',
message: 'Missing value for --snapshot.',
});
assert.deepEqual(parseSyncCliTokens(['sync', '--remove-temp', '--force']), {
kind: 'error',
message: 'Missing value for --remove-temp.',
});
// The `--flag=<value>` form still accepts values that begin with "-".
const parsed = parseSyncCliTokens(['sync', '--snapshot=-weird-name.sqlite']);
assert.equal(parsed.kind, 'run');
assert.equal(parsed.kind === 'run' && parsed.args.syncSnapshotPath, '-weird-name.sqlite');
});
+165
View File
@@ -0,0 +1,165 @@
import type { SyncFlowArgs } from './sync-flow';
export const SYNC_CLI_FLAG = '--sync-cli';
export type ParsedSyncCli =
| { kind: 'help' }
| { kind: 'version' }
| { kind: 'run'; args: SyncFlowArgs }
| { kind: 'error'; message: string };
export function extractSyncCliTokens(argv: readonly string[]): string[] | null {
const index = argv.indexOf(SYNC_CLI_FLAG);
if (index === -1) return null;
return argv.slice(index + 1).filter((token) => token !== SYNC_CLI_FLAG);
}
/**
* Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the
* app's --sync-cli mode. This is the single owner of sync CLI validation:
* the launcher forwards `subminer sync` tokens verbatim, so both entry
* points accept the same command lines and fail the same way.
*/
export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' };
if (tokens.includes('--version') || tokens.includes('-V')) return { kind: 'version' };
const rest = [...tokens];
if (rest[0] !== 'sync') {
return {
kind: 'error',
message: `Expected a "sync" command after ${SYNC_CLI_FLAG} (e.g. ${SYNC_CLI_FLAG} sync --snapshot <file>).`,
};
}
rest.shift();
let host = '';
let snapshot = '';
let merge = '';
let push = false;
let pull = false;
let check = false;
let force = false;
let json = false;
let makeTemp = false;
let removeTemp = '';
let remoteCmd = '';
let dbPath = '';
let logLevel = 'warn';
const valueFlags = new Map<string, (value: string) => void>([
['--snapshot', (value) => (snapshot = value.trim())],
['--merge', (value) => (merge = value.trim())],
['--remove-temp', (value) => (removeTemp = value.trim())],
['--remote-cmd', (value) => (remoteCmd = value.trim())],
['--db', (value) => (dbPath = value.trim())],
['--log-level', (value) => (logLevel = value.trim() || 'warn')],
]);
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i]!;
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
if (assignValue) {
if (token.includes('=')) {
assignValue(token.slice(token.indexOf('=') + 1));
continue;
}
const value = rest[i + 1];
// An option-like token is never a value: `--snapshot --force` must fail
// loudly instead of writing a snapshot to a file named "--force". Paths
// that really do start with "-" can still be passed as `--snapshot=-x`.
if (value === undefined || value.startsWith('-')) {
return { kind: 'error', message: `Missing value for ${token}.` };
}
assignValue(value);
i += 1;
continue;
}
if (token === '--push') push = true;
else if (token === '--pull') pull = true;
else if (token === '--check') check = true;
else if (token === '--force' || token === '-f') force = true;
else if (token === '--json') json = true;
else if (token === '--make-temp') makeTemp = true;
else if (token.startsWith('-')) {
return { kind: 'error', message: `Unknown sync option: ${token}` };
} else if (host) {
return { kind: 'error', message: `Unexpected extra argument: ${token}` };
} else {
host = token.trim();
}
}
if (push && pull) return { kind: 'error', message: 'Sync --push and --pull cannot be combined.' };
if ((push || pull) && !host) {
return { kind: 'error', message: 'Sync --push and --pull require a host.' };
}
if (check && !host) return { kind: 'error', message: 'Sync --check requires a host.' };
if (check && (push || pull || snapshot || merge)) {
return {
kind: 'error',
message: 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
};
}
const modes = [
Boolean(host),
Boolean(snapshot),
Boolean(merge),
makeTemp,
Boolean(removeTemp),
].filter(Boolean).length;
if (modes === 0) {
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
}
if (modes > 1) {
return {
kind: 'error',
message: 'Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.',
};
}
return {
kind: 'run',
args: {
syncHost: host,
syncSnapshotPath: snapshot,
syncMergePath: merge,
syncDirection: push ? 'push' : pull ? 'pull' : 'both',
syncRemoteCmd: remoteCmd,
syncDbPath: dbPath,
syncForce: force,
syncJson: json,
syncCheck: check,
syncMakeTemp: makeTemp,
syncRemoveTempPath: removeTemp,
logLevel,
},
};
}
export function syncCliUsage(): string {
return [
'SubMiner sync CLI',
'',
`Usage: SubMiner ${SYNC_CLI_FLAG} sync [host] [options]`,
'',
'Modes (exactly one):',
' <host> Sync stats with an SSH destination (user@host or ssh alias)',
' --snapshot <file> Write a consistent snapshot of the local stats database',
' --merge <file> Merge a snapshot database file into the local stats database',
' --make-temp Create a sync temp directory and print its path (used over SSH)',
' --remove-temp <dir> Remove a sync temp directory created by --make-temp',
'',
'Options:',
' --push Only merge local stats into the SSH host',
' --pull Only merge stats from the SSH host into the local database',
' --check Test the SSH connection and remote SubMiner availability',
' --db <file> Override the local stats database path',
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
' -f, --force Skip the running-app safety check',
' --json Emit machine-readable NDJSON progress output',
' --log-level <level> Log level',
' --help Show this help',
' --version Show the SubMiner version',
].join('\n');
}
+42
View File
@@ -0,0 +1,42 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveConfigFilePath } from '../../../config/path-resolution';
import { parseConfigContent } from '../../../config/parse';
import { getDefaultConfigDir } from '../../../shared/setup-state';
/**
* Default immersion stats database location, shared by the launcher's history
* command and the app's --sync-cli mode: honor a configured
* immersionTracking.dbPath (raw main-config read, tolerant of comments), else
* <configDir>/immersion.sqlite. Electron- and libsql-free on purpose so the
* bun launcher can import it.
*/
export function resolveImmersionDbPath(): string {
const configPath = resolveConfigFilePath({
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
let configured = '';
try {
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf8'));
const tracking =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).immersionTracking
: null;
if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) {
const dbPath = (tracking as Record<string, unknown>).dbPath;
if (typeof dbPath === 'string') configured = dbPath.trim();
}
} catch {
// no config or unreadable config → default location
}
if (configured) {
return configured.startsWith('~')
? path.join(os.homedir(), configured.slice(1))
: configured;
}
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
}
@@ -0,0 +1,68 @@
import { Database } from '../immersion-tracker/sqlite';
import type { SyncDbOpenOptions } from './wal-retry';
export interface SyncDbRunResult {
changes: number;
lastInsertRowid: number | bigint;
}
export interface SyncDbStatement {
run(...params: unknown[]): SyncDbRunResult;
get(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
}
export interface SyncDb {
/** Prepare (or reuse a cached prepared statement for) the given SQL. */
query(sql: string): SyncDbStatement;
/** Execute SQL that returns no rows (pragmas, transaction control). */
exec(sql: string): void;
close(): void;
}
export type SqlRow = Record<string, unknown>;
export function selectAll(db: SyncDb, sql: string, params: unknown[] = []): SqlRow[] {
return db.query(sql).all(...params) as SqlRow[];
}
export function selectOne(db: SyncDb, sql: string, params: unknown[] = []): SqlRow | undefined {
return (db.query(sql).get(...params) ?? undefined) as SqlRow | undefined;
}
interface LibsqlDatabase {
prepare(sql: string): SyncDbStatement;
exec(sql: string): unknown;
close(): unknown;
}
/**
* libsql (better-sqlite3 API) SQLite connection for the stats-sync engine.
* prepare() is not cached by libsql, so query() keeps a per-connection
* statement cache because the merge prepares a handful of statements and runs them
* once per copied row, so re-preparing would dominate merge time.
*/
export function openLibsqlSyncDb(dbPath: string, options: SyncDbOpenOptions): SyncDb {
const db = new Database(dbPath, {
readonly: options.readonly === true,
fileMustExist: options.create !== true,
}) as unknown as LibsqlDatabase;
const statements = new Map<string, SyncDbStatement>();
return {
query(sql: string): SyncDbStatement {
let statement = statements.get(sql);
if (!statement) {
statement = db.prepare(sql);
statements.set(sql, statement);
}
return statement;
},
exec(sql: string): void {
db.exec(sql);
},
close(): void {
statements.clear();
db.close();
},
};
}
@@ -1,5 +1,5 @@
import { Database } from 'bun:sqlite';
import { insertRow, tableExists, type SyncMergeSummary } from './sync-shared.js';
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, tableExists, type SyncMergeSummary } from './shared';
const ANIME_COPY_COLUMNS = [
'normalized_title_key',
@@ -90,23 +90,15 @@ const WORD_COPY_COLUMNS = [
'frequency_rank',
] as const;
type SqlRow = Record<string, unknown>;
function selectAll(db: Database, sql: string, params: unknown[] = []): SqlRow[] {
return db.query<SqlRow>(sql).all(...params);
}
export function mergeAnime(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
summary: SyncMergeSummary,
): Map<number, number> {
const map = new Map<number, number>();
const byAnilist = local.prepare<SqlRow>('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
const byTitleKey = local.prepare<SqlRow>(
'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?',
);
const fillMissing = local.prepare(
const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?');
const fillMissing = local.query(
`UPDATE imm_anime
SET
title_romaji = COALESCE(title_romaji, ?),
@@ -122,9 +114,8 @@ export function mergeAnime(
`SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`,
)) {
const remoteId = Number(row.anime_id);
const existing =
(row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
byTitleKey.get(row.normalized_title_key);
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined;
if (existing) {
const localId = Number(existing.anime_id);
map.set(remoteId, localId);
@@ -153,17 +144,15 @@ export interface VideoMergeResult {
}
export function mergeVideos(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
animeIdMap: Map<number, number>,
summary: SyncMergeSummary,
): VideoMergeResult {
const videoIdMap = new Map<number, number>();
const addedVideoIds = new Set<number>();
const byKey = local.prepare<SqlRow>(
'SELECT video_id, watched FROM imm_videos WHERE video_key = ?',
);
const setWatched = local.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?');
const byKey = local.query('SELECT video_id, watched FROM imm_videos WHERE video_key = ?');
const setWatched = local.query('UPDATE imm_videos SET watched = 1 WHERE video_id = ?');
for (const row of selectAll(
remote,
@@ -172,7 +161,7 @@ export function mergeVideos(
const remoteId = Number(row.video_id);
const mappedAnimeId =
row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
const existing = byKey.get(row.video_key);
const existing = byKey.get(row.video_key) as SqlRow | undefined;
if (existing) {
const localId = Number(existing.video_id);
videoIdMap.set(remoteId, localId);
@@ -192,8 +181,8 @@ export function mergeVideos(
}
export function mergeMediaMetadata(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
videoIdMap: Map<number, number>,
addedVideoIds: Set<number>,
): void {
@@ -203,31 +192,29 @@ export function mergeMediaMetadata(
const hasBlobStore =
tableExists(local, 'imm_cover_art_blobs') && tableExists(remote, 'imm_cover_art_blobs');
const copyBlob = hasBlobStore
? local.prepare(
? local.query(
`INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
ON CONFLICT(blob_hash) DO NOTHING`,
)
: null;
const readBlob = hasBlobStore
? remote.prepare<SqlRow>('SELECT * FROM imm_cover_art_blobs WHERE blob_hash = ?')
? remote.query('SELECT * FROM imm_cover_art_blobs WHERE blob_hash = ?')
: null;
if (tableExists(remote, 'imm_media_art') && tableExists(local, 'imm_media_art')) {
const localArtExists = local.prepare<SqlRow>(
'SELECT 1 FROM imm_media_art WHERE video_id = ? LIMIT 1',
);
const localArtExists = local.query('SELECT 1 FROM imm_media_art WHERE video_id = ? LIMIT 1');
for (const remoteVideoId of metadataVideoIds) {
const localVideoId = videoIdMap.get(remoteVideoId)!;
if (localArtExists.get(localVideoId)) continue;
const row = remote
.query<SqlRow>(
`SELECT ${MEDIA_ART_COPY_COLUMNS.join(', ')} FROM imm_media_art WHERE video_id = ?`,
)
.get(remoteVideoId);
const row = selectOne(
remote,
`SELECT ${MEDIA_ART_COPY_COLUMNS.join(', ')} FROM imm_media_art WHERE video_id = ?`,
[remoteVideoId],
);
if (!row) continue;
if (row.cover_blob_hash && copyBlob && readBlob) {
const blob = readBlob.get(row.cover_blob_hash);
const blob = readBlob.get(row.cover_blob_hash) as SqlRow | undefined;
if (blob) {
copyBlob.run(blob.blob_hash, blob.cover_blob, blob.CREATED_DATE, blob.LAST_UPDATE_DATE);
}
@@ -242,17 +229,17 @@ export function mergeMediaMetadata(
}
if (tableExists(remote, 'imm_youtube_videos') && tableExists(local, 'imm_youtube_videos')) {
const localYoutubeExists = local.prepare<SqlRow>(
const localYoutubeExists = local.query(
'SELECT 1 FROM imm_youtube_videos WHERE video_id = ? LIMIT 1',
);
for (const remoteVideoId of metadataVideoIds) {
const localVideoId = videoIdMap.get(remoteVideoId)!;
if (localYoutubeExists.get(localVideoId)) continue;
const row = remote
.query<SqlRow>(
`SELECT ${YOUTUBE_COPY_COLUMNS.join(', ')} FROM imm_youtube_videos WHERE video_id = ?`,
)
.get(remoteVideoId);
const row = selectOne(
remote,
`SELECT ${YOUTUBE_COPY_COLUMNS.join(', ')} FROM imm_youtube_videos WHERE video_id = ?`,
[remoteVideoId],
);
if (!row) continue;
insertRow(
local,
@@ -265,8 +252,8 @@ export function mergeMediaMetadata(
}
export function mergeExcludedWords(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
summary: SyncMergeSummary,
): void {
if (
@@ -275,7 +262,7 @@ export function mergeExcludedWords(
) {
return;
}
const insert = local.prepare(
const insert = local.query(
`INSERT INTO imm_stats_excluded_words (headword, word, reading, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(headword, word, reading) DO NOTHING`,
@@ -298,9 +285,11 @@ export function mergeExcludedWords(
/**
* Lazily maps remote imm_words / imm_kanji ids onto local rows by natural key
* ((headword, word, reading) / kanji). New rows are copied with the remote's
* accumulated frequency; rows that already exist locally get their frequency
* incremented later with only the occurrence counts this merge adds (the
* remote total would double-count lines merged in earlier syncs).
* accumulated frequency minus any counts owed to skipped ACTIVE sessions
* (those merge later and re-add their counts); rows that already exist locally
* get their frequency incremented later with only the occurrence counts this
* merge adds (the remote total would double-count lines merged in earlier
* syncs).
*/
export class LexiconResolver {
private readonly wordMap = new Map<number, { localId: number; isNew: boolean }>();
@@ -309,28 +298,61 @@ export class LexiconResolver {
readonly kanjiFrequencyDeltas = new Map<number, number>();
constructor(
private readonly local: Database,
private readonly remote: Database,
private readonly local: SyncDb,
private readonly remote: SyncDb,
private readonly summary: SyncMergeSummary,
) {}
/**
* Occurrence counts the remote's live tracker already baked into `frequency`
* but that belong to skipped ACTIVE sessions. Those lines are not copied this
* merge; they are re-added via addWordOccurrences/addKanjiOccurrences when
* the session finalizes and syncs, so a newly adopted row must not carry them
* or that slice would be counted twice.
*/
private pendingActiveSessionOccurrences(
occurrenceTable: 'imm_word_line_occurrences' | 'imm_kanji_line_occurrences',
idColumn: 'word_id' | 'kanji_id',
remoteId: number,
): number {
const row = selectOne(
this.remote,
`SELECT COALESCE(SUM(o.occurrence_count), 0) AS pending
FROM ${occurrenceTable} o
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
JOIN imm_sessions s ON s.session_id = l.session_id
WHERE o.${idColumn} = ? AND s.ended_at_ms IS NULL`,
[remoteId],
);
return Number(row?.pending ?? 0);
}
private adoptedFrequency(frequency: unknown, pending: number): unknown {
if (pending <= 0 || typeof frequency !== 'number') return frequency;
return Math.max(0, frequency - pending);
}
resolveWord(remoteWordId: number): number {
const cached = this.wordMap.get(remoteWordId);
if (cached) return cached.localId;
const row = this.remote
.query<SqlRow>(`SELECT ${WORD_COPY_COLUMNS.join(', ')} FROM imm_words WHERE id = ?`)
.get(remoteWordId);
const row = selectOne(
this.remote,
`SELECT ${WORD_COPY_COLUMNS.join(', ')} FROM imm_words WHERE id = ?`,
[remoteWordId],
);
if (!row) throw new Error(`Snapshot references missing imm_words row ${remoteWordId}`);
const existing = this.local
.query<SqlRow>('SELECT id FROM imm_words WHERE headword IS ? AND word IS ? AND reading IS ?')
.get(row.headword, row.word, row.reading);
const existing = selectOne(
this.local,
'SELECT id FROM imm_words WHERE headword IS ? AND word IS ? AND reading IS ?',
[row.headword, row.word, row.reading],
);
let entry: { localId: number; isNew: boolean };
if (existing) {
entry = { localId: Number(existing.id), isNew: false };
this.local
.prepare(
.query(
`UPDATE imm_words
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
@@ -338,11 +360,18 @@ export class LexiconResolver {
)
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
} else {
const pending = this.pendingActiveSessionOccurrences(
'imm_word_line_occurrences',
'word_id',
remoteWordId,
);
const localId = insertRow(
this.local,
'imm_words',
WORD_COPY_COLUMNS,
WORD_COPY_COLUMNS.map((column) => row[column]),
WORD_COPY_COLUMNS.map((column) =>
column === 'frequency' ? this.adoptedFrequency(row[column], pending) : row[column],
),
);
entry = { localId, isNew: true };
this.summary.wordsAdded += 1;
@@ -355,19 +384,21 @@ export class LexiconResolver {
const cached = this.kanjiMap.get(remoteKanjiId);
if (cached) return cached.localId;
const row = this.remote
.query<SqlRow>('SELECT kanji, first_seen, last_seen, frequency FROM imm_kanji WHERE id = ?')
.get(remoteKanjiId);
const row = selectOne(
this.remote,
'SELECT kanji, first_seen, last_seen, frequency FROM imm_kanji WHERE id = ?',
[remoteKanjiId],
);
if (!row) throw new Error(`Snapshot references missing imm_kanji row ${remoteKanjiId}`);
const existing = this.local
.query<SqlRow>('SELECT id FROM imm_kanji WHERE kanji IS ?')
.get(row.kanji);
const existing = selectOne(this.local, 'SELECT id FROM imm_kanji WHERE kanji IS ?', [
row.kanji,
]);
let entry: { localId: number; isNew: boolean };
if (existing) {
entry = { localId: Number(existing.id), isNew: false };
this.local
.prepare(
.query(
`UPDATE imm_kanji
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
@@ -375,11 +406,16 @@ export class LexiconResolver {
)
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
} else {
const pending = this.pendingActiveSessionOccurrences(
'imm_kanji_line_occurrences',
'kanji_id',
remoteKanjiId,
);
const localId = insertRow(
this.local,
'imm_kanji',
['kanji', 'first_seen', 'last_seen', 'frequency'],
[row.kanji, row.first_seen, row.last_seen, row.frequency],
[row.kanji, row.first_seen, row.last_seen, this.adoptedFrequency(row.frequency, pending)],
);
entry = { localId, isNew: true };
this.summary.kanjiAdded += 1;
@@ -407,13 +443,13 @@ export class LexiconResolver {
}
applyFrequencyDeltas(): void {
const updateWord = this.local.prepare(
const updateWord = this.local.query(
'UPDATE imm_words SET frequency = COALESCE(frequency, 0) + ? WHERE id = ?',
);
for (const [localId, delta] of this.wordFrequencyDeltas) {
updateWord.run(delta, localId);
}
const updateKanji = this.local.prepare(
const updateKanji = this.local.query(
'UPDATE imm_kanji SET frequency = COALESCE(frequency, 0) + ? WHERE id = ?',
);
for (const [localId, delta] of this.kanjiFrequencyDeltas) {
@@ -1,7 +1,5 @@
import { Database } from 'bun:sqlite';
import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './sync-shared.js';
type SqlRow = Record<string, unknown>;
import { selectAll, type SqlRow, type SyncDb } from './libsql-driver';
import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './shared';
const LOCAL_DAY_EXPR = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`;
const LOCAL_MONTH_EXPR = `CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER)`;
@@ -125,7 +123,7 @@ const MONTHLY_ROLLUP_UPSERT = `
* the app later, which is idempotent.
*/
export function refreshRollupsForNewSessions(
local: Database,
local: SyncDb,
newSessionIds: number[],
summary: SyncMergeSummary,
): void {
@@ -134,12 +132,12 @@ export function refreshRollupsForNewSessions(
const groups = new Map<string, { day: number; month: number; videoId: number }>();
for (let offset = 0; offset < newSessionIds.length; offset += 500) {
const chunk = newSessionIds.slice(offset, offset + 500);
const rows = local
.query<SqlRow>(
`SELECT DISTINCT ${LOCAL_DAY_EXPR} AS rollup_day, ${LOCAL_MONTH_EXPR} AS rollup_month, video_id
FROM imm_sessions WHERE session_id IN (${chunk.map(() => '?').join(',')})`,
)
.all(...chunk);
const rows = selectAll(
local,
`SELECT DISTINCT ${LOCAL_DAY_EXPR} AS rollup_day, ${LOCAL_MONTH_EXPR} AS rollup_month, video_id
FROM imm_sessions WHERE session_id IN (${chunk.map(() => '?').join(',')})`,
chunk,
);
for (const row of rows) {
const day = Number(row.rollup_day);
const month = Number(row.rollup_month);
@@ -149,14 +147,12 @@ export function refreshRollupsForNewSessions(
}
const stampMs = nowDbTimestamp();
const deleteDaily = local.prepare(
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
);
const deleteMonthly = local.prepare(
const deleteDaily = local.query('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?');
const deleteMonthly = local.query(
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
);
const upsertDaily = local.prepare(DAILY_ROLLUP_UPSERT);
const upsertMonthly = local.prepare(MONTHLY_ROLLUP_UPSERT);
const upsertDaily = local.query(DAILY_ROLLUP_UPSERT);
const upsertMonthly = local.query(MONTHLY_ROLLUP_UPSERT);
const monthlyGroups = new Set<string>();
for (const { day, month, videoId } of groups.values()) {
@@ -181,36 +177,39 @@ export function refreshRollupsForNewSessions(
* double-counting sessions that earlier syncs already shared.
*/
export function copyRemoteOnlyRollups(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
videoIdMap: Map<number, number>,
summary: SyncMergeSummary,
): void {
if (!tableExists(remote, 'imm_daily_rollups') || !tableExists(local, 'imm_daily_rollups')) return;
const localDailyExists = local.prepare(
const localDailyExists = local.query(
'SELECT 1 FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ? LIMIT 1',
);
const localDaySessions = local.prepare(
const localDaySessions = local.query(
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_DAY_EXPR} = ? LIMIT 1`,
);
const localMonthSessions = local.prepare(
const localMonthSessions = local.query(
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_MONTH_EXPR} = ? LIMIT 1`,
);
const localMonthSessionsForDay = local.prepare(
// rollup_day is a *local* epoch day, so anchor it at local noon (+43200)
// before reading its month back: plain UTC midnight lands in the previous
// civil month for the 1st of a month at any negative UTC offset.
const localMonthSessionsForDay = local.query(
`SELECT 1 FROM imm_sessions
WHERE video_id = ?
AND ${LOCAL_MONTH_EXPR} = CAST(strftime('%Y%m', CAST(? AS INTEGER) * 86400, 'unixepoch', 'localtime') AS INTEGER)
AND ${LOCAL_MONTH_EXPR} = CAST(strftime('%Y%m', CAST(? AS INTEGER) * 86400 + 43200, 'unixepoch', 'localtime') AS INTEGER)
LIMIT 1`,
);
const insertDaily = local.prepare(
const insertDaily = local.query(
`INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, cards_per_hour, tokens_per_min, lookup_hit_rate,
CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const row of remote.query<SqlRow>('SELECT * FROM imm_daily_rollups').all()) {
for (const row of selectAll(remote, 'SELECT * FROM imm_daily_rollups')) {
if (row.video_id === null) continue;
const localVideoId = videoIdMap.get(Number(row.video_id));
if (localVideoId === undefined) continue;
@@ -234,16 +233,16 @@ export function copyRemoteOnlyRollups(
summary.dailyRollupsCopied += 1;
}
const localMonthlyExists = local.prepare(
const localMonthlyExists = local.query(
'SELECT 1 FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ? LIMIT 1',
);
const insertMonthly = local.prepare(
const insertMonthly = local.query(
`INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const row of remote.query<SqlRow>('SELECT * FROM imm_monthly_rollups').all()) {
for (const row of selectAll(remote, 'SELECT * FROM imm_monthly_rollups')) {
if (row.video_id === null) continue;
const localVideoId = videoIdMap.get(Number(row.video_id));
if (localVideoId === undefined) continue;
@@ -1,6 +1,6 @@
import { Database } from 'bun:sqlite';
import type { LexiconResolver } from './merge-catalog.js';
import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './sync-shared.js';
import type { LexiconResolver } from './merge-catalog';
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './shared';
const SESSION_COPY_COLUMNS = [
'session_uuid',
@@ -71,32 +71,27 @@ const LINE_COPY_COLUMNS = [
'LAST_UPDATE_DATE',
] as const;
type SqlRow = Record<string, unknown>;
export interface SessionMergeResult {
newSessionIds: number[];
}
export function mergeSessions(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
videoIdMap: Map<number, number>,
animeIdMap: Map<number, number>,
lexicon: LexiconResolver,
summary: SyncMergeSummary,
): SessionMergeResult {
const newSessionIds: number[] = [];
const uuidExists = local.prepare<SqlRow>(
'SELECT session_id FROM imm_sessions WHERE session_uuid = ?',
);
const uuidExists = local.query('SELECT session_id FROM imm_sessions WHERE session_uuid = ?');
const remoteSessions = remote
.query<SqlRow>(
`SELECT session_id, video_id, ${SESSION_COPY_COLUMNS.join(', ')}
FROM imm_sessions
ORDER BY CAST(started_at_ms AS REAL) ASC, session_id ASC`,
)
.all();
const remoteSessions = selectAll(
remote,
`SELECT session_id, video_id, ${SESSION_COPY_COLUMNS.join(', ')}
FROM imm_sessions
ORDER BY CAST(started_at_ms AS REAL) ASC, session_id ASC`,
);
for (const session of remoteSessions) {
if (session.ended_at_ms === null) {
@@ -111,7 +106,9 @@ export function mergeSessions(
}
const localVideoId = videoIdMap.get(Number(session.video_id));
if (localVideoId === undefined) {
throw new Error(`Snapshot session ${String(session.session_uuid)} references missing video row`);
throw new Error(
`Snapshot session ${String(session.session_uuid)} references missing video row`,
);
}
const localSessionId = insertRow(
@@ -144,18 +141,18 @@ export function mergeSessions(
}
function copyTelemetry(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
remoteSessionId: number,
localSessionId: number,
summary: SyncMergeSummary,
): void {
const rows = remote
.query<SqlRow>(
`SELECT ${TELEMETRY_COPY_COLUMNS.join(', ')} FROM imm_session_telemetry
WHERE session_id = ? ORDER BY telemetry_id ASC`,
)
.all(remoteSessionId);
const rows = selectAll(
remote,
`SELECT ${TELEMETRY_COPY_COLUMNS.join(', ')} FROM imm_session_telemetry
WHERE session_id = ? ORDER BY telemetry_id ASC`,
[remoteSessionId],
);
for (const row of rows) {
insertRow(
local,
@@ -168,19 +165,19 @@ function copyTelemetry(
}
function copyEvents(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
remoteSessionId: number,
localSessionId: number,
summary: SyncMergeSummary,
): Map<number, number> {
const eventIdMap = new Map<number, number>();
const rows = remote
.query<SqlRow>(
`SELECT event_id, ${EVENT_COPY_COLUMNS.join(', ')} FROM imm_session_events
WHERE session_id = ? ORDER BY event_id ASC`,
)
.all(remoteSessionId);
const rows = selectAll(
remote,
`SELECT event_id, ${EVENT_COPY_COLUMNS.join(', ')} FROM imm_session_events
WHERE session_id = ? ORDER BY event_id ASC`,
[remoteSessionId],
);
for (const row of rows) {
const localEventId = insertRow(
local,
@@ -195,8 +192,8 @@ function copyEvents(
}
function copySubtitleLines(
local: Database,
remote: Database,
local: SyncDb,
remote: SyncDb,
remoteSessionId: number,
localSessionId: number,
localVideoId: number,
@@ -205,30 +202,32 @@ function copySubtitleLines(
lexicon: LexiconResolver,
summary: SyncMergeSummary,
): void {
const rows = remote
.query<SqlRow>(
`SELECT line_id, event_id, anime_id, ${LINE_COPY_COLUMNS.join(', ')} FROM imm_subtitle_lines
WHERE session_id = ? ORDER BY line_id ASC`,
)
.all(remoteSessionId);
const wordOccurrences = remote.prepare<SqlRow>(
const rows = selectAll(
remote,
`SELECT line_id, event_id, anime_id, ${LINE_COPY_COLUMNS.join(', ')} FROM imm_subtitle_lines
WHERE session_id = ? ORDER BY line_id ASC`,
[remoteSessionId],
);
const wordOccurrences = remote.query(
'SELECT word_id, occurrence_count FROM imm_word_line_occurrences WHERE line_id = ?',
);
const kanjiOccurrences = remote.prepare<SqlRow>(
const kanjiOccurrences = remote.query(
'SELECT kanji_id, occurrence_count FROM imm_kanji_line_occurrences WHERE line_id = ?',
);
const insertWordOccurrence = local.prepare(
const insertWordOccurrence = local.query(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, word_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
);
const insertKanjiOccurrence = local.prepare(
const insertKanjiOccurrence = local.query(
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count) VALUES (?, ?, ?)
ON CONFLICT(line_id, kanji_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
);
for (const row of rows) {
const localEventId = row.event_id === null ? null : (eventIdMap.get(Number(row.event_id)) ?? null);
const localAnimeId = row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
const localEventId =
row.event_id === null ? null : (eventIdMap.get(Number(row.event_id)) ?? null);
const localAnimeId =
row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
const localLineId = insertRow(
local,
'imm_subtitle_lines',
@@ -243,13 +242,13 @@ function copySubtitleLines(
);
summary.subtitleLinesAdded += 1;
for (const occurrence of wordOccurrences.all(row.line_id)) {
for (const occurrence of wordOccurrences.all(row.line_id) as SqlRow[]) {
const localWordId = lexicon.resolveWord(Number(occurrence.word_id));
const count = Number(occurrence.occurrence_count);
insertWordOccurrence.run(localLineId, localWordId, count);
lexicon.addWordOccurrences(Number(occurrence.word_id), count);
}
for (const occurrence of kanjiOccurrences.all(row.line_id)) {
for (const occurrence of kanjiOccurrences.all(row.line_id) as SqlRow[]) {
const localKanjiId = lexicon.resolveKanji(Number(occurrence.kanji_id));
const count = Number(occurrence.occurrence_count);
insertKanjiOccurrence.run(localLineId, localKanjiId, count);
@@ -267,14 +266,14 @@ function copySubtitleLines(
* applied, even if the merged session started earlier that day.
*/
function applyMergedSessionLifetime(
local: Database,
local: SyncDb,
sessionId: number,
videoId: number,
session: SqlRow,
): void {
const updatedAtMs = nowDbTimestamp();
const applied = local
.prepare(
.query(
`INSERT INTO imm_lifetime_applied_sessions (session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO NOTHING`,
@@ -282,15 +281,15 @@ function applyMergedSessionLifetime(
.run(sessionId, session.ended_at_ms, updatedAtMs, updatedAtMs);
if (applied.changes <= 0) return;
const telemetry = local
.query<SqlRow>(
`SELECT active_watched_ms, cards_mined, lines_seen, tokens_seen
FROM imm_session_telemetry
WHERE session_id = ?
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1`,
)
.get(sessionId);
const telemetry = selectOne(
local,
`SELECT active_watched_ms, cards_mined, lines_seen, tokens_seen
FROM imm_session_telemetry
WHERE session_id = ?
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1`,
[sessionId],
);
const metric = (telemetryValue: unknown, sessionValue: unknown): number => {
const fromTelemetry = telemetry ? Number(telemetryValue) : Number.NaN;
@@ -302,15 +301,18 @@ function applyMergedSessionLifetime(
const linesSeen = metric(telemetry?.lines_seen, session.lines_seen);
const tokensSeen = metric(telemetry?.tokens_seen, session.tokens_seen);
const video = local
.query<SqlRow>('SELECT anime_id, watched FROM imm_videos WHERE video_id = ?')
.get(videoId);
const video = selectOne(local, 'SELECT anime_id, watched FROM imm_videos WHERE video_id = ?', [
videoId,
]);
const watched = Number(video?.watched ?? 0);
const animeId = video?.anime_id === null || video?.anime_id === undefined ? null : Number(video.anime_id);
const animeId =
video?.anime_id === null || video?.anime_id === undefined ? null : Number(video.anime_id);
const mediaLifetime = local
.query<SqlRow>('SELECT completed FROM imm_lifetime_media WHERE video_id = ?')
.get(videoId);
const mediaLifetime = selectOne(
local,
'SELECT completed FROM imm_lifetime_media WHERE video_id = ?',
[videoId],
);
const hasOtherSessionForVideo = Boolean(
local
.query('SELECT 1 FROM imm_sessions WHERE video_id = ? AND session_id != ? LIMIT 1')
@@ -333,16 +335,19 @@ function applyMergedSessionLifetime(
let animeCompletedDelta = 0;
if (animeId !== null && watched > 0 && isFirstCompletedSessionForVideoRun) {
const animeLifetime = local
.query<SqlRow>('SELECT episodes_completed FROM imm_lifetime_anime WHERE anime_id = ?')
.get(animeId);
const anime = local
.query<SqlRow>('SELECT episodes_total FROM imm_anime WHERE anime_id = ?')
.get(animeId);
const animeLifetime = selectOne(
local,
'SELECT episodes_completed FROM imm_lifetime_anime WHERE anime_id = ?',
[animeId],
);
const anime = selectOne(local, 'SELECT episodes_total FROM imm_anime WHERE anime_id = ?', [
animeId,
]);
const episodesCompletedBefore = Number(animeLifetime?.episodes_completed ?? 0);
const episodesTotal = anime?.episodes_total === null || anime?.episodes_total === undefined
? null
: Number(anime.episodes_total);
const episodesTotal =
anime?.episodes_total === null || anime?.episodes_total === undefined
? null
: Number(anime.episodes_total);
if (
episodesTotal !== null &&
episodesTotal > 0 &&
@@ -354,7 +359,7 @@ function applyMergedSessionLifetime(
}
local
.prepare(
.query(
`UPDATE imm_lifetime_global
SET total_sessions = total_sessions + 1,
total_active_ms = total_active_ms + ?,
@@ -377,7 +382,7 @@ function applyMergedSessionLifetime(
);
local
.prepare(
.query(
`INSERT INTO imm_lifetime_media(
video_id, total_sessions, total_active_ms, total_cards, total_lines_seen,
total_tokens_seen, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
@@ -419,7 +424,7 @@ function applyMergedSessionLifetime(
if (animeId !== null) {
local
.prepare(
.query(
`INSERT INTO imm_lifetime_anime(
anime_id, total_sessions, total_active_ms, total_cards, total_lines_seen,
total_tokens_seen, episodes_started, episodes_completed, first_watched_ms,
@@ -1,22 +1,21 @@
import fs from 'node:fs';
import { Database } from 'bun:sqlite';
import {
LexiconResolver,
mergeAnime,
mergeExcludedWords,
mergeMediaMetadata,
mergeVideos,
} from './merge-catalog.js';
import { mergeSessions } from './merge-sessions.js';
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups.js';
} from './merge-catalog';
import { mergeSessions } from './merge-sessions';
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups';
import {
assertMergeableSchema,
createEmptyMergeSummary,
type SyncMergeSummary,
} from './sync-shared.js';
} from './shared';
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
export type { SyncMergeSummary } from './sync-shared.js';
export { createDbSnapshot, findLiveStatsDaemonPid } from './sync-shared.js';
export type { SyncMergeSummary } from './shared';
/**
* Merge a snapshot of another machine's immersion database into the local
@@ -34,10 +33,10 @@ export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string):
throw new Error(`Snapshot database not found: ${snapshotPath}`);
}
const remote = new Database(snapshotPath, { readonly: true });
let local: Database;
const remote = openLibsqlSyncDb(snapshotPath, { readonly: true });
let local: SyncDb;
try {
local = new Database(localDbPath, { readwrite: true, create: false });
local = openLibsqlSyncDb(localDbPath, { create: false });
} catch (error) {
remote.close();
throw error;
@@ -47,9 +46,9 @@ export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string):
assertMergeableSchema(local, 'Local');
const summary = createEmptyMergeSummary();
local.run('PRAGMA foreign_keys = ON');
local.run('PRAGMA busy_timeout = 5000');
local.run('BEGIN IMMEDIATE');
local.exec('PRAGMA foreign_keys = ON');
local.exec('PRAGMA busy_timeout = 5000');
local.exec('BEGIN IMMEDIATE');
try {
const animeIdMap = mergeAnime(local, remote, summary);
const { videoIdMap, addedVideoIds } = mergeVideos(local, remote, animeIdMap, summary);
@@ -69,10 +68,10 @@ export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string):
refreshRollupsForNewSessions(local, newSessionIds, summary);
copyRemoteOnlyRollups(local, remote, videoIdMap, summary);
local.run('COMMIT');
local.exec('COMMIT');
return summary;
} catch (error) {
local.run('ROLLBACK');
local.exec('ROLLBACK');
throw error;
}
} finally {
@@ -1,29 +1,15 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { SCHEMA_VERSION } from '../../src/core/services/immersion-tracker/types.js';
import { withReadonlyWalRetry } from '../history-db.js';
import { resolveConfigDir } from '../../src/config/path-resolution.js';
import { SCHEMA_VERSION } from '../immersion-tracker/types';
import { getDefaultConfigDir } from '../../../shared/setup-state';
import { withReadonlyWalRetry } from './wal-retry';
import { openLibsqlSyncDb, selectOne, type SyncDb } from './libsql-driver';
export { SCHEMA_VERSION };
export interface SyncMergeSummary {
sessionsMerged: number;
sessionsAlreadyPresent: number;
activeSessionsSkipped: number;
animeAdded: number;
videosAdded: number;
wordsAdded: number;
kanjiAdded: number;
subtitleLinesAdded: number;
telemetryRowsAdded: number;
eventsAdded: number;
excludedWordsAdded: number;
dailyRollupsCopied: number;
monthlyRollupsCopied: number;
rollupGroupsRecomputed: number;
}
import type { SyncMergeSummary } from '../../../shared/sync/sync-events';
export type { SyncMergeSummary };
export function createEmptyMergeSummary(): SyncMergeSummary {
return {
@@ -48,23 +34,19 @@ export function nowDbTimestamp(): string {
return String(Date.now());
}
export function tableExists(db: Database, tableName: string): boolean {
export function tableExists(db: SyncDb, tableName: string): boolean {
return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
);
}
export function readSchemaVersion(db: Database): number | null {
function readSchemaVersion(db: SyncDb): number | null {
if (!tableExists(db, 'imm_schema_version')) return null;
const row = db
.query<{ schema_version: number }>(
'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version',
)
.get();
const row = selectOne(db, 'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version');
return typeof row?.schema_version === 'number' ? row.schema_version : null;
}
export function assertMergeableSchema(db: Database, label: string): void {
export function assertMergeableSchema(db: SyncDb, label: string): void {
const version = readSchemaVersion(db);
if (version === null) {
throw new Error(
@@ -73,7 +55,7 @@ export function assertMergeableSchema(db: Database, label: string): void {
}
if (version !== SCHEMA_VERSION) {
throw new Error(
`${label} database is at schema version ${version} but this launcher expects ${SCHEMA_VERSION}. Update SubMiner on both machines to the same version and run each app once before syncing.`,
`${label} database is at schema version ${version} but this SubMiner install expects ${SCHEMA_VERSION}. Update SubMiner on both machines to the same version and run each app once before syncing.`,
);
}
for (const table of ['imm_sessions', 'imm_videos', 'imm_lifetime_global']) {
@@ -84,14 +66,14 @@ export function assertMergeableSchema(db: Database, label: string): void {
}
export function insertRow(
db: Database,
db: SyncDb,
table: string,
columns: readonly string[],
values: unknown[],
): number {
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
// db.query() caches the prepared statement per SQL string; this runs once
// per copied row, so re-preparing via db.prepare() would dominate merge time.
// query() caches the prepared statement per SQL string; this runs once per
// copied row, so re-preparing each time would dominate merge time.
const result = db.query(sql).run(...values);
return Number(result.lastInsertRowid);
}
@@ -103,10 +85,10 @@ export function createDbSnapshot(dbPath: string, outPath: string): void {
fs.rmSync(outPath, { force: true });
fs.mkdirSync(path.dirname(outPath), { recursive: true });
withReadonlyWalRetry(dbPath, (options) => {
const db = new Database(dbPath, options);
const db = openLibsqlSyncDb(dbPath, options);
try {
assertMergeableSchema(db, 'Local');
db.prepare('VACUUM INTO ?').run(outPath);
db.query('VACUUM INTO ?').run(outPath);
} finally {
db.close();
}
@@ -131,16 +113,11 @@ function isProcessAlive(pid: number): boolean {
function statsDaemonStateCandidates(dbPath: string): string[] {
const homeDir = os.homedir();
const candidates = new Set<string>([path.join(path.dirname(dbPath), 'stats-daemon.json')]);
const configDir = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir,
existsSync: fs.existsSync,
});
candidates.add(path.join(configDir, 'stats-daemon.json'));
candidates.add(path.join(getDefaultConfigDir(), 'stats-daemon.json'));
if (process.platform === 'darwin') {
candidates.add(path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'));
candidates.add(
path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'),
);
}
return [...candidates];
}
@@ -149,7 +126,7 @@ function statsDaemonStateCandidates(dbPath: string): string[] {
* Best-effort guard against merging while a SubMiner process holds the
* tracker's write queue in memory. Detects the background stats daemon via
* its pid state file; the interactive app is caught by the mpv-socket check
* in the sync command.
* in the sync flow.
*/
export function findLiveStatsDaemonPid(dbPath: string): number | null {
for (const statePath of statsDaemonStateCandidates(dbPath)) {
+191
View File
@@ -0,0 +1,191 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertSafeSshHost,
detectRemoteShellFlavor,
quoteForRemoteShell,
resolveRemoteSubminerCommand,
runScp,
shellQuote,
type RemoteRunResult,
} from './ssh';
function remoteResult(status: number, stdout = ''): RemoteRunResult {
return { status, stdout, stderr: '' };
}
test('assertSafeSshHost rejects option-like hosts', () => {
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
assert.throws(() => assertSafeSshHost('-lroot'), /looks like an option/);
});
test('assertSafeSshHost accepts normal destinations', () => {
assert.doesNotThrow(() => assertSafeSshHost('macbook'));
assert.doesNotThrow(() => assertSafeSshHost('user@192.168.1.20'));
assert.doesNotThrow(() => assertSafeSshHost('ssh-alias'));
});
test('shellQuote escapes single quotes and wraps in quotes', () => {
assert.equal(shellQuote('subminer'), `'subminer'`);
assert.equal(shellQuote(`a'; rm -rf ~; '`), `'a'\\''; rm -rf ~; '\\'''`);
});
test('runScp rejects option-like local endpoints before spawning scp', () => {
assert.throws(() => runScp('-oProxyCommand=sh', '/tmp/out.sqlite'), /looks like an option/);
assert.throws(() => runScp('/tmp/in.sqlite', '-bad-destination'), /looks like an option/);
});
test('runScp rejects option-like remote host components', () => {
assert.throws(
() => runScp('-oProxyCommand=sh:/tmp/in.sqlite', '/tmp/out.sqlite'),
/SSH host that looks like an option/,
);
});
test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => {
const calls: Array<{ host: string; remoteCommand: string }> = [];
const command = resolveRemoteSubminerCommand('macbook', null, 'posix', (host, remoteCommand) => {
calls.push({ host, remoteCommand });
return remoteResult(0);
});
assert.equal(
command,
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer',
);
assert.deepEqual(calls, [
{
host: 'macbook',
remoteCommand:
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer --help >/dev/null 2>&1',
},
]);
});
test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => {
const probed: string[] = [];
const command = resolveRemoteSubminerCommand(
'media-box',
null,
'posix',
(_host, remoteCommand) => {
probed.push(remoteCommand);
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
},
);
assert.match(command, / SubMiner --sync-cli$/);
// Launcher candidates (PATH + ~/.local/bin) are tried before app binaries.
assert.equal(probed.length, 3);
assert.match(probed[0]!, / subminer --help /);
assert.match(probed[1]!, / ~\/\.local\/bin\/subminer --help /);
});
test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => {
const asApp = resolveRemoteSubminerCommand(
'media-box',
'/opt/SubMiner.AppImage',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
);
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
const asLauncher = resolveRemoteSubminerCommand(
'media-box',
'/opt/subminer',
'posix',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
);
assert.match(asLauncher, /'\/opt\/subminer'$/);
assert.throws(
() => resolveRemoteSubminerCommand('media-box', '/missing', 'posix', () => remoteResult(127)),
/Remote command not found on media-box: \/missing/,
);
});
test('resolveRemoteSubminerCommand probes Windows install locations without a PATH prefix', () => {
const probed: string[] = [];
const command = resolveRemoteSubminerCommand('win-box', null, 'windows-cmd', (_host, cmd) => {
probed.push(cmd);
return remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1);
});
assert.equal(command, '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
assert.equal(probed[0], 'subminer --help');
assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help');
const powershell = resolveRemoteSubminerCommand(
'win-box',
null,
'windows-powershell',
(_host, cmd) => remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1),
);
assert.equal(powershell, '& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
});
test('resolveRemoteSubminerCommand quotes Windows overrides with double quotes', () => {
const command = resolveRemoteSubminerCommand(
'win-box',
'C:/Apps/SubMiner/SubMiner.exe',
'windows-cmd',
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
);
assert.equal(command, '"C:/Apps/SubMiner/SubMiner.exe" --sync-cli');
});
test('detectRemoteShellFlavor identifies posix, cmd, and powershell remotes', () => {
assert.equal(
detectRemoteShellFlavor('linux-box', (_host, cmd) =>
cmd === 'uname -s' ? remoteResult(0, 'Linux\n') : remoteResult(1),
),
'posix',
);
assert.equal(
detectRemoteShellFlavor('win-box', (_host, cmd) => {
if (cmd === 'uname -s') return remoteResult(1);
if (cmd === 'echo %OS%') return remoteResult(0, 'Windows_NT\r\n');
return remoteResult(1);
}),
'windows-cmd',
);
assert.equal(
detectRemoteShellFlavor('ps-box', (_host, cmd) => {
if (cmd === 'uname -s') return remoteResult(1);
if (cmd === 'echo %OS%') return remoteResult(0, '%OS%\r\n');
if (cmd === 'echo $env:OS') return remoteResult(0, 'Windows_NT\r\n');
return remoteResult(1);
}),
'windows-powershell',
);
// Unidentifiable remotes keep the pre-detection POSIX behavior.
assert.equal(
detectRemoteShellFlavor('odd-box', () => remoteResult(1)),
'posix',
);
});
test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values', () => {
assert.equal(quoteForRemoteShell('posix', "/tmp/it's"), `'/tmp/it'\\''s'`);
assert.equal(
quoteForRemoteShell('windows-cmd', 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab'),
'"C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab"',
);
assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/);
assert.throws(() => quoteForRemoteShell('windows-cmd', 'C:/tmp/%TEMP%/db.sqlite'), /percent/);
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
});
test('quoteForRemoteShell does not let PowerShell expand a quoted value', () => {
// PowerShell expands $(...) and $var inside double quotes, so a single-quoted
// literal is the only safe form; '' is the escape for an embedded quote.
assert.equal(
quoteForRemoteShell('windows-powershell', 'C:/tmp/$(calc.exe)'),
`'C:/tmp/$(calc.exe)'`,
);
assert.equal(quoteForRemoteShell('windows-powershell', "C:/tmp/it's"), `'C:/tmp/it''s'`);
assert.equal(
quoteForRemoteShell('windows-powershell', 'C:/Users/First Last/Temp/subminer-sync-ab'),
`'C:/Users/First Last/Temp/subminer-sync-ab'`,
);
});
+223
View File
@@ -0,0 +1,223 @@
import { spawnSync } from 'node:child_process';
import { SYNC_CLI_FLAG } from './cli-args';
export interface RemoteRunResult {
status: number;
stdout: string;
stderr: string;
}
export interface RunSshOptions {
batchMode?: boolean;
connectTimeoutSeconds?: number;
timeoutMs?: number;
}
/**
* ssh/scp have no `--` terminator for the destination, so a host that starts
* with `-` (e.g. `-oProxyCommand=...`) is parsed as an option. Reject those
* before spawning.
*/
export function assertSafeSshHost(host: string): void {
if (host.startsWith('-')) {
throw new Error(`Refusing to use SSH host that looks like an option: ${host}`);
}
}
/**
* Run a command on the SSH host. stdin stays attached so interactive prompts
* can still read from the terminal; stdout/stderr are captured for callers
* that need actionable remote failure messages.
*/
export function runSsh(
host: string,
remoteCommand: string,
options: RunSshOptions = {},
): RemoteRunResult {
assertSafeSshHost(host);
const args: string[] = [];
if (options.batchMode) args.push('-o', 'BatchMode=yes');
if (options.connectTimeoutSeconds !== undefined) {
args.push('-o', `ConnectTimeout=${options.connectTimeoutSeconds}`);
}
args.push(host, remoteCommand);
const result = spawnSync('ssh', args, {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'pipe'],
timeout: options.timeoutMs,
});
if (result.error) {
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
}
return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
}
function assertSafeScpEndpoint(endpoint: string): void {
if (/^[A-Za-z]:[\\/]/.test(endpoint)) return;
const colon = endpoint.indexOf(':');
const slash = endpoint.indexOf('/');
if (colon <= 0 || (slash !== -1 && slash < colon)) {
if (endpoint.startsWith('-')) {
throw new Error(`Refusing to use scp endpoint that looks like an option: ${endpoint}`);
}
return;
}
const host = endpoint.slice(0, colon);
const remotePath = endpoint.slice(colon + 1);
assertSafeSshHost(host);
if (remotePath.startsWith('-')) {
throw new Error(`Refusing to use scp remote path that looks like an option: ${remotePath}`);
}
}
export function runScp(from: string, to: string): void {
assertSafeScpEndpoint(from);
assertSafeScpEndpoint(to);
const result = spawnSync('scp', ['-q', from, to], {
encoding: 'utf8',
stdio: ['inherit', 'inherit', 'inherit'],
});
if (result.error) {
throw new Error(`Failed to run scp: ${(result.error as Error).message}`);
}
if ((result.status ?? 1) !== 0) {
throw new Error(`scp failed copying ${from} -> ${to}`);
}
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
/**
* The shell that Windows OpenSSH hands remote commands to (cmd.exe by
* default, PowerShell when DefaultShell is changed); it decides quoting,
* environment-variable expansion, and which SubMiner install paths to probe.
*/
export type RemoteShellFlavor = 'posix' | 'windows-cmd' | 'windows-powershell';
/**
* Identify the remote shell with probes that are harmless everywhere:
* `uname -s` only succeeds on a POSIX shell, `echo %OS%` only expands under
* cmd.exe, and `echo $env:OS` only expands under PowerShell. Defaults to
* posix so unreachable/odd hosts fail with the familiar POSIX errors.
*/
export function detectRemoteShellFlavor(
host: string,
runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
): RemoteShellFlavor {
const posixProbe = runRemote(host, 'uname -s');
if (posixProbe.status === 0 && posixProbe.stdout.trim().length > 0) return 'posix';
const cmdProbe = runRemote(host, 'echo %OS%');
if (cmdProbe.status === 0 && cmdProbe.stdout.includes('Windows_NT')) return 'windows-cmd';
const powershellProbe = runRemote(host, 'echo $env:OS');
if (powershellProbe.status === 0 && powershellProbe.stdout.includes('Windows_NT')) {
return 'windows-powershell';
}
return 'posix';
}
/**
* Quote one argument for the detected remote shell. PowerShell expands $(...)
* and $var inside double quotes, so it gets a single-quoted literal ('' escapes
* a quote). cmd.exe has no single-quote form and treats ' literally, so it keeps
* double quotes and rejects values carrying a double quote of their own.
*/
export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string {
if (flavor === 'posix') return shellQuote(value);
if (/[\r\n]/.test(value)) {
throw new Error(`Refusing to quote a value with newlines for a Windows shell: ${value}`);
}
if (flavor === 'windows-powershell') {
return `'${value.replaceAll("'", "''")}'`;
}
if (value.includes('"')) {
throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`);
}
if (value.includes('%')) {
throw new Error(`Refusing to quote a value with percent signs for cmd.exe: ${value}`);
}
return `"${value}"`;
}
const REMOTE_RUNTIME_PATH =
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"';
// The Electron app answers the same launcher-style `sync ...` argv when
// invoked with --sync-cli (SYNC_CLI_FLAG), so a remote machine only needs the
// app installed; the command-line launcher is one candidate, not a
// requirement. Each candidate is the remote invocation to probe, in order.
function defaultRemoteCandidates(flavor: RemoteShellFlavor): string[] {
if (flavor === 'windows-cmd' || flavor === 'windows-powershell') {
// cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and
// the & call operator to run a quoted path.
const launcherShim =
flavor === 'windows-cmd'
? `"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd"`
: `& "$env:LOCALAPPDATA\\SubMiner\\bin\\subminer.cmd"`;
const appInstall =
flavor === 'windows-cmd'
? `"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe"`
: `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`;
return [
// Command-line launcher shim on PATH or in its default install dir.
'subminer',
launcherShim,
// The app binary itself in sync-CLI mode (default NSIS install dir).
`${appInstall} ${SYNC_CLI_FLAG}`,
`SubMiner ${SYNC_CLI_FLAG}`,
];
}
return [
// Command-line launcher (bun script) on PATH or in its default install dir.
'subminer',
'~/.local/bin/subminer',
// The app binary itself in sync-CLI mode.
`SubMiner ${SYNC_CLI_FLAG}`,
`/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
`~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
];
}
function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): string[] {
const quoted = quoteForRemoteShell(flavor, preferred);
const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted;
// App binaries also answer plain --help by opening the GUI-oriented help
// path, so probe the sync-CLI shape first.
return [`${invocation} ${SYNC_CLI_FLAG}`, invocation];
}
/**
* Non-interactive POSIX SSH shells often miss user-installed launchers and
* Bun, so those candidates are probed under the same deterministic PATH sync
* itself uses; Windows shells get their default install locations instead.
* Trusted defaults stay unquoted so the remote shell expands `~`/%VAR%; a
* user-supplied override is quoted to prevent command injection and probed
* both as an app binary (--sync-cli) and as a launcher.
*/
export function resolveRemoteSubminerCommand(
host: string,
preferred: string | null,
flavor: RemoteShellFlavor = 'posix',
runRemote: typeof runSsh = runSsh,
): string {
const candidates = preferred
? preferredCandidates(flavor, preferred)
: defaultRemoteCandidates(flavor);
for (const candidate of candidates) {
const command = flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate}` : candidate;
const probe = runRemote(
host,
flavor === 'posix' ? `${command} --help >/dev/null 2>&1` : `${command} --help`,
);
if (probe.status === 0) {
return command;
}
}
throw new Error(
preferred
? `Remote command not found on ${host}: ${preferred}`
: `SubMiner not found on ${host} (tried the subminer launcher and the SubMiner app binary in their default install locations). Pass --remote-cmd <path> pointing at the SubMiner app or launcher.`,
);
}
@@ -0,0 +1,492 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createEmptyMergeSummary } from './shared';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
type SyncFlowContext,
type SyncFlowDeps,
} from './sync-flow';
function makeContext(overrides: Partial<SyncFlowContext['args']> = {}): SyncFlowContext {
return {
args: {
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn',
...overrides,
},
mpvSocketPath: '',
};
}
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
// recordHostSyncResult defaults to a no-op here: the real disk-writing
// implementations are bound by the entry points, never by the flow itself.
function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return {
createDbSnapshot: () => {},
mergeSnapshotIntoDb: () => createEmptyMergeSummary(),
findLiveStatsDaemonPid: () => null,
assertSafeSshHost: () => {},
detectRemoteShellFlavor: () => 'posix',
resolveRemoteSubminerCommand: () => 'subminer',
runScp: () => {},
runSsh: () => ok(),
canConnectUnixSocket: async () => false,
realpathSync: (candidate) => candidate,
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
consoleLog: () => {},
writeStdout: () => true,
ensureTrackerQuiescent: async () => {},
emitEvent: () => {},
recordHostSyncResult: () => {},
resolveDefaultDbPath: () => '/tracker.sqlite',
...overrides,
};
}
test('ensureTrackerQuiescentFlow ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
let socketConnectable = false;
const deps = makeDeps({
realpathSync: () => '/tracker.sqlite',
canConnectUnixSocket: async () => socketConnectable,
});
await ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
test('ensureTrackerQuiescentFlow rejects a live stats daemon and honors --force', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
const deps = makeDeps({
realpathSync: () => '/tracker.sqlite',
findLiveStatsDaemonPid: () => 4242,
});
await assert.rejects(
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
/stats server is running \(pid 4242\)/,
);
const forced = makeContext({ syncDbPath: '/tmp/local.sqlite', syncForce: true });
await ensureTrackerQuiescentFlow(forced, '/tmp/local.sqlite', deps);
});
test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps = makeDeps({
createDbSnapshot: (dbPath, outPath) => {
calls.push(`snapshot:${dbPath}->${outPath}`);
},
mergeSnapshotIntoDb: (dbPath, snapshotPath) => {
calls.push(`merge:${dbPath}<-${snapshotPath}`);
return createEmptyMergeSummary();
},
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host) => {
calls.push(`host:${host}`);
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
return command.includes(' sync --make-temp') ? ok('/tmp/subminer-sync-remote\n') : ok();
},
runScp: (from, to) => {
calls.push(`scp:${from}->${to}`);
},
});
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
assert.ok(
calls.indexOf('quiescent') < calls.indexOf('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'),
);
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps);
assert.ok(calls.includes('host:media-box'));
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
});
function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return makeDeps({
createDbSnapshot: (_dbPath, outPath) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
return ok();
},
runScp: (from, to) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
...overrides,
});
}
test('runHostSync keeps tracker quiescent through both merges and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
const deps = makeHostDeps(calls, {
mkdtempSync: (prefix) => {
localTmpDir = fs.mkdtempSync(prefix);
return localTmpDir;
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
}
return ok();
},
});
await assert.rejects(
() =>
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
);
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
assert.ok(calls.includes('local-merge'));
assert.ok(calls.some((call) => call.includes(' sync --remove-temp ')));
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps = makeHostDeps([], {
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --snapshot ')) {
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
}
return ok();
},
});
await assert.rejects(
() =>
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
test('runHostSync push only snapshots locally and merges remotely', async () => {
const calls: string[] = [];
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'push' }),
makeHostDeps(calls),
);
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(!calls.includes('local-merge'));
});
test('runHostSync pull only snapshots remotely and merges locally', async () => {
const calls: string[] = [];
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'pull' }),
makeHostDeps(calls),
);
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
assert.ok(calls.includes('local-merge'));
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
});
test('runSyncFlow --json emits NDJSON progress events and a final result', async () => {
const lines: string[] = [];
const remoteSummary = {
...createEmptyMergeSummary(),
sessionsMerged: 2,
videosAdded: 1,
};
const deps = makeHostDeps([], {
consoleLog: (line) => {
lines.push(line);
},
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) {
assert.match(command, / --json(?: |$)/);
return ok(
`${JSON.stringify({ type: 'merge-summary', target: 'local', summary: remoteSummary })}\n` +
`${JSON.stringify({ type: 'result', ok: true, error: null })}\n`,
);
}
return ok();
},
});
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
deps,
);
const events = lines.map((line) => JSON.parse(line));
assert.ok(events.some((event) => event.type === 'stage' && event.stage === 'snapshot-local'));
assert.ok(events.some((event) => event.type === 'merge-summary' && event.target === 'local'));
assert.deepEqual(
events.find((event) => event.type === 'merge-summary' && event.target === 'remote'),
{ type: 'merge-summary', target: 'remote', summary: remoteSummary },
);
assert.deepEqual(events[events.length - 1], { type: 'result', ok: true, error: null });
});
test('runSyncFlow --json emits an error result when the sync fails', async () => {
const lines: string[] = [];
const deps = makeHostDeps([], {
consoleLog: (line) => {
lines.push(line);
},
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
return ok();
},
});
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
deps,
),
);
const events = lines.map((line) => JSON.parse(line));
const last = events[events.length - 1];
assert.equal(last.type, 'result');
assert.equal(last.ok, false);
assert.match(last.error, /Remote merge failed/);
});
test('runHostSync records host sync results for saved-host bookkeeping', async () => {
const recorded: Array<{ host: string; status: string; detail: string | null }> = [];
const record: SyncFlowDeps['recordHostSyncResult'] = (host, status, detail) => {
recorded.push({ host, status, detail });
};
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps([], { recordHostSyncResult: record }),
);
assert.deepEqual(recorded[0], {
host: 'media-box',
status: 'success',
detail: '0 sessions merged; pushed local stats',
});
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps([], {
recordHostSyncResult: record,
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
return ok();
},
}),
),
);
assert.equal(recorded.length, 2);
assert.equal(recorded[1]!.status, 'error');
});
test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
const lines: string[] = [];
const sshOptions: unknown[] = [];
const deps = makeDeps({
consoleLog: (line) => {
lines.push(line);
},
resolveRemoteSubminerCommand: (host, _preferred, _flavor, runRemote) => {
runRemote!(host, 'subminer --help');
return 'subminer';
},
runSsh: (_host, command, options) => {
sshOptions.push(options);
if (command.includes('--version')) return ok('SubMiner 0.18.0\n');
return ok('subminer-check-ok\n');
},
});
await runSyncFlow(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
deps,
);
const events = lines.map((line) => JSON.parse(line));
const check = events.find((event) => event.type === 'check-result');
assert.ok(check);
assert.equal(check.host, 'media-box');
assert.equal(check.sshOk, true);
assert.equal(check.remoteCommand, 'subminer');
assert.equal(check.remoteVersion, 'SubMiner 0.18.0');
assert.equal(check.ok, true);
assert.equal(check.error, null);
assert.equal(sshOptions.length, 3);
assert.ok(
sshOptions.every(
(options) =>
JSON.stringify(options) ===
JSON.stringify({ batchMode: true, connectTimeoutSeconds: 10, timeoutMs: 15_000 }),
),
);
const failLines: string[] = [];
await assert.rejects(() =>
runSyncFlow(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
makeDeps({
consoleLog: (line) => {
failLines.push(line);
},
resolveRemoteSubminerCommand: () => {
throw new Error('Could not find a runnable "subminer" on media-box.');
},
runSsh: () => ok('subminer-check-ok\n'),
}),
),
);
const failEvents = failLines.map((line) => JSON.parse(line));
const failedCheck = failEvents.find((event) => event.type === 'check-result');
assert.ok(failedCheck);
assert.equal(failedCheck.ok, false);
assert.match(failedCheck.error, /Could not find a runnable/);
});
test('runHostSync speaks Windows shells: app command, double quotes, temp protocol', async () => {
const sshCommands: string[] = [];
const scpCalls: string[] = [];
const winTemp = 'C:\\Users\\First Last\\AppData\\Local\\Temp\\subminer-sync-remote';
const appCmd = '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli';
const deps = makeHostDeps([], {
detectRemoteShellFlavor: () => 'windows-cmd',
resolveRemoteSubminerCommand: () => appCmd,
createDbSnapshot: (_dbPath, outPath) => {
fs.writeFileSync(outPath, 'snapshot');
},
runSsh: (_host, command) => {
sshCommands.push(command);
if (command.includes(' sync --make-temp')) return ok(`${winTemp}\r\n`);
return ok();
},
runScp: (from, to) => {
scpCalls.push(`${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
});
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'win-box' }), deps);
const expectedDir = 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-remote';
assert.ok(sshCommands.includes(`${appCmd} sync --snapshot "${expectedDir}/snapshot.sqlite"`));
assert.ok(sshCommands.includes(`${appCmd} sync --merge "${expectedDir}/incoming.sqlite"`));
assert.ok(sshCommands.includes(`${appCmd} sync --remove-temp "${expectedDir}"`));
assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`)));
assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`)));
// No POSIX shell-isms reach a Windows remote.
assert.ok(
!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')),
);
assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME')));
});
test('runSyncFlow --make-temp prints a temp dir and --remove-temp only removes sync temp dirs', async () => {
const printed: string[] = [];
const removed: string[] = [];
const madeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
try {
await runSyncFlow(
makeContext({ syncMakeTemp: true }),
makeDeps({
mkdtempSync: () => madeDir,
consoleLog: (line) => {
printed.push(line);
},
}),
);
assert.deepEqual(printed, [madeDir]);
const removeDeps = makeDeps({
rmSync: (target) => {
removed.push(target);
},
});
await runSyncFlow(makeContext({ syncRemoveTempPath: madeDir }), removeDeps);
assert.deepEqual(removed, [madeDir]);
await assert.rejects(
() => runSyncFlow(makeContext({ syncRemoveTempPath: '/etc' }), removeDeps),
/Refusing to remove a directory outside the sync temp area/,
);
assert.equal(removed.length, 1);
} finally {
fs.rmSync(madeDir, { recursive: true, force: true });
}
});
+482
View File
@@ -0,0 +1,482 @@
import os from 'node:os';
import path from 'node:path';
import { formatMergeSummary } from './merge';
import { quoteForRemoteShell } from './ssh';
import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh';
import {
parseSyncProgressLine,
type SyncMergeSummary,
type SyncProgressEvent,
} from '../../../shared/sync/sync-events';
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
export interface SyncFlowArgs {
syncHost: string;
syncSnapshotPath: string;
syncMergePath: string;
syncDirection: 'both' | 'push' | 'pull' | null;
syncRemoteCmd: string;
syncDbPath: string;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncMakeTemp: boolean;
syncRemoveTempPath: string;
logLevel: string;
}
export interface SyncFlowContext {
args: SyncFlowArgs;
mpvSocketPath: string;
}
/**
* Process/IO seams the sync flow needs stubbed in tests: SSH/scp, the DB
* snapshot/merge engine, filesystem, and progress/bookkeeping output. The
* app's --sync-cli mode (src/main/sync-cli.ts) provides the only production
* binding; pure helpers are imported directly.
*/
export interface SyncFlowDeps {
createDbSnapshot: (dbPath: string, outPath: string) => void;
mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary;
findLiveStatsDaemonPid: (dbPath: string) => number | null;
assertSafeSshHost: (host: string) => void;
detectRemoteShellFlavor: (
host: string,
runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
) => RemoteShellFlavor;
resolveRemoteSubminerCommand: (
host: string,
preferred: string | null,
flavor: RemoteShellFlavor,
runRemote?: (host: string, remoteCommand: string) => RemoteRunResult,
) => string;
runScp: (from: string, to: string) => void;
runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult;
canConnectUnixSocket: (socketPath: string) => Promise<boolean>;
realpathSync: (candidate: string) => string;
mkdtempSync: (prefix: string) => string;
rmSync: (target: string, options: { recursive: boolean; force: boolean }) => void;
consoleLog: (message: string) => void;
writeStdout: (text: string) => boolean;
ensureTrackerQuiescent: (context: SyncFlowContext, dbPath: string) => Promise<void>;
emitEvent: (event: SyncProgressEvent) => void;
recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void;
resolveDefaultDbPath: () => string;
}
/** Expand a leading `~` (as ssh users write paths) and make the path absolute. */
function resolveCliPath(input: string): string {
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
}
/**
* Prefix shared by every sync temp dir, local or remote. Remote temp dirs are
* created and removed by the remote SubMiner itself (sync --make-temp /
* --remove-temp) so the flow never depends on mktemp/rm existing in the
* remote shell. This is what makes Windows remotes work.
*/
const SYNC_TEMP_PREFIX = 'subminer-sync-';
function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string {
return mkdtempSync(path.join(os.tmpdir(), SYNC_TEMP_PREFIX));
}
/** Only dirs directly under os.tmpdir() with the sync prefix may be removed. */
function assertRemovableSyncTempDir(target: string): string {
const resolved = path.resolve(target.trim());
const normalizeCase = (value: string) =>
process.platform === 'win32' ? value.toLowerCase() : value;
const insideTmp =
normalizeCase(path.dirname(resolved)) === normalizeCase(path.resolve(os.tmpdir()));
if (!insideTmp || !path.basename(resolved).startsWith(SYNC_TEMP_PREFIX)) {
throw new Error(`Refusing to remove a directory outside the sync temp area: ${target}`);
}
return resolved;
}
function runMakeTempMode(deps: SyncFlowDeps): void {
deps.consoleLog(makeSyncTempDir(deps.mkdtempSync));
}
function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void {
const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath);
deps.rmSync(target, { recursive: true, force: true });
}
function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string {
const override = context.args.syncDbPath.trim();
return override ? resolveCliPath(override) : deps.resolveDefaultDbPath();
}
function isTrackerDb(dbPath: string, deps: SyncFlowDeps): boolean {
const trackerDbPath = deps.resolveDefaultDbPath();
try {
return deps.realpathSync(dbPath) === deps.realpathSync(trackerDbPath);
} catch {
return dbPath === trackerDbPath;
}
}
export async function ensureTrackerQuiescentFlow(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
// elsewhere needs no guard.
if (!isTrackerDb(dbPath, deps)) return;
const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) {
throw new Error(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
throw new Error(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
}
}
// In --json mode every line on stdout is an NDJSON event: human console output
// is silenced and events are written through the original console logger.
function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
const writeLine = deps.consoleLog;
return {
...deps,
consoleLog: () => {},
writeStdout: () => true,
emitEvent: (event) => writeLine(JSON.stringify(event)),
};
}
async function runSnapshotMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const outPath = resolveCliPath(context.args.syncSnapshotPath);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, outPath);
deps.emitEvent({ type: 'snapshot-created', path: outPath });
deps.consoleLog(outPath);
}
async function runMergeMode(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolveCliPath(context.args.syncMergePath);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
message: `Merging ${snapshotPath} into the local database`,
});
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(formatMergeSummary(summary));
}
function formatHostSyncDetail(
direction: 'both' | 'push' | 'pull',
pulledSummary: SyncMergeSummary | null,
): string {
if (!pulledSummary) return direction === 'push' ? 'Pushed local stats' : 'Sync complete';
const merged = `${pulledSummary.sessionsMerged} session${pulledSummary.sessionsMerged === 1 ? '' : 's'} merged`;
return direction === 'pull' ? merged : `${merged}; pushed local stats`;
}
export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps): Promise<void> {
const { args } = context;
const host = args.syncHost;
deps.assertSafeSshHost(host);
deps.consoleLog(`Checking SSH connection to ${host}...`);
let remoteCommand: string | null = null;
let remoteVersion: string | null = null;
let error: string | null = null;
const runCheck = (checkHost: string, command: string) =>
deps.runSsh(checkHost, command, {
batchMode: true,
connectTimeoutSeconds: 10,
timeoutMs: 15_000,
});
const probe = runCheck(host, 'echo subminer-check-ok');
const sshOk = probe.status === 0 && probe.stdout.includes('subminer-check-ok');
if (!sshOk) {
error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe);
} else {
deps.consoleLog('SSH connection: ok');
try {
const flavor = deps.detectRemoteShellFlavor(host, runCheck);
if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`);
remoteCommand = deps.resolveRemoteSubminerCommand(
host,
args.syncRemoteCmd || null,
flavor,
runCheck,
);
const version = runCheck(host, `${remoteCommand} --version`);
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
deps.consoleLog(
`Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`,
);
} catch (resolveError) {
error = resolveError instanceof Error ? resolveError.message : String(resolveError);
}
}
const ok = sshOk && remoteCommand !== null;
deps.emitEvent({
type: 'check-result',
host,
sshOk,
remoteCommand,
remoteVersion,
ok,
error,
});
if (!ok) {
throw new Error(error ?? `Connection check failed for ${host}.`);
}
deps.consoleLog('Check passed.');
}
// The remote validates --remove-temp against its own tmpdir; this guard only
// keeps garbage output from an earlier failure out of the remote command.
function cleanupRemote(
host: string,
remoteCmd: string,
remoteTmpDir: string,
quote: (value: string) => string,
deps: SyncFlowDeps,
): void {
if (!path.posix.basename(remoteTmpDir).startsWith(SYNC_TEMP_PREFIX)) return;
deps.runSsh(host, `${remoteCmd} sync --remove-temp ${quote(remoteTmpDir)}`);
}
/**
* `sync --make-temp` prints the created dir as its last stdout line (a
* launcher wrapper may log above it). Backslashes are normalized to forward
* slashes: scp, the remote SubMiner, and Windows itself all accept them, and
* it keeps the later `${dir}/file` compositions valid on every platform.
*/
function parseRemoteTempDir(stdout: string): string {
const lines = stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const candidate = (lines[lines.length - 1] ?? '').replaceAll('\\', '/');
return path.posix.basename(candidate).startsWith(SYNC_TEMP_PREFIX) ? candidate : '';
}
function parseRemoteMergeSummary(stdout: string): SyncMergeSummary | null {
for (const line of stdout.split('\n')) {
const event = parseSyncProgressLine(line);
if (event?.type === 'merge-summary' && event.target === 'local') return event.summary;
}
return null;
}
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
const stderr = run.stderr.trim();
return stderr ? `${message}\n${stderr}` : message;
}
export async function runHostSync(
context: SyncFlowContext,
dbPath: string,
deps: SyncFlowDeps,
): Promise<void> {
const { args } = context;
const host = args.syncHost;
const direction = args.syncDirection ?? 'both';
const shouldPull = direction !== 'push';
const shouldPush = direction !== 'pull';
deps.assertSafeSshHost(host);
await deps.ensureTrackerQuiescent(context, dbPath);
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
const quote = (value: string) => quoteForRemoteShell(flavor, value);
if (args.logLevel === 'debug') {
console.error(`Remote subminer command (${flavor}): ${remoteCmd}`);
}
const localTmpDir = makeSyncTempDir(deps.mkdtempSync);
let remoteTmpDir = '';
let pulledSummary: SyncMergeSummary | null = null;
try {
// Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
// main().catch() reports the message the same way fail() would.
const mktemp = deps.runSsh(host, `${remoteCmd} sync --make-temp`);
remoteTmpDir = mktemp.status === 0 ? parseRemoteTempDir(mktemp.stdout) : '';
if (!remoteTmpDir) {
throw new Error(
formatRemoteRunError(`Could not create a temporary directory on ${host}.`, mktemp),
);
}
const forceFlag = args.syncForce ? ' --force' : '';
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, localSnapshot);
}
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
if (shouldPull) {
deps.consoleLog(`Snapshotting ${host}...`);
deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` });
const snapshotRun = deps.runSsh(
host,
`${remoteCmd} sync --snapshot ${quote(remoteSnapshot)}${forceFlag}`,
);
if (snapshotRun.status !== 0) {
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
}
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
if (shouldPull) {
deps.emitEvent({
type: 'stage',
stage: 'download',
message: `Copying snapshot from ${host}`,
});
deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
}
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
if (shouldPush) {
deps.emitEvent({ type: 'stage', stage: 'upload', message: `Copying snapshot to ${host}` });
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
}
if (shouldPull) {
deps.consoleLog(`\nMerging ${host} -> local:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
message: `Merging ${host} into the local database`,
});
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
pulledSummary = summary;
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(formatMergeSummary(summary));
}
if (shouldPush) {
deps.consoleLog(`\nMerging local -> ${host}:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-remote',
message: `Merging the local database into ${host}`,
});
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${quote(incomingSnapshot)}${forceFlag}${args.syncJson ? ' --json' : ''}`,
);
deps.writeStdout(mergeRun.stdout);
const remoteSummary = args.syncJson ? parseRemoteMergeSummary(mergeRun.stdout) : null;
if (remoteSummary) {
deps.emitEvent({ type: 'merge-summary', target: 'remote', summary: remoteSummary });
} else if (mergeRun.stdout.trim()) {
deps.emitEvent({ type: 'remote-output', text: mergeRun.stdout });
}
if (mergeRun.status !== 0) {
const retryCommand =
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
const localUpdate = shouldPull ? ' The local database was updated;' : '';
throw new Error(
formatRemoteRunError(
`Remote merge failed on ${host}.${localUpdate} re-run "${retryCommand}" once the remote issue is fixed.`,
mergeRun,
),
);
}
}
deps.consoleLog('\nSync complete.');
deps.recordHostSyncResult(host, 'success', formatHostSyncDetail(direction, pulledSummary));
} catch (error) {
try {
deps.recordHostSyncResult(
host,
'error',
error instanceof Error ? error.message : String(error),
);
} catch {
// best effort
}
throw error;
} finally {
deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteCmd, remoteTmpDir, quote, deps);
} catch {
// best effort
}
}
}
}
export async function runSyncFlow(
context: SyncFlowContext,
inputDeps: SyncFlowDeps,
): Promise<void> {
let deps = inputDeps;
const { args } = context;
if (args.syncJson) deps = withJsonEvents(deps);
try {
if (args.syncMakeTemp) {
runMakeTempMode(deps);
} else if (args.syncRemoveTempPath) {
runRemoveTempMode(context, deps);
} else {
const dbPath = resolveSyncDbPath(context, deps);
if (args.syncCheck) {
await runCheckMode(context, deps);
} else if (args.syncSnapshotPath) {
await runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
throw new Error('sync requires a host, --snapshot <file>, or --merge <file>.');
}
}
} catch (error) {
if (args.syncJson) {
deps.emitEvent({
type: 'result',
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
}
+61
View File
@@ -0,0 +1,61 @@
import fs from 'node:fs';
export interface SyncDbOpenOptions {
readonly?: boolean;
/**
* Read-write open. The libsql opener treats "not readonly" as read-write,
* but the launcher's bun:sqlite history reader passes these options straight
* to `new Database(...)`, where an explicit readwrite flag is what prevents
* bun from defaulting to readwrite+create. Keep it.
*/
readwrite?: boolean;
create?: boolean;
}
/**
* Opening a WAL-mode SQLite database strictly read-only fails when the -shm
* file is missing or stale (the reader must be able to create it). Retry such
* failures with a read-write handle; the caller still only issues reads.
*/
export function withReadonlyWalRetry<T>(
dbPath: string,
query: (options: SyncDbOpenOptions) => T,
): T {
try {
return query({ readonly: true });
} catch (error) {
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
return query({ readwrite: true, create: false });
}
}
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
if (!isWalModeSqliteDatabase(dbPath)) return false;
const code =
typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const message = error instanceof Error ? error.message : String(error);
const text = `${code} ${message}`.toLowerCase();
return (
text.includes('readonly') ||
text.includes('read-only') ||
text.includes('attempt to write a readonly database') ||
text.includes('sqlite_cantopen') ||
text.includes('unable to open database file')
);
}
function isWalModeSqliteDatabase(dbPath: string): boolean {
const header = Buffer.alloc(20);
let fd: number | null = null;
try {
fd = fs.openSync(dbPath, 'r');
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
}
+7
View File
@@ -180,6 +180,13 @@ test('direct runtime commands forward through app control', () => {
test('entry-only and internal commands do not forward through app control', () => {
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false);
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false);
assert.equal(
shouldForwardStartupArgvViaAppControl(
['SubMiner.exe', '--sync-cli', 'sync', 'media-box', '--json'],
{},
),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}),
false,
+2
View File
@@ -158,6 +158,8 @@ export function shouldForwardStartupArgvViaAppControl(
const args = parseCliArgs(argv);
if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false;
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
// Sync CLI argv is handled headless at entry, never forwarded to a running app.
if (argv.includes('--sync-cli')) return false;
return hasExplicitCommand(args);
}
+5
View File
@@ -31,6 +31,7 @@ import {
} from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { handleSyncCliAtEntry } from './main/sync-cli';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
@@ -222,6 +223,10 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
}
async function runEntryProcess(): Promise<void> {
// Headless sync CLI: must run first (its own --help/--version handling) and
// exit before app.whenReady() so it works over SSH with no display server.
if (await handleSyncCliAtEntry(process.argv, process.env, app.getVersion())) return;
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
+82
View File
@@ -530,8 +530,14 @@ import {
createCreateConfigSettingsWindowHandler,
createCreateFirstRunSetupWindowHandler,
createCreateJellyfinSetupWindowHandler,
createCreateSyncUiWindowHandler,
} from './main/runtime/setup-window-factory';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler';
import { resolveSyncLauncherCommand, runSyncLauncher } from './main/runtime/sync-launcher-client';
import { getSyncHostsPath } from './shared/sync/sync-hosts-store';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
import {
isSameYoutubeMediaPath,
@@ -903,6 +909,7 @@ const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
return [
appState.configSettingsWindow,
appState.syncUiWindow,
appState.yomitanSettingsWindow,
appState.anilistSetupWindow,
appState.jellyfinSetupWindow,
@@ -2233,6 +2240,75 @@ const configSettingsRuntime = createConfigSettingsRuntime({
configSettingsRuntime.registerHandlers();
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => appState.syncUiWindow,
setSettingsWindow: (window) => {
appState.syncUiWindow = window as BrowserWindow | null;
},
createSettingsWindow: createCreateSyncUiWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: path.join(__dirname, 'preload-syncui.js'),
}),
settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
onClosed: () => {
// requestAppQuit (not app.quit) so the forced-exit fallback covers a
// stalled quit; a standalone sync window is the app's only reason to live.
if (appState.initialArgs?.syncWindow) requestAppQuit();
},
log: (message) => logger.error(message),
});
const openSyncUiWindow = () => openSyncUiWindowHandler();
const syncUiRuntime = createSyncUiRuntime({
ipcMain,
hostsFilePath: getSyncHostsPath(USER_DATA_PATH),
snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'),
getDbPath: () => {
const configured = getResolvedConfig().immersionTracking?.dbPath?.trim();
return configured || DEFAULT_IMMERSION_DB_PATH;
},
resolveLauncherCommand: () =>
resolveSyncLauncherCommand({
execPath: process.execPath,
appPath: app.isPackaged ? null : app.getAppPath(),
}),
runLauncher: runSyncLauncher,
getWindow: () => appState.syncUiWindow,
pickSnapshotFile: async () => {
const result = await dialog.showOpenDialog({
title: 'Choose a SubMiner stats snapshot',
filters: [{ name: 'SQLite snapshots', extensions: ['sqlite'] }],
properties: ['openFile'],
});
return result.canceled ? null : (result.filePaths[0] ?? null);
},
revealPath: (targetPath) => shell.showItemInFolder(targetPath),
nowMs: () => Date.now(),
log: (message) => logger.info(message),
notify: (payload) =>
showOverlayNotification({
title: payload.title,
body: payload.body,
variant: payload.variant,
}),
});
syncUiRuntime.registerHandlers();
// Live sync uses a consistent WAL snapshot plus a transactional merge, and it
// skips unfinished sessions, so resident stats writers do not block scheduling.
const syncAutoScheduler = createSyncAutoScheduler({
readState: () => syncUiRuntime.readState(),
isRunning: () => syncUiRuntime.isRunning(),
triggerHostSync: (host, direction) => {
void syncUiRuntime.runHostSync({ host, direction }, { notify: true });
},
nowMs: () => Date.now(),
log: (message) => logger.info(message),
});
syncAutoScheduler.start();
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
platform: process.platform,
dirname: __dirname,
@@ -3893,6 +3969,10 @@ const {
annotationSubtitleWsService.stop();
},
stopTexthookerService: () => texthookerService.stop(),
stopSyncAutoScheduler: async () => {
syncAutoScheduler.stop();
await syncUiRuntime.shutdown();
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
@@ -6006,6 +6086,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openSyncUiWindow: () => openSyncUiWindow(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
@@ -6254,6 +6335,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openSyncUiWindow: () => openSyncUiWindow(),
exportLogs: () => {
void exportLogsFromTray();
},
+1 -1
View File
@@ -13,7 +13,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
+2
View File
@@ -50,6 +50,7 @@ export interface CliCommandRuntimeServiceContext {
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
+2
View File
@@ -210,6 +210,7 @@ export interface CliCommandRuntimeServiceDepsParams {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
@@ -414,6 +415,7 @@ export function createCliCommandRuntimeServiceDeps(
openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp,
@@ -16,6 +16,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -47,7 +50,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 33);
assert.equal(calls.length, 34);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -68,6 +71,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
+4 -1
View File
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
@@ -33,7 +34,7 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
return (): Promise<void> => {
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -70,6 +72,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService();
return Promise.resolve(stopSyncAutoScheduler);
};
}
@@ -19,6 +19,9 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -113,6 +116,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({
@@ -173,6 +177,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void | Promise<void>;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
@@ -73,6 +74,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
stopSyncAutoScheduler: () => deps.stopSyncAutoScheduler(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -74,6 +74,7 @@ test('build cli command context deps maps handlers and values', () => {
},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
@@ -48,6 +48,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -76,6 +76,7 @@ test('cli command context factory composes main deps and context handlers', () =
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -105,6 +105,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
@@ -58,6 +58,7 @@ function createDeps() {
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -53,6 +53,7 @@ export type CliCommandContextFactoryDeps = {
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -134,6 +135,7 @@ export function createCliCommandContext(
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
+1 -1
View File
@@ -153,7 +153,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
};
}
function resolveLauncherResourcePath(options: CommonOptions): string {
export function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options));
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath =
@@ -52,6 +52,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
@@ -45,13 +45,14 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
onClosed: () => calls.push('on-closed'),
});
assert.equal(open(), true);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
assert.ok(handlers.closed);
handlers.closed();
assert.equal(calls.at(-1), 'set:null');
assert.deepEqual(calls.slice(-2), ['set:null', 'on-closed']);
});
test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => {
@@ -13,6 +13,7 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
onClosed?: () => void;
log?: (message: string) => void;
}
@@ -42,6 +43,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
deps.setSettingsWindow(window);
window.on('closed', () => {
deps.setSettingsWindow(null);
deps.onClosed?.();
});
showAndFocus(window);
return true;
@@ -31,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
yomitan: false,
settings: false,
syncWindow: false,
setup: false,
show: false,
hide: false,
+15
View File
@@ -84,3 +84,18 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
backgroundColor: '#24273a',
});
}
export function createCreateSyncUiWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
preloadPath: string;
}) {
return createSetupWindowHandler(deps, {
width: 880,
height: 720,
title: 'SubMiner Sync',
show: false,
resizable: true,
preloadPath: deps.preloadPath,
backgroundColor: '#24273a',
});
}
@@ -15,7 +15,9 @@ test('app lifecycle runtime runner main deps builder maps lifecycle callbacks',
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -23,7 +23,9 @@ test('startup runtime handlers compose lifecycle runner and startup bootstrap ap
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
onWillQuitCleanup: () => {
calls.push('cleanup');
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
@@ -0,0 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createDefaultSyncHostsState,
upsertSyncHost,
recordSyncResult,
type SyncHostsState,
} from '../../shared/sync/sync-hosts-store';
import { createSyncAutoScheduler } from './sync-auto-scheduler';
function makeState(): SyncHostsState {
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'both' }, 0);
state = upsertSyncHost(state, { host: 'manual-box', autoSync: false }, 0);
return state;
}
test('tick triggers a due auto-sync host with its saved direction', () => {
let state = makeState();
state = upsertSyncHost(state, { host: 'auto-box', autoSync: true, direction: 'pull' }, 0);
const triggered: Array<{ host: string; direction: string }> = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host, direction) => triggered.push({ host, direction }),
nowMs: () => 100 * 60_000,
});
scheduler.tick();
assert.deepEqual(triggered, [{ host: 'auto-box', direction: 'pull' }]);
});
test('tick does not require the resident app and stats writer to stop', () => {
const state = makeState();
const triggered: string[] = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
assert.doesNotThrow(() => scheduler.tick());
assert.deepEqual(triggered, ['auto-box']);
});
test('tick skips hosts synced more recently than the interval', () => {
let state = makeState();
state = recordSyncResult(state, 'auto-box', {
atMs: 90 * 60_000,
status: 'success',
detail: null,
});
const triggered: string[] = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000, // 10 minutes later, default interval 30
});
scheduler.tick();
assert.deepEqual(triggered, []);
});
test('tick does nothing while a run is active', () => {
const state = makeState();
const triggered: string[] = [];
const running = createSyncAutoScheduler({
readState: () => state,
isRunning: () => true,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
running.tick();
assert.deepEqual(triggered, []);
});
test('tick triggers at most one host per tick', () => {
let state = makeState();
state = upsertSyncHost(state, { host: 'second-box', autoSync: true }, 0);
const triggered: string[] = [];
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: (host) => triggered.push(host),
nowMs: () => 100 * 60_000,
});
scheduler.tick();
assert.equal(triggered.length, 1);
});
test('tick logs a synchronous trigger failure and remains usable', () => {
const state = makeState();
const logs: string[] = [];
let attempts = 0;
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: () => {
attempts += 1;
throw new Error('spawn exploded');
},
nowMs: () => 100 * 60_000,
log: (message) => logs.push(message),
});
assert.doesNotThrow(() => scheduler.tick());
assert.doesNotThrow(() => scheduler.tick());
assert.equal(attempts, 2);
assert.ok(logs.some((message) => message.includes('spawn exploded')));
});
test('start/stop manage the interval timer', () => {
const state = makeState();
let setCalls = 0;
let clearCalls = 0;
const scheduler = createSyncAutoScheduler({
readState: () => state,
isRunning: () => false,
triggerHostSync: () => {},
nowMs: () => 0,
setIntervalFn: ((): NodeJS.Timeout => {
setCalls += 1;
return 42 as unknown as NodeJS.Timeout;
}) as unknown as typeof setInterval,
clearIntervalFn: (() => {
clearCalls += 1;
}) as unknown as typeof clearInterval,
});
scheduler.start();
scheduler.start(); // idempotent
assert.equal(setCalls, 1);
scheduler.stop();
assert.equal(clearCalls, 1);
});
+55
View File
@@ -0,0 +1,55 @@
import type { SyncDirection, SyncHostsState } from '../../shared/sync/sync-hosts-store';
export interface SyncAutoSchedulerDeps {
readState: () => SyncHostsState;
isRunning: () => boolean;
triggerHostSync: (host: string, direction: SyncDirection) => void;
nowMs: () => number;
log?: (message: string) => void;
tickIntervalMs?: number;
setIntervalFn?: typeof setInterval;
clearIntervalFn?: typeof clearInterval;
}
const DEFAULT_TICK_INTERVAL_MS = 60_000;
export function createSyncAutoScheduler(deps: SyncAutoSchedulerDeps) {
let timer: NodeJS.Timeout | null = null;
function tick(): void {
if (deps.isRunning()) return;
const state = deps.readState();
const intervalMs = state.autoSyncIntervalMinutes * 60_000;
const now = deps.nowMs();
// A failed sync still updates lastSyncAtMs, so errors retry on the next
// interval instead of every tick.
const due = state.hosts.find(
(entry) =>
entry.autoSync && (entry.lastSyncAtMs === null || now - entry.lastSyncAtMs >= intervalMs),
);
if (!due) return;
deps.log?.(`Auto-sync starting for ${due.host} (${due.direction})`);
try {
deps.triggerHostSync(due.host, due.direction);
} catch (error) {
deps.log?.(
`Auto-sync failed to start: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function start(): void {
if (timer !== null) return;
const setIntervalFn = deps.setIntervalFn ?? setInterval;
timer = setIntervalFn(tick, deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS);
}
function stop(): void {
if (timer === null) return;
const clearIntervalFn = deps.clearIntervalFn ?? clearInterval;
clearIntervalFn(timer);
timer = null;
}
return { tick, start, stop };
}
@@ -0,0 +1,330 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import {
runSyncLauncher,
sanitizeSyncLauncherEnv,
type SyncLauncherSpawn,
} from './sync-launcher-client';
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
killed = false;
kill(): boolean {
this.killed = true;
this.emit('close', null, 'SIGTERM');
return true;
}
}
function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; commands: string[][] } {
const children: FakeChild[] = [];
const commands: string[][] = [];
const spawn: SyncLauncherSpawn = (command, args) => {
commands.push([command, ...args]);
const child = new FakeChild();
children.push(child);
return child as never;
};
return { spawn, children, commands };
}
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
const { spawn, children, commands } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
child.stdout.emit('data', Buffer.from('{"type":"stage","stage":"snapshot-'));
child.stdout.emit('data', Buffer.from('local","message":"Snapshotting"}\n{"type":"result",'));
child.stdout.emit('data', Buffer.from('"ok":true,"error":null}\n'));
child.emit('close', 0, null);
const result = await handle.done;
assert.deepEqual(commands[0], ['subminer', 'sync', 'media-box', '--json']);
assert.equal(events.length, 2);
assert.equal(events[0]!.type, 'stage');
assert.deepEqual(events[1], { type: 'result', ok: true, error: null });
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher preserves multibyte characters split across chunks', async () => {
const { spawn, children } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
const line = Buffer.from('{"type":"result","ok":false,"error":"進捗の同期に失敗"}\n');
// Split mid-character: the boundary lands inside a multibyte code point.
const boundary = line.indexOf(Buffer.from('進')) + 1;
child.stdout.emit('data', line.subarray(0, boundary));
child.stdout.emit('data', line.subarray(boundary));
child.emit('close', 1, null);
const result = await handle.done;
assert.deepEqual(events.at(-1), {
type: 'result',
ok: false,
error: '進捗の同期に失敗',
});
assert.equal(result.error, '進捗の同期に失敗');
});
test('runSyncLauncher settles after exit when close never arrives', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stderr.emit('data', Buffer.from('[ERROR] remote refused\n'));
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never fires.
child.emit('exit', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /remote refused/);
});
test('runSyncLauncher reports failures with the result event error or stderr tail', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stdout.emit(
'data',
Buffer.from('{"type":"result","ok":false,"error":"Remote merge failed"}\n'),
);
child.stderr.emit('data', Buffer.from('[ERROR] Remote merge failed\n'));
child.emit('close', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /Remote merge failed/);
});
test('runSyncLauncher falls back to stderr when no result event arrives', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', '--snapshot', '/tmp/x.sqlite', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stderr.emit('data', Buffer.from('[ERROR] boom\n'));
child.emit('close', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /boom/);
});
test('runSyncLauncher settles on exit when the terminal event is already parsed', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
});
children[0]!.stdout.emit(
'data',
Buffer.from('{"type":"result","ok":true,"error":null}\n'),
);
children[0]!.emit('exit', 0, null);
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher waits for terminal NDJSON when exit precedes final stdout data', async () => {
const { spawn, children } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
let settled = false;
void handle.done.then(() => {
settled = true;
});
child.emit('exit', 0, null);
await Promise.resolve();
assert.equal(settled, false);
child.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n'));
assert.deepEqual(await handle.done, { ok: true, error: null });
assert.deepEqual(events.at(-1), { type: 'result', ok: true, error: null });
});
test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
handle.cancel();
assert.equal(child.killed, true);
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.ok(result);
assert.equal(result.ok, false);
assert.match(result.error ?? '', /cancel/i);
});
test('runSyncLauncher cancel settles a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never arrives.
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
handle.cancel();
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync cancelled.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that never completes', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
child.emit('exit', null, 'SIGTERM');
return true;
};
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.equal(child.killed, true);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
});
test('runSyncLauncher surfaces spawn errors', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
children[0]!.emit('error', new Error('ENOENT: subminer not found'));
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /ENOENT/);
});
test('resolveSyncLauncherCommand self-spawns the app in --sync-cli mode', async () => {
const { resolveSyncLauncherCommand } = await import('./sync-launcher-client');
const packaged = resolveSyncLauncherCommand({
execPath: '/opt/SubMiner/subminer-app',
appPath: null,
});
assert.deepEqual(packaged, ['/opt/SubMiner/subminer-app', '--sync-cli']);
const dev = resolveSyncLauncherCommand({
execPath: '/usr/bin/electron',
appPath: '/home/u/SubMiner',
});
assert.deepEqual(dev, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']);
});
test('sanitizeSyncLauncherEnv removes transported parent startup arguments', () => {
const parentEnv = {
PATH: '/usr/bin',
ELECTRON_RUN_AS_NODE: '1',
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--start',
SUBMINER_APP_ARG_1: '--background',
};
assert.deepEqual(sanitizeSyncLauncherEnv(parentEnv), { PATH: '/usr/bin' });
assert.equal(parentEnv.SUBMINER_APP_ARGC, '2');
});
+221
View File
@@ -0,0 +1,221 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args';
/** How long a cancelled sync child gets to exit on SIGTERM before SIGKILL. */
const CANCEL_GRACE_MS = 5000;
/**
* How long a child that exited without terminal NDJSON gets to flush its
* remaining stdout before the exit is treated as authoritative.
*/
const EXIT_DRAIN_MS = 2000;
export interface SyncLauncherChildLike {
stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown;
on(event: 'exit', listener: (code: number | null, signal: string | null) => void): unknown;
on(event: 'error', listener: (error: Error) => void): unknown;
kill(signal?: NodeJS.Signals): boolean;
}
export type SyncLauncherSpawn = (command: string, args: string[]) => SyncLauncherChildLike;
export interface SyncLauncherRunResult {
ok: boolean;
error: string | null;
}
export interface SyncLauncherRunHandle {
cancel: () => void;
done: Promise<SyncLauncherRunResult>;
}
export function sanitizeSyncLauncherEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
delete env.ELECTRON_RUN_AS_NODE;
delete env.SUBMINER_APP_ARGC;
for (const name of Object.keys(env)) {
if (name.startsWith('SUBMINER_APP_ARG_')) delete env[name];
}
return env;
}
// Sync runs in a child copy of this app in headless --sync-cli mode: same
// engine and NDJSON protocol as `subminer sync --json`, with no dependency on
// bun or an installed command-line launcher. In dev runs process.execPath is
// a bare electron binary, so the app path is passed as its entry argument.
export function resolveSyncLauncherCommand(
deps: {
execPath?: string;
appPath?: string | null;
} = {},
): string[] {
const execPath = deps.execPath ?? process.execPath;
const appPath = deps.appPath ?? null;
return appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG];
}
export function runSyncLauncher(options: {
command: string[];
args: string[];
onEvent: (event: SyncProgressEvent) => void;
onStderr?: (text: string) => void;
spawn?: SyncLauncherSpawn;
timeoutMs?: number;
}): SyncLauncherRunHandle {
const spawn =
options.spawn ??
((command, args) => {
// The child must boot as a full Electron app (its entry handles
// --sync-cli); a leaked ELECTRON_RUN_AS_NODE would turn it into node.
const env = sanitizeSyncLauncherEnv(process.env);
return nodeSpawn(command, args, { stdio: 'pipe', env });
});
const [executable, ...prefixArgs] = options.command;
const child = spawn(executable!, [...prefixArgs, ...options.args]);
let stdoutBuffer = '';
let stderrTail = '';
let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null;
let terminationError: string | null = null;
let settleAfterTerminalEvent: (() => void) | null = null;
let settleAfterTermination: (() => boolean) | null = null;
// Decode incrementally: a multibyte character can straddle a chunk boundary,
// and a per-chunk toString() would corrupt it (sync payloads carry Japanese
// media titles and error detail).
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
const decodeChunk = (decoder: StringDecoder, chunk: Buffer | string): string =>
typeof chunk === 'string' ? chunk : decoder.write(chunk);
child.stdout?.on('data', (chunk) => {
stdoutBuffer += decodeChunk(stdoutDecoder, chunk);
let newlineIndex = stdoutBuffer.indexOf('\n');
while (newlineIndex !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex);
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
const event = parseSyncProgressLine(line);
if (event) {
if (event.type === 'result') {
resultEvent = event;
settleAfterTerminalEvent?.();
}
options.onEvent(event);
}
newlineIndex = stdoutBuffer.indexOf('\n');
}
});
child.stderr?.on('data', (chunk) => {
const text = decodeChunk(stderrDecoder, chunk);
stderrTail = `${stderrTail}${text}`.slice(-4000);
options.onStderr?.(text);
});
let killTimer: ReturnType<typeof setTimeout> | null = null;
let operationTimer: ReturnType<typeof setTimeout> | null = null;
let drainTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = (): void => {
if (killTimer !== null) clearTimeout(killTimer);
if (operationTimer !== null) clearTimeout(operationTimer);
if (drainTimer !== null) clearTimeout(drainTimer);
killTimer = null;
operationTimer = null;
drainTimer = null;
};
const done = new Promise<SyncLauncherRunResult>((resolve) => {
let settled = false;
let exitObserved = false;
let exitCode: number | null = null;
const settle = (result: SyncLauncherRunResult): void => {
if (settled) return;
settled = true;
clearTimers();
resolve(result);
};
child.on('error', (error) => {
settle({ ok: false, error: error.message });
});
const settleFromExit = (code: number | null) => {
if (terminationError) {
settle({ ok: false, error: terminationError });
return;
}
if (code === 0) {
settle({ ok: true, error: null });
return;
}
const error =
resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`);
settle({ ok: false, error });
};
settleAfterTerminalEvent = () => {
if (exitObserved) settleFromExit(exitCode);
};
settleAfterTermination = () => {
if (!exitObserved) return false;
settleFromExit(exitCode);
return true;
};
// Electron descendants can retain inherited stdio pipes after the main
// child exits, delaying `close` indefinitely. Once terminal NDJSON has
// arrived, `exit` is authoritative; keep `close` as the fallback.
child.on('exit', (code) => {
exitObserved = true;
exitCode = code;
if (resultEvent || terminationError) {
settleFromExit(code);
return;
}
// No terminal event yet: stdout may still be flushing, so give it a
// bounded window rather than waiting on a `close` that retained pipes
// can withhold forever.
drainTimer = setTimeout(() => {
drainTimer = null;
settleFromExit(code);
}, EXIT_DRAIN_MS);
drainTimer.unref?.();
});
child.on('close', settleFromExit);
});
// A sync child blocked on an ssh password prompt may ignore SIGTERM, so
// escalate to SIGKILL after a grace period.
const terminate = (error: string): void => {
if (terminationError) return;
terminationError = error;
// A child that already exited without terminal NDJSON is still waiting on
// `close`, which descendants holding inherited pipes can delay
// indefinitely. There is nothing left to signal, so settle now instead of
// leaving `done` (and the quit-time shutdown that awaits it) pending.
if (settleAfterTermination?.()) return;
killTimer = setTimeout(() => {
killTimer = null;
try {
child.kill('SIGKILL');
} catch {
// process may already be gone
}
}, CANCEL_GRACE_MS);
killTimer.unref?.();
try {
child.kill('SIGTERM');
} catch {
// process may already be gone
}
};
if (options.timeoutMs !== undefined) {
operationTimer = setTimeout(() => terminate('Sync operation timed out.'), options.timeoutMs);
operationTimer.unref?.();
}
return {
cancel: () => terminate('Sync cancelled.'),
done,
};
}
+23
View File
@@ -0,0 +1,23 @@
import {
readSyncHostsState,
writeSyncHostsState,
type SyncHostsState,
} from '../../shared/sync/sync-hosts-store';
interface SyncUiHostsStateDeps {
hostsFilePath: string;
broadcastStateChanged: () => void;
}
export function createSyncUiHostsState(deps: SyncUiHostsStateDeps) {
function readState(): SyncHostsState {
return readSyncHostsState(deps.hostsFilePath);
}
function writeState(state: SyncHostsState): void {
writeSyncHostsState(deps.hostsFilePath, state);
deps.broadcastStateChanged();
}
return { readState, writeState };
}
+63
View File
@@ -0,0 +1,63 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { removeSyncHost, upsertSyncHost } from '../../shared/sync/sync-hosts-store';
import type {
SyncUiHostUpdateRequest,
SyncUiRunRequest,
SyncUiSnapshot,
SyncUiStartResult,
} from '../../types/sync-ui';
interface SyncUiIpcHandlersDeps {
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
nowMs: () => number;
revealPath: (targetPath: string) => void;
pickSnapshotFile: () => Promise<string | null>;
getSnapshot: () => SyncUiSnapshot;
readState: () => SyncUiSnapshot['hosts'];
writeState: (state: SyncUiSnapshot['hosts']) => void;
runHostSync: (request: SyncUiRunRequest) => SyncUiStartResult;
cancelRun: () => boolean;
checkHost: (host: string) => unknown;
createSnapshot: () => SyncUiStartResult;
mergeSnapshotFile: (filePath: string, force?: boolean) => SyncUiStartResult;
deleteSnapshot: (filePath: string) => void;
}
export function registerSyncUiIpcHandlers(deps: SyncUiIpcHandlersDeps): void {
const channels = IPC_CHANNELS.request;
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => deps.getSnapshot());
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) => {
deps.writeState(
upsertSyncHost(deps.readState(), update as SyncUiHostUpdateRequest, deps.nowMs()),
);
});
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) => {
deps.writeState(removeSyncHost(deps.readState(), String(host)));
});
deps.ipcMain.handle(channels.syncUiSetAutoSyncInterval, (_event, minutes) => {
const value = Number(minutes);
if (!Number.isFinite(value) || value < 1 || value > 24 * 60) {
throw new Error('Auto-sync interval must be between 1 and 1440 minutes.');
}
deps.writeState({ ...deps.readState(), autoSyncIntervalMinutes: Math.floor(value) });
});
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
deps.runHostSync(request as SyncUiRunRequest),
);
deps.ipcMain.handle(channels.syncUiCancelRun, () => deps.cancelRun());
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => deps.checkHost(String(host)));
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => deps.createSnapshot());
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
deps.mergeSnapshotFile(String(filePath), force === true),
);
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) => {
deps.deleteSnapshot(String(filePath));
});
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
deps.revealPath(String(filePath));
return true;
});
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
}
+183
View File
@@ -0,0 +1,183 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { isValidSyncHost } from '../../shared/sync/sync-hosts-store';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type {
SyncUiCheckResult,
SyncUiRunKind,
SyncUiRunState,
SyncUiStartResult,
} from '../../types/sync-ui';
import {
runSyncLauncher,
type SyncLauncherRunHandle,
type SyncLauncherRunResult,
} from './sync-launcher-client';
interface ActiveRun {
id: number;
kind: SyncUiRunKind;
host: string | null;
handle: SyncLauncherRunHandle;
resultSeen: boolean;
completion?: Promise<void>;
}
interface SyncUiRunLifecycleDeps {
runLauncher: typeof runSyncLauncher;
resolveLauncherCommand: () => string[];
log?: (message: string) => void;
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
sendToWindow: (channel: string, payload?: unknown) => void;
broadcastStateChanged: () => void;
}
export function createSyncUiRunLifecycle(deps: SyncUiRunLifecycleDeps) {
let runCounter = 0;
let currentRun: ActiveRun | null = null;
function launchRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: {
notify?: boolean;
timeoutMs?: number;
emitProgress?: boolean;
onEvent?: (event: SyncProgressEvent) => void;
} = {},
): { start: SyncUiStartResult; done: Promise<SyncLauncherRunResult> | null } {
if (currentRun) {
return {
start: { started: false, runId: null, reason: 'A sync operation is already running.' },
done: null,
};
}
const emitProgress = options.emitProgress ?? true;
runCounter += 1;
const runId = runCounter;
const run: ActiveRun = {
id: runId,
kind,
host,
resultSeen: false,
handle: deps.runLauncher({
command: deps.resolveLauncherCommand(),
args,
timeoutMs: options.timeoutMs,
onEvent: (event: SyncProgressEvent) => {
if (event.type === 'result') run.resultSeen = true;
options.onEvent?.(event);
if (emitProgress) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, { runId, kind, host, event });
}
},
onStderr: (text) => deps.log?.(`[sync-ui] ${text.trimEnd()}`),
}),
};
currentRun = run;
run.completion = run.handle.done
.then((result: SyncLauncherRunResult) => {
if (currentRun?.id === runId) currentRun = null;
if (emitProgress && !run.resultSeen) {
deps.sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
runId,
kind,
host,
event: { type: 'result', ok: result.ok, error: result.error },
});
}
deps.broadcastStateChanged();
if (options.notify && deps.notify) {
const target = host ?? 'local database';
deps.notify(
result.ok
? { title: 'Sync complete', body: `Synced with ${target}`, variant: 'success' }
: {
title: 'Sync failed',
body: `${target}: ${result.error ?? 'unknown error'}`,
variant: 'error',
},
);
}
})
.catch((error) => {
deps.log?.(
`[sync-ui] Post-run cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
return { start: { started: true, runId, reason: null }, done: run.handle.done };
}
function startRun(
kind: SyncUiRunKind,
host: string | null,
args: string[],
options: { notify?: boolean } = {},
): SyncUiStartResult {
return launchRun(kind, host, args, options).start;
}
function checkHost(host: string): Promise<SyncUiCheckResult> {
const trimmed = host.trim();
const failed = (error: string): SyncUiCheckResult => ({
host: trimmed,
sshOk: false,
remoteCommand: null,
remoteVersion: null,
ok: false,
error,
});
if (!isValidSyncHost(trimmed)) {
return Promise.resolve(failed(`Invalid sync host: ${host}`));
}
let checkResult: SyncUiCheckResult | null = null;
const { start, done } = launchRun('check', trimmed, ['sync', trimmed, '--check', '--json'], {
timeoutMs: 30_000,
emitProgress: false,
onEvent: (event) => {
if (event.type === 'check-result') {
checkResult = {
host: event.host,
sshOk: event.sshOk,
remoteCommand: event.remoteCommand,
remoteVersion: event.remoteVersion,
ok: event.ok,
error: event.error,
};
}
},
});
if (!start.started || !done) {
return Promise.resolve(failed(start.reason ?? 'A sync operation is already running.'));
}
return done.then((result) => checkResult ?? failed(result.error ?? 'Connection check failed.'));
}
function cancelRun(): boolean {
if (!currentRun) return false;
currentRun.handle.cancel();
return true;
}
async function shutdown(): Promise<void> {
const run = currentRun;
if (!run) return;
run.handle.cancel();
await (run.completion ?? run.handle.done.then(() => undefined));
}
function runState(): SyncUiRunState {
return currentRun
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
: { running: false, runId: null, kind: null, host: null };
}
return {
startRun,
checkHost,
cancelRun,
shutdown,
runState,
isRunning: () => currentRun !== null,
};
}
+319
View File
@@ -0,0 +1,319 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
import type { SyncLauncherRunHandle } from './sync-launcher-client';
import { createSyncUiRuntime, type SyncUiRuntimeDeps } from './sync-ui-runtime';
interface LauncherCall {
args: string[];
timeoutMs: number | undefined;
onEvent: (event: SyncProgressEvent) => void;
finish: (result: { ok: boolean; error: string | null }) => void;
cancelled: boolean;
}
function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
const launcherCalls: LauncherCall[] = [];
const sent: Array<{ channel: string; payload: unknown }> = [];
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const windowStub = {
isDestroyed: () => false,
webContents: {
send: (channel: string, payload?: unknown) => sent.push({ channel, payload }),
},
};
const deps: SyncUiRuntimeDeps = {
ipcMain: {
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, listener);
},
},
hostsFilePath: path.join(root, 'sync-hosts.json'),
snapshotsDir: path.join(root, 'snapshots'),
getDbPath: () => path.join(root, 'immersion.sqlite'),
resolveLauncherCommand: () => ['subminer'],
runLauncher: (options): SyncLauncherRunHandle => {
let finish: (result: { ok: boolean; error: string | null }) => void = () => {};
const done = new Promise<{ ok: boolean; error: string | null }>((resolve) => {
finish = resolve;
});
const call: LauncherCall = {
args: options.args,
timeoutMs: options.timeoutMs,
onEvent: options.onEvent,
finish,
cancelled: false,
};
launcherCalls.push(call);
return {
cancel: () => {
call.cancelled = true;
finish({ ok: false, error: 'Sync cancelled.' });
},
done,
};
},
getWindow: () => windowStub,
pickSnapshotFile: async () => null,
revealPath: () => {},
nowMs: () => 1700000000000,
...overrides,
};
const runtime = createSyncUiRuntime(deps);
runtime.registerHandlers();
const invoke = (channel: string, ...args: unknown[]): unknown => {
const handler = handlers.get(channel);
assert.ok(handler, `missing handler for ${channel}`);
return handler({}, ...args);
};
return { runtime, invoke, launcherCalls, sent, handlers };
}
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-ui-test-'));
const cleanup = () => fs.rmSync(dir, { recursive: true, force: true });
const result = fn(dir);
if (result instanceof Promise) return result.finally(cleanup);
cleanup();
return result;
}
test('registerHandlers registers every sync-ui request channel', () =>
withTempDir((root) => {
const { handlers } = makeTestRig(root);
for (const channel of [
'sync-ui:get-snapshot',
'sync-ui:save-host',
'sync-ui:remove-host',
'sync-ui:set-auto-sync-interval',
'sync-ui:run-sync',
'sync-ui:cancel-run',
'sync-ui:check-host',
'sync-ui:create-snapshot',
'sync-ui:merge-snapshot-file',
'sync-ui:delete-snapshot',
'sync-ui:reveal-snapshot',
'sync-ui:pick-snapshot-file',
]) {
assert.ok(handlers.has(channel), `missing ${channel}`);
}
}));
test('save/remove host round-trips through the hosts file', () =>
withTempDir(async (root) => {
const { invoke, sent } = makeTestRig(root);
await invoke('sync-ui:save-host', {
host: 'user@laptop',
label: 'Laptop',
direction: 'pull',
autoSync: true,
});
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const saved = (await invoke('sync-ui:get-snapshot')) as {
hosts: { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
};
assert.equal(saved.hosts.hosts.length, 1);
assert.equal(saved.hosts.hosts[0]!.direction, 'pull');
assert.equal(saved.hosts.hosts[0]!.autoSync, true);
await invoke('sync-ui:remove-host', 'user@laptop');
const removed = (await invoke('sync-ui:get-snapshot')) as { hosts: { hosts: unknown[] } };
assert.equal(removed.hosts.hosts.length, 0);
}));
test('save-host rejects invalid hosts', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
await assert.rejects(
async () => invoke('sync-ui:save-host', { host: '-oProxyCommand=evil' }),
/Invalid sync host/,
);
}));
test('run-sync uses live mode, forwards progress, and rejects concurrent runs', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls, sent } = makeTestRig(root);
const start = (await invoke('sync-ui:run-sync', {
host: 'media-box',
direction: 'push',
})) as { started: boolean; runId: number };
assert.equal(start.started, true);
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--push', '--force', '--json']);
const second = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(second.started, false);
assert.match(second.reason ?? '', /already running/i);
launcherCalls[0]!.onEvent({ type: 'stage', stage: 'snapshot-local', message: 'Snap' });
const progress = sent.filter((entry) => entry.channel === 'sync-ui:progress');
assert.equal(progress.length, 1);
const payload = progress[0]!.payload as { runId: number; host: string; event: unknown };
assert.equal(payload.runId, start.runId);
assert.equal(payload.host, 'media-box');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const third = (await invoke('sync-ui:run-sync', { host: 'media-box' })) as {
started: boolean;
};
assert.equal(third.started, true);
}));
test('run-sync saves the host so previously used hosts persist', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:run-sync', { host: 'media-box', direction: 'pull' });
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
hosts: { hosts: Array<{ host: string; direction: string }> };
};
assert.equal(snapshot.hosts.hosts[0]!.host, 'media-box');
assert.equal(snapshot.hosts.hosts[0]!.direction, 'pull');
launcherCalls[0]!.finish({ ok: true, error: null });
}));
test('create-snapshot runs the launcher with a timestamped path under the snapshots dir', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const start = (await invoke('sync-ui:create-snapshot')) as { started: boolean };
assert.equal(start.started, true);
const args = launcherCalls[0]!.args;
assert.equal(args[0], 'sync');
assert.equal(args[1], '--snapshot');
assert.ok(args[2]!.startsWith(path.join(root, 'snapshots')));
assert.match(args[2]!, /immersion-.*\.sqlite$/);
assert.equal(args[3], '--json');
launcherCalls[0]!.finish({ ok: true, error: null });
}));
test('delete-snapshot refuses paths outside the snapshots dir', () =>
withTempDir(async (root) => {
const { invoke, sent } = makeTestRig(root);
fs.mkdirSync(path.join(root, 'snapshots'), { recursive: true });
const inside = path.join(root, 'snapshots', 'immersion-1.sqlite');
fs.writeFileSync(inside, 'x');
const outside = path.join(root, 'immersion.sqlite');
fs.writeFileSync(outside, 'x');
await assert.rejects(async () => invoke('sync-ui:delete-snapshot', outside));
await invoke('sync-ui:delete-snapshot', inside);
assert.equal(fs.existsSync(inside), false);
assert.equal(fs.existsSync(outside), true);
// The renderer relies on the state-changed broadcast to refresh its list.
assert.ok(sent.some((entry) => entry.channel === 'sync-ui:state-changed'));
const snapshot = (await invoke('sync-ui:get-snapshot')) as { snapshots: unknown[] };
assert.equal(snapshot.snapshots.length, 0);
}));
test('check-host resolves with the check-result event', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{
ok: boolean;
sshOk: boolean;
}>;
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']);
assert.equal(launcherCalls[0]!.timeoutMs, 30_000);
launcherCalls[0]!.onEvent({
type: 'check-result',
host: 'media-box',
sshOk: true,
remoteCommand: 'subminer',
remoteVersion: '0.18.0',
ok: true,
error: null,
});
launcherCalls[0]!.finish({ ok: true, error: null });
const result = await pending;
assert.equal(result.ok, true);
assert.equal(result.sshOk, true);
}));
test('check-host participates in active-run coordination and can be cancelled', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
const pending = invoke('sync-ui:check-host', 'media-box') as Promise<{ ok: boolean }>;
await new Promise((resolve) => setImmediate(resolve));
const blocked = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
started: boolean;
reason: string | null;
};
assert.equal(blocked.started, false);
assert.match(blocked.reason ?? '', /already running/i);
assert.equal(await invoke('sync-ui:cancel-run'), true);
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal((await pending).ok, false);
}));
test('shutdown cancels and awaits the active launcher run', () =>
withTempDir(async (root) => {
const { runtime, invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:create-snapshot');
await runtime.shutdown();
assert.equal(launcherCalls[0]!.cancelled, true);
assert.equal(runtime.isRunning(), false);
}));
test('post-run side-effect failures are logged without rejecting cleanup', () =>
withTempDir(async (root) => {
const logs: string[] = [];
const { invoke, launcherCalls } = makeTestRig(root, {
getWindow: () => ({
isDestroyed: () => false,
webContents: {
send: () => {
throw new Error('window gone');
},
},
}),
log: (message) => logs.push(message),
});
await invoke('sync-ui:create-snapshot');
launcherCalls[0]!.finish({ ok: true, error: null });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(logs.some((message) => message.includes('window gone')));
}));
test('cancel-run cancels the active run', () =>
withTempDir(async (root) => {
const { invoke, launcherCalls } = makeTestRig(root);
await invoke('sync-ui:run-sync', { host: 'media-box' });
const cancelled = (await invoke('sync-ui:cancel-run')) as boolean;
assert.equal(cancelled, true);
assert.equal(launcherCalls[0]!.cancelled, true);
}));
test('get-snapshot lists snapshot files newest first', () =>
withTempDir(async (root) => {
const { invoke } = makeTestRig(root);
const dir = path.join(root, 'snapshots');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'immersion-old.sqlite'), 'old');
fs.writeFileSync(path.join(dir, 'ignore.txt'), 'x');
fs.writeFileSync(path.join(dir, 'immersion-new.sqlite'), 'new');
fs.utimesSync(path.join(dir, 'immersion-old.sqlite'), new Date(1000), new Date(1000));
fs.utimesSync(path.join(dir, 'immersion-new.sqlite'), new Date(2000), new Date(2000));
const snapshot = (await invoke('sync-ui:get-snapshot')) as {
snapshots: Array<{ name: string }>;
dbPath: string;
run: { running: boolean };
};
assert.deepEqual(
snapshot.snapshots.map((file) => file.name),
['immersion-new.sqlite', 'immersion-old.sqlite'],
);
assert.equal(snapshot.run.running, false);
}));
+132
View File
@@ -0,0 +1,132 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
isValidSyncHost,
upsertSyncHost,
type SyncDirection,
} from '../../shared/sync/sync-hosts-store';
import type { SyncUiRunRequest, SyncUiSnapshot } from '../../types/sync-ui';
import { createSyncUiHostsState } from './sync-ui-hosts-state';
import { registerSyncUiIpcHandlers } from './sync-ui-ipc-handlers';
import { createSyncUiRunLifecycle } from './sync-ui-run-lifecycle';
import { runSyncLauncher } from './sync-launcher-client';
import { createSyncUiSnapshots } from './sync-ui-snapshots';
interface SyncUiWindowLike {
isDestroyed(): boolean;
webContents: { send(channel: string, payload?: unknown): void };
}
export interface SyncUiRuntimeDeps {
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
hostsFilePath: string;
snapshotsDir: string;
getDbPath: () => string;
resolveLauncherCommand: () => string[];
runLauncher: typeof runSyncLauncher;
getWindow: () => SyncUiWindowLike | null;
pickSnapshotFile: () => Promise<string | null>;
revealPath: (targetPath: string) => void;
nowMs: () => number;
log?: (message: string) => void;
notify?: (payload: { title: string; body: string; variant: 'success' | 'error' }) => void;
}
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
function sendToWindow(channel: string, payload?: unknown): void {
const window = deps.getWindow();
if (!window || window.isDestroyed()) return;
window.webContents.send(channel, payload);
}
function broadcastStateChanged(): void {
sendToWindow(IPC_CHANNELS.event.syncUiStateChanged);
}
const hostsState = createSyncUiHostsState({
hostsFilePath: deps.hostsFilePath,
broadcastStateChanged,
});
const runLifecycle = createSyncUiRunLifecycle({
runLauncher: deps.runLauncher,
resolveLauncherCommand: deps.resolveLauncherCommand,
log: deps.log,
notify: deps.notify,
sendToWindow,
broadcastStateChanged,
});
const snapshots = createSyncUiSnapshots({
snapshotsDir: deps.snapshotsDir,
nowMs: deps.nowMs,
startRun: runLifecycle.startRun,
broadcastStateChanged,
});
function getSnapshot(): SyncUiSnapshot {
return {
dbPath: deps.getDbPath(),
hosts: hostsState.readState(),
snapshotsDir: deps.snapshotsDir,
snapshots: snapshots.listSnapshots(),
run: runLifecycle.runState(),
};
}
function runHostSync(request: SyncUiRunRequest, options: { notify?: boolean } = {}) {
const host = request.host.trim();
if (!isValidSyncHost(host)) {
return { started: false, runId: null, reason: `Invalid sync host: ${request.host}` };
}
const direction: SyncDirection = request.direction ?? 'both';
const args = ['sync', host];
if (direction === 'push') args.push('--push');
if (direction === 'pull') args.push('--pull');
// The sync window runs inside the resident app, so waiting for all app
// writers to disappear would make manual and automatic sync impossible.
// WAL keeps snapshots consistent, transactional merges serialize with live
// writes, and unfinished sessions wait for a later sync after finalization.
args.push('--force', '--json');
const result = runLifecycle.startRun('host-sync', host, args, options);
if (result.started) {
// Persist before launcher bookkeeping lands so the host appears immediately.
hostsState.writeState(
upsertSyncHost(
hostsState.readState(),
{ host, direction: request.direction },
deps.nowMs(),
),
);
}
return result;
}
function registerHandlers(): void {
registerSyncUiIpcHandlers({
ipcMain: deps.ipcMain,
nowMs: deps.nowMs,
revealPath: deps.revealPath,
pickSnapshotFile: deps.pickSnapshotFile,
getSnapshot,
readState: hostsState.readState,
writeState: hostsState.writeState,
runHostSync,
cancelRun: runLifecycle.cancelRun,
checkHost: runLifecycle.checkHost,
createSnapshot: snapshots.createSnapshot,
mergeSnapshotFile: snapshots.mergeSnapshotFile,
deleteSnapshot: snapshots.deleteSnapshot,
});
}
return {
registerHandlers,
getSnapshot,
readState: hostsState.readState,
runHostSync,
checkHost: runLifecycle.checkHost,
cancelRun: runLifecycle.cancelRun,
shutdown: runLifecycle.shutdown,
isRunning: runLifecycle.isRunning,
};
}
+74
View File
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import type { SyncUiSnapshotFile, SyncUiStartResult } from '../../types/sync-ui';
interface SyncUiSnapshotsDeps {
snapshotsDir: string;
nowMs: () => number;
startRun: (kind: 'snapshot' | 'merge', host: null, args: string[]) => SyncUiStartResult;
broadcastStateChanged: () => void;
}
function formatSnapshotName(nowMs: number): string {
// Milliseconds prevent snapshots taken in the same second from overwriting each other.
const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`;
}
export function createSyncUiSnapshots(deps: SyncUiSnapshotsDeps) {
function listSnapshots(): SyncUiSnapshotFile[] {
let names: string[];
try {
names = fs.readdirSync(deps.snapshotsDir);
} catch {
return [];
}
const files: SyncUiSnapshotFile[] = [];
for (const name of names) {
if (!name.endsWith('.sqlite')) continue;
const filePath = path.join(deps.snapshotsDir, name);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) continue;
files.push({
path: filePath,
name,
sizeBytes: stat.size,
modifiedAtMs: stat.mtimeMs,
});
} catch {
// file disappeared between readdir and stat
}
}
files.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
return files;
}
function createSnapshot(): SyncUiStartResult {
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
return deps.startRun('snapshot', null, ['sync', '--snapshot', outPath, '--json']);
}
function mergeSnapshotFile(filePath: string, force = false): SyncUiStartResult {
if (!fs.existsSync(filePath)) {
return { started: false, runId: null, reason: `Snapshot file not found: ${filePath}` };
}
const args = ['sync', '--merge', filePath];
if (force) args.push('--force');
args.push('--json');
return deps.startRun('merge', null, args);
}
function deleteSnapshot(filePath: string): void {
const resolved = path.resolve(filePath);
const dir = path.resolve(deps.snapshotsDir);
if (resolved !== path.join(dir, path.basename(resolved)) || !resolved.endsWith('.sqlite')) {
throw new Error('Refusing to delete a file outside the snapshots directory.');
}
fs.rmSync(resolved, { force: true });
deps.broadcastStateChanged();
}
return { listSnapshots, createSnapshot, mergeSnapshotFile, deleteSnapshot };
}
@@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.openSyncUi();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
@@ -71,6 +72,7 @@ test('build tray template handler wires actions and init guards', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -96,6 +98,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup-forced',
'yomitan',
'configuration',
'sync-ui',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
@@ -124,6 +127,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
+5
View File
@@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -66,6 +67,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -103,6 +105,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
openSyncUi: () => {
deps.openSyncUiWindow();
},
exportLogs: () => {
deps.exportLogs();
},
+2
View File
@@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -57,6 +58,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
openSyncUi: () => {},
exportLogs: () => calls.push('open-export-logs'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
+3
View File
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -56,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -79,6 +81,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+14 -6
View File
@@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
openSyncUi: () => calls.push('sync-ui'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
@@ -48,7 +49,7 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 13);
assert.equal(template.length, 14);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
@@ -67,16 +68,19 @@ test('tray menu template contains expected entries and handlers', () => {
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
assert.equal(template[5]!.label, 'Open SubMiner Settings');
assert.equal(template[6]!.label, 'Export Logs');
assert.equal(template[6]!.label, 'Sync Stats && History');
template[6]!.click?.();
assert.equal(template[10]!.label, 'Check for Updates');
template[10]!.click?.();
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[12]!.click?.();
assert.equal(template[7]!.label, 'Export Logs');
template[7]!.click?.();
assert.equal(template[11]!.label, 'Check for Updates');
template[11]!.click?.();
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[13]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'texthooker',
'sync-ui',
'export-logs',
'updates',
'separator',
@@ -95,6 +99,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -123,6 +128,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -149,6 +155,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
@@ -176,6 +183,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
+5
View File
@@ -40,6 +40,7 @@ export type TrayMenuActionHandlers = {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -103,6 +104,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open SubMiner Settings',
click: handlers.openConfigSettings,
},
{
label: 'Sync Stats && History',
click: handlers.openSyncUi,
},
{
label: 'Export Logs',
click: handlers.exportLogs,
+1 -23
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
@@ -93,28 +93,6 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
+1 -1
View File
@@ -14,7 +14,7 @@ export interface AppLifecycleRuntimeRunnerParams {
logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>;
onWillQuitCleanup: () => void;
onWillQuitCleanup: () => void | Promise<void>;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
shouldQuitOnWindowAllClosed: () => boolean;
+2
View File
@@ -152,6 +152,7 @@ export interface AppState {
jellyfinSetupWindow: BrowserWindow | null;
firstRunSetupWindow: BrowserWindow | null;
configSettingsWindow: BrowserWindow | null;
syncUiWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
@@ -238,6 +239,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
jellyfinSetupWindow: null,
firstRunSetupWindow: null,
configSettingsWindow: null,
syncUiWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
mpvClient: null,
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { handleSyncCliAtEntry } from './sync-cli';
test('handleSyncCliAtEntry exits through the process exit dependency', async () => {
const exits: number[] = [];
const handled = await handleSyncCliAtEntry(
['SubMiner', '--sync-cli', 'sync', '--make-temp'],
{},
'0.18.0',
{
run: async () => 7,
exit: (code) => {
exits.push(code);
},
},
);
assert.equal(handled, true);
assert.deepEqual(exits, [7]);
});
test('handleSyncCliAtEntry ignores normal GUI startup', async () => {
const handled = await handleSyncCliAtEntry(['SubMiner', '--start'], {}, '0.18.0', {
run: async () => {
throw new Error('should not run');
},
exit: () => {
throw new Error('should not exit');
},
});
assert.equal(handled, false);
});
+140
View File
@@ -0,0 +1,140 @@
import fs from 'node:fs';
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
import { canConnectSocket } from '../shared/socket-probe';
import { getDefaultConfigDir } from '../shared/setup-state';
import {
extractSyncCliTokens,
parseSyncCliTokens,
syncCliUsage,
} from '../core/services/stats-sync/cli-args';
import { resolveImmersionDbPath } from '../core/services/stats-sync/db-path';
import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
import { mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import {
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
} from '../core/services/stats-sync/ssh';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
type SyncFlowDeps,
} from '../core/services/stats-sync/sync-flow';
import {
recordSyncResult,
readSyncHostsState,
writeSyncHostsState,
getSyncHostsPath,
} from '../shared/sync/sync-hosts-store';
export function shouldHandleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
return extractSyncCliTokens(argv) !== null;
}
function recordHostSyncResultToDisk(
host: string,
status: 'success' | 'error',
detail: string | null,
): void {
try {
const filePath = getSyncHostsPath(getDefaultConfigDir());
const state = recordSyncResult(readSyncHostsState(filePath), host, {
atMs: Date.now(),
status,
detail,
});
writeSyncHostsState(filePath, state);
} catch {
// best effort: bookkeeping must never fail the sync itself
}
}
function buildSyncCliDeps(): SyncFlowDeps {
const deps: SyncFlowDeps = {
createDbSnapshot,
mergeSnapshotIntoDb,
findLiveStatsDaemonPid,
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
canConnectUnixSocket: canConnectSocket,
realpathSync: (candidate) => fs.realpathSync(candidate),
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
consoleLog: (message) => console.log(message),
writeStdout: (text) => process.stdout.write(text),
ensureTrackerQuiescent: async (context, dbPath) =>
ensureTrackerQuiescentFlow(context, dbPath, deps),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
resolveDefaultDbPath: resolveImmersionDbPath,
};
return deps;
}
/**
* Headless sync entry for the Electron app: answers the same launcher-style
* `sync ...` argv as `subminer sync`, so a machine only needs the app
* installed to participate in stats sync (locally or as an SSH remote). Runs
* before any window/display initialization and never touches Electron APIs.
*/
export async function runSyncCliFromProcess(
argv: readonly string[],
appVersion: string,
): Promise<number> {
const tokens = extractSyncCliTokens(argv);
const parsed = parseSyncCliTokens(tokens ?? []);
if (parsed.kind === 'help') {
console.log(syncCliUsage());
return 0;
}
if (parsed.kind === 'version') {
console.log(`SubMiner ${appVersion}`);
return 0;
}
if (parsed.kind === 'error') {
console.error(parsed.message);
console.error(`\n${syncCliUsage()}`);
return 2;
}
const context = {
args: parsed.args,
mpvSocketPath: getDefaultMpvSocketPath(process.platform),
};
try {
await runSyncFlow(context, buildSyncCliDeps());
return 0;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function handleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
appVersion: string,
deps: {
run: typeof runSyncCliFromProcess;
exit: (code: number) => void;
} = {
run: runSyncCliFromProcess,
exit: (code) => process.exit(code),
},
): Promise<boolean> {
if (!shouldHandleSyncCliAtEntry(argv, env)) return false;
const exitCode = await deps.run(argv, appVersion);
// This path runs before app readiness and must not call app.exit(): on Linux
// that can throw and make the entrypoint fall back to full GUI startup.
deps.exit(exitCode);
return true;
}
+49
View File
@@ -0,0 +1,49 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type {
SyncUiAPI,
SyncUiCheckResult,
SyncUiHostUpdateRequest,
SyncUiProgressPayload,
SyncUiRunRequest,
SyncUiSnapshot,
SyncUiStartResult,
} from './types/sync-ui';
const syncUiAPI: SyncUiAPI = {
getSnapshot: (): Promise<SyncUiSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot),
saveHost: (update: SyncUiHostUpdateRequest): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update),
removeHost: (host: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host),
setAutoSyncInterval: (minutes: number): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSetAutoSyncInterval, minutes),
runSync: (request: SyncUiRunRequest): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRunSync, request),
cancelRun: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCancelRun),
checkHost: (host: string): Promise<SyncUiCheckResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCheckHost, host),
createSnapshot: (): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiCreateSnapshot),
mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiMergeSnapshotFile, path, force === true),
deleteSnapshot: (path: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiDeleteSnapshot, path),
revealSnapshot: (path: string): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRevealSnapshot, path),
pickSnapshotFile: (): Promise<string | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiPickSnapshotFile),
onProgress: (listener: (payload: SyncUiProgressPayload) => void): (() => void) => {
const handler = (_event: unknown, payload: SyncUiProgressPayload): void => listener(payload);
ipcRenderer.on(IPC_CHANNELS.event.syncUiProgress, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiProgress, handler);
},
onStateChanged: (listener: () => void): (() => void) => {
const handler = (): void => listener();
ipcRenderer.on(IPC_CHANNELS.event.syncUiStateChanged, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.syncUiStateChanged, handler);
},
};
contextBridge.exposeInMainWorld('syncUiAPI', syncUiAPI);
+14
View File
@@ -111,6 +111,18 @@ export const IPC_CHANNELS = {
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
syncUiGetSnapshot: 'sync-ui:get-snapshot',
syncUiSaveHost: 'sync-ui:save-host',
syncUiRemoveHost: 'sync-ui:remove-host',
syncUiSetAutoSyncInterval: 'sync-ui:set-auto-sync-interval',
syncUiRunSync: 'sync-ui:run-sync',
syncUiCancelRun: 'sync-ui:cancel-run',
syncUiCheckHost: 'sync-ui:check-host',
syncUiCreateSnapshot: 'sync-ui:create-snapshot',
syncUiMergeSnapshotFile: 'sync-ui:merge-snapshot-file',
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
},
event: {
subtitleSet: 'subtitle:set',
@@ -142,6 +154,8 @@ export const IPC_CHANNELS = {
configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle',
syncUiProgress: 'sync-ui:progress',
syncUiStateChanged: 'sync-ui:state-changed',
},
} as const;

Some files were not shown because too many files have changed in this diff Show More