From 3b03339de07df1f557bc198197c1df1833d624d4 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 5 Jun 2026 17:39:42 +0000 Subject: [PATCH 01/21] core: add listening-time tracking as a grow-only counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accrue audible listening on the existing Tick path inside the reducer. The device total is one G-Counter slot: grow-only, merged server-side by max-per-device and sum-across-devices, so concurrent listening on two devices adds rather than overwrites. Design follows the model-council synthesis: - Gate accrual on a new `audio_confirmed_playing` flag (set by PlatformPlaybackStarted, cleared on pause/error/stop), not on intent, so an autoplay-blocked shell counts nothing until audio truly starts. - Clamp each tick's delta (MAX_TICK_ACCRUAL_MS = 5s) so a sleep/wake gap or clock jump can't inflate the counter — the only attack surface a G-Counter has is the input delta. - Tracking is on by default (opt-out) via SetListeningTracking. - Persist a separate `cascade.listening.v1` blob via Effect::PersistListening, independent of settings; restore via Command::RestoreListening, which never lowers a live counter (max-merge guards partial writes). - ApplySyncedTotal moves the display baseline only and never decrements the device slot; ResetListeningData zeros it (shell rotates device_id). - No sync effect is emitted from core — shells observe unsyncedMs and own their own cadence, keeping the core free of network policy. Snapshot gains a `listening` view (tracking flag, device + displayed totals, unsyncedMs, formatted label). No FFI signature changes — the new serde fields flow through the existing JSON wire shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/cascade-core/src/command.rs | 29 ++- crates/cascade-core/src/effect.rs | 30 +-- crates/cascade-core/src/lib.rs | 4 +- crates/cascade-core/src/listening.rs | 296 +++++++++++++++++++++++++++ crates/cascade-core/src/snapshot.rs | 27 +++ crates/cascade-core/src/state.rs | 216 ++++++++++++++++++- 6 files changed, 580 insertions(+), 22 deletions(-) create mode 100644 crates/cascade-core/src/listening.rs diff --git a/crates/cascade-core/src/command.rs b/crates/cascade-core/src/command.rs index 8b98d4b..b51384a 100644 --- a/crates/cascade-core/src/command.rs +++ b/crates/cascade-core/src/command.rs @@ -5,7 +5,11 @@ use serde::{Deserialize, Serialize}; /// Everything that can happen to the core. User actions, platform reports, /// and wall-clock ticks all funnel through here. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum Command { /// User asked to start playback. Play, @@ -47,6 +51,29 @@ pub enum Command { PlatformPlaybackPaused, /// Platform reports a playback error that the user should know about. PlatformPlaybackError { message: String }, + + /// Turn listening-time tracking on or off. On by default; this is the + /// opt-out. Turning it off stops accrual immediately but never erases the + /// total already counted — use [`Command::ResetListeningData`] for that. + SetListeningTracking { enabled: bool }, + /// Restore the persisted listening blob at startup. `json` is the opaque + /// string a previous [`crate::Effect::PersistListening`] handed the shell; + /// the core owns its schema, so the shell stores and returns it verbatim. + /// A missing or unparseable blob is ignored (the in-memory ledger keeps its + /// defaults) — restore never *lowers* a live counter. + RestoreListening { json: String }, + /// Record a successful sync. The server accepted everything up to + /// `synced_through_ms` and reports `server_total_ms` as the cross-device + /// aggregate. Moves the display baseline only; never lowers the device slot. + ApplySyncedTotal { + synced_through_ms: u64, + server_total_ms: u64, + }, + /// "Delete my listening data": zero this device's slot and forget the server + /// aggregate. The shell must rotate its `device_id` alongside this so a + /// stale offline write can't resurrect the deleted total. Leaves the + /// tracking toggle untouched. + ResetListeningData, } #[cfg(test)] diff --git a/crates/cascade-core/src/effect.rs b/crates/cascade-core/src/effect.rs index 3180f80..98c6d93 100644 --- a/crates/cascade-core/src/effect.rs +++ b/crates/cascade-core/src/effect.rs @@ -6,23 +6,26 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum Effect { /// Begin (or resume) playback of the waterfall loop at the given volume. - StartPlayback { - volume_percent: u8, - }, + StartPlayback { volume_percent: u8 }, /// Stop / pause playback. PausePlayback, /// Update the platform's volume control without changing play/pause state. - SetPlatformVolume { - volume_percent: u8, - }, + SetPlatformVolume { volume_percent: u8 }, /// Persist the supplied settings JSON. The platform decides where /// (localStorage, DataStore, file system, …). - PersistSettings { - json: String, - }, + PersistSettings { json: String }, + /// Persist the listening blob. Stored in its own slot + /// (`cascade.listening.v1`), separate from settings, so the two evolve and + /// fail independently. The platform stores the string verbatim and hands it + /// back via [`crate::Command::RestoreListening`] on the next launch. + PersistListening { json: String }, } #[cfg(test)] @@ -34,15 +37,14 @@ mod tests { // `undefined` and the slider stops working — silently. Lock the wire shape. #[test] fn set_platform_volume_serializes_camel_case() { - let json = serde_json::to_string(&Effect::SetPlatformVolume { volume_percent: 25 }) - .unwrap(); + let json = + serde_json::to_string(&Effect::SetPlatformVolume { volume_percent: 25 }).unwrap(); assert_eq!(json, r#"{"type":"setPlatformVolume","volumePercent":25}"#); } #[test] fn start_playback_serializes_camel_case() { - let json = serde_json::to_string(&Effect::StartPlayback { volume_percent: 40 }) - .unwrap(); + let json = serde_json::to_string(&Effect::StartPlayback { volume_percent: 40 }).unwrap(); assert_eq!(json, r#"{"type":"startPlayback","volumePercent":40}"#); } } diff --git a/crates/cascade-core/src/lib.rs b/crates/cascade-core/src/lib.rs index e8d2433..ba21c4b 100644 --- a/crates/cascade-core/src/lib.rs +++ b/crates/cascade-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod command; pub mod effect; +pub mod listening; pub mod settings; pub mod snapshot; pub mod state; @@ -18,8 +19,9 @@ pub mod timer; pub use command::Command; pub use effect::Effect; +pub use listening::{ListeningLedger, PersistedListening, LISTENING_VERSION, MAX_TICK_ACCRUAL_MS}; pub use settings::{PersistedSettings, SETTINGS_VERSION}; -pub use snapshot::{Snapshot, TimerSnapshot, TimerSnapshotKind}; +pub use snapshot::{ListeningSnapshot, Snapshot, TimerSnapshot, TimerSnapshotKind}; pub use state::{PlaybackIntent, State, TimerMode}; use serde::{Deserialize, Serialize}; diff --git a/crates/cascade-core/src/listening.rs b/crates/cascade-core/src/listening.rs new file mode 100644 index 0000000..24116bb --- /dev/null +++ b/crates/cascade-core/src/listening.rs @@ -0,0 +1,296 @@ +//! Listening-time tracking — a grow-only per-device counter (a G-Counter slot). +//! +//! The core accrues *audible* listening time on the existing [`Command::Tick`] +//! path. It is a pure accumulator: it never reads the clock (the platform hands +//! it deltas), never touches the network, and the device slot never decreases. +//! +//! The device's lifetime total is one replica of a G-Counter (grow-only +//! counter). The server merges replicas with `max` per device and `sum` across +//! a user's devices — so two devices listening concurrently *add* rather than +//! overwrite. Crucially, the only thing ever stored is an aggregate +//! millisecond count: no timestamps, no session log. A listening *timeline* +//! cannot be reconstructed from this data, which is the whole defensibility +//! argument for tracking being on by default — we don't merely choose not to +//! store when you listened, we structurally *cannot*. +//! +//! [`Command::Tick`]: crate::Command::Tick + +use serde::{Deserialize, Serialize}; + +/// Storage-schema version for the persisted listening blob. Separate from +/// `SETTINGS_VERSION` because listening data lives in its own blob +/// (`cascade.listening.v1`), distinct from user settings. +pub const LISTENING_VERSION: u32 = 1; + +/// Upper bound on how much a single `Tick` may add to the counter. Shells tick +/// at roughly 250 ms during playback; any delta larger than this is a +/// sleep/wake gap or a clock jump, not real listening, so it is clamped. This +/// is the cheap input-sanitization guard for the one attack surface a G-Counter +/// has — the per-tick delta the merge consumes. +pub const MAX_TICK_ACCRUAL_MS: u64 = 5_000; + +/// The in-memory listening ledger. Held inside [`crate::State`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListeningLedger { + /// Grow-only count of confirmed audible milliseconds on THIS device — the + /// device's G-Counter slot. Never decreases within a device identity. + pub device_total_ms: u64, + /// High-water mark of `device_total_ms` already durably accepted by the + /// server. Sync bookkeeping only; it never lowers `device_total_ms`. + pub synced_through_ms: u64, + /// The server's aggregate across all of the user's devices, as of the last + /// successful sync. `None` until the device first hears from the server + /// (no account, or never synced). Display only. + pub server_total_ms: Option, + /// Whether listening-time tracking is on. Defaults to `true` (opt-out). + pub tracking_enabled: bool, +} + +impl Default for ListeningLedger { + fn default() -> Self { + Self { + device_total_ms: 0, + synced_through_ms: 0, + server_total_ms: None, + tracking_enabled: true, + } + } +} + +impl ListeningLedger { + /// Milliseconds accrued locally that the server has not yet acknowledged — + /// what a shell watches to decide when to sync. Saturating so a hand-edited + /// or partially-written blob can never underflow. + pub fn unsynced_ms(&self) -> u64 { + self.device_total_ms.saturating_sub(self.synced_through_ms) + } + + /// The lifetime total to show the user. With an account this is the server + /// aggregate plus any locally-accrued time not yet synced, so the number + /// climbs live even while listening offline. Without an account it is just + /// this device's slot. + pub fn displayed_total_ms(&self) -> u64 { + match self.server_total_ms { + Some(server) => server.saturating_add(self.unsynced_ms()), + None => self.device_total_ms, + } + } + + /// Accrue one tick of listening, clamped to [`MAX_TICK_ACCRUAL_MS`]. The + /// caller is responsible for checking the gate (tracking on, audio + /// confirmed, not muted) before calling this. + pub fn accrue(&mut self, elapsed_ms: u64) { + let delta = elapsed_ms.min(MAX_TICK_ACCRUAL_MS); + self.device_total_ms = self.device_total_ms.saturating_add(delta); + } + + /// Record a successful sync: the server has accepted everything up to + /// `synced_through_ms` and reports `server_total_ms` as the cross-device + /// aggregate. This only moves the display baseline forward — it never + /// touches `device_total_ms`. Monotonic in `synced_through_ms` so an + /// out-of-order ack can't rewind the high-water mark. + pub fn apply_synced(&mut self, synced_through_ms: u64, server_total_ms: u64) { + self.synced_through_ms = self.synced_through_ms.max(synced_through_ms); + self.server_total_ms = Some(server_total_ms); + } + + /// Zero the local slot for a "delete my listening data" request. The shell + /// is responsible for rotating its `device_id` alongside this so a stale + /// offline write can't later resurrect the deleted total against the old + /// slot. `tracking_enabled` is left as-is — deleting data is not the same + /// as turning the feature off. + pub fn reset(&mut self) { + self.device_total_ms = 0; + self.synced_through_ms = 0; + self.server_total_ms = None; + } + + /// Adopt a restored ledger without ever *lowering* the live counters. A + /// best-effort writer (Windows/macOS) can leave a partially-written blob, + /// and a restore must never regress a lifetime total — so the grow-only + /// fields take the max. `server_total_ms` and `tracking_enabled` are + /// authoritative from the blob. + pub fn restore_from(&mut self, restored: &ListeningLedger) { + self.device_total_ms = self.device_total_ms.max(restored.device_total_ms); + self.synced_through_ms = self.synced_through_ms.max(restored.synced_through_ms); + self.server_total_ms = restored.server_total_ms; + self.tracking_enabled = restored.tracking_enabled; + } + + pub fn to_persisted(&self) -> PersistedListening { + PersistedListening { + version: LISTENING_VERSION, + device_total_ms: self.device_total_ms, + synced_through_ms: self.synced_through_ms, + server_total_ms: self.server_total_ms, + tracking_enabled: self.tracking_enabled, + } + } + + pub fn from_persisted(p: &PersistedListening) -> Self { + Self { + device_total_ms: p.device_total_ms, + synced_through_ms: p.synced_through_ms.min(p.device_total_ms), + server_total_ms: p.server_total_ms, + tracking_enabled: p.tracking_enabled, + } + } +} + +/// The persisted form of the listening ledger — the JSON the platform stores in +/// its own blob, separate from settings. Versioned so future schema changes are +/// handled explicitly. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PersistedListening { + pub version: u32, + pub device_total_ms: u64, + pub synced_through_ms: u64, + /// Persisted so the lifetime display survives a restart instead of dropping + /// to device-only until the next sync. `#[serde(default)]` keeps older + /// blobs (written before this field existed) loadable. + #[serde(default)] + pub server_total_ms: Option, + pub tracking_enabled: bool, +} + +/// Format a millisecond total as a coarse human label: `"12h 34m"`, `"59m"`, +/// `"0m"`. Shells may reformat, but the snapshot ships a ready string so every +/// UI agrees by default. +pub fn format_listening_total(total_ms: u64) -> String { + let total_minutes = total_ms / 60_000; + let hours = total_minutes / 60; + let minutes = total_minutes % 60; + if hours > 0 { + format!("{hours}h {minutes:02}m") + } else { + format!("{minutes}m") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accrue_clamps_per_tick_to_neutralize_clock_jumps() { + let mut l = ListeningLedger::default(); + l.accrue(250); + assert_eq!(l.device_total_ms, 250); + // A 10-minute "tick" is a sleep/wake gap, not listening — clamp it. + l.accrue(600_000); + assert_eq!(l.device_total_ms, 250 + MAX_TICK_ACCRUAL_MS); + } + + #[test] + fn unsynced_is_device_minus_synced_and_never_underflows() { + let mut l = ListeningLedger::default(); + l.accrue(3_000); + assert_eq!(l.unsynced_ms(), 3_000); + l.apply_synced(3_000, 10_000); + assert_eq!(l.unsynced_ms(), 0); + // A stale, smaller ack can't rewind the high-water mark or underflow. + l.apply_synced(1_000, 10_000); + assert_eq!(l.synced_through_ms, 3_000); + assert_eq!(l.unsynced_ms(), 0); + } + + #[test] + fn displayed_total_uses_server_aggregate_plus_unsynced() { + let mut l = ListeningLedger::default(); + l.accrue(5_000); + // No account yet → show the device slot. + assert_eq!(l.displayed_total_ms(), 5_000); + // After a sync that reports a 1h aggregate across devices, the live + // display is aggregate + whatever we've accrued since. + l.apply_synced(5_000, 3_600_000); + l.accrue(2_000); + assert_eq!(l.displayed_total_ms(), 3_600_000 + 2_000); + } + + #[test] + fn apply_synced_never_lowers_device_slot() { + let mut l = ListeningLedger::default(); + l.accrue(4_000); + // Even if the server somehow reports a smaller aggregate, the local + // grow-only slot is untouched. + l.apply_synced(4_000, 1_000); + assert_eq!(l.device_total_ms, 4_000); + } + + #[test] + fn reset_zeros_local_state_but_leaves_tracking_flag() { + let mut l = ListeningLedger::default(); + l.accrue(4_000); + l.apply_synced(4_000, 9_000); + l.tracking_enabled = true; + l.reset(); + assert_eq!(l.device_total_ms, 0); + assert_eq!(l.synced_through_ms, 0); + assert_eq!(l.server_total_ms, None); + assert!(l.tracking_enabled, "deleting data must not flip the toggle"); + } + + #[test] + fn restore_never_regresses_the_lifetime_total() { + let mut live = ListeningLedger::default(); + live.accrue(5_000); // already 5s in memory + // A stale/partial blob says 3s — restoring must not lower the total. + let stale = ListeningLedger { + device_total_ms: 3_000, + synced_through_ms: 0, + server_total_ms: None, + tracking_enabled: false, + }; + live.restore_from(&stale); + assert_eq!(live.device_total_ms, 5_000); + assert!( + !live.tracking_enabled, + "tracking flag is authoritative from blob" + ); + } + + #[test] + fn persisted_round_trips() { + let mut l = ListeningLedger::default(); + l.accrue(1_234); + l.apply_synced(1_000, 8_000); + let json = serde_json::to_string(&l.to_persisted()).unwrap(); + let back: PersistedListening = serde_json::from_str(&json).unwrap(); + assert_eq!(back, l.to_persisted()); + assert_eq!(ListeningLedger::from_persisted(&back), l); + } + + #[test] + fn persisted_blob_is_camel_case_for_the_shells() { + let l = ListeningLedger { + device_total_ms: 7, + synced_through_ms: 3, + server_total_ms: Some(9), + tracking_enabled: true, + }; + let json = serde_json::to_string(&l.to_persisted()).unwrap(); + assert!(json.contains(r#""deviceTotalMs":7"#)); + assert!(json.contains(r#""syncedThroughMs":3"#)); + assert!(json.contains(r#""serverTotalMs":9"#)); + assert!(json.contains(r#""trackingEnabled":true"#)); + } + + #[test] + fn older_blob_without_server_total_still_loads() { + let json = + r#"{"version":1,"deviceTotalMs":100,"syncedThroughMs":50,"trackingEnabled":true}"#; + let p: PersistedListening = serde_json::from_str(json).unwrap(); + assert_eq!(p.server_total_ms, None); + assert_eq!(p.device_total_ms, 100); + } + + #[test] + fn format_total_uses_hours_and_minutes() { + assert_eq!(format_listening_total(0), "0m"); + assert_eq!(format_listening_total(59_000), "0m"); + assert_eq!(format_listening_total(60_000), "1m"); + assert_eq!(format_listening_total(3_600_000), "1h 00m"); + assert_eq!(format_listening_total(45_240_000), "12h 34m"); + } +} diff --git a/crates/cascade-core/src/snapshot.rs b/crates/cascade-core/src/snapshot.rs index 5deee57..8970a6a 100644 --- a/crates/cascade-core/src/snapshot.rs +++ b/crates/cascade-core/src/snapshot.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; +use crate::listening::format_listening_total; use crate::state::{State, DEFAULT_VOLUME_PERCENT}; use crate::timer::{format_remaining, TimerKind}; @@ -32,6 +33,24 @@ pub struct TimerSnapshot { pub progress: f32, } +/// Listening-time view for the UI. `displayed_total_ms` is the number to show; +/// `total_label` is a ready-formatted version of it. `unsynced_ms` is what a +/// shell watches to decide when to sync. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ListeningSnapshot { + pub tracking_enabled: bool, + /// This device's grow-only slot. + pub device_total_ms: u64, + /// The lifetime total to show the user (server aggregate + unsynced when + /// signed in, else the device slot). + pub displayed_total_ms: u64, + /// Locally-accrued milliseconds the server hasn't acknowledged yet. + pub unsynced_ms: u64, + /// `displayed_total_ms` pre-formatted as e.g. `"12h 34m"`. + pub total_label: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Snapshot { @@ -44,6 +63,7 @@ pub struct Snapshot { pub primary_button_label: String, pub timer: TimerSnapshot, pub error_message: Option, + pub listening: ListeningSnapshot, } impl Snapshot { @@ -114,6 +134,13 @@ impl Snapshot { }, timer, error_message: state.last_error.clone(), + listening: ListeningSnapshot { + tracking_enabled: state.listening.tracking_enabled, + device_total_ms: state.listening.device_total_ms, + displayed_total_ms: state.listening.displayed_total_ms(), + unsynced_ms: state.listening.unsynced_ms(), + total_label: format_listening_total(state.listening.displayed_total_ms()), + }, } } } diff --git a/crates/cascade-core/src/state.rs b/crates/cascade-core/src/state.rs index 4e46103..ca8cc72 100644 --- a/crates/cascade-core/src/state.rs +++ b/crates/cascade-core/src/state.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::command::Command; use crate::effect::Effect; +use crate::listening::{ListeningLedger, PersistedListening}; use crate::settings::{PersistedSettings, SETTINGS_VERSION}; use crate::timer::{ActiveTimer, TimerKind}; @@ -49,6 +50,14 @@ pub struct State { pub default_sleep_minutes: Option, pub default_pomodoro_minutes: Option, pub last_error: Option, + /// True only between [`Command::PlatformPlaybackStarted`] and the next + /// pause/error — i.e. audio is *confirmed* playing, not merely intended. + /// Listening accrues on this, not on `intent`, so an autoplay-blocked shell + /// (intent says Playing, audio never started) accrues nothing. + pub audio_confirmed_playing: bool, + /// Listening-time ledger. Restored from its own blob via + /// [`Command::RestoreListening`], not from settings. + pub listening: ListeningLedger, } impl Default for State { @@ -62,6 +71,8 @@ impl Default for State { default_sleep_minutes: Some(30), default_pomodoro_minutes: Some(30), last_error: None, + audio_confirmed_playing: false, + listening: ListeningLedger::default(), } } } @@ -77,6 +88,8 @@ impl State { default_sleep_minutes: s.default_sleep_minutes, default_pomodoro_minutes: s.default_pomodoro_minutes, last_error: None, + audio_confirmed_playing: false, + listening: ListeningLedger::default(), } } @@ -114,6 +127,18 @@ fn push_persist(state: &State, effects: &mut Vec) { } } +fn push_persist_listening(state: &State, effects: &mut Vec) { + if let Ok(json) = serde_json::to_string(&state.listening.to_persisted()) { + effects.push(Effect::PersistListening { json }); + } +} + +/// Whether a tick should count as listening: tracking on, audio *confirmed* +/// playing (not merely intended), and not muted. +fn is_accruing(state: &State) -> bool { + state.listening.tracking_enabled && state.audio_confirmed_playing && !state.muted +} + /// The reducer. Mutates `state` and appends to `effects`. pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { // Every dispatch starts by clearing the one-shot `just completed` flag so @@ -174,6 +199,14 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { state.active_timer = None; } Command::Tick { elapsed_ms } => { + // Accrue listening for the elapsed audible time *before* processing + // timer expiry: the audio was playing during this delta even if the + // timer is about to pause it. `accrue` clamps the per-tick delta, so + // a sleep/wake gap can't inflate the counter. + let was_accruing = is_accruing(state); + if was_accruing { + state.listening.accrue(elapsed_ms); + } if let Some(t) = state.active_timer.as_mut() { let just_expired = t.tick(elapsed_ms); if just_expired { @@ -185,19 +218,58 @@ pub fn reduce(state: &mut State, command: Command, effects: &mut Vec) { } } } + // Persist the new total on the tick we actually counted something, + // so a process kill loses at most one tick of listening. + if was_accruing { + push_persist_listening(state, effects); + } } Command::PlatformPlaybackStarted => { - // Platform confirms playback — clear any stale error. + // Platform confirms audio is genuinely playing — start accruing and + // clear any stale error. + state.audio_confirmed_playing = true; state.last_error = None; } Command::PlatformPlaybackPaused => { // Platform paused us without user input (audio focus, error). Make - // sure our intent matches reality so the UI doesn't lie. + // sure our intent matches reality so the UI doesn't lie, and stop + // accruing — audio is no longer confirmed playing. state.intent = PlaybackIntent::Paused; + state.audio_confirmed_playing = false; } Command::PlatformPlaybackError { message } => { state.last_error = Some(message); state.intent = PlaybackIntent::Paused; + state.audio_confirmed_playing = false; + } + + Command::SetListeningTracking { enabled } => { + state.listening.tracking_enabled = enabled; + push_persist_listening(state, effects); + } + Command::RestoreListening { json } => { + // The shell hands back the opaque blob it stored. A missing or + // unparseable/unknown-version blob is ignored — restore must never + // lower a live counter, and the defaults are already correct. + if let Ok(restored) = serde_json::from_str::(&json) { + if restored.version == crate::listening::LISTENING_VERSION { + let ledger = ListeningLedger::from_persisted(&restored); + state.listening.restore_from(&ledger); + } + } + } + Command::ApplySyncedTotal { + synced_through_ms, + server_total_ms, + } => { + state + .listening + .apply_synced(synced_through_ms, server_total_ms); + push_persist_listening(state, effects); + } + Command::ResetListeningData => { + state.listening.reset(); + push_persist_listening(state, effects); } } } @@ -222,6 +294,9 @@ fn stop_playback(state: &mut State, effects: &mut Vec) { state.intent = PlaybackIntent::Paused; // Mute is a live-playback concern; clear it so the next play isn't silent. state.muted = false; + // Audio is no longer confirmed playing, so listening stops accruing until + // the platform reports it started again. + state.audio_confirmed_playing = false; effects.push(Effect::PausePlayback); push_persist(state, effects); } @@ -299,7 +374,10 @@ mod tests { dispatch(&mut s, Command::Play); dispatch(&mut s, Command::ToggleMute); dispatch(&mut s, Command::Pause); - assert!(!s.muted, "pausing should clear mute so the next play is audible"); + assert!( + !s.muted, + "pausing should clear mute so the next play is audible" + ); } #[test] @@ -307,9 +385,12 @@ mod tests { let mut s = State::default(); let effects = dispatch(&mut s, Command::SetVolume { percent: 250 }); assert_eq!(s.volume_percent, Some(100)); - assert!(effects - .iter() - .any(|e| matches!(e, Effect::SetPlatformVolume { volume_percent: 100 }))); + assert!(effects.iter().any(|e| matches!( + e, + Effect::SetPlatformVolume { + volume_percent: 100 + } + ))); assert!(effects .iter() .any(|e| matches!(e, Effect::PersistSettings { .. }))); @@ -410,4 +491,127 @@ mod tests { assert!(s.active_timer.is_none()); assert!(s.intent.is_playing()); } + + // ---- listening-time accrual ------------------------------------------- + + #[test] + fn intent_alone_does_not_accrue_until_audio_confirmed() { + let mut s = State::default(); + dispatch(&mut s, Command::Play); // intent only — autoplay may be blocked + dispatch(&mut s, Command::Tick { elapsed_ms: 1_000 }); + assert_eq!( + s.listening.device_total_ms, 0, + "must not count time before the platform confirms audio started" + ); + // Platform confirms → now ticks count. + dispatch(&mut s, Command::PlatformPlaybackStarted); + let effects = dispatch(&mut s, Command::Tick { elapsed_ms: 1_000 }); + assert_eq!(s.listening.device_total_ms, 1_000); + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::PersistListening { .. })), + "a counted tick should persist the listening blob" + ); + } + + #[test] + fn muting_stops_accrual_but_not_the_timer() { + let mut s = State::default(); + dispatch(&mut s, Command::Play); + dispatch(&mut s, Command::PlatformPlaybackStarted); + dispatch(&mut s, Command::Tick { elapsed_ms: 1_000 }); + assert_eq!(s.listening.device_total_ms, 1_000); + dispatch(&mut s, Command::ToggleMute); + let effects = dispatch(&mut s, Command::Tick { elapsed_ms: 1_000 }); + assert_eq!( + s.listening.device_total_ms, 1_000, + "muted time is not listening" + ); + assert!(!effects + .iter() + .any(|e| matches!(e, Effect::PersistListening { .. }))); + } + + #[test] + fn disabling_tracking_stops_accrual_without_erasing_total() { + let mut s = State::default(); + dispatch(&mut s, Command::Play); + dispatch(&mut s, Command::PlatformPlaybackStarted); + dispatch(&mut s, Command::Tick { elapsed_ms: 2_000 }); + dispatch(&mut s, Command::SetListeningTracking { enabled: false }); + dispatch(&mut s, Command::Tick { elapsed_ms: 2_000 }); + assert_eq!( + s.listening.device_total_ms, 2_000, + "off = no new accrual, no erase" + ); + } + + #[test] + fn pause_then_resume_requires_fresh_confirmation_to_accrue() { + let mut s = State::default(); + dispatch(&mut s, Command::Play); + dispatch(&mut s, Command::PlatformPlaybackStarted); + dispatch(&mut s, Command::Tick { elapsed_ms: 1_000 }); + dispatch(&mut s, Command::Pause); + // Paused: ticks don't count, and confirmation was cleared. + dispatch(&mut s, Command::Tick { elapsed_ms: 5_000 }); + assert_eq!(s.listening.device_total_ms, 1_000); + // Resume intent without confirmation still doesn't count. + dispatch(&mut s, Command::Play); + dispatch(&mut s, Command::Tick { elapsed_ms: 5_000 }); + assert_eq!(s.listening.device_total_ms, 1_000); + // Fresh confirmation re-enables accrual. + dispatch(&mut s, Command::PlatformPlaybackStarted); + dispatch(&mut s, Command::Tick { elapsed_ms: 1_000 }); + assert_eq!(s.listening.device_total_ms, 2_000); + } + + #[test] + fn restore_listening_loads_blob_without_lowering_live_total() { + let mut s = State::default(); + let blob = serde_json::to_string(&crate::listening::PersistedListening { + version: crate::listening::LISTENING_VERSION, + device_total_ms: 7_200_000, + synced_through_ms: 3_600_000, + server_total_ms: Some(10_000_000), + tracking_enabled: false, + }) + .unwrap(); + dispatch(&mut s, Command::RestoreListening { json: blob }); + assert_eq!(s.listening.device_total_ms, 7_200_000); + assert_eq!(s.listening.server_total_ms, Some(10_000_000)); + assert!(!s.listening.tracking_enabled); + } + + #[test] + fn apply_synced_total_moves_baseline_and_persists() { + let mut s = State::default(); + dispatch(&mut s, Command::Play); + dispatch(&mut s, Command::PlatformPlaybackStarted); + dispatch(&mut s, Command::Tick { elapsed_ms: 5_000 }); + let effects = dispatch( + &mut s, + Command::ApplySyncedTotal { + synced_through_ms: 5_000, + server_total_ms: 9_000_000, + }, + ); + assert_eq!(s.listening.unsynced_ms(), 0); + assert_eq!(s.listening.displayed_total_ms(), 9_000_000); + assert!(effects + .iter() + .any(|e| matches!(e, Effect::PersistListening { .. }))); + } + + #[test] + fn reset_listening_data_zeros_slot_keeps_toggle() { + let mut s = State::default(); + dispatch(&mut s, Command::Play); + dispatch(&mut s, Command::PlatformPlaybackStarted); + dispatch(&mut s, Command::Tick { elapsed_ms: 5_000 }); + dispatch(&mut s, Command::ResetListeningData); + assert_eq!(s.listening.device_total_ms, 0); + assert!(s.listening.tracking_enabled); + } } From 79c8154adb286ef7af4031602d74f0bcb96203da Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 5 Jun 2026 17:40:59 +0000 Subject: [PATCH 02/21] web: rewrite privacy policy for optional account-based sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old copy promised "no accounts" and "no servers that receive data about you," which the listening-time feature contradicts. Rewrite to the accurate posture: - Nothing is stored on a server unless the user creates an account. - Listening time is tracked on-device by default (on by default, opt-out), and is a single cumulative number — never a timeline. State plainly that no dates, times, or per-session entries are recorded, so when/how someone listened cannot be reconstructed. This is the data-minimization defensibility argument for default-on tracking. - Describe what an optional account holds: email (magic-link sign-in, no password) plus the aggregate listening total, and nothing else. - Add sign-out / delete-data / delete-account guidance. Per the synthesis, this copy is a launch gate and must deploy in the same release as the feature itself. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/public/privacy/index.html | 92 +++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/apps/web/public/privacy/index.html b/apps/web/public/privacy/index.html index 2e5b648..4b84744 100644 --- a/apps/web/public/privacy/index.html +++ b/apps/web/public/privacy/index.html @@ -8,7 +8,7 @@ Cascade — Privacy Policy + + + +

