From 8ce04be0bab0f117e4b19f93ea3fd73124a5313c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 2 Aug 2026 13:19:30 +0200 Subject: [PATCH 1/6] feat(canvas): opt-in local cache for plugin Canvases (#473) --- CLAUDE.md | 2 +- docs/features/plugins.md | 2 + docs/features/ui.md | 2 +- src-tauri/crates/app/src/commands/canvas.rs | 110 +++++++++++++++++- src-tauri/crates/app/src/lib.rs | 3 + src-tauri/crates/app/src/paths.rs | 6 + src-tauri/crates/app/tauri.conf.json | 4 +- .../views/settings/PluginOptions.tsx | 92 ++++++++++++--- src/components/views/settings/PluginsCard.tsx | 5 +- src/i18n/locales/ar.json | 7 ++ src/i18n/locales/de.json | 7 ++ src/i18n/locales/en.json | 7 ++ src/i18n/locales/es.json | 7 ++ src/i18n/locales/fr.json | 7 ++ src/i18n/locales/hi.json | 7 ++ src/i18n/locales/id.json | 7 ++ src/i18n/locales/it.json | 7 ++ src/i18n/locales/ja.json | 7 ++ src/i18n/locales/ko.json | 7 ++ src/i18n/locales/nl.json | 7 ++ src/i18n/locales/pt-BR.json | 7 ++ src/i18n/locales/pt.json | 7 ++ src/i18n/locales/ru.json | 7 ++ src/i18n/locales/tr.json | 7 ++ src/i18n/locales/zh-CN.json | 7 ++ src/i18n/locales/zh-TW.json | 7 ++ src/lib/tauri/canvas.ts | 31 +++++ src/lib/tauri/plugins.ts | 7 ++ 28 files changed, 355 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a94d4f87..16c04e9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ These bite you if you ignore them — they're the contract the rest of the codeb - **Track sync emit (Phase 4.d.0.3)**: the scanner pushes `entity: "track"` ops to the server for every new + re-emit track. [`sync::track_emit::emit_track_insert_in_tx`](src-tauri/crates/app/src/sync/track_emit.rs) is the entry point; the scanner wraps it via the private `emit_track_insert_from_extracted` shim ([`commands/scan.rs`](src-tauri/crates/app/src/commands/scan.rs)) so the "Brand-new track" and "existing track re-emit" branches share one wire shape. The skip-fast-path branch (mtime + hash unchanged) also emits — but ONLY when the path actually re-normalised `track_artist` rows (multi-artist count delta from a comma-joined → `";"`-split rewrite); cover + codec backfills are server-derived and don't need to round-trip. Wire shape mirrors the server's `apply::track::insert` (phase 4.d.0.2): `entity_id` is the **file_path** (per-library natural identity — keying on file_hash would break tag-edit re-emit because lofty rewrites embedded metadata frames so the hash changes while the path doesn't). `payload.library_canonical_id` carries the tenant scope; `payload.file_hash` + `payload.file_modified` ride as fields (the latter lets a peer device that shares the same drive skip its own slow-path on the next scan). `payload.added_at` carries the row's ORIGINAL import timestamp on every re-emit (the brand-new branch uses `now`, the update + skip branches read it from the existing row) — a re-emit must NOT bump the peer's `Recently added` ordering. The full audio metadata + `album_title?` / `album_artist_name?` / `is_compilation?` / `artists?: [String]` (the desktop's `";"`-split list — position derives from array index) round-trip. User-initiated track deletes call [`emit_track_delete_in_tx`](src-tauri/crates/app/src/sync/track_emit.rs): the duplicates UI ([`commands/duplicates.rs::delete_tracks`](src-tauri/crates/app/src/commands/duplicates.rs)) AND folder removal ([`commands/library.rs::remove_folder_from_library`](src-tauri/crates/app/src/commands/library.rs)) both emit per-row inside the same SQLite tx as the local DELETE. **Folder removal is NOT a server-side cascade** — the server has no `library_folder` entity, so without per-row emits the tracks linger as ghost rows under the still-live library until the library itself is dropped. Library removal IS server-side cascade (the apply pipeline drops the tracks when the parent `library + delete` op lands) — explicit per-track emits would be redundant + race the cascade. `is_available = 0` (file vanished but not user-deleted) deliberately does NOT emit — the track resurfaces on the next scan if the file reappears, and a noisy delete-then-insert pair would just churn the apply pipeline. **Remaining accumulation path**: a permanently-gone file under a still-live folder/library leaves a ghost row server-side; v1 accepts this, a server-side TTL reaper is the proper long-term fix. Every CRUD command that enqueues track ops calls `state.drain.notify()` post-commit (`scan_folder`, `rescan_library`, `import_paths`, `delete_tracks`, `remove_folder_from_library`) — matches the existing playlist/library convention, drain is edge-triggered. - **Non-frontend control surfaces go through [`player_actions`](src-tauri/crates/app/src/player_actions.rs)**: the tray menu, the OS media keys and the MPD server all need the same "advance the queue → `emit_track_changed` → `emit_queue_changed` → hand the track to the decoder" sequence the frontend gets from `commands::player`. That sequence was copy-pasted in `lib.rs` and `media_controls.rs` until #471 (the second copy was literally commented "Mirror of `lib.rs::spawn_next`") and the two had already drifted. Forgetting one emit leaves a surface showing the previous track, which is exactly the bug duplication kept producing — so a new surface calls `player_actions::{next, previous, play_at_index}` rather than re-deriving it. They're `async` and await rather than spawn: sync callers (souvlaki, tray) wrap in `tauri::async_runtime::spawn` themselves, async ones report the outcome back to their client. - **Adding a new player-bar action**: default it into the overflow ("⋯") menu via [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) first; promote to primary only when usage warrants it; add a Settings pin toggle if both modes make sense. See [`docs/features/ui.md`](docs/features/ui.md#player-bar-layout). -- **Plugins load at runtime, distribution is a separate repo**: the wasmtime host ([`waveflow_core::plugin::runtime`](src-tauri/crates/core/src/plugin/runtime.rs)) loads WASM components at runtime from `/waveflow/plugins/` (writable sideload) + `/plugins/` (installer-bundled, re-seeded at boot). The **store** ([`commands/plugin_store.rs`](src-tauri/crates/app/src/commands/plugin_store.rs)) is the install path: it fetches the curated catalogue from a source cascade (`waveflow.app/api/plugins/registry` → `raw.githubusercontent.com/InstaZDLL/waveflow-plugins/main/registry.json` → jsDelivr — same list, first that answers wins) and installs by downloading the entry's pinned GitHub release, **verifying `plugin.wasm`'s blake3 against the registry entry** (the registry, NOT the release, is the trusted pin — a compromised release fails the hash), sanity-checking the manifest id/version/world, then stage-swapping into the sideload root. The catalogue + each plugin live in **separate repos** ([`InstaZDLL/waveflow-plugins`](https://github.com/InstaZDLL/waveflow-plugins) + per-plugin repos) — never in this repo — so grey-area plugins carry no liability for the signed core and a takedown is one registry commit. Every registry fetch honours [`offline::is_offline()`](src-tauri/crates/app/src/offline.rs). Store UI is `PluginStoreCard` above `PluginsCard` under Settings → Plugins; i18n under `settings.pluginStore.*`. The `metadata` world is wired since Phase 3 (`waveflow:metadata@1.1.0`, same `/v1` manifest label): [`commands/motion_artwork.rs::fetch_album_motion_artwork`](src-tauri/crates/app/src/commands/motion_artwork.rs) fans `album-info` out to enabled metadata plugins for Apple-style animated covers (`motion-cover-url`), rendered as a muted-loop `