diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7b43f2..a057b44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,30 +47,6 @@ jobs: - name: Build (Firefox) run: pnpm build:firefox - server: - name: sync server - runs-on: ubuntu-latest - # Separate pnpm workspace with its own lockfile and its own tsconfig - # (Cloudflare Workers types), excluded from the root tsconfig — so `pnpm - # check` above does not cover it. - steps: - - uses: actions/checkout@v4 - - # No `version:` input — it reads `packageManager` from the root - # package.json, and passing both is an error. - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - run: pnpm install --frozen-lockfile - working-directory: server - - - name: Type check - run: pnpm run check - working-directory: server - # The e2e suite (e2e/run.mjs) is deliberately not run here: it needs a real # Chrome download plus generated WAV fixtures, and it is not currently # all-green — see CONTRIBUTING.md, which tracks the count. Run it locally for diff --git a/.gitignore b/.gitignore index 1776e11..afc8fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,11 +14,6 @@ stats-*.json .wxt web-ext.config.ts -# Cloudflare Workers -.wrangler -.dev.vars -.dev.vars.* - # Editor directories and files .vscode/* !.vscode/extensions.json @@ -45,3 +40,6 @@ public/worklets/pcm-tap-worklet.js # Generated at postinstall / build:before. See scripts/build-vocal-worklet.mjs. public/worklets/vocal-reducer-worklet.js export/ + +# pnpm store created when a local store path is set +.pnpm-store/ diff --git a/CLAUDE.md b/CLAUDE.md index 08d2dc9..29eb722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ pnpm dev:firefox # same, Firefox pnpm check # svelte-check / TypeScript — the only type/lint gate pnpm build # production build → .output/chrome-mv3 pnpm zip # store package -pnpm test:dsp # fast DSP unit tests (node --test on src/features/**/*.test.ts) +pnpm test:dsp # fast unit tests: DSP, chords, library and sync records (node --test on src/**/*.test.ts) pnpm release:dry # show the release plan (version bump, tag) without changing anything pnpm release # full release: check + test, bump patch, build both zips, commit, tag, push ``` @@ -35,25 +35,23 @@ pnpm dlx @puppeteer/browsers install chrome@stable --path ./.browsers # once node e2e/make-tone.mjs ; node e2e/make-stereo-mix.mjs # once, generates WAV fixtures pnpm wxt build --mode testing # `testing` mode grants host perms so no native prompts block the run node e2e/run.mjs # add --headful to watch +node e2e/library.mjs # background library + sync integration (pnpm test:e2e:library) ``` The harness plays a 440 Hz tone and asserts on the **processed output** (e.g. 880 Hz after +12 st) via `window.__noteByNoteDebug` in the content script and `window.__panelDebug` in the side panel. -### Sync server (`server/`, separate pnpm workspace) -`cd server ; pnpm install` — it has its own lockfile, `tsconfig.json` (Cloudflare Workers types), and is `exclude`d from the root tsconfig. See [server/README.md](server/README.md) for deploy. `pnpm run dev` there serves `http://localhost:8787`, which the extension's dev build targets automatically. - ## Architecture This is a **multi-context extension**. The single most important structural fact: **the audio engine lives in the page (content script), not in the side panel.** The side panel is a thin UI mirror that connects to the engine over a typed `chrome.runtime` Port. This is why practice flows (loops, sequences, playback) survive the side panel closing. ### Source layout (vertical feature slices) The tree is organized by **feature**, not by layer: -- **`src/core/`** — shared platform: `engine/` (controller, media-engine/-detect, attach-audio), `audio/` (pipeline, fft, silence-detector), `messaging/` (protocol shell, ports, rpc), `model/` (shared types + defaults + format + track-identity + thumbnail), `persist/` (storage, backup, track-data descriptor registry), `state/` (session, track-sync, connect, view), and `features.ts` (the panel-feature registry). -- **`src/features//`** — one folder per product feature (chords, pitch, speed, vocal-reducer, eq, loops, markers, snippets, count-in, library, sync, settings, shortcuts), each with an `engine/` subfolder (content-script code: worklets, schedulers, DSP factories) and/or a `panel/` subfolder (side-panel stores + components), plus optional `protocol.ts` (its wire-message fragment), `panel/panel.ts` (registration object), and `persist.svelte.ts` (per-track descriptor). **`engine/` and `panel/` never cross-import**, so the content and panel bundles stay separate. +- **`src/core/`** — shared platform: `engine/` (controller, media-engine/-detect, attach-audio), `audio/` (pipeline, fft, silence-detector), `messaging/` (protocol shell, ports, rpc), `model/` (shared types + defaults + format + track-identity + thumbnail), `persist/` (library, backup, migration), and `state/` (session, library, track-sync, connect, view). +- **`src/features//`** — one folder per product feature (chords, pitch, speed, vocal-reducer, eq, loops, markers, snippets, count-in, library, sync, settings, shortcuts), each with an `engine/` subfolder (content-script code: worklets, schedulers, DSP factories) and/or a `panel/` subfolder (side-panel stores + components), plus optional `protocol.ts` (its wire-message fragment). **`engine/` and `panel/` never cross-import**, so the content and panel bundles stay separate. - **`src/ui/`** — shared/presentational UI (Workspace, Panel, PanelStack, Timeline, chrome bars, `shared/` primitives, icons, dismiss). - **`src/dev/`** — preview-only helpers (`browser-shim`, `mock`). - **`src/entrypoints/`** — thin WXT composition roots (unchanged location). -**Dependency direction:** `entrypoints → core composition roots (pipeline, controller, protocol, App, features.ts, track-sync) → features → core primitives (model, messaging, audio/fft, ui)`. Composition roots **import feature contributions** (the "light registration"); **features never import the orchestrators**. Domain types stay central in `core/model/types.ts` (they are the shared engine↔panel wire + persistence contract). +**Dependency direction:** `entrypoints → core composition roots (pipeline, controller, protocol, App, track-sync) → features → core primitives (model, messaging, audio/fft, ui)`. Composition roots wire feature behavior directly; features never import the orchestrators. Domain types stay central in `core/model/types.ts` (the shared engine↔panel contract). ### Execution contexts (`src/entrypoints/`) - **`sidepanel/`** — the Svelte UI. Holds no engine state of its own; mirrors the active tab's engine. @@ -93,17 +91,59 @@ Both worklet processors are shipped as **static files under `public/worklets/`** ### State layer (Svelte 5 runes stores, `*.svelte.ts`) Runes stores (classes with `$state`), one singleton exported per file. All panel-side. Split by ownership: -- **Core (`src/core/state/`):** `session` — mirror of the active tab's engine + the command surface panels call (while no engine is attached, commands fall back to **optimistic local state**, staged and pushed on connect); `connection` (`connect.svelte.ts`) — owns the port lifecycle (one `` prompt from the banner's Connect button in a user gesture, injection, reconnect, capture start/stop; a `#generation` counter drops stale async work) and iterates the **panel-feature registry** ([core/features.ts](src/core/features.ts)) to route engine events into feature stores; `track-sync` — reacts to track changes (auto-save to Recent, reset/remember/carry-over params) and iterates the **per-track descriptor registry** ([core/persist/track-data.ts](src/core/persist/track-data.ts)) to swap each feature's slice in/out of storage; `view`. +- **Core (`src/core/state/`):** `session` mirrors the active tab's engine and provides panel commands; `library` holds the panel's single saved-data snapshot; `connection` owns permissions, injection, port lifecycle and capture, routing chord events directly; `track-sync` loads saved practice data and wires feature edits; `view` selects the open panel. - **Feature-owned (`src/features//panel/`):** `markers`, `snippets`, `chords`, `settings`, `favorites`/`history` (library), `eq-presets`, `shortcuts`. Preview data (`mock`) lives in `src/dev/`. -- Features contribute boot init + event routing via `panel/panel.ts` (registered in `core/features.ts`) and per-track persistence via `persist.svelte.ts` (registered in `core/persist/track-data.ts`). +- App loads the library once. Settings, UI preferences, presets and song lists derive from it; App effects apply the theme and send engine settings. Features submit per-track edits through track-sync. UI preferences are device-local: the panel shows an edit at once and drops the overlay when the storage watch confirms it, so a toggle never waits on the worker. ### Persistence & sync -- [storage.ts](src/core/persist/storage.ts) — WXT `storage.defineItem` wrappers (the full storage schema stays central here). **Per-track data is keyed by a normalized track identity** ([track-identity.ts](src/core/model/track-identity.ts)): site-aware URL normalization (strips `t`/`si`/`utm_*` etc.; collapses YouTube to `watch?v=`) + rounded duration, hashed to `local:track:`. The per-track `TrackData` record is assembled/scattered by feature descriptors ([core/persist/track-data.ts](src/core/persist/track-data.ts)). EQ presets and granted origins live in their own items so "Reset Settings" can't wipe them. -- Optional **cross-device sync** (`src/features/sync/` + `server/`): last-write-wins backup snapshots to a Cloudflare Worker + KV. The secret sync ID **is the whole capability** (open CORS, no other auth). The Worker URLs live once in [sync-hosts.ts](src/features/sync/sync-hosts.ts) (env-free, so `wxt.config.ts` imports it for the manifest); [endpoint.ts](src/features/sync/endpoint.ts) picks localhost in dev, the deployed Worker in prod. The ID rides `storage.sync` between devices and is additionally kept as a **cookie on the sync host** so it survives an uninstall ([id-cookie.ts](src/features/sync/panel/id-cookie.ts) is the canonical explanation). That needs the `cookies` permission plus host access to the sync origin, which is an **optional** host permission requested from the Sync settings / on enable / on connect (a required one would disable the extension on update in Chrome and is opt-in on Firefox); `sync.durable` mirrors whether it is held. The background worker filters the sync host out of the site-grant machinery (`siteOrigins` in [background.ts](src/entrypoints/background.ts)) so it is neither registered for the engine nor removed by Revoke Permissions. + +- One local library owns shared songs/settings/presets/order and device-local Recent, + UI preferences, last-used parameters and analysis. See core/persist/library.ts. +- The background service is the only writer (library-background.ts). Panels use + library-client.ts commands and one storage watch. Commands patch the latest saved + data; Recent and Favorites are projections, not persistent song copies. A saved + song outlives Recent (its practice returns when the page is replayed, which is + what Auto Save off relies on); clearing history is what removes every + non-favorited song, listed or not. +- Track-sync loads a practice session once and submits edits to the saved library. + Restoration uses the initialized panel mirror and does not emit user edits. + Parameter, marker and snippet edits capture their track and values before being + coalesced; pending patches remain readable until the library watch acknowledges + their committed revision. Switching tracks or hiding the panel flushes the batch. + Receiving remote changes never reloads or silently replaces the active session. + Explicit imports reload active sessions and advance a device-local import + revision; the writer rejects practice edits carrying an older revision. + Feature persistence is wired directly in track-sync; there is no descriptor registry. +- The writer validates the resulting library before every command commit. Connection + failures use connection state; only library initialization can open recovery. +- Sync copies the same SharedLibrary snapshot used locally (records.ts). One + updatedAt timestamp chooses the whole winner; equal dates adopt the remote copy. + There are no field merges or deletion markers. Song dates only support display + and sorting. Gzip data spans fixed size-limited slots; a hash prevents partial or + mixed snapshots from being applied. An over-budget upload drops the least + recently used non-favorited songs (fitSnapshot bisects for the smallest cut) and + saves that re-dated trimmed copy locally, so the library and the upload stay one + snapshot; nothing is trimmed while sync is off, and an overflow of favorites + alone still fails, preserving local data and the last successful upload. + Background alarms retry independently of panels. + Identical snapshots do not rewrite storage or toggle sync status. Missing + headers are recovered from complete gzip chunks; partial headerless uploads + get a persisted grace period before repair from the complete local copy. +- Backups use the readable v2 library schema. legacy-backup.ts reads the + released v1 format, and + library-migration.ts collapses its old copies once. + Only released formats need compatibility adapters; intermediate PR formats do not. + Old local storage is retained for recovery, but only local:library is used after + migration. + Automatic legacy migration salvages fields independently (library-recovery.ts). + Invalid current libraries remain untouched and open a recovery screen; a valid + recovery import retains the damaged value under local:libraryRecovery. +- Web identity uses provider ID/normalized URL. Title and duration are metadata. + Local files retain a filename discriminator independent of the extension URL. ## Conventions & gotchas - Path alias `@/` → `src/` (so `@/core/*`, `@/features/*`, `@/ui/*`, `@/dev/*` all resolve). WXT provides the `#imports` virtual module (`storage`, `defineBackground`, `defineContentScript`, the `browser` global) — no explicit import of `browser`. -- **`@/` does not work in two contexts** (they don't share the WXT/Vite resolver): the `node --test` DSP files (`src/features/**/*.test.ts` and the modules they import as *values* — `fft.ts`, `center-cut-dsp.ts`, `detect-bpm.ts`) must use **relative imports with explicit `.ts` extensions**; the esbuild worklet bundles (`src/features/*/engine/*.worklet.ts`) must use **relative imports**. (`import type` is erased, so type-only imports may omit the extension.) +- **`@/` does not work in two contexts** (they don't share the WXT/Vite resolver): the `node --test` files (`src/**/*.test.ts` and the modules they import as *values* — `fft.ts`, `center-cut-dsp.ts`, `detect-bpm.ts`, `backup-codec.ts`, `sync/persist/records.ts`, `core/persist/{library,library-migration,legacy-backup,rekey}.ts`, and what those pull in: `defaults.ts`, `thumbnail.ts`, `track-identity.ts`) must use **relative imports with explicit `.ts` extensions**; the esbuild worklet bundles (`src/features/*/engine/*.worklet.ts`) must use **relative imports**. (`import type` is erased, so type-only imports may omit the extension.) - **Both browsers build MV3** (`manifestVersion: 3` is pinned in [wxt.config.ts](wxt.config.ts) — Firefox would otherwise default to MV2 and drop `optional_host_permissions`). Chromium-only APIs are gated on the build-time flags in [core/platform.ts](src/core/platform.ts) (`CAN_CAPTURE_TAB`, `HAS_SIDE_PANEL_API`), never on runtime `browser.*` probes: Firefox has no `tabCapture`/`offscreen` (so no capture fallback — the offscreen entrypoint is excluded from that build) and no `sidePanel` (the same page is registered as `sidebar_action`). Panel-side the capability travels as a **prop**: an absent `oncapture`/`ontabaudio` is what makes the shared UI drop the affordance. - Debug globals are named `__noteByNote*` / `__panelDebug`. The processor name literal is `note-by-note-center-cut` and **must match on both sides** (`vocal-reducer.worklet.ts` registers it, `vocal-reducer.ts` constructs it) — mismatches throw `InvalidStateError` at runtime and `tsc` won't catch them. - A `MediaElementSource` can be created only once per element per document lifetime, so **extension reloads require a page reload** to reattach. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ce953f..8a4f175 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,13 @@ files. ```powershell pnpm check # svelte-check / TypeScript — the only type or lint gate -pnpm test:dsp # node --test, the DSP unit tests +pnpm test:dsp # node --test, the unit tests (DSP, chords, library, backup migration, sync records) pnpm build # production build → .output/chrome-mv3 ``` -CI runs all three on every pull request, plus the Firefox build and `server/`'s -own `pnpm run check`. Run them locally first anyway; the -turnaround is much faster than waiting on a runner. (A first-time contributor's +CI runs all three on every pull request, plus the Firefox build. Run them +locally first anyway; the turnaround is much faster than waiting on a runner. +(A first-time contributor's workflow run needs a maintainer to click approve, so it may sit for a bit.) There is no ESLint or Prettier config, deliberately — match the style of the code around you. @@ -54,10 +54,11 @@ For the browser-level e2e suite (it plays a 440 Hz tone and asserts on the - **A media element can host exactly one `MediaElementSourceNode`** for the lifetime of the document. Reloading the extension therefore means reloading the page too. -- **The e2e suite is not currently all-green** — 22 of 30. The audio path passes - end to end; the failures are in marker chips, loop and sequence bounds, the +- **The e2e suite is not currently all-green.** The audio path passes end to + end; the failures are in marker chips, loop and sequence bounds, the tab-capture CTA, and the vocal-reducer control. They pre-date any change you - are about to make; compare against a clean checkout before assuming otherwise. + are about to make; compare the tally against a clean checkout before assuming + otherwise. ## Architecture in one paragraph diff --git a/PRIVACY.md b/PRIVACY.md index 22a7f6d..83b13cc 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -32,19 +32,24 @@ it without uninstalling. ## What is transmitted, and when -The extension makes exactly one kind of network request: the optional -**cross-device sync** backup. Nothing else in the extension talks to the network. +The extension makes **no network requests of its own**. The one thing that +leaves your device is the optional **cross-device sync** copy, and it leaves +through your browser's own sync (Chrome sync, Firefox Sync) — the same channel +that carries your bookmarks — to the other devices signed into the same +browser profile. If browser sync is off, nothing leaves the device. -Sync is **on by default**, but it only starts transmitting once you have -something to sync. When it does, it uploads a single snapshot containing: +Sync is **on by default**. It writes a compressed library snapshot into the +browser's synced extension storage containing: -- your settings and UI preferences -- your EQ presets -- your **Recent** and **Favorites** lists — including the **page URL and title** - of tracks you practised -- your per-track data — markers and labels, loop ranges, snippets, chord charts +- settings and EQ presets +- saved songs, including page URLs, titles, durations and thumbnail URLs +- favorite membership and manual order +- saved practice parameters, markers, labels and snippets -Because that snapshot contains the addresses of pages you have visited, the +Recent activity, UI layout, last-used parameters and generated chord analysis +stay on the device. They are included in manual backup exports. + +Because that data contains the addresses of pages you have visited, the Firefox listing declares the `browsingActivity` data-collection category. **Not included:** audio, page content, keystrokes, browsing history beyond the @@ -52,37 +57,33 @@ tracks you practised on, or anything identifying you personally. ### Where it goes -Snapshots are stored by a Cloudflare Worker with Cloudflare KV, at -`https://note-by-note-sync.oapp.workers.dev`, operated by the author. The server -source is in [`server/`](server/) and can be self-hosted — self-hosters change -one constant and rebuild. - -- There are no accounts. A random 43-character **sync ID** is the only - credential; it *is* the capability, so treat it like a password. -- The ID travels in an `X-Sync-Id` header, never in the URL, so it does not land - in request logs. KV is keyed by its SHA-256, so the raw ID is not stored either. -- Snapshots are stored **unencrypted**. Whoever operates the server can read - them. Run your own if that matters to you. -- Snapshots expire after **180 days**, refreshed on every write. -- IP addresses are visible to Cloudflare as part of serving and rate-limiting - requests, per Cloudflare's own data handling. They are not stored by the - Worker or linked to a snapshot. +Into your browser vendor's sync storage, under your browser account, subject to +that vendor's own data handling (Google for Chrome sync, Mozilla for Firefox +Sync — the latter end-to-end encrypted). The author operates no server and can +see none of it. There are no accounts with us and no sync ID. + +The browser caps synced storage at 100 KB per extension and 8 KB per item. +The snapshot is split into size-limited pieces. If it exceeds the total capacity, +Settings reports an error. All data remains saved locally; no songs are +automatically trimmed. The newest complete library replaces older copies, so +edits made on two devices at once can overwrite each other. ### Turning it off and deleting the data -- `Settings → Sync` turns sync off. No further data is transmitted. -- `Settings → Sync → Delete synced data` removes the server-side copy. -- Doing nothing also works: an unused snapshot expires after 180 days. +- `Settings → Sync` turns sync off on that device. No further data is written. +- `Settings → Sync → Delete synced data` empties the synced copy (other devices + with sync still on will write theirs again). +- Uninstalling the extension makes the browser remove its synced storage. ## Permissions and why | Permission | Why | | --- | --- | -| `storage` | Saves your markers, loops, snippets and settings on your device. | +| `storage` | Saves your markers, loops, snippets and settings on your device, and — through the browser's synced storage — carries them to your other devices. | +| `alarms` | Retries background sync while the panel is closed. | | `activeTab`, `scripting` | Injects the audio engine into the tab when you press **Connect**. | | `tabs` | Reads the active tab's URL and title to look up the practice data you saved for that track. | | `tabCapture`, `offscreen` (Chrome only) | Fallback audio path for pages that block the audio worklet. | -| `cookies` + access to the sync server's domain (optional) | Keeps a copy of your sync ID as a cookie on the sync server's domain, the one place the browser does not wipe when the extension is uninstalled — so a reinstall gets your data back. Access to that domain is requested from `Settings → Sync` (or comes with **Connect**), never at install time. The cookie is read only through the extension API and never sent with a request (the sync ID travels in a header). No other site's cookies are touched; `Delete synced data` removes it. | | Access to all sites (optional) | Requested **only** when you first press **Connect**, never at install time, because you choose which sites to practise on. `Settings → Revoke Permissions` takes it back. | ## Children diff --git a/PUBLISHING.md b/PUBLISHING.md index 8f7df87..c4a1855 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -44,7 +44,8 @@ review (a few days; `` + tab capture can stretch it). Only revisit the **Privacy practices** tab if permissions or data use changed — keep it consistent with the manifest's `data_collection_permissions` -(*browsing activity*: sync uploads Recent/Favorites, which carry URLs/titles). +(*browsing activity*: sync ships saved songs and Favorites, which carry +URLs/titles; Recent stays on the device). ## 3 — Firefox Add-ons (AMO) diff --git a/README.md b/README.md index 3a4a377..174867e 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ draws a chart under the timeline. **Keeping your place.** Settings are stored per track against a normalized URL, so reopening a video brings back its pitch, speed, markers, loops and snippets — however you open it, not just from the library. Favorites and -recents live in a library tab, and optional cross-device sync pushes a snapshot -to a small Cloudflare Worker. +recents live in a library tab, and optional cross-device sync carries saved practice data and favorites +to your other browsers through the browser's own sync — no server, no account. ## Installing it @@ -116,8 +116,8 @@ the engine. ## Tests -`pnpm test:dsp` runs the DSP unit tests under `node --test`: the center-cut -math, the CQT, chord decoding. Fast, no browser. +`pnpm test:dsp` runs the unit tests under `node --test`: the center-cut +math, the CQT, chord decoding, library migration, snapshot selection, backups and sync transport. Fast, no browser. The e2e harness is the interesting one. It launches Chrome for Testing with the extension installed, plays a 440 Hz tone, and asserts on the *processed output* — @@ -162,40 +162,47 @@ the other way round. extension means reloading the page too. - `note-by-note-center-cut` is a string literal on both sides of the worklet boundary — `tsc` won't catch a mismatch, you'll get an `InvalidStateError`. -- The e2e suite passes 22 of 30 checks. The audio path is solid; the failures - are in marker chips, loop/sequence bounds, the tab-capture CTA and the - vocal-reducer control — known and pre-existing. - -## Sync server - -`server/` is a separate pnpm workspace (its own lockfile and tsconfig): a -Cloudflare Worker plus KV storing one backup snapshot per sync ID, last write -wins. There are no accounts. The 43-character sync ID *is* the credential, so -treat it like a password. Deploy notes in [server/README.md](server/README.md). - -The ID travels in an `X-Sync-Id` header rather than the URL, because URLs are -recorded verbatim by request logs, and KV is keyed by its SHA-256 so the raw -token isn't stored either. Writes are rate-limited per IP and snapshots carry a -180-day TTL, refreshed on every write. - -A snapshot is your settings, UI preferences, EQ presets, Recent and Favorites -(page URL, title, duration, thumbnail URL) and per-track data (markers with your -labels, loop ranges, snippets, chord charts). **No audio, ever.** It is stored -unencrypted, so whoever operates the Worker can read it — run your own if that -matters to you. Sync is on by default but only mints an ID once you have -something to sync; `Settings → Sync → Delete synced data` removes the server -copy. Nothing else in the extension talks to the network: there is no telemetry -and no analytics. - -The ID lives in the browser's synced extension storage, so a second device on -the same profile picks it up by itself. The browser wipes that storage (on -every device) when the extension is uninstalled, so the ID can also be kept as -a cookie on the sync server's domain, which outlives the extension — that is -what the `cookies` permission and the optional access to that one domain are -for (`Settings → Sync → Keep the ID after a reinstall`; the **Connect** grant -covers it too). How and why, and what it does not survive, is documented in -[id-cookie.ts](src/features/sync/panel/id-cookie.ts). Self-hosters change the -URL in [sync-hosts.ts](src/features/sync/sync-hosts.ts) and rebuild. +- `node e2e/run.mjs` prints a pass/fail tally for the playback suite; + `node e2e/library.mjs` (`pnpm test:e2e:library`) additionally checks concurrent + library edits, remote updates, restart recovery and sync capacity. + +## Sync + +Saved practice data, favorites, EQ presets and settings sync through the browser's +own extension storage. Recent, panel layout, last-used parameters and generated +chord analysis stay on the device. Audio is never transferred. + +One saved song owns its parameters, markers and snippets. Recent and Favorites +are views of that song, so there are no saved-settings copies to keep aligned. +The background is the only library writer; it handles edits, imports and remote +updates even when the panel is closed. Each panel loads and watches one library; +settings, UI preferences, presets and song lists read that same copy. +An active practice session keeps its loaded configuration when sync arrives. +An explicit backup import reloads open songs with the imported practice settings +and cancels pending edits from before the import. Sync never reloads the panel. + +Local storage and sync use the same shared library snapshot, with one timestamp. +The most recently edited snapshot replaces the older copy in full. On equal +timestamps the synced copy wins. Edits made on two devices at once can overwrite +each other, even when they affect different songs; there are no field merges or +deletion markers. + +The snapshot is gzip-compressed and split across fixed storage slots to fit the +browser's 8 KB item limit. A content hash ensures all parts belong to the same +complete snapshot before it is used. If the library exceeds sync capacity, +Settings reports an error and retains the complete local library and the last +successful synced copy. Songs are not automatically discarded to make it fit. +Export a backup to transfer all data, including local history and analysis. + +Backups are readable version-2 JSON containing shared and local sections. Imports +also accept the released version-1 format, converting it once. +Importing a backup replaces the entire library and dates it +as a new edit for sync, including removal of songs absent from the file. Old local +storage is retained as a recovery copy after the first migration. Upgrade all +devices before using the new sync format; older builds cannot read it. +Automatic migration recovers valid songs and fields independently of damaged +records. If a saved library cannot be opened, the panel offers retry, recovery +export and backup import; importing retains a copy of the damaged library locally. ## License diff --git a/SECURITY.md b/SECURITY.md index 902325b..1154d58 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,39 +19,29 @@ credited in the advisory unless you would rather not be. ## What is in scope -The extension itself, and the sync server under [`server/`](server/): +The extension itself: - Escaping the extension's boundaries — anything that lets a web page reach privileged extension APIs through the content script or the message port - Anything that causes the extension to grant, keep, or widen host permissions beyond what the user approved -- Cross-user data access on the sync server, or anything that lets a snapshot - be read or written without its sync ID - Injection through data the extension stores and later renders — track titles, - marker labels, thumbnail URLs, restored sync snapshots + marker labels, thumbnail URLs, synced records and imported backups ## What is not a vulnerability Two properties look like bugs but are the documented design. Reports of these will be closed as working-as-intended: -- **The sync ID is the entire capability.** There are no accounts. Anyone - holding the 43-character random ID can read and overwrite that snapshot, and - the server has open CORS and no other authentication. This is deliberate — - the ID is the secret, and the trade-off is spelled out in - [PRIVACY.md](PRIVACY.md). Reports that it is guessable need to show it is - actually guessable. -- **Sync is last-write-wins.** Two devices editing at once means one loses. - That is a data-loss property, not a security boundary; file it as a bug if - you can trigger it in a way the design does not predict. - -Also out of scope: findings against `note-by-note-sync.oapp.workers.dev` that -require volumetric traffic, and anything that needs the user to already be -running attacker-controlled code locally. - -## A note on scanning - -Please do not run automated scanners or load tests against the hosted sync -Worker — it is a personal Cloudflare account. The server is small and -self-hostable ([`server/README.md`](server/README.md)); test against your own -deployment instead. +- **Sync trusts the browser profile.** There is no server and no credential of + ours: the synced copy lives in the browser's own synced extension storage, + so whoever can sign into the browser profile can read and change it — the + same boundary as the bookmarks. The trade-off is spelled out in + [PRIVACY.md](PRIVACY.md). +- **Sync merges by recency.** Two devices editing the same song at once means + the later edit wins that song. That is a data-loss property, not a security + boundary; file it as a bug if you can trigger it in a way the design does + not predict. + +Also out of scope: anything that needs the user to already be running +attacker-controlled code locally. diff --git a/e2e/library.mjs b/e2e/library.mjs new file mode 100644 index 0000000..49aaf4f --- /dev/null +++ b/e2e/library.mjs @@ -0,0 +1,256 @@ +/** Background-library integration checks in an isolated Chrome profile. + * Run after `wxt build --mode testing`: node e2e/library.mjs */ +import assert from 'node:assert/strict'; +import { globSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer-core'; +import { emptyLibrary, applyCommand } from '../src/core/persist/library.ts'; +import { makeTrackIdentity } from '../src/core/model/track-identity.ts'; +import { DEFAULT_PARAMS, DEFAULT_UI_PREFS } from '../src/core/model/defaults.ts'; +import { encodeSnapshot, readSnapshot, SNAPSHOT_KEY, PREFIX } from '../src/features/sync/persist/records.ts'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const extension = resolve(root, '.output', 'chrome-mv3-testing'); +const executablePath = globSync(resolve(root, '.browsers', 'chrome', '*', 'chrome-win64', 'chrome.exe'))[0]; +if (!executablePath) throw new Error('Chrome for Testing not found under .browsers/'); +const profile = mkdtempSync(join(tmpdir(), 'note-by-note-library-')); +const launch = () => puppeteer.launch({ executablePath, headless: !process.argv.includes('--headful'), userDataDir: profile, protocolTimeout: 30000, + args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`, '--mute-audio'] }); +let browser; +const panelErrors = []; +async function panel() { + const target = await browser.waitForTarget((target) => target.type() === 'service_worker'); + const id = new URL(target.url()).host; + const page = await browser.newPage(); + page.on('pageerror', (error) => { panelErrors.push(error.message); console.error('Panel error:', error.message); }); + await page.goto(`chrome-extension://${id}/sidepanel.html?mock=1`); + return page; +} +async function rpc(page, type, data) { + return page.evaluate(async ({ type, data }) => { + const result = await chrome.runtime.sendMessage({ type, data, timestamp: Date.now(), id: 1 }); + if (result?.err) throw new Error(JSON.stringify(result.err)); + return result?.res; + }, { type, data }); +} +const read = (page) => rpc(page, 'libraryRead'); +const edit = async (page, command) => rpc(page, 'libraryEdit', + command.type === 'practice' || command.type === 'chart' + ? { ...command, importRevision: (await read(page)).local.importRevision ?? 0 } : command); +const sync = (page, action) => rpc(page, 'librarySync', action); +const identity = (n) => makeTrackIdentity(`https://youtube.com/watch?v=integration${n}`, `Song ${n}`, 200); + +try { + browser = await launch(); + const first = await panel(); + const second = await panel(); + await sync(first, 'disable'); + await edit(first, { type: 'import', library: emptyLibrary() }); + + // Exercise App's real startup without an actual website or player. A failed + // injection RPC must leave the library usable and show connection state. + const disconnected = await browser.newPage(); + disconnected.on('pageerror', (error) => panelErrors.push(error.message)); + await disconnected.evaluateOnNewDocument(() => { + chrome.tabs.get = (_id, callback) => { + const tab = { id: 123, url: 'https://example.test/song' }; + if (callback) { callback(tab); return; } + return Promise.resolve(tab); + }; + chrome.permissions.contains = (_permissions, callback) => { + if (callback) { callback(true); return; } + return Promise.resolve(true); + }; + const send = chrome.runtime.sendMessage.bind(chrome.runtime); + chrome.runtime.sendMessage = (...args) => { + if (args[0]?.type === 'ensureInjected') { + globalThis.__injectionFailed = true; + throw new Error('Simulated injection RPC failure'); + } + return send(...args); + }; + }); + const beforeConnection = await read(first); + await disconnected.goto(first.url().replace('?mock=1', '?tabId=123')); + await disconnected.waitForSelector('button[aria-label="Settings"]'); + await disconnected.waitForFunction(() => globalThis.__injectionFailed && globalThis.__panelDebug?.connection() === 'stale'); + assert.equal(await disconnected.$('main[aria-label="Library recovery"]'), null); + assert.deepEqual(await read(disconnected), beforeConnection); + await disconnected.close(); + console.log('PASS injection failure keeps the library open and uses normal connection state'); + + await first.bringToFront(); + await first.waitForSelector('button[aria-label="Settings"]'); + await first.click('button[aria-label="Settings"]'); + await first.waitForSelector('button[aria-label="Light"]'); + await first.click('button[aria-label="Light"]'); + await Promise.all([first, second].map((page) => page.waitForFunction(() => document.documentElement.dataset.theme === 'light', { polling: 50 }))); + assert.equal((await read(first)).shared.settings.theme, 'light'); + await edit(second, { type: 'settings', patch: { theme: 'dark' } }); + await first.waitForFunction(() => document.querySelector('button[aria-label="Dark"]')?.getAttribute('aria-checked') === 'true'); + await first.click('button[aria-label="Close settings"]'); + console.log('PASS settings controls and both panel themes follow the saved library'); + + const collapse = 'section[aria-label="Looper"] button[aria-expanded]'; + await first.waitForSelector(collapse); + await first.click(collapse); + await second.waitForFunction((selector) => document.querySelector(selector)?.getAttribute('aria-expanded') === 'false', { polling: 50 }, collapse); + assert.equal((await read(first)).local.uiPrefs.collapsedSections.looper, true); + await second.bringToFront(); + await second.click(collapse); + await first.waitForFunction((selector) => document.querySelector(selector)?.getAttribute('aria-expanded') === 'true', { polling: 50 }, collapse); + console.log('PASS preference controls update both panels through one library watch'); + + await edit(first, { type: 'preset', name: 'Study EQ', gains: [1, 2, 3] }); + await Promise.all([first, second].map((page) => page.waitForSelector('select[aria-label="EQ preset"] option[value="Study EQ"]'))); + await edit(second, { type: 'preset', name: 'Study EQ', gains: null }); + await first.waitForFunction(() => !document.querySelector('select[aria-label="EQ preset"] option[value="Study EQ"]'), { polling: 50 }); + console.log('PASS preset options follow library additions and deletions'); + + await Promise.all(Array.from({ length: 12 }, (_, n) => edit(n % 2 ? first : second, { + type: 'practice', identity: identity(n), patch: { params: { ...DEFAULT_PARAMS, speed: 0.8 } }, recent: true, + }))); + assert.equal(Object.keys((await read(first)).shared.songs).length, 12); + console.log('PASS concurrent panels preserve all 12 songs'); + + const beforeInvalid = await read(first); + await assert.rejects(edit(first, { type: 'practice', identity: identity(0), + patch: { markers: [{ id: 'invalid', t: null, label: 'Invalid time' }] }, recent: true }), /Damaged library number/); + assert.deepEqual(await read(first), beforeInvalid); + console.log('PASS invalid edits leave the persisted library unchanged'); + + await Promise.all([ + edit(first, { type: 'practice', identity: identity(0), patch: { markers: [{ id: 'm', t: 5, label: 'Verse' }] }, recent: true }), + edit(second, { type: 'practice', identity: identity(0), patch: { params: { ...DEFAULT_PARAMS, speed: 0.5 } }, recent: true }), + ]); + const saved = await read(first); + assert.equal(saved.shared.songs[identity(0).key].practice.markers[0].t, 5); + assert.equal(saved.shared.songs[identity(0).key].practice.params.speed, 0.5); + console.log('PASS concurrent commands patch the latest record'); + + await first.evaluate(() => { window.libraryReloadSentinel = 42; }); + const remote = applyCommand(saved, { type: 'favorite', key: identity(0).key, value: true }, Date.now() + 1000); + const { items: remoteItems } = await encodeSnapshot(remote.shared); + await first.evaluate((items) => chrome.storage.sync.set(items), remoteItems); + await sync(first, 'enable'); + const received = await read(first); + assert.deepEqual(received.shared, remote.shared); + assert.deepEqual(received.local, saved.local); + assert.equal(await first.evaluate(() => window.libraryReloadSentinel), 42); + console.log('PASS remote updates preserve local data and do not reload the panel'); + + await sync(first, 'disable'); + const replacement = applyCommand(emptyLibrary(), { type: 'preset', name: 'Remote only', gains: [1] }, remote.shared.updatedAt + 1000); + const { items: replacementItems } = await encodeSnapshot(replacement.shared); + // Header arrives before the content. Neither the old data nor a partial + // replacement may be uploaded over this newer snapshot. + await first.evaluate(({ key, header }) => chrome.storage.sync.set({ [key]: header }), + { key: SNAPSHOT_KEY, header: replacementItems[SNAPSHOT_KEY] }); + await sync(first, 'enable'); + assert.deepEqual((await read(first)).shared, remote.shared); + assert.match(await first.evaluate(async () => (await chrome.storage.local.get('syncConfig')).syncConfig.lastError), /complete synced library/); + await first.evaluate((items) => chrome.storage.sync.set(items), replacementItems); + await sync(first, 'now'); + assert.deepEqual((await read(first)).shared, replacement.shared); + console.log('PASS incomplete arrivals wait; the newer snapshot replaces the whole library'); + + const edited = { type: 'practice', identity: identity(98), patch: {}, recent: true }; + await edit(first, edited); + const newest = await read(first); + await first.evaluate(async ({ items }) => { + const { syncConfig } = await chrome.storage.local.get('syncConfig'); + await chrome.storage.local.set({ syncConfig: { ...syncConfig, lastPushAt: 0 } }); + await chrome.storage.sync.set(items); + }, { items: remoteItems }); + await sync(first, 'now'); + assert.deepEqual(await readSnapshot(await first.evaluate(() => chrome.storage.sync.get(null))), newest.shared); + console.log('PASS an older remote snapshot is replaced by the newer local copy'); + + await sync(first, 'disable'); + await first.evaluate(() => chrome.storage.sync.clear()); + await edit(first, { type: 'practice', identity: identity(99), patch: {}, recent: true }); + const persisted = await read(first); + // Missing UI preferences must restore defaults without losing saved work. + // Close the panels before seeding raw storage, so their watches only see + // normalized data from the background when they reopen. + const worker = await (await browser.waitForTarget((target) => target.type() === 'service_worker')).worker(); + await first.close(); + await second.close(); + await worker.evaluate(async () => { + const { library } = await chrome.storage.local.get('library'); + delete library.local.uiPrefs; + await chrome.storage.local.set({ library }); + }); + await browser.close(); + browser = await launch(); + const reopened = await panel(); + await reopened.waitForSelector('button[aria-label="Settings"]'); + const restored = await read(reopened); + assert.deepEqual(restored.local, { ...persisted.local, uiPrefs: DEFAULT_UI_PREFS }); + assert.deepEqual(await reopened.evaluate(async () => (await chrome.storage.local.get('library')).library), restored); + console.log('PASS missing preferences recover on restart without losing saved work'); + assert.deepEqual((await read(reopened)).shared, persisted.shared); + await reopened.evaluate(async () => { + const { syncConfig } = await chrome.storage.local.get('syncConfig'); + await chrome.storage.local.set({ syncConfig: { ...syncConfig, lastPushAt: 0 } }); + }); + await sync(reopened, 'enable'); + const uploaded = await readSnapshot(await reopened.evaluate(() => chrome.storage.sync.get(null))); + assert.deepEqual(uploaded, persisted.shared); + console.log('PASS restart preserves saved work and uploads it without the original panels'); + + // A failed/interrupted upload is repaired from the local complete snapshot. + await reopened.evaluate(async (key) => { + const { syncConfig } = await chrome.storage.local.get('syncConfig'); + await chrome.storage.local.set({ syncConfig: { ...syncConfig, lastPushAt: 0 } }); + await chrome.storage.sync.set({ [key]: 'interrupted' }); + }, PREFIX + 'chunk:0'); + await sync(reopened, 'now'); + assert.deepEqual(await readSnapshot(await reopened.evaluate(() => chrome.storage.sync.get(null))), persisted.shared); + console.log('PASS an interrupted local upload is repaired'); + + const large = Array.from({ length: 5000 }, (_, n) => ({ id: `m${n}`, t: n, label: crypto.randomUUID() })); + await edit(reopened, { type: 'practice', identity: identity(100), patch: { markers: large }, recent: true }); + // Capacity is checked only when an upload is due, before any remote write. + await reopened.evaluate(async () => { + const { syncConfig } = await chrome.storage.local.get('syncConfig'); + await chrome.storage.local.set({ syncConfig: { ...syncConfig, lastPushAt: 0 } }); + }); + await sync(reopened, 'now'); + const config = await reopened.evaluate(async () => (await chrome.storage.local.get('syncConfig')).syncConfig); + assert.match(config.lastError, /storage is full/); + assert.equal((await read(reopened)).shared.songs[identity(100).key].practice.markers.length, 5000); + assert.deepEqual(await readSnapshot(await reopened.evaluate(() => chrome.storage.sync.get(null))), persisted.shared); + console.log('PASS sync capacity errors retain every local marker'); + + await sync(reopened, 'delete'); + assert.equal(Object.keys(await reopened.evaluate(() => chrome.storage.sync.get(null))).length, 0); + assert.equal((await read(reopened)).shared.songs[identity(100).key].practice.markers.length, 5000); + console.log('PASS deleting the remote copy disables sync and preserves the local library'); + + // A corrupt saved record must expose a working recovery UI on the next wake. + const recoveryBackup = await read(reopened); + const damaged = structuredClone(recoveryBackup); + damaged.local.recent.broken = 'not a date'; + await reopened.evaluate((library) => chrome.storage.local.set({ library }), damaged); + await browser.close(); + browser = await launch(); + const recovery = await panel(); + await recovery.setViewport({ width: 400, height: 700 }); + await recovery.waitForSelector('main[aria-label="Library recovery"]'); + assert.match(await recovery.$eval('main', (node) => node.textContent), /saved data is still on this device/); + await recovery.screenshot({ path: resolve(root, '.output', 'library-recovery.png') }); + const file = join(profile, 'restore.json'); + writeFileSync(file, JSON.stringify({ format: 'note-by-note-backup', version: 2, exportedAt: Date.now(), ...recoveryBackup })); + recovery.once('dialog', (dialog) => dialog.accept()); + await (await recovery.$('input[aria-label="Import backup"]')).uploadFile(file); + await recovery.waitForSelector('button[aria-label="Settings"]'); + assert.deepEqual((await read(recovery)).shared.songs, recoveryBackup.shared.songs); + assert.deepEqual(await recovery.evaluate(async () => (await chrome.storage.local.get('libraryRecovery')).libraryRecovery), damaged); + console.log('PASS corrupt data shows recovery controls and backup import restores the panel'); + assert.deepEqual(panelErrors, [], 'panels must not report unhandled errors'); +} finally { + await browser?.close(); +} diff --git a/package.json b/package.json index 1105d05..62c6c7c 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,9 @@ "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", "check": "svelte-check --tsconfig ./tsconfig.json", - "test:dsp": "node --test \"src/features/**/*.test.ts\"", + "test:dsp": "node --test \"src/**/*.test.ts\"", "test:e2e": "node e2e/run.mjs", + "test:e2e:library": "node e2e/library.mjs", "release": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/release.ps1", "release:dry": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/release.ps1 -DryRun", "postinstall": "node scripts/copy-rubberband-wasm.mjs && node scripts/build-rubberband-worklet.mjs && node scripts/build-vocal-worklet.mjs && node scripts/build-pcm-tap-worklet.mjs && wxt prepare" diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ebf96d7..90010e6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,4 @@ -# Not a monorepo (server/ has its own workspace) — this file exists only to -# hold pnpm settings, which pnpm 11 reads here rather than from package.json. +# Not a monorepo — this file exists only to hold pnpm settings, which pnpm 11 reads here rather than from package.json. packages: [] # Dependency build scripts. esbuild installs its native binary (the worklet diff --git a/server/README.md b/server/README.md deleted file mode 100644 index d82f63b..0000000 --- a/server/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# note-by-note-sync - -Minimal Cloudflare Worker + KV backend for Note by Note device sync. Stores one -backup snapshot per secret sync ID. No accounts, no auth beyond the ID itself. - -## API - -One route, `/v1/backup`. The sync ID goes in an **`X-Sync-Id` header**, never in -the path or query — URLs are recorded verbatim by Workers Logs and every other -edge request log, and the ID is the whole credential. - -- `GET /v1/backup` → the stored backup JSON, or `404` if the ID has no data. -- `PUT /v1/backup` → stores the request body (must be a Note by Note backup - ≤ 1 MiB), `204`. -- `DELETE /v1/backup` → removes the snapshot, `204`. - -IDs must match `[A-Za-z0-9_-]{43,64}`, matching what the extension generates -(32 random bytes as base64url). Shorter IDs are rejected: a hand-picked one -would be guessable, and guessing it is the whole attack. - -KV is keyed by `SHA-256(id)`, so the raw token isn't stored either. `GET` falls -back to the raw-ID key once, for blobs written before that change; the next -`PUT` rewrites them under the hashed key. - -Snapshots carry a 180-day TTL, refreshed on every write, so abandoned blobs age -out. The extension re-seeds an expired one automatically. - -Writes (`PUT`/`DELETE`) are rate-limited per IP via the `SYNC_WRITE_LIMIT` -binding — 60/minute, far above a real client's 5-second push debounce, and -enough to make scripted abuse of the free KV write quota uninteresting. Reads -are not limited, so a shared NAT can't lock its users out of pulling. - -Failures return a CORS-bearing `502` rather than letting the exception escape: -Cloudflare's own error page carries no CORS headers, which the browser reports -as a network failure indistinguishable from being offline. - -> **Upgrading from the pre-header API:** the old `/v1/:id` route is gone, so -> deploy this Worker and the matching extension build together. An older -> extension against this Worker (or vice versa) gets a `404` on every call. - -## Deploy (once) - -```sh -pnpm install -pnpm wrangler kv namespace create SYNC_KV # paste the id into wrangler.jsonc -pnpm run deploy -``` - -Then set the deployed URL as `SYNC_ENDPOINT` in `../src/features/sync/panel/api.ts` and rebuild the extension. - -## Local development - -```sh -pnpm run dev # serves http://localhost:8787 with a local KV emulation -``` - -The extension's dev build (`pnpm dev` at the repo root) targets `http://localhost:8787` automatically. diff --git a/server/package.json b/server/package.json deleted file mode 100644 index b7d9b1b..0000000 --- a/server/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "note-by-note-sync", - "private": true, - "version": "0.1.0", - "license": "GPL-2.0-or-later", - "type": "module", - "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", - "check": "tsc --noEmit" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20250705.0", - "typescript": "^5.9.3", - "wrangler": "^4.24.0" - } -} diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml deleted file mode 100644 index 7f78ecb..0000000 --- a/server/pnpm-lock.yaml +++ /dev/null @@ -1,888 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20250705.0 - version: 4.20260702.1 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - wrangler: - specifier: ^4.24.0 - version: 4.111.0(@cloudflare/workers-types@4.20260702.1) - -packages: - - '@cloudflare/kv-asset-handler@0.5.0': - resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} - engines: {node: '>=22.0.0'} - - '@cloudflare/unenv-preset@2.16.1': - resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: '>1.20260305.0 <2.0.0-0' - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/workerd-darwin-64@1.20260710.1': - resolution: {integrity: sha512-OqJl2eWF5+y9jarMm3YqqCTUe7Hd4ihogX5jyRU8iaAgOVyDr/Bk6aXpPCVUi1/MHzO93a18R/TmSTtzmB0sQw==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20260710.1': - resolution: {integrity: sha512-MYBqWgUblO+VlGvO73zYsH3hB9tdRj+yLyt5IHDFWryipb2l1efmNiWtAOkIhSRfypqLYGFrfpaDm2Hg00XVKw==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20260710.1': - resolution: {integrity: sha512-lVWUgqI8qrkqvaCBGElu1kdaUFdAvaS2RD8K4qkCFP9hI3f5TCXumEs5qWSeZkvKum0+X/uJZ5hBFWsYI5SmoQ==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20260710.1': - resolution: {integrity: sha512-kDwDPItBjAI4JL0df9Fma2N+Qggbm77IB/DnroAkEGQ79fpR80sYMyuB/ZQKyjEk9f48Ocq7HCCLq59qVSyNqA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20260710.1': - resolution: {integrity: sha512-GcLHy1oN1dfK6g1Z7UDV9f5xMGyTfPwcjWQ0sfWKH31IsoEVCRapnj3IC0PoIrDbnoo6irGPP0CwVs3WzdTajw==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workers-types@4.20260702.1': - resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} - - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} - - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - - '@sindresorhus/is@7.2.0': - resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} - engines: {node: '>=18'} - - '@speed-highlight/core@1.2.17': - resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} - - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - miniflare@4.20260710.0: - resolution: {integrity: sha512-x1LLRkU6o1p7hiKrB0TRnL0MJn6xFOT+/vrlEQINz5cRDKLP8ru4hBqWTIvXAetzr1acKAnmAaG84pQ4W/K14g==} - engines: {node: '>=22.0.0'} - hasBin: true - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} - engines: {node: '>=18'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} - - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - - workerd@1.20260710.1: - resolution: {integrity: sha512-U2sBPPrb9U97sBKnnMN6Kv8p65903P35nwMkPE9vSH/bRuRqkZ3a1EjUw3jV28RhiyXpkLF77Evzw8XimFxyTw==} - engines: {node: '>=16'} - hasBin: true - - wrangler@4.111.0: - resolution: {integrity: sha512-bffpI9EyrnpKkF/1S+RaIv8oRD93GtbsA7TlfWwOsGJGB7VO3jVbdGzpC9TU7Bqom3z7jUxcte4Z9MPhaQ4HoQ==} - engines: {node: '>=22.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^5.20260710.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - -snapshots: - - '@cloudflare/kv-asset-handler@0.5.0': {} - - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260710.1)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260710.1 - - '@cloudflare/workerd-darwin-64@1.20260710.1': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20260710.1': - optional: true - - '@cloudflare/workerd-linux-64@1.20260710.1': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20260710.1': - optional: true - - '@cloudflare/workerd-windows-64@1.20260710.1': - optional: true - - '@cloudflare/workers-types@4.20260702.1': {} - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@emnapi/runtime@1.11.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.11.2 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@poppinss/colors@4.1.6': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.5': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.3': {} - - '@sindresorhus/is@7.2.0': {} - - '@speed-highlight/core@1.2.17': {} - - blake3-wasm@2.1.5: {} - - cookie@1.1.1: {} - - detect-libc@2.1.2: {} - - error-stack-parser-es@1.0.5: {} - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - fsevents@2.3.3: - optional: true - - kleur@4.1.5: {} - - miniflare@4.20260710.0: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.28.0 - workerd: 1.20260710.1 - ws: 8.21.0 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - path-to-regexp@6.3.0: {} - - pathe@2.0.3: {} - - semver@7.8.5: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - supports-color@10.2.2: {} - - tslib@2.8.1: - optional: true - - typescript@5.9.3: {} - - undici@7.28.0: {} - - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - - workerd@1.20260710.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260710.1 - '@cloudflare/workerd-darwin-arm64': 1.20260710.1 - '@cloudflare/workerd-linux-64': 1.20260710.1 - '@cloudflare/workerd-linux-arm64': 1.20260710.1 - '@cloudflare/workerd-windows-64': 1.20260710.1 - - wrangler@4.111.0(@cloudflare/workers-types@4.20260702.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260710.1) - blake3-wasm: 2.1.5 - esbuild: 0.28.1 - miniflare: 4.20260710.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20260710.1 - optionalDependencies: - '@cloudflare/workers-types': 4.20260702.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - ws@8.21.0: {} - - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.3 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.17 - cookie: 1.1.1 - youch-core: 0.3.3 diff --git a/server/pnpm-workspace.yaml b/server/pnpm-workspace.yaml deleted file mode 100644 index 14e463d..0000000 --- a/server/pnpm-workspace.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Makes server/ its own install root (the repo root is a pnpm config root), -# and lets wrangler's runtime (workerd) install its binary. -allowBuilds: - esbuild: true - workerd: true - sharp: true diff --git a/server/src/index.ts b/server/src/index.ts deleted file mode 100644 index 068310e..0000000 --- a/server/src/index.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Note by Note sync backend: a key-value store for backup snapshots, keyed by a - * client-generated secret ID. The ID is the whole capability — anyone holding - * it can read and write the blob — so CORS is open (`*`) and no other auth - * exists. Deployed once by the developer; see ../README.md. - * - * Because the ID *is* the credential it is passed in the `X-Sync-Id` header, - * never in the path or query: URLs are recorded verbatim by Workers Logs and - * every other edge-side request log. For the same reason KV is keyed by - * SHA-256(id) rather than the raw value, so a KV key listing discloses nothing - * usable either. - * - * KV is eventually consistent (~60 s across edges); the extension self-heals - * via periodic pulls, so no stronger consistency is needed here. - */ - -interface Env { - SYNC_KV: KVNamespace; - /** Per-IP write limiter; see `ratelimits` in wrangler.jsonc. */ - SYNC_WRITE_LIMIT: RateLimit; -} - -/** Matches the extension's generated IDs: 32 random bytes → 43-char base64url. - * Anything shorter would be guessable, so it is rejected before touching KV. */ -const ID_RE = /^[A-Za-z0-9_-]{43,64}$/; - -const MAX_BODY_BYTES = 1024 * 1024; - -/** Snapshots outlive any realistic gap between a user's sessions, but not - * forever — an abandoned blob eventually ages out instead of costing storage - * indefinitely. Refreshed on every write. The extension re-seeds an expired - * blob automatically (see `#reconcile` in sync.svelte.ts). */ -const TTL_SECONDS = 180 * 24 * 60 * 60; - -const CORS_HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, X-Sync-Id', - 'Access-Control-Max-Age': '86400', -}; - -function respond(status: number, body: string | null, headers: Record = {}) { - return new Response(body, { status, headers: { ...CORS_HEADERS, ...headers } }); -} - -/** KV key for a sync ID. Hex SHA-256 so the secret itself is never stored. */ -async function kvKey(id: string): Promise { - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(id)); - return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); -} - -export default { - async fetch(request: Request, env: Env): Promise { - if (request.method === 'OPTIONS') return respond(204, null); - - if (new URL(request.url).pathname !== '/v1/backup') return respond(404, 'Not found'); - - const id = request.headers.get('X-Sync-Id') ?? ''; - if (!ID_RE.test(id)) return respond(400, 'Invalid or missing sync ID'); - const key = await kvKey(id); - - if (request.method === 'GET') { - let value: string | null; - try { - value = await env.SYNC_KV.get(key); - // Pre-hashing deployments stored the blob under the raw ID. Fall back - // once; the next PUT rewrites it under the hashed key. - if (value === null) value = await env.SYNC_KV.get(id); - } catch { - return respond(502, 'Storage temporarily unavailable'); - } - if (value === null) return respond(404, 'No data for this sync ID'); - return respond(200, value, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'X-Content-Type-Options': 'nosniff', - }); - } - - if (request.method === 'PUT' || request.method === 'DELETE') { - // Writes are the expensive, abusable direction. Reads are left alone so a - // shared NAT can't lock its users out of pulling. - const { success } = await env.SYNC_WRITE_LIMIT.limit({ - key: request.headers.get('cf-connecting-ip') ?? 'unknown', - }); - if (!success) return respond(429, 'Too many requests', { 'Retry-After': '60' }); - } - - if (request.method === 'PUT') { - // Reject early when the client declares an oversized body, before buffering. - if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) { - return respond(413, 'Snapshot too large'); - } - const body = await request.text(); - // `body.length` counts UTF-16 units; the cap is bytes, so measure UTF-8. - if (new TextEncoder().encode(body).byteLength > MAX_BODY_BYTES) { - return respond(413, 'Snapshot too large'); - } - // Sniff the payload so the namespace can't be used as a generic dump. - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - return respond(400, 'Body is not valid JSON'); - } - const record = parsed as Record | null; - if ( - record === null || - typeof record !== 'object' || - record.format !== 'note-by-note-backup' || - typeof record.exportedAt !== 'number' - ) { - return respond(400, 'Body is not a Note by Note backup'); - } - try { - await env.SYNC_KV.put(key, body, { expirationTtl: TTL_SECONDS }); - } catch { - return respond(502, 'Storage temporarily unavailable'); - } - return respond(204, null); - } - - if (request.method === 'DELETE') { - try { - // Both keys: a blob written before the hashing change is still the - // user's, and "delete my data" has to mean it. - await Promise.all([env.SYNC_KV.delete(key), env.SYNC_KV.delete(id)]); - } catch { - return respond(502, 'Storage temporarily unavailable'); - } - return respond(204, null); - } - - return respond(405, 'Method not allowed', { Allow: 'GET, PUT, DELETE, OPTIONS' }); - }, -}; diff --git a/server/tsconfig.json b/server/tsconfig.json deleted file mode 100644 index a1efe2e..0000000 --- a/server/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], - "strict": true, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/server/wrangler.jsonc b/server/wrangler.jsonc deleted file mode 100644 index 6bdee90..0000000 --- a/server/wrangler.jsonc +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "note-by-note-sync", - "main": "src/index.ts", - "compatibility_date": "2026-07-01", - "observability": { - "enabled": true - }, - "kv_namespaces": [ - { - "binding": "SYNC_KV", - "id": "53d23d1799a74bbca74ec8b98fa98b51", - "remote": true - } - ], - // Per-IP cap on writes (PUT/DELETE). A real client pushes on a 5 s debounce - // and pulls every 5 min, so this is far above normal use and only bites - // scripted abuse. `namespace_id` is a per-Worker label, not an account - // resource — self-hosters can leave it as-is. - "ratelimits": [ - { - "name": "SYNC_WRITE_LIMIT", - "namespace_id": "1001", - "simple": { - "limit": 60, - "period": 60 - } - } - ] -} \ No newline at end of file diff --git a/src/core/features.ts b/src/core/features.ts deleted file mode 100644 index 9f43764..0000000 --- a/src/core/features.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { EngineEvent } from './messaging/protocol'; -import { chordsFeature } from '../features/chords/panel/panel'; -import { eqFeature } from '../features/eq/panel/panel'; -import { libraryFeature } from '../features/library/panel/panel'; -import { settingsFeature } from '../features/settings/panel/panel'; - -/** The snapshot variant of EngineEvent (the engine's full "current state"). */ -export type SnapshotEvent = Extract; - -/** A side-panel feature's registration surface. The composition roots — App - * (boot init) and the connection manager (port event routing) — iterate these - * instead of hard-coding each feature. This is the "light registration" seam: - * core imports the feature contributions; features never import the roots. - * A feature only implements the hooks it needs. */ -export interface PanelFeature { - /** Async storage load at panel boot. Run concurrently across features. */ - init?(): Promise | void; - /** Route an engine→panel event into feature-owned state that lives outside - * the session mirror (e.g. chords' PCM stream). */ - routeEvent?(event: EngineEvent): void; - /** React to a fresh engine snapshot (the full current-state message). */ - onSnapshot?(snapshot: SnapshotEvent): void; - /** The engine port disconnected (navigation/reload/teardown). */ - onDisconnect?(): void; -} - -/** Every panel feature that contributes boot init or engine-event routing. - * (Features whose only per-track state is swapped by track-sync — markers, - * snippets — register there instead, via core/persist/track-data.ts.) */ -export const features: PanelFeature[] = [ - settingsFeature, - libraryFeature, - eqFeature, - chordsFeature, -]; diff --git a/src/core/messaging/protocol.ts b/src/core/messaging/protocol.ts index 735c601..d47fcd0 100644 --- a/src/core/messaging/protocol.ts +++ b/src/core/messaging/protocol.ts @@ -1,3 +1,4 @@ +import type { Library, LibraryCommand } from '../persist/library'; import type { ConnectionState, EffectParams, @@ -121,6 +122,10 @@ export type OffscreenCommand = /** RPC handled by the background service worker (via @webext-core/messaging). */ export interface ProtocolMap { + libraryRead(): Promise; + /** The committed shared revision lets panels retire edits once their watch catches up. */ + libraryEdit(command: LibraryCommand): Promise; + librarySync(action: 'now' | 'enable' | 'disable' | 'delete'): Promise; /** Request per-origin host permission, inject + persist the content script. * Must run after the side panel already obtained the permission grant. */ ensureInjected(data: { tabId: number }): Promise<{ ok: boolean; error?: string }>; diff --git a/src/core/model/track-identity.ts b/src/core/model/track-identity.ts index 559df16..7d75cd1 100644 --- a/src/core/model/track-identity.ts +++ b/src/core/model/track-identity.ts @@ -10,14 +10,17 @@ function normalizeUrl(rawUrl: string): string { } catch { return rawUrl; } + if (url.protocol === 'chrome-extension:' || url.protocol === 'moz-extension:') { + return `${url.protocol}//${url.host}${url.pathname}`; + } const host = url.hostname.replace(/^www\./, ''); // Site-aware rules: keep only the media id where we know it. - if (host.endsWith('youtube.com')) { - const v = url.searchParams.get('v'); + if (host === 'youtube.com' || host.endsWith('.youtube.com')) { + const v = url.searchParams.get('v') ?? /^\/(?:shorts|embed)\/([^/]+)/.exec(url.pathname)?.[1]; if (v) return `https://youtube.com/watch?v=${v}`; - // Shorts / embeds carry the id in the path. + // Any other youtube path is its own page (a channel, a playlist view). return `https://youtube.com${url.pathname}`; } if (host === 'youtu.be') { @@ -48,12 +51,15 @@ function hash(text: string): string { return (h >>> 0).toString(16); } -/** Whether two library rows describe the same song. Deliberately not a `key` - * comparison: the duration baked into `key` drifts (pre-roll ads, metadata that - * settles late), which would split one song across several Recent rows. The - * title is what keeps local files apart — they all share the local-player URL. */ -export function isSameTrack(a: TrackIdentity, b: TrackIdentity): boolean { - return a.normalizedUrl === b.normalizedUrl && a.title === b.title; +/** Web media use a stable URL/provider ID; title and duration are metadata. + * Local files retain the existing filename discriminator. */ +export function songKey(identity: Pick): string { + const url = identity.normalizedUrl; + if (url.startsWith('https://youtube.com/watch?v=')) return 'yt:' + url.slice('https://youtube.com/watch?v='.length); + // Local-player URLs include a browser-specific extension ID. The file name + // is the existing local-file discriminator; it must work across installations. + if (/^(chrome|moz)-extension:/.test(url)) return 'file:' + hash(cleanTitle(identity.title)); + return 'web:' + hash(url); } export function makeTrackIdentity( @@ -62,11 +68,11 @@ export function makeTrackIdentity( durationSec: number, ): TrackIdentity { const normalizedUrl = normalizeUrl(pageUrl); - const duration = Number.isFinite(durationSec) ? Math.round(durationSec) : 0; + const cleaned = cleanTitle(title); return { - key: `${hash(normalizedUrl)}:${duration}`, + key: songKey({ normalizedUrl, title: cleaned }), normalizedUrl, - title: cleanTitle(title), - durationSec: duration, + title: cleaned, + durationSec: Number.isFinite(durationSec) ? Math.round(durationSec) : 0, }; } diff --git a/src/core/model/types.ts b/src/core/model/types.ts index 6b1b1b1..2398e89 100644 --- a/src/core/model/types.ts +++ b/src/core/model/types.ts @@ -99,7 +99,9 @@ export interface ChordChart { /** Stable identity of a piece of media, so settings/markers/snippets survive * reloads and URL noise. */ export interface TrackIdentity { - /** `${hash(normalizedUrl)}:${round(duration)}` */ + /** The saved song's key — see `songKey` in core/model/track-identity.ts. + * Derived from the provider id or normalized URL, never from the duration, + * which drifts (pre-roll ads, metadata that settles late). */ key: string; normalizedUrl: string; title: string; @@ -115,8 +117,7 @@ export interface TrackData { sequenceLoop: boolean; /** Count in on play and before each snippet repeat lap (not on section loop). */ sequenceCountIn: boolean; - /** Cached chord/key chart from the last analysis run. null = never analyzed. - * (null, not undefined — patches serialize over the port, dropping undefined.) */ + /** Legacy track shape and local analysis cache. New shared practice excludes this field. */ chordChart?: ChordChart | null; /** Chords panel switch. Kept apart from the chart so switching off hides the * panel without discarding the analysis. Undefined on pre-switch records. */ @@ -130,13 +131,14 @@ export interface HistoryEntry { params: EffectParams; thumbnailUrl?: string; pageUrl: string; - createdAt: number; + /** Last save — the date Recent sorts and displays by. */ updatedAt: number; } /** A song the user starred (History → Favorites). Persists independently of * the LRU-capped Recent list. Stored array order = manual sort order. */ export interface FavoriteEntry extends HistoryEntry { + /** When the song was starred, for display and sorting. */ favoritedAt: number; /** Last time the track was opened or played, for "Last Accessed" sorting. */ lastAccessedAt: number; diff --git a/src/core/persist/backup-codec.ts b/src/core/persist/backup-codec.ts new file mode 100644 index 0000000..7e97f2e --- /dev/null +++ b/src/core/persist/backup-codec.ts @@ -0,0 +1,112 @@ +import { DEFAULT_PARAMS, DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../model/defaults.ts'; +import { songKey } from '../model/track-identity.ts'; +import type { Library, SharedLibrary } from './library.ts'; +import { parseBackupJson as parseLegacy } from './legacy-backup.ts'; +import { migrateBackup } from './library-migration.ts'; + +export const BACKUP_FORMAT = 'note-by-note-backup'; +export const BACKUP_VERSION = 2; +export interface Backup extends Library { format: typeof BACKUP_FORMAT; version: typeof BACKUP_VERSION; exportedAt: number } + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Damaged library data.'); + return value as Record; +} +function number(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Damaged library number.'); +} +function string(value: unknown) { + if (typeof value !== 'string') throw new Error('Damaged library text.'); +} +function array(value: unknown): any[] { + if (!Array.isArray(value)) throw new Error('Damaged library list.'); + return value; +} +/** Missing preference groups use defaults, just like missing individual fields. */ +export function defaults(value: unknown, fallback: T, recover = false): T { + let source: Record; + try { source = object(value ?? {}); } catch (error) { + if (!recover) throw error; + return structuredClone(fallback); + } + const result = structuredClone(fallback) as Record; + for (const [key, expected] of Object.entries(result)) { + if (!(key in source)) continue; + try { + const next = source[key]; + if (expected === null) { if (next !== null) number(next); } + else if (Array.isArray(expected)) array(next).forEach(number); + else if (typeof expected === 'object') { result[key] = defaults(next, expected, recover); continue; } + else if (typeof next !== typeof expected) throw new Error('Damaged library setting.'); + else if (typeof next === 'number') number(next); + result[key] = next; + } catch (error) { if (!recover) throw error; } + } + return result as T; +} +export function parseShared(value: unknown): SharedLibrary { + const shared = structuredClone(object(value)); + number(shared.updatedAt); + if (shared.updatedAt < 0) throw new Error('Damaged library revision.'); + shared.settings = defaults(shared.settings, DEFAULT_SETTINGS); + array(shared.favoriteOrder).forEach(string); + for (const [key, raw] of Object.entries(object(shared.songs))) { + const song = object(raw); + if (song.favoritedAt !== null) number(song.favoritedAt); + const practice = song.practice; + if (!/^(yt|file|web):/.test(key)) throw new Error('Damaged song key.'); + object(practice); + number(practice.updatedAt); + const identity = object(practice.identity); + string(identity.normalizedUrl); string(identity.title); number(identity.durationSec); + if (key !== songKey(identity as any)) throw new Error('Song identity does not match its key.'); + identity.key = key; + string(practice.pageUrl); + if (practice.thumbnailUrl !== undefined) string(practice.thumbnailUrl); + if (practice.params !== undefined) practice.params = defaults(practice.params, DEFAULT_PARAMS); + array(practice.markers).forEach((m) => { object(m); string(m.id); string(m.label); number(m.t); }); + array(practice.snippets).forEach((s) => { + object(s); string(s.id); string(s.name); number(s.startT); number(s.endT); + if (s.repeats !== null && s.repeats !== Infinity) number(s.repeats); + if (typeof s.enabled !== 'boolean') throw new Error('Damaged snippet.'); + for (const amount of Object.values(object(s.overrides))) number(amount); + }); + if (typeof practice.sequenceLoop !== 'boolean' || typeof practice.sequenceCountIn !== 'boolean') throw new Error('Damaged sequence.'); + if (practice.chordsEnabled !== undefined && typeof practice.chordsEnabled !== 'boolean') throw new Error('Damaged chord setting.'); + } + for (const preset of Object.values(object(shared.presets))) array(preset).forEach(number); + return { updatedAt: shared.updatedAt, settings: shared.settings, songs: shared.songs, + presets: shared.presets, favoriteOrder: shared.favoriteOrder }; +} +export function parseLibrary(value: unknown): Library { + const source = object(value); + const local = object(source.local ?? {}); + const charts = object(local.charts ?? {}); + for (const chart of Object.values(charts)) { + if (chart === null) continue; + object(chart); number(chart.computedAt); number(chart.coverage); number(chart.analyzedFrom); number(chart.analyzedTo); + array(chart.segments).forEach((s) => { object(s); number(s.startT); number(s.endT); string(s.label); number(s.confidence); }); + if (chart.key !== null) { object(chart.key); string(chart.key.tonic); string(chart.key.mode); number(chart.key.confidence); } + } + const lastAccessed = object(local.lastAccessed ?? {}); + const recent = object(local.recent ?? {}); + Object.values(recent).forEach(number); + Object.values(lastAccessed).forEach(number); + if (local.importRevision !== undefined) { + number(local.importRevision); + if (!Number.isSafeInteger(local.importRevision) || local.importRevision < 0) throw new Error('Damaged import revision.'); + } + return { + shared: parseShared(source.shared), + local: { uiPrefs: defaults(local.uiPrefs, DEFAULT_UI_PREFS), recent, lastAccessed, charts, + ...(local.lastUsedParams ? { lastUsedParams: defaults(local.lastUsedParams, DEFAULT_PARAMS) } : {}), + ...(local.importRevision !== undefined ? { importRevision: local.importRevision } : {}) }, + }; +} +export function parseBackupJson(value: unknown): Backup { + const raw = object(value); + if (raw.format !== BACKUP_FORMAT) throw new Error("That file isn't a Note by Note backup."); + if (raw.version > BACKUP_VERSION) throw new Error('That backup was made by a newer version of Note by Note.'); + const library = parseLibrary(raw.version === BACKUP_VERSION ? raw : migrateBackup(parseLegacy(raw))); + return { format: BACKUP_FORMAT, version: BACKUP_VERSION, exportedAt: raw.exportedAt ?? 0, ...library }; +} diff --git a/src/core/persist/backup.ts b/src/core/persist/backup.ts index a20e809..c91aaf2 100644 --- a/src/core/persist/backup.ts +++ b/src/core/persist/backup.ts @@ -1,154 +1,18 @@ -import { DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../model/defaults'; -import type { - EqPreset, - FavoriteEntry, - HistoryEntry, - Settings, - TrackData, - UiPrefs, -} from '../model/types'; -import { - eqPresetsItem, - favoritesItem, - historyItem, - removeAllTrackData, - saveTrackData, - settingsItem, - uiPrefsItem, -} from './storage'; - -/** Marks a file as ours, so a stray JSON can be rejected on sight. */ -const FORMAT = 'note-by-note-backup'; - -/** Bump only for changes older files can't be read as; `parseBackup` accepts - * anything up to this and backfills what a lower version lacked. */ -const VERSION = 1; - -/** Everything a user owns, in one file. Host permissions are deliberately out: - * they live in the browser's permission store, and only a prompt can grant - * them — a backup that listed origins would restore access it can't give. */ -export interface Backup { - format: typeof FORMAT; - version: number; - exportedAt: number; - appVersion: string; - settings: Settings; - uiPrefs: UiPrefs; - history: HistoryEntry[]; - favorites: FavoriteEntry[]; - eqPresets: EqPreset[]; - /** Per-track markers and snippets, one entry per saved track. */ - tracks: TrackData[]; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** Raw storage keys have no `local:` prefix — see `trackDataKey`. */ -async function loadAllTrackData(): Promise { - const snapshot = await browser.storage.local.get(null); - return Object.entries(snapshot) - .filter(([key]) => key.startsWith('track:')) - .map(([, value]) => value as TrackData); -} +import { BACKUP_FORMAT, BACKUP_VERSION, parseBackupJson, type Backup } from './backup-codec'; +import { editLibrary, readLibrary } from './library-client'; +export type { Backup }; export async function createBackup(): Promise { - const [settings, uiPrefs, history, favorites, eqPresets, tracks] = - await Promise.all([ - settingsItem.getValue(), - uiPrefsItem.getValue(), - historyItem.getValue(), - favoritesItem.getValue(), - eqPresetsItem.getValue(), - loadAllTrackData(), - ]); - return { - format: FORMAT, - version: VERSION, - exportedAt: Date.now(), - appVersion: browser.runtime.getManifest().version, - settings, - uiPrefs, - history, - favorites, - eqPresets, - tracks, - }; + return { format: BACKUP_FORMAT, version: BACKUP_VERSION, exportedAt: Date.now(), ...await readLibrary() }; } - -/** Suggested download name, e.g. `note-by-note-backup-2026-07-17.json`. */ -export function backupFilename(exportedAt: number): string { - const day = new Date(exportedAt).toISOString().slice(0, 10); - return `note-by-note-backup-${day}.json`; -} - -function requireArray(value: unknown, field: string): unknown[] { - if (!Array.isArray(value)) { - throw new Error(`This backup's "${field}" list is missing or damaged.`); - } - return value; +export function backupFilename(at: number): string { + return 'note-by-note-backup-' + new Date(at).toISOString().slice(0, 10) + '.json'; } - -/** Entries are keyed by `identity.key`; without one they can't be stored or - * matched back to a track, so a file carrying them is not usable. */ -function requireKeyedArray(value: unknown, field: string): T[] { - const list = requireArray(value, field); - const keyed = list.every( - (e) => isRecord(e) && isRecord(e.identity) && typeof e.identity.key === 'string', - ); - if (!keyed) throw new Error(`This backup's "${field}" list is damaged.`); - return list as T[]; -} - -/** - * Reads a backup file's text into a `Backup`, or throws an `Error` whose - * message is safe to show the user. Objects are backfilled from the defaults - * so a file from an older build gains any setting added since. - */ export function parseBackup(text: string): Backup { let raw: unknown; - try { - raw = JSON.parse(text); - } catch { - throw new Error("That file isn't valid JSON."); - } - if (!isRecord(raw) || raw.format !== FORMAT) { - throw new Error("That file isn't a Note by Note backup."); - } - if (typeof raw.version !== 'number' || raw.version > VERSION) { - throw new Error('That backup was made by a newer version of Note by Note.'); - } - return { - format: FORMAT, - version: raw.version, - exportedAt: typeof raw.exportedAt === 'number' ? raw.exportedAt : 0, - appVersion: typeof raw.appVersion === 'string' ? raw.appVersion : '', - settings: { ...DEFAULT_SETTINGS, ...(isRecord(raw.settings) ? raw.settings : {}) }, - uiPrefs: { - ...structuredClone(DEFAULT_UI_PREFS), - ...(isRecord(raw.uiPrefs) ? raw.uiPrefs : {}), - }, - history: requireKeyedArray(raw.history, 'history'), - favorites: requireKeyedArray(raw.favorites, 'favorites'), - eqPresets: requireArray(raw.eqPresets, 'eqPresets') as EqPreset[], - tracks: requireKeyedArray(raw.tracks, 'tracks'), - }; + try { raw = JSON.parse(text); } catch { throw new Error("That file isn't valid JSON."); } + return parseBackupJson(raw); } - -/** - * Replaces every stored value with the backup's, dropping data the file does - * not carry — a restore reproduces the machine it came from rather than - * merging into whatever is here. Host permissions are left untouched. - */ export async function restoreBackup(backup: Backup): Promise { - await removeAllTrackData(); - await Promise.all([ - settingsItem.setValue(backup.settings), - uiPrefsItem.setValue(backup.uiPrefs), - historyItem.setValue(backup.history), - favoritesItem.setValue(backup.favorites), - eqPresetsItem.setValue(backup.eqPresets), - ...backup.tracks.map(saveTrackData), - ]); + await editLibrary({ type: 'import', library: backup }); } diff --git a/src/core/persist/legacy-backup.ts b/src/core/persist/legacy-backup.ts new file mode 100644 index 0000000..3d6e962 --- /dev/null +++ b/src/core/persist/legacy-backup.ts @@ -0,0 +1,79 @@ +// Read-only adapter for the version-1 backup format, the only one any released +// build ever wrote. New writes use the library schema (backup-codec.ts). +import { DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../model/defaults.ts'; + +import { rekeyByIdentity } from './rekey.ts'; + +import type { + EqPreset, + FavoriteEntry, + HistoryEntry, + Settings, + TrackData, + UiPrefs, +} from '../model/types'; + +const BACKUP_FORMAT = 'note-by-note-backup'; + +const BACKUP_VERSION = 1; + +export interface Backup { + format: typeof BACKUP_FORMAT; + version: number; + settings: Settings; + uiPrefs: UiPrefs; + history: HistoryEntry[]; + favorites: FavoriteEntry[]; + eqPresets: EqPreset[]; + /** Per-track markers and snippets, one entry per saved track. */ + tracks: TrackData[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function damaged(section: string): Error { + return new Error(`This backup's "${section}" list is damaged.`); +} + +function arr(value: unknown, section: string): unknown[] { + if (!Array.isArray(value)) throw damaged(section); + return value; +} + +function identifiedArr(value: unknown, section: string): T[] { + const list = arr(value, section); + const identified = list.every( + (e) => isRecord(e) && isRecord(e.identity) && typeof e.identity.normalizedUrl === 'string', + ); + if (!identified) throw damaged(section); + return list as T[]; +} + +function normalizeV1(raw: Record): Backup { + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + settings: { + ...DEFAULT_SETTINGS, + ...(isRecord(raw.settings) ? raw.settings : {}), + } as Settings, + uiPrefs: { + ...(JSON.parse(JSON.stringify(DEFAULT_UI_PREFS)) as UiPrefs), + ...(isRecord(raw.uiPrefs) ? raw.uiPrefs : {}), + }, + history: rekeyByIdentity(identifiedArr(raw.history, 'history')), + favorites: rekeyByIdentity(identifiedArr(raw.favorites, 'favorites')), + eqPresets: arr(raw.eqPresets, 'eqPresets') as EqPreset[], + tracks: rekeyByIdentity(identifiedArr(raw.tracks, 'tracks')), + }; +} + +export function parseBackupJson(raw: unknown): Backup { + if (!isRecord(raw) || raw.format !== BACKUP_FORMAT) { + throw new Error("That file isn't a Note by Note backup."); + } + if (raw.version === BACKUP_VERSION) return normalizeV1(raw); + throw new Error('That backup is in a format this version of Note by Note no longer reads.'); +} diff --git a/src/core/persist/library-background.test.ts b/src/core/persist/library-background.test.ts new file mode 100644 index 0000000..c6a303e --- /dev/null +++ b/src/core/persist/library-background.test.ts @@ -0,0 +1,255 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { fileURLToPath } from 'node:url'; +import { applyCommand, emptyLibrary } from './library.ts'; +import { makeTrackIdentity } from '../model/track-identity.ts'; +import { bytesUsed, encodeSnapshot, readSnapshot, PREFIX, SNAPSHOT_KEY } from '../../features/sync/persist/records.ts'; +import { randomBytes } from 'node:crypto'; + +// Bundle the real worker with only its browser/RPC boundaries replaced. Each +// start gets fresh queues and listeners while its saved storage/alarms survive. +const bundled = await build({ entryPoints: [fileURLToPath(new URL('./library-background.ts', import.meta.url))], + bundle: true, write: false, platform: 'node', format: 'esm', plugins: [{ name: 'browser-test', setup(builder) { + builder.onResolve({ filter: /^(#imports)$|\/messaging\/rpc$/ }, ({ path }) => ({ path, namespace: 'test' })); + builder.onLoad({ filter: /.*/, namespace: 'test' }, ({ path }) => ({ contents: path === '#imports' + ? 'export const storage = { defineItem: (...args) => ({ getValue: () => globalThis.libraryTest.item(...args).getValue(), setValue: (v) => globalThis.libraryTest.item(...args).setValue(v) }) };' + : 'export const onMessage = (...args) => globalThis.libraryTest.onMessage(...args); export const sendMessage = () => {};', loader: 'js' })); + } }] }); +const { startLibraryBackground } = await import('data:text/javascript;base64,' + Buffer.from(bundled.outputFiles[0].text).toString('base64')); + +function harness(local: Record, sync: Record = {}) { + const areas = { local: structuredClone(local), sync: structuredClone(sync) }; + const writes: { area: string; items: Record }[] = []; + const alarms = new Map>(); + const handlers = new Map Promise>(); + let changed: ((changes: Record, area: string) => void)[] = []; + let alarmListeners: ((alarm: { name: string }) => void)[] = []; + const area = (name: keyof typeof areas) => ({ + async get(key: string | null) { return structuredClone(key === null ? areas[name] : { [key]: areas[name][key] }); }, + async set(items: Record) { + writes.push({ area: name, items: structuredClone(items) }); + Object.assign(areas[name], structuredClone(items)); + for (const listener of changed) listener(items, name); + }, + async remove(keys: string[]) { for (const key of keys) delete areas[name][key]; }, + }); + const browserMock = { + storage: { local: area('local'), sync: area('sync'), onChanged: { addListener: (fn: typeof changed[number]) => changed.push(fn) } }, + alarms: { + async get(name: string) { return alarms.get(name); }, + async create(name: string, info: { when?: number; periodInMinutes?: number }) { + alarms.set(name, { scheduledTime: info.when ?? Date.now() + info.periodInMinutes! * 60000 }); + }, + async clear(name: string) { return alarms.delete(name); }, + onAlarm: { addListener: (fn: typeof alarmListeners[number]) => alarmListeners.push(fn) }, + }, + }; + const global = globalThis as any; + global.browser = browserMock; + global.libraryTest = { + onMessage: (name: string, fn: typeof handlers extends Map ? V : never) => handlers.set(name, fn), + item: (key: string, { fallback }: { fallback: unknown }) => ({ + async getValue() { return structuredClone(areas.local[key.split(':')[1]] ?? fallback); }, + async setValue(value: unknown) { await browserMock.storage.local.set({ [key.split(':')[1]]: value }); }, + }), + }; + const rpc = (name: string, data?: unknown) => handlers.get(name)!({ data }); + return { areas, writes, alarms, rpc, + async start() { changed = []; alarmListeners = []; handlers.clear(); startLibraryBackground(); await rpc('librarySync', 'now'); }, + async fire(name: string) { for (const listener of alarmListeners) listener({ name }); await rpc('librarySync', 'now'); }, + }; +} +const config = { enabled: true, syncing: false, lastPushAt: 0, lastSyncedAt: 10, usedBytes: 0, lastError: null }; +const saved = () => applyCommand(emptyLibrary(), { type: 'preset', name: 'Saved', gains: [1] }, 100); + +test('settled sync and worker restarts do not rewrite data, flicker status or postpone safety alarms', async (t) => { + let now = 100000; + t.mock.method(Date, 'now', () => now); + const library = saved(); + const { items } = await encodeSnapshot(library.shared); + const h = harness({ library, syncConfig: { ...config, usedBytes: bytesUsed(items) } }, items); + await h.start(); + const deadline = h.alarms.get('library-sync-safety')!.scheduledTime; + assert.deepEqual(h.writes, []); + now += 10000; + await h.start(); + await h.rpc('libraryRead'); + await h.fire('library-sync-safety'); + assert.deepEqual(h.writes, []); + assert.equal(h.alarms.get('library-sync-safety')!.scheduledTime, deadline); +}); + +test('a different equal-date remote snapshot is adopted once', async () => { + const library = saved(); + const remote = { ...library.shared, presets: { Remote: [2] } }; + const { items } = await encodeSnapshot(remote); + const h = harness({ library, syncConfig: config }, items); + await h.start(); + assert.deepEqual(h.areas.local.library.shared, remote); + assert.equal(h.writes.filter((w) => w.items.library).length, 1); + h.writes.length = 0; + await h.rpc('librarySync', 'now'); + assert.deepEqual(h.writes, []); +}); + +test('rate-limited uploads defer compression and status changes', async (t) => { + t.mock.method(Date, 'now', () => 100000); + const library = saved(); + const { items } = await encodeSnapshot(emptyLibrary().shared); + const h = harness({ library, syncConfig: { ...config, lastPushAt: 99999, usedBytes: bytesUsed(items) } }, items); + let compressed = 0; + const compress = globalThis.CompressionStream; + t.mock.method(globalThis, 'CompressionStream', class extends compress { constructor(format: CompressionFormat) { super(format); compressed++; } }); + await h.start(); + assert.equal(compressed, 0); + assert.deepEqual(h.writes, []); + assert.equal(h.alarms.get('library-sync')!.scheduledTime, 129999); +}); + +test('complete headerless data keeps the newer remote revision and repairs the header', async () => { + const library = saved(); + const remote = { ...library.shared, updatedAt: 200, presets: { Newer: [2] } }; + const { items } = await encodeSnapshot(remote); + delete items[SNAPSHOT_KEY]; + const h = harness({ library, syncConfig: config }, items); + await h.start(); + assert.deepEqual(h.areas.local.library.shared, remote); + assert.deepEqual(await readSnapshot(h.areas.sync), remote); + assert.ok(h.areas.sync[SNAPSHOT_KEY]); +}); + +test('orphaned partial chunks repair after a bounded wait that survives worker restarts', async (t) => { + let now = 100000; + t.mock.method(Date, 'now', () => now); + const library = saved(); + const h = harness({ library, syncConfig: config }, { [PREFIX + 'chunk:0']: 'partial' }); + await h.start(); + assert.equal(h.areas.local.syncConfig.incompleteSince, now); + assert.equal(h.writes.filter((w) => w.area === 'sync').length, 0); + now += 60000; + await h.start(); + assert.equal(h.areas.local.syncConfig.incompleteSince, 100000); + now += 60001; + await h.fire('library-sync'); + assert.deepEqual(await readSnapshot(h.areas.sync), library.shared); + assert.equal(h.areas.local.syncConfig.lastError, null); +}); + +test('a damaged saved library stays intact and can be replaced through the recovery import', async () => { + const damaged = saved() as any; + damaged.local.recent = { broken: 'bad date' }; + const h = harness({ library: damaged, syncConfig: config }); + await h.start(); + await assert.rejects(h.rpc('libraryRead'), /number/); + assert.deepEqual(h.areas.local.library, damaged); + const restored = saved(); + await h.rpc('libraryEdit', { type: 'import', library: restored }); + const library = await h.rpc('libraryRead'); + assert.deepEqual(library.shared.presets, restored.shared.presets); + assert.deepEqual(h.areas.local.libraryRecovery, damaged); + assert.equal(library.local.importRevision, 1); +}); + +test('invalid edits are rejected without writing or poisoning the next worker startup', async () => { + const original = saved(); + const h = harness({ library: original, syncConfig: { ...config, enabled: false } }); + await h.start(); + await h.rpc('libraryRead'); + h.writes.length = 0; + const identity = makeTrackIdentity('https://youtube.com/watch?v=validation', 'Song', 100); + const invalid = [ + { type: 'practice', identity, patch: { markers: [{ id: 'm', t: NaN, label: 'Bad time' }] }, recent: true }, + { type: 'practice', identity, patch: { snippets: [{ id: 's', name: 'Bad range', startT: NaN, + endT: 20, repeats: 1, enabled: true, overrides: {} }] }, recent: true }, + { type: 'settings', patch: { seekInterval: NaN } }, + { type: 'preset', name: 'Bad gains', gains: [NaN] }, + ]; + for (const command of invalid) { + await assert.rejects(h.rpc('libraryEdit', JSON.parse(JSON.stringify(command))), /Damaged/); + assert.deepEqual(h.areas.local.library, original); + } + assert.deepEqual(h.writes, []); + await h.start(); + assert.deepEqual(await h.rpc('libraryRead'), original); + const revision = await h.rpc('libraryEdit', { type: 'preset', name: 'Valid after rejection', gains: [2] }); + assert.equal(revision, h.areas.local.library.shared.updatedAt); + assert.deepEqual(h.areas.local.library.shared.presets['Valid after rejection'], [2]); +}); + +test('validation preserves infinite snippet repeats encoded as null', async () => { + const h = harness({ library: saved(), syncConfig: { ...config, enabled: false } }); + await h.start(); + const identity = makeTrackIdentity('https://youtube.com/watch?v=infinite', 'Song', 100); + await h.rpc('libraryEdit', JSON.parse(JSON.stringify({ type: 'practice', identity, recent: true, + patch: { snippets: [{ id: 's', name: 'Repeat', startT: 2, endT: 8, repeats: Infinity, enabled: true, overrides: {} }] } }))); + await h.start(); + assert.equal((await h.rpc('libraryRead')).shared.songs[identity.key].practice.snippets[0].repeats, null); +}); + +test('adopting headerless remote data dates the pull even while header repair is rate limited', async (t) => { + t.mock.method(Date, 'now', () => 100000); + const library = saved(); + const remote = { ...library.shared, updatedAt: 200, presets: { Remote: [2] } }; + const { items } = await encodeSnapshot(remote); + delete items[SNAPSHOT_KEY]; + const h = harness({ library, syncConfig: { ...config, lastPushAt: 99999 } }, items); + await h.start(); + assert.deepEqual(h.areas.local.library.shared, remote); + assert.equal(h.areas.local.syncConfig.lastSyncedAt, 100000); + assert.equal(h.writes.filter((w) => w.area === 'sync').length, 0); +}); + +test('an over-budget library uploads a trimmed copy, saves it and then settles', async (t) => { + t.mock.method(Date, 'now', () => 500000); + let library = emptyLibrary(); + const keys: string[] = []; + for (let n = 0; n < 40; n++) { + const identity = makeTrackIdentity(`https://youtube.com/watch?v=song${n}`, `Song ${n}`, 200); + keys.push(identity.key); + library = applyCommand(library, { type: 'practice', identity, + patch: { markers: [{ id: 'm', t: 1, label: randomBytes(4000).toString('base64') }] }, recent: true }, 100 + n); + library = applyCommand(library, { type: 'chart', key: identity.key, chart: null }, 100 + n); + } + library = applyCommand(library, { type: 'favorite', key: keys[0], value: true }, 1000); + await assert.rejects(encodeSnapshot(library.shared), /storage is full/); + + const h = harness({ library, syncConfig: config }); + await h.start(); + const stored = h.areas.local.library; + const uploaded = (await readSnapshot(h.areas.sync))!; + // The saved library and the upload are the same trimmed snapshot, re-dated so + // no other device can push the dropped songs back. + assert.deepEqual(stored.shared, uploaded); + assert.ok(uploaded.updatedAt > library.shared.updatedAt); + const dropped = keys.filter((key) => !uploaded.songs[key]); + assert.ok(dropped.length > 0); + assert.ok(!dropped.includes(keys[0])); + for (const key of dropped) { + assert.ok(!(key in stored.local.recent), `${key} left in recent`); + assert.ok(!(key in stored.local.lastAccessed), `${key} left in lastAccessed`); + assert.ok(!(key in stored.local.charts), `${key} left in charts`); + } + assert.equal(h.areas.local.syncConfig.lastError, null); + assert.equal(h.areas.local.syncConfig.usedBytes, bytesUsed(h.areas.sync)); + + h.writes.length = 0; + await h.rpc('librarySync', 'now'); + assert.deepEqual(h.writes, []); +}); + +test('an overflow of favorites alone keeps the library and reports the failure', async (t) => { + t.mock.method(Date, 'now', () => 500000); + let library = emptyLibrary(); + for (let n = 0; n < 40; n++) { + const identity = makeTrackIdentity(`https://youtube.com/watch?v=fav${n}`, `Fav ${n}`, 200); + library = applyCommand(library, { type: 'practice', identity, + patch: { markers: [{ id: 'm', t: 1, label: randomBytes(4000).toString('base64') }] }, recent: true }, 100 + n); + library = applyCommand(library, { type: 'favorite', key: identity.key, value: true }, 1000); + } + const h = harness({ library, syncConfig: config }); + await h.start(); + assert.deepEqual(h.areas.local.library, library); + assert.equal(h.writes.filter((w) => w.area === 'sync').length, 0); + assert.match(h.areas.local.syncConfig.lastError, /storage is full/); +}); diff --git a/src/core/persist/library-background.ts b/src/core/persist/library-background.ts new file mode 100644 index 0000000..443b96b --- /dev/null +++ b/src/core/persist/library-background.ts @@ -0,0 +1,180 @@ +import { onMessage } from '../messaging/rpc'; +import { applyCommand, emptyLibrary, newestSnapshot, type Library } from './library'; +import { libraryItem } from './library-client'; +import { parseLibrary } from './backup-codec'; +import { recoverLegacyStorage } from './library-recovery'; +import { bytesUsed, encodeSnapshot, fitSnapshot, hash, IncompleteSnapshot, PREFIX, readSnapshot, SNAPSHOT_KEY } from '../../features/sync/persist/records'; +import { loadSyncConfig, syncConfigItem } from '../../features/sync/persist/sync-config'; + +const WAKE = 'library-sync'; +const SAFETY = 'library-sync-safety'; +/** Chromium allows ~2 writes/second sustained; one push per 30 s is well under + * it and still lets a burst of edits ride out together. */ +const PUSH_INTERVAL = 30000; +const INCOMPLETE_GRACE = 120000; + +/** One writer for edits, imports and sync. Saved snapshots survive worker restarts. */ +export function startLibraryBackground() { + let queue: Promise = Promise.resolve(); + const enqueue = (work: () => Promise): Promise => { + const result = queue.then(work, work); + queue = result.catch(() => {}); + return result; + }; + let ready: Promise | undefined; + const init = () => ready ??= (async () => { + // Only the migration needs every old key. Ordinary wakes read one item. + const { library } = await browser.storage.local.get('library'); + if (library !== undefined) { + const normalized = parseLibrary(library); + if (JSON.stringify(normalized) !== JSON.stringify(library)) await libraryItem.setValue(normalized); + } else { + await libraryItem.setValue(recoverLegacyStorage(await browser.storage.local.get(null))); + } + })().catch((error) => { ready = undefined; throw error; }); + const schedule = async () => { + const config = await loadSyncConfig(); + if (config.enabled) await browser.alarms.create(WAKE, { when: Math.max(Date.now() + 5000, config.lastPushAt + PUSH_INTERVAL) }); + }; + const reconcile = async () => { + const config = await loadSyncConfig(); + if (!config.enabled) return; + const before = JSON.stringify(config); + let active = false; + const begin = async () => { + if (!active) { active = true; await syncConfigItem.setValue({ ...config, syncing: true }); } + }; + try { + await init(); + const existing = await browser.storage.sync.get(null); + config.usedBytes = bytesUsed(existing); + const local = await libraryItem.getValue(); + const remote = await readSnapshot(existing).catch(async (error) => { + // Repair an interrupted upload from our complete local copy. A newer + // incomplete snapshot must finish arriving before we can choose a winner. + if (error instanceof IncompleteSnapshot) { + if (error.updatedAt !== null && local.shared.updatedAt >= error.updatedAt) return null; + if (error.updatedAt === null) { + const fingerprint = await hash(JSON.stringify(Object.entries(existing) + .filter(([key]) => key.startsWith(PREFIX)).sort(([a], [b]) => a.localeCompare(b)))); + if (config.incompleteHash !== fingerprint || config.incompleteSince === undefined) { + config.incompleteHash = fingerprint; + config.incompleteSince = Date.now(); + } + // Let separate key arrivals settle. A permanently orphaned upload + // is repaired from our complete copy after a bounded, durable wait. + if (Date.now() >= config.incompleteSince + INCOMPLETE_GRACE) return null; + await browser.alarms.create(WAKE, { when: config.incompleteSince + INCOMPLETE_GRACE }); + } + } + throw error; + }); + const shared = remote ? newestSnapshot(local.shared, remote) : local.shared; + const adopting = shared !== local.shared; + let current = adopting ? { ...local, shared } : local; + if (adopting) { + await begin(); + await libraryItem.setValue(current); + config.lastSyncedAt = Date.now(); + } + config.lastError = null; + delete config.incompleteSince; + delete config.incompleteHash; + const uploading = !remote || shared.updatedAt > remote.updatedAt || !existing[SNAPSHOT_KEY]; + if (uploading) { + if (Date.now() < config.lastPushAt + PUSH_INTERVAL) { await schedule(); return; } + await begin(); + const fitted = await fitSnapshot(shared, current.local.lastAccessed, existing); + let { items, usedBytes } = fitted; + if (fitted.dropped.length) { + // Re-dated so the trimmed copy is the one winner: left on the old + // revision, another device's full snapshot would push the songs we + // just dropped straight back. Saved before uploading, so the local + // library and the uploaded one stay the same snapshot. + const trimmed = { ...fitted.shared, updatedAt: Math.max(Date.now(), shared.updatedAt + 1) }; + const device = { ...current.local, recent: { ...current.local.recent }, + lastAccessed: { ...current.local.lastAccessed }, charts: { ...current.local.charts } }; + for (const key of fitted.dropped) { + delete device.recent[key]; + delete device.lastAccessed[key]; + delete device.charts[key]; + } + current = { shared: trimmed, local: device }; + await libraryItem.setValue(current); + ({ items, usedBytes } = await encodeSnapshot(trimmed, existing)); + } + await browser.storage.sync.set(items); + config.lastPushAt = Date.now(); + config.usedBytes = usedBytes; + } + if (adopting || uploading || !config.lastSyncedAt) config.lastSyncedAt = Date.now(); + } catch (error) { + config.lastError = error instanceof Error ? error.message : String(error); + } finally { + config.syncing = false; + if (active || before !== JSON.stringify(config)) await syncConfigItem.setValue(config); + } + }; + onMessage('libraryRead', () => enqueue(async () => { await init(); return libraryItem.getValue(); })); + onMessage('libraryEdit', ({ data }) => enqueue(async () => { + let current: Library; + if (data.type === 'import') { + data.library = parseLibrary(data.library); + // Recovery imports must work even when initialization cannot parse the + // saved library. Keep that original available before replacing it. + const raw = (await browser.storage.local.get('library')).library; + try { current = raw === undefined ? emptyLibrary() : parseLibrary(raw); } + catch { + await browser.storage.local.set({ libraryRecovery: raw }); + current = emptyLibrary(); + const damaged = raw as Partial | null; + const updatedAt = damaged?.shared?.updatedAt; + const importRevision = damaged?.local?.importRevision; + if (typeof updatedAt === 'number' && Number.isFinite(updatedAt) && updatedAt >= 0) current.shared.updatedAt = updatedAt; + if (typeof importRevision === 'number' && Number.isSafeInteger(importRevision) && importRevision >= 0) current.local.importRevision = importRevision; + } + } else { + await init(); + current = await libraryItem.getValue(); + } + // Reject malformed edits before they can poison the next worker startup. + const next = parseLibrary(applyCommand(current, data)); + await libraryItem.setValue(next); + if (data.type === 'import') ready = Promise.resolve(); + if (current.shared.updatedAt !== next.shared.updatedAt) await schedule(); + return next.shared.updatedAt; + })); + onMessage('librarySync', ({ data }) => enqueue(async () => { + const config = await loadSyncConfig(); + if (data === 'disable' || data === 'delete') { + // Disabled before anything is removed, so a worker restart part-way + // through the delete cannot wake up and upload the library again. + await syncConfigItem.setValue(data === 'delete' + ? { ...config, enabled: false, syncing: false, lastSyncedAt: 0, usedBytes: 0, lastError: null } + : { ...config, enabled: false, syncing: false }); + await browser.alarms.clear(WAKE); + if (data === 'delete') { + const items = await browser.storage.sync.get(null); + const keys = Object.keys(items).filter((key) => key.startsWith(PREFIX)); + if (keys.length) await browser.storage.sync.remove(keys); + } + return; + } + if (data === 'enable') await syncConfigItem.setValue({ ...config, enabled: true }); + await reconcile(); + })); + browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === WAKE || alarm.name === SAFETY) void enqueue(reconcile); + }); + browser.storage.onChanged.addListener((changes, area) => { + if (area === 'sync' && Object.keys(changes).some((key) => key.startsWith(PREFIX))) { + void enqueue(reconcile); + } + }); + // Creating an existing alarm postpones it. Keep its deadline across wakes; + // recreate only when the browser has dropped it (for example after restart). + void browser.alarms.get(SAFETY).then((alarm) => { + if (!alarm) return browser.alarms.create(SAFETY, { periodInMinutes: 30 }); + }); + void enqueue(reconcile); +} diff --git a/src/core/persist/library-client.ts b/src/core/persist/library-client.ts new file mode 100644 index 0000000..31f0e3a --- /dev/null +++ b/src/core/persist/library-client.ts @@ -0,0 +1,10 @@ +import { storage } from '#imports'; +import { sendMessage } from '../messaging/rpc'; +import { emptyLibrary, type Library, type LibraryCommand } from './library'; + +/** Panels observe the library; the background is its only writer. */ +export const libraryItem = storage.defineItem('local:library', { fallback: emptyLibrary() }); +export const readLibrary = async () => sendMessage('libraryRead', undefined); +export const editLibrary = async (command: LibraryCommand): Promise => { + return sendMessage('libraryEdit', JSON.parse(JSON.stringify(command)) as LibraryCommand); +}; diff --git a/src/core/persist/library-migration.ts b/src/core/persist/library-migration.ts new file mode 100644 index 0000000..8a4316a --- /dev/null +++ b/src/core/persist/library-migration.ts @@ -0,0 +1,54 @@ +import { DEFAULT_PARAMS } from '../model/defaults.ts'; +import { makeTrackIdentity } from '../model/track-identity.ts'; +import { defineEntry, emptyLibrary, type Library, type Practice, type SavedSong } from './library.ts'; +import type { Backup } from './legacy-backup.ts'; + +/** Collapse old Recent/Favorites/track copies once, at the boundary. */ +export function migrateBackup(backup: Backup): Library { + const library = emptyLibrary(); + const { shared, local } = library; + const { lastUsedParams, ...settings } = backup.settings; + shared.settings = { ...shared.settings, ...settings }; + local.lastUsedParams = lastUsedParams; + local.uiPrefs = { ...local.uiPrefs, ...backup.uiPrefs }; + const ensure = (identity: Practice['identity']): SavedSong => { + const normalized = makeTrackIdentity(identity.normalizedUrl, identity.title, identity.durationSec); + return shared.songs[normalized.key] ??= { + practice: { identity: normalized, pageUrl: normalized.normalizedUrl, updatedAt: 0, + markers: [], snippets: [], sequenceLoop: false, sequenceCountIn: false }, + favoritedAt: null, + }; + }; + const observe = (at = 0) => { shared.updatedAt = Math.max(shared.updatedAt, at); }; + // History dates saved parameters; a favorite's star date must not outrank an edit. + const entries = [...backup.favorites, ...backup.history] + .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0)); + for (const entry of entries) { + const song = ensure(entry.identity); + song.practice = { ...song.practice, params: { ...DEFAULT_PARAMS, ...entry.params }, + pageUrl: entry.pageUrl, thumbnailUrl: entry.thumbnailUrl, updatedAt: entry.updatedAt ?? 0 }; + observe(entry.updatedAt); + } + for (const track of [...backup.tracks].sort((a, b) => a.updatedAt - b.updatedAt)) { + const song = ensure(track.identity); + const { identity, updatedAt, chordChart, ...data } = track; + song.practice = { ...song.practice, ...data, updatedAt: Math.max(updatedAt, song.practice.updatedAt) }; + local.charts[song.practice.identity.key] = chordChart ?? null; + observe(updatedAt); + } + for (const entry of backup.history) { + const key = ensure(entry.identity).practice.identity.key; + local.recent[key] = entry.updatedAt; + local.lastAccessed[key] = entry.updatedAt; + } + for (const entry of backup.favorites) { + const song = ensure(entry.identity); + song.favoritedAt = entry.favoritedAt; + const key = song.practice.identity.key; + local.lastAccessed[key] = Math.max(local.lastAccessed[key] ?? 0, entry.lastAccessedAt); + observe(entry.favoritedAt); + } + shared.favoriteOrder = backup.favorites.map((f) => ensure(f.identity).practice.identity.key); + for (const preset of backup.eqPresets) defineEntry(shared.presets, preset.name, preset.gains); + return library; +} diff --git a/src/core/persist/library-recovery.test.ts b/src/core/persist/library-recovery.test.ts new file mode 100644 index 0000000..f444e32 --- /dev/null +++ b/src/core/persist/library-recovery.test.ts @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { recoverLegacyStorage } from './library-recovery.ts'; +import { DEFAULT_PARAMS, DEFAULT_SETTINGS } from '../model/defaults.ts'; +import { makeTrackIdentity } from '../model/track-identity.ts'; +import { parseBackupJson } from './backup-codec.ts'; + +const a = makeTrackIdentity('https://youtube.com/watch?v=a', 'A', 100); +const b = makeTrackIdentity('https://youtube.com/watch?v=b', 'B', 200); +const marker = { id: 'good', t: 12, label: 'Verse' }; +const snippet = { id: 's', name: 'Solo', startT: 2, endT: 8, repeats: null, enabled: true, overrides: {} }; + +test('one damaged legacy row or field cannot discard unrelated saved work', () => { + const raw = { + history: [{ identity: a, pageUrl: a.normalizedUrl, params: { ...DEFAULT_PARAMS, speed: 0.7 } }, + { identity: b, updatedAt: 50, pageUrl: b.normalizedUrl, params: DEFAULT_PARAMS }, null], + favorites: [{ identity: b, updatedAt: 50, favoritedAt: 60, lastAccessedAt: 70, params: DEFAULT_PARAMS }], + 'track:a': { identity: a, updatedAt: 10, markers: [marker, { ...marker, id: 'bad', t: NaN }], + snippets: [snippet, { ...snippet, id: 'bad', startT: 'broken' }], sequenceLoop: true, chordChart: { coverage: 'bad' } }, + 'track:b': { identity: b, updatedAt: 20, markers: [{ ...marker, id: 'b' }], snippets: [snippet] }, + eqPresets: [{ name: 'Keep me', gains: [1, 2] }, { name: 'Bad', gains: ['bad'] }], + settings: { theme: 'dark', seekInterval: 'broken', keymap: { playPause: 'KeyP', seekBack: 123 } }, + uiPrefs: { collapsedSections: { looper: true, tools: 'broken' } }, + }; + const before = structuredClone(raw); + const recovered = recoverLegacyStorage(raw); + assert.deepEqual(raw, before, 'source records stay available for recovery'); + assert.deepEqual(Object.keys(recovered.shared.songs), [a.key, b.key]); + assert.deepEqual(recovered.shared.songs[a.key].practice.markers, [marker]); + assert.deepEqual(recovered.shared.songs[a.key].practice.snippets, [snippet]); + assert.equal(recovered.shared.songs[a.key].practice.params!.speed, 0.7); + assert.equal(recovered.local.recent[a.key], 0, 'missing dates use a safe default'); + assert.equal(recovered.shared.songs[b.key].favoritedAt, 60); + assert.equal(recovered.shared.songs[b.key].practice.markers[0].id, 'b'); + assert.deepEqual(recovered.shared.presets, { 'Keep me': [1, 2] }); + assert.equal(recovered.shared.settings.theme, 'dark'); + assert.equal(recovered.shared.settings.seekInterval, DEFAULT_SETTINGS.seekInterval); + assert.equal(recovered.shared.settings.keymap.playPause, 'KeyP'); + assert.equal(recovered.shared.settings.keymap.seekBack, DEFAULT_SETTINGS.keymap.seekBack); + assert.equal(recovered.local.uiPrefs.collapsedSections.looper, true); + assert.equal(recovered.local.charts[a.key], null); +}); + +test('a malformed legacy group does not discard other groups', () => { + const recovered = recoverLegacyStorage({ history: false, favorites: [null], settings: 'bad', + 'track:good': { identity: a, markers: [marker] }, eqPresets: [{ name: 'Still here', gains: [3] }] }); + assert.equal(recovered.shared.songs[a.key].practice.markers[0].t, 12); + assert.deepEqual(recovered.shared.presets, { 'Still here': [3] }); +}); + +test('automatic recovery does not make backup imports accept damaged records', () => { + assert.throws(() => parseBackupJson({ format: 'note-by-note-backup', version: 1, + history: [], favorites: [], eqPresets: [], + tracks: [{ identity: a, updatedAt: 1, markers: [{ ...marker, t: 'broken' }], snippets: [], sequenceLoop: false, sequenceCountIn: false }], + }), /number/); +}); diff --git a/src/core/persist/library-recovery.ts b/src/core/persist/library-recovery.ts new file mode 100644 index 0000000..1abcfc4 --- /dev/null +++ b/src/core/persist/library-recovery.ts @@ -0,0 +1,64 @@ +import { DEFAULT_PARAMS, DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../model/defaults.ts'; +import { makeTrackIdentity } from '../model/track-identity.ts'; +import type { ChordChart, FavoriteEntry, HistoryEntry, TrackData, TrackIdentity } from '../model/types'; +import { defaults, parseLibrary } from './backup-codec.ts'; +import { emptyLibrary } from './library.ts'; +import { migrateBackup } from './library-migration.ts'; +import { rekeyByIdentity } from './rekey.ts'; + +const record = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +const list = (value: unknown): any[] => Array.isArray(value) ? value : []; +const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const date = (value: unknown) => finite(value) && value >= 0 ? value : 0; + +/** The automatic upgrade salvages each field independently. Backup-file imports + * remain strict, and the original storage keys are retained for recovery. */ +export function recoverLegacyStorage(raw: Record) { + const identified = (rows: unknown): (Record & { identity: TrackIdentity; updatedAt: number })[] => list(rows).flatMap((value) => { + const row = record(value), identity = record(row.identity); + if (typeof identity.normalizedUrl !== 'string') return []; + return [{ ...row, identity: makeTrackIdentity(identity.normalizedUrl, + typeof identity.title === 'string' ? identity.title : identity.normalizedUrl, + finite(identity.durationSec) ? identity.durationSec : 0), updatedAt: date(row.updatedAt) }]; + }); + const entry = (row: ReturnType[number]): HistoryEntry => ({ + identity: row.identity, updatedAt: row.updatedAt, + pageUrl: typeof row.pageUrl === 'string' ? row.pageUrl : row.identity.normalizedUrl, + ...(typeof row.thumbnailUrl === 'string' ? { thumbnailUrl: row.thumbnailUrl } : {}), + params: defaults(row.params, DEFAULT_PARAMS, true), + }); + const chart = (value: unknown): ChordChart | null => { + try { + const candidate = emptyLibrary(); + candidate.local.charts.recovery = value as ChordChart; + return parseLibrary(candidate).local.charts.recovery; + } catch { return null; } + }; + const tracks: TrackData[] = identified(Object.entries(raw) + .filter(([key]) => key.startsWith('track:')).map(([, value]) => value)).map((row) => ({ + identity: row.identity, updatedAt: row.updatedAt, + markers: list(row.markers).filter((m) => m && typeof m.id === 'string' + && typeof m.label === 'string' && finite(m.t)), + snippets: list(row.snippets).filter((s) => s && typeof s.id === 'string' && typeof s.name === 'string' + && finite(s.startT) && finite(s.endT) && (s.repeats === null || s.repeats === Infinity || finite(s.repeats)) + && typeof s.enabled === 'boolean' && s.overrides && typeof s.overrides === 'object' + && !Array.isArray(s.overrides) && Object.values(s.overrides).every(finite)), + sequenceLoop: row.sequenceLoop === true, sequenceCountIn: row.sequenceCountIn === true, + ...(typeof row.chordsEnabled === 'boolean' ? { chordsEnabled: row.chordsEnabled } : {}), + chordChart: chart(row.chordChart ?? null), + })); + const history = identified(raw.history).map(entry); + const favorites: FavoriteEntry[] = identified(raw.favorites).map((row) => ({ + ...entry(row), favoritedAt: date(row.favoritedAt), lastAccessedAt: date(row.lastAccessedAt), + })); + const settings = defaults(raw.settings, DEFAULT_SETTINGS, true); + const lastUsed = record(raw.settings).lastUsedParams; + if (lastUsed) settings.lastUsedParams = defaults(lastUsed, DEFAULT_PARAMS, true); + return parseLibrary(migrateBackup({ format: 'note-by-note-backup', version: 1, + settings, uiPrefs: defaults(raw.uiPrefs, DEFAULT_UI_PREFS, true), + history: rekeyByIdentity(history), favorites: rekeyByIdentity(favorites), tracks: rekeyByIdentity(tracks), + eqPresets: list(raw.eqPresets).filter((p) => p && typeof p.name === 'string' + && Array.isArray(p.gains) && p.gains.every(finite)), + })); +} diff --git a/src/core/persist/library.test.ts b/src/core/persist/library.test.ts new file mode 100644 index 0000000..517b746 --- /dev/null +++ b/src/core/persist/library.test.ts @@ -0,0 +1,242 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyCommand, emptyLibrary, favoriteEntries, newestSnapshot, recentEntries } from './library.ts'; +import { parseBackupJson, parseLibrary } from './backup-codec.ts'; +import { DEFAULT_PARAMS, DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../model/defaults.ts'; +import { makeTrackIdentity } from '../model/track-identity.ts'; + +const identity = makeTrackIdentity('https://www.youtube.com/watch?v=example', 'Song', 200); +const save = (library = emptyLibrary(), speed = 0.8, now = 100) => applyCommand(library, { + type: 'practice', identity, patch: { params: { ...DEFAULT_PARAMS, speed } }, recent: true, + importRevision: library.local.importRevision, +}, now); + +test('Recent and Favorites project the same saved parameters; removing Recent preserves a favorite', () => { + let library = applyCommand(save(), { type: 'favorite', key: identity.key, value: true }, 200); + library = save(library, 0.5); + assert.equal(recentEntries(library)[0].params.speed, 0.5); + assert.equal(favoriteEntries(library)[0].params.speed, 0.5); + library = applyCommand(library, { type: 'recent.remove', key: identity.key }); + assert.equal(recentEntries(library).length, 0); + assert.equal(favoriteEntries(library)[0].params.speed, 0.5); + library = applyCommand(library, { type: 'visit', key: identity.key }, 1000); + assert.equal(recentEntries(library).length, 0); + assert.equal(favoriteEntries(library)[0].lastAccessedAt, 1000); +}); + +test('the newer complete snapshot wins, including conflicting edits and unrelated changes', () => { + const base = applyCommand(save(), { type: 'favorite', key: identity.key, value: true }, 200); + const removed = applyCommand(base, { type: 'favorite', key: identity.key, value: false }, 300); + const edited = save(base, 0.4, 400); + const winner = newestSnapshot(removed.shared, edited.shared); + assert.equal(winner, edited.shared); + assert.equal(newestSnapshot(edited.shared, removed.shared), edited.shared); + // The later edit wins as a whole, even though it carries the older star. + assert.equal(winner.songs[identity.key].favoritedAt, 200); + assert.equal(winner.songs[identity.key].practice.params!.speed, 0.4); + const otherDevice = applyCommand(emptyLibrary(), { type: 'preset', name: 'Only remote', gains: [2] }, 500); + assert.equal(newestSnapshot(edited.shared, otherDevice.shared), otherDevice.shared); + assert.deepEqual(otherDevice.shared.songs, {}); +}); + +test('equal timestamps adopt the synced snapshot and stay settled', () => { + const local = save(emptyLibrary(), 0.8, 100).shared; + const remote = save(emptyLibrary(), 0.5, 100).shared; + assert.equal(newestSnapshot(local, remote), remote); + assert.equal(newestSnapshot(remote, remote), remote); + assert.equal(newestSnapshot(local, structuredClone(local)), local, 'identical remote data retains the local object'); +}); + +test('imports reject edits from old sessions, including songs absent from the backup', () => { + const original = save(); + const imported = applyCommand(original, { type: 'import', library: emptyLibrary() }); + const command = { type: 'practice' as const, identity, patch: { markers: [{ id: 'old', t: 4, label: 'Old' }] }, recent: true }; + assert.throws(() => applyCommand(imported, command), /backup was imported/); + assert.throws(() => applyCommand(imported, { type: 'chart', key: identity.key, chart: null }), /backup was imported/); + assert.throws(() => applyCommand(imported, { type: 'settings', patch: { lastUsedParams: DEFAULT_PARAMS }, importRevision: 0 }), /backup was imported/); + const edited = applyCommand(imported, { ...command, importRevision: imported.local.importRevision }); + assert.equal(edited.shared.songs[identity.key].practice.markers[0].id, 'old'); + const importedAgain = applyCommand(imported, { type: 'import', library: original }); + assert.throws(() => applyCommand(importedAgain, { ...command, importRevision: imported.local.importRevision }), /backup was imported/); + assert.equal(parseLibrary(importedAgain).local.importRevision, 2); +}); + +test('favoriting a missing song reports the failure', () => { + assert.throws(() => applyCommand(emptyLibrary(), { type: 'favorite', key: identity.key, value: true }), /no longer in the library/); +}); + +test('local activity, layout, last-used parameters and chord analysis never date shared data', () => { + const original = save(); + let next = applyCommand(original, { type: 'visit', key: identity.key }); + next = applyCommand(next, { type: 'chart', key: identity.key, chart: null }); + next = applyCommand(next, { type: 'uiPrefs', patch: { markerView: 'list' } }); + next = applyCommand(next, { type: 'settings', patch: { lastUsedParams: { ...DEFAULT_PARAMS, speed: 0.2 } } }); + assert.deepEqual(next.shared, original.shared); +}); + +test('deleting songs and presets removes them from the winning snapshot without tombstones', () => { + const library = applyCommand(save(), { type: 'preset', name: 'Old', gains: [1] }, 200); + let removed = applyCommand(library, { type: 'recent.remove', key: identity.key }, 300); + removed = applyCommand(removed, { type: 'preset', name: 'Old', gains: null }, 400); + assert.deepEqual(removed.shared.songs, {}); + assert.deepEqual(removed.shared.presets, {}); + assert.deepEqual(removed.local.recent, {}); + assert.deepEqual(removed.local.lastAccessed, {}); + assert.equal(newestSnapshot(library.shared, removed.shared), removed.shared); + assert.deepEqual(applyCommand(library, { type: 'recent.remove' }).shared.songs, {}); +}); + +test('field patches from panels preserve other changes on the same device', () => { + let library = save(); + library = applyCommand(library, { type: 'practice', identity, patch: { markers: [{ id: 'm', t: 4, label: 'Verse' }] }, recent: true }); + library = save(library, 0.3); + assert.deepEqual(library.shared.songs[identity.key].practice.markers, [{ id: 'm', t: 4, label: 'Verse' }]); +}); + +test('preference patches preserve other controls and do not create a sync revision', () => { + const original = save(); + let library = applyCommand(original, { type: 'uiPrefs', patch: { collapsedSections: { looper: true } } }); + library = applyCommand(library, { type: 'uiPrefs', patch: { collapsedSections: { tools: true } } }); + library = applyCommand(library, { type: 'uiPrefs', patch: { boundaryLabels: { start: 'Intro' } } }); + library = applyCommand(library, { type: 'uiPrefs', patch: { boundaryLabels: { end: 'Outro' }, markerView: 'list' } }); + assert.deepEqual(library.local.uiPrefs.collapsedSections, { ...DEFAULT_UI_PREFS.collapsedSections, looper: true, tools: true }); + assert.deepEqual(library.local.uiPrefs.boundaryLabels, { start: 'Intro', end: 'Outro' }); + assert.equal(library.local.uiPrefs.markerView, 'list'); + assert.deepEqual(library.shared, original.shared); +}); + +test('the storage boundary fills defaults for older settings and nested UI preferences', () => { + const older = JSON.parse(JSON.stringify(save())); + delete older.shared.settings.keymap.playPause; + delete older.shared.settings.countInBeep; + delete older.local.uiPrefs.collapsedSections.looper; + delete older.local.uiPrefs.boundaryLabels; + const restored = parseLibrary(older); + assert.equal(restored.shared.settings.keymap.playPause, DEFAULT_SETTINGS.keymap.playPause); + assert.equal(restored.shared.settings.countInBeep, DEFAULT_SETTINGS.countInBeep); + assert.equal(restored.local.uiPrefs.collapsedSections.looper, DEFAULT_UI_PREFS.collapsedSections.looper); + assert.deepEqual(restored.local.uiPrefs.boundaryLabels, DEFAULT_UI_PREFS.boundaryLabels); +}); + +test('missing or null preference groups use defaults without changing saved songs or dates', () => { + const saved = applyCommand(save(), { type: 'favorite', key: identity.key, value: true }, 200); + for (const absent of [undefined, null]) { + const older = { ...saved, local: { ...saved.local, uiPrefs: absent } }; + const before = structuredClone(older); + assert.deepEqual(parseLibrary(older), saved); + assert.deepEqual(older, before); + const partial = { ...saved, shared: { ...saved.shared, settings: { theme: 'dark', keymap: absent } }, + local: { ...saved.local, uiPrefs: { markerView: 'list', collapsed: absent, collapsedSections: absent, boundaryLabels: absent } } }; + const restored = parseLibrary(partial); + assert.deepEqual(restored.shared.settings, { ...DEFAULT_SETTINGS, theme: 'dark' }); + assert.deepEqual(restored.local.uiPrefs, { ...DEFAULT_UI_PREFS, markerView: 'list' }); + assert.deepEqual(restored.shared.songs, saved.shared.songs); + assert.deepEqual(parseLibrary(restored), restored); + assert.deepEqual(parseLibrary({ ...saved, shared: { ...saved.shared, settings: absent } }), saved); + } +}); + +test('absent local metadata defaults independently while malformed present data is rejected', () => { + const saved = save(); + for (const absent of [undefined, null]) { + for (const key of ['recent', 'lastAccessed', 'charts']) { + const older = { ...saved, local: { ...saved.local, [key]: absent } }; + assert.deepEqual(parseLibrary(older), { ...saved, local: { ...saved.local, [key]: {} } }); + } + assert.deepEqual(parseLibrary({ ...saved, local: absent }), { ...saved, local: emptyLibrary().local }); + } + for (const invalid of [false, 1, 'invalid', []]) { + assert.throws(() => parseLibrary({ ...saved, local: { ...saved.local, uiPrefs: invalid } }), /data/); + } + assert.throws(() => parseLibrary({ ...saved, shared: null }), /data/); + assert.throws(() => parseLibrary({ ...saved, local: { ...saved.local, recent: { [identity.key]: NaN } } }), /number/); +}); + +test('replacement imports and subsequent edits advance beyond observed or imported future dates', () => { + const current = save(emptyLibrary(), 0.8, 100000); + const replaced = applyCommand(current, { type: 'import', library: emptyLibrary() }, 1); + assert.deepEqual(replaced.shared.songs, {}); + assert.equal(replaced.shared.updatedAt, 100001); + assert.equal(newestSnapshot(current.shared, replaced.shared), replaced.shared); + assert.ok(save(replaced).shared.updatedAt > replaced.shared.updatedAt); + const future = save(emptyLibrary(), 0.5, 200000); + assert.equal(applyCommand(current, { type: 'import', library: future }, 1).shared.updatedAt, 200001); +}); + +test('released version-1 backups import parameters, favorites, markers and presets', () => { + const entry = { identity, pageUrl: identity.normalizedUrl, params: { ...DEFAULT_PARAMS, speed: 0.7 }, updatedAt: 10 }; + const migrated = parseBackupJson({ format: 'note-by-note-backup', version: 1, + settings: { ...DEFAULT_SETTINGS, lastUsedParams: DEFAULT_PARAMS }, uiPrefs: DEFAULT_UI_PREFS, + eqPresets: [{ name: 'Practice', gains: [1, 2, 3] }], + history: [entry], favorites: [{ ...entry, params: DEFAULT_PARAMS, updatedAt: 5, favoritedAt: 15, lastAccessedAt: 16 }], + tracks: [{ identity, updatedAt: 11, markers: [{ id: 'm', t: 42, label: '' }], snippets: [], sequenceLoop: false, sequenceCountIn: false, chordChart: null }], + }); + const song = migrated.shared.songs[identity.key]; + assert.equal(song.practice.params!.speed, 0.7); + assert.equal(song.practice.markers[0].t, 42); + assert.equal(song.favoritedAt, 15); + assert.equal(migrated.shared.updatedAt, 15); + assert.equal(migrated.local.lastAccessed[identity.key], 16); + assert.equal(migrated.shared.settings.lastUsedParams, undefined); + assert.deepEqual(migrated.shared.presets, { Practice: [1, 2, 3] }); + assert.deepEqual(migrated.local.lastUsedParams, DEFAULT_PARAMS); + assert.equal(migrated.version, 2); + const exported = JSON.parse(JSON.stringify(migrated)); + assert.deepEqual(parseBackupJson(exported), exported); +}); + +test('version-1 URL aliases migrate to one favorite with the newest practice data', () => { + const entries = ['https://youtube.com/shorts/example', 'https://youtube.com/embed/example', + 'https://youtube.com/watch?v=example'].map((url, i) => ({ + identity: { key: 'old:' + i, normalizedUrl: url, title: 'Song', durationSec: 100 }, + pageUrl: url, params: { ...DEFAULT_PARAMS, speed: 0.5 + i * 0.1 }, + updatedAt: i + 1, favoritedAt: i + 1, lastAccessedAt: i + 1, + })); + const migrated = parseBackupJson({ format: 'note-by-note-backup', version: 1, + history: entries, favorites: entries, tracks: [], eqPresets: [] }); + assert.deepEqual(Object.keys(migrated.shared.songs), ['yt:example']); + assert.deepEqual(migrated.shared.favoriteOrder, ['yt:example']); + assert.equal(migrated.shared.songs['yt:example'].practice.params!.speed, 0.7); + assert.equal(migrated.local.recent['yt:example'], 3); +}); + +test('new backups round-trip and reject malformed or unsupported formats', () => { + const backup = { format: 'note-by-note-backup', version: 2, exportedAt: 100, ...save() }; + assert.deepEqual(parseBackupJson(JSON.parse(JSON.stringify(backup))), backup); + for (const version of [3, 4, 5]) assert.throws(() => parseBackupJson({ ...backup, version }), /newer version/); + assert.throws(() => parseBackupJson({ ...backup, version: 0 }), /no longer reads/); + const damaged = structuredClone(backup); + damaged.shared.songs[identity.key].practice.identity.key = 'old-key'; + assert.equal(parseBackupJson(damaged).shared.songs[identity.key].practice.identity.key, identity.key); + damaged.shared.songs[identity.key].practice.identity.normalizedUrl = 'https://different.example'; + assert.throws(() => parseBackupJson(damaged), /identity/); + assert.throws(() => parseBackupJson({ ...backup, shared: { ...backup.shared, updatedAt: -1 } }), /revision/); +}); + +test('preset names matching object properties can be saved, backed up and deleted', () => { + let library = emptyLibrary(); + for (const name of ['constructor', '__proto__']) library = applyCommand(library, { type: 'preset', name, gains: [1] }); + assert.deepEqual(Object.keys(parseLibrary(library).shared.presets), ['constructor', '__proto__']); + library = applyCommand(library, { type: 'preset', name: '__proto__', gains: null }); + assert.deepEqual(Object.keys(library.shared.presets), ['constructor']); +}); + +test('clearing history also removes saved songs that no list can reach', () => { + const hidden = makeTrackIdentity('https://www.youtube.com/watch?v=hidden', 'Hidden', 100); + const kept = makeTrackIdentity('https://www.youtube.com/watch?v=kept', 'Kept', 100); + // Auto Save off: saved with its practice data, never added to Recent. + let library = applyCommand(save(), { type: 'practice', identity: hidden, + patch: { markers: [{ id: 'm', t: 1, label: 'Solo' }] }, recent: false }, 200); + library = applyCommand(library, { type: 'practice', identity: kept, patch: {}, recent: false }, 300); + library = applyCommand(library, { type: 'favorite', key: kept.key, value: true }, 400); + assert.deepEqual(recentEntries(library).map((entry) => entry.identity.key), [identity.key]); + assert.equal(library.shared.songs[hidden.key].practice.markers?.length, 1); + + const cleared = applyCommand(library, { type: 'recent.remove' }, 500); + assert.deepEqual(Object.keys(cleared.shared.songs), [kept.key]); + assert.deepEqual(cleared.local.recent, {}); + assert.deepEqual(Object.keys(cleared.local.lastAccessed), [kept.key]); + assert.deepEqual(favoriteEntries(cleared).map((entry) => entry.identity.key), [kept.key]); + // Removing one song still only touches that song. + assert.ok(applyCommand(library, { type: 'recent.remove', key: identity.key }, 500).shared.songs[hidden.key]); +}); diff --git a/src/core/persist/library.ts b/src/core/persist/library.ts new file mode 100644 index 0000000..e9f5193 --- /dev/null +++ b/src/core/persist/library.ts @@ -0,0 +1,180 @@ +import { DEFAULT_PARAMS, DEFAULT_SETTINGS, DEFAULT_UI_PREFS, HISTORY_LIMIT } from '../model/defaults.ts'; +import type { ChordChart, EffectParams, FavoriteEntry, HistoryEntry, Settings, TrackData, TrackIdentity, UiPrefs } from '../model/types'; + +export interface Practice extends Omit { + params?: EffectParams; + pageUrl: string; + thumbnailUrl?: string; +} +export interface SavedSong { + practice: Practice; + /** Display metadata, not a sync revision. Null means not favorited. */ + favoritedAt: number | null; +} +/** The same snapshot is saved locally and sent to browser sync. */ +export interface SharedLibrary { + updatedAt: number; + settings: Settings; + songs: Record; + presets: Record; + favoriteOrder: string[]; +} +export interface Library { + shared: SharedLibrary; + local: { + uiPrefs: UiPrefs; + recent: Record; + lastAccessed: Record; + charts: Record; + lastUsedParams?: EffectParams; + /** Invalidates edits from sessions opened before a replacement import. */ + importRevision?: number; + }; +} + +/** Preset names may spell object properties such as __proto__. */ +export function defineEntry(target: Record, key: string, value: T): void { + Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }); +} +export function emptyLibrary(): Library { + return { + shared: { updatedAt: 0, settings: structuredClone(DEFAULT_SETTINGS), songs: {}, presets: {}, favoriteOrder: [] }, + local: { uiPrefs: structuredClone(DEFAULT_UI_PREFS), recent: {}, lastAccessed: {}, charts: {} }, + }; +} + +/** One winner for the entire library. On equal timestamps the synced copy wins. */ +export function newestSnapshot(local: SharedLibrary, remote: SharedLibrary): SharedLibrary { + if (local.updatedAt === remote.updatedAt && JSON.stringify(local) === JSON.stringify(remote)) return local; + return local.updatedAt > remote.updatedAt ? local : remote; +} + +export type UiPrefsPatch = { + [K in keyof UiPrefs]?: UiPrefs[K] extends object ? Partial : UiPrefs[K]; +}; + +/** Shared with the panel, which shows a preference before the writer confirms it. */ +export function mergeUiPrefs(current: UiPrefs, patch: UiPrefsPatch): UiPrefs { + return { + ...current, ...patch, + collapsed: { ...current.collapsed, ...patch.collapsed }, + collapsedSections: { ...current.collapsedSections, ...patch.collapsedSections }, + boundaryLabels: { ...current.boundaryLabels, ...patch.boundaryLabels }, + }; +} + +export type LibraryCommand = + | { type: 'practice'; identity: TrackIdentity; patch: Partial; recent: boolean; importRevision?: number } + | { type: 'favorite'; key: string; value: boolean } + | { type: 'order'; keys: string[] } + | { type: 'visit'; key: string } + | { type: 'recent.remove'; key?: string } + | { type: 'chart'; key: string; chart: ChordChart | null; importRevision?: number } + | { type: 'settings'; patch: Partial; reset?: boolean; importRevision?: number } + | { type: 'uiPrefs'; patch: UiPrefsPatch } + | { type: 'preset'; name: string; gains: number[] | null } + | { type: 'import'; library: Library }; + +/** Only the background writes. Commands from panels patch the current snapshot. */ +export function applyCommand(library: Library, command: LibraryCommand, now = Date.now()): Library { + if ((command.type === 'practice' || command.type === 'chart' + || (command.type === 'settings' && command.importRevision !== undefined)) + && (command.importRevision ?? 0) !== (library.local.importRevision ?? 0)) { + throw new Error('A backup was imported. Reload the song before saving more practice edits.'); + } + const next = structuredClone(command.type === 'import' ? command.library : library); + const { shared, local } = next; + switch (command.type) { + case 'import': local.importRevision = (library.local.importRevision ?? 0) + 1; break; + case 'practice': { + const key = command.identity.key; + const song = shared.songs[key] ?? { practice: { + identity: command.identity, pageUrl: command.identity.normalizedUrl, updatedAt: now, + markers: [], snippets: [], sequenceLoop: false, sequenceCountIn: false, + }, favoritedAt: null }; + song.practice = { ...song.practice, ...command.patch, identity: command.identity, updatedAt: now }; + defineEntry(shared.songs, key, song); + if (command.recent || key in local.recent) local.recent[key] = now; + local.lastAccessed[key] = now; + local.recent = Object.fromEntries(Object.entries(local.recent).sort((a, b) => b[1] - a[1]).slice(0, HISTORY_LIMIT)); + break; + } + case 'favorite': { + const song = shared.songs[command.key]; + if (!song) { + if (command.value) throw new Error('This song is no longer in the library. Save it before adding it to Favorites.'); + break; + } + song.favoritedAt = command.value ? now : null; + shared.favoriteOrder = shared.favoriteOrder.filter((key) => key !== command.key); + if (command.value) shared.favoriteOrder.unshift(command.key); + break; + } + case 'order': shared.favoriteOrder = [...new Set(command.keys)].filter((key) => shared.songs[key]?.favoritedAt != null); break; + case 'visit': { + if (shared.songs[command.key]) local.lastAccessed[command.key] = now; + break; + } + case 'recent.remove': { + // Clearing history also forgets songs no list can reach: ones pushed past + // HISTORY_LIMIT, and ones saved while Auto Save was off. + const keys = command.key === undefined + ? [...new Set([...Object.keys(local.recent), ...Object.keys(shared.songs)])] + : [command.key]; + for (const key of keys) { + delete local.recent[key]; + if (shared.songs[key]?.favoritedAt != null) continue; + delete shared.songs[key]; + delete local.lastAccessed[key]; + delete local.charts[key]; + } + break; + } + case 'chart': local.charts[command.key] = command.chart; break; + case 'settings': { + const { lastUsedParams, ...patch } = command.patch; + if (lastUsedParams) local.lastUsedParams = lastUsedParams; + shared.settings = { ...(command.reset ? structuredClone(DEFAULT_SETTINGS) : shared.settings), ...patch }; + if (patch.rememberSettings) shared.settings.autoReset = false; + if (patch.autoReset) shared.settings.rememberSettings = false; + if (command.reset) delete local.lastUsedParams; + break; + } + case 'uiPrefs': local.uiPrefs = mergeUiPrefs(local.uiPrefs, command.patch); break; + case 'preset': { + if (command.gains === null) delete shared.presets[command.name]; + else defineEntry(shared.presets, command.name, command.gains); + break; + } + } + // Local activity never makes an older shared snapshot win. Imports are an + // explicit replacement, even when the file happens to contain the same data. + if (command.type === 'import' || JSON.stringify(shared) !== JSON.stringify(library.shared)) { + shared.updatedAt = Math.max(now, library.shared.updatedAt + 1, shared.updatedAt + 1); + } + return next; +} + +/** UI rows are projections. They are never written back as library copies. */ +function songEntry(key: string, library: Library): HistoryEntry | null { + const practice = library.shared.songs[key]?.practice; + if (!practice) return null; + return { + identity: practice.identity, pageUrl: practice.pageUrl, thumbnailUrl: practice.thumbnailUrl, + params: practice.params ?? structuredClone(DEFAULT_PARAMS), + updatedAt: library.local.recent[key] ?? practice.updatedAt, + }; +} +export function recentEntries(library: Library): HistoryEntry[] { + return Object.keys(library.local.recent).map((key) => songEntry(key, library)) + .filter((entry) => entry !== null).sort((a, b) => b.updatedAt - a.updatedAt); +} +export function favoriteEntries(library: Library): FavoriteEntry[] { + const keys = [...new Set([...library.shared.favoriteOrder, ...Object.keys(library.shared.songs).sort()])]; + return keys.flatMap((key) => { + const song = library.shared.songs[key]; + const entry = songEntry(key, library); + return song?.favoritedAt != null && entry ? [{ ...entry, favoritedAt: song.favoritedAt, + lastAccessedAt: library.local.lastAccessed[key] ?? song.favoritedAt }] : []; + }); +} diff --git a/src/core/persist/rekey.test.ts b/src/core/persist/rekey.test.ts new file mode 100644 index 0000000..f21a79d --- /dev/null +++ b/src/core/persist/rekey.test.ts @@ -0,0 +1,86 @@ +// Run with: pnpm test:dsp (node --test). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { rekeyByIdentity, type Keyed } from './rekey.ts'; +import { makeTrackIdentity, songKey } from '../model/track-identity.ts'; +import type { TrackIdentity } from '../model/types.ts'; + +const URL_A = 'https://www.youtube.com/watch?v=aaaaaaaaaaa'; +const URL_B = 'https://www.youtube.com/watch?v=bbbbbbbbbbb'; + +/** A row as an older build stored it: the key it used baked in the duration. */ +function stored(url: string, title: string, durationSec: number, updatedAt: number) { + const identity: TrackIdentity = { + ...makeTrackIdentity(url, title, durationSec), + key: `legacy:${durationSec}`, + }; + return { identity, updatedAt, mark: `${title}@${durationSec}` }; +} + +test('the key is re-derived from the media identity', () => { + const [row] = rekeyByIdentity([stored(URL_A, 'Song', 200, 1)]); + assert.equal(row.identity.key, songKey(row.identity)); + assert.equal(row.identity.durationSec, 200, 'duration is kept, as metadata'); +}); + +test('copies of one song under drifted durations collapse to the newest', () => { + const rows = rekeyByIdentity([ + stored(URL_A, 'Song', 200, 10), + stored(URL_A, 'Song', 201, 30), + stored(URL_A, 'Song', 199, 20), + ]); + assert.equal(rows.length, 1); + assert.equal(rows[0].mark, 'Song@201', 'the most recently written copy'); +}); + +test('late web titles do not create another song', () => { + const rows = rekeyByIdentity([ + stored(URL_A, 'Song', 200, 10), + stored(URL_A, 'Other', 200, 10), + ]); + assert.equal(rows.length, 1); +}); + +test('local files remain distinct and do not depend on the installation URL', () => { + const a = makeTrackIdentity('chrome-extension://aaa/local-player.html', 'one.mp3', 20); + const b = makeTrackIdentity('moz-extension://bbb/local-player.html', 'one.mp3', 21); + const c = makeTrackIdentity('chrome-extension://aaa/local-player.html', 'two.mp3', 20); + assert.equal(a.key, b.key); + assert.notEqual(a.key, c.key); +}); + +test('list order is kept, at the position of the first copy seen', () => { + const rows = rekeyByIdentity([ + stored(URL_A, 'First', 200, 10), + stored(URL_B, 'Second', 200, 10), + stored(URL_A, 'First', 201, 99), + ]); + assert.deepEqual(rows.map((r) => r.mark), ['First@201', 'Second@200']); +}); + +test('rows without a usable identity are dropped, never merged into one', () => { + const junk = [ + { identity: undefined, updatedAt: 1 }, + { identity: {}, updatedAt: 2 }, + null, + ] as unknown as Keyed[]; + assert.deepEqual(rekeyByIdentity([...junk, stored(URL_A, 'Song', 200, 1)]).length, 1); +}); + +test('re-running it changes nothing', () => { + const once = rekeyByIdentity([stored(URL_A, 'Song', 200, 10), stored(URL_B, 'Two', 90, 20)]); + assert.deepEqual(rekeyByIdentity(once), once); +}); + +test('previously distinct shorts, embed and watch URLs collapse before migration', () => { + const rows = ['https://youtube.com/shorts/aaaaaaaaaaa', + 'https://youtube.com/embed/aaaaaaaaaaa', 'https://youtube.com/watch?v=aaaaaaaaaaa'].map((url, i) => ({ + ...stored(url, 'Song', 100, i + 1), + identity: { key: 'old:' + i, normalizedUrl: url, title: 'Song', durationSec: 100 }, + })); + const result = rekeyByIdentity(rows); + assert.equal(result.length, 1); + assert.equal(result[0].identity.key, 'yt:aaaaaaaaaaa'); + assert.equal(result[0].updatedAt, 3); + assert.deepEqual(rekeyByIdentity(result), result); +}); diff --git a/src/core/persist/rekey.ts b/src/core/persist/rekey.ts new file mode 100644 index 0000000..1958471 --- /dev/null +++ b/src/core/persist/rekey.ts @@ -0,0 +1,40 @@ +import { makeTrackIdentity } from '../model/track-identity.ts'; +import type { TrackIdentity } from '../model/types'; + +/** + * Re-deriving stored keys, for the one-time migration in `library-migration.ts`. + * + * Pure and DOM-free (relative `.ts` imports; runs under `node --test`) because + * it is the one step of that migration that can lose data: rows a key change + * brings together have to be collapsed, and the right copy has to survive. + */ + +export interface Keyed { + identity: TrackIdentity; + updatedAt?: number; +} + +const at = (row: Keyed) => row.updatedAt ?? 0; + +/** + * Rows under the identity derived from their URL now, with copies that land on + * the same key collapsed to the most recently written one — a song saved under + * two durations was always one song, and this is where its copies finally meet. + * + * List order is kept (Recent is newest-first, Favorites is the manual order): a + * survivor takes the position of the first copy seen, not of the copy that won. + * Rows without a usable identity are dropped rather than given a key derived + * from `undefined`, which would collide every one of them into a single row. + */ +export function rekeyByIdentity(rows: T[]): T[] { + const byKey = new Map(); + for (const row of rows) { + if (typeof row?.identity?.normalizedUrl !== 'string') continue; + const identity = makeTrackIdentity(row.identity.normalizedUrl, row.identity.title, row.identity.durationSec); + const key = identity.key; + const next = { ...row, identity }; + const current = byKey.get(key); + if (!current || at(next) >= at(current)) byKey.set(key, next); + } + return [...byKey.values()]; +} diff --git a/src/core/persist/storage.ts b/src/core/persist/storage.ts index 8557bfb..f79ad1a 100644 --- a/src/core/persist/storage.ts +++ b/src/core/persist/storage.ts @@ -1,96 +1,2 @@ -import { storage, type StorageItemKey, type WxtStorageItem } from '#imports'; -import { DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../model/defaults'; -import type { - EqPreset, - FavoriteEntry, - HistoryEntry, - Settings, - TrackData, - UiPrefs, -} from '../model/types'; - -/** Rebuild a value as plain arrays/objects, stripping any Svelte `$state` - * proxies on the way. - * - * Chrome serializes storage writes to JSON and reads straight through a proxy; - * Firefox structured-clones them and throws DataCloneError, rejecting the write - * with nothing persisted. Panel stores are expected to `$state.snapshot` before - * writing, but one missed call is an invisible, browser-specific data-loss bug — - * so every write goes through here as well. - * - * A rebuild, not `structuredClone`: that throws on a proxy, which is the very - * case being defended against. Safe because this schema is JSON-shaped - * throughout (numbers, strings, booleans, arrays, plain objects); a Date, Map or - * typed array added later would need handling here first. - * - * Plain function, not the `$state.snapshot` rune: background.ts imports this - * module and runes only compile inside Svelte files. */ -function toPlain(value: T): T { - if (Array.isArray(value)) return value.map(toPlain) as T; - if (value === null || typeof value !== 'object') return value; - const out: Record = {}; - for (const [k, v] of Object.entries(value)) out[k] = toPlain(v); - return out as T; -} - -/** `storage.defineItem` with proxy-stripping on every write. Annotated rather - * than inferred: `storage.defineItem` is overloaded five ways, so deriving this - * signature from it makes the inference circular. */ -function defineItem( - key: StorageItemKey, - options: { fallback: T }, -): WxtStorageItem> { - const item = storage.defineItem(key, options); - const setValue = item.setValue.bind(item); - item.setValue = (value: T) => setValue(toPlain(value)); - return item; -} - -export const settingsItem = defineItem('local:settings', { - fallback: DEFAULT_SETTINGS, -}); - -export const uiPrefsItem = defineItem('local:uiPrefs', { - fallback: DEFAULT_UI_PREFS, -}); - -/** Recent history (Auto Save), newest first. */ -export const historyItem = defineItem('local:history', { - fallback: [], -}); - -/** Starred songs (History → Favorites). Array order = manual sort order. */ -export const favoritesItem = defineItem('local:favorites', { - fallback: [], -}); - -/** EQ curves the user saved (Equalizer → preset row). Array order = save order. - * Kept out of `settings` so Reset Settings can't wipe them. */ -export const eqPresetsItem = defineItem('local:eqPresets', { - fallback: [], -}); - -/** Origins the user has granted host permission for (mirrors permissions API, - * used to show/revoke the list without a permissions query round-trip). */ -export const grantedOriginsItem = defineItem('local:grantedOrigins', { - fallback: [], -}); - -/** Per-track markers/snippets, keyed by TrackIdentity.key. */ -export function trackDataKey(key: string) { - return `local:track:${key}` as const; -} - -export async function loadTrackData(key: string): Promise { - return (await storage.getItem(trackDataKey(key))) ?? null; -} - -export async function saveTrackData(data: TrackData): Promise { - await storage.setItem(trackDataKey(data.identity.key), toPlain(data)); -} - -export async function removeAllTrackData(): Promise { - const snapshot = await browser.storage.local.get(null); - const keys = Object.keys(snapshot).filter((k) => k.startsWith('track:')); - if (keys.length) await browser.storage.local.remove(keys); -} +import { storage } from '#imports'; +export const grantedOriginsItem = storage.defineItem('local:grantedOrigins', { fallback: [] }); diff --git a/src/core/persist/track-data.ts b/src/core/persist/track-data.ts deleted file mode 100644 index 9203bd6..0000000 --- a/src/core/persist/track-data.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { TrackData } from '../model/types'; -import { chordsTrackData } from '../../features/chords/persist.svelte'; -import { markersTrackData } from '../../features/markers/persist.svelte'; -import { snippetsTrackData } from '../../features/snippets/persist.svelte'; - -/** A feature's slice of the single per-track record (TrackData). track-sync - * binds, loads, and collects each descriptor without knowing the feature's - * internals — the persistence half of the "light registration" seam. TrackData - * stays typed in core (the descriptor keys are its fields). */ -export interface TrackDataDescriptor { - /** Wire the feature store's change hook to re-persist the whole record. */ - bind(persist: () => void): void; - /** Populate this feature's fields on a fresh record being saved. */ - collect(data: TrackData): void; - /** Restore this feature's state from a loaded record (null = never saved). */ - load(data: TrackData | null): void; -} - -export const trackDataDescriptors: TrackDataDescriptor[] = [ - markersTrackData, - snippetsTrackData, - chordsTrackData, -]; diff --git a/src/core/state/connect.svelte.ts b/src/core/state/connect.svelte.ts index 0f5d3a6..e7aadd5 100644 --- a/src/core/state/connect.svelte.ts +++ b/src/core/state/connect.svelte.ts @@ -1,7 +1,7 @@ import { UI_PORT, type EngineCommand, type EngineEvent } from '../messaging/protocol'; import { connectToTab, type TypedPort } from '../messaging/ports'; import { sendMessage } from '../messaging/rpc'; -import { features } from '../features'; +import { chords } from '../../features/chords/panel/chords.svelte'; import { session } from './session.svelte'; import { settings } from '../../features/settings/panel/settings.svelte'; @@ -16,16 +16,19 @@ function isLocalPlayer(url: string | undefined): boolean { /** A fresh engine starts on the default preset, so this runs on every attach as * well as on change — a no-op while nothing is attached. */ -function pushSettings() { - session.send({ - type: 'settings', +let lastSettings = ''; +export function pushSettings(force = false) { + const command = { + type: 'settings' as const, seekInterval: settings.current.seekInterval, lowLatency: settings.current.lowLatency, formantPreserved: settings.current.formantPreserved, countInBeats: settings.current.countInBeats, countInBpm: settings.current.countInBpm, countInBeep: settings.current.countInBeep, - }); + }; + const serialized = JSON.stringify(command); + if ((force || serialized !== lastSettings) && session.send(command)) lastSettings = serialized; } /** Side-panel side of the connection: binds the session store to the engine in @@ -39,8 +42,6 @@ class ConnectionManager { #generation = 0; async init() { - settings.onChange = pushSettings; - // Chromium opens one panel document per tab at `sidepanel.html?tabId=N` // (see core/side-panel.ts): pin to that tab. Hidden behind another tab // this document stays alive, and following activation there would leave @@ -86,6 +87,16 @@ class ConnectionManager { async #connect() { const generation = ++this.#generation; + try { + await this.#attach(generation); + } catch (error) { + if (generation !== this.#generation) return; + session.connection = 'stale'; + console.error('[note-by-note] connecting to the player failed', error); + } + } + + async #attach(generation: number) { this.#port?.disconnect(); this.#port = null; session.detachTransport(); @@ -137,10 +148,8 @@ class ConnectionManager { this.#port = port; port.onMessage((event) => { session.apply(event); - // Feature-owned traffic that lives outside the session mirror (e.g. the - // chords store) is routed through the panel feature registry. - for (const f of features) f.routeEvent?.(event); - if (event.type === 'snapshot') for (const f of features) f.onSnapshot?.(event); + if (event.type === 'pcm') chords.pushPcm(event.samples, event.sampleRate, event.t, event.speed); + if (event.type === 'snapshot') chords.syncActive(event.chordActive); if (event.type === 'state' || event.type === 'snapshot') { void this.#refineNoPlayer(); } @@ -149,14 +158,13 @@ class ConnectionManager { if (this.#port !== port) return; this.#port = null; session.detachTransport(); - for (const f of features) f.onDisconnect?.(); if (session.connection !== 'restricted' && session.connection !== 'idle') { session.connection = 'stale'; } }); session.attachTransport((cmd) => port.send(cmd)); port.send({ type: 'hello' }); - pushSettings(); + pushSettings(true); } /** "No compatible player": engine finds nothing but the tab is audible. */ diff --git a/src/core/state/library.svelte.ts b/src/core/state/library.svelte.ts new file mode 100644 index 0000000..090353b --- /dev/null +++ b/src/core/state/library.svelte.ts @@ -0,0 +1,14 @@ +import { emptyLibrary, type Library } from '../persist/library'; +import { libraryItem, readLibrary } from '../persist/library-client'; + +/** One panel-side copy. Settings, preferences, presets and song lists read it. */ +class LibraryStore { + current = $state.raw(emptyLibrary()); + async init() { + let changed = false; + libraryItem.watch((value) => { changed = true; this.current = value ?? emptyLibrary(); }); + const initial = await readLibrary(); + if (!changed) this.current = initial; + } +} +export const library = new LibraryStore(); diff --git a/src/core/state/persistence.test.ts b/src/core/state/persistence.test.ts new file mode 100644 index 0000000..33b6485 --- /dev/null +++ b/src/core/state/persistence.test.ts @@ -0,0 +1,428 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build, transformSync } from 'esbuild'; +import { compileModule } from 'svelte/compiler'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { applyCommand, emptyLibrary, type Library, type LibraryCommand } from '../persist/library.ts'; +import { DEFAULT_PARAMS, DEFAULT_UI_PREFS } from '../model/defaults.ts'; +import { makeTrackIdentity } from '../model/track-identity.ts'; + +const stubs: Record = { + '/library-client': 'export const readLibrary = () => h.read(); export const editLibrary = (command) => h.edit(command); export const libraryItem = { watch: (fn) => h.watch = fn };', + '/library.svelte': 'export const library = h.library;', + '/session.svelte': 'export const session = h.session;', + '/settings.svelte': 'export const settings = h.settings;', + '/markers.svelte': 'export const markers = h.markers;', + '/snippets.svelte': 'export const snippets = h.snippets;', + '/chords.svelte': 'export const chords = h.chords;', + '/btc-chords': 'export class BtcChordEngine {}', + '/side-panel': 'export const openTabWithPanel = (url) => h.navigations.push(url);', + '/messaging/ports': 'export const connectToTab = () => h.port;', + '/messaging/rpc': 'export const sendMessage = (...args) => h.rpc(...args);', + '/persist/sync-config': 'export const DEFAULT_SYNC_CONFIG = {}; export const withSyncDefaults = (v) => v; export const loadSyncConfig = () => h.loadConfig(); export const syncConfigItem = { watch: (fn) => h.watch = fn };', +}; +async function bundle(file: string) { + const built = await build({ entryPoints: [fileURLToPath(new URL(file, import.meta.url))], bundle: true, + write: false, platform: 'node', format: 'esm', plugins: [{ name: 'panel-test', setup(builder) { + builder.onResolve({ filter: /.*/ }, ({ path }) => { + const stub = Object.keys(stubs).find((suffix) => path.endsWith(suffix)); + return stub ? { path: stub, namespace: 'test' } : undefined; + }); + builder.onLoad({ filter: /.*/, namespace: 'test' }, ({ path }) => ({ contents: 'const h = globalThis.panelTest; ' + stubs[path] })); + builder.onLoad({ filter: /\.svelte\.ts$/, namespace: 'file' }, ({ path }) => ({ + contents: compileModule(transformSync(readFileSync(path, 'utf8'), { loader: 'ts', target: 'esnext' }).code, + { filename: path, generate: 'client' }).js.code, + })); + } }] }); + return built.outputFiles[0].text; +} +const [trackCode, syncCode, connectionCode, sessionCode, settingsCode, chordsCode] = await Promise.all([ + bundle('./track-sync.svelte.ts'), bundle('../../features/sync/panel/sync.svelte.ts'), bundle('./connect.svelte.ts'), + bundle('./session.svelte.ts'), bundle('../../features/settings/panel/settings.svelte.ts'), + bundle('../../features/chords/panel/chords.svelte.ts'), +]); +let instance = 0; +const load = (code: string) => import('data:text/javascript;base64,' + Buffer.from(code).toString('base64') + '#' + instance++); +async function harness(initial: Library) { + let saved = structuredClone(initial); + const { session } = await load(sessionCode); + const h = { + library: { current: structuredClone(saved) }, + settings: { get current() { return saved.shared.settings; } }, + session, commands: [] as unknown[], autoPublish: true, + markers: { list: [] as any[], onPersist: null as any, load(list: any[]) { this.list = list; } }, + snippets: { list: [] as any[], sequenceLoop: false, sequenceCountIn: false, onPersist: null as any, + load(list: any[], loop: boolean, countIn: boolean) { this.list = list; this.sequenceLoop = loop; this.sequenceCountIn = countIn; } }, + chords: null as unknown as typeof import('../../features/chords/panel/chords.svelte').chords, + rpc: async (..._args: unknown[]): Promise => ({ ok: true }), + port: { disconnected: false, disconnect() {}, onMessage() {}, onDisconnect() {}, send(_command: unknown) {} }, + navigations: [] as string[], edits: [] as LibraryCommand[], watch: null as any, + async read() { return structuredClone(saved); }, + async edit(command: LibraryCommand) { + h.edits.push(structuredClone(command)); + saved = applyCommand(saved, JSON.parse(JSON.stringify(command))); + if (h.autoPublish) h.publish(); + return saved.shared.updatedAt; + }, + publish() { h.library.current = structuredClone(saved); h.watch?.(h.library.current); }, + replace(library: Library) { saved = applyCommand(saved, { type: 'import', library }); h.publish(); }, + }; + (globalThis as any).panelTest = h; + h.chords = (await load(chordsCode)).chords; + (globalThis as any).browser = { tabs: { update: (_id: number, { url }: { url: string }) => h.navigations.push(url) } }; + session.attachTransport((command: unknown) => h.commands.push(command)); + return h; +} +async function connectTrack(h: Awaited>) { + const { trackSync } = await load(trackCode); + trackSync.init(); + h.session.onUserParamsChange = () => trackSync.onParamsChanged(); + h.session.onEngineDetached = () => trackSync.onEngineLost(); + return trackSync; +} +const a = makeTrackIdentity('chrome-extension://test/local-player.html', 'A.mp3', 100); +const b = makeTrackIdentity('chrome-extension://test/local-player.html', 'B.mp3', 100); +const media = { title: a.title, pageUrl: a.normalizedUrl, duration: 100, hasVideo: false }; +const withMarker = (id: string) => applyCommand(emptyLibrary(), { type: 'practice', identity: a, + patch: { params: DEFAULT_PARAMS, markers: [{ id, t: 3, label: id }] }, recent: true }); + +test('real session restoration reaches the engine without triggering a user edit', async () => { + const h = await harness(emptyLibrary()); + let edits = 0; + h.session.onUserParamsChange = () => edits++; + h.session.restoreParams({ ...DEFAULT_PARAMS, speed: 0.6 }); + assert.equal(h.session.params.speed, 0.6); + assert.equal(edits, 0); + assert.equal((h.commands[0] as any).patch.speed, 0.6); + h.session.patchParams({ speed: 0.8 }); + assert.equal(edits, 1); +}); + +test('remembered edits work before connecting and on immediate track switches before storage echoes', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const initial = applyCommand(emptyLibrary(), { type: 'settings', patch: { + rememberSettings: true, lastUsedParams: { ...DEFAULT_PARAMS, speed: 0.9 }, + } }); + const h = await harness(initial); + const trackSync = await connectTrack(h); + h.autoPublish = false; + h.session.patchParams({ speed: 0.7 }); + t.mock.timers.tick(2000); + assert.equal((await h.read()).local.lastUsedParams!.speed, 0.7); + assert.deepEqual((await h.read()).shared.songs, {}); + await trackSync.onMedia(media); + assert.equal(h.session.params.speed, 0.7); + h.session.patchParams({ speed: 0.6 }); + await trackSync.onMedia({ ...media, title: b.title }); + assert.equal(h.session.params.speed, 0.6); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 0.6); + h.publish(); + // Imported preferences and last-used values replace all pending local ones. + const imported = applyCommand(emptyLibrary(), { type: 'settings', patch: { + rememberSettings: true, lastUsedParams: { ...DEFAULT_PARAMS, speed: 0.8 }, + } }); + h.session.patchParams({ speed: 0.5 }); + h.replace(imported); + assert.equal(h.session.params.speed, 0.8); + t.mock.timers.tick(2000); + assert.equal((await h.read()).local.lastUsedParams!.speed, 0.8); +}); + +test('track changes use the ready mirror and a visit failure cannot mix song data', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const initial = applyCommand(withMarker('A marker'), { type: 'practice', identity: b, + patch: { params: { ...DEFAULT_PARAMS, speed: 0.6 }, markers: [{ id: 'B', t: 7, label: 'B marker' }] }, recent: true }); + const h = await harness(initial); + const trackSync = await connectTrack(h); + t.mock.method(h, 'read', async () => { throw new Error('The worker read is unavailable'); }); + await trackSync.onMedia(media); + t.mock.timers.tick(2000); + assert.equal(h.edits.filter((c) => c.type === 'practice').length, 0); + const edit = h.edit.bind(h); + t.mock.method(h, 'edit', (command: LibraryCommand) => command.type === 'visit' + ? Promise.reject(new Error('Visit failed')) : edit(command)); + await assert.rejects(trackSync.onMedia({ ...media, title: b.title }), /Visit failed/); + assert.equal(h.markers.list[0].id, 'B'); + h.markers.list[0].t = 9; + h.markers.onPersist(h.markers.list); + t.mock.timers.tick(2000); + assert.equal(h.library.current.shared.songs[a.key].practice.markers[0].t, 3); + assert.equal(h.library.current.shared.songs[b.key].practice.markers[0].t, 9); +}); + +test('pending edits capture their track and values before an engine snapshot replaces parameters', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + h.session.patchParams({ speed: 0.75 }); + // An incoming engine snapshot changes params before it reports the new media. + h.session.params = structuredClone(DEFAULT_PARAMS); + await trackSync.onMedia({ ...media, title: b.title }); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 0.75); + assert.equal((await h.read()).shared.songs[a.key].practice.pageUrl, media.pageUrl); + t.mock.timers.tick(2000); + assert.equal((await h.read()).shared.songs[b.key], undefined); +}); + +test('rapid return and explicit reopen retain edits until the library watch catches up', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + h.autoPublish = false; + h.session.patchParams({ speed: 0.75 }); + h.markers.list[0].t = 12; + h.markers.onPersist(h.markers.list); + await trackSync.onMedia({ ...media, title: b.title }); + assert.equal(h.library.current.shared.songs[a.key].practice.params!.speed, 1); + await trackSync.onMedia(media); + assert.equal(h.session.params.speed, 0.75); + assert.equal(h.markers.list[0].t, 12); + await trackSync.openHistoryEntry(4, { identity: a, pageUrl: a.normalizedUrl, updatedAt: 1, params: DEFAULT_PARAMS }); + assert.equal(h.session.params.speed, 0.75); + h.publish(); + await h.edit({ type: 'practice', identity: a, patch: { params: { ...DEFAULT_PARAMS, speed: 0.9 } }, recent: true }); + h.publish(); + // Acknowledged patches must not mask subsequent library changes. + await trackSync.openHistoryEntry(4, { identity: a, pageUrl: a.normalizedUrl, updatedAt: 1, params: DEFAULT_PARAMS }); + assert.equal(h.session.params.speed, 0.9); +}); + +test('marker and snippet bursts share one immutable save and engine detach flushes it', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + for (let i = 0; i < 50; i++) { + h.markers.list[0].t = i; + h.markers.onPersist(h.markers.list); + } + h.snippets.list = [{ id: 's', name: 'Verse', startT: 2, endT: 8, repeats: Infinity, enabled: true, overrides: {} }]; + h.snippets.onPersist(); + assert.equal(h.edits.filter((c) => c.type === 'practice').length, 0); + // Mutating the live store later cannot alter the already queued edit. + h.markers.list[0].t = 99; + h.session.detachTransport(); + const practice = (await h.read()).shared.songs[a.key].practice; + assert.equal(practice.markers[0].t, 49); + assert.equal(practice.snippets[0].endT, 8); + assert.equal(practice.snippets[0].repeats, null); + assert.equal(h.edits.filter((c) => c.type === 'practice').length, 1); + await trackSync.onMedia(media); + h.session.patchParams({ speed: 0.7 }); + t.mock.timers.tick(2000); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 0.7); +}); + +test('engine detach saves the final chord chart before clearing the track', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + h.chords.setEnabled(true); + const chart = { segments: [{ startT: 0, endT: 20, label: 'C', confidence: 1 }], key: null, + coverage: 0.2, analyzedFrom: 0, analyzedTo: 20, computedAt: 100 }; + h.chords.chart = chart; + h.chords.phase = 'analyzing'; + h.session.patchParams({ speed: 0.75 }); + + h.session.detachTransport(); + + const saved = await h.read(); + assert.equal(h.chords.phase, 'idle'); + assert.deepEqual(saved.local.charts[a.key], chart); + assert.equal(saved.shared.songs[a.key].practice.params!.speed, 0.75); + await trackSync.onMedia(media); + assert.deepEqual(h.chords.chart, chart); +}); + +test('failed hydration leaves no writable track and a later load can recover', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + const loadMarkers = h.markers.load.bind(h.markers); + let failed = false; + t.mock.method(h.markers, 'load', (list: any[]) => { + if (!failed) { failed = true; throw new Error('Cannot hydrate'); } + loadMarkers(list); + }); + await assert.rejects(trackSync.onMedia({ ...media, title: b.title }), /Cannot hydrate/); + assert.deepEqual(h.markers.list, []); + h.session.patchParams({ speed: 0.5 }); + t.mock.timers.tick(2000); + assert.equal((await h.read()).shared.songs[b.key], undefined); + await trackSync.onMedia({ ...media, title: b.title }); + h.session.patchParams({ speed: 0.6 }); + t.mock.timers.tick(2000); + assert.equal((await h.read()).shared.songs[b.key].practice.params!.speed, 0.6); +}); + +test('a later edit survives acknowledgement of an earlier save and panel flush commits it', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + h.autoPublish = false; + h.session.patchParams({ speed: 0.8 }); + trackSync.flush(); + h.session.patchParams({ speed: 0.6 }); + await new Promise((resolve) => setImmediate(resolve)); + h.publish(); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 0.8); + await trackSync.onMedia({ ...media, title: b.title }); + await trackSync.onMedia(media); + assert.equal(h.session.params.speed, 0.6); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 0.6); +}); + +test('engine loss while a visit is in flight cannot disable later saves', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + const edit = h.edit.bind(h); + let finish!: () => void; + let delay = true; + t.mock.method(h, 'edit', async (command: LibraryCommand) => { + if (command.type === 'visit' && delay) { + delay = false; + await new Promise((resolve) => { finish = resolve; }); + } + return edit(command); + }); + const loading = trackSync.onMedia(media); + h.session.detachTransport(); + finish(); + await loading; + await trackSync.onMedia(media); + h.session.patchParams({ speed: 0.7 }); + t.mock.timers.tick(2000); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 0.7); +}); + +test('import replaces active markers and cancels pending parameter saves before another edit', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('old')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + h.session.patchParams({ speed: 0.5 }); + h.replace(withMarker('imported')); + // Replacement is synchronous, so there is no gap with the old stores. + assert.equal(h.markers.list[0].id, 'imported'); + t.mock.timers.tick(2000); + assert.equal(h.edits.filter((command) => command.type === 'practice').length, 0); + h.markers.list[0].t = 20; + h.markers.onPersist(h.markers.list); + t.mock.timers.tick(2000); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual((await h.read()).shared.songs[a.key].practice.markers, [{ id: 'imported', t: 20, label: 'imported' }]); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 1); +}); + +test('opening another local preset preserves pending edits and never saves the preview onto the loaded file', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await harness(withMarker('A marker')); + const trackSync = await connectTrack(h); + await trackSync.onMedia(media); + h.session.patchParams({ speed: 0.8 }); + await trackSync.openHistoryEntry(4, { identity: b, pageUrl: b.normalizedUrl, updatedAt: 1, params: { ...DEFAULT_PARAMS, speed: 0.6 } }); + assert.deepEqual(h.navigations, []); + assert.equal(h.session.params.speed, 0.6); + assert.equal(h.markers.list[0].id, 'A marker'); + h.markers.onPersist(h.markers.list); + t.mock.timers.tick(2000); + assert.ok((await h.read()).shared.songs[a.key]); + assert.equal((await h.read()).shared.songs[a.key].practice.params!.speed, 0.8); + assert.equal((await h.read()).shared.songs[b.key], undefined); +}); + +test('sync status changes during the initial read cannot be missed or overwritten', async () => { + let finish!: (value: unknown) => void; + const h = { watch: null as any, loadConfig: () => new Promise((resolve) => { finish = resolve; }) }; + (globalThis as any).panelTest = h; + const { sync } = await load(syncCode); + const ready = sync.init(); + assert.equal(typeof h.watch, 'function'); + h.watch({ enabled: true, syncing: false, lastError: null }); + finish({ enabled: true, syncing: true }); + await ready; + assert.equal(sync.status, 'idle'); +}); + +test('engine settings are sent only on meaningful changes or a forced engine attach', async () => { + const h = await harness(emptyLibrary()); + const { pushSettings } = await load(connectionCode); + pushSettings(); + await h.edit({ type: 'uiPrefs', patch: { markerView: 'list' } }); + pushSettings(); + assert.equal(h.commands.length, 1); + await h.edit({ type: 'settings', patch: { countInBeats: 8 } }); + pushSettings(); + assert.equal(h.commands.length, 2); + pushSettings(true); + assert.equal(h.commands.length, 3); +}); + +test('an injection RPC rejection uses connection state and keeps reconnect listeners working', async (t) => { + const h = await harness(withMarker('A marker')); + const global = globalThis as any; + const previousLocation = global.location; + global.location = { search: '?tabId=4' }; + t.after(() => { global.location = previousLocation; }); + let onUpdated: (tabId: number, info: object) => void = () => {}; + global.browser = { + runtime: { getURL: (path: string) => 'chrome-extension://test' + path }, + permissions: { contains: async () => true }, + tabs: { get: async () => ({ id: 4, url: 'https://example.com/song' }), + onUpdated: { addListener: (fn: typeof onUpdated) => { onUpdated = fn; } } }, + }; + let attempts = 0; + h.rpc = async () => { + if (++attempts === 1) throw new Error('Worker restarted'); + return { ok: true }; + }; + t.mock.method(console, 'error', () => {}); + const send = t.mock.method(h.port, 'send', (_command: unknown) => {}); + const { connection } = await load(connectionCode); + await connection.init(); + assert.equal(h.session.connection, 'stale'); + assert.equal((await h.read()).shared.songs[a.key].practice.markers[0].id, 'A marker'); + onUpdated(4, { status: 'complete' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(attempts, 2); + assert.ok(send.mock.calls.some((call) => (call.arguments[0] as any)?.type === 'hello')); +}); + +test('a preference shows before the worker answers, survives an unrelated write and reverts on failure', async (t) => { + const h = await harness(emptyLibrary()); + h.autoPublish = false; + const { uiPrefs } = await load(settingsCode); + uiPrefs.init(); + assert.equal(uiPrefs.current.markerView, DEFAULT_UI_PREFS.markerView); + + // The toggle lands immediately; nothing waits on the service worker. + const pending = uiPrefs.setMarkerView('list'); + assert.equal(uiPrefs.current.markerView, 'list'); + await pending; + assert.equal(uiPrefs.current.markerView, 'list'); + // Nested groups merge like the writer does: other panels keep their state. + await uiPrefs.toggleCollapsed('pitch'); + assert.equal(uiPrefs.current.collapsed.pitch, !DEFAULT_UI_PREFS.collapsed.pitch); + assert.equal(uiPrefs.current.collapsed.speed, DEFAULT_UI_PREFS.collapsed.speed); + assert.equal(uiPrefs.current.markerView, 'list'); + + // Once the saved copy catches up the overlay is dropped, so another panel wins. + h.publish(); + assert.equal(uiPrefs.current, h.library.current.local.uiPrefs); + h.autoPublish = true; + await uiPrefs.setLibraryTab('favorites'); + assert.equal(uiPrefs.current, h.library.current.local.uiPrefs); + assert.equal(uiPrefs.current.libraryTab, 'favorites'); + + t.mock.method(console, 'error', () => {}); + h.edit = async () => { throw new Error('Worker restarted'); }; + await uiPrefs.setMarkerView('blocks'); + assert.equal(uiPrefs.current.markerView, 'list'); +}); diff --git a/src/core/state/session.svelte.ts b/src/core/state/session.svelte.ts index 932d612..02a6056 100644 --- a/src/core/state/session.svelte.ts +++ b/src/core/state/session.svelte.ts @@ -299,6 +299,16 @@ class SessionStore { // ─── Effect params ─────────────────────────────────────────── patchParams(patch: Partial) { + this.#applyParams(patch); + this.onUserParamsChange?.(); + } + + /** Restore playback state without treating it as an edit to the saved song. */ + restoreParams(params: EffectParams) { + this.#applyParams(params); + } + + #applyParams(patch: Partial) { if (patch.speed !== undefined) { patch.speed = clampSpeed(patch.speed); } @@ -306,7 +316,6 @@ class SessionStore { const snapshot = $state.snapshot(patch) as Partial; this.send({ type: 'params', patch: snapshot }); if (this.capturing) this.captureRelay?.params(snapshot); - this.onUserParamsChange?.(); } /** Ask the engine to measure the playing tempo and set `baseBpm`. The engine diff --git a/src/core/state/track-sync.svelte.ts b/src/core/state/track-sync.svelte.ts index 28938c9..24aa013 100644 --- a/src/core/state/track-sync.svelte.ts +++ b/src/core/state/track-sync.svelte.ts @@ -1,264 +1,201 @@ import { DEFAULT_PARAMS } from '../model/defaults'; import { makeTrackIdentity } from '../model/track-identity'; -import type { EffectParams, HistoryEntry, MediaInfo, TrackData, TrackIdentity } from '../model/types'; -import { touchFavorite } from '../../features/library/persist/favorites'; -import { removeHistoryEntry, upsertHistory } from '../../features/library/persist/history'; -import { findSavedEntry } from '../../features/library/panel/saved-settings'; -import { loadTrackData, saveTrackData } from '../persist/storage'; +import type { EffectParams, HistoryEntry, MediaInfo, TrackIdentity } from '../model/types'; +import type { Library, LibraryCommand, Practice } from '../persist/library'; +import { editLibrary, libraryItem } from '../persist/library-client'; import { openTabWithPanel } from '../side-panel'; -import { trackDataDescriptors } from '../persist/track-data'; import { session } from './session.svelte'; import { settings } from '../../features/settings/panel/settings.svelte'; +import { markers } from '../../features/markers/panel/markers.svelte'; +import { snippets } from '../../features/snippets/panel/snippets.svelte'; +import { chords } from '../../features/chords/panel/chords.svelte'; +import { library } from './library.svelte'; -/** Reacts to track changes — auto-saves the previous track to - * Recent, then resets / remembers / carries over params, and swaps the - * per-track markers & snippets in and out of storage. */ +type PracticeEdit = Extract; +type RememberedParams = { params: EffectParams; importRevision: number }; + +/** An active session loads saved data once. Library updates never interrupt playback. */ class TrackSync { #identity: TrackIdentity | null = null; - #pageUrl = ''; - #thumbnailUrl: string | undefined; - #saveTimer: ReturnType | undefined; - /** The params last set for #identity. `session.params` is overwritten by the - * incoming engine's snapshot before that snapshot's media event reaches us, - * so the outgoing track has to be saved from this, not from the mirror. */ - #params: EffectParams | null = null; - /** True once the user touched a control on this track — gates the Recent save. */ - #userAdjusted = false; - /** Key of an in-flight #apply. Every connect delivers more than one snapshot - * and #identity is only assigned after an awaited save, so without this two - * runs both take the track-switch branch and the loser applies auto-reset - * over the params the winner just restored. */ - #applyingKey: string | null = null; - /** The song whose saved settings are currently applied, as `url\ntitle`. Set - * only on a successful restore, so a lookup that missed — the title had not - * settled yet — is retried on the next media event. Deliberately excludes the - * duration: drifting duration is what it has to survive. */ - #restoredFor: string | null = null; - #zeroDurationTimer: ReturnType | undefined; + #media: MediaInfo | null = null; + #baselineParams: EffectParams | null = null; + #chordsEnabled = false; + #importRevision = 0; + #timer: ReturnType | undefined; + #pending: PracticeEdit | undefined; + #rememberParams: RememberedParams | undefined; + #lastUsed: RememberedParams | undefined; + /** Edits stay available for A -> B -> A until the storage watch acknowledges + * their commit. Only unacknowledged patches are overlaid on the library. */ + #drafts = new Map; committedAt?: number }>(); init() { - for (const d of trackDataDescriptors) { - d.bind(() => { - // Marker/snippet/chord edits count as adjusting a control. - this.#userAdjusted = true; - this.#persistTrackData(); - void this.#saveCurrent(); - }); - } + this.#importRevision = library.current.local.importRevision ?? 0; + libraryItem.watch((value) => { + if (!value) return; + this.#acknowledge(value); + const revision = value.local.importRevision ?? 0; + if (revision === this.#importRevision) return; + this.#importRevision = revision; + // Imports replace the session too. Discard pending pre-import edits; + // the writer rejects old revisions already in flight. + this.#cancelPending(); + this.#drafts.clear(); + this.#lastUsed = undefined; + this.#identity = null; + session.stopSequence(); + session.clearLoop(); + // Use the event's snapshot, independently of storage-listener ordering. + void this.onMedia(this.#media, value).catch((error) => console.error('[note-by-note] loading imported practice failed', error)); + }); + markers.onPersist = (list) => this.#queue({ markers: list }); + snippets.onPersist = () => this.#queue({ snippets: $state.snapshot(snippets.list), + sequenceLoop: snippets.sequenceLoop, sequenceCountIn: snippets.sequenceCountIn }); + chords.onPersist = () => { + if (!this.#identity) return; + // Generated analysis is local and does not date the saved practice record. + void editLibrary({ type: 'chart', key: this.#identity.key, chart: $state.snapshot(chords.chart), + importRevision: this.#importRevision }).catch((error) => console.error('[note-by-note] saving chart failed', error)); + if (this.#chordsEnabled !== chords.enabled) { + this.#chordsEnabled = chords.enabled; + this.#queue({ chordsEnabled: chords.enabled }); + } + }; } - /** The engine link dropped — reload, reopened tab, tab switch. Whatever - * reconnects starts on the default preset, so this song has to be restored - * again; flush any live edits first so the restore reads them back. */ onEngineLost() { - // #saveCurrent reads #userAdjusted and #params before its first await, so - // clearing the flag on the next line can't race it. - void this.#saveCurrent(); - this.#userAdjusted = false; - this.#restoredFor = null; + // Save the analyzer's final chart while it still belongs to this track. + chords.onDisconnect(); + this.#flush(); + this.#identity = null; + this.#media = null; + this.#baselineParams = null; } - /** Called for every media info event from the engine. */ - async onMedia(media: MediaInfo | null) { - clearTimeout(this.#zeroDurationTimer); + /** Commit before the panel is hidden or its document is closed. */ + flush() { this.#flush(); } + + async onMedia(media: MediaInfo | null, saved = library.current) { + // Null is transient (detecting / source change). Real loss has its own hook. if (!media) return; - if (!media.duration) { - // Duration 0 usually means metadata is still loading — but streams and - // unseekable sources never report one. Give durationchange a moment to - // supersede before keying the track without a duration. - this.#zeroDurationTimer = setTimeout(() => { - void this.#apply(media); - }, 3000); - return; + const identity = makeTrackIdentity(media.pageUrl, media.title, media.duration); + if (this.#identity?.key === identity.key) { this.#media = media; this.#identity = identity; return; } + this.#flush(); + this.#identity = null; + this.#media = media; + this.#baselineParams = null; + this.#importRevision = saved.local.importRevision ?? 0; + // Hydration is synchronous: no worker read can leave the previous song's + // stores attached to a new identity. Restoration never emits user edits. + try { + const practice = { ...saved.shared.songs[identity.key]?.practice, ...this.#drafts.get(identity.key)?.patch }; + markers.load($state.snapshot(practice.markers ?? [])); + snippets.load($state.snapshot(practice.snippets ?? []), practice.sequenceLoop ?? false, practice.sequenceCountIn ?? false); + chords.load($state.snapshot(saved.local.charts[identity.key] ?? null), practice.chordsEnabled); + this.#chordsEnabled = chords.enabled; + const params = practice.params ?? (saved.shared.settings.autoReset ? DEFAULT_PARAMS : + saved.shared.settings.rememberSettings ? this.#lastUsed?.params ?? saved.local.lastUsedParams : undefined); + if (params) session.restoreParams($state.snapshot(params) as EffectParams); + this.#baselineParams = $state.snapshot(session.params) as EffectParams; + this.#identity = identity; + } catch (error) { + markers.load([]); + snippets.load([], false, false); + chords.load(null, false); + throw error; } - await this.#apply(media); - } - - /** Puts a song's saved settings back on, however it was opened — a clicked - * row, a typed URL, a link, an SPA navigation, a reload. Recent and Favorites - * hand a song back the way it was left; the "new song" preference governs only - * songs with nothing saved. */ - #restoreSaved(identity: TrackIdentity): boolean { - const token = `${identity.normalizedUrl}\n${identity.title}`; - if (this.#restoredFor === token) return false; - const entry = findSavedEntry(identity); - if (!entry) return false; - this.#restoredFor = token; - // $state.snapshot, not structuredClone: the entry belongs to a $state store, - // so its params are a proxy (structuredClone throws DataCloneError on one) - // and patchParams would otherwise assign its EQ band array by reference. - this.#applyParams($state.snapshot(entry.params) as EffectParams); - return true; + await editLibrary({ type: 'visit', key: identity.key }); } - /** Sets params on the track's behalf rather than the user's. patchParams fires - * onParamsChanged synchronously, so without this every restore and every auto - * reset would count as an edit and re-save the entry it just read. */ - #applyParams(params: EffectParams) { - session.patchParams(params); - this.#userAdjusted = false; - clearTimeout(this.#saveTimer); + onParamsChanged() { + const params = $state.snapshot(session.params) as EffectParams; + this.#rememberParams = settings.current.rememberSettings ? { params, importRevision: this.#importRevision } : undefined; + if (this.#rememberParams) this.#lastUsed = this.#rememberParams; + if (this.#identity) { + this.#baselineParams = params; + this.#queue({ params }); + } else if (this.#rememberParams) { + // Remember parameter edits made before a player is connected too. + this.#schedule(); + } } - async #apply(media: MediaInfo) { - const identity = makeTrackIdentity(media.pageUrl, media.title, media.duration); - if (identity.key === this.#applyingKey) return; - - if (identity.key === this.#identity?.key) { - // Same track — but the title may have settled late (SPA navigation). - if (identity.title !== this.#identity.title) { - this.#identity = identity; - if (this.#userAdjusted) await this.#saveCurrent(); - } - // A no-op unless something changed what this song is or what is applied to - // it: the title just settled, or the engine restarted on the defaults. - if (!this.#userAdjusted) this.#restoreSaved(identity); - return; + #queue(patch: Partial) { + if (!this.#identity || !this.#baselineParams) return; + const key = this.#identity.key; + const draft = this.#drafts.get(key); + if (!library.current.shared.songs[key]?.practice.params && !draft?.patch.params) { + patch = { params: this.#baselineParams, ...patch }; } + // Capture values now, including the URL and revision. Engine echoes and + // navigation cannot change what a delayed save writes. + this.#pending = $state.snapshot({ type: 'practice', identity: this.#identity, + importRevision: this.#importRevision, recent: settings.current.autoSave, + patch: { ...this.#pending?.patch, ...patch, + pageUrl: this.#media?.pageUrl ?? this.#identity.normalizedUrl, thumbnailUrl: this.#media?.thumbnailUrl }, + }) as PracticeEdit; + this.#drafts.set(key, { patch: { ...draft?.patch, ...this.#pending.patch } }); + this.#schedule(); + } - // Same page, new duration — the metadata settled late (ad, slow load) and - // the track got keyed with a stale duration. Re-key in place instead of - // treating it as a track switch, so Recents doesn't get a duplicate row. - if (this.#identity && identity.normalizedUrl === this.#identity.normalizedUrl) { - const staleKey = this.#identity.key; - const adjusted = this.#userAdjusted; - this.#identity = identity; - this.#pageUrl = media.pageUrl; - this.#thumbnailUrl = media.thumbnailUrl ?? this.#thumbnailUrl; - // The key was wrong until now, so the real key's slice was never loaded. - const data = await loadTrackData(identity.key); - if (data) for (const d of trackDataDescriptors) d.load(data); - // Their edits outrank anything stored; otherwise a lookup that missed on - // the stale identity gets another go now the duration has settled. - if (!adjusted) this.#restoreSaved(identity); - if (adjusted) { - await removeHistoryEntry(staleKey); - await this.#saveCurrent(); - // Only carry the stale key's slice over when the real key has none, so - // this can't overwrite a saved record with an emptied one. - if (!data) this.#persistTrackData(); - } - return; - } - - this.#applyingKey = identity.key; - try { - // Leaving the previous track: auto-save it with its final settings. - await this.#saveCurrent(); - - this.#identity = identity; - this.#pageUrl = media.pageUrl; - this.#thumbnailUrl = media.thumbnailUrl; - - // Restore this track's per-feature data (markers, snippets, chords) if - // we've seen it before — each feature scatters its own slice. - const data = await loadTrackData(identity.key); - for (const d of trackDataDescriptors) d.load(data); - - // A different song, so nothing is applied for it yet — even if we happen - // to be coming back to one restored earlier. Reset after both awaits, so - // this branch is the last writer and an event that slipped through during - // them can't restore only to be auto-reset over. - this.#restoredFor = null; - this.#userAdjusted = false; - // Starting params: the song's own saved settings > auto reset > remember - // > carry over. The last three are for songs with nothing saved. - if (!this.#restoreSaved(identity)) { - if (settings.current.autoReset) { - this.#applyParams(structuredClone(DEFAULT_PARAMS)); - } else if (settings.current.rememberSettings && settings.current.lastUsedParams) { - // $state.snapshot, not structuredClone: settings.current is a rune, so - // lastUsedParams is a proxy and structuredClone throws on it. - this.#applyParams($state.snapshot(settings.current.lastUsedParams) as EffectParams); - } - } - this.#params = $state.snapshot(session.params) as EffectParams; - - // If the track is favorited, bump its Last Accessed timestamp. - await touchFavorite(identity, { - pageUrl: media.pageUrl, - thumbnailUrl: media.thumbnailUrl, - }); - } finally { - this.#applyingKey = null; - } + #schedule() { + clearTimeout(this.#timer); + this.#timer = setTimeout(() => this.#flush(), 1500); } - /** Called on (debounced) param changes to keep history and - * "Remember settings" fresh without waiting for a track switch. */ - onParamsChanged() { - this.#userAdjusted = true; - this.#params = $state.snapshot(session.params) as EffectParams; - clearTimeout(this.#saveTimer); - this.#saveTimer = setTimeout(() => { - void this.#saveCurrent(); - if (settings.current.rememberSettings) { - void settings.update({ - lastUsedParams: $state.snapshot(session.params) as EffectParams, - }); - } - }, 1500); + #cancelPending() { + clearTimeout(this.#timer); + this.#timer = undefined; + this.#pending = undefined; + this.#rememberParams = undefined; } - async #saveCurrent() { - if (!this.#identity) return; - // Nothing was edited on this track — mirroring now would write auto-reset - // or carried-over state over what the user actually saved. - if (!this.#userAdjusted) return; - const params = this.#params ?? ($state.snapshot(session.params) as EffectParams); - // Both copies, from one value, in one call: a song that is favorited *and* - // in Recent must never end up with the two disagreeing. Auto Save off stops - // new rows being added, not an existing one being kept current. - await touchFavorite(this.#identity, { params }); - await upsertHistory( - this.#identity, - params, - this.#pageUrl, - this.#thumbnailUrl, - !settings.current.autoSave, - ); + #flush() { + const command = this.#pending; + const params = this.#rememberParams; + this.#cancelPending(); + if (command) { + const draft = this.#drafts.get(command.identity.key)!; + void editLibrary(command).then((committedAt) => { + draft.committedAt = committedAt; + this.#acknowledge(library.current); + }).catch((error) => { + if (this.#drafts.get(command.identity.key) === draft) this.#drafts.delete(command.identity.key); + console.error('[note-by-note] saving practice failed', error); + }); + } + if (params) void editLibrary({ type: 'settings', patch: { lastUsedParams: params.params }, + importRevision: params.importRevision }).then(() => this.#acknowledge(library.current)).catch((error) => { + if (this.#lastUsed === params) this.#lastUsed = undefined; + console.error('[note-by-note] saving last-used settings failed', error); + }); } - #persistTrackData() { - if (!this.#identity) return; - // Build the single record; each feature descriptor fills in its own fields. - const data: TrackData = { - identity: this.#identity, - markers: [], - snippets: [], - sequenceLoop: false, - sequenceCountIn: false, - chordChart: null, - chordsEnabled: false, - updatedAt: Date.now(), - }; - for (const d of trackDataDescriptors) d.collect(data); - void saveTrackData(data); + #acknowledge(saved: Library) { + if (this.#lastUsed && JSON.stringify(saved.local.lastUsedParams) === JSON.stringify(this.#lastUsed.params)) this.#lastUsed = undefined; + for (const [key, draft] of this.#drafts) { + if (draft.committedAt !== undefined && saved.shared.updatedAt >= draft.committedAt) this.#drafts.delete(key); + } } - /** A row in the Songs list was clicked: go there. Its settings need no staging - * — arriving at a song is what puts them back on, whoever asked for it. */ async openHistoryEntry(tabId: number | null, entry: HistoryEntry) { - const target = makeTrackIdentity(entry.pageUrl, '', 0).normalizedUrl; - const playing = session.media?.pageUrl; - // Already on that page: apply in place. Re-navigating to the URL it is - // already on would reload for nothing (losing the playhead), and may not - // fire a media event at all. - if (playing && makeTrackIdentity(playing, '', 0).normalizedUrl === target) { - // Snapshot: `entry` belongs to a $state store, so patchParams would - // otherwise assign its EQ band array straight off the entry. - this.#applyParams($state.snapshot(entry.params) as EffectParams); + const playing = this.#identity?.key; + if (playing?.startsWith('file:') && entry.identity.key.startsWith('file:') && playing !== entry.identity.key) { + // Preserve actual edits to A before previewing B's preset on its player. + // File objects cannot be restored from a saved URL. + this.#flush(); + session.restoreParams($state.snapshot(entry.params) as EffectParams); return; } - - if (tabId != null) { - await browser.tabs.update(tabId, { url: entry.pageUrl }); - } else { - // No engine tab to reuse — a fresh one the panel follows (a plain create - // would activate a tab the panel isn't enabled on and hide it). - await openTabWithPanel(entry.pageUrl); + if (playing === entry.identity.key) { + this.#flush(); + this.#identity = null; + await this.onMedia(this.#media); + return; } + const url = entry.identity.key.startsWith('file:') ? browser.runtime.getURL('/local-player.html') : entry.pageUrl; + if (tabId != null) await browser.tabs.update(tabId, { url }); + else await openTabWithPanel(url); } } - export const trackSync = new TrackSync(); diff --git a/src/dev/browser-shim.ts b/src/dev/browser-shim.ts index 1986a3d..1625ed0 100644 --- a/src/dev/browser-shim.ts +++ b/src/dev/browser-shim.ts @@ -1,3 +1,4 @@ +import { applyCommand, emptyLibrary, type LibraryCommand } from '../core/persist/library'; /** Dev-only: lets the side panel render in a plain browser tab (UI preview / * screenshots) by installing a minimal in-memory `chrome` polyfill. No-op when * real extension APIs exist. Import FIRST in the entrypoint. */ @@ -91,7 +92,13 @@ if (!existing?.storage) { onMessage: makeEvent(), onDisconnect: makeEvent(), }), - sendMessage: async () => undefined, + sendMessage: async (message: { type: string; data: LibraryCommand }) => { + const stored = await shim.storage.local.get('library'); + const library = (stored.library ?? emptyLibrary()) as ReturnType; + if (message.type === 'libraryRead') return { res: library }; + if (message.type === 'libraryEdit') await shim.storage.local.set({ library: applyCommand(library, message.data) }); + return { res: undefined }; + }, }, tabs: { query: async () => [], diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 7c15cb5..a98107f 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -1,9 +1,9 @@ +import { startLibraryBackground } from '@/core/persist/library-background'; import type { OffscreenCommand } from '@/core/messaging/protocol'; import { onMessage } from '@/core/messaging/rpc'; import { grantedOriginsItem } from '@/core/persist/storage'; import { CAN_CAPTURE_TAB, HAS_SIDE_PANEL_API } from '@/core/platform'; import { disablePanelForTab, enablePanelForTab, isPanelShowing } from '@/core/side-panel'; -import { SYNC_HOST_PATTERNS } from '@/features/sync/sync-hosts'; /** Firefox's sidebar API. WXT's `browser` types are Chromium-shaped and don't * declare it, so reach it through a narrow cast — only ever on the Firefox @@ -28,13 +28,6 @@ function originPattern(url: string): string | null { } } -/** The sync host is held for the ID cookie, not for practising on — it is - * neither a site to register the engine on nor one "Revoke Permissions" should - * take back. Everything else `permissions.getAll()` reports is a site grant. */ -function siteOrigins(origins: string[]): string[] { - return origins.filter((o) => !SYNC_HOST_PATTERNS.includes(o)); -} - /** Content-script match patterns for the origins we hold. `` is a * valid permission origin but not a valid registration match, so the broad * grant collapses to the http(s) wildcard. */ @@ -72,12 +65,12 @@ async function syncRegistration(matches: string[]) { * extensions UI, or revoke button). */ async function syncFromPermissions() { const { origins = [] } = await browser.permissions.getAll(); - const sites = siteOrigins(origins); - await grantedOriginsItem.setValue(sites); - await syncRegistration(registrationMatches(sites)); + await grantedOriginsItem.setValue(origins); + await syncRegistration(registrationMatches(origins)); } export default defineBackground(() => { + startLibraryBackground(); // Scope the panel to the tabs it was opened on: the manifest's // `side_panel.default_path` enables it everywhere, so once opened it would // follow the user to every tab. With the default disabled and the click @@ -178,11 +171,8 @@ export default defineBackground(() => { onMessage('revokeAllPermissions', async () => { const { origins = [] } = await browser.permissions.getAll(); - // `` covers the sync host, so revoking it also ends cookie access; - // the panel re-requests it from the Sync settings when needed. - const sites = siteOrigins(origins); - if (sites.length) { - await browser.permissions.remove({ origins: sites }).catch(() => { + if (origins.length) { + await browser.permissions.remove({ origins }).catch(() => { // Some patterns may already be gone; onRemoved still reconciles below. }); } diff --git a/src/entrypoints/local-player/main.ts b/src/entrypoints/local-player/main.ts index 73fb579..bb43deb 100644 --- a/src/entrypoints/local-player/main.ts +++ b/src/entrypoints/local-player/main.ts @@ -2,14 +2,13 @@ import { mount } from 'svelte'; import LocalPlayer from './LocalPlayer.svelte'; import '@/assets/theme.css'; import { applyTheme } from '@/features/settings/panel/settings.svelte'; -import { settingsItem } from '@/core/persist/storage'; +import { readLibrary } from '@/core/persist/library-client'; // Match the side panel's chosen theme. Apply 'auto' synchronously (follows the // OS, live) to avoid a flash, then refine from the stored choice once loaded. applyTheme('auto'); -void settingsItem - .getValue() - .then((s) => applyTheme(s?.theme ?? 'auto')) +void readLibrary() + .then((library) => applyTheme(library.shared.settings.theme)) .catch(() => {}); const app = mount(LocalPlayer, { diff --git a/src/entrypoints/sidepanel/App.svelte b/src/entrypoints/sidepanel/App.svelte index 30a4d0f..071fc4e 100644 --- a/src/entrypoints/sidepanel/App.svelte +++ b/src/entrypoints/sidepanel/App.svelte @@ -5,14 +5,15 @@ import LibraryView from '@/features/library/panel/LibraryView.svelte'; import SettingsView from '@/features/settings/panel/SettingsView.svelte'; import TooltipLayer from '@/ui/shared/TooltipLayer.svelte'; + import LibraryRecovery from '@/ui/LibraryRecovery.svelte'; import { sendMessage } from '@/core/messaging/rpc'; import { openTabWithPanel } from '@/core/side-panel'; import { installMockState, installMockTicker } from '@/dev/mock'; - import { connection } from '@/core/state/connect.svelte'; + import { connection, pushSettings } from '@/core/state/connect.svelte'; import { CAN_CAPTURE_TAB } from '@/core/platform'; - import { features } from '@/core/features'; + import { library } from '@/core/state/library.svelte'; import { session } from '@/core/state/session.svelte'; - import { applyTheme, settings } from '@/features/settings/panel/settings.svelte'; + import { applyTheme, settings, uiPrefs } from '@/features/settings/panel/settings.svelte'; import { installShortcuts } from '@/features/shortcuts/panel/shortcuts'; import { trackSync } from '@/core/state/track-sync.svelte'; import { view } from '@/core/state/view.svelte'; @@ -22,11 +23,15 @@ const mock = params.has('mock'); // ?mock=1&play=1 also runs the playhead, for previewing time-driven UI. const mockPlay = mock && params.has('play'); - // Each panel feature loads its own storage concurrently (see core/features.ts). - const ready = Promise.all(features.map((f) => f.init?.())).then( + $effect(() => applyTheme(settings.current.theme)); + $effect(pushSettings); + + // Only a saved-library failure belongs in the recovery screen. + const ready = library.init(); + void ready.then( async () => { - applyTheme(settings.current.theme); trackSync.init(); + uiPrefs.init(); session.onMediaEvent = (media) => { trackSync.onMedia(media).catch((err: unknown) => { console.error('[note-by-note] track sync failed', err); @@ -44,14 +49,14 @@ }; } installShortcuts(); - // Fire-and-forget: opening the panel must not wait on the network. + // Fire-and-forget: opening the panel must not wait on storage. void sync.init(); if (mock) { installMockState(); if (mockPlay) installMockTicker(); } else await connection.init(); }, - ); + ).catch((error) => console.error('[note-by-note] initializing panel failed', error)); // Opened from here rather than via the background: the side panel has to // follow the user to the player tab, and only this document holds the @@ -71,6 +76,11 @@ } + trackSync.flush()} /> + { + if (document.visibilityState === 'hidden') trackSync.flush(); +}} /> + {#await ready then}
+{:catch error} + {/await} diff --git a/src/features/chords/panel/panel.ts b/src/features/chords/panel/panel.ts deleted file mode 100644 index 954bfbd..0000000 --- a/src/features/chords/panel/panel.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { EngineEvent } from '../../../core/messaging/protocol'; -import type { PanelFeature, SnapshotEvent } from '../../../core/features'; -import { chords } from './chords.svelte'; - -/** Chord/key detection lives in its own track-scoped store (like markers), so - * its engine traffic is routed here rather than through the session mirror. */ -export const chordsFeature: PanelFeature = { - routeEvent(event: EngineEvent) { - if (event.type === 'pcm') chords.pushPcm(event.samples, event.sampleRate, event.t, event.speed); - }, - onSnapshot(snapshot: SnapshotEvent) { - chords.syncActive(snapshot.chordActive); - }, - onDisconnect() { - chords.onDisconnect(); - }, -}; diff --git a/src/features/chords/persist.svelte.ts b/src/features/chords/persist.svelte.ts deleted file mode 100644 index 2d240d5..0000000 --- a/src/features/chords/persist.svelte.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TrackDataDescriptor } from '../../core/persist/track-data'; -import { chords } from './panel/chords.svelte'; - -/** Chords' slice of the per-track record: the analyzed chart and the panel - * switch (separate, so switching off keeps the analysis). */ -export const chordsTrackData: TrackDataDescriptor = { - bind(persist) { - chords.onPersist = persist; - }, - collect(data) { - data.chordChart = $state.snapshot(chords.chart); - data.chordsEnabled = chords.enabled; - }, - load(data) { - chords.load(data?.chordChart ?? null, data?.chordsEnabled); - }, -}; diff --git a/src/features/eq/panel/eq-presets.svelte.ts b/src/features/eq/panel/eq-presets.svelte.ts index d50078a..e0efa0b 100644 --- a/src/features/eq/panel/eq-presets.svelte.ts +++ b/src/features/eq/panel/eq-presets.svelte.ts @@ -1,21 +1,14 @@ import { BUILTIN_EQ_PRESETS, EQ_BANDS } from '../../../core/model/defaults'; import type { EqPreset } from '../../../core/model/types'; -import { deleteEqPreset, saveEqPreset } from '../persist/eq-presets'; -import { eqPresetsItem } from '../../../core/persist/storage'; +import { editLibrary } from '../../../core/persist/library-client'; +import { library } from '../../../core/state/library.svelte'; /** Slider gains are multiples of 0.5 dB, so anything closer than this is the * same curve; the tolerance only guards against float drift. */ const GAIN_EPSILON = 0.01; class EqPresetsStore { - saved = $state([]); - - async init() { - this.saved = await eqPresetsItem.getValue(); - eqPresetsItem.watch((value) => { - this.saved = value ?? []; - }); - } + saved = $derived(Object.entries(library.current.shared.presets).map(([name, gains]) => ({ name, gains }))); /** Built-ins first, then the user's, as listed in the dropdown. */ get all(): EqPreset[] { @@ -39,11 +32,11 @@ class EqPresetsStore { } async save(name: string, gains: number[]) { - await saveEqPreset(name, gains); + await editLibrary({ type: 'preset', name, gains }); } async remove(name: string) { - await deleteEqPreset(name); + await editLibrary({ type: 'preset', name, gains: null }); } } diff --git a/src/features/eq/panel/panel.ts b/src/features/eq/panel/panel.ts deleted file mode 100644 index 19b05e1..0000000 --- a/src/features/eq/panel/panel.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PanelFeature } from '../../../core/features'; -import { eqPresets } from './eq-presets.svelte'; - -/** User-saved EQ presets load from storage at panel boot. */ -export const eqFeature: PanelFeature = { - init: () => eqPresets.init(), -}; diff --git a/src/features/eq/persist/eq-presets.ts b/src/features/eq/persist/eq-presets.ts deleted file mode 100644 index d0740c3..0000000 --- a/src/features/eq/persist/eq-presets.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { eqPresetsItem } from '../../../core/persist/storage'; - -/** Save the current curve under `name`. An existing preset with that name is - * replaced in place — that's the edit and rename path, so there's no separate - * UI for either. */ -export async function saveEqPreset(name: string, gains: number[]): Promise { - const list = await eqPresetsItem.getValue(); - const index = list.findIndex((p) => p.name === name); - if (index === -1) { - await eqPresetsItem.setValue([...list, { name, gains }]); - return; - } - const next = [...list]; - next[index] = { name, gains }; - await eqPresetsItem.setValue(next); -} - -export async function deleteEqPreset(name: string): Promise { - const list = await eqPresetsItem.getValue(); - await eqPresetsItem.setValue(list.filter((p) => p.name !== name)); -} diff --git a/src/features/library/panel/favorites.svelte.ts b/src/features/library/panel/favorites.svelte.ts index eeef566..3ef706c 100644 --- a/src/features/library/panel/favorites.svelte.ts +++ b/src/features/library/panel/favorites.svelte.ts @@ -1,67 +1,24 @@ -import type { FavoriteEntry, HistoryEntry, TrackIdentity } from '../../../core/model/types'; -import { - addFavorite, - removeFavorite, - setFavoritesOrder, -} from '../persist/favorites'; -import { isSameTrack } from '../../../core/model/track-identity'; -import { favoritesItem } from '../../../core/persist/storage'; +import type { HistoryEntry, TrackIdentity } from '../../../core/model/types'; +import { favoriteEntries } from '../../../core/persist/library'; +import { editLibrary } from '../../../core/persist/library-client'; +import { library } from '../../../core/state/library.svelte'; -/** Every write below is fired from a click handler as a floating promise, and - * the store only repaints from the `favoritesItem.watch` callback — so a failed - * write repaints nothing and reads as a dead button. Log instead of vanishing. */ -async function write(what: string, run: () => Promise): Promise { - try { - await run(); - } catch (err) { - console.error(`[note-by-note] favorites: ${what} failed:`, err); - } -} +/** The list only repaints from the storage watch, so a rejected write leaves the + * control looking dead. Log instead of vanishing. */ +const write = (run: Promise) => + run.catch((error: unknown) => console.error('[note-by-note] library write failed', error)); class FavoritesStore { - entries = $state([]); - - async init() { - this.entries = await favoritesItem.getValue(); - favoritesItem.watch((value) => { - this.entries = value ?? []; - }); - } - - /** By song, not by key: a favorite stored under a duration that has since - * drifted is still this track, and its star has to read as lit. */ - has(identity: TrackIdentity): boolean { - return this.entries.some((e) => isSameTrack(e.identity, identity)); - } - - async toggle(entry: HistoryEntry) { - // Unstar the row as it was stored — its key may differ from this one's. - const existing = this.entries.find((e) => isSameTrack(e.identity, entry.identity)); - // $state.snapshot: `entry` belongs to the history store, so it and its - // nested identity/params are proxies. Firefox structured-clones storage - // writes and throws DataCloneError on a proxy (Chrome, which serializes to - // JSON, does not) — without this the star silently never lights. - await write('toggle', () => - existing - ? removeFavorite(existing.identity.key) - : addFavorite($state.snapshot(entry) as HistoryEntry), - ); - } - - async remove(key: string) { - await write('remove', () => removeFavorite(key)); - } - - /** Commit a new manual order (complete list of identity keys). Applied - * optimistically so the list doesn't snap back while storage round-trips. */ - async reorder(keys: string[]) { - const byKey = new Map(this.entries.map((e) => [e.identity.key, e])); - const next = keys - .map((k) => byKey.get(k)) - .filter((e): e is (typeof this.entries)[number] => e !== undefined); - if (next.length === this.entries.length) this.entries = next; - await write('reorder', () => setFavoritesOrder(keys)); + /** Derived, not a getter: the rows are rebuilt only when the library changes, + * not on every read while the Songs sheet renders. */ + entries = $derived(favoriteEntries(library.current)); + + has(identity: TrackIdentity) { return library.current.shared.songs[identity.key]?.favoritedAt != null; } + toggle(entry: HistoryEntry) { + return write(editLibrary({ type: 'favorite', key: entry.identity.key, + value: library.current.shared.songs[entry.identity.key]?.favoritedAt == null })); } + remove(key: string) { return write(editLibrary({ type: 'favorite', key, value: false })); } + reorder(keys: string[]) { return write(editLibrary({ type: 'order', keys })); } } - export const favorites = new FavoritesStore(); diff --git a/src/features/library/panel/history.svelte.ts b/src/features/library/panel/history.svelte.ts index 93d06ae..addc700 100644 --- a/src/features/library/panel/history.svelte.ts +++ b/src/features/library/panel/history.svelte.ts @@ -1,27 +1,12 @@ -import type { HistoryEntry } from '../../../core/model/types'; -import { clearHistory, dedupeHistory, removeHistoryEntry } from '../persist/history'; -import { historyItem } from '../../../core/persist/storage'; +import { recentEntries } from '../../../core/persist/library'; +import { editLibrary } from '../../../core/persist/library-client'; +import { library } from '../../../core/state/library.svelte'; class HistoryStore { - entries = $state([]); + /** Derived, not a getter: see the note in favorites.svelte.ts. */ + entries = $derived(recentEntries(library.current)); - async init() { - // Rows saved before dedupe-on-write can already be duplicated; collapse - // them once, on the way in, so the list the user sees is the stored one. - await dedupeHistory(); - this.entries = await historyItem.getValue(); - historyItem.watch((value) => { - this.entries = value ?? []; - }); - } - - async remove(key: string) { - await removeHistoryEntry(key); - } - - async clear() { - await clearHistory(); - } + remove(key: string) { return editLibrary({ type: 'recent.remove', key }); } + clear() { return editLibrary({ type: 'recent.remove' }); } } - export const history = new HistoryStore(); diff --git a/src/features/library/panel/panel.ts b/src/features/library/panel/panel.ts deleted file mode 100644 index ef3f890..0000000 --- a/src/features/library/panel/panel.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { PanelFeature } from '../../../core/features'; -import { favorites } from './favorites.svelte'; -import { history } from './history.svelte'; - -/** Recent history + favorites load from storage at panel boot. */ -export const libraryFeature: PanelFeature = { - async init() { - await Promise.all([history.init(), favorites.init()]); - }, -}; diff --git a/src/features/library/panel/saved-settings.ts b/src/features/library/panel/saved-settings.ts deleted file mode 100644 index 03b02d4..0000000 --- a/src/features/library/panel/saved-settings.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { HistoryEntry, TrackIdentity } from '../../../core/model/types'; -import { favorites } from './favorites.svelte'; -import { history } from './history.svelte'; - -/** The settings saved for a song that is being *visited* — a typed URL, a link, - * an SPA navigation — or null when it has none. - * - * Deliberately looser than `isSameTrack`: at a page's first media event the - * title has usually not settled (sites rewrite document.title after the element - * fires), so demanding a title match would miss the very case this exists for. - * The URL alone is not enough either — every local file reports the local-player - * page URL and is told apart only by its title. So the title is required exactly - * when the URL is ambiguous, i.e. when it holds more than one song. */ -export function findSavedEntry(identity: TrackIdentity): HistoryEntry | null { - // Favorites first: the two lists are written together and cannot disagree, - // but with Auto Save off only the favorite is guaranteed to exist. - const pool = [...favorites.entries, ...history.entries].filter( - (e) => e.identity.normalizedUrl === identity.normalizedUrl, - ); - const titled = pool.filter((e) => e.identity.title === identity.title); - if (titled.length > 0) return titled[0]; - // Count songs, not rows — a favorited song is a row in both lists. - const oneSong = new Set(pool.map((e) => e.identity.title)).size === 1; - return oneSong ? pool[0] : null; -} diff --git a/src/features/library/persist/favorites.ts b/src/features/library/persist/favorites.ts deleted file mode 100644 index 17f4cda..0000000 --- a/src/features/library/persist/favorites.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { EffectParams, HistoryEntry, TrackIdentity } from '../../../core/model/types'; -import { isSameTrack } from '../../../core/model/track-identity'; -import { favoritesItem } from '../../../core/persist/storage'; - -/** Star a song: copy the history entry into the Favorites library (top of the - * manual order). No-op if already favorited. */ -export async function addFavorite(entry: HistoryEntry): Promise { - const list = await favoritesItem.getValue(); - // By song, not by key — starring the same track after its duration settled - // differently must not add a second row. - if (list.some((e) => isSameTrack(e.identity, entry.identity))) return; - const now = Date.now(); - await favoritesItem.setValue([ - { ...entry, favoritedAt: now, lastAccessedAt: now }, - ...list, - ]); -} - -export async function removeFavorite(key: string): Promise { - const list = await favoritesItem.getValue(); - await favoritesItem.setValue(list.filter((e) => e.identity.key !== key)); -} - -/** Persist a new manual order (list of identity keys, complete). */ -export async function setFavoritesOrder(keys: string[]): Promise { - const list = await favoritesItem.getValue(); - const byKey = new Map(list.map((e) => [e.identity.key, e])); - const next = keys.map((k) => byKey.get(k)).filter((e) => e !== undefined); - // Keep entries missing from `keys` (e.g. added concurrently) at the top. - const missing = list.filter((e) => !keys.includes(e.identity.key)); - await favoritesItem.setValue([...missing, ...next]); -} - -/** Refresh a favorite when its track is opened/played: bump Last Accessed and - * mirror the latest settings. No-op if the track isn't favorited. - * - * Matched by song, like every other favorites operation. Matching on - * `identity.key` would stop finding the row as soon as the duration drifted - * (pre-roll ad, late metadata) — the favorite would then freeze while Recent, - * which matches by song, kept updating, and the two copies would disagree. */ -export async function touchFavorite( - identity: TrackIdentity, - patch?: { params?: EffectParams; pageUrl?: string; thumbnailUrl?: string }, -): Promise { - const list = await favoritesItem.getValue(); - const index = list.findIndex((e) => isSameTrack(e.identity, identity)); - if (index === -1) return; - const now = Date.now(); - const entry = list[index]; - const next = [...list]; - next[index] = { - ...entry, - // Adopt the current identity so the row stops being pinned to whatever - // duration it happened to be saved under. - identity, - params: patch?.params ?? entry.params, - pageUrl: patch?.pageUrl ?? entry.pageUrl, - thumbnailUrl: patch?.thumbnailUrl ?? entry.thumbnailUrl, - lastAccessedAt: now, - updatedAt: patch?.params ? now : entry.updatedAt, - }; - await favoritesItem.setValue(next); -} diff --git a/src/features/library/persist/history.ts b/src/features/library/persist/history.ts deleted file mode 100644 index b3feb85..0000000 --- a/src/features/library/persist/history.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { HISTORY_LIMIT } from '../../../core/model/defaults'; -import type { EffectParams, HistoryEntry, TrackIdentity } from '../../../core/model/types'; -import { isSameTrack } from '../../../core/model/track-identity'; -import { historyItem } from '../../../core/persist/storage'; - -/** Insert or refresh a Recent entry (newest first, LRU-capped). - * - * `onlyExisting` refreshes a row that is already there but never adds one — - * what Auto Save off means, since that toggle is about *adding* every song you - * play. Keeping the row current either way is what stops the Recent copy from - * drifting away from the Favorites copy of the same song. */ -export async function upsertHistory( - identity: TrackIdentity, - params: EffectParams, - pageUrl: string, - thumbnailUrl?: string, - onlyExisting = false, -): Promise { - const list = await historyItem.getValue(); - const now = Date.now(); - const existing = list.find((e) => isSameTrack(e.identity, identity)); - if (!existing && onlyExisting) return; - const entry = { - identity, - params, - pageUrl, - thumbnailUrl: thumbnailUrl ?? existing?.thumbnailUrl, - createdAt: existing?.createdAt ?? now, - updatedAt: now, - }; - // Matched by song, not by key: this row supersedes every older one for the - // same song, so a duration that settled differently can't leave a twin behind. - const next = [entry, ...list.filter((e) => !isSameTrack(e.identity, identity))]; - await historyItem.setValue(next.slice(0, HISTORY_LIMIT)); -} - -/** Collapse rows written before saves were matched by song (one song split - * across several durations). The list is newest-first, so the first row for a - * song wins and the older twins are dropped. */ -export async function dedupeHistory(): Promise { - const list = await historyItem.getValue(); - const kept: HistoryEntry[] = []; - for (const entry of list) { - if (!kept.some((e) => isSameTrack(e.identity, entry.identity))) kept.push(entry); - } - if (kept.length !== list.length) await historyItem.setValue(kept); -} - -export async function removeHistoryEntry(key: string): Promise { - const list = await historyItem.getValue(); - await historyItem.setValue(list.filter((e) => e.identity.key !== key)); -} - -export async function clearHistory(): Promise { - await historyItem.setValue([]); -} diff --git a/src/features/markers/persist.svelte.ts b/src/features/markers/persist.svelte.ts deleted file mode 100644 index 3685c1e..0000000 --- a/src/features/markers/persist.svelte.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { TrackDataDescriptor } from '../../core/persist/track-data'; -import { markers } from './panel/markers.svelte'; - -/** Markers' slice of the per-track record. */ -export const markersTrackData: TrackDataDescriptor = { - bind(persist) { - markers.onPersist = persist; - }, - collect(data) { - data.markers = $state.snapshot(markers.list); - }, - load(data) { - markers.load(data?.markers ?? []); - }, -}; diff --git a/src/features/settings/panel/SettingsView.svelte b/src/features/settings/panel/SettingsView.svelte index 1c74f86..ceff7a8 100644 --- a/src/features/settings/panel/SettingsView.svelte +++ b/src/features/settings/panel/SettingsView.svelte @@ -19,7 +19,7 @@ restoreBackup, } from '@/core/persist/backup'; import { history } from '@/features/library/panel/history.svelte'; - import { applyTheme, settings } from '@/features/settings/panel/settings.svelte'; + import { settings } from '@/features/settings/panel/settings.svelte'; import { session } from '@/core/state/session.svelte'; import { view } from '@/core/state/view.svelte'; import { sync } from '@/features/sync/panel/sync.svelte'; @@ -47,7 +47,7 @@ let notice = $state<{ ok: boolean; text: string } | null>(null); /** UI-level view of the `autoReset` / `rememberSettings` pair, which the - * settings store keeps mutually exclusive. Both off = carry over. */ + * background keeps mutually exclusive. Both off = carry over. */ type NewSongBehavior = 'defaults' | 'keep' | 'lastUsed'; const themeOptions: { value: Theme; label: string; icon: IconName }[] = [ @@ -89,11 +89,6 @@ }); } - function setTheme(value: Theme) { - void settings.update({ theme: value }); - applyTheme(value); - } - function setTabAudio(on: boolean) { void settings.update({ tabAudio: on }); ontabaudio?.(on); @@ -110,7 +105,7 @@ } function clearHistoryConfirmed() { - if (confirm('Remove all saved songs from the history list?')) { + if (confirm('Remove every saved song that is not a Favorite, with its markers, snippets and settings?')) { void history.clear(); } } @@ -118,7 +113,6 @@ function resetSettingsConfirmed() { if (!confirm('Restore all extension settings to their defaults?')) return; void settings.reset(); - applyTheme('auto'); } function revokeConfirmed() { @@ -136,9 +130,8 @@ notice = null; try { const backup = await createBackup(); - const blob = new Blob([JSON.stringify(backup, null, 2)], { - type: 'application/json', - }); + const text = JSON.stringify(backup, null, 2); + const blob = new Blob([text], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -147,8 +140,9 @@ // The download reads the blob after click() returns, so the URL has to // outlive this task. setTimeout(() => URL.revokeObjectURL(url), 0); - const songs = backup.history.length + backup.favorites.length; - notice = { ok: true, text: `Saved ${link.download} (${songs} songs).` }; + const songs = Object.keys(backup.shared.songs).length; + const kb = Math.max(1, Math.round(new TextEncoder().encode(text).length / 1024)); + notice = { ok: true, text: `Saved ${link.download} (${songs} songs, ${kb} KB).` }; } catch (err) { notice = { ok: false, text: `Export failed: ${message(err)}` }; } finally { @@ -164,13 +158,12 @@ const backup = parseBackup(await file.text()); const ok = confirm( 'Replace all settings, history, favorites, presets, markers and snippets ' + - 'with the contents of this file? Your current data is lost.', + 'with the contents of this file? Your current data is lost.' + + (sync.enabled ? ' The replacement of saved practice data, favorites, presets and settings also syncs.' : ''), ); if (!ok) return; await restoreBackup(backup); - // Every store reads storage once at start-up; a reload is the honest way - // to get the whole panel — theme, open track, engine — onto new data. - location.reload(); + notice = { ok: true, text: 'Backup imported. Open songs now use the imported practice settings.' }; } catch (err) { notice = { ok: false, text: `Import failed: ${message(err)}` }; } finally { @@ -187,20 +180,8 @@ let syncBusy = $state(false); let syncNotice = $state<{ ok: boolean; text: string } | null>(null); - let connectId = $state(''); - let idCopied = $state(false); async function setSyncEnabled(on: boolean) { - if (on && sync.syncId && !sync.lastSyncedAt) { - // An ID this device never synced with — inherited from another device - // through browser sync. Joining is pull-first, same as Connect. - const ok = confirm( - 'A sync ID from your other device was found. Replace all data on this ' + - "device with the synced copy? If the ID has no data yet, this device's " + - 'data is uploaded instead.', - ); - if (!ok) return; - } syncBusy = true; syncNotice = null; try { @@ -211,30 +192,18 @@ } } - /** An ID arrived from another device while this one already held data. */ - async function resolveConsent(accept: boolean) { - syncBusy = true; - syncNotice = null; - try { - // Accepting reloads the panel, so nothing after it runs. - if (accept) await sync.acceptRemote(); - else await sync.keepLocal(); - } finally { - syncBusy = false; - } - } - async function deleteSyncedData() { const ok = confirm( - 'Delete the synced copy of your data from the server? Sync will be turned ' + - 'off. Data on this device is not affected.', + "Delete the copy of your data in the browser's sync storage? Sync will be " + + 'turned off on this device. Data on this device is not affected, and other ' + + 'devices with sync on will upload their copy again.', ); if (!ok) return; syncBusy = true; syncNotice = null; try { await sync.deleteRemote(); - syncNotice = { ok: true, text: 'Synced data deleted from the server.' }; + syncNotice = { ok: true, text: 'Synced data deleted.' }; } catch (err) { syncNotice = { ok: false, text: `Delete failed: ${message(err)}` }; } finally { @@ -242,47 +211,6 @@ } } - async function keepAfterReinstall() { - syncNotice = null; - if (!(await sync.keepAfterReinstall())) { - syncNotice = { ok: false, text: 'Permission not granted — the ID is not kept after a reinstall.' }; - } - } - - async function copySyncId() { - if (!sync.syncId) return; - await navigator.clipboard.writeText(sync.syncId); - idCopied = true; - setTimeout(() => (idCopied = false), 1500); - } - - async function connectSync() { - const id = connectId.trim(); - if (!id) return; - const ok = confirm( - 'Replace all data on this device with the synced copy? ' + - "If the ID has no data yet, this device's data is uploaded instead.", - ); - if (!ok) return; - syncBusy = true; - syncNotice = null; - try { - // The 'applied' path never returns here — it reloads the panel. - const result = await sync.connectWithId(id); - if (result === 'uploaded') { - connectId = ''; - syncNotice = { - ok: true, - text: "No synced data found for that ID — this device's data was uploaded.", - }; - } - } catch (err) { - syncNotice = { ok: false, text: `Connect failed: ${message(err)}` }; - } finally { - syncBusy = false; - } - } - function lastSynced(ts: number): string { if (!ts) return 'never'; const minutes = Math.round((Date.now() - ts) / 60000); @@ -373,7 +301,7 @@ void settings.update({ theme })} />
@@ -559,84 +487,14 @@
{@render prefText( 'Sync between devices', - 'Keep your settings, songs, presets, markers and snippets the same everywhere. No account needed — devices are linked by a private ID.', + "Sync saved practice settings, favorites, presets, markers and snippets. The most recently edited library replaces the older copy, so changes made on two devices at once can overwrite each other. Recent, layout and chord analysis stay on this device. Uses your browser's built-in sync: sign in to the browser with sync turned on and it reaches your other devices. No account with us, no server.", )} - sync.enabled, (on) => void setSyncEnabled(on)} label="Sync between devices" />
- {#if sync.needsConsent} -
- {@render prefText( - 'A sync ID from your other device was found', - 'This device already has data of its own, so nothing has been changed yet. Use the synced copy and replace what is here, or keep this device and upload it instead.', - )} -
- - -
-
- {/if} - {#if sync.enabled && sync.syncId} -
- {@render prefText( - 'Your sync ID', - 'Devices signed into the same browser profile pick this ID up automatically; elsewhere, enter it by hand. Keep it private — anyone who has it can read and change your data.', - )} -
- {sync.syncId} - -
-
-
- - {@render prefText( - 'Keep the ID after a reinstall', - sync.durable - ? 'On. A copy is kept in this browser profile, outside the extension — but not past clearing the browser’s cookies, so keep a copy for that.' - : 'Uninstalling wipes the ID from the extension. Allow access to the sync server’s domain to keep a copy in this browser profile instead — nothing else is accessed.', - )} - {#if !sync.durable} - - {/if} -
+ {#if sync.enabled}
@@ -665,58 +525,18 @@
{@render prefText( 'Delete synced data', - 'Removes the copy stored on the sync server and turns sync off. Data on this device is kept.', + "Empties the copy in the browser's sync storage and turns sync off here. Data on this device is kept.", )}
- {:else} - {#if sync.syncId} -
- {sync.lastSyncedAt - ? 'Sync is off. Turn it back on to keep using your existing sync ID.' - : 'A sync ID from your other device was found — turn on sync to use it.'} -
- {:else if sync.enabled} -
- Sync is on. Your private ID is created as soon as there is something to - sync — a song in Recent, a marker, an EQ preset. On a new device, the ID - from your other devices arrives through browser sync within a minute or so; - if it doesn't, paste it below. -
- {/if} -
- {@render prefText( - 'Connect with a sync ID', - 'Paste the ID from your other device. Its synced data replaces what is on this device.', - )} -
- - -
-
{/if} {#if syncNotice}
- {@render prefText('Backup file', 'Export or import all your data. Rarely needed with sync on.')} + {@render prefText('Backup file', 'Export or import everything, including local history and chord analysis.')}
diff --git a/src/features/settings/panel/panel.ts b/src/features/settings/panel/panel.ts deleted file mode 100644 index b6c008e..0000000 --- a/src/features/settings/panel/panel.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PanelFeature } from '../../../core/features'; -import { settings, uiPrefs } from './settings.svelte'; - -/** Settings + cosmetic UI prefs load from storage at panel boot. */ -export const settingsFeature: PanelFeature = { - async init() { - await Promise.all([settings.init(), uiPrefs.init()]); - }, -}; diff --git a/src/features/settings/panel/settings.svelte.ts b/src/features/settings/panel/settings.svelte.ts index 4d63ad4..9ad40eb 100644 --- a/src/features/settings/panel/settings.svelte.ts +++ b/src/features/settings/panel/settings.svelte.ts @@ -1,131 +1,63 @@ -import { DEFAULT_KEYMAP, DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../../../core/model/defaults'; import type { PanelId, SectionId, Settings, UiPrefs } from '../../../core/model/types'; -import { settingsItem, uiPrefsItem } from '../../../core/persist/storage'; +import { mergeUiPrefs, type UiPrefsPatch } from '../../../core/persist/library'; +import { editLibrary, libraryItem } from '../../../core/persist/library-client'; +import { library } from '../../../core/state/library.svelte'; -/** Settings synced two-way with storage.local. Components mutate via `update`. */ +/** Views of the single library copy. All changes go through the background. */ class SettingsStore { - current = $state(structuredClone(DEFAULT_SETTINGS)); - loaded = $state(false); - #writing = false; - - /** Wired by the connection layer, which pushes the engine-relevant settings - * to the tab. Fires on every path that lands a new value in - * `current` — the engine can't observe this store itself. */ - onChange: ((next: Settings) => void) | null = null; - - /** Stored settings may predate newly added fields — backfill from defaults so - * a missing key never reaches the engine as `undefined` (which the port drops, - * e.g. a NaN count-in duration that never elapses). */ - #withDefaults(value: Settings | null): Settings { - return { - ...structuredClone(DEFAULT_SETTINGS), - ...value, - // Merged one level deeper: a keymap stored before an action existed would - // otherwise leave that action `undefined`, which the dispatcher can never - // match (the hotkey silently does nothing) and the Help sheet — which - // reads the keymap unconditionally — renders as a blank row. - keymap: { ...DEFAULT_KEYMAP, ...value?.keymap }, - }; - } - - async init() { - this.current = this.#withDefaults(await settingsItem.getValue()); - this.loaded = true; - settingsItem.watch((value) => { - if (this.#writing) return; - this.current = this.#withDefaults(value); - this.onChange?.(this.current); - }); - } + current = $derived({ + ...library.current.shared.settings, lastUsedParams: library.current.local.lastUsedParams, + }); - async update(patch: Partial) { - const next = { ...this.current, ...patch }; - // Auto Reset and Remember settings are alternatives — enabling one - // switches the other off. - if (patch.rememberSettings) next.autoReset = false; - if (patch.autoReset) next.rememberSettings = false; - this.current = next; - this.onChange?.(next); - this.#writing = true; - try { - // $state.snapshot, like UiPrefsStore below: `next` is spread off the - // `current` rune, so nested `keymap`/`lastUsedParams` are still proxies. - // Firefox structured-clones storage writes and throws DataCloneError on a - // proxy — settings would apply for the session but never persist. - await settingsItem.setValue($state.snapshot(next) as Settings); - } finally { - this.#writing = false; - } + update(patch: Partial) { + return editLibrary({ type: 'settings', patch: $state.snapshot(patch) }); } - async reset() { - this.current = structuredClone(DEFAULT_SETTINGS); - this.onChange?.(this.current); - await settingsItem.setValue($state.snapshot(this.current) as Settings); + reset() { + return editLibrary({ type: 'settings', patch: {}, reset: true }); } } class UiPrefsStore { - current = $state(structuredClone(DEFAULT_UI_PREFS)); - #writing = false; - - /** Stored values may predate newly added prefs — backfill from defaults. */ - #withDefaults(value: UiPrefs | null): UiPrefs { - return { ...structuredClone(DEFAULT_UI_PREFS), ...value }; - } - - async init() { - this.current = this.#withDefaults(await uiPrefsItem.getValue()); - uiPrefsItem.watch((value) => { - if (this.#writing) return; - this.current = this.#withDefaults(value); + /** Preferences are device-local, so a toggle can show immediately instead of + * waiting for the service worker to wake, write and echo back. Held only until + * the saved copy matches, so another tab's panel still wins afterwards. */ + #optimistic = $state.raw(null); + current = $derived(this.#optimistic ?? library.current.local.uiPrefs); + + init() { + libraryItem.watch((value) => { + if (this.#optimistic && JSON.stringify(value?.local.uiPrefs) === JSON.stringify(this.#optimistic)) { + this.#optimistic = null; + } }); } - async #save() { - this.#writing = true; - try { - await uiPrefsItem.setValue($state.snapshot(this.current)); - } finally { - this.#writing = false; - } + update(patch: UiPrefsPatch) { + this.#optimistic = mergeUiPrefs(this.current, patch); + // A rejected write must not keep showing a preference that was never saved. + // Nothing awaits these, so revert and report rather than throwing. + return editLibrary({ type: 'uiPrefs', patch }).catch((error: unknown) => { + this.#optimistic = null; + console.error('[note-by-note] saving preferences failed', error); + }); } toggleCollapsed(panel: PanelId) { - this.current.collapsed[panel] = !this.current.collapsed[panel]; - void this.#save(); + return this.update({ collapsed: { [panel]: !this.current.collapsed[panel] } }); } toggleSectionCollapsed(section: SectionId) { - this.current.collapsedSections[section] = !this.current.collapsedSections[section]; - void this.#save(); + return this.update({ collapsedSections: { [section]: !this.current.collapsedSections[section] } }); } - setMarkerView(view: UiPrefs['markerView']) { - this.current.markerView = view; - void this.#save(); - } - - setTimelineFollow(on: boolean) { - this.current.timelineFollow = on; - void this.#save(); - } - - setFavoritesSort(sort: UiPrefs['favoritesSort']) { - this.current.favoritesSort = sort; - void this.#save(); - } - - setLibraryTab(tab: UiPrefs['libraryTab']) { - this.current.libraryTab = tab; - void this.#save(); - } + setMarkerView(markerView: UiPrefs['markerView']) { return this.update({ markerView }); } + setTimelineFollow(timelineFollow: boolean) { return this.update({ timelineFollow }); } + setFavoritesSort(favoritesSort: UiPrefs['favoritesSort']) { return this.update({ favoritesSort }); } + setLibraryTab(libraryTab: UiPrefs['libraryTab']) { return this.update({ libraryTab }); } - /** Override a virtual boundary marker's label; empty text restores the - * default ("Start"/"End"). */ setBoundaryLabel(which: 'start' | 'end', label: string) { - this.current.boundaryLabels[which] = label.trim(); - void this.#save(); + void this.update({ boundaryLabels: { [which]: label.trim() } }); } } diff --git a/src/features/snippets/persist.svelte.ts b/src/features/snippets/persist.svelte.ts deleted file mode 100644 index 26ae2c0..0000000 --- a/src/features/snippets/persist.svelte.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { TrackDataDescriptor } from '../../core/persist/track-data'; -import { snippets } from './panel/snippets.svelte'; - -/** Snippets' slice of the per-track record (the snippet list plus the two - * sequence flags). */ -export const snippetsTrackData: TrackDataDescriptor = { - bind(persist) { - snippets.onPersist = persist; - }, - collect(data) { - data.snippets = $state.snapshot(snippets.list); - data.sequenceLoop = snippets.sequenceLoop; - data.sequenceCountIn = snippets.sequenceCountIn; - }, - load(data) { - snippets.load( - data?.snippets ?? [], - data?.sequenceLoop ?? false, - data?.sequenceCountIn ?? false, - ); - }, -}; diff --git a/src/features/sync/endpoint.ts b/src/features/sync/endpoint.ts deleted file mode 100644 index 0646150..0000000 --- a/src/features/sync/endpoint.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SYNC_ENDPOINT_DEV, SYNC_ENDPOINT_PROD, syncHostPattern } from './sync-hosts'; - -/** The Worker this build talks to: localhost in dev, the deployed one in prod. - * CORS is open there, so the sync calls themselves need no host permission; - * only the ID cookie does (see panel/id-cookie.ts). */ -export const SYNC_ENDPOINT = import.meta.env.DEV ? SYNC_ENDPOINT_DEV : SYNC_ENDPOINT_PROD; - -/** Optional host permission that lets the `cookies` API touch the sync host. */ -export const SYNC_ORIGIN_PATTERN = syncHostPattern(SYNC_ENDPOINT); diff --git a/src/features/sync/panel/api.ts b/src/features/sync/panel/api.ts deleted file mode 100644 index cb44cfe..0000000 --- a/src/features/sync/panel/api.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { parseBackup, type Backup } from '../../../core/persist/backup'; -import { SYNC_ENDPOINT } from '../endpoint'; - -export class SyncHttpError extends Error { - constructor( - public readonly status: number, - message: string, - ) { - super(message); - this.name = 'SyncHttpError'; - } -} - -/** The ID is the credential, so it travels in a header rather than the path — - * URLs end up verbatim in the Worker's request logs. */ -const BACKUP_URL = `${SYNC_ENDPOINT}/v1/backup`; - -function errorFor(status: number): SyncHttpError { - if (status === 429) { - return new SyncHttpError(status, 'Too many sync requests — try again in a minute.'); - } - if (status === 502) return new SyncHttpError(status, 'Sync storage is temporarily unavailable.'); - return new SyncHttpError(status, `Sync server error (${status})`); -} - -/** The stored snapshot, or null if the ID has no data yet (404). */ -export async function pullSnapshot(id: string): Promise { - const res = await fetch(BACKUP_URL, { headers: { 'X-Sync-Id': id } }); - if (res.status === 404) return null; - if (!res.ok) throw errorFor(res.status); - return parseBackup(await res.text()); -} - -export async function pushSnapshot(id: string, backup: Backup): Promise { - const res = await fetch(BACKUP_URL, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'X-Sync-Id': id }, - body: JSON.stringify(backup), - }); - if (!res.ok) throw errorFor(res.status); -} - -/** Removes the snapshot from the server. A 404 counts as success — the goal is - * "no data under this ID", and it is already true. */ -export async function deleteSnapshot(id: string): Promise { - const res = await fetch(BACKUP_URL, { method: 'DELETE', headers: { 'X-Sync-Id': id } }); - if (!res.ok && res.status !== 404) throw errorFor(res.status); -} diff --git a/src/features/sync/panel/hash.ts b/src/features/sync/panel/hash.ts deleted file mode 100644 index 8eef05f..0000000 --- a/src/features/sync/panel/hash.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Backup } from '../../../core/persist/backup'; - -/** JSON with object keys sorted at every level, so the same data always - * yields the same string regardless of construction order. */ -export function stableStringify(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(stableStringify).join(',')}]`; - } - if (typeof value === 'object' && value !== null) { - const entries = Object.entries(value as Record) - .filter(([, v]) => v !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`); - return `{${entries.join(',')}}`; - } - return JSON.stringify(value) ?? 'null'; -} - -/** - * Content hash of a snapshot's data fields — excludes `exportedAt` and - * `appVersion` so re-exporting unchanged data hashes the same. Tracks are - * sorted by identity key because their storage-enumeration order is arbitrary. - */ -export async function snapshotHash(backup: Backup): Promise { - const canonical = stableStringify({ - settings: backup.settings, - uiPrefs: backup.uiPrefs, - history: backup.history, - favorites: backup.favorites, - eqPresets: backup.eqPresets, - tracks: [...backup.tracks].sort((a, b) => - a.identity.key < b.identity.key ? -1 : a.identity.key > b.identity.key ? 1 : 0, - ), - }); - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)); - return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); -} diff --git a/src/features/sync/panel/id-cookie.ts b/src/features/sync/panel/id-cookie.ts deleted file mode 100644 index a4094cd..0000000 --- a/src/features/sync/panel/id-cookie.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { SYNC_ID_RE } from '../persist/sync-config'; -import { SYNC_ENDPOINT, SYNC_ORIGIN_PATTERN } from '../endpoint'; - -/** - * Durable copy of the sync ID — the canonical explanation; README, PRIVACY - * and the Settings copy defer to this file. - * - * `storage.sync` carries the ID to the user's other devices, but the browser - * purges it — on every device on the account — the moment the extension is - * uninstalled, and a reinstall then mints a fresh ID with no way back to the - * old snapshot. Cookies belong to the profile, not the extension, so one set on - * the sync server's domain is the one store that outlives an uninstall. It is - * read and written only through the `cookies` API and never rides along with a - * request: the sync calls carry the ID in a header, and a cookie is meaningless - * to the Worker anyway. - * - * The `cookies` API needs host access to the sync origin. That is an - * *optional* host permission (a required one would disable the extension on - * update until re-approved on Chrome, and is opt-in on Firefox regardless), so - * everything here is best-effort until `requestIdCookieAccess` succeeded in a - * user gesture — `` from Connect covers it too. `sync.durable` - * mirrors that state so the panel never promises a copy it can't keep. - * - * Lifecycle: written whenever sync is on with an ID (the store's `#reflect` - * is the single call site), removed by "Delete synced data" so a later - * reinstall does not resurrect an identity the user abandoned. Clearing browser - * cookies loses it — the panel keeps showing the ID for copying. - */ -const NAME = 'syncId'; -/** Chromium caps cookie lifetime at 400 days; refreshed once per panel start. */ -const MAX_AGE_S = 400 * 24 * 60 * 60; - -export async function hasIdCookieAccess(): Promise { - try { - return await browser.permissions.contains({ origins: [SYNC_ORIGIN_PATTERN] }); - } catch { - return false; - } -} - -/** Must run in a user gesture. Resolves true when access is held afterwards. */ -export async function requestIdCookieAccess(): Promise { - try { - return await browser.permissions.request({ origins: [SYNC_ORIGIN_PATTERN] }); - } catch { - return false; - } -} - -export async function readIdCookie(): Promise { - try { - const cookie = await browser.cookies.get({ url: SYNC_ENDPOINT, name: NAME }); - return cookie && SYNC_ID_RE.test(cookie.value) ? cookie.value : null; - } catch { - return null; - } -} - -/** Best-effort: a missing permission must not break sync itself. No `secure` - * attribute — the cookie is never sent, and localhost in dev is plain http. */ -export async function writeIdCookie(id: string): Promise { - try { - await browser.cookies.set({ - url: SYNC_ENDPOINT, - name: NAME, - value: id, - httpOnly: true, - sameSite: 'strict', - expirationDate: Math.floor(Date.now() / 1000) + MAX_AGE_S, - }); - } catch { - // Unsupported or not permitted here — the sync area still has the ID. - } -} - -export async function removeIdCookie(): Promise { - try { - await browser.cookies.remove({ url: SYNC_ENDPOINT, name: NAME }); - } catch { - // Nothing to remove, or no access — either way there is no copy to keep. - } -} diff --git a/src/features/sync/panel/sync.svelte.ts b/src/features/sync/panel/sync.svelte.ts index 52a41f0..86f5264 100644 --- a/src/features/sync/panel/sync.svelte.ts +++ b/src/features/sync/panel/sync.svelte.ts @@ -1,536 +1,24 @@ -import { createBackup, restoreBackup, type Backup } from '../../../core/persist/backup'; -import { - DEFAULT_SYNC_CONFIG, - generateSyncId, - loadSyncState, - SYNC_ID_RE, - syncConfigItem, - syncIdItem, - type SyncConfig, -} from '../persist/sync-config'; -import { session } from '../../../core/state/session.svelte'; -import { deleteSnapshot, pullSnapshot, pushSnapshot, SyncHttpError } from './api'; -import { - hasIdCookieAccess, - readIdCookie, - removeIdCookie, - requestIdCookieAccess, - writeIdCookie, -} from './id-cookie'; -import { snapshotHash } from './hash'; +import { sendMessage } from '../../../core/messaging/rpc'; +import { QUOTA_BYTES } from '../persist/records'; +import { DEFAULT_SYNC_CONFIG, loadSyncConfig, syncConfigItem, withSyncDefaults } from '../persist/sync-config'; -/** Trailing debounce after the last data change before pushing. */ -const PUSH_DEBOUNCE_MS = 5000; -/** How often to check the server for another device's changes. */ -const PULL_INTERVAL_MS = 5 * 60 * 1000; - -/** Raw storage keys (no `local:` prefix in change events) that belong to the - * backup snapshot. `syncConfig` itself is deliberately absent. */ -const SYNCED_KEY_RE = /^(settings|uiPrefs|history|favorites|eqPresets|track:)/; - -function errorMessage(err: unknown): string { - if (err instanceof SyncHttpError) return err.message; - if (err instanceof TypeError) return 'Could not reach the sync server.'; - return err instanceof Error ? err.message : 'Sync failed.'; -} - -/** - * Device sync: mirrors the backup snapshot (see `persist/backup.ts`) to the - * sync server under a secret ID, last-write-wins. Local changes are detected - * via storage change events and pushed after a debounce; remote changes are - * pulled at startup and on an interval, and applied via `restoreBackup` plus - * a panel reload (stores read storage once at startup — same rationale as the - * import flow in SettingsView). - * - * Runs only in the sidepanel: it is the sole writer of synced data, so there - * is nothing to observe while it's closed. A change the panel didn't manage - * to push before closing is remembered via `pendingPush`. - */ +/** Status projection only. Sync runs in the background, independently of panel lifetime. */ class SyncStore { - enabled = $state(false); - syncId = $state(null); - lastSyncedAt = $state(0); - lastError = $state(null); - /** An ID arrived from another device while this one already held data of its - * own. Applying would overwrite it, so the panel asks first — see - * `acceptRemote` / `keepLocal`. */ - needsConsent = $state(false); - /** Whether the ID's durable copy can be kept — host access to the sync - * origin is held (see id-cookie.ts). Drives the Settings copy. */ - durable = $state(false); - #syncing = $state(false); - - status = $derived<'off' | 'syncing' | 'error' | 'idle'>( - !this.enabled ? 'off' : this.#syncing ? 'syncing' : this.lastError ? 'error' : 'idle', - ); - - #config: SyncConfig = { ...DEFAULT_SYNC_CONFIG }; - /** Mirrors `syncIdItem` — the ID lives in browser-synced storage (see - * `persist/sync-config.ts`), separate from the per-device bookkeeping. */ - #id: string | null = null; - /** The ID last handed to `writeIdCookie` by this document, so `#reflect` - * writes the cookie once per identity rather than on every state change. */ - #cookieId: string | null = null; - /** Suppresses the echo of our own `syncConfigItem` write. The ID watcher - * needs no such flag: its own echo carries `value === #id` and is skipped by - * value, so a real event from another device is never dropped. */ - #writing = false; - /** Suppresses change events while `restoreBackup` writes a remote snapshot, - * so applying can't schedule a push of what was just pulled. */ - #applying = false; - #pushTimer: ReturnType | undefined; - /** Serializes pushes and reconciles so they can't interleave. */ - #queue: Promise = Promise.resolve(); - + config = $state({ ...DEFAULT_SYNC_CONFIG }); + enabled = $derived(this.config.enabled); + lastSyncedAt = $derived(this.config.lastSyncedAt); + lastError = $derived(this.config.lastError); + status = $derived(!this.enabled ? 'off' : this.config.syncing ? 'syncing' : this.lastError ? 'error' : 'idle'); + usedPercent = $derived(Math.round(this.config.usedBytes / QUOTA_BYTES * 100)); async init() { - const [{ config, syncId }, kept, durable] = await Promise.all([ - loadSyncState(), - readIdCookie(), - hasIdCookieAccess(), - ]); - this.durable = durable; - this.#config = config; - this.#id = syncId; - this.#reflect(); - // Watchers go up before any write below, so nothing arriving from another - // device in the meantime is missed. - syncConfigItem.watch((value) => { - if (this.#writing) return; - this.#config = value ?? { ...DEFAULT_SYNC_CONFIG }; - this.#reflect(); - }); - // The ID is browser-synced: another device on this browser profile can - // hand this one an ID (or replace it) at any time. - syncIdItem.watch((value) => { - if (value === this.#id) return; - if (value === null && this.#id) { - // The key was deleted, not replaced — the sync area was purged (an - // uninstall on another device, a sync reset). Another device changing - // identity on purpose always arrives as a different string. Put our ID - // back rather than drift into minting a new one over the same data. - // Consent is not a factor: it gates applying remote data (see - // `#startReconcile`), not holding an identity. - void this.#writeId(this.#id); - return; - } - this.#id = value; - this.#reflect(); - if (!this.#config.enabled) return; - // Identity changed while enabled: the bookkeeping refers to the old - // blob — reset it and reconcile against the new one. - void this.#saveConfig({ lastSyncedAt: 0, lastSyncedHash: null, pendingPush: false }).then( - () => this.#startReconcile({ allowApply: session.media === null }), - ); - }); - // Connect's `` grant (and Revoke Permissions) changes cookie - // access without going through this store. - browser.permissions.onAdded.addListener(() => void this.#refreshDurable()); - browser.permissions.onRemoved.addListener(() => void this.#refreshDurable()); - - // Best-effort like every other sync-area write: a failure here must not - // keep the listeners below from being installed. - await this.#recoverId(kept).catch(() => {}); - - browser.storage.local.onChanged.addListener((changes) => { - if (this.#applying) return; - if (!this.#config.enabled) return; - if (!Object.keys(changes).some((key) => SYNCED_KEY_RE.test(key))) return; - this.#onDataChanged(); - }); - - if (this.#config.enabled && this.#id) { - await this.#startReconcile({ allowApply: true }); - } - - // A track being loaded defers remote applies (the reload would interrupt - // practice) to the next panel open or a manual "Sync now". - setInterval(() => { - if (!this.#config.enabled || !this.#id) return; - void this.#startReconcile({ allowApply: session.media === null }); - }, PULL_INTERVAL_MS); - } - - /** - * Reconcile, but never silently overwrite data this device already has. - * - * Sync ships on, and the ID rides browser-profile sync, so a device can be - * handed an identity it never asked for. When that device is empty (a fresh - * install — the case this default exists for) adopting is what the user - * wants and there is nothing to lose, so consent is implied. When it already - * holds a library, applying the remote would delete it: raise `needsConsent` - * and let the panel ask instead. - */ - async #startReconcile(opts: { allowApply: boolean }) { - const id = this.#id; - if (!this.#config.enabled || !id) return; - if (this.#config.consentedId !== id) { - if (!(await this.#isPristine())) { - this.needsConsent = true; - return; - } - await this.#saveConfig({ consentedId: id }); - } - await this.#enqueue(() => this.#reconcile(opts)); - } - - /** Nothing here a remote snapshot could destroy. Settings and UI prefs are - * excluded deliberately — they are a keystroke to redo, and weighing them - * would make the common "installed, opened it once" path prompt for nothing. */ - async #isPristine(): Promise { - const local = await createBackup(); - return ( - local.history.length === 0 && - local.favorites.length === 0 && - local.tracks.length === 0 && - local.eqPresets.length === 0 - ); - } - - /** User chose the synced copy: apply it over this device's data. */ - async acceptRemote(): Promise { - const id = this.#id; - if (!id) return; - this.needsConsent = false; - await this.#saveConfig({ consentedId: id }); - await this.#enqueue(() => this.#reconcile({ allowApply: true })); - } - - /** User chose this device's data: keep it and let it win the next push. */ - async keepLocal(): Promise { - const id = this.#id; - if (!id) return; - this.needsConsent = false; - await this.#saveConfig({ consentedId: id, lastChangedAt: Date.now(), pendingPush: true }); - await this.#enqueue(() => this.#push({ force: true })); - } - - /** Turns sync on. With no ID anywhere — none kept from an earlier enable, - * none received from another device via browser sync — generates one and - * uploads this device's data; otherwise reuses the ID and reconciles. */ - async enable(): Promise { - // In a gesture, so the durable copy can be authorised in the same breath. - await this.#ensureDurable(); - const fresh = this.#id === null; - const id = this.#id ?? generateSyncId(); - await this.#saveId(id); - // Turning it on by hand is the consent. - await this.#saveConfig({ enabled: true, consentedId: id, lastError: null }); - this.needsConsent = false; - await this.#enqueue(() => - fresh ? this.#push({ force: true }) : this.#reconcile({ allowApply: true }), - ); - } - - /** Keeps the ID so re-enabling picks the same remote blob back up. */ - async disable(): Promise { - clearTimeout(this.#pushTimer); - this.needsConsent = false; - await this.#saveConfig({ enabled: false }); - } - - /** - * Removes this ID's snapshot from the server. Sync is switched off too — - * leaving it on would re-upload from the next change and quietly undo the - * deletion. The ID is kept, so turning sync back on starts a fresh blob; its - * durable copy is not, so a reinstall does not quietly bring it back. - */ - async deleteRemote(): Promise { - const id = this.#id; - if (!id) return; - clearTimeout(this.#pushTimer); - this.needsConsent = false; - await this.#enqueue(async () => { - this.#syncing = true; - try { - await deleteSnapshot(id); - await this.#saveConfig({ - enabled: false, - lastSyncedAt: 0, - lastSyncedHash: null, - pendingPush: false, - lastError: null, - }); - this.#cookieId = null; - await removeIdCookie(); - } catch (err) { - await this.#saveConfig({ lastError: errorMessage(err) }); - throw err; - } finally { - this.#syncing = false; - } - }); - } - - /** - * Links this device to an existing sync ID. Pull-first: existing remote data - * replaces this device's (the UI confirms beforehand; ends in a reload) and - * never the other way around — a fresh install must not clobber the remote. - * Returns 'uploaded' when the ID had no data yet and local data seeded it. - */ - async connectWithId(rawId: string): Promise<'applied' | 'uploaded'> { - const id = rawId.trim(); - if (!SYNC_ID_RE.test(id)) throw new Error("That doesn't look like a sync ID."); - await this.#ensureDurable(); - await this.#saveId(id); - // Typing in someone else's ID, past the UI's confirm, is the consent. - await this.#saveConfig({ - enabled: true, - consentedId: id, - lastSyncedAt: 0, - lastSyncedHash: null, - pendingPush: false, - lastError: null, - }); - this.needsConsent = false; - return this.#enqueue(async () => { - try { - const remote = await pullSnapshot(id); - if (remote) { - await this.#applyRemote(remote); - return 'applied' as const; - } - await this.#push({ force: true }); - return 'uploaded' as const; - } catch (err) { - await this.#saveConfig({ lastError: errorMessage(err) }); - throw err; - } - }); - } - - async syncNow(): Promise { - await this.#startReconcile({ allowApply: true }); - } - - /** Settings → "Keep after reinstall". Must run in a user gesture. */ - async keepAfterReinstall(): Promise { - return this.#ensureDurable(); - } - - /** Asks for cookie access if it isn't held yet; a refusal is not an error — - * sync works without the durable copy, the panel just says so. */ - async #ensureDurable(): Promise { - if (!this.durable) this.durable = await requestIdCookieAccess(); - if (this.durable) await this.#onDurable(); - return this.durable; - } - - async #refreshDurable() { - const durable = await hasIdCookieAccess(); - if (durable === this.durable) return; - this.durable = durable; - if (durable) await this.#onDurable(); - } - - /** Access just became available. On a reinstall it usually arrives after - * `init` (Connect is the first thing pressed), so the cookie could not be - * read then — look again before an ID is minted over it; otherwise write - * (or refresh) the copy now that it is allowed. */ - async #onDurable() { - if (this.#id === null) await this.#recoverId(await readIdCookie()); - this.#cookieId = null; - this.#reflect(); - } - - /** No ID in the sync area: a fresh install, or a reinstall — the browser - * purged the extension's synced storage on uninstall. The cookie is this - * profile's own earlier ID, so taking it back is what the user expects and - * needs no consent; the next reconcile pulls the data. */ - async #recoverId(kept: string | null) { - if (this.#id === null && kept) await this.#adoptId(kept); - } - - #reflect() { - this.enabled = this.#config.enabled; - this.syncId = this.#id; - this.lastSyncedAt = this.#config.lastSyncedAt; - this.lastError = this.#config.lastError; - if (!this.#config.enabled || this.#config.consentedId === this.#id) this.needsConsent = false; - // The one place the durable copy is written: whenever sync is on with an - // ID — received, minted, connected or restored — once per identity per - // document (which also keeps its expiry rolling). - const keep = this.#config.enabled ? this.#id : null; - if (keep && keep !== this.#cookieId) { - this.#cookieId = keep; - void writeIdCookie(keep); - } - } - - /** An ID this device originated or recovered is its own — consent implied. */ - async #adoptId(id: string) { - await this.#saveId(id); - await this.#saveConfig({ consentedId: id }); - } - - /** Sync-area writes are quota-limited (~120/min), so no-ops are skipped. */ - async #saveId(id: string | null) { - if (id === this.#id) return; - this.#id = id; - this.#reflect(); - await this.#writeId(id); - } - - /** Writes the sync area only — `#id` is already what it should be, and the - * cookie follows from `#reflect`. */ - async #writeId(id: string | null) { - await syncIdItem.setValue(id); - } - - async #saveConfig(patch: Partial) { - this.#config = { ...this.#config, ...patch }; - this.#reflect(); - this.#writing = true; - try { - await syncConfigItem.setValue(this.#config); - } finally { - this.#writing = false; - } - } - - #enqueue(op: () => Promise): Promise { - const result = this.#queue.then(op, op); - this.#queue = result.catch(() => {}); - return result; - } - - #onDataChanged() { - // Persisted immediately (not on the debounce) so a panel closed mid-burst - // still knows there is unpushed data next time it opens. - void this.#saveConfig({ pendingPush: true, lastChangedAt: Date.now() }); - clearTimeout(this.#pushTimer); - this.#pushTimer = setTimeout(() => { - void this.#enqueue(() => this.#push()); - }, PUSH_DEBOUNCE_MS); - } - - /** Uploads the current snapshot. The hash guard makes echoes (and no-op - * writes) free; `force` skips it for seeding an empty remote. */ - async #push(opts: { force?: boolean } = {}) { - if (!this.#config.enabled) return; - // Sync ships on, but an ID is minted only once there is something to push. - // Deferring leaves room for a profile-synced ID from another device to - // arrive first, and avoids seeding a blob for an install nobody uses. - // Minting our own means this device started the data set — consent implied. - if (!this.#id) { - await this.#adoptId(generateSyncId()); - opts = { force: true }; - } - const id = this.#id; - if (!id) return; - this.#syncing = true; - try { - const local = await createBackup(); - const hash = await snapshotHash(local); - if (!opts.force && hash === this.#config.lastSyncedHash) { - if (this.#config.pendingPush || this.#config.lastError) { - await this.#saveConfig({ pendingPush: false, lastError: null }); - } - return; - } - await pushSnapshot(id, local); - await this.#saveConfig({ - lastSyncedAt: local.exportedAt, - lastSyncedHash: hash, - pendingPush: false, - lastError: null, - }); - } catch (err) { - // pendingPush stays set — retried on the next change, interval tick, - // startup, or manual sync. No retry timer. - await this.#saveConfig({ lastError: errorMessage(err) }); - } finally { - this.#syncing = false; - } - } - - /** Pull-and-decide: push, apply remote, or nothing — last write wins. */ - async #reconcile(opts: { allowApply: boolean }) { - const id = this.#id; - if (!this.#config.enabled || !id) return; - this.#syncing = true; - try { - const remote = await pullSnapshot(id); - const local = await createBackup(); - const localHash = await snapshotHash(local); - const localChanged = localHash !== this.#config.lastSyncedHash; - - if (remote === null) { - // First device on this ID, or the blob expired server-side: seed it. - await this.#uploadLocal(local, localHash); - return; - } - if (remote.exportedAt === this.#config.lastSyncedAt) { - // Remote is still what we last synced; push if we have news. - if (localChanged || this.#config.pendingPush) await this.#uploadLocal(local, localHash); - else if (this.#config.lastError) await this.#saveConfig({ lastError: null }); - return; - } - // Another device pushed since our last sync. - if ((await snapshotHash(remote)) === localHash) { - // Same content, different timestamp — adopt its bookkeeping, skip the reload. - await this.#saveConfig({ - lastSyncedAt: remote.exportedAt, - lastSyncedHash: localHash, - pendingPush: false, - lastError: null, - }); - return; - } - if (!localChanged || remote.exportedAt > this.#config.lastChangedAt) { - if (opts.allowApply) await this.#applyRemote(remote); - // else: deferred — don't push over a newer remote either. - } else { - await this.#uploadLocal(local, localHash); - } - } catch (err) { - await this.#saveConfig({ lastError: errorMessage(err) }); - } finally { - this.#syncing = false; - } - } - - async #uploadLocal(local: Backup, hash: string) { - const id = this.#id; - if (!id) return; - await pushSnapshot(id, local); - await this.#saveConfig({ - lastSyncedAt: local.exportedAt, - lastSyncedHash: hash, - pendingPush: false, - lastError: null, - }); - } - - /** Overwrites local data with the remote snapshot and reloads the panel - * (mirrors the import flow — stores read storage once at startup). The hash - * is taken from a re-read because `parseBackup` backfills defaults, so what - * landed in storage can differ from the remote bytes. */ - async #applyRemoteImpl(remote: Backup) { - await restoreBackup(remote); - const applied = await createBackup(); - await this.#saveConfig({ - lastSyncedAt: remote.exportedAt, - lastSyncedHash: await snapshotHash(applied), - pendingPush: false, - lastError: null, - }); - // After the reload, reconcile sees remote.exportedAt === lastSyncedAt and - // an unchanged hash — no loop. - location.reload(); - } - - async #applyRemote(remote: Backup) { - // #applying stays set: the page is about to reload, and nothing that - // happens between restore and reload should schedule a push. - this.#applying = true; - clearTimeout(this.#pushTimer); - try { - await this.#applyRemoteImpl(remote); - } catch (err) { - this.#applying = false; - throw err; - } - } + let changed = false; + syncConfigItem.watch((value) => { changed = true; this.config = withSyncDefaults(value); }); + const initial = await loadSyncConfig(); + if (!changed) this.config = initial; + } + enable = () => sendMessage('librarySync', 'enable'); + disable = () => sendMessage('librarySync', 'disable'); + syncNow = () => sendMessage('librarySync', 'now'); + deleteRemote = () => sendMessage('librarySync', 'delete'); } - export const sync = new SyncStore(); diff --git a/src/features/sync/persist/records.test.ts b/src/features/sync/persist/records.test.ts new file mode 100644 index 0000000..b372675 --- /dev/null +++ b/src/features/sync/persist/records.test.ts @@ -0,0 +1,140 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { applyCommand, emptyLibrary } from '../../../core/persist/library.ts'; +import { makeTrackIdentity } from '../../../core/model/track-identity.ts'; +import { encodeSnapshot, fitSnapshot, readSnapshot, bytesUsed, IncompleteSnapshot, PREFIX, SNAPSHOT_KEY } from './records.ts'; + +const song = makeTrackIdentity('https://youtube.com/watch?v=song', 'Song', 200); +const save = (label = '', now = 100) => applyCommand(emptyLibrary(), { + type: 'practice', identity: song, patch: { markers: [{ id: 'm', t: 1, label }] }, recent: true, +}, now).shared; + +test('sync round-trips exactly the shared snapshot, including large songs across chunks', async () => { + const shared = save(randomBytes(18000).toString('base64')); + const { items, usedBytes } = await encodeSnapshot(shared); + assert.deepEqual(await readSnapshot(items), shared); + assert.ok((items[SNAPSHOT_KEY] as { chunks: number }).chunks > 1); + assert.equal(usedBytes, bytesUsed(items)); + for (const [key, value] of Object.entries(items)) assert.ok(bytesUsed({ [key]: value }) <= 8192); + assert.deepEqual((await encodeSnapshot(shared, items)).items, items); +}); + +test('missing, reordered and mixed chunks never produce a partial library', async () => { + const { items } = await encodeSnapshot(save(randomBytes(18000).toString('base64'))); + const missing = { ...items }; + delete missing[PREFIX + 'chunk:1']; + await assert.rejects(readSnapshot(missing), IncompleteSnapshot); + const noHeader = { ...items }; + delete noHeader[SNAPSHOT_KEY]; + assert.deepEqual(await readSnapshot(noHeader), await readSnapshot(items)); + const { items: other } = await encodeSnapshot(save('other device at the same timestamp')); + await assert.rejects(readSnapshot({ ...items, [PREFIX + 'chunk:0']: other[PREFIX + 'chunk:0'] }), IncompleteSnapshot); + const reversed = Object.fromEntries(Object.entries(items).reverse()); + assert.deepEqual(await readSnapshot(reversed), await readSnapshot(items)); +}); + +test('a smaller replacement clears all old chunks in the same write', async () => { + const { items: large } = await encodeSnapshot(save(randomBytes(18000).toString('base64'))); + const small = applyCommand({ shared: save(), local: emptyLibrary().local }, { type: 'import', library: emptyLibrary() }, 200).shared; + const { items } = await encodeSnapshot(small, large); + assert.equal(items[PREFIX + 'chunk:1'], ''); + assert.deepEqual(await readSnapshot({ ...large, ...items }), small); +}); + +test('capacity failures leave both the library and existing sync storage intact', async () => { + const shared = save(randomBytes(100000).toString('base64')); + const before = structuredClone(shared); + const { items: existing } = await encodeSnapshot(save('last complete upload')); + const existingBefore = structuredClone(existing); + await assert.rejects(encodeSnapshot(shared, existing), /storage is full/); + assert.deepEqual(shared, before); + assert.deepEqual(existing, existingBefore); + await assert.rejects(encodeSnapshot(save(), { unrelated: 'x'.repeat(102400) }), /storage is full/); +}); + +test('empty sync storage is distinct from a valid empty library or an incomplete transfer', async () => { + assert.equal(await readSnapshot({ unrelated: 'kept' }), null); + const { items } = await encodeSnapshot(emptyLibrary().shared); + assert.deepEqual(await readSnapshot(items), emptyLibrary().shared); + const broken = { ...items, [PREFIX + 'chunk:0']: '' }; + await assert.rejects(readSnapshot(broken), (error: unknown) => error instanceof IncompleteSnapshot && error.updatedAt === 0); +}); + +test('a lost header is recovered from complete chunks with the original revision', async () => { + const shared = save(); + const { items } = await encodeSnapshot(shared); + delete items[SNAPSHOT_KEY]; + assert.deepEqual(await readSnapshot(items), shared); + items[PREFIX + 'chunk:0'] = 'interrupted'; + await assert.rejects(readSnapshot(items), (error: unknown) => error instanceof IncompleteSnapshot && error.updatedAt === null); +}); + +test('unsupported and damaged snapshots fail before adoption or upload', async () => { + const { items } = await encodeSnapshot(save()); + const header = items[SNAPSHOT_KEY] as Record; + await assert.rejects(readSnapshot({ ...items, [SNAPSHOT_KEY]: { ...header, version: 2 } }), /Unsupported/); + await assert.rejects(readSnapshot({ ...items, [SNAPSHOT_KEY]: { ...header, chunks: 10000 } }), /Damaged/); + await assert.rejects(readSnapshot({ ...items, [SNAPSHOT_KEY]: { ...header, updatedAt: 999 } }), /revision/); +}); + +const bulky = (count: number, favorite: (n: number) => boolean = () => false) => { + const keys: string[] = []; + let library = emptyLibrary(); + for (let n = 0; n < count; n++) { + const identity = makeTrackIdentity(`https://youtube.com/watch?v=song${n}`, `Song ${n}`, 200); + keys.push(identity.key); + // Random base64 barely compresses, so a handful of songs overflow the quota. + library = applyCommand(library, { type: 'practice', identity, + patch: { markers: [{ id: 'm', t: 1, label: randomBytes(4000).toString('base64') }] }, recent: true }, 100 + n); + } + for (const [n, key] of keys.entries()) { + if (favorite(n)) library = applyCommand(library, { type: 'favorite', key, value: true }, 1000); + } + return { ...library, keys }; +}; + +test('a snapshot that already fits is uploaded whole', async () => { + const { shared, local } = bulky(3); + const fitted = await fitSnapshot(shared, local.lastAccessed); + assert.deepEqual(fitted.dropped, []); + assert.equal(fitted.shared, shared); + assert.equal(fitted.usedBytes, bytesUsed(fitted.items)); + assert.deepEqual(await readSnapshot(fitted.items), shared); +}); + +test('an oversized snapshot drops the least recently used songs, and only as few as needed', async () => { + const { shared, local, keys } = bulky(40, (n) => n < 2); + await assert.rejects(encodeSnapshot(shared), /storage is full/); + const fitted = await fitSnapshot(shared, local.lastAccessed); + assert.ok(fitted.dropped.length > 0); + assert.deepEqual(await readSnapshot(fitted.items), fitted.shared); + assert.equal(fitted.usedBytes, bytesUsed(fitted.items)); + // Favorites are kept even though they are the two oldest songs, and nothing + // outside the song list is ever cut. + for (const key of keys.slice(0, 2)) assert.ok(fitted.shared.songs[key], `${key} was dropped`); + assert.deepEqual(fitted.shared.settings, shared.settings); + assert.deepEqual(fitted.shared.presets, shared.presets); + assert.deepEqual(fitted.shared.favoriteOrder, shared.favoriteOrder); + // Oldest first: every dropped song was accessed before every song kept. + const age = (key: string) => local.lastAccessed[key]; + const kept = Object.keys(fitted.shared.songs).filter((key) => !keys.slice(0, 2).includes(key)); + assert.ok(Math.max(...fitted.dropped.map(age)) < Math.min(...kept.map(age))); + // Minimal: putting the most recent casualty back overflows again. + const restored = fitted.dropped[fitted.dropped.length - 1]; + await assert.rejects(encodeSnapshot({ ...fitted.shared, + songs: { ...fitted.shared.songs, [restored]: shared.songs[restored] } }), /storage is full/); +}); + +test('an overflow of favorites alone still reports capacity failure', async () => { + const { shared, local } = bulky(40, () => true); + await assert.rejects(fitSnapshot(shared, local.lastAccessed), /storage is full/); +}); + +test('one song too big to sync is reported, never made to fit by dropping the rest', async () => { + const { shared, local, keys } = bulky(4); + const newest = keys[keys.length - 1]; + const huge = { ...shared, songs: { ...shared.songs, [newest]: { ...shared.songs[newest], + practice: { ...shared.songs[newest].practice, markers: [{ id: 'm', t: 1, label: randomBytes(120000).toString('base64') }] } } } }; + await assert.rejects(fitSnapshot(huge, local.lastAccessed), /storage is full/); +}); diff --git a/src/features/sync/persist/records.ts b/src/features/sync/persist/records.ts new file mode 100644 index 0000000..108b546 --- /dev/null +++ b/src/features/sync/persist/records.ts @@ -0,0 +1,125 @@ +import type { SharedLibrary } from '../../../core/persist/library'; +import { parseShared } from '../../../core/persist/backup-codec.ts'; + +export const PREFIX = 'nbn:'; +export const SNAPSHOT_KEY = PREFIX + 'library'; +export const QUOTA_BYTES = 102400; +const CHUNK_SIZE = 7800; // Leaves room for the key and JSON below the 8192-byte item limit. +const MAX_CHUNKS = Math.ceil(QUOTA_BYTES / CHUNK_SIZE); +const encoder = new TextEncoder(); +export const bytesUsed = (items: Record) => Object.entries(items) + .reduce((total, [key, value]) => total + encoder.encode(key + JSON.stringify(value)).length, 0); + +async function compress(text: string): Promise { + const buffer = await new Response(new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'))).arrayBuffer(); + let binary = ''; + for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte); + return btoa(binary); +} +async function decompress(data: string): Promise { + const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); + return new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))).text(); +} +export async function hash(data: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(data)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export class IncompleteSnapshot extends Error { + readonly updatedAt: number | null; + constructor(updatedAt: number | null) { + super('Waiting for the complete synced library. All data is kept on this device.'); + this.updatedAt = updatedAt; + } +} + +/** No partial library is ever applied, even if browser sync delivers keys separately. */ +export async function readSnapshot(items: Record): Promise { + const header = items[SNAPSHOT_KEY]; + if (!header) { + if (Object.keys(items).some((key) => key.startsWith(PREFIX + 'chunk:'))) { + // A lost header need not lose the snapshot: gzip verifies its own checksum. + // Recover its revision from complete slots before choosing which copy wins. + const chunks = Array.from({ length: MAX_CHUNKS }, (_, n) => items[PREFIX + 'chunk:' + n] ?? ''); + try { + if (chunks.some((chunk) => typeof chunk !== 'string')) throw new Error('Damaged chunk.'); + return parseShared(JSON.parse(await decompress(chunks.join('')))); + } catch { throw new IncompleteSnapshot(null); } + } + return null; + } + if (header.version !== 1) throw new Error('Unsupported synced data. Update Note by Note on all devices.'); + if (!Number.isFinite(header.updatedAt) || header.updatedAt < 0 || !Number.isInteger(header.chunks) + || header.chunks < 1 || header.chunks > MAX_CHUNKS || typeof header.hash !== 'string') { + throw new Error('Damaged synced library.'); + } + const chunks = Array.from({ length: header.chunks }, (_, n) => items[PREFIX + 'chunk:' + n]); + if (chunks.some((chunk) => typeof chunk !== 'string') || await hash(chunks.join('')) !== header.hash) { + throw new IncompleteSnapshot(header.updatedAt); + } + const snapshot = parseShared(JSON.parse(await decompress(chunks.join('')))); + if (snapshot.updatedAt !== header.updatedAt) throw new Error('Damaged synced library revision.'); + return snapshot; +} + +/** Upload the entire snapshot or report capacity failure before changing storage. */ +export async function encodeSnapshot(shared: SharedLibrary, existing: Record = {}): Promise<{ items: Record; usedBytes: number }> { + const data = await compress(JSON.stringify(shared)); + const items: Record = { + [SNAPSHOT_KEY]: { version: 1, updatedAt: shared.updatedAt, chunks: Math.ceil(data.length / CHUNK_SIZE), hash: await hash(data) }, + }; + // Fixed slots let a smaller snapshot clear its old tail in the same set() call. + for (let n = 0; n < MAX_CHUNKS; n++) items[PREFIX + 'chunk:' + n] = data.slice(n * CHUNK_SIZE, (n + 1) * CHUNK_SIZE); + const usedBytes = bytesUsed({ ...existing, ...items }); + if (data.length > CHUNK_SIZE * MAX_CHUNKS || usedBytes > QUOTA_BYTES + || Object.keys({ ...existing, ...items }).length > 512) { + throw new Error('Browser sync storage is full. All data is kept on this device; export a backup to transfer it.'); + } + return { items, usedBytes }; +} + +/** + * The same upload, made to fit by dropping the least recently used songs that + * are not favorites. Settings, presets, favorite order, every favorite and the + * song in hand are kept, so an overflow of those alone still reports capacity + * failure rather than trimming its way to an empty library. + * + * Dropping more songs can only shrink the payload, so the smallest prefix that + * fits is found by bisection — a handful of compressions instead of one per song. + */ +export async function fitSnapshot(shared: SharedLibrary, lastAccessed: Record, + existing: Record = {}) { + const age = (key: string) => lastAccessed[key] ?? shared.songs[key].practice.updatedAt; + // The song being practised right now is never a candidate: one song too big to + // sync must report capacity failure, not empty the library to make itself fit. + const droppable = Object.keys(shared.songs) + .filter((key) => shared.songs[key].favoritedAt == null) + .sort((a, b) => age(a) - age(b)) + .slice(0, -1); + const without = (count: number): SharedLibrary => { + if (count === 0) return shared; + const dropped = new Set(droppable.slice(0, count)); + return { ...shared, songs: Object.fromEntries(Object.entries(shared.songs).filter(([key]) => !dropped.has(key))) }; + }; + const attempt = async (count: number) => { + try { return { ok: true as const, count, ...await encodeSnapshot(without(count), existing) }; } + catch (error) { return { ok: false as const, error }; } + }; + + const whole = await attempt(0); + if (whole.ok) return { items: whole.items, usedBytes: whole.usedBytes, shared, dropped: [] as string[] }; + if (!droppable.length) throw whole.error; + // The bisection assumes both ends are known: everything still over budget has + // nothing left to give up, so report the original capacity failure. + let fitted = await attempt(droppable.length); + if (!fitted.ok) throw fitted.error; + let lo = 1; + let hi = droppable.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + const result = await attempt(mid); + if (result.ok) { fitted = result; hi = mid; } else lo = mid + 1; + } + return { items: fitted.items, usedBytes: fitted.usedBytes, + shared: without(fitted.count), dropped: droppable.slice(0, fitted.count) }; +} diff --git a/src/features/sync/persist/sync-config.ts b/src/features/sync/persist/sync-config.ts index 86b2391..00dc799 100644 --- a/src/features/sync/persist/sync-config.ts +++ b/src/features/sync/persist/sync-config.ts @@ -1,101 +1,19 @@ import { storage } from '#imports'; - -/** Per-device sync bookkeeping. Deliberately not part of `Backup` (like - * grantedOrigins): restoring a backup from another device must not clobber - * this device's bookkeeping. */ export interface SyncConfig { enabled: boolean; - /** `exportedAt` of the last snapshot pushed or applied; 0 = never synced. */ lastSyncedAt: number; - /** Wall clock of the last local data change — the local side of - * last-write-wins against a remote snapshot's `exportedAt`. */ - lastChangedAt: number; - /** Hash of the data fields at last sync; a matching hash means there is - * nothing new to push (also swallows the echo of applying a remote copy). */ - lastSyncedHash: string | null; - /** A change happened but the push hasn't landed yet — survives the panel - * closing mid-debounce so the next open retries. */ - pendingPush: boolean; + lastPushAt: number; lastError: string | null; - /** The sync ID this device agreed to join, if any. Sync is on by default and - * the ID arrives over browser-profile sync, so a device can be handed an - * identity it never asked for — and adopting one means a remote snapshot can - * overwrite everything here. Consent is granted implicitly when there is - * nothing to lose (a fresh install, or this device minted the ID itself) and - * explicitly via the panel otherwise. Stored per *identity*, not as a flag: - * a second ID arriving later is a second decision. Per-device like the rest - * of this record — consent doesn't travel in a backup. */ - consentedId: string | null; + usedBytes: number; + syncing: boolean; + /** Persisted across worker wakes while headerless chunks are still arriving. */ + incompleteSince?: number; + incompleteHash?: string; } - export const DEFAULT_SYNC_CONFIG: SyncConfig = { - enabled: true, - lastSyncedAt: 0, - lastChangedAt: 0, - lastSyncedHash: null, - pendingPush: false, - lastError: null, - consentedId: null, + enabled: true, lastSyncedAt: 0, lastPushAt: 0, lastError: null, usedBytes: 0, syncing: false, }; - -export const syncConfigItem = storage.defineItem('local:syncConfig', { - fallback: DEFAULT_SYNC_CONFIG, -}); - -/** Secret capability token, 43-char base64url (32 random bytes). Lives in the - * browser-synced `sync` area so Chrome/Firefox carry it to the user's other - * devices on the same browser profile — a second install only needs the - * toggle, not a pasted ID. Kept after disable so re-enabling reuses the same - * remote blob. */ -export const syncIdItem = storage.defineItem('sync:syncId', { - fallback: null, -}); - -/** `consentedId` postdates the first release. A device that had already synced - * with its ID consented back when the user switched sync on, so re-asking on - * upgrade would be noise. Returns whether it wrote anything. */ -function backfillConsent(config: SyncConfig, syncId: string | null): boolean { - if (config.consentedId !== undefined) return false; - config.consentedId = config.lastSyncedAt > 0 ? syncId : null; - return true; -} - -/** Reads both items, moving a pre-split `syncId` out of `local:syncConfig` - * into the sync area on the first run after the update. */ -export async function loadSyncState(): Promise<{ config: SyncConfig; syncId: string | null }> { - const [raw, storedId] = await Promise.all([syncConfigItem.getValue(), syncIdItem.getValue()]); - const { syncId: legacyId, ...config } = raw as SyncConfig & { syncId?: string | null }; - if (legacyId === undefined) { - if (backfillConsent(config, storedId)) await syncConfigItem.setValue(config); - return { config, syncId: storedId }; - } - let syncId = storedId; - if (legacyId !== null && storedId === null) { - syncId = legacyId; - await syncIdItem.setValue(legacyId); - } else if (legacyId !== null && storedId !== legacyId) { - // Another device in the profile already established a different identity; - // adopt it and drop bookkeeping that referred to the old blob. - config.lastSyncedAt = 0; - config.lastSyncedHash = null; - config.pendingPush = false; - } - backfillConsent(config, syncId); - await syncConfigItem.setValue(config); - return { config, syncId }; -} - -/** The server accepts `[A-Za-z0-9_-]{43,64}` — same check client-side so a - * mistyped ID fails before a network round-trip. The lower bound matches what - * `generateSyncId` produces: anything shorter is guessable, and since the ID is - * the only credential, a hand-picked short one would be squattable. */ -export const SYNC_ID_RE = /^[A-Za-z0-9_-]{43,64}$/; - -/** 32 random bytes as base64url (43 chars). The ID is the whole secret. */ -export function generateSyncId(): string { - const bytes = crypto.getRandomValues(new Uint8Array(32)); - return btoa(String.fromCharCode(...bytes)) - .replaceAll('+', '-') - .replaceAll('/', '_') - .replace(/=+$/, ''); -} +export const syncConfigItem = storage.defineItem('local:syncConfig', { fallback: DEFAULT_SYNC_CONFIG }); +/** A stored config written before a field existed still lacks it. */ +export const withSyncDefaults = (value: Partial | null): SyncConfig => ({ ...DEFAULT_SYNC_CONFIG, ...value }); +export const loadSyncConfig = async (): Promise => withSyncDefaults(await syncConfigItem.getValue()); diff --git a/src/features/sync/sync-hosts.ts b/src/features/sync/sync-hosts.ts deleted file mode 100644 index f23c3f2..0000000 --- a/src/features/sync/sync-hosts.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * The sync Worker's addresses (see /server) — the single place they are - * written down. Kept free of `import.meta.env` so wxt.config.ts can import it - * at manifest-build time; `endpoint.ts` picks the one the bundle talks to. - * Self-hosters change the production URL here and rebuild. - */ -export const SYNC_ENDPOINT_PROD = 'https://note-by-note-sync.oapp.workers.dev'; -/** `cd server ; pnpm run dev` — the dev build targets this automatically. */ -export const SYNC_ENDPOINT_DEV = 'http://localhost:8787'; - -/** Host match pattern for a Worker origin, as the manifest and the - * `permissions` API want it. */ -export function syncHostPattern(endpoint: string): string { - return `${new URL(endpoint).origin}/*`; -} - -/** Every pattern a build might hold for the sync host. The background worker - * uses this to tell the sync host apart from sites the user granted. */ -export const SYNC_HOST_PATTERNS: readonly string[] = [ - syncHostPattern(SYNC_ENDPOINT_PROD), - syncHostPattern(SYNC_ENDPOINT_DEV), -]; diff --git a/src/ui/LibraryRecovery.svelte b/src/ui/LibraryRecovery.svelte new file mode 100644 index 0000000..39a140f --- /dev/null +++ b/src/ui/LibraryRecovery.svelte @@ -0,0 +1,50 @@ + + +
+

Your saved library could not be opened

+

Your saved data is still on this device. Retry, keep a copy of the original data, or restore an existing Note by Note backup.

+

{message(error)}

+
+ + + +
+ {#if notice}

{notice}

{/if} +
diff --git a/store/long-description-firefox.md b/store/long-description-firefox.md index 89c3bd3..68d66cf 100644 --- a/store/long-description-firefox.md +++ b/store/long-description-firefox.md @@ -41,7 +41,7 @@ Most pages attach directly: a Web Audio graph on the page's own audio or video e - No telemetry. No analytics. No ads. No accounts. Nothing is sold or shared. - Audio never leaves your device. Every effect — pitch, speed, vocal reduction, EQ, chord detection — runs locally in your browser. -- Cross-device sync is the only network request the extension ever makes. It is on by default and can be switched off in Settings, and it carries no audio: just your settings, markers, loops, snippets, and the page URLs and titles of the tracks in your library. +- The extension makes no network requests. Cross-device sync goes through Firefox Sync (no server, no account with us); it is on by default, can be switched off in Settings, and carries no audio: just your settings, markers, loops, snippets, and the page URLs and titles of the tracks in your library. - Full policy: [PRIVACY.md](https://github.com/patrickiel/note-by-note/blob/main/PRIVACY.md) **Open source** diff --git a/store/long-description.txt b/store/long-description.txt index 54d8c1a..5418c35 100644 --- a/store/long-description.txt +++ b/store/long-description.txt @@ -73,10 +73,11 @@ PRIVACY • No telemetry. No analytics. No ads. No accounts. Nothing is sold or shared. • Audio never leaves your device. Every effect — pitch, speed, vocal reduction, EQ, chord detection — runs locally in your browser. -• Cross-device sync is the only network request the extension ever makes. It is - on by default and can be switched off in Settings, and it carries no audio: - just your settings, markers, loops, snippets, and the page URLs and titles of - the tracks in your library. +• The extension makes no network requests. Cross-device sync goes through your + browser's own sync (no server, no account with us); it is on by default, can + be switched off in Settings, and carries no audio: just your settings, + markers, loops, snippets, and the page URLs and titles of the tracks in your + library. • Full policy: https://github.com/patrickiel/note-by-note/blob/main/PRIVACY.md diff --git a/store/privacy-policy-firefox.md b/store/privacy-policy-firefox.md index 64d2d45..9c81bd6 100644 --- a/store/privacy-policy-firefox.md +++ b/store/privacy-policy-firefox.md @@ -17,33 +17,30 @@ Uninstalling the extension removes all of it. Settings → Reset Settings clears **What is transmitted, and when** -The extension makes exactly one kind of network request: the optional cross-device sync backup. Nothing else in the extension talks to the network. +The extension makes no network requests of its own. The one thing that leaves your device is the optional cross-device sync copy, and it leaves through Firefox Sync — the same channel that carries your bookmarks — to the other devices signed into the same Firefox account. If Firefox Sync is off, nothing leaves the device. -Sync is on by default, but it only starts transmitting once you have something to sync. When it does, it uploads a single snapshot containing your settings and UI preferences, your EQ presets, your Recent and Favorites lists — including the page URL and title of tracks you practised — and your per-track data (markers and labels, loop ranges, snippets, chord charts). +Sync is on by default. It writes a compressed library snapshot containing settings, EQ presets, saved songs (including URLs, titles, durations and thumbnail URLs), favorites and manual order, practice parameters, markers, labels and snippets. Recent activity, UI layout, last-used parameters and generated chord analysis stay on the device and are included in manual backup exports. -Because that snapshot contains the addresses of pages you have visited, this listing declares the `browsingActivity` data-collection category. +Because that data contains the addresses of pages you have visited, this listing declares the `browsingActivity` data-collection category. Not included: audio, page content, keystrokes, browsing history beyond the tracks you practised on, or anything identifying you personally. **Where it goes** -Snapshots are stored by a Cloudflare Worker with Cloudflare KV, at `https://note-by-note-sync.oapp.workers.dev`, operated by the author. The server source is in the repository and can be self-hosted — self-hosters change one URL (`src/features/sync/sync-hosts.ts`) and rebuild. +Into Firefox Sync's storage under your Firefox account, end-to-end encrypted, subject to Mozilla's own data handling. The author operates no server and can see none of it. There are no accounts with us and no sync ID. -- There are no accounts. A random 43-character sync ID is the only credential; it *is* the capability, so treat it like a password. -- The ID travels in an `X-Sync-Id` header, never in the URL, so it does not land in request logs. KV is keyed by its SHA-256, so the raw ID is not stored either. -- Snapshots are stored **unencrypted**. Whoever operates the server can read them. Run your own if that matters to you. -- Snapshots expire after 180 days, refreshed on every write. -- IP addresses are visible to Cloudflare as part of serving and rate-limiting requests, per Cloudflare's own data handling. They are not stored by the Worker or linked to a snapshot. +Firefox caps synced storage at 100 KB per extension and 8 KB per item. The snapshot is split into size-limited pieces. If it exceeds the total capacity, Settings reports an error. All data remains saved locally; no songs are automatically trimmed. The newest complete library replaces older copies, so edits made on two devices at once can overwrite each other. **Turning it off and deleting the data** -- Settings → Sync turns sync off. No further data is transmitted. -- Settings → Sync → Delete synced data removes the server-side copy. -- Doing nothing also works: an unused snapshot expires after 180 days. +- Settings → Sync turns sync off on that device. No further data is written. +- Settings → Sync → Delete synced data empties the synced copy (other devices with sync still on will write theirs again). +- Uninstalling the extension makes Firefox remove its synced storage. **Permissions and why** - `storage` — saves your markers, loops, snippets and settings on your device. +- `alarms` — retries background sync while the panel is closed. - `activeTab`, `scripting` — injects the audio engine into the tab when you press Connect. - `tabs` — reads the active tab's URL and title to look up the practice data you saved for that track. - Access to all sites (optional) — requested **only** when you first press Connect, never at install time, because you choose which sites to practise on. Settings → Revoke Permissions takes it back. @@ -60,4 +57,4 @@ Material changes to this policy will be recorded in the repository's commit hist Questions or requests: open an issue at [github.com/patrickiel/note-by-note/issues](https://github.com/patrickiel/note-by-note/issues). -Last updated: 28 July 2026. Operator: Patrick Demichiel. +Last updated: 7 September 2026. Operator: Patrick Demichiel. diff --git a/tsconfig.json b/tsconfig.json index 196d90a..c57681c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,5 @@ // Unit tests run via Node type-stripping, which needs explicit .ts // extensions on relative imports. "allowImportingTsExtensions": true - }, - // The sync worker has its own tsconfig with Cloudflare types. - "exclude": ["server"] + } } diff --git a/wxt.config.ts b/wxt.config.ts index ce199f1..58601f1 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -2,7 +2,6 @@ import { mkdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'wxt'; -import { SYNC_ENDPOINT_PROD, SYNC_HOST_PATTERNS, syncHostPattern } from './src/features/sync/sync-hosts'; // Persistent dev profile: extensions installed here (e.g. uBlock Origin // Lite for YouTube-breakage repros), logins, and site data survive between @@ -104,9 +103,10 @@ export default defineConfig({ // `data_collection_permissions` only in 140 — which is also the // current ESR line, so nothing supported is left behind. strict_min_version: '140.0', - // Sync ships Recent/Favorites off the device, and those carry the - // page URL, title and thumbnail of every track practised — AMO - // counts that as browsing activity. `required` rather than + // Sync ships saved songs and Favorites off the device (through + // Firefox Sync's own storage — no server of ours), and those carry + // the page URL, title and thumbnail of every track practised — AMO + // counts that as browsing activity. (Recent stays on the device.) `required` rather than // `optional` because sync is on out of the box // (DEFAULT_SYNC_CONFIG.enabled), and a manifest claim that // contradicts the submission form is a rejection. Nothing else is @@ -125,23 +125,20 @@ export default defineConfig({ // gets `sidebar_action`, which needs no permission). `tabCapture` and // `offscreen` are Chromium-only APIs — see src/core/platform.ts, which gates // every caller on the same build target. + // `storage` covers `storage.sync`, which is all cross-device sync needs: + // no host permission, no `cookies`, no `identity` (Chromium's "is browser + // sync on" probe would want `identity.email` — an install warning — so + // there is no such probe; the Settings copy tells the user instead). permissions: [ 'storage', - 'cookies', + 'alarms', 'activeTab', 'scripting', 'tabs', ...(browser === 'firefox' ? [] : ['tabCapture', 'offscreen']), ], - // `` is what Connect asks for. The sync host is what the ID - // cookie needs (see src/features/sync/panel/id-cookie.ts) — requested from - // the Sync settings, and covered by `` too once that is granted. - // Non-production builds also list the localhost Worker so `pnpm dev` can - // exercise the cookie path. - optional_host_permissions: [ - '', - ...(mode === 'production' ? [syncHostPattern(SYNC_ENDPOINT_PROD)] : SYNC_HOST_PATTERNS), - ], + // `` is what Connect asks for. + optional_host_permissions: [''], action: { default_title: 'Note by Note', },