Tracking Listening Time Across Six Platforms — Without Building a Surveillance Tool

+

+ Jacob Stephens  + June 5, 2026  + Engineering +

+ + +

Cascade is a white-noise app: it plays one waterfall recording, reliably, on web, Android, macOS, Windows, iOS, and watchOS. I wanted to show people how long they'd spent listening — totalled across every device they use. That is a deceptively dangerous feature to build, because the obvious implementation is a surveillance tool. Here's how I built it so that it structurally cannot be one.

+ +

One core, six shells

+

Cascade has an unusual shape: a single headless Rust core (cascade-core) owns all the state and intent, and six thin native shells own the side effects — audio, OS integration, UI. The core has no clock, no filesystem, no network. Time is fed in: the platform sends a Tick command with the elapsed milliseconds.

+

That constraint turned out to be the whole trick. Because every shell already shares one definition of "what's happening," I could add listening-time accrual in exactly one place and have it mean the same thing on all six platforms. No per-platform stopwatch, no six subtly-different definitions of a "session."

+ +

Count audio, not intent

+

The naïve version counts time while the app thinks it's playing. That over-counts badly. A browser that blocks autoplay reports "playing" the instant you click — but no sound is coming out until a user gesture unlocks it. So I gated accrual on confirmed playback, not intent: a flag flipped by the platform's real "audio actually started" signal, cleared on pause or error.

