From 531c2b5d96ced35c57e55a9156bb5ec2ec3fa12c Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 001/163] cipher: stop rebuilding the extraction stack on every track Three interlocking causes made each track start refetch the 2.9 MB player.js and spawn a fresh hidden WebKit process: - the cipher and PoToken webviews were torn down 15 s after use, which any song outlives; keep them while media is loaded and release after 5 min idle - the player hash was re-read from iframe_api on every resolve because current_hash.txt was written but never read; pin it for the cache TTL - a WEB_REMIX HEAD 403, routine on capped videos, ran the full self-heal (delete player.js, drop the bridge and the PoToken session) each time; allow one heal per ten minutes Measured on a Ryoku desktop: six consecutive track changes with no fetch and no webview build, where before each one paid ~1.2 s and a WebProcess. --- src-tauri/src/cipher/fetcher.rs | 14 ++++++++++++-- src-tauri/src/lib.rs | 26 +++++++++++++++++--------- src-tauri/src/orchestrator.rs | 22 ++++++++++++++++++++-- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/cipher/fetcher.rs b/src-tauri/src/cipher/fetcher.rs index 7390bec..cce0d3d 100644 --- a/src-tauri/src/cipher/fetcher.rs +++ b/src-tauri/src/cipher/fetcher.rs @@ -53,7 +53,6 @@ impl PlayerJsFetcher { format!("https://www.youtube.com/s/player/{hash}/player_ias.vflset/en_GB/base.js"); let js = get(&url).await?.error_for_status()?.text().await?; std::fs::write(&cached, &js)?; - std::fs::write(self.cache_dir.join("current_hash.txt"), format!("{hash}\n{}", now_secs()))?; tracing::info!(hash, bytes = js.len(), "fetched fresh player.js"); Ok(PlayerJs { js, hash }) } @@ -74,9 +73,20 @@ impl PlayerJsFetcher { } } + /// The player hash, pinned on disk for the cache TTL so a listening session keeps one + /// player: YouTube hands different builds to consecutive `iframe_api` requests, and + /// following every answer meant a fresh 3 MB `base.js` and a webview rebuild per track. async fn current_hash(&self) -> Result { + let pin = self.cache_dir.join("current_hash.txt"); + if let Some(hash) = read_if_fresh(&pin).and_then(|s| s.lines().next().map(str::to_owned)) { + if !hash.is_empty() { + return Ok(hash); + } + } let body = get(IFRAME_API).await?.error_for_status()?.text().await?; - extract_hash(&body).ok_or(Error::NoHash) + let hash = extract_hash(&body).ok_or(Error::NoHash)?; + std::fs::write(&pin, format!("{hash}\n{}", now_secs()))?; + Ok(hash) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7286a44..d2b99f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -392,16 +392,22 @@ pub fn run() { } }); } - // Hidden authentication JS is burst work, not resident UI. Tear helpers down quickly - // after real work and keep only their Rust-side analysis/session state. + // The hidden cipher/PoToken webviews are burst workers, but every track start needs + // them: tearing them down 15 s after use meant a fresh WebKit process, a 3 MB JS + // injection and a full analysis per track. Keep them resident while media is + // loaded and only release them after a long idle with nothing playing. { let cipher = cipher.clone(); let potoken = potoken.clone(); + let st = app_state.clone(); tauri::async_runtime::spawn(async move { loop { - tokio::time::sleep(Duration::from_secs(10)).await; - cipher.teardown_if_idle(Duration::from_secs(15)).await; - potoken.teardown_if_idle(Duration::from_secs(15)).await; + tokio::time::sleep(Duration::from_secs(30)).await; + if st.player.has_loaded_media() { + continue; + } + cipher.teardown_if_idle(Duration::from_secs(300)).await; + potoken.teardown_if_idle(Duration::from_secs(300)).await; } }); } @@ -414,10 +420,12 @@ pub fn run() { main_window::enforce_floating_geometry(app.handle()); main_window::request_hyprland_float(app.handle()); // The main WebView starts hidden to avoid exposing an unpainted WebKit frame. - // Svelte normally reveals it immediately from `onMount`; this bounded native - // fallback guarantees that a lost readiness message can never leave a normal - // cold launch permanently tray-only. - main_window::arm_reveal_failsafe(app.handle(), Duration::from_millis(1500)); + // Svelte normally reveals it from `onMount`; this bounded native fallback only + // guarantees that a lost readiness message can never leave a cold launch + // permanently tray-only. A cold WebKitGTK + SvelteKit mount takes 1-3 s on a + // laptop, so the fallback sits well past that or it fires before the handshake + // and shows an unstyled frame. + main_window::arm_reveal_failsafe(app.handle(), Duration::from_millis(4000)); spawn_heap_trimmer(); } Ok(()) diff --git a/src-tauri/src/orchestrator.rs b/src-tauri/src/orchestrator.rs index fac1113..3c67f30 100644 --- a/src-tauri/src/orchestrator.rs +++ b/src-tauri/src/orchestrator.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use innertube::{ find_format, rustypipe_fallback, AudioQuality, Clients, Format, InnerTube, PlayerResponse, @@ -65,6 +65,8 @@ pub enum ResolveError { /// Client keys that need the `n`-transform applied to their stream URLs. stream selection. const NEEDS_N_TRANSFORM: [&str; 4] = ["WEB", "WEB_REMIX", "WEB_CREATOR", "TVHTML5"]; +/// Minimum spacing between two off-hot-path self-heals (`take_heal_slot`). +const HEAL_WINDOW: Duration = Duration::from_secs(10 * 60); // WEB_REMIX is validated with a HEAD like every other client — see `validate_head`. @@ -86,6 +88,11 @@ pub struct Orchestrator { /// (stream selection §2). Cleared when the cipher self-heals. `Arc` so the off-hot-path self-heal /// task can clear it. web_remix_failed: Arc>>, + /// When the last self-heal ran. A WEB_REMIX HEAD 403 is routine on capped videos (see the + /// validation comment in `resolve`), so healing on every one of them threw away player.js, + /// the cipher webview and the PoToken session per track. One heal per window is plenty to + /// catch a config that really went stale. + last_heal: Arc>>, } impl Orchestrator { @@ -101,6 +108,7 @@ impl Orchestrator { cipher, potoken, web_remix_failed: Arc::new(Mutex::new(HashSet::new())), + last_heal: Arc::new(Mutex::new(None)), } } @@ -110,6 +118,16 @@ impl Orchestrator { self.web_remix_failed.lock().await.insert(video_id.to_owned()); } + /// Claim the one self-heal allowed per `HEAL_WINDOW`; false while a recent heal is cooling. + async fn take_heal_slot(&self) -> bool { + let mut last = self.last_heal.lock().await; + if last.is_some_and(|t| t.elapsed() < HEAL_WINDOW) { + return false; + } + *last = Some(Instant::now()); + true + } + /// Resolve a videoId to a playable stream. stream selection full algorithm. pub async fn resolve( &self, @@ -284,7 +302,7 @@ impl Orchestrator { main_ping.clone().or_else(|| playback_ping(&resp, &key)), is_upload, )); - } else if needs_n { + } else if needs_n && self.take_heal_slot().await { // A cipher client that fails validation may have a stale config → self-heal off // the hot path so it never blocks falling through (stream selection §7). If the heal // changes the config table, clear the WEB_REMIX failure memory (stream selection §2). From a75d030472867f4df363a1b4be90f5edbfbdc31a Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 002/163] player: name the mpv stream after the track mpv titled the stream by its URL, so the PipeWire node carried the full signed googlevideo link. Pass a per-file force-media-title (Artist - Title) through loadfile's options, quoted for the command parser and length-prefixed for the option parser so any title round-trips verbatim. Includes a probe example that asserts the round trip. --- crates/player/examples/play.rs | 4 ++-- crates/player/examples/title.rs | 27 +++++++++++++++++++++++++++ crates/player/src/lib.rs | 24 ++++++++++++++++++++---- src-tauri/src/state.rs | 31 ++++++++++++++++++++++++------- 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 crates/player/examples/title.rs diff --git a/crates/player/examples/play.rs b/crates/player/examples/play.rs index 02b802d..0b2f0a9 100644 --- a/crates/player/examples/play.rs +++ b/crates/player/examples/play.rs @@ -17,8 +17,8 @@ async fn main() { let mut p = Player::new(cache.to_str().unwrap()).expect("player"); let mut events = p.take_events().unwrap(); - p.load(&a, &HashMap::new(), None).expect("load A"); - p.enqueue(&b).expect("enqueue B"); + p.load(&a, &HashMap::new(), None, "A").expect("load A"); + p.enqueue(&b, "B").expect("enqueue B"); p.play().expect("play"); let mut ended = 0; diff --git a/crates/player/examples/title.rs b/crates/player/examples/title.rs new file mode 100644 index 0000000..991f24f --- /dev/null +++ b/crates/player/examples/title.rs @@ -0,0 +1,27 @@ +//! Media-title probe: `cargo run -p player --example title -- [title]` +//! Loads a file with an awkward per-file title (spaces, commas, quotes, non-ASCII) and asserts +//! that mpv reports it back verbatim. + +use std::collections::HashMap; + +use player::Player; + +fn main() { + let a = std::env::args().nth(1).expect("usage: cargo run -p player --example title -- "); + let cache = std::env::temp_dir().join("ryotunes-player-example"); + std::fs::create_dir_all(&cache).ok(); + let p = Player::new(cache.to_str().unwrap()).expect("player"); + let title = std::env::args() + .nth(2) + .unwrap_or_else(|| "Ünïcode Artist, \"quoted\" – A Title".to_owned()); + p.load(&a, &HashMap::new(), None, &title).expect("load"); + let mut got = String::new(); + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(100)); + got = p.media_title().unwrap_or_default(); + if !got.is_empty() { break; } + } + println!("want: {title}"); + println!("got: {got}"); + assert_eq!(got, title); +} diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index 52dde9a..bb88bf3 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -122,16 +122,19 @@ impl Player { self.events.take() } - /// Load and play a fresh URL, replacing the playlist. + /// Load and play a fresh URL, replacing the playlist. `title` names the stream for mpv + /// (and so for the PipeWire node); without it mpv uses the URL, which for a signed + /// googlevideo link puts the whole token into the audio graph. pub fn load( &self, url: &str, headers: &HashMap, gain_db: Option, + title: &str, ) -> Result<(), Error> { self.apply_headers(headers)?; self.set_gain(gain_db)?; - self.mpv.command("loadfile", &["ed(url), "replace"])?; + self.mpv.command("loadfile", &["ed(url), "replace", "-1", &title_option(title)])?; // Close→pause can happen before mpv's property notification reaches the event thread. // Mark the session loaded immediately so background lifecycle can never mistake that tiny // transition window for an empty player. The observed property corrects this on failure/EOF. @@ -144,8 +147,8 @@ impl Player { /// Note: mpv's `http-header-fields`/`user-agent` are global properties, so appended tracks /// inherit the currently-set headers. Ordinary direct-URL streams do not require per-track /// cookies; upload-specific headers are handled before a track is loaded. - pub fn enqueue(&self, url: &str) -> Result<(), Error> { - self.mpv.command("loadfile", &["ed(url), "append"])?; + pub fn enqueue(&self, url: &str, title: &str) -> Result<(), Error> { + self.mpv.command("loadfile", &["ed(url), "append", "-1", &title_option(title)])?; Ok(()) } @@ -162,6 +165,11 @@ impl Player { self.mpv.get_property::("idle-active").unwrap_or(true) } + /// The title mpv reports for the current file (the per-file `force-media-title` once set). + pub fn media_title(&self) -> Option { + self.mpv.get_property::("media-title").ok() + } + /// Cheap event-driven loaded-media state for background lifecycle decisions. Unlike /// [`Self::is_idle`], this never takes mpv's core lock and therefore cannot turn a transient /// property-query failure during Pause into a false "nothing loaded" result. @@ -414,6 +422,14 @@ fn quoted(arg: &str) -> String { format!("\"{}\"", arg.replace('\\', "\\\\").replace('"', "\\\"")) } +/// Per-file `force-media-title` for `loadfile`'s options argument. Two parsers see it: the +/// command-string parser (libmpv2 joins the args into one line) needs the token quoted, and the +/// comma-separated option list inside needs the value length-prefixed (`%len%...`), which takes +/// any bytes verbatim. A per-file option also leaves the gapless lookahead's title untouched. +fn title_option(title: &str) -> String { + quoted(&format!("force-media-title=%{}%{}", title.len(), title)) +} + /// Slider percent → mpv `volume` value, over a 60 dB range. mpv applies gain = (v/100)³, /// i.e. 60·log10(v/100) dB, so v = 100·10^(−(1−s/100)^1.5) yields −60·(1−s/100)^1.5 dB: /// 50% is −21 dB, 25% is −39 dB, 1% is −59 dB. 0 stays a hard mute. diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f921a47..e89d67b 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1504,9 +1504,12 @@ impl AppState { if self.generation.load(Ordering::SeqCst) != gen { return false; // user moved on } - if let Err(e) = - self.player.load(&data.stream_url, &data.headers, loudness_gain(data.loudness_db)) - { + if let Err(e) = self.player.load( + &data.stream_url, + &data.headers, + loudness_gain(data.loudness_db), + &media_title(&item.title, &item.artists), + ) { self.emit_error(&item.video_id, &e.to_string()); return false; } @@ -1664,7 +1667,8 @@ impl AppState { } // Headers are global in mpv; the direct-URL clients need none beyond UA, which the // current track already set. Just append the URL. - if let Err(e) = self.player.enqueue(&data.stream_url) { + let title = q.items.get(next_idx).map(|i| media_title(&i.title, &i.artists)).unwrap_or_default(); + if let Err(e) = self.player.enqueue(&data.stream_url, &title) { tracing::warn!(error = %e, "enqueue lookahead failed"); return; } @@ -2434,9 +2438,12 @@ impl AppState { if self.generation.load(Ordering::SeqCst) != gen { return; // superseded by a newer sync } - if let Err(e) = - self.player.load(&data.stream_url, &data.headers, loudness_gain(data.loudness_db)) - { + if let Err(e) = self.player.load( + &data.stream_url, + &data.headers, + loudness_gain(data.loudness_db), + &media_title(&track.title, &track.artist), + ) { self.emit_error(&track.id, &e.to_string()); return; } @@ -3381,6 +3388,16 @@ fn loudness_gain(loudness_db: Option) -> Option { (gain < -0.05).then(|| gain.max(-24.0)) } +/// "Artist – Title" for mpv's per-file media title (what PipeWire and mpv's own metadata show). +fn media_title(title: &str, artists: &str) -> String { + match (title.trim(), artists.trim()) { + ("", "") => "Ryotunes".to_owned(), + (t, "") => t.to_owned(), + ("", a) => a.to_owned(), + (t, a) => format!("{a} – {t}"), + } +} + /// Loudness target, matching YouTube Music's own player rather than the video site's -14. const TARGET_LUFS: f64 = -7.0; From 079755d3cafe76db61a2f80b6d3115c81d99c1d7 Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 003/163] startup: bound the palette wait before the reveal handshake The reveal was gated on the Ryoku token IPC and retried for ~1.85 s against a 1.5 s native failsafe, so a cold WebKit mount was routinely revealed natively and unstyled. Race the palette against 250 ms; the native failsafe moved to 4 s in the previous commit's lib.rs change. --- ui/src/routes/+layout.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 22e9254..2c6a273 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -111,9 +111,14 @@ // `visible:false`, which deadlocks the only callback capable of revealing that window. // Start the bridge handshake immediately; retries use ordinary timers, which continue // to run for a hidden WebView. Native code also owns a bounded reveal failsafe. + // Palette-before-reveal is worth a short wait, not the whole IPC round-trip: gating on + // the token invoke alone routinely outran the native 1.5 s failsafe on a cold WebKit + // process, so the window was revealed natively, unhandshaken. Bound the wait. const begin = () => { if (!cancelled) void attempt(0); }; - if (beforeReveal) void beforeReveal.then(begin, begin); - else begin(); + if (beforeReveal) { + const bounded = Promise.race([beforeReveal, new Promise((r) => setTimeout(r, 250))]); + void bounded.then(begin, begin); + } else begin(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; } From 7f9feedfa406516a4b4c712448af124592557f9c Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 004/163] build: default the custom-protocol feature Tauri sets cfg(dev) whenever custom-protocol is absent, so the source PKGBUILD's plain cargo build produced a binary that opened build.devUrl. Default the feature on; cargo tauri dev still passes --no-default-features. --- src-tauri/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6aeb4bf..75c7a81 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,13 @@ rust-version = "1.80" name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] +# Tauri's `dev` cfg is the absence of `custom-protocol`: without it a release binary still +# points its window at `build.devUrl`. Default it on so `cargo build --release` (the source +# PKGBUILD) ships the bundled frontend; `cargo tauri dev` passes --no-default-features. +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] + [build-dependencies] tauri-build = { version = "2", features = [] } From 432aa04c8277e0f686af425507c83e5e082c77de Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 22:06:44 -0400 Subject: [PATCH 005/163] docs: native client design spec and core extraction plan --- .../plans/2026-09-05-core-extraction.md | 759 ++++++++++++++++++ .../specs/2026-09-05-native-client-design.md | 341 ++++++++ 2 files changed, 1100 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-core-extraction.md create mode 100644 docs/superpowers/specs/2026-09-05-native-client-design.md diff --git a/docs/superpowers/plans/2026-09-05-core-extraction.md b/docs/superpowers/plans/2026-09-05-core-extraction.md new file mode 100644 index 0000000..41f2f51 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-core-extraction.md @@ -0,0 +1,759 @@ +# Core Extraction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move everything in `src-tauri/src` that is not a window, a webview or a Tauri command into a Tauri-free `crates/core` crate behind three traits, leaving the Tauri host a thin adapter with identical behaviour. + +**Architecture:** `crates/core` owns `AppState`, the orchestrator, database, lyrics, local library, integrations, cipher and PoToken. It reaches the outside world only through `EventSink` (server push), `JsBridge`/`JsSession` (a JavaScript environment), `LoginFlow` (interactive sign-in) and a `Paths` value. The Tauri host in `src-tauri` implements the three traits with `AppHandle::emit`, `webview.rs` and `session.rs`, and every `#[tauri::command]` becomes a forwarder into `core`. This is phase 1 of `docs/superpowers/specs/2026-09-05-native-client-design.md`; phases 2-4 (daemon, QML client, cutover) get their own plans once this lands. + +**Tech Stack:** Rust 2021 workspace (`Cargo.toml` at the repo root), tokio, serde, `async-trait`, Tauri 2 (host only), libmpv via `crates/player`, `crates/innertube`. + +**Spec:** `docs/superpowers/specs/2026-09-05-native-client-design.md` + +## Global Constraints + +- `cargo test --workspace --locked`, `cargo check --workspace --locked`, `cargo fmt --all -- --check` and `scripts/release-check.sh` must pass after every task (they are the release gates in `README.md`). +- No behaviour change: the Svelte UI, the hidden cipher/PoToken webviews, MPRIS, tray, Last.fm, Discord, Listen Together and login work exactly as before; `RUST_LOG=info` logs show the same `cipher: building webview`, `PoToken minter ready`, `resolved stream client="WEB_REMIX"` lines. +- `crates/core` must not depend on `tauri`, `tauri-plugin-*`, `webkit2gtk` or `ksni`; `cargo tree -p ryotunes-core -i tauri` must print `error: package ID specification tauri did not match any packages`. +- Commit subjects follow the existing history style (`build: ...`, `host: ...`, `core: ...`), imperative, no trailing period, no attribution trailers. +- Do not touch `ui/`, `crates/innertube`, `crates/player`, `crates/listen-protocol`, `crates/sync-server`. +- The workspace `Cargo.lock` is locked: adding `async-trait` is the only new dependency; add it to `[workspace.dependencies]` and run `cargo update -p async-trait --precise ` once, commit the lock. + +--- + +### Task 1: Scaffold `crates/core` with the host traits + +**Files:** +- Create: `crates/core/Cargo.toml` +- Create: `crates/core/src/lib.rs` +- Create: `crates/core/src/host.rs` +- Modify: `Cargo.toml` (workspace members + `async-trait`) +- Test: `crates/core/src/host.rs` (`#[cfg(test)]`) + +**Interfaces:** +- Produces: `ryotunes_core::host::{EventSink, JsBridge, JsSession, JsError, LoginFlow, LoginResult, LoginError, Paths}` used by every later task. + +- [ ] **Step 1: Add the crate to the workspace** + +`Cargo.toml` (root): + +```toml +[workspace] +resolver = "2" +members = [ + "crates/innertube", + "crates/player", + "crates/listen-protocol", + "crates/sync-server", + "crates/core", + "src-tauri", +] + +[workspace.dependencies] +# ... existing lines unchanged ... +async-trait = "0.1" +ryotunes-core = { path = "crates/core" } +``` + +- [ ] **Step 2: Write `crates/core/Cargo.toml`** + +```toml +[package] +name = "ryotunes-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Ryotunes playback core: state, orchestration, integrations. No UI." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util"] } +tracing = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +futures-util = { workspace = true } +rand = { workspace = true } +listen-protocol = { workspace = true } +innertube = { path = "../innertube" } +player = { path = "../player" } +rusqlite = { version = "0.32", features = ["bundled"] } +base64 = "0.22" +regex = "1" +urlencoding = "2" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "stream"] } +souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +discord-rich-presence = "1.1.0" +md-5 = "0.10" +lofty = "0.22.2" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" +``` + +- [ ] **Step 3: Write the traits and the failing test** + +`crates/core/src/lib.rs`: + +```rust +//! Ryotunes playback core. Everything that is not a window lives here; the host (Tauri today, the +//! daemon tomorrow) supplies the three traits in [`host`]. +pub mod host; +``` + +`crates/core/src/host.rs`: + +```rust +//! The seams between the core and whoever hosts it. + +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::Value; + +/// Server-push channel: the host fans an event out to every listening UI. +pub trait EventSink: Send + Sync + 'static { + fn emit(&self, event: &'static str, payload: Value); +} + +#[derive(Debug, thiserror::Error)] +pub enum JsError { + #[error("js session '{0}' does not exist")] + Gone(String), + #[error("js eval failed: {0}")] + Eval(String), + #[error("timed out after {0:?}")] + Timeout(Duration), + #[error("js environment reported an error: {0}")] + BadEnvironment(String), + #[error("js environment build failed: {0}")] + Build(String), +} + +/// A JavaScript environment able to run YouTube's player.js and BotGuard harnesses. +#[async_trait::async_trait] +pub trait JsBridge: Send + Sync + 'static { + async fn create( + &self, + label: &str, + harness_html: &str, + init_script: &str, + ) -> Result, JsError>; +} + +/// One live environment. Mirrors `src-tauri/src/webview.rs` `Bridge` one to one. +#[async_trait::async_trait] +pub trait JsSession: Send + Sync { + fn eval(&self, js: &str) -> Result<(), JsError>; + async fn eval_json(&self, js: String, timeout: Duration) -> Result; + async fn call_async(&self, expr: &str, timeout: Duration) -> Result; + fn exists(&self) -> bool; + fn destroy(&self); + /// A second handle to the same environment (the PoToken minter keeps one per session). + fn clone_session(&self) -> Box; +} + +#[derive(Debug, Clone)] +pub struct LoginResult { + /// `name=value` pairs for the `.youtube.com` domain, as the cookie jar hands them out. + pub cookies: Vec<(String, String)>, + /// Google account index the user picked (`authuser`). + pub authuser: u32, +} + +#[derive(Debug, thiserror::Error)] +pub enum LoginError { + #[error("sign-in was cancelled")] + Cancelled, + #[error("sign-in failed: {0}")] + Failed(String), +} + +/// The interactive Google sign-in, owned by the host because it needs a visible browser. +#[async_trait::async_trait] +pub trait LoginFlow: Send + Sync + 'static { + async fn sign_in(&self) -> Result; +} + +/// Where the core keeps its files. The host resolves them (Tauri's `app_data_dir`, or XDG). +#[derive(Debug, Clone)] +pub struct Paths { + pub data_dir: PathBuf, + pub cache_dir: PathBuf, +} + +impl Paths { + pub fn covers_dir(&self) -> PathBuf { + self.data_dir.join("covers") + } + pub fn db_path(&self) -> PathBuf { + self.data_dir.join("ryotunes.db") + } +} + +#[cfg(test)] +pub mod test_support { + use super::*; + use std::sync::Mutex; + + /// An `EventSink` that records what was emitted, for unit tests of the core. + #[derive(Default)] + pub struct RecordingSink { + pub events: Mutex>, + } + + impl EventSink for RecordingSink { + fn emit(&self, event: &'static str, payload: Value) { + self.events.lock().unwrap().push((event, payload)); + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::RecordingSink; + use super::*; + + #[test] + fn recording_sink_keeps_order() { + let sink = RecordingSink::default(); + sink.emit("playback-state", Value::String("playing".into())); + sink.emit("position", serde_json::json!({ "position": 1.5 })); + let events = sink.events.lock().unwrap(); + assert_eq!(events[0].0, "playback-state"); + assert_eq!(events[1].1["position"], 1.5); + } + + #[test] + fn paths_derive_children() { + let p = Paths { data_dir: "/d".into(), cache_dir: "/c".into() }; + assert_eq!(p.covers_dir(), PathBuf::from("/d/covers")); + assert_eq!(p.db_path(), PathBuf::from("/d/ryotunes.db")); + } +} +``` + +- [ ] **Step 4: Run the tests and the isolation check** + +Run: `cargo test -p ryotunes-core` +Expected: 2 passed. + +Run: `cargo tree -p ryotunes-core -i tauri` +Expected: `error: package ID specification `tauri` did not match any packages`. + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.toml Cargo.lock crates/core +git commit -m "core: add the ryotunes-core crate with the host traits" +``` + +--- + +### Task 2: Move the Tauri-free modules + +**Files:** +- Move (`git mv`): `src-tauri/src/db.rs`, `src-tauri/src/http.rs`, `src-tauri/src/radio.rs`, `src-tauri/src/lyrics.rs`, `src-tauri/src/discord.rs` to `crates/core/src/` +- Modify: `crates/core/src/lib.rs`, `src-tauri/src/lib.rs`, `src-tauri/src/commands.rs`, `src-tauri/src/state.rs` + +**Interfaces:** +- Produces: `ryotunes_core::{db, http, radio, lyrics, discord}` with the same `pub` items the host used via `crate::db` etc. + +- [ ] **Step 1: Confirm the modules are Tauri-free** + +Run: `grep -n 'tauri\|AppHandle' src-tauri/src/db.rs src-tauri/src/http.rs src-tauri/src/radio.rs src-tauri/src/lyrics.rs src-tauri/src/discord.rs` +Expected: no output (verified 2026-09-05; if a line appears, stop and treat that module in Task 4). + +- [ ] **Step 2: Move them and declare them** + +```bash +git mv src-tauri/src/db.rs crates/core/src/db.rs +git mv src-tauri/src/http.rs crates/core/src/http.rs +git mv src-tauri/src/radio.rs crates/core/src/radio.rs +git mv src-tauri/src/lyrics.rs crates/core/src/lyrics.rs +git mv src-tauri/src/discord.rs crates/core/src/discord.rs +``` + +`crates/core/src/lib.rs`: + +```rust +pub mod host; + +pub mod db; +pub mod discord; +pub mod http; +pub mod lyrics; +pub mod radio; +``` + +In `src-tauri/src/lib.rs` delete `mod db; mod discord; mod http; mod lyrics; mod radio;` and add: + +```rust +use ryotunes_core::{db, discord, http, lyrics, radio}; +``` + +In `src-tauri/Cargo.toml` add `ryotunes-core = { workspace = true }` under `[dependencies]`. Any `crate::db::`/`crate::lyrics::` path inside the moved files that pointed at each other becomes `crate::` inside core (they moved together) and `ryotunes_core::` in the host; `pub(crate)` items the host uses become `pub`. + +- [ ] **Step 3: Build and test** + +Run: `cargo check --workspace --locked && cargo test --workspace --locked` +Expected: both green; the `db`, `lyrics`, `radio` unit tests now run from `ryotunes-core`. + +- [ ] **Step 4: Commit** + +```bash +git add -A crates/core src-tauri/src src-tauri/Cargo.toml +git commit -m "core: move db, http, radio, lyrics and discord out of the host" +``` + +--- + +### Task 3: Move cipher and PoToken behind `JsBridge` + +**Files:** +- Move: `src-tauri/src/cipher/` (mod.rs, config.rs, extractor.rs, fetcher.rs) and `src-tauri/src/potoken/` (mod.rs, jsutil.rs) to `crates/core/src/` +- Move: `src-tauri/cipher_configs.json` to `crates/core/cipher_configs.json` (the `include_str!` path in `config.rs` changes to `"../../cipher_configs.json"`), `src-tauri/po_token.html` to `crates/core/po_token.html` +- Modify: `src-tauri/src/webview.rs` (implement the traits), `src-tauri/src/lib.rs` (construction) +- Test: existing `cipher::tests`, `extractor::tests`, `config::tests`, `potoken::jsutil` tests move with the files + +**Interfaces:** +- Consumes: `host::{JsBridge, JsSession, JsError}` from Task 1. +- Produces: `ryotunes_core::cipher::CipherDeobfuscator::new(js: Arc, app_data_dir: &Path, config: Arc)`, `ryotunes_core::potoken::PoTokenGenerator::new(js: Arc, db: Arc)`; every other method signature unchanged. + +- [ ] **Step 1: Write the failing compile** + +Move the directories: + +```bash +git mv src-tauri/src/cipher crates/core/src/cipher +git mv src-tauri/src/potoken crates/core/src/potoken +git mv src-tauri/cipher_configs.json crates/core/cipher_configs.json +git mv src-tauri/po_token.html crates/core/po_token.html +``` + +Add `pub mod cipher; pub mod potoken;` to `crates/core/src/lib.rs`. Run `cargo check -p ryotunes-core`. +Expected: errors on `use tauri::AppHandle` and `use crate::webview::Bridge` in `cipher/mod.rs` and `potoken/mod.rs`; that is the list of edits for Step 2. + +- [ ] **Step 2: Replace `AppHandle`/`Bridge` with the traits** + +In `crates/core/src/cipher/mod.rs`: + +```rust +use std::sync::Arc; +use crate::host::{JsBridge, JsSession}; + +#[derive(Default)] +struct Inner { + bridge: Option>, + // ... the other fields unchanged ... +} + +pub struct CipherDeobfuscator { + js: Arc, + fetcher: PlayerJsFetcher, + config: Arc, + inner: Mutex, +} + +impl CipherDeobfuscator { + pub fn new(js: Arc, app_data_dir: &Path, config: Arc) -> Self { + CipherDeobfuscator { + fetcher: PlayerJsFetcher::new(app_data_dir), + config, + inner: Mutex::new(Inner::default()), + js, + } + } +} +``` + +Mechanical substitutions in both modules, each a one-liner: + +| Before | After | +|---|---| +| `Bridge::create(&self.app, LABEL, HARNESS, init).await` | `self.js.create(LABEL, HARNESS, init).await` | +| `inner.bridge.clone()?` (cipher) | `inner.bridge.as_ref().map(\|b\| b.clone_session())?` | +| `let _ = b.destroy();` | `b.destroy();` | +| `bridge.eval_json(js, CALL_TIMEOUT).await` | unchanged | +| `bridge.call_async(expr, LOAD_TIMEOUT).await.map_err(\|e\| e.to_string())` | unchanged (`JsError` implements `Display`) | +| `tauri::async_runtime::spawn(async move { ... })` | `tokio::spawn(async move { ... })` | +| `crate::webview::Error::BadWebview(msg)` matches in `potoken/mod.rs` | `JsError::BadEnvironment(msg)` | + +`Inner::default()` requires `Option>: Default`, which holds. + +- [ ] **Step 3: Implement the traits on the Tauri bridge** + +Append to `src-tauri/src/webview.rs`: + +```rust +use ryotunes_core::host::{JsBridge, JsError, JsSession}; + +impl From for JsError { + fn from(e: Error) -> Self { + match e { + Error::Gone(l) => JsError::Gone(l), + Error::Eval(m) => JsError::Eval(m), + Error::Timeout(d) => JsError::Timeout(d), + Error::BadWebview(m) => JsError::BadEnvironment(m), + Error::Build(m) => JsError::Build(m), + } + } +} + +/// The host's `JsBridge`: one hidden Tauri webview per label. +pub struct TauriJs { + pub app: AppHandle, +} + +#[async_trait::async_trait] +impl JsBridge for TauriJs { + async fn create( + &self, + label: &str, + harness_html: &str, + init_script: &str, + ) -> Result, JsError> { + let bridge = Bridge::create(&self.app, label, harness_html, init_script).await?; + Ok(Box::new(bridge)) + } +} + +#[async_trait::async_trait] +impl JsSession for Bridge { + fn eval(&self, js: &str) -> Result<(), JsError> { + Bridge::eval(self, js).map_err(Into::into) + } + async fn eval_json(&self, js: String, timeout: Duration) -> Result { + Bridge::eval_json(self, js, timeout).await.map_err(Into::into) + } + async fn call_async(&self, expr: &str, timeout: Duration) -> Result { + Bridge::call_async(self, expr, timeout).await.map_err(Into::into) + } + fn exists(&self) -> bool { + Bridge::exists(self) + } + fn destroy(&self) { + let _ = Bridge::destroy(self); + } + fn clone_session(&self) -> Box { + Box::new(self.clone()) + } +} +``` + +Add `async-trait = { workspace = true }` to `src-tauri/Cargo.toml`. + +In `src-tauri/src/lib.rs` replace the two constructions: + +```rust +let js: Arc = Arc::new(webview::TauriJs { app: handle.clone() }); +let cipher = Arc::new(CipherDeobfuscator::new(js.clone(), &data_dir, config)); +let potoken = Arc::new(PoTokenGenerator::new(js.clone(), db.clone())); +``` + +- [ ] **Step 4: Build, test, and smoke the real thing** + +Run: `cargo check --workspace --locked && cargo test --workspace --locked` +Expected: green; `cipher::tests::parses_signature_cipher` and friends run from core. + +Run: `cd ui && pnpm build && cd .. && cargo tauri build --no-bundle` then `RUST_LOG=info ./target/release/ryotunes` and skip to a fresh track. +Expected log lines, in order: `cipher: building webview`, `cipher analysis complete sig_available=true n_available=true`, `PoToken minter ready`, `resolved stream client="WEB_REMIX"`. Two extra `WebKitWebProcess` appear in `ps`, as before. + +- [ ] **Step 5: Commit** + +```bash +git add -A crates/core src-tauri +git commit -m "core: move cipher and potoken behind the JsBridge seam" +``` + +--- + +### Task 4: Move the integrations behind `EventSink` and `Paths` + +**Files:** +- Move: `src-tauri/src/media.rs`, `src-tauri/src/lastfm.rs`, `src-tauri/src/local.rs`, `src-tauri/src/listentogether/` to `crates/core/src/` +- Create: `src-tauri/src/host_sink.rs` (the `EventSink` over `AppHandle`) +- Create: `src-tauri/src/local_scope.rs` (the Tauri asset-protocol allow-listing that stays in the host) +- Modify: `src-tauri/src/lib.rs`, `src-tauri/src/commands.rs`, `src-tauri/src/ryoku_theme.rs` + +**Interfaces:** +- Consumes: `host::{EventSink, Paths}`. +- Produces: `ryotunes_core::media::spawn(sink: Arc, commands: MediaCommands) -> Option` where `MediaCommands` is the channel the host already drains in `media::handle_event`; `ryotunes_core::lastfm::spawn(session_key: Option) -> LastfmHandle` and `lastfm::emit_state(sink: &dyn EventSink, ...)`; `ryotunes_core::listentogether::LtSession::new(sink: Arc, url: String)`; `ryotunes_core::local::{scan, covers_dir(paths: &Paths), ...}`. + +- [ ] **Step 1: Write the host sink and its test** + +`src-tauri/src/host_sink.rs`: + +```rust +//! `EventSink` for the Tauri host: every core event becomes a Tauri event on every window. + +use ryotunes_core::host::EventSink; +use serde_json::Value; +use tauri::{AppHandle, Emitter}; + +pub struct TauriSink(pub AppHandle); + +impl EventSink for TauriSink { + fn emit(&self, event: &'static str, payload: Value) { + if let Err(e) = self.0.emit(event, payload) { + tracing::debug!(event, error = %e, "event emit failed (no window?)"); + } + } +} +``` + +There is no unit test for the sink itself (it needs a running Tauri app); its contract is covered by the smoke test in Step 5. The core side is tested with `RecordingSink` in Step 3. + +- [ ] **Step 2: Move the modules and replace `AppHandle`** + +```bash +git mv src-tauri/src/media.rs crates/core/src/media.rs +git mv src-tauri/src/lastfm.rs crates/core/src/lastfm.rs +git mv src-tauri/src/local.rs crates/core/src/local.rs +git mv src-tauri/src/listentogether crates/core/src/listentogether +``` + +Add `pub mod media; pub mod lastfm; pub mod local; pub mod listentogether;` to core's `lib.rs`. + +Substitutions: + +| File | Before | After | +|---|---|---| +| `media.rs:81` | `pub fn spawn(app: AppHandle) -> Option` | `pub fn spawn(sink: Arc, commands: tokio::sync::mpsc::UnboundedSender) -> Option` | +| `media.rs:193` | `pub(crate) fn handle_event(app: &AppHandle, event: MediaControlEvent)` | deleted from core; the host keeps a `handle_media_event(state: Arc, event)` in `lib.rs` draining the receiver and calling the same `AppState` methods it calls today | +| `lastfm.rs:279` | `fn emit_state(app: &tauri::AppHandle, ...)` | `pub fn emit_state(sink: &dyn EventSink, ...)` with `sink.emit("lastfm-state", json!({...}))` | +| `lastfm.rs:113,314` | `tauri::async_runtime::spawn` | `tokio::spawn` | +| `listentogether/mod.rs:146,156` | `app: AppHandle` | `sink: Arc` | +| `listentogether/mod.rs` `emit_state` | `self.app.emit("lt-state", ...)` | `self.sink.emit("lt-state", ...)` | +| `local.rs:686` | `pub fn covers_dir(app: &tauri::AppHandle) -> PathBuf` | `pub fn covers_dir(paths: &Paths) -> PathBuf { paths.covers_dir() }` (the XDG fallbacks move into the host's `Paths` construction) | +| `local.rs:646,664` | `allow_covers`, `allow_music_paths` | moved verbatim to `src-tauri/src/local_scope.rs`, they are Tauri asset-protocol scope calls | + +- [ ] **Step 3: Test the core side with the recording sink** + +Add to `crates/core/src/listentogether/mod.rs` tests: + +```rust +#[tokio::test] +async fn emit_state_reaches_the_sink() { + use crate::host::test_support::RecordingSink; + let sink = std::sync::Arc::new(RecordingSink::default()); + let (session, _rx) = LtSession::new(sink.clone(), "wss://example.invalid".into()); + session.emit_state().await; + let events = sink.events.lock().unwrap(); + assert_eq!(events.last().map(|e| e.0), Some("lt-state")); +} +``` + +`RecordingSink` is `#[cfg(test)]` and `pub`, so it is reachable from core's own tests. + +Run: `cargo test -p ryotunes-core emit_state_reaches_the_sink` +Expected: PASS. + +- [ ] **Step 4: Wire the host** + +In `src-tauri/src/lib.rs` setup: + +```rust +let paths = ryotunes_core::host::Paths { + data_dir: data_dir.clone(), + cache_dir: cache_root.clone(), +}; +let sink: Arc = Arc::new(host_sink::TauriSink(handle.clone())); +let (media_tx, mut media_rx) = tokio::sync::mpsc::unbounded_channel(); +let media = media::spawn(sink.clone(), media_tx); +let (lt, lt_sync_rx) = listentogether::LtSession::new(sink.clone(), lt_url); +``` + +and after `app_state` exists: + +```rust +{ + let st = app_state.clone(); + tauri::async_runtime::spawn(async move { + while let Some(ev) = media_rx.recv().await { + handle_media_event(st.clone(), ev).await; + } + }); +} +``` + +`handle_media_event` is the body of the old `media::handle_event`, moved into `lib.rs` unchanged apart from taking `Arc` instead of looking it up through `app.state()`. + +`ryoku_theme.rs` stays in the host for now (it is Linux desktop glue that emits `ryoku-theme-changed`): change `spawn_watcher(app: tauri::AppHandle)` to `spawn_watcher(sink: Arc)` and `app.emit(...)` to `sink.emit(...)` so it is ready to move in phase 2. + +- [ ] **Step 5: Build, test, smoke** + +Run: `cargo check --workspace --locked && cargo test --workspace --locked` +Expected: green. + +Run: `cargo tauri build --no-bundle && RUST_LOG=info ./target/release/ryotunes`, play a track, then `playerctl -p ryotunes pause` and `playerctl -p ryotunes play`. +Expected: the UI reflects both changes within a second (the `playback-state` event went through `TauriSink`); `busctl --user list | grep MediaPlayer2.ryotunes` shows the MPRIS name; Settings shows the Last.fm status card unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add -A crates/core src-tauri +git commit -m "core: move media, lastfm, local and listen together behind EventSink" +``` + +--- + +### Task 5: Move `orchestrator.rs`, `state.rs` and the session logic + +**Files:** +- Move: `src-tauri/src/orchestrator.rs`, `src-tauri/src/state.rs` to `crates/core/src/` +- Split: `src-tauri/src/session.rs` into `crates/core/src/session.rs` (cookie/account bookkeeping, `allowed_login_navigation` and its tests) and `src-tauri/src/login_webview.rs` (the visible Google login window, implementing `LoginFlow`) +- Modify: `src-tauri/src/lib.rs`, `src-tauri/src/commands.rs` + +**Interfaces:** +- Consumes: everything above. +- Produces: `ryotunes_core::state::AppState::new(it, clients, player, db, sink: Arc, login: Arc, paths: Paths, orchestrator, lt, media, discord, lastfm)`; `AppState::sign_in(self: &Arc)` which awaits `self.login.sign_in()` and then runs today's cookie-application code; `AppState::emit(&self, event, payload)` replacing every `self.app.emit`. + +- [ ] **Step 1: Move and let the compiler list the edits** + +```bash +git mv src-tauri/src/orchestrator.rs crates/core/src/orchestrator.rs +git mv src-tauri/src/state.rs crates/core/src/state.rs +git mv src-tauri/src/session.rs crates/core/src/session.rs +``` + +Add `pub mod orchestrator; pub mod state; pub mod session;` to core's `lib.rs`. Run `cargo check -p ryotunes-core`. +Expected: errors only at `state.rs:15,54,328` (`AppHandle`), the ten `self.app.emit(...)` sites, the two `tauri::async_runtime::spawn` sites (`state.rs:820,1314`), and `session.rs:13-15` plus `open_login`. + +- [ ] **Step 2: Replace `app` with `sink` + `login` in `AppState`** + +```rust +use std::sync::Arc; +use crate::host::{EventSink, LoginFlow, Paths}; + +pub struct AppState { + // ... + pub sink: Arc, + pub login: Arc, + pub paths: Paths, + // ... +} + +impl AppState { + pub fn emit(&self, event: &'static str, payload: T) { + match serde_json::to_value(payload) { + Ok(v) => self.sink.emit(event, v), + Err(e) => tracing::warn!(event, error = %e, "event payload not serializable"), + } + } +} +``` + +Then every `let _ = self.app.emit("x", y);` becomes `self.emit("x", y);` (ten sites), `tauri::async_runtime::spawn` becomes `tokio::spawn`, and `AppState::new` takes `sink`, `login`, `paths` in place of `app`. + +- [ ] **Step 3: Split the session** + +`crates/core/src/session.rs` keeps `allowed_login_navigation`, its tests, `LOGIN_URL`, the cookie-to-`Session` application code, and gains: + +```rust +impl AppState { + /// Runs the host's interactive sign-in and applies the result exactly as `open_login` did. + pub async fn sign_in(self: &Arc) { + match self.login.sign_in().await { + Ok(result) => self.apply_login(result).await, + Err(crate::host::LoginError::Cancelled) => {} + Err(e) => self.emit("login-error", e.to_string()), + } + } +} +``` + +where `apply_login(result: LoginResult)` is the tail of today's `open_login` (cookie harvest done, session written, `auth-changed`/`login-done`/`account-selection-required` emitted). + +`src-tauri/src/login_webview.rs` keeps the `WebviewWindowBuilder` login window, the `on_navigation` allow-list (calling `ryotunes_core::session::allowed_login_navigation`), the `PageLoadEvent` cookie harvest, and implements: + +```rust +pub struct TauriLogin { pub app: AppHandle } + +#[async_trait::async_trait] +impl LoginFlow for TauriLogin { + async fn sign_in(&self) -> Result { + // today's open_login body up to the point where cookies are in hand, + // returning them through a oneshot instead of touching AppState + } +} +``` + +`commands::login_webview` becomes `state.sign_in().await`. + +- [ ] **Step 4: Build, test, smoke the login and playback** + +Run: `cargo check --workspace --locked && cargo test --workspace --locked && cargo fmt --all -- --check` +Expected: green; `session::tests` (the allow-list cases at old `session.rs:168-180`) run from core. + +Run the app, sign out, sign in again through the account menu. +Expected: the Google window opens, closes on completion, `auth-changed` reaches the UI (avatar appears), a fresh track resolves with `client="WEB_REMIX"`. + +- [ ] **Step 5: Commit** + +```bash +git add -A crates/core src-tauri +git commit -m "core: move state, orchestrator and session; host implements LoginFlow" +``` + +--- + +### Task 6: Shrink the host to forwarders and run the release gates + +**Files:** +- Modify: `src-tauri/src/commands.rs` (every handler forwards into `ryotunes_core`), `src-tauri/src/lib.rs` (setup builds `Paths`, the three trait objects, then `AppState::new`), `src-tauri/Cargo.toml` (drop dependencies now only used by core: `rusqlite`, `base64`, `regex`, `urlencoding`, `reqwest`, `souvlaki`, `tokio-tungstenite`, `rustls`, `discord-rich-presence`, `md-5`, `lofty`; keep `tauri*`, `webkit2gtk`, `ksni`, `libc`, `serde*`, `tokio`, `tracing*`, `async-trait`, `ryotunes-core`) +- Modify: `docs/ARCHITECTURE.md` (add the crate table from the spec's 4.1), `scripts/check-rust-structure.py` if it asserts the old module list +- Test: `scripts/release-check.sh` + +**Interfaces:** +- Consumes: everything above. +- Produces: the host as it will stay until phase 4: `commands.rs` contains no logic beyond argument shaping. + +- [ ] **Step 1: Make every command a forwarder** + +The pattern, applied to all 94 handlers (the compiler enforces completeness: any `crate::` path left in `commands.rs` fails to resolve): + +```rust +#[tauri::command] +pub async fn search(state: St<'_>, query: String) -> Result, String> { + state.search(&query).await.map_err(|e| e.to_string()) +} +``` + +where the body that used to live in the command becomes `AppState::search` in core when it touched state, or stays a plain call when it already was one line. Handlers that need Tauri (`open_mini`, `close_mini`, `login_webview`, `open_external`, dialogs in `add_local_folder`/`export_playlist_file`/`import_playlist_file`) keep their Tauri calls and forward the data part. + +- [ ] **Step 2: Run the structure and release checks** + +Run: `python3 scripts/check-rust-structure.py && python3 scripts/check-source-shapes.py && scripts/release-check.sh` +Expected: green. If `check-rust-structure.py` enumerates `src-tauri/src` modules, update its list to the host's remaining files: `lib.rs main.rs commands.rs webview.rs host_sink.rs login_webview.rs local_scope.rs main_window.rs mini.rs tray.rs taskbar.rs ryoku_theme.rs`. + +- [ ] **Step 3: Full gates and dependency audit** + +Run: `cargo fmt --all -- --check && cargo test --workspace --locked && cargo check --workspace --locked && cargo tree -p ryotunes-core -i tauri` +Expected: green, and the last command errors with `did not match any packages`. + +Run: `cargo tauri build --no-bundle && ls -la target/release/ryotunes` +Expected: builds; binary size within 5% of the previous release build. + +- [ ] **Step 4: Measure that nothing regressed** + +With the built binary: `RUST_LOG=info ./target/release/ryotunes`, play, scroll Home for 10 s, open lyrics, close the window while playing, reopen from the tray. Record with the baseline instruments (`top -b -d 5 -n 3 -p `, `/proc//smaps_rollup`). +Expected: the same numbers as the 2026-09-04 baseline within noise (host ~3% + web ~2% playing; ~585 MB PSS); hibernation still destroys the main webview (`ps` shows the main `WebKitWebProcess` gone while playback continues). + +- [ ] **Step 5: Commit** + +```bash +git add -A src-tauri crates/core docs/ARCHITECTURE.md scripts +git commit -m "host: forward every command into ryotunes-core" +``` + +--- + +## Self-review + +- Spec coverage (phase 1 only): 4.1 crate table (Tasks 1-6), 4.2 the three traits (Task 1, used in 3-5), `Paths` (Tasks 1, 4), `tokio::spawn` replacement (Tasks 3-5), behaviour parity gates (Global Constraints, Task 6 Step 4). Sections 4.3-4.4 (protocol, lifecycle), 5 (client), 6 daemon-side `JsBridge`, 7 and 8.2-8.4 are later phases by design. +- Placeholders: none; every code step shows the code or the exact substitution. +- Type consistency: `EventSink::emit(&self, &'static str, Value)` everywhere; `JsSession::clone_session` is defined in Task 1 and used in Task 3; `Paths::covers_dir` defined in Task 1 and used in Task 4; `AppState::emit` defined in Task 5 and used by the substitutions there. diff --git a/docs/superpowers/specs/2026-09-05-native-client-design.md b/docs/superpowers/specs/2026-09-05-native-client-design.md new file mode 100644 index 0000000..fd817eb --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-native-client-design.md @@ -0,0 +1,341 @@ +# Ryotunes native client: design + +Status: draft for review. Date: 2026-09-05. + +## 1. Why + +Ryotunes 2.4.1 was measured on the Ryoku dev laptop (Ryzen 9 7940HS, Radeon +780M iGPU driving the 2560x1600@165 panel, RTX 4060 dGPU, Hyprland 0.56.2, +WebKitGTK 2.52.6) before any change: + +| State | CPU | Memory | +|---|---|---| +| Window open, idle | ~0% | host 187 MB + UI web process 367 MB + network process 30 MB = 585 MB PSS (880 MB RSS) | +| Playing, Home visible | host 3% + web process 2% | as above | +| Scrolling Home | web process 60% + host 16-19% of one core | UI web process VRAM 198 -> 446 MiB after a few minutes of browsing | +| First track resolved | cipher and PoToken hidden webviews appear | +88 MB and +75 MB PSS (two extra `WebKitWebProcess`), resident while media is loaded, torn down after 300 s idle | + +The Rust side is already tuned (`src-tauri/src/lib.rs` `tune_webview`, position +throttling, queue fingerprinting, `malloc_trim`, `vid=no` mpv, hibernation that +really destroys the main webview). What remains is structural: + +1. WebKitGTK is the floor: 3 processes, ~450-550 MB PSS for a large SPA, and + layout+paint of a non-virtualized DOM at up to 165 Hz while scrolling. +2. The GTK host is a second renderer: it composites WebKit's DMA-BUF into a + GTK3 window (the 12% host main-thread share while scrolling). +3. YouTube's signature/n-transform and PoToken need a real browser engine + today (see section 6), so two hidden `WebKitWebProcess`es are the price of + WEB_REMIX playback, not of Tauri. + +The backend is the good part: `crates/innertube`, `crates/player` (libmpv), +`crates/listen-protocol`, and in `src-tauri/src` the `state.rs`, +`orchestrator.rs`, `db.rs`, `lyrics.rs`, `local.rs`, `discord.rs`, `lastfm.rs`, +`media.rs`, `radio.rs`, `listentogether/`, `cipher/`, `potoken/` modules +(~21k lines). Tauri coupling is thin and concentrated: `commands.rs` (94 +`#[tauri::command]` handlers), `lib.rs`, `tray.rs`, `main_window.rs`, +`webview.rs`, `mini.rs`, `session.rs` (login webview), and `AppHandle::emit` +calls in `state.rs` (10), `media.rs`, `local.rs`, `lastfm.rs`, +`listentogether/`, `ryoku_theme.rs`. + +The desktop itself was the larger heat source (Hyprland 75% + `qs` 26% of a +core with Ryotunes quit); that is fixed separately in ryoku-arch +(`heat/idle-drift`) and the GPU mode was switched to hybrid. This document is +about making Ryotunes itself cheap. + +## 2. Goals + +- Same product, same look: Home, Search, Library, Playlist, Album, Artist, + Radio, Now Playing, Queue, Lyrics, Settings, the mini player, MPRIS, tray, + media keys, Last.fm, Discord, Listen Together, local library, Follow System + theming. `docs/DESIGN.md` stays the design contract. +- Steady state on the dev laptop: client <= 120 MB PSS and ~0% CPU idle, + daemon <= 100 MB PSS playing (plus the hidden JS helpers only while media is + loaded), no continuous frame production, scrolling a long list in single + digits of a core. +- Playback survives the UI: closing the window is `exit(0)` of the client; + the daemon keeps playing. No destroy/recreate dance, no failsafe timers. +- The Ryoku shell talks to the daemon directly (push events over the same + socket) instead of polling MPRIS every 500 ms. +- Keep Linux-first. Nothing in the design forbids other platforms, but no + task is spent on them. + +## 3. Non-goals + +- Reimplementing the YouTube cipher/PoToken outside a browser engine (section 6 + explains why that is a research project, not a task). +- Video playback, Spotify, or any new provider. +- A new theme system: the client consumes Ryoku's palette singletons. + +## 4. Architecture + +```mermaid +flowchart LR + SHELL["Ryoku shell (qs)\nnow-playing widget"] -- "JSON lines over unix socket\n(push events)" --> D + UI["ryotunes client\nQML (Quickshell runtime)"] -- "JSON lines over unix socket" --> D["ryotunesd\nRust daemon"] + CLI["ryotunes-cli --json"] --> D + D --> CORE["crates/core\nstate · orchestrator · db · lyrics\nlocal · discord · lastfm · listen together"] + CORE --> MPV["crates/player (libmpv)"] + D --> MPRIS["MPRIS · tray · media keys"] + D --> JS["hidden WebKitGTK views\ncipher · PoToken · Google login\n(webkit2gtk-rs, on demand)"] +``` + +Three processes at most: the daemon, the client, and WebKit helpers the daemon +spawns on demand. The daemon is the only writer of playback state, the +database, and integrations. The client is a view: it holds no playback truth +and can be killed at any time. + +### 4.1 Crates and binaries + +| Path | Role | Origin | +|---|---|---| +| `crates/innertube`, `crates/player`, `crates/listen-protocol`, `crates/sync-server` | unchanged | existing | +| `crates/core` | `AppState`, orchestrator, db, lyrics, local, discord, lastfm, media (MPRIS), radio, listentogether, cipher, potoken, session (login), settings. No Tauri. Talks to the outside through three traits (4.2) | moved from `src-tauri/src` | +| `crates/protocol` | request/response/event types, `serde` only, shared by daemon, CLI, tests | new | +| `crates/ryotunesd` | binary: socket server, single-instance lock, systemd-friendly lifecycle, GTK thread hosting the hidden WebKitGTK views, tray | new; absorbs `tray.rs`, `webview.rs`, the login part of `session.rs` | +| `crates/ryotunes-cli` | `ryotunes-cli [json]` and `ryotunes-cli events`; the shell's and scripts' entry point | new | +| `client/` | the QML client run by Quickshell: `qs -c ryotunes` (shipped as `/usr/share/ryotunes/client`), plus `client/mini/` for the mini player window | new, replaces `ui/` | +| `src-tauri/` | deleted at the end of phase 4 | removed | + +### 4.2 The three traits `crates/core` talks through + +`core` must not know who renders. Everything Tauri provided is one of: + +```rust +/// Server-push channel to whoever is listening (the daemon fans out to sockets). +pub trait EventSink: Send + Sync + 'static { + fn emit(&self, event: &'static str, payload: serde_json::Value); +} + +/// A JavaScript environment able to run YouTube's player.js and BotGuard. +/// Implemented by a hidden WebKitGTK view in the daemon (section 6). +#[async_trait::async_trait] +pub trait JsBridge: Send + Sync { + async fn create(&self, label: &str, harness_html: &str, init_script: &str) -> Result, JsError>; +} +#[async_trait::async_trait] +pub trait JsSession: Send + Sync { + fn eval(&self, js: &str) -> Result<(), JsError>; + async fn eval_json(&self, js: String, timeout: Duration) -> Result; + async fn call_async(&self, expr: &str, timeout: Duration) -> Result; + fn exists(&self) -> bool; + fn destroy(&self); +} + +/// The interactive Google sign-in. Yields cookies + the chosen authuser index. +#[async_trait::async_trait] +pub trait LoginFlow: Send + Sync { + async fn sign_in(&self) -> Result; +} +``` + +`webview.rs` already has exactly the `JsSession` shape (`eval`, `eval_json`, +`call_async`, `exists`, `destroy`); `cipher/` and `potoken/` keep calling the +same methods. `tauri::async_runtime::spawn` becomes `tokio::spawn`. + +### 4.3 Socket protocol + +- Path: `$XDG_RUNTIME_DIR/ryotunes/ryotunesd.sock`, mode 0700 directory, socket + created under `umask 077` (the ryoku daemon's rule, `ryoku/shell/ipc/daemon.go:178-185`). +- Single instance: `ryotunesd.sock.lock` held with `flock` for the process + lifetime; a second start connects to the incumbent and asks it to `show` + (today's `tauri-plugin-single-instance` behaviour). +- Framing: newline-delimited JSON, UTF-8, one object per line, both ways. +- Request: `{"id": 12, "method": "play", "params": {"videoId": "..."}}`. +- Response: `{"id": 12, "result": {...}}` or `{"id": 12, "error": {"code": "upload_unavailable", "message": "..."}}`. +- Event: `{"event": "position", "data": {"position": 12.3}}`. A client opts in + with `{"id": 1, "method": "subscribe", "params": {"events": ["*"]}}`; the + daemon replies with the full current state (`get_playback`, `get_queue`, + `get_settings`, `auth`) so a fresh client resynchronises in one round trip, + which is what `frontend_ready` does today. +- Methods: the 94 handlers in `src-tauri/src/lib.rs` `generate_handler!`, same + names, same parameter and result JSON as the `#[tauri::command]` functions in + `commands.rs` (the Svelte `api.ts` is the reference for shapes), plus four + control methods: `hello`, `subscribe`, `show` (raise the client window), + `quit`. Only `frontend_ready`, `open_mini`, `close_mini`, `login_webview` + change meaning: `frontend_ready` becomes `subscribe`; `open_mini`/`close_mini` + are client-side; `login_webview` becomes `sign_in` (the daemon opens the + login window, section 6). +- Events: `playback-state`, `position`, `duration`, `volume`, `now-playing`, + `queue-changed`, `queue-index`, `stop-after-current`, `playback-error`, + `playback-notice`, `rating`, `cover-error`, `auth-changed`, `login-done`, + `login-error`, `account-selection-required`, `local-changed`, `lt-state`, + `lt-notice`, `ryoku-theme-changed` (kept for non-QML consumers such as the + shell's own widgets), same payloads as today. +- Cadence: `position` stays at 4 Hz while any client is subscribed to it and + 1 Hz otherwise (today's `PositionThrottle`, keyed on subscriptions instead of + window visibility). +- Versioning: `{"method": "hello"}` returns `{"protocol": 1, "daemon": "2.5.0"}`; + a client refuses to run against an older major. + +### 4.4 Lifecycle + +- `ryotunesd` is a systemd user unit (`ryotunesd.service`, socket-activated + via `ryotunesd.socket`), started on first client connection. Today's + behaviours map 1:1: tray-only with nothing playing exits after the bounded + 5-minute idle (`main_window.rs` `IDLE_EXIT_GRACE`); explicit Quit stops + playback, unregisters MPRIS and exits; the client closing while playing is + "hibernation" for free. +- The client is `qs -c ryotunes` (a Quickshell config, section 5). The + `ryotunes` command launches it; a second launch raises the window via the + daemon (`show`). Hyprland keeps matching the exact title `^(Ryotunes)$` for + the float-and-centre rule; the mini window keeps `Ryotunes Mini`. + +## 5. The client + +### 5.1 Runtime: Quickshell, pure QML + +The client is a Quickshell configuration, like `ryoku/hub` (`qs -c hub` with a +Go backend) and the shell. Reasons over a C++/Qt host or cxx-qt: + +- Zero C++ and zero FFI: Quickshell already gives QML unix sockets + (`Quickshell.Io.Socket` with `SplitParser` for line framing), processes, + file views, `FloatingWindow` toplevels, and hot reload. +- The same Qt libraries are resident for the shell (measured reference: a + Quickshell process with a full-screen surface idles at 83 MB PSS and 0% CPU + on this laptop). +- Follow System is literally `import Ryoku.Ui.Singletons` and `Theme.*`, the + palette the shell uses; no inotify bridge, no CSS variable diffing. +- Ryoku's `I18n`, `Motion`, `Perf` (reduce-motion, power tiers) singletons + apply to the client for free. + +Fallback for non-Ryoku hosts is out of scope; the app is Ryoku-native by +charter (`README.md` "Built for Ryoku, not merely compatible with it"). + +### 5.2 Structure + +``` +client/ + shell.qml # Quickshell root: main FloatingWindow + mini window + daemon connection + Daemon.qml # Singleton: Socket, request ids, promise-style call(), event signals, reconnection + Playback.qml # Singleton: mirrors playback/queue state from events (the client's only state) + pages/ Home.qml Search.qml Library.qml Playlist.qml Album.qml Artist.qml Radio.qml Settings.qml + surfaces/ NowPlaying.qml Queue.qml Lyrics.qml PlayerBar.qml Sidebar.qml TopBar.qml + mini/ Mini.qml + components/ Artwork.qml Shelf.qml MediaCard.qml TrackRow.qml TrackList.qml (ListView with reuseItems) Chip.qml Hairline.qml + style/ Tokens.qml (spacing, radii, type scale from ui/src/lib/ryotunes.css) Fonts.qml +``` + +Rules carried over from `docs/DESIGN.md`, restated for QML: + +- Every list is a `ListView` (or `GridView`) with `reuseItems: true` and a + bounded `cacheBuffer`; Home is one `ListView` of shelves whose delegates are + horizontal `ListView`s. This is the stable-DOM promise done properly: no + physical mount/unmount jumps because delegates keep their size. +- No `Timer`/`FrameAnimation` while idle. Lyrics word timing uses one + `Timer` at 67 ms only while the lyrics surface is visible and playing + (the current `LyricsView.svelte` rule), stopped otherwise. +- Artwork through `Image { asynchronous: true; cache: true; sourceSize: ... }` + with the same thumbnail sizing rules as `thumb()` in `ui/src/lib/api.ts`. +- Blur: one `MultiEffect`/`FastBlur` source per surface, never per card, and + disabled under `Perf.blurDisabled`. +- Motion follows `Perf.reduceMotion` and `Motion` durations. + +### 5.3 Mini player + +A second `FloatingWindow` in the same config (title `Ryotunes Mini`), toggled +from the player bar; it subscribes to the same `Playback` singleton. The main +window can be hidden while the mini stays. + +## 6. JavaScript challenges (cipher, PoToken) and sign-in + +Facts established in code (`src-tauri/src/cipher/config.rs:1-18`, +`cipher/extractor.rs:24-38`, `cipher/mod.rs:29-48`): + +- The 2025+ VM-dispatch `player.js` has no statically extractable sig/n + function. rustypipe 0.11's regex + QuickJS path (`rustypipe/src/deobfuscate.rs`) + and yt-dlp's regexes are dead on the players YouTube serves; Ryotunes, + like Metrolist, runs YouTube's own 2.9 MB `player.js` in a real browser and + evaluates a registry-supplied call template (`Ii(25,558,INPUT)`) and the + player's URL class (`new g.(url, true)`) inside the IIFE closure. + `player.js` initialises against `window`, `document`, `navigator`, + `location`, timers: a bare QuickJS context cannot run it. +- PoToken is BotGuard: a VM in JavaScript that fingerprints the environment. + Every non-browser implementation (rustypipe-botguard, bgutil) ships a + stripped Deno plus a jsdom-class DOM to satisfy it. + +Decision for the daemon: keep the hidden-browser mechanism, without Tauri. +`ryotunesd` owns one GTK main loop thread and creates `WebKitWebView`s +through `webkit2gtk-rs` on demand, with the same harness HTML and injection +(`po_token.html`, `cipher/extractor.rs build_injection`), implementing +`JsBridge`/`JsSession`. Cost is unchanged (88 + 75 MB PSS while media is +loaded, released after 300 s idle) and behaviour is identical, which is the +point: the cutover changes the renderer, not the extraction stack. + +Sign-in uses the same thread: `LoginFlow` opens a visible `WebKitWebView` in +a GTK window on `accounts.google.com`, with today's navigation allow-list +(`session.rs allowed_login_navigation`) and cookie harvesting, and closes it +on completion. Cookies stay in the daemon; the client never sees them. + +Follow-ups, deliberately outside this design, each a bounded task later: + +- A "lightweight streaming" setting that puts the PoToken-free clients first + (`VISIONOS -> ANDROID_VR -> IOS`, `crates/innertube/src/clients.rs`), so no + hidden browser is ever created, at the cost of WEB_REMIX-only features. +- A Deno-based solver (yt-dlp's `ejs` for the cipher, `rustypipe-botguard` + for PoToken) as an on-demand subprocess, zero resident memory, once Ryoku + packages Deno. + +## 7. Shell integration + +The shell's now-playing widget polls `player.position` every 500 ms +(`ryoku/shell/quickshell/shell/modules/bar/barstyles/qsbar/panels/MprisPanel.qml:46-60`) +and drives cava from PipeWire. With the daemon, `services/Media.qml` can +prefer a `ryotunes` source that connects to the socket and receives +`position`/`now-playing` pushes; MPRIS remains for every other player. This is +a ryoku-arch change and lands after the daemon ships; nothing in the daemon +is shell-specific beyond the socket location. + +## 8. Migration + +Each phase leaves a working, shippable product. + +1. **Core extraction (this plan's sub-project).** Create `crates/core` and + `crates/protocol`; move the modules; introduce the three traits; the Tauri + host implements them (`EventSink` = `AppHandle::emit`, `JsBridge` = + `webview.rs`, `LoginFlow` = `session.rs`). `commands.rs` shrinks to + one-line forwarders into `core`. Behaviour identical; the release gates in + `scripts/release-check.sh` and `cargo test --workspace` stay green. +2. **Daemon.** `ryotunesd` with the socket protocol, `ryotunes-cli`, + webkit2gtk-rs `JsBridge` and `LoginFlow`, MPRIS and tray moved over, + systemd units, packaging as a second binary in the same package. The + Tauri app keeps working unchanged (it still embeds `core`), so nothing + user-visible moves yet. +3. **QML client.** Built against the daemon page by page in `client/`, in + the order Home, player bar, queue, Now Playing, search, library, playlist, + album, artist, lyrics, radio, settings, mini. Ships behind a + `ryotunes --qml` flag until parity, then becomes the default. +4. **Cutover.** Delete `src-tauri/`, `ui/`, the WebKit tuning, the + hibernation lifecycle, `taskbar.rs`, macOS/Windows configs; packaging + becomes daemon + client config + desktop entry; `docs/ARCHITECTURE.md` + rewritten; the Hyprland rule unchanged. + +## 9. Verification + +Same instruments as the baseline, recorded in the Ryoku vault journal +(`~/.local/share/ryoku/rashin/journal/2026-09-04.md`): + +- `top -b -d 5 -n 3 -p ` for CPU; `/proc//smaps_rollup` `Pss:` for + memory; `nvidia-smi` for VRAM and dGPU handles; the per-thread sampler for + render threads; Hyprland's `debug:overlay` for frames per second. +- Scenarios: idle window open; playing on Home; scrolling Home for 10 s; + lyrics open while playing; client closed while playing; 30-minute soak. +- Pass criteria: section 2 numbers; no `/dev/nvidia*` handles in client or + daemon in hybrid GPU mode; zero compositor frames from the client while idle + (overlay FPS unchanged with the client mapped). + +## 10. Risks + +- Quickshell as an app runtime: `FloatingWindow` is a first-class type but + most Quickshell users build layers; keyboard focus, window title, and + `xdg-decoration` handling must be verified on Hyprland in the first client + task. Fallback is a 60-line C++ `main.cpp` hosting the same QML; the QML + does not change. +- webkit2gtk-rs inside a tokio daemon: GTK wants its own thread with a + `glib::MainLoop`; every WebKit call marshals through `glib::idle_add` and a + oneshot channel, the pattern `webview.rs` already uses via + `run_on_main_thread`. +- YouTube rotates players and challenge formats; the design keeps the exact + current mechanism so the daemon inherits Ryotunes' registry self-heal + unchanged. +- Two long-lived processes to package and update together: the daemon and + the client carry the same version and `hello` refuses a mismatch. From cef4a8781c07b68e657b3a7963bf7675fcd64019 Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 22:43:48 -0400 Subject: [PATCH 006/163] ui: commit a seek drag that WebKit never released Both seek sliders hold the dragged value locally and commit it on the input's change event. Release the thumb outside the window (the bar sits on the bottom edge) and WebKitGTK delivers neither change nor pointerup; the local value then shadowed every later position tick and the timeline sat frozen for the rest of the session while playback carried on. Reproduced by dragging past the window edge: MPRIS kept ticking, the bar did not. A shared guard now commits the pending drag on pointerup, pointercancel, lostpointercapture, window blur, or the first pointer movement with no button held, and a track change ends a drag outright. --- ui/src/lib/components/MiniPlayer.svelte | 13 +++++++-- ui/src/lib/components/PlayerBar.svelte | 18 ++++++++++--- ui/src/lib/seek-drag.ts | 36 +++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 ui/src/lib/seek-drag.ts diff --git a/ui/src/lib/components/MiniPlayer.svelte b/ui/src/lib/components/MiniPlayer.svelte index 4adf238..1fd236e 100644 --- a/ui/src/lib/components/MiniPlayer.svelte +++ b/ui/src/lib/components/MiniPlayer.svelte @@ -17,6 +17,7 @@ MusicNote01Icon } from '@hugeicons/core-free-icons'; import * as api from '$lib/api'; + import { seekReleaseGuard } from '$lib/seek-drag'; import { playback, setPlaybackPosition, @@ -58,20 +59,26 @@ }); let seekDrag = $state(null); + let seekInput: HTMLInputElement | undefined = $state(); const shownPosition = $derived(seekDrag ?? playback.position); const progress = $derived(playback.duration > 0 ? Math.min(100, Math.max(0, shownPosition / playback.duration * 100)) : 0); + // A track change ends any drag: the value belonged to the previous track. + $effect(() => { + void playback.now?.videoId; + seekDrag = null; + }); const fmt = (secs: number) => { if (!Number.isFinite(secs) || secs <= 0) return '0:00'; const total = Math.floor(secs); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; }; function onSeekInput(e: Event) { seekDrag = Number((e.currentTarget as HTMLInputElement).value); } - function onSeekCommit(e: Event) { - const value = Number((e.currentTarget as HTMLInputElement).value); + function commitSeek(value: number) { setPlaybackPosition(value); seekDrag = null; void api.seek(value); } + function onSeekCommit(e: Event) { commitSeek(Number((e.currentTarget as HTMLInputElement).value)); } let volDragging = $state(false); let justLiked = $state(false); @@ -133,8 +140,10 @@ min="0" max={playback.duration || 0} value={shownPosition} + bind:this={seekInput} oninput={onSeekInput} onchange={onSeekCommit} + {@attach seekReleaseGuard(() => seekDrag !== null, () => commitSeek(Number(seekInput?.value ?? seekDrag)))} aria-label="Seek" /> {fmt(playback.duration)} diff --git a/ui/src/lib/components/PlayerBar.svelte b/ui/src/lib/components/PlayerBar.svelte index 73d5a40..54302a8 100644 --- a/ui/src/lib/components/PlayerBar.svelte +++ b/ui/src/lib/components/PlayerBar.svelte @@ -24,6 +24,7 @@ import { fade } from 'svelte/transition'; import { Button } from '$lib/components/ui/button'; import * as api from '$lib/api'; + import { seekReleaseGuard } from '$lib/seek-drag'; import { np, playback, @@ -98,21 +99,30 @@ }); // Seek: while dragging, hold a local value so incoming mpv position ticks can't yank the thumb - // back under the pointer; only invoke the (expensive) seek on release. + // back under the pointer; only invoke the (expensive) seek on release. `seekReleaseGuard` + // commits the drag for a release WebKit never delivers (see $lib/seek-drag). let seekDrag = $state(null); + let seekInput: HTMLInputElement | undefined = $state(); const shownPosition = $derived(seekDrag ?? playback.position); const seekPct = $derived(playback.duration > 0 ? Math.min(100, Math.max(0, (shownPosition / playback.duration) * 100)) : 0); + // A track change ends any drag: the value belonged to the previous track. + $effect(() => { + void playback.now?.videoId; + seekDrag = null; + }); const SEEK_WAVE = 'M 0 5 Q 1.250 1 2.500 5 Q 3.750 9 5.000 5 Q 6.250 1 7.500 5 Q 8.750 9 10.000 5 Q 11.250 1 12.500 5 Q 13.750 9 15.000 5 Q 16.250 1 17.500 5 Q 18.750 9 20.000 5 Q 21.250 1 22.500 5 Q 23.750 9 25.000 5 Q 26.250 1 27.500 5 Q 28.750 9 30.000 5 Q 31.250 1 32.500 5 Q 33.750 9 35.000 5 Q 36.250 1 37.500 5 Q 38.750 9 40.000 5 Q 41.250 1 42.500 5 Q 43.750 9 45.000 5 Q 46.250 1 47.500 5 Q 48.750 9 50.000 5 Q 51.250 1 52.500 5 Q 53.750 9 55.000 5 Q 56.250 1 57.500 5 Q 58.750 9 60.000 5 Q 61.250 1 62.500 5 Q 63.750 9 65.000 5 Q 66.250 1 67.500 5 Q 68.750 9 70.000 5 Q 71.250 1 72.500 5 Q 73.750 9 75.000 5 Q 76.250 1 77.500 5 Q 78.750 9 80.000 5 Q 81.250 1 82.500 5 Q 83.750 9 85.000 5 Q 86.250 1 87.500 5 Q 88.750 9 90.000 5 Q 91.250 1 92.500 5 Q 93.750 9 95.000 5 Q 96.250 1 97.500 5 Q 98.750 9 100.000 5'; function onSeekInput(e: Event) { seekDrag = Number((e.target as HTMLInputElement).value); } - function onSeekCommit(e: Event) { - const v = Number((e.target as HTMLInputElement).value); + function commitSeek(v: number) { setPlaybackPosition(v); seekDrag = null; api.seek(v); } + function onSeekCommit(e: Event) { + commitSeek(Number((e.target as HTMLInputElement).value)); + } const onVolume = (e: Event) => dragVolume(Number((e.target as HTMLInputElement).value)); const onVolumeCommit = (e: Event) => commitVolume(Number((e.target as HTMLInputElement).value)); @@ -306,8 +316,10 @@ min="0" max={playback.duration || 0} value={shownPosition} + bind:this={seekInput} oninput={onSeekInput} onchange={onSeekCommit} + {@attach seekReleaseGuard(() => seekDrag !== null, () => commitSeek(Number(seekInput?.value ?? seekDrag)))} aria-label="Seek" /> diff --git a/ui/src/lib/seek-drag.ts b/ui/src/lib/seek-drag.ts new file mode 100644 index 0000000..d51d0b5 --- /dev/null +++ b/ui/src/lib/seek-drag.ts @@ -0,0 +1,36 @@ +/** + * Seek-thumb release safety net for the range inputs in the player bar and the mini player. + * + * Both hold the dragged value locally (`seekDrag`) so mpv's position ticks cannot yank the thumb + * while the pointer is down, and commit on the input's `change` event. WebKitGTK only fires + * `change` for a release it actually receives: let go of the thumb outside the window (the bar + * sits on the window's bottom edge, so dragging past it is one flick away) and neither `change` + * nor `pointerup` arrives. The local value then shadows every later tick and the timeline + * freezes for the rest of the session while playback carries on. + * + * This attachment commits the pending drag on every signal that the button is no longer down: + * `pointerup`/`pointercancel`/`lostpointercapture` on the input, the window losing focus, and, + * for the release nobody delivered, the first pointer movement anywhere with no button held. + */ +export function seekReleaseGuard(pending: () => boolean, commit: () => void) { + return (input: HTMLInputElement) => { + const release = () => { + if (pending()) commit(); + }; + const onWindowMove = (e: PointerEvent) => { + if (e.buttons === 0) release(); + }; + input.addEventListener('pointerup', release); + input.addEventListener('pointercancel', release); + input.addEventListener('lostpointercapture', release); + window.addEventListener('pointermove', onWindowMove, { passive: true }); + window.addEventListener('blur', release); + return () => { + input.removeEventListener('pointerup', release); + input.removeEventListener('pointercancel', release); + input.removeEventListener('lostpointercapture', release); + window.removeEventListener('pointermove', onWindowMove); + window.removeEventListener('blur', release); + }; + }; +} From b21eda615f42f08f7d18357179d7042fc2b2b667 Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 22:43:48 -0400 Subject: [PATCH 007/163] ui: stop restyling the whole page on every scroll event Home toggled ryo-is-scrolling on
on each scroll event and again 110 ms after the last one, and the queue/lyrics wheel helper did the same on its scroller. Rules keyed on that class matched every and every promoted .ryo-art-wash layer, so each toggle was a full style recalc plus compositing-layer churn, twice per wheel notch. Scrolling is otherwise paint-bound in WebKitGTK (measured the same 60% of a core with and without the toggle), so the class and its rules go rather than get cheaper. --- ui/src/lib/ryoku-scroll.ts | 31 ------------------------------- ui/src/lib/ryotunes.css | 17 ----------------- ui/src/routes/+page.svelte | 17 +++++------------ 3 files changed, 5 insertions(+), 60 deletions(-) diff --git a/ui/src/lib/ryoku-scroll.ts b/ui/src/lib/ryoku-scroll.ts index bbbf168..bb1c79a 100644 --- a/ui/src/lib/ryoku-scroll.ts +++ b/ui/src/lib/ryoku-scroll.ts @@ -8,34 +8,14 @@ * * Only legacy/coarse LINE/PAGE wheel events get a short velocity tail. This is deliberately small: * the goal is to take the square edge off a mouse-wheel notch, not to replace the browser scroller. - * The attachment also marks a scroller while it is moving so expensive card-hover polish can step - * aside until the scroll settles. */ export function ryokuWheelScroll(el: HTMLElement) { let raf = 0; let velocity = 0; // px per nominal 60 Hz frame let lastFrame = 0; - let scrollEndTimer = 0; - let lastScrollAt = 0; const clamp = (v: number) => Math.max(0, Math.min(v, el.scrollHeight - el.clientHeight)); - function settleScrolling() { - const remaining = 110 - (performance.now() - lastScrollAt); - if (remaining > 1) { - scrollEndTimer = window.setTimeout(settleScrolling, remaining); - return; - } - scrollEndTimer = 0; - el.classList.remove('ryo-is-scrolling'); - } - - function markScrolling() { - lastScrollAt = performance.now(); - if (!el.classList.contains('ryo-is-scrolling')) el.classList.add('ryo-is-scrolling'); - if (!scrollEndTimer) scrollEndTimer = window.setTimeout(settleScrolling, 110); - } - function stopKinetic() { if (raf) cancelAnimationFrame(raf); raf = 0; @@ -52,7 +32,6 @@ export function ryokuWheelScroll(el: HTMLElement) { const before = el.scrollTop; const next = clamp(before + velocity * frameScale); el.scrollTop = next; - markScrolling(); // Around a 170–230 ms useful tail at 60 Hz, close to Ryoku's move/swap register. velocity *= Math.pow(0.80, frameScale); @@ -86,27 +65,17 @@ export function ryokuWheelScroll(el: HTMLElement) { // Add an impulse instead of chasing a target. Repeated notches therefore build velocity like // Flickable rather than restarting an ease-to-target curve on every event. velocity = Math.max(-80, Math.min(80, velocity + pixels * 0.22)); - markScrolling(); if (!raf) raf = requestAnimationFrame(kineticFrame); } - function onNativeScroll() { - // Covers touchpad, scrollbar drag, keyboard and programmatic scrolling too. - markScrolling(); - } - el.addEventListener('wheel', onWheel, { passive: false }); - el.addEventListener('scroll', onNativeScroll, { passive: true }); el.addEventListener('pointerdown', stopKinetic, { passive: true }); el.addEventListener('touchstart', stopKinetic, { passive: true }); window.addEventListener('keydown', stopKinetic, { passive: true }); return () => { stopKinetic(); - if (scrollEndTimer) window.clearTimeout(scrollEndTimer); - el.classList.remove('ryo-is-scrolling'); el.removeEventListener('wheel', onWheel); - el.removeEventListener('scroll', onNativeScroll); el.removeEventListener('pointerdown', stopKinetic); el.removeEventListener('touchstart', stopKinetic); window.removeEventListener('keydown', stopKinetic); diff --git a/ui/src/lib/ryotunes.css b/ui/src/lib/ryotunes.css index 3f53d59..5a68819 100644 --- a/ui/src/lib/ryotunes.css +++ b/ui/src/lib/ryotunes.css @@ -141,16 +141,6 @@ body::after { content: none !important; } } -.ryo-main.ryo-is-scrolling .group img, -.ryo-main.ryo-is-scrolling .group:hover img { - transition: none !important; - transform: none !important; -} -.ryo-main.ryo-is-scrolling .group button[aria-label="Play"] { - opacity: 0 !important; - transform: none !important; - transition: none !important; -} .content-in { @@ -1834,8 +1824,6 @@ html { .ryo-home-body .group:hover img, .ryo-page-scroll .group:hover img, .ryo-library-body .group:hover img { transform:scale(1.018) !important; } -.ryo-main.ryo-is-scrolling .group img, -.ryo-main.ryo-is-scrolling .group:hover img { transform:none !important; transition:none !important; } .ryo-home-body [class*="shadow"], .ryo-page-scroll [class*="shadow"], .ryo-library-body [class*="shadow"] { box-shadow:none !important; } @@ -2286,8 +2274,6 @@ html[data-ryoku-reduced-motion="true"] .ryo-lyrics-resolving-foot i::after { ani .ryo-home-body .group\/card, .ryo-home-body .group\/pick { box-shadow: none !important; } .ryo-home-body img { backface-visibility: hidden; } -.ryo-is-scrolling .ryo-familiar-art img, -.ryo-is-scrolling .ryo-search-inspector-art img { animation: none !important; } .ryo-familiar-surface { min-height: 314px; grid-template-columns: minmax(250px,.72fr) minmax(470px,1.28fr); } @@ -4610,9 +4596,6 @@ html[data-ryoku-reduced-motion="true"] .ryo-lyrics-resolving-foot i::after { ani /* Remove the only remaining expensive backdrop blur in regular playback UI. */ .ryo-lyrics-return { backdrop-filter:none !important; -webkit-backdrop-filter:none !important; } -/* During active scrolling, trade decorative polish for frame stability; restore immediately after. */ -.ryo-is-scrolling img { transition:none !important; } -.ryo-is-scrolling .ryo-art-wash { will-change:auto !important; } /* Hidden/background windows should not keep decorative transitions alive. */ @media (prefers-reduced-motion:reduce) { diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 8bbbf6c..b693d24 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -215,17 +215,14 @@ } } - // Home uses the layout's
as its scroll container. The observer only marks active - // scrolling and handles shallow-window resize correction. + // Home uses the layout's
as its scroll container. The observer only handles + // shallow-window resize correction. It used to also toggle `ryo-is-scrolling` on
on + // every scroll event and 110 ms after the last one; with rules keyed on that class reaching + // every and every promoted `.ryo-art-wash` layer, each toggle was a style recalc of the + // whole page plus compositing-layer churn, twice per wheel notch: the scroll lag itself. function watchScroll(node: HTMLElement) { const el = node.closest('main'); if (!el) return; - let settle: number | undefined; - const onScroll = () => { - el.classList.add('ryo-is-scrolling'); - if (settle) window.clearTimeout(settle); - settle = window.setTimeout(() => el.classList.remove('ryo-is-scrolling'), 110); - }; /* @@ -249,13 +246,9 @@ if (changed && el.scrollTop > 0 && el.scrollTop < 320) el.scrollTop = 0; }); - el.addEventListener('scroll', onScroll, { passive: true }); resize.observe(el); return () => { - el.removeEventListener('scroll', onScroll); resize.disconnect(); - if (settle) window.clearTimeout(settle); - el.classList.remove('ryo-is-scrolling'); }; } From 1d8689284b237e6052ab90cf739ebc6f027c53da Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 22:49:10 -0400 Subject: [PATCH 008/163] core: add the ryotunes-core crate with the host traits --- Cargo.lock | 30 ++++++++++ Cargo.toml | 3 + crates/core/Cargo.toml | 34 +++++++++++ crates/core/src/host.rs | 127 ++++++++++++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 3 + 5 files changed, 197 insertions(+) create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/host.rs create mode 100644 crates/core/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 71f6de7..213eab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3929,6 +3929,36 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "ryotunes-core" +version = "2.4.1" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "discord-rich-presence", + "futures-util", + "innertube", + "libc", + "listen-protocol", + "lofty", + "md-5", + "player", + "rand 0.8.7", + "regex", + "reqwest 0.12.28", + "rusqlite", + "rustls", + "serde", + "serde_json", + "souvlaki", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tracing", + "urlencoding", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 1e20f83..07c5c12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/player", "crates/listen-protocol", "crates/sync-server", + "crates/core", "src-tauri", ] @@ -23,6 +24,8 @@ thiserror = "2" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } rand = "0.8" listen-protocol = { path = "crates/listen-protocol" } +async-trait = "0.1" +ryotunes-core = { path = "crates/core" } # Release profile: thin LTO + one codegen unit for cross-crate optimization, symbols stripped # for size: the desktop package should stay lean without changing runtime behaviour. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..b30e01a --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "ryotunes-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Ryotunes playback core: state, orchestration, integrations. No UI." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util"] } +tracing = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +futures-util = { workspace = true } +rand = { workspace = true } +listen-protocol = { workspace = true } +innertube = { path = "../innertube" } +player = { path = "../player" } +rusqlite = { version = "0.32", features = ["bundled"] } +base64 = "0.22" +regex = "1" +urlencoding = "2" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "stream"] } +souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +discord-rich-presence = "1.1.0" +md-5 = "0.10" +lofty = "0.22.2" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" diff --git a/crates/core/src/host.rs b/crates/core/src/host.rs new file mode 100644 index 0000000..565d514 --- /dev/null +++ b/crates/core/src/host.rs @@ -0,0 +1,127 @@ +//! The seams between the core and whoever hosts it. + +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::Value; + +/// Server-push channel: the host fans an event out to every listening UI. +pub trait EventSink: Send + Sync + 'static { + fn emit(&self, event: &'static str, payload: Value); +} + +#[derive(Debug, thiserror::Error)] +pub enum JsError { + #[error("js session '{0}' does not exist")] + Gone(String), + #[error("js eval failed: {0}")] + Eval(String), + #[error("timed out after {0:?}")] + Timeout(Duration), + #[error("js environment reported an error: {0}")] + BadEnvironment(String), + #[error("js environment build failed: {0}")] + Build(String), +} + +/// A JavaScript environment able to run YouTube's player.js and BotGuard harnesses. +#[async_trait::async_trait] +pub trait JsBridge: Send + Sync + 'static { + async fn create( + &self, + label: &str, + harness_html: &str, + init_script: &str, + ) -> Result, JsError>; +} + +/// One live environment. Mirrors `src-tauri/src/webview.rs` `Bridge` one to one. +#[async_trait::async_trait] +pub trait JsSession: Send + Sync { + fn eval(&self, js: &str) -> Result<(), JsError>; + async fn eval_json(&self, js: String, timeout: Duration) -> Result; + async fn call_async(&self, expr: &str, timeout: Duration) -> Result; + fn exists(&self) -> bool; + fn destroy(&self); + /// A second handle to the same environment (the PoToken minter keeps one per session). + fn clone_session(&self) -> Box; +} + +#[derive(Debug, Clone)] +pub struct LoginResult { + /// `name=value` pairs for the `.youtube.com` domain, as the cookie jar hands them out. + pub cookies: Vec<(String, String)>, + /// Google account index the user picked (`authuser`). + pub authuser: u32, +} + +#[derive(Debug, thiserror::Error)] +pub enum LoginError { + #[error("sign-in was cancelled")] + Cancelled, + #[error("sign-in failed: {0}")] + Failed(String), +} + +/// The interactive Google sign-in, owned by the host because it needs a visible browser. +#[async_trait::async_trait] +pub trait LoginFlow: Send + Sync + 'static { + async fn sign_in(&self) -> Result; +} + +/// Where the core keeps its files. The host resolves them (Tauri's `app_data_dir`, or XDG). +#[derive(Debug, Clone)] +pub struct Paths { + pub data_dir: PathBuf, + pub cache_dir: PathBuf, +} + +impl Paths { + pub fn covers_dir(&self) -> PathBuf { + self.data_dir.join("covers") + } + pub fn db_path(&self) -> PathBuf { + self.data_dir.join("ryotunes.db") + } +} + +#[cfg(test)] +pub mod test_support { + use super::*; + use std::sync::Mutex; + + /// An `EventSink` that records what was emitted, for unit tests of the core. + #[derive(Default)] + pub struct RecordingSink { + pub events: Mutex>, + } + + impl EventSink for RecordingSink { + fn emit(&self, event: &'static str, payload: Value) { + self.events.lock().unwrap().push((event, payload)); + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::RecordingSink; + use super::*; + + #[test] + fn recording_sink_keeps_order() { + let sink = RecordingSink::default(); + sink.emit("playback-state", Value::String("playing".into())); + sink.emit("position", serde_json::json!({ "position": 1.5 })); + let events = sink.events.lock().unwrap(); + assert_eq!(events[0].0, "playback-state"); + assert_eq!(events[1].1["position"], 1.5); + } + + #[test] + fn paths_derive_children() { + let p = Paths { data_dir: "/d".into(), cache_dir: "/c".into() }; + assert_eq!(p.covers_dir(), PathBuf::from("/d/covers")); + assert_eq!(p.db_path(), PathBuf::from("/d/ryotunes.db")); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..221e4a2 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,3 @@ +//! Ryotunes playback core. Everything that is not a window lives here; the host (Tauri today, the +//! daemon tomorrow) supplies the three traits in [`host`]. +pub mod host; From 3c0dbda2e138fb665ddab5386c58b134b60def56 Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 22:50:30 -0400 Subject: [PATCH 009/163] host: add RYOTUNES_WEBKIT_FEATURES for renderer profiling WebKitGTK 2.42's feature API can flip compositing borders and repaint counters on, which is how to see what a scroll actually repaints; the webkit2gtk crate does not bind it yet, so the five calls are declared here and gated on an env var that is normally unset. --- src-tauri/src/lib.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2b99f8..be96f3b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,6 +103,7 @@ fn tune_webview(win: &tauri::WebviewWindow) { settings.set_enable_webrtc(false); settings.set_enable_webgl(false); settings.set_enable_html5_database(false); // WebSQL. localStorage is a separate switch. + webkit_features::apply_from_env(&settings); } }); match res { @@ -113,6 +114,87 @@ fn tune_webview(win: &tauri::WebviewWindow) { } } +/// `RYOTUNES_WEBKIT_FEATURES=Name=1,Other=0` flips WebKitGTK feature flags on the app's webviews +/// (the 2.42 feature API, which the `webkit2gtk` crate does not bind yet). A developer knob for +/// profiling the renderer: `CompositingBordersVisible=1,CompositingRepaintCountersVisible=1` draws +/// layer borders and per-layer repaint counters, which is how to see what a scroll repaints. +/// Unknown names are logged and skipped; the variable is normally unset. +#[cfg(target_os = "linux")] +mod webkit_features { + use std::ffi::CStr; + use std::os::raw::c_char; + + use webkit2gtk::glib::translate::ToGlibPtr; + + #[repr(C)] + pub struct WebKitFeatureList { + _private: [u8; 0], + } + #[repr(C)] + pub struct WebKitFeature { + _private: [u8; 0], + } + extern "C" { + fn webkit_settings_get_all_features() -> *mut WebKitFeatureList; + fn webkit_feature_list_get_length(list: *mut WebKitFeatureList) -> usize; + fn webkit_feature_list_get( + list: *mut WebKitFeatureList, + index: usize, + ) -> *mut WebKitFeature; + fn webkit_feature_list_unref(list: *mut WebKitFeatureList); + fn webkit_feature_get_identifier(feature: *mut WebKitFeature) -> *const c_char; + fn webkit_settings_set_feature_enabled( + settings: *mut webkit2gtk::ffi::WebKitSettings, + feature: *mut WebKitFeature, + enabled: webkit2gtk::glib::ffi::gboolean, + ); + } + + pub fn apply_from_env(settings: &webkit2gtk::Settings) { + let Ok(spec) = std::env::var("RYOTUNES_WEBKIT_FEATURES") else { return }; + let wanted: Vec<(&str, bool)> = spec + .split(',') + .filter_map(|kv| kv.split_once('=')) + .map(|(k, v)| (k.trim(), matches!(v.trim(), "1" | "true" | "on"))) + .collect(); + if wanted.is_empty() { + return; + } + // Safe: the list is owned for the duration of the loop and unref'd once; every pointer + // handed out by `webkit_feature_list_get` is borrowed from it. `settings` outlives the call. + unsafe { + let list = webkit_settings_get_all_features(); + let len = webkit_feature_list_get_length(list); + let mut applied = Vec::new(); + for i in 0..len { + let feature = webkit_feature_list_get(list, i); + let id = CStr::from_ptr(webkit_feature_get_identifier(feature)).to_string_lossy(); + if let Some((_, on)) = wanted.iter().find(|(k, _)| *k == id) { + webkit_settings_set_feature_enabled( + settings.to_glib_none().0, + feature, + (*on).into(), + ); + applied.push(format!("{id}={}", *on as u8)); + } + } + webkit_feature_list_unref(list); + for (k, _) in &wanted { + if !applied.iter().any(|a| a.starts_with(&format!("{k}="))) { + tracing::warn!( + feature = *k, + "RYOTUNES_WEBKIT_FEATURES: unknown WebKit feature" + ); + } + } + tracing::info!( + features = applied.join(","), + "webkit: feature flags from RYOTUNES_WEBKIT_FEATURES" + ); + } + } +} + /// [`tune_webview`] for a window looked up by label. No-op if it isn't up. #[cfg(target_os = "linux")] pub(crate) fn tune_webview_labelled(app: &tauri::AppHandle, label: &str) { From 7da9371ff051394d5ec1e6187ce43bc6446b00fb Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 22:53:33 -0400 Subject: [PATCH 010/163] build: bring the release gate back to green on a clean checkout The cold-start reveal failsafe grew to 4000 ms in 531c2b5 but the invariant still asked for 1500; the private-path scan matched the .git file of a git worktree; two files had drifted from rustfmt. --- crates/player/examples/title.rs | 4 +++- scripts/check-release-invariants.py | 2 +- scripts/release-check.sh | 2 +- src-tauri/src/state.rs | 3 ++- ui/src/routes/+page.svelte | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/player/examples/title.rs b/crates/player/examples/title.rs index 991f24f..3ebcace 100644 --- a/crates/player/examples/title.rs +++ b/crates/player/examples/title.rs @@ -19,7 +19,9 @@ fn main() { for _ in 0..40 { std::thread::sleep(std::time::Duration::from_millis(100)); got = p.media_title().unwrap_or_default(); - if !got.is_empty() { break; } + if !got.is_empty() { + break; + } } println!("want: {title}"); println!("got: {got}"); diff --git a/scripts/check-release-invariants.py b/scripts/check-release-invariants.py index 5d28d07..79146d5 100755 --- a/scripts/check-release-invariants.py +++ b/scripts/check-release-invariants.py @@ -176,7 +176,7 @@ def req(ok, msg): req("const teardownReady = acknowledgeFrontend('main', ryokuTokens.ready)" in layout and "const teardownReady = acknowledgeFrontend('mini', ryokuTokens.ready)" in layout, 'main/mini theme-prime/reveal handshake not shared') req('pub fn frontend_ready(app: &AppHandle)' in main and 'w.show().map_err' in main and 'crate::mini::close(app);' in main, 'main reveal does not close mini only after successful show') req('pub fn arm_reveal_failsafe(app: &AppHandle, delay: Duration)' in main and 'frontend readiness deadline expired' in main, 'native hidden-window reveal failsafe missing') -req('arm_reveal_failsafe(app.handle(), Duration::from_millis(1500))' in lib and 'arm_reveal_failsafe(app, Duration::from_millis(220))' in main, 'cold-start/second-launch reveal recovery missing') +req('arm_reveal_failsafe(app.handle(), Duration::from_millis(4000))' in lib and 'arm_reveal_failsafe(app, Duration::from_millis(220))' in main, 'cold-start/second-launch reveal recovery missing') req('crate::tray::show_main(&app);' in commands and 'pub async fn close_mini' in commands, 'mini restore button does not use shared main restore path') req('RYOTUNES_APP_ID: &str = "dev.ryoku.ryotunes"' in main and 'RYOTUNES_MAIN_TITLE: &str = "Ryotunes"' in main, 'stable main window identity missing') req('args(["keyword", "windowrulev2"' not in main and "arg(\"windowrulev2\")" not in main, 'runtime Hyprland windowrule injection returned') diff --git a/scripts/release-check.sh b/scripts/release-check.sh index 255b40b..5125da9 100755 --- a/scripts/release-check.sh +++ b/scripts/release-check.sh @@ -14,7 +14,7 @@ grep -q '"identifier": "dev.ryoku.ryotunes"' src-tauri/tauri.conf.json || { say say check 'private-machine and secret patterns' # Do not scan license/upstream attribution for project names. This scan is for actual release data. -if grep -RniE --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules --exclude-dir=.pnpm-store --exclude='UPSTREAM.md' --exclude='release-check.sh' \ +if grep -RniE --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules --exclude-dir=.pnpm-store --exclude='UPSTREAM.md' --exclude='release-check.sh' --exclude='.git' \ '(/home/[A-Za-z0-9._-]+/|/Users/[A-Za-z0-9._-]+/|[A-Z]:\\Users\\[^\\]+\\|BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|[A-Za-z0-9-]+\.ts\.net)' .; then say FAIL 'machine-specific path, endpoint, or secret-like value found' fail=1 diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e89d67b..e33550a 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1667,7 +1667,8 @@ impl AppState { } // Headers are global in mpv; the direct-URL clients need none beyond UA, which the // current track already set. Just append the URL. - let title = q.items.get(next_idx).map(|i| media_title(&i.title, &i.artists)).unwrap_or_default(); + let title = + q.items.get(next_idx).map(|i| media_title(&i.title, &i.artists)).unwrap_or_default(); if let Err(e) = self.player.enqueue(&data.stream_url, &title) { tracing::warn!(error = %e, "enqueue lookahead failed"); return; diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index b693d24..b86d27b 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -216,7 +216,7 @@ } // Home uses the layout's
as its scroll container. The observer only handles - // shallow-window resize correction. It used to also toggle `ryo-is-scrolling` on
on + // shallow-window resize correction. It used to also toggle a scrolling class on
on // every scroll event and 110 ms after the last one; with rules keyed on that class reaching // every and every promoted `.ryo-art-wash` layer, each toggle was a style recalc of the // whole page plus compositing-layer churn, twice per wheel notch: the scroll lag itself. From c169171d24dfdcf9a49e5891c7c70d01a30207c8 Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 23:04:22 -0400 Subject: [PATCH 011/163] core: move db and http out of the host --- Cargo.lock | 1 + {src-tauri => crates/core}/src/db.rs | 0 {src-tauri => crates/core}/src/http.rs | 0 crates/core/src/lib.rs | 3 +++ src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 3 +-- 6 files changed, 6 insertions(+), 2 deletions(-) rename {src-tauri => crates/core}/src/db.rs (100%) rename {src-tauri => crates/core}/src/http.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 213eab2..a501d4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3911,6 +3911,7 @@ dependencies = [ "reqwest 0.12.28", "rusqlite", "rustls", + "ryotunes-core", "serde", "serde_json", "souvlaki", diff --git a/src-tauri/src/db.rs b/crates/core/src/db.rs similarity index 100% rename from src-tauri/src/db.rs rename to crates/core/src/db.rs diff --git a/src-tauri/src/http.rs b/crates/core/src/http.rs similarity index 100% rename from src-tauri/src/http.rs rename to crates/core/src/http.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 221e4a2..e4fa1c1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,6 @@ //! Ryotunes playback core. Everything that is not a window lives here; the host (Tauri today, the //! daemon tomorrow) supplies the three traits in [`host`]. pub mod host; + +pub mod db; +pub mod http; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 75c7a81..232ee5c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" innertube = { path = "../crates/innertube" } player = { path = "../crates/player" } +ryotunes-core = { workspace = true } souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } # Listen Together protocol and WebSocket transport. listen-protocol = { workspace = true } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2b99f8..4195a49 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,9 +2,7 @@ mod cipher; mod commands; -mod db; mod discord; -mod http; mod lastfm; mod listentogether; mod local; @@ -29,6 +27,7 @@ use std::time::Duration; use innertube::{Clients, InnerTube, Locale, Session}; use player::{Player, PlayerEvent}; use tauri::{Emitter, Manager}; +use ryotunes_core::{db, http}; use cipher::{CipherDeobfuscator, PlayerConfigStore}; use db::Db; From 2c63f31e4f71e6c15813d99dedafea30e851cf50 Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 23:04:36 -0400 Subject: [PATCH 012/163] docs: split the core extraction plan by dependency order --- .../plans/2026-09-05-core-extraction.md | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-core-extraction.md b/docs/superpowers/plans/2026-09-05-core-extraction.md index 41f2f51..e439226 100644 --- a/docs/superpowers/plans/2026-09-05-core-extraction.md +++ b/docs/superpowers/plans/2026-09-05-core-extraction.md @@ -253,28 +253,27 @@ git commit -m "core: add the ryotunes-core crate with the host traits" --- -### Task 2: Move the Tauri-free modules +### Task 2: Move the leaf modules **Files:** -- Move (`git mv`): `src-tauri/src/db.rs`, `src-tauri/src/http.rs`, `src-tauri/src/radio.rs`, `src-tauri/src/lyrics.rs`, `src-tauri/src/discord.rs` to `crates/core/src/` -- Modify: `crates/core/src/lib.rs`, `src-tauri/src/lib.rs`, `src-tauri/src/commands.rs`, `src-tauri/src/state.rs` +- Move (`git mv`): `src-tauri/src/db.rs`, `src-tauri/src/http.rs` to `crates/core/src/` +- Modify: `crates/core/src/lib.rs`, `src-tauri/src/lib.rs`, `src-tauri/Cargo.toml` **Interfaces:** -- Produces: `ryotunes_core::{db, http, radio, lyrics, discord}` with the same `pub` items the host used via `crate::db` etc. +- Produces: `ryotunes_core::{db, http}` with the same `pub` items the host used via `crate::db` / `crate::http`. -- [ ] **Step 1: Confirm the modules are Tauri-free** +Only these two are leaves. `radio.rs` imports `crate::orchestrator::PlaybackData`, `lyrics.rs` takes `&AppState`, and `lyrics.rs`/`discord.rs` call `crate::local::is_local_song`; those three ride with the modules they depend on (`local` in Task 4, `orchestrator`/`state` in Task 5). Core can never depend on the host, so a module moves only once everything it imports is already in core. -Run: `grep -n 'tauri\|AppHandle' src-tauri/src/db.rs src-tauri/src/http.rs src-tauri/src/radio.rs src-tauri/src/lyrics.rs src-tauri/src/discord.rs` -Expected: no output (verified 2026-09-05; if a line appears, stop and treat that module in Task 4). +- [ ] **Step 1: Confirm the two modules import nothing from the host** + +Run: `grep -n 'tauri\|AppHandle\|crate::' src-tauri/src/db.rs src-tauri/src/http.rs` +Expected: no `tauri`/`AppHandle` hits and no `crate::` path outside `crate::db`/`crate::http` themselves. - [ ] **Step 2: Move them and declare them** ```bash git mv src-tauri/src/db.rs crates/core/src/db.rs git mv src-tauri/src/http.rs crates/core/src/http.rs -git mv src-tauri/src/radio.rs crates/core/src/radio.rs -git mv src-tauri/src/lyrics.rs crates/core/src/lyrics.rs -git mv src-tauri/src/discord.rs crates/core/src/discord.rs ``` `crates/core/src/lib.rs`: @@ -283,30 +282,27 @@ git mv src-tauri/src/discord.rs crates/core/src/discord.rs pub mod host; pub mod db; -pub mod discord; pub mod http; -pub mod lyrics; -pub mod radio; ``` -In `src-tauri/src/lib.rs` delete `mod db; mod discord; mod http; mod lyrics; mod radio;` and add: +In `src-tauri/src/lib.rs` delete `mod db; mod http;` and add: ```rust -use ryotunes_core::{db, discord, http, lyrics, radio}; +use ryotunes_core::{db, http}; ``` -In `src-tauri/Cargo.toml` add `ryotunes-core = { workspace = true }` under `[dependencies]`. Any `crate::db::`/`crate::lyrics::` path inside the moved files that pointed at each other becomes `crate::` inside core (they moved together) and `ryotunes_core::` in the host; `pub(crate)` items the host uses become `pub`. +In `src-tauri/Cargo.toml` add `ryotunes-core = { workspace = true }` under `[dependencies]`. `pub(crate)` items the host uses become `pub`. - [ ] **Step 3: Build and test** Run: `cargo check --workspace --locked && cargo test --workspace --locked` -Expected: both green; the `db`, `lyrics`, `radio` unit tests now run from `ryotunes-core`. +Expected: both green; the `db` unit tests now run from `ryotunes-core`. - [ ] **Step 4: Commit** ```bash git add -A crates/core src-tauri/src src-tauri/Cargo.toml -git commit -m "core: move db, http, radio, lyrics and discord out of the host" +git commit -m "core: move db and http out of the host" ``` --- @@ -601,16 +597,16 @@ git commit -m "core: move media, lastfm, local and listen together behind EventS --- -### Task 5: Move `orchestrator.rs`, `state.rs` and the session logic +### Task 5: Move `orchestrator.rs`, `state.rs`, their dependants and the session logic **Files:** -- Move: `src-tauri/src/orchestrator.rs`, `src-tauri/src/state.rs` to `crates/core/src/` +- Move: `src-tauri/src/orchestrator.rs`, `src-tauri/src/state.rs`, `src-tauri/src/radio.rs`, `src-tauri/src/lyrics.rs`, `src-tauri/src/discord.rs` to `crates/core/src/` (the last three were deferred from Task 2: `radio` imports `orchestrator::PlaybackData`, `lyrics` takes `&AppState`, `lyrics`/`discord` call `local::is_local_song`, which Task 4 put in core) - Split: `src-tauri/src/session.rs` into `crates/core/src/session.rs` (cookie/account bookkeeping, `allowed_login_navigation` and its tests) and `src-tauri/src/login_webview.rs` (the visible Google login window, implementing `LoginFlow`) - Modify: `src-tauri/src/lib.rs`, `src-tauri/src/commands.rs` **Interfaces:** - Consumes: everything above. -- Produces: `ryotunes_core::state::AppState::new(it, clients, player, db, sink: Arc, login: Arc, paths: Paths, orchestrator, lt, media, discord, lastfm)`; `AppState::sign_in(self: &Arc)` which awaits `self.login.sign_in()` and then runs today's cookie-application code; `AppState::emit(&self, event, payload)` replacing every `self.app.emit`. +- Produces: `ryotunes_core::state::AppState::new(it, clients, player, db, sink: Arc, login: Arc, paths: Paths, orchestrator, lt, media, discord, lastfm)`; `AppState::sign_in(self: &Arc)` which awaits `self.login.sign_in()` and then runs today's cookie-application code; `AppState::emit(&self, event, payload)` replacing every `self.app.emit`; `ryotunes_core::{radio, lyrics, discord}` with their existing `pub` items. - [ ] **Step 1: Move and let the compiler list the edits** @@ -618,10 +614,13 @@ git commit -m "core: move media, lastfm, local and listen together behind EventS git mv src-tauri/src/orchestrator.rs crates/core/src/orchestrator.rs git mv src-tauri/src/state.rs crates/core/src/state.rs git mv src-tauri/src/session.rs crates/core/src/session.rs +git mv src-tauri/src/radio.rs crates/core/src/radio.rs +git mv src-tauri/src/lyrics.rs crates/core/src/lyrics.rs +git mv src-tauri/src/discord.rs crates/core/src/discord.rs ``` -Add `pub mod orchestrator; pub mod state; pub mod session;` to core's `lib.rs`. Run `cargo check -p ryotunes-core`. -Expected: errors only at `state.rs:15,54,328` (`AppHandle`), the ten `self.app.emit(...)` sites, the two `tauri::async_runtime::spawn` sites (`state.rs:820,1314`), and `session.rs:13-15` plus `open_login`. +Add `pub mod orchestrator; pub mod state; pub mod session; pub mod radio; pub mod lyrics; pub mod discord;` to core's `lib.rs` and replace `mod ...;` with `use ryotunes_core::{...};` in the host. Run `cargo check -p ryotunes-core`. +Expected: errors only at `state.rs:15,54,328` (`AppHandle`), the ten `self.app.emit(...)` sites, the `tauri::async_runtime::spawn` sites in `state.rs` (`820`, `1314`, `1391`, `2066`) and `orchestrator.rs`, and `session.rs:13-15` plus `open_login`. `radio`, `lyrics` and `discord` need no edits beyond their `crate::` paths, which stay valid because their targets moved with them. - [ ] **Step 2: Replace `app` with `sink` + `login` in `AppState`** From 0b17495615dac31305f66c3deb770950ef2d964a Mon Sep 17 00:00:00 2001 From: carlos Date: Fri, 4 Sep 2026 23:15:40 -0400 Subject: [PATCH 013/163] core: move cipher and potoken behind the JsBridge seam --- Cargo.lock | 1 + .../core}/cipher_configs.json | 0 {src-tauri => crates/core}/po_token.html | 0 .../core}/src/cipher/config.rs | 0 .../core}/src/cipher/extractor.rs | 0 .../core}/src/cipher/fetcher.rs | 0 {src-tauri => crates/core}/src/cipher/mod.rs | 39 ++++++------- crates/core/src/host.rs | 4 ++ crates/core/src/lib.rs | 2 + .../core}/src/potoken/jsutil.rs | 0 {src-tauri => crates/core}/src/potoken/mod.rs | 45 +++++++------- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 10 ++-- src-tauri/src/webview.rs | 58 +++++++++++++++++++ 14 files changed, 112 insertions(+), 48 deletions(-) rename {src-tauri => crates/core}/cipher_configs.json (100%) rename {src-tauri => crates/core}/po_token.html (100%) rename {src-tauri => crates/core}/src/cipher/config.rs (100%) rename {src-tauri => crates/core}/src/cipher/extractor.rs (100%) rename {src-tauri => crates/core}/src/cipher/fetcher.rs (100%) rename {src-tauri => crates/core}/src/cipher/mod.rs (94%) rename {src-tauri => crates/core}/src/potoken/jsutil.rs (100%) rename {src-tauri => crates/core}/src/potoken/mod.rs (93%) diff --git a/Cargo.lock b/Cargo.lock index a501d4a..349652a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3896,6 +3896,7 @@ name = "ryotunes" version = "2.4.1" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "discord-rich-presence", "futures-util", diff --git a/src-tauri/cipher_configs.json b/crates/core/cipher_configs.json similarity index 100% rename from src-tauri/cipher_configs.json rename to crates/core/cipher_configs.json diff --git a/src-tauri/po_token.html b/crates/core/po_token.html similarity index 100% rename from src-tauri/po_token.html rename to crates/core/po_token.html diff --git a/src-tauri/src/cipher/config.rs b/crates/core/src/cipher/config.rs similarity index 100% rename from src-tauri/src/cipher/config.rs rename to crates/core/src/cipher/config.rs diff --git a/src-tauri/src/cipher/extractor.rs b/crates/core/src/cipher/extractor.rs similarity index 100% rename from src-tauri/src/cipher/extractor.rs rename to crates/core/src/cipher/extractor.rs diff --git a/src-tauri/src/cipher/fetcher.rs b/crates/core/src/cipher/fetcher.rs similarity index 100% rename from src-tauri/src/cipher/fetcher.rs rename to crates/core/src/cipher/fetcher.rs diff --git a/src-tauri/src/cipher/mod.rs b/crates/core/src/cipher/mod.rs similarity index 94% rename from src-tauri/src/cipher/mod.rs rename to crates/core/src/cipher/mod.rs index 885e89d..4ea26b7 100644 --- a/src-tauri/src/cipher/mod.rs +++ b/crates/core/src/cipher/mod.rs @@ -1,7 +1,7 @@ //! `CipherDeobfuscator` (cipher runtime) — the signature/`n`-transform runtime the orchestrator calls. //! //! Ties [`fetcher`] (player.js) + [`extractor`]/[`config`] (function names) + a hidden cipher -//! webview ([`crate::webview`]) that runs YouTube's own code. Every public method degrades +//! webview (the host's `webview.rs`) that runs YouTube's own code. Every public method degrades //! gracefully: a webview or extraction failure yields `None` / the original URL, and the //! orchestrator falls through to the non-cipher fallback clients (stream selection §5). @@ -16,10 +16,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use serde_json::Value; -use tauri::AppHandle; use tokio::sync::Mutex; -use crate::webview::Bridge; +use crate::host::{JsBridge, JsSession}; use fetcher::PlayerJsFetcher; const CIPHER_LABEL: &str = "ryotunes-cipher"; @@ -49,7 +48,7 @@ const DISCOVERY_JS: &str = r#"(function(){ #[derive(Default)] struct Inner { - bridge: Option, + bridge: Option>, sts: Option, built_epoch: u64, n_available: bool, @@ -67,19 +66,19 @@ struct Inner { } pub struct CipherDeobfuscator { - app: AppHandle, + js: Arc, fetcher: PlayerJsFetcher, config: Arc, inner: Mutex, } impl CipherDeobfuscator { - pub fn new(app: AppHandle, app_data_dir: &Path, config: Arc) -> Self { + pub fn new(js: Arc, app_data_dir: &Path, config: Arc) -> Self { CipherDeobfuscator { fetcher: PlayerJsFetcher::new(app_data_dir), config, inner: Mutex::new(Inner::default()), - app, + js, } } @@ -112,7 +111,7 @@ impl CipherDeobfuscator { let mut inner = self.inner.lock().await; inner.analyzed = false; // force re-fetch + re-analysis if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; } @@ -122,7 +121,7 @@ impl CipherDeobfuscator { async fn try_deobfuscate(&self, cipher: &str) -> Option { self.ensure_analyzed().await.ok()?; let (s, sp, base) = parse_cipher(cipher)?; - let bridge = self.inner.lock().await.bridge.clone()?; + let bridge = self.inner.lock().await.bridge.as_ref().map(|b| b.clone_session())?; let js = format!( "(function(){{try{{return String(window._cipherSigFunc({}));}}catch(e){{return null;}}}})()", js_string(&s) @@ -150,7 +149,7 @@ impl CipherDeobfuscator { if !inner.n_available { return None; } - let bridge = inner.bridge.clone()?; + let bridge = inner.bridge.as_ref().map(|b| b.clone_session())?; drop(inner); let re = regex::Regex::new(r"[?&]n=([^&]+)").ok()?; @@ -179,7 +178,7 @@ impl CipherDeobfuscator { let mut inner = self.inner.lock().await; inner.analyzed = false; // next ensure_analyzed rebuilds if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; } @@ -203,7 +202,7 @@ impl CipherDeobfuscator { inner.bridge.is_some() && inner.last_used.is_some_and(|used| used.elapsed() >= idle); if expired { if let Some(bridge) = inner.bridge.take() { - let _ = bridge.destroy(); + bridge.destroy(); } inner.last_used = None; tracing::debug!(?idle, "cipher webview torn down (idle)"); @@ -235,7 +234,7 @@ impl CipherDeobfuscator { // Unknown player hash — pull the registries off the hot path; a validated config for it // lands on the next rebuild (cipher runtime §forceRefresh). This run can't decipher. let config = self.config.clone(); - tauri::async_runtime::spawn(async move { + tokio::spawn(async move { config.force_refresh().await; }); } @@ -251,7 +250,7 @@ impl CipherDeobfuscator { if cfg.is_none() { let mut inner = self.inner.lock().await; if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; inner.sts = sts; @@ -273,15 +272,15 @@ impl CipherDeobfuscator { { let mut inner = self.inner.lock().await; if let Some(b) = inner.bridge.take() { - let _ = b.destroy(); + b.destroy(); } inner.last_used = None; } - let bridge = Bridge::create(&self.app, CIPHER_LABEL, HARNESS, "") + let bridge = self.js.create(CIPHER_LABEL, HARNESS, "") .await .map_err(|e| e.to_string())?; - if let Err(e) = Self::load_player(&bridge, &injected).await { - let _ = bridge.destroy(); // don't orphan the hidden window on a failed load + if let Err(e) = Self::load_player(&*bridge, &injected).await { + bridge.destroy(); // don't orphan the hidden window on a failed load return Err(e); } let n_available = matches!( @@ -302,7 +301,7 @@ impl CipherDeobfuscator { "cipher: discovery found no usable sig/n on this player — dropping the webview \ (KI-1; rebuilt on config-epoch change or self-heal)" ); - let _ = bridge.destroy(); + bridge.destroy(); inner.bridge = None; inner.last_used = None; } @@ -317,7 +316,7 @@ impl CipherDeobfuscator { /// Inject player.js + discovery into a freshly-built cipher `bridge` and wait for discovery to /// finish. Split out so `ensure_analyzed` can destroy the webview on any of these failures. - async fn load_player(bridge: &Bridge, injected: &str) -> Result<(), String> { + async fn load_player(bridge: &dyn JsSession, injected: &str) -> Result<(), String> { bridge.eval(injected).map_err(|e| e.to_string())?; bridge.eval(DISCOVERY_JS).map_err(|e| e.to_string())?; // Wait for discovery to finish, then the caller reads whether n/sig are usable. diff --git a/crates/core/src/host.rs b/crates/core/src/host.rs index 565d514..b6a16d5 100644 --- a/crates/core/src/host.rs +++ b/crates/core/src/host.rs @@ -33,6 +33,10 @@ pub trait JsBridge: Send + Sync + 'static { harness_html: &str, init_script: &str, ) -> Result, JsError>; + + /// Destroy any environment left behind under `label` by a cancelled `create` and wait until the + /// label is free again. Same semantics as the host's `destroy_and_wait`. + async fn reclaim(&self, label: &str); } /// One live environment. Mirrors `src-tauri/src/webview.rs` `Bridge` one to one. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e4fa1c1..0f07497 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,5 +2,7 @@ //! daemon tomorrow) supplies the three traits in [`host`]. pub mod host; +pub mod cipher; pub mod db; pub mod http; +pub mod potoken; diff --git a/src-tauri/src/potoken/jsutil.rs b/crates/core/src/potoken/jsutil.rs similarity index 100% rename from src-tauri/src/potoken/jsutil.rs rename to crates/core/src/potoken/jsutil.rs diff --git a/src-tauri/src/potoken/mod.rs b/crates/core/src/potoken/mod.rs similarity index 93% rename from src-tauri/src/potoken/mod.rs rename to crates/core/src/potoken/mod.rs index 577a8e2..dbaafa0 100644 --- a/src-tauri/src/potoken/mod.rs +++ b/crates/core/src/potoken/mod.rs @@ -16,13 +16,12 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use serde_json::Value; -use tauri::AppHandle; use tokio::sync::Mutex; use tokio::time::timeout; use crate::db::{now_secs, Db}; use crate::http::WEB_UA; -use crate::webview::{Bridge, Error as WebviewError}; +use crate::host::{JsBridge, JsSession, JsError}; const GOOGLE_API_KEY: &str = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"; const REQUEST_KEY: &str = "O43z0dpjhgX20SCx4KAo"; @@ -52,7 +51,7 @@ const GLUE: &str = r#"window.__lm={ struct Minter { session_id: String, expires_at: Instant, - bridge: Bridge, + bridge: Box, last_used: Instant, } @@ -89,7 +88,7 @@ impl SessionToken { } pub struct PoTokenGenerator { - app: AppHandle, + js: Arc, db: Arc, minter: Mutex>, /// Session token cache (PoToken flow: minted from visitorData, ~12h TTL). Lives OUTSIDE the @@ -102,7 +101,7 @@ pub struct PoTokenGenerator { } impl PoTokenGenerator { - pub fn new(app: AppHandle, db: Arc) -> Self { + pub fn new(js: Arc, db: Arc) -> Self { // A token stored by a previous run is as good as one minted now, right up to its expiry. // A wrong-session or expired one is simply never returned by `cached_session_token`, so it // costs nothing to load it optimistically and let the normal validity check reject it. @@ -115,7 +114,7 @@ impl PoTokenGenerator { ); } PoTokenGenerator { - app, + js, db, minter: Mutex::new(None), session_token: Mutex::new(stored), @@ -146,7 +145,7 @@ impl PoTokenGenerator { Ok(Ok(_guard)) => self.cached_session_token(visitor_data).await, Ok(Err(e)) => { tracing::warn!(error = %e, "PoToken session mint failed — degrading"); - if matches!(e, MintError::Webview(WebviewError::BadWebview(_))) { + if matches!(e, MintError::Webview(JsError::BadEnvironment(_))) { self.webview_bad.store(true, Ordering::SeqCst); } self.teardown().await; @@ -174,7 +173,7 @@ impl PoTokenGenerator { Ok(Ok(pot)) => Some(pot), Ok(Err(e)) => { tracing::warn!(video_id, error = %e, "PoToken streaming mint failed — degrading"); - if matches!(e, MintError::Webview(WebviewError::BadWebview(_))) { + if matches!(e, MintError::Webview(JsError::BadEnvironment(_))) { self.webview_bad.store(true, Ordering::SeqCst); } self.teardown().await; @@ -209,7 +208,7 @@ impl PoTokenGenerator { let mut guard = self.minter.lock().await; if !guard.as_ref().is_some_and(|m| m.valid_for(visitor_data)) { if let Some(old) = guard.take() { - let _ = old.bridge.destroy(); + old.bridge.destroy(); } *guard = Some(self.create_minter(visitor_data).await?); } @@ -225,14 +224,14 @@ impl PoTokenGenerator { let mut guard = self.ensure_minter(visitor_data).await?; let minter = guard.as_mut().expect("minter present"); minter.last_used = Instant::now(); - let bridge = minter.bridge.clone(); - match mint_token(&bridge, video_id.as_bytes()).await { + let bridge = minter.bridge.clone_session(); + match mint_token(&*bridge, video_id.as_bytes()).await { Ok(pot) => Ok(pot), Err(e) => { tracing::debug!(error = %e, "per-video mint failed, rebuilding minter once"); - let _ = bridge.destroy(); + bridge.destroy(); let fresh = self.create_minter(visitor_data).await?; - let pot = mint_token(&fresh.bridge, video_id.as_bytes()).await?; + let pot = mint_token(&*fresh.bridge, video_id.as_bytes()).await?; *guard = Some(fresh); Ok(pot) } @@ -241,11 +240,11 @@ impl PoTokenGenerator { /// Full BotGuard bootstrap: Create → runBotGuard → GenerateIT → createMinter → session token. async fn create_minter(&self, session_id: &str) -> Result { - let bridge = Bridge::create(&self.app, POTOKEN_LABEL, HARNESS, GLUE).await?; - match self.bootstrap_minter(&bridge, session_id).await { + let bridge = self.js.create(POTOKEN_LABEL, HARNESS, GLUE).await?; + match self.bootstrap_minter(&*bridge, session_id).await { Ok(m) => Ok(m), Err(e) => { - let _ = bridge.destroy(); // don't orphan the hidden window on a failed bootstrap + bridge.destroy(); // don't orphan the hidden window on a failed bootstrap Err(e) } } @@ -255,7 +254,7 @@ impl PoTokenGenerator { /// `create_minter` can destroy the webview on any error path. async fn bootstrap_minter( &self, - bridge: &Bridge, + bridge: &dyn JsSession, session_id: &str, ) -> Result { // 1. /Create → descrambled challengeData for runBotGuard. @@ -303,7 +302,7 @@ impl PoTokenGenerator { Ok(Minter { session_id: session_id.to_owned(), expires_at: Instant::now() + good_for, - bridge: bridge.clone(), + bridge: bridge.clone_session(), last_used: Instant::now(), }) } @@ -351,7 +350,7 @@ impl PoTokenGenerator { let mut guard = self.minter.lock().await; if let Some(m) = guard.as_ref() { if m.last_used.elapsed() >= idle { - let _ = m.bridge.destroy(); + m.bridge.destroy(); *guard = None; tracing::debug!("PoToken webview torn down (idle)"); } @@ -360,16 +359,16 @@ impl PoTokenGenerator { async fn teardown(&self) { if let Some(m) = self.minter.lock().await.take() { - let _ = m.bridge.destroy(); + m.bridge.destroy(); } // A failed/cancelled create_minter (or a mint timeout that cancelled us mid-bootstrap) // leaves an untracked window on our label — reclaim it so no hidden webview is orphaned. - crate::webview::destroy_and_wait(&self.app, POTOKEN_LABEL).await; + self.js.reclaim(POTOKEN_LABEL).await; } } /// [webview] obtain one PoToken for `identifier` (raw UTF-8 bytes) → URL-safe base64. -async fn mint_token(bridge: &Bridge, identifier: &[u8]) -> Result { +async fn mint_token(bridge: &dyn JsSession, identifier: &[u8]) -> Result { let arr = bridge .call_async(&format!("__lm.mint({})", jsutil::js_byte_array(identifier)), CALL_TIMEOUT) .await?; @@ -388,7 +387,7 @@ async fn mint_token(bridge: &Bridge, identifier: &[u8]) -> Result