From 754616d657a03c9ccb1eebc2667031a421473762 Mon Sep 17 00:00:00 2001 From: Max Boksem <15022344+MaxMB15@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:05:40 +0000 Subject: [PATCH 01/60] feat(remote): run this device as a remote-control target on macOS and Linux The platform layer around mvp_core::remote's Target. One tokio task owns the Target and carries out its actions: it listens on TCP (port 47130 when it's free), advertises _cinette._tcp through the discovery module, runs each connection's reader and writer, and calls tick at the core's next deadline. Time comes from a clock that counts sleep (CLOCK_BOOTTIME on Linux, CLOCK_MONOTONIC on macOS), so a session whose 30 s grace ran out while the computer slept ends instead of resuming without asking. The device key is a 0600 file in the app data folder, replaced in one step. Catalog queries are answered from the cache, with every item built by CatalogItem::from_channel. Commands for the player go to the webview as remote://command. The player carries them out through its playback destination, so a remote load is the target's own play and goes in its history, and it sends its state back through remote_set_state while a controller is connected. A command the player doesn't finish in 10 s fails. --- .../src/commands/tests/arguments.json | 10 +- .../src-tauri/src/commands/tests/mod.rs | 9 +- apps/player/src-tauri/src/lib.rs | 16 + apps/player/src-tauri/src/remote/catalog.rs | 128 ++++ .../src-tauri/src/remote/catalog_tests.rs | 232 +++++++ apps/player/src-tauri/src/remote/clock.rs | 66 ++ apps/player/src-tauri/src/remote/commands.rs | 370 ++++++++++ .../src-tauri/src/remote/commands_tests.rs | 106 +++ apps/player/src-tauri/src/remote/conn.rs | 71 ++ apps/player/src-tauri/src/remote/identity.rs | 143 ++++ apps/player/src-tauri/src/remote/mod.rs | 37 + apps/player/src-tauri/src/remote/service.rs | 241 +++++++ apps/player/src-tauri/src/remote/target.rs | 428 ++++++++++++ .../src-tauri/src/remote/target_tests.rs | 653 ++++++++++++++++++ apps/player/src/App.tsx | 3 + .../components/player/VideoPlayer.test.tsx | 3 + .../src/components/player/VideoPlayer.tsx | 3 + apps/player/src/hooks/useRemoteTarget.test.ts | 261 +++++++ apps/player/src/hooks/useRemoteTarget.ts | 127 ++++ apps/player/src/lib/remote-target.test.ts | 340 +++++++++ apps/player/src/lib/remote-target.ts | 184 +++++ apps/player/src/lib/tauri.test.ts | 61 ++ apps/player/src/lib/tauri.ts | 50 ++ 23 files changed, 3540 insertions(+), 2 deletions(-) create mode 100644 apps/player/src-tauri/src/remote/catalog.rs create mode 100644 apps/player/src-tauri/src/remote/catalog_tests.rs create mode 100644 apps/player/src-tauri/src/remote/clock.rs create mode 100644 apps/player/src-tauri/src/remote/commands.rs create mode 100644 apps/player/src-tauri/src/remote/commands_tests.rs create mode 100644 apps/player/src-tauri/src/remote/conn.rs create mode 100644 apps/player/src-tauri/src/remote/identity.rs create mode 100644 apps/player/src-tauri/src/remote/mod.rs create mode 100644 apps/player/src-tauri/src/remote/service.rs create mode 100644 apps/player/src-tauri/src/remote/target.rs create mode 100644 apps/player/src-tauri/src/remote/target_tests.rs create mode 100644 apps/player/src/hooks/useRemoteTarget.test.ts create mode 100644 apps/player/src/hooks/useRemoteTarget.ts create mode 100644 apps/player/src/lib/remote-target.test.ts create mode 100644 apps/player/src/lib/remote-target.ts diff --git a/apps/player/src-tauri/src/commands/tests/arguments.json b/apps/player/src-tauri/src/commands/tests/arguments.json index 56bcd6fa..2d4e8034 100644 --- a/apps/player/src-tauri/src/commands/tests/arguments.json +++ b/apps/player/src-tauri/src/commands/tests/arguments.json @@ -103,5 +103,13 @@ "required": ["channelId", "startAt", "endAt"], "optional": ["program"] }, - "stop_recording": { "required": ["id"] } + "stop_recording": { "required": ["id"] }, + "remote_get_settings": {}, + "remote_set_settings": { "required": ["settings"] }, + "remote_reset_identity": {}, + "remote_answer_consent": { "required": ["request", "allow"] }, + "remote_sessions": {}, + "remote_disconnect": { "required": ["sessionId"] }, + "remote_set_state": { "required": ["state"] }, + "remote_finish_command": { "required": ["token", "outcome"] } } diff --git a/apps/player/src-tauri/src/commands/tests/mod.rs b/apps/player/src-tauri/src/commands/tests/mod.rs index c65c8bb6..d4fb8b2e 100644 --- a/apps/player/src-tauri/src/commands/tests/mod.rs +++ b/apps/player/src-tauri/src/commands/tests/mod.rs @@ -194,7 +194,14 @@ fn sample(argument: &str) -> Value { "episodes" | "ids" => json!([]), "rangeStart" | "rangeEnd" | "duration" | "fileId" | "durationSeconds" | "limit" | "positionSeconds" | "newSortOrder" | "sortOrder" | "n" | "startAt" | "endAt" => json!(1), - "keep" => json!(true), + "keep" | "allow" => json!(true), + "request" | "token" => json!(1), + "outcome" => json!("ok"), + "settings" => json!({ "discoverable": true, "remoteControl": "ask", "deviceName": null }), + "state" => json!({ + "status": "idle", "item": null, "position": 0, "duration": 0, "volume": null, + "subtitleId": null, "audioId": null, "error": null + }), "episode" => serde_json::to_value(Channel::default()).unwrap(), _ => json!("x"), } diff --git a/apps/player/src-tauri/src/lib.rs b/apps/player/src-tauri/src/lib.rs index 5d831f63..276bc984 100644 --- a/apps/player/src-tauri/src/lib.rs +++ b/apps/player/src-tauri/src/lib.rs @@ -10,12 +10,14 @@ mod downloads; mod identifier; mod logging; mod progress; +mod remote; #[cfg(test)] mod capabilities_tests; #[cfg(test)] mod test_support; use commands::AppState; +use remote::commands as remote_commands; use mvp_core::cache::store::CacheStore; use std::sync::Mutex; use tauri::Manager; @@ -135,6 +137,14 @@ macro_rules! register_commands { } register_commands![ + remote_commands::remote_get_settings, + remote_commands::remote_set_settings, + remote_commands::remote_reset_identity, + remote_commands::remote_answer_consent, + remote_commands::remote_sessions, + remote_commands::remote_disconnect, + remote_commands::remote_set_state, + remote_commands::remote_finish_command, commands::load_m3u_playlist, commands::load_m3u_file, commands::load_xtream_provider, @@ -333,6 +343,10 @@ pub fn run() { app.manage(crate::downloads::recordings::Recorder::default()); crate::downloads::manager::restore_settings(app.handle()); + // Listens for other Cinette devices and advertises this one. + #[cfg(any(target_os = "linux", target_os = "macos"))] + crate::remote::start(app.handle(), app_dir.clone()); + #[cfg(desktop)] { use tauri_plugin_shell::ShellExt; @@ -393,6 +407,8 @@ pub fn run() { if let tauri::RunEvent::Exit = event { crate::downloads::manager::stop_all_for_exit(app); crate::downloads::recordings::stop_all_for_exit(app); + #[cfg(any(target_os = "linux", target_os = "macos"))] + crate::remote::stop_for_exit(app); } }); } diff --git a/apps/player/src-tauri/src/remote/catalog.rs b/apps/player/src-tauri/src/remote/catalog.rs new file mode 100644 index 00000000..c033fb9f --- /dev/null +++ b/apps/player/src-tauri/src/remote/catalog.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! Answers a controller's catalog queries from this device's cache. +//! +//! Every item goes through [`CatalogItem::from_channel`], which has no field +//! for a URL and drops logos that could point at a provider. + +use super::target::CatalogPage; +use mvp_core::cache::store::CacheStore; +use mvp_core::models::channel::Channel; +use mvp_core::remote::catalog::{provider_host, CatalogItem, CatalogQuery, MAX_PAGE}; +use mvp_core::remote::message::ErrorCode; + +/// Answers `query` from the cache: what this device can play right now. +pub fn answer(cache: &CacheStore, query: CatalogQuery) -> CatalogPage { + let failed = |e: mvp_core::cache::store::CacheError| { + tracing::warn!("[remote] catalog query failed: {e}"); + ErrorCode::Failed + }; + let providers = cache.get_providers().map_err(failed)?; + let hosts: Vec = providers + .iter() + .flat_map(|p| [Some(p.url.as_str()), p.epg_url.as_deref()]) + .flatten() + .filter_map(provider_host) + .collect(); + match query { + CatalogQuery::Episodes { series_id } => { + // Only episodes this device has already loaded. Fetching the + // rest from the provider comes with the controller's series view. + let episodes = cache.get_series_episodes(&series_id).map_err(failed)?; + Ok((items(&episodes, &hosts), None)) + } + query => { + let channels = cache.get_all_channels().map_err(failed)?; + answer_from(&channels, &hosts, query) + } + } +} + +fn items(channels: &[Channel], hosts: &[String]) -> Vec { + channels + .iter() + .take(MAX_PAGE) + .map(|c| CatalogItem::from_channel(c, hosts)) + .collect() +} + +/// The answer from a list of channels, in the order the cache gives them. +pub(crate) fn answer_from( + channels: &[Channel], + hosts: &[String], + query: CatalogQuery, +) -> CatalogPage { + let limit = |limit: u32| (limit as usize).clamp(1, MAX_PAGE); + match query { + CatalogQuery::Groups { content_type } => { + let mut groups: Vec<&str> = channels + .iter() + .filter(|c| c.content_type == content_type) + .map(|c| c.group_title.as_str()) + .collect(); + groups.sort_by_cached_key(|g| (g.to_lowercase(), g.to_string())); + groups.dedup(); + // A group goes out as an item whose id and name are the group's + // name, which `items` takes back. + let page = groups + .into_iter() + .take(MAX_PAGE) + .map(|g| CatalogItem { + id: g.to_owned(), + name: g.to_owned(), + content_type: content_type.clone(), + group: g.to_owned(), + ..CatalogItem::default() + }) + .collect(); + Ok((page, None)) + } + CatalogQuery::Items { + content_type, + group, + cursor, + limit: wanted, + } => { + let in_group: Vec<&Channel> = channels + .iter() + .filter(|c| c.content_type == content_type && c.group_title == group) + .collect(); + let start = match cursor { + None => 0, + Some(id) => in_group + .iter() + .position(|c| c.id == id) + .ok_or(ErrorCode::NotFound)?, + }; + let end = (start + limit(wanted)).min(in_group.len()); + let page = in_group[start..end] + .iter() + .map(|c| CatalogItem::from_channel(c, hosts)) + .collect(); + let next = in_group.get(end).map(|c| c.id.clone()); + Ok((page, next)) + } + CatalogQuery::Search { + query, + content_type, + limit: wanted, + } => { + // The same match as the channel list's search box. + let query = query.to_lowercase(); + let page = channels + .iter() + .filter(|c| content_type.as_ref().is_none_or(|t| c.content_type == *t)) + .filter(|c| c.name.to_lowercase().contains(&query)) + .take(limit(wanted)) + .map(|c| CatalogItem::from_channel(c, hosts)) + .collect(); + Ok((page, None)) + } + CatalogQuery::Episodes { .. } => Err(ErrorCode::Unsupported), + } +} + +#[cfg(test)] +#[path = "catalog_tests.rs"] +mod tests; diff --git a/apps/player/src-tauri/src/remote/catalog_tests.rs b/apps/player/src-tauri/src/remote/catalog_tests.rs new file mode 100644 index 00000000..062e003c --- /dev/null +++ b/apps/player/src-tauri/src/remote/catalog_tests.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +use super::*; + +fn channel(id: &str, name: &str, content_type: &str, group: &str) -> Channel { + Channel { + id: id.into(), + name: name.into(), + // A made-up login, which must never reach a controller. + url: format!("http://iptv.example/live/user/pass/{id}.ts"), + group_title: group.into(), + content_type: content_type.into(), + ..Channel::default() + } +} + +fn channels() -> Vec { + vec![ + channel("live-1", "BBC One", "live", "UK"), + channel("live-2", "bbc two", "live", "UK"), + channel("live-3", "CNN", "live", "news"), + channel("live-4", "Sky News", "live", "News"), + channel("movie-1", "Big Fish", "movie", "Drama"), + channel("live-5", "ITV", "live", "UK"), + ] +} + +fn ids(page: &CatalogPage) -> Vec<&str> { + page.as_ref() + .unwrap() + .0 + .iter() + .map(|i| i.id.as_str()) + .collect() +} + +#[test] +fn groups_are_listed_once_each_in_name_order_for_one_type() { + let page = answer_from( + &channels(), + &[], + CatalogQuery::Groups { + content_type: "live".into(), + }, + ); + let (items, next) = page.as_ref().unwrap(); + assert_eq!(ids(&page), ["News", "news", "UK"]); + assert_eq!(items[2].name, "UK"); + assert_eq!(items[2].group, "UK"); + assert_eq!(items[2].content_type, "live"); + assert_eq!(*next, None); +} + +#[test] +fn groups_stop_at_a_page() { + let many: Vec = (0..MAX_PAGE + 5) + .map(|i| channel(&format!("c{i}"), "x", "live", &format!("g{i:03}"))) + .collect(); + let page = answer_from( + &many, + &[], + CatalogQuery::Groups { + content_type: "live".into(), + }, + ); + assert_eq!(page.unwrap().0.len(), MAX_PAGE); +} + +fn items_query(cursor: Option<&str>, limit: u32) -> CatalogQuery { + CatalogQuery::Items { + content_type: "live".into(), + group: "UK".into(), + cursor: cursor.map(str::to_owned), + limit, + } +} + +#[test] +fn items_come_a_page_at_a_time() { + let all = channels(); + let first = answer_from(&all, &[], items_query(None, 2)); + assert_eq!(ids(&first), ["live-1", "live-2"]); + let next = first.unwrap().1; + assert_eq!(next.as_deref(), Some("live-5")); + let second = answer_from(&all, &[], items_query(next.as_deref(), 2)); + assert_eq!(ids(&second), ["live-5"]); + assert_eq!(second.unwrap().1, None); +} + +#[test] +fn a_page_that_ends_exactly_at_the_last_item_has_no_next() { + let page = answer_from(&channels(), &[], items_query(None, 3)); + assert_eq!(ids(&page), ["live-1", "live-2", "live-5"]); + assert_eq!(page.unwrap().1, None); +} + +#[test] +fn items_limits_are_kept_between_one_and_a_page() { + let many: Vec = (0..MAX_PAGE + 5) + .map(|i| channel(&format!("c{i}"), "x", "live", "UK")) + .collect(); + assert_eq!( + answer_from(&many, &[], items_query(None, 0)) + .unwrap() + .0 + .len(), + 1 + ); + assert_eq!( + answer_from(&many, &[], items_query(None, 1000)) + .unwrap() + .0 + .len(), + MAX_PAGE + ); +} + +#[test] +fn an_unknown_cursor_is_not_found() { + assert_eq!( + answer_from(&channels(), &[], items_query(Some("live-3"), 2)), + Err(ErrorCode::NotFound), + "live-3 is in another group" + ); +} + +#[test] +fn search_matches_names_without_case_like_the_channel_list() { + let all = channels(); + let search = |query: &str, content_type: Option<&str>, limit| { + answer_from( + &all, + &[], + CatalogQuery::Search { + query: query.into(), + content_type: content_type.map(str::to_owned), + limit, + }, + ) + }; + assert_eq!(ids(&search("BBC", None, 10)), ["live-1", "live-2"]); + assert_eq!(ids(&search("bi", None, 10)), ["movie-1"]); + assert_eq!(ids(&search("i", Some("movie"), 10)), ["movie-1"]); + assert_eq!(ids(&search("n", Some("live"), 2)), ["live-1", "live-3"]); + assert_eq!(search("n", None, 2).unwrap().1, None); +} + +#[test] +fn items_never_carry_the_stream_url_and_drop_provider_logos() { + let mut all = channels(); + all[0].logo_url = Some("http://iptv.example/logo.png".into()); + all[1].logo_url = Some("https://logos.example/bbc2.png".into()); + let page = answer_from(&all, &["iptv.example".into()], items_query(None, 10)).unwrap(); + assert_eq!(page.0[0].logo, None); + assert_eq!( + page.0[1].logo.as_deref(), + Some("https://logos.example/bbc2.png") + ); + let text = serde_json::to_string(&page.0).unwrap(); + assert!(!text.contains("pass"), "{text}"); +} + +#[test] +fn answer_reads_the_cache_and_hides_provider_hosts() { + use mvp_core::models::playlist::{Provider, ProviderType}; + let cache = CacheStore::open_in_memory().unwrap(); + cache + .upsert_provider(&Provider { + id: "p1".into(), + name: "Mine".into(), + provider_type: ProviderType::Xtream, + url: "http://iptv.example:8080".into(), + username: Some("user".into()), + password: Some("pass".into()), + last_updated: None, + channel_count: 0, + epg_url: Some("http://epg.example/guide.xml".into()), + }) + .unwrap(); + let mut on_epg_host = channel("p1-live-1", "One", "live", "UK"); + on_epg_host.logo_url = Some("http://epg.example/one.png".into()); + let mut on_server = channel("p1-live-2", "Two", "live", "UK"); + on_server.logo_url = Some("http://iptv.example:8080/two.png".into()); + cache + .save_channels("p1", &[on_epg_host, on_server]) + .unwrap(); + + let page = answer(&cache, items_query(None, 10)).unwrap(); + assert_eq!(page.0.len(), 2); + assert!(page.0.iter().all(|i| i.logo.is_none()), "{page:?}"); + + let episodes = answer( + &cache, + CatalogQuery::Episodes { + series_id: "p1-series-9".into(), + }, + ); + assert_eq!(episodes, Ok((Vec::new(), None)), "nothing cached yet"); +} + +#[test] +fn episodes_come_from_the_cache() { + let cache = CacheStore::open_in_memory().unwrap(); + let mut ep = channel("xt-ep-9-s1-0", "Pilot", "series", "Drama"); + ep.season = Some(1); + ep.episode = Some(1); + cache.save_series_episodes("p1-series-9", &[ep]).unwrap(); + let page = answer( + &cache, + CatalogQuery::Episodes { + series_id: "p1-series-9".into(), + }, + ) + .unwrap(); + assert_eq!(page.0.len(), 1); + assert_eq!(page.0[0].season, Some(1)); +} + +#[test] +fn episodes_are_not_answered_from_the_channel_list() { + assert_eq!( + answer_from( + &channels(), + &[], + CatalogQuery::Episodes { + series_id: "x".into() + } + ), + Err(ErrorCode::Unsupported) + ); +} diff --git a/apps/player/src-tauri/src/remote/clock.rs b/apps/player/src-tauri/src/remote/clock.rs new file mode 100644 index 00000000..669f0eb6 --- /dev/null +++ b/apps/player/src-tauri/src/remote/clock.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! Time that keeps counting while the computer sleeps. +//! +//! `std::time::Instant` stops during sleep on macOS and Linux. The remote +//! core measures the 30 s a dropped session may resume in with `Instant`s, +//! so after an hour asleep a session would look seconds old and come back +//! without asking. [`now`] gives an `Instant` that moves on by the time +//! slept, so those deadlines pass as they should. + +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// The time on a clock that counts sleep, since some fixed point. +fn awake_and_asleep() -> Duration { + // Linux's CLOCK_MONOTONIC stops in suspend and CLOCK_BOOTTIME doesn't. + // macOS's CLOCK_MONOTONIC counts sleep (CLOCK_UPTIME_RAW is the one that + // stops, and Instant uses it). + #[cfg(target_os = "linux")] + const CLOCK: libc::clockid_t = libc::CLOCK_BOOTTIME; + #[cfg(target_os = "macos")] + const CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC; + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `ts` is a valid timespec to write into, and CLOCK exists on + // this platform, so the call can't fail. + unsafe { libc::clock_gettime(CLOCK, &mut ts) }; + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) +} + +/// Now, as an `Instant` that includes the time slept since the first call. +pub fn now() -> Instant { + static START: OnceLock<(Instant, Duration)> = OnceLock::new(); + let (instant, clock) = *START.get_or_init(|| (Instant::now(), awake_and_asleep())); + instant + awake_and_asleep().saturating_sub(clock) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn moves_with_real_time_while_awake() { + let a = now(); + let real = Instant::now(); + std::thread::sleep(Duration::from_millis(30)); + let elapsed = now() - a; + let real_elapsed = real.elapsed(); + assert!(elapsed >= Duration::from_millis(30), "{elapsed:?}"); + let drift = elapsed.abs_diff(real_elapsed); + assert!(drift < Duration::from_millis(5), "{drift:?}"); + } + + #[test] + fn never_goes_backwards() { + let mut last = now(); + for _ in 0..1000 { + let next = now(); + assert!(next >= last); + last = next; + } + } +} diff --git a/apps/player/src-tauri/src/remote/commands.rs b/apps/player/src-tauri/src/remote/commands.rs new file mode 100644 index 00000000..87e31589 --- /dev/null +++ b/apps/player/src-tauri/src/remote/commands.rs @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! The `remote_*` commands the webview calls for remote control. +//! +//! They're registered on every platform, so the frontend's calls are the +//! same everywhere. Where remote control doesn't run yet (iOS) they fail +//! with [`UNAVAILABLE`]. + +use mvp_core::models::channel::Channel; +use mvp_core::remote::message::{Capabilities, ErrorCode, PlaybackSnapshot, PlaybackStatus, Track}; +use mvp_core::remote::settings::{ControlMode, RemoteSettings}; +use serde::{Deserialize, Serialize}; +use tauri::{command, AppHandle, Runtime}; + +/// The error where remote control doesn't run. +#[cfg_attr(any(target_os = "linux", target_os = "macos"), allow(dead_code))] +pub const UNAVAILABLE: &str = "Remote control isn't available on this device yet."; + +/// The settings screen's view of remote control. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSettingsView { + pub discoverable: bool, + pub remote_control: ControlMode, + /// The name the user gave, if any. + pub device_name: Option, + /// The name used when none is given: the device type. + pub default_name: String, + pub device_id: String, +} + +/// The settings as the webview sends them. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSettingsInput { + pub discoverable: bool, + pub remote_control: ControlMode, + pub device_name: Option, +} + +impl RemoteSettingsInput { + /// A blank or all-control-character name means "use the default". + fn into_settings(self) -> RemoteSettings { + RemoteSettings { + discoverable: self.discoverable, + remote_control: self.remote_control, + device_name: self + .device_name + .as_deref() + .and_then(mvp_core::remote::settings::clean_name), + } + } +} + +/// What's playing, as the player sends it. The item comes as the channel's +/// fields, and only what [`mvp_core::remote::catalog::CatalogItem`] keeps +/// goes on to controllers. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerState { + pub status: PlaybackStatus, + pub item: Option, + pub position: f64, + pub duration: f64, + pub volume: Option, + #[serde(default)] + pub subtitles: Vec, + pub subtitle_id: Option, + #[serde(default)] + pub audio: Vec, + pub audio_id: Option, + #[serde(default)] + pub capabilities: Capabilities, + pub error: Option, +} + +/// The playing channel's fields that a controller may see, plus its logo, +/// which is only passed on if it can't point at a provider. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerItem { + pub id: String, + pub name: String, + pub content_type: String, + #[serde(default)] + pub group: String, + pub logo_url: Option, + pub series_title: Option, + pub season: Option, + pub episode: Option, +} + +/// A number the webview sent, or 0 if it isn't one. +fn finite(value: f64) -> f64 { + if value.is_finite() { + value.max(0.0) + } else { + 0.0 + } +} + +impl PlayerState { + /// The snapshot controllers get. `provider_hosts` decide which logos + /// can go. + pub fn into_snapshot(self, provider_hosts: &[String]) -> PlaybackSnapshot { + let item = self.item.map(|i| { + let channel = Channel { + id: i.id, + name: i.name, + content_type: i.content_type, + group_title: i.group, + logo_url: i.logo_url, + series_title: i.series_title, + season: i.season, + episode: i.episode, + ..Channel::default() + }; + mvp_core::remote::catalog::CatalogItem::from_channel(&channel, provider_hosts) + }); + PlaybackSnapshot { + status: self.status, + item, + position: finite(self.position), + duration: finite(self.duration), + volume: self + .volume + .filter(|v| v.is_finite()) + .map(|v| v.round().clamp(0.0, 100.0) as u8), + subtitles: self.subtitles, + subtitle_id: self.subtitle_id, + audio: self.audio, + audio_id: self.audio_id, + capabilities: self.capabilities, + error: self.error, + } + } +} + +/// What the player answers to a `remote://command`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CommandOutcome { + Ok, + /// The item changed before the command could run. + StaleItem, + /// The player can't do this now. + Unsupported, + /// The item to load isn't one this device has. + NotFound, + /// The player tried and failed. + Failed, +} + +impl CommandOutcome { + pub fn into_result(self) -> Result<(), ErrorCode> { + match self { + Self::Ok => Ok(()), + Self::StaleItem => Err(ErrorCode::StaleItem), + Self::Unsupported => Err(ErrorCode::Unsupported), + Self::NotFound => Err(ErrorCode::NotFound), + Self::Failed => Err(ErrorCode::Failed), + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod imp { + use super::*; + use crate::remote::service::{SessionEvent, DEVICE_TYPE}; + use crate::remote::RemoteService; + use mvp_core::remote::consent::Answer; + use tauri::{Manager, State}; + + pub fn service(app: &AppHandle) -> Result, String> { + app.try_state::() + .ok_or_else(|| "Remote control didn't start on this device.".to_owned()) + } + + pub fn settings_view(service: &RemoteService) -> RemoteSettingsView { + let settings = service.settings(); + RemoteSettingsView { + discoverable: settings.discoverable, + remote_control: settings.remote_control, + device_name: settings.device_name, + default_name: mvp_core::remote::settings::default_name(DEVICE_TYPE).to_owned(), + device_id: service.device_id().to_string(), + } + } + + pub fn answer(service: &RemoteService, request: u64, allow: bool) { + let answer = if allow { Answer::Allow } else { Answer::Deny }; + service.handle.answer(request, answer); + } + + pub async fn sessions(service: &RemoteService) -> Vec { + service + .handle + .sessions() + .await + .iter() + .map(SessionEvent::from) + .collect() + } + + pub fn provider_hosts(app: &AppHandle) -> Vec { + let state = app.state::(); + let Ok(cache) = state.cache.lock() else { + return Vec::new(); + }; + cache + .get_providers() + .unwrap_or_default() + .iter() + .flat_map(|p| [Some(p.url.as_str()), p.epg_url.as_deref()]) + .flatten() + .filter_map(mvp_core::remote::catalog::provider_host) + .collect() + } +} + +/// The remote-control settings and this device's id. +#[command] +pub async fn remote_get_settings( + app: AppHandle, +) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + Ok(imp::settings_view(&*imp::service(&app)?)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = app; + Err(UNAVAILABLE.into()) + } +} + +/// Saves the settings. Never allow ends every session at once. +#[command] +pub async fn remote_set_settings( + app: AppHandle, + settings: RemoteSettingsInput, +) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let service = imp::service(&app)?; + service.set_settings(&app, settings.into_settings())?; + Ok(imp::settings_view(&service)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, settings); + Err(UNAVAILABLE.into()) + } +} + +/// Makes a new device key, so a new device id. Every session ends. +#[command] +pub async fn remote_reset_identity( + app: AppHandle, +) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let service = imp::service(&app)?; + service.reset_identity()?; + Ok(imp::settings_view(&service)) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = app; + Err(UNAVAILABLE.into()) + } +} + +/// The user's answer to a consent dialog. Closing the dialog is a deny. +#[command] +pub async fn remote_answer_consent( + app: AppHandle, + request: u64, + allow: bool, +) -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + imp::answer(&*imp::service(&app)?, request, allow); + Ok(()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, request, allow); + Err(UNAVAILABLE.into()) + } +} + +/// The controllers, connected or waiting to resume. +#[command] +pub async fn remote_sessions(app: AppHandle) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let sessions = imp::sessions(&*imp::service(&app)?).await; + serde_json::to_value(sessions).map_err(|e| e.to_string()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = app; + Err(UNAVAILABLE.into()) + } +} + +/// Ends one controller's session. +#[command] +pub async fn remote_disconnect( + app: AppHandle, + session_id: String, +) -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + imp::service(&app)?.handle.disconnect(session_id); + Ok(()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, session_id); + Err(UNAVAILABLE.into()) + } +} + +/// The player's state, for controllers. +#[command] +pub async fn remote_set_state( + app: AppHandle, + state: PlayerState, +) -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let service = imp::service(&app)?; + let hosts = imp::provider_hosts(&app); + service.handle.set_state(state.into_snapshot(&hosts)); + Ok(()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, state); + Err(UNAVAILABLE.into()) + } +} + +/// The player's answer to a `remote://command`. +#[command] +pub async fn remote_finish_command( + app: AppHandle, + token: u64, + outcome: CommandOutcome, +) -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + imp::service(&app)? + .handle + .finish_command(token, outcome.into_result()); + Ok(()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, token, outcome); + Err(UNAVAILABLE.into()) + } +} + +#[cfg(test)] +#[path = "commands_tests.rs"] +mod tests; diff --git a/apps/player/src-tauri/src/remote/commands_tests.rs b/apps/player/src-tauri/src/remote/commands_tests.rs new file mode 100644 index 00000000..8cab32e6 --- /dev/null +++ b/apps/player/src-tauri/src/remote/commands_tests.rs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +use super::*; +use serde_json::json; + +fn player_state(extra: serde_json::Value) -> PlayerState { + let mut value = json!({ + "status": "playing", + "item": { + "id": "p1-live-7", + "name": "News", + "contentType": "live", + "group": "UK", + "logoUrl": "http://iptv.example/logo.png" + }, + "position": 12.5, + "duration": 0, + "volume": 80, + "subtitleId": null, + "audioId": null, + "error": null + }); + for (k, v) in extra.as_object().unwrap() { + value[k] = v.clone(); + } + serde_json::from_value(value).unwrap() +} + +#[test] +fn the_player_state_becomes_a_snapshot_without_provider_logos() { + let snapshot = player_state(json!({})).into_snapshot(&["iptv.example".into()]); + let item = snapshot.item.unwrap(); + assert_eq!(item.id, "p1-live-7"); + assert_eq!(item.name, "News"); + assert_eq!(item.group, "UK"); + assert_eq!(item.logo, None); + assert_eq!(snapshot.status, PlaybackStatus::Playing); + assert_eq!(snapshot.position, 12.5); + assert_eq!(snapshot.volume, Some(80)); + + let kept = player_state(json!({})).into_snapshot(&[]); + assert_eq!( + kept.item.unwrap().logo.as_deref(), + Some("http://iptv.example/logo.png") + ); +} + +#[test] +fn numbers_that_are_not_numbers_or_out_of_range_are_cleaned() { + // JSON has no NaN, so check the conversion directly. + let mut state = player_state(json!({ "volume": 140.4, "position": -3 })); + state.duration = f64::NAN; + let snapshot = state.into_snapshot(&[]); + assert_eq!(snapshot.volume, Some(100)); + assert_eq!(snapshot.position, 0.0); + assert_eq!(snapshot.duration, 0.0); + + let mut state = player_state(json!({ "volume": 49.5 })); + state.position = f64::INFINITY; + let snapshot = state.into_snapshot(&[]); + assert_eq!(snapshot.volume, Some(50)); + assert_eq!(snapshot.position, 0.0); + + let mut state = player_state(json!({ "volume": -2 })); + assert_eq!(state.clone().into_snapshot(&[]).volume, Some(0)); + state.volume = Some(f64::NAN); + assert_eq!(state.clone().into_snapshot(&[]).volume, None); + state.volume = None; + assert_eq!(state.into_snapshot(&[]).volume, None); +} + +#[test] +fn nothing_playing_has_no_item() { + let snapshot = player_state(json!({ "item": null, "status": "idle" })).into_snapshot(&[]); + assert_eq!(snapshot.item, None); + assert_eq!(snapshot.status, PlaybackStatus::Idle); +} + +#[test] +fn outcomes_map_to_error_codes() { + let parse = |s: &str| serde_json::from_value::(json!(s)).unwrap(); + assert_eq!(parse("ok").into_result(), Ok(())); + assert_eq!(parse("staleItem").into_result(), Err(ErrorCode::StaleItem)); + assert_eq!( + parse("unsupported").into_result(), + Err(ErrorCode::Unsupported) + ); + assert_eq!(parse("notFound").into_result(), Err(ErrorCode::NotFound)); + assert_eq!(parse("failed").into_result(), Err(ErrorCode::Failed)); +} + +#[test] +fn a_blank_name_means_the_default() { + let input = |name: Option<&str>| RemoteSettingsInput { + discoverable: false, + remote_control: ControlMode::Never, + device_name: name.map(str::to_owned), + }; + let s = input(Some(" Den TV ")).into_settings(); + assert_eq!(s.device_name.as_deref(), Some("Den TV")); + assert!(!s.discoverable); + assert_eq!(s.remote_control, ControlMode::Never); + assert_eq!(input(Some(" \n ")).into_settings().device_name, None); + assert_eq!(input(None).into_settings().device_name, None); +} diff --git a/apps/player/src-tauri/src/remote/conn.rs b/apps/player/src-tauri/src/remote/conn.rs new file mode 100644 index 00000000..57308b8e --- /dev/null +++ b/apps/player/src-tauri/src/remote/conn.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! One TCP connection: a task that reads frames and a task that writes them. + +use mvp_core::remote::channel::FrameReader; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; + +/// What a connection's reader reports. +#[derive(Debug)] +pub enum Read { + /// One whole frame's contents. + Frame(Vec), + /// The connection ended, or sent something that isn't a frame. + Gone, +} + +/// Queues bytes for the writer. Dropping it closes the connection once +/// everything queued is written. +pub type Writer = mpsc::UnboundedSender>; + +/// Starts the reader and writer for `stream`. Each read goes to `on_read` +/// with `id`, ending with one [`Read::Gone`]. +pub fn spawn( + stream: TcpStream, + id: T, + on_read: mpsc::UnboundedSender<(T, Read)>, +) -> Writer { + // Frames are small and someone is waiting on each. + let _ = stream.set_nodelay(true); + let (mut read_half, mut write_half) = stream.into_split(); + let reader = tokio::spawn(async move { + let mut frames = FrameReader::new(); + let mut buf = vec![0u8; 16 * 1024]; + 'read: loop { + match read_half.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => frames.push(&buf[..n]), + } + loop { + match frames.next_frame() { + Ok(Some(frame)) => { + if on_read.send((id, Read::Frame(frame))).is_err() { + return; + } + } + Ok(None) => break, + Err(_) => break 'read, + } + } + } + let _ = on_read.send((id, Read::Gone)); + }); + let (writer, mut queue) = mpsc::unbounded_channel::>(); + tokio::spawn(async move { + while let Some(bytes) = queue.recv().await { + if write_half.write_all(&bytes).await.is_err() { + // The reader sees the broken connection too, and says Gone. + return; + } + } + // The queue was dropped: this side closed the connection. + let _ = write_half.shutdown().await; + // The other side may never close its end. Nothing it sends now + // matters, so stop reading. + reader.abort(); + }); + writer +} diff --git a/apps/player/src-tauri/src/remote/identity.rs b/apps/player/src-tauri/src/remote/identity.rs new file mode 100644 index 00000000..992b74c6 --- /dev/null +++ b/apps/player/src-tauri/src/remote/identity.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! This device's long-term remote-control key, kept in a file only the user +//! can read. +//! +//! The file holds the 32 private bytes and nothing else. The public half and +//! the device id are worked out from them. The app data folder already holds +//! the cache with every provider's login in it, so the key gets the same +//! protection rather than a store of its own. + +use mvp_core::remote::identity::{DeviceKey, KEY_LEN}; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// The key's file name in the app data folder. +pub const KEY_FILE: &str = "remote-device.key"; + +fn key_path(dir: &Path) -> PathBuf { + dir.join(KEY_FILE) +} + +/// The saved key, or a new one saved in its place when there's none or the +/// file is broken. A new key means a new device id. +pub fn load_or_create(dir: &Path) -> io::Result { + let path = key_path(dir); + match fs::read(&path) { + Ok(bytes) => match <[u8; KEY_LEN]>::try_from(bytes.as_slice()) { + Ok(private) => return Ok(DeviceKey::from_private(private)), + Err(_) => tracing::warn!( + "[remote] {} is {} bytes, not {KEY_LEN}; making a new device key", + path.display(), + bytes.len() + ), + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + let key = DeviceKey::generate(); + save(dir, &key)?; + tracing::info!("[remote] new device id {}", key.device_id()); + Ok(key) +} + +/// Saves `key` in place of any earlier one. It goes to a temporary file +/// that only the user can read, which then replaces the old file in one +/// step, so a crash leaves either the old key or the new one. +pub fn save(dir: &Path, key: &DeviceKey) -> io::Result<()> { + fs::create_dir_all(dir)?; + let tmp = dir.join(format!("{KEY_FILE}.tmp")); + // A leftover from a crash may have other permissions; start afresh. + match fs::remove_file(&tmp) { + Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e), + _ => {} + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&tmp)?; + file.write_all(key.private_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(&tmp, key_path(dir)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn makes_a_key_once_and_reads_it_back() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path()).unwrap(); + let again = load_or_create(dir.path()).unwrap(); + assert_eq!(first.device_id(), again.device_id()); + assert_eq!( + fs::read(dir.path().join(KEY_FILE)).unwrap(), + first.private_bytes().to_vec() + ); + assert!(!dir.path().join("remote-device.key.tmp").exists()); + } + + #[cfg(unix)] + #[test] + fn only_the_user_can_read_it() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + load_or_create(dir.path()).unwrap(); + let mode = fs::metadata(dir.path().join(KEY_FILE)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn a_broken_file_gets_a_new_key() { + let dir = tempfile::tempdir().unwrap(); + for broken in [&[1u8; 31][..], &[1u8; 33][..], &[][..]] { + fs::write(dir.path().join(KEY_FILE), broken).unwrap(); + let key = load_or_create(dir.path()).unwrap(); + assert_eq!( + fs::read(dir.path().join(KEY_FILE)).unwrap(), + key.private_bytes().to_vec(), + "{} bytes", + broken.len() + ); + } + } + + #[test] + fn saving_replaces_the_key_and_a_leftover_temp_file() { + let dir = tempfile::tempdir().unwrap(); + let old = load_or_create(dir.path()).unwrap(); + fs::write(dir.path().join("remote-device.key.tmp"), b"junk").unwrap(); + let new = DeviceKey::generate(); + save(dir.path(), &new).unwrap(); + let read = load_or_create(dir.path()).unwrap(); + assert_eq!(read.device_id(), new.device_id()); + assert_ne!(read.device_id(), old.device_id()); + } + + #[test] + fn makes_the_folder_if_needed() { + let dir = tempfile::tempdir().unwrap(); + let inner = dir.path().join("a").join("b"); + load_or_create(&inner).unwrap(); + assert!(inner.join(KEY_FILE).exists()); + } + + #[cfg(unix)] + #[test] + fn an_unreadable_file_is_an_error_not_a_new_key() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join(KEY_FILE)).unwrap(); + assert!(load_or_create(dir.path()).is_err()); + } +} diff --git a/apps/player/src-tauri/src/remote/mod.rs b/apps/player/src-tauri/src/remote/mod.rs new file mode 100644 index 00000000..e1c63ef4 --- /dev/null +++ b/apps/player/src-tauri/src/remote/mod.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! Remote control between Cinette devices on the same network: the +//! platform layer around `mvp_core::remote`, on macOS and Linux. +//! +//! [`target`] runs this device as a target: it listens on TCP, advertises +//! `_cinette._tcp`, and drives the core's `Target`. Commands for the player +//! go to the webview as `remote://command` events, so a remote play goes +//! through the same code as a local one and is recorded in this device's +//! history. The `remote_*` commands in [`commands`] are how the webview +//! answers and changes settings. +//! +//! iOS and Android will drive the same core with their own sockets and +//! discovery. Until then the commands say remote control isn't available. + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub mod catalog; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub mod clock; +pub mod commands; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod conn; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub mod identity; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod service; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub mod target; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub use service::{start, stop_for_exit, RemoteService}; + +/// Where remote-control settings are kept, beside the app's other settings. +pub const SETTINGS_STORE_FILE: &str = "settings.json"; +/// Their key in the store. +pub const SETTINGS_KEY: &str = "remote"; diff --git a/apps/player/src-tauri/src/remote/service.rs b/apps/player/src-tauri/src/remote/service.rs new file mode 100644 index 00000000..ca8b8e90 --- /dev/null +++ b/apps/player/src-tauri/src/remote/service.rs @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! Starts the target when the app starts, and connects it to Tauri: events +//! for the webview, the cache for catalog answers, and DNS-SD. + +use super::target::{self, CatalogPage, TargetHandle, TargetHost}; +use super::{identity, SETTINGS_KEY, SETTINGS_STORE_FILE}; +use crate::commands::AppState; +use crate::discovery::{self, Advertisement, Registration}; +use mvp_core::remote::catalog::CatalogQuery; +use mvp_core::remote::consent::RequestId; +use mvp_core::remote::identity::{DeviceId, DeviceKey}; +use mvp_core::remote::message::{Command, DeviceType, ErrorCode}; +use mvp_core::remote::settings::RemoteSettings; +use mvp_core::remote::target::{ConsentPrompt, SessionInfo}; +use serde::Serialize; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, MutexGuard}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tauri_plugin_store::StoreExt; + +/// This device's type, as other devices see it. +#[cfg(target_os = "macos")] +pub const DEVICE_TYPE: DeviceType = DeviceType::Mac; +#[cfg(target_os = "linux")] +pub const DEVICE_TYPE: DeviceType = DeviceType::LinuxPc; + +/// The running target, as Tauri managed state. +pub struct RemoteService { + pub(crate) handle: TargetHandle, + key_dir: PathBuf, + state: Mutex, +} + +struct Saved { + settings: RemoteSettings, + device_id: DeviceId, +} + +/// Locks a mutex, taking it over if a thread panicked holding it. The +/// values inside are whole after any single assignment. +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl RemoteService { + pub fn settings(&self) -> RemoteSettings { + lock(&self.state).settings.clone() + } + + pub fn device_id(&self) -> DeviceId { + lock(&self.state).device_id.clone() + } + + /// Saves the settings and hands them to the target. + pub fn set_settings( + &self, + app: &AppHandle, + settings: RemoteSettings, + ) -> Result<(), String> { + let store = app.store(SETTINGS_STORE_FILE).map_err(|e| e.to_string())?; + store.set( + SETTINGS_KEY, + serde_json::to_value(&settings).map_err(|e| e.to_string())?, + ); + store.save().map_err(|e| e.to_string())?; + lock(&self.state).settings = settings.clone(); + self.handle.update_settings(settings); + Ok(()) + } + + /// Makes and saves a new key, so a new device id. Every session ends. + pub fn reset_identity(&self) -> Result { + let key = DeviceKey::generate(); + identity::save(&self.key_dir, &key).map_err(|e| e.to_string())?; + let id = key.device_id(); + lock(&self.state).device_id = id.clone(); + self.handle.reset_identity(key); + tracing::info!("[remote] device identity reset; new id {id}"); + Ok(id) + } +} + +/// What `remote://consent` carries. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConsentEvent { + pub request: RequestId, + pub device_id: String, + pub name: String, + pub device_type: DeviceType, + pub code: String, + pub expires_in_ms: u64, +} + +/// One controller, as `remote://sessions` lists it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionEvent { + pub session_id: String, + pub device_id: String, + pub name: String, + pub device_type: DeviceType, + pub connected: bool, +} + +impl From<&SessionInfo> for SessionEvent { + fn from(s: &SessionInfo) -> Self { + Self { + session_id: s.session_id.clone(), + device_id: s.device_id.to_string(), + name: s.name.clone(), + device_type: s.device_type, + connected: s.connected, + } + } +} + +/// What `remote://command` carries. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandEvent<'a> { + pub token: u64, + pub command: &'a Command, +} + +struct TauriHost { + app: AppHandle, + advertisement: Mutex>, +} + +impl TargetHost for TauriHost { + fn show_consent(&self, prompt: &ConsentPrompt) { + let event = ConsentEvent { + request: prompt.request, + device_id: prompt.device_id.to_string(), + name: prompt.name.clone(), + device_type: prompt.device_type, + code: prompt.code.clone(), + expires_in_ms: prompt + .expires_at + .saturating_duration_since(super::clock::now()) + .as_millis() as u64, + }; + let _ = self.app.emit("remote://consent", event); + // A dialog behind other windows would time out unseen. + crate::show_main_window(&self.app); + } + + fn hide_consent(&self, request: RequestId) { + let _ = self.app.emit( + "remote://consent-hidden", + serde_json::json!({ "request": request }), + ); + } + + fn sessions_changed(&self, sessions: &[SessionInfo]) { + let list: Vec = sessions.iter().map(SessionEvent::from).collect(); + let _ = self.app.emit("remote://sessions", list); + } + + fn apply(&self, token: u64, command: &Command) { + let _ = self + .app + .emit("remote://command", CommandEvent { token, command }); + } + + fn catalog(&self, query: CatalogQuery) -> CatalogPage { + let state = self.app.state::(); + let cache = state.cache.lock().map_err(|_| ErrorCode::Failed)?; + super::catalog::answer(&cache, query) + } + + fn advertise(&self, registration: Option) { + let mut current = lock(&self.advertisement); + // The old record goes first, with a goodbye, so the name is free. + *current = None; + if let Some(registration) = registration { + match discovery::advertise(®istration) { + Ok(ad) => *current = Some(ad), + Err(e) => tracing::warn!("[remote] couldn't advertise: {e}"), + } + } + } +} + +/// The saved settings, or the defaults. +fn load_settings(app: &AppHandle) -> RemoteSettings { + app.store(SETTINGS_STORE_FILE) + .ok() + .and_then(|store| store.get(SETTINGS_KEY)) + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default() +} + +/// Starts the target and manages a [`RemoteService`]. A failure is logged +/// and the app carries on without remote control. +pub fn start(app: &AppHandle, key_dir: PathBuf) { + let key = match identity::load_or_create(&key_dir) { + Ok(key) => key, + Err(e) => { + tracing::error!("[remote] couldn't load the device key: {e}"); + return; + } + }; + let settings = load_settings(app); + let host = Arc::new(TauriHost { + app: app.clone(), + advertisement: Mutex::new(None), + }); + let device_id = key.device_id(); + let started = tauri::async_runtime::block_on(async { + let listener = target::bind_default().await?; + target::start(listener, key, DEVICE_TYPE, settings.clone(), host) + }); + match started { + Ok(handle) => { + tracing::info!( + "[remote] listening on port {} as {device_id}", + handle.port() + ); + app.manage(RemoteService { + handle, + key_dir, + state: Mutex::new(Saved { + settings, + device_id, + }), + }); + } + Err(e) => tracing::error!("[remote] couldn't listen: {e}"), + } +} + +/// Closes every connection with `shutdown` and withdraws the record. +pub fn stop_for_exit(app: &AppHandle) { + if let Some(service) = app.try_state::() { + tauri::async_runtime::block_on(service.handle.shutdown()); + } +} diff --git a/apps/player/src-tauri/src/remote/target.rs b/apps/player/src-tauri/src/remote/target.rs new file mode 100644 index 00000000..175eb0c4 --- /dev/null +++ b/apps/player/src-tauri/src/remote/target.rs @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! The task that runs this device as a remote-control target. +//! +//! One task owns the core's [`Target`], so nothing else touches it and it +//! needs no lock. Everything that happens reaches it through its inbox: +//! new connections, frames, the user's answers, settings, the player's +//! state. It carries out the [`Action`]s the core returns, and calls +//! [`Target::tick`] when the core's next deadline comes. +//! +//! What it can't do itself goes to a [`TargetHost`]: showing the dialog, +//! applying commands to the player, reading the catalog and advertising. +//! The app's host does that through Tauri; tests use a fake one. + +use super::clock; +use super::conn::{self, Read, Writer}; +use crate::discovery::{Registration, Txt}; +use mvp_core::remote::catalog::{CatalogItem, CatalogQuery}; +use mvp_core::remote::consent::{Answer, RequestId}; +use mvp_core::remote::identity::DeviceKey; +use mvp_core::remote::message::{Command, DeviceType, ErrorCode, PlaybackSnapshot}; +use mvp_core::remote::settings::RemoteSettings; +use mvp_core::remote::target::{ + Action, ConsentPrompt, DiscoveryRecord, SessionInfo, Target, SERVICE_TYPE, +}; +use mvp_core::remote::ConnId; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, oneshot}; + +/// How long the player has to carry out a command before the controller +/// hears `failed`. +pub const COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +/// The longest the task sleeps without looking at the clock. Timers don't +/// count time asleep, so after a wake this is how soon the deadlines that +/// passed during the sleep are seen. +const MAX_NAP: Duration = Duration::from_secs(2); +/// How long quitting waits for the last frames to be written. +const SHUTDOWN_GRACE: Duration = Duration::from_millis(500); + +/// A catalog answer: a page of items and the cursor for the next one. +pub type CatalogPage = Result<(Vec, Option), ErrorCode>; + +/// What the target task asks of the app. +pub trait TargetHost: Send + Sync + 'static { + /// Show this consent dialog, in place of any other. + fn show_consent(&self, prompt: &ConsentPrompt); + /// Take this request's dialog off screen. + fn hide_consent(&self, request: RequestId); + /// The controllers changed. + fn sessions_changed(&self, sessions: &[SessionInfo]); + /// Carry out a command on the player, then call + /// [`TargetHandle::finish_command`] with `token`. + fn apply(&self, token: u64, command: &Command); + /// Answer a catalog query. Runs on a blocking thread. + fn catalog(&self, query: CatalogQuery) -> CatalogPage; + /// Advertise this, in place of anything advertised before, or stop. + fn advertise(&self, registration: Option); +} + +enum Input { + Accepted(TcpStream), + Answer(RequestId, Answer), + Disconnect(String), + Settings(RemoteSettings), + ResetIdentity(DeviceKey), + State(Box), + Finished(u64, Result<(), ErrorCode>), + CatalogDone(ConnId, u64, CatalogPage), + Sessions(oneshot::Sender>), + Shutdown(oneshot::Sender<()>), +} + +/// Talks to the running target task. Cheap to clone. +#[derive(Clone)] +pub struct TargetHandle { + inbox: mpsc::UnboundedSender, + port: u16, +} + +impl TargetHandle { + /// The TCP port the target listens on. + pub fn port(&self) -> u16 { + self.port + } + + fn send(&self, input: Input) { + // After shutdown there's no one to tell, and nothing to do. + let _ = self.inbox.send(input); + } + + pub fn answer(&self, request: RequestId, answer: Answer) { + self.send(Input::Answer(request, answer)); + } + + pub fn disconnect(&self, session_id: String) { + self.send(Input::Disconnect(session_id)); + } + + pub fn update_settings(&self, settings: RemoteSettings) { + self.send(Input::Settings(settings)); + } + + /// Ends every session and starts again with this key. + pub fn reset_identity(&self, key: DeviceKey) { + self.send(Input::ResetIdentity(key)); + } + + pub fn set_state(&self, state: PlaybackSnapshot) { + self.send(Input::State(Box::new(state))); + } + + pub fn finish_command(&self, token: u64, result: Result<(), ErrorCode>) { + self.send(Input::Finished(token, result)); + } + + /// The controllers, connected or waiting to resume. + pub async fn sessions(&self) -> Vec { + let (tx, rx) = oneshot::channel(); + self.send(Input::Sessions(tx)); + rx.await.unwrap_or_default() + } + + /// Closes every connection with `shutdown`, stops advertising, and + /// waits a moment for the last frames to go out. + pub async fn shutdown(&self) { + let (tx, rx) = oneshot::channel(); + self.send(Input::Shutdown(tx)); + let _ = rx.await; + } +} + +/// Binds the port remote control listens on: [`DEFAULT_PORT`] on every +/// address when it's free, any free port otherwise. One socket on `[::]` +/// takes IPv4 too; where that fails, `0.0.0.0`. +pub async fn bind_default() -> std::io::Result { + let candidates = [ + format!("[::]:{DEFAULT_PORT}"), + format!("0.0.0.0:{DEFAULT_PORT}"), + "[::]:0".to_owned(), + "0.0.0.0:0".to_owned(), + ]; + let mut last = None; + for address in candidates { + match TcpListener::bind(&address).await { + Ok(listener) => return Ok(listener), + Err(e) => last = Some(e), + } + } + Err(last.unwrap_or_else(|| std::io::Error::other("no address to bind"))) +} + +/// The port remote control uses when it's free, so a firewall rule can name it. +pub const DEFAULT_PORT: u16 = 47130; + +/// Starts the target on `listener`. +pub fn start( + listener: TcpListener, + key: DeviceKey, + device_type: DeviceType, + settings: RemoteSettings, + host: Arc, +) -> std::io::Result { + start_with(listener, key, device_type, settings, host, COMMAND_TIMEOUT) +} + +/// [`start`], with the time the player has for each command. +fn start_with( + listener: TcpListener, + key: DeviceKey, + device_type: DeviceType, + settings: RemoteSettings, + host: Arc, + command_timeout: Duration, +) -> std::io::Result { + let port = listener.local_addr()?.port(); + let (inbox, rx) = mpsc::unbounded_channel(); + let accept_inbox = inbox.clone(); + let accept = tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, _)) => { + if accept_inbox.send(Input::Accepted(stream)).is_err() { + break; + } + } + // Too many open files and the like pass; don't spin on them. + Err(e) => { + tracing::warn!("[remote] accept failed: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + }); + let (reads, read_rx) = mpsc::unbounded_channel(); + let task = Task { + target: Target::new(key.clone(), device_type, settings), + key, + device_type, + host, + port, + inbox: inbox.clone(), + writers: HashMap::new(), + reads, + pending: HashMap::new(), + next_token: 0, + command_timeout, + }; + tokio::spawn(task.run(rx, read_rx, accept)); + Ok(TargetHandle { inbox, port }) +} + +struct Pending { + conn: ConnId, + request: u64, + deadline: Instant, +} + +struct Task { + target: Target, + key: DeviceKey, + device_type: DeviceType, + host: Arc, + port: u16, + inbox: mpsc::UnboundedSender, + writers: HashMap, + reads: mpsc::UnboundedSender<(ConnId, Read)>, + /// Commands the player hasn't finished, by token. + pending: HashMap, + next_token: u64, + command_timeout: Duration, +} + +impl Task { + async fn run( + mut self, + mut inbox: mpsc::UnboundedReceiver, + mut reads: mpsc::UnboundedReceiver<(ConnId, Read)>, + accept: tokio::task::JoinHandle<()>, + ) { + self.advertise(self.target.discovery_record()); + loop { + let nap = self + .next_deadline() + .map_or(MAX_NAP, |at| at.saturating_duration_since(clock::now())) + .min(MAX_NAP); + tokio::select! { + input = inbox.recv() => match input { + Some(Input::Shutdown(done)) => { + accept.abort(); + let actions = self.target.shutdown(); + self.carry_out(actions); + self.writers.clear(); + self.host.advertise(None); + tokio::time::sleep(SHUTDOWN_GRACE).await; + let _ = done.send(()); + return; + } + Some(input) => self.handle(input), + None => { + accept.abort(); + return; + } + }, + Some((conn, read)) = reads.recv() => { + let now = clock::now(); + let actions = match read { + Read::Frame(frame) => self.target.receive(conn, &frame, now), + Read::Gone => { + self.writers.remove(&conn); + self.target.disconnected(conn, now) + } + }; + self.carry_out(actions); + } + () = tokio::time::sleep(nap) => self.tick(), + } + } + } + + fn next_deadline(&self) -> Option { + let commands = self.pending.values().map(|p| p.deadline).min(); + [self.target.next_deadline(), commands] + .into_iter() + .flatten() + .min() + } + + fn tick(&mut self) { + let now = clock::now(); + let actions = self.target.tick(now); + self.carry_out(actions); + let late: Vec = self + .pending + .iter() + .filter(|(_, p)| p.deadline <= now) + .map(|(&token, _)| token) + .collect(); + for token in late { + tracing::warn!("[remote] the player didn't finish command {token} in time"); + self.finish(token, Err(ErrorCode::Failed)); + } + } + + fn finish(&mut self, token: u64, result: Result<(), ErrorCode>) { + if let Some(p) = self.pending.remove(&token) { + let actions = self.target.finish_command(p.conn, p.request, result); + self.carry_out(actions); + } + } + + fn handle(&mut self, input: Input) { + let now = clock::now(); + let actions = match input { + Input::Accepted(stream) => { + match self.target.accept(now) { + Some(conn) => { + let writer = conn::spawn(stream, conn, self.reads.clone()); + self.writers.insert(conn, writer); + } + None => tracing::info!("[remote] too many connections; turned one away"), + } + Vec::new() + } + Input::Answer(request, answer) => self.target.answer(request, answer, now), + Input::Disconnect(session_id) => self.target.disconnect(&session_id), + Input::Settings(settings) => self.target.update_settings(settings, now), + Input::ResetIdentity(key) => { + let mut actions = self.target.shutdown(); + let settings = self.target.settings().clone(); + self.key = key; + self.target = Target::new(self.key.clone(), self.device_type, settings); + self.pending.clear(); + actions.push(Action::Advertise(self.target.discovery_record())); + actions.push(Action::SessionsChanged(Vec::new())); + actions + } + Input::State(state) => self.target.set_state(*state, now), + Input::Finished(token, result) => { + self.finish(token, result); + Vec::new() + } + Input::CatalogDone(conn, request, page) => { + self.target.finish_catalog(conn, request, page) + } + Input::Sessions(reply) => { + let _ = reply.send(self.target.sessions()); + Vec::new() + } + Input::Shutdown(_) => unreachable!("run handles Shutdown"), + }; + self.carry_out(actions); + } + + fn carry_out(&mut self, actions: Vec) { + for action in actions { + match action { + Action::Send { conn, bytes } => { + if let Some(writer) = self.writers.get(&conn) { + let _ = writer.send(bytes); + } + } + Action::Close { conn } => { + // The writer flushes what's queued, then closes. + self.writers.remove(&conn); + self.pending.retain(|_, p| p.conn != conn); + } + Action::ShowConsent(prompt) => self.host.show_consent(&prompt), + Action::HideConsent(request) => self.host.hide_consent(request), + Action::SessionsChanged(sessions) => self.host.sessions_changed(&sessions), + Action::Apply { + conn, + request, + command, + } => { + self.next_token += 1; + let token = self.next_token; + self.pending.insert( + token, + Pending { + conn, + request, + deadline: clock::now() + self.command_timeout, + }, + ); + self.host.apply(token, &command); + } + Action::Catalog { + conn, + request, + query, + } => { + let host = self.host.clone(); + let inbox = self.inbox.clone(); + tokio::task::spawn_blocking(move || { + let page = host.catalog(query); + let _ = inbox.send(Input::CatalogDone(conn, request, page)); + }); + } + Action::Advertise(record) => self.advertise(record), + } + } + } + + fn advertise(&self, record: Option) { + self.host + .advertise(record.map(|r| registration(r, &self.key, self.port))); + } +} + +/// The DNS-SD registration for the core's record. The SRV host is the +/// device id, so the machine's own host name isn't published. +fn registration(record: DiscoveryRecord, key: &DeviceKey, port: u16) -> Registration { + Registration { + service_type: SERVICE_TYPE.to_owned(), + instance: record.instance, + host_label: key.device_id().to_string(), + port, + txt: Txt::new(record.txt), + } +} + +#[cfg(test)] +#[path = "target_tests.rs"] +mod tests; diff --git a/apps/player/src-tauri/src/remote/target_tests.rs b/apps/player/src-tauri/src/remote/target_tests.rs new file mode 100644 index 00000000..4269943c --- /dev/null +++ b/apps/player/src-tauri/src/remote/target_tests.rs @@ -0,0 +1,653 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! The target task over loopback TCP, with the core's own controller on the +//! other end and a fake host in place of the webview. + +use super::*; +use mvp_core::remote::channel::FrameReader; +use mvp_core::remote::controller::{Controller, Event}; +use mvp_core::remote::message::{CloseReason, Feature, Hello, PlaybackStatus, PROTOCOL_VERSION}; +use mvp_core::remote::settings::ControlMode; +use std::sync::Mutex; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::timeout; + +const WAIT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, PartialEq)] +enum Seen { + Consent(RequestId, String), + Hidden(RequestId), + Sessions(Vec<(String, bool)>), + Apply(u64, Command), + Advertise(Option), +} + +struct FakeHost { + seen: mpsc::UnboundedSender, + catalog_calls: Mutex>, +} + +impl TargetHost for FakeHost { + fn show_consent(&self, prompt: &ConsentPrompt) { + let _ = self + .seen + .send(Seen::Consent(prompt.request, prompt.code.clone())); + } + fn hide_consent(&self, request: RequestId) { + let _ = self.seen.send(Seen::Hidden(request)); + } + fn sessions_changed(&self, sessions: &[SessionInfo]) { + let list = sessions + .iter() + .map(|s| (s.name.clone(), s.connected)) + .collect(); + let _ = self.seen.send(Seen::Sessions(list)); + } + fn apply(&self, token: u64, command: &Command) { + let _ = self.seen.send(Seen::Apply(token, command.clone())); + } + fn catalog(&self, query: CatalogQuery) -> CatalogPage { + self.catalog_calls.lock().unwrap().push(query); + Ok(( + vec![CatalogItem { + id: "live-1".into(), + name: "News".into(), + content_type: "live".into(), + group: "UK".into(), + ..CatalogItem::default() + }], + Some("live-2".into()), + )) + } + fn advertise(&self, registration: Option) { + let _ = self.seen.send(Seen::Advertise(registration)); + } +} + +struct Harness { + handle: TargetHandle, + key: DeviceKey, + seen: mpsc::UnboundedReceiver, +} + +impl Harness { + async fn start(mode: ControlMode) -> Self { + Self::start_with_timeout(mode, COMMAND_TIMEOUT).await + } + + async fn start_with_timeout(mode: ControlMode, command_timeout: Duration) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let key = DeviceKey::generate(); + let (tx, seen) = mpsc::unbounded_channel(); + let host = Arc::new(FakeHost { + seen: tx, + catalog_calls: Mutex::new(Vec::new()), + }); + let settings = RemoteSettings { + remote_control: mode, + ..RemoteSettings::default() + }; + let handle = start_with( + listener, + key.clone(), + DeviceType::LinuxPc, + settings, + host, + command_timeout, + ) + .unwrap(); + let mut harness = Self { handle, key, seen }; + let first = harness.next_seen().await; + assert!(matches!(first, Seen::Advertise(Some(_))), "{first:?}"); + harness + } + + async fn next_seen(&mut self) -> Seen { + timeout(WAIT, self.seen.recv()) + .await + .expect("the host heard nothing") + .unwrap() + } + + /// The next thing the host saw that `pick` wants, skipping the rest. + async fn wait_for(&mut self, mut pick: impl FnMut(&Seen) -> Option) -> T { + loop { + let seen = self.next_seen().await; + if let Some(found) = pick(&seen) { + return found; + } + } + } + + async fn connect(&self, name: &str) -> Client { + self.connect_as(DeviceKey::generate(), name, None).await + } + + async fn connect_as(&self, key: DeviceKey, name: &str, resume: Option) -> Client { + let stream = tokio::net::TcpStream::connect(("127.0.0.1", self.handle.port())) + .await + .unwrap(); + let hello = Hello { + id: key.device_id(), + name: name.into(), + device_type: DeviceType::Iphone, + versions: vec![PROTOCOL_VERSION], + features: vec![Feature::Control, Feature::Catalog], + }; + let (controller, first) = + Controller::connect(&key, hello, self.key.device_id(), resume).unwrap(); + let mut client = Client { + stream, + controller, + reader: FrameReader::new(), + events: Vec::new(), + key, + }; + client.write(&first).await; + client + } +} + +struct Client { + stream: tokio::net::TcpStream, + controller: Controller, + reader: FrameReader, + events: Vec, + key: DeviceKey, +} + +impl Client { + async fn write(&mut self, bytes: &[u8]) { + self.stream.write_all(bytes).await.unwrap(); + } + + /// Reads until `pick` finds an event, answering the handshake on the way. + /// `None` when the connection closed first. + async fn wait_for(&mut self, mut pick: impl FnMut(&Event) -> Option) -> Option { + let mut buf = vec![0u8; 16 * 1024]; + let mut checked = 0; + loop { + while checked < self.events.len() { + let event = &self.events[checked]; + checked += 1; + if let Some(found) = pick(event) { + return Some(found); + } + } + let n = timeout(WAIT, self.stream.read(&mut buf)) + .await + .expect("the target sent nothing") + .unwrap_or(0); + if n == 0 { + return None; + } + self.reader.push(&buf[..n]); + while let Some(frame) = self.reader.next_frame().unwrap() { + let output = self.controller.receive(&frame); + for bytes in output.send { + self.write(&bytes).await; + } + self.events.extend(output.events); + } + } + } + + async fn code(&mut self) -> String { + self.wait_for(|e| match e { + Event::Verified { code, .. } => Some(code.to_string()), + _ => None, + }) + .await + .expect("verified") + } + + /// Drives the handshake until the target says it's asking its user. + async fn pending(&mut self) { + self.wait_for(|e| matches!(e, Event::Pending { .. }).then_some(())) + .await + .expect("pending"); + } + + async fn connected(&mut self) -> String { + self.wait_for(|e| match e { + Event::Connected { session_id, .. } => Some(session_id.clone()), + _ => None, + }) + .await + .expect("connected") + } + + async fn closed(&mut self) -> Option { + self.wait_for(|e| match e { + Event::Closed(reason) => Some(*reason), + _ => None, + }) + .await + } + + async fn command(&mut self, command: Command) -> Result { + let (id, bytes) = self.controller.command(command).unwrap(); + self.write(&bytes).await; + self.wait_for(|e| match e { + Event::Reply { request, result } if *request == id => Some(*result), + _ => None, + }) + .await + .expect("a reply") + } +} + +fn playing(item: &str) -> PlaybackSnapshot { + PlaybackSnapshot { + status: PlaybackStatus::Playing, + item: Some(CatalogItem { + id: item.into(), + name: "News".into(), + content_type: "live".into(), + ..CatalogItem::default() + }), + volume: Some(50), + capabilities: mvp_core::remote::message::Capabilities { + pause: true, + volume: true, + ..Default::default() + }, + ..PlaybackSnapshot::default() + } +} + +#[tokio::test] +async fn advertises_the_cores_record_under_the_device_id() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let key = DeviceKey::generate(); + let (tx, mut seen) = mpsc::unbounded_channel(); + let host = Arc::new(FakeHost { + seen: tx, + catalog_calls: Mutex::new(Vec::new()), + }); + let _handle = start( + listener, + key.clone(), + DeviceType::LinuxPc, + RemoteSettings::default(), + host, + ) + .unwrap(); + let Some(Seen::Advertise(Some(r))) = seen.recv().await else { + panic!("advertised"); + }; + let id = key.device_id().to_string(); + assert_eq!(r.service_type, "_cinette._tcp"); + assert_eq!(r.instance, "Linux PC"); + assert_eq!(r.host_label, id); + assert_eq!(r.port, port); + assert_eq!(r.txt.get_str("id"), Some(id.as_str())); + assert_eq!(r.txt.get_str("t"), Some("linux-pc")); + assert_eq!(r.txt.get_str("rc"), Some("1")); + assert_eq!(r.txt.get_str("v"), Some("1")); + assert_eq!(r.txt.get_str("n"), Some("Linux PC")); +} + +#[tokio::test] +async fn always_allow_lets_a_controller_in_and_attaches_to_what_plays() { + let mut h = Harness::start(ControlMode::Always).await; + h.handle.set_state(playing("live-7")); + let mut phone = h.connect("Phone").await; + phone.connected().await; + let state = phone + .wait_for(|e| match e { + Event::Connected { state, .. } => Some(state.clone()), + _ => None, + }) + .await + .unwrap(); + assert_eq!( + state.state.item.unwrap().id, + "live-7", + "nothing was interrupted" + ); + let sessions = h + .wait_for(|s| match s { + Seen::Sessions(list) if !list.is_empty() => Some(list.clone()), + _ => None, + }) + .await; + assert_eq!(sessions, vec![("Phone".to_owned(), true)]); +} + +#[tokio::test] +async fn only_when_allowed_shows_the_code_both_sides_see() { + let mut h = Harness::start(ControlMode::Ask).await; + let mut phone = h.connect("Phone").await; + let code = phone.code().await; + let (request, shown) = h + .wait_for(|s| match s { + Seen::Consent(r, c) => Some((*r, c.clone())), + _ => None, + }) + .await; + assert_eq!(shown, code); + h.handle.answer(request, Answer::Allow); + phone.connected().await; + assert_eq!( + h.wait_for(|s| match s { + Seen::Hidden(r) => Some(*r), + _ => None, + }) + .await, + request + ); +} + +#[tokio::test] +async fn deny_grants_nothing() { + let mut h = Harness::start(ControlMode::Ask).await; + let mut phone = h.connect("Phone").await; + phone.pending().await; + let request = h + .wait_for(|s| match s { + Seen::Consent(r, _) => Some(*r), + _ => None, + }) + .await; + h.handle.answer(request, Answer::Deny); + assert_eq!(phone.closed().await, Some(CloseReason::Denied)); + assert!(phone.controller.session_id().is_none()); +} + +#[tokio::test] +async fn never_allow_refuses_without_a_dialog() { + let mut h = Harness::start(ControlMode::Never).await; + let mut phone = h.connect("Phone").await; + assert_eq!(phone.closed().await, Some(CloseReason::NotAllowed)); + h.handle.set_state(playing("x")); + assert!(h.handle.sessions().await.is_empty()); + while let Ok(seen) = h.seen.try_recv() { + assert!(!matches!(seen, Seen::Consent(..)), "no dialog"); + } +} + +#[tokio::test] +async fn commands_go_to_the_player_and_its_answer_comes_back() { + let mut h = Harness::start(ControlMode::Always).await; + h.handle.set_state(playing("live-7")); + let mut phone = h.connect("Phone").await; + phone.connected().await; + + let (id, bytes) = phone + .controller + .command(Command::Pause { + item_id: "live-7".into(), + }) + .unwrap(); + phone.write(&bytes).await; + let token = h + .wait_for(|s| match s { + Seen::Apply(token, Command::Pause { item_id }) if item_id == "live-7" => Some(*token), + _ => None, + }) + .await; + h.handle.finish_command(token, Ok(())); + let result = phone + .wait_for(|e| match e { + Event::Reply { request, result } if *request == id => Some(*result), + _ => None, + }) + .await; + assert!(matches!(result, Some(Ok(_))), "{result:?}"); + + // The core checks what it can before the player sees it. + assert_eq!( + phone + .command(Command::Pause { + item_id: "live-8".into() + }) + .await, + Err(ErrorCode::StaleItem) + ); +} + +#[tokio::test] +async fn the_players_error_reaches_the_controller() { + let mut h = Harness::start(ControlMode::Always).await; + let mut phone = h.connect("Phone").await; + phone.connected().await; + let (id, bytes) = phone + .controller + .command(Command::Load { + item_id: "gone".into(), + start_at: None, + }) + .unwrap(); + phone.write(&bytes).await; + let token = h + .wait_for(|s| match s { + Seen::Apply(token, _) => Some(*token), + _ => None, + }) + .await; + h.handle.finish_command(token, Err(ErrorCode::NotFound)); + let result = phone + .wait_for(|e| match e { + Event::Reply { request, result } if *request == id => Some(*result), + _ => None, + }) + .await; + assert_eq!(result, Some(Err(ErrorCode::NotFound))); +} + +#[tokio::test] +async fn a_command_the_player_never_finishes_fails() { + let mut h = Harness::start_with_timeout(ControlMode::Always, Duration::from_millis(200)).await; + let mut phone = h.connect("Phone").await; + phone.connected().await; + let (id, bytes) = phone.controller.command(Command::Stop).unwrap(); + phone.write(&bytes).await; + let token = h + .wait_for(|s| match s { + Seen::Apply(token, Command::Stop) => Some(*token), + _ => None, + }) + .await; + let result = phone + .wait_for(|e| match e { + Event::Reply { request, result } if *request == id => Some(*result), + _ => None, + }) + .await; + assert_eq!(result, Some(Err(ErrorCode::Failed))); + // A late answer changes nothing. + h.handle.finish_command(token, Ok(())); + assert!(h.handle.sessions().await.len() == 1); +} + +#[tokio::test] +async fn every_controller_gets_every_state() { + let h = Harness::start(ControlMode::Always).await; + let mut phone = h.connect("Phone").await; + let mut laptop = h.connect("Laptop").await; + phone.connected().await; + laptop.connected().await; + h.handle.set_state(playing("live-9")); + for client in [&mut phone, &mut laptop] { + let state = client + .wait_for(|e| match e { + Event::State(s) if s.state.item.is_some() => Some(s.clone()), + _ => None, + }) + .await + .unwrap(); + assert_eq!(state.state.item.unwrap().id, "live-9"); + } +} + +#[tokio::test] +async fn catalog_queries_are_answered_by_the_host() { + let h = Harness::start(ControlMode::Always).await; + let mut phone = h.connect("Phone").await; + phone.connected().await; + let (id, bytes) = phone + .controller + .catalog(CatalogQuery::Search { + query: "news".into(), + content_type: None, + limit: 10, + }) + .unwrap(); + phone.write(&bytes).await; + let (items, next) = phone + .wait_for(|e| match e { + Event::Page { + request, + items, + next, + } if *request == id => Some((items.clone(), next.clone())), + _ => None, + }) + .await + .unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "live-1"); + assert_eq!(next.as_deref(), Some("live-2")); +} + +#[tokio::test] +async fn never_allow_ends_open_sessions_and_says_so_in_the_record() { + let mut h = Harness::start(ControlMode::Always).await; + let mut phone = h.connect("Phone").await; + phone.connected().await; + h.handle.update_settings(RemoteSettings { + remote_control: ControlMode::Never, + ..RemoteSettings::default() + }); + assert_eq!(phone.closed().await, Some(CloseReason::Revoked)); + let record = h + .wait_for(|s| match s { + Seen::Advertise(r) => Some(r.clone()), + _ => None, + }) + .await + .unwrap(); + assert_eq!(record.txt.get_str("rc"), Some("0")); + assert!(h.handle.sessions().await.is_empty()); +} + +#[tokio::test] +async fn turning_discovery_off_stops_advertising_and_keeps_sessions() { + let mut h = Harness::start(ControlMode::Always).await; + let mut phone = h.connect("Phone").await; + phone.connected().await; + h.handle.update_settings(RemoteSettings { + discoverable: false, + remote_control: ControlMode::Always, + device_name: None, + }); + let record = h + .wait_for(|s| match s { + Seen::Advertise(r) => Some(r.clone()), + _ => None, + }) + .await; + assert_eq!(record, None); + assert_eq!(h.handle.sessions().await.len(), 1); + let mut other = h.connect("Other").await; + assert_eq!(other.closed().await, Some(CloseReason::NotDiscoverable)); + // The open session still takes commands. + let (_, bytes) = phone.controller.command(Command::Stop).unwrap(); + phone.write(&bytes).await; + h.wait_for(|s| matches!(s, Seen::Apply(_, Command::Stop)).then_some(())) + .await; +} + +#[tokio::test] +async fn the_target_can_disconnect_a_controller_and_the_target_keeps_playing() { + let mut h = Harness::start(ControlMode::Always).await; + h.handle.set_state(playing("live-1")); + let mut phone = h.connect("Phone").await; + let session = phone.connected().await; + h.handle.disconnect(session); + assert_eq!(phone.closed().await, Some(CloseReason::Revoked)); + assert!(h.handle.sessions().await.is_empty()); + while let Ok(seen) = h.seen.try_recv() { + assert!( + !matches!(seen, Seen::Apply(..)), + "nothing was sent to the player" + ); + } +} + +#[tokio::test] +async fn a_dropped_connection_resumes_without_asking_again() { + let mut h = Harness::start(ControlMode::Ask).await; + let key = DeviceKey::generate(); + let mut phone = h.connect_as(key.clone(), "Phone", None).await; + phone.pending().await; + let request = h + .wait_for(|s| match s { + Seen::Consent(r, _) => Some(*r), + _ => None, + }) + .await; + h.handle.answer(request, Answer::Allow); + let session = phone.connected().await; + drop(phone); + // The session waits to resume. + h.wait_for(|s| match s { + Seen::Sessions(list) if list == &[("Phone".to_owned(), false)] => Some(()), + _ => None, + }) + .await; + + let mut again = h.connect_as(key, "Phone", Some(session.clone())).await; + assert_eq!(again.connected().await, session); + while let Ok(seen) = h.seen.try_recv() { + assert!(!matches!(seen, Seen::Consent(..)), "no second dialog"); + } + assert_eq!( + again.key.device_id(), + h.handle.sessions().await[0].device_id + ); +} + +#[tokio::test] +async fn shutting_down_tells_every_controller_and_stops_advertising() { + let mut h = Harness::start(ControlMode::Always).await; + let mut phone = h.connect("Phone").await; + phone.connected().await; + h.handle.shutdown().await; + assert_eq!(phone.closed().await, Some(CloseReason::Shutdown)); + assert_eq!( + h.wait_for(|s| match s { + Seen::Advertise(r) => Some(r.clone()), + _ => None, + }) + .await, + None + ); +} + +#[tokio::test] +async fn a_new_identity_ends_sessions_and_advertises_the_new_id() { + let mut h = Harness::start(ControlMode::Always).await; + let mut phone = h.connect("Phone").await; + phone.connected().await; + let new_key = DeviceKey::generate(); + h.handle.reset_identity(new_key.clone()); + assert_eq!(phone.closed().await, Some(CloseReason::Shutdown)); + let record = h + .wait_for(|s| match s { + Seen::Advertise(Some(r)) => Some(r.clone()), + _ => None, + }) + .await; + let id = new_key.device_id().to_string(); + assert_eq!(record.txt.get_str("id"), Some(id.as_str())); + assert_eq!(record.host_label, id); + + // Connecting to the old id now fails the handshake. + let mut stale = h.connect("Phone").await; + assert_eq!(stale.closed().await, Some(CloseReason::IdentityMismatch)); +} diff --git a/apps/player/src/App.tsx b/apps/player/src/App.tsx index 6d8656b4..306d810e 100644 --- a/apps/player/src/App.tsx +++ b/apps/player/src/App.tsx @@ -15,6 +15,7 @@ import { useUpdateChecker } from "./hooks/useUpdateChecker"; import { useSplashScreen } from "./hooks/useSplashScreen"; import { useDonationPrompt } from "./hooks/useDonationPrompt"; import { useLockScreenSkip } from "./hooks/useLockScreenSkip"; +import { useRemoteTarget } from "./hooks/useRemoteTarget"; import { FullscreenProvider } from "./lib/fullscreen-context"; import { DownloadsProvider } from "@/hooks/useDownloads"; import { isMobilePlatform } from "@/lib/platform"; @@ -55,6 +56,8 @@ const AppRoutes = ({ updateState }: AppRoutesProps) => { }); useLockScreenSkip(); + // Other Cinette devices' commands for this one. + useRemoteTarget(); // No donation prompt on mobile, where App Store rules don't allow one. const donation = useDonationPrompt({ enabled: splash.dismissed && !isMobilePlatform() }); diff --git a/apps/player/src/components/player/VideoPlayer.test.tsx b/apps/player/src/components/player/VideoPlayer.test.tsx index adff1446..a983e4b9 100644 --- a/apps/player/src/components/player/VideoPlayer.test.tsx +++ b/apps/player/src/components/player/VideoPlayer.test.tsx @@ -98,6 +98,9 @@ const tauri = vi.hoisted(() => ({ stopDownload: vi.fn(async () => {}), removeDownload: vi.fn(async () => {}), getSkipSegments: vi.fn(async () => []), + // No device controls this one in these tests. + remoteSessions: vi.fn(async () => []), + remoteSetState: vi.fn(async () => {}), })); /** diff --git a/apps/player/src/components/player/VideoPlayer.tsx b/apps/player/src/components/player/VideoPlayer.tsx index 74414e55..526d905b 100644 --- a/apps/player/src/components/player/VideoPlayer.tsx +++ b/apps/player/src/components/player/VideoPlayer.tsx @@ -17,6 +17,7 @@ import { useState, useCallback, useEffect, useLayoutEffect, useRef, useMemo } fr import { useLocation, useNavigate } from "react-router"; import { useMpv } from "@/hooks/useMpv"; import { useLocalPlayback } from "@/hooks/useLocalPlayback"; +import { usePublishRemoteState } from "@/hooks/useRemoteTarget"; import { useLatestRef } from "@/hooks/useLatestRef"; import { useChanged } from "@/hooks/useChanged"; import { useMediaDetails } from "@/hooks/useMediaDetails"; @@ -470,6 +471,8 @@ export const PlayerView = () => { // The player's controls go through the active destination, which is // always this device for now. It starts an item the way the player does. const playback = useLocalPlayback(mpv, activeChannel, commitPlayback); + // Other Cinette devices control this player and see what it plays. + usePublishRemoteState(playback, activeChannel); /** * Entry point for every "play this item" action. Movies and episodes with diff --git a/apps/player/src/hooks/useRemoteTarget.test.ts b/apps/player/src/hooks/useRemoteTarget.test.ts new file mode 100644 index 00000000..da6d3a28 --- /dev/null +++ b/apps/player/src/hooks/useRemoteTarget.test.ts @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { EventCallback } from "@tauri-apps/api/event"; + +const handlers = vi.hoisted(() => new Map>()); +const unlisteners = vi.hoisted(() => [] as ReturnType[]); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn((event: string, handler: EventCallback) => { + handlers.set(event, handler); + const stop = vi.fn(() => handlers.delete(event)); + unlisteners.push(stop); + return Promise.resolve(stop); + }), +})); + +vi.mock("@/lib/tauri", () => ({ + remoteFinishCommand: vi.fn(() => Promise.resolve()), + remoteSessions: vi.fn(() => Promise.resolve([])), + remoteSetState: vi.fn(() => Promise.resolve()), +})); + +const navigate = vi.hoisted(() => vi.fn()); +vi.mock("react-router", () => ({ useNavigate: () => navigate })); + +const channels = vi.hoisted(() => ({ list: [] as Channel[] })); +vi.mock("@/hooks/useChannels", () => ({ useChannels: () => ({ channels: channels.list }) })); + +import { listen } from "@tauri-apps/api/event"; +import { remoteFinishCommand, remoteSessions, remoteSetState } from "@/lib/tauri"; +import { usePublishRemoteState, useRemoteSessions, useRemoteTarget } from "./useRemoteTarget"; +import { getMountedPlayer, setMountedPlayer } from "@/lib/remote-target"; +import { localCapabilities, type PlaybackDestination, type PlaybackState } from "@/lib/destination"; +import type { Channel } from "@/lib/types"; + +const channel: Channel = { + id: "live-1", + name: "News", + url: "http://iptv.example/live/u/p/1.ts", + groupTitle: "UK", + isFavorite: false, + contentType: "live", + sources: [], +}; + +const emit = (event: string, payload: unknown) => + act(() => { + handlers.get(event)?.({ event, id: 1, payload }); + }); + +const session = (connected: boolean) => ({ + sessionId: "s1", + deviceId: "d1", + name: "Phone", + deviceType: "iphone", + connected, +}); + +const playingState: PlaybackState = { + status: "playing", + item: { title: "News", contentType: "live", ref: "live-1" }, + position: 5, + positionAt: 0, + duration: 0, + volume: 40, + subtitles: [], + subtitleId: null, + audio: [], + audioId: null, + connection: "connected", + error: null, +}; + +const fakeDestination = () => { + const listeners = new Set<(s: PlaybackState) => void>(); + let current = playingState; + const destination = { + capabilities: localCapabilities(false), + getState: () => current, + subscribe: (l: (s: PlaybackState) => void) => { + listeners.add(l); + return () => listeners.delete(l); + }, + load: vi.fn(() => Promise.resolve()), + play: vi.fn(() => Promise.resolve()), + }; + const push = (next: PlaybackState) => { + current = next; + for (const l of listeners) l(next); + }; + return { + destination: destination as unknown as PlaybackDestination, + listeners, + push, + mocks: destination, + }; +}; + +beforeEach(() => { + handlers.clear(); + unlisteners.length = 0; + channels.list = [channel]; + setMountedPlayer(null); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useRemoteTarget", () => { + it("opens the player for a load when none is open and reports ok", async () => { + renderHook(() => useRemoteTarget()); + await waitFor(() => expect(handlers.has("remote://command")).toBe(true)); + emit("remote://command", { token: 4, command: { action: "load", itemId: "live-1" } }); + await waitFor(() => expect(remoteFinishCommand).toHaveBeenCalledWith(4, "ok")); + expect(navigate).toHaveBeenCalledWith("/player", { + state: { url: channel.url, channelName: "News", channel }, + }); + }); + + it("uses the channels as they are when the command comes", async () => { + channels.list = []; + const { rerender } = renderHook(() => useRemoteTarget()); + await waitFor(() => expect(handlers.has("remote://command")).toBe(true)); + channels.list = [channel]; + rerender(); + emit("remote://command", { token: 5, command: { action: "load", itemId: "live-1" } }); + await waitFor(() => expect(remoteFinishCommand).toHaveBeenCalledWith(5, "ok")); + expect(vi.mocked(listen)).toHaveBeenCalledTimes(1); + }); + + it("sends other commands to the mounted player", async () => { + const { destination, mocks } = fakeDestination(); + setMountedPlayer(destination); + renderHook(() => useRemoteTarget()); + await waitFor(() => expect(handlers.has("remote://command")).toBe(true)); + emit("remote://command", { token: 6, command: { action: "play", itemId: "live-1" } }); + await waitFor(() => expect(remoteFinishCommand).toHaveBeenCalledWith(6, "ok")); + expect(mocks.play).toHaveBeenCalledTimes(1); + }); + + it("reports what wasn't found", async () => { + renderHook(() => useRemoteTarget()); + await waitFor(() => expect(handlers.has("remote://command")).toBe(true)); + emit("remote://command", { token: 7, command: { action: "load", itemId: "nope" } }); + await waitFor(() => expect(remoteFinishCommand).toHaveBeenCalledWith(7, "notFound")); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("stops listening when unmounted, even before listening started", async () => { + const view = renderHook(() => useRemoteTarget()); + await waitFor(() => expect(unlisteners).toHaveLength(1)); + view.unmount(); + expect(unlisteners[0]).toHaveBeenCalledTimes(1); + + let resolve: (stop: () => void) => void = () => {}; + vi.mocked(listen).mockImplementationOnce( + () => new Promise((r) => (resolve = r as typeof resolve)) + ); + const early = renderHook(() => useRemoteTarget()); + early.unmount(); + const stop = vi.fn(); + await act(async () => resolve(stop)); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it("survives a failure to listen", async () => { + vi.mocked(listen).mockRejectedValueOnce(new Error("no Tauri")); + const view = renderHook(() => useRemoteTarget()); + await act(async () => {}); + view.unmount(); + }); +}); + +describe("useRemoteSessions", () => { + it("starts from the backend's list and follows the events", async () => { + vi.mocked(remoteSessions).mockResolvedValueOnce([session(true)]); + const { result } = renderHook(() => useRemoteSessions()); + await waitFor(() => expect(result.current).toEqual([session(true)])); + await waitFor(() => expect(handlers.has("remote://sessions")).toBe(true)); + emit("remote://sessions", [session(false)]); + expect(result.current).toEqual([session(false)]); + }); + + it("stays empty where remote control doesn't run", async () => { + vi.mocked(remoteSessions).mockRejectedValueOnce("unavailable"); + const { result } = renderHook(() => useRemoteSessions()); + await act(async () => {}); + expect(result.current).toEqual([]); + }); + + it("ignores a list that arrives after unmounting", async () => { + let resolve: (list: ReturnType[]) => void = () => {}; + vi.mocked(remoteSessions).mockImplementationOnce(() => new Promise((r) => (resolve = r))); + const { result, unmount } = renderHook(() => useRemoteSessions()); + unmount(); + await act(async () => resolve([session(true)])); + expect(result.current).toEqual([]); + }); +}); + +describe("usePublishRemoteState", () => { + it("registers the player while mounted", () => { + const { destination } = fakeDestination(); + const view = renderHook(() => usePublishRemoteState(destination, channel)); + expect(getMountedPlayer()).toBe(destination); + view.unmount(); + expect(getMountedPlayer()).toBeNull(); + }); + + it("leaves a newer player registered when an older one unmounts", () => { + const older = fakeDestination().destination; + const newer = fakeDestination().destination; + const view = renderHook(() => usePublishRemoteState(older, channel)); + setMountedPlayer(newer); + view.unmount(); + expect(getMountedPlayer()).toBe(newer); + }); + + it("sends nothing until a controller is connected", async () => { + const { destination, push } = fakeDestination(); + renderHook(() => usePublishRemoteState(destination, channel)); + await waitFor(() => expect(handlers.has("remote://sessions")).toBe(true)); + push({ ...playingState, position: 9 }); + emit("remote://sessions", [session(false)]); + expect(remoteSetState).not.toHaveBeenCalled(); + }); + + it("sends the state at once and on every change while connected", async () => { + vi.spyOn(performance, "now").mockReturnValue(0); + const { destination, push, listeners } = fakeDestination(); + renderHook(() => usePublishRemoteState(destination, channel)); + await waitFor(() => expect(handlers.has("remote://sessions")).toBe(true)); + emit("remote://sessions", [session(true)]); + expect(remoteSetState).toHaveBeenCalledTimes(1); + expect(vi.mocked(remoteSetState).mock.calls[0][0]).toMatchObject({ + status: "playing", + position: 5, + item: { id: "live-1", name: "News" }, + }); + act(() => push({ ...playingState, status: "paused", position: 9 })); + expect(vi.mocked(remoteSetState).mock.calls[1][0]).toMatchObject({ + status: "paused", + position: 9, + }); + emit("remote://sessions", []); + expect(listeners.size, "stops listening when no one is connected").toBe(0); + vi.restoreAllMocks(); + }); + + it("keeps going when the backend refuses a state", async () => { + vi.mocked(remoteSetState).mockRejectedValue("not running"); + const { destination } = fakeDestination(); + vi.mocked(remoteSessions).mockResolvedValueOnce([session(true)]); + renderHook(() => usePublishRemoteState(destination, null)); + await waitFor(() => expect(remoteSetState).toHaveBeenCalled()); + expect(vi.mocked(remoteSetState).mock.calls[0][0].item).toBeNull(); + }); +}); diff --git a/apps/player/src/hooks/useRemoteTarget.ts b/apps/player/src/hooks/useRemoteTarget.ts new file mode 100644 index 00000000..dd297038 --- /dev/null +++ b/apps/player/src/hooks/useRemoteTarget.ts @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { listen } from "@tauri-apps/api/event"; +import { useChannels } from "@/hooks/useChannels"; +import { useLatestRef } from "@/hooks/useLatestRef"; +import type { PlaybackDestination } from "@/lib/destination"; +import { + applyRemoteCommand, + getMountedPlayer, + remotePlayerState, + setMountedPlayer, + type RemoteCommand, +} from "@/lib/remote-target"; +import { + remoteFinishCommand, + remoteSessions, + remoteSetState, + type RemoteSession, +} from "@/lib/tauri"; +import type { Channel } from "@/lib/types"; + +/** + * Listens to a Tauri event for as long as the component is mounted. The + * handler is read when an event comes, so it can change without + * listening again. + */ +const useTauriEvent = (event: string, handler: (payload: T) => void): void => { + const handlerRef = useLatestRef(handler); + useEffect(() => { + let current = true; + let unlisten: (() => void) | undefined; + listen(event, ({ payload }) => handlerRef.current(payload)) + .then((stop) => { + if (current) unlisten = stop; + else stop(); + }) + .catch(() => {}); + return () => { + current = false; + unlisten?.(); + }; + }, [event, handlerRef]); +}; + +/** + * Carries out the commands other Cinette devices send this one. Mounted + * once, in `App`. A load with no player open opens it the way the channel + * list does, so the play is this device's own and goes in its history. + */ +export const useRemoteTarget = (): void => { + const { channels } = useChannels(); + const navigate = useNavigate(); + const channelsRef = useLatestRef(channels); + const navigateRef = useLatestRef(navigate); + useTauriEvent<{ token: number; command: RemoteCommand }>( + "remote://command", + ({ token, command }) => { + void applyRemoteCommand(command, { + player: getMountedPlayer(), + findChannel: (id) => channelsRef.current.find((c) => c.id === id), + openPlayer: (channel) => + navigateRef.current("/player", { + state: { url: channel.url, channelName: channel.name, channel }, + }), + }) + .then((outcome) => remoteFinishCommand(token, outcome)) + .catch(() => {}); + } + ); +}; + +/** The devices controlling this one, kept up to date. Empty where remote + * control doesn't run. */ +export const useRemoteSessions = (): RemoteSession[] => { + const [sessions, setSessions] = useState([]); + useTauriEvent("remote://sessions", setSessions); + useEffect(() => { + let current = true; + remoteSessions() + .then((list) => { + if (current) setSessions(list); + }) + .catch(() => {}); + return () => { + current = false; + }; + }, []); + return sessions; +}; + +/** + * Makes the player's destination the one remote commands act on, and while + * a controller is connected, sends it every state the player reports. + */ +export const usePublishRemoteState = ( + player: PlaybackDestination, + channel: Channel | null +): void => { + const connected = useRemoteSessions().some((s) => s.connected); + const channelRef = useLatestRef(channel); + + useEffect(() => { + setMountedPlayer(player); + return () => { + if (getMountedPlayer() === player) setMountedPlayer(null); + }; + }, [player]); + + useEffect(() => { + if (!connected) return; + const send = (state: ReturnType) => { + const remote = remotePlayerState( + state, + channelRef.current, + player.capabilities, + performance.now() + ); + remoteSetState(remote).catch(() => {}); + }; + send(player.getState()); + const unsubscribe = player.subscribe(send); + return unsubscribe; + }, [connected, player, channelRef]); +}; diff --git a/apps/player/src/lib/remote-target.test.ts b/apps/player/src/lib/remote-target.test.ts new file mode 100644 index 00000000..37d98761 --- /dev/null +++ b/apps/player/src/lib/remote-target.test.ts @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + applyRemoteCommand, + getMountedPlayer, + remotePlayerState, + setMountedPlayer, + type CommandTarget, + type RemoteCommand, +} from "./remote-target"; +import { + localCapabilities, + UnsupportedError, + type PlaybackCapabilities, + type PlaybackDestination, + type PlaybackState, +} from "./destination"; +import type { Channel } from "./types"; + +const channel: Channel = { + id: "p1-live-7", + name: "News", + url: "http://iptv.example/live/user/pass/7.ts", + logoUrl: "https://logos.example/news.png", + groupTitle: "UK", + isFavorite: false, + contentType: "live", + sources: [], +}; + +const episode: Channel = { + ...channel, + id: "xt-ep-9-s1-2", + name: "Pilot", + contentType: "series", + logoUrl: undefined, + seriesTitle: "The Show", + season: 1, + episode: 2, +}; + +const state = (overrides: Partial = {}): PlaybackState => ({ + status: "playing", + item: { title: "News", contentType: "live", ref: channel.id }, + position: 100, + positionAt: 1_000, + duration: 0, + volume: 70, + subtitles: [{ id: "s1", label: "English" }], + subtitleId: "s1", + audio: [{ id: "a1", label: "Main", language: "en" }], + audioId: "a1", + connection: "connected", + error: null, + ...overrides, +}); + +describe("remotePlayerState", () => { + it("moves a playing position on by the time since it was read", () => { + const remote = remotePlayerState(state(), channel, localCapabilities(false), 3_500); + expect(remote.position).toBe(102.5); + }); + + it("leaves a paused position where it is", () => { + const remote = remotePlayerState( + state({ status: "paused" }), + channel, + localCapabilities(true), + 3_500 + ); + expect(remote.position).toBe(100); + }); + + it("never moves a position back for a clock read before the state", () => { + const remote = remotePlayerState(state(), channel, localCapabilities(false), 500); + expect(remote.position).toBe(100); + }); + + it("names the channel with only the fields a controller may see", () => { + const remote = remotePlayerState(state(), channel, localCapabilities(false), 1_000); + expect(remote.item).toStrictEqual({ + id: "p1-live-7", + name: "News", + contentType: "live", + group: "UK", + logoUrl: "https://logos.example/news.png", + seriesTitle: null, + season: null, + episode: null, + }); + expect(JSON.stringify(remote)).not.toContain("pass"); + }); + + it("names an episode's show, season and number", () => { + const remote = remotePlayerState( + state({ item: { title: "Pilot", contentType: "series", ref: episode.id } }), + episode, + localCapabilities(true), + 1_000 + ); + expect(remote.item).toMatchObject({ + logoUrl: null, + seriesTitle: "The Show", + season: 1, + episode: 2, + }); + }); + + it("has no item when nothing plays, even with a channel picked", () => { + const idle = state({ status: "idle", item: null }); + expect(remotePlayerState(idle, channel, localCapabilities(false), 0).item).toBeNull(); + expect(remotePlayerState(state(), null, localCapabilities(false), 0).item).toBeNull(); + }); + + it("passes the rest of the state through", () => { + const remote = remotePlayerState( + state({ status: "buffering", duration: 3_600 }), + channel, + localCapabilities(true), + 1_000 + ); + expect(remote).toMatchObject({ + status: "buffering", + duration: 3_600, + volume: 70, + subtitles: [{ id: "s1", label: "English" }], + subtitleId: "s1", + audio: [{ id: "a1", label: "Main", language: "en" }], + audioId: "a1", + error: null, + }); + }); + + it("turns the capabilities into the protocol's flags", () => { + const caps: PlaybackCapabilities = { + seek: true, + pause: false, + volume: "absolute", + subtitles: "select", + externalSubtitles: true, + audioTracks: true, + resumeAt: false, + catalog: false, + }; + expect(remotePlayerState(state(), channel, caps, 1_000).capabilities).toStrictEqual({ + pause: false, + seek: true, + volume: true, + subtitles: true, + audioTracks: true, + resumeAt: false, + }); + const none = { ...caps, volume: "none" as const, subtitles: "none" as const }; + const remote = remotePlayerState(state(), channel, none, 1_000); + expect(remote.capabilities.volume).toBe(false); + expect(remote.capabilities.subtitles).toBe(false); + expect(remote.volume, "no volume where it can't be set").toBeNull(); + }); + + it("sends an error's message, or its code without one", () => { + const withMessage = state({ + status: "error", + error: { code: "failed", message: "Stream ended" }, + }); + expect(remotePlayerState(withMessage, channel, localCapabilities(false), 0).error).toBe( + "Stream ended" + ); + const bare = state({ status: "error", error: { code: "refused" } }); + expect(remotePlayerState(bare, channel, localCapabilities(false), 0).error).toBe("refused"); + }); +}); + +const fakePlayer = (current: PlaybackState = state()) => { + const player = { + load: vi.fn(() => Promise.resolve()), + play: vi.fn(() => Promise.resolve()), + pause: vi.fn(() => Promise.resolve()), + seek: vi.fn<(seconds: number) => Promise>(() => Promise.resolve()), + setVolume: vi.fn<(volume: number) => Promise>(() => Promise.resolve()), + selectSubtitle: vi.fn<(trackId: string | null) => Promise>(() => Promise.resolve()), + selectAudio: vi.fn<(trackId: string) => Promise>(() => Promise.resolve()), + stop: vi.fn(() => Promise.resolve()), + getState: () => current, + }; + return player; +}; + +const target = (player: ReturnType | null) => ({ + player: player as unknown as PlaybackDestination | null, + findChannel: (id: string) => [channel, episode].find((c) => c.id === id), + openPlayer: vi.fn(), +}); + +describe("applyRemoteCommand", () => { + afterEach(() => vi.restoreAllMocks()); + + it("loads a channel on the open player at the start asked for", async () => { + const player = fakePlayer(); + const t = target(player); + const load: RemoteCommand = { action: "load", itemId: episode.id, startAt: 42 }; + await expect(applyRemoteCommand(load, t)).resolves.toBe("ok"); + expect(player.load).toHaveBeenCalledWith( + { kind: "local", channel: episode }, + { startAt: 42 } + ); + expect(t.openPlayer).not.toHaveBeenCalled(); + }); + + it("opens the player for a load when none is open", async () => { + const t = target(null); + await expect(applyRemoteCommand({ action: "load", itemId: channel.id }, t)).resolves.toBe( + "ok" + ); + expect(t.openPlayer).toHaveBeenCalledWith(channel); + }); + + it("says a load of something this device doesn't have isn't found", async () => { + const player = fakePlayer(); + const t = target(player); + await expect(applyRemoteCommand({ action: "load", itemId: "nope" }, t)).resolves.toBe( + "notFound" + ); + expect(player.load).not.toHaveBeenCalled(); + expect(t.openPlayer).not.toHaveBeenCalled(); + }); + + it("stops the open player, and stopping nothing is fine", async () => { + const player = fakePlayer(); + await expect(applyRemoteCommand({ action: "stop" }, target(player))).resolves.toBe("ok"); + expect(player.stop).toHaveBeenCalledTimes(1); + await expect(applyRemoteCommand({ action: "stop" }, target(null))).resolves.toBe("ok"); + }); + + it("fails other commands when no player is open", async () => { + for (const command of [ + { action: "play", itemId: channel.id }, + { action: "volume", value: 10 }, + ] satisfies RemoteCommand[]) { + await expect(applyRemoteCommand(command, target(null))).resolves.toBe("failed"); + } + }); + + it("refuses a command about an item that's no longer playing", async () => { + const player = fakePlayer(); + await expect( + applyRemoteCommand({ action: "pause", itemId: "other" }, target(player)) + ).resolves.toBe("staleItem"); + expect(player.pause).not.toHaveBeenCalled(); + const idle = fakePlayer(state({ item: null })); + await expect( + applyRemoteCommand({ action: "play", itemId: channel.id }, target(idle)) + ).resolves.toBe("staleItem"); + }); + + it("passes play, pause, volume and tracks to the player", async () => { + const player = fakePlayer(); + const t = target(player); + const id = channel.id; + expect(await applyRemoteCommand({ action: "play", itemId: id }, t)).toBe("ok"); + expect(await applyRemoteCommand({ action: "pause", itemId: id }, t)).toBe("ok"); + expect(await applyRemoteCommand({ action: "volume", value: 35 }, t)).toBe("ok"); + expect(await applyRemoteCommand({ action: "subtitle", itemId: id, trackId: null }, t)).toBe( + "ok" + ); + expect(await applyRemoteCommand({ action: "audio", itemId: id, trackId: "a2" }, t)).toBe( + "ok" + ); + expect(player.play).toHaveBeenCalledTimes(1); + expect(player.pause).toHaveBeenCalledTimes(1); + expect(player.setVolume).toHaveBeenCalledWith(35); + expect(player.selectSubtitle).toHaveBeenCalledWith(null); + expect(player.selectAudio).toHaveBeenCalledWith("a2"); + }); + + it("seeks to a position, kept inside the item", async () => { + const player = fakePlayer(state({ status: "paused", duration: 600 })); + const t = target(player); + const id = channel.id; + await applyRemoteCommand({ action: "seek", itemId: id, to: 250 }, t); + await applyRemoteCommand({ action: "seek", itemId: id, to: 900 }, t); + await applyRemoteCommand({ action: "seek", itemId: id, to: -5 }, t); + await applyRemoteCommand({ action: "seek", itemId: id, to: 600 }, t); + expect(player.seek.mock.calls.map(([s]) => s)).toEqual([250, 600, 0, 600]); + }); + + it("seeks by an amount from where a paused player is", async () => { + const player = fakePlayer(state({ status: "paused", duration: 600 })); + const t = target(player); + await applyRemoteCommand({ action: "seek", itemId: channel.id, by: -30 }, t); + await applyRemoteCommand({ action: "seek", itemId: channel.id }, t); + expect(player.seek.mock.calls.map(([s]) => s)).toEqual([70, 100]); + }); + + it("seeks by an amount from where a playing player has got to", async () => { + vi.spyOn(performance, "now").mockReturnValue(5_000); + const player = fakePlayer(state({ status: "playing", duration: 0 })); + await applyRemoteCommand({ action: "seek", itemId: channel.id, by: 10 }, target(player)); + expect(player.seek).toHaveBeenCalledWith(114); + }); + + it("doesn't count time from a clock read before the state", async () => { + vi.spyOn(performance, "now").mockReturnValue(0); + const player = fakePlayer(state({ status: "playing" })); + await applyRemoteCommand({ action: "seek", itemId: channel.id, by: 10 }, target(player)); + expect(player.seek).toHaveBeenCalledWith(110); + }); + + it("says unsupported when the player rejects with that code", async () => { + const player = fakePlayer(); + player.selectAudio.mockRejectedValue(new UnsupportedError("Choosing an audio track")); + await expect( + applyRemoteCommand( + { action: "audio", itemId: channel.id, trackId: "a2" }, + target(player) + ) + ).resolves.toBe("unsupported"); + }); + + it("says failed for any other rejection", async () => { + const player = fakePlayer(); + player.play.mockRejectedValue(new Error("mpv went away")); + player.load.mockRejectedValue(null); + const t = target(player); + expect(await applyRemoteCommand({ action: "play", itemId: channel.id }, t)).toBe("failed"); + expect(await applyRemoteCommand({ action: "load", itemId: channel.id }, t)).toBe("failed"); + }); +}); + +describe("the mounted player", () => { + it("is whatever was set last", () => { + expect(getMountedPlayer()).toBeNull(); + const player = fakePlayer() as unknown as PlaybackDestination; + setMountedPlayer(player); + expect(getMountedPlayer()).toBe(player); + setMountedPlayer(null); + expect(getMountedPlayer()).toBeNull(); + }); +}); diff --git a/apps/player/src/lib/remote-target.ts b/apps/player/src/lib/remote-target.ts new file mode 100644 index 00000000..def86ad3 --- /dev/null +++ b/apps/player/src/lib/remote-target.ts @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +/** + * This device as a remote-control target: carrying out the commands other + * Cinette devices send, and describing what plays for them. + * + * The Rust side has already checked each command against the last state it + * sent (the item, the numbers, the capabilities), so these helpers only + * carry it out. + */ + +import type { PlaybackCapabilities, PlaybackDestination, PlaybackState } from "@/lib/destination"; +import type { Channel } from "@/lib/types"; + +/** A command from a controller, as `remote://command` carries it. */ +export type RemoteCommand = + | { action: "load"; itemId: string; startAt?: number } + | { action: "play"; itemId: string } + | { action: "pause"; itemId: string } + | { action: "seek"; itemId: string; to?: number; by?: number } + | { action: "volume"; value: number } + | { action: "subtitle"; itemId: string; trackId: string | null } + | { action: "audio"; itemId: string; trackId: string } + | { action: "stop" }; + +/** What the player answers, for `remote_finish_command`. */ +export type CommandOutcome = "ok" | "staleItem" | "unsupported" | "notFound" | "failed"; + +/** The player's state as `remote_set_state` takes it. */ +export interface RemotePlayerState { + status: PlaybackState["status"]; + item: { + id: string; + name: string; + contentType: Channel["contentType"]; + group: string; + logoUrl: string | null; + seriesTitle: string | null; + season: number | null; + episode: number | null; + } | null; + position: number; + duration: number; + volume: number | null; + subtitles: PlaybackState["subtitles"]; + subtitleId: string | null; + audio: PlaybackState["audio"]; + audioId: string | null; + capabilities: { + pause: boolean; + seek: boolean; + volume: boolean; + subtitles: boolean; + audioTracks: boolean; + resumeAt: boolean; + }; + error: string | null; +} + +/** + * The state controllers see. `now` is `performance.now()`, so a playing + * position moves on by the time since it was read. + */ +export const remotePlayerState = ( + state: PlaybackState, + channel: Channel | null, + capabilities: PlaybackCapabilities, + now: number +): RemotePlayerState => { + const moving = state.status === "playing"; + const position = moving + ? state.position + Math.max(0, now - state.positionAt) / 1000 + : state.position; + return { + status: state.status, + item: + channel && state.item + ? { + id: channel.id, + name: channel.name, + contentType: channel.contentType, + group: channel.groupTitle, + logoUrl: channel.logoUrl ?? null, + seriesTitle: channel.seriesTitle ?? null, + season: channel.season ?? null, + episode: channel.episode ?? null, + } + : null, + position, + duration: state.duration, + volume: capabilities.volume === "absolute" ? state.volume : null, + subtitles: state.subtitles, + subtitleId: state.subtitleId, + audio: state.audio, + audioId: state.audioId, + capabilities: { + pause: capabilities.pause, + seek: capabilities.seek, + volume: capabilities.volume === "absolute", + subtitles: capabilities.subtitles === "select", + audioTracks: capabilities.audioTracks, + resumeAt: capabilities.resumeAt, + }, + error: state.error ? (state.error.message ?? state.error.code) : null, + }; +}; + +/** What carrying out a command needs. */ +export interface CommandTarget { + /** The mounted player's destination, or null when no player is open. */ + player: PlaybackDestination | null; + /** This device's channel with this id, if it has one. */ + findChannel: (id: string) => Channel | undefined; + /** Opens the player on a channel when none is open. */ + openPlayer: (channel: Channel) => void; +} + +const outcomeOf = async (run: () => Promise): Promise => { + try { + await run(); + return "ok"; + } catch (e) { + return (e as { code?: string } | null)?.code === "unsupported" ? "unsupported" : "failed"; + } +}; + +/** Carries out one command on this device's player. */ +export const applyRemoteCommand = async ( + command: RemoteCommand, + target: CommandTarget +): Promise => { + const { player } = target; + if (command.action === "load") { + const channel = target.findChannel(command.itemId); + if (!channel) return "notFound"; + if (!player) { + target.openPlayer(channel); + return "ok"; + } + const startAt = command.startAt; + return outcomeOf(() => player.load({ kind: "local", channel }, { startAt })); + } + if (command.action === "stop") { + // With no player open, nothing plays, which is what stop asks for. + return player ? outcomeOf(() => player.stop()) : "ok"; + } + if (!player) return "failed"; + if (command.action !== "volume" && player.getState().item?.ref !== command.itemId) + return "staleItem"; + switch (command.action) { + case "play": + return outcomeOf(() => player.play()); + case "pause": + return outcomeOf(() => player.pause()); + case "seek": { + const state = player.getState(); + // A relative seek counts from where the player is now. + const elapsed = + state.status === "playing" + ? Math.max(0, performance.now() - state.positionAt) / 1000 + : 0; + const to = command.to ?? state.position + elapsed + (command.by ?? 0); + const end = state.duration > 0 ? state.duration : Number.POSITIVE_INFINITY; + return outcomeOf(() => player.seek(Math.min(Math.max(0, to), end))); + } + case "volume": + return outcomeOf(() => player.setVolume(command.value)); + case "subtitle": + return outcomeOf(() => player.selectSubtitle(command.trackId)); + case "audio": + return outcomeOf(() => player.selectAudio(command.trackId)); + } +}; + +let mountedPlayer: PlaybackDestination | null = null; + +/** The player page registers its destination while it's open. */ +export const setMountedPlayer = (player: PlaybackDestination | null): void => { + mountedPlayer = player; +}; + +/** The open player's destination, or null. */ +export const getMountedPlayer = (): PlaybackDestination | null => mountedPlayer; diff --git a/apps/player/src/lib/tauri.test.ts b/apps/player/src/lib/tauri.test.ts index 9b5ebd6a..028876a1 100644 --- a/apps/player/src/lib/tauri.test.ts +++ b/apps/player/src/lib/tauri.test.ts @@ -10,6 +10,7 @@ import { deferred } from "@/test/deferred"; import { mpvLoad, mpvStop } from "./tauri"; import * as tauri from "./tauri"; import type { Channel } from "./types"; +import type { RemotePlayerState } from "./remote-target"; import commandArguments from "../../src-tauri/src/commands/tests/arguments.json"; const ARGUMENTS_FILE = "src-tauri/src/commands/tests/arguments.json"; @@ -123,6 +124,27 @@ describe("backend commands", () => { type Case = [name: string, call: () => Promise, command: string, args?: object]; + const remoteState: RemotePlayerState = { + status: "idle", + item: null, + position: 0, + duration: 0, + volume: null, + subtitles: [], + subtitleId: null, + audio: [], + audioId: null, + capabilities: { + pause: false, + seek: false, + volume: false, + subtitles: false, + audioTracks: false, + resumeAt: false, + }, + error: null, + }; + const cases: Case[] = [ // mpv ["mpvPlay", () => tauri.mpvPlay(), "plugin:mpv|mpv_play"], @@ -595,6 +617,45 @@ describe("backend commands", () => { }, ], ["stopRecording", () => tauri.stopRecording("r1"), "stop_recording", { id: "r1" }], + // remote control + ["remoteGetSettings", () => tauri.remoteGetSettings(), "remote_get_settings"], + [ + "remoteSetSettings", + () => + tauri.remoteSetSettings({ + discoverable: false, + remoteControl: "never", + deviceName: "Den", + }), + "remote_set_settings", + { settings: { discoverable: false, remoteControl: "never", deviceName: "Den" } }, + ], + ["remoteResetIdentity", () => tauri.remoteResetIdentity(), "remote_reset_identity"], + [ + "remoteAnswerConsent", + () => tauri.remoteAnswerConsent(3, true), + "remote_answer_consent", + { request: 3, allow: true }, + ], + ["remoteSessions", () => tauri.remoteSessions(), "remote_sessions"], + [ + "remoteDisconnect", + () => tauri.remoteDisconnect("s1"), + "remote_disconnect", + { sessionId: "s1" }, + ], + [ + "remoteSetState", + () => tauri.remoteSetState(remoteState), + "remote_set_state", + { state: remoteState }, + ], + [ + "remoteFinishCommand", + () => tauri.remoteFinishCommand(9, "staleItem"), + "remote_finish_command", + { token: 9, outcome: "staleItem" }, + ], ]; const rows = cases.map(([name, call, command, args]) => ({ name, call, command, args })); diff --git a/apps/player/src/lib/tauri.ts b/apps/player/src/lib/tauri.ts index 62b148e0..e295c0ca 100644 --- a/apps/player/src/lib/tauri.ts +++ b/apps/player/src/lib/tauri.ts @@ -2,6 +2,7 @@ // Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. import { invoke } from "@tauri-apps/api/core"; +import type { CommandOutcome, RemotePlayerState } from "@/lib/remote-target"; import type { MpvPlayerState, Channel, @@ -510,3 +511,52 @@ export const scheduleRecording = ( /** Stop a recording and keep what it recorded, or cancel one that hasn't started. */ export const stopRecording = (id: string): Promise => invoke("stop_recording", { id }); + +/** Who may control this device from another Cinette device. */ +export type RemoteControlMode = "always" | "ask" | "never"; + +/** The remote-control settings and this device's id. */ +export interface RemoteSettings { + discoverable: boolean; + remoteControl: RemoteControlMode; + /** The name the user gave this device, or null for the default. */ + deviceName: string | null; + /** The name used when none is given: the device type, like "Mac". */ + defaultName: string; + deviceId: string; +} + +/** A device controlling this one, connected or waiting to resume. */ +export interface RemoteSession { + sessionId: string; + deviceId: string; + name: string; + deviceType: string; + connected: boolean; +} + +export const remoteGetSettings = (): Promise => invoke("remote_get_settings"); + +/** Never allow ends every session at once. */ +export const remoteSetSettings = (settings: { + discoverable: boolean; + remoteControl: RemoteControlMode; + deviceName: string | null; +}): Promise => invoke("remote_set_settings", { settings }); + +/** A new device id. Every session ends. */ +export const remoteResetIdentity = (): Promise => invoke("remote_reset_identity"); + +export const remoteAnswerConsent = (request: number, allow: boolean): Promise => + invoke("remote_answer_consent", { request, allow }); + +export const remoteSessions = (): Promise => invoke("remote_sessions"); + +export const remoteDisconnect = (sessionId: string): Promise => + invoke("remote_disconnect", { sessionId }); + +export const remoteSetState = (state: RemotePlayerState): Promise => + invoke("remote_set_state", { state }); + +export const remoteFinishCommand = (token: number, outcome: CommandOutcome): Promise => + invoke("remote_finish_command", { token, outcome }); From db23f71581e8f7efb045fec21d5b7b9ec6ab3358 Mon Sep 17 00:00:00 2001 From: Max Boksem <15022344+MaxMB15@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:13:50 +0000 Subject: [PATCH 02/60] test(remote): drive the app's remote commands and events over loopback Starts the target through the mock app on a loopback port, with an advertiser that only records, and checks the settings file, the consent, sessions and command events, state and catalog answers, a new identity and quitting against a phone on a real socket. The loopback controller moves into a test module both test files share. --- .cargo/mutants.toml | 4 + apps/player/src-tauri/src/remote/mod.rs | 2 + apps/player/src-tauri/src/remote/service.rs | 86 ++-- .../src-tauri/src/remote/service_tests.rs | 443 ++++++++++++++++++ .../src-tauri/src/remote/target_tests.rs | 120 +---- .../src-tauri/src/remote/test_client.rs | 138 ++++++ 6 files changed, 644 insertions(+), 149 deletions(-) create mode 100644 apps/player/src-tauri/src/remote/service_tests.rs create mode 100644 apps/player/src-tauri/src/remote/test_client.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 97818464..31210c6f 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -63,6 +63,10 @@ exclude_re = [ # Starts caffeinate or systemd-inhibit, which would change the machine's # own sleep settings. The tests check the command it runs. 'src-tauri/src/downloads/keep_awake\.rs:.*\bhold_while_running\b', + # These bind remote control's real port and advertise this machine on the + # network. The tests start the target on loopback with start_on instead. + 'src-tauri/src/remote/service\.rs:.*\b(start|advertise_on_network)\b', + 'src-tauri/src/remote/target\.rs:.*\bbind_default\b', ] # Without --in-place, cargo-mutants copies the tree to build each mutant. This diff --git a/apps/player/src-tauri/src/remote/mod.rs b/apps/player/src-tauri/src/remote/mod.rs index e1c63ef4..2c578c23 100644 --- a/apps/player/src-tauri/src/remote/mod.rs +++ b/apps/player/src-tauri/src/remote/mod.rs @@ -27,6 +27,8 @@ pub mod identity; mod service; #[cfg(any(target_os = "linux", target_os = "macos"))] pub mod target; +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod test_client; #[cfg(any(target_os = "linux", target_os = "macos"))] pub use service::{start, stop_for_exit, RemoteService}; diff --git a/apps/player/src-tauri/src/remote/service.rs b/apps/player/src-tauri/src/remote/service.rs index ca8b8e90..488db0fe 100644 --- a/apps/player/src-tauri/src/remote/service.rs +++ b/apps/player/src-tauri/src/remote/service.rs @@ -125,8 +125,12 @@ pub struct CommandEvent<'a> { pub command: &'a Command, } +/// Puts a record on the network, or doesn't. +type Advertiser = Box Option + Send + Sync>; + struct TauriHost { app: AppHandle, + advertiser: Advertiser, advertisement: Mutex>, } @@ -177,14 +181,18 @@ impl TargetHost for TauriHost { // The old record goes first, with a goodbye, so the name is free. *current = None; if let Some(registration) = registration { - match discovery::advertise(®istration) { - Ok(ad) => *current = Some(ad), - Err(e) => tracing::warn!("[remote] couldn't advertise: {e}"), - } + *current = (self.advertiser)(®istration); } } } +/// Advertises on the network this device is on. +fn advertise_on_network(registration: &Registration) -> Option { + discovery::advertise(registration) + .inspect_err(|e| tracing::warn!("[remote] couldn't advertise: {e}")) + .ok() +} + /// The saved settings, or the defaults. fn load_settings(app: &AppHandle) -> RemoteSettings { app.store(SETTINGS_STORE_FILE) @@ -194,43 +202,51 @@ fn load_settings(app: &AppHandle) -> RemoteSettings { .unwrap_or_default() } -/// Starts the target and manages a [`RemoteService`]. A failure is logged -/// and the app carries on without remote control. +/// Starts the target on every address and advertises it, and manages a +/// [`RemoteService`]. A failure is logged and the app carries on without +/// remote control. pub fn start(app: &AppHandle, key_dir: PathBuf) { - let key = match identity::load_or_create(&key_dir) { - Ok(key) => key, - Err(e) => { - tracing::error!("[remote] couldn't load the device key: {e}"); - return; - } - }; + let started = tauri::async_runtime::block_on(async { + let listener = target::bind_default().await.map_err(|e| e.to_string())?; + start_on(app, key_dir, listener, Box::new(advertise_on_network)) + }); + if let Err(e) = started { + tracing::error!("[remote] remote control didn't start: {e}"); + } +} + +/// [`start`] on `listener`, advertising through `advertiser`. Call it inside +/// the async runtime. +fn start_on( + app: &AppHandle, + key_dir: PathBuf, + listener: tokio::net::TcpListener, + advertiser: Advertiser, +) -> Result<(), String> { + let key = identity::load_or_create(&key_dir) + .map_err(|e| format!("couldn't load the device key: {e}"))?; let settings = load_settings(app); let host = Arc::new(TauriHost { app: app.clone(), + advertiser, advertisement: Mutex::new(None), }); let device_id = key.device_id(); - let started = tauri::async_runtime::block_on(async { - let listener = target::bind_default().await?; - target::start(listener, key, DEVICE_TYPE, settings.clone(), host) + let handle = target::start(listener, key, DEVICE_TYPE, settings.clone(), host) + .map_err(|e| format!("couldn't listen: {e}"))?; + tracing::info!( + "[remote] listening on port {} as {device_id}", + handle.port() + ); + app.manage(RemoteService { + handle, + key_dir, + state: Mutex::new(Saved { + settings, + device_id, + }), }); - match started { - Ok(handle) => { - tracing::info!( - "[remote] listening on port {} as {device_id}", - handle.port() - ); - app.manage(RemoteService { - handle, - key_dir, - state: Mutex::new(Saved { - settings, - device_id, - }), - }); - } - Err(e) => tracing::error!("[remote] couldn't listen: {e}"), - } + Ok(()) } /// Closes every connection with `shutdown` and withdraws the record. @@ -239,3 +255,7 @@ pub fn stop_for_exit(app: &AppHandle) { tauri::async_runtime::block_on(service.handle.shutdown()); } } + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/apps/player/src-tauri/src/remote/service_tests.rs b/apps/player/src-tauri/src/remote/service_tests.rs new file mode 100644 index 00000000..13414f61 --- /dev/null +++ b/apps/player/src-tauri/src/remote/service_tests.rs @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! The target as the app runs it, through the mock app: its commands, its +//! events and its settings file, with a phone on a loopback connection. +//! Nothing is advertised on the network; the advertiser only records. + +use super::*; +use crate::remote::test_client::{self, Client, WAIT}; +use crate::test_support::TestApp; +use mvp_core::models::channel::Channel; +use mvp_core::models::playlist::{Provider, ProviderType}; +use mvp_core::remote::controller::Event; +use mvp_core::remote::message::CloseReason; +use serde_json::{json, Value}; +use tauri::test::MockRuntime; +use tauri::Listener; +use tokio::sync::mpsc; +use tokio::time::timeout; + +struct Running { + app: TestApp, + events: mpsc::UnboundedReceiver<(String, Value)>, + /// Events read while waiting for another, oldest first. + unclaimed: Vec<(String, Value)>, + advertised: Arc>>, + port: u16, +} + +const EVENTS: [&str; 4] = [ + "remote://consent", + "remote://consent-hidden", + "remote://sessions", + "remote://command", +]; + +impl Running { + /// The app with remote control started, after `before` has had the + /// chance to save settings. + async fn start(before: impl FnOnce(&TestApp)) -> Self { + let app = TestApp::new(); + before(&app); + let (tx, events) = mpsc::unbounded_channel(); + for name in EVENTS { + let tx = tx.clone(); + app.handle().listen_any(name, move |event| { + let payload = serde_json::from_str(event.payload()).unwrap(); + let _ = tx.send((name.to_owned(), payload)); + }); + } + let advertised = Arc::new(Mutex::new(Vec::new())); + let record = advertised.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + start_on( + &app.handle(), + app.app_dir(), + listener, + Box::new(move |r: &Registration| { + lock(&record).push(r.clone()); + None + }), + ) + .unwrap(); + Self { + app, + events, + unclaimed: Vec::new(), + advertised, + port, + } + } + + fn invoke(&self, command: &str, args: Value) -> Result { + self.app.invoke(command, args) + } + + fn service_settings(&self) -> RemoteSettings { + self.app.handle().state::().settings() + } + + fn service_device_id(&self) -> DeviceId { + self.app.handle().state::().device_id() + } + + fn device_id(&self) -> DeviceId { + DeviceId::parse( + self.invoke("remote_get_settings", json!({})).unwrap()["deviceId"] + .as_str() + .unwrap(), + ) + .unwrap() + } + + async fn phone(&self) -> Client { + test_client::connect( + self.port, + self.device_id(), + DeviceKey::generate(), + "Phone", + None, + ) + .await + } + + /// The oldest event called `name` not yet taken, waiting for one. + async fn event(&mut self, name: &str) -> Value { + if let Some(i) = self.unclaimed.iter().position(|(got, _)| got == name) { + return self.unclaimed.remove(i).1; + } + loop { + let (got, payload) = timeout(WAIT, self.events.recv()) + .await + .unwrap_or_else(|_| panic!("no {name} event came")) + .unwrap(); + if got == name { + return payload; + } + self.unclaimed.push((got, payload)); + } + } + + fn last_advertised(&self) -> Option { + lock(&self.advertised).last().cloned() + } +} + +fn saved_settings(app: &TestApp) -> Value { + let text = std::fs::read_to_string(app.app_dir().join(SETTINGS_STORE_FILE)).unwrap(); + serde_json::from_str::(&text).unwrap()[SETTINGS_KEY].clone() +} + +#[tokio::test(flavor = "multi_thread")] +async fn starts_with_the_defaults_and_a_saved_key() { + let running = Running::start(|_| {}).await; + let view = running.invoke("remote_get_settings", json!({})).unwrap(); + assert_eq!(view["discoverable"], json!(true)); + assert_eq!(view["remoteControl"], json!("ask")); + assert_eq!(view["deviceName"], Value::Null); + assert_eq!(view["defaultName"], json!("Linux PC")); + let key = std::fs::read(running.app.app_dir().join(identity::KEY_FILE)).unwrap(); + let key = DeviceKey::from_private(key.try_into().unwrap()); + assert_eq!(view["deviceId"], json!(key.device_id().to_string())); + assert_eq!(running.service_device_id(), key.device_id()); + + // The first record goes out as the target starts. + let deadline = std::time::Instant::now() + WAIT; + while running.last_advertised().is_none() && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let record = running.last_advertised().expect("advertised"); + assert_eq!(record.port, running.port); + assert_eq!(record.instance, "Linux PC"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn uses_the_settings_saved_last_time() { + let running = Running::start(|app| { + let store = app.handle().store(SETTINGS_STORE_FILE).unwrap(); + store.set( + SETTINGS_KEY, + json!({ "discoverable": true, "remoteControl": "never", "deviceName": "Den" }), + ); + store.save().unwrap(); + }) + .await; + let view = running.invoke("remote_get_settings", json!({})).unwrap(); + assert_eq!(view["remoteControl"], json!("never")); + assert_eq!(view["deviceName"], json!("Den")); + let mut phone = running.phone().await; + assert_eq!(phone.closed().await, Some(CloseReason::NotAllowed)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn saving_settings_writes_them_and_tells_the_target() { + let running = Running::start(|_| {}).await; + let view = running + .invoke( + "remote_set_settings", + json!({ "settings": { "discoverable": true, "remoteControl": "always", "deviceName": " Den " } }), + ) + .unwrap(); + assert_eq!(view["remoteControl"], json!("always")); + assert_eq!(view["deviceName"], json!("Den")); + assert_eq!( + saved_settings(&running.app), + json!({ "discoverable": true, "remoteControl": "always", "deviceName": "Den" }) + ); + assert_eq!( + running.service_settings().device_name.as_deref(), + Some("Den") + ); + // Always allow lets the phone straight in. + let mut phone = running.phone().await; + phone.connected().await; + let deadline = std::time::Instant::now() + WAIT; + while running.last_advertised().map(|r| r.instance) != Some("Den".into()) + && std::time::Instant::now() < deadline + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_eq!(running.last_advertised().unwrap().instance, "Den"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn consent_goes_to_the_webview_and_its_answer_comes_back() { + let mut running = Running::start(|_| {}).await; + let mut phone = running.phone().await; + let code = phone.code().await; + phone.pending().await; + let consent = running.event("remote://consent").await; + assert_eq!(consent["name"], json!("Phone")); + assert_eq!(consent["deviceType"], json!("iphone")); + assert_eq!(consent["code"], json!(code)); + assert_eq!( + consent["deviceId"], + json!(phone.key.device_id().to_string()) + ); + let expires = consent["expiresInMs"].as_u64().unwrap(); + assert!((25_000..=30_000).contains(&expires), "{expires}"); + + running + .invoke( + "remote_answer_consent", + json!({ "request": consent["request"], "allow": true }), + ) + .unwrap(); + phone.connected().await; + assert_eq!( + running.event("remote://consent-hidden").await, + json!({ "request": consent["request"] }) + ); + let sessions = running.event("remote://sessions").await; + assert_eq!(sessions[0]["name"], json!("Phone")); + assert_eq!(sessions[0]["deviceType"], json!("iphone")); + assert_eq!(sessions[0]["connected"], json!(true)); + assert_eq!( + sessions[0]["deviceId"], + json!(phone.key.device_id().to_string()) + ); + let listed = running.invoke("remote_sessions", json!({})).unwrap(); + assert_eq!(listed, sessions); + + running + .invoke( + "remote_disconnect", + json!({ "sessionId": sessions[0]["sessionId"] }), + ) + .unwrap(); + assert_eq!(phone.closed().await, Some(CloseReason::Revoked)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_denied_request_grants_nothing() { + let mut running = Running::start(|_| {}).await; + let mut phone = running.phone().await; + phone.pending().await; + let consent = running.event("remote://consent").await; + running + .invoke( + "remote_answer_consent", + json!({ "request": consent["request"], "allow": false }), + ) + .unwrap(); + assert_eq!(phone.closed().await, Some(CloseReason::Denied)); +} + +async fn connected(running: &Running) -> Client { + running + .invoke( + "remote_set_settings", + json!({ "settings": { "discoverable": true, "remoteControl": "always", "deviceName": null } }), + ) + .unwrap(); + let mut phone = running.phone().await; + phone.connected().await; + phone +} + +#[tokio::test(flavor = "multi_thread")] +async fn commands_reach_the_webview_and_its_outcome_reaches_the_phone() { + let mut running = Running::start(|_| {}).await; + let mut phone = connected(&running).await; + let (id, bytes) = phone.controller.command(Command::Stop).unwrap(); + phone.write(&bytes).await; + let command = running.event("remote://command").await; + assert_eq!(command["command"], json!({ "action": "stop" })); + running + .invoke( + "remote_finish_command", + json!({ "token": command["token"], "outcome": "failed" }), + ) + .unwrap(); + let reply = phone + .wait_for(|e| match e { + Event::Reply { request, result } if *request == id => Some(*result), + _ => None, + }) + .await; + assert_eq!(reply, Some(Err(ErrorCode::Failed))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_players_state_reaches_the_phone_without_provider_logos() { + let running = Running::start(|app| { + app.state() + .cache + .lock() + .unwrap() + .upsert_provider(&Provider { + id: "p1".into(), + name: "Mine".into(), + provider_type: ProviderType::Xtream, + url: "http://iptv.example:8080".into(), + username: Some("user".into()), + password: Some("pass".into()), + last_updated: None, + channel_count: 0, + epg_url: None, + }) + .unwrap(); + }) + .await; + let mut phone = connected(&running).await; + running + .invoke( + "remote_set_state", + json!({ "state": { + "status": "playing", + "item": { "id": "p1-live-1", "name": "News", "contentType": "live", + "logoUrl": "http://iptv.example:8080/logo.png" }, + "position": 3, "duration": 0, "volume": 20, + "subtitleId": null, "audioId": null, "error": null + } }), + ) + .unwrap(); + let state = phone + .wait_for(|e| match e { + Event::State(s) if s.state.item.is_some() => Some(s.clone()), + _ => None, + }) + .await + .unwrap(); + let item = state.state.item.unwrap(); + assert_eq!(item.id, "p1-live-1"); + assert_eq!(item.logo, None); + assert_eq!(state.state.volume, Some(20)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_queries_read_the_cache() { + let running = Running::start(|app| { + let channel = Channel { + id: "p1-live-1".into(), + name: "News".into(), + url: "http://iptv.example/live/u/p/1.ts".into(), + group_title: "UK".into(), + content_type: "live".into(), + ..Channel::default() + }; + let state = app.state(); + let cache = state.cache.lock().unwrap(); + cache + .upsert_provider(&Provider { + id: "p1".into(), + name: "Mine".into(), + provider_type: ProviderType::M3u, + url: "http://iptv.example/list.m3u".into(), + username: None, + password: None, + last_updated: None, + channel_count: 1, + epg_url: None, + }) + .unwrap(); + cache.save_channels("p1", &[channel]).unwrap(); + }) + .await; + let mut phone = connected(&running).await; + let (id, bytes) = phone + .controller + .catalog(CatalogQuery::Groups { + content_type: "live".into(), + }) + .unwrap(); + phone.write(&bytes).await; + let items = phone + .wait_for(|e| match e { + Event::Page { request, items, .. } if *request == id => Some(items.clone()), + _ => None, + }) + .await + .unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "UK"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_new_identity_is_saved_and_ends_sessions() { + let running = Running::start(|_| {}).await; + let mut phone = connected(&running).await; + let before = running.device_id(); + let view = running.invoke("remote_reset_identity", json!({})).unwrap(); + let after = view["deviceId"].as_str().unwrap().to_owned(); + assert_ne!(after, before.to_string()); + let key = std::fs::read(running.app.app_dir().join(identity::KEY_FILE)).unwrap(); + let key = DeviceKey::from_private(key.try_into().unwrap()); + assert_eq!(key.device_id().to_string(), after); + assert_eq!(phone.closed().await, Some(CloseReason::Shutdown)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn quitting_tells_the_phone() { + let running = Running::start(|_| {}).await; + let mut phone = connected(&running).await; + let handle: tauri::AppHandle = running.app.handle(); + tokio::task::spawn_blocking(move || stop_for_exit(&handle)) + .await + .unwrap(); + assert_eq!(phone.closed().await, Some(CloseReason::Shutdown)); +} + +#[test] +fn quitting_without_remote_control_does_nothing() { + let app = TestApp::new(); + stop_for_exit(&app.handle()); +} + +#[test] +fn commands_say_when_remote_control_did_not_start() { + let app = TestApp::new(); + for (command, args) in [ + ("remote_get_settings", json!({})), + ("remote_sessions", json!({})), + ("remote_disconnect", json!({ "sessionId": "s" })), + ] { + assert_eq!( + app.invoke(command, args), + Err(json!("Remote control didn't start on this device.")), + "{command}" + ); + } +} diff --git a/apps/player/src-tauri/src/remote/target_tests.rs b/apps/player/src-tauri/src/remote/target_tests.rs index 4269943c..8b1f597f 100644 --- a/apps/player/src-tauri/src/remote/target_tests.rs +++ b/apps/player/src-tauri/src/remote/target_tests.rs @@ -5,16 +5,13 @@ //! other end and a fake host in place of the webview. use super::*; -use mvp_core::remote::channel::FrameReader; -use mvp_core::remote::controller::{Controller, Event}; -use mvp_core::remote::message::{CloseReason, Feature, Hello, PlaybackStatus, PROTOCOL_VERSION}; +use crate::remote::test_client::{connect, Client, WAIT}; +use mvp_core::remote::controller::Event; +use mvp_core::remote::message::{CloseReason, PlaybackStatus}; use mvp_core::remote::settings::ControlMode; use std::sync::Mutex; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::timeout; -const WAIT: Duration = Duration::from_secs(5); - #[derive(Debug, Clone, PartialEq)] enum Seen { Consent(RequestId, String), @@ -126,116 +123,7 @@ impl Harness { } async fn connect_as(&self, key: DeviceKey, name: &str, resume: Option) -> Client { - let stream = tokio::net::TcpStream::connect(("127.0.0.1", self.handle.port())) - .await - .unwrap(); - let hello = Hello { - id: key.device_id(), - name: name.into(), - device_type: DeviceType::Iphone, - versions: vec![PROTOCOL_VERSION], - features: vec![Feature::Control, Feature::Catalog], - }; - let (controller, first) = - Controller::connect(&key, hello, self.key.device_id(), resume).unwrap(); - let mut client = Client { - stream, - controller, - reader: FrameReader::new(), - events: Vec::new(), - key, - }; - client.write(&first).await; - client - } -} - -struct Client { - stream: tokio::net::TcpStream, - controller: Controller, - reader: FrameReader, - events: Vec, - key: DeviceKey, -} - -impl Client { - async fn write(&mut self, bytes: &[u8]) { - self.stream.write_all(bytes).await.unwrap(); - } - - /// Reads until `pick` finds an event, answering the handshake on the way. - /// `None` when the connection closed first. - async fn wait_for(&mut self, mut pick: impl FnMut(&Event) -> Option) -> Option { - let mut buf = vec![0u8; 16 * 1024]; - let mut checked = 0; - loop { - while checked < self.events.len() { - let event = &self.events[checked]; - checked += 1; - if let Some(found) = pick(event) { - return Some(found); - } - } - let n = timeout(WAIT, self.stream.read(&mut buf)) - .await - .expect("the target sent nothing") - .unwrap_or(0); - if n == 0 { - return None; - } - self.reader.push(&buf[..n]); - while let Some(frame) = self.reader.next_frame().unwrap() { - let output = self.controller.receive(&frame); - for bytes in output.send { - self.write(&bytes).await; - } - self.events.extend(output.events); - } - } - } - - async fn code(&mut self) -> String { - self.wait_for(|e| match e { - Event::Verified { code, .. } => Some(code.to_string()), - _ => None, - }) - .await - .expect("verified") - } - - /// Drives the handshake until the target says it's asking its user. - async fn pending(&mut self) { - self.wait_for(|e| matches!(e, Event::Pending { .. }).then_some(())) - .await - .expect("pending"); - } - - async fn connected(&mut self) -> String { - self.wait_for(|e| match e { - Event::Connected { session_id, .. } => Some(session_id.clone()), - _ => None, - }) - .await - .expect("connected") - } - - async fn closed(&mut self) -> Option { - self.wait_for(|e| match e { - Event::Closed(reason) => Some(*reason), - _ => None, - }) - .await - } - - async fn command(&mut self, command: Command) -> Result { - let (id, bytes) = self.controller.command(command).unwrap(); - self.write(&bytes).await; - self.wait_for(|e| match e { - Event::Reply { request, result } if *request == id => Some(*result), - _ => None, - }) - .await - .expect("a reply") + connect(self.handle.port(), self.key.device_id(), key, name, resume).await } } diff --git a/apps/player/src-tauri/src/remote/test_client.rs b/apps/player/src-tauri/src/remote/test_client.rs new file mode 100644 index 00000000..c735d6a0 --- /dev/null +++ b/apps/player/src-tauri/src/remote/test_client.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! A controller on the other end of a loopback connection, for tests: the +//! core's own `Controller` driving a real TCP socket. + +use mvp_core::remote::channel::FrameReader; +use mvp_core::remote::controller::{Controller, Event}; +use mvp_core::remote::identity::{DeviceId, DeviceKey}; +use mvp_core::remote::message::{ + CloseReason, Command, DeviceType, ErrorCode, Feature, Hello, PROTOCOL_VERSION, +}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::timeout; + +/// How long a test waits for anything. +pub const WAIT: Duration = Duration::from_secs(5); + +/// Connects to the target with id `target` on this machine's `port`, as a +/// phone with key `key`, and sends the first handshake frame. +pub async fn connect( + port: u16, + target: DeviceId, + key: DeviceKey, + name: &str, + resume: Option, +) -> Client { + let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let hello = Hello { + id: key.device_id(), + name: name.into(), + device_type: DeviceType::Iphone, + versions: vec![PROTOCOL_VERSION], + features: vec![Feature::Control, Feature::Catalog], + }; + let (controller, first) = Controller::connect(&key, hello, target, resume).unwrap(); + let mut client = Client { + stream, + controller, + reader: FrameReader::new(), + events: Vec::new(), + key, + }; + client.write(&first).await; + client +} + +pub struct Client { + pub stream: tokio::net::TcpStream, + pub controller: Controller, + pub reader: FrameReader, + pub events: Vec, + pub key: DeviceKey, +} + +impl Client { + pub async fn write(&mut self, bytes: &[u8]) { + self.stream.write_all(bytes).await.unwrap(); + } + + /// Reads until `pick` finds an event, answering the handshake on the way. + /// `None` when the connection closed first. + pub async fn wait_for(&mut self, mut pick: impl FnMut(&Event) -> Option) -> Option { + let mut buf = vec![0u8; 16 * 1024]; + let mut checked = 0; + loop { + while checked < self.events.len() { + let event = &self.events[checked]; + checked += 1; + if let Some(found) = pick(event) { + return Some(found); + } + } + let n = timeout(WAIT, self.stream.read(&mut buf)) + .await + .expect("the target sent nothing") + .unwrap_or(0); + if n == 0 { + return None; + } + self.reader.push(&buf[..n]); + while let Some(frame) = self.reader.next_frame().unwrap() { + let output = self.controller.receive(&frame); + for bytes in output.send { + self.write(&bytes).await; + } + self.events.extend(output.events); + } + } + } + + pub async fn code(&mut self) -> String { + self.wait_for(|e| match e { + Event::Verified { code, .. } => Some(code.to_string()), + _ => None, + }) + .await + .expect("verified") + } + + /// Drives the handshake until the target says it's asking its user. + pub async fn pending(&mut self) { + self.wait_for(|e| matches!(e, Event::Pending { .. }).then_some(())) + .await + .expect("pending"); + } + + pub async fn connected(&mut self) -> String { + self.wait_for(|e| match e { + Event::Connected { session_id, .. } => Some(session_id.clone()), + _ => None, + }) + .await + .expect("connected") + } + + pub async fn closed(&mut self) -> Option { + self.wait_for(|e| match e { + Event::Closed(reason) => Some(*reason), + _ => None, + }) + .await + } + + pub async fn command(&mut self, command: Command) -> Result { + let (id, bytes) = self.controller.command(command).unwrap(); + self.write(&bytes).await; + self.wait_for(|e| match e { + Event::Reply { request, result } if *request == id => Some(*result), + _ => None, + }) + .await + .expect("a reply") + } +} From cf5f3be34a417fea10048fafa9497473bbfc4fba Mon Sep 17 00:00:00 2001 From: Max Boksem <15022344+MaxMB15@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:58:48 +0000 Subject: [PATCH 03/60] feat(remote): control another Cinette device from macOS and Linux Browses _cinette._tcp and keeps a list of the other devices, leaving out this one and records that don't read as Cinette protocol 1. The device being controlled stays listed as unavailable when discovery loses it. A link task per target owns the core's Controller: it connects to each address in turn, runs the handshake, reports the code and the target's answer, and passes commands and catalog queries through, with a ping after 10 s of quiet. A connection that drops without the target closing it reconnects with backoff and the session id, so the session resumes without a dialog, until the target's 30 s grace runs out, counted with time asleep. Each link has a generation, so events from a link that was left or replaced never reach the webview. The webview gets remote://devices, remote://controller and remote://state, and the remote_devices, remote_connect, remote_leave, remote_command and remote_catalog commands. On the frontend, a CinetteDestination plays the other device's items by id, and the remote-control store holds the devices and the link. --- .../src/commands/tests/arguments.json | 7 +- .../src-tauri/src/commands/tests/mod.rs | 2 + apps/player/src-tauri/src/lib.rs | 5 + apps/player/src-tauri/src/remote/commands.rs | 104 ++++ apps/player/src-tauri/src/remote/devices.rs | 175 ++++++ .../src-tauri/src/remote/devices_tests.rs | 235 ++++++++ apps/player/src-tauri/src/remote/link.rs | 461 +++++++++++++++ .../player/src-tauri/src/remote/link_tests.rs | 525 ++++++++++++++++++ apps/player/src-tauri/src/remote/mod.rs | 4 + apps/player/src-tauri/src/remote/service.rs | 236 +++++++- .../src-tauri/src/remote/service_tests.rs | 269 ++++++++- apps/player/src-tauri/src/test_support.rs | 44 +- .../player/src/hooks/useRemoteControl.test.ts | 118 ++++ apps/player/src/hooks/useRemoteControl.ts | 58 ++ .../src/lib/cinette-destination.test.ts | 253 +++++++++ apps/player/src/lib/cinette-destination.ts | 240 ++++++++ apps/player/src/lib/remote-control.test.ts | 218 ++++++++ apps/player/src/lib/remote-control.ts | 138 +++++ apps/player/src/lib/tauri.test.ts | 15 + apps/player/src/lib/tauri.ts | 42 +- 20 files changed, 3118 insertions(+), 31 deletions(-) create mode 100644 apps/player/src-tauri/src/remote/devices.rs create mode 100644 apps/player/src-tauri/src/remote/devices_tests.rs create mode 100644 apps/player/src-tauri/src/remote/link.rs create mode 100644 apps/player/src-tauri/src/remote/link_tests.rs create mode 100644 apps/player/src/hooks/useRemoteControl.test.ts create mode 100644 apps/player/src/hooks/useRemoteControl.ts create mode 100644 apps/player/src/lib/cinette-destination.test.ts create mode 100644 apps/player/src/lib/cinette-destination.ts create mode 100644 apps/player/src/lib/remote-control.test.ts create mode 100644 apps/player/src/lib/remote-control.ts diff --git a/apps/player/src-tauri/src/commands/tests/arguments.json b/apps/player/src-tauri/src/commands/tests/arguments.json index 2d4e8034..e43b7dff 100644 --- a/apps/player/src-tauri/src/commands/tests/arguments.json +++ b/apps/player/src-tauri/src/commands/tests/arguments.json @@ -111,5 +111,10 @@ "remote_sessions": {}, "remote_disconnect": { "required": ["sessionId"] }, "remote_set_state": { "required": ["state"] }, - "remote_finish_command": { "required": ["token", "outcome"] } + "remote_finish_command": { "required": ["token", "outcome"] }, + "remote_devices": {}, + "remote_connect": { "required": ["deviceId"] }, + "remote_leave": {}, + "remote_command": { "required": ["command"] }, + "remote_catalog": { "required": ["catalogQuery"] } } diff --git a/apps/player/src-tauri/src/commands/tests/mod.rs b/apps/player/src-tauri/src/commands/tests/mod.rs index d4fb8b2e..165ef4b3 100644 --- a/apps/player/src-tauri/src/commands/tests/mod.rs +++ b/apps/player/src-tauri/src/commands/tests/mod.rs @@ -197,6 +197,8 @@ fn sample(argument: &str) -> Value { "keep" | "allow" => json!(true), "request" | "token" => json!(1), "outcome" => json!("ok"), + "command" => json!({ "action": "stop" }), + "catalogQuery" => json!({ "kind": "groups", "contentType": "live" }), "settings" => json!({ "discoverable": true, "remoteControl": "ask", "deviceName": null }), "state" => json!({ "status": "idle", "item": null, "position": 0, "duration": 0, "volume": null, diff --git a/apps/player/src-tauri/src/lib.rs b/apps/player/src-tauri/src/lib.rs index 276bc984..4ca7a0ac 100644 --- a/apps/player/src-tauri/src/lib.rs +++ b/apps/player/src-tauri/src/lib.rs @@ -145,6 +145,11 @@ register_commands![ remote_commands::remote_disconnect, remote_commands::remote_set_state, remote_commands::remote_finish_command, + remote_commands::remote_devices, + remote_commands::remote_connect, + remote_commands::remote_leave, + remote_commands::remote_command, + remote_commands::remote_catalog, commands::load_m3u_playlist, commands::load_m3u_file, commands::load_xtream_provider, diff --git a/apps/player/src-tauri/src/remote/commands.rs b/apps/player/src-tauri/src/remote/commands.rs index 87e31589..e77fefcc 100644 --- a/apps/player/src-tauri/src/remote/commands.rs +++ b/apps/player/src-tauri/src/remote/commands.rs @@ -203,6 +203,21 @@ mod imp { .collect() } + pub fn not_connected() -> String { + "notConnected".to_owned() + } + + /// The error code the webview gets, as the protocol spells it. + pub fn link_error(e: crate::remote::link::LinkError) -> String { + match e { + crate::remote::link::LinkError::NotConnected => not_connected(), + crate::remote::link::LinkError::Refused(code) => serde_json::to_value(code) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| "failed".to_owned()), + } + } + pub fn provider_hosts(app: &AppHandle) -> Vec { let state = app.state::(); let Ok(cache) = state.cache.lock() else { @@ -365,6 +380,95 @@ pub async fn remote_finish_command( } } +/// The other Cinette devices on the network. +#[command] +pub async fn remote_devices(app: AppHandle) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + serde_json::to_value(imp::service(&app)?.devices()).map_err(|e| e.to_string()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = app; + Err(UNAVAILABLE.into()) + } +} + +/// Asks a device for control. Progress comes as `remote://controller`. +#[command] +pub async fn remote_connect( + app: AppHandle, + device_id: String, +) -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let id = mvp_core::remote::identity::DeviceId::parse(&device_id) + .ok_or_else(|| "That isn't a device id.".to_owned())?; + imp::service(&app)?.connect(id) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, device_id); + Err(UNAVAILABLE.into()) + } +} + +/// Ends the session with the controlled device, or stops asking. That +/// device keeps playing. +#[command] +pub async fn remote_leave(app: AppHandle) -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + imp::service(&app)?.leave().await; + Ok(()) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = app; + Err(UNAVAILABLE.into()) + } +} + +/// Sends a playback command to the controlled device. Resolves with that +/// device's state version once it carried the command out; fails with an +/// error code as the protocol spells it, such as `stale_item`, or +/// `notConnected` when there is no session. +#[command] +pub async fn remote_command( + app: AppHandle, + command: mvp_core::remote::message::Command, +) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let link = imp::service(&app)?.link().ok_or_else(imp::not_connected)?; + link.command(command).await.map_err(imp::link_error) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, command); + Err(UNAVAILABLE.into()) + } +} + +/// A page of the controlled device's catalog. +#[command] +pub async fn remote_catalog( + app: AppHandle, + catalog_query: mvp_core::remote::catalog::CatalogQuery, +) -> Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let link = imp::service(&app)?.link().ok_or_else(imp::not_connected)?; + let (items, next) = link.catalog(catalog_query).await.map_err(imp::link_error)?; + Ok(serde_json::json!({ "items": items, "next": next })) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (app, catalog_query); + Err(UNAVAILABLE.into()) + } +} + #[cfg(test)] #[path = "commands_tests.rs"] mod tests; diff --git a/apps/player/src-tauri/src/remote/devices.rs b/apps/player/src-tauri/src/remote/devices.rs new file mode 100644 index 00000000..620f99f6 --- /dev/null +++ b/apps/player/src-tauri/src/remote/devices.rs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! The other Cinette devices on the network, as `_cinette._tcp` browsing +//! finds them. +//! +//! A record is only a hint: the handshake proves which key is behind an id. +//! Records that don't read as a Cinette device (an unknown version, an id +//! that doesn't parse) are left out, and so is this device's own record. + +use crate::discovery::{Event, Txt}; +use mvp_core::remote::identity::DeviceId; +use mvp_core::remote::message::{DeviceType, PROTOCOL_VERSION}; +use mvp_core::remote::settings::clean_name; +use serde::Serialize; +use std::collections::BTreeMap; +use std::net::{IpAddr, SocketAddr}; + +/// A device as the picker shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDevice { + pub device_id: String, + pub name: String, + pub device_type: DeviceType, + /// False when its owner set Remote control to Never allow. + pub control_allowed: bool, + /// False once discovery lost it. Only the device this one controls is + /// kept listed after that. + pub available: bool, +} + +#[derive(Debug, Clone, PartialEq)] +struct Entry { + id: DeviceId, + device: RemoteDevice, + addrs: Vec, + port: u16, +} + +/// What a record says, if it's a Cinette device this one can talk to. +fn read_record(txt: &Txt) -> Option<(DeviceId, String, DeviceType, bool)> { + let speaks_ours = txt + .get_str("v")? + .split(',') + .any(|v| v.trim().parse::().ok() == Some(PROTOCOL_VERSION)); + if !speaks_ours { + return None; + } + let id = DeviceId::parse(txt.get_str("id")?)?; + let device_type = txt + .get_str("t") + .and_then(DeviceType::parse) + .unwrap_or(DeviceType::Other); + let name = txt + .get_str("n") + .and_then(clean_name) + .unwrap_or_else(|| mvp_core::remote::settings::default_name(device_type).to_owned()); + let control_allowed = txt.get_str("rc") == Some("1"); + Some((id, name, device_type, control_allowed)) +} + +/// The devices found so far, by DNS-SD instance name. +#[derive(Debug, Default)] +pub struct Devices { + own: Option, + entries: BTreeMap, + /// The device being controlled stays listed when discovery loses it. + keep: Option, +} + +impl Devices { + /// Devices, leaving out the one with id `own`. + pub fn new(own: DeviceId) -> Self { + Self { + own: Some(own), + ..Self::default() + } + } + + /// This device's id changed. + pub fn set_own(&mut self, own: DeviceId) { + self.entries.retain(|_, e| e.id != own); + self.own = Some(own); + } + + /// Keeps `id` listed, as unavailable, if discovery loses it. + pub fn keep(&mut self, id: Option) { + self.keep = id; + let keep = self.keep.clone(); + self.entries + .retain(|_, e| e.device.available || Some(&e.id) == keep.as_ref()); + } + + /// Takes in a discovery event. True when the list changed. + pub fn apply(&mut self, event: Event) -> bool { + match event { + Event::Found { + instance, + addrs, + port, + txt, + .. + } => { + let Some((id, name, device_type, control_allowed)) = read_record(&txt) else { + return self.entries.remove(&instance).is_some(); + }; + if Some(&id) == self.own.as_ref() { + return false; + } + let entry = Entry { + device: RemoteDevice { + device_id: id.to_string(), + name, + device_type, + control_allowed, + available: true, + }, + id, + addrs, + port, + }; + // The same device under a new instance name replaces the old one. + let before = self.entries.len(); + self.entries + .retain(|i, e| *i == instance || e.id != entry.id); + let replaced = self.entries.len() != before; + let changed = self.entries.get(&instance) != Some(&entry); + self.entries.insert(instance, entry); + changed || replaced + } + Event::Removed { instance } => { + let keep = self.keep.clone(); + match self.entries.get_mut(&instance) { + Some(e) if Some(&e.id) == keep.as_ref() => { + let was = e.device.available; + e.device.available = false; + was + } + Some(_) => self.entries.remove(&instance).is_some(), + None => false, + } + } + } + } + + /// The list for the picker, by name. + pub fn list(&self) -> Vec { + let mut list: Vec = self.entries.values().map(|e| e.device.clone()).collect(); + list.sort_by(|a, b| a.name.cmp(&b.name).then(a.device_id.cmp(&b.device_id))); + list + } + + /// Where to reach a device, IPv4 first, or nothing when it isn't + /// available now. + pub fn addresses(&self, id: &DeviceId) -> Vec { + self.entries + .values() + .filter(|e| &e.id == id && e.device.available) + .flat_map(|e| e.addrs.iter().map(|a| SocketAddr::new(*a, e.port))) + .collect() + } + + /// The device with this id, if it's listed. + pub fn get(&self, id: &DeviceId) -> Option<&RemoteDevice> { + self.entries + .values() + .find(|e| &e.id == id) + .map(|e| &e.device) + } +} + +#[cfg(test)] +#[path = "devices_tests.rs"] +mod tests; diff --git a/apps/player/src-tauri/src/remote/devices_tests.rs b/apps/player/src-tauri/src/remote/devices_tests.rs new file mode 100644 index 00000000..dcad7f72 --- /dev/null +++ b/apps/player/src-tauri/src/remote/devices_tests.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +use super::*; +use mvp_core::remote::identity::DeviceKey; +use std::net::Ipv4Addr; + +fn ip(last: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(192, 168, 1, last)) +} + +fn found(instance: &str, id: &DeviceId, extra: &[(&str, &str)]) -> Event { + let mut txt: Vec<(String, String)> = vec![ + ("v".into(), "1".into()), + ("id".into(), id.to_string()), + ("n".into(), "Living Room".into()), + ("t".into(), "fire-tv".into()), + ("rc".into(), "1".into()), + ]; + for (k, v) in extra { + txt.retain(|(key, _)| key != k); + txt.push(((*k).into(), (*v).into())); + } + Event::Found { + instance: instance.into(), + host: "tv.local.".into(), + addrs: vec![ip(20)], + port: 47130, + txt: Txt::new(txt), + } +} + +fn id() -> DeviceId { + DeviceKey::generate().device_id() +} + +#[test] +fn a_record_becomes_a_device() { + let own = id(); + let tv = id(); + let mut devices = Devices::new(own); + assert!(devices.apply(found("Living Room", &tv, &[]))); + assert_eq!( + devices.list(), + vec![RemoteDevice { + device_id: tv.to_string(), + name: "Living Room".into(), + device_type: DeviceType::FireTv, + control_allowed: true, + available: true, + }] + ); + assert_eq!(devices.addresses(&tv), vec![SocketAddr::new(ip(20), 47130)]); + assert_eq!(devices.get(&tv).unwrap().name, "Living Room"); + assert!( + !devices.apply(found("Living Room", &tv, &[])), + "nothing changed" + ); +} + +#[test] +fn never_allow_shows_as_not_allowed() { + let tv = id(); + let mut devices = Devices::new(id()); + devices.apply(found("TV", &tv, &[("rc", "0")])); + assert!(!devices.list()[0].control_allowed); + devices.apply(found("TV", &tv, &[("rc", "yes")])); + assert!(!devices.list()[0].control_allowed, "only 1 allows"); +} + +#[test] +fn this_devices_own_record_is_left_out() { + let own = id(); + let mut devices = Devices::new(own.clone()); + assert!(!devices.apply(found("Me", &own, &[]))); + assert!(devices.list().is_empty()); + + let other = id(); + devices.apply(found("Other", &other, &[])); + devices.set_own(other.clone()); + assert!( + devices.list().is_empty(), + "a new own id drops a listing with it" + ); + assert!(!devices.apply(found("Other", &other, &[]))); +} + +#[test] +fn records_that_are_not_ours_are_left_out() { + let tv = id(); + let mut devices = Devices::new(id()); + for bad in [ + found("A", &tv, &[("v", "2")]), + found("B", &tv, &[("id", "not-an-id")]), + found("C", &tv, &[("v", "")]), + ] { + assert!(!devices.apply(bad)); + } + let Event::Found { + instance, + host, + addrs, + port, + .. + } = found("D", &tv, &[]) + else { + unreachable!() + }; + let no_version = Event::Found { + instance, + host, + addrs, + port, + txt: Txt::new([("id", tv.to_string())]), + }; + assert!(!devices.apply(no_version)); + assert!(devices.list().is_empty()); +} + +#[test] +fn a_record_that_stops_reading_as_ours_is_dropped() { + let tv = id(); + let mut devices = Devices::new(id()); + devices.apply(found("TV", &tv, &[])); + assert!(devices.apply(found("TV", &tv, &[("v", "9")]))); + assert!(devices.list().is_empty()); +} + +#[test] +fn a_later_version_list_that_includes_ours_is_fine() { + let tv = id(); + let mut devices = Devices::new(id()); + assert!(devices.apply(found("TV", &tv, &[("v", "2, 1")]))); +} + +#[test] +fn a_missing_name_or_type_falls_back() { + let tv = id(); + let mut devices = Devices::new(id()); + devices.apply(found("TV", &tv, &[("n", " "), ("t", "toaster")])); + let d = &devices.list()[0]; + assert_eq!(d.device_type, DeviceType::Other); + assert_eq!( + d.name, + mvp_core::remote::settings::default_name(DeviceType::Other) + ); + + devices.apply(found("TV", &tv, &[("n", "Den\u{7}TV")])); + assert_eq!( + devices.list()[0].name, + "Den TV", + "control characters are cleaned" + ); + assert_eq!(devices.list()[0].device_type, DeviceType::FireTv); +} + +#[test] +fn removing_drops_a_device_unless_it_is_kept() { + let tv = id(); + let den = id(); + let mut devices = Devices::new(id()); + devices.apply(found("TV", &tv, &[])); + devices.apply(found("Den", &den, &[("n", "Den")])); + devices.keep(Some(tv.clone())); + + assert!(devices.apply(Event::Removed { + instance: "Den".into() + })); + assert!(devices.get(&den).is_none()); + + assert!(devices.apply(Event::Removed { + instance: "TV".into() + })); + assert!(!devices.get(&tv).unwrap().available); + assert!(devices.addresses(&tv).is_empty(), "nowhere to reach it now"); + assert!(!devices.apply(Event::Removed { + instance: "TV".into() + })); + assert!(!devices.apply(Event::Removed { + instance: "nobody".into() + })); + + assert!(devices.apply(found("TV", &tv, &[])), "found again"); + assert!(devices.get(&tv).unwrap().available); + + devices.apply(Event::Removed { + instance: "TV".into(), + }); + devices.keep(None); + assert!(devices.get(&tv).is_none(), "no longer kept, so dropped"); +} + +#[test] +fn keeping_a_device_leaves_available_ones_listed() { + let tv = id(); + let mut devices = Devices::new(id()); + devices.apply(found("TV", &tv, &[])); + devices.keep(None); + assert!(devices.get(&tv).is_some()); +} + +#[test] +fn a_device_under_a_new_instance_name_replaces_the_old_listing() { + let tv = id(); + let mut devices = Devices::new(id()); + devices.apply(found("Living Room", &tv, &[])); + assert!(devices.apply(found("Living Room (2)", &tv, &[]))); + assert_eq!(devices.list().len(), 1); + assert!(devices.apply(Event::Removed { + instance: "Living Room (2)".into() + })); + assert!(devices.list().is_empty()); +} + +#[test] +fn the_list_is_in_name_order() { + let mut devices = Devices::new(id()); + devices.apply(found("1", &id(), &[("n", "Kitchen")])); + devices.apply(found("2", &id(), &[("n", "Bedroom")])); + let a = id(); + let b = id(); + devices.apply(found("3", &a, &[("n", "Same")])); + devices.apply(found("4", &b, &[("n", "Same")])); + let names: Vec = devices.list().into_iter().map(|d| d.name).collect(); + assert_eq!(names, ["Bedroom", "Kitchen", "Same", "Same"]); + let same: Vec = devices + .list() + .into_iter() + .filter(|d| d.name == "Same") + .map(|d| d.device_id) + .collect(); + let mut sorted = vec![a.to_string(), b.to_string()]; + sorted.sort(); + assert_eq!(same, sorted, "ties go by id"); +} diff --git a/apps/player/src-tauri/src/remote/link.rs b/apps/player/src-tauri/src/remote/link.rs new file mode 100644 index 00000000..43f361c4 --- /dev/null +++ b/apps/player/src-tauri/src/remote/link.rs @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! This device controlling another: one task per target, owning the core's +//! [`Controller`]. +//! +//! The task connects, runs the handshake, waits for the target's answer and +//! then passes commands and catalog queries through, with a ping when +//! nothing else went out for 10 s. When the connection drops without the +//! target closing it, the task reconnects with the session id, so the +//! session resumes without a dialog, for as long as the target keeps it +//! (30 s, counted with time asleep). After that it gives up: control never +//! comes back silently once a session has ended. + +use super::clock; +use super::conn::{self, Read, Writer}; +use mvp_core::remote::catalog::{CatalogItem, CatalogQuery}; +use mvp_core::remote::controller::{Controller, Event, PING_INTERVAL}; +use mvp_core::remote::identity::{DeviceId, DeviceKey}; +use mvp_core::remote::message::{CloseReason, Command, ErrorCode, Hello, VersionedState}; +use mvp_core::remote::target::IDLE_TIMEOUT; +use serde::Serialize; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot}; + +/// How long one connection attempt may take. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// How long the handshake may take once connected. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); +/// The waits between reconnection attempts. The last one repeats. +const BACKOFF: [Duration; 4] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), +]; +/// The longest the task sleeps without looking at the clock, so a sleep of +/// the whole computer is noticed soon after it wakes. +const MAX_NAP: Duration = Duration::from_secs(2); + +/// Where the connection stands, as `remote://controller` reports it. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde( + tag = "phase", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Phase { + Connecting, + /// The handshake is done. Both screens show this code. + Verifying { + code: String, + }, + /// The target is asking its user. + Pending { + expires_in_ms: u64, + }, + Connected, + /// The connection dropped, and the session can still resume. + Reconnecting { + attempt: u32, + }, + /// It's over. `reason` is the target's, or one of the local ones. + Closed { + reason: Ended, + }, +} + +/// Why a connection ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Ended { + /// The target or the handshake closed it, for this reason. + Target(CloseReason), + /// No address of the device answered. + Unreachable, + /// The connection dropped and didn't come back in time. + Lost, + /// This device left. + Left, +} + +/// What the task tells the app. `link` is the [`LinkConfig::generation`] +/// of the link that says it, so the app can ignore one it already replaced. +pub trait LinkHost: Send + Sync + 'static { + fn phase(&self, link: u64, device: &DeviceId, phase: &Phase); + fn state(&self, link: u64, device: &DeviceId, state: &VersionedState); +} + +/// Where to reach the target. Asked again before each attempt, since its +/// address may change while the connection is down. +pub type Addresses = Arc Vec + Send + Sync>; + +/// A catalog page, or why there isn't one. +pub type Page = Result<(Vec, Option), LinkError>; + +/// Why a command or query got no answer from the target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LinkError { + /// The target refused it with this code. + Refused(ErrorCode), + /// There's no session right now. + NotConnected, +} + +enum Input { + Command(Command, oneshot::Sender>), + Catalog(CatalogQuery, oneshot::Sender), + Leave(oneshot::Sender<()>), +} + +/// Talks to a running link. +#[derive(Clone)] +pub struct LinkHandle { + inbox: mpsc::UnboundedSender, + generation: u64, +} + +impl LinkHandle { + /// The number it was started with. + pub fn generation(&self) -> u64 { + self.generation + } + + /// Sends a command. Resolves with the target's state version once it + /// carried it out. + pub async fn command(&self, command: Command) -> Result { + let (tx, rx) = oneshot::channel(); + self.inbox + .send(Input::Command(command, tx)) + .map_err(|_| LinkError::NotConnected)?; + rx.await.unwrap_or(Err(LinkError::NotConnected)) + } + + pub async fn catalog(&self, query: CatalogQuery) -> Page { + let (tx, rx) = oneshot::channel(); + self.inbox + .send(Input::Catalog(query, tx)) + .map_err(|_| LinkError::NotConnected)?; + rx.await.unwrap_or(Err(LinkError::NotConnected)) + } + + /// Stops asking, or ends the session. The target keeps playing. + pub async fn leave(&self) { + let (tx, rx) = oneshot::channel(); + if self.inbox.send(Input::Leave(tx)).is_ok() { + let _ = rx.await; + } + } +} + +/// Everything a link needs to connect. +pub struct LinkConfig { + pub key: DeviceKey, + pub hello: Hello, + pub target: DeviceId, + pub addresses: Addresses, + /// How long after a drop the session can still resume: the target's + /// [`mvp_core::remote::sessions::RESUME_GRACE`]. + pub grace: Duration, + /// A number that tells this link from earlier ones to the same device. + pub generation: u64, +} + +/// Starts connecting to a target. +pub fn start(config: LinkConfig, host: Arc) -> LinkHandle { + let (inbox, rx) = mpsc::unbounded_channel(); + let generation = config.generation; + tokio::spawn(run(config, host, rx)); + LinkHandle { inbox, generation } +} + +/// How one connection ended. +enum Outcome { + /// For good, for this reason. + Ended(Ended), + /// It dropped. The session, if there is one, may resume. + Dropped, +} + +async fn run( + config: LinkConfig, + host: Arc, + mut inbox: mpsc::UnboundedReceiver, +) { + let device = config.target.clone(); + let link = config.generation; + let mut session: Option = None; + let mut dropped_at: Option = None; + let mut attempt: u32 = 0; + loop { + host.phase( + link, + &device, + &if attempt == 0 { + Phase::Connecting + } else { + Phase::Reconnecting { attempt } + }, + ); + let outcome = match connect_any(&(config.addresses)()).await { + Some(stream) => { + let mut session_back = false; + let result = connection( + &config, + &host, + stream, + &mut session, + &mut session_back, + &mut inbox, + ) + .await; + if session_back { + // The session came back, so a later drop starts afresh. + dropped_at = None; + attempt = 0; + } + result + } + None if session.is_some() => Outcome::Dropped, + None => Outcome::Ended(Ended::Unreachable), + }; + let ended = match outcome { + Outcome::Ended(ended) => Some(ended), + Outcome::Dropped if session.is_none() => Some(Ended::Lost), + Outcome::Dropped => { + let since = *dropped_at.get_or_insert_with(clock::now); + let wait = BACKOFF[(attempt as usize).min(BACKOFF.len() - 1)]; + if clock::now() + wait >= since + config.grace { + Some(Ended::Lost) + } else { + attempt += 1; + match wait_or_leave(wait, &mut inbox).await { + true => Some(Ended::Left), + false => None, + } + } + } + }; + if let Some(reason) = ended { + host.phase(link, &device, &Phase::Closed { reason }); + return; + } + } +} + +/// Waits before the next attempt, answering what comes in meanwhile. True +/// when this device left. +async fn wait_or_leave(wait: Duration, inbox: &mut mpsc::UnboundedReceiver) -> bool { + let until = tokio::time::Instant::now() + wait; + loop { + tokio::select! { + input = inbox.recv() => match input { + None => return true, + Some(Input::Leave(done)) => { + let _ = done.send(()); + return true; + } + Some(input) => refuse(input), + }, + () = tokio::time::sleep_until(until) => return false, + } + } +} + +fn refuse(input: Input) { + match input { + Input::Command(_, reply) => { + let _ = reply.send(Err(LinkError::NotConnected)); + } + Input::Catalog(_, reply) => { + let _ = reply.send(Err(LinkError::NotConnected)); + } + Input::Leave(done) => { + let _ = done.send(()); + } + } +} + +/// The first of `addrs` that takes a connection. +async fn connect_any(addrs: &[SocketAddr]) -> Option { + for addr in addrs { + if let Ok(Ok(stream)) = + tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await + { + return Some(stream); + } + } + None +} + +enum Waiting { + Command(oneshot::Sender>), + Catalog(oneshot::Sender), +} + +/// One connection, from the handshake until it ends. +async fn connection( + config: &LinkConfig, + host: &Arc, + stream: TcpStream, + session: &mut Option, + session_back: &mut bool, + inbox: &mut mpsc::UnboundedReceiver, +) -> Outcome { + let device = &config.target; + let link = config.generation; + let (mut controller, first) = match Controller::connect( + &config.key, + config.hello.clone(), + config.target.clone(), + session.clone(), + ) { + Ok(started) => started, + Err(reason) => return Outcome::Ended(Ended::Target(reason)), + }; + let (reads_tx, mut reads) = mpsc::unbounded_channel(); + let writer: Writer = conn::spawn(stream, (), reads_tx); + let _ = writer.send(first); + let opened = clock::now(); + let mut last_heard = opened; + let mut last_sent = opened; + let mut verified = false; + let mut waiting: HashMap = HashMap::new(); + + let finish = |waiting: HashMap| { + for (_, w) in waiting { + match w { + Waiting::Command(reply) => { + let _ = reply.send(Err(LinkError::NotConnected)); + } + Waiting::Catalog(reply) => { + let _ = reply.send(Err(LinkError::NotConnected)); + } + } + } + }; + + loop { + tokio::select! { + read = reads.recv() => { + let frame = match read { + Some(((), Read::Frame(frame))) => frame, + _ => { + finish(waiting); + return Outcome::Dropped; + } + }; + last_heard = clock::now(); + let output = controller.receive(&frame); + for bytes in output.send { + let _ = writer.send(bytes); + last_sent = clock::now(); + } + for event in output.events { + match event { + Event::Verified { code, .. } => { + verified = true; + host.phase(link, device, &Phase::Verifying { code: code.to_string() }); + } + Event::Pending { expires_in } => host.phase( + link, + device, + &Phase::Pending { expires_in_ms: u64::from(expires_in) * 1000 }, + ), + Event::Connected { session_id, state } => { + *session = Some(session_id); + *session_back = true; + host.phase(link, device, &Phase::Connected); + host.state(link, device, &state); + } + Event::State(state) => host.state(link, device, &state), + Event::Reply { request, result } => { + if let Some(Waiting::Command(reply)) = waiting.remove(&request) { + let _ = reply.send(result.map_err(LinkError::Refused)); + } + } + Event::Page { request, items, next } => { + if let Some(Waiting::Catalog(reply)) = waiting.remove(&request) { + let _ = reply.send(Ok((items, next))); + } + } + Event::Error(code) => { + tracing::warn!("[remote] {device} reported {code:?}"); + } + Event::Closed(reason) => { + finish(waiting); + // A resume the target no longer knows, after it + // restarted, is a new request; it asks again. + return Outcome::Ended(Ended::Target(reason)); + } + } + } + } + input = inbox.recv() => match input { + None => { + if let Some(bytes) = controller.leave() { + let _ = writer.send(bytes); + } + finish(waiting); + return Outcome::Ended(Ended::Left); + } + Some(Input::Leave(done)) => { + if let Some(bytes) = controller.leave() { + let _ = writer.send(bytes); + } + finish(waiting); + // Dropping the writer flushes the goodbye, then closes. + drop(writer); + let _ = done.send(()); + return Outcome::Ended(Ended::Left); + } + Some(Input::Command(command, reply)) => match controller.command(command) { + Ok((id, bytes)) => { + let _ = writer.send(bytes); + last_sent = clock::now(); + waiting.insert(id, Waiting::Command(reply)); + } + Err(_) => { + let _ = reply.send(Err(LinkError::NotConnected)); + } + }, + Some(Input::Catalog(query, reply)) => match controller.catalog(query) { + Ok((id, bytes)) => { + let _ = writer.send(bytes); + last_sent = clock::now(); + waiting.insert(id, Waiting::Catalog(reply)); + } + Err(_) => { + let _ = reply.send(Err(LinkError::NotConnected)); + } + }, + }, + () = tokio::time::sleep(MAX_NAP.min(PING_INTERVAL)) => {} + } + let now = clock::now(); + if !verified && now.duration_since(opened) >= HANDSHAKE_TIMEOUT { + finish(waiting); + return Outcome::Ended(Ended::Unreachable); + } + if verified && now.duration_since(last_heard) >= IDLE_TIMEOUT { + finish(waiting); + return Outcome::Dropped; + } + if now.duration_since(last_sent) >= PING_INTERVAL + && let Some(bytes) = controller.ping() + { + let _ = writer.send(bytes); + last_sent = now; + } + } +} + +#[cfg(test)] +#[path = "link_tests.rs"] +mod tests; diff --git a/apps/player/src-tauri/src/remote/link_tests.rs b/apps/player/src-tauri/src/remote/link_tests.rs new file mode 100644 index 00000000..02086bd0 --- /dev/null +++ b/apps/player/src-tauri/src/remote/link_tests.rs @@ -0,0 +1,525 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +//! A link against the real target task over loopback, with a proxy between +//! them that can drop the connection the way a network does. + +use super::*; +use crate::discovery::Registration; +use crate::remote::target::{self, CatalogPage, TargetHandle, TargetHost}; +use crate::remote::test_client::WAIT; +use mvp_core::remote::consent::{Answer, RequestId}; +use mvp_core::remote::message::{DeviceType, Feature, PlaybackSnapshot, PROTOCOL_VERSION}; +use mvp_core::remote::settings::{ControlMode, RemoteSettings}; +use mvp_core::remote::target::{ConsentPrompt, SessionInfo}; +use std::sync::Mutex; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +/// A target host that carries out every command at once. +struct Player { + target: Mutex>, + prompts: mpsc::UnboundedSender<(RequestId, String)>, +} + +impl TargetHost for Player { + fn show_consent(&self, prompt: &ConsentPrompt) { + let _ = self.prompts.send((prompt.request, prompt.code.clone())); + } + fn hide_consent(&self, _: RequestId) {} + fn sessions_changed(&self, _: &[SessionInfo]) {} + fn apply(&self, token: u64, _: &Command) { + if let Some(target) = self.target.lock().unwrap().as_ref() { + target.finish_command(token, Ok(())); + } + } + fn catalog(&self, _: CatalogQuery) -> CatalogPage { + Ok(( + vec![CatalogItem { + id: "live-1".into(), + name: "News".into(), + ..CatalogItem::default() + }], + None, + )) + } + fn advertise(&self, _: Option) {} +} + +struct Target { + handle: TargetHandle, + key: DeviceKey, + port: u16, + prompts: mpsc::UnboundedReceiver<(RequestId, String)>, +} + +async fn target(mode: ControlMode) -> Target { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let key = DeviceKey::generate(); + let (tx, prompts) = mpsc::unbounded_channel(); + let player = Arc::new(Player { + target: Mutex::new(None), + prompts: tx, + }); + let settings = RemoteSettings { + remote_control: mode, + ..RemoteSettings::default() + }; + let handle = target::start( + listener, + key.clone(), + DeviceType::FireTv, + settings, + player.clone(), + ) + .unwrap(); + *player.target.lock().unwrap() = Some(handle.clone()); + Target { + handle, + key, + port, + prompts, + } +} + +/// Forwards connections to the target, and can cut them or stop taking new +/// ones. +struct Proxy { + port: u16, + accepting: Arc>, + pipes: Arc>>>, +} + +impl Proxy { + async fn new(to: u16) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let accepting = Arc::new(Mutex::new(true)); + let pipes: Arc>>> = Arc::default(); + let (open, list) = (accepting.clone(), pipes.clone()); + tokio::spawn(async move { + while let Ok((mut inbound, _)) = listener.accept().await { + if !*open.lock().unwrap() { + // Refused, as an unreachable device would be. + drop(inbound); + continue; + } + let pipe = tokio::spawn(async move { + let mut outbound = TcpStream::connect(("127.0.0.1", to)).await.unwrap(); + let _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await; + }); + list.lock().unwrap().push(pipe); + } + }); + Self { + port, + accepting, + pipes, + } + } + + /// Cuts every connection without a word to either side, as a dropped + /// network does, and stops forwarding new ones when `refuse` is set. + fn cut(&self, refuse: bool) { + *self.accepting.lock().unwrap() = !refuse; + for pipe in self.pipes.lock().unwrap().drain(..) { + pipe.abort(); + } + } + + fn reopen(&self) { + *self.accepting.lock().unwrap() = true; + } +} + +#[derive(Default)] +struct Seen { + phases: Vec, + states: Vec, +} + +struct Recorder { + seen: Mutex, + tx: mpsc::UnboundedSender<()>, +} + +impl LinkHost for Recorder { + fn phase(&self, _: u64, _: &DeviceId, phase: &Phase) { + self.seen.lock().unwrap().phases.push(phase.clone()); + let _ = self.tx.send(()); + } + fn state(&self, _: u64, _: &DeviceId, state: &VersionedState) { + self.seen.lock().unwrap().states.push(state.clone()); + let _ = self.tx.send(()); + } +} + +struct Link { + handle: LinkHandle, + recorder: Arc, + changes: mpsc::UnboundedReceiver<()>, +} + +impl Link { + fn phases(&self) -> Vec { + self.recorder.seen.lock().unwrap().phases.clone() + } + + fn states(&self) -> Vec { + self.recorder.seen.lock().unwrap().states.clone() + } + + /// Waits until `done` holds. + async fn until(&mut self, mut done: impl FnMut(&Self) -> bool) { + let result = timeout(WAIT, async { + while !done(self) { + self.changes.recv().await.unwrap(); + } + }) + .await; + assert!(result.is_ok(), "timed out; phases {:?}", self.phases()); + } + + async fn until_phase(&mut self, phase: Phase) { + self.until(|l| l.phases().contains(&phase)).await; + } + + async fn closed(&mut self) -> Ended { + self.until(|l| matches!(l.phases().last(), Some(Phase::Closed { .. }))) + .await; + match self.phases().last() { + Some(Phase::Closed { reason }) => *reason, + _ => unreachable!(), + } + } +} + +fn link_to(target: &Target, port: u16, grace: Duration) -> Link { + link_with( + target, + Arc::new(move || vec![SocketAddr::from(([127, 0, 0, 1], port))]), + grace, + ) +} + +fn link_with(target: &Target, addresses: Addresses, grace: Duration) -> Link { + let (tx, changes) = mpsc::unbounded_channel(); + let recorder = Arc::new(Recorder { + seen: Mutex::default(), + tx, + }); + let key = DeviceKey::generate(); + let hello = Hello { + id: key.device_id(), + name: "Mac".into(), + device_type: DeviceType::Mac, + versions: vec![PROTOCOL_VERSION], + features: vec![Feature::Control, Feature::Catalog], + }; + let handle = super::start( + LinkConfig { + key, + hello, + target: target.key.device_id(), + addresses, + grace, + generation: 7, + }, + recorder.clone(), + ); + Link { + handle, + recorder, + changes, + } +} + +const GRACE: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn connects_and_attaches_to_what_plays() { + let t = target(ControlMode::Always).await; + t.handle.set_state(PlaybackSnapshot { + position: 12.0, + ..PlaybackSnapshot::default() + }); + let mut link = link_to(&t, t.port, GRACE); + link.until_phase(Phase::Connected).await; + let phases = link.phases(); + assert_eq!(phases[0], Phase::Connecting); + assert!(matches!(phases[1], Phase::Verifying { ref code } if code.len() == 6)); + assert_eq!(phases[2], Phase::Connected); + link.until(|l| !l.states().is_empty()).await; + assert_eq!(link.states()[0].state.position, 12.0); + assert_eq!(link.handle.generation(), 7); +} + +#[tokio::test] +async fn waits_for_consent_with_the_same_code_and_hears_a_denial() { + let mut t = target(ControlMode::Ask).await; + let mut link = link_to(&t, t.port, GRACE); + link.until(|l| { + l.phases() + .iter() + .any(|p| matches!(p, Phase::Pending { .. })) + }) + .await; + let (request, shown) = timeout(WAIT, t.prompts.recv()).await.unwrap().unwrap(); + let code = link + .phases() + .iter() + .find_map(|p| match p { + Phase::Verifying { code } => Some(code.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(shown, code); + assert!( + link.phases().contains(&Phase::Pending { + expires_in_ms: 30_000 + }), + "{:?}", + link.phases() + ); + t.handle.answer(request, Answer::Deny); + assert_eq!(link.closed().await, Ended::Target(CloseReason::Denied)); +} + +#[tokio::test] +async fn commands_and_catalog_queries_get_their_answers() { + let t = target(ControlMode::Always).await; + let mut link = link_to(&t, t.port, GRACE); + link.until_phase(Phase::Connected).await; + assert!(link.handle.command(Command::Stop).await.is_ok()); + let refused = link + .handle + .command(Command::Pause { + item_id: "nothing".into(), + }) + .await; + assert_eq!(refused, Err(LinkError::Refused(ErrorCode::StaleItem))); + let page = link + .handle + .catalog(CatalogQuery::Search { + query: "news".into(), + content_type: None, + limit: 10, + }) + .await + .unwrap(); + assert_eq!(page.0[0].id, "live-1"); +} + +#[tokio::test] +async fn a_device_that_does_not_answer_is_unreachable() { + let t = target(ControlMode::Always).await; + let mut nowhere = link_with(&t, Arc::new(Vec::new), GRACE); + assert_eq!(nowhere.closed().await, Ended::Unreachable); + + let closed_port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let mut refused = link_to(&t, closed_port, GRACE); + assert_eq!(refused.closed().await, Ended::Unreachable); + assert_eq!( + refused.handle.command(Command::Stop).await, + Err(LinkError::NotConnected) + ); +} + +#[tokio::test] +async fn tries_each_address_in_turn() { + let t = target(ControlMode::Always).await; + let closed_port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let port = t.port; + let mut link = link_with( + &t, + Arc::new(move || { + vec![ + SocketAddr::from(([127, 0, 0, 1], closed_port)), + SocketAddr::from(([127, 0, 0, 1], port)), + ] + }), + GRACE, + ); + link.until_phase(Phase::Connected).await; +} + +#[tokio::test] +async fn a_dropped_connection_resumes_without_asking_again() { + let mut t = target(ControlMode::Ask).await; + let proxy = Proxy::new(t.port).await; + let mut link = link_to(&t, proxy.port, GRACE); + let (request, _) = timeout(WAIT, t.prompts.recv()).await.unwrap().unwrap(); + t.handle.answer(request, Answer::Allow); + link.until_phase(Phase::Connected).await; + + proxy.cut(false); + link.until_phase(Phase::Reconnecting { attempt: 1 }).await; + link.until(|l| { + l.phases() + .iter() + .filter(|p| **p == Phase::Connected) + .count() + == 2 + }) + .await; + assert!(t.prompts.try_recv().is_err(), "no second dialog"); + assert!(link.handle.command(Command::Stop).await.is_ok()); + assert_eq!(t.handle.sessions().await.len(), 1); +} + +#[tokio::test] +async fn commands_while_reconnecting_are_not_sent() { + let t = target(ControlMode::Always).await; + let proxy = Proxy::new(t.port).await; + let mut link = link_to(&t, proxy.port, GRACE); + link.until_phase(Phase::Connected).await; + proxy.cut(true); + link.until_phase(Phase::Reconnecting { attempt: 1 }).await; + assert_eq!( + link.handle.command(Command::Stop).await, + Err(LinkError::NotConnected) + ); + proxy.reopen(); + link.until(|l| { + l.phases() + .iter() + .filter(|p| **p == Phase::Connected) + .count() + == 2 + }) + .await; +} + +#[tokio::test] +async fn gives_up_when_the_grace_runs_out() { + let t = target(ControlMode::Always).await; + let proxy = Proxy::new(t.port).await; + let mut link = link_to(&t, proxy.port, Duration::from_millis(1500)); + link.until_phase(Phase::Connected).await; + proxy.cut(true); + assert_eq!(link.closed().await, Ended::Lost); + let reconnects = link + .phases() + .iter() + .filter(|p| matches!(p, Phase::Reconnecting { .. })) + .count(); + assert!(reconnects >= 1, "{:?}", link.phases()); +} + +#[tokio::test] +async fn a_drop_before_control_was_granted_is_lost() { + let t = target(ControlMode::Ask).await; + let proxy = Proxy::new(t.port).await; + let mut link = link_to(&t, proxy.port, GRACE); + link.until(|l| { + l.phases() + .iter() + .any(|p| matches!(p, Phase::Pending { .. })) + }) + .await; + proxy.cut(true); + assert_eq!(link.closed().await, Ended::Lost); +} + +#[tokio::test] +async fn leaving_ends_the_session_and_the_target_plays_on() { + let t = target(ControlMode::Always).await; + let mut link = link_to(&t, t.port, GRACE); + link.until_phase(Phase::Connected).await; + link.handle.leave().await; + assert_eq!(link.closed().await, Ended::Left); + let deadline = tokio::time::Instant::now() + WAIT; + while !t.handle.sessions().await.is_empty() { + assert!( + tokio::time::Instant::now() < deadline, + "the target kept the session" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + // A link that's gone takes nothing more. + link.handle.leave().await; + assert_eq!( + link.handle + .catalog(CatalogQuery::Groups { + content_type: "live".into() + }) + .await, + Err(LinkError::NotConnected) + ); +} + +#[tokio::test] +async fn leaving_while_reconnecting_stops_trying() { + let t = target(ControlMode::Always).await; + let proxy = Proxy::new(t.port).await; + let mut link = link_to(&t, proxy.port, GRACE); + link.until_phase(Phase::Connected).await; + proxy.cut(true); + link.until_phase(Phase::Reconnecting { attempt: 1 }).await; + link.handle.leave().await; + assert_eq!(link.closed().await, Ended::Left); +} + +#[tokio::test] +async fn the_target_closing_ends_it_without_retrying() { + let t = target(ControlMode::Always).await; + let mut link = link_to(&t, t.port, GRACE); + link.until_phase(Phase::Connected).await; + t.handle.shutdown().await; + assert_eq!(link.closed().await, Ended::Target(CloseReason::Shutdown)); + assert!( + !link + .phases() + .iter() + .any(|p| matches!(p, Phase::Reconnecting { .. })), + "{:?}", + link.phases() + ); +} + +#[tokio::test] +async fn the_wrong_key_at_the_address_is_refused() { + let t = target(ControlMode::Always).await; + let other = target(ControlMode::Always).await; + // Discovery said `t`, but `other` answers. + let mut link = link_to(&t, other.port, GRACE); + assert_eq!( + link.closed().await, + Ended::Target(CloseReason::IdentityMismatch) + ); +} + +#[tokio::test] +async fn phases_read_as_the_webview_expects() { + let json = |p: Phase| serde_json::to_value(p).unwrap(); + assert_eq!( + json(Phase::Connecting), + serde_json::json!({ "phase": "connecting" }) + ); + assert_eq!( + json(Phase::Pending { + expires_in_ms: 30_000 + }), + serde_json::json!({ "phase": "pending", "expiresInMs": 30_000 }) + ); + assert_eq!( + json(Phase::Closed { + reason: Ended::Target(CloseReason::Revoked) + }), + serde_json::json!({ "phase": "closed", "reason": { "target": "revoked" } }) + ); + assert_eq!( + json(Phase::Closed { + reason: Ended::Lost + }), + serde_json::json!({ "phase": "closed", "reason": "lost" }) + ); +} diff --git a/apps/player/src-tauri/src/remote/mod.rs b/apps/player/src-tauri/src/remote/mod.rs index 2c578c23..1e52181b 100644 --- a/apps/player/src-tauri/src/remote/mod.rs +++ b/apps/player/src-tauri/src/remote/mod.rs @@ -22,8 +22,12 @@ pub mod commands; #[cfg(any(target_os = "linux", target_os = "macos"))] mod conn; #[cfg(any(target_os = "linux", target_os = "macos"))] +pub mod devices; +#[cfg(any(target_os = "linux", target_os = "macos"))] pub mod identity; #[cfg(any(target_os = "linux", target_os = "macos"))] +pub mod link; +#[cfg(any(target_os = "linux", target_os = "macos"))] mod service; #[cfg(any(target_os = "linux", target_os = "macos"))] pub mod target; diff --git a/apps/player/src-tauri/src/remote/service.rs b/apps/player/src-tauri/src/remote/service.rs index 488db0fe..5411fc0f 100644 --- a/apps/player/src-tauri/src/remote/service.rs +++ b/apps/player/src-tauri/src/remote/service.rs @@ -4,6 +4,8 @@ //! Starts the target when the app starts, and connects it to Tauri: events //! for the webview, the cache for catalog answers, and DNS-SD. +use super::devices::{Devices, RemoteDevice}; +use super::link::{self, LinkConfig, LinkHandle, LinkHost, Phase}; use super::target::{self, CatalogPage, TargetHandle, TargetHost}; use super::{identity, SETTINGS_KEY, SETTINGS_STORE_FILE}; use crate::commands::AppState; @@ -11,7 +13,10 @@ use crate::discovery::{self, Advertisement, Registration}; use mvp_core::remote::catalog::CatalogQuery; use mvp_core::remote::consent::RequestId; use mvp_core::remote::identity::{DeviceId, DeviceKey}; -use mvp_core::remote::message::{Command, DeviceType, ErrorCode}; +use mvp_core::remote::message::{ + Command, DeviceType, ErrorCode, Feature, Hello, VersionedState, PROTOCOL_VERSION, +}; +use mvp_core::remote::sessions::RESUME_GRACE; use mvp_core::remote::settings::RemoteSettings; use mvp_core::remote::target::{ConsentPrompt, SessionInfo}; use serde::Serialize; @@ -19,6 +24,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex, MutexGuard}; use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_store::StoreExt; +use tokio::sync::mpsc; /// This device's type, as other devices see it. #[cfg(target_os = "macos")] @@ -26,16 +32,22 @@ pub const DEVICE_TYPE: DeviceType = DeviceType::Mac; #[cfg(target_os = "linux")] pub const DEVICE_TYPE: DeviceType = DeviceType::LinuxPc; -/// The running target, as Tauri managed state. +/// Remote control as Tauri managed state: the running target, the devices +/// discovery found, and the device this one controls, if any. pub struct RemoteService { pub(crate) handle: TargetHandle, key_dir: PathBuf, state: Mutex, + devices: Arc>, + link: Arc>>, + link_host: Arc, + /// The last link's generation. + links: std::sync::atomic::AtomicU64, } struct Saved { settings: RemoteSettings, - device_id: DeviceId, + key: DeviceKey, } /// Locks a mutex, taking it over if a thread panicked holding it. The @@ -50,7 +62,70 @@ impl RemoteService { } pub fn device_id(&self) -> DeviceId { - lock(&self.state).device_id.clone() + lock(&self.state).key.device_id() + } + + /// The other Cinette devices discovery found. + pub fn devices(&self) -> Vec { + lock(&self.devices).list() + } + + /// The link to the device this one controls, if any. + pub fn link(&self) -> Option { + lock(&self.link).clone() + } + + /// Starts asking `device` for control, leaving any device controlled now. + pub fn connect(&self, device: DeviceId) -> Result<(), String> { + let allowed = lock(&self.devices).get(&device).map(|d| d.control_allowed); + match allowed { + None => return Err("That device isn't on the network now.".into()), + Some(false) => return Err("That device doesn't allow remote control.".into()), + Some(true) => {} + } + let (key, name) = { + let saved = lock(&self.state); + (saved.key.clone(), saved.settings.name(DEVICE_TYPE)) + }; + let hello = Hello { + id: key.device_id(), + name, + device_type: DEVICE_TYPE, + versions: vec![PROTOCOL_VERSION], + features: vec![Feature::Control, Feature::Catalog], + }; + let devices = self.devices.clone(); + let target = device.clone(); + let config = LinkConfig { + key, + hello, + target: device.clone(), + addresses: Arc::new(move || lock(&devices).addresses(&target)), + grace: RESUME_GRACE, + generation: self + .links + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1, + }; + lock(&self.devices).keep(Some(device)); + // The slot stays locked until the new link is in it, so the link's + // first event already finds itself current. + let mut slot = lock(&self.link); + let old = slot.replace(link::start(config, self.link_host.clone())); + drop(slot); + if let Some(old) = old { + tauri::async_runtime::spawn(async move { old.leave().await }); + } + Ok(()) + } + + /// Ends the session, or stops asking. The other device keeps playing. + pub async fn leave(&self) { + let old = lock(&self.link).take(); + if let Some(old) = old { + old.leave().await; + } + lock(&self.devices).keep(None); } /// Saves the settings and hands them to the target. @@ -75,8 +150,13 @@ impl RemoteService { let key = DeviceKey::generate(); identity::save(&self.key_dir, &key).map_err(|e| e.to_string())?; let id = key.device_id(); - lock(&self.state).device_id = id.clone(); + lock(&self.state).key = key.clone(); + lock(&self.devices).set_own(id.clone()); self.handle.reset_identity(key); + // A session this device holds on another one ends too. + if let Some(old) = lock(&self.link).take() { + tauri::async_runtime::spawn(async move { old.leave().await }); + } tracing::info!("[remote] device identity reset; new id {id}"); Ok(id) } @@ -193,6 +273,122 @@ fn advertise_on_network(registration: &Registration) -> Option { .ok() } +/// What `remote://controller` carries: the device and where the connection +/// to it stands. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ControllerEvent<'a> { + pub device_id: String, + #[serde(flatten)] + pub phase: &'a Phase, +} + +/// What `remote://state` carries. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StateEvent<'a> { + pub device_id: String, + pub state: &'a VersionedState, +} + +struct TauriLinkHost { + app: AppHandle, + devices: Arc>, + link: Arc>>, +} + +impl TauriLinkHost { + /// Whether `link` is still the one this device uses. One that was + /// replaced or left says nothing more to the webview. + fn current(&self, link: u64) -> bool { + lock(&self.link) + .as_ref() + .is_some_and(|l| l.generation() == link) + } +} + +impl LinkHost for TauriLinkHost { + fn phase(&self, link: u64, device: &DeviceId, phase: &Phase) { + if let Phase::Closed { reason } = phase { + tracing::info!("[remote] the session with {device} ended: {reason:?}"); + let mut slot = lock(&self.link); + if !slot.as_ref().is_some_and(|l| l.generation() == link) { + return; + } + *slot = None; + drop(slot); + let list = { + let mut devices = lock(&self.devices); + devices.keep(None); + devices.list() + }; + let _ = self.app.emit("remote://devices", list); + } else if !self.current(link) { + return; + } + let _ = self.app.emit( + "remote://controller", + ControllerEvent { + device_id: device.to_string(), + phase, + }, + ); + } + + fn state(&self, link: u64, device: &DeviceId, state: &VersionedState) { + if !self.current(link) { + return; + } + let _ = self.app.emit( + "remote://state", + StateEvent { + device_id: device.to_string(), + state, + }, + ); + } +} + +/// Browses for other Cinette devices on the network. +fn browse_network() -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel(); + tauri::async_runtime::spawn(async move { + let mut browser = match discovery::browse(mvp_core::remote::target::SERVICE_TYPE) { + Ok(browser) => browser, + Err(e) => { + tracing::warn!("[remote] couldn't look for other devices: {e}"); + return; + } + }; + while let Some(event) = browser.next().await { + if tx.send(event).is_err() { + return; + } + } + }); + rx +} + +/// Keeps the device list up to date from discovery, and tells the webview +/// when it changes. +fn follow_discovery( + app: AppHandle, + devices: Arc>, + mut found: mpsc::UnboundedReceiver, +) { + tauri::async_runtime::spawn(async move { + while let Some(event) = found.recv().await { + let list = { + let mut devices = lock(&devices); + devices.apply(event).then(|| devices.list()) + }; + if let Some(list) = list { + let _ = app.emit("remote://devices", list); + } + } + }); +} + /// The saved settings, or the defaults. fn load_settings(app: &AppHandle) -> RemoteSettings { app.store(SETTINGS_STORE_FILE) @@ -208,7 +404,13 @@ fn load_settings(app: &AppHandle) -> RemoteSettings { pub fn start(app: &AppHandle, key_dir: PathBuf) { let started = tauri::async_runtime::block_on(async { let listener = target::bind_default().await.map_err(|e| e.to_string())?; - start_on(app, key_dir, listener, Box::new(advertise_on_network)) + start_on( + app, + key_dir, + listener, + Box::new(advertise_on_network), + browse_network(), + ) }); if let Err(e) = started { tracing::error!("[remote] remote control didn't start: {e}"); @@ -222,6 +424,7 @@ fn start_on( key_dir: PathBuf, listener: tokio::net::TcpListener, advertiser: Advertiser, + found: mpsc::UnboundedReceiver, ) -> Result<(), String> { let key = identity::load_or_create(&key_dir) .map_err(|e| format!("couldn't load the device key: {e}"))?; @@ -232,8 +435,11 @@ fn start_on( advertisement: Mutex::new(None), }); let device_id = key.device_id(); - let handle = target::start(listener, key, DEVICE_TYPE, settings.clone(), host) + let handle = target::start(listener, key.clone(), DEVICE_TYPE, settings.clone(), host) .map_err(|e| format!("couldn't listen: {e}"))?; + let devices = Arc::new(Mutex::new(Devices::new(device_id.clone()))); + let link = Arc::new(Mutex::new(None)); + follow_discovery(app.clone(), devices.clone(), found); tracing::info!( "[remote] listening on port {} as {device_id}", handle.port() @@ -241,10 +447,15 @@ fn start_on( app.manage(RemoteService { handle, key_dir, - state: Mutex::new(Saved { - settings, - device_id, + state: Mutex::new(Saved { settings, key }), + link_host: Arc::new(TauriLinkHost { + app: app.clone(), + devices: devices.clone(), + link: link.clone(), }), + devices, + link, + links: std::sync::atomic::AtomicU64::new(0), }); Ok(()) } @@ -252,7 +463,10 @@ fn start_on( /// Closes every connection with `shutdown` and withdraws the record. pub fn stop_for_exit(app: &AppHandle) { if let Some(service) = app.try_state::() { - tauri::async_runtime::block_on(service.handle.shutdown()); + tauri::async_runtime::block_on(async { + service.leave().await; + service.handle.shutdown().await; + }); } } diff --git a/apps/player/src-tauri/src/remote/service_tests.rs b/apps/player/src-tauri/src/remote/service_tests.rs index 13414f61..cba67c1d 100644 --- a/apps/player/src-tauri/src/remote/service_tests.rs +++ b/apps/player/src-tauri/src/remote/service_tests.rs @@ -25,13 +25,18 @@ struct Running { unclaimed: Vec<(String, Value)>, advertised: Arc>>, port: u16, + /// Stands in for discovery: what's sent here is what browsing found. + found: mpsc::UnboundedSender, } -const EVENTS: [&str; 4] = [ +const EVENTS: [&str; 7] = [ "remote://consent", "remote://consent-hidden", "remote://sessions", "remote://command", + "remote://devices", + "remote://controller", + "remote://state", ]; impl Running { @@ -52,6 +57,7 @@ impl Running { let record = advertised.clone(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); + let (found, found_rx) = mpsc::unbounded_channel(); start_on( &app.handle(), app.app_dir(), @@ -60,6 +66,7 @@ impl Running { lock(&record).push(r.clone()); None }), + found_rx, ) .unwrap(); Self { @@ -68,6 +75,7 @@ impl Running { unclaimed: Vec::new(), advertised, port, + found, } } @@ -441,3 +449,262 @@ fn commands_say_when_remote_control_did_not_start() { ); } } + +/// Two app instances in one process: `mac` finds `tv` as discovery would. +async fn pair(tv_mode: &str) -> (Running, Running) { + let mac = Running::start(|_| {}).await; + let tv = Running::start(|_| {}).await; + tv.invoke( + "remote_set_settings", + json!({ "settings": { "discoverable": true, "remoteControl": tv_mode, "deviceName": "TV" } }), + ) + .unwrap(); + let deadline = std::time::Instant::now() + WAIT; + let record = loop { + match tv.last_advertised() { + Some(r) if r.instance == "TV" && r.txt.get_str("rc").is_some() => { + let allowed = + r.txt.get_str("rc") == Some(if tv_mode == "never" { "0" } else { "1" }); + if allowed { + break r; + } + } + _ => {} + } + assert!(std::time::Instant::now() < deadline, "tv never advertised"); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + mac.found + .send(discovery::Event::Found { + instance: record.instance.clone(), + host: format!("{}.local.", record.host_label), + addrs: vec![std::net::IpAddr::from([127, 0, 0, 1])], + port: record.port, + txt: record.txt.clone(), + }) + .unwrap(); + (mac, tv) +} + +async fn phase(running: &mut Running, wanted: &str) -> Value { + loop { + let event = running.event("remote://controller").await; + if event["phase"] == json!(wanted) { + return event; + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn one_instance_controls_another() { + let (mut mac, mut tv) = pair("ask").await; + let devices = mac.event("remote://devices").await; + let tv_id = tv.device_id().to_string(); + assert_eq!( + devices, + json!([{ "deviceId": tv_id, "name": "TV", "deviceType": "linux-pc", + "controlAllowed": true, "available": true }]) + ); + assert_eq!(mac.invoke("remote_devices", json!({})).unwrap(), devices); + + mac.invoke("remote_connect", json!({ "deviceId": tv_id })) + .unwrap(); + let verifying = phase(&mut mac, "verifying").await; + assert_eq!(verifying["deviceId"], json!(tv_id)); + let consent = tv.event("remote://consent").await; + assert_eq!( + consent["code"], verifying["code"], + "both screens show one code" + ); + assert_eq!(consent["name"], json!("Linux PC")); + phase(&mut mac, "pending").await; + tv.invoke( + "remote_answer_consent", + json!({ "request": consent["request"], "allow": true }), + ) + .unwrap(); + phase(&mut mac, "connected").await; + let first = mac.event("remote://state").await; + assert_eq!(first["deviceId"], json!(tv_id)); + + // A command goes to the TV's player and its answer comes back. + let reply = { + let handle = mac.app.handle(); + tokio::task::spawn_blocking(move || { + crate::test_support::invoke_on( + &handle, + "remote_command", + json!({ "command": { "action": "stop" } }), + ) + }) + }; + let command = tv.event("remote://command").await; + tv.invoke( + "remote_finish_command", + json!({ "token": command["token"], "outcome": "ok" }), + ) + .unwrap(); + assert!(reply.await.unwrap().unwrap().is_u64()); + + // A state from the TV reaches the Mac. + tv.invoke( + "remote_set_state", + json!({ "state": { + "status": "paused", "item": null, "position": 1, "duration": 0, + "volume": null, "subtitleId": null, "audioId": null, "error": null + } }), + ) + .unwrap(); + loop { + let state = mac.event("remote://state").await; + if state["state"]["state"]["status"] == json!("paused") { + break; + } + } + + // A catalog page from the TV's cache. + let page = mac + .invoke( + "remote_catalog", + json!({ "catalogQuery": { "kind": "groups", "contentType": "live" } }), + ) + .unwrap(); + assert_eq!(page, json!({ "items": [], "next": null })); + + // Leaving ends the session on both sides. The webview asked, so no + // event tells it. + mac.invoke("remote_leave", json!({})).unwrap(); + let deadline = std::time::Instant::now() + WAIT; + while tv.invoke("remote_sessions", json!({})).unwrap() != json!([]) { + assert!(std::time::Instant::now() < deadline); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!( + mac.invoke("remote_command", json!({ "command": { "action": "stop" } })), + Err(json!("notConnected")) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_target_disconnecting_ends_the_link() { + let (mut mac, mut tv) = pair("always").await; + let tv_id = tv.device_id().to_string(); + mac.event("remote://devices").await; + mac.invoke("remote_connect", json!({ "deviceId": tv_id })) + .unwrap(); + phase(&mut mac, "connected").await; + let sessions = tv.event("remote://sessions").await; + tv.invoke( + "remote_disconnect", + json!({ "sessionId": sessions[0]["sessionId"] }), + ) + .unwrap(); + assert_eq!( + phase(&mut mac, "closed").await["reason"], + json!({ "target": "revoked" }) + ); + assert_eq!( + mac.invoke( + "remote_catalog", + json!({ "catalogQuery": { "kind": "groups", "contentType": "live" } }) + ), + Err(json!("notConnected")) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_refused_command_names_its_code() { + let (mut mac, tv) = pair("always").await; + let tv_id = tv.device_id().to_string(); + mac.event("remote://devices").await; + mac.invoke("remote_connect", json!({ "deviceId": tv_id })) + .unwrap(); + phase(&mut mac, "connected").await; + assert_eq!( + mac.invoke( + "remote_command", + json!({ "command": { "action": "pause", "itemId": "nothing" } }) + ), + Err(json!("stale_item")) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn only_devices_on_the_network_that_allow_control_can_be_asked() { + let (mut mac, tv) = pair("never").await; + mac.event("remote://devices").await; + assert_eq!( + mac.invoke( + "remote_connect", + json!({ "deviceId": tv.device_id().to_string() }) + ), + Err(json!("That device doesn't allow remote control.")) + ); + assert_eq!( + mac.invoke( + "remote_connect", + json!({ "deviceId": DeviceKey::generate().device_id().to_string() }) + ), + Err(json!("That device isn't on the network now.")) + ); + assert_eq!( + mac.invoke("remote_connect", json!({ "deviceId": "nope" })), + Err(json!("That isn't a device id.")) + ); + assert_eq!( + mac.invoke("remote_command", json!({ "command": { "action": "stop" } })), + Err(json!("notConnected")) + ); + // Leaving with nothing to leave is fine. + mac.invoke("remote_leave", json!({})).unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_device_that_goes_away_leaves_the_list() { + let (mut mac, _tv) = pair("ask").await; + mac.event("remote://devices").await; + mac.found + .send(discovery::Event::Removed { + instance: "TV".into(), + }) + .unwrap(); + assert_eq!(mac.event("remote://devices").await, json!([])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn connecting_again_is_not_undone_by_the_old_link_ending() { + let (mut mac, mut tv) = pair("always").await; + let tv_id = tv.device_id().to_string(); + mac.event("remote://devices").await; + mac.invoke("remote_connect", json!({ "deviceId": tv_id })) + .unwrap(); + phase(&mut mac, "connected").await; + mac.invoke("remote_leave", json!({})).unwrap(); + mac.invoke("remote_connect", json!({ "deviceId": tv_id })) + .unwrap(); + phase(&mut mac, "connected").await; + // The new session works, and the old one's end said nothing. + let reply = { + let handle = mac.app.handle(); + tokio::task::spawn_blocking(move || { + crate::test_support::invoke_on( + &handle, + "remote_command", + json!({ "command": { "action": "stop" } }), + ) + }) + }; + let command = tv.event("remote://command").await; + tv.invoke( + "remote_finish_command", + json!({ "token": command["token"], "outcome": "ok" }), + ) + .unwrap(); + assert!(reply.await.unwrap().is_ok()); + while let Ok((name, payload)) = mac.events.try_recv() { + assert!( + !(name == "remote://controller" && payload["phase"] == json!("closed")), + "{payload}" + ); + } +} diff --git a/apps/player/src-tauri/src/test_support.rs b/apps/player/src-tauri/src/test_support.rs index 3e9fc698..f4d2665e 100644 --- a/apps/player/src-tauri/src/test_support.rs +++ b/apps/player/src-tauri/src/test_support.rs @@ -100,23 +100,7 @@ impl TestApp { /// would get. An error is the message the frontend would be shown. The /// mock runtime's webview opens no real window. pub(crate) fn invoke(&self, command: &str, args: Value) -> Result { - let webview = match self.app.get_webview_window("main") { - Some(webview) => webview, - None => WebviewWindowBuilder::new(&self.app, "main", Default::default()) - .build() - .expect("open a mock webview"), - }; - let request = InvokeRequest { - cmd: command.into(), - callback: CallbackFn(0), - error: CallbackFn(1), - url: "tauri://localhost".parse().unwrap(), - body: InvokeBody::Json(args), - headers: Default::default(), - invoke_key: INVOKE_KEY.into(), - }; - get_ipc_response(&webview, request) - .map(|reply| reply.deserialize().expect("read the command's reply")) + invoke_on(self.app.handle(), command, args) } } @@ -210,3 +194,29 @@ mod tests { assert!(!file.exists()); } } + +/// [`TestApp::invoke`] through a handle, for a call made from another +/// thread while the test does something else. +pub(crate) fn invoke_on( + app: &AppHandle, + command: &str, + args: Value, +) -> Result { + let webview = match app.get_webview_window("main") { + Some(webview) => webview, + None => WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("open a mock webview"), + }; + let request = InvokeRequest { + cmd: command.into(), + callback: CallbackFn(0), + error: CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: InvokeBody::Json(args), + headers: Default::default(), + invoke_key: INVOKE_KEY.into(), + }; + get_ipc_response(&webview, request) + .map(|reply| reply.deserialize().expect("read the command's reply")) +} diff --git a/apps/player/src/hooks/useRemoteControl.test.ts b/apps/player/src/hooks/useRemoteControl.test.ts new file mode 100644 index 00000000..7e3898ab --- /dev/null +++ b/apps/player/src/hooks/useRemoteControl.test.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { EventCallback } from "@tauri-apps/api/event"; + +const handlers = vi.hoisted(() => new Map>()); +const stops = vi.hoisted(() => [] as ReturnType[]); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn((event: string, handler: EventCallback) => { + handlers.set(event, handler); + const stop = vi.fn(); + stops.push(stop); + return Promise.resolve(stop); + }), +})); + +vi.mock("@/lib/tauri", () => ({ + remoteDevices: vi.fn(() => Promise.resolve([])), + remoteConnect: vi.fn(() => Promise.resolve()), + remoteLeave: vi.fn(() => Promise.resolve()), + remoteCommand: vi.fn(() => Promise.resolve(4)), +})); + +import { listen } from "@tauri-apps/api/event"; +import { remoteCommand, remoteConnect, remoteLeave } from "@/lib/tauri"; +import { remoteControl, useRemoteControl } from "./useRemoteControl"; + +const tv = { + deviceId: "tv1", + name: "Living Room", + deviceType: "fire-tv", + controlAllowed: true, + available: true, +}; + +beforeEach(() => { + handlers.clear(); + stops.length = 0; + vi.clearAllMocks(); +}); + +describe("useRemoteControl", () => { + it("shares one store and follows events while used", async () => { + expect(remoteControl()).toBe(remoteControl()); + const a = renderHook(() => useRemoteControl()); + const b = renderHook(() => useRemoteControl()); + await waitFor(() => expect(handlers.has("remote://devices")).toBe(true)); + expect(vi.mocked(listen)).toHaveBeenCalledTimes(3); + act(() => handlers.get("remote://devices")!({ event: "", id: 0, payload: [tv] })); + expect(a.result.current.devices).toStrictEqual([tv]); + expect(b.result.current.devices).toStrictEqual([tv]); + a.unmount(); + await act(async () => {}); + expect(stops.every((s) => s.mock.calls.length === 0)).toBe(true); + b.unmount(); + await waitFor(() => expect(stops.every((s) => s.mock.calls.length === 1)).toBe(true)); + }); + + it("connects and leaves through the backend", async () => { + const { result, unmount } = renderHook(() => useRemoteControl()); + await act(() => result.current.connect(tv)); + expect(remoteConnect).toHaveBeenCalledWith("tv1"); + expect(result.current.link?.deviceId).toBe("tv1"); + act(() => + handlers.get("remote://state")!({ + event: "", + id: 0, + payload: { + deviceId: "tv1", + state: { + epoch: "e", + version: 1, + state: { + status: "paused", + item: { id: "live-1", name: "News", contentType: "live", group: "" }, + position: 0, + duration: 0, + volume: 10, + subtitles: [], + subtitleId: null, + audio: [], + audioId: null, + capabilities: { + pause: true, + seek: false, + volume: true, + subtitles: false, + audioTracks: false, + resumeAt: false, + }, + error: null, + }, + }, + }, + }) + ); + // When it arrived, by this page's clock. + const at = result.current.link!.destination.getState().positionAt; + expect(at).toBeGreaterThan(0); + expect(at).toBeLessThanOrEqual(performance.now()); + await act(() => result.current.link!.destination.play()); + expect(remoteCommand).toHaveBeenCalledWith({ action: "play", itemId: "live-1" }); + await act(() => result.current.leave()); + expect(remoteLeave).toHaveBeenCalledTimes(1); + expect(result.current.link).toBeNull(); + unmount(); + }); + + it("keeps working when listening fails", async () => { + vi.mocked(listen).mockRejectedValueOnce(new Error("no Tauri")); + const { unmount } = renderHook(() => useRemoteControl()); + await act(async () => {}); + unmount(); + await act(async () => {}); + }); +}); diff --git a/apps/player/src/hooks/useRemoteControl.ts b/apps/player/src/hooks/useRemoteControl.ts new file mode 100644 index 00000000..92a5511b --- /dev/null +++ b/apps/player/src/hooks/useRemoteControl.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +import { useEffect, useSyncExternalStore } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + createRemoteControl, + type RemoteControl, + type RemoteControlState, +} from "@/lib/remote-control"; +import { remoteCommand, remoteConnect, remoteDevices, remoteLeave } from "@/lib/tauri"; + +/** The app's one remote control, made on first use. */ +let shared: RemoteControl | null = null; +/** How many components use it, so it follows events only while they do. */ +let users = 0; +let stopping: Promise<() => void> | null = null; + +export const remoteControl = (): RemoteControl => { + shared ??= createRemoteControl({ + devices: remoteDevices, + connect: remoteConnect, + leave: remoteLeave, + command: remoteCommand, + listen: (event, handler) => listen(event, ({ payload }) => handler(payload as never)), + now: performance.now.bind(performance), + }); + return shared; +}; + +/** + * The other Cinette devices and the one this device controls, kept up to + * date while any component uses it. + */ +export const useRemoteControl = (): RemoteControlState & { + connect: RemoteControl["connect"]; + leave: RemoteControl["leave"]; +} => { + const control = remoteControl(); + const state = useSyncExternalStore(control.subscribe, control.getState); + useEffect( + () => { + users += 1; + if (users === 1) stopping = control.start().catch(() => () => {}); + return () => { + users -= 1; + if (users === 0 && stopping) { + const done = stopping; + stopping = null; + void done.then((stop) => stop()); + } + }; + }, + // Stryker disable next-line ArrayDeclaration: control is the app's one store, so the effect runs once whatever the list holds. + [control] + ); + return { ...state, connect: control.connect, leave: control.leave }; +}; diff --git a/apps/player/src/lib/cinette-destination.test.ts b/apps/player/src/lib/cinette-destination.test.ts new file mode 100644 index 00000000..0a484b34 --- /dev/null +++ b/apps/player/src/lib/cinette-destination.test.ts @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +import { describe, it, expect, vi } from "vitest"; +import { + connectionOf, + createCinetteDestination, + remoteCapabilities, + remotePlaybackState, + type RemoteSnapshot, +} from "./cinette-destination"; +import type { RemoteCommand } from "./remote-target"; +import type { Channel } from "./types"; + +const snapshot = (overrides: Partial = {}): RemoteSnapshot => ({ + status: "playing", + item: { id: "live-7", name: "News", contentType: "live", group: "UK" }, + position: 30, + duration: 0, + volume: 55, + subtitles: [{ id: "s1", label: "English" }], + subtitleId: null, + audio: [{ id: "a1", label: "Main" }], + audioId: "a1", + capabilities: { + pause: true, + seek: false, + volume: true, + subtitles: true, + audioTracks: true, + resumeAt: false, + }, + error: null, + ...overrides, +}); + +describe("remotePlaybackState", () => { + it("names the item by the other device's id and keeps the rest", () => { + const state = remotePlaybackState(snapshot(), "connected", 1_234); + expect(state).toStrictEqual({ + status: "playing", + item: { title: "News", contentType: "live", ref: "live-7" }, + position: 30, + positionAt: 1_234, + duration: 0, + volume: 55, + subtitles: [{ id: "s1", label: "English" }], + subtitleId: null, + audio: [{ id: "a1", label: "Main" }], + audioId: "a1", + connection: "connected", + error: null, + }); + }); + + it("reads each known content type and calls anything else live", () => { + for (const t of ["live", "movie", "series", "recording"]) { + const item = { id: "x", name: "X", contentType: t, group: "" }; + const state = remotePlaybackState(snapshot({ item }), "connected", 0); + expect(state.item?.contentType).toBe(t); + } + const odd = { id: "x", name: "X", contentType: "radio", group: "" }; + expect(remotePlaybackState(snapshot({ item: odd }), "connected", 0).item?.contentType).toBe( + "live" + ); + }); + + it("has no item when nothing plays and no volume when it can't be set", () => { + const state = remotePlaybackState( + snapshot({ item: null, volume: null, status: "idle" }), + "connected", + 0 + ); + expect(state.item).toBeNull(); + expect(state.volume).toBe(0); + }); + + it("turns the other device's error into a failed error", () => { + const state = remotePlaybackState( + snapshot({ status: "error", error: "Stream ended" }), + "connected", + 0 + ); + expect(state.error).toStrictEqual({ code: "failed", message: "Stream ended" }); + }); + + it("says how the connection stands", () => { + const connection = connectionOf; + expect(connection({ phase: "connected" })).toBe("connected"); + expect(connection({ phase: "reconnecting", attempt: 2 })).toBe("reconnecting"); + expect(connection({ phase: "connecting" })).toBe("reconnecting"); + expect(connection({ phase: "pending", expiresInMs: 30_000 })).toBe("reconnecting"); + expect(connection({ phase: "verifying", code: "123456" })).toBe("reconnecting"); + expect(connection({ phase: "closed", reason: "lost" })).toBe("lost"); + }); +}); + +describe("remoteCapabilities", () => { + it("follows what the other device's player can do, and has a catalog", () => { + expect(remoteCapabilities(snapshot())).toStrictEqual({ + seek: false, + pause: true, + volume: "absolute", + subtitles: "select", + externalSubtitles: false, + audioTracks: true, + resumeAt: false, + catalog: true, + }); + const none = snapshot({ + capabilities: { + pause: false, + seek: true, + volume: false, + subtitles: false, + audioTracks: false, + resumeAt: true, + }, + }); + expect(remoteCapabilities(none)).toMatchObject({ + seek: true, + pause: false, + volume: "none", + subtitles: "none", + audioTracks: false, + resumeAt: true, + }); + }); +}); + +const destination = () => { + const sent: RemoteCommand[] = []; + const send = vi.fn(async (command: RemoteCommand) => { + sent.push(command); + return 1; + }); + return { d: createCinetteDestination("dev1", "Living Room", send), sent, send }; +}; + +describe("createCinetteDestination", () => { + it("names the device and its catalog", () => { + const { d } = destination(); + expect(d.id).toBe("cinette:dev1"); + expect(d.kind).toBe("cinette"); + expect(d.name).toBe("Living Room"); + expect(d.catalog).toStrictEqual({ kind: "remote", deviceId: "dev1" }); + }); + + it("starts with nothing playing while it connects", () => { + const { d } = destination(); + expect(d.getState()).toStrictEqual({ + status: "idle", + item: null, + position: 0, + positionAt: 0, + duration: 0, + volume: 0, + subtitles: [], + subtitleId: null, + audio: [], + audioId: null, + connection: "reconnecting", + error: null, + }); + expect(d.capabilities).toStrictEqual({ + seek: false, + pause: false, + volume: "none", + subtitles: "none", + externalSubtitles: false, + audioTracks: false, + resumeAt: false, + catalog: true, + }); + }); + + it("plays the other device's items, never this device's channels", async () => { + const { d, sent } = destination(); + await expect(d.route({ kind: "remote", itemId: "live-1", title: "One" })).resolves.toEqual({ + ok: true, + route: { via: "cinette" }, + }); + const channel = { id: "live-1" } as Channel; + await expect(d.route({ kind: "local", channel })).resolves.toEqual({ + ok: false, + reason: "notFound", + }); + await expect(d.load({ kind: "local", channel })).rejects.toThrow( + "Playing this device's channel on another device isn't supported here" + ); + await d.load({ kind: "remote", itemId: "movie-2", title: "Film" }, { startAt: 90 }); + await d.load({ kind: "remote", itemId: "live-1", title: "One" }); + expect(sent).toStrictEqual([ + { action: "load", itemId: "movie-2", startAt: 90 }, + { action: "load", itemId: "live-1" }, + ]); + }); + + it("names the playing item in every command about it", async () => { + const { d, sent } = destination(); + d.receive(snapshot(), 0); + await d.play(); + await d.pause(); + await d.seek(42); + await d.setVolume(33.4); + await d.selectSubtitle(null); + await d.selectAudio("a2"); + await d.stop(); + expect(sent).toStrictEqual([ + { action: "play", itemId: "live-7" }, + { action: "pause", itemId: "live-7" }, + { action: "seek", itemId: "live-7", to: 42 }, + { action: "volume", value: 33 }, + { action: "subtitle", itemId: "live-7", trackId: null }, + { action: "audio", itemId: "live-7", trackId: "a2" }, + { action: "stop" }, + ]); + }); + + it("refuses commands about an item when nothing plays", async () => { + const { d, send } = destination(); + await expect(d.play()).rejects.toThrow("Controlling nothing isn't supported here"); + await expect(d.seek(3)).rejects.toMatchObject({ code: "unsupported" }); + expect(send).not.toHaveBeenCalled(); + await d.stop(); + await d.setVolume(10); + expect(send).toHaveBeenCalledTimes(2); + }); + + it("passes a refused command's error on", async () => { + const send = vi.fn(() => Promise.reject("stale_item")); + const d = createCinetteDestination("dev1", "TV", send); + d.receive(snapshot(), 0); + await expect(d.pause()).rejects.toBe("stale_item"); + }); + + it("tells subscribers about every new state and phase, until they leave", () => { + const { d } = destination(); + const seen: string[] = []; + const stop = d.subscribe((s) => seen.push(`${s.status}/${s.connection}/${s.positionAt}`)); + const before = d.getState(); + expect(d.getState(), "the same object until it changes").toBe(before); + d.setPhase({ phase: "connected" }); + d.receive(snapshot({ status: "paused" }), 500); + expect(d.getState()).not.toBe(before); + expect(d.getState().status).toBe("paused"); + expect(d.capabilities.pause).toBe(true); + stop(); + d.receive(snapshot(), 900); + expect(seen).toStrictEqual(["idle/connected/0", "paused/connected/500"]); + expect(d.getState().positionAt).toBe(900); + }); +}); diff --git a/apps/player/src/lib/cinette-destination.ts b/apps/player/src/lib/cinette-destination.ts new file mode 100644 index 00000000..a07ca44e --- /dev/null +++ b/apps/player/src/lib/cinette-destination.ts @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +/** + * Another Cinette device as a {@link PlaybackDestination}. It plays items + * from its own catalog, known here only by their ids, with its own mpv, so + * every format plays there. Its state comes from the `remote://state` + * messages the Rust side passes on, newest first already. + */ + +import type { + PlaybackCapabilities, + PlaybackDestination, + PlaybackError, + PlaybackState, + PlaybackStatus, + Track, +} from "@/lib/destination"; +import { UnsupportedError } from "@/lib/destination"; +import type { RemoteCommand } from "@/lib/remote-target"; +import type { Channel } from "@/lib/types"; + +/** One item of the other device's catalog. */ +export interface CatalogItem { + id: string; + name: string; + contentType: string; + group: string; + logo?: string; + seriesTitle?: string; + season?: number; + episode?: number; + nowPlaying?: string; +} + +/** The other device's state, as its player reports it. */ +export interface RemoteSnapshot { + status: PlaybackStatus; + item: CatalogItem | null; + position: number; + duration: number; + volume: number | null; + subtitles: Track[]; + subtitleId: string | null; + audio: Track[]; + audioId: string | null; + capabilities: { + pause: boolean; + seek: boolean; + volume: boolean; + subtitles: boolean; + audioTracks: boolean; + resumeAt: boolean; + }; + error: string | null; +} + +/** Where the connection to the other device stands. */ +export type LinkPhase = + | { phase: "connecting" } + | { phase: "verifying"; code: string } + | { phase: "pending"; expiresInMs: number } + | { phase: "connected" } + | { phase: "reconnecting"; attempt: number } + | { phase: "closed"; reason: LinkEnded }; + +/** Why a connection ended: the other device's reason, or one of these. */ +export type LinkEnded = { target: string } | "unreachable" | "lost" | "left"; + +/** The content types besides live, which is what anything else shows as. */ +const contentTypes: readonly Channel["contentType"][] = ["movie", "series", "recording"]; + +const contentTypeOf = (t: string): Channel["contentType"] => + contentTypes.find((known) => known === t) ?? "live"; + +/** The other device's state in the destination's terms. `at` is + * `performance.now()` when it arrived. */ +export const remotePlaybackState = ( + snapshot: RemoteSnapshot, + connection: PlaybackState["connection"], + at: number +): PlaybackState => { + const error: PlaybackError | null = snapshot.error + ? { code: "failed", message: snapshot.error } + : null; + return { + status: snapshot.status, + item: snapshot.item + ? { + title: snapshot.item.name, + contentType: contentTypeOf(snapshot.item.contentType), + ref: snapshot.item.id, + } + : null, + position: snapshot.position, + positionAt: at, + duration: snapshot.duration, + volume: snapshot.volume ?? 0, + subtitles: snapshot.subtitles, + subtitleId: snapshot.subtitleId, + audio: snapshot.audio, + audioId: snapshot.audioId, + connection, + error, + }; +}; + +/** How a connection in this phase shows in the player. */ +export const connectionOf = (phase: LinkPhase): PlaybackState["connection"] => { + switch (phase.phase) { + case "connected": + return "connected"; + case "closed": + return "lost"; + default: + return "reconnecting"; + } +}; + +/** What the other device's player can do now. */ +export const remoteCapabilities = (snapshot: RemoteSnapshot): PlaybackCapabilities => ({ + seek: snapshot.capabilities.seek, + pause: snapshot.capabilities.pause, + volume: snapshot.capabilities.volume ? "absolute" : "none", + subtitles: snapshot.capabilities.subtitles ? "select" : "none", + // Subtitle files are this device's; the other one has its own. + externalSubtitles: false, + audioTracks: snapshot.capabilities.audioTracks, + resumeAt: snapshot.capabilities.resumeAt, + catalog: true, +}); + +const nothingPlaying: RemoteSnapshot = { + status: "idle", + item: null, + position: 0, + duration: 0, + volume: null, + subtitles: [], + subtitleId: null, + audio: [], + audioId: null, + capabilities: { + pause: false, + seek: false, + volume: false, + subtitles: false, + audioTracks: false, + resumeAt: false, + }, + error: null, +}; + +/** Sends one command, resolving once the other device carried it out. */ +export type SendCommand = (command: RemoteCommand) => Promise; + +/** A destination for another Cinette device, and the way to feed it. */ +export interface CinetteDestination extends PlaybackDestination { + /** A newer state from the other device. */ + receive(snapshot: RemoteSnapshot, at: number): void; + /** The connection moved on. */ + setPhase(phase: LinkPhase): void; +} + +/** + * The destination for the device `deviceId`, called `name`. Commands go + * out through `send`. A command about the item playing names it, so the + * other device refuses it if the item changed meanwhile. + */ +export const createCinetteDestination = ( + deviceId: string, + name: string, + send: SendCommand +): CinetteDestination => { + let snapshot = nothingPlaying; + // Not connected yet. + let connection: PlaybackState["connection"] = "reconnecting"; + let arrived = 0; + let state = remotePlaybackState(snapshot, connection, arrived); + const listeners = new Set<(state: PlaybackState) => void>(); + const publish = () => { + state = remotePlaybackState(snapshot, connection, arrived); + for (const listener of listeners) listener(state); + }; + const current = (): string => { + const id = snapshot.item?.id; + if (id === undefined) throw new UnsupportedError("Controlling nothing"); + return id; + }; + const run = async (command: () => RemoteCommand): Promise => { + await send(command()); + }; + return { + id: `cinette:${deviceId}`, + kind: "cinette", + name, + catalog: { kind: "remote", deviceId }, + get capabilities() { + return remoteCapabilities(snapshot); + }, + route: async (item) => + item.kind === "remote" + ? { ok: true, route: { via: "cinette" } } + : { ok: false, reason: "notFound" }, + load: async (item, opts) => { + if (item.kind !== "remote") + throw new UnsupportedError("Playing this device's channel on another device"); + const startAt = opts?.startAt; + await send( + startAt === undefined + ? { action: "load", itemId: item.itemId } + : { action: "load", itemId: item.itemId, startAt } + ); + }, + play: () => run(() => ({ action: "play", itemId: current() })), + pause: () => run(() => ({ action: "pause", itemId: current() })), + seek: (seconds) => run(() => ({ action: "seek", itemId: current(), to: seconds })), + setVolume: (volume) => run(() => ({ action: "volume", value: Math.round(volume) })), + selectSubtitle: (trackId) => + run(() => ({ action: "subtitle", itemId: current(), trackId })), + selectAudio: (trackId) => run(() => ({ action: "audio", itemId: current(), trackId })), + stop: () => run(() => ({ action: "stop" })), + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + receive: (next, at) => { + snapshot = next; + arrived = at; + publish(); + }, + setPhase: (next) => { + connection = connectionOf(next); + publish(); + }, + }; +}; diff --git a/apps/player/src/lib/remote-control.test.ts b/apps/player/src/lib/remote-control.test.ts new file mode 100644 index 00000000..b3a71f84 --- /dev/null +++ b/apps/player/src/lib/remote-control.test.ts @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +import { describe, it, expect, vi } from "vitest"; +import { createRemoteControl, type RemoteControlBackend } from "./remote-control"; +import type { RemoteSnapshot } from "./cinette-destination"; +import type { RemoteDevice } from "./tauri"; + +const tv: RemoteDevice = { + deviceId: "tv1", + name: "Living Room", + deviceType: "fire-tv", + controlAllowed: true, + available: true, +}; + +const den: RemoteDevice = { ...tv, deviceId: "den1", name: "Den" }; + +const snapshot: RemoteSnapshot = { + status: "playing", + item: { id: "live-7", name: "News", contentType: "live", group: "UK" }, + position: 30, + duration: 0, + volume: 55, + subtitles: [], + subtitleId: null, + audio: [], + audioId: null, + capabilities: { + pause: true, + seek: false, + volume: true, + subtitles: false, + audioTracks: false, + resumeAt: false, + }, + error: null, +}; + +const backend = () => { + const handlers = new Map void>(); + const stops: ReturnType[] = []; + const b = { + devices: vi.fn(() => Promise.resolve([tv])), + connect: vi.fn<(deviceId: string) => Promise>(() => Promise.resolve()), + leave: vi.fn(() => Promise.resolve()), + command: vi.fn(() => Promise.resolve(1)), + listen: vi.fn((event: string, handler: (payload: T) => void) => { + handlers.set(event, handler as (payload: unknown) => void); + const stop = vi.fn(); + stops.push(stop); + return Promise.resolve(stop); + }), + now: vi.fn(() => 5_000), + }; + const emit = (event: string, payload: unknown) => handlers.get(event)?.(payload); + return { b: b as RemoteControlBackend & typeof b, emit, stops }; +}; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe("createRemoteControl", () => { + it("lists the devices the backend knows and follows the events", async () => { + const { b, emit, stops } = backend(); + const rc = createRemoteControl(b); + expect(rc.getState()).toStrictEqual({ devices: [], link: null, ended: null }); + const changes = vi.fn(); + rc.subscribe(changes); + const stop = await rc.start(); + await flush(); + expect(rc.getState().devices).toStrictEqual([tv]); + emit("remote://devices", [tv, den]); + expect(rc.getState().devices).toStrictEqual([tv, den]); + expect(changes).toHaveBeenCalledTimes(2); + stop(); + expect(stops).toHaveLength(3); + for (const s of stops) expect(s).toHaveBeenCalledTimes(1); + }); + + it("keeps the list from events when the first fetch fails", async () => { + const { b, emit } = backend(); + b.devices.mockRejectedValueOnce("unavailable"); + const rc = createRemoteControl(b); + await rc.start(); + emit("remote://devices", [den]); + await flush(); + expect(rc.getState().devices).toStrictEqual([den]); + }); + + it("connects, follows the phases and feeds the destination", async () => { + const { b, emit } = backend(); + const rc = createRemoteControl(b); + await rc.start(); + await rc.connect(tv); + expect(b.connect).toHaveBeenCalledWith("tv1"); + const link = rc.getState().link!; + expect(link.deviceId).toBe("tv1"); + expect(link.name).toBe("Living Room"); + expect(link.phase).toStrictEqual({ phase: "connecting" }); + expect(link.destination.id).toBe("cinette:tv1"); + + emit("remote://controller", { deviceId: "tv1", phase: "verifying", code: "123456" }); + expect(rc.getState().link!.phase).toStrictEqual({ phase: "verifying", code: "123456" }); + emit("remote://controller", { deviceId: "tv1", phase: "connected" }); + expect(link.destination.getState().connection).toBe("connected"); + + emit("remote://state", { + deviceId: "tv1", + state: { epoch: "e", version: 3, state: snapshot }, + }); + expect(link.destination.getState()).toMatchObject({ + status: "playing", + item: { ref: "live-7" }, + positionAt: 5_000, + }); + + await link.destination.pause(); + expect(b.command).toHaveBeenCalledWith({ action: "pause", itemId: "live-7" }); + }); + + it("ignores events about other devices", async () => { + const { b, emit } = backend(); + const rc = createRemoteControl(b); + await rc.start(); + emit("remote://controller", { deviceId: "tv1", phase: "connected" }); + emit("remote://state", { + deviceId: "tv1", + state: { epoch: "e", version: 1, state: snapshot }, + }); + expect(rc.getState().link).toBeNull(); + await rc.connect(tv); + emit("remote://controller", { deviceId: "den1", phase: "closed", reason: "lost" }); + emit("remote://state", { + deviceId: "den1", + state: { epoch: "e", version: 1, state: snapshot }, + }); + const link = rc.getState().link!; + expect(link.phase).toStrictEqual({ phase: "connecting" }); + expect(link.destination.getState().status).toBe("idle"); + }); + + it("drops the link and says why when it ends", async () => { + const { b, emit } = backend(); + const rc = createRemoteControl(b); + await rc.start(); + await rc.connect(tv); + const destination = rc.getState().link!.destination; + emit("remote://controller", { + deviceId: "tv1", + phase: "closed", + reason: { target: "revoked" }, + }); + expect(rc.getState().link).toBeNull(); + expect(rc.getState().ended).toStrictEqual({ + name: "Living Room", + reason: { target: "revoked" }, + }); + expect(destination.getState().connection).toBe("lost"); + await rc.connect(den); + expect(rc.getState().ended, "a new link clears it").toBeNull(); + }); + + it("drops the link when connecting fails", async () => { + const { b } = backend(); + b.connect.mockRejectedValueOnce("That device isn't on the network now."); + const rc = createRemoteControl(b); + await expect(rc.connect(tv)).rejects.toBe("That device isn't on the network now."); + expect(rc.getState().link).toBeNull(); + }); + + it("leaves a newer link alone when an older connect fails", async () => { + const { b } = backend(); + let fail: (e: unknown) => void = () => {}; + b.connect.mockImplementationOnce(() => new Promise((_, reject) => (fail = reject))); + const rc = createRemoteControl(b); + const first = rc.connect(tv); + await rc.connect(den); + fail("gone"); + await expect(first).rejects.toBe("gone"); + expect(rc.getState().link?.deviceId).toBe("den1"); + }); + + it("passes on a failed connect's error after leaving meanwhile", async () => { + const { b } = backend(); + let fail: (e: unknown) => void = () => {}; + b.connect.mockImplementationOnce(() => new Promise((_, reject) => (fail = reject))); + const rc = createRemoteControl(b); + const pending = rc.connect(tv); + await rc.leave(); + fail("gone"); + await expect(pending).rejects.toBe("gone"); + expect(rc.getState().link).toBeNull(); + }); + + it("leaving drops the link at once and tells the backend", async () => { + const { b } = backend(); + const rc = createRemoteControl(b); + await rc.connect(tv); + const changes = vi.fn(); + rc.subscribe(changes); + await rc.leave(); + expect(rc.getState().link).toBeNull(); + expect(rc.getState().ended).toBeNull(); + expect(b.leave).toHaveBeenCalledTimes(1); + expect(changes).toHaveBeenCalledTimes(1); + }); + + it("stops telling a subscriber that left", async () => { + const { b, emit } = backend(); + const rc = createRemoteControl(b); + await rc.start(); + const changes = vi.fn(); + const stop = rc.subscribe(changes); + stop(); + emit("remote://devices", []); + expect(changes).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/player/src/lib/remote-control.ts b/apps/player/src/lib/remote-control.ts new file mode 100644 index 00000000..2e5597d1 --- /dev/null +++ b/apps/player/src/lib/remote-control.ts @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. + +/** + * This device as a controller: the other Cinette devices on the network, + * and the one it controls, if any, as a {@link CinetteDestination}. + * + * One device is controlled at a time. Choosing "This device" again is + * leaving, which ends the session; the other device keeps playing. + */ + +import { + createCinetteDestination, + type CinetteDestination, + type LinkEnded, + type LinkPhase, + type RemoteSnapshot, +} from "@/lib/cinette-destination"; +import type { RemoteCommand } from "@/lib/remote-target"; +import type { RemoteDevice } from "@/lib/tauri"; + +/** The device this one controls, and how the connection stands. */ +export interface Link { + deviceId: string; + name: string; + phase: LinkPhase; + destination: CinetteDestination; +} + +export interface RemoteControlState { + devices: RemoteDevice[]; + link: Link | null; + /** The last link that ended, and why, until another starts. */ + ended: { name: string; reason: LinkEnded } | null; +} + +/** What the store needs from the backend. */ +export interface RemoteControlBackend { + devices(): Promise; + connect(deviceId: string): Promise; + leave(): Promise; + command(command: RemoteCommand): Promise; + listen(event: string, handler: (payload: T) => void): Promise<() => void>; + /** `performance.now()`. */ + now(): number; +} + +export interface RemoteControl { + getState(): RemoteControlState; + subscribe(listener: () => void): () => void; + /** Starts following the backend's events. Returns how to stop. */ + start(): Promise<() => void>; + /** Asks `device` for control, leaving any device controlled now. */ + connect(device: RemoteDevice): Promise; + /** Ends the session, or stops asking. */ + leave(): Promise; +} + +type ControllerEvent = { deviceId: string } & LinkPhase; +type StateEvent = { + deviceId: string; + state: { epoch: string; version: number; state: RemoteSnapshot }; +}; + +export const createRemoteControl = (backend: RemoteControlBackend): RemoteControl => { + let state: RemoteControlState = { devices: [], link: null, ended: null }; + const listeners = new Set<() => void>(); + const set = (next: Partial) => { + state = { ...state, ...next }; + for (const listener of listeners) listener(); + }; + + const onPhase = ({ deviceId, ...phase }: ControllerEvent) => { + const link = state.link; + if (link?.deviceId !== deviceId) return; + link.destination.setPhase(phase); + if (phase.phase === "closed") { + set({ link: null, ended: { name: link.name, reason: phase.reason } }); + } else { + set({ link: { ...link, phase } }); + } + }; + + const onState = ({ deviceId, state: versioned }: StateEvent) => { + const link = state.link; + if (link?.deviceId !== deviceId) return; + link.destination.receive(versioned.state, backend.now()); + }; + + return { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + start: async () => { + const stops = await Promise.all([ + backend.listen("remote://devices", (devices) => set({ devices })), + backend.listen("remote://controller", onPhase), + backend.listen("remote://state", onState), + ]); + // Events that came before listening started are in this list. + backend + .devices() + .then((devices) => set({ devices })) + .catch(() => {}); + return () => { + for (const stop of stops) stop(); + }; + }, + connect: async (device) => { + const destination = createCinetteDestination(device.deviceId, device.name, (c) => + backend.command(c) + ); + set({ + link: { + deviceId: device.deviceId, + name: device.name, + phase: { phase: "connecting" }, + destination, + }, + ended: null, + }); + try { + await backend.connect(device.deviceId); + } catch (e) { + if (state.link?.destination === destination) set({ link: null }); + throw e; + } + }, + leave: async () => { + set({ link: null }); + await backend.leave(); + }, + }; +}; diff --git a/apps/player/src/lib/tauri.test.ts b/apps/player/src/lib/tauri.test.ts index 028876a1..c3696649 100644 --- a/apps/player/src/lib/tauri.test.ts +++ b/apps/player/src/lib/tauri.test.ts @@ -656,6 +656,21 @@ describe("backend commands", () => { "remote_finish_command", { token: 9, outcome: "staleItem" }, ], + ["remoteDevices", () => tauri.remoteDevices(), "remote_devices"], + ["remoteConnect", () => tauri.remoteConnect("abc"), "remote_connect", { deviceId: "abc" }], + ["remoteLeave", () => tauri.remoteLeave(), "remote_leave"], + [ + "remoteCommand", + () => tauri.remoteCommand({ action: "seek", itemId: "live-1", to: 30 }), + "remote_command", + { command: { action: "seek", itemId: "live-1", to: 30 } }, + ], + [ + "remoteCatalog", + () => tauri.remoteCatalog({ kind: "search", query: "news", limit: 20 }), + "remote_catalog", + { catalogQuery: { kind: "search", query: "news", limit: 20 } }, + ], ]; const rows = cases.map(([name, call, command, args]) => ({ name, call, command, args })); diff --git a/apps/player/src/lib/tauri.ts b/apps/player/src/lib/tauri.ts index e295c0ca..378456b3 100644 --- a/apps/player/src/lib/tauri.ts +++ b/apps/player/src/lib/tauri.ts @@ -2,7 +2,8 @@ // Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7. import { invoke } from "@tauri-apps/api/core"; -import type { CommandOutcome, RemotePlayerState } from "@/lib/remote-target"; +import type { CatalogItem } from "@/lib/cinette-destination"; +import type { CommandOutcome, RemoteCommand, RemotePlayerState } from "@/lib/remote-target"; import type { MpvPlayerState, Channel, @@ -560,3 +561,42 @@ export const remoteSetState = (state: RemotePlayerState): Promise => export const remoteFinishCommand = (token: number, outcome: CommandOutcome): Promise => invoke("remote_finish_command", { token, outcome }); + +/** Another Cinette device on the network. */ +export interface RemoteDevice { + deviceId: string; + name: string; + /** Like `mac`, `linux-pc` or `fire-tv`. */ + deviceType: string; + /** False when that device is set to Never allow. */ + controlAllowed: boolean; + /** False when discovery lost it. */ + available: boolean; +} + +export const remoteDevices = (): Promise => invoke("remote_devices"); + +/** Asks a device for control. Progress comes as `remote://controller`. */ +export const remoteConnect = (deviceId: string): Promise => + invoke("remote_connect", { deviceId }); + +/** Ends the session with the controlled device. It keeps playing. */ +export const remoteLeave = (): Promise => invoke("remote_leave"); + +/** Resolves with the other device's state version once it carried the + * command out. Rejects with the protocol's error code, like `stale_item`, + * or `notConnected`. */ +export const remoteCommand = (command: RemoteCommand): Promise => + invoke("remote_command", { command }); + +/** A query for the controlled device's catalog. */ +export type CatalogQuery = + | { kind: "groups"; contentType: string } + | { kind: "items"; contentType: string; group: string; cursor?: string; limit: number } + | { kind: "search"; query: string; contentType?: string; limit: number } + | { kind: "episodes"; seriesId: string }; + +export const remoteCatalog = ( + catalogQuery: CatalogQuery +): Promise<{ items: CatalogItem[]; next: string | null }> => + invoke("remote_catalog", { catalogQuery }); From 5badce66c29ea9c457fed6625213893306daa76f Mon Sep 17 00:00:00 2001 From: Max Boksem <15022344+MaxMB15@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:35:22 +0000 Subject: [PATCH 04/60] feat(remote): add the device picker, consent dialog and remote control bars - The device picker lists this device, other Cinette devices (TVs first), and room for Cast and AirPlay, each group under its heading. A row that can't be chosen stays listed, greyed out, with the reason. A route this device has to convert asks before it starts. The Devices button in the sidebar opens it. - The consent dialog on the target names the device and its type, shows the code in large digits and counts down. Closing it denies. - While this device asks another, a dialog shows the same code and Cancel. - While it controls another, a bar on every page says which device plays and what, with play, pause and stop, and Return to independent playback. A link that ends by itself says why once. - While other devices control this one, a bar names them, each with Disconnect. - The channels page reads the other device's catalog while it's the one that plays: groups, pages of items, series episodes and search. --- apps/player/src/App.tsx | 8 +- apps/player/src/components/AppLayout.test.tsx | 37 +- apps/player/src/components/AppLayout.tsx | 12 +- .../components/remote/ChannelsPage.test.tsx | 46 +++ .../src/components/remote/ChannelsPage.tsx | 17 + .../components/remote/ConsentDialog.test.tsx | 141 ++++++++ .../src/components/remote/ConsentDialog.tsx | 77 ++++ .../components/remote/DevicePicker.test.tsx | 146 ++++++++ .../src/components/remote/DevicePicker.tsx | 193 ++++++++++ .../components/remote/RemoteCatalog.test.tsx | 228 ++++++++++++ .../src/components/remote/RemoteCatalog.tsx | 245 +++++++++++++ .../components/remote/RemoteControls.test.tsx | 339 ++++++++++++++++++ .../src/components/remote/RemoteControls.tsx | 249 +++++++++++++ apps/player/src/hooks/useRemoteTarget.test.ts | 7 + apps/player/src/hooks/useRemoteTarget.ts | 45 ++- apps/player/src/lib/device-picker.test.ts | 146 ++++++++ apps/player/src/lib/device-picker.ts | 142 ++++++++ apps/player/src/lib/remote-control.test.ts | 12 + apps/player/src/lib/remote-control.ts | 9 +- apps/player/src/lib/remote-devices.test.ts | 107 ++++++ apps/player/src/lib/remote-devices.ts | 68 ++++ 21 files changed, 2266 insertions(+), 8 deletions(-) create mode 100644 apps/player/src/components/remote/ChannelsPage.test.tsx create mode 100644 apps/player/src/components/remote/ChannelsPage.tsx create mode 100644 apps/player/src/components/remote/ConsentDialog.test.tsx create mode 100644 apps/player/src/components/remote/ConsentDialog.tsx create mode 100644 apps/player/src/components/remote/DevicePicker.test.tsx create mode 100644 apps/player/src/components/remote/DevicePicker.tsx create mode 100644 apps/player/src/components/remote/RemoteCatalog.test.tsx create mode 100644 apps/player/src/components/remote/RemoteCatalog.tsx create mode 100644 apps/player/src/components/remote/RemoteControls.test.tsx create mode 100644 apps/player/src/components/remote/RemoteControls.tsx create mode 100644 apps/player/src/lib/device-picker.test.ts create mode 100644 apps/player/src/lib/device-picker.ts create mode 100644 apps/player/src/lib/remote-devices.test.ts create mode 100644 apps/player/src/lib/remote-devices.ts diff --git a/apps/player/src/App.tsx b/apps/player/src/App.tsx index 306d810e..980e9cd8 100644 --- a/apps/player/src/App.tsx +++ b/apps/player/src/App.tsx @@ -4,12 +4,14 @@ import { Routes, Route } from "react-router"; import { AppLayout } from "./components/AppLayout"; import { PlayerView } from "./components/player/VideoPlayer"; -import { ChannelList } from "./components/channels/ChannelList"; +import { ChannelsPage } from "./components/remote/ChannelsPage"; import { PlaylistManager } from "./components/playlist/PlaylistManager"; import { Settings } from "./components/settings/Settings"; import { UpdateBanner } from "./components/UpdateBanner"; import { SplashScreen } from "./components/SplashScreen"; import { DonationPopup } from "./components/DonationPopup"; +import { ConsentDialog } from "./components/remote/ConsentDialog"; +import { ConnectStatusDialog } from "./components/remote/RemoteControls"; import { ChannelsContext, useChannels, useChannelsProvider } from "./hooks/useChannels"; import { useUpdateChecker } from "./hooks/useUpdateChecker"; import { useSplashScreen } from "./hooks/useSplashScreen"; @@ -68,7 +70,7 @@ const AppRoutes = ({ updateState }: AppRoutesProps) => { }> - } /> + } /> } /> } /> } /> @@ -76,6 +78,8 @@ const AppRoutes = ({ updateState }: AppRoutesProps) => {