+
// inside the pure reducer, on the existing Tick path
+if tracking_enabled && audio_confirmed_playing && !muted {
+    let delta = elapsed_ms.min(MAX_TICK_ACCRUAL_MS); // clamp clock jumps
+    device_total_ms = device_total_ms.saturating_add(delta);
+}
+

Note the clamp. The merge math I'm about to describe is provably correct, but it consumes a per-tick delta as input — and a laptop sleeping for three hours produces one enormous "tick" on wake. Capping each tick at five seconds (shells tick roughly every 250ms) means a sleep/wake gap or a fiddled clock can't inflate the number. The input delta is the only attack surface a grow-only counter has, so that's where the guard goes.

+ +

The merge problem: why "latest total" is wrong

+

Here's the bug almost everyone ships first. You listen for two hours on your phone and one hour on your laptop. Both sync. If the server keeps "the latest total it received," one of those devices silently erases the other's hours. The whole point of cross-device tracking is that concurrent listening must add, not overwrite.

+

The clean answer is a G-Counter — a grow-only counter, one of the simplest CRDTs. Each device owns its own slot and only ever increases it. The server merges with two boring operations:

+
-- on write: keep the higher value for this device's slot
+INSERT INTO device_counters (user_id, device_id, total_ms)
+VALUES ($1, $2, $3)
+ON CONFLICT (user_id, device_id)
+DO UPDATE SET total_ms = GREATEST(device_counters.total_ms, EXCLUDED.total_ms);
+
+-- on read: the lifetime total is the sum across the user's devices
+SELECT COALESCE(SUM(total_ms), 0) FROM device_counters WHERE user_id = $1;
+

