mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
Compare commits
17 Commits
main
...
a4c12165af
| Author | SHA1 | Date | |
|---|---|---|---|
|
a4c12165af
|
|||
|
a013a7ea55
|
|||
|
f8c10edce0
|
|||
|
c9f85473bb
|
|||
|
25cca8ce24
|
|||
|
08419fbc8e
|
|||
|
94260bab16
|
|||
|
7ed4d4f8e2
|
|||
|
cd046b310a
|
|||
|
ffa183b1a1
|
|||
|
04095eebf7
|
|||
|
93d4bbe9a5
|
|||
|
cff164183a
|
|||
|
ac72c23dab
|
|||
|
187437b681
|
|||
|
97aaf44b3c
|
|||
|
0a3f76c0a8
|
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, 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. When launched with `subminer sync --ui`, closing the window also exits the attached CLI process cleanly.
|
||||
- Added `subminer sync <host> --check` to test the SSH connection and remote launcher availability without syncing, and `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes).
|
||||
@@ -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**
|
||||
|
||||
@@ -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 — so 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.
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
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):
|
||||
|
||||
@@ -106,6 +112,21 @@ subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the lo
|
||||
|
||||
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.
|
||||
|
||||
### Sync window
|
||||
|
||||
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine:
|
||||
|
||||
- **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 a merge summary (sessions, words, kanji, rollups) when a run finishes. Runs can be cancelled, and guard failures offer a one-click `--force` retry.
|
||||
- **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) whenever no mpv session or stats server is using the database; results surface as overlay notifications. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
@@ -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
@@ -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. `--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.
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
launchAppBackgroundDetached,
|
||||
launchTexthookerOnly,
|
||||
runAppCommandInteractive,
|
||||
runAppCommandWithInherit,
|
||||
} from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type AppCommandDeps = {
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||
runAppCommandInteractive: (appPath: string, appArgs: string[]) => void;
|
||||
launchAppBackgroundDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
@@ -15,6 +17,7 @@ type AppCommandDeps = {
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
runAppCommandWithInherit,
|
||||
runAppCommandInteractive,
|
||||
launchAppBackgroundDetached,
|
||||
};
|
||||
|
||||
@@ -30,6 +33,10 @@ export function runAppPassthroughCommand(
|
||||
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (args.syncUi) {
|
||||
deps.runAppCommandInteractive(appPath, ['--sync-window']);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
runAppCommandInteractive: () => calls.push('interactive'),
|
||||
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');
|
||||
},
|
||||
runAppCommandInteractive: () => calls.push('interactive'),
|
||||
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);
|
||||
},
|
||||
runAppCommandInteractive: () => detached.push('interactive'),
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
@@ -255,6 +258,21 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
assert.deepEqual(detached, []);
|
||||
});
|
||||
|
||||
test('sync UI command attaches the app directly to the terminal', () => {
|
||||
const context = createContext();
|
||||
context.args.syncUi = true;
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
runAppCommandWithInherit: () => calls.push('piped'),
|
||||
runAppCommandInteractive: (_appPath, appArgs) => calls.push(`direct:${appArgs.join(' ')}`),
|
||||
launchAppBackgroundDetached: () => calls.push('detached'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['direct:--sync-window']);
|
||||
});
|
||||
|
||||
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||
const context = createContext();
|
||||
context.args.mpvStatus = true;
|
||||
|
||||
@@ -44,6 +44,11 @@ function createContext(): LauncherCommandContext {
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { buildSyncCliArgv, 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,
|
||||
@@ -19,6 +18,10 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
logLevel: 'warn',
|
||||
...overrides,
|
||||
} as Args,
|
||||
@@ -26,232 +29,120 @@ 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: '' };
|
||||
function makeArgs(overrides: Partial<Parameters<typeof buildSyncCliArgv>[0]>) {
|
||||
return {
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both' as const,
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
logLevel: 'warn' as const,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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({ syncHost: 'media-box', syncJson: true }), 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.equal(await runSyncCommand(makeContext({ sync: false }), deps), false);
|
||||
assert.equal(spawned.length, 1);
|
||||
});
|
||||
|
||||
test('buildSyncCliArgv forwards every sync option', () => {
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(
|
||||
makeArgs({
|
||||
syncHost: 'media-box',
|
||||
syncDirection: 'pull',
|
||||
syncRemoteCmd: '/opt/SubMiner.AppImage',
|
||||
syncDbPath: '/tmp/db.sqlite',
|
||||
syncForce: true,
|
||||
syncJson: true,
|
||||
logLevel: 'debug',
|
||||
}),
|
||||
),
|
||||
[
|
||||
'--sync-cli',
|
||||
'sync',
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
);
|
||||
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.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncSnapshotPath: '/tmp/out.sqlite' })),
|
||||
['--sync-cli', 'sync', '--snapshot', '/tmp/out.sqlite', '--log-level', 'warn'],
|
||||
);
|
||||
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.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncHost: 'media-box', syncCheck: true })),
|
||||
['--sync-cli', 'sync', 'media-box', '--check', '--log-level', 'warn'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncMakeTemp: true })),
|
||||
['--sync-cli', 'sync', '--make-temp', '--log-level', 'warn'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncRemoveTempPath: '/tmp/subminer-sync-x' })),
|
||||
['--sync-cli', 'sync', '--remove-temp', '/tmp/subminer-sync-x', '--log-level', 'warn'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncMergePath: '/tmp/in.sqlite', syncForce: true })),
|
||||
['--sync-cli', 'sync', '--merge', '/tmp/in.sqlite', '--force', '--log-level', 'warn'],
|
||||
);
|
||||
});
|
||||
|
||||
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
|
||||
const calls: string[] = [];
|
||||
let localTmpDir = '';
|
||||
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
||||
calls.push(`snapshot:${outPath}`);
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
runAppCommand: () => {
|
||||
throw new Error('should not spawn');
|
||||
},
|
||||
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');
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
|
||||
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/,
|
||||
() => runSyncCommand(makeContext({ syncHost: 'media-box' }, null), deps),
|
||||
/SubMiner app binary not found \(sync runs inside the app\)/,
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
}),
|
||||
makeDirectionDeps(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 runSyncCommand(
|
||||
makeContext({
|
||||
syncDbPath: '/tmp/local.sqlite',
|
||||
syncHost: 'media-box',
|
||||
syncDirection: 'pull',
|
||||
}),
|
||||
makeDirectionDeps(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 ')));
|
||||
});
|
||||
|
||||
@@ -1,253 +1,73 @@
|
||||
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 { fail } from '../log.js';
|
||||
import { runAppCommandInteractive } from '../mpv.js';
|
||||
import type { Args } from '../types.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
|
||||
}
|
||||
}
|
||||
}
|
||||
type SyncArgs = Pick<
|
||||
Args,
|
||||
| 'syncHost'
|
||||
| 'syncSnapshotPath'
|
||||
| 'syncMergePath'
|
||||
| 'syncDirection'
|
||||
| 'syncRemoteCmd'
|
||||
| 'syncDbPath'
|
||||
| 'syncForce'
|
||||
| 'syncJson'
|
||||
| 'syncCheck'
|
||||
| 'syncMakeTemp'
|
||||
| 'syncRemoveTempPath'
|
||||
| 'logLevel'
|
||||
>;
|
||||
|
||||
/** Rebuild the app's --sync-cli argv from the launcher's parsed sync args. */
|
||||
export function buildSyncCliArgv(args: SyncArgs): string[] {
|
||||
const argv = ['--sync-cli', 'sync'];
|
||||
if (args.syncHost) argv.push(args.syncHost);
|
||||
if (args.syncSnapshotPath) argv.push('--snapshot', args.syncSnapshotPath);
|
||||
if (args.syncMergePath) argv.push('--merge', args.syncMergePath);
|
||||
if (args.syncMakeTemp) argv.push('--make-temp');
|
||||
if (args.syncRemoveTempPath) argv.push('--remove-temp', args.syncRemoveTempPath);
|
||||
if (args.syncDirection === 'push') argv.push('--push');
|
||||
if (args.syncDirection === 'pull') argv.push('--pull');
|
||||
if (args.syncCheck) argv.push('--check');
|
||||
if (args.syncRemoteCmd) argv.push('--remote-cmd', args.syncRemoteCmd);
|
||||
if (args.syncDbPath) argv.push('--db', args.syncDbPath);
|
||||
if (args.syncForce) argv.push('--force');
|
||||
if (args.syncJson) argv.push('--json');
|
||||
argv.push('--log-level', args.logLevel);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* `subminer sync` is a thin proxy: the sync engine only executes inside the
|
||||
* SubMiner app (--sync-cli mode, libsql), so the launcher and the app cannot
|
||||
* drift apart. The launcher contributes its parser/help and app discovery;
|
||||
* 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, buildSyncCliArgv(context.args));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,13 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -198,7 +204,13 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -246,7 +258,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -292,7 +310,13 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
|
||||
@@ -207,6 +207,11 @@ export function createDefaultArgs(
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncUi: false,
|
||||
logLevel: loggingConfig.level ?? 'warn',
|
||||
logRotation: loggingConfig.rotation ?? 7,
|
||||
passwordStore: '',
|
||||
@@ -281,8 +286,16 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
|
||||
parsed.syncDbPath = invocations.syncDbPath ?? '';
|
||||
parsed.syncForce = invocations.syncForce;
|
||||
parsed.syncJson = invocations.syncJson;
|
||||
parsed.syncCheck = invocations.syncCheck;
|
||||
parsed.syncMakeTemp = invocations.syncMakeTemp;
|
||||
parsed.syncRemoveTempPath = invocations.syncRemoveTempPath ?? '';
|
||||
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) {
|
||||
|
||||
@@ -61,3 +61,33 @@ test('parseCliPrograms rejects conflicting or hostless one-way sync directions',
|
||||
/--push and --pull require a host/,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseCliPrograms captures sync --json and --check flags', () => {
|
||||
const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer');
|
||||
assert.equal(json.invocations.syncJson, true);
|
||||
assert.equal(json.invocations.syncCheck, false);
|
||||
|
||||
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
|
||||
assert.equal(check.invocations.syncCheck, true);
|
||||
assert.equal(check.invocations.syncJson, true);
|
||||
assert.equal(check.invocations.syncHost, 'media-box');
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects invalid sync --check combinations', () => {
|
||||
assert.throws(() => parseCliPrograms(['sync', '--check'], 'subminer'), /--check requires a host/);
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'),
|
||||
/--check cannot be combined/,
|
||||
);
|
||||
});
|
||||
|
||||
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', '--ui'], 'subminer'),
|
||||
/--ui cannot be combined/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,13 @@ export interface CliInvocations {
|
||||
syncRemoteCmd: string | null;
|
||||
syncDbPath: string | null;
|
||||
syncForce: boolean;
|
||||
syncJson: boolean;
|
||||
syncCheck: boolean;
|
||||
syncMakeTemp: boolean;
|
||||
syncRemoveTempPath: string | null;
|
||||
syncLogLevel: string | null;
|
||||
syncUiTriggered: boolean;
|
||||
syncUiLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
@@ -75,7 +81,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 {
|
||||
@@ -178,7 +188,13 @@ export function parseCliPrograms(
|
||||
let syncRemoteCmd: string | null = null;
|
||||
let syncDbPath: string | null = null;
|
||||
let syncForce = false;
|
||||
let syncJson = false;
|
||||
let syncCheck = false;
|
||||
let syncMakeTemp = false;
|
||||
let syncRemoveTempPath: string | null = null;
|
||||
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 +335,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,18 +347,57 @@ export function parseCliPrograms(
|
||||
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
|
||||
const push = options.push === true;
|
||||
const pull = options.pull === true;
|
||||
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.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;
|
||||
}
|
||||
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 (check && !host) {
|
||||
throw new Error('Sync --check requires a host.');
|
||||
}
|
||||
if (check && (push || pull || snapshot || merge)) {
|
||||
throw new Error(
|
||||
'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
|
||||
);
|
||||
}
|
||||
if ((makeTemp || removeTemp) && (push || pull || check)) {
|
||||
throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.');
|
||||
}
|
||||
const modes = [
|
||||
Boolean(host),
|
||||
Boolean(snapshot),
|
||||
Boolean(merge),
|
||||
makeTemp,
|
||||
Boolean(removeTemp),
|
||||
].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.');
|
||||
throw new Error('Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.');
|
||||
}
|
||||
syncTriggered = true;
|
||||
syncHost = host || null;
|
||||
@@ -348,6 +408,10 @@ export function parseCliPrograms(
|
||||
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
|
||||
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
|
||||
syncForce = options.force === true;
|
||||
syncJson = options.json === true;
|
||||
syncCheck = check;
|
||||
syncMakeTemp = makeTemp;
|
||||
syncRemoveTempPath = removeTemp || null;
|
||||
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
@@ -470,7 +534,13 @@ export function parseCliPrograms(
|
||||
syncRemoteCmd,
|
||||
syncDbPath,
|
||||
syncForce,
|
||||
syncJson,
|
||||
syncCheck,
|
||||
syncMakeTemp,
|
||||
syncRemoveTempPath,
|
||||
syncLogLevel,
|
||||
syncUiTriggered,
|
||||
syncUiLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
doctorRefreshKnownWords,
|
||||
|
||||
+6
-43
@@ -6,6 +6,12 @@ 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 {
|
||||
isReadonlyWalRetryError,
|
||||
withReadonlyWalRetry,
|
||||
} from '../src/core/services/stats-sync/wal-retry.js';
|
||||
|
||||
export { isReadonlyWalRetryError, withReadonlyWalRetry };
|
||||
|
||||
export function resolveImmersionDbPath(): string {
|
||||
const root = readLauncherMainConfigObject();
|
||||
@@ -43,49 +49,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),
|
||||
|
||||
@@ -38,6 +38,11 @@ function createArgs(): Args {
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -579,6 +579,11 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncUi: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
+24
-2
@@ -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 {
|
||||
@@ -1444,16 +1445,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,17 @@ 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';
|
||||
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
|
||||
import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js';
|
||||
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js';
|
||||
|
||||
// The engine only executes inside the app (libsql driver) in production, so
|
||||
// these merge tests run through that same binding; bun:sqlite is used only to
|
||||
// build fixtures and inspect results.
|
||||
const createDbSnapshot = (dbPath: string, outPath: string) =>
|
||||
createDbSnapshotWith(openLibsqlSyncDb, dbPath, outPath);
|
||||
const mergeSnapshotIntoDb = (localDbPath: string, snapshotPath: string) =>
|
||||
mergeSnapshotIntoDbWith(openLibsqlSyncDb, localDbPath, snapshotPath);
|
||||
import {
|
||||
createImmersionDbFixture,
|
||||
insertFixtureSession,
|
||||
@@ -596,3 +606,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 });
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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>.`,
|
||||
);
|
||||
}
|
||||
@@ -121,6 +121,11 @@ export interface Args {
|
||||
syncRemoteCmd: string;
|
||||
syncDbPath: string;
|
||||
syncForce: boolean;
|
||||
syncJson: boolean;
|
||||
syncCheck: boolean;
|
||||
syncMakeTemp: boolean;
|
||||
syncRemoveTempPath: string;
|
||||
syncUi: boolean;
|
||||
logLevel: LogLevel;
|
||||
logRotation: LogRotation;
|
||||
passwordStore: string;
|
||||
|
||||
+2
-1
@@ -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",
|
||||
"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",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **Sentence Audio Normalization**
|
||||
- Generated sentence audio is now normalized to -23 LUFS by default, giving mined clips consistent volume across shows.
|
||||
- Clips captured from playback can also mirror mpv's software volume curve, with a limiter to prevent clipping when boosted.
|
||||
- Both behaviors are controlled independently and can be turned off in the Anki Connect media settings.
|
||||
|
||||
- **Watch History Browser**
|
||||
- Added `subminer -H` / `--history` to browse local watch history, replay the last episode, continue to the next one, or jump to any past episode.
|
||||
- Works with fzf or rofi; the rofi picker shows AniList cover art already stored in the stats database.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Known-Word Highlighting Accuracy**
|
||||
- Highlighting now compares subtitle and Anki-card readings, so it no longer confuses homographs like 床/とこ vs 床/ゆか or unrelated kanji that happen to share a reading.
|
||||
- Standalone suffix words such as さん or れる are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are already treated (configurable if you'd rather keep them annotated).
|
||||
- Cards without readings still fall back to word-only matching; the highlighting cache upgrades automatically with no action needed.
|
||||
|
||||
- **Stats Trend Charts**
|
||||
- Overhauled trend charts with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and sortable multi-column tooltips.
|
||||
|
||||
- **New App Icon**
|
||||
- Replaced the SubMiner icon with new pixel-art submarine artwork contributed by an anonymous community member, now used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||
|
||||
- **Launcher Preview Layout**
|
||||
- fzf previews now sit below the launcher menu instead of beside it, giving long titles and metadata more horizontal room.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Character Name Recognition**
|
||||
- Character dictionaries now correctly split unspaced AniList native names and validate readings, so overlay portraits, highlights, and hover lookups work reliably even without MeCab installed.
|
||||
- Name matches survive punctuation and unmatched text and no longer get overridden by generic dictionary matches or wrongly split from longer words like 空気.
|
||||
- Existing installs regenerate automatically and upgrade to exact splits once MeCab is available — no action needed.
|
||||
|
||||
- **Frequency & JLPT Highlighting Coverage**
|
||||
- Content adverbs (確かに, やはり) and kanji nouns MeCab tags as non-independent (日, 点, 以外) are now correctly included in frequency/JLPT highlighting and vocabulary stats.
|
||||
- Lexicalized kana expressions like かといって keep their frequency annotations, while interjections, pronouns, and pure grammar fragments are still filtered out as noise.
|
||||
|
||||
- **Unparsed Text Hover Lookup**
|
||||
- Subtitle text Yomitan can't fully parse — truncated inflections like とこ戻ろ… or elongation runs like ぅ~ — is hoverable again for dictionary lookup.
|
||||
- These runs stay excluded from frequency/JLPT highlighting and vocabulary stats, same as bracketed captions and punctuation-only text.
|
||||
|
||||
- **Karaoke-Style Secondary Subtitles**
|
||||
- Secondary subtitles no longer flood the screen with dozens of one-syllable lines during karaoke-style openings and endings.
|
||||
- Repeated events now collapse into a single line, and the secondary subtitle area stays capped to a strip at the top.
|
||||
|
||||
- **Kiku Field Grouping Reliability**
|
||||
- The manual field-grouping dialog now stays above fullscreen mpv on Hyprland/Wayland and keeps working across repeated attempts.
|
||||
- Abandoned grouping windows close automatically after a timeout or failure, and each attempt now reports a clear success or error, including when the original card can no longer be loaded.
|
||||
|
||||
- **Background Stats Server Startup**
|
||||
- Background `subminer app` launches now start the stats server automatically when enabled, and skip startup if one is already running.
|
||||
|
||||
- **AniList Cover Art Timing**
|
||||
- Stats now fetches the best-match AniList cover as soon as a new series starts playing, so artwork appears in the timeline immediately instead of only after visiting the series page.
|
||||
- Existing series missing art get backfilled automatically on the next Stats page visit.
|
||||
|
||||
- **YouTube Direct Stream Playback**
|
||||
- Fixed direct YouTube stream extraction so mpv's EDL stream URLs are parsed correctly, preventing corrupted signed video URLs and the resulting ffmpeg 403 errors.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
|
||||
- Normalize generated Anki audio by default by @ksyasuda in #135
|
||||
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
|
||||
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
|
||||
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
|
||||
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
|
||||
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
|
||||
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
|
||||
- fix(stats): start stats server on background app launch by @ksyasuda in #144
|
||||
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
|
||||
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
|
||||
- fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings by @ksyasuda in #147
|
||||
- fix(stats): fetch cover art eagerly at session start instead of on series page visit by @ksyasuda in #148
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CliArgs {
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -122,6 +123,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -269,6 +271,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;
|
||||
@@ -541,6 +544,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -618,6 +622,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -688,6 +693,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -744,6 +750,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -139,6 +140,58 @@ 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;
|
||||
await Promise.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,
|
||||
@@ -251,7 +304,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;
|
||||
};
|
||||
@@ -273,7 +326,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']);
|
||||
});
|
||||
|
||||
@@ -352,3 +405,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']);
|
||||
});
|
||||
|
||||
@@ -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,44 @@ 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;
|
||||
deps.quitApp();
|
||||
});
|
||||
});
|
||||
|
||||
deps.onActivate(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -137,6 +138,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openConfigSettingsWindow: () => {
|
||||
calls.push('openConfigSettingsWindow');
|
||||
},
|
||||
openSyncUiWindow: () => {
|
||||
calls.push('openSyncUiWindow');
|
||||
},
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
@@ -659,6 +663,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
openFirstRunSetup: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -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,81 @@
|
||||
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 mirrors launcher sync validation', () => {
|
||||
assert.equal(parseSyncCliTokens([]).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).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');
|
||||
});
|
||||
@@ -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. Mirrors the launcher's `subminer sync` validation 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: {
|
||||
sync: true,
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Minimal SQLite driver surface the stats-sync engine runs against. The
|
||||
// launcher binds it to bun:sqlite; the Electron app binds it to libsql. Both
|
||||
// implementations must cache prepared statements per SQL string in query():
|
||||
// the merge runs one insert per copied row, so re-preparing would dominate
|
||||
// merge time.
|
||||
|
||||
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 SyncDbOpenOptions {
|
||||
readonly?: boolean;
|
||||
readwrite?: boolean;
|
||||
create?: boolean;
|
||||
}
|
||||
|
||||
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 OpenSyncDb = (dbPath: string, options: SyncDbOpenOptions) => SyncDb;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Database } from '../immersion-tracker/sqlite';
|
||||
import type { OpenSyncDb, SyncDb, SyncDbRunResult, SyncDbStatement } from './driver';
|
||||
|
||||
interface LibsqlStatement {
|
||||
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
|
||||
get(...params: unknown[]): unknown;
|
||||
all(...params: unknown[]): unknown[];
|
||||
}
|
||||
|
||||
interface LibsqlDatabase {
|
||||
prepare(sql: string): LibsqlStatement;
|
||||
exec(sql: string): unknown;
|
||||
close(): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* libsql (better-sqlite3 API) binding of the SyncDb driver for the Electron
|
||||
* app. prepare() is not cached by libsql, so query() keeps a per-connection
|
||||
* statement cache — the merge prepares a handful of statements and runs them
|
||||
* once per copied row.
|
||||
*/
|
||||
export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): 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 {
|
||||
const cached = statements.get(sql);
|
||||
if (cached) return cached;
|
||||
const statement = db.prepare(sql);
|
||||
const wrapped: SyncDbStatement = {
|
||||
run(...params: unknown[]): SyncDbRunResult {
|
||||
return statement.run(...params);
|
||||
},
|
||||
get(...params: unknown[]): unknown {
|
||||
return statement.get(...params);
|
||||
},
|
||||
all(...params: unknown[]): unknown[] {
|
||||
return statement.all(...params);
|
||||
},
|
||||
};
|
||||
statements.set(sql, wrapped);
|
||||
return wrapped;
|
||||
},
|
||||
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 './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 './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 './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 type { OpenSyncDb, SyncDb } from './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
|
||||
@@ -26,7 +25,11 @@ export { createDbSnapshot, findLiveStatsDaemonPid } from './sync-shared.js';
|
||||
* window is preserved on both sides. Idempotent: re-merging the same
|
||||
* snapshot is a no-op.
|
||||
*/
|
||||
export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string): SyncMergeSummary {
|
||||
export function mergeSnapshotIntoDb(
|
||||
open: OpenSyncDb,
|
||||
localDbPath: string,
|
||||
snapshotPath: string,
|
||||
): SyncMergeSummary {
|
||||
if (!fs.existsSync(localDbPath)) {
|
||||
throw new Error(`Local stats database not found: ${localDbPath}`);
|
||||
}
|
||||
@@ -34,10 +37,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 = open(snapshotPath, { readonly: true });
|
||||
let local: SyncDb;
|
||||
try {
|
||||
local = new Database(localDbPath, { readwrite: true, create: false });
|
||||
local = open(localDbPath, { readwrite: true, create: false });
|
||||
} catch (error) {
|
||||
remote.close();
|
||||
throw error;
|
||||
@@ -47,9 +50,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 +72,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 { resolveConfigDir } from '../../../config/path-resolution';
|
||||
import { withReadonlyWalRetry } from './wal-retry';
|
||||
import { selectOne, type OpenSyncDb, type SyncDb } from './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 {
|
||||
export 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,29 +66,29 @@ 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);
|
||||
}
|
||||
|
||||
export function createDbSnapshot(dbPath: string, outPath: string): void {
|
||||
export function createDbSnapshot(open: OpenSyncDb, dbPath: string, outPath: string): void {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error(`Stats database not found: ${dbPath}`);
|
||||
}
|
||||
fs.rmSync(outPath, { force: true });
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
withReadonlyWalRetry(dbPath, (options) => {
|
||||
const db = new Database(dbPath, options);
|
||||
const db = open(dbPath, options);
|
||||
try {
|
||||
assertMergeableSchema(db, 'Local');
|
||||
db.prepare('VACUUM INTO ?').run(outPath);
|
||||
db.query('VACUUM INTO ?').run(outPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -140,7 +122,9 @@ function statsDaemonStateCandidates(dbPath: string): string[] {
|
||||
});
|
||||
candidates.add(path.join(configDir, '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 +133,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)) {
|
||||
@@ -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'`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
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 {
|
||||
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: typeof runSsh = runSsh,
|
||||
): 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, so a remote machine only needs the app installed;
|
||||
// the command-line launcher is one candidate, not a requirement.
|
||||
const APP_SYNC_CLI_FLAG = '--sync-cli';
|
||||
|
||||
interface RemoteCandidate {
|
||||
invocation: string;
|
||||
}
|
||||
|
||||
function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] {
|
||||
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.
|
||||
{ invocation: 'subminer' },
|
||||
{ invocation: launcherShim },
|
||||
// The app binary itself in sync-CLI mode (default NSIS install dir).
|
||||
{ invocation: `${appInstall} ${APP_SYNC_CLI_FLAG}` },
|
||||
{ invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` },
|
||||
];
|
||||
}
|
||||
return [
|
||||
// Command-line launcher (bun script) on PATH or in its default install dir.
|
||||
{ invocation: 'subminer' },
|
||||
{ invocation: '~/.local/bin/subminer' },
|
||||
// The app binary itself in sync-CLI mode.
|
||||
{ invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` },
|
||||
{ invocation: `/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` },
|
||||
{ invocation: `~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${APP_SYNC_CLI_FLAG}` },
|
||||
];
|
||||
}
|
||||
|
||||
function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): RemoteCandidate[] {
|
||||
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: `${invocation} ${APP_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.invocation}` : candidate.invocation;
|
||||
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,480 @@
|
||||
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: {
|
||||
sync: true,
|
||||
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(),
|
||||
formatMergeSummary: () => 'summary',
|
||||
findLiveStatsDaemonPid: () => null,
|
||||
assertSafeSshHost: () => {},
|
||||
detectRemoteShellFlavor: () => 'posix',
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
runScp: () => {},
|
||||
runSsh: () => ok(),
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
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',
|
||||
resolvePath: (value) => value,
|
||||
...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}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
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>/,
|
||||
);
|
||||
|
||||
assert.equal(await runSyncFlow(makeContext({ sync: false }), deps), false);
|
||||
});
|
||||
|
||||
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 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 ok('remote summary text\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.ok(
|
||||
events.some(
|
||||
(event) => event.type === 'remote-output' && event.text.includes('remote summary text'),
|
||||
),
|
||||
);
|
||||
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 deps = makeDeps({
|
||||
consoleLog: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
runSsh: (_host, command) => {
|
||||
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);
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { quoteForRemoteShell } from './ssh';
|
||||
import type { RemoteRunResult, RemoteShellFlavor } from './ssh';
|
||||
import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events';
|
||||
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
|
||||
|
||||
export interface SyncFlowArgs {
|
||||
sync: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the sync flow touches outside plain computation. The launcher
|
||||
* binds these to bun:sqlite-backed engine functions and its own logger; the
|
||||
* app's --sync-cli mode binds libsql and console output. Tests stub freely.
|
||||
*/
|
||||
export interface SyncFlowDeps {
|
||||
createDbSnapshot: (dbPath: string, outPath: string) => void;
|
||||
mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary;
|
||||
formatMergeSummary: (summary: SyncMergeSummary) => string;
|
||||
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,
|
||||
) => string;
|
||||
runScp: (from: string, to: string) => void;
|
||||
runSsh: (host: string, remoteCommand: string) => RemoteRunResult;
|
||||
fail: (message: string) => never;
|
||||
log: (level: string, configuredLevel: string, message: string) => void;
|
||||
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;
|
||||
resolvePath: (value: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — that is what makes Windows remotes work.
|
||||
*/
|
||||
export const SYNC_TEMP_PREFIX = 'subminer-sync-';
|
||||
|
||||
export 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. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function runMakeTempMode(deps: SyncFlowDeps): void {
|
||||
deps.consoleLog(makeSyncTempDir(deps.mkdtempSync));
|
||||
}
|
||||
|
||||
export function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void {
|
||||
const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath);
|
||||
deps.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string {
|
||||
const override = context.args.syncDbPath.trim();
|
||||
return override ? deps.resolvePath(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) {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
|
||||
const writeLine = deps.consoleLog;
|
||||
return {
|
||||
...deps,
|
||||
consoleLog: () => {},
|
||||
writeStdout: () => true,
|
||||
emitEvent: (event) => writeLine(JSON.stringify(event)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSnapshotMode(
|
||||
context: SyncFlowContext,
|
||||
dbPath: string,
|
||||
deps: SyncFlowDeps,
|
||||
): Promise<void> {
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const outPath = deps.resolvePath(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);
|
||||
}
|
||||
|
||||
export async function runMergeMode(
|
||||
context: SyncFlowContext,
|
||||
dbPath: string,
|
||||
deps: SyncFlowDeps,
|
||||
): Promise<void> {
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const snapshotPath = deps.resolvePath(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(deps.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 probe = deps.runSsh(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, deps.runSsh);
|
||||
if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`);
|
||||
remoteCommand = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
|
||||
const version = deps.runSsh(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 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);
|
||||
deps.log('debug', args.logLevel, `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(deps.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}`,
|
||||
);
|
||||
deps.writeStdout(mergeRun.stdout);
|
||||
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<boolean> {
|
||||
let deps = inputDeps;
|
||||
const { args } = context;
|
||||
if (!args.sync) return false;
|
||||
if (args.syncJson) deps = withJsonEvents(deps);
|
||||
|
||||
try {
|
||||
if (args.syncMakeTemp) {
|
||||
runMakeTempMode(deps);
|
||||
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
|
||||
return true;
|
||||
}
|
||||
if (args.syncRemoveTempPath) {
|
||||
runRemoveTempMode(context, deps);
|
||||
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
|
||||
return true;
|
||||
}
|
||||
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 {
|
||||
deps.fail('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 });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import type { SyncDbOpenOptions } from './driver';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,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 { runSyncCliFromProcess, shouldHandleSyncCliAtEntry } from './main/sync-cli';
|
||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||
import {
|
||||
@@ -221,6 +222,14 @@ 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 (shouldHandleSyncCliAtEntry(process.argv, process.env)) {
|
||||
const exitCode = await runSyncCliFromProcess(process.argv, app.getVersion());
|
||||
app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeHelpEnv(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
|
||||
+78
@@ -529,8 +529,13 @@ 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 { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
@@ -902,6 +907,7 @@ const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
|
||||
return [
|
||||
appState.configSettingsWindow,
|
||||
appState.syncUiWindow,
|
||||
appState.yomitanSettingsWindow,
|
||||
appState.anilistSetupWindow,
|
||||
appState.jellyfinSetupWindow,
|
||||
@@ -2227,6 +2233,72 @@ 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),
|
||||
log: (message) => logger.error(message),
|
||||
});
|
||||
const openSyncUiWindow = () => openSyncUiWindowHandler();
|
||||
|
||||
const syncUiRuntime = createSyncUiRuntime({
|
||||
ipcMain,
|
||||
hostsFilePath: path.join(USER_DATA_PATH, 'sync-hosts.json'),
|
||||
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();
|
||||
|
||||
// Auto-sync only runs while no mpv session or app-owned stats server could be
|
||||
// writing the immersion database; the launcher's quiescence guard covers
|
||||
// writers this process does not know about.
|
||||
const syncAutoScheduler = createSyncAutoScheduler({
|
||||
readState: () => syncUiRuntime.readState(),
|
||||
isRunning: () => syncUiRuntime.isRunning(),
|
||||
canAutoSync: () => appState.mpvClient === null && appState.statsServer === null,
|
||||
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,
|
||||
@@ -3879,6 +3951,10 @@ const {
|
||||
annotationSubtitleWsService.stop();
|
||||
},
|
||||
stopTexthookerService: () => texthookerService.stop(),
|
||||
stopSyncAutoScheduler: async () => {
|
||||
syncAutoScheduler.stop();
|
||||
await syncUiRuntime.shutdown();
|
||||
},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
|
||||
@@ -5983,6 +6059,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindow(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
@@ -6231,6 +6308,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindow(),
|
||||
exportLogs: () => {
|
||||
void exportLogsFromTray();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'];
|
||||
@@ -413,6 +414,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: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -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,140 @@
|
||||
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,
|
||||
canAutoSync: () => true,
|
||||
triggerHostSync: (host, direction) => triggered.push({ host, direction }),
|
||||
nowMs: () => 100 * 60_000,
|
||||
});
|
||||
|
||||
scheduler.tick();
|
||||
assert.deepEqual(triggered, [{ host: 'auto-box', direction: 'pull' }]);
|
||||
});
|
||||
|
||||
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,
|
||||
canAutoSync: () => true,
|
||||
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 or auto sync is blocked', () => {
|
||||
const state = makeState();
|
||||
const triggered: string[] = [];
|
||||
const running = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => true,
|
||||
canAutoSync: () => true,
|
||||
triggerHostSync: (host) => triggered.push(host),
|
||||
nowMs: () => 100 * 60_000,
|
||||
});
|
||||
running.tick();
|
||||
|
||||
const blocked = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
isRunning: () => false,
|
||||
canAutoSync: () => false,
|
||||
triggerHostSync: (host) => triggered.push(host),
|
||||
nowMs: () => 100 * 60_000,
|
||||
});
|
||||
blocked.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,
|
||||
canAutoSync: () => true,
|
||||
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,
|
||||
canAutoSync: () => true,
|
||||
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,
|
||||
canAutoSync: () => true,
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { SyncDirection, SyncHostsState } from '../../shared/sync/sync-hosts-store';
|
||||
|
||||
export interface SyncAutoSchedulerDeps {
|
||||
readState: () => SyncHostsState;
|
||||
isRunning: () => boolean;
|
||||
canAutoSync: () => 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() || !deps.canAutoSync()) 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,137 @@
|
||||
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, 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 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 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,
|
||||
});
|
||||
handle.cancel();
|
||||
assert.equal(children[0]!.killed, true);
|
||||
|
||||
const result = await handle.done;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /cancel/i);
|
||||
});
|
||||
|
||||
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.command, ['/opt/SubMiner/subminer-app', '--sync-cli']);
|
||||
assert.equal(packaged.error, null);
|
||||
|
||||
const dev = resolveSyncLauncherCommand({
|
||||
execPath: '/usr/bin/electron',
|
||||
appPath: '/home/u/SubMiner',
|
||||
});
|
||||
assert.deepEqual(dev.command, ['/usr/bin/electron', '/home/u/SubMiner', '--sync-cli']);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
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;
|
||||
|
||||
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: '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 interface SyncLauncherResolution {
|
||||
command: string[] | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
} = {},
|
||||
): SyncLauncherResolution {
|
||||
const execPath = deps.execPath ?? process.execPath;
|
||||
const appPath = deps.appPath ?? null;
|
||||
return {
|
||||
command: appPath ? [execPath, appPath, SYNC_CLI_FLAG] : [execPath, SYNC_CLI_FLAG],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function runSyncLauncher(options: {
|
||||
command: string[];
|
||||
args: string[];
|
||||
onEvent: (event: SyncProgressEvent) => void;
|
||||
onStderr?: (text: string) => void;
|
||||
spawn?: SyncLauncherSpawn;
|
||||
}): 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 = { ...process.env };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
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 cancelled = false;
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
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;
|
||||
options.onEvent(event);
|
||||
}
|
||||
newlineIndex = stdoutBuffer.indexOf('\n');
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderrTail = `${stderrTail}${text}`.slice(-4000);
|
||||
options.onStderr?.(text);
|
||||
});
|
||||
|
||||
const done = new Promise<SyncLauncherRunResult>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: SyncLauncherRunResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
child.on('error', (error) => {
|
||||
settle({ ok: false, error: error.message });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (cancelled) {
|
||||
settle({ ok: false, error: 'Sync cancelled.' });
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
// A sync child blocked on an ssh password prompt ignores SIGTERM, so escalate
|
||||
// to SIGKILL if it is still alive after a grace period. The timer is cleared
|
||||
// on close so a cancelled run cannot hold the process open.
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearKillTimer = (): void => {
|
||||
if (killTimer === null) return;
|
||||
clearTimeout(killTimer);
|
||||
killTimer = null;
|
||||
};
|
||||
child.on('close', clearKillTimer);
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// process may already be gone
|
||||
}
|
||||
killTimer = setTimeout(() => {
|
||||
killTimer = null;
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// process may already be gone
|
||||
}
|
||||
}, CANCEL_GRACE_MS);
|
||||
killTimer.unref?.();
|
||||
},
|
||||
done,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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[];
|
||||
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: () => ({ command: ['subminer'], error: null }),
|
||||
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,
|
||||
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 } = makeTestRig(root);
|
||||
const saved = (await invoke('sync-ui:save-host', {
|
||||
host: 'user@laptop',
|
||||
label: 'Laptop',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
})) as { hosts: Array<{ host: string; direction: string; autoSync: boolean }> };
|
||||
assert.equal(saved.hosts.length, 1);
|
||||
assert.equal(saved.hosts[0]!.direction, 'pull');
|
||||
assert.equal(saved.hosts[0]!.autoSync, true);
|
||||
|
||||
const removed = (await invoke('sync-ui:remove-host', 'user@laptop')) as {
|
||||
hosts: unknown[];
|
||||
};
|
||||
assert.equal(removed.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 spawns the launcher, 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',
|
||||
force: true,
|
||||
})) 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 } = 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));
|
||||
const remaining = (await invoke('sync-ui:delete-snapshot', inside)) as unknown[];
|
||||
assert.equal(remaining.length, 0);
|
||||
assert.equal(fs.existsSync(inside), false);
|
||||
assert.equal(fs.existsSync(outside), true);
|
||||
}));
|
||||
|
||||
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']);
|
||||
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);
|
||||
}));
|
||||
@@ -0,0 +1,377 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
isValidSyncHost,
|
||||
readSyncHostsState,
|
||||
removeSyncHost,
|
||||
upsertSyncHost,
|
||||
writeSyncHostsState,
|
||||
type SyncDirection,
|
||||
type SyncHostsState,
|
||||
} from '../../shared/sync/sync-hosts-store';
|
||||
import type { SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import type {
|
||||
SyncUiCheckResult,
|
||||
SyncUiHostUpdateRequest,
|
||||
SyncUiRunKind,
|
||||
SyncUiRunRequest,
|
||||
SyncUiRunState,
|
||||
SyncUiSnapshot,
|
||||
SyncUiSnapshotFile,
|
||||
SyncUiStartResult,
|
||||
} from '../../types/sync-ui';
|
||||
import type {
|
||||
SyncLauncherResolution,
|
||||
SyncLauncherRunHandle,
|
||||
SyncLauncherRunResult,
|
||||
} from './sync-launcher-client';
|
||||
import { runSyncLauncher } from './sync-launcher-client';
|
||||
|
||||
export 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: () => SyncLauncherResolution;
|
||||
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;
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
id: number;
|
||||
kind: SyncUiRunKind;
|
||||
host: string | null;
|
||||
handle: SyncLauncherRunHandle;
|
||||
resultSeen: boolean;
|
||||
completion?: Promise<void>;
|
||||
}
|
||||
|
||||
// Milliseconds are part of the stamp so two snapshots taken in the same second
|
||||
// do not land on the same path and silently overwrite each other.
|
||||
export function formatSnapshotName(nowMs: number): string {
|
||||
const iso = new Date(nowMs).toISOString();
|
||||
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
|
||||
return `immersion-${stamp}.sqlite`;
|
||||
}
|
||||
|
||||
export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
let runCounter = 0;
|
||||
let currentRun: ActiveRun | null = null;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function readState(): SyncHostsState {
|
||||
return readSyncHostsState(deps.hostsFilePath);
|
||||
}
|
||||
|
||||
function writeState(state: SyncHostsState): SyncHostsState {
|
||||
writeSyncHostsState(deps.hostsFilePath, state);
|
||||
broadcastStateChanged();
|
||||
return state;
|
||||
}
|
||||
|
||||
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 runState(): SyncUiRunState {
|
||||
return currentRun
|
||||
? { running: true, runId: currentRun.id, kind: currentRun.kind, host: currentRun.host }
|
||||
: { running: false, runId: null, kind: null, host: null };
|
||||
}
|
||||
|
||||
function getSnapshot(): SyncUiSnapshot {
|
||||
return {
|
||||
dbPath: deps.getDbPath(),
|
||||
hosts: readState(),
|
||||
snapshotsDir: deps.snapshotsDir,
|
||||
snapshots: listSnapshots(),
|
||||
run: runState(),
|
||||
};
|
||||
}
|
||||
|
||||
function startRun(
|
||||
kind: SyncUiRunKind,
|
||||
host: string | null,
|
||||
args: string[],
|
||||
options: { notify?: boolean } = {},
|
||||
): SyncUiStartResult {
|
||||
if (currentRun) {
|
||||
return {
|
||||
started: false,
|
||||
runId: null,
|
||||
reason: 'A sync operation is already running.',
|
||||
};
|
||||
}
|
||||
const resolution = deps.resolveLauncherCommand();
|
||||
if (!resolution.command) {
|
||||
return { started: false, runId: null, reason: resolution.error };
|
||||
}
|
||||
runCounter += 1;
|
||||
const runId = runCounter;
|
||||
const run: ActiveRun = {
|
||||
id: runId,
|
||||
kind,
|
||||
host,
|
||||
resultSeen: false,
|
||||
handle: deps.runLauncher({
|
||||
command: resolution.command,
|
||||
args,
|
||||
onEvent: (event: SyncProgressEvent) => {
|
||||
if (event.type === 'result') run.resultSeen = true;
|
||||
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 the launcher died without emitting a result event (spawn failure,
|
||||
// kill), synthesize one so the renderer can settle its progress view.
|
||||
if (!run.resultSeen) {
|
||||
sendToWindow(IPC_CHANNELS.event.syncUiProgress, {
|
||||
runId,
|
||||
kind,
|
||||
host,
|
||||
event: { type: 'result', ok: result.ok, error: result.error },
|
||||
});
|
||||
}
|
||||
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 { started: true, runId, reason: null };
|
||||
}
|
||||
|
||||
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');
|
||||
if (request.force) args.push('--force');
|
||||
args.push('--json');
|
||||
const result = startRun('host-sync', host, args, options);
|
||||
if (result.started) {
|
||||
// Remember the host (and the direction used) before the launcher's own
|
||||
// bookkeeping lands, so it shows up in the UI immediately.
|
||||
writeState(upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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}`));
|
||||
}
|
||||
if (currentRun) {
|
||||
return Promise.resolve(failed('A sync operation is already running.'));
|
||||
}
|
||||
const resolution = deps.resolveLauncherCommand();
|
||||
if (!resolution.command) {
|
||||
return Promise.resolve(failed(resolution.error ?? 'Launcher unavailable.'));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let checkResult: SyncUiCheckResult | null = null;
|
||||
runCounter += 1;
|
||||
const runId = runCounter;
|
||||
const handle = deps.runLauncher({
|
||||
command: resolution.command!,
|
||||
args: ['sync', trimmed, '--check', '--json'],
|
||||
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,
|
||||
};
|
||||
}
|
||||
},
|
||||
onStderr: (text) => deps.log?.(`[sync-ui check] ${text.trimEnd()}`),
|
||||
});
|
||||
const run: ActiveRun = {
|
||||
id: runId,
|
||||
kind: 'check',
|
||||
host: trimmed,
|
||||
handle,
|
||||
resultSeen: false,
|
||||
};
|
||||
currentRun = run;
|
||||
run.completion = handle.done
|
||||
.then((result) => {
|
||||
if (currentRun?.id === runId) currentRun = null;
|
||||
resolve(checkResult ?? failed(result.error ?? 'Connection check failed.'));
|
||||
broadcastStateChanged();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (currentRun?.id === runId) currentRun = null;
|
||||
resolve(failed(error instanceof Error ? error.message : String(error)));
|
||||
deps.log?.(
|
||||
`[sync-ui check] Cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSnapshot(): SyncUiStartResult {
|
||||
const outPath = path.join(deps.snapshotsDir, formatSnapshotName(deps.nowMs()));
|
||||
return 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 startRun('merge', null, args);
|
||||
}
|
||||
|
||||
function deleteSnapshot(filePath: string): SyncUiSnapshotFile[] {
|
||||
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 });
|
||||
broadcastStateChanged();
|
||||
return listSnapshots();
|
||||
}
|
||||
|
||||
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 registerHandlers(): void {
|
||||
const channels = IPC_CHANNELS.request;
|
||||
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) =>
|
||||
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs())),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) =>
|
||||
writeState(removeSyncHost(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.');
|
||||
}
|
||||
return writeState({ ...readState(), autoSyncIntervalMinutes: Math.floor(value) });
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiRunSync, (_event, request) =>
|
||||
runHostSync(request as SyncUiRunRequest),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiCancelRun, () => cancelRun());
|
||||
deps.ipcMain.handle(channels.syncUiCheckHost, (_event, host) => checkHost(String(host)));
|
||||
deps.ipcMain.handle(channels.syncUiCreateSnapshot, () => createSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiMergeSnapshotFile, (_event, filePath, force) =>
|
||||
mergeSnapshotFile(String(filePath), force === true),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiDeleteSnapshot, (_event, filePath) =>
|
||||
deleteSnapshot(String(filePath)),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiRevealSnapshot, (_event, filePath) => {
|
||||
deps.revealPath(String(filePath));
|
||||
return true;
|
||||
});
|
||||
deps.ipcMain.handle(channels.syncUiPickSnapshotFile, () => deps.pickSnapshotFile());
|
||||
}
|
||||
|
||||
return {
|
||||
registerHandlers,
|
||||
getSnapshot,
|
||||
readState,
|
||||
runHostSync,
|
||||
checkHost,
|
||||
cancelRun,
|
||||
shutdown,
|
||||
isRunning: () => currentRun !== null,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { resolveConfigDir, resolveConfigFilePath } from '../config/path-resolution';
|
||||
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
|
||||
import {
|
||||
extractSyncCliTokens,
|
||||
parseSyncCliTokens,
|
||||
syncCliUsage,
|
||||
} from '../core/services/stats-sync/cli-args';
|
||||
import {
|
||||
createDbSnapshot,
|
||||
findLiveStatsDaemonPid,
|
||||
} from '../core/services/stats-sync/shared';
|
||||
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
|
||||
import {
|
||||
assertSafeSshHost,
|
||||
detectRemoteShellFlavor,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
} from '../core/services/stats-sync/ssh';
|
||||
import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver';
|
||||
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 resolveAppConfigDir(): string {
|
||||
return resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTildePath(input: string): string {
|
||||
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
|
||||
}
|
||||
|
||||
// Same resolution as the launcher: honor a configured immersionTracking.dbPath
|
||||
// (raw config read, tolerant of comments), else <configDir>/immersion.sqlite.
|
||||
function resolveDefaultDbPath(): string {
|
||||
const configPath = resolveConfigFilePath({
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
let configured = '';
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
||||
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 resolveTildePath(configured);
|
||||
return path.join(resolveAppConfigDir(), 'immersion.sqlite');
|
||||
}
|
||||
|
||||
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
const finish = (value: boolean) => {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
function recordHostSyncResultToDisk(
|
||||
host: string,
|
||||
status: 'success' | 'error',
|
||||
detail: string | null,
|
||||
): void {
|
||||
try {
|
||||
const filePath = getSyncHostsPath(resolveAppConfigDir());
|
||||
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: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath),
|
||||
mergeSnapshotIntoDb: (localDbPath, snapshotPath) =>
|
||||
mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath),
|
||||
formatMergeSummary,
|
||||
findLiveStatsDaemonPid,
|
||||
assertSafeSshHost,
|
||||
detectRemoteShellFlavor,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: (level, configured, message) => {
|
||||
if (level === 'debug' && configured !== 'debug') return;
|
||||
console.error(message);
|
||||
},
|
||||
canConnectUnixSocket,
|
||||
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,
|
||||
resolvePath: resolveTildePath,
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
import type {
|
||||
SyncHostsState,
|
||||
SyncUiAPI,
|
||||
SyncUiCheckResult,
|
||||
SyncUiHostUpdateRequest,
|
||||
SyncUiProgressPayload,
|
||||
SyncUiRunRequest,
|
||||
SyncUiSnapshot,
|
||||
SyncUiSnapshotFile,
|
||||
SyncUiStartResult,
|
||||
} from './types/sync-ui';
|
||||
|
||||
const syncUiAPI: SyncUiAPI = {
|
||||
getSnapshot: (): Promise<SyncUiSnapshot> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiGetSnapshot),
|
||||
saveHost: (update: SyncUiHostUpdateRequest): Promise<SyncHostsState> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiSaveHost, update),
|
||||
removeHost: (host: string): Promise<SyncHostsState> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.syncUiRemoveHost, host),
|
||||
setAutoSyncInterval: (minutes: number): Promise<SyncHostsState> =>
|
||||
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<SyncUiSnapshotFile[]> =>
|
||||
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);
|
||||
@@ -119,6 +119,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',
|
||||
@@ -149,6 +161,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;
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseSyncProgressLine } from './sync-events';
|
||||
|
||||
test('parseSyncProgressLine parses known event types', () => {
|
||||
assert.deepEqual(
|
||||
parseSyncProgressLine('{"type":"stage","stage":"snapshot-local","message":"Snapshotting"}'),
|
||||
{ type: 'stage', stage: 'snapshot-local', message: 'Snapshotting' },
|
||||
);
|
||||
const summaryLine = JSON.stringify({
|
||||
type: 'merge-summary',
|
||||
target: 'local',
|
||||
summary: {
|
||||
sessionsMerged: 2,
|
||||
sessionsAlreadyPresent: 0,
|
||||
activeSessionsSkipped: 0,
|
||||
animeAdded: 0,
|
||||
videosAdded: 0,
|
||||
wordsAdded: 0,
|
||||
kanjiAdded: 0,
|
||||
subtitleLinesAdded: 0,
|
||||
telemetryRowsAdded: 0,
|
||||
eventsAdded: 0,
|
||||
excludedWordsAdded: 0,
|
||||
dailyRollupsCopied: 0,
|
||||
monthlyRollupsCopied: 0,
|
||||
rollupGroupsRecomputed: 0,
|
||||
},
|
||||
});
|
||||
const parsed = parseSyncProgressLine(summaryLine);
|
||||
assert.equal(parsed?.type, 'merge-summary');
|
||||
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
|
||||
type: 'result',
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseSyncProgressLine rejects non-events and garbage', () => {
|
||||
assert.equal(parseSyncProgressLine('plain text output'), null);
|
||||
assert.equal(parseSyncProgressLine('{"no":"type"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"unknown-event"}'), null);
|
||||
assert.equal(parseSyncProgressLine(''), null);
|
||||
assert.equal(parseSyncProgressLine('42'), null);
|
||||
});
|
||||
|
||||
test('parseSyncProgressLine rejects events missing required fields', () => {
|
||||
// A half-formed event used to be cast straight through; the UI reads
|
||||
// `.ok` / `.summary` directly and would misreport a failed sync as done.
|
||||
assert.equal(parseSyncProgressLine('{"type":"result"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"result","ok":"yes","error":null}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"merge-summary","target":"local"}'), null);
|
||||
assert.equal(
|
||||
parseSyncProgressLine('{"type":"merge-summary","target":"nowhere","summary":{}}'),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseSyncProgressLine('{"type":"stage","stage":"bogus","message":"x"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"snapshot-created"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"check-result","host":"h","sshOk":true}'), null);
|
||||
|
||||
// Well-formed events still parse.
|
||||
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
|
||||
type: 'result',
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// NDJSON progress protocol emitted by `subminer sync --json` and consumed by
|
||||
// the sync UI's launcher client. Keep this file free of Electron/bun imports —
|
||||
// it is shared between the launcher (bun) and the app main process (node).
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type SyncStage =
|
||||
| 'snapshot-local'
|
||||
| 'snapshot-remote'
|
||||
| 'download'
|
||||
| 'upload'
|
||||
| 'merge-local'
|
||||
| 'merge-remote';
|
||||
|
||||
export type SyncProgressEvent =
|
||||
| { type: 'stage'; stage: SyncStage; message: string }
|
||||
| { type: 'merge-summary'; target: 'local' | 'remote'; summary: SyncMergeSummary }
|
||||
| { type: 'remote-output'; text: string }
|
||||
| { type: 'snapshot-created'; path: string }
|
||||
| {
|
||||
type: 'check-result';
|
||||
host: string;
|
||||
sshOk: boolean;
|
||||
remoteCommand: string | null;
|
||||
remoteVersion: string | null;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
| { type: 'result'; ok: boolean; error: string | null };
|
||||
|
||||
const SUMMARY_KEYS: readonly (keyof SyncMergeSummary)[] = [
|
||||
'sessionsMerged',
|
||||
'sessionsAlreadyPresent',
|
||||
'activeSessionsSkipped',
|
||||
'animeAdded',
|
||||
'videosAdded',
|
||||
'wordsAdded',
|
||||
'kanjiAdded',
|
||||
'subtitleLinesAdded',
|
||||
'telemetryRowsAdded',
|
||||
'eventsAdded',
|
||||
'excludedWordsAdded',
|
||||
'dailyRollupsCopied',
|
||||
'monthlyRollupsCopied',
|
||||
'rollupGroupsRecomputed',
|
||||
];
|
||||
|
||||
const STAGES: readonly SyncStage[] = [
|
||||
'snapshot-local',
|
||||
'snapshot-remote',
|
||||
'download',
|
||||
'upload',
|
||||
'merge-local',
|
||||
'merge-remote',
|
||||
];
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isMergeSummary(value: unknown): value is SyncMergeSummary {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return SUMMARY_KEYS.every((key) => typeof record[key] === 'number');
|
||||
}
|
||||
|
||||
/**
|
||||
* The events cross a process boundary (the sync child's stdout), so each variant
|
||||
* is validated field-by-field: a half-formed `result` or `merge-summary` would
|
||||
* otherwise be cast straight into the UI, which reads `.ok`/`.summary` directly.
|
||||
*/
|
||||
export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const event = parsed as Record<string, unknown>;
|
||||
|
||||
switch (event.type) {
|
||||
case 'stage':
|
||||
return STAGES.includes(event.stage as SyncStage) && isString(event.message)
|
||||
? (parsed as SyncProgressEvent)
|
||||
: null;
|
||||
case 'merge-summary':
|
||||
return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary)
|
||||
? (parsed as SyncProgressEvent)
|
||||
: null;
|
||||
case 'remote-output':
|
||||
return isString(event.text) ? (parsed as SyncProgressEvent) : null;
|
||||
case 'snapshot-created':
|
||||
return isString(event.path) ? (parsed as SyncProgressEvent) : null;
|
||||
case 'check-result':
|
||||
return isString(event.host) &&
|
||||
typeof event.sshOk === 'boolean' &&
|
||||
typeof event.ok === 'boolean' &&
|
||||
isNullableString(event.remoteCommand) &&
|
||||
isNullableString(event.remoteVersion) &&
|
||||
isNullableString(event.error)
|
||||
? (parsed as SyncProgressEvent)
|
||||
: null;
|
||||
case 'result':
|
||||
return typeof event.ok === 'boolean' && isNullableString(event.error)
|
||||
? (parsed as SyncProgressEvent)
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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 {
|
||||
createDefaultSyncHostsState,
|
||||
getSyncHostsPath,
|
||||
isValidSyncHost,
|
||||
normalizeSyncHostsState,
|
||||
readSyncHostsState,
|
||||
recordSyncResult,
|
||||
removeSyncHost,
|
||||
upsertSyncHost,
|
||||
writeSyncHostsState,
|
||||
} from './sync-hosts-store';
|
||||
|
||||
function withTempDir(fn: (dir: string) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-hosts-test-'));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('createDefaultSyncHostsState returns empty v1 state', () => {
|
||||
assert.deepEqual(createDefaultSyncHostsState(), {
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: 60,
|
||||
hosts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('isValidSyncHost accepts ssh destinations and rejects unsafe values', () => {
|
||||
assert.equal(isValidSyncHost('desktop'), true);
|
||||
assert.equal(isValidSyncHost('user@10.0.0.5'), true);
|
||||
assert.equal(isValidSyncHost('my-alias.local'), true);
|
||||
assert.equal(isValidSyncHost(''), false);
|
||||
assert.equal(isValidSyncHost(' '), false);
|
||||
assert.equal(isValidSyncHost('-oProxyCommand=evil'), false);
|
||||
assert.equal(isValidSyncHost('host with spaces'), false);
|
||||
});
|
||||
|
||||
test('upsertSyncHost adds a new host with defaults and preserves fields on update', () => {
|
||||
const state = createDefaultSyncHostsState();
|
||||
const added = upsertSyncHost(state, { host: 'user@laptop' }, 1000);
|
||||
assert.deepEqual(added.hosts, [
|
||||
{
|
||||
host: 'user@laptop',
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: 1000,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
},
|
||||
]);
|
||||
// original state untouched
|
||||
assert.equal(state.hosts.length, 0);
|
||||
|
||||
const synced = recordSyncResult(added, 'user@laptop', {
|
||||
atMs: 2000,
|
||||
status: 'success',
|
||||
detail: 'Sessions merged: 3',
|
||||
});
|
||||
const updated = upsertSyncHost(synced, { host: 'user@laptop', direction: 'push' }, 3000);
|
||||
assert.equal(updated.hosts.length, 1);
|
||||
const entry = updated.hosts[0]!;
|
||||
assert.equal(entry.direction, 'push');
|
||||
assert.equal(entry.addedAtMs, 1000);
|
||||
assert.equal(entry.lastSyncAtMs, 2000);
|
||||
assert.equal(entry.lastSyncStatus, 'success');
|
||||
assert.equal(entry.lastSyncDetail, 'Sessions merged: 3');
|
||||
});
|
||||
|
||||
test('upsertSyncHost trims host and throws on invalid host', () => {
|
||||
const state = upsertSyncHost(createDefaultSyncHostsState(), { host: ' desktop ' }, 1);
|
||||
assert.equal(state.hosts[0]!.host, 'desktop');
|
||||
assert.throws(() => upsertSyncHost(state, { host: '-bad' }, 2));
|
||||
});
|
||||
|
||||
test('removeSyncHost removes only the matching host', () => {
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'a' }, 1);
|
||||
state = upsertSyncHost(state, { host: 'b' }, 2);
|
||||
const removed = removeSyncHost(state, 'a');
|
||||
assert.deepEqual(
|
||||
removed.hosts.map((entry) => entry.host),
|
||||
['b'],
|
||||
);
|
||||
assert.equal(removeSyncHost(removed, 'missing').hosts.length, 1);
|
||||
});
|
||||
|
||||
test('recordSyncResult upserts unknown hosts so CLI syncs are remembered', () => {
|
||||
const state = recordSyncResult(createDefaultSyncHostsState(), 'user@server', {
|
||||
atMs: 5000,
|
||||
status: 'error',
|
||||
detail: 'Remote merge failed',
|
||||
});
|
||||
assert.equal(state.hosts.length, 1);
|
||||
const entry = state.hosts[0]!;
|
||||
assert.equal(entry.host, 'user@server');
|
||||
assert.equal(entry.addedAtMs, 5000);
|
||||
assert.equal(entry.lastSyncAtMs, 5000);
|
||||
assert.equal(entry.lastSyncStatus, 'error');
|
||||
assert.equal(entry.lastSyncDetail, 'Remote merge failed');
|
||||
});
|
||||
|
||||
test('normalizeSyncHostsState drops invalid entries and falls back to defaults', () => {
|
||||
assert.deepEqual(normalizeSyncHostsState(null), createDefaultSyncHostsState());
|
||||
assert.deepEqual(normalizeSyncHostsState('junk'), createDefaultSyncHostsState());
|
||||
assert.deepEqual(normalizeSyncHostsState({ version: 99 }), createDefaultSyncHostsState());
|
||||
|
||||
const normalized = normalizeSyncHostsState({
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: 15,
|
||||
hosts: [
|
||||
{
|
||||
host: 'desktop',
|
||||
label: 'Desktop PC',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
addedAtMs: 10,
|
||||
lastSyncAtMs: 20,
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncDetail: 'ok',
|
||||
},
|
||||
{ host: '-bad', direction: 'both', autoSync: false, addedAtMs: 1 },
|
||||
{ host: 'dupe', direction: 'sideways', autoSync: false, addedAtMs: 1 },
|
||||
'not-an-object',
|
||||
],
|
||||
});
|
||||
assert.equal(normalized.autoSyncIntervalMinutes, 15);
|
||||
assert.deepEqual(normalized.hosts, [
|
||||
{
|
||||
host: 'desktop',
|
||||
label: 'Desktop PC',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
addedAtMs: 10,
|
||||
lastSyncAtMs: 20,
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncDetail: 'ok',
|
||||
},
|
||||
{
|
||||
host: 'dupe',
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: 1,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('read/write round-trips state and tolerates missing or corrupt files', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = getSyncHostsPath(root);
|
||||
assert.equal(filePath, path.join(root, 'sync-hosts.json'));
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
|
||||
fs.writeFileSync(filePath, '{corrupt');
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
|
||||
writeSyncHostsState(filePath, state);
|
||||
assert.deepEqual(readSyncHostsState(filePath), state);
|
||||
});
|
||||
});
|
||||
|
||||
test('writeSyncHostsState creates parent directories', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = path.join(root, 'nested', 'dir', 'sync-hosts.json');
|
||||
writeSyncHostsState(filePath, createDefaultSyncHostsState());
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
});
|
||||
});
|
||||
|
||||
test('writeSyncHostsState leaves the previous file intact when the write fails', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = getSyncHostsPath(root);
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
|
||||
writeSyncHostsState(filePath, state);
|
||||
|
||||
// A crash mid-write must not truncate the live file: readSyncHostsState
|
||||
// falls back to defaults on a corrupt file, which would drop every host.
|
||||
let next = upsertSyncHost(state, { host: 'user@desktop' }, 43);
|
||||
assert.throws(() =>
|
||||
writeSyncHostsState(filePath, next, {
|
||||
renameSync: () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(readSyncHostsState(filePath), state);
|
||||
assert.equal(fs.existsSync(`${filePath}.tmp`), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type SyncDirection = 'both' | 'push' | 'pull';
|
||||
export type SyncResultStatus = 'success' | 'error';
|
||||
|
||||
export interface SyncHostEntry {
|
||||
host: string;
|
||||
label: string | null;
|
||||
direction: SyncDirection;
|
||||
autoSync: boolean;
|
||||
addedAtMs: number;
|
||||
lastSyncAtMs: number | null;
|
||||
lastSyncStatus: SyncResultStatus | null;
|
||||
lastSyncDetail: string | null;
|
||||
}
|
||||
|
||||
export interface SyncHostsState {
|
||||
version: 1;
|
||||
autoSyncIntervalMinutes: number;
|
||||
hosts: SyncHostEntry[];
|
||||
}
|
||||
|
||||
export interface SyncHostUpdate {
|
||||
host: string;
|
||||
label?: string | null;
|
||||
direction?: SyncDirection;
|
||||
autoSync?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncResultUpdate {
|
||||
atMs: number;
|
||||
status: SyncResultStatus;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 60;
|
||||
|
||||
export function createDefaultSyncHostsState(): SyncHostsState {
|
||||
return {
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Hosts are passed as a single spawn argument to ssh, so the only dangerous
|
||||
// shapes are option injection (leading "-") and multi-token values.
|
||||
export function isValidSyncHost(host: string): boolean {
|
||||
const trimmed = host.trim();
|
||||
return trimmed.length > 0 && !trimmed.startsWith('-') && !/\s/.test(trimmed);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isDirection(value: unknown): value is SyncDirection {
|
||||
return value === 'both' || value === 'push' || value === 'pull';
|
||||
}
|
||||
|
||||
function normalizeHostEntry(value: unknown): SyncHostEntry | null {
|
||||
const record = asObject(value);
|
||||
if (!record) return null;
|
||||
const host = typeof record.host === 'string' ? record.host.trim() : '';
|
||||
if (!isValidSyncHost(host)) return null;
|
||||
return {
|
||||
host,
|
||||
label: typeof record.label === 'string' && record.label.trim() ? record.label.trim() : null,
|
||||
direction: isDirection(record.direction) ? record.direction : 'both',
|
||||
autoSync: record.autoSync === true,
|
||||
addedAtMs:
|
||||
typeof record.addedAtMs === 'number' && Number.isFinite(record.addedAtMs)
|
||||
? record.addedAtMs
|
||||
: 0,
|
||||
lastSyncAtMs:
|
||||
typeof record.lastSyncAtMs === 'number' && Number.isFinite(record.lastSyncAtMs)
|
||||
? record.lastSyncAtMs
|
||||
: null,
|
||||
lastSyncStatus:
|
||||
record.lastSyncStatus === 'success' || record.lastSyncStatus === 'error'
|
||||
? record.lastSyncStatus
|
||||
: null,
|
||||
lastSyncDetail: typeof record.lastSyncDetail === 'string' ? record.lastSyncDetail : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSyncHostsState(value: unknown): SyncHostsState {
|
||||
const record = asObject(value);
|
||||
if (!record || record.version !== 1) return createDefaultSyncHostsState();
|
||||
const hosts = Array.isArray(record.hosts)
|
||||
? record.hosts.map(normalizeHostEntry).filter((entry): entry is SyncHostEntry => entry !== null)
|
||||
: [];
|
||||
const interval = record.autoSyncIntervalMinutes;
|
||||
return {
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes:
|
||||
typeof interval === 'number' && Number.isFinite(interval) && interval >= 1
|
||||
? Math.floor(interval)
|
||||
: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
|
||||
hosts,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertSyncHost(
|
||||
state: SyncHostsState,
|
||||
update: SyncHostUpdate,
|
||||
nowMs: number,
|
||||
): SyncHostsState {
|
||||
const host = update.host.trim();
|
||||
if (!isValidSyncHost(host)) {
|
||||
throw new Error(`Invalid sync host: ${JSON.stringify(update.host)}`);
|
||||
}
|
||||
const existing = state.hosts.find((entry) => entry.host === host);
|
||||
const base: SyncHostEntry = existing ?? {
|
||||
host,
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: nowMs,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
};
|
||||
const next: SyncHostEntry = {
|
||||
...base,
|
||||
label: update.label !== undefined ? update.label : base.label,
|
||||
direction: update.direction ?? base.direction,
|
||||
autoSync: update.autoSync ?? base.autoSync,
|
||||
};
|
||||
const hosts = existing
|
||||
? state.hosts.map((entry) => (entry.host === host ? next : entry))
|
||||
: [...state.hosts, next];
|
||||
return { ...state, hosts };
|
||||
}
|
||||
|
||||
export function removeSyncHost(state: SyncHostsState, host: string): SyncHostsState {
|
||||
return { ...state, hosts: state.hosts.filter((entry) => entry.host !== host.trim()) };
|
||||
}
|
||||
|
||||
export function recordSyncResult(
|
||||
state: SyncHostsState,
|
||||
host: string,
|
||||
result: SyncResultUpdate,
|
||||
): SyncHostsState {
|
||||
const upserted = upsertSyncHost(state, { host }, result.atMs);
|
||||
const hosts = upserted.hosts.map((entry) =>
|
||||
entry.host === host.trim()
|
||||
? {
|
||||
...entry,
|
||||
lastSyncAtMs: result.atMs,
|
||||
lastSyncStatus: result.status,
|
||||
lastSyncDetail: result.detail,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
return { ...upserted, hosts };
|
||||
}
|
||||
|
||||
export function getSyncHostsPath(configDir: string): string {
|
||||
return path.join(configDir, 'sync-hosts.json');
|
||||
}
|
||||
|
||||
export function readSyncHostsState(
|
||||
filePath: string,
|
||||
deps?: {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
|
||||
},
|
||||
): SyncHostsState {
|
||||
const existsSync = deps?.existsSync ?? fs.existsSync;
|
||||
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
|
||||
if (!existsSync(filePath)) return createDefaultSyncHostsState();
|
||||
try {
|
||||
return normalizeSyncHostsState(JSON.parse(readFileSync(filePath, 'utf8')));
|
||||
} catch {
|
||||
return createDefaultSyncHostsState();
|
||||
}
|
||||
}
|
||||
|
||||
// Written via a sibling temp file + rename so an interrupted write cannot leave
|
||||
// a truncated sync-hosts.json behind (readSyncHostsState would silently fall
|
||||
// back to defaults, dropping every configured host).
|
||||
export function writeSyncHostsState(
|
||||
filePath: string,
|
||||
state: SyncHostsState,
|
||||
deps?: {
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||
renameSync?: (from: string, to: string) => void;
|
||||
rmSync?: (target: string, options: { force: true }) => void;
|
||||
},
|
||||
): void {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
||||
const renameSync = deps?.renameSync ?? fs.renameSync;
|
||||
const rmSync = deps?.rmSync ?? fs.rmSync;
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
try {
|
||||
renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
rmSync(tempPath, { force: true });
|
||||
} catch {
|
||||
// best effort: the temp file is disposable
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self';"
|
||||
/>
|
||||
<title>SubMiner Sync</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="sync-shell">
|
||||
<header class="sync-header">
|
||||
<div class="brand-block">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Immersion Sync</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<div id="runPill" class="run-pill idle" role="status">Idle</div>
|
||||
<div id="dbPath" class="db-path" title="Local immersion database"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sync-scroll">
|
||||
<section class="panel" aria-labelledby="devicesTitle">
|
||||
<div class="panel-head">
|
||||
<h2 id="devicesTitle">Devices</h2>
|
||||
<div class="auto-interval">
|
||||
<label for="autoIntervalInput">Auto-sync every</label>
|
||||
<input id="autoIntervalInput" type="number" min="1" max="1440" step="1" />
|
||||
<span>min</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hostList" class="host-list"></div>
|
||||
<div id="hostListEmpty" class="empty-note hidden">
|
||||
No saved devices yet. Hosts you sync from the command line show up here too.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="addDeviceTitle">
|
||||
<div class="panel-head">
|
||||
<h2 id="addDeviceTitle">Add a device</h2>
|
||||
</div>
|
||||
<div class="add-host-row">
|
||||
<input
|
||||
id="newHostInput"
|
||||
class="text-input mono"
|
||||
type="text"
|
||||
placeholder="user@hostname or ssh alias"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<input
|
||||
id="newLabelInput"
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="Label (optional)"
|
||||
/>
|
||||
<button id="testHostButton" class="ghost-button" type="button">Test connection</button>
|
||||
<button id="addHostButton" class="primary-button" type="button">Add device</button>
|
||||
</div>
|
||||
<div id="checkResult" class="check-result hidden"></div>
|
||||
<details class="setup-help">
|
||||
<summary>Setup checklist</summary>
|
||||
<ol>
|
||||
<li>
|
||||
Both machines need SSH key access:
|
||||
<code>ssh-copy-id user@hostname</code> (no password prompts).
|
||||
</li>
|
||||
<li>
|
||||
The remote machine just needs SubMiner installed. It is found automatically in its
|
||||
default install location (Windows), as <code>SubMiner</code> on the remote PATH, a
|
||||
macOS <code>/Applications</code> install, or the optional
|
||||
<code>subminer</code> command-line launcher. Windows remotes need the built-in
|
||||
OpenSSH Server enabled.
|
||||
</li>
|
||||
<li>
|
||||
Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on the
|
||||
remote in one go.
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="activityPanel" class="panel hidden" aria-labelledby="activityTitle">
|
||||
<div class="panel-head">
|
||||
<h2 id="activityTitle">Activity</h2>
|
||||
<button id="cancelButton" class="danger-button hidden" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="stageList" class="stage-list"></div>
|
||||
<pre id="remoteOutput" class="remote-output hidden"></pre>
|
||||
<div id="resultCard" class="result-card hidden"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="snapshotsTitle">
|
||||
<div class="panel-head">
|
||||
<h2 id="snapshotsTitle">Snapshots</h2>
|
||||
<div class="panel-actions">
|
||||
<button id="mergeFileButton" class="ghost-button" type="button">
|
||||
Merge a snapshot file…
|
||||
</button>
|
||||
<button id="createSnapshotButton" class="primary-button" type="button">
|
||||
Create snapshot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-hint">
|
||||
A snapshot is a consistent point-in-time copy of the immersion database, handy as a
|
||||
manual backup before big merges. Stored in
|
||||
<span id="snapshotsDir" class="mono"></span>
|
||||
</p>
|
||||
<div id="snapshotList" class="snapshot-list"></div>
|
||||
<div id="snapshotListEmpty" class="empty-note hidden">No snapshots yet.</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="syncui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,809 @@
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Catppuccin Macchiato */
|
||||
--ctp-rosewater: #f4dbd6;
|
||||
--ctp-flamingo: #f0c6c6;
|
||||
--ctp-pink: #f5bde6;
|
||||
--ctp-mauve: #c6a0f6;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-maroon: #ee99a0;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-teal: #8bd5ca;
|
||||
--ctp-sky: #91d7e3;
|
||||
--ctp-sapphire: #7dc4e4;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--ctp-overlay2: #939ab7;
|
||||
--ctp-overlay1: #8087a2;
|
||||
--ctp-overlay0: #6e738d;
|
||||
--ctp-surface2: #5b6078;
|
||||
--ctp-surface1: #494d64;
|
||||
--ctp-surface0: #363a4f;
|
||||
--ctp-base: #24273a;
|
||||
--ctp-mantle: #1e2030;
|
||||
--ctp-crust: #181926;
|
||||
|
||||
/* Semantic */
|
||||
--bg: var(--ctp-base);
|
||||
--panel: rgba(36, 39, 58, 0.85);
|
||||
--panel-elevated: rgba(54, 58, 79, 0.55);
|
||||
--line: rgba(110, 115, 141, 0.28);
|
||||
--line-soft: rgba(110, 115, 141, 0.14);
|
||||
--text: var(--ctp-text);
|
||||
--muted: var(--ctp-subtext0);
|
||||
--faint: var(--ctp-overlay1);
|
||||
--accent: var(--ctp-blue);
|
||||
--accent-strong: var(--ctp-lavender);
|
||||
--highlight: var(--ctp-mauve);
|
||||
--danger: var(--ctp-red);
|
||||
--ok: var(--ctp-green);
|
||||
--warn: var(--ctp-peach);
|
||||
--shadow: rgba(0, 0, 0, 0.42);
|
||||
--mono:
|
||||
'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', ui-monospace, 'DejaVu Sans Mono',
|
||||
monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(1200px 480px at 85% -10%, rgba(138, 173, 244, 0.09), transparent 60%),
|
||||
radial-gradient(900px 420px at -10% 110%, rgba(198, 160, 246, 0.07), transparent 55%),
|
||||
var(--ctp-base);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
background-color: rgba(110, 115, 141, 0.35);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(138, 173, 244, 0.45);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ shell */
|
||||
|
||||
.sync-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sync-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 26px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(30, 32, 48, 0.92), rgba(30, 32, 48, 0.6));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.db-path {
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.run-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.run-pill::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.run-pill.running {
|
||||
color: var(--ctp-sky);
|
||||
border-color: rgba(145, 215, 227, 0.45);
|
||||
}
|
||||
|
||||
.run-pill.running::before {
|
||||
background: var(--ctp-sky);
|
||||
box-shadow: 0 0 8px rgba(145, 215, 227, 0.8);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 20px 26px 30px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ panels */
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 10px 28px -18px var(--shadow);
|
||||
padding: 16px 18px 18px;
|
||||
animation: panel-in 0.35s ease both;
|
||||
}
|
||||
|
||||
.panel:nth-child(2) {
|
||||
animation-delay: 0.04s;
|
||||
}
|
||||
|
||||
.panel:nth-child(3) {
|
||||
animation-delay: 0.08s;
|
||||
}
|
||||
|
||||
.panel:nth-child(4) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.panel:nth-child(5) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
@keyframes panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.banner {
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.banner-warn {
|
||||
border: 1px solid rgba(245, 169, 127, 0.4);
|
||||
background: rgba(245, 169, 127, 0.08);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
padding: 14px 4px 4px;
|
||||
font-size: 13px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ buttons */
|
||||
|
||||
.primary-button,
|
||||
.ghost-button,
|
||||
.danger-button,
|
||||
.mini-button {
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.05s ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: transparent;
|
||||
border-color: var(--line);
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.ghost-button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
background: transparent;
|
||||
border-color: rgba(237, 135, 150, 0.5);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.danger-button:hover:not(:disabled) {
|
||||
background: rgba(237, 135, 150, 0.12);
|
||||
}
|
||||
|
||||
.mini-button {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mini-button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mini-button.danger:hover:not(:disabled) {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ inputs */
|
||||
|
||||
.text-input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: rgba(24, 25, 38, 0.6);
|
||||
padding: 8px 12px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(138, 173, 244, 0.18);
|
||||
}
|
||||
|
||||
.auto-interval {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auto-interval input {
|
||||
width: 64px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.6);
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.auto-interval input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ hosts */
|
||||
|
||||
.host-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.host-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-elevated);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.host-card.syncing {
|
||||
border-color: rgba(145, 215, 227, 0.45);
|
||||
}
|
||||
|
||||
.host-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.host-id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.host-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-sapphire);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.host-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.host-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.host-status .detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.status-dot.success {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 6px rgba(166, 218, 149, 0.7);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 6px rgba(237, 135, 150, 0.7);
|
||||
}
|
||||
|
||||
.host-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.direction-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.direction-toggle button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.direction-toggle button + button {
|
||||
border-left: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.direction-toggle button.active {
|
||||
background: rgba(138, 173, 244, 0.18);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.auto-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.auto-toggle input {
|
||||
accent-color: var(--ctp-mauve);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.host-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ add host */
|
||||
|
||||
.add-host-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.check-result {
|
||||
margin-top: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.check-result.ok {
|
||||
border-color: rgba(166, 218, 149, 0.4);
|
||||
background: rgba(166, 218, 149, 0.07);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.check-result.fail {
|
||||
border-color: rgba(237, 135, 150, 0.4);
|
||||
background: rgba(237, 135, 150, 0.07);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.check-result .sub {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.setup-help {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.setup-help summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.setup-help ol {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.setup-help code {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
background: rgba(24, 25, 38, 0.7);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ activity */
|
||||
|
||||
.stage-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stage-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 4px;
|
||||
font-size: 13.5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stage-item + .stage-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: -8px;
|
||||
width: 2px;
|
||||
height: 12px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.stage-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--line);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.stage-item.active .stage-icon {
|
||||
border-color: var(--ctp-sky);
|
||||
border-top-color: transparent;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.stage-item.done .stage-icon {
|
||||
border-color: var(--ok);
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.stage-item.done .stage-icon::after {
|
||||
content: '✓';
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.stage-item.failed .stage-icon {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.stage-item.failed .stage-icon::after {
|
||||
content: '✕';
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.stage-item.active {
|
||||
color: var(--ctp-sky);
|
||||
}
|
||||
|
||||
.stage-item.done {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.remote-output {
|
||||
margin: 12px 0 0;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 10px;
|
||||
background: var(--ctp-crust);
|
||||
padding: 10px 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--ctp-subtext1);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
margin-top: 14px;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(166, 218, 149, 0.35);
|
||||
background: rgba(166, 218, 149, 0.06);
|
||||
}
|
||||
|
||||
.result-card.error {
|
||||
border-color: rgba(237, 135, 150, 0.4);
|
||||
background: rgba(237, 135, 150, 0.06);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--ok);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-card.error .result-title {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.result-error-detail {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext1);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.count-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.count-cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px dashed var(--line-soft);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.count-cell b {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ snapshots */
|
||||
|
||||
.snapshot-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.snapshot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.snapshot-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.snapshot-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--ctp-teal);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.snapshot-meta {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.snapshot-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatBytes, formatRelativeTime, summarizeMergeCounts } from './syncui-format';
|
||||
|
||||
test('formatBytes renders human readable sizes', () => {
|
||||
assert.equal(formatBytes(0), '0 B');
|
||||
assert.equal(formatBytes(532), '532 B');
|
||||
assert.equal(formatBytes(2048), '2.0 KB');
|
||||
assert.equal(formatBytes(5 * 1024 * 1024), '5.0 MB');
|
||||
assert.equal(formatBytes(1.5 * 1024 * 1024 * 1024), '1.5 GB');
|
||||
});
|
||||
|
||||
test('formatRelativeTime renders past timestamps', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
assert.equal(formatRelativeTime(null, now), 'never');
|
||||
assert.equal(formatRelativeTime(now - 20_000, now), 'just now');
|
||||
assert.equal(formatRelativeTime(now - 5 * 60_000, now), '5 min ago');
|
||||
assert.equal(formatRelativeTime(now - 3 * 3_600_000, now), '3 h ago');
|
||||
assert.equal(formatRelativeTime(now - 2 * 86_400_000, now), '2 d ago');
|
||||
});
|
||||
|
||||
test('summarizeMergeCounts lists only non-zero counts', () => {
|
||||
const summary = {
|
||||
sessionsMerged: 3,
|
||||
sessionsAlreadyPresent: 12,
|
||||
activeSessionsSkipped: 0,
|
||||
animeAdded: 1,
|
||||
videosAdded: 2,
|
||||
wordsAdded: 0,
|
||||
kanjiAdded: 0,
|
||||
subtitleLinesAdded: 450,
|
||||
telemetryRowsAdded: 3,
|
||||
eventsAdded: 4,
|
||||
excludedWordsAdded: 0,
|
||||
dailyRollupsCopied: 0,
|
||||
monthlyRollupsCopied: 0,
|
||||
rollupGroupsRecomputed: 4,
|
||||
};
|
||||
const lines = summarizeMergeCounts(summary);
|
||||
assert.ok(lines.some((line) => line.label === 'Telemetry rows' && line.value === 3));
|
||||
assert.ok(lines.some((line) => line.label === 'Events' && line.value === 4));
|
||||
assert.deepEqual(lines, [
|
||||
{ label: 'Sessions merged', value: 3 },
|
||||
{ label: 'Already present', value: 12 },
|
||||
{ label: 'Series added', value: 1 },
|
||||
{ label: 'Videos added', value: 2 },
|
||||
{ label: 'Subtitle lines', value: 450 },
|
||||
{ label: 'Telemetry rows', value: 3 },
|
||||
{ label: 'Events', value: 4 },
|
||||
{ label: 'Rollups recomputed', value: 4 },
|
||||
]);
|
||||
|
||||
const empty = summarizeMergeCounts({
|
||||
...summary,
|
||||
sessionsMerged: 0,
|
||||
sessionsAlreadyPresent: 0,
|
||||
animeAdded: 0,
|
||||
videosAdded: 0,
|
||||
subtitleLinesAdded: 0,
|
||||
telemetryRowsAdded: 0,
|
||||
eventsAdded: 0,
|
||||
rollupGroupsRecomputed: 0,
|
||||
});
|
||||
assert.deepEqual(empty, [{ label: 'Sessions merged', value: 0 }]);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SyncMergeSummary } from '../shared/sync/sync-events';
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function formatRelativeTime(atMs: number | null, nowMs: number): string {
|
||||
if (atMs === null) return 'never';
|
||||
const elapsed = Math.max(0, nowMs - atMs);
|
||||
if (elapsed < 60_000) return 'just now';
|
||||
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} min ago`;
|
||||
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} h ago`;
|
||||
return `${Math.floor(elapsed / 86_400_000)} d ago`;
|
||||
}
|
||||
|
||||
export interface MergeCountLine {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const MERGE_COUNT_FIELDS: Array<{ key: keyof SyncMergeSummary; label: string }> = [
|
||||
{ key: 'sessionsMerged', label: 'Sessions merged' },
|
||||
{ key: 'sessionsAlreadyPresent', label: 'Already present' },
|
||||
{ key: 'activeSessionsSkipped', label: 'Active skipped' },
|
||||
{ key: 'animeAdded', label: 'Series added' },
|
||||
{ key: 'videosAdded', label: 'Videos added' },
|
||||
{ key: 'wordsAdded', label: 'Words added' },
|
||||
{ key: 'kanjiAdded', label: 'Kanji added' },
|
||||
{ key: 'subtitleLinesAdded', label: 'Subtitle lines' },
|
||||
{ key: 'telemetryRowsAdded', label: 'Telemetry rows' },
|
||||
{ key: 'eventsAdded', label: 'Events' },
|
||||
{ key: 'excludedWordsAdded', label: 'Excluded words' },
|
||||
{ key: 'dailyRollupsCopied', label: 'Daily rollups' },
|
||||
{ key: 'monthlyRollupsCopied', label: 'Monthly rollups' },
|
||||
{ key: 'rollupGroupsRecomputed', label: 'Rollups recomputed' },
|
||||
];
|
||||
|
||||
// "Sessions merged" always shows (even at 0, it is the headline number);
|
||||
// everything else appears only when non-zero.
|
||||
export function summarizeMergeCounts(summary: SyncMergeSummary): MergeCountLine[] {
|
||||
return MERGE_COUNT_FIELDS.filter(
|
||||
(field) => field.key === 'sessionsMerged' || (summary[field.key] ?? 0) > 0,
|
||||
).map((field) => ({ label: field.label, value: summary[field.key] ?? 0 }));
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import type {
|
||||
SyncDirection,
|
||||
SyncHostEntry,
|
||||
SyncUiAPI,
|
||||
SyncUiCheckResult,
|
||||
SyncUiProgressPayload,
|
||||
SyncUiSnapshot,
|
||||
SyncUiStartResult,
|
||||
} from '../types/sync-ui';
|
||||
import { formatBytes, formatRelativeTime, summarizeMergeCounts } from './syncui-format';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
syncUiAPI: SyncUiAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const api = window.syncUiAPI;
|
||||
|
||||
function el<T extends HTMLElement>(id: string): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) throw new Error(`Missing element #${id}`);
|
||||
return node as T;
|
||||
}
|
||||
|
||||
const runPill = el<HTMLDivElement>('runPill');
|
||||
const dbPathEl = el<HTMLDivElement>('dbPath');
|
||||
const hostList = el<HTMLDivElement>('hostList');
|
||||
const hostListEmpty = el<HTMLDivElement>('hostListEmpty');
|
||||
const autoIntervalInput = el<HTMLInputElement>('autoIntervalInput');
|
||||
const newHostInput = el<HTMLInputElement>('newHostInput');
|
||||
const newLabelInput = el<HTMLInputElement>('newLabelInput');
|
||||
const testHostButton = el<HTMLButtonElement>('testHostButton');
|
||||
const addHostButton = el<HTMLButtonElement>('addHostButton');
|
||||
const checkResultEl = el<HTMLDivElement>('checkResult');
|
||||
const activityPanel = el<HTMLElement>('activityPanel');
|
||||
const cancelButton = el<HTMLButtonElement>('cancelButton');
|
||||
const stageList = el<HTMLDivElement>('stageList');
|
||||
const remoteOutput = el<HTMLPreElement>('remoteOutput');
|
||||
const resultCard = el<HTMLDivElement>('resultCard');
|
||||
const snapshotsDirEl = el<HTMLSpanElement>('snapshotsDir');
|
||||
const snapshotList = el<HTMLDivElement>('snapshotList');
|
||||
const snapshotListEmpty = el<HTMLDivElement>('snapshotListEmpty');
|
||||
const createSnapshotButton = el<HTMLButtonElement>('createSnapshotButton');
|
||||
const mergeFileButton = el<HTMLButtonElement>('mergeFileButton');
|
||||
|
||||
let snapshot: SyncUiSnapshot | null = null;
|
||||
let activeRunId: number | null = null;
|
||||
let retryWithForce: (() => void) | null = null;
|
||||
|
||||
const DIRECTIONS: Array<{ id: SyncDirection; label: string; title: string }> = [
|
||||
{ id: 'both', label: '⇄ Two-way', title: 'Merge both databases into each other' },
|
||||
{ id: 'push', label: '⇡ Push', title: 'Only merge this machine into the device' },
|
||||
{ id: 'pull', label: '⇣ Pull', title: 'Only merge the device into this machine' },
|
||||
];
|
||||
|
||||
function describeRun(kind: string, host: string | null): string {
|
||||
if (kind === 'host-sync') return `Syncing with ${host ?? 'device'}`;
|
||||
if (kind === 'merge') return 'Merging snapshot';
|
||||
if (kind === 'snapshot') return 'Creating snapshot';
|
||||
return 'Working';
|
||||
}
|
||||
|
||||
function setRunPill(): void {
|
||||
const running = snapshot?.run.running ?? false;
|
||||
runPill.classList.toggle('running', running);
|
||||
runPill.classList.toggle('idle', !running);
|
||||
runPill.textContent = running
|
||||
? describeRun(snapshot!.run.kind ?? '', snapshot!.run.host)
|
||||
: 'Idle';
|
||||
cancelButton.classList.toggle('hidden', !running);
|
||||
}
|
||||
|
||||
function clearActivity(): void {
|
||||
stageList.replaceChildren();
|
||||
remoteOutput.textContent = '';
|
||||
remoteOutput.classList.add('hidden');
|
||||
resultCard.replaceChildren();
|
||||
resultCard.classList.add('hidden');
|
||||
}
|
||||
|
||||
function markStageStates(state: 'done' | 'failed'): void {
|
||||
for (const item of stageList.querySelectorAll('.stage-item.active')) {
|
||||
item.classList.remove('active');
|
||||
item.classList.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
function addStage(message: string): void {
|
||||
markStageStates('done');
|
||||
const item = document.createElement('div');
|
||||
item.className = 'stage-item active';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'stage-icon';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = message;
|
||||
item.append(icon, text);
|
||||
stageList.appendChild(item);
|
||||
}
|
||||
|
||||
function showResult(ok: boolean, error: string | null): void {
|
||||
markStageStates(ok ? 'done' : 'failed');
|
||||
resultCard.classList.remove('hidden');
|
||||
resultCard.classList.toggle('error', !ok);
|
||||
const title = document.createElement('div');
|
||||
title.className = 'result-title';
|
||||
title.textContent = ok ? 'Sync complete' : 'Sync failed';
|
||||
resultCard.prepend(title);
|
||||
if (!ok && error) {
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'result-error-detail';
|
||||
detail.textContent = error;
|
||||
resultCard.appendChild(detail);
|
||||
if (retryWithForce && /--force/.test(error)) {
|
||||
const retry = document.createElement('button');
|
||||
retry.className = 'ghost-button';
|
||||
retry.style.marginTop = '10px';
|
||||
retry.type = 'button';
|
||||
retry.textContent = 'Retry with --force (skip the running-app check)';
|
||||
retry.addEventListener('click', () => {
|
||||
const action = retryWithForce;
|
||||
retryWithForce = null;
|
||||
action?.();
|
||||
});
|
||||
resultCard.appendChild(retry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendMergeSummary(
|
||||
target: 'local' | 'remote',
|
||||
lines: ReturnType<typeof summarizeMergeCounts>,
|
||||
): void {
|
||||
resultCard.classList.remove('hidden');
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'result-title';
|
||||
heading.textContent = target === 'local' ? 'Merged into this machine' : 'Merged into the device';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'count-grid';
|
||||
for (const line of lines) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'count-cell';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = line.label;
|
||||
const value = document.createElement('b');
|
||||
value.textContent = String(line.value);
|
||||
cell.append(label, value);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
resultCard.append(heading, grid);
|
||||
}
|
||||
|
||||
function handleProgress(payload: SyncUiProgressPayload): void {
|
||||
if (payload.runId !== activeRunId) {
|
||||
activeRunId = payload.runId;
|
||||
clearActivity();
|
||||
activityPanel.classList.remove('hidden');
|
||||
}
|
||||
const event = payload.event;
|
||||
if (event.type === 'stage') {
|
||||
addStage(event.message);
|
||||
} else if (event.type === 'snapshot-created') {
|
||||
addStage(`Snapshot written to ${event.path}`);
|
||||
} else if (event.type === 'remote-output') {
|
||||
remoteOutput.classList.remove('hidden');
|
||||
remoteOutput.textContent += event.text;
|
||||
} else if (event.type === 'merge-summary') {
|
||||
appendMergeSummary(event.target, summarizeMergeCounts(event.summary));
|
||||
} else if (event.type === 'result') {
|
||||
showResult(event.ok, event.error);
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRun(action: () => Promise<SyncUiStartResult>): Promise<void> {
|
||||
const result = await action();
|
||||
if (!result.started) {
|
||||
activityPanel.classList.remove('hidden');
|
||||
clearActivity();
|
||||
activeRunId = null;
|
||||
showResult(false, result.reason ?? 'Could not start the sync.');
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function runHostSync(entry: SyncHostEntry): Promise<SyncUiStartResult> {
|
||||
retryWithForce = () => {
|
||||
void startRun(() => api.runSync({ host: entry.host, direction: entry.direction, force: true }));
|
||||
};
|
||||
return api.runSync({ host: entry.host, direction: entry.direction });
|
||||
}
|
||||
|
||||
function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
|
||||
const running = snapshot?.run.running ?? false;
|
||||
const syncingThis = running && snapshot?.run.host === entry.host;
|
||||
const card = document.createElement('div');
|
||||
card.className = `host-card${syncingThis ? ' syncing' : ''}`;
|
||||
|
||||
const top = document.createElement('div');
|
||||
top.className = 'host-top';
|
||||
const id = document.createElement('div');
|
||||
id.className = 'host-id';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'host-name';
|
||||
name.textContent = entry.host;
|
||||
id.appendChild(name);
|
||||
if (entry.label) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'host-label';
|
||||
label.textContent = entry.label;
|
||||
id.appendChild(label);
|
||||
}
|
||||
const status = document.createElement('div');
|
||||
status.className = 'host-status';
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `status-dot${entry.lastSyncStatus ? ` ${entry.lastSyncStatus}` : ''}`;
|
||||
const statusText = document.createElement('span');
|
||||
statusText.className = 'detail';
|
||||
const when = formatRelativeTime(entry.lastSyncAtMs, Date.now());
|
||||
statusText.textContent =
|
||||
entry.lastSyncStatus === null
|
||||
? 'never synced'
|
||||
: `${when}${entry.lastSyncDetail ? ` · ${entry.lastSyncDetail}` : ''}`;
|
||||
statusText.title = entry.lastSyncDetail ?? '';
|
||||
status.append(dot, statusText);
|
||||
top.append(id, status);
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'host-controls';
|
||||
|
||||
const toggle = document.createElement('div');
|
||||
toggle.className = 'direction-toggle';
|
||||
toggle.setAttribute('role', 'group');
|
||||
toggle.setAttribute('aria-label', `Sync direction for ${entry.host}`);
|
||||
for (const direction of DIRECTIONS) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = direction.label;
|
||||
button.title = direction.title;
|
||||
button.classList.toggle('active', entry.direction === direction.id);
|
||||
button.addEventListener('click', () => {
|
||||
void api.saveHost({ host: entry.host, direction: direction.id }).then(refreshFromState);
|
||||
});
|
||||
toggle.appendChild(button);
|
||||
}
|
||||
|
||||
const auto = document.createElement('label');
|
||||
auto.className = 'auto-toggle';
|
||||
const autoBox = document.createElement('input');
|
||||
autoBox.type = 'checkbox';
|
||||
autoBox.checked = entry.autoSync;
|
||||
autoBox.addEventListener('change', () => {
|
||||
void api.saveHost({ host: entry.host, autoSync: autoBox.checked }).then(refreshFromState);
|
||||
});
|
||||
const autoText = document.createElement('span');
|
||||
autoText.textContent = 'Auto-sync';
|
||||
auto.append(autoBox, autoText);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'host-actions';
|
||||
const syncButton = document.createElement('button');
|
||||
syncButton.type = 'button';
|
||||
syncButton.className = 'primary-button';
|
||||
syncButton.textContent = syncingThis ? 'Syncing…' : 'Sync now';
|
||||
syncButton.disabled = running;
|
||||
syncButton.addEventListener('click', () => {
|
||||
void startRun(() => runHostSync(entry));
|
||||
});
|
||||
const testButton = document.createElement('button');
|
||||
testButton.type = 'button';
|
||||
testButton.className = 'mini-button';
|
||||
testButton.textContent = 'Test';
|
||||
testButton.addEventListener('click', () => {
|
||||
testButton.disabled = true;
|
||||
testButton.textContent = 'Testing…';
|
||||
void api
|
||||
.checkHost(entry.host)
|
||||
.then((result) => renderCheckResult(result))
|
||||
.finally(() => {
|
||||
testButton.disabled = false;
|
||||
testButton.textContent = 'Test';
|
||||
});
|
||||
});
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'mini-button danger';
|
||||
removeButton.textContent = 'Remove';
|
||||
removeButton.addEventListener('click', () => {
|
||||
if (!window.confirm(`Remove ${entry.host} from saved devices?`)) return;
|
||||
void api.removeHost(entry.host).then(refreshFromState);
|
||||
});
|
||||
actions.append(syncButton, testButton, removeButton);
|
||||
|
||||
controls.append(toggle, auto, actions);
|
||||
card.append(top, controls);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderCheckResult(result: SyncUiCheckResult): void {
|
||||
checkResultEl.classList.remove('hidden', 'ok', 'fail');
|
||||
checkResultEl.classList.add(result.ok ? 'ok' : 'fail');
|
||||
checkResultEl.replaceChildren();
|
||||
const headline = document.createElement('div');
|
||||
headline.textContent = result.ok
|
||||
? `✓ ${result.host} is ready to sync`
|
||||
: `✕ ${result.host} is not ready`;
|
||||
checkResultEl.appendChild(headline);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'sub';
|
||||
if (result.ok) {
|
||||
sub.textContent = `SSH ok · remote launcher: ${result.remoteCommand ?? '?'}${
|
||||
result.remoteVersion ? ` (${result.remoteVersion})` : ''
|
||||
}`;
|
||||
} else {
|
||||
sub.textContent = result.error ?? (result.sshOk ? 'Remote launcher missing.' : 'SSH failed.');
|
||||
}
|
||||
checkResultEl.appendChild(sub);
|
||||
}
|
||||
|
||||
function renderHosts(): void {
|
||||
const hosts = snapshot?.hosts.hosts ?? [];
|
||||
hostList.replaceChildren(...hosts.map(renderHostCard));
|
||||
hostListEmpty.classList.toggle('hidden', hosts.length > 0);
|
||||
}
|
||||
|
||||
function renderSnapshots(): void {
|
||||
const files = snapshot?.snapshots ?? [];
|
||||
snapshotsDirEl.textContent = snapshot?.snapshotsDir ?? '';
|
||||
snapshotList.replaceChildren(
|
||||
...files.map((file) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'snapshot-row';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'snapshot-name';
|
||||
name.textContent = file.name;
|
||||
name.title = file.path;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'snapshot-meta';
|
||||
meta.textContent = `${formatBytes(file.sizeBytes)} · ${new Date(
|
||||
file.modifiedAtMs,
|
||||
).toLocaleString()}`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'snapshot-actions';
|
||||
const merge = document.createElement('button');
|
||||
merge.type = 'button';
|
||||
merge.className = 'mini-button';
|
||||
merge.textContent = 'Merge';
|
||||
merge.title = 'Merge this snapshot into the local database';
|
||||
merge.disabled = snapshot?.run.running ?? false;
|
||||
merge.addEventListener('click', () => {
|
||||
retryWithForce = () => {
|
||||
void startRun(() => api.mergeSnapshotFile(file.path, true));
|
||||
};
|
||||
void startRun(() => api.mergeSnapshotFile(file.path));
|
||||
});
|
||||
const reveal = document.createElement('button');
|
||||
reveal.type = 'button';
|
||||
reveal.className = 'mini-button';
|
||||
reveal.textContent = 'Reveal';
|
||||
reveal.addEventListener('click', () => {
|
||||
void api.revealSnapshot(file.path);
|
||||
});
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'mini-button danger';
|
||||
remove.textContent = 'Delete';
|
||||
remove.addEventListener('click', () => {
|
||||
if (!window.confirm(`Delete snapshot ${file.name}?`)) return;
|
||||
void api.deleteSnapshot(file.path).then(refreshFromState);
|
||||
});
|
||||
actions.append(merge, reveal, remove);
|
||||
row.append(name, meta, actions);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
snapshotListEmpty.classList.toggle('hidden', files.length > 0);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
snapshot = await api.getSnapshot();
|
||||
dbPathEl.textContent = snapshot.dbPath;
|
||||
dbPathEl.title = snapshot.dbPath;
|
||||
if (document.activeElement !== autoIntervalInput) {
|
||||
autoIntervalInput.value = String(snapshot.hosts.autoSyncIntervalMinutes);
|
||||
}
|
||||
setRunPill();
|
||||
renderHosts();
|
||||
renderSnapshots();
|
||||
createSnapshotButton.disabled = snapshot.run.running;
|
||||
mergeFileButton.disabled = snapshot.run.running;
|
||||
}
|
||||
|
||||
function refreshFromState(): void {
|
||||
void refresh();
|
||||
}
|
||||
|
||||
addHostButton.addEventListener('click', () => {
|
||||
const host = newHostInput.value.trim();
|
||||
if (!host) {
|
||||
newHostInput.focus();
|
||||
return;
|
||||
}
|
||||
const label = newLabelInput.value.trim();
|
||||
void api
|
||||
.saveHost({ host, label: label || null })
|
||||
.then(() => {
|
||||
newHostInput.value = '';
|
||||
newLabelInput.value = '';
|
||||
checkResultEl.classList.add('hidden');
|
||||
return refresh();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
renderCheckResult({
|
||||
host,
|
||||
sshOk: false,
|
||||
remoteCommand: null,
|
||||
remoteVersion: null,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
newHostInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') addHostButton.click();
|
||||
});
|
||||
|
||||
testHostButton.addEventListener('click', () => {
|
||||
const host = newHostInput.value.trim();
|
||||
if (!host) {
|
||||
newHostInput.focus();
|
||||
return;
|
||||
}
|
||||
testHostButton.disabled = true;
|
||||
testHostButton.textContent = 'Testing…';
|
||||
void api
|
||||
.checkHost(host)
|
||||
.then((result) => renderCheckResult(result))
|
||||
.finally(() => {
|
||||
testHostButton.disabled = false;
|
||||
testHostButton.textContent = 'Test connection';
|
||||
});
|
||||
});
|
||||
|
||||
autoIntervalInput.addEventListener('change', () => {
|
||||
const minutes = Number(autoIntervalInput.value);
|
||||
if (!Number.isFinite(minutes) || minutes < 1) return;
|
||||
void api.setAutoSyncInterval(Math.floor(minutes)).then(refreshFromState);
|
||||
});
|
||||
|
||||
cancelButton.addEventListener('click', () => {
|
||||
void api.cancelRun();
|
||||
});
|
||||
|
||||
createSnapshotButton.addEventListener('click', () => {
|
||||
retryWithForce = null;
|
||||
void startRun(() => api.createSnapshot());
|
||||
});
|
||||
|
||||
mergeFileButton.addEventListener('click', () => {
|
||||
void api.pickSnapshotFile().then((path) => {
|
||||
if (!path) return;
|
||||
retryWithForce = () => {
|
||||
void startRun(() => api.mergeSnapshotFile(path, true));
|
||||
};
|
||||
void startRun(() => api.mergeSnapshotFile(path));
|
||||
});
|
||||
});
|
||||
|
||||
api.onProgress(handleProgress);
|
||||
api.onStateChanged(refreshFromState);
|
||||
void refresh();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user