That's the entire sync engine. No vector clocks, no conflict UI, no "which version wins" — GREATEST on write, SUM on read. Two devices adding concurrently is not a conflict; it's just addition. The device is a CRDT replica and the server is almost stupid, which is exactly what you want a server holding other people's data to be.

+ +

The part I actually care about: it can't store a timeline

+

Tracking how long someone listens is one schema change away from tracking when they listen — what time they fall asleep, when they're at their desk, the shape of their week. I did not want to hold that data, and "we promise not to look" is a weak claim. So I made it impossible to express.

+

The account stores an email address and one integer per device. No timestamps. No session log. No event stream. You can ask the database "how much has this person listened?" and it can answer. You cannot ask it "when?" — there is nowhere for that answer to live.

+

This is the difference between we choose not to store your timeline and we cannot. The second is checkable: open the four-table schema and there is simply no column for it. It's also why I was comfortable making tracking on-by-default — the data minimization is a structural property of the design, not a setting someone has to trust.

+ +

Auth without the baggage

+

The account exists only to add numbers across devices, so it should carry as little as possible. Sign-in is an emailed magic link — no passwords to store or leak. Sessions are opaque server-side tokens, not JWTs, stored only as SHA-256 hashes. That choice pays off at deletion time: "log out everywhere" and "delete my account" are a single DELETE that takes effect immediately, instead of waiting out a signed token's expiry.

+

One subtle bug hides in every grow-only-counter design: deletion. If you delete your data but a forgotten phone is offline in a drawer, it can later sync a stale-but-higher counter and resurrect the total you deleted. The fix is small — deleting rotates the device's id, so any late write lands in a fresh slot instead of the grave you just dug. Grow-only counters and "delete my data" are in tension, and that rotation is the reconciliation.

+ +

Where the cleverness does not go

+

The core never decides when to sync. It exposes one number — how much time hasn't been pushed yet — and each shell owns the cadence, because syncing depends on things only the shell knows: are we online, are we signed in, is the app about to be suspended. The web shell flushes on pagehide; Android flushes in onStop. Keeping network policy out of the core is what lets the same pure reducer drive a watch and a desktop without caring which it is.

+ +

What it added up to

+

One Rust core, six shells, and a deliberately small Rust/Axum + Postgres service — four tables, a handful of endpoints — behind a hardened systemd unit, deployed as Terraform + Ansible. The measurement is identical everywhere because it lives in one place; concurrent devices add instead of clobbering because the merge is a CRDT; and the scariest version of the feature is off the table because the schema can't represent it.

+

The lesson I keep relearning: the strongest privacy guarantee isn't a policy, it's a data model that makes the bad version unbuildable.

+ + + + + From c6411f69d17f1151c1fc1565422acb64acc760aa Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Fri, 5 Jun 2026 20:06:35 +0000 Subject: [PATCH 20/21] windows: account sign-in and listening-time sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the Windows sync vertical (compile-verified via CI): - SyncApi: typed System.Net.Http client for the sync endpoints. - AccountStore: session token + email + a stable device id under LocalAppData, with rotation on delete (so a stale write can't resurrect a deleted slot). - AppViewModel: account state + relay commands (request link, complete sign-in, sign out, delete data/account) and a SyncAsync loop folding the server aggregate back via ApplySyncedTotal. Cadence in the shell: immediate on sign-in + once 30s of unsynced time accrues; a 401 drops the session locally. - MainWindow: a "sync across devices" panel (email + paste-the-link sign-in, signed-in state, manage/delete). Desktop protocol-activation (open the app from the https link) and DPAPI/ Credential Locker token storage are the on-device finishing — the scaffold uses a paste-the-link flow and a LocalAppData file. Not run on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/windows/Cascade/Converters.cs | 9 + apps/windows/Cascade/MainWindow.xaml | 48 +++++- apps/windows/Cascade/Services/AccountStore.cs | 79 +++++++++ apps/windows/Cascade/Services/SyncApi.cs | 82 +++++++++ .../Cascade/ViewModels/AppViewModel.cs | 163 ++++++++++++++++++ 5 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 apps/windows/Cascade/Services/AccountStore.cs create mode 100644 apps/windows/Cascade/Services/SyncApi.cs diff --git a/apps/windows/Cascade/Converters.cs b/apps/windows/Cascade/Converters.cs index e972c68..2e7a0af 100644 --- a/apps/windows/Cascade/Converters.cs +++ b/apps/windows/Cascade/Converters.cs @@ -27,4 +27,13 @@ public static string VolumeReadout(int percent, bool muted) => public static string TrackingLabel(bool enabled) => enabled ? "Tracking on" : "Tracking off"; + + public static Visibility VisibleIf(bool b) => + b ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility VisibleIfSignedIn(Account? account) => + account is not null ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility VisibleIfSignedOut(Account? account) => + account is null ? Visibility.Visible : Visibility.Collapsed; } diff --git a/apps/windows/Cascade/MainWindow.xaml b/apps/windows/Cascade/MainWindow.xaml index d7c3b73..16e5cc3 100644 --- a/apps/windows/Cascade/MainWindow.xaml +++ b/apps/windows/Cascade/MainWindow.xaml @@ -20,6 +20,7 @@ + @@ -157,9 +158,54 @@ Content="{x:Bind local:Converters.TrackingLabel(ViewModel.Snapshot.Listening.TrackingEnabled), Mode=OneWay}" /> + + + + + + + + +