From 27fb1ccf6710f1fcc781a8106418a2b2b44bdfbd Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 11 Oct 2026 17:24:08 +0200 Subject: [PATCH 1/4] feat: delays up to 1 hour, Twitch events and alerts, smart lights, two copies side by side Delay - The delay goes up to 1 hour. The buffer decides how much of it fits, from one estimate the app works out (real buffer file, bitrate averaged over 10 s and held at its recent peak, measured afresh for each stream). - Every way in refuses a delay the buffer can't hold: dashboard, dock, HTTP API, hotkeys, MIDI and the integrations' "Set the delay" step. - A delay switched on while the buffer fills starts by itself. Updates - A new build published under the same version is spotted by SHA-256. - Automatic checks with a quiet tray notice, and what an update brings. - Self-update shuts down cleanly first and comes back with a password on. Two InstantClones side by side - One running copy per setup (instance lock): a second launch opens the running dashboard instead of moving its ports. - Per-copy logins, startup entries, buffer files and port reservations. - A copy never re-registers OBS over another running copy. - Tray icon colour is a setting (blue by default); two copies on the same colour still never match while both run. Integrations - Twitch EventSub: follows, subs, gifts, bits, raids, rewards, ad breaks. - Alerts page: seven themes, ten sounds, uploads, per-alert editing. - Smart lights and plugs on the LAN: WiZ, LIFX, Govee, Yeelight, Kasa, Shelly, WLED. - More OBS steps and events, Check OBS, drafts, drag and drop builder. - Shared recipes carry their alerts; nothing in one can load from the web. Dashboard and dock - Destinations say why they fail, with Retry now and Details. - OBS tab shows the real registration state; the dock is reworked. - Logs tab, tab switching, toasts, sliders and the colour picker polished. Fixes found in the release reviews - A non-ASCII character after a template name closed the app. - An overlay named "Reset" wiped every overlay; a failed rename deleted it. - Short stream keys were written to the log unredacted. - Saving a destination could empty OBS's stream settings. - Windows: opening the dashboard and making the desktop shortcut no longer flash a console window, and neither does the test binary. --- .github/workflows/release.yml | 8 + .gitignore | 2 + Cargo.lock | 2 +- Cargo.toml | 2 +- clippy.toml | 3 + src/auth.rs | 117 +- src/autostart.rs | 325 ++- src/buffer.rs | 104 +- src/compat.rs | 323 +-- src/config.rs | 1000 ++++---- src/controller.rs | 1769 +++++++++---- src/controller/delay_sim.rs | 73 +- src/controller/hold_sim.rs | 86 +- src/controller/sim.rs | 24 + src/controller/user_sim.rs | 83 +- src/crash_hold.rs | 80 +- src/crash_protection.rs | 90 +- src/crypto.rs | 117 +- src/diagnostics.rs | 605 +++++ src/egress_failure.rs | 211 ++ src/h264.rs | 1295 +++++----- src/instance_lock.rs | 372 +++ src/integrations/alert_media.rs | 256 ++ src/integrations/alerts.rs | 503 +++- src/integrations/api.rs | 674 ++++- src/integrations/clock.rs | 15 +- src/integrations/effects.rs | 125 +- src/integrations/engine.rs | 314 ++- src/integrations/event.rs | 119 +- src/integrations/host.rs | 45 +- src/integrations/lights/govee.rs | 142 ++ src/integrations/lights/http.rs | 297 +++ src/integrations/lights/kasa.rs | 257 ++ src/integrations/lights/lifx.rs | 180 ++ src/integrations/lights/mod.rs | 487 ++++ src/integrations/lights/net.rs | 148 ++ src/integrations/lights/wiz.rs | 156 ++ src/integrations/lights/yeelight.rs | 237 ++ src/integrations/mod.rs | 5 + src/integrations/model.rs | 197 +- src/integrations/obsws.rs | 872 +++++-- src/integrations/presets.rs | 352 ++- src/integrations/recipe.rs | 417 +++- src/integrations/runner.rs | 627 ++++- src/integrations/session.rs | 11 - src/integrations/store.rs | 10 +- src/integrations/template.rs | 169 +- src/integrations/twitch/auth.rs | 9 +- src/integrations/twitch/eventsub.rs | 789 ++++++ src/integrations/twitch/helix.rs | 40 +- src/integrations/twitch/irc.rs | 92 +- src/integrations/twitch/mod.rs | 227 +- src/integrations/ws.rs | 271 ++ src/json.rs | 15 +- src/local_eb_config.rs | 56 +- src/main.rs | 470 +++- src/midi.rs | 17 +- src/obs_register.rs | 884 ++++--- src/portcheck.rs | 180 +- src/rtmp/amf0.rs | 225 +- src/rtmp/chunk.rs | 189 +- src/rtmp/client.rs | 146 +- src/rtmp/handshake.rs | 41 + src/rtmp/server.rs | 17 +- src/rtmp/tcp.rs | 2 +- src/self_update.rs | 360 ++- src/sha256.rs | 42 +- src/sink.rs | 40 +- src/slate/mod.rs | 8 +- src/slate/themes.rs | 2 +- src/sync.rs | 9 + src/test_support.rs | 101 + src/trace.rs | 2 +- src/tray.rs | 519 +++- src/update_check.rs | 878 ++++++- src/web.rs | 1380 +++++++++-- web/alerts.html | 653 ++++- web/dock.html | 511 ++-- web/dock.js | 972 ++++++-- web/index.html | 3548 ++++++++++++++++++++++----- web/integrations.js | 2448 ++++++++++++++++-- web/overlay-runtime.js | 1 - 82 files changed, 23023 insertions(+), 5427 deletions(-) create mode 100644 clippy.toml create mode 100644 src/diagnostics.rs create mode 100644 src/egress_failure.rs create mode 100644 src/instance_lock.rs create mode 100644 src/integrations/alert_media.rs create mode 100644 src/integrations/lights/govee.rs create mode 100644 src/integrations/lights/http.rs create mode 100644 src/integrations/lights/kasa.rs create mode 100644 src/integrations/lights/lifx.rs create mode 100644 src/integrations/lights/mod.rs create mode 100644 src/integrations/lights/net.rs create mode 100644 src/integrations/lights/wiz.rs create mode 100644 src/integrations/lights/yeelight.rs create mode 100644 src/integrations/twitch/eventsub.rs create mode 100644 src/integrations/ws.rs create mode 100644 src/test_support.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fca2c82..1b32e5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,16 @@ permissions: # The Twitch app id the Integrations tab logs in with (Device Code flow, a # public client: the id is not a secret). Baked in at compile time through # option_env!; set it as a repository variable named TWITCH_CLIENT_ID. +# +# INSTANTCLONE_RELEASE_BUILD marks the binaries as release builds: only they +# compare their own SHA-256 with the published one to spot a new build of +# the same version (update_check.rs). A local build never matches it. +# INSTANTCLONE_BUILD_COMMIT is the commit a build came from, so an update +# can list what it brings since (the commits on top of it). env: INSTANTCLONE_TWITCH_CLIENT_ID: ${{ vars.TWITCH_CLIENT_ID }} + INSTANTCLONE_RELEASE_BUILD: "1" + INSTANTCLONE_BUILD_COMMIT: ${{ github.sha }} jobs: # Gate 1: tests must pass on the exact tagged commit before we ship. diff --git a/.gitignore b/.gitignore index 91fc311..fe38b15 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ /instantclone.config.json # Any ring-buffer file (custom buffer_path names used in local/e2e runs). *.buf +# Sounds and pictures uploaded for alerts. +/alerts-media/ # Editor / IDE /.vscode/ diff --git a/Cargo.lock b/Cargo.lock index 03ed2d8..869194d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "instantclone" -version = "0.1.15" +version = "0.1.16" dependencies = [ "bytes", "flate2", diff --git a/Cargo.toml b/Cargo.toml index e280172..de31242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "instantclone" -version = "0.1.15" +version = "0.1.16" edition = "2021" authors = ["s1moscs"] license = "GPL-3.0-only" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..fd2d813 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +disallowed-macros = [ + { path = "std::eprintln", reason = "panics once stderr is gone, and a release build aborts on a panic: use log_line!" }, +] diff --git a/src/auth.rs b/src/auth.rs index cc6ed9b..b9a6d41 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -270,59 +270,61 @@ mod tests { ); } - /// Each failure past the threshold doubles the lockout until it reaches - /// `max_lockout`, where it stays. Read back without sleeping: `check` - /// reports how long is left, which right after arming is the armed value. + /// Failed logins lock a client out for longer each time, up to a cap, and never overflow. #[test] - fn lockout_doubles_past_the_threshold_up_to_the_cap() { - let r = RateLimiter::new( - 2, - Duration::from_secs(10), - Duration::from_secs(45), - Duration::from_secs(3600), - ); - let c = "peer"; - r.record_failure(c); - assert!(r.check(c).is_ok(), "one failure is under the threshold"); - for want in [10, 20, 40, 45, 45] { + fn lockouts_start_at_the_threshold_double_and_stay_bounded() { + // lockout doubles past the threshold up to the cap + // Each failure past the threshold doubles the lockout until it reaches + // `max_lockout`, where it stays. Read back without sleeping: `check` + // reports how long is left, which right after arming is the armed value. + { + let r = RateLimiter::new( + 2, + Duration::from_secs(10), + Duration::from_secs(45), + Duration::from_secs(3600), + ); + let c = "peer"; r.record_failure(c); - assert_lockout(&r, c, want); + assert!(r.check(c).is_ok(), "one failure is under the threshold"); + for want in [10, 20, 40, 45, 45] { + r.record_failure(c); + assert_lockout(&r, c, want); + } } - } - - /// A lockout never outlasts the forgiveness window. `check` forgets an - /// entry once it is older than the window, so a longer lock would be - /// wiped early and the two settings would disagree about when a client - /// may try again. - #[test] - fn lockout_is_clamped_to_the_forgiveness_window() { - let r = RateLimiter::new( - 1, - Duration::from_secs(10), - Duration::from_secs(3600), - Duration::from_secs(25), - ); - let c = "peer"; - for want in [10, 20, 25, 25] { - r.record_failure(c); - assert_lockout(&r, c, want); + // lockout is clamped to the forgiveness window + // A lockout never outlasts the forgiveness window. `check` forgets an + // entry once it is older than the window, so a longer lock would be + // wiped early and the two settings would disagree about when a client + // may try again. + { + let r = RateLimiter::new( + 1, + Duration::from_secs(10), + Duration::from_secs(3600), + Duration::from_secs(25), + ); + let c = "peer"; + for want in [10, 20, 25, 25] { + r.record_failure(c); + assert_lockout(&r, c, want); + } } - } - - /// A client that keeps failing must not overflow the doubling. Without the - /// cap on the shift, the 33rd failure past the threshold is `1u32 << 32`: - /// a panic in a debug build, and in release a shift that wraps back to a - /// short lockout, handing the most persistent guesser the fastest retries. - #[test] - fn endless_failures_do_not_overflow_the_doubling() { - let day = Duration::from_secs(24 * 3600); - let r = RateLimiter::new(1, Duration::from_secs(1), day, day); - let c = "peer"; - for _ in 0..100 { - r.record_failure(c); + // endless failures do not overflow the doubling + // A client that keeps failing must not overflow the doubling. Without the + // cap on the shift, the 33rd failure past the threshold is `1u32 << 32`: + // a panic in a debug build, and in release a shift that wraps back to a + // short lockout, handing the most persistent guesser the fastest retries. + { + let day = Duration::from_secs(24 * 3600); + let r = RateLimiter::new(1, Duration::from_secs(1), day, day); + let c = "peer"; + for _ in 0..100 { + r.record_failure(c); + } + // The shift is capped at 6: base * 64. + assert_lockout(&r, c, 64); } - // The shift is capped at 6: base * 64. - assert_lockout(&r, c, 64); } /// The tracked-client map is bounded, and when a flood fills it the @@ -354,23 +356,4 @@ mod tests { assert!(r.check("client-1").is_err(), "newer entries are kept"); assert!(r.check("newcomer").is_err(), "the newcomer is tracked"); } - - #[test] - fn rate_limiter_locks_and_forgives_on_success() { - let r = RateLimiter::new( - 3, - Duration::from_secs(30), - Duration::from_secs(600), - Duration::from_secs(600), - ); - let c = "peer"; - assert!(r.check(c).is_ok()); - r.record_failure(c); - r.record_failure(c); - assert!(r.check(c).is_ok()); // 2 < 3, still allowed - r.record_failure(c); - assert!(r.check(c).is_err()); // 3rd failure locks - r.record_success(c); - assert!(r.check(c).is_ok()); // success clears the lock - } } diff --git a/src/autostart.rs b/src/autostart.rs index 339f373..33b5526 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -1,4 +1,4 @@ -//! "Start with Windows" - one value under the per-user Run key. +//! "Start with Windows" - one value per copy under the per-user Run key. //! //! The registry is the source of truth here, deliberately: it is not //! mirrored into `Settings`. Windows gives users their own switches for @@ -14,7 +14,9 @@ use std::io; /// Value name under the Run key. Stable - renaming it would strand the old -/// entry and silently start InstantClone twice. +/// entry and silently start InstantClone twice. It stays the first name a +/// copy takes (see `value_names`), so a single copy keeps the entry every +/// earlier build wrote. #[cfg(windows)] const VALUE_NAME: &str = "InstantClone"; @@ -36,25 +38,83 @@ fn run_command() -> io::Result { Ok(format!("\"{}\" --no-browser", exe.display())) } -/// Whether InstantClone is currently registered to start at login. -/// -/// Any failure reads as "not enabled": the value being absent is itself -/// the common failure, and a missing Run key on an exotic Windows install -/// means the same thing to the user either way. +/// The Run values a copy may use, in the order it takes them. Two copies +/// from two folders each need their own: with one shared name, switching +/// it on in the second copy repointed the first copy's entry, and +/// switching it off there deleted it. The second name is readable in +/// regedit ("InstantClone (Second channel)"); the third only breaks a tie +/// between two folders of the same name. #[cfg(windows)] -pub fn is_enabled() -> bool { - is_enabled_under(RUN_KEY) +fn value_names(exe: &std::path::Path) -> [String; 3] { + let folder = exe + .parent() + .and_then(|dir| dir.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let path = exe.to_string_lossy().to_lowercase(); + let id = &crate::sha256::hex(path.as_bytes())[..8]; + [ + VALUE_NAME.to_string(), + format!("{VALUE_NAME} ({folder})"), + format!("{VALUE_NAME} ({folder} {id})"), + ] } -/// `is_enabled` against any HKCU subkey. Split out so the round-trip test -/// can use a private key instead of the developer's real Run key. +/// Who a Run value belongs to, judged by the exe its command starts. #[cfg(windows)] -fn is_enabled_under(subkey: &str) -> bool { +#[derive(Debug, PartialEq, Eq)] +enum Owner { + /// No such value. + Nobody, + /// It starts this exe. + Us, + /// It starts an exe that no longer exists: left by a copy that was moved + /// or deleted, free to take over. + Gone, + /// It starts another copy that is still there. Never touched. + OtherCopy, +} + +/// The exe a Run command starts: the quoted path we write, or the first +/// word of a hand-written unquoted one. +#[cfg(windows)] +fn command_exe(command: &str) -> &str { + let command = command.trim(); + match command.strip_prefix('"') { + Some(rest) => rest.split('"').next().unwrap_or(rest), + None => command.split_whitespace().next().unwrap_or(command), + } +} + +/// Pure apart from `exists`, so the rules are testable without a registry. +#[cfg(windows)] +fn owner_of( + command: Option<&str>, + exe: &std::path::Path, + exists: impl Fn(&std::path::Path) -> bool, +) -> Owner { + let Some(command) = command else { + return Owner::Nobody; + }; + let target = std::path::Path::new(command_exe(command)); + // Windows paths compare without case. + if target.to_string_lossy().to_lowercase() == exe.to_string_lossy().to_lowercase() { + Owner::Us + } else if exists(target) { + Owner::OtherCopy + } else { + Owner::Gone + } +} + +/// A string value under an HKCU subkey, or None when it is missing. +#[cfg(windows)] +fn read_value(subkey: &str, name: &str) -> Option { use windows_sys::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_SZ}; let subkey = wide(subkey); - let name = wide(VALUE_NAME); - // Null output buffer + null size: asks only "does this value exist, - // and is it a string?" without reading the data back. + let name = wide(name); + // First ask the size, then read into a buffer of it. + let mut bytes: u32 = 0; let rc = unsafe { RegGetValueW( HKEY_CURRENT_USER, @@ -63,17 +123,68 @@ fn is_enabled_under(subkey: &str) -> bool { RRF_RT_REG_SZ, std::ptr::null_mut(), std::ptr::null_mut(), + &mut bytes, + ) + }; + if rc != 0 || bytes == 0 { + return None; + } + let mut buf = vec![0u16; (bytes as usize).div_ceil(2)]; + let rc = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + name.as_ptr(), + RRF_RT_REG_SZ, std::ptr::null_mut(), + buf.as_mut_ptr().cast(), + &mut bytes, ) }; - rc == 0 + if rc != 0 { + return None; + } + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + Some(String::from_utf16_lossy(&buf[..len])) +} + +/// Each of this copy's candidate value names with its current owner. +#[cfg(windows)] +fn candidates(subkey: &str) -> io::Result> { + let exe = std::env::current_exe()?; + Ok(value_names(&exe) + .into_iter() + .map(|name| { + let owner = owner_of(read_value(subkey, &name).as_deref(), &exe, |p| p.exists()); + (name, owner) + }) + .collect()) +} + +/// Whether this copy is registered to start at login. Another copy's entry +/// doesn't count: each dashboard shows its own switch. +/// +/// Any failure reads as "not enabled": the value being absent is itself +/// the common failure, and a missing Run key on an exotic Windows install +/// means the same thing to the user either way. +#[cfg(windows)] +pub fn is_enabled() -> bool { + is_enabled_under(RUN_KEY) +} + +/// `is_enabled` against any HKCU subkey. Split out so the round-trip test +/// can use a private key instead of the developer's real Run key. +#[cfg(windows)] +fn is_enabled_under(subkey: &str) -> bool { + candidates(subkey).is_ok_and(|names| names.iter().any(|(_, owner)| *owner == Owner::Us)) } -/// Add or remove the Run-key entry. +/// Add or remove this copy's Run entry. /// /// Enabling always rewrites the command rather than checking first, so an /// entry left behind by a previous install location is corrected instead -/// of pointing at an executable that no longer exists. +/// of pointing at an executable that no longer exists. Another copy's +/// entry is never rewritten or removed. #[cfg(windows)] pub fn set(enabled: bool) -> io::Result<()> { set_under(RUN_KEY, enabled) @@ -83,38 +194,45 @@ pub fn set(enabled: bool) -> io::Result<()> { /// a private key instead of the developer's real Run key. #[cfg(windows)] fn set_under(subkey: &str, enabled: bool) -> io::Result<()> { - use windows_sys::Win32::System::Registry::{ - RegDeleteKeyValueW, RegSetKeyValueW, HKEY_CURRENT_USER, REG_SZ, - }; - let subkey = wide(subkey); - let name = wide(VALUE_NAME); - - let rc = if enabled { - let value = wide(&run_command()?); - // Length is in BYTES and must include the terminating null. - let bytes = (value.len() * std::mem::size_of::()) as u32; - unsafe { - RegSetKeyValueW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - name.as_ptr(), - REG_SZ, - value.as_ptr() as *const std::ffi::c_void, - bytes, - ) + let names = candidates(subkey)?; + if !enabled { + for (name, _) in names.iter().filter(|(_, owner)| *owner == Owner::Us) { + delete_value(subkey, name)?; } - } else { - let rc = unsafe { RegDeleteKeyValueW(HKEY_CURRENT_USER, subkey.as_ptr(), name.as_ptr()) }; - // ERROR_FILE_NOT_FOUND - already absent, which is the state the - // caller asked for. Disabling twice is not an error. - const ERROR_FILE_NOT_FOUND: u32 = 2; - if rc == ERROR_FILE_NOT_FOUND { - 0 - } else { - rc - } - }; + return Ok(()); + } + // Ours if we have one, otherwise the first that is free. + let name = names + .iter() + .find(|(_, owner)| *owner == Owner::Us) + .or_else(|| { + names + .iter() + .find(|(_, owner)| matches!(owner, Owner::Nobody | Owner::Gone)) + }) + .map(|(name, _)| name) + .ok_or_else(|| io::Error::other("every startup entry name is taken by another copy"))?; + write_value(subkey, name, &run_command()?) +} +#[cfg(windows)] +fn write_value(subkey: &str, name: &str, command: &str) -> io::Result<()> { + use windows_sys::Win32::System::Registry::{RegSetKeyValueW, HKEY_CURRENT_USER, REG_SZ}; + let subkey = wide(subkey); + let name = wide(name); + let value = wide(command); + // Length is in BYTES and must include the terminating null. + let bytes = (value.len() * std::mem::size_of::()) as u32; + let rc = unsafe { + RegSetKeyValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + name.as_ptr(), + REG_SZ, + value.as_ptr() as *const std::ffi::c_void, + bytes, + ) + }; if rc == 0 { Ok(()) } else { @@ -122,6 +240,22 @@ fn set_under(subkey: &str, enabled: bool) -> io::Result<()> { } } +#[cfg(windows)] +fn delete_value(subkey: &str, name: &str) -> io::Result<()> { + use windows_sys::Win32::System::Registry::{RegDeleteKeyValueW, HKEY_CURRENT_USER}; + let subkey = wide(subkey); + let name = wide(name); + let rc = unsafe { RegDeleteKeyValueW(HKEY_CURRENT_USER, subkey.as_ptr(), name.as_ptr()) }; + // ERROR_FILE_NOT_FOUND - already absent, which is the state the caller + // asked for. Disabling twice is not an error. + const ERROR_FILE_NOT_FOUND: u32 = 2; + if rc == 0 || rc == ERROR_FILE_NOT_FOUND { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(rc as i32)) + } +} + // ── Linux: a freedesktop autostart entry ──────────────────────────── // // The XDG spec's equivalent of the Run key is a `.desktop` file dropped in @@ -315,15 +449,21 @@ mod tests { /// deleting it leaves nothing behind, and never the real Run key: a test /// that touched that could rewrite or drop the developer's own startup /// entry, and a panic midway would leave it that way. - const TEST_KEY: &str = r"Software\InstantCloneAutostartTest"; + /// A private key per test process, so two runs at once never share one. + fn test_key(name: &str) -> String { + format!( + r"Software\InstantCloneAutostartTest{name}{}", + std::process::id() + ) + } - /// Deletes `TEST_KEY` on drop, so a failed assertion still cleans up. - struct TestKeyGuard; + /// Deletes its key on drop, so a failed assertion still cleans up. + struct TestKeyGuard(String); impl Drop for TestKeyGuard { fn drop(&mut self) { use windows_sys::Win32::System::Registry::{RegDeleteTreeW, HKEY_CURRENT_USER}; - let subkey = wide(TEST_KEY); + let subkey = wide(&self.0); unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, subkey.as_ptr()) }; } } @@ -333,25 +473,86 @@ mod tests { /// developer's own startup. #[test] fn enable_disable_round_trip() { - let _cleanup = TestKeyGuard; + let key = test_key(""); + let _cleanup = TestKeyGuard(key.clone()); assert!( - !is_enabled_under(TEST_KEY), + !is_enabled_under(&key), "stale test key from an earlier run" ); - set_under(TEST_KEY, true).expect("enable"); - assert!( - is_enabled_under(TEST_KEY), - "value must exist after enabling" - ); + set_under(&key, true).expect("enable"); + assert!(is_enabled_under(&key), "value must exist after enabling"); - set_under(TEST_KEY, false).expect("disable"); + set_under(&key, false).expect("disable"); assert!( - !is_enabled_under(TEST_KEY), + !is_enabled_under(&key), "value must be gone after disabling" ); // Disabling an already-absent value is a no-op, not an error. - set_under(TEST_KEY, false).expect("disable twice"); + set_under(&key, false).expect("disable twice"); + } + + /// A second copy (another folder) switching autostart on or off + /// leaves the first copy's entry exactly as it was, and an entry left + /// by a copy that no longer exists is taken over. + #[test] + fn each_copy_has_its_own_startup_entry() { + let key = test_key("Copies"); + let _cleanup = TestKeyGuard(key.clone()); + let key = key.as_str(); + // A file that exists and isn't this exe stands in for the other copy. + let other_exe = + std::env::temp_dir().join(format!("ic-other-copy-{}.exe", std::process::id())); + std::fs::write(&other_exe, b"").unwrap(); + let other = format!(r#""{}" --no-browser"#, other_exe.display()); + let other = other.as_str(); + write_value(key, VALUE_NAME, other).unwrap(); + assert!(!is_enabled_under(key), "the other copy's entry isn't ours"); + + set_under(key, true).expect("enable"); + assert!(is_enabled_under(key)); + assert_eq!( + read_value(key, VALUE_NAME).as_deref(), + Some(other), + "left alone" + ); + + set_under(key, false).expect("disable"); + assert!(!is_enabled_under(key)); + assert_eq!( + read_value(key, VALUE_NAME).as_deref(), + Some(other), + "still there" + ); + + let gone = r#""C:\InstantCloneMovedAway\InstantClone.exe" --no-browser"#; + write_value(key, VALUE_NAME, gone).unwrap(); + set_under(key, true).expect("enable over a gone copy"); + assert_eq!(read_value(key, VALUE_NAME), Some(run_command().unwrap())); + let _ = std::fs::remove_file(&other_exe); + } + + #[test] + fn run_commands_are_read_for_their_exe_owner_and_value_name() { + let exe = std::path::Path::new(r"C:\Streams\Main\InstantClone.exe"); + let ours = r#""c:\streams\main\instantclone.exe" --no-browser"#; + assert_eq!( + owner_of(Some(ours), exe, |_| true), + Owner::Us, + "case-insensitive" + ); + let theirs = r#""C:\Streams\Second\InstantClone.exe" --no-browser"#; + assert_eq!(owner_of(Some(theirs), exe, |_| true), Owner::OtherCopy); + assert_eq!(owner_of(Some(theirs), exe, |_| false), Owner::Gone); + assert_eq!(owner_of(None, exe, |_| true), Owner::Nobody); + assert_eq!( + command_exe(r"C:\Tools\ic.exe --no-browser"), + r"C:\Tools\ic.exe" + ); + let names = value_names(exe); + assert_eq!(names[0], "InstantClone"); + assert_eq!(names[1], "InstantClone (Main)"); + assert!(names[2].starts_with("InstantClone (Main ") && names[2].len() == 28); } } diff --git a/src/buffer.rs b/src/buffer.rs index 6b63b06..cf69b67 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -7,7 +7,9 @@ //! //! The in-memory `VecDeque` is the canonical view of *what* is in the //! buffer. Each entry is 32 bytes, so 10 minutes of audio+video indexes in -//! under 2 MB regardless of the bitrate of the underlying media. +//! about 2 MB (an hour, the longest delay, in about 12 MB; two to three times +//! that with Enhanced Broadcasting's extra video tracks) regardless of the +//! bitrate of the underlying media. use crate::sync::Mutex; use std::collections::VecDeque; @@ -34,6 +36,10 @@ pub struct TagMeta { pub struct DiskRing { file: Mutex, + /// The path and size this ring was created with. Settings can name a + /// different buffer, which only takes effect after a restart; the + /// dashboard compares the two to say so. + path: std::path::PathBuf, capacity: u64, inner: Mutex, @@ -113,6 +119,30 @@ impl DiskRing { // with backoff so a 100 ms AV scan doesn't permanently // block the user's cold start. let mut file = open_with_retry(path)?; + // Ours alone for as long as we run. A second InstantClone pointed at + // the same file (two setups sharing one absolute buffer_path) would + // otherwise resize it under the first one's live delay, write over + // its tags, and delete it on quit. Locked before the resize so the + // loser never touches the length. The OS drops the lock with the + // process, so a crash leaves nothing stale. + match file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => { + return Err(std::io::Error::new( + std::io::ErrorKind::ResourceBusy, + "another InstantClone is using this buffer file: give each copy its own \ + buffer_path", + )); + } + // A filesystem without locks (some network or virtual drives) + // ran fine before the lock existed: carry on without it. + Err(std::fs::TryLockError::Error(e)) => { + log_line!( + "buffer: can't lock {} ({e}), carrying on unlocked", + path.display() + ); + } + } set_len_with_retry(&file, capacity)?; // Seek back to 0 so the first append writes at the start. // open_with_retry positions the cursor at end-of-file when @@ -121,6 +151,7 @@ impl DiskRing { let file = file; // freeze rebinding Ok(Self { file: Mutex::new(file), + path: path.to_path_buf(), capacity, inner: Mutex::new(RingInner { write_cursor: 0, @@ -501,7 +532,24 @@ impl DiskRing { /// reaches back no further at this bitrate. A delay longer than that /// can never fill; the controller then delays by what the ring holds. pub fn is_saturated(&self) -> bool { - self.inner.lock().indexed_bytes >= self.capacity - self.capacity / 16 + self.inner.lock().indexed_bytes >= self.usable_bytes() + } + + /// How many bytes of tags the ring holds before it counts as full (see + /// `is_saturated`). What a delay can use, so the controller's capacity + /// estimate is built on it. + pub fn usable_bytes(&self) -> u64 { + self.capacity - self.capacity / 16 + } + + /// The buffer file in use, as the settings named it at startup. + pub fn path(&self) -> &Path { + &self.path + } + + /// The size of the buffer file in use, in bytes. + pub fn capacity(&self) -> u64 { + self.capacity } } @@ -632,6 +680,26 @@ mod tests { assert_eq!(buf, payload); } + /// Two rings on one file would write over each other's tags, and the + /// second one's quit would delete the first one's buffer. The second is + /// refused, with the length untouched, until the first is gone. + #[test] + fn a_buffer_file_in_use_is_refused_to_a_second_ring() { + let t = tmp(4096); + let err = DiskRing::create(&t.1, 8192) + .err() + .expect("the file is taken"); + assert_eq!(err.kind(), std::io::ErrorKind::ResourceBusy); + assert!(err.to_string().contains("another InstantClone")); + assert_eq!(std::fs::metadata(&t.1).unwrap().len(), 4096); + let path = t.1.clone(); + drop(t); + let again = DiskRing::create(&path, 4096); + assert!(again.is_ok(), "free again once the first ring is gone"); + drop(again); + let _ = std::fs::remove_file(&path); + } + #[test] fn seq_headers_bypass_the_ring() { let t = tmp(4096); @@ -781,17 +849,20 @@ mod tests { assert!(t.0.try_read_seq(seqs[2], &mut buf).unwrap().is_some()); } + /// A delay lands on the newest keyframe at or before its moment, never + /// a newer one: exactly on one, between two, and none when every + /// keyframe in the ring is newer. #[test] - fn newest_idr_returns_the_last_one() { + fn newest_idr_at_or_before_never_picks_a_newer_keyframe() { let t = tmp(4096); let idr = primary_idr_payload(30); - t.0.append(9, 100, &idr, true, false).unwrap(); - t.0.append(9, 200, &[0u8; 30], false, false).unwrap(); - t.0.append(9, 300, &idr, true, false).unwrap(); - t.0.append(9, 400, &[0u8; 30], false, false).unwrap(); - let m = t.0.newest_idr().expect("has IDR"); - assert_eq!(m.ts_ms, 300); - assert!(m.is_idr); + t.0.append(9, 1_000, &idr, true, false).unwrap(); + t.0.append(9, 1_500, &[0u8; 30], false, false).unwrap(); + t.0.append(9, 3_000, &idr, true, false).unwrap(); + let at = |ts| t.0.newest_idr_at_or_before(ts).map(|m| m.ts_ms); + assert_eq!(at(3_000), Some(3_000), "exactly on one"); + assert_eq!(at(2_999), Some(1_000), "between two"); + assert_eq!(at(999), None, "every keyframe is newer"); } #[test] @@ -886,19 +957,6 @@ mod tests { // `meta.is_idr` to find its own canvas's first keyframe. assert!(t.0.find_by_seq(rung).unwrap().1.is_idr); } - - #[test] - fn newest_idr_after_skips_stale_publisher_idrs() { - // Models the publisher-reconnect case: there are old IDRs in the - // ring from before the reconnect; we want only the new session's. - let t = tmp(4096); - let idr = primary_idr_payload(30); - let old_seq = t.0.append(9, 100, &idr, true, false).unwrap().unwrap(); - let new_seq = t.0.append(9, 200, &idr, true, false).unwrap().unwrap(); - let m = t.0.newest_idr_after(old_seq).expect("found"); - assert_eq!(m.seq, new_seq); - assert!(t.0.newest_idr_after(new_seq + 100).is_none()); - } } #[cfg(test)] diff --git a/src/compat.rs b/src/compat.rs index fc1311f..e8e14aa 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -44,6 +44,14 @@ const WANTS_2S_KEYFRAME: &[&str] = &["twitch", "youtube", "kick", "trovo", "rest /// accepted but downscaled server-side at best, rejected at worst. const KICK_MAX_HEIGHT: u32 = 1080; +/// Where each fix lives in OBS, so the warning is something to act on. The +/// dashboard adds that OBS applies it the next time streaming starts. +const KEYFRAME_FIX: &str = + "In OBS: Settings → Output → Streaming (Output Mode: Advanced) → Keyframe Interval: 2 s."; +const RESOLUTION_FIX: &str = + "In OBS: Settings → Video → Output (Scaled) Resolution: 1080p or lower."; +const CODEC_FIX: &str = "In OBS: Settings → Output → Streaming → Video Encoder: an H.264 one."; + /// Produce at most one line describing every compatibility problem with /// the current stream, or `None` when there is nothing worth saying. /// @@ -88,7 +96,7 @@ fn keyframe_issue(params: &StreamParams, live: &[&Destination]) -> Option Option Option { return None; } Some(format!( - "Video codec is {}. Kick needs H.264.", + "Video codec is {}. Kick needs H.264. {CODEC_FIX}", params.codec.label() )) } @@ -202,187 +210,182 @@ mod tests { } } - fn params() -> StreamParams { - StreamParams { + /// A healthy 1080p, 2 s keyframe, H.264 stream, changed by `change`. + fn stream(change: impl Fn(&mut StreamParams)) -> StreamParams { + let mut params = StreamParams { width: 1920, height: 1080, keyframe_interval_ms: 2_000, codec: VideoCodec::Avc, - } - } - - #[test] - fn healthy_stream_is_silent() { - let dests = vec![dest("twitch", true), dest("kick", true)]; - assert_eq!(compat_warning(¶ms(), &dests), None); - } - - #[test] - fn no_destinations_is_silent() { - assert_eq!(compat_warning(¶ms(), &[]), None); - } - - /// The whole point of the tolerance: a user who correctly set 2 s - /// measures slightly over it because of encoder jitter, and must not - /// be nagged for it. - #[test] - fn two_second_keyframe_with_jitter_does_not_warn() { - let mut p = params(); - p.keyframe_interval_ms = 2_100; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); - } - - #[test] - fn four_second_keyframe_warns_and_names_platforms() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - let dests = vec![dest("kick", true), dest("youtube", true)]; - assert_eq!( - compat_warning(&p, &dests), - Some("Keyframe interval measures 4.0s. Kick and YouTube expect 2s.".into()) - ); - } - - #[test] - fn single_platform_uses_singular_verb() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Keyframe interval measures 4.0s. Kick expects 2s.".into()) - ); - } - - /// Two destinations on the same platform must not render as - /// "Twitch and Twitch". - #[test] - fn duplicate_platforms_are_deduplicated() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - let mut second = dest("twitch", true); - second.id = "twitch-2".into(); - let dests = vec![dest("twitch", true), second]; - assert_eq!( - compat_warning(&p, &dests), - Some("Keyframe interval measures 4.0s. Twitch expects 2s.".into()) - ); - } - - #[test] - fn disabled_destinations_are_ignored() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - assert_eq!(compat_warning(&p, &[dest("kick", false)]), None); - } - - /// The sink accepts anything - warning about it would be noise. - #[test] - fn sink_alone_never_warns() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - p.height = 1440; - p.codec = VideoCodec::Av1; - assert_eq!(compat_warning(&p, &[dest("sink", true)]), None); - } - - /// A custom ingest has unknown requirements, so guessing is worse - /// than staying quiet. - #[test] - fn custom_platform_has_no_keyframe_opinion() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - assert_eq!(compat_warning(&p, &[dest("custom", true)]), None); + }; + change(&mut params); + params } - #[test] - fn unmeasured_keyframe_interval_is_silent() { - let mut p = params(); - p.keyframe_interval_ms = 0; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); + fn healthy() -> StreamParams { + stream(|_| {}) } - #[test] - fn tall_canvas_warns_only_when_kick_is_enabled() { - let mut p = params(); - p.height = 1440; - assert_eq!(compat_warning(&p, &[dest("twitch", true)]), None); - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Video is 1440p. Kick caps at 1080p.".into()) - ); + fn long_keyframes() -> StreamParams { + stream(|p| p.keyframe_interval_ms = 4_000) } - /// Pins the threshold itself: 2600 ms is the last silent value. Moving - /// it either way changes who gets nagged, so it must be a deliberate - /// edit to `KEYFRAME_WARN_MS`, not a `<` vs `<=` slip. + /// Nothing is said when the stream is fine, when the measurement isn't + /// in yet, or when nobody who would mind is being streamed to. #[test] - fn keyframe_warning_starts_just_past_the_threshold() { - let mut p = params(); - p.keyframe_interval_ms = 2_600; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); - p.keyframe_interval_ms = 2_601; - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Keyframe interval measures 2.6s. Kick expects 2s.".into()) - ); - } - - /// A 9:16 canvas is 1080p on its short side, the same pixel budget as - /// 1920x1080, which Kick's ingest accepts in either orientation. - /// Reading the height alone called it "1920p" and warned falsely. - #[test] - fn portrait_1080p_canvas_does_not_trip_the_kick_cap() { - let mut p = params(); - p.width = 1080; - p.height = 1920; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); - // A portrait canvas past the cap still warns, named by its short side. - p.width = 1440; - p.height = 2560; - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Video is 1440p. Kick caps at 1080p.".into()) - ); - } - - #[test] - fn undecoded_resolution_is_silent() { - let mut p = params(); - p.height = 0; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); + fn stays_silent_when_nothing_is_wrong_or_nobody_minds() { + let kick = [dest("kick", true)]; + let cases: [(&str, StreamParams, &[Destination]); 13] = [ + ( + "a healthy stream", + healthy(), + &[dest("twitch", true), dest("kick", true)], + ), + ("no destinations", healthy(), &[]), + // The whole point of the tolerance: a user who correctly set 2 s + // measures slightly over it because of encoder jitter. + ( + "2 s with encoder jitter", + stream(|p| p.keyframe_interval_ms = 2_100), + &kick, + ), + // Pins the threshold: 2600 ms is the last silent value, so moving + // it is a deliberate edit to KEYFRAME_WARN_MS, not a `<=` slip. + ( + "the last silent interval", + stream(|p| p.keyframe_interval_ms = 2_600), + &kick, + ), + ( + "an interval not measured yet", + stream(|p| p.keyframe_interval_ms = 0), + &kick, + ), + ( + "a switched-off destination", + long_keyframes(), + &[dest("kick", false)], + ), + // A custom ingest has unknown requirements: guessing is worse. + ("a custom server", long_keyframes(), &[dest("custom", true)]), + // The sink accepts anything: warning about it would be noise. + ( + "the test sink, with everything wrong", + stream(|p| { + p.keyframe_interval_ms = 4_000; + p.height = 1440; + p.codec = VideoCodec::Av1; + }), + &[dest("sink", true)], + ), + ( + "1440p to Twitch only", + stream(|p| p.height = 1440), + &[dest("twitch", true)], + ), + // A 9:16 canvas is 1080p on its short side, which Kick accepts. + // Reading the height alone called it "1920p" and warned falsely. + ( + "a portrait 1080p canvas", + stream(|p| (p.width, p.height) = (1080, 1920)), + &kick, + ), + ("a size not decoded yet", stream(|p| p.height = 0), &kick), + ( + "HEVC to YouTube only", + stream(|p| p.codec = VideoCodec::Hevc), + &[dest("youtube", true)], + ), + ( + "a codec not known yet", + stream(|p| p.codec = VideoCodec::Unknown), + &kick, + ), + ]; + for (case, params, dests) in cases { + assert_eq!(compat_warning(¶ms, dests), None, "{case}"); + } } + /// The keyframe warning starts just past the threshold and names each + /// platform that expects 2 s once, however many destinations use it. #[test] - fn non_h264_warns_for_kick_only() { - let mut p = params(); - p.codec = VideoCodec::Hevc; - assert_eq!(compat_warning(&p, &[dest("youtube", true)]), None); - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Video codec is HEVC. Kick needs H.264.".into()) - ); + fn a_long_keyframe_interval_names_who_expects_2s() { + let mut second_twitch = dest("twitch", true); + second_twitch.id = "twitch-2".into(); + let cases: [(StreamParams, &[Destination], &str); 3] = [ + ( + long_keyframes(), + &[dest("kick", true), dest("youtube", true)], + "Keyframe interval measures 4.0s. Kick and YouTube expect 2s.", + ), + ( + long_keyframes(), + &[dest("twitch", true), second_twitch], + "Keyframe interval measures 4.0s. Twitch expects 2s.", + ), + ( + stream(|p| p.keyframe_interval_ms = 2_601), + &[dest("kick", true)], + "Keyframe interval measures 2.6s. Kick expects 2s.", + ), + ]; + for (params, dests, said) in cases { + assert_eq!( + compat_warning(¶ms, dests), + Some(format!("{said} {KEYFRAME_FIX}")) + ); + } } + /// Kick caps video at 1080p on its short side, whichever way up and + /// whichever side was decoded, and takes H.264 only. #[test] - fn unknown_codec_is_silent() { - let mut p = params(); - p.codec = VideoCodec::Unknown; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); + fn kick_is_warned_about_tall_video_and_other_codecs() { + let too_tall = format!("Video is 1440p. Kick caps at 1080p. {RESOLUTION_FIX}"); + let cases = [ + ("1440p", stream(|p| p.height = 1440), too_tall.clone()), + ( + "portrait 1440p", + stream(|p| (p.width, p.height) = (1440, 2560)), + too_tall.clone(), + ), + ( + "a height without a width", + stream(|p| (p.width, p.height) = (0, 1440)), + too_tall, + ), + ( + "HEVC", + stream(|p| p.codec = VideoCodec::Hevc), + format!("Video codec is HEVC. Kick needs H.264. {CODEC_FIX}"), + ), + ]; + for (case, params, said) in cases { + assert_eq!( + compat_warning(¶ms, &[dest("kick", true)]), + Some(said), + "{case}" + ); + } } /// Multiple problems collapse into ONE line - the clutter guard. #[test] fn multiple_issues_join_into_a_single_line() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - p.height = 1440; - p.codec = VideoCodec::Av1; - let warning = compat_warning(&p, &[dest("kick", true)]).unwrap(); + let everything = stream(|p| { + p.keyframe_interval_ms = 4_000; + p.height = 1440; + p.codec = VideoCodec::Av1; + }); + let warning = compat_warning(&everything, &[dest("kick", true)]).unwrap(); assert_eq!( warning, - "Keyframe interval measures 4.0s. Kick expects 2s. · \ - Video is 1440p. Kick caps at 1080p. · \ - Video codec is AV1. Kick needs H.264." + format!( + "Keyframe interval measures 4.0s. Kick expects 2s. {KEYFRAME_FIX} · \ + Video is 1440p. Kick caps at 1080p. {RESOLUTION_FIX} · \ + Video codec is AV1. Kick needs H.264. {CODEC_FIX}" + ) ); assert_eq!(warning.lines().count(), 1); } diff --git a/src/config.rs b/src/config.rs index 4d09ff7..13a3d86 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,11 @@ pub const MAX_DOCK_LAYOUT_LEN: usize = 8 * 1024; const MIN_BUFFER_MB: u64 = 50; // 1 TiB. Guards `buffer_bytes()` against overflow from a hand-edited config. const MAX_BUFFER_MB: u64 = 1024 * 1024; +/// Longest delay anything can arm: 1 hour. The buffer size is what really +/// limits a delay (arming refuses one it can't hold at the stream's +/// bitrate); this only stops a typo or a hand-edited config from asking for +/// days. The web pages hold the same number as `DELAY_MAX_S`. +pub const MAX_DELAY_MS: u32 = 3_600_000; /// Ports for the built-in "Local test sink" destination (platform /// `"sink"`). The egress supervisor spawns `instantclone sink` as a @@ -106,12 +111,29 @@ pub struct Settings { /// streamer hits Arm with a non-zero value) so "last used" sticks /// across sessions without a separate UI knob to maintain it. pub auto_arm_delay_ms: u32, - /// Whether the dashboard checks GitHub for a newer release on load. - /// Default true - the check is a passive nicety (cached 10 min - /// server-side, failures silent). Off for the privacy-minded or + /// Whether InstantClone checks GitHub for a newer release: shortly after + /// start and daily (see `update_check::watch_for_updates`), and when the + /// dashboard loads. Default true - the check is a passive nicety (cached + /// 10 min server-side, failures silent). Off for the privacy-minded or /// air-gapped: it is the app's one routine outbound call, so it gets /// an explicit opt-out in System -> About. pub update_check_enabled: bool, + /// Windows: a quiet tray notification when an automatic check finds an + /// update. Default true; the check itself is `update_check_enabled`. + pub update_tray_notify: bool, + /// Windows: the tray icon's colour, an index into + /// `instance_lock::TRAY_COLORS`. Default 0, the brand blue. The colour + /// shown can differ for one run (see `instance_lock::show_tray_color`). + pub tray_color: u8, + /// State: the update the tray last told the user about (a version, or a + /// version and build hash for a new build of it), so each one is + /// announced once, across restarts. + pub update_notified: String, + /// State: the version and build commit of the last build whose "what's + /// new" the user saw (or the first build of a fresh install). A running + /// build that differs is an update, in-app or by hand. + pub last_seen_version: String, + pub last_seen_commit: String, /// Whether a normal launch opens the dashboard in the browser. Default /// true. Turning it off suits streamers who keep InstantClone running /// in the tray (or autostart it) and don't want a tab every launch - @@ -152,6 +174,8 @@ pub struct Settings { pub discord_channels: Vec, /// Phone pushes (ntfy). pub phone: PhoneConnection, + /// Where the alerts page puts its cards and how big they are. + pub alerts_look: crate::integrations::alerts::Look, /// Whether the old single `discord_webhook_url` has been turned into a /// Discord connection plus a "Stream alerts" integration. False only in /// configs written before integrations existed. @@ -773,6 +797,10 @@ impl Settings { ]); writeln!(f, "connection.phone={}", one_line(&v.to_json()))?; } + if self.alerts_look != Default::default() { + let look = self.alerts_look.to_json().to_json(); + writeln!(f, "alerts.look={}", one_line(&look))?; + } for i in &self.integrations { writeln!( f, @@ -885,6 +913,12 @@ impl Settings { name: "Tournament".into(), delay_ms: 300_000, }, + // Needs more than the default buffer at most bitrates: its + // chip says so and leads to the buffer setting. + DelayProfile { + name: "Long".into(), + delay_ms: 900_000, + }, ], destinations: Vec::new(), discord_webhook_url: String::new(), @@ -912,6 +946,11 @@ impl Settings { // behaviour. Each has an explicit opt-out in the System tab. update_check_enabled: true, open_dashboard_on_launch: true, + update_tray_notify: true, + tray_color: 0, + update_notified: String::new(), + last_seen_version: String::new(), + last_seen_commit: String::new(), // Fresh install hasn't seeded the preset overlays yet; the // dashboard does it on first load, then flips this true. overlays_seeded: false, @@ -926,6 +965,7 @@ impl Settings { integrations: Vec::new(), discord_channels: Vec::new(), phone: PhoneConnection::default(), + alerts_look: Default::default(), // A fresh install has no old webhook to migrate; `load` sets // this after its one-time pass either way. integrations_migrated: false, @@ -982,6 +1022,11 @@ impl Settings { }); } s.migrate_discord_webhook(); + // `!setdelay` copies from up to 0.1.15 (a 10 minute limit, and + // "Delay set to 0s"). The file catches up on the next save. + for integration in &mut s.integrations { + crate::integrations::presets::upgrade_setdelay(integration); + } // Clamp / sanitize on load - hand-edited values can otherwise // hit divide-by-zero (buffer_mb=0 → `% capacity` in DiskRing) or // bind two services to the same port (one will silently fail). @@ -1013,14 +1058,14 @@ impl Settings { // least the UI is reachable to fix it. self.web_port = if self.ingest_port == 7799 { 7800 } else { 7799 }; } - // Cap delay values to the same 10-minute ceiling the runtime uses. - self.armed_delay_ms = self.armed_delay_ms.min(600_000); - self.target_delay_ms = self.target_delay_ms.min(600_000); + // Cap delay values to the same ceiling the runtime uses. + self.armed_delay_ms = self.armed_delay_ms.min(MAX_DELAY_MS); + self.target_delay_ms = self.target_delay_ms.min(MAX_DELAY_MS); // auto_arm_delay_ms is the value used when auto-arm fires. Clamp - // to the same 10-minute ceiling; a 0 from a fresh install or a + // to the same ceiling; a 0 from a fresh install or a // hand-edited config falls back to the 15 s default so we never // auto-arm at "0 s of delay" (which would be a no-op + confusing). - self.auto_arm_delay_ms = self.auto_arm_delay_ms.min(600_000); + self.auto_arm_delay_ms = self.auto_arm_delay_ms.min(MAX_DELAY_MS); if self.auto_arm_delay_ms == 0 { self.auto_arm_delay_ms = 15_000; } @@ -1221,6 +1266,21 @@ impl Settings { if !self.open_dashboard_on_launch { writeln!(f, "open_dashboard_on_launch=false")?; } + if !self.update_tray_notify { + writeln!(f, "update_tray_notify=false")?; + } + if self.tray_color != 0 { + writeln!(f, "tray_color={}", self.tray_color)?; + } + if !self.update_notified.is_empty() { + writeln!(f, "update_notified={}", one_line(&self.update_notified))?; + } + if !self.last_seen_version.is_empty() { + writeln!(f, "last_seen_version={}", one_line(&self.last_seen_version))?; + } + if !self.last_seen_commit.is_empty() { + writeln!(f, "last_seen_commit={}", one_line(&self.last_seen_commit))?; + } if self.overlays_seeded { writeln!(f, "overlays_seeded=true")?; } @@ -1378,6 +1438,11 @@ impl Settings { "auto_activate_when_ready" => self.auto_activate_when_ready = value == "true", "update_check_enabled" => self.update_check_enabled = value != "false", "open_dashboard_on_launch" => self.open_dashboard_on_launch = value != "false", + "update_tray_notify" => self.update_tray_notify = value != "false", + "tray_color" => self.tray_color = value.parse().unwrap_or(0), + "update_notified" => self.update_notified = value.into(), + "last_seen_version" => self.last_seen_version = value.into(), + "last_seen_commit" => self.last_seen_commit = value.into(), "overlays_seeded" => self.overlays_seeded = value == "true", "auto_arm_delay_ms" => { if let Ok(v) = value.parse() { @@ -1413,6 +1478,11 @@ impl Settings { k if k.starts_with("connection.discord.") => { self.load_discord_channel(&k["connection.discord.".len()..], value) } + "alerts.look" => { + if let Ok(v) = json::parse(value) { + self.alerts_look = crate::integrations::alerts::Look::from_json(&v); + } + } "connection.phone" => { if let Ok(v) = json::parse(value) { self.phone = PhoneConnection { @@ -1689,8 +1759,22 @@ impl Settings { } else { "" }; + // The colours to pick the tray icon's from, so the page holds no + // copy of the list. + use crate::instance_lock::{shown_tray_color, tray_color_hex, TRAY_COLORS}; + let tray_colors = (0u8..) + .zip(TRAY_COLORS) + .map(|(index, (name, _))| { + format!( + r#"{{"name":{},"hex":{}}}"#, + json_str(name), + json_str(&tray_color_hex(index)) + ) + }) + .collect::>() + .join(","); format!( - r#"{{"configured":{c},"ingest_port":{ip},"ingest_bind_all":{iba},"web_port":{wp},"web_bind_all":{wba},"buffer_mb":{bm},"buffer_path":{bp},"target_delay_ms":{td},"obs_url":{ou},"discord_webhook_url":{dw},"webhook_set":{ws},"overlays_dir":{ov},"tracing_enabled":{te},"auto_arm_on_connect":{aaoc},"auto_activate_when_ready":{aawr},"auto_arm_delay_ms":{aadm},"overlays_seeded":{os},"start_with_windows":{sww},"update_check_enabled":{uce},"open_dashboard_on_launch":{odol},"ingest_key":{ik},"auth_enabled":{ae},"dock_token":{dt},"os":{osname},"version":{ver},"hotkeys":{hotkeys},"midi":{midi},"midi_device":{midid},"crash_protection":{cp},"twitch_client_id":{tci},"twitch_app_built_in":{tbi},"destinations":{dests}}}"#, + r#"{{"configured":{c},"ingest_port":{ip},"ingest_bind_all":{iba},"web_port":{wp},"web_bind_all":{wba},"buffer_mb":{bm},"buffer_path":{bp},"target_delay_ms":{td},"obs_url":{ou},"discord_webhook_url":{dw},"webhook_set":{ws},"overlays_dir":{ov},"tracing_enabled":{te},"auto_arm_on_connect":{aaoc},"auto_activate_when_ready":{aawr},"auto_arm_delay_ms":{aadm},"overlays_seeded":{os},"start_with_windows":{sww},"update_check_enabled":{uce},"update_tray_notify":{utn},"tray_color":{tc},"tray_color_choice":{tcc},"tray_colors":[{tcs}],"open_dashboard_on_launch":{odol},"ingest_key":{ik},"auth_enabled":{ae},"dock_token":{dt},"os":{osname},"version":{ver},"hotkeys":{hotkeys},"midi":{midi},"midi_device":{midid},"crash_protection":{cp},"twitch_client_id":{tci},"twitch_app_built_in":{tbi},"destinations":{dests}}}"#, c = self.configured, sww = start_with_windows, ik = json_str(ik_shown), @@ -1715,6 +1799,10 @@ impl Settings { aadm = self.auto_arm_delay_ms, os = self.overlays_seeded, uce = self.update_check_enabled, + utn = self.update_tray_notify, + tc = json_str(&tray_color_hex(shown_tray_color())), + tcc = self.tray_color, + tcs = tray_colors, odol = self.open_dashboard_on_launch, hotkeys = hotkeys, midi = midi, @@ -1735,7 +1823,7 @@ impl Settings { errs.push("web_port must be > 0".into()); } if self.ingest_port == self.web_port { - errs.push("ingest_port and web_port must differ - they share the same socket otherwise and one will silently fail to bind".into()); + errs.push("ingest_port and web_port must differ: they share the same socket otherwise and one will silently fail to bind".into()); } if self.buffer_mb < MIN_BUFFER_MB { errs.push(format!("buffer_mb must be at least {}", MIN_BUFFER_MB)); @@ -1781,7 +1869,7 @@ impl Settings { if d.platform == "custom" || d.platform == "kick" { if d.custom_egress_url.is_empty() { let need = if d.platform == "kick" { - "Kick Server URL required - copy it from your Kick dashboard (Settings -> Stream)" + "Kick Server URL required. Copy it from your Kick dashboard (Settings -> Stream)" } else { "custom RTMP URL required" }; @@ -1800,7 +1888,7 @@ impl Settings { // path instead, so custom doesn't force a separate key. if d.platform == "kick" && d.stream_key.is_empty() { errs.push(format!( - "{}: Kick stream key required - it's next to the Server field in your dashboard", + "{}: Kick stream key required. It's next to the Server field in your dashboard", d.name )); continue; @@ -2022,9 +2110,26 @@ fn non_empty(s: &String) -> Option<&String> { } } +/// Where a path from the settings file actually lives: a relative one sits +/// beside that settings file, an absolute one stays as it is. +/// +/// Without this a relative path resolves against the working directory, +/// which is the settings folder in every normal launch but not when +/// `CONFIG_PATH` names a settings file elsewhere. Two such copies started +/// from one folder then both picked `./instantclone.buf` there: one buffer +/// file for two live delays, deleted by whichever quit first. The settings +/// folder is always writable (every save writes a `.tmp` beside it), so it +/// is also a place the buffer can always be created. +pub fn beside_config(config_path: &Path, path: &Path) -> PathBuf { + match config_path.parent() { + Some(dir) if !dir.as_os_str().is_empty() => dir.join(path), + _ => path.to_path_buf(), + } +} + /// Compile-time OS tag the dashboard reads to pick platform-correct copy /// (the start-at-login wording, the file-manager name, the tray mention). -fn os_name() -> &'static str { +pub fn os_name() -> &'static str { #[cfg(windows)] { "windows" @@ -2408,6 +2513,41 @@ fn hex_nibble(b: u8) -> Option { #[cfg(test)] mod tests { + /// Two copies started from one folder with CONFIG_PATH pointing at two + /// settings files each get the buffer beside their own settings, never + /// one shared `./instantclone.buf`. The default launch is unchanged. + #[test] + fn a_relative_path_lives_beside_its_settings_file() { + let default = PathBuf::from("./instantclone.buf"); + assert_eq!( + super::beside_config(Path::new("C:/Streams/A/instantclone.config.json"), &default), + Path::new("C:/Streams/A").join(&default) + ); + assert_ne!( + super::beside_config(Path::new("C:/Streams/A/c.json"), &default), + super::beside_config(Path::new("C:/Streams/B/c.json"), &default) + ); + assert_eq!( + super::beside_config(Path::new("./instantclone.config.json"), &default), + Path::new(".").join(&default), + "the default layout resolves where it always did" + ); + assert_eq!( + super::beside_config(Path::new("instantclone.config.json"), &default), + default + ); + let absolute = if cfg!(windows) { + "D:\\ring.buf" + } else { + "/srv/ring.buf" + }; + assert_eq!( + super::beside_config(Path::new("C:/Streams/A/c.json"), Path::new(absolute)), + PathBuf::from(absolute), + "an absolute path stays where the user put it" + ); + } + // Mirrors obs_register: what we tell the user to paste into OBS has to // resolve under either IP Family, so it stays a hostname. #[test] @@ -2478,8 +2618,8 @@ mod tests { /// hash must NEVER appear in the client-facing JSON (only `auth_enabled`). #[test] fn secret_fields_round_trip_and_hash_stays_server_side() { - let dir = std::env::temp_dir().join(format!("ic-cfg-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let scratch = crate::test_support::TestDir::new("ic-cfg"); + let dir = scratch.path().to_path_buf(); let path = dir.join("sec.config.json"); let mut s = Settings::defaults(); @@ -2521,8 +2661,6 @@ mod tests { .unwrap() .dashboard_password_hash .is_empty()); - - let _ = std::fs::remove_dir_all(&dir); } /// A newline smuggled into a string setting must not inject a second @@ -2530,8 +2668,8 @@ mod tests { /// ingest_bind_all). The writer strips control chars. #[test] fn config_write_blocks_newline_injection() { - let dir = std::env::temp_dir().join(format!("ic-inj-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let scratch = crate::test_support::TestDir::new("ic-inj"); + let dir = scratch.path().to_path_buf(); let path = dir.join("inj.config.json"); let mut s = Settings::defaults(); @@ -2546,22 +2684,45 @@ mod tests { let loaded = Settings::load(&path).unwrap(); assert!(!loaded.ingest_bind_all, "injection flipped ingest_bind_all"); assert!(!loaded.discord_webhook_url.contains('\n')); - - let _ = std::fs::remove_dir_all(&dir); } + /// Zero or clashing ports, unsafe paths, and an ingest key that isn't one. #[test] - fn validate_rejects_unsafe_ingest_key() { - let mut s = Settings::defaults(); - s.ingest_key = "good-key_123".into(); - assert!(!s.validate().iter().any(|e| e.contains("ingest key"))); - for bad in ["has space", "has?query", "tab\tinside"] { - s.ingest_key = bad.into(); + fn settings_that_cannot_work_fail_validation() { + // settings validate rejects zero ports and collisions + { + let mut s = Settings::defaults(); + s.ingest_port = 0; + assert!(s.validate().iter().any(|e| e.contains("ingest_port"))); + + let mut s2 = Settings::defaults(); + s2.web_port = s2.ingest_port; + assert!(s2.validate().iter().any(|e| e.contains("must differ"))); + } + // settings validate rejects unsafe paths + { + let mut s = Settings::defaults(); + s.buffer_path = PathBuf::from("../../system32/cmd.exe"); + let errs = s.validate(); assert!( - s.validate().iter().any(|e| e.contains("ingest key")), - "should reject {bad:?}" + errs.iter().any(|e| e.contains("buffer_path looks unsafe")), + "expected buffer_path validation error, got: {:?}", + errs ); } + // validate rejects unsafe ingest key + { + let mut s = Settings::defaults(); + s.ingest_key = "good-key_123".into(); + assert!(!s.validate().iter().any(|e| e.contains("ingest key"))); + for bad in ["has space", "has?query", "tab\tinside"] { + s.ingest_key = bad.into(); + assert!( + s.validate().iter().any(|e| e.contains("ingest key")), + "should reject {bad:?}" + ); + } + } } #[test] @@ -2599,6 +2760,20 @@ mod tests { /// The dashboard swaps platform-specific copy off this field, so it must /// always be present and non-empty. + /// The page draws the tray colour picker from this list and marks the + /// setup's choice in it. + #[test] + fn to_json_lists_the_tray_colours_and_the_one_chosen() { + let mut settings = Settings::defaults(); + settings.tray_color = 3; + let json = settings.to_json(false, true); + assert!(json.contains(r#""tray_color_choice":3"#), "{json}"); + assert!( + json.contains(r##""tray_colors":[{"name":"Blue","hex":"#5ac8fa"},"##), + "{json}" + ); + } + #[test] fn to_json_carries_the_os_tag() { let json = Settings::defaults().to_json(false, true); @@ -2623,228 +2798,256 @@ mod tests { } } + /// Plain pairs, percent and plus encoding, empty values, and a stray percent before a multi-byte character. #[test] - fn parse_form_handles_basic_encoding() { - let h = parse_form("name=Test&platform=twitch&stream_key=abc"); - assert_eq!(h.get("name"), Some(&"Test".to_string())); - assert_eq!(h.get("platform"), Some(&"twitch".to_string())); - assert_eq!(h.get("stream_key"), Some(&"abc".to_string())); - } - - #[test] - fn parse_form_handles_percent_and_plus_encoding() { - // `+` decodes to space, `%21` to `!`, `%C3%A9` to UTF-8 `é`. - let h = parse_form("a=hello+world&b=%21&c=%C3%A9"); - assert_eq!(h.get("a"), Some(&"hello world".to_string())); - assert_eq!(h.get("b"), Some(&"!".to_string())); - assert_eq!(h.get("c"), Some(&"é".to_string())); - } - - /// Regression: a bare `%` before a multi-byte UTF-8 char used to slice the - /// `&str` on a non-char boundary and panic (a remote process abort under - /// panic=abort). It must now decode losslessly and never panic. - #[test] - fn url_decode_survives_percent_before_multibyte_char() { - // `%€`, a stray `%` at the very end, and a truncated `%A`. - assert_eq!(url_decode("%€"), "%€"); - assert_eq!(url_decode("abc%"), "abc%"); - assert_eq!(url_decode("x%Ay"), "x%Ay"); - // A real escape still works, and mixed content round-trips. - assert_eq!(url_decode("a%20b%E2%82%ACc"), "a b€c"); - } - - #[test] - fn parse_form_handles_empty_value() { - let h = parse_form("stream_key=&platform=custom"); - assert_eq!(h.get("stream_key"), Some(&"".to_string())); - assert_eq!(h.get("platform"), Some(&"custom".to_string())); - } - - #[test] - fn is_path_safe_blocks_traversal_and_system_dirs() { - assert!(!is_path_safe(&PathBuf::from("../../../etc/passwd"))); - assert!(!is_path_safe(&PathBuf::from( - "C:\\Windows\\System32\\evil.dll" - ))); - assert!(!is_path_safe(&PathBuf::from("/etc/shadow"))); - assert!(!is_path_safe(&PathBuf::from(""))); - } - - #[test] - fn is_path_safe_allows_normal_paths() { - assert!(is_path_safe(&PathBuf::from("./instantclone.buf"))); - assert!(is_path_safe(&PathBuf::from("D:\\streams\\buffer.bin"))); - assert!(is_path_safe(&PathBuf::from("/home/user/buf"))); - } - - #[test] - fn sink_destination_is_self_contained() { - // No stream key, no custom URL - the local test sink needs - // neither. The URL is the fixed managed-child endpoint, and - // validate() must not demand a key for it. - let d = Destination { - id: "x".into(), - name: "Test sink".into(), - enabled: true, - platform: "sink".into(), - stream_key: String::new(), - custom_egress_url: String::new(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT).as_str()) - ); - assert!(d.is_well_formed()); - let mut s = Settings::defaults(); - s.destinations.push(d); - assert!( - s.validate().is_empty(), - "sink destination must validate without a key: {:?}", - s.validate() - ); - } - - #[test] - fn destination_egress_url_for_twitch_appends_key() { - let d = Destination { - id: "x".into(), - name: "T".into(), - enabled: true, - platform: "twitch".into(), - stream_key: "live_123_abc".into(), - custom_egress_url: String::new(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - let url = d.egress_url().unwrap(); - assert!(url.starts_with("rtmp://live.twitch.tv/app/")); - assert!(url.ends_with("/live_123_abc")); - } - - #[test] - fn destination_egress_url_region_pin_uses_live_dash_slug() { - let d = Destination { - id: "x".into(), - name: "T".into(), - enabled: true, - platform: "twitch".into(), - stream_key: "k".into(), - custom_egress_url: String::new(), - twitch_ingest: "fra".into(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://live-fra.twitch.tv/app/k") - ); - } - - #[test] - fn destination_custom_url_with_embedded_key_is_not_double_appended() { - // A custom URL that already includes app+key (2+ path segments) - // must be passed through verbatim, even if stream_key is set. - let d = Destination { - id: "x".into(), - name: "C".into(), - enabled: true, - platform: "custom".into(), - stream_key: "ignored".into(), - custom_egress_url: "rtmp://my.server/live/secret_key".into(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://my.server/live/secret_key"), - "custom URL with app+key must not get an extra key appended" - ); - } - - #[test] - fn destination_youtube_backup_uses_b_ingest() { - let d = Destination { - id: "x".into(), - name: "Y".into(), - enabled: true, - platform: "youtube".into(), - stream_key: "abcd".into(), - custom_egress_url: String::new(), - twitch_ingest: String::new(), - youtube_ingest: "backup".into(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://b.rtmp.youtube.com/live2/abcd?backup=1"), - "YouTube backup ingest needs the ?backup=1 suffix so their \ - edge enables real fail-over, not just a duplicate stream" - ); + fn form_bodies_decode_like_a_browser_sends_them() { + // parse form handles basic encoding + { + let h = parse_form("name=Test&platform=twitch&stream_key=abc"); + assert_eq!(h.get("name"), Some(&"Test".to_string())); + assert_eq!(h.get("platform"), Some(&"twitch".to_string())); + assert_eq!(h.get("stream_key"), Some(&"abc".to_string())); + } + // parse form handles percent and plus encoding + { + // `+` decodes to space, `%21` to `!`, `%C3%A9` to UTF-8 `é`. + let h = parse_form("a=hello+world&b=%21&c=%C3%A9"); + assert_eq!(h.get("a"), Some(&"hello world".to_string())); + assert_eq!(h.get("b"), Some(&"!".to_string())); + assert_eq!(h.get("c"), Some(&"é".to_string())); + } + // parse form handles empty value + { + let h = parse_form("stream_key=&platform=custom"); + assert_eq!(h.get("stream_key"), Some(&"".to_string())); + assert_eq!(h.get("platform"), Some(&"custom".to_string())); + } + // url decode survives percent before multibyte char + // Regression: a bare `%` before a multi-byte UTF-8 char used to slice the + // `&str` on a non-char boundary and panic (a remote process abort under + // panic=abort). It must now decode losslessly and never panic. + { + // `%€`, a stray `%` at the very end, and a truncated `%A`. + assert_eq!(url_decode("%€"), "%€"); + assert_eq!(url_decode("abc%"), "abc%"); + assert_eq!(url_decode("x%Ay"), "x%Ay"); + // A real escape still works, and mixed content round-trips. + assert_eq!(url_decode("a%20b%E2%82%ACc"), "a b€c"); + } } + /// Where the app may be told to write. #[test] - fn destination_egress_url_for_kick_uses_pasted_server_plus_key() { - // Kick gives each streamer a distinct Server URL (rtmps) in the - // dashboard; we store it in custom_egress_url and append the key. - // The rtmps scheme makes the egress socket upgrade to TLS - the - // old hardcoded rtmp:// host connected on 1935 and silently failed. - let d = Destination { - id: "x".into(), - name: "K".into(), - enabled: true, - platform: "kick".into(), - stream_key: "sk_test_key".into(), - custom_egress_url: "rtmps://fa723fc1b171.global-contribute.live-video.net:443/app" - .into(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - let url = d.egress_url().unwrap(); - assert!(url.starts_with("rtmps://"), "kick must use rtmps: {url}"); - assert!(url.ends_with("/app/sk_test_key")); - assert!(d.is_well_formed()); + fn paths_are_safe_unless_they_escape_or_hit_system_folders() { + // is path safe blocks traversal and system dirs + { + assert!(!is_path_safe(&PathBuf::from("../../../etc/passwd"))); + assert!(!is_path_safe(&PathBuf::from( + "C:\\Windows\\System32\\evil.dll" + ))); + assert!(!is_path_safe(&PathBuf::from("/etc/shadow"))); + assert!(!is_path_safe(&PathBuf::from(""))); + } + // is path safe allows normal paths + { + assert!(is_path_safe(&PathBuf::from("./instantclone.buf"))); + assert!(is_path_safe(&PathBuf::from("D:\\streams\\buffer.bin"))); + assert!(is_path_safe(&PathBuf::from("/home/user/buf"))); + } } + /// Where a destination's stream is sent: per platform, with its key in the right place. #[test] - fn kick_server_url_without_app_path_gets_app_appended() { - // Streamer pastes just the host (or host with a trailing slash), - // leaving off "/app". We add Kick's fixed app so it still resolves - // to a well-formed host/app/key URL instead of erroring. - for server in [ - "rtmps://fa723fc1b171.global-contribute.live-video.net/", - "rtmps://fa723fc1b171.global-contribute.live-video.net", - ] { + fn each_platform_builds_its_egress_url() { + // sink destination is self contained + { + // No stream key, no custom URL - the local test sink needs + // neither. The URL is the fixed managed-child endpoint, and + // validate() must not demand a key for it. + let d = Destination { + id: "x".into(), + name: "Test sink".into(), + enabled: true, + platform: "sink".into(), + stream_key: String::new(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT).as_str()) + ); + assert!(d.is_well_formed()); + let mut s = Settings::defaults(); + s.destinations.push(d); + assert!( + s.validate().is_empty(), + "sink destination must validate without a key: {:?}", + s.validate() + ); + } + // destination egress url for twitch appends key + { + let d = Destination { + id: "x".into(), + name: "T".into(), + enabled: true, + platform: "twitch".into(), + stream_key: "live_123_abc".into(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + let url = d.egress_url().unwrap(); + assert!(url.starts_with("rtmp://live.twitch.tv/app/")); + assert!(url.ends_with("/live_123_abc")); + } + // destination egress url region pin uses live dash slug + { + let d = Destination { + id: "x".into(), + name: "T".into(), + enabled: true, + platform: "twitch".into(), + stream_key: "k".into(), + custom_egress_url: String::new(), + twitch_ingest: "fra".into(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://live-fra.twitch.tv/app/k") + ); + } + // destination custom url with embedded key is not double appended + { + // A custom URL that already includes app+key (2+ path segments) + // must be passed through verbatim, even if stream_key is set. + let d = Destination { + id: "x".into(), + name: "C".into(), + enabled: true, + platform: "custom".into(), + stream_key: "ignored".into(), + custom_egress_url: "rtmp://my.server/live/secret_key".into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://my.server/live/secret_key"), + "custom URL with app+key must not get an extra key appended" + ); + } + // destination youtube backup uses b ingest + { + let d = Destination { + id: "x".into(), + name: "Y".into(), + enabled: true, + platform: "youtube".into(), + stream_key: "abcd".into(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: "backup".into(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://b.rtmp.youtube.com/live2/abcd?backup=1"), + "YouTube backup ingest needs the ?backup=1 suffix so their \ + edge enables real fail-over, not just a duplicate stream" + ); + } + // destination egress url for kick uses pasted server plus key + { + // Kick gives each streamer a distinct Server URL (rtmps) in the + // dashboard; we store it in custom_egress_url and append the key. + // The rtmps scheme makes the egress socket upgrade to TLS - the + // old hardcoded rtmp:// host connected on 1935 and silently failed. let d = Destination { id: "x".into(), name: "K".into(), enabled: true, platform: "kick".into(), stream_key: "sk_test_key".into(), - custom_egress_url: server.into(), + custom_egress_url: "rtmps://fa723fc1b171.global-contribute.live-video.net:443/app" + .into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + let url = d.egress_url().unwrap(); + assert!(url.starts_with("rtmps://"), "kick must use rtmps: {url}"); + assert!(url.ends_with("/app/sk_test_key")); + assert!(d.is_well_formed()); + } + // kick server url without app path gets app appended + { + // Streamer pastes just the host (or host with a trailing slash), + // leaving off "/app". We add Kick's fixed app so it still resolves + // to a well-formed host/app/key URL instead of erroring. + for server in [ + "rtmps://fa723fc1b171.global-contribute.live-video.net/", + "rtmps://fa723fc1b171.global-contribute.live-video.net", + ] { + let d = Destination { + id: "x".into(), + name: "K".into(), + enabled: true, + platform: "kick".into(), + stream_key: "sk_test_key".into(), + custom_egress_url: server.into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmps://fa723fc1b171.global-contribute.live-video.net/app/sk_test_key"), + "host-only Kick server URL '{server}' should gain /app" + ); + assert!(d.is_well_formed()); + } + } + // destination custom rtmps url is accepted + { + // The Custom platform is the escape hatch for any TLS ingest + // (a non-standard Kick host, Facebook, etc.). rtmps:// must + // validate and pass through, well-formed with app+key. + let d = Destination { + id: "x".into(), + name: "C".into(), + enabled: true, + platform: "custom".into(), + stream_key: "ignored".into(), + custom_egress_url: "rtmps://edge.example.net/app/streamkey".into(), twitch_ingest: String::new(), youtube_ingest: String::new(), vod_audio: false, @@ -2854,10 +3057,12 @@ mod tests { }; assert_eq!( d.egress_url().as_deref(), - Some("rtmps://fa723fc1b171.global-contribute.live-video.net/app/sk_test_key"), - "host-only Kick server URL '{server}' should gain /app" + Some("rtmps://edge.example.net/app/streamkey") ); assert!(d.is_well_formed()); + assert!(is_rtmp_scheme("rtmps://edge.example.net/app/k")); + assert!(is_rtmp_scheme("rtmp://edge.example.net/app/k")); + assert!(!is_rtmp_scheme("https://edge.example.net/app/k")); } } @@ -2910,35 +3115,6 @@ mod tests { assert!(s.validate().iter().any(|e| e.contains("Kick Server URL"))); } - #[test] - fn destination_custom_rtmps_url_is_accepted() { - // The Custom platform is the escape hatch for any TLS ingest - // (a non-standard Kick host, Facebook, etc.). rtmps:// must - // validate and pass through, well-formed with app+key. - let d = Destination { - id: "x".into(), - name: "C".into(), - enabled: true, - platform: "custom".into(), - stream_key: "ignored".into(), - custom_egress_url: "rtmps://edge.example.net/app/streamkey".into(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmps://edge.example.net/app/streamkey") - ); - assert!(d.is_well_formed()); - assert!(is_rtmp_scheme("rtmps://edge.example.net/app/k")); - assert!(is_rtmp_scheme("rtmp://edge.example.net/app/k")); - assert!(!is_rtmp_scheme("https://edge.example.net/app/k")); - } - #[test] fn destination_well_formed_requires_app_and_key() { let bad = Destination { @@ -2957,29 +3133,16 @@ mod tests { audio_track: "auto".into(), }; assert!(!bad.is_well_formed()); - } - - #[test] - fn settings_validate_rejects_zero_ports_and_collisions() { - let mut s = Settings::defaults(); - s.ingest_port = 0; - assert!(s.validate().iter().any(|e| e.contains("ingest_port"))); - - let mut s2 = Settings::defaults(); - s2.web_port = s2.ingest_port; - assert!(s2.validate().iter().any(|e| e.contains("must differ"))); - } - - #[test] - fn settings_validate_rejects_unsafe_paths() { - let mut s = Settings::defaults(); - s.buffer_path = PathBuf::from("../../system32/cmd.exe"); - let errs = s.validate(); - assert!( - errs.iter().any(|e| e.contains("buffer_path looks unsafe")), - "expected buffer_path validation error, got: {:?}", - errs - ); + let no_key = Destination { + custom_egress_url: "rtmp://my.server/app".into(), + ..bad.clone() + }; + assert!(!no_key.is_well_formed(), "an app with no key"); + let whole = Destination { + custom_egress_url: "rtmp://my.server/app/key".into(), + ..bad + }; + assert!(whole.is_well_formed()); } #[test] @@ -2987,15 +3150,8 @@ mod tests { // The save writer skips default values for the behaviour keys // so a fresh install's config file stays clean. Verify by // saving a defaults() snapshot and grepping the file. - let path = std::env::temp_dir().join(format!( - "ic-test-behavior-defaults-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-test-behavior-defaults"); + let path = scratch.join("settings.ini"); let mut s = Settings::defaults(); s.destinations.push(Destination { @@ -3027,14 +3183,12 @@ mod tests { !text.contains("auto_arm_delay_ms"), "default 15000 must not be written" ); - - let _ = std::fs::remove_file(&path); } #[test] fn behavior_sanitize_clamps_and_fills_zero() { // sanitize_load() runs after load(); it should clamp the - // delay to 600s and replace a zero (e.g. from a hand-edited + // delay to MAX_DELAY_MS and replace a zero (e.g. from a hand-edited // legacy config or downgrade roundtrip) with the 15 s default // so auto-arm never tries to arm at 0 s (= disarm, confusing). let mut s = Settings::defaults(); @@ -3049,8 +3203,8 @@ mod tests { s.auto_arm_delay_ms = 9_999_999; s.sanitize_load(); assert_eq!( - s.auto_arm_delay_ms, 600_000, - "above ceiling must clamp to 10 min" + s.auto_arm_delay_ms, MAX_DELAY_MS, + "above ceiling must clamp to 1 hour" ); } @@ -3060,15 +3214,8 @@ mod tests { // file holds the new contents AND no `.tmp` sibling is left on // disk. The latter is the visible sign of a successful rename // (vs. a hypothetical fallback path that wrote in place). - let path = std::env::temp_dir().join(format!( - "ic-test-atomic-save-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-test-atomic-save"); + let path = scratch.join("settings.ini"); // First save: file didn't exist beforehand. let mut a = Settings::defaults(); @@ -3102,8 +3249,6 @@ mod tests { "save() left a stale {} on disk", tmp.display() ); - - let _ = std::fs::remove_file(&path); } /// The actual atomicity guarantee: when the write cannot complete, the @@ -3113,7 +3258,7 @@ mod tests { /// write) would already have destroyed the original at that point. #[test] fn a_failed_save_leaves_the_existing_file_untouched() { - let path = scratch_config_path("atomic"); + let (_scratch, path) = scratch_config("atomic"); let mut s = Settings::defaults(); s.web_port = 9001; s.save(&path).expect("first save"); @@ -3126,7 +3271,6 @@ mod tests { let result = s.save(&path); let after = std::fs::read(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert!(result.is_err(), "the blocked temp write must surface"); assert_eq!(after, before, "the original config must be untouched"); } @@ -3135,10 +3279,9 @@ mod tests { fn missing_config_file_falls_back_to_defaults() { // The cold-start case: nothing to read yet. Defaults, written out // at once so the user can find the file, and normal saving after. - let path = scratch_config_path("missing"); + let (_scratch, path) = scratch_config("missing"); let startup = Settings::load_for_startup(&path).expect("fresh install"); let written = path.exists(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); let s = startup.settings; assert_eq!(s.ingest_port, 1935); assert_eq!(s.web_port, 7799); @@ -3160,7 +3303,7 @@ mod tests { /// and stream key was overwritten with defaults on launch. #[test] fn an_unreadable_config_is_copied_aside_and_not_overwritten() { - let path = scratch_config_path("locked"); + let (_scratch, path) = scratch_config("locked"); let original = b"configured=true\ndestination.0.stream_key=live_abc\n".to_vec(); std::fs::write(&path, &original).unwrap(); let earlier_copy = path.with_file_name("instantclone.cfg.unreadable"); @@ -3171,7 +3314,6 @@ mod tests { let on_disk = std::fs::read(&path).unwrap(); let first_copy = std::fs::read(&earlier_copy).unwrap(); let new_copy = std::fs::read(path.with_file_name("instantclone.cfg.unreadable.2")); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(on_disk, original, "the original must be untouched"); assert_eq!( first_copy, b"an older copy", @@ -3189,11 +3331,10 @@ mod tests { fn an_unreadable_config_that_cannot_be_copied_stops_startup() { // A directory at the config path reads and copies as an error on // every OS, without needing a real lock. - let path = scratch_config_path("dir"); + let (_scratch, path) = scratch_config("dir"); std::fs::create_dir(&path).unwrap(); let result = Settings::load_for_startup_with(&path, locked_read); let still_there = path.is_dir(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert!(result.is_err()); assert!(still_there); } @@ -3202,7 +3343,7 @@ mod tests { /// copy and no notice: a brief antivirus scan is not worth a dialog. #[test] fn a_briefly_locked_config_loads_after_a_retry() { - let path = scratch_config_path("brief"); + let (_scratch, path) = scratch_config("brief"); std::fs::write(&path, "configured=true\nweb_port=9124\n").unwrap(); let reads = std::cell::Cell::new(0); let flaky = |p: &Path| { @@ -3215,7 +3356,6 @@ mod tests { }; let startup = Settings::load_for_startup_with(&path, flaky).expect("load"); let copied = path.with_file_name("instantclone.cfg.unreadable").exists(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(startup.settings.web_port, 9124); assert!(startup.may_save && startup.notice.is_none()); assert!(!copied); @@ -3225,10 +3365,6 @@ mod tests { fn parse_hotkey_accepts_modifier_plus_key_and_canonicalizes() { // Order-independent, case-insensitive, alias-aware; canonical form // is a fixed modifier order so the same combo always renders alike. - assert_eq!( - canonicalize_hotkey("alt+ctrl+d").as_deref(), - Some("Ctrl+Alt+D") - ); assert_eq!( canonicalize_hotkey("CONTROL + SHIFT + f8").as_deref(), Some("Ctrl+Shift+F8") @@ -3359,7 +3495,7 @@ mod tests { #[test] fn no_destination_field_can_inject_a_config_line() { let mut s = Settings::defaults(); - let mut d = Destination { + let d = Destination { id: "d1".into(), name: "Main\nweb_bind_all=true".into(), enabled: true, @@ -3373,17 +3509,10 @@ mod tests { stream_format: String::new(), audio_track: "auto".into(), }; - d.name = d.name.clone(); s.destinations = vec![d]; - let path = std::env::temp_dir().join(format!( - "ic-inject-{}-{}.conf", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); + let scratch = crate::test_support::TestDir::new("ic-inject"); + let path = scratch.join("settings.conf"); s.save(&path).expect("save"); let written = std::fs::read_to_string(&path).expect("read back"); @@ -3406,21 +3535,14 @@ mod tests { assert!(back.dashboard_password_hash.is_empty()); assert!(!back.web_bind_all); assert_eq!(back.web_port, s.web_port); - let _ = std::fs::remove_file(&path); } /// Config files get hand-edited and half-written. Loading one must not /// panic, and must not let a broken line take a good setting with it. #[test] fn a_damaged_config_file_still_loads() { - let path = std::env::temp_dir().join(format!( - "ic-edge-cfg-{}-{}.conf", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); + let scratch = crate::test_support::TestDir::new("ic-edge-cfg"); + let path = scratch.join("settings.conf"); std::fs::write( &path, "\n\ @@ -3433,7 +3555,8 @@ mod tests { hotkey.toggle=Ctrl+Alt+D\n\ hotkey.nonsense=Ctrl+Alt+E\n\ midi.toggle=note:1:200\n\ - target_delay_ms=99999999999999999999\n\ + armed_delay_ms=99999999999999999999\n\ + target_delay_ms=99999999\n\ a truncated final line", ) .expect("write"); @@ -3445,12 +3568,15 @@ mod tests { s.midi.toggle.is_empty(), "an out-of-range signature is dropped, not stored" ); - assert!( - s.target_delay_ms <= 600_000, - "an absurd delay is clamped, got {}", - s.target_delay_ms + assert_eq!( + s.target_delay_ms, MAX_DELAY_MS, + "a delay past the top is clamped to it" + ); + assert_eq!( + s.armed_delay_ms, + Settings::defaults().armed_delay_ms, + "a number too big to read is ignored" ); - let _ = std::fs::remove_file(&path); } /// A stream key, webhook token or egress URL is user-supplied text that @@ -3583,15 +3709,8 @@ mod tests { #[test] fn hotkeys_round_trip_through_save_and_load() { - let path = std::env::temp_dir().join(format!( - "ic-test-hotkeys-roundtrip-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-test-hotkeys-roundtrip"); + let path = scratch.join("settings.ini"); let mut s = Settings::defaults(); // A lowercase, out-of-order input must be stored canonicalized. @@ -3625,20 +3744,15 @@ mod tests { assert!(loaded.hotkeys.activate.is_empty(), "unbound stays unbound"); // A defaults() config must not write any hotkey line. - let bare = - std::env::temp_dir().join(format!("ic-test-hotkeys-bare-{}.ini", std::process::id())); - let _ = std::fs::remove_file(&bare); + let bare = scratch.join("bare.ini"); let mut d = Settings::defaults(); - d.destinations = loaded.destinations.clone(); + d.destinations = loaded.destinations; d.save(&bare).expect("save bare"); let text = std::fs::read_to_string(&bare).expect("read bare"); assert!( !text.contains("hotkey."), "unbound hotkeys must stay out of the config file" ); - - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(&bare); } /// Note 36 on channel 1 is pad 1 on half the controllers ever made, so @@ -3690,20 +3804,11 @@ Name@x" } #[test] - fn midi_signatures_validate_and_route() { - assert_eq!(canonicalize_midi("NOTE:1:36").as_deref(), Some("note:1:36")); + fn midi_bindings_keep_only_valid_signatures_and_route_them() { assert_eq!( canonicalize_midi(" cc : 10 : 20 ").as_deref(), Some("cc:10:20") ); - assert!(canonicalize_midi("note:0:36").is_none(), "channel below 1"); - assert!( - canonicalize_midi("note:17:36").is_none(), - "channel above 16" - ); - assert!(canonicalize_midi("cc:1:128").is_none(), "data above 127"); - assert!(canonicalize_midi("pitch:1:1").is_none(), "unknown kind"); - assert!(canonicalize_midi("note:1").is_none(), "missing data"); assert!(canonicalize_midi("note:1:1:1").is_none(), "trailing junk"); let mut m = MidiBindings::default(); @@ -3717,19 +3822,13 @@ Name@x" assert_eq!(m.action_for(""), None); } - /// Unique scratch path per call, so parallel tests never share a file - /// and none of them can reach the user's real settings. - fn scratch_config_path(tag: &str) -> PathBuf { - use std::sync::atomic::{AtomicU32, Ordering}; - static NEXT: AtomicU32 = AtomicU32::new(0); - let dir = std::env::temp_dir().join(format!( - "ic-cfg-{tag}-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir.join("instantclone.cfg") + /// A unique scratch config path, so parallel tests never share a file and + /// none can reach the user's real settings. Keep the folder guard alive: + /// dropping it removes the folder. + fn scratch_config(tag: &str) -> (crate::test_support::TestDir, PathBuf) { + let dir = crate::test_support::TestDir::new(&format!("cfg-{tag}")); + let path = dir.join("instantclone.cfg"); + (dir, path) } fn test_destination(id: &str, platform: &str, key: &str) -> Destination { @@ -3824,6 +3923,11 @@ Name@x" auto_arm_delay_ms: 25_000, update_check_enabled: false, open_dashboard_on_launch: false, + update_tray_notify: false, + tray_color: 3, + update_notified: "0.1.17".into(), + last_seen_version: "0.1.16".into(), + last_seen_commit: "5493bf1".into(), overlays_seeded: true, // A layout value holding its own `=`: the line parser must split // on the first `=` only. @@ -3883,6 +3987,19 @@ Name@x" server: "https://ntfy.example".into(), topic: "texaz-alerts".into(), }, + alerts_look: crate::integrations::alerts::Look { + theme: "signal".into(), + position: "top".into(), + align: "right".into(), + scale: 140, + font: "Orbitron".into(), + accent: "#ff7eb6".into(), + opacity: Some(80), + radius: Some(0), + sound: "media:ding.mp3".into(), + css: ".card .text { letter-spacing: 1px }".into(), + ..Default::default() + }, integrations_migrated: true, twitch_client_id: "abcdefghij0123456789klmnopqrst".into(), twitch_shared_chat: true, @@ -3916,7 +4033,7 @@ Name@x" /// alerts" integration, once, and a config saved afterwards keeps them. #[test] fn old_discord_webhook_is_migrated_once() { - let path = scratch_config_path("webhook-migration"); + let (_scratch, path) = scratch_config("webhook-migration"); std::fs::write( &path, "discord_webhook_url=https://discord.com/api/webhooks/9/tok @@ -3939,13 +4056,12 @@ Name@x" let again = Settings::load(&path).unwrap(); assert_eq!(again.integrations.len(), 1, "migrated exactly once"); assert_eq!(again.discord_channels, loaded.discord_channels); - let _ = std::fs::remove_file(&path); } /// A damaged integration line is skipped; everything else still loads. #[test] fn a_broken_integration_line_does_not_cost_the_rest() { - let path = scratch_config_path("broken-integration"); + let (_scratch, path) = scratch_config("broken-integration"); let good = crate::integrations::presets::build("delay_command", "good".into(), "") .unwrap() .to_json() @@ -3965,7 +4081,6 @@ integrations_migrated=true assert_eq!(loaded.web_port, 7811); assert_eq!(loaded.integrations.len(), 1); assert_eq!(loaded.integrations[0].id, "good"); - let _ = std::fs::remove_file(&path); } /// Every setting survives save then load unchanged. The one test that @@ -3973,16 +4088,16 @@ integrations_migrated=true /// read, which otherwise shows up as a setting that resets on restart. #[test] fn every_setting_round_trips_through_save_and_load() { - let path = scratch_config_path("full-roundtrip"); + let (_scratch, path) = scratch_config("full-roundtrip"); let original = every_field_non_default(); original.save(&path).unwrap(); let loaded = Settings::load(&path); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(loaded.unwrap(), original); } - /// Guards the guard: if `every_field_non_default` drifted back to a - /// default somewhere, the round trip would stop covering that field. + /// Guards the guard, spot-checking the fields most likely to drift: if + /// `every_field_non_default` set one back to its default, the round trip + /// would stop covering it. #[test] fn the_round_trip_fixture_really_is_non_default() { let s = every_field_non_default(); @@ -4013,7 +4128,7 @@ integrations_migrated=true /// at its default. #[test] fn a_0_1_14_config_loads_intact() { - let path = scratch_config_path("v0114"); + let (_scratch, path) = scratch_config("v0114"); std::fs::write( &path, "# InstantClone - written by the app. Hand edits OK.\n\ @@ -4048,7 +4163,6 @@ integrations_migrated=true ) .unwrap(); let loaded = Settings::load(&path); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); let mut expected = Settings::defaults(); expected.configured = true; @@ -4070,7 +4184,7 @@ integrations_migrated=true /// do not know are skipped without disturbing the ones we do. #[test] fn unknown_keys_from_a_newer_build_do_not_disturb_known_ones() { - let path = scratch_config_path("newer"); + let (_scratch, path) = scratch_config("newer"); let original = every_field_non_default(); original.save(&path).unwrap(); let mut text = std::fs::read_to_string(&path).unwrap(); @@ -4081,7 +4195,6 @@ integrations_migrated=true let loaded = Settings::load(&path).unwrap(); loaded.save(&path).unwrap(); let resaved = std::fs::read_to_string(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(loaded, original); // Pinned behavior: unknown lines are dropped on the next save, so a // rollback followed by any settings change loses the newer build's @@ -4103,7 +4216,7 @@ integrations_migrated=true /// every stream key. The damaged character may be lost; nothing else is. #[test] fn a_config_with_non_utf8_bytes_still_loads_its_other_fields() { - let path = scratch_config_path("ansi"); + let (_scratch, path) = scratch_config("ansi"); let mut bytes = b"configured=true\nweb_port=9123\ndestination.0.id=d1\n\ destination.0.enabled=true\ndestination.0.platform=twitch\n\ destination.0.stream_key=live_abc\ndestination.0.name=Espa" @@ -4113,7 +4226,6 @@ integrations_migrated=true std::fs::write(&path, &bytes).unwrap(); let s = Settings::load(&path); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); let s = s.expect("a non-UTF-8 byte must not fail the whole load"); assert!(s.configured); assert_eq!(s.web_port, 9123); @@ -4128,7 +4240,7 @@ integrations_migrated=true /// stream the user removed would go live again after a restart. #[test] fn deleting_the_last_destination_survives_a_restart() { - let path = scratch_config_path("last-dest"); + let (_scratch, path) = scratch_config("last-dest"); let mut s = Settings::defaults(); s.destinations .push(test_destination("d1", "youtube", "secret-key")); @@ -4140,7 +4252,6 @@ integrations_migrated=true loaded.save(&path).unwrap(); let reloaded = Settings::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert!( reloaded.destinations.is_empty(), "deleted destination came back: {:?}", @@ -4148,40 +4259,41 @@ integrations_migrated=true ); } - /// A config from before destinations existed (only the legacy - /// single-destination lines) still becomes one enabled destination. - #[test] - fn a_pre_destinations_config_still_migrates() { - let path = scratch_config_path("legacy"); - std::fs::write(&path, "configured=true\nplatform=youtube\nstream_key=abc\n").unwrap(); - let s = Settings::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - assert_eq!(s.destinations.len(), 1); - let d = &s.destinations[0]; - assert_eq!( - (d.platform.as_str(), d.stream_key.as_str()), - ("youtube", "abc") - ); - assert!(d.enabled); - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://a.rtmp.youtube.com/live2/abc") - ); - } - - /// The custom-URL-only variant: a key embedded in the URL and no - /// `stream_key=` line must migrate too, and keep the URL intact. + /// The single-destination settings of the first versions become a destination. #[test] - fn a_pre_destinations_custom_url_config_still_migrates() { - let path = scratch_config_path("legacy-custom"); - let url = "rtmp://ingest.example.com/app/embedded-key"; - std::fs::write(&path, format!("platform=custom\ncustom_egress_url={url}\n")).unwrap(); - let s = Settings::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - assert_eq!(s.destinations.len(), 1); - assert_eq!(s.destinations[0].platform, "custom"); - assert_eq!(s.destinations[0].custom_egress_url, url); - assert_eq!(s.destinations[0].egress_url().as_deref(), Some(url)); + fn a_config_from_before_destinations_still_migrates() { + // a pre destinations config still migrates + // A config from before destinations existed (only the legacy + // single-destination lines) still becomes one enabled destination. + { + let (_scratch, path) = scratch_config("legacy"); + std::fs::write(&path, "configured=true\nplatform=youtube\nstream_key=abc\n").unwrap(); + let s = Settings::load(&path).unwrap(); + assert_eq!(s.destinations.len(), 1); + let d = &s.destinations[0]; + assert_eq!( + (d.platform.as_str(), d.stream_key.as_str()), + ("youtube", "abc") + ); + assert!(d.enabled); + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://a.rtmp.youtube.com/live2/abc") + ); + } + // a pre destinations custom url config still migrates + // The custom-URL-only variant: a key embedded in the URL and no + // `stream_key=` line must migrate too, and keep the URL intact. + { + let (_scratch, path) = scratch_config("legacy-custom"); + let url = "rtmp://ingest.example.com/app/embedded-key"; + std::fs::write(&path, format!("platform=custom\ncustom_egress_url={url}\n")).unwrap(); + let s = Settings::load(&path).unwrap(); + assert_eq!(s.destinations.len(), 1); + assert_eq!(s.destinations[0].platform, "custom"); + assert_eq!(s.destinations[0].custom_egress_url, url); + assert_eq!(s.destinations[0].egress_url().as_deref(), Some(url)); + } } /// Other tests trust `is_valid_json` to catch broken hand-written JSON, diff --git a/src/controller.rs b/src/controller.rs index ac93c83..58b7549 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -10,6 +10,8 @@ use crate::buffer::{DiskRing, TagMeta}; use crate::compat::StreamParams; +use crate::config::MAX_DELAY_MS; +use crate::egress_failure::EgressFailure; use crate::h264::{AudioCodec, VideoCodec}; use crate::integrations::host::fmt_duration; use crate::integrations::{Event, EventKind}; @@ -28,19 +30,19 @@ use tokio::sync::{Mutex, Notify}; /// audio / video tag (~150-300 per second per destination); switching /// to `Instant::now()` is a vDSO call (~10ns) and immune to clock /// adjustments. -fn process_now_ms() -> u64 { +pub(crate) fn process_now_ms() -> u64 { static ANCHOR: OnceLock = OnceLock::new(); let start = ANCHOR.get_or_init(Instant::now); Instant::now().saturating_duration_since(*start).as_millis() as u64 } /// Rolling bitrate, lock-free: bytes are summed over windows of at least -/// `RATE_WINDOW_MS` and the rate is the last full window. A window that -/// hasn't rolled for `RATE_STALE_MS` means nothing has arrived since (the -/// stream stopped or froze), so the rate reads 0 rather than holding the -/// last second's value forever. +/// `WINDOW_MS` and the rate is the last full window. A window that hasn't +/// rolled for `RATE_STALE_SLACK_MS` past its length means nothing has +/// arrived since (the stream stopped or froze), so the rate reads 0 rather +/// than holding the last window's value forever. #[derive(Default)] -pub struct RateMeter { +pub struct RateMeter { window_bytes: AtomicU64, /// 0 = no window yet. window_start_ms: AtomicU64, @@ -48,9 +50,16 @@ pub struct RateMeter { } const RATE_WINDOW_MS: u64 = 1_000; -const RATE_STALE_MS: u64 = 2_500; +const RATE_STALE_SLACK_MS: u64 = 1_500; +/// The window capacity estimates plan on: five 2 s keyframe intervals, so a +/// keyframe's burst averages out. A 1 s window reads well above a CBR +/// stream's real rate whenever it holds a keyframe, which would refuse +/// delays the buffer really holds. +const CAPACITY_WINDOW_MS: u64 = 10_000; + +impl RateMeter { + const STALE_MS: u64 = WINDOW_MS + RATE_STALE_SLACK_MS; -impl RateMeter { pub fn note(&self, bytes: usize) { self.note_at(bytes, process_now_ms()); } @@ -60,15 +69,18 @@ impl RateMeter { let start = self.window_start_ms.load(Ordering::Relaxed); // First bytes, or the first after a gap: they mark the start of a // fresh window (so they count toward the time before it), instead - // of the silence being averaged into the rate. - if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS { + // of the silence being averaged into the rate. The rate before the + // gap is forgotten: until this window fills, there is none, rather + // than the last stream's (which the capacity estimate would plan by). + if start == 0 || now_ms.saturating_sub(start) > Self::STALE_MS { self.window_bytes.store(0, Ordering::Relaxed); self.window_start_ms.store(now_ms, Ordering::Relaxed); + self.kbps.store(0, Ordering::Relaxed); return; } let total = self.window_bytes.fetch_add(bytes as u64, Ordering::Relaxed) + bytes as u64; let elapsed = now_ms.saturating_sub(start); - if elapsed >= RATE_WINDOW_MS { + if elapsed >= WINDOW_MS { // bytes * 8 / ms = kbps self.kbps .store(((total * 8) / elapsed) as u32, Ordering::Relaxed); @@ -81,9 +93,17 @@ impl RateMeter { self.kbps_at(process_now_ms()) } + /// Start over, as if nothing had been measured: for a new stream, whose + /// first window must not hold the tail of the one before it. + fn reset(&self) { + self.window_bytes.store(0, Ordering::Relaxed); + self.window_start_ms.store(0, Ordering::Relaxed); + self.kbps.store(0, Ordering::Relaxed); + } + fn kbps_at(&self, now_ms: u64) -> u32 { let start = self.window_start_ms.load(Ordering::Relaxed); - if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS { + if start == 0 || now_ms.saturating_sub(start) > Self::STALE_MS { return 0; } self.kbps.load(Ordering::Relaxed) @@ -155,7 +175,7 @@ impl ActivateError { ActivateError::NoIngest => NO_INGEST.to_string(), ActivateError::BufferShort { remaining_ms } => { let secs = ((*remaining_ms + 500) / 1000).max(1); - format!("buffer is still building - wait ~{}s", secs) + format!("buffer is still building, wait ~{}s", secs) } } } @@ -172,7 +192,7 @@ const LOG_LINES_MAX: usize = 1_500; /// a crash-protection hold before a missing picture counts as a freeze. const FIRST_VIDEO_GRACE: Duration = Duration::from_secs(10); -pub const NO_INGEST: &str = "OBS isn't sending anything yet - start streaming first"; +pub const NO_INGEST: &str = "OBS isn't sending anything yet. Start streaming first"; /// One hotkey / MIDI press, as the dashboard renders it: which action ran, /// where it came from, whether it was refused, and a sequence number so a @@ -185,11 +205,74 @@ pub struct FiredAction { pub problem: Option, } +/// The armed delay is longer than the buffer holds at this stream's +/// bitrate, so it runs at what the buffer holds. Arming refuses such a +/// delay once a bitrate is measured; this is the case it couldn't refuse: +/// a delay armed before there was one (auto-arm at connect, the one saved +/// from last time), or a stream that got heavier after arming. +#[derive(Debug, Clone, PartialEq)] +pub struct CapacityShort { + /// The delay that was armed. + pub armed_ms: u32, + /// About how long a delay the buffer holds: what actually runs. + pub holds_ms: u32, + /// "Buffer too small for ...", ready to show the user. + pub message: String, +} + /// Hidden slack beyond what the user sees as the target. Equal to the /// IDR-search tolerance, so a "5s armed" cut can always land on a real /// IDR even if the nearest one happens to be slightly past the boundary. const BUFFER_SLACK_MS: u32 = 2_000; +/// Bitrate a capacity estimate plans for before a stream has been measured: +/// 10 Mbps, so a guess made before going live errs on the short side. +const CAPACITY_REFERENCE_KBPS: u32 = 10_000; +/// Below this the meter is still warming up after a connect (or nothing is +/// arriving), so the estimate plans for the reference instead. +const CAPACITY_MIN_MEASURED_KBPS: f64 = 500.0; +/// How fast the planning bitrate lets go of a peak, per second. A real drop +/// shows within about half a minute; a short dip doesn't unlock a delay the +/// buffer can't hold at the stream's normal bitrate. +const CAPACITY_HOLD_DECAY_PER_S: f64 = 0.94; + +/// The longest delay `usable_bytes` of ring holds at `kbps`: the time they +/// take to fill, less the slack trimming keeps on top of the delay. +fn delay_capacity_ms(usable_bytes: u64, kbps: u32) -> u32 { + // bytes * 8 = bits, and kbps is bits per millisecond. + let fill_ms = usable_bytes * 8 / u64::from(kbps.max(1)); + fill_ms + .saturating_sub(u64::from(BUFFER_SLACK_MS)) + .min(u64::from(u32::MAX)) as u32 +} + +/// The buffer size, in MB, that holds a delay of `ms` at `kbps`: the +/// inverse of `delay_capacity_ms`, with the ring's unusable sixteenth. +/// Rounded up at every step, so the size it names always holds the delay. +fn buffer_mb_for(ms: u32, kbps: u32) -> u64 { + let bytes = (u64::from(ms.saturating_add(BUFFER_SLACK_MS)) * u64::from(kbps)).div_ceil(8); + (bytes * 16).div_ceil(15).div_ceil(1024 * 1024) +} + +/// What every surface says when a delay of `ms` doesn't fit: the buffer +/// holds `capacity` ms at `kbps`, and the size that would hold it. +fn buffer_too_small(ms: u32, capacity: u32, kbps: u32) -> String { + format!( + "Buffer too small for {}: it holds about {} at {:.1} Mbps. \ + Raise the buffer size to at least {} MB in System, then restart.", + fmt_duration(u64::from(ms)), + fmt_duration(u64::from(capacity)), + f64::from(kbps) / 1000.0, + buffer_mb_for(ms, kbps) + ) +} + +/// A held peak bitrate `elapsed_s` seconds on, given what the meter reads +/// now: it rises at once and lets go slowly. +fn hold_peak(held: f64, elapsed_s: f64, measured: f64) -> f64 { + (held * CAPACITY_HOLD_DECAY_PER_S.powf(elapsed_s)).max(measured) +} + /// How many IDR-to-IDR gaps to average before freezing the keyframe-interval /// measurement. Five gaps is ~10 s of a normally-configured stream: long /// enough that one odd GOP at stream start can't skew the mean, short enough @@ -308,6 +391,13 @@ pub struct DestinationState { /// still matches - both reads happen under the override mutex so the /// check can't race the disconnect's clear+bump. pub session_epoch: AtomicU64, + /// Why the last attempt to stream here failed, and when the next one + /// starts, for the dashboard card. Cleared when a pump starts and once a + /// session proves the endpoint works. + pub failure: crate::sync::Mutex>, + /// "Retry now" from the dashboard: cuts the current backoff wait short. + /// Checked by the same 200 ms poll as `shutdown_requested`. + pub retry_now: AtomicBool, } /// The Enhanced Broadcasting config last handed to OBS, and the Twitch @@ -372,6 +462,30 @@ impl DestinationState { vertical_primary_track: AtomicU8::new(0xFF), vod_fetch_pending: AtomicBool::new(false), session_epoch: AtomicU64::new(0), + failure: crate::sync::Mutex::new(None), + retry_now: AtomicBool::new(false), + } + } + + /// Ask a destination waiting out its backoff to try again now. False + /// when it isn't waiting (connecting, streaming, or nothing to retry). + pub fn request_retry(&self) -> bool { + let waiting = self + .failure + .lock() + .as_ref() + .is_some_and(|f| f.retry_at.is_some()); + if waiting { + self.retry_now.store(true, Ordering::Relaxed); + } + waiting + } + + /// The backoff wait is over: the card says "connecting" again, keeping + /// the reason until the attempt succeeds or fails anew. + fn mark_retrying(&self) { + if let Some(f) = self.failure.lock().as_mut() { + f.retry_at = None; } } @@ -540,6 +654,12 @@ pub struct Controller { /// streamer is already in the active state and a subsequent cut /// shouldn't snap back to "active" via auto-activate. auto_activate_pending: AtomicBool, + /// The pending arm was promised to go on by itself once the buffer + /// holds it (`POST /delay`, "Delay on/off" pressed while it fills), so + /// it does even with "Activate when ready" off. Only read while + /// `auto_activate_pending` is set, and reset by every fresh arm, so it + /// never outlives the arm it was asked for. + activate_once_ready: AtomicBool, /// Input-timeline timestamp (u64, expand_ts domain) of a scheduled /// "cut after this airs" mark. 0 = none pending. Set by /// `schedule_safe_cut` to the live-edge ts at the moment the @@ -584,6 +704,15 @@ pub struct Controller { /// "The buffer holds only N s" was logged for the armed delay; cleared /// on the next arm so each new value is checked once. capacity_capped_logged: AtomicBool, + /// The armed delay `check_armed_capacity` has already checked against + /// this stream's measured bitrate (0: none yet). Reset per publisher. + capacity_checked_ms: AtomicU32, + /// The armed delay is longer than the buffer holds, so it runs short. + /// See `capacity_short`. + capacity_short: crate::sync::Mutex>, + /// Bumped each time a new `capacity_short` is reported, so the runtime + /// raises one tray notice per report. + capacity_short_seq: AtomicU64, /// Process ms of the last "rejected publisher" log line (see /// `log_rejected_publish`). last_rejection_log_ms: AtomicU64, @@ -632,6 +761,9 @@ pub struct Controller { // Ingest-side stats ingest_disconnects: AtomicU32, rate_in: RateMeter, // inbound (from OBS) + /// The inbound rate averaged over `CAPACITY_WINDOW_MS`: what capacity + /// estimates plan on. + rate_in_mean: RateMeter, // Video tags only. Viewers decode the video track, so the Twitch // mobile-decoder check compares this, not the total with audio. rate_video_in: RateMeter, @@ -779,6 +911,9 @@ pub struct Controller { // so a dashboard that goes away mid-capture cannot leave them off. #[cfg_attr(not(windows), allow(dead_code))] hotkey_capture_until_ms: AtomicU64, + /// Peak-held inbound kbps and when it was last updated (process ms), for + /// `capacity_kbps`. + capacity_kbps_hold: crate::sync::Mutex<(f64, u64)>, // Actions whose hotkey the OS refused to register, because another app // already owns the combo. Written by the tray on every (re)bind, read by @@ -786,26 +921,24 @@ pub struct Controller { // line alone is somewhere nobody setting a hotkey is looking. hotkey_conflicts: crate::sync::Mutex>, - // Raised when a hotkey or MIDI action moves the delay state. Those two - // paths run outside the web layer, which is where every other state - // change gets written to the config file, so the runtime watches this - // and persists on their behalf. - #[cfg_attr(not(windows), allow(dead_code))] + // Raised when a hotkey, MIDI, tray or integration action moves the delay + // state. Those paths run outside the web layer, which is where every + // other state change gets written to the config file, so the runtime + // watches this and persists on their behalf. state_dirty: Notify, } impl Controller { pub fn new(ring: Arc, initial_armed_delay_ms: u32) -> Self { - // Match arm_delay's clamp. A persisted (or hand-edited) config - // value larger than the cap would otherwise wedge the egress - // pump in `preparing` forever because the buffer can never grow - // to a delay we don't actually allow. - let initial_armed_delay_ms = initial_armed_delay_ms.min(600_000); + // Match arm_delay's clamp, so a hand-edited config can't arm past + // the ceiling at startup either. + let initial_armed_delay_ms = initial_armed_delay_ms.min(MAX_DELAY_MS); Self { ring, armed_delay_ms: AtomicU32::new(initial_armed_delay_ms), target_delay_ms: AtomicU32::new(0), auto_activate_pending: AtomicBool::new(false), + activate_once_ready: AtomicBool::new(false), safe_cut_input_ts: AtomicU64::new(0), ingest_alive: AtomicBool::new(false), buffer_building: AtomicBool::new(false), @@ -817,6 +950,9 @@ impl Controller { hold_tail_end_seq: AtomicU64::new(NO_SEQ), tail_plays_out: AtomicBool::new(false), capacity_capped_logged: AtomicBool::new(false), + capacity_checked_ms: AtomicU32::new(0), + capacity_short: crate::sync::Mutex::new(None), + capacity_short_seq: AtomicU64::new(0), last_rejection_log_ms: AtomicU64::new(0), resumed_after_seq: AtomicU64::new(NO_SEQ), replay_floor_seq: AtomicU64::new(NO_SEQ), @@ -828,6 +964,7 @@ impl Controller { destinations: crate::sync::RwLock::new(HashMap::new()), ingest_disconnects: AtomicU32::new(0), rate_in: RateMeter::default(), + rate_in_mean: RateMeter::default(), rate_video_in: RateMeter::default(), publisher_local: AtomicBool::new(false), integrations: std::sync::OnceLock::new(), @@ -872,6 +1009,7 @@ impl Controller { last_action: crate::sync::Mutex::new(None), last_action_seq: AtomicU64::new(0), hotkey_capture_until_ms: AtomicU64::new(0), + capacity_kbps_hold: crate::sync::Mutex::new((0.0, 0)), hotkey_conflicts: crate::sync::Mutex::new(Vec::new()), state_dirty: Notify::new(), } @@ -949,6 +1087,12 @@ impl Controller { /// single (often misclassified) tag; this version auto-clears as /// soon as multi-track stops, and is always off when ingest is /// not alive. + /// OBS is publishing Enhanced Broadcasting, but no session was handed + /// to it in this run: it is continuing one from before a restart. + pub fn publishing_unbrokered_eb(&self) -> bool { + self.ingest_alive() && self.multitrack_video() && self.eb_session.lock().is_none() + } + pub fn multitrack_video(&self) -> bool { if !self.ingest_alive.load(Ordering::Relaxed) { return false; @@ -1059,8 +1203,9 @@ impl Controller { /// sticky bool used to live on `multitrack_video` itself; we keep it /// here just to throttle the log to once. pub fn note_multitrack_video(&self) { + // 0 means "never": a tag in the process's first millisecond isn't. self.last_multitrack_video_ms - .store(process_now_ms(), Ordering::Relaxed); + .store(process_now_ms().max(1), Ordering::Relaxed); if !self.multitrack_video.swap(true, Ordering::Relaxed) { // Twitch destinations pass the multi-track tag through // bit-faithfully (Enhanced Broadcasting → transcoded @@ -1219,6 +1364,20 @@ impl Controller { self.destinations.write().remove(id); } + /// Why a destination's last attempt failed, if it did. Read-only: unlike + /// `destination_state` it never registers a destination that has no pump. + pub fn destination_failure(&self, id: &str) -> Option { + self.destinations.read().get(id)?.failure.lock().clone() + } + + /// "Retry now" for one destination. False when it isn't waiting to retry. + pub fn retry_destination_now(&self, id: &str) -> bool { + self.destinations + .read() + .get(id) + .is_some_and(|d| d.request_retry()) + } + /// Snapshot of every (id → state) pair. Used by graceful-shutdown /// paths that need to flip flags on every pump in one pass. pub fn all_destination_states(&self) -> Vec<(String, Arc)> { @@ -1282,16 +1441,24 @@ impl Controller { /// jump back to it (there is no gradual rewind - see /// `compute_delay_cut`'s build-buffer-first branch). pub fn arm_delay(&self, ms: u32) { - let ms = ms.min(600_000); + let ms = ms.min(MAX_DELAY_MS); let previous_target = self.target_delay_ms.load(Ordering::Relaxed); - self.capacity_capped_logged.store(false, Ordering::Relaxed); - self.armed_delay_ms.store(ms, Ordering::Relaxed); + // A delay armed anew (another value, or after a disarm) is judged + // afresh, against the bitrate as it is now. Arming the value that is + // already armed (a toggle after a cut) keeps its verdict: clearing it + // would hide a short delay for seconds and repeat its notices. + if self.armed_delay_ms.swap(ms, Ordering::Relaxed) != ms { + self.capacity_capped_logged.store(false, Ordering::Relaxed); + self.capacity_checked_ms.store(0, Ordering::Relaxed); + *self.capacity_short.lock() = None; + } if ms == 0 { // Disarm wipes target as well. self.target_delay_ms.store(0, Ordering::Relaxed); // Disarm clears any pending auto-activate too - there's // nothing to auto-activate into anymore. self.auto_activate_pending.store(false, Ordering::Relaxed); + self.activate_once_ready.store(false, Ordering::Relaxed); // A pending "cut after this airs" mark dies with the delay // it was going to cut. self.safe_cut_input_ts.store(0, Ordering::Relaxed); @@ -1305,11 +1472,29 @@ impl Controller { } else { // Fresh arm: target was 0 (we were disarmed, in cut-hold, // or in passthrough). Mark the auto-activate slot eligible - // - one shot when the buffer hits ready. + // - one shot when the buffer hits ready. A plain arm makes no + // promise to go on by itself; the callers that do say so after. self.auto_activate_pending.store(true, Ordering::Relaxed); + self.activate_once_ready.store(false, Ordering::Relaxed); } } + /// Switch the armed delay on as soon as the buffer holds it, this once, + /// whatever "Activate when ready" is set to. For the callers that tell + /// the streamer it will: without it they would arm, say "starts as soon + /// as it is ready", and leave the stream live for good. + pub fn activate_once_ready(&self) { + self.activate_once_ready.store(true, Ordering::Relaxed); + } + + /// Whether the supervisor should switch the delay on when the buffer is + /// ready: an arm is waiting, and either the "Activate when ready" + /// setting (`setting_on`) or the arm's own caller asked for it. + pub fn auto_activates(&self, setting_on: bool) -> bool { + self.auto_activate_pending() + && (setting_on || self.activate_once_ready.load(Ordering::Relaxed)) + } + /// Switch the armed delay on. Caller-side check: only acts if the /// buffer holds at least the armed amount. Returns Err otherwise. /// The error carries a static prefix the UI maps to a human message; @@ -1382,7 +1567,7 @@ impl Controller { /// on some future activate. pub fn schedule_safe_cut(&self) -> Result { if self.target_delay_ms.load(Ordering::Relaxed) == 0 { - return Err("no delay is active - nothing to schedule"); + return Err("no delay is active, so there is nothing to schedule"); } let Some(latest) = self.ring.latest_ts() else { return Err("no stream data yet"); @@ -1408,6 +1593,22 @@ impl Controller { self.ingest_alive.store(true, Ordering::Relaxed); } + /// Make the inbound meters read `kbps`, as a stream at that bitrate + /// would after its first ten seconds, without waiting them. + #[cfg(test)] + pub fn note_inbound_kbps_for_test(&self, kbps: u32) { + self.ingest_alive.store(true, Ordering::Relaxed); + let now = process_now_ms().max(1); + self.rate_in.note_at(0, now); + self.rate_in + .note_at(kbps as usize * 1000 / 8, now + RATE_WINDOW_MS); + self.rate_in_mean.note_at(0, now); + self.rate_in_mean.note_at( + kbps as usize * CAPACITY_WINDOW_MS as usize / 8, + now + CAPACITY_WINDOW_MS, + ); + } + /// The most recent hotkey / MIDI action, if there has been one this run. pub fn last_action(&self) -> Option { self.last_action.lock().clone() @@ -1508,7 +1709,7 @@ impl Controller { }; match self.slowest_live_consumer_ts() { // Clamp to u32: a u64 delta can't realistically exceed - // 600_000 ms (our hard armed-delay ceiling) but we cap to + // MAX_DELAY_MS (our armed-delay ceiling) but we cap to // be safe - the UI consumes a u32 number anyway. Some(ts) => latest.saturating_sub(ts).min(u32::MAX as u64) as u32, None => 0, @@ -1598,6 +1799,148 @@ impl Controller { pub fn bitrate_kbps(&self) -> u32 { self.rate_in.kbps() } + + /// The highest recent 10 s mean inbound rate, let go of slowly: what + /// capacity estimates plan for. None until a stream has been measured + /// for ten seconds (or once it has faded after one). Every tag OBS + /// sends lands in the ring, so the inbound rate is exactly how fast the + /// buffer fills. + pub fn measured_capacity_kbps(&self) -> Option { + // No stream, no measurement: the last one's fading peak must not + // decide what fits the next (a chat `!setdelay` right after going + // offline). A crash-protection hold is the same stream, waiting. + if !self.ingest_alive() && !self.hold_active() { + return None; + } + let now = process_now_ms(); + let measured = f64::from(self.rate_in_mean.kbps()); + let mut hold = self.capacity_kbps_hold.lock(); + let elapsed_s = now.saturating_sub(hold.1) as f64 / 1000.0; + // No reading (a crash-protection hold, and the first window after + // it) is silence, not a lighter stream: the peak stands as it was. + // Decayed through a 30 s hold, it let a delay that doesn't fit pass. + let held = if measured == 0.0 { + hold.0 + } else { + hold_peak(hold.0, elapsed_s, measured) + }; + *hold = (held, now); + (held >= CAPACITY_MIN_MEASURED_KBPS).then_some(held as u32) + } + + /// The bitrate capacity estimates plan for: the measured one, or the + /// 10 Mbps reference before there is one. + pub fn capacity_kbps(&self) -> u32 { + self.measured_capacity_kbps() + .unwrap_or(CAPACITY_REFERENCE_KBPS) + } + + /// The longest delay the buffer holds at `capacity_kbps`. The one + /// estimate every surface uses: the dashboard and the dock read it from + /// the state, and every way of arming checks it (`capacity_problem`). + pub fn delay_capacity_ms(&self) -> u32 { + delay_capacity_ms(self.ring.usable_bytes(), self.capacity_kbps()) + } + + /// Why the buffer can't hold a delay of `ms`, ready to show the user, or + /// None when it can. Only a measured bitrate refuses: before there is + /// one the estimate is a guess, and a delay that turns out longer than + /// the buffer still runs, at what the buffer holds. + pub fn capacity_problem(&self, ms: u32) -> Option { + let (capacity, kbps) = self.measured_fit(ms)?.err()?; + Some(buffer_too_small(ms, capacity, kbps)) + } + + /// Whether a delay of `ms` fits at the measured bitrate: None before + /// there is a measurement, otherwise Ok, or Err with what the buffer + /// holds instead and the bitrate that decided it. + fn measured_fit(&self, ms: u32) -> Option> { + let kbps = self.measured_capacity_kbps()?; + let capacity = delay_capacity_ms(self.ring.usable_bytes(), kbps); + Some(if ms <= capacity { + Ok(()) + } else { + Err((capacity, kbps)) + }) + } + + /// Check the armed delay against this stream's bitrate, once per armed + /// value per stream, as soon as there is a measurement. Every way of + /// arming refuses a delay the buffer can't hold, but only once a bitrate + /// is measured, and the delay armed at connect never had one: auto-arm + /// fires the moment OBS connects, and the delay saved from last time is + /// armed before OBS connects at all. One `!setdelay 3600` becomes the + /// next stream's auto-arm, so this is the only place that ever says it + /// doesn't fit. Called by the egress supervisor a few seconds into a + /// stream, when the meter has settled. + pub fn check_armed_capacity(&self) { + let armed = self.armed_delay_ms(); + if armed == 0 || self.capacity_checked_ms.load(Ordering::Relaxed) == armed { + return; + } + let Some(fit) = self.measured_fit(armed) else { + return; + }; + self.capacity_checked_ms.store(armed, Ordering::Relaxed); + match fit { + // It fits by the measured bitrate. A short the full buffer + // itself reported stands: that one is what really happened. + Ok(()) => { + if !self.capacity_capped_logged.load(Ordering::Relaxed) { + *self.capacity_short.lock() = None; + } + } + Err((holds_ms, kbps)) => { + // The buffer filled before this check and already said so, + // with the exact figure: one notice is enough. + let told = self.capacity_short.lock().as_ref().map(|s| s.armed_ms); + if told == Some(armed) { + return; + } + let message = buffer_too_small(armed, holds_ms, kbps); + self.log(format!( + "delay: the {} armed doesn't fit. {message}", + fmt_duration(u64::from(armed)) + )); + self.report_capacity_short(CapacityShort { + armed_ms: armed, + holds_ms, + message, + }); + } + } + } + + /// Publish a short delay for the dashboard and the tray notice. + fn report_capacity_short(&self, short: CapacityShort) { + *self.capacity_short.lock() = Some(short); + self.capacity_short_seq.fetch_add(1, Ordering::Relaxed); + } + + /// The armed delay runs short of what was armed, if it does: for the + /// dashboard (`capacity_short` in /state). A report about a delay that + /// is no longer the armed one is over. + pub fn capacity_short(&self) -> Option { + let armed = self.armed_delay_ms(); + self.capacity_short + .lock() + .clone() + .filter(|short| armed > 0 && short.armed_ms == armed) + } + + /// The delay that runs for `armed_ms`: what the buffer holds when it + /// can't hold all of it (see `capacity_short`), else `armed_ms`. + pub fn delay_runs_ms(&self, armed_ms: u32) -> u32 { + self.capacity_short() + .filter(|short| short.armed_ms == armed_ms) + .map_or(armed_ms, |short| short.holds_ms.min(armed_ms)) + } + + /// Bumped on every new short-delay report; see `capacity_short`. + pub fn capacity_short_seq(&self) -> u64 { + self.capacity_short_seq.load(Ordering::Relaxed) + } + /// Inbound video bitrate alone (no audio tracks, no sequence headers). pub fn video_bitrate_kbps(&self) -> u32 { self.rate_video_in.kbps() @@ -1612,6 +1955,7 @@ impl Controller { /// Called from the ingest path on every audio/video tag. pub fn note_inbound_bytes(&self, n: usize) { self.rate_in.note(n); + self.rate_in_mean.note(n); } pub fn note_ingest_disconnect(&self) { @@ -1624,12 +1968,18 @@ impl Controller { /// other - invaluable for diagnosing "the bouncing happened around /// 30 seconds in". pub fn log(&self, line: impl Into) { + let line = line.into(); + // A Linux server's only window on the app is its journal (or the + // terminal it runs in), and the dashboard log lives in memory. The + // journal stamps its own time, so the line goes out bare. + #[cfg(not(windows))] + log_line!("{line}"); let mut q = self.logs.lock(); if q.len() >= LOG_LINES_MAX { q.pop_front(); } let ts_s = process_now_ms() as f64 / 1000.0; - q.push_back(format!("[+{:>8.3}s] {}", ts_s, line.into())); + q.push_back(format!("[+{:>8.3}s] {}", ts_s, line)); } pub fn clear_logs(&self) { @@ -1756,7 +2106,6 @@ impl Controller { // restarts near ts 0, so the mark would sit unreachable and // fire hours later (or never) - clear it with the session. self.safe_cut_input_ts.store(0, Ordering::Relaxed); - // A goodbye from an earlier connection must not mark this session's // end as a clean stop. self.unpublish_received.store(false, Ordering::Relaxed); @@ -1771,6 +2120,17 @@ impl Controller { let resuming = self.hold_active(); if resuming { self.forget_unclaimed_eb_session(); + } else { + // A new stream is measured on its own. The last one's held peak + // (a 20 Mbps stream before a 6 Mbps one) would refuse delays that + // fit for half a minute, and the armed delay gets checked against + // this one's bitrate once it is measured. A resume is the same + // stream: it keeps all of it, so its notice isn't given twice. + self.rate_in_mean.reset(); + *self.capacity_kbps_hold.lock() = (0.0, 0); + self.capacity_checked_ms.store(0, Ordering::Relaxed); + self.capacity_capped_logged.store(false, Ordering::Relaxed); + *self.capacity_short.lock() = None; } // Bump token so any prior egress reader knows it's stale. let token = self.publisher_token.fetch_add(1, Ordering::SeqCst) + 1; @@ -1886,11 +2246,32 @@ impl Controller { } self.capacity_capped_logged.store(true, Ordering::Relaxed); self.log(format!( - "delay: the buffer is full at {} s at this bitrate, short of the {} s armed, so the delay runs at {} s. Raise the buffer size in System for the full delay.", + "delay: the buffer is full at {} s at this bitrate, short of the {} s armed, so the \ + delay runs at {} s. Raise the buffer size in System for the full delay.", fill / 1000, armed / 1000, fill / 1000 )); + // The same news the dashboard and tray get from the check after + // connect. Raised as a new notice only when that check didn't + // already say so (the stream got heavier since); either way the + // dashboard gets what the full buffer actually holds. + // A full buffer spans `fill` ms: the rate that filled it is exact, + // where the planning one falls back to 10 Mbps on a quiet stream. + let filled_kbps = (self.ring.usable_bytes() * 8 / u64::from(fill.max(1))) + .clamp(1, u64::from(u32::MAX)) as u32; + let short = CapacityShort { + armed_ms: armed, + holds_ms: fill, + message: buffer_too_small(armed, fill, filled_kbps), + }; + let mut current = self.capacity_short.lock(); + if current.as_ref().is_some_and(|c| c.armed_ms == armed) { + *current = Some(short); + } else { + drop(current); + self.report_capacity_short(short); + } } /// Slowest consumer across all destinations. Used by trim to ensure @@ -2655,16 +3036,38 @@ impl Controller { problem } + /// Go back to live once what's airing now has aired (an integration's + /// "Back to live after this airs"). Unlike the hotkey, whose second + /// press cancels a waiting cut, this keeps one that is already + /// waiting: a scene rule that fires twice (lobby, then menu) still cuts. + pub fn cut_after_airs(&self, source: &str) -> Option { + if self.safe_cut_pending() { + self.log(format!("[{source}] cut after this airs - already waiting")); + return None; + } + self.run_named_action("cut_after", 0, source) + } + /// Set the delay to `ms` (an integration's "set the delay"): arms it /// when nothing is armed, and changes it in place when a delay is armed /// or on air. Unlike the `arm` hotkey action, which toggles, this never /// disarms, so `!setdelay 30` twice leaves a 30 s delay. - pub fn set_delay_to(&self, ms: u32, source: &str) { - let ms = ms.clamp(1000, 600_000); - self.arm_delay(ms); - self.log(format!("[{source}] delay set to {} s", ms / 1000)); - self.record_fired_action("arm", source, None); + /// + /// Returns why it couldn't, like `run_named_action`: a delay the buffer + /// can't hold is refused rather than armed short. + pub fn set_delay_to(&self, ms: u32, source: &str) -> Option { + let ms = ms.clamp(1000, MAX_DELAY_MS); + let problem = self.capacity_problem(ms); + match &problem { + Some(why) => self.log(format!("[{source}] delay not set - {why}")), + None => { + self.arm_delay(ms); + self.log(format!("[{source}] delay set to {} s", ms / 1000)); + } + } + self.record_fired_action("arm", source, problem.clone()); self.state_dirty.notify_one(); + problem } /// Back to no delay at all (an integration's "Disarm"): live, and @@ -2715,12 +3118,21 @@ impl Controller { let ms = if self.armed_delay_ms() > 0 { self.armed_delay_ms() } else { + // A new delay: the buffer has to hold it. One already armed was + // checked when it was armed. + if let Some(problem) = self.capacity_problem(default_ms) { + self.log(format!("[{source}] delay on ignored - {problem}")); + return Some(problem); + } default_ms }; self.arm_delay(ms); match self.activate_delay() { Ok(d) => { - self.log(format!("[{source}] delay on - {} s", d / 1000)); + self.log(format!( + "[{source}] delay on - {} s", + self.delay_runs_ms(d) / 1000 + )); None } Err(e) => { @@ -2733,10 +3145,13 @@ impl Controller { // case resolves on its own - with nothing publishing, "still // filling" would be a wait that never ends. Some(match e { - ActivateError::BufferShort { .. } => format!( - "Buffer still filling - the {} s delay starts as soon as it is ready.", - ms / 1000 - ), + ActivateError::BufferShort { .. } => { + self.activate_once_ready(); + format!( + "Buffer still filling. The {} s delay starts as soon as it is ready.", + ms / 1000 + ) + } other => other.message(), }) } @@ -2763,6 +3178,10 @@ impl Controller { self.log(format!("[{source}] arm ignored - {NO_INGEST}")); return Some(NO_INGEST.to_string()); } + if let Some(problem) = self.capacity_problem(default_ms) { + self.log(format!("[{source}] arm ignored - {problem}")); + return Some(problem); + } self.arm_delay(default_ms); self.log(format!("[{source}] armed {} s", default_ms / 1000)); None @@ -2771,7 +3190,8 @@ impl Controller { fn action_activate(&self, source: &str) -> Option { match self.activate_delay() { Ok(d) => { - self.log(format!("[{source}] activated - {} s delay", d / 1000)); + let runs = self.delay_runs_ms(d); + self.log(format!("[{source}] activated - {} s delay", runs / 1000)); None } Err(e) => { @@ -2832,13 +3252,21 @@ pub async fn run_egress( url: String, dest: Arc, ) -> io::Result<()> { + // A fresh pump (a new key, a changed URL, switched back on) starts + // without the reason the previous one failed. + *dest.failure.lock() = None; let parsed = match EgressUrl::parse(&url) { Ok(p) => p, Err(e) => { ctrl.log(format!( - "[{}] invalid URL ({}) - fix it in Settings", + "[{}] invalid URL ({}) - fix it with Edit in Destinations", label, e )); + *dest.failure.lock() = Some(EgressFailure::new( + crate::egress_failure::BAD_URL, + &e.to_string(), + None, + )); tokio::time::sleep(Duration::from_secs(3600)).await; return Ok(()); } @@ -2893,9 +3321,12 @@ pub async fn run_egress( continue; } waiting_for_delay = false; - eprintln!( + log_line!( "[egress {}] connecting to {}:{}/{}", - label, parsed.host, parsed.port, parsed.app + label, + parsed.host, + parsed.port, + parsed.app ); ctrl.log(format!( "[{}] connecting to {}:{}", @@ -2904,6 +3335,8 @@ pub async fn run_egress( // Bounded: an edge that accepts TCP and then stalls in the TLS or // RTMP handshake would otherwise hold this destination on // "connecting" forever, never retrying. + // Why this attempt failed, for the card: (advice, scrubbed reason). + let mut failed: Option<(&'static str, String)> = None; let connected = tokio::time::timeout(CONNECT_TIMEOUT, EgressClient::connect(&parsed)) .await .unwrap_or_else(|_| { @@ -2924,7 +3357,18 @@ pub async fn run_egress( ); } let sink = client.spawn_reader_drain(); - let pump_result = pump_dest(&ctrl, &dest, sink).await; + let pump = pump_dest(&ctrl, &dest, sink); + tokio::pin!(pump); + let pump_result = tokio::select! { + ended = &mut pump => ended, + // Up this long, the endpoint works: the reason the last + // try failed is history, and must not sit on a live + // destination until this session ends. + _ = tokio::time::sleep(STABLE_SESSION) => { + *dest.failure.lock() = None; + pump.await + } + }; dest.egress_alive.store(false, Ordering::Relaxed); // Not reading any more: its position must not hold back the // buffer's trim while it reconnects or waits. @@ -2943,6 +3387,7 @@ pub async fn run_egress( let lasted = connected_at.elapsed(); if (!dropped && lasted >= MIN_CLEAN_SESSION) || lasted >= STABLE_SESSION { backoff = Duration::from_secs(1); + *dest.failure.lock() = None; } if let Err(e) = pump_result { // Twitch/etc. sometimes echo the stream key in error @@ -2951,7 +3396,7 @@ pub async fn run_egress( // key shows up in /logs (screen-shareable) and in // the Discord webhook payload. let safe = scrub_secret(&e.to_string(), &parsed.stream_key); - eprintln!("[egress {}] pump error: {}", label, safe); + log_line!("[egress {}] pump error: {}", label, safe); ctrl.log(format!("[{}] disconnected ({})", label, safe)); ctrl.emit( Event::new(EventKind::DestinationDropped) @@ -2962,6 +3407,16 @@ pub async fn run_egress( if ctrl.destination_alive_summary().0 == 0 { ctrl.emit(Event::new(EventKind::AllDestinationsDown)); } + let quick = lasted < crate::egress_failure::QUICK_DROP; + // The lost session is Twitch's: only a Twitch destination + // (the one taking multi-track video) is told so. + let twitch = dest.pass_through_multitrack_video.load(Ordering::Relaxed); + let advice = if quick && twitch && ctrl.publishing_unbrokered_eb() { + crate::egress_failure::EB_SESSION_LOST + } else { + crate::egress_failure::advice_for(&e, quick) + }; + failed = Some((advice, safe)); } // Counted here (instead of at the loop tail) so a fresh // connect or a connect failure never bumps it: "Egress @@ -2973,9 +3428,11 @@ pub async fn run_egress( } Err(e) => { let safe = scrub_secret(&e.to_string(), &parsed.stream_key); - eprintln!( + log_line!( "[egress {}] connect failed: {} (next try in {:?})", - label, safe, backoff + label, + safe, + backoff ); ctrl.log(format!( "[{}] connect failed ({}), retrying in {}s", @@ -2983,11 +3440,18 @@ pub async fn run_egress( safe, backoff.as_secs() )); + failed = Some((crate::egress_failure::advice_for(&e, false), safe)); } } + if let Some((advice, detail)) = failed { + // Reset before the retry time is published, so a "Retry now" + // can only land on this wait, never skip the next one. + dest.retry_now.store(false, Ordering::Relaxed); + *dest.failure.lock() = Some(EgressFailure::new(advice, &detail, Some(backoff))); + } // Cancellable backoff sleep - wake every 200 ms to check the // shutdown flag so a disable doesn't have to wait the full - // 30 s backoff window before stopping. + // 30 s backoff window before stopping, and for "Retry now". let deadline = tokio::time::Instant::now() + backoff; loop { if dest.shutdown_requested.load(Ordering::Relaxed) { @@ -2997,11 +3461,16 @@ pub async fn run_egress( )); return Ok(()); } + if dest.retry_now.swap(false, Ordering::Relaxed) { + ctrl.log(format!("[{label}] retrying now")); + break; + } if tokio::time::Instant::now() >= deadline { break; } tokio::time::sleep(Duration::from_millis(200)).await; } + dest.mark_retrying(); backoff = (backoff * 2).min(max_backoff); } } @@ -3025,8 +3494,10 @@ fn scrub_secret(text: &str, secret: &str) -> String { // so it goes entirely. Platform-issued keys are never this short, but a // custom RTMP destination takes whatever the user pasted - and passing // that through unredacted put it straight into /logs and the Discord - // payload, which is the one thing this function exists to prevent. - if chars.len() < 6 { + // payload, which is the one thing this function exists to prevent. The + // six characters shown must be at most half of it: a 6-character key + // used to come back whole, and an 8-character one minus two. + if chars.len() < 12 { return text.replace(secret, "…"); } let head: String = chars[..3].iter().collect(); @@ -4246,7 +4717,7 @@ mod tests { /// instead of averaging the silence in. #[test] fn a_bitrate_drops_to_zero_when_bytes_stop() { - let meter = RateMeter::default(); + let meter: RateMeter = RateMeter::default(); for ms in (0..=3_000).step_by(100) { meter.note_at(100_000, 10_000 + ms); } @@ -4258,6 +4729,44 @@ mod tests { } assert_eq!(meter.kbps_at(31_000), 4_000, "no silence averaged in"); } + + /// A 6 Mbps CBR stream with a keyframe every 2 s, 20 times a P frame: + /// the 1 s meter reads well above the real rate whenever a window holds + /// the keyframe, while the 10 s planning meter stays near it. Planning + /// on the 1 s peaks refused delays the buffer really held. + #[test] + fn the_planning_meter_averages_out_keyframe_bursts() { + let fast: RateMeter = RateMeter::default(); + let mean: RateMeter = RateMeter::default(); + // 1.5 MB per 60-frame GOP: 59 P frames and one keyframe of 20. + let p_frame = 1_500_000 / 79; + let (mut fast_max, mut mean_lo, mut mean_hi) = (0, u32::MAX, 0); + for frame in 0..(30 * 40) { + let at = 1_000 + frame * 33; + let bytes = if frame % 60 == 0 { + p_frame * 20 + } else { + p_frame + }; + fast.note_at(bytes, at as u64); + mean.note_at(bytes, at as u64); + if at > 12_000 { + fast_max = fast_max.max(fast.kbps_at(at as u64)); + let m = mean.kbps_at(at as u64); + mean_lo = mean_lo.min(m); + mean_hi = mean_hi.max(m); + } + } + // 1.5 MB per 1.98 s is about 6060 kbps. + assert!( + fast_max > 7_000, + "the 1 s meter sees the bursts: {fast_max}" + ); + assert!( + (5_800..=6_400).contains(&mean_lo) && (5_800..=6_400).contains(&mean_hi), + "the planning meter stays near 6 Mbps: {mean_lo}..{mean_hi}" + ); + } use std::env; use std::sync::atomic::{AtomicU32 as TestUniq, Ordering as TestOrd}; @@ -4333,20 +4842,23 @@ mod tests { // ── Shutdown signal (web Quit/Restart + tray Quit converge here) ── + /// The main loop is told which one was asked for. #[tokio::test] - async fn shutdown_signal_reports_restart() { - let h = harness(0); - // notify_one stores a permit, so wait_shutdown resolves immediately - // even though the request fires before we await - no lost wakeup. - h.ctrl.request_restart(); - assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Restart); - } - - #[tokio::test] - async fn shutdown_signal_reports_quit() { - let h = harness(0); - h.ctrl.request_quit(); - assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Quit); + async fn shutdown_says_restart_or_quit() { + // shutdown signal reports restart + { + let h = harness(0); + // notify_one stores a permit, so wait_shutdown resolves immediately + // even though the request fires before we await - no lost wakeup. + h.ctrl.request_restart(); + assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Restart); + } + // shutdown signal reports quit + { + let h = harness(0); + h.ctrl.request_quit(); + assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Quit); + } } // ── Ingest key (optional publisher auth, off by default) ───────── @@ -4639,6 +5151,65 @@ mod tests { ); } + /// "Delay on/off" pressed while the buffer fills tells the streamer the + /// delay starts as soon as it is ready. It has to, with "Activate when + /// ready" off too: that is the default, and the press used to arm, + /// make the promise, and leave the stream live for good. + #[test] + fn a_delay_switched_on_while_the_buffer_fills_goes_on_once_ready() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + h.ctrl.arm_delay(5_000); + assert!( + !h.ctrl.auto_activates(false), + "a plain arm waits for Activate when the setting is off" + ); + assert!(h.ctrl.auto_activates(true), "and goes on with it on"); + + let said = h.ctrl.run_named_action("toggle", 5_000, "hotkey"); + assert!( + said.is_some_and(|s| s.contains("starts as soon as it is ready")), + "the press says the delay will start by itself" + ); + assert!( + h.ctrl.auto_activates(false), + "so it does, with the setting off" + ); + + // The buffer fills and the supervisor's activate succeeds: used up. + feed_seconds(&h.ctrl, 0, 6, 30); + assert!(h.ctrl.activate_delay().is_ok(), "the buffer holds it now"); + assert!(!h.ctrl.auto_activates(false), "one shot"); + + // Cut, then a plain arm: the old promise must not ride along. + h.ctrl.stop_delay(); + h.ctrl.arm_delay(5_000); + assert!( + !h.ctrl.auto_activates(false), + "a later plain arm makes no promise" + ); + } + + /// Anything that calls the promise off does: cutting to live while it + /// fills, and disarming. + #[test] + fn a_promised_delay_is_called_off_by_a_cut_or_a_disarm() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let _ = h.ctrl.run_named_action("toggle", 5_000, "hotkey"); + assert!(h.ctrl.auto_activates(false)); + h.ctrl.stop_delay(); + assert!(!h.ctrl.auto_activates(false), "a cut keeps the stream live"); + + let _ = h.ctrl.run_named_action("toggle", 5_000, "hotkey"); + assert!(h.ctrl.auto_activates(false)); + h.ctrl.arm_delay(0); + assert!( + !h.ctrl.auto_activates(false), + "a disarm leaves nothing to start" + ); + } + #[test] fn cut_consumes_auto_activate_pending_so_it_sticks() { // The bug-of-record: without this clear, the supervisor sees @@ -4737,11 +5308,251 @@ mod tests { #[test] fn arm_clamps_to_max() { - // Hard ceiling at 600 s. A persisted-or-hand-edited 999 s must - // be clamped so the buffer can actually catch up. + // Ceiling at 1 hour, so a typo can't arm a delay of days. + let h = harness(0); + h.ctrl.arm_delay(99_999_999); + assert_eq!(h.ctrl.armed_delay_ms(), MAX_DELAY_MS); + } + + #[test] + fn arm_takes_delays_past_ten_minutes() { + // The old 600 s ceiling is gone: 15 minutes arms as asked, and so + // does the ceiling itself. Whether the buffer holds it is checked + // where the request comes in, from its size and the bitrate. + let h = harness(0); + h.ctrl.arm_delay(900_000); + assert_eq!(h.ctrl.armed_delay_ms(), 900_000); + h.ctrl.set_delay_to(MAX_DELAY_MS, "integration"); + assert_eq!(h.ctrl.armed_delay_ms(), MAX_DELAY_MS); + } + + #[test] + fn the_size_a_refusal_asks_for_holds_the_delay() { + for (ms, kbps) in [ + (900_000, 6_000), + (MAX_DELAY_MS, 20_000), + (15_000, 2_500), + (1_000, 501), + ] { + let capacity = buffer_mb_for(ms, kbps) * 1024 * 1024; + let usable = capacity - capacity / 16; // as DiskRing::usable_bytes + assert!( + delay_capacity_ms(usable, kbps) >= ms, + "{} MB at {kbps} kbps holds {ms} ms", + buffer_mb_for(ms, kbps) + ); + } + // 15 minutes at 6 Mbps, the README's example. + assert_eq!(buffer_mb_for(900_000, 6_000), 689); + } + + #[test] + fn the_planning_bitrate_rises_at_once_and_lets_go_slowly() { + assert_eq!( + hold_peak(3_000.0, 1.0, 8_000.0), + 8_000.0, + "a rise counts at once" + ); + assert!( + hold_peak(8_000.0, 0.25, 3_000.0) > 7_800.0, + "a dip doesn't unlock more" + ); + assert_eq!( + hold_peak(8_000.0, 30.0, 3_000.0), + 3_000.0, + "a real drop shows in 30 s" + ); + } + + #[test] + fn capacity_plans_for_the_reference_until_a_bitrate_is_measured() { + let h = harness(0); + assert_eq!(h.ctrl.capacity_kbps(), CAPACITY_REFERENCE_KBPS); + assert!( + h.ctrl.capacity_problem(600_000).is_none(), + "a guess never refuses" + ); + + h.ctrl.note_inbound_kbps_for_test(8_000); + assert_eq!(h.ctrl.capacity_kbps(), 8_000); + // The harness ring is 4 MB: under 4 s at 8 Mbps. + assert!(h.ctrl.delay_capacity_ms() < 4_000); + let why = h + .ctrl + .capacity_problem(30_000) + .expect("30 s is too long for 4 MB"); + assert!( + why.starts_with("Buffer too small for 30 s") && why.contains("at least 33 MB"), + "{why}" + ); + assert!(h.ctrl.capacity_problem(1_000).is_none()); + } + + /// Arming the value that is already armed (a toggle after a cut) keeps + /// its "doesn't fit" report; a disarm in between has it judged again. + #[test] + fn a_short_delays_report_lasts_until_the_delay_is_armed_anew() { + let h = harness(0); + h.ctrl.note_inbound_kbps_for_test(8_000); + h.ctrl.arm_delay(30_000); + h.ctrl.check_armed_capacity(); + assert!(h.ctrl.capacity_short().is_some(), "4 MB at 8 Mbps"); + h.ctrl.arm_delay(30_000); + assert!(h.ctrl.capacity_short().is_some(), "the same value: kept"); + h.ctrl.arm_delay(0); + h.ctrl.arm_delay(30_000); + assert!( + h.ctrl.capacity_short().is_none(), + "armed anew: not yet judged" + ); + h.ctrl.check_armed_capacity(); + assert!(h.ctrl.capacity_short().is_some(), "and judged again"); + } + + /// Silence is not a lighter stream: through a crash-protection hold (and + /// the first window after it) the peak stands, where its decay used to + /// let a delay that doesn't fit pass right after the resume. + #[test] + fn the_peak_bitrate_is_not_decayed_by_silence() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + // A peak from long ago by the clock, and no reading since. + *h.ctrl.capacity_kbps_hold.lock() = (8_000.0, 0); + assert_eq!(h.ctrl.measured_capacity_kbps(), Some(8_000)); + assert!( + h.ctrl.capacity_problem(30_000).is_some(), + "still doesn't fit" + ); + } + + /// The buffer filling before the 10 s check has already said the delay + /// runs short: the check doesn't say it a second time. + #[test] + fn a_short_delay_the_full_buffer_reported_is_not_reported_again() { + let h = harness(0); + h.ctrl.note_inbound_kbps_for_test(8_000); + h.ctrl.arm_delay(30_000); + h.ctrl.report_capacity_short(CapacityShort { + armed_ms: 30_000, + holds_ms: 4_000, + message: "the buffer is full".into(), + }); + let told = h.ctrl.capacity_short_seq(); + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short_seq(), told, "no second notice"); + assert_eq!( + h.ctrl.capacity_short().unwrap().holds_ms, + 4_000, + "the exact figure stays" + ); + } + + /// Offline, nothing is measured: the stream that just ended must not + /// refuse a delay set for the next one. Back on, its peak still holds. + #[test] + fn with_no_stream_there_is_no_measured_bitrate() { let h = harness(0); - h.ctrl.arm_delay(9_999_999); - assert_eq!(h.ctrl.armed_delay_ms(), 600_000); + h.ctrl.note_inbound_kbps_for_test(8_000); + assert!(h.ctrl.capacity_problem(30_000).is_some(), "4 MB at 8 Mbps"); + h.ctrl.ingest_alive.store(false, Ordering::Relaxed); + assert_eq!(h.ctrl.measured_capacity_kbps(), None); + assert!( + h.ctrl.capacity_problem(30_000).is_none(), + "a guess never refuses" + ); + h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + assert_eq!(h.ctrl.measured_capacity_kbps(), Some(8_000)); + } + + /// After a gap the meter knows no rate until a new window fills. It used + /// to report the last stream's until then, which the capacity estimate + /// planned by (and held as its peak) for the new stream's first 10 s. + #[test] + fn a_rate_meter_forgets_the_rate_from_before_a_gap() { + let meter = RateMeter::::default(); + let start = 1_000; + meter.note_at(0, start); + meter.note_at(25_000_000, start + CAPACITY_WINDOW_MS); + assert_eq!(meter.kbps_at(start + CAPACITY_WINDOW_MS), 20_000); + let back = start + CAPACITY_WINDOW_MS + 60_000; + meter.note_at(10_000, back); + assert_eq!(meter.kbps_at(back), 0, "nothing measured since the gap"); + meter.note_at(7_500_000, back + CAPACITY_WINDOW_MS); + assert_eq!(meter.kbps_at(back + CAPACITY_WINDOW_MS), 6_000); + } + + /// A new stream doesn't inherit the last one's peak bitrate, which used + /// to refuse delays that fit until it decayed. + #[tokio::test] + async fn a_new_stream_drops_the_last_ones_peak_bitrate() { + let h = harness(0); + h.ctrl.note_inbound_kbps_for_test(20_000); + assert_eq!(h.ctrl.measured_capacity_kbps(), Some(20_000)); + // That stream ends, and OBS starts another. + h.ctrl.ingest_alive.store(false, Ordering::Relaxed); + h.ctrl + .begin_publish("literally-anything", "127.0.0.1") + .await + .expect("publishes"); + assert_eq!(h.ctrl.capacity_kbps_hold.lock().0, 0.0); + // Neither the held peak nor the meter's last window carries over: + // OBS restarting within seconds used to mix both streams into one. + assert_eq!(h.ctrl.measured_capacity_kbps(), None); + } + + #[test] + fn hotkeys_and_integrations_refuse_a_delay_the_buffer_cant_hold() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + h.ctrl.note_inbound_kbps_for_test(8_000); + let refused = |why: Option| why.is_some_and(|w| w.starts_with("Buffer too small")); + assert!(refused(h.ctrl.run_named_action("arm", 30_000, "hotkey"))); + assert!(refused(h.ctrl.run_named_action("toggle", 30_000, "midi"))); + assert!(refused(h.ctrl.set_delay_to(30_000, "integration"))); + assert_eq!(h.ctrl.armed_delay_ms(), 0, "nothing was armed short"); + + assert!(h.ctrl.set_delay_to(1_000, "integration").is_none()); + assert_eq!(h.ctrl.armed_delay_ms(), 1_000); + } + + /// A delay armed before there was a bitrate (auto-arm at connect, or + /// the one saved from last time) is checked once one is measured, and + /// a short one is reported once: in the state, the log, and a notice. + #[test] + fn a_delay_armed_before_a_measurement_is_checked_once_one_arrives() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + h.ctrl.arm_delay(30_000); + + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short(), None, "no measurement, no verdict"); + assert_eq!(h.ctrl.capacity_short_seq(), 0); + + h.ctrl.note_inbound_kbps_for_test(8_000); + h.ctrl.check_armed_capacity(); + let short = h.ctrl.capacity_short().expect("30 s doesn't fit 4 MB"); + assert_eq!(short.armed_ms, 30_000); + assert!(short.holds_ms < 4_000, "{short:?}"); + assert!(short.message.starts_with("Buffer too small for 30 s")); + assert_eq!(h.ctrl.capacity_short_seq(), 1); + assert_eq!(h.ctrl.delay_runs_ms(30_000), short.holds_ms, "what runs"); + assert_eq!(h.ctrl.delay_runs_ms(20_000), 20_000, "about another delay"); + assert!(h + .ctrl + .logs + .lock() + .iter() + .any(|l| l.contains("the 30 s armed doesn't fit"))); + + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short_seq(), 1, "once per armed value"); + + // A delay that fits clears it, and so does disarming. + h.ctrl.arm_delay(1_000); + assert_eq!(h.ctrl.capacity_short(), None, "about another delay"); + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short(), None); + assert_eq!(h.ctrl.capacity_short_seq(), 1); } // ── Named action (Hotkey & MIDI) test suite ────────────────────── @@ -4838,6 +5649,7 @@ mod tests { assert_eq!(h.ctrl.run_named_action("arm", 5_000, "hotkey"), None); assert_eq!(h.ctrl.run_named_action("arm", 5_000, "hotkey"), None); assert_eq!(h.ctrl.run_named_action("cut", 0, "hotkey"), None); + // An unknown name is ignored, as `run_named_action` documents. assert_eq!(h.ctrl.run_named_action("nonsense", 0, "hotkey"), None); // Activating with nothing armed cannot work, and says so. @@ -5008,6 +5820,18 @@ mod tests { assert!(!h.ctrl.safe_cut_pending(), "2nd press must cancel safe cut"); } + #[test] + fn an_integration_cut_keeps_one_already_waiting() { + let h = harness(0); + h.ctrl.arm_delay(3_000); + feed_seconds(&h.ctrl, 0, 5, 30); + h.ctrl.activate_delay().unwrap(); + + h.ctrl.cut_after_airs("integration"); + h.ctrl.cut_after_airs("integration"); + assert!(h.ctrl.safe_cut_pending(), "a second run must not cancel it"); + } + #[test] fn named_action_toggle_clears_pending_safe_cut() { let h = harness(0); @@ -5025,105 +5849,106 @@ mod tests { assert!(!h.ctrl.safe_cut_pending()); } - /// A wrap does not arrive as one tidy crossing. Audio buffering means - /// several tags can still be in flight when video crosses, and every one - /// of them has to read back into the epoch it was stamped in. + /// Late, repeated or early timestamps around the 32-bit rollover stay where they belong. #[test] - fn several_late_tags_in_a_row_all_land_in_the_old_epoch() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; + fn the_timeline_moves_to_a_new_epoch_only_on_a_real_wrap() { + // several late tags in a row all land in the old epoch + // A wrap does not arrive as one tidy crossing. Audio buffering means + // several tags can still be in flight when video crosses, and every one + // of them has to read back into the epoch it was stamped in. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; + + h.ctrl.on_tag(9, 0xFFFF_FF00, &v, false, false); + h.ctrl.on_tag(9, 0x0000_0020, &v, false, false); // video crosses + assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x20)); + + // Three stragglers, each later than the one before but all still on + // the old side of the counter. + for wire in [0xFFFF_FFA0u32, 0xFFFF_FFC0, 0xFFFF_FFE0] { + h.ctrl.on_tag(8, wire, &v, false, false); + assert_eq!( + h.ctrl.ring.latest_ts(), + Some(wire as u64), + "straggler {wire:#x} left its epoch" + ); + } - h.ctrl.on_tag(9, 0xFFFF_FF00, &v, false, false); - h.ctrl.on_tag(9, 0x0000_0020, &v, false, false); // video crosses - assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x20)); + // And the counter is still able to cross forward afterwards. + h.ctrl.on_tag(9, 0x0000_0040, &v, false, false); + assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x40)); + } + // tags sharing a timestamp do not move the epoch + // Audio and video routinely carry the same timestamp. Equal is neither + // ahead nor behind, and must not be read as either. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; + + h.ctrl.on_tag(9, 5_000, &v, false, false); + h.ctrl.on_tag(8, 5_000, &v, false, false); + h.ctrl.on_tag(9, 5_000, &v, false, false); + assert_eq!(h.ctrl.ring.latest_ts(), Some(5_000)); + + // Same, but sitting exactly on the wrap boundary. + let h2 = harness(0); + h2.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h2.ctrl.on_tag(9, 0xFFFF_FFFF, &v, false, false); + h2.ctrl.on_tag(8, 0xFFFF_FFFF, &v, false, false); + assert_eq!(h2.ctrl.ring.latest_ts(), Some(0xFFFF_FFFF)); + h2.ctrl.on_tag(9, 0, &v, false, false); + assert_eq!( + h2.ctrl.ring.latest_ts(), + Some(1u64 << 32), + "the very next millisecond is the next epoch" + ); + } + // a jump of exactly half the counter does not move the epoch + // Half the counter apart is the point where "ahead" and "behind" stop + // being distinguishable. Whichever way it is read, one tag must not + // move the timeline by 49.7 days. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; - // Three stragglers, each later than the one before but all still on - // the old side of the counter. - for wire in [0xFFFF_FFA0u32, 0xFFFF_FFC0, 0xFFFF_FFE0] { - h.ctrl.on_tag(8, wire, &v, false, false); + h.ctrl.on_tag(9, 0, &v, false, false); + h.ctrl.on_tag(9, 1u32 << 31, &v, false, false); assert_eq!( h.ctrl.ring.latest_ts(), - Some(wire as u64), - "straggler {wire:#x} left its epoch" + Some(1u64 << 31), + "exactly half the counter ahead is still this epoch" ); } + // a tag from before the first one cannot jump the timeline + // A publisher session starts at epoch 0, and there is no epoch below it. + // A tag stamped before the first one we saw cannot be read as "the + // previous cycle", and must not be read as 49.7 days into the future + // either - that value reaches `trim_older_than` and empties the ring. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; - // And the counter is still able to cross forward afterwards. - h.ctrl.on_tag(9, 0x0000_0040, &v, false, false); - assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x40)); - } - - /// Audio and video routinely carry the same timestamp. Equal is neither - /// ahead nor behind, and must not be read as either. - #[test] - fn tags_sharing_a_timestamp_do_not_move_the_epoch() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; - - h.ctrl.on_tag(9, 5_000, &v, false, false); - h.ctrl.on_tag(8, 5_000, &v, false, false); - h.ctrl.on_tag(9, 5_000, &v, false, false); - assert_eq!(h.ctrl.ring.latest_ts(), Some(5_000)); - - // Same, but sitting exactly on the wrap boundary. - let h2 = harness(0); - h2.ctrl.ingest_alive.store(true, Ordering::Relaxed); - h2.ctrl.on_tag(9, 0xFFFF_FFFF, &v, false, false); - h2.ctrl.on_tag(8, 0xFFFF_FFFF, &v, false, false); - assert_eq!(h2.ctrl.ring.latest_ts(), Some(0xFFFF_FFFF)); - h2.ctrl.on_tag(9, 0, &v, false, false); - assert_eq!( - h2.ctrl.ring.latest_ts(), - Some(1u64 << 32), - "the very next millisecond is the next epoch" - ); - } - - /// Half the counter apart is the point where "ahead" and "behind" stop - /// being distinguishable. Whichever way it is read, one tag must not - /// move the timeline by 49.7 days. - #[test] - fn a_jump_of_exactly_half_the_counter_does_not_move_the_epoch() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; - - h.ctrl.on_tag(9, 0, &v, false, false); - h.ctrl.on_tag(9, 1u32 << 31, &v, false, false); - assert_eq!( - h.ctrl.ring.latest_ts(), - Some(1u64 << 31), - "exactly half the counter ahead is still this epoch" - ); - } - - /// A publisher session starts at epoch 0, and there is no epoch below it. - /// A tag stamped before the first one we saw cannot be read as "the - /// previous cycle", and must not be read as 49.7 days into the future - /// either - that value reaches `trim_older_than` and empties the ring. - #[test] - fn a_tag_from_before_the_first_one_cannot_jump_the_timeline() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; - - // A short, ordinary session. - h.ctrl.on_tag(9, 0, &v, true, false); - h.ctrl.on_tag(9, 1_000, &v, false, false); - h.ctrl.on_tag(9, 2_000, &v, false, false); - let before = h.ctrl.ring.latest_ts().expect("a populated ring"); + // A short, ordinary session. + h.ctrl.on_tag(9, 0, &v, true, false); + h.ctrl.on_tag(9, 1_000, &v, false, false); + h.ctrl.on_tag(9, 2_000, &v, false, false); + let before = h.ctrl.ring.latest_ts().expect("a populated ring"); - // Now a tag stamped "60 ms before zero", which is what an audio - // track lagging the video would carry if the session began at 0. - h.ctrl.on_tag(8, 0u32.wrapping_sub(60), &v, false, false); - let after = h.ctrl.ring.latest_ts().expect("a populated ring"); + // Now a tag stamped "60 ms before zero", which is what an audio + // track lagging the video would carry if the session began at 0. + h.ctrl.on_tag(8, 0u32.wrapping_sub(60), &v, false, false); + let after = h.ctrl.ring.latest_ts().expect("a populated ring"); - assert!( - after <= before + 60_000, - "a stray early tag moved the timeline to {after} from {before}" - ); + assert!( + after <= before + 60_000, + "a stray early tag moved the timeline to {after} from {before}" + ); + } } /// A pump that reaches the IDR wait has already opened and authenticated @@ -5207,21 +6032,26 @@ mod tests { // Under the floor there is no safe head/tail, so the whole key goes // rather than passing through into the log line untouched. assert_eq!(scrub_secret("failed for abc12", "abc12"), "failed for …"); + // A head and a tail of three would be all of a 6-character key, and + // most of an 8-character one. + assert_eq!(scrub_secret("failed for abc123", "abc123"), "failed for …"); + assert_eq!( + scrub_secret("failed for abcd1234", "abcd1234"), + "failed for …" + ); + assert_eq!( + scrub_secret("failed for abcdef123456", "abcdef123456"), + "failed for abc…456" + ); } - /// Audio and video cross each other by a few milliseconds all the time, - /// so at the moment the u32 wire clock rolls over, one track is past it - /// and the other is not. The straggler must be read in the epoch it was - /// actually stamped in: promoting it puts one tag 49.7 days ahead, and - /// `on_tag` feeds that to the trim, which then measures the whole ring - /// against a cutoff past every frame in it and evicts the lot. /// OBS stops an Enhanced Broadcasting stream with SequenceEnd tags /// stamped 0. They must not rewind the timeline: the buffer's newest /// moment is what the delay still to air is measured against. #[test] fn an_end_of_stream_tag_stamped_zero_does_not_rewind_the_timeline() { let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); let v = [0x27u8; 64]; h.ctrl.on_tag(9, 200_000, &v, false, false); h.ctrl.on_tag(9, 200_033, &v, false, false); @@ -5247,10 +6077,16 @@ mod tests { ); } + /// Audio and video cross each other by a few milliseconds all the time, + /// so at the moment the u32 wire clock rolls over, one track is past it + /// and the other is not. The straggler must be read in the epoch it was + /// actually stamped in: promoting it puts one tag 49.7 days ahead, and + /// `on_tag` feeds that to the trim, which then measures the whole ring + /// against a cutoff past every frame in it and evicts the lot. #[test] fn a_late_tag_at_the_wrap_stays_in_the_epoch_it_came_from() { let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); let v = [0x27u8; 64]; let a = [0xafu8; 64]; @@ -5377,7 +6213,7 @@ mod tests { /// duration passes through the same u32 truncation OBS puts on the wire. fn soak(hours: u64, reconnect_every_ms: u64, interleave_audio: bool) -> SoakStats { let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.arm_delay(SOAK_ARMED_MS); // Small payloads: a run this long rewrites the ring's whole capacity @@ -5397,11 +6233,19 @@ mod tests { reconnect_every_ms }; let (mut tags, mut wraps, mut reconnects) = (0u64, 0u64, 0u64); + // A keyframe every 2 s of the session's clock, counted from the last + // one. Read off the clock's phase instead (`session_ms % 2_000`), the + // coarse stretch after a wrap steps past every keyframe, and the trim + // rightly keeps everything since the last one a delay could join on. + let mut next_idr_ms: u64 = 0; let started = std::time::Instant::now(); while stream_ms < total_ms { let session_ms = stream_ms - session_start; - let is_idr = session_ms % 2_000 < FRAME_MS; + let is_idr = session_ms >= next_idr_ms; + if is_idr { + next_idr_ms = session_ms + 2_000; + } let payload: &[u8] = if is_idr { &idr } else { &pframe }; // Video, then the audio tag that was already in flight when the @@ -5430,6 +6274,7 @@ mod tests { if stream_ms >= next_reconnect { soak_restart(&h.ctrl); session_start = stream_ms; + next_idr_ms = 0; prev_seq = None; next_reconnect += reconnect_every_ms; reconnects += 1; @@ -5685,9 +6530,9 @@ mod tests { assert!(h.ctrl.end_hold_now()); h.ctrl.check_ingest_freeze_at(last + 60_000); assert!(!h.ctrl.hold_active(), "still the same freeze"); - // Video comes back, then OBS freezes again: that one is protected. - let last = freeze(&h); - assert!(last > 0); + // Video comes back, then OBS freezes again: that one is protected + // (`freeze` checks that its hold opens). + freeze(&h); } /// With a delay armed, the screen stays up after OBS is back until its @@ -6088,7 +6933,7 @@ mod tests { let s = h.ctrl.destination_state(id); *s.eb_override_url.lock() = Some(format!("rtmps://ivs/{id}")); } - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.mark_ingest_dead(); for (id, s) in h.ctrl.all_destination_states() { assert!( @@ -6098,14 +6943,6 @@ mod tests { } } - /// `try_claim_vod_fetch` is single-flight: exactly one caller wins - /// while a fetch is in flight. The supervisor fires every ~2 s; before - /// this guard every tick spawned a fresh Twitch API call, each - /// allocating a distinct IVS session and forcing an extra egress - /// restart - the multi-session, wrong-broadcast-type symptom seen in - /// Twitch Inspector. The latch is released by the fetch task on - /// completion (modelled here by the explicit store), after which the - /// next tick may claim again (e.g. to retry a failed fetch). /// A hold keeps a destination's Twitch session only when it keeps that /// destination on air: live, and coverable by the screen. One that is /// down, or a vertical one whose canvas isn't known yet (the screen @@ -6144,65 +6981,75 @@ mod tests { } } + /// The latch that stops every supervisor tick from asking Twitch for another session. #[test] - fn try_claim_vod_fetch_admits_one_claimant() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch(), "first caller must win the claim"); - for tick in 0..5 { + fn one_vod_session_fetch_at_a_time_and_only_when_needed() { + // try claim vod fetch admits one claimant + // `try_claim_vod_fetch` is single-flight: exactly one caller wins + // while a fetch is in flight. The supervisor fires every ~2 s; before + // this guard every tick spawned a fresh Twitch API call, each + // allocating a distinct IVS session and forcing an extra egress + // restart - the multi-session, wrong-broadcast-type symptom seen in + // Twitch Inspector. The latch is released by the fetch task on + // completion (modelled here by the explicit store), after which the + // next tick may claim again (e.g. to retry a failed fetch). + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch(), "first caller must win the claim"); + for tick in 0..5 { + assert!( + !s.try_claim_vod_fetch(), + "tick {tick} must see a fetch already in flight" + ); + } + // Fetch task finished (success or failure) - latch released. + s.vod_fetch_pending.store(false, Ordering::Relaxed); + assert!( + s.try_claim_vod_fetch(), + "after the fetch ends the next tick must be able to claim" + ); + } + // try claim vod fetch skips when session already allocated + // The claim re-checks `eb_override_url` under its mutex before + // committing: a fetch that completed on an earlier tick (setting the + // override) must abort a redundant claim AND leave the latch clear, + // so the destination isn't left falsely "fetching" forever. + { + let s = DestinationState::new("main".into()); + *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); assert!( !s.try_claim_vod_fetch(), - "tick {tick} must see a fetch already in flight" + "must not claim when a session already exists" + ); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "a skipped claim must release the latch, not leave it stuck" + ); + } + // try claim vod fetch reclaims after override cleared + // Regression guard for the lockout hole: the latch is decoupled from + // the override's lifecycle. The multitrack-config proxy (web.rs) and + // publisher disconnect both clear `eb_override_url` without touching + // the latch. After a successful fetch (override set, latch clear), + // clearing the override - as those paths do - must let the next tick + // re-claim and fetch a fresh session, not lock the destination into + // the legacy Source-Only ingest forever. + { + let s = DestinationState::new("main".into()); + // Post-success state: session allocated, latch released by the task. + *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); + s.vod_fetch_pending.store(false, Ordering::Relaxed); + assert!( + !s.try_claim_vod_fetch(), + "a live session must block a re-fetch" + ); + // Override cleared elsewhere (proxy cleanup / disconnect). + *s.eb_override_url.lock() = None; + assert!( + s.try_claim_vod_fetch(), + "cleared override must allow a fresh fetch - no permanent lockout" ); } - // Fetch task finished (success or failure) - latch released. - s.vod_fetch_pending.store(false, Ordering::Relaxed); - assert!( - s.try_claim_vod_fetch(), - "after the fetch ends the next tick must be able to claim" - ); - } - - /// The claim re-checks `eb_override_url` under its mutex before - /// committing: a fetch that completed on an earlier tick (setting the - /// override) must abort a redundant claim AND leave the latch clear, - /// so the destination isn't left falsely "fetching" forever. - #[test] - fn try_claim_vod_fetch_skips_when_session_already_allocated() { - let s = DestinationState::new("main".into()); - *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); - assert!( - !s.try_claim_vod_fetch(), - "must not claim when a session already exists" - ); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "a skipped claim must release the latch, not leave it stuck" - ); - } - - /// Regression guard for the lockout hole: the latch is decoupled from - /// the override's lifecycle. The multitrack-config proxy (web.rs) and - /// publisher disconnect both clear `eb_override_url` without touching - /// the latch. After a successful fetch (override set, latch clear), - /// clearing the override - as those paths do - must let the next tick - /// re-claim and fetch a fresh session, not lock the destination into - /// the legacy Source-Only ingest forever. - #[test] - fn try_claim_vod_fetch_reclaims_after_override_cleared() { - let s = DestinationState::new("main".into()); - // Post-success state: session allocated, latch released by the task. - *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); - s.vod_fetch_pending.store(false, Ordering::Relaxed); - assert!( - !s.try_claim_vod_fetch(), - "a live session must block a re-fetch" - ); - // Override cleared elsewhere (proxy cleanup / disconnect). - *s.eb_override_url.lock() = None; - assert!( - s.try_claim_vod_fetch(), - "cleared override must allow a fresh fetch - no permanent lockout" - ); } /// The single-flight guarantee under real thread contention: when a @@ -6232,122 +7079,114 @@ mod tests { ); } - /// A fetch that returns while still in its own session applies normally. - #[test] - fn apply_vod_session_if_current_writes_when_session_unchanged() { - let s = DestinationState::new("main".into()); - let epoch = s.session_epoch(); - assert!( - s.apply_vod_session_if_current("rtmps://ivs/live".into(), epoch), - "same-session fetch must apply" - ); - assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/live".into())); - } - - /// Regression guard for the late-completion race: a VOD-session fetch - /// spawned in one publisher session must NOT write its IVS URL if OBS - /// disconnected (and bumped the epoch) while the request was in flight. - /// Writing it would point the next stream at a dead session - the - /// Source-Only failure this whole path exists to avoid. - #[test] - fn apply_vod_session_if_current_discards_after_disconnect() { - let s = DestinationState::new("main".into()); - let epoch = s.session_epoch(); // captured when the fetch is spawned - s.invalidate_session_override(); // OBS disconnects mid-fetch - assert!( - !s.apply_vod_session_if_current("rtmps://ivs/stale".into(), epoch), - "a fetch outliving its session must be discarded" - ); - assert!( - s.eb_override_url.lock().is_none(), - "the stale URL must not leak into the next session" - ); - } - - /// `invalidate_session_override` clears the URL and advances the epoch - /// together, so the disconnect both forgets the old session and trips - /// any in-flight fetch's apply-time guard. - #[test] - fn invalidate_session_override_clears_url_and_bumps_epoch() { - let s = DestinationState::new("main".into()); - *s.eb_override_url.lock() = Some("rtmps://ivs/old".into()); - let before = s.session_epoch(); - s.invalidate_session_override(); - assert!(s.eb_override_url.lock().is_none(), "url must clear"); - assert_eq!(s.session_epoch(), before + 1, "epoch must advance"); - } - - /// Across a full disconnect/reconnect, a fetch from the NEW session - /// still applies: the epoch the supervisor captures after reconnect - /// matches the current one, so only the pre-disconnect fetch is stale. - #[test] - fn apply_vod_session_if_current_applies_for_fresh_session_after_reconnect() { - let s = DestinationState::new("main".into()); - s.invalidate_session_override(); // disconnect bumps epoch - let fresh_epoch = s.session_epoch(); // supervisor re-captures post-reconnect - assert!( - s.apply_vod_session_if_current("rtmps://ivs/new".into(), fresh_epoch), - "a fetch from the new session must apply" - ); - assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/new".into())); - } - - /// `complete_vod_fetch` on success applies the URL, reports Applied, - /// and releases the latch so the override (now Some) is what blocks - /// any re-fetch. - #[test] - fn complete_vod_fetch_applies_and_releases_on_success() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch(), "latch held, as in production"); - let epoch = s.session_epoch(); - assert_eq!( - s.complete_vod_fetch(Some("rtmps://ivs/live".into()), epoch), - VodFetchOutcome::Applied - ); - assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/live".into())); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "latch must be released after a successful completion" - ); - } - - /// A result that outlived its session reports DiscardedStale, writes - /// nothing, and STILL releases the latch (the previously untested - /// release-on-every-path invariant). + /// A disconnect bumps the epoch: an answer from before it is dropped, one after it applies. #[test] - fn complete_vod_fetch_discards_stale_and_releases() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch()); - let epoch = s.session_epoch(); - s.invalidate_session_override(); // disconnect mid-fetch - assert_eq!( - s.complete_vod_fetch(Some("rtmps://ivs/stale".into()), epoch), - VodFetchOutcome::DiscardedStale - ); - assert!(s.eb_override_url.lock().is_none()); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "latch must be released even when the result is discarded" - ); + fn a_vod_session_belongs_to_the_publisher_session_that_asked() { + // apply vod session if current discards after disconnect + // Regression guard for the late-completion race: a VOD-session fetch + // spawned in one publisher session must NOT write its IVS URL if OBS + // disconnected (and bumped the epoch) while the request was in flight. + // Writing it would point the next stream at a dead session - the + // Source-Only failure this whole path exists to avoid. + { + let s = DestinationState::new("main".into()); + let epoch = s.session_epoch(); // captured when the fetch is spawned + s.invalidate_session_override(); // OBS disconnects mid-fetch + assert!( + !s.apply_vod_session_if_current("rtmps://ivs/stale".into(), epoch), + "a fetch outliving its session must be discarded" + ); + assert!( + s.eb_override_url.lock().is_none(), + "the stale URL must not leak into the next session" + ); + } + // invalidate session override clears url and bumps epoch + // `invalidate_session_override` clears the URL and advances the epoch + // together, so the disconnect both forgets the old session and trips + // any in-flight fetch's apply-time guard. + { + let s = DestinationState::new("main".into()); + *s.eb_override_url.lock() = Some("rtmps://ivs/old".into()); + let before = s.session_epoch(); + s.invalidate_session_override(); + assert!(s.eb_override_url.lock().is_none(), "url must clear"); + assert_eq!(s.session_epoch(), before + 1, "epoch must advance"); + } + // apply vod session if current applies for fresh session after reconnect + // Across a full disconnect/reconnect, a fetch from the NEW session + // still applies: the epoch the supervisor captures after reconnect + // matches the current one, so only the pre-disconnect fetch is stale. + { + let s = DestinationState::new("main".into()); + s.invalidate_session_override(); // disconnect bumps epoch + let fresh_epoch = s.session_epoch(); // supervisor re-captures post-reconnect + assert!( + s.apply_vod_session_if_current("rtmps://ivs/new".into(), fresh_epoch), + "a fetch from the new session must apply" + ); + assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/new".into())); + } } - /// A failed fetch reports Failed, leaves no override, and releases the - /// latch so the next supervisor tick can retry. + /// Success, a stale answer and a failure each release the latch. #[test] - fn complete_vod_fetch_releases_latch_on_failure() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch()); - let epoch = s.session_epoch(); - assert_eq!(s.complete_vod_fetch(None, epoch), VodFetchOutcome::Failed); - assert!(s.eb_override_url.lock().is_none()); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "latch must be released on failure" - ); - assert!( - s.try_claim_vod_fetch(), - "a released latch lets the next tick retry" - ); + fn a_finished_vod_fetch_applies_only_to_its_own_session_and_always_releases() { + // complete vod fetch applies and releases on success + // `complete_vod_fetch` on success applies the URL, reports Applied, + // and releases the latch so the override (now Some) is what blocks + // any re-fetch. + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch(), "latch held, as in production"); + let epoch = s.session_epoch(); + assert_eq!( + s.complete_vod_fetch(Some("rtmps://ivs/live".into()), epoch), + VodFetchOutcome::Applied + ); + assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/live".into())); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "latch must be released after a successful completion" + ); + } + // complete vod fetch discards stale and releases + // A result that outlived its session reports DiscardedStale, writes + // nothing, and STILL releases the latch (the previously untested + // release-on-every-path invariant). + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch()); + let epoch = s.session_epoch(); + s.invalidate_session_override(); // disconnect mid-fetch + assert_eq!( + s.complete_vod_fetch(Some("rtmps://ivs/stale".into()), epoch), + VodFetchOutcome::DiscardedStale + ); + assert!(s.eb_override_url.lock().is_none()); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "latch must be released even when the result is discarded" + ); + } + // complete vod fetch releases latch on failure + // A failed fetch reports Failed, leaves no override, and releases the + // latch so the next supervisor tick can retry. + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch()); + let epoch = s.session_epoch(); + assert_eq!(s.complete_vod_fetch(None, epoch), VodFetchOutcome::Failed); + assert!(s.eb_override_url.lock().is_none()); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "latch must be released on failure" + ); + assert!( + s.try_claim_vod_fetch(), + "a released latch lets the next tick retry" + ); + } } /// Stress the apply-vs-disconnect race: a late fetch's apply and the @@ -6382,22 +7221,13 @@ mod tests { } } - /// `note_multitrack_video` is sticky-with-decay: once set, the - /// chip stays lit for a short window after the last multi-track - /// tag arrived (so a momentary pause between IDRs doesn't drop - /// the chip), and decays to false once stale. `reset_codec_state` - /// must wipe it immediately so a fresh non-EB publisher session - /// doesn't inherit the previous session's EB flag. + /// `reset_codec_state` wipes the Enhanced Broadcasting chip at once, so + /// a fresh non-EB publisher session doesn't inherit the last one's. #[test] fn eb_chip_clears_on_publisher_reset() { let h = harness(0); - // `multitrack_video()` returns false when ingest is dead, so - // simulate an active publisher session first. Also sleep - // briefly so `process_now_ms()` advances past 0 - the chip - // uses 0 as a sentinel for "never set" and would otherwise - // race the process anchor on a freshly-started test binary. - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - std::thread::sleep(std::time::Duration::from_millis(2)); + // `multitrack_video()` reads false while ingest is dead. + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.note_multitrack_video(); assert!(h.ctrl.multitrack_video(), "chip must light on first tag"); h.ctrl.reset_codec_state(); @@ -6466,7 +7296,7 @@ mod tests { // 3. Simulate a clean stream teardown or crash. The supervisor invokes // `mark_ingest_dead`, which updates atomic states but leaves headers // intact by design to allow ongoing egress readers to recover. - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.mark_ingest_dead(); { @@ -6539,7 +7369,7 @@ mod tests { // standing in for a real prior stream. Three tags is enough to // populate the index front - the bug is purely about the ts // values at the front vs back, not tag count. - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); for offset in 0u64..3 { h.ctrl .ring @@ -6604,27 +7434,93 @@ mod tests { } } + /// Nothing before the first gap; then the mean, counting only keyframes that move time on. #[test] - fn keyframe_interval_is_zero_before_any_gap_is_measured() { - let h = harness(0); - assert_eq!(h.ctrl.keyframe_interval_ms(), 0); - // One IDR opens the window but is not itself a gap. - feed_idrs(&h.ctrl, 0, 2_000, 1); - assert_eq!(h.ctrl.keyframe_interval_ms(), 0); + fn the_keyframe_interval_is_the_mean_gap_between_real_keyframes() { + // keyframe interval is zero before any gap is measured + { + let h = harness(0); + assert_eq!(h.ctrl.keyframe_interval_ms(), 0); + // One IDR opens the window but is not itself a gap. + feed_idrs(&h.ctrl, 0, 2_000, 1); + assert_eq!(h.ctrl.keyframe_interval_ms(), 0); + } + // keyframe interval measures mean spacing + // Gaps of 2, 4 and 3 s read as their mean, 3 s: not the last gap, the + // first one or the smallest. + { + let h = harness(0); + let mut payload = [0u8; 50]; + payload[0] = 0x17; + for at in [0, 2_000, 6_000, 9_000] { + h.ctrl.on_tag(9, at, &payload, true, false); + } + assert_eq!(h.ctrl.keyframe_interval_ms(), 3_000); + } + // non advancing timestamps do not pollute the mean + // A repeated or backwards timestamp would otherwise register as a + // zero-width gap and drag the mean toward 0, silencing a real warning. + { + let h = harness(0); + let mut payload = [0u8; 50]; + payload[0] = 0x17; + h.ctrl.on_tag(9, 0, &payload, true, false); + h.ctrl.on_tag(9, 4_000, &payload, true, false); + // Same timestamp three times over - a duplicated tag. + for _ in 0..3 { + h.ctrl.on_tag(9, 4_000, &payload, true, false); + } + assert_eq!(h.ctrl.keyframe_interval_ms(), 4_000); + } + // only idr video tags are sampled + // Non-IDR video and audio tags must not be counted as keyframes. + { + let h = harness(0); + let mut idr = [0u8; 50]; + idr[0] = 0x17; + let mut p = [0u8; 50]; + p[0] = 0x27; + h.ctrl.on_tag(9, 0, &idr, true, false); + // P-frames and audio in between must be ignored entirely. + for i in 1..10 { + h.ctrl.on_tag(9, i * 100, &p, false, false); + h.ctrl.on_tag(8, i * 100, &[0xaf, 0x01, 0x21], false, false); + } + h.ctrl.on_tag(9, 2_000, &idr, true, false); + assert_eq!(h.ctrl.keyframe_interval_ms(), 2_000); + } } + /// What each destination is sent: a Twitch one passes multi-track video + /// through; a vertical one sends its canvas, or nothing until that + /// canvas is known; everyone else gets the main picture. #[test] - fn keyframe_interval_measures_mean_spacing() { - let h = harness(0); - feed_idrs(&h.ctrl, 0, 2_000, 4); - assert_eq!(h.ctrl.keyframe_interval_ms(), 2_000); + fn each_destination_gets_the_video_it_asked_for() { + use crate::h264::VideoEgress; + let s = DestinationState::new("d".into()); + assert_eq!(s.video_egress(), Some(VideoEgress::Track(0))); + s.egress_vertical.store(true, Ordering::Relaxed); + s.vertical_primary_track.store(0xFF, Ordering::Relaxed); + assert_eq!(s.video_egress(), None, "no 9:16 canvas yet: wait"); + s.vertical_primary_track.store(1, Ordering::Relaxed); + assert_eq!(s.video_egress(), Some(VideoEgress::Track(1))); + s.pass_through_multitrack_video + .store(true, Ordering::Relaxed); + assert_eq!(s.video_egress(), Some(VideoEgress::Passthrough)); } + /// The second audio track (copyright-safe audio) counts as there only + /// once OBS sends it; the live track always is, a higher one never is. #[test] - fn keyframe_interval_measures_a_four_second_gop() { + fn an_audio_track_is_on_the_wire_only_once_obs_sends_it() { + use crate::h264::AudioEgress; let h = harness(0); - feed_idrs(&h.ctrl, 0, 4_000, 4); - assert_eq!(h.ctrl.keyframe_interval_ms(), 4_000); + assert!(h.ctrl.audio_target_on_wire(AudioEgress::Track(0))); + assert!(h.ctrl.audio_target_on_wire(AudioEgress::Passthrough)); + assert!(!h.ctrl.audio_target_on_wire(AudioEgress::Track(1))); + h.ctrl.second_audio_track.store(true, Ordering::Relaxed); + assert!(h.ctrl.audio_target_on_wire(AudioEgress::Track(1))); + assert!(!h.ctrl.audio_target_on_wire(AudioEgress::Track(2))); } /// The measurement must FREEZE once the sample budget is spent. @@ -6647,40 +7543,6 @@ mod tests { ); } - /// A repeated or backwards timestamp would otherwise register as a - /// zero-width gap and drag the mean toward 0, silencing a real warning. - #[test] - fn non_advancing_timestamps_do_not_pollute_the_mean() { - let h = harness(0); - let mut payload = [0u8; 50]; - payload[0] = 0x17; - h.ctrl.on_tag(9, 0, &payload, true, false); - h.ctrl.on_tag(9, 4_000, &payload, true, false); - // Same timestamp three times over - a duplicated tag. - for _ in 0..3 { - h.ctrl.on_tag(9, 4_000, &payload, true, false); - } - assert_eq!(h.ctrl.keyframe_interval_ms(), 4_000); - } - - /// Non-IDR video and audio tags must not be counted as keyframes. - #[test] - fn only_idr_video_tags_are_sampled() { - let h = harness(0); - let mut idr = [0u8; 50]; - idr[0] = 0x17; - let mut p = [0u8; 50]; - p[0] = 0x27; - h.ctrl.on_tag(9, 0, &idr, true, false); - // P-frames and audio in between must be ignored entirely. - for i in 1..10 { - h.ctrl.on_tag(9, i * 100, &p, false, false); - h.ctrl.on_tag(8, i * 100, &[0xaf, 0x01, 0x21], false, false); - } - h.ctrl.on_tag(9, 2_000, &idr, true, false); - assert_eq!(h.ctrl.keyframe_interval_ms(), 2_000); - } - /// A reconnect may bring a completely different OBS profile, so the /// previous session's measurements must not leak into the new one. #[test] @@ -6698,18 +7560,6 @@ mod tests { assert_eq!((after.width, after.height), (0, 0)); } - /// OBS's sequence header decodes to the resolution the compatibility - /// check sees, width and height each in their own place. - #[test] - fn stream_params_reports_the_resolution_obs_sends() { - let h = harness(0); - assert_eq!(h.ctrl.stream_params().width, 0, "nothing sent yet"); - let header = crate::slate::test_sequence_header(64, 36); - h.ctrl.on_tag(9, 0, &header, false, true); - let p = h.ctrl.stream_params(); - assert_eq!((p.width, p.height), (64, 36)); - } - /// Dead band stays at the tuned 1500 ms for a 2 s GOP (and while /// unmeasured), and widens past half a GOP for long-keyframe encoders so /// the same-IDR re-cut bounce can't reappear at 3-4 s cadences. @@ -6717,8 +7567,7 @@ mod tests { fn recut_dead_band_tracks_keyframe_interval() { assert_eq!(recut_dead_band_ms(0), 1_500, "unmeasured keeps the floor"); assert_eq!(recut_dead_band_ms(2_000), 1_500, "2 s GOP is unchanged"); - assert!(recut_dead_band_ms(4_000) > 2_000, "4 s GOP widens the band"); - assert_eq!(recut_dead_band_ms(4_000), 2_500); + assert_eq!(recut_dead_band_ms(4_000), 2_500, "4 s GOP widens the band"); // Must always clear half a GOP, or the bounce returns. for kf in [1_000u32, 2_000, 3_000, 4_000, 6_000] { assert!( @@ -6818,4 +7667,72 @@ mod tests { .count(); assert_eq!(refusals, 1); } + + /// A destination whose server refuses the connection says why on its + /// card (never with the key), when it retries, and "Retry now" skips + /// the wait instead of sitting out the backoff. + #[tokio::test] + async fn a_refused_destination_reports_why_and_retries_on_request() { + let mut sim = sim::Sim::new(false).await; + // A port nothing listens on: bound once, then released. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let dest = sim.ctrl.destination_state("refused"); + let url = format!("rtmp://127.0.0.1:{port}/app/secret_key_123"); + sim.pumps.push(tokio::spawn(run_egress( + sim.ctrl.clone(), + "refused".into(), + url, + dest.clone(), + ))); + sim.obs_connects().await; + + // The first failure backs off 1 s, the second 2 s: wait for that one. + let mut second = None; + for _ in 0..40 { + sim.obs_sends(200).await; + let f = dest.failure.lock().clone(); + if let Some(f) = f.filter(|f| { + f.retry_at + .is_some_and(|at| at - f.at >= Duration::from_secs(2)) + }) { + second = Some(f); + break; + } + } + let second = second.expect("two failed attempts recorded"); + assert_eq!(second.advice, crate::egress_failure::REFUSED); + assert!( + !second.detail.contains("secret_key_123"), + "{}", + second.detail + ); + + // The wait ends when the card stops counting down: connecting again, + // long before the 2 s backoff is up. (Windows takes ~2 s to report a + // refused port, so the next failure itself would come too late.) + assert!(sim.ctrl.retry_destination_now("refused")); + let asked = Instant::now(); + let mut retried = false; + for _ in 0..8 { + sim.obs_sends(100).await; + if dest + .failure + .lock() + .as_ref() + .is_some_and(|f| f.retry_at.is_none() || f.at > second.at) + { + retried = true; + break; + } + } + assert!(retried, "Retry now started a new attempt"); + assert!(asked.elapsed() < Duration::from_millis(1_000)); + assert!(!sim.ctrl.retry_destination_now("unknown")); + + dest.shutdown_requested.store(true, Ordering::Relaxed); + } } diff --git a/src/controller/delay_sim.rs b/src/controller/delay_sim.rs index 83a6e85..6e42214 100644 --- a/src/controller/delay_sim.rs +++ b/src/controller/delay_sim.rs @@ -7,7 +7,9 @@ //! runs, that nothing airs more often than the delay itself explains, and //! that timestamps never go back. -use super::sim::{assert_strictly_increasing, wait, Frame, Sim, FRAME_MS, GOP_FRAMES}; +use super::sim::{ + assert_strictly_increasing, wait, Frame, Sim, FRAME_MS, GOP_FRAMES, TIMER_SLOP_MS, +}; use super::*; use std::collections::HashSet; @@ -27,20 +29,9 @@ async fn delay_sim() -> Sim { sim } -/// How long after OBS made it `frame` reached the platform. -fn delay_ms(sim: &Sim, frame: &Frame) -> u32 { - frame - .at - .duration_since(sim.produced[&frame.number]) - .as_millis() as u32 -} - /// Every frame in `frames` ran between `min_ms` and `max_ms` behind OBS. fn assert_delays(sim: &Sim, frames: &[Frame], min_ms: u32, max_ms: u32, what: &str) { - let delays: Vec<(u32, u32)> = frames - .iter() - .map(|f| (f.number, delay_ms(sim, f))) - .collect(); + let delays: Vec<(u32, u32)> = frames.iter().map(|f| (f.number, sim.delay_ms(f))).collect(); let off = delays .iter() .find(|(_, ms)| !(min_ms..=max_ms).contains(ms)); @@ -50,9 +41,6 @@ fn assert_delays(sim: &Sim, frames: &[Frame], min_ms: u32, max_ms: u32, what: &s ); } -/// Sleeps can wake a few ms early on a loaded test runner. -const TIMER_SLOP_MS: u32 = 50; - /// A delay is honored: every frame in `frames` ran at least `delay_ms` /// behind OBS (never closer to live than asked) and at most one keyframe /// interval more, which is how far back the newest keyframe that old sits. @@ -344,7 +332,7 @@ async fn raising_the_delay_on_air_jumps_back_exactly_once() { again.is_none(), "{again:?} aired twice after raising the delay" ); - let reached = delay_ms(&sim, after.last().unwrap()); + let reached = sim.delay_ms(after.last().unwrap()); assert!( reached + TIMER_SLOP_MS >= raised, "ran {reached} ms behind, asked for {raised}" @@ -412,6 +400,12 @@ async fn destination_added_mid_delay_on_a_long_gop(sim: &mut Sim) { /// The added destination joins on a keyframe and plays on from there, /// without the jump back a live start followed by a cut to the delay would /// show, at least the delay behind and within a keyframe interval of it. +/// +/// The keyframe nearest the delay when it is added is OBS's first one, 4.3 s +/// back, but the buffer keeps only the armed delay plus 2 s, so it was +/// trimmed as soon as the first destination played past it. The seed used +/// to fall back to OBS's next keyframe, at the live edge: the added +/// destination's viewers watched live while everyone else was delayed. #[tokio::test] async fn a_destination_added_mid_delay_joins_without_a_jump_on_a_long_gop() { let mut sim = delay_sim().await; @@ -426,26 +420,6 @@ async fn a_destination_added_mid_delay_joins_without_a_jump_on_a_long_gop() { sim.assert_timestamps_monotonic(1); } -/// The keyframe nearest the delay when the destination is added is OBS's -/// first one, 4.3 s back, but the buffer keeps only the armed delay plus -/// 2 s, so it was trimmed as soon as the first destination played past it. -/// With no keyframe left to join on, the seed waits for OBS's next one and -/// starts there, at the live edge: the added destination's viewers watch -/// live while the other destination and the dashboard are on the delay. -#[tokio::test] -async fn a_destination_added_mid_delay_on_a_long_gop_never_airs_live() { - let mut sim = delay_sim().await; - destination_added_mid_delay_on_a_long_gop(&mut sim).await; - - let frames = sim.frames(1); - let first = frames.first().expect("the added destination is on air"); - let joined = delay_ms(&sim, first); - assert!( - joined + TIMER_SLOP_MS >= DELAY_MS, - "joined {joined} ms behind OBS with a {DELAY_MS} ms delay on" - ); -} - /// Going delayed with a 4 s keyframe interval: a 2 s delay can only land /// on a keyframe about 0 s or 4 s back, and it takes the one at least the /// delay old, then stays put instead of chasing the exact delay. @@ -710,7 +684,7 @@ async fn obs_restarting_mid_delay_never_puts_viewers_on_live() { let frames = sim.frames(1); let first = frames.first().expect("the destination came back"); - let joined = delay_ms(&sim, first); + let joined = sim.delay_ms(first); assert!( joined + TIMER_SLOP_MS >= DELAY_MS, "came back {joined} ms behind OBS with a {DELAY_MS} ms delay on" @@ -787,7 +761,7 @@ async fn a_delay_longer_than_the_buffer_runs_at_what_it_holds() { frames.len() ); assert_strictly_increasing(&frames, "a delay capped by the buffer"); - let behind = delay_ms(&sim, frames.last().unwrap()); + let behind = sim.delay_ms(frames.last().unwrap()); assert!( (2_500..=7_500).contains(&behind), "delayed by about what the buffer holds, not {behind} ms" @@ -808,6 +782,20 @@ async fn a_delay_longer_than_the_buffer_runs_at_what_it_holds() { .any(|line| line.contains("the buffer is full")), "the log says the delay is capped" ); + // And the dashboard and tray hear it too, once, with what really runs. + let short = sim.ctrl.capacity_short().expect("reported as short"); + assert_eq!(short.armed_ms, 30_000); + assert!( + (2_500..=7_500).contains(&short.holds_ms), + "{}", + short.holds_ms + ); + assert!(short.message.starts_with("Buffer too small for 30 s")); + assert_eq!( + sim.ctrl.capacity_short_seq(), + 1, + "one notice, not one a tag" + ); sim.assert_timestamps_monotonic(0); } @@ -835,9 +823,8 @@ async fn a_longer_keyframe_interval_than_measured_never_replays() { sim.destination_connects("platform").await; sim.obs_sends(10_000).await; - let frames = sim.frames(0); - let back = backward_jumps(&frames); - assert!(back.is_empty(), "the picture jumped back at {back:?}"); - assert_airs_at_most_twice(&frames); + // Already delayed when the destination joins: nothing may jump back, + // and nothing may air twice even in a row. + assert_strictly_increasing(&sim.frames(0), "after the join"); sim.assert_timestamps_monotonic(0); } diff --git a/src/controller/hold_sim.rs b/src/controller/hold_sim.rs index 669f8fe..a4fb239 100644 --- a/src/controller/hold_sim.rs +++ b/src/controller/hold_sim.rs @@ -5,7 +5,7 @@ //! the gap, nothing from before the hold is replayed, and timestamps never //! go back. -use super::sim::{assert_strictly_increasing, wait, Frame, Sim}; +use super::sim::{assert_strictly_increasing, wait, Frame, Sim, TIMER_SLOP_MS}; use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; @@ -40,10 +40,7 @@ async fn crash_screen_then_obs_back_on_the_same_connection() { "rejoins at a keyframe" ); assert_strictly_increasing(&frames, "crash and back"); - assert!( - !sim.commands(0).contains(&"deleteStream".into()), - "never ended" - ); + assert!(!sim.ended(0), "never ended"); assert!(sim.received().iter().all(|m| m.conn == 0), "one connection"); sim.assert_timestamps_monotonic(0); } @@ -68,11 +65,7 @@ async fn a_crash_mid_delay_airs_the_tail_once_and_the_delay_survives_the_return( let crashed = Instant::now(); wait(3_000).await; - let tail: Vec = sim - .frames(0) - .into_iter() - .filter(|f| f.at > crashed) - .collect(); + let tail = sim.frames_after(0, crashed); assert_strictly_increasing(&tail, "the tail"); assert_eq!( tail.last().map(|f| f.number), @@ -93,13 +86,13 @@ async fn a_crash_mid_delay_airs_the_tail_once_and_the_delay_survives_the_return( sim.obs_connects().await; let back = Instant::now(); sim.obs_sends(3_000).await; - let after: Vec = sim.frames(0).into_iter().filter(|f| f.at > back).collect(); + let after = sim.frames_after(0, back); let rejoined = after.first().expect("the stream came back"); assert!(rejoined.keyframe, "rejoins at a keyframe"); - let delayed_by = rejoined.at.duration_since(sim.produced[&rejoined.number]); + let delayed_by = sim.delay_ms(rejoined); assert!( - delayed_by >= Duration::from_millis(1_000), - "still delayed after OBS came back, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "rejoined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); assert_strictly_increasing(&after, "after the return"); sim.assert_timestamps_monotonic(0); @@ -132,10 +125,10 @@ async fn obs_back_mid_tail_never_puts_viewers_on_live() { .find(|f| f.at > back && sim.produced[&f.number] > back) .expect("the stream came back"); assert!(first_new.keyframe, "rejoins at a keyframe"); - let delayed_by = first_new.at.duration_since(sim.produced[&first_new.number]); + let delayed_by = sim.delay_ms(&first_new); assert!( - delayed_by >= Duration::from_millis(2_000), - "OBS's new video must air delayed, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "OBS's new video aired {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); sim.assert_timestamps_monotonic(0); } @@ -154,7 +147,7 @@ async fn end_now_from_a_plain_thread_ends_the_destination_cleanly() { let hotkey = std::thread::spawn(move || ctrl.run_named_action("end_hold", 0, "hotkey")); assert_eq!(hotkey.join().expect("no panic off the runtime"), None); wait(800).await; - assert!(sim.commands(0).contains(&"deleteStream".into())); + assert!(sim.ended(0)); assert!(sim.pumps[0].is_finished(), "the pump ended"); } @@ -209,7 +202,7 @@ async fn stopping_during_a_freeze_ends_the_destination() { sim.obs_stalls(500).await; sim.obs_stops(); wait(800).await; - assert!(sim.commands(0).contains(&"deleteStream".into())); + assert!(sim.ended(0)); } /// Crash protection off: OBS dropping ends the destination at once, as @@ -223,7 +216,7 @@ async fn protection_off_ends_the_destination_when_obs_drops() { sim.obs_crashes(); let crashed = Instant::now(); wait(800).await; - assert!(sim.commands(0).contains(&"deleteStream".into())); + assert!(sim.ended(0)); assert_eq!(sim.screen_tags(0, crashed, Instant::now()), 0, "no screen"); } @@ -253,10 +246,7 @@ async fn obs_stopping_while_the_delay_rebuilds_ends_the_destination() { let back_on_air = sim.frames(0).into_iter().filter(|f| f.at > back).count(); assert_eq!(back_on_air, 0, "OBS's short new video never aired"); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "the stream ended when OBS stopped" - ); + assert!(sim.ended(0), "the stream ended when OBS stopped"); assert!(sim.pumps[0].is_finished(), "the pump ended"); sim.assert_timestamps_monotonic(0); } @@ -296,21 +286,14 @@ async fn a_hold_that_runs_out_mid_tail_airs_the_tail_in_full_then_ends() { sim.ctrl.expire_hold(); wait(3_000).await; - let tail: Vec = sim - .frames(0) - .into_iter() - .filter(|f| f.at > crashed) - .collect(); + let tail = sim.frames_after(0, crashed); assert_eq!( tail.last().map(|f| f.number), Some(last_live), "the whole tail aired" ); assert_strictly_increasing(&tail, "the tail"); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "then it ended" - ); + assert!(sim.ended(0), "then it ended"); assert!(sim.pumps[0].is_finished(), "the pump ended"); sim.assert_timestamps_monotonic(0); } @@ -344,10 +327,7 @@ async fn a_freeze_hold_that_runs_out_never_replays_the_tail() { let frames = sim.frames(0); let from = frames.iter().rposition(|f| f.at < froze).unwrap_or(0); assert_strictly_increasing(&frames[from..], "a frozen OBS past the hold"); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "the destination ended" - ); + assert!(sim.ended(0), "the destination ended"); sim.assert_timestamps_monotonic(0); } @@ -371,7 +351,7 @@ async fn end_now_mid_tail_ends_the_stream_at_once() { assert!(sim.ctrl.end_hold_now(), "a hold was on"); wait(1_000).await; - assert!(sim.commands(0).contains(&"deleteStream".into()), "it ended"); + assert!(sim.ended(0), "it ended"); assert!( sim.frames(0).last().is_some_and(|f| f.number < last_live), "without airing the rest of the tail" @@ -401,7 +381,7 @@ async fn obs_leaves_mid_delay(sim: &mut Sim, stopped: bool) -> (u32, Instant) { /// What viewers see once OBS left: everything it sent, over the delay's /// length, then a clean end with no reconnect screen. fn assert_tail_aired_then_ended(sim: &mut Sim, last_live: u32, left: Instant) { - let tail: Vec = sim.frames(0).into_iter().filter(|f| f.at > left).collect(); + let tail = sim.frames_after(0, left); assert_eq!( tail.last().map(|f| f.number), Some(last_live), @@ -413,10 +393,7 @@ fn assert_tail_aired_then_ended(sim: &mut Sim, last_live: u32, left: Instant) { took >= 1_500, "the tail took the delay to air, not {took} ms" ); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "then it ended" - ); + assert!(sim.ended(0), "then it ended"); assert_eq!(sim.screen_tags(0, left, Instant::now()), 0, "no screen"); assert!(sim.pumps[0].is_finished(), "the pump ended"); sim.assert_timestamps_monotonic(0); @@ -471,7 +448,7 @@ async fn end_now_after_a_stop_cuts_the_rest_of_the_delay() { assert!(sim.ctrl.end_hold_now(), "the delay was airing"); wait(1_000).await; - assert!(sim.commands(0).contains(&"deleteStream".into()), "it ended"); + assert!(sim.ended(0), "it ended"); assert!( sim.frames(0).last().is_some_and(|f| f.number < last_live), "without airing the rest of the delay" @@ -508,12 +485,9 @@ async fn a_freeze_rejoin_lands_at_least_the_delay_back() { .find(|f| f.at > back && sim.produced[&f.number] > back) .expect("the stream came back"); assert!(rejoined.keyframe, "rejoins at a keyframe"); - let delayed_by = rejoined - .at - .duration_since(sim.produced[&rejoined.number]) - .as_millis() as u32; + let delayed_by = sim.delay_ms(&rejoined); assert!( - delayed_by + 50 >= DELAY_MS, + delayed_by + TIMER_SLOP_MS >= DELAY_MS, "rejoined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); sim.assert_timestamps_monotonic(0); @@ -552,12 +526,9 @@ async fn a_destination_after_an_ended_freeze_never_replays_what_already_aired() frames.iter().all(|f| f.number > last_before_freeze), "nothing from before the freeze airs again" ); - let delayed_by = first - .at - .duration_since(sim.produced[&first.number]) - .as_millis() as u32; + let delayed_by = sim.delay_ms(first); assert!( - sim.produced[&first.number] > back && delayed_by + 50 >= DELAY_MS, + sim.produced[&first.number] > back && delayed_by + TIMER_SLOP_MS >= DELAY_MS, "joins OBS's new video {delayed_by} ms behind, at least the delay" ); assert_strictly_increasing(&frames, "after an ended freeze"); @@ -597,12 +568,9 @@ async fn a_destination_after_a_short_freeze_joins_at_once() { "it waited {:?} to go live", first.at.duration_since(joined) ); - let delayed_by = first - .at - .duration_since(sim.produced[&first.number]) - .as_millis() as u32; + let delayed_by = sim.delay_ms(first); assert!( - delayed_by + 50 >= DELAY_MS, + delayed_by + TIMER_SLOP_MS >= DELAY_MS, "joined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); } diff --git a/src/controller/sim.rs b/src/controller/sim.rs index 421e5ae..8d11b11 100644 --- a/src/controller/sim.rs +++ b/src/controller/sim.rs @@ -14,6 +14,9 @@ use tokio::sync::mpsc; /// OBS's frame interval (10 fps) and default keyframe cadence (every 500 ms). pub(super) const FRAME_MS: u32 = 100; +/// Sleeps can wake a few ms early on a loaded test runner, so "at least the +/// delay behind" is checked this much short of it. +pub(super) const TIMER_SLOP_MS: u32 = 50; pub(super) const GOP_FRAMES: u32 = 5; /// The stream's resolution: small, so the screen encodes in no time. const WIDTH: usize = 64; @@ -238,6 +241,27 @@ impl Sim { .count() } + /// Connection `conn`'s frames that reached the platform after `at`. + pub(super) fn frames_after(&mut self, conn: usize, at: Instant) -> Vec { + self.frames(conn) + .into_iter() + .filter(|f| f.at > at) + .collect() + } + + /// How long after OBS made it `frame` reached the platform, in ms. + pub(super) fn delay_ms(&self, frame: &Frame) -> u32 { + frame + .at + .duration_since(self.produced[&frame.number]) + .as_millis() as u32 + } + + /// Whether connection `conn`'s stream ended with its goodbye. + pub(super) fn ended(&mut self, conn: usize) -> bool { + self.commands(conn).contains(&"deleteStream".into()) + } + pub(super) fn commands(&mut self, conn: usize) -> Vec { self.received() .iter() diff --git a/src/controller/user_sim.rs b/src/controller/user_sim.rs index 42dafdb..9a6d838 100644 --- a/src/controller/user_sim.rs +++ b/src/controller/user_sim.rs @@ -4,7 +4,7 @@ //! from a new OBS session airs undelayed, timestamps never go back, and a //! stream that ends sends its goodbye (`deleteStream`). -use super::sim::{assert_strictly_increasing, wait, Frame, Sim}; +use super::sim::{assert_strictly_increasing, wait, Frame, Sim, TIMER_SLOP_MS}; use std::collections::HashSet; use std::time::{Duration, Instant}; @@ -21,19 +21,6 @@ async fn delayed_stream(sim: &mut Sim, ms: u32) { sim.obs_sends(ms).await; } -fn frames_after(sim: &mut Sim, conn: usize, at: Instant) -> Vec { - sim.frames(conn).into_iter().filter(|f| f.at > at).collect() -} - -fn ended(sim: &mut Sim, conn: usize) -> bool { - sim.commands(conn).contains(&"deleteStream".into()) -} - -/// How long after OBS made it a frame reached viewers. -fn delay_of(sim: &Sim, frame: &Frame) -> Duration { - frame.at.duration_since(sim.produced[&frame.number]) -} - /// A multistreamer (Twitch and YouTube), no crash protection, stops in /// OBS: both platforms air the whole delay, then both end. #[tokio::test] @@ -54,14 +41,14 @@ async fn multistreamer_stop_airs_the_delay_on_every_platform() { wait(3_000).await; for conn in 0..2 { - let tail = frames_after(&mut sim, conn, stopped); + let tail = sim.frames_after(conn, stopped); assert_eq!( tail.last().map(|f| f.number), Some(last_live), "platform {conn} aired the whole delay" ); assert_strictly_increasing(&tail, "the tail"); - assert!(ended(&mut sim, conn), "platform {conn} ended"); + assert!(sim.ended(conn), "platform {conn} ended"); sim.assert_timestamps_monotonic(conn); } assert_eq!(sim.ctrl.tail_left(), None); @@ -79,39 +66,25 @@ async fn armed_but_not_on_yet_a_stop_ends_at_once() { sim.obs_stops(); wait(800).await; - assert!(ended(&mut sim, 0), "ended within the moment"); + assert!(sim.ended(0), "ended within the moment"); assert_eq!(sim.ctrl.tail_left(), None, "nothing to count down"); } -/// Stops, then starts OBS again a moment later while the delay of the +/// OBS crashes (no crash protection) and comes back while the delay of the /// first stream is still airing: the new stream reaches viewers delayed, -/// never live. -#[tokio::test] -async fn restarting_mid_tail_never_airs_the_new_stream_live() { - restart_mid_tail(true).await; -} - -/// Same, when OBS crashed instead (no crash protection) and came back. +/// never live. (A stop and restart is `obs_restarts_mid_delay`.) #[tokio::test] async fn coming_back_from_a_crash_mid_tail_never_airs_live() { - restart_mid_tail(false).await; -} - -async fn restart_mid_tail(stopped: bool) { let mut sim = Sim::new(false).await; sim.destination_runs("platform"); delayed_stream(&mut sim, 1_000).await; - if stopped { - sim.obs_stops(); - } else { - sim.obs_crashes(); - } + sim.obs_crashes(); wait(600).await; sim.obs_connects().await; let first_new = sim.next_frame; sim.obs_sends(4_500).await; - assert!(ended(&mut sim, 0), "the first stream ended"); + assert!(sim.ended(0), "the first stream ended"); let new: Vec = sim .frames(1) .into_iter() @@ -119,10 +92,10 @@ async fn restart_mid_tail(stopped: bool) { .collect(); let first = new.first().expect("the destination came back"); assert!(first.keyframe, "starts on a keyframe"); - let delayed_by = delay_of(&sim, first); + let delayed_by = sim.delay_ms(first); assert!( - delayed_by >= Duration::from_millis(1_500), - "the new stream airs delayed, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "the new stream airs {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); sim.assert_timestamps_monotonic(1); } @@ -140,7 +113,7 @@ async fn cut_to_live_during_the_tail_ends_the_stream() { sim.ctrl.stop_delay(); wait(1_200).await; - assert!(ended(&mut sim, 0), "it ended"); + assert!(sim.ended(0), "it ended"); assert!( sim.frames(0).last().is_some_and(|f| f.number < last_live), "without airing the rest" @@ -171,8 +144,8 @@ async fn cut_after_this_airs_then_a_stop() { frames.iter().any(|f| f.number == mark), "the marked moment aired" ); - assert_strictly_increasing(&frames_after(&mut sim, 0, stopped), "after the stop"); - assert!(ended(&mut sim, 0), "it ended"); + assert_strictly_increasing(&sim.frames_after(0, stopped), "after the stop"); + assert!(sim.ended(0), "it ended"); sim.assert_timestamps_monotonic(0); } @@ -187,7 +160,7 @@ async fn end_now_then_a_new_stream_starts_delayed() { wait(300).await; assert!(sim.ctrl.end_hold_now(), "the delay was airing"); wait(500).await; - assert!(ended(&mut sim, 0), "ended at once"); + assert!(sim.ended(0), "ended at once"); sim.obs_connects().await; let first_new = sim.next_frame; sim.obs_sends(4_500).await; @@ -197,10 +170,10 @@ async fn end_now_then_a_new_stream_starts_delayed() { .into_iter() .find(|f| f.number >= first_new) .expect("the new stream aired"); - let delayed_by = delay_of(&sim, &first); + let delayed_by = sim.delay_ms(&first); assert!( - delayed_by >= Duration::from_millis(1_500), - "delayed, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "{delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); } @@ -224,9 +197,9 @@ async fn the_countdown_goes_down_then_away() { assert_eq!(sim.ctrl.tail_left(), None, "gone once it aired"); } -/// A streamer who stops and starts three times in a row (scene setup, -/// a crash of their own, a restart): every stream airs delayed and whole, -/// and nothing airs twice across all of them. +/// A streamer who stops and starts OBS three times in a row (setting up +/// scenes, say): every stream airs whole, and nothing airs twice across +/// all of them. #[tokio::test] async fn three_stops_and_starts_in_a_row() { const SHORT_DELAY_MS: u32 = 1_000; @@ -242,8 +215,12 @@ async fn three_stops_and_starts_in_a_row() { // replays from then on. let activated = Instant::now(); let mut last_lives = Vec::new(); + // Each stream runs long enough for the destination to rejoin it: after + // a stop it airs the 1 s tail, then waits out a backoff that doubles + // per short session (2 s by the third), which left a 1.5 s stream only + // 0.3 s of slack on a busy machine. for round in 0..3 { - sim.obs_sends(1_500).await; + sim.obs_sends(2_500).await; last_lives.push(sim.next_frame - 1); sim.obs_stops(); wait(1_800).await; @@ -279,13 +256,15 @@ async fn a_delay_longer_than_the_buffer_then_a_stop() { sim.obs_connects().await; sim.destination_connects("platform").await; sim.obs_sends(4_000).await; - let _ = sim.ctrl.activate_delay(); + sim.ctrl + .activate_delay() + .expect("activates at what the buffer holds"); sim.obs_sends(2_000).await; sim.obs_stops(); let stopped = Instant::now(); wait(5_000).await; - assert_strictly_increasing(&frames_after(&mut sim, 0, stopped), "after the stop"); - assert!(ended(&mut sim, 0), "it ended"); + assert_strictly_increasing(&sim.frames_after(0, stopped), "after the stop"); + assert!(sim.ended(0), "it ended"); sim.assert_timestamps_monotonic(0); } diff --git a/src/crash_hold.rs b/src/crash_hold.rs index fca863c..8c59f4d 100644 --- a/src/crash_hold.rs +++ b/src/crash_hold.rs @@ -827,41 +827,45 @@ mod tests { tag } + /// During a hold each audio track gets silence in its own format, or none when we can't make it. #[test] - fn silence_matches_the_streams_own_aac_config() { - // AAC-LC, 48 kHz (index 3), stereo: 00010 0011 0010 000. - let stereo = silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])).unwrap(); - assert_eq!(stereo.frame, SILENT_AAC_STEREO); - assert_eq!(stereo.framing, Framing::Legacy); - assert_eq!(audio_tag(&stereo)[..2], [0xAF, AAC_RAW]); - assert!((stereo.frame_ms - 21.333).abs() < 0.01); - // AAC-LC, 44.1 kHz (index 4), mono. - let mono = silent_audio_from_header(&aac_header(0xAE, [0x12, 0x08])).unwrap(); - assert_eq!(mono.frame, SILENT_AAC_MONO); - assert!((mono.frame_ms - 23.22).abs() < 0.01); - } - - #[test] - fn a_vod_audio_track_gets_silence_under_its_own_track_id() { - let header = one_track_header(0x95, b"mp4a", 1, &[0x11, 0x90]); - let silence = silent_audio_from_header(&header).unwrap(); - assert_eq!(silence.framing, Framing::OneTrack(1)); - let tag = audio_tag(&silence); - assert_eq!(tag[..7], [0x95, 0x01, b'm', b'p', b'4', b'a', 1]); - // It flattens back to the legacy silent frame any ingest accepts. - let flat = crate::h264::flatten_multitrack_audio(&tag, 1).unwrap(); - assert_eq!(flat[..2], [0xAF, AAC_RAW]); - assert_eq!(&flat[2..], SILENT_AAC_STEREO); - } - - #[test] - fn unsupported_audio_gets_no_silence() { - // HE-AAC (object type 5), 5.1 channels, a non-AAC tag, Opus. - assert!(silent_audio_from_header(&aac_header(0xAF, [0x29, 0x90])).is_none()); - assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0xB0])).is_none()); - assert!(silent_audio_from_header(&[0x2F, 0x00, 0x11, 0x90]).is_none()); - assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])[..2]).is_none()); - assert!(silent_audio_from_header(&one_track_header(0x95, b"Opus", 1, &[0; 8])).is_none()); + fn silence_matches_the_audio_it_stands_in_for() { + // silence matches the streams own aac config + { + // AAC-LC, 48 kHz (index 3), stereo: 00010 0011 0010 000. + let stereo = silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])).unwrap(); + assert_eq!(stereo.frame, SILENT_AAC_STEREO); + assert_eq!(stereo.framing, Framing::Legacy); + assert_eq!(audio_tag(&stereo)[..2], [0xAF, AAC_RAW]); + assert!((stereo.frame_ms - 21.333).abs() < 0.01); + // AAC-LC, 44.1 kHz (index 4), mono. + let mono = silent_audio_from_header(&aac_header(0xAE, [0x12, 0x08])).unwrap(); + assert_eq!(mono.frame, SILENT_AAC_MONO); + assert!((mono.frame_ms - 23.22).abs() < 0.01); + } + // a vod audio track gets silence under its own track id + { + let header = one_track_header(0x95, b"mp4a", 1, &[0x11, 0x90]); + let silence = silent_audio_from_header(&header).unwrap(); + assert_eq!(silence.framing, Framing::OneTrack(1)); + let tag = audio_tag(&silence); + assert_eq!(tag[..7], [0x95, 0x01, b'm', b'p', b'4', b'a', 1]); + // It flattens back to the legacy silent frame any ingest accepts. + let flat = crate::h264::flatten_multitrack_audio(&tag, 1).unwrap(); + assert_eq!(flat[..2], [0xAF, AAC_RAW]); + assert_eq!(&flat[2..], SILENT_AAC_STEREO); + } + // unsupported audio gets no silence + { + // HE-AAC (object type 5), 5.1 channels, a non-AAC tag, Opus. + assert!(silent_audio_from_header(&aac_header(0xAF, [0x29, 0x90])).is_none()); + assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0xB0])).is_none()); + assert!(silent_audio_from_header(&[0x2F, 0x00, 0x11, 0x90]).is_none()); + assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])[..2]).is_none()); + assert!( + silent_audio_from_header(&one_track_header(0x95, b"Opus", 1, &[0; 8])).is_none() + ); + } } fn framing_and_codec(header: &[u8]) -> Option<(Framing, SlateCodec)> { @@ -903,7 +907,7 @@ mod tests { assert_eq!(target.fourcc, *b"hvc1"); assert_eq!((target.shape.width, target.shape.height), (2560, 1440)); - let mut ten_bit = config.clone(); + let mut ten_bit = config; ten_bit[17] = 0xF8 | 2; // bitDepthLumaMinus8 assert_eq!( screen_target(&one_track_header(0x96, b"hvc1", 1, &ten_bit)), @@ -1004,9 +1008,8 @@ mod tests { #[test] fn the_screen_stops_waiting_for_the_delay_after_the_grace() { const DELAY_MS: u64 = 10_000; - let path = - std::env::temp_dir().join(format!("ic-rebuild-grace-{}.buf", std::process::id())); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-rebuild-grace"); + let path = scratch.join("ring.buf"); let ring = Arc::new(crate::buffer::DiskRing::create(&path, 1 << 20).unwrap()); let ctrl = Controller::new(ring, 0); let keyframe = [0x17, 1, 0, 0, 0]; @@ -1026,7 +1029,6 @@ mod tests { let waited = Duration::from_millis(DELAY_MS) + REBUILD_GRACE; rebuild.as_mut().unwrap().since = Instant::now() - waited; assert_eq!(delay_rebuilt(&ctrl, &mut rebuild, DELAY_MS), first_new); - let _ = std::fs::remove_file(&path); } #[test] diff --git a/src/crash_protection.rs b/src/crash_protection.rs index 693d368..9853b8b 100644 --- a/src/crash_protection.rs +++ b/src/crash_protection.rs @@ -283,46 +283,58 @@ mod tests { assert_eq!(loaded, settings); } + /// Whatever the form sends, the settings stay in range and printable. #[test] - fn hold_secs_is_clamped_and_garbage_ignored() { - let mut settings = CrashProtection::default(); - settings.set("hold_secs", "5"); - assert_eq!(settings.hold_secs, HOLD_SECS_MIN); - settings.set("hold_secs", "9999"); - assert_eq!(settings.hold_secs, HOLD_SECS_MAX); - settings.set("hold_secs", "soon"); - assert_eq!(settings.hold_secs, HOLD_SECS_MAX); - } - - #[test] - fn bad_colours_and_themes_are_dropped() { - let mut settings = CrashProtection::default(); - settings.set("accent", "red"); - settings.set("accent", "#12345"); - settings.set("theme", "signal"); - assert_eq!(settings.accent, ACCENT_DEFAULT); - assert_eq!(settings.theme, SlateTheme::Whisper); - } - - #[test] - fn empty_background_means_theme_default() { - let mut settings = CrashProtection::default(); - settings.set("background", "#202020"); - settings.set("background", ""); - assert_eq!(settings.background, None); - assert_eq!( - settings.resolved_background(), - SlateTheme::Whisper.default_background() - ); - } - - #[test] - fn text_loses_control_characters_and_is_capped() { - let mut settings = CrashProtection::default(); - settings.set("headline", " Back\nsoon\t "); - assert_eq!(settings.headline, "Backsoon"); - settings.set("subline", &"x".repeat(100)); - assert_eq!(settings.subline.chars().count(), SUBLINE_MAX_CHARS); + fn bad_values_are_clamped_dropped_or_cleaned() { + // hold secs is clamped and garbage ignored + { + let mut settings = CrashProtection::default(); + settings.set("hold_secs", "5"); + assert_eq!(settings.hold_secs, HOLD_SECS_MIN); + settings.set("hold_secs", "9999"); + assert_eq!(settings.hold_secs, HOLD_SECS_MAX); + settings.set("hold_secs", "soon"); + assert_eq!(settings.hold_secs, HOLD_SECS_MAX); + } + // bad colours and themes are dropped + { + let mut settings = CrashProtection::default(); + settings.set("accent", "#ff8800"); + settings.set("theme", "arcade"); + let good = settings.accent; + assert_ne!(good, ACCENT_DEFAULT, "the good colour was taken"); + settings.set("accent", "red"); + settings.set("accent", "#12345"); + settings.set("theme", "signal"); + assert_eq!( + settings.accent, good, + "a bad colour keeps the last good one" + ); + assert_eq!( + settings.theme, + SlateTheme::Arcade, + "and so does a bad theme" + ); + } + // empty background means theme default + { + let mut settings = CrashProtection::default(); + settings.set("background", "#202020"); + settings.set("background", ""); + assert_eq!(settings.background, None); + assert_eq!( + settings.resolved_background(), + SlateTheme::Whisper.default_background() + ); + } + // text loses control characters and is capped + { + let mut settings = CrashProtection::default(); + settings.set("headline", " Back\nsoon\t "); + assert_eq!(settings.headline, "Backsoon"); + settings.set("subline", &"x".repeat(100)); + assert_eq!(settings.subline.chars().count(), SUBLINE_MAX_CHARS); + } } #[test] diff --git a/src/crypto.rs b/src/crypto.rs index ce1fbb0..00a0b0f 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -186,69 +186,53 @@ fn unhex(s: &str) -> Option> { mod tests { use super::*; - /// RFC 4231 HMAC-SHA256 test case 1: key = 0x0b x20, data = "Hi There". + /// Published HMAC-SHA256 vectors, picked for the branches they reach. #[test] - fn hmac_rfc4231_case1() { - let mac = hmac_sha256(&[0x0b; 20], b"Hi There"); - assert_eq!( - sha256::hex_bytes(&mac), - "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" - ); - } - - /// RFC 4231 test case 2: key = "Jefe", data = "what do ya want for nothing?". - #[test] - fn hmac_rfc4231_case2() { - let mac = hmac_sha256(b"Jefe", b"what do ya want for nothing?"); - assert_eq!( - sha256::hex_bytes(&mac), - "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" - ); - } - - /// RFC 4231 test case 6: a 131-byte key, longer than the 64-byte block, so - /// it must be hashed down first. Cases 1 and 2 use short keys and never - /// reach that branch; a password longer than 64 bytes does. - #[test] - fn hmac_rfc4231_case6_hashes_an_oversized_key() { - let mac = hmac_sha256( - &[0xaa; 131], - b"Test Using Larger Than Block-Size Key - Hash Key First", - ); - assert_eq!( - sha256::hex_bytes(&mac), - "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" - ); - } - - /// RFC 4231 test case 7: an oversized key AND a message longer than one - /// block, so the inner hash spans several blocks. - #[test] - fn hmac_rfc4231_case7_oversized_key_and_message() { - let mac = hmac_sha256( - &[0xaa; 131], - b"This is a test using a larger than block-size key and a larger than \ - block-size data. The key needs to be hashed before being used by the \ - HMAC algorithm.", - ); - assert_eq!( - sha256::hex_bytes(&mac), - "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2" - ); - } - - /// A key of exactly one block is used as-is, not hashed. This is the - /// boundary of the `>` in `hmac_sha256`: turning it into `>=` would hash a - /// 64-byte key and change every MAC made with one. Expected value is the - /// NIST HMAC-SHA256 "keylen=blocklen" example (key = bytes 0x00..0x3f). - #[test] - fn hmac_key_of_exactly_one_block_is_not_hashed() { - let key: Vec = (0u8..64).collect(); - let mac = hmac_sha256(&key, b"Sample message for keylen=blocklen"); - assert_eq!( - sha256::hex_bytes(&mac), - "8bb9a1db9806f20df7f77b82138c7914d174d59e13dc4d0169c9057b133e1d62" - ); + fn hmac_matches_the_published_vectors() { + let one_block_key: Vec = (0u8..64).collect(); + let cases: [(&str, &[u8], &[u8], &str); 5] = [ + ( + "RFC 4231 case 1: a short key", + &[0x0b; 20], + b"Hi There", + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", + ), + ( + "RFC 4231 case 2: a text key", + b"Jefe", + b"what do ya want for nothing?", + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", + ), + // A 131-byte key is longer than the 64-byte block, so it is + // hashed down first: the branch a password over 64 bytes takes. + ( + "RFC 4231 case 6: an oversized key", + &[0xaa; 131], + b"Test Using Larger Than Block-Size Key - Hash Key First", + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54", + ), + // And a message longer than one block: the inner hash spans several. + ( + "RFC 4231 case 7: an oversized key and message", + &[0xaa; 131], + b"This is a test using a larger than block-size key and a larger than \ + block-size data. The key needs to be hashed before being used by the \ + HMAC algorithm.", + "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2", + ), + // A key of exactly one block is used as-is, not hashed: the + // boundary of the `>` in `hmac_sha256`. With `>=` every MAC made + // with a 64-byte key would change. (NIST "keylen=blocklen".) + ( + "NIST: a key of exactly one block", + &one_block_key, + b"Sample message for keylen=blocklen", + "8bb9a1db9806f20df7f77b82138c7914d174d59e13dc4d0169c9057b133e1d62", + ), + ]; + for (case, key, message, mac) in cases { + assert_eq!(sha256::hex_bytes(&hmac_sha256(key, message)), mac, "{case}"); + } } /// Published PBKDF2-HMAC-SHA256 vectors (password "password", salt "salt"). @@ -298,8 +282,6 @@ mod tests { fn verify_rejects_garbage() { assert!(!verify_password("x", "")); assert!(!verify_password("x", "not-a-hash")); - assert!(!verify_password("x", "pbkdf2-sha256$0$aa$bb")); - assert!(!verify_password("x", "pbkdf2-sha256$1$zz$bb")); // non-hex salt } /// Every malformed variant of a hash that is otherwise correct for this @@ -335,6 +317,13 @@ mod tests { "a non-numeric count", ), (STORED_VECTOR.replacen("$1$", "$-1$", 1), "a negative count"), + // Zero rounds would hand back the one-round digest: without the + // guard this very copy would verify. + (STORED_VECTOR.replacen("$1$", "$0$", 1), "a zero count"), + ( + STORED_VECTOR.replacen("73616c74", "7361zz74", 1), + "a salt that isn't hex", + ), ] { assert!(!verify_password("password", &stored), "{why}: {stored}"); } diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..a7614c4 --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,605 @@ +//! What InstantClone hands over when something goes wrong: the crash log a +//! panic leaves beside the settings, and the plain-text report behind the +//! Logs tab's "Copy diagnostics" (`GET /diagnostics`). +//! +//! Both quote settings and log lines, and both end up pasted into a bug +//! report or a Discord channel, so both go through one scrub on the way out: +//! every secret the settings hold is cut out, and every web address is cut +//! back to its host. +//! +//! Release builds abort on a panic and have no console, so before this the +//! only trace of a crash was the 1 500-line log, which lives in memory and +//! dies with the process. + +use crate::config::Settings; +use crate::controller::Controller; +use crate::integrations::clock::iso_utc; +use crate::integrations::model::Trigger; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// The crash log's file name, beside the settings file. +pub const CRASH_LOG_NAME: &str = "instantclone-crash.log"; +/// Past this the next report starts the file over. A report is ~30 KB, so +/// this keeps the last several crashes and never grows without bound. +const CRASH_LOG_MAX_BYTES: u64 = 256 * 1024; +/// Log lines a crash report keeps: the last minutes before it. +const CRASH_LOG_LINES: usize = 200; +/// Starts every report; the crash time follows it, then ` ====`. +const CRASH_HEADER: &str = "==== InstantClone closed unexpectedly at "; +/// Appended once the next start has told the user, so it says so only once. +const SEEN_MARK: &str = "---- reported on the next start"; +/// What a secret becomes. +const REDACTED: &str = "[redacted]"; + +/// The secrets of the settings in force, kept apart from the settings +/// channel so the panic hook never has to borrow it: a panic inside a +/// settings update would otherwise wait forever on the lock it holds, and a +/// process that should abort would hang instead. +static SECRETS: crate::sync::Mutex> = crate::sync::Mutex::new(Vec::new()); + +/// Refresh what the crash log scrubs. Called at startup and on every +/// settings change. +pub fn remember_secrets(settings: &Settings) { + *SECRETS.lock() = secrets_of(settings); +} + +/// Every secret value the settings hold: stream keys, custom URLs (which +/// can carry a key), the ingest key, the dock token, the password hash, +/// Discord webhooks, the phone topic and the integrations' hook tokens. +/// Longest first, so a secret that contains another goes whole. +fn secrets_of(s: &Settings) -> Vec { + let mut out = Vec::new(); + let mut add = |value: &str| { + let value = value.trim(); + if !value.is_empty() { + out.push(value.to_string()); + } + }; + for value in [ + &s.ingest_key, + &s.stream_key, + &s.custom_egress_url, + &s.dashboard_password_hash, + &s.dock_token, + &s.discord_webhook_url, + &s.phone.topic, + ] { + add(value); + } + for d in &s.destinations { + add(&d.stream_key); + add(&d.custom_egress_url); + } + for channel in &s.discord_channels { + add(&channel.url); + } + for handler in s.integrations.iter().flat_map(|i| &i.handlers) { + if let Trigger::Webhook { token } | Trigger::Shortcut { token, .. } = &handler.trigger { + add(token); + } + } + out.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); + out.dedup(); + out +} + +/// `text` with every secret cut out and every web address cut to its host. +fn scrub(text: &str, secrets: &[String]) -> String { + let mut out = text.to_string(); + for secret in secrets { + if out.contains(secret.as_str()) { + out = out.replace(secret.as_str(), REDACTED); + } + } + crate::integrations::runner::redact_urls(&out) +} + +/// Now, in Unix milliseconds. +fn unix_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +/// A log line with the wall-clock time it was logged in front. Log lines +/// carry the process clock (`[+ 12.345s]`), which says nothing to someone +/// matching them against an OBS log or a viewer's "at 9:14 it froze". +fn with_wall_clock(line: &str, now_unix_ms: u64, now_process_ms: u64) -> String { + let logged_at_s = line + .strip_prefix("[+") + .and_then(|rest| rest.split_once("s]")) + .and_then(|(secs, _)| secs.trim().parse::().ok()); + match logged_at_s { + Some(secs) => { + let started = now_unix_ms.saturating_sub(now_process_ms); + let at_ms = started + (secs * 1000.0) as u64; + format!("{} {line}", iso_utc(at_ms / 1000)) + } + None => line.to_string(), + } +} + +/// The commit this binary was built from (set by the release workflow). +fn build_label() -> String { + let version = crate::update_check::current_version(); + match option_env!("INSTANTCLONE_BUILD_COMMIT").filter(|c| !c.is_empty()) { + Some(commit) => format!("{version} (commit {commit})"), + None => format!("{version} (local build)"), + } +} + +/// The crash log beside the settings file at `config_path`. +pub fn crash_log_path(config_path: &Path) -> PathBuf { + crate::config::beside_config(config_path, Path::new(CRASH_LOG_NAME)) +} + +/// Write a crash report before the process goes down. Release builds +/// abort on a panic, so this runs just before the abort, on whatever thread +/// panicked. It only ever `try_lock`s: that thread may hold the log or the +/// secrets, and waiting on them would turn an abort into a hang. +pub fn install_panic_hook(ctrl: Arc, crash_log: PathBuf) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let message = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "(no message)".to_string()); + let location = info + .location() + .map_or_else(String::new, |l| format!("{}:{}", l.file(), l.line())); + let thread = std::thread::current(); + let lines: Option> = ctrl.logs.try_lock().map(|q| { + let skip = q.len().saturating_sub(CRASH_LOG_LINES); + q.iter().skip(skip).cloned().collect() + }); + let secrets = SECRETS.try_lock().map(|s| s.clone()); + let report = crash_report( + &Crash { + message: &message, + location: &location, + thread: thread.name().unwrap_or("unnamed"), + lines: lines.as_deref(), + secrets: secrets.as_deref(), + }, + unix_now_ms(), + crate::controller::process_now_ms(), + ); + append_bounded(&crash_log, &report); + previous(info); + })); +} + +/// What the panic hook knows about a crash. +struct Crash<'a> { + message: &'a str, + location: &'a str, + thread: &'a str, + /// The last log lines, None when the panicking thread held the log. + lines: Option<&'a [String]>, + /// None when they couldn't be read: the log lines are left out then, + /// since there is nothing to scrub them with. + secrets: Option<&'a [String]>, +} + +fn crash_report(crash: &Crash, now_unix_ms: u64, now_process_ms: u64) -> String { + let secrets = crash.secrets.unwrap_or(&[]); + let mut out = format!( + "{CRASH_HEADER}{} ====\nVersion: {}\nOS: {} {}\nThread: {}\nPanic: {}\nAt: {}\n", + iso_utc(now_unix_ms / 1000), + build_label(), + std::env::consts::OS, + std::env::consts::ARCH, + crash.thread, + crash.message, + crash.location, + ); + match (crash.lines, crash.secrets) { + (Some(lines), Some(_)) => { + out.push_str(&format!("Last {} log lines:\n", lines.len())); + for line in lines { + out.push_str(&with_wall_clock(line, now_unix_ms, now_process_ms)); + out.push('\n'); + } + } + (None, _) => out.push_str("(the log was busy at the moment of the crash)\n"), + (Some(_), None) => out.push_str("(log lines left out: the secrets to hide were busy)\n"), + } + out.push('\n'); + scrub(&out, secrets) +} + +/// Append `report` to the crash log, starting the file over once it has +/// grown past `CRASH_LOG_MAX_BYTES`. Best effort: a crash log that can't be +/// written must not stop the process going down. +fn append_bounded(path: &Path, report: &str) { + let too_big = std::fs::metadata(path).is_ok_and(|m| m.len() > CRASH_LOG_MAX_BYTES); + let file = std::fs::OpenOptions::new() + .create(true) + .append(!too_big) + .write(true) + .truncate(too_big) + .open(path); + if let Ok(mut file) = file { + let _ = file.write_all(report.as_bytes()); + let _ = file.flush(); + } +} + +/// If the last run crashed and nobody has been told, say so once in the +/// log, pointing at the crash log, and mark it told. +pub fn announce_previous_crash(crash_log: &Path, ctrl: &Controller) { + let Ok(text) = std::fs::read_to_string(crash_log) else { + return; + }; + let Some(when) = unreported_crash(&text) else { + return; + }; + let shown = std::path::absolute(crash_log).unwrap_or_else(|_| crash_log.to_path_buf()); + ctrl.log(format!( + "[crash] InstantClone closed unexpectedly at {when} (UTC). What it was doing is in {} - \ + attach that file if you report it.", + shown.display() + )); + if let Ok(mut file) = std::fs::OpenOptions::new().append(true).open(crash_log) { + let _ = writeln!(file, "{SEEN_MARK}, {}", iso_utc(unix_now_ms() / 1000)); + } +} + +/// The time of the newest crash in the log, unless a start already +/// reported it. +fn unreported_crash(text: &str) -> Option<&str> { + let start = text.rfind(CRASH_HEADER)? + CRASH_HEADER.len(); + let rest = &text[start..]; + if rest.contains(SEEN_MARK) { + return None; + } + rest.split_once(" ====").map(|(when, _)| when) +} + +/// When the newest crash in the log happened, reported or not, for the +/// diagnostics header. +fn last_crash(crash_log: &Path) -> Option { + let text = std::fs::read_to_string(crash_log).ok()?; + let start = text.rfind(CRASH_HEADER)? + CRASH_HEADER.len(); + let (when, _) = text[start..].split_once(" ====")?; + Some(when.to_string()) +} + +/// `1h 02m 03s`. +fn hms(secs: u64) -> String { + format!("{}h {:02}m {:02}s", secs / 3600, secs / 60 % 60, secs % 60) +} + +/// The plain-text report behind "Copy diagnostics": who we are, the +/// settings (already redacted for display), what the delay and every +/// destination are doing, and the log with wall-clock times. Scrubbed of +/// every configured secret as the last step, so nothing a section printed +/// can slip through. +pub fn report(ctrl: &Controller, settings: &Settings, config_path: &Path) -> String { + let now_unix_ms = unix_now_ms(); + let now_process_ms = crate::controller::process_now_ms(); + let uptime = ctrl.uptime_secs(); + let mut out = String::with_capacity(64 * 1024); + let mut line = |text: String| { + out.push_str(&text); + out.push('\n'); + }; + + line("InstantClone diagnostics".into()); + line(format!("Version: {}", build_label())); + line(format!( + "OS: {} {}", + std::env::consts::OS, + std::env::consts::ARCH + )); + line(format!( + "Started: {} UTC (up {})", + iso_utc((now_unix_ms / 1000).saturating_sub(uptime)), + hms(uptime) + )); + line(format!("Generated: {} UTC", iso_utc(now_unix_ms / 1000))); + line(format!( + "Last crash: {}", + last_crash(&crash_log_path(config_path)).map_or_else( + || "none on record".to_string(), + |when| format!("{when}, see {CRASH_LOG_NAME}") + ) + )); + + line(String::new()); + line("== State ==".into()); + line(format!( + "Delay: {} | armed {} ms, on air {} ms (target {} ms)", + ctrl.phase(), + ctrl.armed_delay_ms(), + ctrl.current_delay_ms(), + ctrl.target_delay_ms() + )); + // The ring is made once at startup: a size saved since then waits for a + // restart, so report the one running and name the saved one apart. + let running_mb = ctrl.ring.capacity() / (1024 * 1024); + let saved = if running_mb == settings.buffer_mb { + String::new() + } else { + format!( + " (saved {} MB, applies after a restart)", + settings.buffer_mb + ) + }; + line(format!( + "Buffer: {running_mb} MB{saved}, {} ms filled of {} ms wanted, holds about {} ms at {} kbps", + ctrl.buffer_fill_ms(), + ctrl.target_buffer_ms(), + ctrl.delay_capacity_ms(), + ctrl.capacity_kbps() + )); + if let Some(short) = ctrl.capacity_short() { + line(format!( + "Capacity: runs at {} ms. {}", + short.holds_ms, short.message + )); + } + line(format!( + "OBS: {} | {} / {}, {} kbps in ({} video), {} disconnects", + if ctrl.ingest_alive() { + "streaming" + } else { + "not streaming" + }, + ctrl.video_codec().label(), + ctrl.audio_codec().label(), + ctrl.bitrate_kbps(), + ctrl.video_bitrate_kbps(), + ctrl.ingest_disconnects() + )); + if let Some(hold) = ctrl.hold_status() { + line(format!( + "Crash protection: holding ({:?}), {} s left of {} s", + hold.reason, + hold.remaining.as_secs(), + hold.total.as_secs() + )); + } + if let Some(tail) = ctrl.tail_left() { + line(format!( + "Delay tail: {} ms still airing after OBS left", + tail.as_millis() + )); + } + let conflicts = ctrl.hotkey_conflicts(); + if !conflicts.is_empty() { + line(format!( + "Hotkeys another app holds: {}", + conflicts.join(", ") + )); + } + let snapshot = ctrl.destination_snapshot(); + line(format!("Destinations: {}", settings.destinations.len())); + for d in &settings.destinations { + let live = snapshot.iter().find(|t| t.0 == d.id); + let (alive, kbps, reconnects) = live.map_or((false, 0, 0), |t| (t.1, t.3, t.7)); + line(format!( + " - {} [{}{}] {} | {}, {} kbps, {} reconnects", + d.name, + d.platform, + if d.wants_vertical() { ", vertical" } else { "" }, + if d.enabled { "on" } else { "off" }, + if alive { "connected" } else { "not connected" }, + kbps, + reconnects + )); + } + + line(String::new()); + line("== Settings ==".into()); + line(settings.to_json(crate::autostart::is_enabled(), false)); + + let lines: Vec = ctrl.logs.lock().iter().cloned().collect(); + line(String::new()); + line(format!("== Log ({} lines) ==", lines.len())); + for entry in &lines { + line(with_wall_clock(entry, now_unix_ms, now_process_ms)); + } + scrub(&out, &secrets_of(settings)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Destination; + + fn destination(platform: &str, key: &str, url: &str) -> Destination { + Destination { + id: platform.into(), + name: platform.into(), + enabled: true, + platform: platform.into(), + stream_key: key.into(), + custom_egress_url: url.into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + } + } + + fn settings_with_secrets() -> Settings { + let mut s = Settings::defaults(); + s.ingest_key = "ingest-secret-1".into(); + s.dock_token = "0123abcd4567ef89".into(); + s.phone.topic = "my-private-topic".into(); + s.destinations + .push(destination("twitch", "live_123456_abcdefSECRET", "")); + s.destinations.push(destination( + "custom", + "", + "rtmp://edge.example/app/customKEY987", + )); + s.discord_channels + .push(crate::integrations::model::DiscordChannel { + id: "d1".into(), + name: "alerts".into(), + url: "https://discord.com/api/webhooks/1/webhookTOKENabc".into(), + }); + s + } + + /// Each place a secret is kept is on the list. The scrub test below + /// can't show this: a URL is cut down to its host anyway, so a webhook + /// would vanish from its text even if nobody listed it. + #[test] + fn every_secret_source_is_listed() { + let mut s = Settings::defaults(); + s.ingest_key = "s-ingest".into(); + s.stream_key = "s-stream".into(); + s.custom_egress_url = "s-egress".into(); + s.dashboard_password_hash = "s-hash".into(); + s.dock_token = "s-dock".into(); + s.discord_webhook_url = "s-webhook".into(); + s.phone.topic = "s-topic".into(); + s.destinations + .push(destination("custom", "s-dest-key", "s-dest-url")); + s.discord_channels + .push(crate::integrations::model::DiscordChannel { + id: "d".into(), + name: "d".into(), + url: "s-channel".into(), + }); + let listed = secrets_of(&s); + for secret in [ + "s-ingest", + "s-stream", + "s-egress", + "s-hash", + "s-dock", + "s-webhook", + "s-topic", + "s-dest-key", + "s-dest-url", + "s-channel", + ] { + assert!(listed.iter().any(|l| l == secret), "{secret} is not listed"); + } + } + + #[test] + fn every_configured_secret_is_cut_out() { + let s = settings_with_secrets(); + let secrets = secrets_of(&s); + let text = "key live_123456_abcdefSECRET, ingest ingest-secret-1, dock \ + 0123abcd4567ef89, topic my-private-topic, url \ + rtmp://edge.example/app/customKEY987 and \ + https://discord.com/api/webhooks/1/webhookTOKENabc ok"; + let clean = scrub(text, &secrets); + for leaked in [ + "abcdefSECRET", + "ingest-secret-1", + "0123abcd4567ef89", + "my-private-topic", + "customKEY987", + "webhookTOKENabc", + ] { + assert!(!clean.contains(leaked), "{leaked} in {clean}"); + } + assert!(clean.contains("key [redacted]"), "{clean}"); + assert!(clean.ends_with(" ok"), "{clean}"); + } + + /// A key that contains another key goes whole, not half-redacted. + #[test] + fn longer_secrets_are_cut_before_the_ones_they_contain() { + let mut s = Settings::defaults(); + s.ingest_key = "abcd".into(); + s.dock_token = "abcdefgh".into(); + assert_eq!( + secrets_of(&s), + vec!["abcdefgh".to_string(), "abcd".to_string()] + ); + assert_eq!(scrub("t=abcdefgh", &secrets_of(&s)), "t=[redacted]"); + } + + #[test] + fn log_lines_get_the_wall_clock_time_they_were_logged() { + // The process started at 2026-10-07T10:00:00Z and has run 60 s. + let now_unix_ms = 1_791_367_200_000 + 60_000; + let line = "[+ 12.345s] [Main] connected"; + assert_eq!( + with_wall_clock(line, now_unix_ms, 60_000), + "2026-10-07T10:00:12Z [+ 12.345s] [Main] connected" + ); + assert_eq!(with_wall_clock("no stamp", now_unix_ms, 60_000), "no stamp"); + } + + #[test] + fn a_crash_report_is_scrubbed_and_says_when() { + let s = settings_with_secrets(); + let secrets = secrets_of(&s); + let lines = vec!["[+ 1.000s] [Main] key live_123456_abcdefSECRET refused".to_string()]; + let report = crash_report( + &Crash { + message: "index out of bounds in ingest-secret-1", + location: "src/controller.rs:42", + thread: "main", + lines: Some(&lines), + secrets: Some(&secrets), + }, + 1_791_367_200_000, + 5_000, + ); + assert!(report + .starts_with("==== InstantClone closed unexpectedly at 2026-10-07T10:00:00Z ====")); + assert!(report.contains("At: src/controller.rs:42")); + assert!(report.contains("Last 1 log lines:")); + assert!(!report.contains("abcdefSECRET") && !report.contains("ingest-secret-1")); + assert!(!report.contains('\u{2014}'), "no em dashes"); + + // Without the secrets the log lines stay out rather than go raw. + let blind = crash_report( + &Crash { + lines: Some(&lines), + secrets: None, + message: "boom", + location: "", + thread: "tray", + }, + 1_791_367_200_000, + 5_000, + ); + assert!(!blind.contains("abcdefSECRET")); + assert!(blind.contains("log lines left out")); + } + + /// The next start says so once: the mark it leaves silences the start + /// after, until a new crash is appended. + #[test] + fn a_crash_is_reported_once_on_the_next_start() { + let first = format!("{CRASH_HEADER}2026-10-07T10:00:00Z ====\nPanic: x\n\n"); + assert_eq!(unreported_crash(&first), Some("2026-10-07T10:00:00Z")); + let seen = format!("{first}{SEEN_MARK}, 2026-10-07T10:05:00Z\n"); + assert_eq!(unreported_crash(&seen), None); + let again = format!("{seen}{CRASH_HEADER}2026-10-08T09:00:00Z ====\nPanic: y\n\n"); + assert_eq!(unreported_crash(&again), Some("2026-10-08T09:00:00Z")); + assert_eq!(unreported_crash("nothing here"), None); + } + + #[test] + fn the_crash_log_stays_bounded() { + let scratch = crate::test_support::TestDir::new("ic-crash-log"); + let path = scratch.join("crash.log"); + let big = "x".repeat(CRASH_LOG_MAX_BYTES as usize + 10); + append_bounded(&path, &big); + append_bounded(&path, "fresh report\n"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "fresh report\n"); + append_bounded(&path, "second\n"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "fresh report\nsecond\n" + ); + } +} diff --git a/src/egress_failure.rs b/src/egress_failure.rs new file mode 100644 index 0000000..64a3e63 --- /dev/null +++ b/src/egress_failure.rs @@ -0,0 +1,211 @@ +//! Why a destination can't stream, in words a streamer can act on. +//! +//! The egress loop keeps the last failed connection of each destination. The +//! dashboard card shows its advice, the scrubbed technical reason and when +//! the next attempt starts, so a wrong stream key is no longer an endless +//! "Connecting...". + +use std::io; +use std::time::{Duration, Instant}; + +/// A publish the platform accepted and then closed within this long is read +/// as a rejected key: Twitch answers a wrong key that way, not with an RTMP +/// error. +pub const QUICK_DROP: Duration = Duration::from_secs(10); + +/// Longest technical reason kept for the card, in characters. +const MAX_DETAIL_CHARS: usize = 200; + +pub const KEY_REJECTED: &str = "Stream key rejected. Check the key in Edit, and that nothing else \ + is streaming with it."; +pub const CLOSED_AT_START: &str = "The platform closed the stream as soon as it started. This \ + usually means a wrong or expired stream key: check it in Edit."; +pub const REFUSED: &str = + "The server refused the connection. Check the server address and port in Edit."; +pub const NOT_FOUND: &str = + "Server address not found. Check your internet connection and the server address in Edit."; +pub const TLS_FAILED: &str = "The secure connection (RTMPS) failed. Check the server address in \ + Edit and that your PC's date and time are right."; +pub const TIMED_OUT: &str = "The server didn't answer in time. Check your internet connection, \ + or pick another server in Edit."; +pub const UNREACHABLE: &str = "No route to the server. Check your internet connection."; +pub const DROPPED: &str = "The connection to the platform dropped."; +pub const BAD_URL: &str = "This server address isn't valid. Fix it in Edit."; +pub const UNKNOWN: &str = "Couldn't connect to the platform."; +/// OBS is publishing Enhanced Broadcasting with a session this run of +/// InstantClone never handed out (it restarted mid-stream): Twitch only +/// takes the session OBS asks for when it starts streaming. +pub const EB_SESSION_LOST: &str = "InstantClone restarted during an Enhanced Broadcasting stream, \ + and Twitch won't take that session back. Stop and start streaming in OBS to begin a new one."; + +/// The last failed attempt to stream to one destination. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EgressFailure { + /// Plain advice, one of the constants above. + pub advice: &'static str, + /// The technical reason, already scrubbed of the stream key. + pub detail: String, + pub at: Instant, + /// When the next attempt starts. None while it is connecting, and for a + /// failure that retrying can't fix. + pub retry_at: Option, + /// False for a failure no retry fixes (a malformed address): the card + /// then asks for an edit instead of counting down. + pub retries: bool, +} + +impl EgressFailure { + pub fn new(advice: &'static str, detail: &str, retry_in: Option) -> Self { + let at = Instant::now(); + Self { + advice, + detail: detail.chars().take(MAX_DETAIL_CHARS).collect(), + at, + retry_at: retry_in.map(|d| at + d), + retries: retry_in.is_some(), + } + } +} + +/// Plain advice for a failed connection or a dropped session. `quick_drop` +/// is a session the platform ended within `QUICK_DROP` of accepting it. +pub fn advice_for(err: &io::Error, quick_drop: bool) -> &'static str { + let text = err.to_string().to_ascii_lowercase(); + let has = |needles: &[&str]| needles.iter().any(|n| text.contains(n)); + // RTMP status codes and descriptions first: they name the real cause, + // where the socket error that follows them is generic. + if has(&[ + "badname", + "publish.denied", + "unauthorized", + "authentication", + "auth failed", + "forbidden", + "stream key", + ]) { + return KEY_REJECTED; + } + if has(&["tls"]) { + return TLS_FAILED; + } + // Windows' WSAHOST_NOT_FOUND (11001) and WSANO_DATA (11004), and the + // getaddrinfo wording on Linux. + if has(&[ + "failed to lookup", + "no such host", + "os error 11001", + "os error 11004", + "name or service not known", + "temporary failure in name resolution", + "nodename nor servname", + ]) { + return NOT_FOUND; + } + match err.kind() { + io::ErrorKind::ConnectionRefused => return REFUSED, + io::ErrorKind::TimedOut => return TIMED_OUT, + io::ErrorKind::NetworkUnreachable | io::ErrorKind::HostUnreachable => return UNREACHABLE, + _ => {} + } + // Errors rebuilt from text (the pump's) lose their kind; WSAECONNREFUSED + // is 10061, WSAETIMEDOUT 10060. + if has(&["refused", "os error 10061"]) { + return REFUSED; + } + if has(&["timed out", "no answer within", "os error 10060"]) { + return TIMED_OUT; + } + if has(&["unreachable", "os error 10051", "os error 10065"]) { + return UNREACHABLE; + } + if quick_drop { + return CLOSED_AT_START; + } + let dropped = matches!( + err.kind(), + io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::BrokenPipe + | io::ErrorKind::UnexpectedEof + ); + if dropped || has(&["early eof", "reset", "aborted", "broken pipe", "closed"]) { + return DROPPED; + } + UNKNOWN +} + +#[cfg(test)] +mod tests { + use super::*; + + fn other(text: &str) -> io::Error { + io::Error::other(text.to_string()) + } + + /// A refused publish points at the stream key; network failures say what + /// failed on the network; anything else is honestly unknown. + #[test] + fn each_failure_gets_its_own_advice() { + // rtmp publish refusals read as a rejected key + { + for text in [ + "RTMP NetStream.Publish.BadName: Stream already publishing", + "RTMP NetStream.Publish.Denied: denied", + "RTMP _error: Authentication failed", + "RTMP _error: invalid stream key", + ] { + assert_eq!(advice_for(&other(text), false), KEY_REJECTED, "{text}"); + } + } + // a publish closed right away points at the key + { + let reset = io::Error::from(io::ErrorKind::ConnectionReset); + assert_eq!(advice_for(&reset, true), CLOSED_AT_START); + assert_eq!(advice_for(&reset, false), DROPPED); + } + // socket failures get their own advice + { + let refused = io::Error::from(io::ErrorKind::ConnectionRefused); + assert_eq!(advice_for(&refused, false), REFUSED); + let timeout = io::Error::new(io::ErrorKind::TimedOut, "no answer within 20 s"); + assert_eq!(advice_for(&timeout, false), TIMED_OUT); + let dns = other("No such host is known. (os error 11001)"); + assert_eq!(advice_for(&dns, false), NOT_FOUND); + let linux_dns = + other("failed to lookup address information: Name or service not known"); + assert_eq!(advice_for(&linux_dns, false), NOT_FOUND); + let tls = other("TLS handshake failed: certificate expired"); + assert_eq!(advice_for(&tls, false), TLS_FAILED); + } + // errors that lost their kind are read from the text + { + let refused = other("No connection could be made (os error 10061)"); + assert_eq!(advice_for(&refused, false), REFUSED); + let silent = other("operation timed out"); + assert_eq!(advice_for(&silent, false), TIMED_OUT); + } + // a dns failure is not mistaken for a rejected key on a quick drop + { + let dns = other("No such host is known. (os error 11001)"); + assert_eq!(advice_for(&dns, true), NOT_FOUND); + } + // anything else is a plain unknown + { + assert_eq!(advice_for(&other("something odd"), false), UNKNOWN); + } + } + + #[test] + fn the_detail_is_capped_on_a_character_boundary() { + let long = "é".repeat(500); + let failure = EgressFailure::new(UNKNOWN, &long, None); + assert_eq!(failure.detail.chars().count(), MAX_DETAIL_CHARS); + assert!(failure.retry_at.is_none() && !failure.retries); + } + + #[test] + fn a_retry_delay_sets_the_next_attempt() { + let failure = EgressFailure::new(REFUSED, "refused", Some(Duration::from_secs(4))); + assert_eq!(failure.retry_at, Some(failure.at + Duration::from_secs(4))); + } +} diff --git a/src/h264.rs b/src/h264.rs index 9d00e93..2116ae3 100644 --- a/src/h264.rs +++ b/src/h264.rs @@ -1344,6 +1344,7 @@ pub fn is_aac_seq_header(payload: &[u8]) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::test_support::Lcg; /// Builds a length-prefixed NAL unit (the format AVC video tags use /// after the 5-byte FLV/AVC header). `nal_type` is the 5-bit type @@ -1367,76 +1368,162 @@ mod tests { out } + /// Legacy FLV AVC tags: keyframes are found by walking the NALs, not by + /// trusting the frame-type nibble, and damaged tags never panic. #[test] - fn avc_idr_detected_from_nal_walk() { - // packet_type 1 = NALU; contains NAL type 5 (IDR slice) - let tag = avc_tag(1, &[nal(5, 100)]); - let info = classify_video_tag(&tag); - assert!(info.is_idr, "single IDR NAL must be detected"); - assert!(!info.is_seq_header); - assert_eq!(info.codec, VideoCodec::Avc); - } - - #[test] - fn avc_non_idr_not_flagged() { - // NAL type 1 = non-IDR slice; encoder MIGHT lie via the FrameType - // nibble - we trust the NAL walk and refuse to call it an IDR. - let tag = avc_tag(1, &[nal(1, 50)]); - let info = classify_video_tag(&tag); - assert!(!info.is_idr); - } - - #[test] - fn avc_seq_header_flagged_not_idr() { - // packet_type 0 = AVCDecoderConfigurationRecord (SPS/PPS) - let tag = avc_tag(0, &[]); - let info = classify_video_tag(&tag); - assert!(info.is_seq_header); - assert!( - !info.is_idr, - "seq headers carry SPS/PPS, never an IDR slice" - ); - } - - #[test] - fn empty_payload_returns_unknown() { - let info = classify_video_tag(&[]); - assert!(!info.is_idr); - assert!(!info.is_seq_header); - assert_eq!(info.codec, VideoCodec::Unknown); - } - - #[test] - fn truncated_nal_does_not_panic_or_falsely_signal_idr() { - // Length prefix says 100 bytes follow, but only 4 are present. - // The walk must return cleanly (no IDR found) without panicking. - let truncated = avc_tag(1, &[vec![0x00, 0x00, 0x00, 100, 0x05]]); - let info = classify_video_tag(&truncated); - assert!(!info.is_idr); - } - - #[test] - fn enhanced_rtmp_keyframe_detected() { - // Enhanced RTMP: byte0 = IsEx(1) | FrameType(3, keyframe=1) | PacketType(4, CodedFrames=1) - let byte0 = 0x80 | (1u8 << 4) | 1; - let mut payload = vec![byte0]; - payload.extend_from_slice(b"hvc1"); - payload.extend_from_slice(&[0; 16]); - let info = classify_video_tag(&payload); - assert!(info.is_idr); - assert_eq!(info.codec, VideoCodec::Hevc); - } - - #[test] - fn enhanced_rtmp_seq_header_not_idr() { - // PacketType=0 (SequenceStart) - must not be flagged as IDR - let byte0 = 0x80 | (1u8 << 4); - let mut payload = vec![byte0]; - payload.extend_from_slice(b"av01"); - let info = classify_video_tag(&payload); - assert!(info.is_seq_header); - assert!(!info.is_idr); - assert_eq!(info.codec, VideoCodec::Av1); + fn legacy_avc_video_tags_are_classified() { + // avc idr detected from nal walk + { + // packet_type 1 = NALU; contains NAL type 5 (IDR slice) + let tag = avc_tag(1, &[nal(5, 100)]); + let info = classify_video_tag(&tag); + assert!(info.is_idr, "single IDR NAL must be detected"); + assert!(!info.is_seq_header); + assert_eq!(info.codec, VideoCodec::Avc); + } + // avc non idr not flagged + { + // NAL type 1 = non-IDR slice; encoder MIGHT lie via the FrameType + // nibble - we trust the NAL walk and refuse to call it an IDR. + let tag = avc_tag(1, &[nal(1, 50)]); + let info = classify_video_tag(&tag); + assert!(!info.is_idr); + } + // avc seq header flagged not idr + { + // packet_type 0 = AVCDecoderConfigurationRecord (SPS/PPS) + let tag = avc_tag(0, &[]); + let info = classify_video_tag(&tag); + assert!(info.is_seq_header); + assert!( + !info.is_idr, + "seq headers carry SPS/PPS, never an IDR slice" + ); + } + // avc multi nal payload detects idr anywhere + { + // A tag with [SEI, non-IDR slice, IDR slice]. The walk must + // keep going past the first non-IDR and still find the IDR. + let tag = avc_tag(1, &[nal(6, 20), nal(1, 30), nal(5, 50)]); + let info = classify_video_tag(&tag); + assert!(info.is_idr, "IDR in 3rd NAL must be detected"); + } + // truncated nal does not panic or falsely signal idr + { + // Length prefix says 100 bytes follow, but only 4 are present. + // The walk must return cleanly (no IDR found) without panicking. + let truncated = avc_tag(1, &[vec![0x00, 0x00, 0x00, 100, 0x05]]); + let info = classify_video_tag(&truncated); + assert!(!info.is_idr); + } + // a zero length nal at the end of a tag is not an idr and does not panic + { + // A length prefix of 0 as the last 4 bytes passes the "does the NAL + // fit" check (4 + 0 <= 4) and the walk then read the NAL header one + // byte past the slice. That runs on every published video tag, and + // with `panic = "abort"` one malformed frame killed the process. + let bare = [0x17, 0x01, 0, 0, 0, 0, 0, 0, 0]; + assert!(!classify_video_tag(&bare).is_idr); + + let mut after_a_slice = avc_tag(1, &[nal(1, 10)]); + after_a_slice.extend_from_slice(&[0, 0, 0, 0]); + assert!(!classify_video_tag(&after_a_slice).is_idr); + + // An empty NAL in the middle is skipped, so an IDR after it still counts. + let mut idr_after_empty = avc_tag(1, &[]); + idr_after_empty.extend_from_slice(&[0, 0, 0, 0]); + idr_after_empty.extend_from_slice(&nal(5, 8)); + assert!(classify_video_tag(&idr_after_empty).is_idr); + } + // avc legacy with unknown codec id returns unknown + { + // codec_id != 7 (we only understand AVC in legacy mode) + let tag = vec![0x12, 1, 0, 0, 0, 0x05]; // codec_id = 2 (Sorenson) + let info = classify_video_tag(&tag); + assert_eq!(info.codec, VideoCodec::Unknown); + assert!(!info.is_idr); + } + // empty payload returns unknown + { + let info = classify_video_tag(&[]); + assert!(!info.is_idr); + assert!(!info.is_seq_header); + assert_eq!(info.codec, VideoCodec::Unknown); + } + } + + /// Enhanced RTMP tags, single-track and multitrack: keyframes, sequence + /// headers and metadata are each told apart. + #[test] + fn enhanced_rtmp_video_tags_are_classified() { + // enhanced rtmp keyframe detected + { + // Enhanced RTMP: byte0 = IsEx(1) | FrameType(3, keyframe=1) | PacketType(4, CodedFrames=1) + let byte0 = 0x80 | (1u8 << 4) | 1; + let mut payload = vec![byte0]; + payload.extend_from_slice(b"hvc1"); + payload.extend_from_slice(&[0; 16]); + let info = classify_video_tag(&payload); + assert!(info.is_idr); + assert_eq!(info.codec, VideoCodec::Hevc); + } + // enhanced rtmp seq header not idr + { + // PacketType=0 (SequenceStart) - must not be flagged as IDR + let byte0 = 0x80 | (1u8 << 4); + let mut payload = vec![byte0]; + payload.extend_from_slice(b"av01"); + let info = classify_video_tag(&payload); + assert!(info.is_seq_header); + assert!(!info.is_idr); + assert_eq!(info.codec, VideoCodec::Av1); + } + // enhanced rtmp metadata packettype 4 not idr + { + // packet_type 4 = Metadata (mid-stream HDR colorInfo etc.). + // Even if the encoder sets FrameType=1, this packet carries no + // slice data and must not be treated as an IDR cut point. + let byte0 = 0x80 | (1u8 << 4) | 4; + let mut payload = vec![byte0]; + payload.extend_from_slice(b"hvc1"); + payload.extend_from_slice(&[0; 8]); + let info = classify_video_tag(&payload); + assert!(info.is_metadata); + assert!(!info.is_idr, "Metadata packets must not be IDR cuts"); + assert!(!info.is_seq_header); + assert_eq!(info.codec, VideoCodec::Hevc); + } + // enhanced rtmp multitrack codedframes is idr + { + // outer packet_type=6 (Multitrack), nested packet_type=1 + // (CodedFrames). FrameType=1 → real keyframe. + let byte0 = 0x80 | (1u8 << 4) | 6; + let byte1 = 0x01; // nested CodedFrames + let payload = vec![byte0, byte1, 0, 0, 0, 0]; + let info = classify_video_tag(&payload); + assert!(info.is_multitrack); + assert!(info.is_idr); + assert!(!info.is_seq_header); + } + // enhanced rtmp multitrack seqstart caches as seq header + { + // outer packet_type=6 (Multitrack), nested packet_type=0 + // (SequenceStart). This is the decoder-config tag for an + // Enhanced Broadcasting stream - MUST be flagged as seq + // header or the controller never caches it and every cut + // emits a stale or empty config to the destination, which + // then fails to decode the post-cut frames. + let byte0 = 0x80 | (1u8 << 4) | 6; // FrameType=1, packet_type=Multitrack + let byte1 = 0x00; // nested SequenceStart + let payload = vec![byte0, byte1, 0, 0, 0, 0]; + let info = classify_video_tag(&payload); + assert!(info.is_multitrack); + assert!(info.is_seq_header); + // FrameType=1 set on a seq header tag must NOT be flagged + // as IDR - seq headers carry decoder config, never slice + // data. Same rule as the single-track classifier. + assert!(!info.is_idr); + } } #[test] @@ -1488,14 +1575,88 @@ mod tests { ); } - #[test] - fn aac_seq_header_detected() { - // sound_format 10 (AAC), packet_type 0 (AudioSpecificConfig) - let payload = [0xA0, 0x00, 0x12, 0x10]; - let info = classify_audio_tag(&payload); - assert!(info.is_seq_header); - assert_eq!(info.codec, AudioCodec::Aac); - assert!(is_aac_seq_header(&payload)); + /// The audio config tag must be recognised in legacy, Enhanced single-track + /// and multitrack framing, with the track it belongs to. + #[test] + fn audio_sequence_headers_are_told_from_frames_in_every_framing() { + // aac seq header detected + { + // sound_format 10 (AAC), packet_type 0 (AudioSpecificConfig) + let payload = [0xA0, 0x00, 0x12, 0x10]; + let info = classify_audio_tag(&payload); + assert!(info.is_seq_header); + assert_eq!(info.codec, AudioCodec::Aac); + assert!(is_aac_seq_header(&payload)); + } + // enhanced single track seq header is detected + { + // The user-visible regression for issue #9 (post-v0.1.3 manual + // test): with VOD audio enabled, OBS sends the live track as + // Enhanced-RTMP single-track. v0.1.0..0.1.3 read PacketType + // from byte 1 (which is 'm' = 0x6D, low nibble 0x0D) and never + // matched the SequenceStart value - so the AudioSpecificConfig + // tag flowed into the ring like a normal frame and got + // evicted, leaving Twitch's decoder unconfigured. + let tag = enhanced_audio_single_track(0); + let info = classify_audio_tag(&tag); + assert!( + info.is_seq_header, + "Enhanced-RTMP single-track byte 0 = 0x90 must classify as seq header" + ); + assert!(!info.is_multitrack); + assert_eq!(info.codec, AudioCodec::Aac); + // sink.rs asks through the shim, which must agree. + assert!(is_aac_seq_header(&tag)); + } + // enhanced single track coded frame is not seq header + { + let tag = enhanced_audio_single_track(1); + let info = classify_audio_tag(&tag); + assert!(!info.is_seq_header); + assert!(!info.is_multitrack); + assert_eq!(info.codec, AudioCodec::Aac); + } + // multitrack audio seqstart detected as seq header + { + // Pre-Phase-A, this tag would have been treated as a regular + // runtime tag (is_seq_header=false) because the outer + // PacketType is 5 (Multitrack), not 0 (SequenceStart). With + // the nested-packet-type check it correctly reads as a seq + // header that needs caching for replay across cuts. + let tag = enhanced_audio_onetrack(0, 0); + let info = classify_audio_tag(&tag); + assert!( + info.is_seq_header, + "nested PT=0 must be flagged as seq header" + ); + assert!(info.is_multitrack); + assert_eq!(info.codec, AudioCodec::Aac); + } + // multitrack audio codedframes is not seq header + { + let tag = enhanced_audio_onetrack(0, 1); + let info = classify_audio_tag(&tag); + assert!(!info.is_seq_header); + assert!(info.is_multitrack); + } + // audio seq header track id reads onetrack byte 6 + { + // OBS writes TrackId at byte 6 of the audio tag payload + // (byte 0 = 0x95, byte 1 = mt|nested, bytes 2..6 = FourCC, + // byte 6 = TrackId). Our cache keys on this so a publisher + // reconnect can replay every track's config. + for track in 0u8..=3 { + let tag = enhanced_audio_onetrack(track, 0); + assert_eq!(audio_seq_header_track_id(&tag), track); + } + } + // audio seq header track id returns zero for legacy aac + { + // Legacy AAC seq header: sound_format=10, AACPacketType=0. + // No track-id field exists; helper must return 0. + let tag = vec![0xaf, 0x00, 0x12, 0x10, 0x56, 0xe5]; + assert_eq!(audio_seq_header_track_id(&tag), 0); + } } #[test] @@ -1511,64 +1672,6 @@ mod tests { // ── Additional edge cases ────────────────────────────────────── - #[test] - fn avc_multi_nal_payload_detects_idr_anywhere() { - // A tag with [SEI, non-IDR slice, IDR slice]. The walk must - // keep going past the first non-IDR and still find the IDR. - let tag = avc_tag(1, &[nal(6, 20), nal(1, 30), nal(5, 50)]); - let info = classify_video_tag(&tag); - assert!(info.is_idr, "IDR in 3rd NAL must be detected"); - } - - #[test] - fn enhanced_rtmp_metadata_packettype_4_not_idr() { - // packet_type 4 = Metadata (mid-stream HDR colorInfo etc.). - // Even if the encoder sets FrameType=1, this packet carries no - // slice data and must not be treated as an IDR cut point. - let byte0 = 0x80 | (1u8 << 4) | 4; - let mut payload = vec![byte0]; - payload.extend_from_slice(b"hvc1"); - payload.extend_from_slice(&[0; 8]); - let info = classify_video_tag(&payload); - assert!(info.is_metadata); - assert!(!info.is_idr, "Metadata packets must not be IDR cuts"); - assert!(!info.is_seq_header); - assert_eq!(info.codec, VideoCodec::Hevc); - } - - #[test] - fn enhanced_rtmp_multitrack_codedframes_is_idr() { - // outer packet_type=6 (Multitrack), nested packet_type=1 - // (CodedFrames). FrameType=1 → real keyframe. - let byte0 = 0x80 | (1u8 << 4) | 6; - let byte1 = 0x01; // nested CodedFrames - let payload = vec![byte0, byte1, 0, 0, 0, 0]; - let info = classify_video_tag(&payload); - assert!(info.is_multitrack); - assert!(info.is_idr); - assert!(!info.is_seq_header); - } - - #[test] - fn enhanced_rtmp_multitrack_seqstart_caches_as_seq_header() { - // outer packet_type=6 (Multitrack), nested packet_type=0 - // (SequenceStart). This is the decoder-config tag for an - // Enhanced Broadcasting stream - MUST be flagged as seq - // header or the controller never caches it and every cut - // emits a stale or empty config to the destination, which - // then fails to decode the post-cut frames. - let byte0 = 0x80 | (1u8 << 4) | 6; // FrameType=1, packet_type=Multitrack - let byte1 = 0x00; // nested SequenceStart - let payload = vec![byte0, byte1, 0, 0, 0, 0]; - let info = classify_video_tag(&payload); - assert!(info.is_multitrack); - assert!(info.is_seq_header); - // FrameType=1 set on a seq header tag must NOT be flagged - // as IDR - seq headers carry decoder config, never slice - // data. Same rule as the single-track classifier. - assert!(!info.is_idr); - } - // ── select_video_bytes: per-destination flatten policy ─────────── // // The pump uses this helper to decide what bytes to put on the wire @@ -1601,51 +1704,86 @@ mod tests { payload } + /// Twitch gets multitrack video as it came; every other platform gets the + /// main track flattened, and nothing from the other tracks. #[test] - fn select_video_bytes_passes_single_track_through_unchanged_in_both_modes() { - // Invariant 1: single-track tags never get rewritten. The - // pass_through flag is meaningless when there's nothing to - // flatten - and the borrow path means no copy happens either. - let tag = avc_single_track_keyframe_bytes(); - let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) - .expect("single-track always forwards"); - let youtube = - select_video_bytes(&tag, VideoEgress::Track(0)).expect("single-track always forwards"); - assert_eq!(twitch.as_ref(), tag.as_slice()); - assert_eq!(youtube.as_ref(), tag.as_slice()); - assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); - assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); - } - - #[test] - fn select_video_bytes_passes_multitrack_through_for_twitch() { - // Invariant 2: Twitch destinations get the raw multi-track - // bytes verbatim - that's what unlocks the transcoded ladder. - // True for every TrackId, not just the primary. Twitch Dual Format - // rides on this too: the vertical canvas is just more TrackIds on - // the wire (`canvas_index` only exists in the config JSON). - for track in 0u8..=4 { - let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + fn each_destination_gets_the_video_bytes_it_can_take() { + // select video bytes passes single track through unchanged in both modes + { + // Invariant 1: single-track tags never get rewritten. The + // pass_through flag is meaningless when there's nothing to + // flatten - and the borrow path means no copy happens either. + let tag = avc_single_track_keyframe_bytes(); let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) - .unwrap_or_else(|| panic!("twitch must forward track {track}")); + .expect("single-track always forwards"); + let youtube = select_video_bytes(&tag, VideoEgress::Track(0)) + .expect("single-track always forwards"); assert_eq!(twitch.as_ref(), tag.as_slice()); + assert_eq!(youtube.as_ref(), tag.as_slice()); assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); + assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); + } + // select video bytes passes multitrack through for twitch + { + // Invariant 2: Twitch destinations get the raw multi-track + // bytes verbatim - that's what unlocks the transcoded ladder. + // True for every TrackId, not just the primary. Twitch Dual Format + // rides on this too: the vertical canvas is just more TrackIds on + // the wire (`canvas_index` only exists in the config JSON). + for track in 0u8..=4 { + let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) + .unwrap_or_else(|| panic!("twitch must forward track {track}")); + assert_eq!(twitch.as_ref(), tag.as_slice()); + assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); + } + } + // select video bytes flattens track0 multitrack for non twitch + { + // Defensive path: an encoder that sends the *primary* as + // OneTrack-format TrackId 0 (rather than as a separate legacy + // AVC tag) still gets a flattened single-track tag for + // non-Twitch destinations. In the wild OBS sends the primary + // as legacy, so this path rarely fires - but we want a future + // encoder change not to silently blank YouTube. + let tag = enhanced_rtmp_onetrack_video_bytes_track(0); + let youtube = + select_video_bytes(&tag, VideoEgress::Track(0)).expect("track 0 must be forwarded"); + assert_eq!(youtube.as_ref(), flat_hvc1_keyframe().as_slice()); + assert!(matches!(youtube, std::borrow::Cow::Owned(_))); + } + // select video bytes drops nonzero track multitrack for non twitch + { + // The core fix: OBS sends the simulcast ladder as OneTrack + // tags with TrackId 1..N, one tag per resolution per frame. + // Forwarding every one to YouTube delivered N frames per PTS + // → decoder storm → reconnect cascade. These tags must be + // dropped; the primary arrives separately as a legacy / + // Enhanced single-track tag. With Dual Format, TrackId 1+ also + // carries the vertical canvas, which a horizontal dest never wants. + for track in 1u8..=4 { + let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + assert!( + select_video_bytes(&tag, VideoEgress::Track(0)).is_none(), + "TrackId {track} must be dropped for non-Twitch", + ); + } + } + // select video bytes falls back to raw when flatten cannot parse + { + // Pathological multi-track tag (declares multi-track but the + // payload is too short to contain a valid inner structure). + // The pump must NOT crash and must NOT swallow the tag - + // letting it through unchanged matches the old ingest-side + // fallback so the destination gets a chance to surface the + // error properly. Truncated below the TrackId byte means we + // can't tell which track it is; treat that as "forward". + let truncated = vec![0x80 | (1u8 << 4) | 6, 0]; // 2 bytes total + let youtube = select_video_bytes(&truncated, VideoEgress::Track(0)) + .expect("truncated tags must still forward"); + assert_eq!(youtube.as_ref(), truncated.as_slice()); + assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); } - } - - #[test] - fn select_video_bytes_flattens_track0_multitrack_for_non_twitch() { - // Defensive path: an encoder that sends the *primary* as - // OneTrack-format TrackId 0 (rather than as a separate legacy - // AVC tag) still gets a flattened single-track tag for - // non-Twitch destinations. In the wild OBS sends the primary - // as legacy, so this path rarely fires - but we want a future - // encoder change not to silently blank YouTube. - let tag = enhanced_rtmp_onetrack_video_bytes_track(0); - let youtube = - select_video_bytes(&tag, VideoEgress::Track(0)).expect("track 0 must be forwarded"); - assert_eq!(youtube.as_ref(), flat_hvc1_keyframe().as_slice()); - assert!(matches!(youtube, std::borrow::Cow::Owned(_))); } /// What `enhanced_rtmp_onetrack_video_bytes_track(_)` must flatten to, @@ -1707,24 +1845,6 @@ mod tests { ); } - #[test] - fn select_video_bytes_drops_nonzero_track_multitrack_for_non_twitch() { - // The core fix: OBS sends the simulcast ladder as OneTrack - // tags with TrackId 1..N, one tag per resolution per frame. - // Forwarding every one to YouTube delivered N frames per PTS - // → decoder storm → reconnect cascade. These tags must be - // dropped; the primary arrives separately as a legacy / - // Enhanced single-track tag. With Dual Format, TrackId 1+ also - // carries the vertical canvas, which a horizontal dest never wants. - for track in 1u8..=4 { - let tag = enhanced_rtmp_onetrack_video_bytes_track(track); - assert!( - select_video_bytes(&tag, VideoEgress::Track(0)).is_none(), - "TrackId {track} must be dropped for non-Twitch", - ); - } - } - // ── Vertical-canvas selection: SPS orientation + Track(n) routing ── // // A vertical destination forwards ONLY the portrait canvas's track, @@ -1852,20 +1972,71 @@ mod tests { ); } - #[test] - fn sps_dimensions_returns_none_on_unparseable_input() { - // Empty, truncated, and non-AVC inputs must all fail cleanly with - // no panic (this runs on hostile wire bytes). - assert_eq!(sps_dimensions(&[]), None); - assert_eq!(sps_dimensions(&[0x17, 0x00]), None); - let mut truncated = legacy_avc_seq_header(40, 30); - truncated.truncate(9); // cut into the SPS - assert_eq!(sps_dimensions(&truncated), None); - // HEVC single-track seq header (hvc1) is not AVC - unparseable. - let mut hevc = vec![0x90u8]; - hevc.extend_from_slice(b"hvc1"); - hevc.extend_from_slice(&[0u8; 16]); - assert_eq!(sps_dimensions(&hevc), None); + /// A sequence header that can't be read, or lies about its size, yields nothing. + #[test] + fn a_damaged_sps_gives_no_size_and_never_panics() { + // sps dimensions returns none on unparseable input + { + // Empty, truncated, and non-AVC inputs must all fail cleanly with + // no panic (this runs on hostile wire bytes). + assert_eq!(sps_dimensions(&[]), None); + assert_eq!(sps_dimensions(&[0x17, 0x00]), None); + let mut truncated = legacy_avc_seq_header(40, 30); + truncated.truncate(9); // cut into the SPS + assert_eq!(sps_dimensions(&truncated), None); + // HEVC single-track seq header (hvc1) is not AVC - unparseable. + let mut hevc = vec![0x90u8]; + hevc.extend_from_slice(b"hvc1"); + hevc.extend_from_slice(&[0u8; 16]); + assert_eq!(sps_dimensions(&hevc), None); + } + // sps dimensions rejects out of range scaling deltas without panicking + { + // delta_scale is -128..=127 by spec. A hostile SPS can carry up to + // +/-(2^31 - 1), and `last_scale + delta + 256` overflowed i32: a + // panic in debug, a silently wrong parse in release. Nonsense like + // that means the SPS is not one we can size, so the parse gives up. + for bad_delta in [128, -129, i32::MAX, -i32::MAX] { + let mut f = SpsFields::progressive(120, 68); + f.scaling_lists = vec![(0, vec![bad_delta, 0])]; + assert_eq!(rich_sps_dimensions(&f), None, "delta {bad_delta}"); + } + } + // sps dimensions huge crop returns none not panic + { + // A malformed SPS can carry near-u32::MAX frame-crop Exp-Golomb + // values; the crop math must stay checked so this returns None + // instead of overflow-panicking in debug builds. + let mut w = BitWriter::new(); + w.put_bits(66, 8); // baseline profile (skips the high-profile block) + w.put_bits(0, 8); + w.put_bits(30, 8); + w.put_ue(0); // sps_id + w.put_ue(0); // log2_max_frame_num_minus4 + w.put_ue(0); // pic_order_cnt_type + w.put_ue(0); // log2_max_pic_order_cnt_lsb_minus4 + w.put_ue(0); // max_num_ref_frames + w.put_bit(0); // gaps_in_frame_num_value_allowed_flag + w.put_ue(39); // pic_width_in_mbs_minus1 + w.put_ue(21); // pic_height_in_map_units_minus1 + w.put_bit(1); // frame_mbs_only_flag + w.put_bit(0); // direct_8x8_inference_flag + w.put_bit(1); // frame_cropping_flag + w.put_ue(4_000_000_000); // crop_left + w.put_ue(4_000_000_000); // crop_right -> left + right overflows u32 + w.put_ue(0); + w.put_ue(0); + w.put_bit(0); // vui_parameters_present_flag + let mut sps = vec![0x67u8]; + sps.extend(w.finish()); + let mut cfg = vec![1u8, 66, 0, 30, 0xFF, 0xE1]; + cfg.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + cfg.extend_from_slice(&sps); + cfg.push(0); + let mut tag = vec![0x17u8, 0x00, 0, 0, 0]; + tag.extend(cfg); + assert_eq!(sps_dimensions(&tag), None); + } } /// Read back the size from the reconnect screen's own HEVC parameter @@ -2046,19 +2217,6 @@ mod tests { assert_eq!(rich_sps_dimensions(&f), Some((1920, 1080))); } - #[test] - fn sps_dimensions_rejects_out_of_range_scaling_deltas_without_panicking() { - // delta_scale is -128..=127 by spec. A hostile SPS can carry up to - // +/-(2^31 - 1), and `last_scale + delta + 256` overflowed i32: a - // panic in debug, a silently wrong parse in release. Nonsense like - // that means the SPS is not one we can size, so the parse gives up. - for bad_delta in [128, -129, i32::MAX, -i32::MAX] { - let mut f = SpsFields::progressive(120, 68); - f.scaling_lists = vec![(0, vec![bad_delta, 0])]; - assert_eq!(rich_sps_dimensions(&f), None, "delta {bad_delta}"); - } - } - #[test] fn sps_dimensions_applies_cropping_per_chroma_format() { // Portrait 1080x1920 is coded 1088 wide; crop_right 4 x 2 (4:2:0). @@ -2118,100 +2276,6 @@ mod tests { assert_eq!(strip_emulation_prevention(&[0x42, 3]), [0x42, 3]); } - #[test] - fn sps_dimensions_huge_crop_returns_none_not_panic() { - // A malformed SPS can carry near-u32::MAX frame-crop Exp-Golomb - // values; the crop math must stay checked so this returns None - // instead of overflow-panicking in debug builds. - let mut w = BitWriter::new(); - w.put_bits(66, 8); // baseline profile (skips the high-profile block) - w.put_bits(0, 8); - w.put_bits(30, 8); - w.put_ue(0); // sps_id - w.put_ue(0); // log2_max_frame_num_minus4 - w.put_ue(0); // pic_order_cnt_type - w.put_ue(0); // log2_max_pic_order_cnt_lsb_minus4 - w.put_ue(0); // max_num_ref_frames - w.put_bit(0); // gaps_in_frame_num_value_allowed_flag - w.put_ue(39); // pic_width_in_mbs_minus1 - w.put_ue(21); // pic_height_in_map_units_minus1 - w.put_bit(1); // frame_mbs_only_flag - w.put_bit(0); // direct_8x8_inference_flag - w.put_bit(1); // frame_cropping_flag - w.put_ue(4_000_000_000); // crop_left - w.put_ue(4_000_000_000); // crop_right -> left + right overflows u32 - w.put_ue(0); - w.put_ue(0); - w.put_bit(0); // vui_parameters_present_flag - let mut sps = vec![0x67u8]; - sps.extend(w.finish()); - let mut cfg = vec![1u8, 66, 0, 30, 0xFF, 0xE1]; - cfg.extend_from_slice(&(sps.len() as u16).to_be_bytes()); - cfg.extend_from_slice(&sps); - cfg.push(0); - let mut tag = vec![0x17u8, 0x00, 0, 0, 0]; - tag.extend(cfg); - assert_eq!(sps_dimensions(&tag), None); - } - - #[test] - fn a_zero_length_nal_at_the_end_of_a_tag_is_not_an_idr_and_does_not_panic() { - // A length prefix of 0 as the last 4 bytes passes the "does the NAL - // fit" check (4 + 0 <= 4) and the walk then read the NAL header one - // byte past the slice. That runs on every published video tag, and - // with `panic = "abort"` one malformed frame killed the process. - let bare = [0x17, 0x01, 0, 0, 0, 0, 0, 0, 0]; - assert!(!classify_video_tag(&bare).is_idr); - - let mut after_a_slice = avc_tag(1, &[nal(1, 10)]); - after_a_slice.extend_from_slice(&[0, 0, 0, 0]); - assert!(!classify_video_tag(&after_a_slice).is_idr); - - // An empty NAL in the middle is skipped, so an IDR after it still counts. - let mut idr_after_empty = avc_tag(1, &[]); - idr_after_empty.extend_from_slice(&[0, 0, 0, 0]); - idr_after_empty.extend_from_slice(&nal(5, 8)); - assert!(classify_video_tag(&idr_after_empty).is_idr); - } - - /// Deterministic LCG for the fuzz tests: fixed seed, no dependency, and a - /// failure reproduces from the iteration number alone. - struct Lcg(u64); - - impl Lcg { - fn next_u32(&mut self) -> u32 { - self.0 = self - .0 - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - (self.0 >> 33) as u32 - } - - fn below(&mut self, n: u32) -> u32 { - self.next_u32() % n - } - - fn pick(&mut self, items: &[T]) -> T { - items[self.below(items.len() as u32) as usize] - } - - /// Zero- and 0xFF-heavy bytes. Uniform bytes almost never form a - /// zero length prefix or a long Exp-Golomb run, which are exactly the - /// shapes that break parsers. - fn byte(&mut self) -> u8 { - match self.below(8) { - 0..=2 => 0x00, - 3 => 0xFF, - _ => self.next_u32() as u8, - } - } - - fn bytes(&mut self, max_len: u32) -> Vec { - let n = self.below(max_len + 1); - (0..n).map(|_| self.byte()).collect() - } - } - /// Length-prefixed NALs whose declared lengths are sometimes honest, /// sometimes zero and sometimes past the end of the tag. fn fuzz_nal_units(rng: &mut Lcg) -> Vec { @@ -2474,101 +2538,107 @@ mod tests { assert_eq!(seq_header_codec(&[]), VideoCodec::Unknown); } + /// Which track a 9:16 destination follows. #[test] - fn detect_vertical_primary_track_picks_largest_portrait() { - let mut headers = std::collections::BTreeMap::new(); - headers.insert(0u8, legacy_avc_seq_header(120, 68)); // 1920x1088 landscape - headers.insert(3u8, onetrack_avc_seq_header(3, 34, 60)); // 544x960 portrait - headers.insert(4u8, onetrack_avc_seq_header(4, 68, 120)); // 1088x1920 portrait (bigger) - assert_eq!(detect_vertical_primary_track(&headers), Some(4)); - } - - #[test] - fn detect_vertical_primary_track_none_when_all_landscape() { - let mut headers = std::collections::BTreeMap::new(); - headers.insert(0u8, legacy_avc_seq_header(120, 68)); - headers.insert(1u8, onetrack_avc_seq_header(1, 80, 45)); - assert_eq!(detect_vertical_primary_track(&headers), None); + fn the_vertical_track_is_the_largest_portrait_one() { + // detect vertical primary track picks largest portrait + { + let mut headers = std::collections::BTreeMap::new(); + headers.insert(0u8, legacy_avc_seq_header(120, 68)); // 1920x1088 landscape + headers.insert(3u8, onetrack_avc_seq_header(3, 34, 60)); // 544x960 portrait + headers.insert(4u8, onetrack_avc_seq_header(4, 68, 120)); // 1088x1920 portrait (bigger) + assert_eq!(detect_vertical_primary_track(&headers), Some(4)); + } + // detect vertical primary track none when all landscape + { + let mut headers = std::collections::BTreeMap::new(); + headers.insert(0u8, legacy_avc_seq_header(120, 68)); + headers.insert(1u8, onetrack_avc_seq_header(1, 80, 45)); + assert_eq!(detect_vertical_primary_track(&headers), None); + } } + /// A 9:16 destination is sent its canvas's track, rewritten so any platform reads it. #[test] - fn select_video_bytes_vertical_forwards_only_target_track() { - // Track(1) keeps OneTrack TrackId 1 (flattened) and drops every - // other track plus the legacy horizontal primary. - let track1 = enhanced_rtmp_onetrack_video_bytes_track(1); - let track2 = enhanced_rtmp_onetrack_video_bytes_track(2); - let legacy_horizontal = avc_single_track_keyframe_bytes(); + fn a_vertical_destination_gets_only_its_own_canvas() { + // select video bytes vertical forwards only target track + { + // Track(1) keeps OneTrack TrackId 1 (flattened) and drops every + // other track plus the legacy horizontal primary. + let track1 = enhanced_rtmp_onetrack_video_bytes_track(1); + let track2 = enhanced_rtmp_onetrack_video_bytes_track(2); + let legacy_horizontal = avc_single_track_keyframe_bytes(); - let kept = select_video_bytes(&track1, VideoEgress::Track(1)) - .expect("vertical target track must forward"); - assert_eq!(kept.as_ref(), flat_hvc1_keyframe().as_slice()); + let kept = select_video_bytes(&track1, VideoEgress::Track(1)) + .expect("vertical target track must forward"); + assert_eq!(kept.as_ref(), flat_hvc1_keyframe().as_slice()); - assert!( - select_video_bytes(&track2, VideoEgress::Track(1)).is_none(), - "non-target OneTrack rung must be dropped for vertical" - ); - assert!( - select_video_bytes(&legacy_horizontal, VideoEgress::Track(1)).is_none(), - "legacy horizontal primary must be dropped for a vertical destination" - ); - } - - #[test] - fn select_video_bytes_vertical_avc_rewrites_to_legacy() { - // A OneTrack avc1 vertical track must reach a non-Twitch dest as - // LEGACY AVC (0x17/0x27 ...), not Enhanced-RTMP - YouTube's vertical - // ingest is flaky with E-RTMP avc1 and aborts on a ~11 s cycle. - - // Sequence header -> legacy AVC seq header, config record intact. - let seq = onetrack_avc_seq_header(1, 30, 40); - let out = select_video_bytes(&seq, VideoEgress::Track(1)) - .expect("vertical avc seq header forwards"); - assert_eq!(out[0], 0x17, "legacy AVC seq-header frame byte"); - assert_eq!(out[1], 0x00, "AVCPacketType: sequence header"); - assert_eq!(&out[2..5], &[0, 0, 0], "composition time zero"); - assert_eq!(&out[5..], &build_avc_config(30, 40)[..]); - - // CodedFramesX keyframe (no composition time) -> legacy 0x17 NALU. - let mut kf = vec![0x96u8, 0x03]; // key|Multitrack, OneTrack|CodedFramesX - kf.extend_from_slice(b"avc1"); - kf.push(1); - kf.extend_from_slice(&[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); // len-prefixed IDR - let out = select_video_bytes(&kf, VideoEgress::Track(1)).expect("keyframe forwards"); - assert_eq!(out[0], 0x17, "legacy keyframe head"); - assert_eq!(out[1], 0x01, "AVCPacketType: NALU"); - assert_eq!( - &out[2..5], - &[0, 0, 0], - "CodedFramesX carries composition time 0" - ); - assert_eq!(&out[5..], &[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); - - // CodedFrames inter frame preserves the 3-byte composition time. - let mut inter = vec![0xA6u8, 0x01]; // inter|Multitrack, OneTrack|CodedFrames - inter.extend_from_slice(b"avc1"); - inter.push(1); - inter.extend_from_slice(&[0x00, 0x00, 0x10]); // composition time - inter.extend_from_slice(&[0, 0, 0, 3, 0x41, 9, 9]); // len-prefixed P NAL - let out = select_video_bytes(&inter, VideoEgress::Track(1)).expect("inter forwards"); - assert_eq!(out[0], 0x27, "legacy inter head"); - assert_eq!(out[1], 0x01); - assert_eq!( - &out[2..5], - &[0x00, 0x00, 0x10], - "composition time preserved" - ); - assert_eq!(&out[5..], &[0, 0, 0, 3, 0x41, 9, 9]); - - // A OneTrack avc1 metadata/colour packet has no legacy equivalent - // and is dropped rather than mixing E-RTMP into a legacy stream. - let mut meta = vec![0x96u8, 0x04]; // OneTrack | Metadata(4) - meta.extend_from_slice(b"avc1"); - meta.push(1); - meta.extend_from_slice(&[1, 2, 3, 4]); - assert!( - select_video_bytes(&meta, VideoEgress::Track(1)).is_none(), - "avc1 metadata packet must be dropped for a legacy vertical stream" - ); + assert!( + select_video_bytes(&track2, VideoEgress::Track(1)).is_none(), + "non-target OneTrack rung must be dropped for vertical" + ); + assert!( + select_video_bytes(&legacy_horizontal, VideoEgress::Track(1)).is_none(), + "legacy horizontal primary must be dropped for a vertical destination" + ); + } + // select video bytes vertical avc rewrites to legacy + { + // A OneTrack avc1 vertical track must reach a non-Twitch dest as + // LEGACY AVC (0x17/0x27 ...), not Enhanced-RTMP - YouTube's vertical + // ingest is flaky with E-RTMP avc1 and aborts on a ~11 s cycle. + + // Sequence header -> legacy AVC seq header, config record intact. + let seq = onetrack_avc_seq_header(1, 30, 40); + let out = select_video_bytes(&seq, VideoEgress::Track(1)) + .expect("vertical avc seq header forwards"); + assert_eq!(out[0], 0x17, "legacy AVC seq-header frame byte"); + assert_eq!(out[1], 0x00, "AVCPacketType: sequence header"); + assert_eq!(&out[2..5], &[0, 0, 0], "composition time zero"); + assert_eq!(&out[5..], &build_avc_config(30, 40)[..]); + + // CodedFramesX keyframe (no composition time) -> legacy 0x17 NALU. + let mut kf = vec![0x96u8, 0x03]; // key|Multitrack, OneTrack|CodedFramesX + kf.extend_from_slice(b"avc1"); + kf.push(1); + kf.extend_from_slice(&[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); // len-prefixed IDR + let out = select_video_bytes(&kf, VideoEgress::Track(1)).expect("keyframe forwards"); + assert_eq!(out[0], 0x17, "legacy keyframe head"); + assert_eq!(out[1], 0x01, "AVCPacketType: NALU"); + assert_eq!( + &out[2..5], + &[0, 0, 0], + "CodedFramesX carries composition time 0" + ); + assert_eq!(&out[5..], &[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); + + // CodedFrames inter frame preserves the 3-byte composition time. + let mut inter = vec![0xA6u8, 0x01]; // inter|Multitrack, OneTrack|CodedFrames + inter.extend_from_slice(b"avc1"); + inter.push(1); + inter.extend_from_slice(&[0x00, 0x00, 0x10]); // composition time + inter.extend_from_slice(&[0, 0, 0, 3, 0x41, 9, 9]); // len-prefixed P NAL + let out = select_video_bytes(&inter, VideoEgress::Track(1)).expect("inter forwards"); + assert_eq!(out[0], 0x27, "legacy inter head"); + assert_eq!(out[1], 0x01); + assert_eq!( + &out[2..5], + &[0x00, 0x00, 0x10], + "composition time preserved" + ); + assert_eq!(&out[5..], &[0, 0, 0, 3, 0x41, 9, 9]); + + // A OneTrack avc1 metadata/colour packet has no legacy equivalent + // and is dropped rather than mixing E-RTMP into a legacy stream. + let mut meta = vec![0x96u8, 0x04]; // OneTrack | Metadata(4) + meta.extend_from_slice(b"avc1"); + meta.push(1); + meta.extend_from_slice(&[1, 2, 3, 4]); + assert!( + select_video_bytes(&meta, VideoEgress::Track(1)).is_none(), + "avc1 metadata packet must be dropped for a legacy vertical stream" + ); + } } // ── is_primary_video_idr: cut-target gate for EB ladder cuts ── @@ -2580,86 +2650,60 @@ mod tests { // After: only primary-track IDRs are candidates, the legacy // decoder always has its anchor at the cut boundary. - #[test] - fn is_primary_video_idr_accepts_legacy_avc_keyframe() { - let tag = avc_single_track_keyframe_bytes(); - assert!(is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_rejects_legacy_avc_inter_frame() { - // FrameType=2 (inter) + CodecID=7 (AVC) = 0x27. Same shape as - // the keyframe builder above, just with the leading byte flipped. - let mut tag = avc_single_track_keyframe_bytes(); - tag[0] = 0x27; - assert!(!is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_accepts_onetrack_multitrack_track_0() { - // EB ladder primary rung at TrackId=0 - the cut anchor we want. - let tag = enhanced_rtmp_onetrack_video_bytes_track(0); - assert!(is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_rejects_onetrack_multitrack_nonzero_tracks() { - // EB ladder rungs 1..=4 each carry their own IDR cadence but - // must NOT be picked as a cut anchor - if the cut lands here - // the legacy decoder on the destination has no reference. - // Twitch Dual Format's vertical canvas is one of these TrackIds, - // so cuts are anchored to the horizontal canvas only; a vertical - // cut is clean because OBS aligns both canvases' keyframes. If - // cuts ever become canvas-aware, this is the guard that must flip. - for track in 1u8..=4 { - let tag = enhanced_rtmp_onetrack_video_bytes_track(track); - assert!( - !is_primary_video_idr(&tag), - "TrackId {track} must not be a cut candidate" - ); + /// A delay may only land on a keyframe of the main picture, in any framing. + #[test] + fn only_the_main_tracks_keyframes_are_primary_idrs() { + // is primary video idr accepts legacy avc keyframe + { + let tag = avc_single_track_keyframe_bytes(); + assert!(is_primary_video_idr(&tag)); + } + // is primary video idr rejects legacy avc inter frame + { + // FrameType=2 (inter) + CodecID=7 (AVC) = 0x27. Same shape as + // the keyframe builder above, just with the leading byte flipped. + let mut tag = avc_single_track_keyframe_bytes(); + tag[0] = 0x27; + assert!(!is_primary_video_idr(&tag)); + } + // is primary video idr accepts onetrack multitrack track 0 + { + // EB ladder primary rung at TrackId=0 - the cut anchor we want. + let tag = enhanced_rtmp_onetrack_video_bytes_track(0); + assert!(is_primary_video_idr(&tag)); + } + // is primary video idr rejects onetrack multitrack nonzero tracks + { + // EB ladder rungs 1..=4 each carry their own IDR cadence but + // must NOT be picked as a cut anchor - if the cut lands here + // the legacy decoder on the destination has no reference. + // Twitch Dual Format's vertical canvas is one of these TrackIds, + // so cuts are anchored to the horizontal canvas only; a vertical + // cut is clean because OBS aligns both canvases' keyframes. If + // cuts ever become canvas-aware, this is the guard that must flip. + for track in 1u8..=4 { + let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + assert!( + !is_primary_video_idr(&tag), + "TrackId {track} must not be a cut candidate" + ); + } + } + // is primary video idr rejects enhanced p frame + { + // Enhanced-RTMP single-track inter-frame: IsEx | FrameType=2 | + // PacketType=CodedFrames(1) -> bits assembled as 0xA1. + let tag = vec![0x80 | (2u8 << 4) | 1, b'a', b'v', b'c', b'1', 0xAA, 0xBB]; + assert!(!is_primary_video_idr(&tag)); + } + // is primary video idr handles truncated multitrack gracefully + { + // Multitrack header declared but payload too short to read the + // TrackId byte - safer to refuse cut-candidacy than to land on + // an unknown track. + let truncated = vec![0x80 | (1u8 << 4) | 6, 0x01, b'a', b'v', b'c', b'1']; + assert!(!is_primary_video_idr(&truncated)); } - } - - #[test] - fn is_primary_video_idr_rejects_enhanced_p_frame() { - // Enhanced-RTMP single-track inter-frame: IsEx | FrameType=2 | - // PacketType=CodedFrames(1) -> bits assembled as 0xA1. - let tag = vec![0x80 | (2u8 << 4) | 1, b'a', b'v', b'c', b'1', 0xAA, 0xBB]; - assert!(!is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_handles_truncated_multitrack_gracefully() { - // Multitrack header declared but payload too short to read the - // TrackId byte - safer to refuse cut-candidacy than to land on - // an unknown track. - let truncated = vec![0x80 | (1u8 << 4) | 6, 0x01, b'a', b'v', b'c', b'1']; - assert!(!is_primary_video_idr(&truncated)); - } - - #[test] - fn select_video_bytes_falls_back_to_raw_when_flatten_cannot_parse() { - // Pathological multi-track tag (declares multi-track but the - // payload is too short to contain a valid inner structure). - // The pump must NOT crash and must NOT swallow the tag - - // letting it through unchanged matches the old ingest-side - // fallback so the destination gets a chance to surface the - // error properly. Truncated below the TrackId byte means we - // can't tell which track it is; treat that as "forward". - let truncated = vec![0x80 | (1u8 << 4) | 6, 0]; // 2 bytes total - let youtube = select_video_bytes(&truncated, VideoEgress::Track(0)) - .expect("truncated tags must still forward"); - assert_eq!(youtube.as_ref(), truncated.as_slice()); - assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); - } - - #[test] - fn avc_legacy_with_unknown_codec_id_returns_unknown() { - // codec_id != 7 (we only understand AVC in legacy mode) - let tag = vec![0x12, 1, 0, 0, 0, 0x05]; // codec_id = 2 (Sorenson) - let info = classify_video_tag(&tag); - assert_eq!(info.codec, VideoCodec::Unknown); - assert!(!info.is_idr); } // ── Enhanced-RTMP audio (VOD audio path) ─────────────────────── @@ -2719,108 +2763,70 @@ mod tests { payload } + /// The audio twin of the video rule: Twitch gets every track, others get + /// the one they asked for as plain AAC, or the live track when theirs isn't sent. #[test] - fn enhanced_single_track_seq_header_is_detected() { - // The user-visible regression for issue #9 (post-v0.1.3 manual - // test): with VOD audio enabled, OBS sends the live track as - // Enhanced-RTMP single-track. v0.1.0..0.1.3 read PacketType - // from byte 1 (which is 'm' = 0x6D, low nibble 0x0D) and never - // matched the SequenceStart value - so the AudioSpecificConfig - // tag flowed into the ring like a normal frame and got - // evicted, leaving Twitch's decoder unconfigured. - let tag = enhanced_audio_single_track(0); - let info = classify_audio_tag(&tag); - assert!( - info.is_seq_header, - "Enhanced-RTMP single-track byte 0 = 0x90 must classify as seq header" - ); - assert!(!info.is_multitrack); - assert_eq!(info.codec, AudioCodec::Aac); - // sink.rs asks through the shim, which must agree. - assert!(is_aac_seq_header(&tag)); - } - - #[test] - fn enhanced_single_track_coded_frame_is_not_seq_header() { - let tag = enhanced_audio_single_track(1); - let info = classify_audio_tag(&tag); - assert!(!info.is_seq_header); - assert!(!info.is_multitrack); - assert_eq!(info.codec, AudioCodec::Aac); - } - - #[test] - fn multitrack_audio_seqstart_detected_as_seq_header() { - // Pre-Phase-A, this tag would have been treated as a regular - // runtime tag (is_seq_header=false) because the outer - // PacketType is 5 (Multitrack), not 0 (SequenceStart). With - // the nested-packet-type check it correctly reads as a seq - // header that needs caching for replay across cuts. - let tag = enhanced_audio_onetrack(0, 0); - let info = classify_audio_tag(&tag); - assert!( - info.is_seq_header, - "nested PT=0 must be flagged as seq header" - ); - assert!(info.is_multitrack); - assert_eq!(info.codec, AudioCodec::Aac); - } - - #[test] - fn multitrack_audio_codedframes_is_not_seq_header() { - let tag = enhanced_audio_onetrack(0, 1); - let info = classify_audio_tag(&tag); - assert!(!info.is_seq_header); - assert!(info.is_multitrack); - } - - #[test] - fn audio_seq_header_track_id_reads_onetrack_byte_6() { - // OBS writes TrackId at byte 6 of the audio tag payload - // (byte 0 = 0x95, byte 1 = mt|nested, bytes 2..6 = FourCC, - // byte 6 = TrackId). Our cache keys on this so a publisher - // reconnect can replay every track's config. - for track in 0u8..=3 { - let tag = enhanced_audio_onetrack(track, 0); - assert_eq!(audio_seq_header_track_id(&tag), track); + fn each_destination_gets_the_audio_track_it_asked_for() { + // select audio bytes single track always forwards + { + // Legacy AAC / Enhanced single-track is the only audio there is, so it + // forwards unchanged for EVERY policy - including Track(1): a live + // track beats a silent destination when the 2nd track never arrived. + let legacy_aac = vec![0xaf, 0x01, 0x12, 0x10, 0x56]; + for egress in [ + AudioEgress::Passthrough, + AudioEgress::Track(0), + AudioEgress::Track(1), + ] { + let out = select_audio_bytes(&legacy_aac, egress, false) + .expect("single-track audio must forward"); + assert_eq!(out.as_ref(), legacy_aac.as_slice()); + assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + } } - } - - #[test] - fn audio_seq_header_track_id_returns_zero_for_legacy_aac() { - // Legacy AAC seq header: sound_format=10, AACPacketType=0. - // No track-id field exists; helper must return 0. - let tag = vec![0xaf, 0x00, 0x12, 0x10, 0x56, 0xe5]; - assert_eq!(audio_seq_header_track_id(&tag), 0); - } - - #[test] - fn select_audio_bytes_single_track_always_forwards() { - // Legacy AAC / Enhanced single-track is the only audio there is, so it - // forwards unchanged for EVERY policy - including Track(1): a live - // track beats a silent destination when the 2nd track never arrived. - let legacy_aac = vec![0xaf, 0x01, 0x12, 0x10, 0x56]; - for egress in [ - AudioEgress::Passthrough, - AudioEgress::Track(0), - AudioEgress::Track(1), - ] { - let out = select_audio_bytes(&legacy_aac, egress, false) - .expect("single-track audio must forward"); - assert_eq!(out.as_ref(), legacy_aac.as_slice()); - assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + // select audio bytes falls back to live track when target absent + { + // A dest pinned to Track(1) but a stream that only carries the live + // track (OneTrack TrackId 0) still gets audio: the requested track is + // missing, so it falls back to the live track 0, flattened to legacy. + let live_only = enhanced_audio_onetrack(0, 1); + let out = select_audio_bytes(&live_only, AudioEgress::Track(1), false) + .expect("must fall back to the live track, not go silent"); + assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); + } + // select audio bytes passes multitrack through for twitch + { + // Twitch destination (Passthrough): every track's bytes flow + // through bit-faithfully so Twitch's VOD-audio slot keeps its feed. + for track in 0u8..=3 { + let tag = enhanced_audio_onetrack(track, 1); + let out = select_audio_bytes(&tag, AudioEgress::Passthrough, true) + .unwrap_or_else(|| panic!("twitch must forward track {track}")); + assert_eq!(out.as_ref(), tag.as_slice()); + } + } + // select audio bytes drops untargeted track for single track dest + { + // A single-track destination pinned to Track(0) drops every other + // OneTrack rung (e.g. the VOD track), which is meaningless to + // YouTube / Kick. + for track in 1u8..=3 { + let tag = enhanced_audio_onetrack(track, 1); + assert!( + select_audio_bytes(&tag, AudioEgress::Track(0), true).is_none(), + "TrackId {track} must be dropped when the dest wants Track(0)", + ); + } + } + // select audio bytes flattens target track to legacy aac + { + // The clean/second audio track routed to a single-track dest is + // flattened to legacy AAC (0xAF ...), the universally-accepted form. + let live = enhanced_audio_onetrack(0, 1); + let out = select_audio_bytes(&live, AudioEgress::Track(0), true) + .expect("track 0 must reach a Track(0) destination"); + assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); } - } - - #[test] - fn select_audio_bytes_falls_back_to_live_track_when_target_absent() { - // A dest pinned to Track(1) but a stream that only carries the live - // track (OneTrack TrackId 0) still gets audio: the requested track is - // missing, so it falls back to the live track 0, flattened to legacy. - let live_only = enhanced_audio_onetrack(0, 1); - let out = select_audio_bytes(&live_only, AudioEgress::Track(1), false) - .expect("must fall back to the live track, not go silent"); - assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); } /// OBS sends its live track as plain AAC and the VOD / clean track as a @@ -2842,96 +2848,57 @@ mod tests { assert!(select_audio_bytes(&clean, AudioEgress::Track(0), true).is_none()); } + /// One track out of a multitrack audio tag, in every layout, as legacy AAC. #[test] - fn select_audio_bytes_passes_multitrack_through_for_twitch() { - // Twitch destination (Passthrough): every track's bytes flow - // through bit-faithfully so Twitch's VOD-audio slot keeps its feed. - for track in 0u8..=3 { - let tag = enhanced_audio_onetrack(track, 1); - let out = select_audio_bytes(&tag, AudioEgress::Passthrough, true) - .unwrap_or_else(|| panic!("twitch must forward track {track}")); - assert_eq!(out.as_ref(), tag.as_slice()); + fn multitrack_audio_flattens_to_plain_aac() { + // flatten multitrack audio maps seq and frame packet types + { + // SequenceStart (nested 0) -> AACPacketType 0; CodedFrames (1) -> 1. + let seq = enhanced_audio_onetrack(1, 0); + assert_eq!( + flatten_multitrack_audio(&seq, 1).unwrap(), + vec![0xAF, 0x00, 0x12, 0x10, 0x56, 0xe5], + ); + let frame = enhanced_audio_onetrack(1, 1); + assert_eq!( + flatten_multitrack_audio(&frame, 1).unwrap(), + vec![0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5], + ); } - } - - #[test] - fn select_audio_bytes_drops_untargeted_track_for_single_track_dest() { - // A single-track destination pinned to Track(0) drops every other - // OneTrack rung (e.g. the VOD track), which is meaningless to - // YouTube / Kick. - for track in 1u8..=3 { - let tag = enhanced_audio_onetrack(track, 1); + // flatten multitrack audio missing track returns none + { + let tag = enhanced_audio_onetrack(0, 1); assert!( - select_audio_bytes(&tag, AudioEgress::Track(0), true).is_none(), - "TrackId {track} must be dropped when the dest wants Track(0)", + flatten_multitrack_audio(&tag, 2).is_none(), + "a track not present in the tag must return None (drop)", ); } - } - - #[test] - fn select_audio_bytes_flattens_target_track_to_legacy_aac() { - // The clean/second audio track routed to a single-track dest is - // flattened to legacy AAC (0xAF ...), the universally-accepted form. - let live = enhanced_audio_onetrack(0, 1); - let out = select_audio_bytes(&live, AudioEgress::Track(0), true) - .expect("track 0 must reach a Track(0) destination"); - assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); - - let clean = enhanced_audio_onetrack(1, 1); - let out = select_audio_bytes(&clean, AudioEgress::Track(1), true) - .expect("track 1 must reach a Track(1) destination"); - assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); - } - - #[test] - fn flatten_multitrack_audio_maps_seq_and_frame_packet_types() { - // SequenceStart (nested 0) -> AACPacketType 0; CodedFrames (1) -> 1. - let seq = enhanced_audio_onetrack(1, 0); - assert_eq!( - flatten_multitrack_audio(&seq, 1).unwrap(), - vec![0xAF, 0x00, 0x12, 0x10, 0x56, 0xe5], - ); - let frame = enhanced_audio_onetrack(1, 1); - assert_eq!( - flatten_multitrack_audio(&frame, 1).unwrap(), - vec![0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5], - ); - } - - #[test] - fn flatten_multitrack_audio_missing_track_returns_none() { - let tag = enhanced_audio_onetrack(0, 1); - assert!( - flatten_multitrack_audio(&tag, 2).is_none(), - "a track not present in the tag must return None (drop)", - ); - } + // flatten multitrack audio many tracks layouts + { + // ManyTracks (mt_type 1): [0x95][0x1X][FourCC] then repeated + // [TrackId][Size(3 BE)][payload]. Two AAC tracks; pick track 1. + let mut many = vec![0x95, 0x11, b'm', b'p', b'4', b'a']; + for (tid, data) in [(0u8, [0xAAu8, 0xBB]), (1u8, [0xCC, 0xDD])] { + many.push(tid); + many.extend_from_slice(&[0, 0, data.len() as u8]); + many.extend_from_slice(&data); + } + assert_eq!( + flatten_multitrack_audio(&many, 1).unwrap(), + vec![0xAF, 0x01, 0xCC, 0xDD], + ); - #[test] - fn flatten_multitrack_audio_many_tracks_layouts() { - // ManyTracks (mt_type 1): [0x95][0x1X][FourCC] then repeated - // [TrackId][Size(3 BE)][payload]. Two AAC tracks; pick track 1. - let mut many = vec![0x95, 0x11, b'm', b'p', b'4', b'a']; - for (tid, data) in [(0u8, [0xAAu8, 0xBB]), (1u8, [0xCC, 0xDD])] { - many.push(tid); - many.extend_from_slice(&[0, 0, data.len() as u8]); - many.extend_from_slice(&data); + // ManyTracksManyCodecs (mt_type 2): repeated + // [TrackId][FourCC][Size(3 BE)][payload]. Track 1 is Opus -> + // Enhanced single-track output (0x90 | nested, FourCC, payload). + let mut mtmc = vec![0x95, 0x21]; + mtmc.extend_from_slice(&[0, b'm', b'p', b'4', b'a', 0, 0, 2, 0x11, 0x22]); + mtmc.extend_from_slice(&[1, b'O', b'p', b'u', b's', 0, 0, 2, 0x33, 0x44]); + assert_eq!( + flatten_multitrack_audio(&mtmc, 1).unwrap(), + vec![0x91, b'O', b'p', b'u', b's', 0x33, 0x44], + ); } - assert_eq!( - flatten_multitrack_audio(&many, 1).unwrap(), - vec![0xAF, 0x01, 0xCC, 0xDD], - ); - - // ManyTracksManyCodecs (mt_type 2): repeated - // [TrackId][FourCC][Size(3 BE)][payload]. Track 1 is Opus -> - // Enhanced single-track output (0x90 | nested, FourCC, payload). - let mut mtmc = vec![0x95, 0x21]; - mtmc.extend_from_slice(&[0, b'm', b'p', b'4', b'a', 0, 0, 2, 0x11, 0x22]); - mtmc.extend_from_slice(&[1, b'O', b'p', b'u', b's', 0, 0, 2, 0x33, 0x44]); - assert_eq!( - flatten_multitrack_audio(&mtmc, 1).unwrap(), - vec![0x91, b'O', b'p', b'u', b's', 0x33, 0x44], - ); } /// Enhanced RTMP: only coded frames carry a picture. A SequenceEnd diff --git a/src/instance_lock.rs b/src/instance_lock.rs new file mode 100644 index 0000000..ca22554 --- /dev/null +++ b/src/instance_lock.rs @@ -0,0 +1,372 @@ +//! One running copy per setup. +//! +//! Two InstantClones side by side are fine as long as each has its own +//! folder: its own settings, ports and buffer. Two copies of the SAME setup +//! are never fine. They would fight over the ports, write the same buffer +//! file, and the second one's quit would delete the first one's buffer from +//! under a live stream. The usual way to get there is innocent: double-click +//! the exe while it already sits in the tray. +//! +//! The guard is an OS lock on a file beside the settings, taken before +//! anything else is read or written and held for the life of the process. +//! The OS drops it when the process ends, however it ends, so a crash or a +//! power cut never leaves a stale lock the way a PID file would. +//! +//! Copies side by side also need telling apart at a glance, so each running +//! copy shows its tray icon in its own colour (see `show_tray_color`). + +use std::fs::{File, OpenOptions, TryLockError}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// Holds the lock until dropped, which in practice is process exit. +pub struct InstanceLock { + _file: File, +} + +pub enum Acquired { + /// This process now owns the setup. + Locked(InstanceLock), + /// Another running InstantClone owns it. + HeldElsewhere, + /// The lock file could not be opened (a read-only folder, say). Nothing + /// is known either way; the caller carries on as before this existed. + Unavailable(io::Error), +} + +/// `.lock`, beside the settings file it guards. Named after +/// the settings file rather than fixed, so two settings files in one folder +/// (picked with CONFIG_PATH) are two setups. +pub fn lock_path(config_path: &Path) -> PathBuf { + let mut name = config_path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_else(|| "instantclone.config.json".into()); + name.push(".lock"); + config_path.with_file_name(name) +} + +/// Take the lock for the setup at `config_path`. `wait` is how long to keep +/// trying while another process holds it: zero for a normal launch, a few +/// seconds on a restart, where the outgoing process still holds it until it +/// has finished exiting. +pub fn acquire(config_path: &Path, wait: Duration) -> Acquired { + let file = match open(&lock_path(config_path)) { + Ok(file) => file, + Err(e) => return Acquired::Unavailable(e), + }; + let deadline = Instant::now() + wait; + loop { + match file.try_lock() { + Ok(()) => return Acquired::Locked(InstanceLock { _file: file }), + Err(TryLockError::WouldBlock) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(150)); + } + Err(TryLockError::WouldBlock) => return Acquired::HeldElsewhere, + Err(TryLockError::Error(e)) => return Acquired::Unavailable(e), + } + } +} + +/// Open (creating if needed) without truncating. The file is never deleted: +/// removing it while another process waits on it would let a third one lock +/// a fresh file beside the deleted one, and two copies would run. +fn open(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + // Hidden on Windows: it is an implementation detail sitting next to the + // exe the user double-clicks. Only applies when the file is created. + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + options.attributes(FILE_ATTRIBUTE_HIDDEN); + } + options.open(path) +} + +/// The colours the tray icon comes in, by name and RGB. The first, the brand +/// blue, is every setup's until another is picked in System settings. +pub const TRAY_COLORS: [(&str, [u8; 3]); 5] = [ + ("Blue", [0x5a, 0xc8, 0xfa]), + ("Purple", [0xbf, 0x5a, 0xf2]), + ("Orange", [0xff, 0x9f, 0x0a]), + ("Green", [0x30, 0xd1, 0x58]), + ("Pink", [0xff, 0x37, 0x5f]), +]; + +/// `#rrggbb` for a tray colour, for the dashboard's tab icon. An index out of +/// range reads as the brand colour. +pub fn tray_color_hex(index: u8) -> String { + let (_, [r, g, b]) = TRAY_COLORS + .get(index as usize) + .copied() + .unwrap_or(TRAY_COLORS[0]); + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// Something this copy holds until dropped, which in practice is process +/// exit: a tray colour, or a port it is about to listen on. On Windows a +/// named mutex, which the OS removes with the last handle, so a crash never +/// strands one. Elsewhere there is no tray, and the port check that follows +/// a claim is all there is. +pub struct SessionClaim { + #[cfg(windows)] + handle: windows_sys::Win32::Foundation::HANDLE, +} + +// SAFETY: the handle is only ever closed, and Windows lets any thread close +// a handle its process owns. +#[cfg(windows)] +unsafe impl Send for SessionClaim {} + +#[cfg(windows)] +impl Drop for SessionClaim { + fn drop(&mut self) { + unsafe { windows_sys::Win32::Foundation::CloseHandle(self.handle) }; + } +} + +/// The tray colour this copy shows, and its hold on it while it runs. +static TRAY_COLOR: crate::sync::Mutex<(u8, Option)> = + crate::sync::Mutex::new((0, None)); + +/// Show the tray colour picked in System settings (`chosen`), and return the +/// colour shown. That is another one, for this run only, while a different +/// running copy already shows the chosen one: two icons side by side never +/// match, and the usual case is a folder copied with its settings. `wait` +/// covers a restart, where the outgoing process can hold its colour a moment +/// after it lets go of the setup lock. +pub fn show_tray_color(chosen: u8, wait: Duration) -> u8 { + let mut shown = TRAY_COLOR.lock(); + // Let go first, so picking the colour already shown finds it free. + shown.1 = None; + #[cfg(windows)] + { + *shown = claim_color_in(r"Local\InstantClone.TrayColor", chosen, wait); + } + // No tray here: the colour is only the dashboard's tab icon. + #[cfg(not(windows))] + { + let _ = wait; + shown.0 = chosen; + } + shown.0 +} + +/// The tray colour this copy shows right now (see `show_tray_color`). +pub fn shown_tray_color() -> u8 { + TRAY_COLOR.lock().0 +} + +/// `preferred` when no other running copy shows it, otherwise the first +/// colour none of them shows. With every colour taken, the preferred one is +/// shown unclaimed. +#[cfg(windows)] +fn claim_color_in(prefix: &str, preferred: u8, wait: Duration) -> (u8, Option) { + let count = TRAY_COLORS.len() as u8; + let preferred = if preferred < count { preferred } else { 0 }; + if let Some(claim) = claim_waiting(prefix, preferred.into(), wait) { + return (preferred, Some(claim)); + } + for color in (0..count).filter(|&c| c != preferred) { + if let Some(claim) = try_claim(prefix, color.into()) { + return (color, Some(claim)); + } + } + (preferred, None) +} + +/// Reserve a port this copy is about to listen on, or None when another +/// InstantClone has. Two copies started at the same moment (both set to +/// start with Windows, say) would otherwise both find a shared port free, +/// since neither is listening yet, and the slower one would be left with a +/// port it can never bind. The reservation settles it: one copy gets it, the +/// other gets the usual port-conflict choice. Machine-wide, like ports. +/// `wait` covers a restart, as for the tray colour. +pub fn reserve_port(port: u16, wait: Duration) -> Option { + #[cfg(windows)] + { + claim_waiting(r"Global\InstantClone.Port", port.into(), wait) + } + #[cfg(not(windows))] + { + let _ = (port, wait); + Some(SessionClaim {}) + } +} + +/// `try_claim`, trying again until `wait` is up. +#[cfg(windows)] +fn claim_waiting(prefix: &str, item: u32, wait: Duration) -> Option { + let deadline = Instant::now() + wait; + loop { + if let Some(claim) = try_claim(prefix, item) { + return Some(claim); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Create the item's named mutex, or None when another process already has +/// it (or, under `Global\`, another user's process: then opening it is +/// refused, which counts as taken too). `Local\` names are this Windows +/// session's own, so another user signed in on the PC never decides this +/// user's tray colours. +#[cfg(windows)] +fn try_claim(prefix: &str, item: u32) -> Option { + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS}; + use windows_sys::Win32::System::Threading::CreateMutexW; + let name: Vec = format!("{prefix}.{item}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; + if handle.is_null() { + return None; + } + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + unsafe { CloseHandle(handle) }; + return None; + } + Some(SessionClaim { handle }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_config(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ic-instance-lock-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("instantclone.config.json") + } + + #[test] + fn the_lock_sits_beside_the_settings_file_it_guards() { + assert_eq!( + lock_path(Path::new("C:/Streams/Main/instantclone.config.json")), + PathBuf::from("C:/Streams/Main/instantclone.config.json.lock") + ); + assert_eq!( + lock_path(Path::new("./other.json")), + PathBuf::from("./other.json.lock") + ); + } + + /// The same setup can't be taken twice while the first holder lives, + /// and is free again the moment it is gone. + #[test] + fn a_second_copy_of_the_same_setup_is_refused_until_the_first_ends() { + let config = scratch_config("same"); + let Acquired::Locked(first) = acquire(&config, Duration::ZERO) else { + panic!("the first copy gets the lock"); + }; + assert!(matches!( + acquire(&config, Duration::ZERO), + Acquired::HeldElsewhere + )); + drop(first); + assert!(matches!( + acquire(&config, Duration::ZERO), + Acquired::Locked(_) + )); + let _ = std::fs::remove_dir_all(config.parent().unwrap()); + } + + /// A restart waits for the outgoing process instead of giving up. + #[test] + fn a_restart_waits_for_the_outgoing_copy_to_let_go() { + let config = scratch_config("restart"); + let Acquired::Locked(outgoing) = acquire(&config, Duration::ZERO) else { + panic!("the outgoing copy holds the lock"); + }; + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(outgoing); + }); + assert!(matches!( + acquire(&config, Duration::from_secs(5)), + Acquired::Locked(_) + )); + release.join().unwrap(); + let _ = std::fs::remove_dir_all(config.parent().unwrap()); + } + + /// A setup keeps its colour while nobody else shows it; a copy wanting + /// a colour that is taken gets a free one instead, and every colour is + /// free again once its holder is gone. + #[cfg(windows)] + #[test] + fn copies_side_by_side_never_share_a_tray_colour() { + let prefix = format!(r"Local\InstantCloneTest.TrayColor.{}", std::process::id()); + let (first, held_first) = claim_color_in(&prefix, 2, Duration::ZERO); + assert_eq!(first, 2, "the colour it had last time"); + let (second, held_second) = claim_color_in(&prefix, 2, Duration::ZERO); + assert_eq!(second, 0, "a copied setup takes the first free colour"); + assert!(held_first.is_some() && held_second.is_some()); + + drop(held_first); + let (again, _held) = claim_color_in(&prefix, 2, Duration::ZERO); + assert_eq!(again, 2, "free again once its holder is gone"); + + let (out_of_range, _held) = claim_color_in(&prefix, 99, Duration::ZERO); + assert_eq!(out_of_range, 1, "a bad value reads as cyan, which is taken"); + drop(held_second); + } + + /// A port reserved by one copy is refused to the next until the first + /// lets go of it. In a private namespace, so a real copy running on this + /// PC can't collide with the test. + #[cfg(windows)] + #[test] + fn a_reserved_port_is_refused_to_a_second_copy() { + let prefix = format!(r"Local\InstantCloneTest.Port.{}", std::process::id()); + let port = 7799; + { + let _first = claim_waiting(&prefix, port, Duration::ZERO).expect("free"); + assert!( + claim_waiting(&prefix, port, Duration::ZERO).is_none(), + "taken" + ); + } + assert!( + claim_waiting(&prefix, port, Duration::ZERO).is_some(), + "free again" + ); + } + + /// The tab icon's colour matches the tray's. + #[test] + fn tray_colours_read_as_hex() { + assert_eq!(tray_color_hex(0), "#5ac8fa"); + assert_eq!(tray_color_hex(1), "#bf5af2"); + assert_eq!(tray_color_hex(200), "#5ac8fa"); + } + + /// Different settings files are different setups, even in one folder. + #[test] + fn two_setups_lock_independently() { + let main = scratch_config("two"); + let other = main.with_file_name("second.config.json"); + let Acquired::Locked(first) = acquire(&main, Duration::ZERO) else { + panic!("the first setup locks"); + }; + assert!(matches!( + acquire(&other, Duration::ZERO), + Acquired::Locked(_) + )); + drop(first); + let _ = std::fs::remove_dir_all(main.parent().unwrap()); + } +} diff --git a/src/integrations/alert_media.rs b/src/integrations/alert_media.rs new file mode 100644 index 0000000..ab87b2e --- /dev/null +++ b/src/integrations/alert_media.rs @@ -0,0 +1,256 @@ +//! Sounds and pictures uploaded for alerts, kept in `alerts-media` next to +//! the config and served to the alerts page at `/alerts/media/`. +//! +//! A name is checked against a strict character set and a short list of +//! extensions, so it can never leave the folder, and each file is served +//! with the type its extension says (never sniffed): an upload can only +//! ever be played as a sound or shown as a picture. + +use std::path::{Path, PathBuf}; + +pub const DIR: &str = "alerts-media"; +pub const MAX_BYTES: usize = 10 * 1024 * 1024; +const MAX_FILES: usize = 200; +const MAX_NAME: usize = 64; +/// How a step or the page's look names an upload: `media:ding.mp3`. +pub const REF_PREFIX: &str = "media:"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Sound, + Picture, +} + +impl Kind { + pub fn id(self) -> &'static str { + match self { + Kind::Sound => "sound", + Kind::Picture => "picture", + } + } +} + +/// Extension, kind, and the content type it is served with. +const TYPES: &[(&str, Kind, &str)] = &[ + ("mp3", Kind::Sound, "audio/mpeg"), + ("ogg", Kind::Sound, "audio/ogg"), + ("wav", Kind::Sound, "audio/wav"), + ("m4a", Kind::Sound, "audio/mp4"), + ("webm", Kind::Sound, "audio/webm"), + ("png", Kind::Picture, "image/png"), + ("jpg", Kind::Picture, "image/jpeg"), + ("jpeg", Kind::Picture, "image/jpeg"), + ("gif", Kind::Picture, "image/gif"), + ("webp", Kind::Picture, "image/webp"), +]; + +pub struct Media { + pub name: String, + pub kind: Kind, + pub bytes: u64, +} + +/// Where uploads live: beside the config, like the buffer. +pub fn folder(cfg_path: &Path) -> PathBuf { + crate::config::beside_config(cfg_path, Path::new(DIR)) +} + +/// The kind and content type of a stored file's name, or `None` when the +/// name isn't one this folder could hold. +pub fn type_of(name: &str) -> Option<(Kind, &'static str)> { + let safe = !name.is_empty() + && name.len() <= MAX_NAME + && !name.starts_with('.') + && !name.contains("..") + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)) + && !is_device_name(name.split('.').next().unwrap_or("")); + if !safe { + return None; + } + let (_, ext) = name.rsplit_once('.')?; + TYPES + .iter() + .find(|(e, ..)| *e == ext) + .map(|(_, kind, ctype)| (*kind, *ctype)) +} + +/// A name Windows keeps for a device (`con`, `nul`, `com1`...), whatever +/// follows its first dot: a file saved as `con.mp3` would go to the device. +fn is_device_name(stem: &str) -> bool { + let numbered = |prefix: &str| { + stem.strip_prefix(prefix) + .is_some_and(|n| n.len() == 1 && matches!(n.as_bytes()[0], b'1'..=b'9')) + }; + matches!(stem, "con" | "prn" | "aux" | "nul") || numbered("com") || numbered("lpt") +} + +/// `media:` naming an upload of this kind. +pub fn is_ref(text: &str, kind: Kind) -> bool { + text.strip_prefix(REF_PREFIX) + .and_then(type_of) + .is_some_and(|(k, _)| k == kind) +} + +/// The name an upload is stored under: lower case, spaces as dashes, +/// anything else dropped. `None` when it isn't a type we serve. +pub fn clean_name(original: &str) -> Option { + let base = original + .rsplit(['/', '\\']) + .next() + .unwrap_or("") + .to_ascii_lowercase(); + let (stem, ext) = base.rsplit_once('.')?; + let stem: String = stem + .chars() + .map(|c| if c.is_whitespace() { '-' } else { c }) + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .take(MAX_NAME - 10) + .collect(); + let stem = stem.trim_matches(['-', '_']); + let name = match stem { + "" => format!("file.{ext}"), + device if is_device_name(device) => format!("{device}-file.{ext}"), + _ => format!("{stem}.{ext}"), + }; + type_of(&name).map(|_| name) +} + +/// Every upload, by name. +pub fn list(folder: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(folder) else { + return Vec::new(); + }; + let mut out: Vec = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let (kind, _) = type_of(&name)?; + let bytes = e.metadata().ok().filter(|m| m.is_file())?.len(); + Some(Media { name, kind, bytes }) + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// Keep an upload; returns the name it was saved under (with a number +/// added when that name is taken). +pub fn save(folder: &Path, original: &str, data: &[u8]) -> Result { + let name = clean_name(original) + .ok_or("use an MP3, OGG, WAV, M4A or WEBM sound, or a PNG, JPG, GIF or WEBP picture")?; + if data.is_empty() { + return Err("that file is empty".to_string()); + } + if data.len() > MAX_BYTES { + return Err("that file is over 10 MB".to_string()); + } + if list(folder).len() >= MAX_FILES { + return Err(format!( + "there are already {MAX_FILES} uploads: delete some first" + )); + } + std::fs::create_dir_all(folder).map_err(|e| format!("couldn't make the folder: {e}"))?; + let name = free_name(folder, &name); + // Written aside, then moved in: the page never plays half a file. + let part = folder.join(format!(".{name}.part")); + std::fs::write(&part, data).map_err(|e| format!("couldn't save it: {e}"))?; + std::fs::rename(&part, folder.join(&name)).map_err(|e| { + let _ = std::fs::remove_file(&part); + format!("couldn't save it: {e}") + })?; + Ok(name) +} + +fn free_name(folder: &Path, name: &str) -> String { + if !folder.join(name).exists() { + return name.to_string(); + } + let (stem, ext) = name.rsplit_once('.').unwrap_or((name, "")); + (2..) + .map(|n| format!("{stem}-{n}.{ext}")) + .find(|candidate| !folder.join(candidate).exists()) + .unwrap_or_else(|| name.to_string()) +} + +pub fn delete(folder: &Path, name: &str) -> Result<(), String> { + type_of(name).ok_or("there's no such upload")?; + std::fs::remove_file(folder.join(name)).map_err(|e| format!("couldn't delete it: {e}")) +} + +/// Where an upload is on disk and its content type, for serving. +pub fn locate(folder: &Path, name: &str) -> Option<(PathBuf, &'static str)> { + let (_, ctype) = type_of(name)?; + Some((folder.join(name), ctype)) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Scratch(PathBuf); + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn names_can_never_leave_the_folder() { + for bad in [ + "../config.mp3", + "..\\x.mp3", + "a/b.mp3", + ".hidden.mp3", + "page.html", + "pic.svg", + "Ding.mp3", + "", + "con.mp3", + "nul.tar.png", + "com1.wav", + ] { + assert!(type_of(bad).is_none(), "{bad}"); + } + assert_eq!(type_of("ding-2.mp3").map(|t| t.1), Some("audio/mpeg")); + assert_eq!(type_of("party.gif").map(|t| t.0), Some(Kind::Picture)); + assert!(is_ref("media:party.gif", Kind::Picture)); + assert!(!is_ref("media:party.gif", Kind::Sound)); + assert!(!is_ref("media:../x.mp3", Kind::Sound)); + } + + #[test] + fn uploaded_names_are_cleaned() { + assert_eq!( + clean_name("C:\\Users\\me\\My Follow Sound!.MP3").as_deref(), + Some("my-follow-sound.mp3") + ); + assert_eq!(clean_name("???.png").as_deref(), Some("file.png")); + // Windows device names can't be files: they get a suffix. + assert_eq!(clean_name("CON.mp3").as_deref(), Some("con-file.mp3")); + assert_eq!(clean_name("lpt9.gif").as_deref(), Some("lpt9-file.gif")); + assert_eq!(clean_name("console.mp3").as_deref(), Some("console.mp3")); + assert_eq!(clean_name("evil.html"), None); + assert_eq!(clean_name("noext"), None); + } + + #[test] + fn uploads_are_kept_listed_and_deleted() { + let dir = Scratch( + std::env::temp_dir().join(format!("ic-media-{}", &crate::crypto::random_token()[..8])), + ); + let first = save(&dir.0, "Ding.mp3", b"ID3data").unwrap(); + let second = save(&dir.0, "ding.mp3", b"ID3more").unwrap(); + assert_eq!( + (first.as_str(), second.as_str()), + ("ding.mp3", "ding-2.mp3") + ); + assert!(save(&dir.0, "empty.png", b"").is_err()); + let names: Vec = list(&dir.0).into_iter().map(|m| m.name).collect(); + assert_eq!(names, ["ding-2.mp3", "ding.mp3"]); + delete(&dir.0, "ding.mp3").unwrap(); + assert_eq!(list(&dir.0).len(), 1); + assert!(delete(&dir.0, "../ding-2.mp3").is_err()); + } +} diff --git a/src/integrations/alerts.rs b/src/integrations/alerts.rs index 49801d0..a705a19 100644 --- a/src/integrations/alerts.rs +++ b/src/integrations/alerts.rs @@ -5,12 +5,24 @@ //! id once, in order, so two alerts close together queue instead of //! overlapping. Only what a step chose to show is here: it is on stream //! anyway, which is why the page needs no login. +//! +//! How every card looks is the page's theme (`Look`): a ready-made style +//! with its own sound, then any of its parts changed, down to custom CSS. +//! It is sent along with the alerts, so a change shows in OBS at once. +//! Each alert can still bring its own accent color, sound and picture. +use super::alert_media::{self, Kind}; use crate::json::{self, Value}; use crate::sync::Mutex; use std::collections::VecDeque; +use std::sync::OnceLock; use std::time::{Duration, Instant}; +/// The alerts browser source itself. +const PAGE: &str = include_str!("../../web/alerts.html"); +/// Stands in the page for its own fingerprint (`page_id`). +const PAGE_ID_MARK: &str = "__ALERTS_PAGE_ID__"; + /// Alerts kept for a page that (re)connects. Older ones are never replayed. const KEEP: usize = 8; /// How long an alert stays on the board: a page that reconnects after this @@ -20,12 +32,277 @@ pub const MIN_SECONDS: u64 = 2; pub const MAX_SECONDS: u64 = 30; const MAX_TITLE: usize = 80; const MAX_TEXT: usize = 300; +const MAX_LINK: usize = 500; +const MAX_CSS: usize = 20_000; +pub const DEFAULT_VOLUME: u8 = 70; +/// An alert's sound that plays the theme's. +pub const THEME_SOUND: &str = "theme"; +/// The sounds the alerts page makes itself, with no file to find. +pub const SOUNDS: &[&str] = &[ + "chime", "pop", "bell", "rise", "coin", "sparkle", "whoosh", "fanfare", "zap", "blip", +]; +/// The ready-made looks (web/alerts.html draws them). +pub const THEMES: &[&str] = &[ + "island", "signal", "banner", "bubble", "minimal", "paper", "cinema", +]; +/// Fonts the page can load (from Google Fonts), or `system`. +pub const FONTS: &[&str] = &[ + "system", + "Inter", + "Poppins", + "Montserrat", + "Fredoka", + "Rubik", + "Bebas Neue", + "Oswald", + "Bangers", + "Orbitron", + "Press Start 2P", + "Permanent Marker", + "Roboto Mono", + "Chakra Petch", + "Playfair Display", + "Special Elite", +]; +pub const ANIMATIONS: &[&str] = &["slide", "pop", "fade", "zoom", "bounce", "drop"]; + +/// One alert as a "Show on stream" step (or the dashboard's preview) +/// puts it up. +#[derive(Clone, Debug, PartialEq)] +pub struct Card { + pub title: String, + pub text: String, + pub seconds: u64, + /// `#rrggbb`, or empty for the theme's accent. + pub color: String, + /// `theme`, one of `SOUNDS`, an upload (`media:...`), a web link to a + /// sound file, or empty for none. + pub sound: String, + /// 0 to 100. + pub volume: u8, + /// An upload (`media:...`) or a web link to a picture or GIF, or empty. + pub image: String, +} + +impl Card { + /// A card with just words, in the theme's colors, silent. + pub fn plain(title: &str, text: &str, seconds: u64) -> Card { + Card { + title: title.to_string(), + text: text.to_string(), + seconds, + color: String::new(), + sound: String::new(), + volume: DEFAULT_VOLUME, + image: String::new(), + } + } + + /// What the page can safely show: lengths capped, one line each, and a + /// color, sound or picture that isn't valid left out. + fn cleaned(self) -> Card { + Card { + title: one_line(&self.title, MAX_TITLE), + text: one_line(&self.text, MAX_TEXT), + seconds: self.seconds.clamp(MIN_SECONDS, MAX_SECONDS), + color: color_or_empty(&self.color), + sound: if is_sound(&self.sound) { + self.sound + } else { + String::new() + }, + volume: self.volume.min(100), + image: if is_picture(&self.image) { + self.image + } else { + String::new() + }, + } + } +} + +/// A fingerprint of the page this build serves. The page carries its own +/// and the feed says the current one: OBS keeps a browser source running +/// through an app update, and a page that sees they differ reloads itself +/// instead of drawing new looks with old code. +pub fn page_id() -> &'static str { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| { + // FNV-1a: stable across builds of the same page, no dependency. + let hash = PAGE.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) + }); + format!("{hash:016x}") + }) +} + +/// The page as served, carrying its fingerprint. +pub fn page_html() -> String { + PAGE.replace(PAGE_ID_MARK, page_id()) +} + +/// `#rrggbb`. +pub fn is_color(text: &str) -> bool { + text.len() == 7 && text.starts_with('#') && text[1..].bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn color_or_empty(text: &str) -> String { + if is_color(text) { + text.to_ascii_lowercase() + } else { + String::new() + } +} + +/// A plain `http(s)://` link the page can put in a `src` as it is. +pub fn is_web_link(text: &str) -> bool { + let rest = text + .strip_prefix("https://") + .or_else(|| text.strip_prefix("http://")); + rest.is_some_and(|rest| { + !rest.is_empty() + && text.len() <= MAX_LINK + && !text + .chars() + .any(|c| c.is_whitespace() || c.is_control() || "\"'<>\\`{}".contains(c)) + }) +} + +/// Empty (no sound), the theme's, a built-in one, an upload, or a link to +/// a sound file. +pub fn is_sound(text: &str) -> bool { + text.is_empty() + || text == THEME_SOUND + || SOUNDS.contains(&text) + || alert_media::is_ref(text, Kind::Sound) + || is_web_link(text) +} + +/// An uploaded picture or a link to one. +pub fn is_picture(text: &str) -> bool { + alert_media::is_ref(text, Kind::Picture) || is_web_link(text) +} + +/// How every card on the alerts page looks: a theme, then any part of it +/// changed. Empty strings and `None` keep the theme's own. +#[derive(Clone, Debug, PartialEq)] +pub struct Look { + /// One of `THEMES`. + pub theme: String, + /// `bottom`, `top` or `middle`. + pub position: String, + /// `center`, `left` or `right`. + pub align: String, + /// Size in percent, 50 to 200. + pub scale: u16, + /// One of `FONTS`, or empty. + pub font: String, + /// Text, card background and accent: `#rrggbb` or empty. + pub text: String, + pub background: String, + pub accent: String, + /// The card background's opacity in percent. + pub opacity: Option, + /// Corner roundness in pixels. + pub radius: Option, + /// One of `ANIMATIONS`, or empty. + pub animation: String, + /// Where an alert's picture goes: `left`, `top`, or empty. + pub picture: String, + /// The sound alerts set to "Theme sound" play: one of `SOUNDS`, an + /// upload, a link, or empty for the theme's own. + pub sound: String, + /// Extra CSS, put in the page as it is. + pub css: String, +} + +impl Default for Look { + fn default() -> Look { + Look { + theme: "island".to_string(), + position: "bottom".to_string(), + align: "center".to_string(), + scale: 100, + font: String::new(), + text: String::new(), + background: String::new(), + accent: String::new(), + opacity: None, + radius: None, + animation: String::new(), + picture: String::new(), + sound: String::new(), + css: String::new(), + } + } +} + +impl Look { + /// Read a saved or sent look; anything unknown keeps the theme's. + pub fn from_json(v: &Value) -> Look { + let d = Look::default(); + let pick = |name: &str, allowed: &[&str], fallback: String| { + let value = v.str_or(name, ""); + if allowed.contains(&value) { + value.to_string() + } else { + fallback + } + }; + let number = |name: &str, max: f64| { + v.get(name) + .and_then(Value::as_f64) + .filter(|n| n.is_finite()) + .map(|n| n.clamp(0.0, max).round()) + }; + let sound = v.str_or("sound", ""); + Look { + theme: pick("theme", THEMES, d.theme), + position: pick("position", &["bottom", "top", "middle"], d.position), + align: pick("align", &["center", "left", "right"], d.align), + scale: number("scale", 200.0).map_or(d.scale, |n| (n as u16).max(50)), + font: pick("font", FONTS, d.font), + text: color_or_empty(v.str_or("text", "")), + background: color_or_empty(v.str_or("background", "")), + accent: color_or_empty(v.str_or("accent", "")), + opacity: number("opacity", 100.0).map(|n| n as u8), + radius: number("radius", 48.0).map(|n| n as u8), + animation: pick("animation", ANIMATIONS, d.animation), + picture: pick("picture", &["left", "top"], d.picture), + // "Theme sound" here would point back at itself. + sound: if sound != THEME_SOUND && is_sound(sound) { + sound.to_string() + } else { + String::new() + }, + css: v.str_or("css", "").chars().take(MAX_CSS).collect(), + } + } + + pub fn to_json(&self) -> Value { + let maybe = |n: Option| n.map_or(Value::Null, |n| Value::Num(f64::from(n))); + json::obj([ + ("theme", json::str(&self.theme)), + ("position", json::str(&self.position)), + ("align", json::str(&self.align)), + ("scale", Value::Num(f64::from(self.scale))), + ("font", json::str(&self.font)), + ("text", json::str(&self.text)), + ("background", json::str(&self.background)), + ("accent", json::str(&self.accent)), + ("opacity", maybe(self.opacity)), + ("radius", maybe(self.radius)), + ("animation", json::str(&self.animation)), + ("picture", json::str(&self.picture)), + ("sound", json::str(&self.sound)), + ("css", json::str(&self.css)), + ]) + } +} struct Alert { id: u64, - title: String, - text: String, - ms: u64, + card: Card, at: Instant, } @@ -34,6 +311,8 @@ pub struct Board { /// restart knows the ids started over. boot: String, inner: Mutex, + /// Alerts pages open right now: (in OBS, anywhere else). + pages: Mutex<(usize, usize)>, } #[derive(Default)] @@ -42,23 +321,39 @@ struct Inner { alerts: VecDeque, } +/// Counts one open alerts page for as long as it lives. +pub struct PageOpen<'a> { + board: &'a Board, + in_obs: bool, +} + +impl Drop for PageOpen<'_> { + fn drop(&mut self) { + let mut pages = self.board.pages.lock(); + if self.in_obs { + pages.0 = pages.0.saturating_sub(1); + } else { + pages.1 = pages.1.saturating_sub(1); + } + } +} + impl Board { pub fn new() -> Board { Board { boot: crate::crypto::random_token()[..8].to_string(), inner: Mutex::new(Inner::default()), + pages: Mutex::new((0, 0)), } } - /// Put an alert up for `seconds` (clamped to what reads well). - pub fn show(&self, title: &str, text: &str, seconds: u64) { + /// Put an alert up (cleaned of anything the page can't show). + pub fn show(&self, card: Card) { let mut inner = self.inner.lock(); inner.last_id += 1; let alert = Alert { id: inner.last_id, - title: one_line(title, MAX_TITLE), - text: one_line(text, MAX_TEXT), - ms: seconds.clamp(MIN_SECONDS, MAX_SECONDS) * 1000, + card: card.cleaned(), at: Instant::now(), }; if inner.alerts.len() >= KEEP { @@ -67,29 +362,56 @@ impl Board { inner.alerts.push_back(alert); } - /// The fresh alerts, oldest first. Unchanged between alerts, so the - /// page's event stream only sends when something new is up. - pub fn to_json(&self) -> String { + /// An alerts page connected; it counts until the guard is dropped. + pub fn page_opened(&self, in_obs: bool) -> PageOpen<'_> { + let mut pages = self.pages.lock(); + if in_obs { + pages.0 += 1; + } else { + pages.1 += 1; + } + PageOpen { + board: self, + in_obs, + } + } + + /// How many alerts pages are open, in OBS and anywhere else. + pub fn pages_open(&self) -> (usize, usize) { + *self.pages.lock() + } + + /// The fresh alerts, oldest first, and the page's look. Unchanged + /// between alerts, so the page's event stream only sends when + /// something new is up. + pub fn to_json(&self, look: &Look) -> String { let mut inner = self.inner.lock(); inner.alerts.retain(|a| a.at.elapsed() < FRESH); - let alerts = Value::Arr( - inner - .alerts - .iter() - .map(|a| { - json::obj([ - ("id", Value::Num(a.id as f64)), - ("title", json::str(&a.title)), - ("text", json::str(&a.text)), - ("ms", Value::Num(a.ms as f64)), - ]) - }) - .collect(), - ); - json::obj([("boot", json::str(&self.boot)), ("alerts", alerts)]).to_json() + let alerts = Value::Arr(inner.alerts.iter().map(alert_json).collect()); + json::obj([ + ("boot", json::str(&self.boot)), + ("page", json::str(page_id())), + ("look", look.to_json()), + ("alerts", alerts), + ]) + .to_json() } } +fn alert_json(a: &Alert) -> Value { + let c = &a.card; + json::obj([ + ("id", Value::Num(a.id as f64)), + ("title", json::str(&c.title)), + ("text", json::str(&c.text)), + ("ms", Value::Num((c.seconds * 1000) as f64)), + ("color", json::str(&c.color)), + ("sound", json::str(&c.sound)), + ("volume", Value::Num(f64::from(c.volume))), + ("image", json::str(&c.image)), + ]) +} + fn one_line(text: &str, max: usize) -> String { let flat: String = text .chars() @@ -103,14 +425,22 @@ fn one_line(text: &str, max: usize) -> String { mod tests { use super::*; + fn alerts_of(board: &Board) -> Vec { + let doc = json::parse(&board.to_json(&Look::default())).unwrap(); + doc.get("alerts") + .and_then(Value::as_array) + .unwrap() + .to_vec() + } + #[test] fn alerts_queue_with_rising_ids_and_sane_lengths() { let board = Board::new(); - board.show("Clip!", "by Ana\r\nnice", 1); - board.show("", &"x".repeat(900), 99); - let doc = json::parse(&board.to_json()).unwrap(); + board.show(Card::plain("Clip!", "by Ana\r\nnice", 1)); + board.show(Card::plain("", &"x".repeat(900), 99)); + let doc = json::parse(&board.to_json(&Look::default())).unwrap(); assert_eq!(doc.str_or("boot", "").len(), 8); - let list = doc.get("alerts").and_then(Value::as_array).unwrap(); + let list = alerts_of(&board); assert_eq!(list.len(), 2); assert_eq!(list[0].str_or("text", ""), "by Ana nice"); assert_eq!(list[0].u64_or("ms", 0), MIN_SECONDS * 1000); @@ -123,11 +453,118 @@ mod tests { fn only_the_last_few_are_kept() { let board = Board::new(); for n in 0..20 { - board.show("", &n.to_string(), 5); + board.show(Card::plain("", &n.to_string(), 5)); } - let doc = json::parse(&board.to_json()).unwrap(); - let list = doc.get("alerts").and_then(Value::as_array).unwrap(); + let list = alerts_of(&board); assert_eq!(list.len(), KEEP); assert_eq!(list[0].str_or("text", ""), "12"); } + + /// The page puts the color, sound and picture straight into the card: + /// only values it can use as they are get through. + #[test] + fn a_cards_color_sound_and_picture_are_checked() { + let board = Board::new(); + let mut good = Card::plain("New follower", "Ana", 6); + good.color = "#FF00AA".to_string(); + good.sound = "media:ding.mp3".to_string(); + good.image = "media:party.gif".to_string(); + board.show(good); + let mut bad = Card::plain("", "Ana", 6); + bad.color = "red;background:url(x)".to_string(); + bad.sound = "javascript:alert(1)".to_string(); + bad.image = "https://x.example/a.gif\" onerror=\"x".to_string(); + bad.volume = 250; + board.show(bad); + let list = alerts_of(&board); + assert_eq!(list[0].str_or("color", ""), "#ff00aa"); + assert_eq!(list[0].str_or("sound", ""), "media:ding.mp3"); + assert_eq!(list[0].str_or("image", ""), "media:party.gif"); + assert_eq!( + (list[1].str_or("color", "x"), list[1].str_or("sound", "x")), + ("", "") + ); + assert_eq!(list[1].str_or("image", "x"), ""); + assert_eq!(list[1].u64_or("volume", 0), 100); + assert!(is_sound("https://cdn.example/ding.mp3")); + assert!(is_sound(THEME_SOUND)); + assert!(!is_sound("media:party.gif")); + assert!(!is_picture("media:ding.mp3")); + assert!(!is_web_link("https://")); + assert!(!is_web_link("ftp://x.example/a.mp3")); + } + + #[test] + fn a_look_keeps_only_what_the_page_can_use() { + let sent = json::parse( + r##"{"theme":"signal","position":"top","align":"sideways","scale":900, + "font":"Comic Sans","text":"#FFFFFF","background":"url(x)","opacity":40, + "radius":-5,"animation":"spin","picture":"top","sound":"theme","css":".card{}"}"##, + ) + .unwrap(); + let look = Look::from_json(&sent); + assert_eq!( + ( + look.theme.as_str(), + look.position.as_str(), + look.align.as_str() + ), + ("signal", "top", "center") + ); + assert_eq!( + (look.scale, look.opacity, look.radius), + (200, Some(40), Some(0)) + ); + assert_eq!((look.font.as_str(), look.text.as_str()), ("", "#ffffff")); + assert_eq!( + (look.background.as_str(), look.animation.as_str()), + ("", "") + ); + assert_eq!((look.picture.as_str(), look.sound.as_str()), ("top", "")); + assert_eq!(look.css, ".card{}"); + assert_eq!(Look::from_json(&Value::Null), Look::default()); + let back = Look::from_json(&look.to_json()); + assert_eq!(back, look); + } + + /// The page served and the feed agree on the fingerprint, so a page + /// only ever reloads after a real update. + #[test] + fn the_page_knows_its_own_fingerprint() { + let html = page_html(); + assert!(!html.contains(PAGE_ID_MARK)); + assert!(html.contains(&format!("'{}'", page_id()))); + let doc = json::parse(&Board::new().to_json(&Look::default())).unwrap(); + assert_eq!(doc.str_or("page", ""), page_id()); + } + + /// A theme the dashboard offers is drawn by the page, coming in and + /// going out its own way. + #[test] + fn every_theme_has_its_own_motion() { + // The theme's own rule that names its entrance, wherever it sits + // and in whatever order its properties come. + for theme in THEMES { + let head = format!("body.theme-{theme}{{"); + let motion = PAGE + .match_indices(&head) + .map(|(at, _)| &PAGE[at..at + PAGE[at..].find('}').unwrap_or(PAGE.len() - at)]) + .find(|rule| rule.contains("--theme-in:")); + let rule = motion.unwrap_or_else(|| panic!("{theme} has no entrance")); + assert!(rule.contains("--theme-out:"), "{theme} has no exit"); + } + } + + #[test] + fn open_pages_are_counted_while_they_stay() { + let board = Board::new(); + let obs = board.page_opened(true); + { + let _browser = board.page_opened(false); + assert_eq!(board.pages_open(), (1, 1)); + } + assert_eq!(board.pages_open(), (1, 0)); + drop(obs); + assert_eq!(board.pages_open(), (0, 0)); + } } diff --git a/src/integrations/api.rs b/src/integrations/api.rs index e87eab2..0b6812a 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -11,7 +11,7 @@ use super::model::{ valid_discord_webhook, valid_id, DiscordChannel, Integration, PhoneConnection, Roles, Trigger, MAX_INTEGRATIONS, }; -use super::{engine, presets, recipe, runner, twitch::Which}; +use super::{alert_media, alerts, engine, lights, obsws, presets, recipe, runner, twitch::Which}; use crate::config::Settings; use crate::controller::Controller; use crate::json::{self, Value}; @@ -74,11 +74,18 @@ pub async fn route( let handle = ctrl.integrations()?.clone(); let req = || json::parse(body).unwrap_or(Value::Null); let reply = match (method, path) { - ("GET", "/integrations") => ok(overview(&handle, &settings.borrow(), local_steps_allowed)), + ("GET", "/integrations") => ok(overview( + &handle, + &settings.borrow(), + cfg_path, + local_steps_allowed, + )), ("GET", "/integrations/activity") => ok(json::obj([ ("activity", handle.activity_json()), ("stats", handle.stats_json()), ("twitch", handle.twitch.status_json()), + ("obs", handle.obs_status()), + ("alerts", alerts_json(&handle, &settings.borrow(), cfg_path)), ])), ("POST", "/integrations/save") => save(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/add") => add(&req(), settings, cfg_path, local_steps_allowed), @@ -99,16 +106,39 @@ pub async fn route( None => fail("nothing to measure"), }, ("GET", "/integrations/obs") => ok(handle.obs_status()), + ("POST", "/integrations/lights/scan") => scan_lights().await, + ("POST", "/integrations/lights/try") => try_light(&req()).await, ("POST", "/integrations/fetch") => fetch(&req()).await, ("POST", "/integrations/convert") => convert(&req()), ("POST", "/integrations/alerts/test") => { - handle.alerts.show( - "InstantClone", - "This is where your alerts show up on stream.", - 6, - ); + handle.alerts.show(test_card(&req())); done() } + ("POST", "/integrations/alerts/media/upload") => upload_media(&req(), cfg_path), + ("POST", "/integrations/alerts/media/delete") => { + let folder = alert_media::folder(cfg_path); + match alert_media::delete(&folder, req().str_or("name", "")) { + Ok(()) => done(), + Err(e) => fail(e), + } + } + ("POST", "/integrations/alerts/look") => { + let look = alerts::Look::from_json(&req()); + update(settings, cfg_path, |s| { + s.alerts_look = look; + Ok(()) + }) + } + ("POST", "/integrations/alerts/add-to-obs") => { + let action = obsws::Action::BrowserSource { + name: ALERTS_SOURCE.to_string(), + url: alerts_url(settings.borrow().web_port), + }; + match handle.obs_now(action).await { + Ok(()) => done(), + Err(e) => fail(e), + } + } ("POST", "/integrations/midi/learn") => midi_learn(ctrl), ("POST", "/integrations/midi/poll") => { let captured = ctrl.midi().take_captured_for(&[MIDI_LEARN]); @@ -129,7 +159,7 @@ pub async fn route( } done() } - ("POST", "/integrations/export") => export(&req(), &settings.borrow()), + ("POST", "/integrations/export") => export(&req(), &settings.borrow(), cfg_path), ("POST", "/integrations/import") => import(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/connections/discord") => save_discord(&req(), settings, cfg_path), ("POST", "/connections/discord/delete") => delete_discord(&req(), settings, cfg_path), @@ -234,7 +264,89 @@ fn update( done() } -fn overview(handle: &super::Handle, s: &Settings, local_steps_allowed: bool) -> Value { +/// What the alerts browser source is called when the dashboard adds it. +const ALERTS_SOURCE: &str = "InstantClone alerts"; + +/// The alerts page's address for OBS on this PC. +fn alerts_url(web_port: u16) -> String { + format!("http://127.0.0.1:{web_port}/alerts") +} + +/// The alerts page: its look, where it's open right now, and the sounds +/// and pictures uploaded for it. +fn alerts_json(handle: &super::Handle, s: &Settings, cfg_path: &Path) -> Value { + let (in_obs, elsewhere) = handle.alerts.pages_open(); + let media = alert_media::list(&alert_media::folder(cfg_path)) + .into_iter() + .map(|m| { + json::obj([ + ("name", json::str(&m.name)), + ("kind", json::str(m.kind.id())), + ("bytes", Value::Num(m.bytes as f64)), + ]) + }) + .collect(); + json::obj([ + ("look", s.alerts_look.to_json()), + ("in_obs", Value::Num(in_obs as f64)), + ("elsewhere", Value::Num(elsewhere as f64)), + ("url", json::str(alerts_url(s.web_port))), + ("media", Value::Arr(media)), + ]) +} + +/// Keep a sound or picture sent from the dashboard (base64url in JSON: +/// the API's bodies are text). +fn upload_media(req: &Value, cfg_path: &Path) -> Reply { + let Some(data) = recipe::base64url_decode(req.str_or("data", "")) else { + return fail("that upload arrived damaged; try again"); + }; + match alert_media::save( + &alert_media::folder(cfg_path), + req.str_or("name", ""), + &data, + ) { + Ok(name) => ok(json::obj([ + ("ok", Value::Bool(true)), + ("name", json::str(name)), + ])), + Err(e) => fail(e), + } +} + +/// The alert "Show it on stream" sends from the editor, already filled in +/// with sample values; with nothing sent, a generic one. An alert with only +/// a small title is still that alert, not the generic one. +fn test_card(req: &Value) -> alerts::Card { + let text = req.str_or("text", "").trim(); + if text.is_empty() && req.str_or("title", "").trim().is_empty() { + return alerts::Card::plain( + "InstantClone", + "This is where your alerts show up on stream.", + 6, + ); + } + alerts::Card { + title: req.str_or("title", "").to_string(), + text: text.to_string(), + seconds: req.str_or("seconds", "6").trim().parse().unwrap_or(6), + color: req.str_or("color", "").trim().to_string(), + sound: req.str_or("sound", "").trim().to_string(), + volume: req + .str_or("volume", "") + .trim() + .parse() + .unwrap_or(alerts::DEFAULT_VOLUME), + image: req.str_or("image", "").trim().to_string(), + } +} + +fn overview( + handle: &super::Handle, + s: &Settings, + cfg_path: &Path, + local_steps_allowed: bool, +) -> Value { // Previews and "Try it" use the real channel once Twitch is connected. let channel = handle.twitch.channel(); let global = Value::Arr( @@ -305,6 +417,9 @@ fn overview(handle: &super::Handle, s: &Settings, local_steps_allowed: bool) -> if let Err(e) = check_connections(i, s) { p.invalid.push(e); } + if let Some(e) = command_taken(i, s) { + p.invalid.push(e); + } ( i.id.clone(), Value::Arr(p.invalid.into_iter().map(json::str).collect()), @@ -314,6 +429,10 @@ fn overview(handle: &super::Handle, s: &Settings, local_steps_allowed: bool) -> ), ), ("twitch", handle.twitch.status_json()), + ("obs", handle.obs_status()), + ("alerts", alerts_json(handle, s, cfg_path)), + // For the "Report it" links, which fill in the issue form's version. + ("version", json::str(env!("CARGO_PKG_VERSION"))), ("twitch_shared_chat", Value::Bool(s.twitch_shared_chat)), // Markers, clips and chat act on the logged-in channel; say so when // the Twitch destinations stream somewhere else. @@ -400,13 +519,32 @@ fn save( if let Some(e) = shortcut_taken(&integration, &settings.borrow()) { return fail(e); } - // Switched on, it must be ready to run; switched off, it saves as a - // draft and the dashboard lists what is still missing. - if integration.enabled && !problems.incomplete.is_empty() { - return fail(format!( - "{}. Finish it, or switch it off to save it as a draft.", - problems.incomplete.join("; ") - )); + let was_on = settings + .borrow() + .integrations + .iter() + .any(|i| i.id == integration.id && i.enabled); + if integration.enabled { + if let Some(e) = command_taken(&integration, &settings.borrow()) { + if was_on { + return fail(e); + } + problems.incomplete.push(e); + } + } + // Switched on, it must be ready to run. One already running keeps + // running as it was rather than stop over a half-made edit; a new one + // or a draft saves as a draft, switched off, so no work is lost, and + // the dashboard lists what is still missing. + let drafted = integration.enabled && !problems.incomplete.is_empty(); + if drafted { + if was_on { + return fail(format!( + "{}. Finish it, or switch it off to save it as a draft.", + problems.incomplete.join("; ") + )); + } + integration.enabled = false; } let id = integration.id.clone(); let warnings = unknown_vars(&integration); @@ -432,6 +570,7 @@ fn save( ok(json::obj([ ("ok", Value::Bool(true)), ("id", json::str(&id)), + ("drafted", Value::Bool(drafted)), ( "warnings", Value::Arr(warnings.into_iter().map(json::str).collect()), @@ -489,6 +628,32 @@ pub fn unknown_vars(integration: &Integration) -> Vec { "" => "text".to_string(), name => name.to_string(), }), + StepKind::Obs if s.param("action") == "check" => { + let name = match s.param("save_as").trim() { + "" => "obs", + name => name, + }; + let mut fields: Vec = ["scene", "streaming", "recording"] + .map(String::from) + .to_vec(); + let sources = super::obsws::listed_names(s.param("source")); + if !sources.is_empty() { + fields.extend(["muted", "visible"].map(String::from)); + } + if sources.len() > 1 { + for key in super::obsws::source_keys(&sources) { + fields.extend([format!("{key}.muted"), format!("{key}.visible")]); + } + } + known.extend(fields.iter().map(|field| format!("{name}.{field}"))); + } + StepKind::Program if s.param("wait") == "yes" => { + let name = match s.param("save_as").trim() { + "" => "program", + name => name, + }; + known.extend(["output", "code", "ok"].map(|field| format!("{name}.{field}"))); + } _ => {} }); visit_steps(&h.steps, &mut |s| { @@ -548,6 +713,35 @@ fn shortcut_taken(integration: &Integration, s: &Settings) -> Option { }) } +/// A chat command or alias `integration` answers that another switched-on +/// integration answers too: the engine runs every match, so one `!delay` +/// would get two replies. Whether `integration` itself is on is the +/// caller's question. +fn command_taken(integration: &Integration, s: &Settings) -> Option { + fn commands(i: &Integration) -> impl Iterator { + i.handlers + .iter() + .filter(|h| h.enabled) + .filter_map(|h| match &h.trigger { + Trigger::ChatCommand { + command, aliases, .. + } => Some(std::iter::once(command).chain(aliases)), + _ => None, + }) + .flatten() + } + s.integrations + .iter() + .filter(|other| other.enabled && other.id != integration.id) + .find_map(|other| { + let word = commands(integration).find(|w| commands(other).any(|o| o == *w))?; + Some(format!( + "\"{}\" already answers {word}; change the command, or switch that one off", + other.name + )) + }) +} + /// Every Discord step must point at a connection that exists. /// Whether every enabled Twitch destination streams to a channel other /// than `user_id`'s. The channel comes from the id Twitch puts in a stream @@ -618,8 +812,13 @@ fn add( if s.integrations.len() >= MAX_INTEGRATIONS { return Err(format!("you already have {MAX_INTEGRATIONS} integrations")); } - let built = + let mut built = presets::build(preset, new_id(), &channel).ok_or("unknown catalog entry")?; + // "Add another" of a chat command: the copy waits, switched off, + // until it gets a command of its own. + if built.enabled && command_taken(&built, s).is_some() { + built.enabled = false; + } // A catalog entry that writes a file could never be finished. if !local_steps_allowed && built.has_local_effects() { if presets_to_add.len() > 1 { @@ -670,6 +869,9 @@ fn toggle( if let Some(taken) = shortcut_taken(integration, s) { return Err(taken); } + if let Some(taken) = command_taken(integration, s) { + return Err(taken); + } } s.integrations[at].enabled = enabled; Ok(()) @@ -684,12 +886,12 @@ fn duplicate(req: &Value, settings: &Arc>, cfg_path: &Pa if s.integrations.len() >= MAX_INTEGRATIONS { return Err(format!("you already have {MAX_INTEGRATIONS} integrations")); } - let original = s + let at = s .integrations .iter() - .find(|i| i.id == id) - .cloned() + .position(|i| i.id == id) .ok_or("that integration no longer exists")?; + let original = s.integrations[at].clone(); // The copy starts off, with no hotkey or pad and its own web call // link: otherwise one press, command or link would run both. let mut copy = original; @@ -714,7 +916,8 @@ fn duplicate(req: &Value, settings: &Arc>, cfg_path: &Pa _ => {} } } - s.integrations.push(copy); + // Right after the original, where the user is looking. + s.integrations.insert(at + 1, copy); Ok(()) }); with_id(reply, &returned) @@ -789,6 +992,32 @@ fn convert(req: &Value) -> Reply { /// Longest answer the editor's "Send request" shows. const FETCH_SHOWN: usize = 200_000; +/// Look for lights and plugs on the network (a few seconds). +async fn scan_lights() -> Reply { + let found = tokio::task::spawn_blocking(|| lights::scan(lights::SCAN_WAIT)) + .await + .unwrap_or_default(); + ok(json::obj([( + "devices", + Value::Arr(found.iter().map(lights::Device::to_json).collect()), + )])) +} + +/// Flash a device so the user sees which one it is. +async fn try_light(req: &Value) -> Reply { + let device = req.str_or("device", "").to_string(); + let rgb = lights::parse_color(req.str_or("color", "")).unwrap_or([255, 255, 255]); + let flash = lights::Command::Flash { + rgb, + brightness: None, + }; + match tokio::task::spawn_blocking(move || lights::send(&device, flash)).await { + Ok(Ok(note)) => ok(json::obj([("note", json::str(note.unwrap_or_default()))])), + Ok(Err(why)) => fail(why), + Err(_) => fail("the light test crashed"), + } +} + /// The editor's "Send request": make the request a web request step would, /// with the values the user typed, and show what came back. async fn fetch(req: &Value) -> Reply { @@ -831,7 +1060,7 @@ async fn fetch(req: &Value) -> Reply { } } -fn export(req: &Value, s: &Settings) -> Reply { +fn export(req: &Value, s: &Settings, cfg_path: &Path) -> Reply { let ids: Vec<&str> = req .get("ids") .and_then(Value::as_array) @@ -852,9 +1081,15 @@ fn export(req: &Value, s: &Settings) -> Reply { "" => chosen[0].name.clone(), n => n.to_string(), }; + // Alerts bring the theme and their uploads unless that's unticked. + let look = req.bool_or("with_theme", true).then_some(&s.alerts_look); + let folder = alert_media::folder(cfg_path); ok(json::obj([ ("ok", Value::Bool(true)), - ("recipe", json::str(recipe::export(&name, &chosen))), + ( + "recipe", + json::str(recipe::export(&name, &chosen, look, &folder)), + ), ])) } @@ -891,6 +1126,10 @@ fn import( ("items", Value::Arr(items)), ("uses_discord", Value::Bool(parsed.uses_discord())), ("local_effects", Value::Bool(parsed.has_local_effects())), + ( + "alerts", + shared_alerts_review(&parsed, &settings.borrow().alerts_look), + ), ])); } if parsed.has_local_effects() && !local_steps_allowed { @@ -912,6 +1151,21 @@ fn import( recipe::map_discord(&mut parsed, &channel); } let count = parsed.integrations.len(); + if would_overflow(settings, count) { + return fail(format!( + "that would go over {MAX_INTEGRATIONS} integrations" + )); + } + let (theme, added_media) = + match keep_shared_alerts(&mut parsed, req.bool_or("use_theme", false), cfg_path) { + Ok(kept) => kept, + Err(e) => return fail(e), + }; + let ids: Vec = parsed + .integrations + .iter() + .map(|i| json::str(&i.id)) + .collect(); let reply = update(settings, cfg_path, move |s| { if s.integrations.len() + count > MAX_INTEGRATIONS { return Err(format!( @@ -919,17 +1173,113 @@ fn import( )); } s.integrations.extend(parsed.integrations); + if let Some(theme) = theme { + s.alerts_look = theme; + } Ok(()) }); if reply.0 != "200 OK" { + // Nothing was added, so the uploads saved for it go too. + forget_media(&alert_media::folder(cfg_path), &added_media); return reply; } ok(json::obj([ ("ok", Value::Bool(true)), ("added", Value::Num(count as f64)), + ("ids", Value::Arr(ids)), ])) } +/// Whether adding this many integrations goes over the limit; checked +/// again under the write lock, this one keeps uploads from being saved +/// for an import that can't happen. +fn would_overflow(settings: &Arc>, adding: usize) -> bool { + settings.borrow().integrations.len() + adding > MAX_INTEGRATIONS +} + +/// What a recipe's alerts bring, for the import preview: its theme, the +/// uploads that came along and those that didn't, and whether this PC's +/// alerts page still has the plain default theme (then taking theirs is +/// ticked to start). +fn shared_alerts_review(parsed: &recipe::Recipe, mine: &alerts::Look) -> Value { + let Some(shared) = &parsed.alerts else { + return Value::Null; + }; + let named = |name: &str| { + json::obj([ + ("name", json::str(name)), + ("kind", json::str(recipe::media_kind(name))), + ]) + }; + json::obj([ + ("look", shared.look.to_json()), + ( + "media", + Value::Arr(shared.media.iter().map(|(n, _)| named(n)).collect()), + ), + ( + "left_out", + Value::Arr(shared.left_out.iter().map(|n| named(n)).collect()), + ), + ( + "mine_is_default", + Value::Bool(*mine == alerts::Look::default()), + ), + ]) +} + +/// Save the uploads a recipe brought and point its alerts at them. +/// Returns the sharer's theme (pointed at those uploads too) when the +/// importer chose to use it. +fn keep_shared_alerts( + parsed: &mut recipe::Recipe, + use_theme: bool, + cfg_path: &Path, +) -> Result<(Option, Vec), String> { + let Some(shared) = parsed.alerts.take() else { + // Shared without its uploads: an alert naming one falls back (the + // theme sound, no picture) instead of pointing at a file that isn't + // here, or at one of the importer's that happens to share the name. + recipe::point_media(parsed, None, &std::collections::BTreeMap::new()); + return Ok((None, Vec::new())); + }; + let folder = alert_media::folder(cfg_path); + // What was here before: an identical upload is reused, and a failed + // import may only take back the files it saved itself. + let here: std::collections::HashSet = alert_media::list(&folder) + .into_iter() + .map(|m| m.name) + .collect(); + let mut saved = std::collections::BTreeMap::new(); + let mut added = Vec::new(); + for (name, data) in &shared.media { + let kept = match recipe::keep_shared_media(&folder, name, data) { + Ok(kept) => kept, + Err(e) => { + forget_media(&folder, &added); + return Err(format!("couldn't keep {name}: {e}")); + } + }; + if !here.contains(&kept) && !added.contains(&kept) { + added.push(kept.clone()); + } + saved.insert(name.clone(), kept); + } + let mut theme = use_theme.then_some(shared.look); + recipe::point_media(parsed, theme.as_mut(), &saved); + Ok((theme, added)) +} + +/// Remove the uploads a failed import saved, so they don't stay behind +/// unused and count toward the uploads limit. +fn forget_media(folder: &Path, names: &[String]) { + for name in names { + if let Err(e) = alert_media::delete(folder, name) { + log_line!("[integrations] couldn't remove {name} after a failed import: {e}"); + } + } +} + /// Who can start a chat trigger, in words. The broadcaster always can. fn who_can(roles: Roles) -> &'static str { match (roles.everyone, roles.subs, roles.vips, roles.mods) { @@ -991,8 +1341,12 @@ fn effect_review(i: &Integration) -> Vec { add("danger", "Anyone in chat can change your delay") } StepKind::DelayAction => add("caution", "Changes your delay"), - StepKind::Obs => add("caution", "Controls OBS (scenes, sources, text)"), + StepKind::Obs => add( + "caution", + "Controls OBS (scenes, sources, recording, audio)", + ), StepKind::Http => add("caution", "Calls a web address you fill in"), + StepKind::Light => add("caution", "Controls a light or plug on your network"), StepKind::Clip => add("info", "Makes clips"), StepKind::Marker => add("info", "Adds VOD markers"), StepKind::Discord => add("info", "Posts on Discord"), @@ -1037,7 +1391,8 @@ fn summary(i: &Integration) -> String { .iter() .map(|h| super::model::count_steps(&h.steps)) .sum(); - format!("When {} · {steps} steps", triggers.join(", ")) + let unit = if steps == 1 { "step" } else { "steps" }; + format!("When {} · {steps} {unit}", triggers.join(", ")) } fn save_discord(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { @@ -1264,15 +1619,35 @@ mod tests { .params .insert("text".into(), "{delya} for {user} at {time}".into()); assert_eq!(unknown_vars(&i), vec!["delya".to_string()]); - for p in presets::PRESETS { - let built = presets::build(p.id, "x".into(), "c").unwrap(); - assert!( - unknown_vars(&built).is_empty(), - "{}: {:?}", - p.id, - unknown_vars(&built) - ); - } + } + + #[test] + fn checks_and_waited_programs_fill_their_values() { + use super::super::model::{Step, StepKind}; + let mut i = presets::build("delay_command", "d".into(), "").unwrap(); + let text = + "{obs.muted?muted:unmuted} {cam.scene} {cam.desk.muted} {rank.output} {program.code}"; + i.handlers[0].steps = vec![ + Step::new(StepKind::Obs, &[("action", "check"), ("source", "Mic")]), + Step::new( + StepKind::Obs, + &[ + ("action", "check"), + ("source", "Desk\nMic"), + ("save_as", "cam"), + ], + ), + Step::new(StepKind::Program, &[("wait", "yes"), ("save_as", "rank")]), + Step::new(StepKind::Chat, &[("text", text)]), + ]; + assert_eq!(unknown_vars(&i), vec!["program.code".to_string()]); + i.handlers[0].steps[0] + .params + .insert("source".into(), String::new()); + assert_eq!( + unknown_vars(&i), + vec!["obs.muted".to_string(), "program.code".to_string()] + ); } #[test] @@ -1303,8 +1678,8 @@ mod tests { #[test] fn programs_and_files_are_locked_when_the_request_may_not_set_them_up() { use super::super::model::{Step, StepKind}; - let dir = std::env::temp_dir().join(format!("ic-local-steps-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let scratch = crate::test_support::TestDir::new("ic-local-steps"); + let dir = scratch.path().to_path_buf(); let cfg = dir.join("instantclone.config.json"); let settings = Arc::new(watch::channel(Settings::defaults()).0); let mut i = presets::build("delay_command", "d".into(), "").unwrap(); @@ -1328,14 +1703,17 @@ mod tests { let shared = json::obj([ ( "recipe", - json::str(recipe::export("kit", std::slice::from_ref(&i))), + json::str(recipe::export( + "kit", + std::slice::from_ref(&i), + None, + Path::new(""), + )), ), ("apply", Value::Bool(true)), ("allow_local", Value::Bool(true)), ]); assert!(locked(import(&shared, &settings, &cfg, false))); - - let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -1370,12 +1748,224 @@ mod tests { assert!(effects.contains(&("danger".into(), "Runs a program on this PC".into()))); } + /// A recipe's uploads are saved before its integrations are added. If + /// that fails, only the files it saved are taken back: an identical file + /// that was already here was reused, and stays. + #[test] + fn a_failed_import_only_takes_back_the_uploads_it_saved() { + let scratch = crate::test_support::TestDir::new("ic-import-media"); + let cfg = scratch.path().join("instantclone.config.json"); + let folder = alert_media::folder(&cfg); + let mine = alert_media::save(&folder, "ding.mp3", b"mine").unwrap(); + let mut parsed = recipe::Recipe { + name: "shared".into(), + integrations: Vec::new(), + alerts: Some(recipe::SharedAlerts { + look: alerts::Look::default(), + media: vec![ + ("ding.mp3".into(), b"mine".to_vec()), + ("pop.mp3".into(), b"theirs".to_vec()), + ], + left_out: Vec::new(), + }), + }; + let (_, added) = keep_shared_alerts(&mut parsed, false, &cfg).unwrap(); + assert_eq!(added, ["pop.mp3"]); + forget_media(&folder, &added); + let left: Vec = alert_media::list(&folder) + .into_iter() + .map(|m| m.name) + .collect(); + assert_eq!(left, [mine]); + } + + /// A recipe shared without its uploads: its alerts fall back instead of + /// naming a file the importer doesn't have (or a different one of + /// theirs with the same name). + #[test] + fn alerts_shared_without_their_uploads_fall_back() { + use crate::integrations::model::{Step, StepKind}; + let scratch = crate::test_support::TestDir::new("ic-import-no-media"); + let cfg = scratch.path().join("instantclone.config.json"); + let mut integration = presets::build("delay_command", "d".into(), "").unwrap(); + integration.handlers[0].steps = vec![Step::new( + StepKind::Overlay, + &[("sound", "media:ding.mp3"), ("image", "media:party.gif")], + )]; + let mut parsed = recipe::Recipe { + name: "shared".into(), + integrations: vec![integration], + alerts: None, + }; + keep_shared_alerts(&mut parsed, false, &cfg).unwrap(); + let step = &parsed.integrations[0].handlers[0].steps[0]; + assert_eq!(step.param("sound"), alerts::THEME_SOUND); + assert_eq!(step.param("image"), ""); + } + + /// "Show it on stream" for an alert with a title and no text used to + /// show the generic card and still say the alert was on the page. + #[test] + fn an_alert_with_only_a_title_is_shown_as_itself() { + let titled = test_card(&json::parse(r#"{"title":"New follower","text":" "}"#).unwrap()); + assert_eq!(titled.title, "New follower"); + assert_eq!(titled.text, ""); + assert_eq!(test_card(&json::parse("{}").unwrap()).title, "InstantClone"); + } + #[test] fn summaries_name_the_triggers() { + // The step count is the preset's own business: only its shape here. let i = presets::build("mod_controls", "m".into(), "").unwrap(); - assert_eq!( - summary(&i), - "When !cut in chat, !setdelay in chat · 8 steps" + let text = summary(&i); + assert!( + text.starts_with("When !cut in chat, !setdelay in chat · "), + "{text}" + ); + assert!(text.ends_with(" steps"), "{text}"); + let one = presets::build("delay_command", "d".into(), "").unwrap(); + assert_eq!(summary(&one), "When !delay in chat · 1 step"); + } + + /// Settings and a config file of their own, for tests that save. + fn scratch(name: &str) -> (Arc>, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("ic-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + ( + Arc::new(watch::channel(Settings::defaults()).0), + dir.join("instantclone.config.json"), + ) + } + + fn stored(settings: &watch::Sender, id: &str) -> Option { + settings + .borrow() + .integrations + .iter() + .find(|i| i.id == id) + .cloned() + } + + #[test] + fn two_integrations_never_answer_the_same_chat_command() { + let (settings, cfg) = scratch("commands"); + let first = presets::build("delay_command", "first".into(), "").unwrap(); + assert_eq!(save(&first.to_json(), &settings, &cfg, true).0, "200 OK"); + + // A second "!delay", switched on: saved, but as a draft. + let second = presets::build("delay_command", "second".into(), "").unwrap(); + let reply = save(&second.to_json(), &settings, &cfg, true); + assert_eq!(reply.0, "200 OK"); + assert!(reply.2.contains(r#""drafted":true"#), "{}", reply.2); + assert!(reply.2.contains("already answers !delay"), "{}", reply.2); + assert!(!stored(&settings, "second").unwrap().enabled); + + // Switching it on is refused, and so is an alias that clashes. + let on = json::obj([("id", json::str("second")), ("enabled", Value::Bool(true))]); + let refused = toggle(&on, &settings, &cfg, true); + assert!( + refused.0 != "200 OK" && refused.2.contains("already answers"), + "{}", + refused.2 ); + let mut alias = second; + if let Trigger::ChatCommand { + command, aliases, .. + } = &mut alias.handlers[0].trigger + { + *command = "!wait".into(); + *aliases = vec!["!delay".into()]; + } + assert!(command_taken(&alias, &settings.borrow()).is_some()); + + // Its own command: no clash, and it switches on. + if let Trigger::ChatCommand { aliases, .. } = &mut alias.handlers[0].trigger { + aliases.clear(); + } + let reply = save(&alias.to_json(), &settings, &cfg, true); + assert!(reply.2.contains(r#""drafted":false"#), "{}", reply.2); + assert!(stored(&settings, "second").unwrap().enabled); + + // A switched-off one is no clash, and one integration never clashes + // with itself. + assert_eq!(command_taken(&first, &settings.borrow()), None); + let off = json::obj([("id", json::str("first")), ("enabled", Value::Bool(false))]); + assert_eq!(toggle(&off, &settings, &cfg, true).0, "200 OK"); + let mut again = alias.clone(); + again.id = "third".into(); + if let Trigger::ChatCommand { command, .. } = &mut again.handlers[0].trigger { + *command = "!delay".into(); + } + assert_eq!(command_taken(&again, &settings.borrow()), None); + + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); + } + + #[test] + fn adding_another_chat_command_adds_it_switched_off() { + let (settings, cfg) = scratch("add-another"); + let add_one = || { + let reply = add( + &json::obj([("preset", json::str("delay_command"))]), + &settings, + &cfg, + true, + ); + assert_eq!(reply.0, "200 OK", "{}", reply.2); + }; + add_one(); + add_one(); + let on: Vec = settings + .borrow() + .integrations + .iter() + .map(|i| i.enabled) + .collect(); + assert_eq!(on, vec![true, false]); + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); + } + + #[test] + fn an_unfinished_save_keeps_the_work_without_stopping_a_running_one() { + let (settings, cfg) = scratch("drafts"); + let mut webhook = presets::build("webhook", "hook".into(), "").unwrap(); + webhook.enabled = true; + let reply = save(&webhook.to_json(), &settings, &cfg, true); + assert_eq!(reply.0, "200 OK", "a new one is kept as a draft"); + assert!(reply.2.contains(r#""drafted":true"#), "{}", reply.2); + assert!(!stored(&settings, "hook").unwrap().enabled); + + // One that runs keeps running as it was: the edit is refused. + let running = presets::build("delay_command", "cmd".into(), "").unwrap(); + assert_eq!(save(&running.to_json(), &settings, &cfg, true).0, "200 OK"); + let mut broken = running.clone(); + broken.handlers[0].steps[0] + .params + .insert("text".into(), String::new()); + let reply = save(&broken.to_json(), &settings, &cfg, true); + assert_ne!(reply.0, "200 OK"); + assert_eq!(stored(&settings, "cmd").unwrap(), running); + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); + } + + #[test] + fn a_copy_lands_right_after_its_original() { + let (settings, cfg) = scratch("duplicate"); + for id in ["a", "b"] { + let i = presets::build("delay_notice", id.into(), "").unwrap(); + assert_eq!(save(&i.to_json(), &settings, &cfg, true).0, "200 OK"); + } + let reply = duplicate(&json::obj([("id", json::str("a"))]), &settings, &cfg); + assert_eq!(reply.0, "200 OK"); + let order: Vec = settings + .borrow() + .integrations + .iter() + .map(|i| i.id.clone()) + .collect(); + assert_eq!(order[0], "a"); + assert!(reply.2.contains(&order[1]), "the copy is second: {order:?}"); + assert_eq!(order[2], "b"); + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); } } diff --git a/src/integrations/clock.rs b/src/integrations/clock.rs index 456f104..c629574 100644 --- a/src/integrations/clock.rs +++ b/src/integrations/clock.rs @@ -92,10 +92,15 @@ mod tests { } #[test] - fn formats_are_zero_padded_and_plausible() { - let t = super::now(); - assert!(t.year >= 2024 && (1..=12).contains(&t.month) && (1..=31).contains(&t.day)); - assert_eq!(t.hm().len(), 5); - assert_eq!(t.date().len(), 10); + fn formats_are_zero_padded() { + let t = super::LocalTime { + year: 2026, + month: 3, + day: 7, + hour: 9, + minute: 5, + }; + assert_eq!(t.hm(), "09:05"); + assert_eq!(t.date(), "2026-03-07"); } } diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index 957bd44..e25ae87 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -3,7 +3,7 @@ use super::host::{ ClipInfo, DiscordCard, DiscordMessage, DiscordPosted, Host, HttpRequest, HttpResponse, - LiveState, + LiveState, ProgramOutput, }; use super::twitch::Twitch; use crate::controller::Controller; @@ -28,6 +28,10 @@ const MAX_FILE_TEXT: usize = 1024 * 1024; /// they are reaped (no zombies on Unix) without a waiting thread each, and /// capped so a runaway trigger can't fork the machine to its knees. const MAX_RUNNING_PROGRAMS: usize = 16; +/// How long a step waits for a program whose answer it keeps. +pub const PROGRAM_WAIT: Duration = Duration::from_secs(10); +/// What is kept of a program's answer. +const MAX_PROGRAM_OUTPUT: usize = 64 * 1024; /// Marks every web request InstantClone makes. A step can name this PC's /// own dashboard as its address; arriving with this, the dashboard treats @@ -241,6 +245,11 @@ impl Host for RealHost { destinations_total: total as usize, obs_live: self.ctrl.ingest_alive(), bitrate_kbps: self.ctrl.bitrate_kbps() as u64, + delay_max_s: self + .ctrl + .delay_capacity_ms() + .min(crate::config::MAX_DELAY_MS) + / 1000, channel: self.twitch.channel(), } } @@ -317,15 +326,20 @@ impl Host for RealHost { }; match action { // "Set the delay", not the hotkey's arm/disarm toggle. - "arm" => { - self.ctrl.set_delay_to(default_ms, "integration"); - Ok(()) - } + "arm" => match self.ctrl.set_delay_to(default_ms, "integration") { + None => Ok(()), + Some(problem) => Err(problem), + }, "disarm" => { self.ctrl.disarm("integration"); Ok(()) } - "toggle" | "activate" | "cut" | "cut_after" | "end_hold" => { + // Never cancels a cut already waiting, unlike the hotkey. + "cut_after" => match self.ctrl.cut_after_airs("integration") { + None => Ok(()), + Some(problem) => Err(problem), + }, + "toggle" | "activate" | "cut" | "end_hold" => { match self .ctrl .run_named_action(action, default_ms, "integration") @@ -361,6 +375,13 @@ impl Host for RealHost { Ok(()) } + fn program_output(&self, path: &str, args: &[String]) -> Result { + if path.is_empty() { + return Err("pick a program to run".to_string()); + } + run_and_read(path, args) + } + fn file(&self, path: &str, text: &str, append: bool) -> Result<(), String> { if path.is_empty() { return Err("pick a file".to_string()); @@ -384,11 +405,11 @@ impl Host for RealHost { result.map_err(|e| format!("couldn't write it: {e}")) } - fn overlay(&self, title: &str, text: &str, seconds: u64) { - self.alerts.show(title, text, seconds); + fn overlay(&self, card: super::alerts::Card) { + self.alerts.show(card); } - fn obs(&self, action: super::obsws::Action) -> Result<(), String> { + fn obs(&self, action: super::obsws::Action) -> Result { let (reply, outcome) = std::sync::mpsc::channel(); let wait = super::obsws::CALL_TIMEOUT + Duration::from_secs(1); let deadline = std::time::Instant::now() + wait; @@ -404,6 +425,14 @@ impl Host for RealHost { .recv_timeout(wait) .unwrap_or_else(|_| Err("OBS didn't answer in time".to_string())) } + + fn light( + &self, + device: &str, + command: super::lights::Command, + ) -> Result, String> { + super::lights::send(device, command) + } } fn discord_status(status: u16) -> Result<(), String> { @@ -440,6 +469,66 @@ fn webhook_message_url(url: &str, id: &str) -> String { /// The webhook's picture, so messages carry the InstantClone mark instead /// of Discord's default avatar. Discord fetches it; nothing is uploaded. +/// Run a program with no window and keep what it prints, waiting +/// `PROGRAM_WAIT` at most: one still running then is stopped. Its output +/// is read on a thread of its own, so a program printing more than is kept +/// never blocks on a full pipe. +fn run_and_read(path: &str, args: &[String]) -> Result { + use std::io::Read as _; + let mut command = std::process::Command::new(path); + command + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = command + .spawn() + .map_err(|e| format!("couldn't start it: {e}"))?; + let mut stdout = child.stdout.take().ok_or("couldn't read its answer")?; + let (sent, read) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut kept = Vec::new(); + let mut chunk = [0u8; 8192]; + while let Ok(n) = stdout.read(&mut chunk) { + if n == 0 { + break; + } + let room = MAX_PROGRAM_OUTPUT.saturating_sub(kept.len()); + kept.extend_from_slice(&chunk[..n.min(room)]); + } + let _ = sent.send(kept); + }); + let deadline = std::time::Instant::now() + PROGRAM_WAIT; + let status = loop { + if let Some(status) = child.try_wait().map_err(|e| format!("lost it: {e}"))? { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "it was still running after {} s, so it was stopped", + PROGRAM_WAIT.as_secs() + )); + } + std::thread::sleep(Duration::from_millis(25)); + }; + // Something it started may hold the output open: don't wait on that. + let kept = read + .recv_timeout(Duration::from_secs(1)) + .unwrap_or_default(); + Ok(ProgramOutput { + code: status.code(), + output: String::from_utf8_lossy(&kept).trim().to_string(), + }) +} + const AVATAR_URL: &str = "https://raw.githubusercontent.com/Soulhackzlol/InstantClone/main/docs/icon.png"; @@ -615,6 +704,24 @@ fn mention_rules(ping: &str) -> (String, Value) { mod tests { use super::*; + /// A real program through the system shell: what it printed (trimmed) + /// and how it ended both come back. + #[test] + fn a_programs_answer_and_exit_code_come_back() { + #[cfg(windows)] + let (shell, script) = ("cmd", "/C echo rank: Diamond & exit /b 3"); + #[cfg(not(windows))] + let (shell, script) = ("sh", "-c|echo ' rank: Diamond'; exit 3"); + #[cfg(windows)] + let args: Vec = script.splitn(2, ' ').map(str::to_string).collect(); + #[cfg(not(windows))] + let args: Vec = script.split('|').map(str::to_string).collect(); + let answer = run_and_read(shell, &args).unwrap(); + assert_eq!(answer.output, "rank: Diamond"); + assert_eq!(answer.code, Some(3)); + assert!(run_and_read("no-such-program-ic", &[]).is_err()); + } + fn text(content: &str, ping: &str) -> String { discord_body(&DiscordMessage { content: content.into(), diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index 3b725a8..fe2fdd2 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -26,7 +26,7 @@ use super::model::{ pad_matches, ChatActivity, DiscordChannel, Integration, MatchMode, PhoneConnection, Roles, Trigger, }; -use super::obsws::{self, SceneNews}; +use super::obsws::{self, ObsNews}; use super::runner::{self, RunContext, RunEnv, RunStatus, StepLog}; use super::session::Session; use super::store::Store; @@ -39,7 +39,7 @@ use crate::json::{self, Value}; use crate::sync::Mutex; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot, watch, Semaphore}; @@ -51,6 +51,41 @@ const PER_INTEGRATION_RUNS: usize = 16; const GLOBAL_STARTED: usize = 64; const ACTIVITY_KEEP: usize = 150; const TICK: Duration = Duration::from_millis(250); +/// Why a run may not start now, before the run limits are asked. +#[derive(Debug, PartialEq)] +enum HeldBack { + /// Inside the integration's quiet hours: silently skipped. + Quiet, + /// Too soon after this trigger's last run. + Cooldown, +} + +/// Quiet hours first, then the trigger's cooldown. `last_run` is when this +/// trigger last started a run; `minute_of_day` is local time. +fn held_back( + integration: &Integration, + last_run: Option, + minute_of_day: u16, + now: Instant, +) -> Option { + if integration.quiet.is_some_and(|q| q.contains(minute_of_day)) { + return Some(HeldBack::Quiet); + } + let cooldown = Duration::from_millis(integration.cooldown_ms); + let too_soon = last_run.is_some_and(|t| now.saturating_duration_since(t) < cooldown); + too_soon.then_some(HeldBack::Cooldown) +} + +/// Whether a viewer used a chat command too recently to use it again. +fn viewer_cooling_down(last: Option, user_cooldown_ms: u64, now: Instant) -> bool { + last.is_some_and(|t| now.saturating_duration_since(t) < Duration::from_millis(user_cooldown_ms)) +} + +/// Each viewer waits out a command's cooldown on their own. +fn viewer_key(integration_id: &str, idx: usize, login: &str) -> String { + format!("{integration_id}#{idx}#{login}") +} + /// How often per-viewer and per-trigger bookkeeping is trimmed. A busy /// chat adds an entry per viewer per command; without trimming, a long /// stream would only ever grow them. @@ -69,9 +104,11 @@ const FLAP_WINDOW: Duration = Duration::from_secs(300); pub const ACTIVITY_WARMUP: Duration = Duration::from_secs(30); /// How often chat activity triggers judge chat at most. const ACTIVITY_READ_EVERY: Duration = Duration::from_millis(250); -/// How long the dashboard keeps the OBS connection open after asking for -/// the scene list, so picking a scene works before anything is saved. +/// How long the dashboard keeps the OBS connection open after it last +/// asked, so its OBS pill and scene lists show the real thing. const OBS_FOR_DASHBOARD: Duration = Duration::from_secs(60); +/// How long a dashboard request waits for OBS to connect. +const OBS_CONNECT_WAIT: Duration = Duration::from_secs(8); enum Input { Event(Event), @@ -88,7 +125,7 @@ enum Input { }, #[cfg_attr(not(windows), allow(dead_code))] Shortcut(Shortcut, String), - Scene(SceneNews), + Obs(ObsNews), Login(Which), CancelLogin, Logout(Which), @@ -184,6 +221,10 @@ pub struct Handle { obs: Arc>, /// Until when (Unix ms) the dashboard wants OBS connected. obs_wanted_until: AtomicU64, + /// Whether an enabled integration uses OBS. + obs_in_use: AtomicBool, + /// Asks OBS for things the dashboard does itself (not a step's). + obs_commands: mpsc::Sender, /// Why chat activity triggers can't fire right now, or empty: see /// `Engine::activity_gate`. For the dashboard's live meter. activity_held: Mutex<&'static str>, @@ -391,13 +432,70 @@ impl Handle { } /// The OBS connection and its scenes. Asking keeps OBS connected for a - /// minute, so the scene list fills in while a trigger is being set up. + /// minute: the open dashboard asks every few seconds, so it shows how + /// OBS really stands, not just whether an integration needed it. `used` + /// says whether one does. pub fn obs_status(&self) -> Value { + self.keep_obs_for_dashboard(); + let mut status = self.obs.lock().clone(); + // Asking connects it on the engine's next tick. + if matches!(status.state, "" | "off") { + status.state = "connecting"; + } + let mut json = status.to_json(); + if let Value::Obj(fields) = &mut json { + let used = self.obs_in_use.load(Ordering::Relaxed); + fields.push(("used".to_string(), Value::Bool(used))); + } + json + } + + fn keep_obs_for_dashboard(&self) { self.obs_wanted_until.store( unix_ms() + OBS_FOR_DASHBOARD.as_millis() as u64, Ordering::Relaxed, ); - self.obs.lock().to_json() + } + + /// Ask OBS for something the dashboard does itself, connecting first + /// when nothing keeps OBS connected. Waits for OBS's answer. + pub async fn obs_now(&self, action: obsws::Action) -> Result<(), String> { + self.keep_obs_for_dashboard(); + let give_up = Instant::now() + OBS_CONNECT_WAIT; + loop { + let (state, problem) = { + let s = self.obs.lock(); + (s.state, s.problem.clone()) + }; + if state == "connected" { + break; + } + if Instant::now() >= give_up { + return Err(if problem.is_empty() { + "OBS isn't connected. Is OBS open?".to_string() + } else { + problem + }); + } + tokio::time::sleep(TICK).await; + } + let (reply, outcome) = std::sync::mpsc::channel(); + let wait = obsws::CALL_TIMEOUT * 3; + self.obs_commands + .try_send(obsws::Command { + action, + reply, + deadline: Instant::now() + wait, + }) + .map_err(|_| "OBS is busy; try again in a moment".to_string())?; + tokio::task::spawn_blocking(move || { + outcome + .recv_timeout(wait) + .unwrap_or_else(|_| Err("OBS didn't answer in time".to_string())) + .map(drop) + }) + .await + .unwrap_or_else(|_| Err("OBS didn't answer in time".to_string())) } } @@ -409,16 +507,18 @@ pub fn start( ) -> std::io::Result> { let (tx, rx) = mpsc::channel(QUEUE); let (chat_tx, chat_rx) = mpsc::channel(256); + let (twitch_events_tx, mut twitch_events) = mpsc::channel(64); let store = Arc::new(Store::open(&data_dir)); let log_ctrl = ctrl.clone(); let twitch = Arc::new(Twitch::new( store.clone(), - chat_tx, + (chat_tx, twitch_events_tx), Box::new(move |line| log_ctrl.log(line)), &settings.borrow().twitch_client_id, )); let alerts = Arc::new(Board::new()); let obs = Arc::new(Mutex::new(obsws::Status::default())); + let (obs_tx, obs_commands) = mpsc::channel(16); let handle = Arc::new(Handle { tx, activity: Mutex::new(VecDeque::new()), @@ -431,6 +531,8 @@ pub fn start( chat: Arc::new(Mutex::new(ChatWindow::default())), obs: obs.clone(), obs_wanted_until: AtomicU64::new(0), + obs_in_use: AtomicBool::new(false), + obs_commands: obs_tx.clone(), activity_held: Mutex::new("offline"), }); let engine_handle = handle.clone(); @@ -449,7 +551,6 @@ pub fn start( }; runtime.block_on(async move { twitch.start(); - let (obs_tx, obs_commands) = mpsc::channel(16); let host = Arc::new(RealHost { ctrl: ctrl.clone(), twitch: twitch.clone(), @@ -460,14 +561,23 @@ pub fn start( onair: Mutex::new("off"), obs: obs_tx, }); - // OBS's scene news reaches the engine like any other input. + // News from OBS reaches the engine like any other input. let (want_obs, wanted) = watch::channel(false); let (news_tx, mut news_rx) = mpsc::channel(64); tokio::spawn(obsws::run(wanted, obs, news_tx, obs_commands)); - let scene_tx = engine_handle.tx.clone(); + // So do Twitch's events (follows, subs, raids...). + let twitch_events_to = engine_handle.tx.clone(); + tokio::spawn(async move { + while let Some(event) = twitch_events.recv().await { + if twitch_events_to.send(Input::Event(event)).await.is_err() { + return; + } + } + }); + let obs_news_tx = engine_handle.tx.clone(); tokio::spawn(async move { while let Some(news) = news_rx.recv().await { - if scene_tx.send(Input::Scene(news)).await.is_err() { + if obs_news_tx.send(Input::Obs(news)).await.is_err() { return; } } @@ -640,6 +750,7 @@ impl Engine { self.update_activity_gate(); self.settle_scenes(); self.update_obs_want(); + self.update_twitch_events(); self.run_timers(); self.save_uses_if_due(); if last_prune.elapsed() >= PRUNE_EVERY { @@ -738,7 +849,8 @@ impl Engine { reply, } => self.test(*integration, handler, run, reply), Input::Shortcut(from, signature) => self.shortcut(from, &signature), - Input::Scene(news) => self.scene(news), + Input::Obs(ObsNews::Happened(event)) => self.event(event), + Input::Obs(news) => self.scene(news), Input::Login(which) => self.handle.twitch.begin_login(which), Input::CancelLogin => self.handle.twitch.cancel_login(), Input::Logout(which) => self.handle.twitch.logout(which), @@ -1092,25 +1204,25 @@ impl Engine { roles, user_cooldown_ms, } if (*command == first || aliases.contains(&first)) && allowed(*roles, &msg) => { - let key = format!("{}#{idx}#{}", i.id, msg.user_login); - let cooled = self + let last = self .viewer_last - .get(&key) - .is_some_and(|t| t.elapsed() < Duration::from_millis(*user_cooldown_ms)); - (!cooled).then(|| (i.clone(), idx, first.clone())) + .get(&viewer_key(&i.id, idx, &msg.user_login)); + let cooling = + viewer_cooling_down(last.copied(), *user_cooldown_ms, Instant::now()); + (!cooling).then(|| (i.clone(), idx, first.clone())) } Trigger::ChatMessage { pattern, mode, roles, } if allowed(*roles, &msg) && text_matches(text, pattern, *mode) => { - Some((i.clone(), idx, "chat".to_string())) + Some((i, idx, "chat".to_string())) } _ => None, }) .collect(); for (integration, idx, label) in candidates { - let key = format!("{}#{idx}#{}", integration.id, msg.user_login); + let key = viewer_key(&integration.id, idx, &msg.user_login); let vars = chat_vars(&msg, &args); let trigger = if label == "chat" { "Chat message".to_string() @@ -1209,10 +1321,11 @@ impl Engine { /// starts that trigger's settle time; it fires once the scene stayed. /// The scene on air when OBS connects counts as a switch to it when a /// stream is already on (the app or OBS restarted mid-stream). - fn scene(&mut self, news: SceneNews) { + fn scene(&mut self, news: ObsNews) { let (name, switched) = match news { - SceneNews::Current(name) => (name, false), - SceneNews::Switched(name) => (name, true), + ObsNews::Current(name) => (name, false), + ObsNews::Switched(name) => (name, true), + ObsNews::Happened(_) => return, }; let previous = std::mem::replace(&mut self.scene_now, name.clone()); if previous == name { @@ -1319,18 +1432,39 @@ impl Engine { self.scene_pending.retain(|_, p| p.scene == now_scene); } - /// Keep OBS connected while a scene trigger or an OBS step is on, or - /// the dashboard is picking a scene. + /// Listen on Twitch for the events enabled integrations start on. + fn update_twitch_events(&mut self) { + let mut wanted: Vec = Vec::new(); + for (_, _, h) in self.enabled_handlers() { + if let Trigger::Event { kind, .. } = &h.trigger { + if super::twitch::eventsub::subscription(*kind).is_some() && !wanted.contains(kind) + { + wanted.push(*kind); + } + } + } + // In a fixed order: the same set must compare equal. + wanted.sort_by_key(|k| EventKind::ALL.iter().position(|a| a == k)); + self.host.twitch.want_events(wanted); + } + + /// Keep OBS connected while a scene trigger, an OBS event trigger or an + /// OBS step is on, or the dashboard is open. fn update_obs_want(&mut self) { let uses_obs = |h: &super::model::Handler| { - let mut found = matches!(h.trigger, Trigger::Scene { .. }); + let mut found = match &h.trigger { + Trigger::Scene { .. } => true, + Trigger::Event { kind, .. } => kind.needs_obs(), + _ => false, + }; super::model::visit_steps(&h.steps, &mut |s| { found |= s.enabled && s.kind == super::model::StepKind::Obs }); found }; - let wanted = self.enabled_handlers().any(|(_, _, h)| uses_obs(h)) - || unix_ms() < self.handle.obs_wanted_until.load(Ordering::Relaxed); + let in_use = self.enabled_handlers().any(|(_, _, h)| uses_obs(h)); + self.handle.obs_in_use.store(in_use, Ordering::Relaxed); + let wanted = in_use || unix_ms() < self.handle.obs_wanted_until.load(Ordering::Relaxed); if *self.want_obs.borrow() != wanted { self.want_obs.send_replace(wanted); } @@ -1496,28 +1630,24 @@ impl Engine { reply_to: Option, trigger: String, ) -> bool { - if let Some(quiet) = integration.quiet { - let now = super::clock::now(); - if quiet.contains((now.hour * 60 + now.minute) as u16) { + let key = format!("{}#{idx}", integration.id); + let clock = super::clock::now(); + let minute_of_day = (clock.hour * 60 + clock.minute) as u16; + let last = self.last_run.get(&key).copied(); + match held_back(&integration, last, minute_of_day, Instant::now()) { + Some(HeldBack::Quiet) => return false, + Some(HeldBack::Cooldown) => { + // A button pressed too soon says so: the press wasn't lost. + // Chat, much busier, doesn't fill the log with them. + if matches!( + integration.handlers[idx].trigger, + Trigger::Shortcut { .. } | Trigger::ChatActivity(_) + ) { + self.turned_away(&integration, trigger, "cooldown"); + } return false; } - } - let key = format!("{}#{idx}", integration.id); - if integration.cooldown_ms > 0 - && self - .last_run - .get(&key) - .is_some_and(|t| t.elapsed() < Duration::from_millis(integration.cooldown_ms)) - { - // A button pressed too soon says so: the press wasn't lost. - // Chat, much busier, doesn't fill the log with them. - if matches!( - integration.handlers[idx].trigger, - Trigger::Shortcut { .. } | Trigger::ChatActivity(_) - ) { - self.turned_away(&integration, trigger, "cooldown"); - } - return false; + None => {} } let (started_limit, active_limit) = self .limits @@ -1965,7 +2095,7 @@ pub const ACTIVITY_SAMPLES: &[(&str, &str)] = &[ ("chatters", "15"), ("word_share", "58"), ("top_word", "pog"), - ("user", "Ana"), + ("user", "Nat"), ("message", "CLIP THAT"), ]; @@ -2048,7 +2178,6 @@ mod tests { !guard.suppress(&Event::new(EventKind::HoldOpened), t0), "other events pass" ); - assert!(!guard.suppress(&Event::new(EventKind::HoldOpened), t0)); } #[test] @@ -2123,10 +2252,93 @@ mod tests { assert!(wildcard(&"a".repeat(200), &"*a".repeat(50))); } + /// An integration for the cooldown and quiet-hours tests. + fn timed(cooldown_ms: u64, quiet: Option<(u16, u16)>) -> Integration { + let mut i = crate::integrations::presets::build("delay_command", "t".into(), "").unwrap(); + i.cooldown_ms = cooldown_ms; + i.quiet = quiet.map(|(from, to)| crate::integrations::model::QuietHours { from, to }); + i + } + + /// A run starts again only once its cooldown has passed, measured from + /// the run before; no cooldown, or no run yet, never holds one back. + #[test] + fn a_cooldown_holds_back_a_run_until_it_passes() { + let now = Instant::now() + Duration::from_secs(60); + let ago = |ms: u64| Some(now - Duration::from_millis(ms)); + let every_10s = timed(10_000, None); + for (last, held, case) in [ + (None, None, "never ran"), + (ago(1_000), Some(HeldBack::Cooldown), "1 s after"), + (ago(9_999), Some(HeldBack::Cooldown), "just short"), + (ago(10_000), None, "exactly the cooldown"), + (ago(30_000), None, "long after"), + ] { + assert_eq!(held_back(&every_10s, last, 600, now), held, "{case}"); + } + assert_eq!( + held_back(&timed(0, None), ago(1), 600, now), + None, + "no cooldown" + ); + } + + /// Quiet hours run from `from` up to `to`, overnight too, and come + /// before the cooldown: a quiet run isn't "too soon", it's skipped. + #[test] + fn quiet_hours_hold_back_every_run_inside_them() { + let now = Instant::now(); + let at = |i: &Integration, minute: u16| held_back(i, None, minute, now); + let office = timed(0, Some((9 * 60, 17 * 60))); + let night = timed(0, Some((23 * 60, 7 * 60))); + for (integration, minute, held, case) in [ + (&office, 8 * 60 + 59, None, "08:59, before"), + (&office, 9 * 60, Some(HeldBack::Quiet), "09:00, from"), + (&office, 16 * 60 + 59, Some(HeldBack::Quiet), "16:59"), + (&office, 17 * 60, None, "17:00, to is not included"), + (&night, 22 * 60 + 59, None, "22:59"), + (&night, 23 * 60, Some(HeldBack::Quiet), "23:00"), + (&night, 0, Some(HeldBack::Quiet), "midnight"), + (&night, 6 * 60 + 59, Some(HeldBack::Quiet), "06:59"), + (&night, 7 * 60, None, "07:00"), + ] { + assert_eq!(at(integration, minute), held, "{case}"); + } + let both = timed(10_000, Some((0, 24 * 60))); + assert_eq!( + held_back(&both, Some(now), 600, now), + Some(HeldBack::Quiet), + "quiet wins over the cooldown" + ); + } + + /// One viewer's cooldown doesn't hold back another, and it ends on time. + #[test] + fn each_viewer_waits_out_a_command_cooldown_alone() { + assert_ne!(viewer_key("i", 0, "ana"), viewer_key("i", 0, "bo")); + assert_ne!( + viewer_key("i", 0, "ana"), + viewer_key("i", 1, "ana"), + "per trigger too" + ); + let now = Instant::now() + Duration::from_secs(60); + let used = Some(now - Duration::from_millis(5_000)); + assert!(viewer_cooling_down(used, 30_000, now)); + assert!( + !viewer_cooling_down(used, 5_000, now), + "over once it has passed" + ); + assert!(!viewer_cooling_down(None, 30_000, now), "first time"); + } + #[test] fn matching_ignores_case() { assert!(text_matches("POG champ", "pog", MatchMode::StartsWith)); assert!(text_matches(" GG ", "gg", MatchMode::Exact)); + } + + #[test] + fn an_empty_pattern_never_matches() { assert!(!text_matches("gg", "", MatchMode::Contains)); } @@ -2166,7 +2378,7 @@ mod tests { } #[test] - fn tests_read_like_the_filtered_event() { + fn test_runs_sample_vars_take_filter_values() { let filters = BTreeMap::from([ ("destination".to_string(), "Kick".to_string()), ("reason".to_string(), "*timeout*".to_string()), diff --git a/src/integrations/event.rs b/src/integrations/event.rs index f7d58ce..7ca5e6c 100644 --- a/src/integrations/event.rs +++ b/src/integrations/event.rs @@ -34,6 +34,20 @@ pub enum EventKind { DestinationStillDown, DestinationUnstable, DestinationSteady, + // From OBS itself, over its WebSocket server (see `obsws`). + ObsRecordingStarted, + ObsRecordingStopped, + ObsReplaySaved, + ObsMuteChanged, + // From Twitch, over EventSub (see `twitch::eventsub`). + TwitchFollow, + TwitchSub, + TwitchResub, + TwitchGift, + TwitchCheer, + TwitchRaid, + TwitchRedeem, + TwitchAd, } /// One variable an event carries: its name and a sample value. @@ -47,7 +61,7 @@ pub type ParamSpec = (&'static str, &'static str, &'static str); const MAX_PARAM: f64 = 1_000_000.0; impl EventKind { - pub const ALL: [EventKind; 24] = [ + pub const ALL: [EventKind; 36] = [ EventKind::ObsConnected, EventKind::ObsDisconnected, EventKind::EbDetected, @@ -73,6 +87,18 @@ impl EventKind { EventKind::DelayChanged, EventKind::DelayOff, EventKind::DelayDisarmed, + EventKind::ObsRecordingStarted, + EventKind::ObsRecordingStopped, + EventKind::ObsReplaySaved, + EventKind::ObsMuteChanged, + EventKind::TwitchFollow, + EventKind::TwitchSub, + EventKind::TwitchResub, + EventKind::TwitchGift, + EventKind::TwitchCheer, + EventKind::TwitchRaid, + EventKind::TwitchRedeem, + EventKind::TwitchAd, ]; pub fn id(self) -> &'static str { @@ -101,6 +127,18 @@ impl EventKind { EventKind::DestinationStillDown => "destination_still_down", EventKind::DestinationUnstable => "destination_unstable", EventKind::DestinationSteady => "destination_steady", + EventKind::ObsRecordingStarted => "obs_recording_started", + EventKind::ObsRecordingStopped => "obs_recording_stopped", + EventKind::ObsReplaySaved => "obs_replay_saved", + EventKind::ObsMuteChanged => "obs_mute_changed", + EventKind::TwitchFollow => "twitch_follow", + EventKind::TwitchSub => "twitch_sub", + EventKind::TwitchResub => "twitch_resub", + EventKind::TwitchGift => "twitch_gift", + EventKind::TwitchCheer => "twitch_cheer", + EventKind::TwitchRaid => "twitch_raid", + EventKind::TwitchRedeem => "twitch_redeem", + EventKind::TwitchAd => "twitch_ad", } } @@ -134,6 +172,18 @@ impl EventKind { EventKind::DestinationStillDown => "A platform stays down", EventKind::DestinationUnstable => "A platform keeps dropping", EventKind::DestinationSteady => "A platform is steady again", + EventKind::ObsRecordingStarted => "OBS starts recording", + EventKind::ObsRecordingStopped => "OBS stops recording", + EventKind::ObsReplaySaved => "OBS saves a replay", + EventKind::ObsMuteChanged => "A source is muted or unmuted", + EventKind::TwitchFollow => "Someone follows", + EventKind::TwitchSub => "Someone subscribes", + EventKind::TwitchResub => "Someone resubscribes", + EventKind::TwitchGift => "Someone gifts subs", + EventKind::TwitchCheer => "Someone cheers bits", + EventKind::TwitchRaid => "A raid comes in", + EventKind::TwitchRedeem => "Someone redeems channel points", + EventKind::TwitchAd => "An ad break starts", } } @@ -163,9 +213,32 @@ impl EventKind { | EventKind::DelayArmed | EventKind::DelayReady | EventKind::DelayDisarmed => "Delay", + EventKind::ObsRecordingStarted + | EventKind::ObsRecordingStopped + | EventKind::ObsReplaySaved + | EventKind::ObsMuteChanged => "OBS", + EventKind::TwitchFollow + | EventKind::TwitchSub + | EventKind::TwitchResub + | EventKind::TwitchGift + | EventKind::TwitchCheer + | EventKind::TwitchRaid + | EventKind::TwitchRedeem + | EventKind::TwitchAd => "Twitch", } } + /// Something InstantClone itself saw happen, not OBS or Twitch telling + /// it (those need a connection to them while a trigger listens). + pub fn is_own(self) -> bool { + !matches!(self.group(), "OBS" | "Twitch") + } + + /// Heard from OBS's WebSocket server, which must then be connected. + pub fn needs_obs(self) -> bool { + self.group() == "OBS" + } + /// Variables the event itself carries, on top of the always-available /// ones in `vars::GLOBAL`. pub fn vars(self) -> &'static [VarSpec] { @@ -234,10 +307,45 @@ impl EventKind { ("drops", "4"), ("down_total", "2m 30s"), ], + // `file`: where OBS saved it. + EventKind::ObsRecordingStopped | EventKind::ObsReplaySaved => { + &[("file", r"C:\Videos\2026-10-08 21-04-11.mkv")] + } + // `obs_source`, not `source`: shortcuts already use that name. + EventKind::ObsMuteChanged => &[("obs_source", "Mic/Aux"), ("muted", "yes")], + // `user` is "Anonymous" for an anonymous gift or cheer. `tier` + // is 1, 2 or 3. + EventKind::TwitchFollow => &[("user", "Nat")], + EventKind::TwitchSub => &[("user", "Nat"), ("tier", "1"), ("gift", "no")], + EventKind::TwitchResub => &[ + ("user", "Nat"), + ("tier", "1"), + ("months", "13"), + ("streak", "4"), + ("message", "13 months, still here"), + ], + EventKind::TwitchGift => &[ + ("user", "Nat"), + ("count", "5"), + ("tier", "1"), + ("total", "42"), + ], + EventKind::TwitchCheer => { + &[("user", "Nat"), ("bits", "500"), ("message", "Cheer500 gg")] + } + EventKind::TwitchRaid => &[("user", "Nat"), ("viewers", "42")], + EventKind::TwitchRedeem => &[ + ("user", "Nat"), + ("reward", "Hydrate"), + ("cost", "500"), + ("input", "with lemon"), + ], + EventKind::TwitchAd => &[("seconds", "90"), ("automatic", "yes")], EventKind::ObsConnected | EventKind::EbDetected | EventKind::AllDestinationsDown - | EventKind::StreamStarted => &[], + | EventKind::StreamStarted + | EventKind::ObsRecordingStarted => &[], } } @@ -378,7 +486,7 @@ mod tests { } #[test] - fn samples_carry_every_declared_var() { + fn a_sample_event_reads_like_a_real_one() { let e = Event::sample(EventKind::DestinationDropped); assert_eq!(e.var("destination"), Some("YouTube")); assert_eq!(e.var("reason"), Some("connection timed out")); @@ -397,9 +505,4 @@ mod tests { let _ = std::time::Duration::from_secs_f64(kind.param(&set("inf"), "after_s") * 60.0); assert!(kind.is_param("after_s") && !kind.is_param("destination")); } - - #[test] - fn catalog_is_valid_json() { - assert!(crate::config::is_valid_json(&catalog_json().to_json())); - } } diff --git a/src/integrations/host.rs b/src/integrations/host.rs index 4812b55..0280d94 100644 --- a/src/integrations/host.rs +++ b/src/integrations/host.rs @@ -21,6 +21,9 @@ pub struct LiveState { /// Whether OBS is streaming to InstantClone right now. pub obs_live: bool, pub bitrate_kbps: u64, + /// The longest delay the buffer holds right now, in whole seconds, + /// never past the top (`config::MAX_DELAY_MS`). + pub delay_max_s: u32, /// The connected Twitch channel's login, or empty. pub channel: String, /// Whether a stream is on (see `session::Session`): between streams @@ -49,6 +52,14 @@ pub struct ClipInfo { pub url: String, } +/// What a program a step waited for answered. +pub struct ProgramOutput { + /// Its exit code; `None` when it ended without one. + pub code: Option, + /// What it printed, trimmed. + pub output: String, +} + /// A Discord message to post: plain `content`, a card, or both (the /// content then sits above the card, where a link unfurls into a player). #[derive(Clone, Debug, Default, PartialEq)] @@ -112,11 +123,22 @@ pub trait Host: Send + Sync { /// delay for `arm`. fn delay_action(&self, action: &str, ms: u32) -> Result<(), String>; fn program(&self, path: &str, args: &[String]) -> Result<(), String>; + /// Run a program and wait for it (a short while at most), keeping what + /// it printed. + fn program_output(&self, path: &str, args: &[String]) -> Result; fn file(&self, path: &str, text: &str, append: bool) -> Result<(), String>; - /// Put a card on the alerts browser source for `seconds`. - fn overlay(&self, title: &str, text: &str, seconds: u64); - /// Ask OBS to do something, through its WebSocket server. - fn obs(&self, action: super::obsws::Action) -> Result<(), String>; + /// Put a card on the alerts browser source. + fn overlay(&self, card: super::alerts::Card); + /// Ask OBS to do something, or what it's doing, through its WebSocket + /// server. A check answers with values; an action with none. + fn obs(&self, action: super::obsws::Action) -> Result; + /// Drive a light or plug on the network (`device` is a reference from + /// `lights`). Says where it found the device when it had moved. + fn light( + &self, + device: &str, + command: super::lights::Command, + ) -> Result, String>; } /// `30s`, `1m 30s`, or empty for no delay, so `{delay|off}` reads naturally. @@ -125,11 +147,14 @@ pub fn fmt_delay(ms: u32) -> String { return String::new(); } let total = (ms + 500) / 1000; - match (total / 60, total % 60) { - (0, s) => format!("{s}s"), - (m, 0) => format!("{m}m"), - (m, s) => format!("{m}m {s}s"), - } + let (h, m, s) = (total / 3600, total % 3600 / 60, total % 60); + let parts = [(h, "h"), (m, "m"), (s, "s")]; + let shown: Vec = parts + .iter() + .filter(|(n, _)| *n > 0) + .map(|(n, unit)| format!("{n}{unit}")) + .collect(); + shown.join(" ") } /// A stream position, the way YouTube chapters and VOD players write it: @@ -175,6 +200,8 @@ mod tests { assert_eq!(fmt_delay(90_000), "1m 30s"); assert_eq!(fmt_delay(120_000), "2m"); assert_eq!(fmt_delay(29_600), "30s"); + assert_eq!(fmt_delay(3_600_000), "1h"); + assert_eq!(fmt_delay(3_930_000), "1h 5m 30s"); } #[test] diff --git a/src/integrations/lights/govee.rs b/src/integrations/lights/govee.rs new file mode 100644 index 0000000..eccc092 --- /dev/null +++ b/src/integrations/lights/govee.rs @@ -0,0 +1,142 @@ +//! Govee's LAN API, as govee-local-api uses it. Each device needs "LAN +//! Control" switched on in the Govee Home app first. +//! +//! A scan goes to the multicast group on port 4001 and devices answer on +//! port 4002 of this PC; commands go to the device on port 4003, which +//! sends no answer to them. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; +use std::time::Duration; + +const GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); +const SCAN_PORT: u16 = 4001; +const ANSWER_PORT: u16 = 4002; +pub const COMMAND_PORT: u16 = 4003; +const SCAN: &[u8] = br#"{"msg":{"cmd":"scan","data":{"account_topic":"reserve"}}}"#; +/// Commands are never answered, so each goes out twice: a lost one on +/// Wi-Fi would otherwise go unnoticed. +const SENDS: usize = 2; +const BETWEEN: Duration = Duration::from_millis(60); + +pub fn scan(wait: Duration) -> Vec { + // Answers come to a fixed port. Another Govee tool on this PC may hold + // it; then there's no hearing them. + let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, ANSWER_PORT)) else { + return Vec::new(); + }; + let _ = socket.send_to(SCAN, (GROUP, SCAN_PORT)); + net::collect(&socket, wait) + .into_iter() + .filter_map(|(from, data)| device_of(from, &data)) + .collect() +} + +/// A scan answer: `{"msg":{"cmd":"scan","data":{"ip":..,"device":..,"sku":..}}}`. +fn device_of(from: Ipv4Addr, data: &[u8]) -> Option { + let v = json::parse(&String::from_utf8_lossy(data)).ok()?; + if v.path("msg.cmd").and_then(Value::as_str) != Some("scan") { + return None; + } + let d = v.path("msg.data")?; + let id = d.str_or("device", "").to_string(); + if id.is_empty() { + return None; + } + let address = d.str_or("ip", "").parse().unwrap_or(from); + let model = d.str_or("sku", ""); + Some(Device { + brand: Brand::Govee, + address, + name: format!("Govee {model} ··{}", net::tail(&id)).replace(" ", " "), + id, + plug: false, + }) +} + +fn command(cmd: &str, data: Value) -> String { + json::obj([("msg", json::obj([("cmd", json::str(cmd)), ("data", data)]))]).to_json() +} + +/// The commands for a state, in order: power, then color, then brightness. +pub fn messages(state: State) -> Vec { + let number = |n: u8| Value::Num(f64::from(n)); + let mut out = vec![command( + "turn", + json::obj([("value", number(u8::from(state.on)))]), + )]; + if !state.on { + return out; + } + if let Some([r, g, b]) = state.color { + let color = json::obj([("r", number(r)), ("g", number(g)), ("b", number(b))]); + out.push(command( + "colorwc", + json::obj([("color", color), ("colorTemInKelvin", number(0))]), + )); + } + if state.color.is_some() || state.brightness.is_some() { + out.push(command( + "brightness", + json::obj([("value", number(super::percent(&state)))]), + )); + } + out +} + +pub fn set(to: SocketAddr, state: State) -> Result<(), Failure> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .map_err(|e| Failure::Unreachable(format!("couldn't open a network socket: {e}")))?; + for message in messages(state) { + for _ in 0..SENDS { + socket + .send_to(message.as_bytes(), to) + .map_err(|e| Failure::Unreachable(format!("couldn't reach {}: {e}", to.ip())))?; + std::thread::sleep(BETWEEN); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scan_answers_name_the_device() { + let answer = br#"{"msg":{"cmd":"scan","data":{"ip":"192.168.1.23","device":"1F:80:C5:32:32:36:72:4E","sku":"H618E","bleVersionHard":"3.01.01","wifiVersionSoft":"1.02.03"}}}"#; + let d = device_of(Ipv4Addr::new(192, 168, 1, 99), answer).unwrap(); + assert_eq!( + d.address, + Ipv4Addr::new(192, 168, 1, 23), + "the address it says" + ); + assert_eq!(d.id, "1F:80:C5:32:32:36:72:4E"); + assert_eq!(d.name, "Govee H618E ··724e"); + assert!(device_of(d.address, br#"{"msg":{"cmd":"devStatus","data":{}}}"#).is_none()); + } + + #[test] + fn a_color_is_power_color_then_brightness() { + let state = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(60), + }; + assert_eq!( + messages(state), + [ + r#"{"msg":{"cmd":"turn","data":{"value":1}}}"#, + r#"{"msg":{"cmd":"colorwc","data":{"color":{"r":255,"g":0,"b":0},"colorTemInKelvin":0}}}"#, + r#"{"msg":{"cmd":"brightness","data":{"value":60}}}"#, + ] + ); + let off = State { + on: false, + color: Some([1, 2, 3]), + brightness: None, + }; + assert_eq!(messages(off).len(), 1, "off is only off"); + } +} diff --git a/src/integrations/lights/http.rs b/src/integrations/lights/http.rs new file mode 100644 index 0000000..0df4d78 --- /dev/null +++ b/src/integrations/lights/http.rs @@ -0,0 +1,297 @@ +//! Shelly relays and plugs, and WLED lights: both answer plain HTTP on the +//! local network, as aioshelly and python-wled talk to them. +//! +//! Neither answers a broadcast, so a scan knocks on port 80 of every +//! address on this PC's network (a /24) and asks whatever answers what it +//! is: Shelly at `/shelly`, WLED at `/json/info`. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::Duration; + +const KNOCK: Duration = Duration::from_millis(300); +const ASK: Duration = Duration::from_millis(1500); +const COMMAND: Duration = Duration::from_secs(3); +/// How many addresses are knocked on at once. +const WORKERS: usize = 32; +const MAX_ANSWER: u64 = 64 * 1024; + +pub fn scan() -> Vec { + let Some(me) = net::local_ipv4() else { + return Vec::new(); + }; + let [a, b, c, _] = me.octets(); + let hosts: Vec = (1..=254u8) + .map(|d| Ipv4Addr::new(a, b, c, d)) + .filter(|ip| *ip != me) + .collect(); + std::thread::scope(|scope| { + let workers: Vec<_> = hosts + .chunks(hosts.len().div_ceil(WORKERS)) + .map(|chunk| { + scope.spawn(move || { + chunk + .iter() + .filter_map(|ip| identify(*ip)) + .collect::>() + }) + }) + .collect(); + workers + .into_iter() + .flat_map(|w| w.join().unwrap_or_default()) + .collect() + }) +} + +/// What answers on port 80 at `ip`, if it's a Shelly or a WLED. +fn identify(ip: Ipv4Addr) -> Option { + TcpStream::connect_timeout(&SocketAddr::from((ip, 80)), KNOCK).ok()?; + if let Some(v) = get(ip, "/shelly", ASK) { + return shelly_of(ip, &v); + } + wled_of(ip, &get(ip, "/json/info", ASK)?) +} + +/// The agent for a device on this network: plain HTTP, giving up after +/// `timeout`, with every status handed back as Ok for `answered` to read. +/// It never follows a redirect, so a device can only answer for itself, +/// never send the call on to another address. +fn lan_agent(timeout: Duration) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(timeout)) + .max_redirects(0) + .http_status_as_error(false) + .build() + .into() +} + +fn get(ip: Ipv4Addr, path: &str, timeout: Duration) -> Option { + let mut resp = lan_agent(timeout) + .get(&format!("http://{ip}{path}")) + .call() + .ok()?; + let text = resp + .body_mut() + .with_config() + .limit(MAX_ANSWER) + .read_to_string() + .ok()?; + json::parse(&text).ok() +} + +/// `/shelly`: generation 2 and later say `gen`; generation 1 says `type`. +fn shelly_of(ip: Ipv4Addr, v: &Value) -> Option { + let mac = v.str_or("mac", "").to_string(); + if mac.is_empty() { + return None; + } + let gen2 = v + .get("gen") + .and_then(Value::as_f64) + .is_some_and(|g| g >= 2.0); + let model = if gen2 { + v.str_or("app", "") + } else { + v.str_or("type", "") + }; + if !gen2 && model.is_empty() { + return None; + } + let name = match v.get("name").and_then(Value::as_str) { + Some(name) if !name.is_empty() => name.to_string(), + _ => format!("Shelly {model} ··{}", net::tail(&mac)), + }; + let brand = if gen2 { Brand::Shelly2 } else { Brand::Shelly1 }; + Some(Device { + brand, + address: ip, + id: mac, + name, + plug: true, + }) +} + +/// `/json/info`: WLED says so in `brand`. +fn wled_of(ip: Ipv4Addr, v: &Value) -> Option { + if v.str_or("brand", "") != "WLED" { + return None; + } + let mac = v.str_or("mac", "").to_string(); + if mac.is_empty() { + return None; + } + let name = match v.str_or("name", "") { + "" => format!("WLED ··{}", net::tail(&mac)), + name => name.to_string(), + }; + Some(Device { + brand: Brand::Wled, + address: ip, + id: mac, + name, + plug: false, + }) +} + +/// What the Shelly switch call is for this generation. +pub fn shelly_path(generation: u8, on: bool) -> String { + if generation >= 2 { + format!("/rpc/Switch.Set?id=0&on={on}") + } else { + format!("/relay/0?turn={}", if on { "on" } else { "off" }) + } +} + +pub fn shelly(ip: Ipv4Addr, generation: u8, on: bool) -> Result<(), Failure> { + let url = format!("http://{ip}{}", shelly_path(generation, on)); + let result = lan_agent(COMMAND).get(&url).call(); + answered(result, "Shelly", ip) +} + +/// WLED's state: on, brightness (0 to 255) and segment 0's color, solid. +pub fn wled_body(state: State) -> String { + let mut body = vec![("on".to_string(), Value::Bool(state.on))]; + if state.on { + if state.color.is_some() || state.brightness.is_some() { + // Percent to 0-255, rounded in whole numbers (50% is 128). + let bri = (u32::from(super::percent(&state)) * 255 + 50) / 100; + body.push(("bri".to_string(), Value::Num(f64::from(bri)))); + } + if let Some(rgb) = state.color { + let col = Value::Arr(vec![Value::Arr( + rgb.iter().map(|c| Value::Num(f64::from(*c))).collect(), + )]); + let segment = json::obj([ + ("id", Value::Num(0.0)), + ("col", col), + ("fx", Value::Num(0.0)), + ]); + body.push(("seg".to_string(), Value::Arr(vec![segment]))); + } + } + Value::Obj(body).to_json() +} + +pub fn wled(ip: Ipv4Addr, state: State) -> Result<(), Failure> { + let result = lan_agent(COMMAND) + .post(&format!("http://{ip}/json/state")) + .header("Content-Type", "application/json") + .send(wled_body(state)); + answered(result, "WLED", ip) +} + +fn answered( + result: Result, ureq::Error>, + what: &str, + ip: Ipv4Addr, +) -> Result<(), Failure> { + // The agent hands back every status as Ok: read it here. + match result.map(|resp| resp.status().as_u16()) { + Ok(200..=299) => Ok(()), + Ok(401) => Err(Failure::Refused(format!( + "the {what} at {ip} has a password. Turn it off in its app or web page to use it here" + ))), + Ok(code) => Err(Failure::Refused(format!( + "the {what} at {ip} said no (HTTP {code})" + ))), + Err(e) => Err(Failure::Unreachable(format!( + "the {what} at {ip} didn't answer ({e})" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shelly_generations_are_told_apart() { + let ip = Ipv4Addr::new(192, 168, 1, 40); + let gen2 = json::parse(r#"{"name":"Ring light","id":"shellyplugsg3-abcdef","mac":"ABCDEF123456","model":"S3PL-00112EU","gen":3,"app":"PlugSG3","auth_en":false}"#).unwrap(); + let d = shelly_of(ip, &gen2).unwrap(); + assert_eq!( + (d.brand, d.name.as_str(), d.plug), + (Brand::Shelly2, "Ring light", true) + ); + let gen1 = + json::parse(r#"{"type":"SHPLG-S","mac":"A4CF12F45678","auth":false,"fw":"20230913"}"#) + .unwrap(); + let d = shelly_of(ip, &gen1).unwrap(); + assert_eq!( + (d.brand, d.name.as_str()), + (Brand::Shelly1, "Shelly SHPLG-S ··5678") + ); + assert_eq!(shelly_path(1, true), "/relay/0?turn=on"); + assert_eq!(shelly_path(3, false), "/rpc/Switch.Set?id=0&on=false"); + } + + #[test] + fn wled_is_known_by_its_brand_and_set_by_segment() { + let ip = Ipv4Addr::new(192, 168, 1, 41); + let info = json::parse( + r#"{"ver":"0.14.0","name":"Desk strip","brand":"WLED","mac":"3c71bf0a1b2c"}"#, + ) + .unwrap(); + assert_eq!(wled_of(ip, &info).unwrap().name, "Desk strip"); + assert!(wled_of(ip, &json::parse(r#"{"brand":"Other","mac":"x"}"#).unwrap()).is_none()); + let red = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(50), + }; + assert_eq!( + wled_body(red), + r#"{"on":true,"bri":128,"seg":[{"id":0,"col":[[255,0,0]],"fx":0}]}"# + ); + assert_eq!( + wled_body(State { + on: false, + color: None, + brightness: None + }), + r#"{"on":false}"# + ); + } + + /// The agent hands every status back as Ok: a device answering 401 or + /// 500 must still read as refused, not as switched. A redirect is an + /// answer too, never followed to the address it names. + #[test] + fn a_device_saying_no_is_not_reported_as_done() { + use std::io::{Read, Write}; + let answer = |status: &'static str| { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf); + // Followed, the redirect would meet a closed port instead. + let reply = format!( + "HTTP/1.1 {status}\r\nLocation: http://127.0.0.1:9/\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n" + ); + sock.write_all(reply.as_bytes()).unwrap(); + }); + let result = lan_agent(COMMAND) + .get(&format!("http://127.0.0.1:{port}/relay/0?turn=on")) + .call(); + answered(result, "Shelly", Ipv4Addr::LOCALHOST) + }; + assert_eq!(answer("200 OK"), Ok(())); + assert!( + matches!(answer("401 Unauthorized"), Err(Failure::Refused(why)) if why.contains("password")), + "a password-protected device" + ); + assert!( + matches!(answer("500 Internal Server Error"), Err(Failure::Refused(why)) if why.contains("HTTP 500")), + "a device error" + ); + assert!( + matches!(answer("302 Found"), Err(Failure::Refused(why)) if why.contains("HTTP 302")), + "a redirect, not followed" + ); + } +} diff --git a/src/integrations/lights/kasa.rs b/src/integrations/lights/kasa.rs new file mode 100644 index 0000000..999e385 --- /dev/null +++ b/src/integrations/lights/kasa.rs @@ -0,0 +1,257 @@ +//! TP-Link Kasa plugs and bulbs that still speak the original local +//! protocol, as python-kasa's XOR transport does: JSON "encrypted" with an +//! autokey XOR starting at 171. A scan broadcasts `get_sysinfo` over UDP +//! on port 9999; commands go over TCP on 9999, each prefixed by its length. +//! +//! Newer Kasa firmware and Tapo devices need a TP-Link account login +//! (their KLAP protocol) and aren't found by this scan. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::Duration; + +pub const PORT: u16 = 9999; +const KEY: u8 = 171; +const SYSINFO: &str = r#"{"system":{"get_sysinfo":{}}}"#; +const TIMEOUT: Duration = Duration::from_secs(3); +/// A Kasa answer is a few kilobytes at most. +const MAX_ANSWER: usize = 64 * 1024; +const LIGHT_SERVICE: &str = "smartlife.iot.smartbulb.lightingservice"; + +fn encrypt(plain: &str) -> Vec { + let mut key = KEY; + plain + .bytes() + .map(|b| { + key ^= b; + key + }) + .collect() +} + +fn decrypt(cipher: &[u8]) -> String { + let mut key = KEY; + let plain: Vec = cipher + .iter() + .map(|&c| { + let p = key ^ c; + key = c; + p + }) + .collect(); + String::from_utf8_lossy(&plain).into_owned() +} + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + // Over UDP the length prefix is left out. + let ask = encrypt(SYSINFO); + for to in net::broadcast_addresses() { + let _ = socket.send_to(&ask, (to, PORT)); + } + net::collect(&socket, wait) + .into_iter() + .filter_map(|(address, data)| device_of(address, &decrypt(&data))) + .collect() +} + +/// A `get_sysinfo` answer as a device: a plug, or a bulb. +fn device_of(address: Ipv4Addr, answer: &str) -> Option { + let v = json::parse(answer).ok()?; + let info = v.path("system.get_sysinfo")?; + let kind = + format!("{}{}", info.str_or("type", ""), info.str_or("mic_type", "")).to_ascii_uppercase(); + let brand = if kind.contains("PLUG") { + Brand::KasaPlug + } else if kind.contains("BULB") { + Brand::KasaBulb + } else { + return None; + }; + let id = info.str_or("deviceId", "").to_string(); + if id.is_empty() { + return None; + } + let name = match info.str_or("alias", "") { + "" => format!("Kasa {}", info.str_or("model", "")) + .trim() + .to_string(), + alias => alias.to_string(), + }; + Some(Device { + brand, + address, + id, + name, + plug: brand == Brand::KasaPlug, + }) +} + +/// The command for a state: a plug's relay, or a bulb's light state. +pub fn request(plug: bool, state: State) -> String { + if plug { + let relay = json::obj([("state", Value::Num(f64::from(u8::from(state.on))))]); + return json::obj([("system", json::obj([("set_relay_state", relay)]))]).to_json(); + } + let mut light = vec![ + ( + "on_off".to_string(), + Value::Num(f64::from(u8::from(state.on))), + ), + ("transition_period".to_string(), Value::Num(300.0)), + ]; + if state.on { + if let Some(rgb) = state.color { + let (hue, saturation, _) = super::to_hsv(rgb); + light.push(("hue".to_string(), Value::Num(hue.round()))); + light.push(( + "saturation".to_string(), + Value::Num((saturation * 100.0).round()), + )); + light.push(("color_temp".to_string(), Value::Num(0.0))); + } + if state.color.is_some() || state.brightness.is_some() { + light.push(( + "brightness".to_string(), + Value::Num(f64::from(super::percent(&state))), + )); + } + light.push(("ignore_default".to_string(), Value::Num(1.0))); + } + json::obj([( + LIGHT_SERVICE, + json::obj([("transition_light_state", Value::Obj(light))]), + )]) + .to_json() +} + +pub fn set(to: SocketAddr, plug: bool, state: State) -> Result<(), Failure> { + let answer = call(to, &request(plug, state))?; + let v = json::parse(&answer).unwrap_or(Value::Null); + // Every module answers with err_code 0 when it did it. + let code = |path: &str| v.path(path).and_then(Value::as_f64); + let result = if plug { + code("system.set_relay_state.err_code") + } else { + code(&format!("{LIGHT_SERVICE}.transition_light_state.err_code")) + }; + match result { + Some(0.0) => Ok(()), + _ => Err(Failure::Refused( + "the Kasa device didn't take it".to_string(), + )), + } +} + +/// One request over TCP: length-prefixed both ways. +fn call(to: SocketAddr, plain: &str) -> Result { + let lost = |e: std::io::Error| { + Failure::Unreachable(format!( + "the Kasa device at {} didn't answer ({e})", + to.ip() + )) + }; + let mut stream = TcpStream::connect_timeout(&to, TIMEOUT).map_err(lost)?; + stream.set_read_timeout(Some(TIMEOUT)).map_err(lost)?; + let body = encrypt(plain); + let mut message = (body.len() as u32).to_be_bytes().to_vec(); + message.extend_from_slice(&body); + stream.write_all(&message).map_err(lost)?; + let mut length = [0u8; 4]; + stream.read_exact(&mut length).map_err(lost)?; + let length = u32::from_be_bytes(length) as usize; + if length > MAX_ANSWER { + return Err(Failure::Refused( + "the Kasa device sent too much".to_string(), + )); + } + let mut answer = vec![0u8; length]; + stream.read_exact(&mut answer).map_err(lost)?; + Ok(decrypt(&answer)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + /// The bytes python-kasa's XorEncryption makes of the discovery query. + #[test] + fn xor_matches_python_kasa() { + let cipher = encrypt(SYSINFO); + let hex: String = cipher.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "d0f281f88bff9af7d5ef94b6d1b4c09fec95e68fe187e8caf08bf68bf6" + ); + assert_eq!(decrypt(&cipher), SYSINFO); + } + + #[test] + fn sysinfo_tells_plugs_from_bulbs() { + let plug = r#"{"system":{"get_sysinfo":{"alias":"Desk lamp","type":"IOT.SMARTPLUGSWITCH","deviceId":"8006AB","model":"HS103(US)","relay_state":0}}}"#; + let d = device_of(Ipv4Addr::new(192, 168, 1, 5), plug).unwrap(); + assert_eq!( + (d.brand, d.name.as_str(), d.plug), + (Brand::KasaPlug, "Desk lamp", true) + ); + let bulb = r#"{"system":{"get_sysinfo":{"alias":"","mic_type":"IOT.SMARTBULB","deviceId":"80121C","model":"KL130(US)","is_color":1}}}"#; + let d = device_of(Ipv4Addr::new(192, 168, 1, 6), bulb).unwrap(); + assert_eq!( + (d.brand, d.name.as_str()), + (Brand::KasaBulb, "Kasa KL130(US)") + ); + assert!(device_of( + d.address, + r#"{"system":{"get_sysinfo":{"type":"IOT.SMARTCAM","deviceId":"x"}}}"# + ) + .is_none()); + } + + #[test] + fn a_plug_switches_over_tcp_with_its_length() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = listener.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut length = [0u8; 4]; + stream.read_exact(&mut length).unwrap(); + let mut body = vec![0u8; u32::from_be_bytes(length) as usize]; + stream.read_exact(&mut body).unwrap(); + let asked = decrypt(&body); + let answer = encrypt(r#"{"system":{"set_relay_state":{"err_code":0}}}"#); + stream + .write_all(&(answer.len() as u32).to_be_bytes()) + .unwrap(); + stream.write_all(&answer).unwrap(); + asked + }); + let on = State { + on: true, + color: None, + brightness: None, + }; + assert_eq!(set(at, true, on), Ok(())); + assert_eq!( + fake.join().unwrap(), + r#"{"system":{"set_relay_state":{"state":1}}}"# + ); + } + + #[test] + fn a_bulb_color_is_hue_saturation_brightness() { + let red = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(70), + }; + assert_eq!( + request(false, red), + r#"{"smartlife.iot.smartbulb.lightingservice":{"transition_light_state":{"on_off":1,"transition_period":300,"hue":0,"saturation":100,"color_temp":0,"brightness":70,"ignore_default":1}}}"# + ); + } +} diff --git a/src/integrations/lights/lifx.rs b/src/integrations/lights/lifx.rs new file mode 100644 index 0000000..5f5e7c0 --- /dev/null +++ b/src/integrations/lights/lifx.rs @@ -0,0 +1,180 @@ +//! LIFX: its binary LAN protocol over UDP on port 56700, as aiolifx packs +//! it. Every packet starts with a 36-byte little-endian header; a scan +//! broadcasts "get the light's state", which answers with its label. + +use super::{net, Brand, Device, Failure, State}; +use std::net::SocketAddr; +use std::time::Duration; + +pub const PORT: u16 = 56700; +const HEADER: usize = 36; +/// The protocol number every packet carries. +const PROTOCOL: u16 = 1024; +/// Any non-zero source: lights then answer us directly, not by broadcast. +const SOURCE: u32 = 0x1C1A_5E7D; +const LIGHT_GET: u16 = 101; +const LIGHT_SET_COLOR: u16 = 102; +const LIGHT_STATE: u16 = 107; +const LIGHT_SET_POWER: u16 = 117; +const ACKNOWLEDGEMENT: u16 = 45; +/// The white point colors are mixed at. +const KELVIN: u16 = 3500; +const TRIES: usize = 3; +const EACH: Duration = Duration::from_millis(400); + +/// A packet: header (frame, frame address, protocol header) and payload. +fn packet(kind: u16, target: [u8; 8], ack: bool, payload: &[u8]) -> Vec { + let tagged = target == [0; 8]; + let mut out = Vec::with_capacity(HEADER + payload.len()); + out.extend_from_slice(&((HEADER + payload.len()) as u16).to_le_bytes()); + let flags = PROTOCOL | (1 << 12) | (u16::from(tagged) << 13); + out.extend_from_slice(&flags.to_le_bytes()); + out.extend_from_slice(&SOURCE.to_le_bytes()); + out.extend_from_slice(&target); + out.extend_from_slice(&[0; 6]); + // ack_required, and res_required for a question. + out.push(if ack { 0b10 } else { 0b01 }); + out.push(0); // sequence + out.extend_from_slice(&[0; 8]); + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&[0; 2]); + out.extend_from_slice(payload); + out +} + +fn kind_of(data: &[u8]) -> Option { + (data.len() >= HEADER).then(|| u16::from_le_bytes([data[32], data[33]])) +} + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + let ask = packet(LIGHT_GET, [0; 8], false, &[]); + for to in net::broadcast_addresses() { + let _ = socket.send_to(&ask, (to, PORT)); + } + net::collect(&socket, wait) + .into_iter() + .filter_map(|(address, data)| { + let (id, label) = light_state(&data)?; + Some(Device { + brand: Brand::Lifx, + address, + name: if label.is_empty() { + format!("LIFX ··{}", net::tail(&id)) + } else { + label + }, + id, + plug: false, + }) + }) + .collect() +} + +/// A LightState answer: the light's id (its MAC, as hex) and label. +fn light_state(data: &[u8]) -> Option<(String, String)> { + if kind_of(data)? != LIGHT_STATE || data.len() < HEADER + 44 { + return None; + } + let id: String = data[8..14].iter().map(|b| format!("{b:02x}")).collect(); + let label = &data[HEADER + 12..HEADER + 44]; + let end = label.iter().position(|&b| b == 0).unwrap_or(label.len()); + Some(( + id, + String::from_utf8_lossy(&label[..end]).trim().to_string(), + )) +} + +/// The 8-byte target of a light: its MAC, then two zero bytes. +fn target(id: &str) -> Option<[u8; 8]> { + let mut out = [0u8; 8]; + for (i, slot) in out.iter_mut().take(6).enumerate() { + *slot = u8::from_str_radix(id.get(i * 2..i * 2 + 2)?, 16).ok()?; + } + Some(out) +} + +/// SetColor's payload: a reserved byte, hue, saturation, brightness and +/// kelvin (each 0 to 65535), then the fade in milliseconds. +fn color_payload(rgb: [u8; 3], brightness: u8) -> Vec { + let (hue, saturation, _) = super::to_hsv(rgb); + let scale = |x: f64| (x * 65535.0).round().clamp(0.0, 65535.0) as u16; + let mut out = vec![0u8]; + out.extend_from_slice(&scale(hue / 360.0).to_le_bytes()); + out.extend_from_slice(&scale(saturation).to_le_bytes()); + out.extend_from_slice(&scale(f64::from(brightness) / 100.0).to_le_bytes()); + out.extend_from_slice(&KELVIN.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out +} + +fn power_payload(on: bool) -> Vec { + let mut out = (if on { u16::MAX } else { 0 }).to_le_bytes().to_vec(); + out.extend_from_slice(&0u32.to_le_bytes()); + out +} + +pub fn set(to: SocketAddr, id: &str, state: State) -> Result<(), Failure> { + let target = + target(id).ok_or_else(|| Failure::Refused("pick the LIFX light again".to_string()))?; + let acked = |r: &[u8]| kind_of(r) == Some(ACKNOWLEDGEMENT); + if let Some(rgb) = state.color.filter(|_| state.on) { + let color = packet( + LIGHT_SET_COLOR, + target, + true, + &color_payload(rgb, super::percent(&state)), + ); + net::request(to, &color, TRIES, EACH, acked)?; + } + let power = packet(LIGHT_SET_POWER, target, true, &power_payload(state.on)); + net::request(to, &power, TRIES, EACH, acked).map(drop) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_header_matches_the_lan_protocol() { + let p = packet(LIGHT_GET, [0; 8], false, &[]); + assert_eq!(p.len(), 36); + assert_eq!(u16::from_le_bytes([p[0], p[1]]), 36, "size"); + // protocol 1024, addressable, tagged (a broadcast) + assert_eq!(u16::from_le_bytes([p[2], p[3]]), 0x3400); + assert_eq!(p[22], 0b01, "res_required"); + assert_eq!(u16::from_le_bytes([p[32], p[33]]), 101); + + let mac = target("d073d5010203").unwrap(); + assert_eq!(mac, [0xd0, 0x73, 0xd5, 1, 2, 3, 0, 0]); + let p = packet(LIGHT_SET_COLOR, mac, true, &color_payload([255, 0, 0], 100)); + assert_eq!(p.len(), 36 + 13); + assert_eq!(u16::from_le_bytes([p[2], p[3]]), 0x1400, "not tagged"); + assert_eq!(&p[8..14], &[0xd0, 0x73, 0xd5, 1, 2, 3]); + assert_eq!(p[22], 0b10, "ack_required"); + // hue 0, saturation and brightness full, 3500 K, no fade + assert_eq!( + &p[36..49], + &[0, 0, 0, 255, 255, 255, 255, 0xac, 0x0d, 0, 0, 0, 0] + ); + } + + #[test] + fn a_light_state_gives_id_and_label() { + let mut answer = packet( + LIGHT_STATE, + target("d073d5010203").unwrap(), + false, + &[0; 52], + ); + answer[HEADER + 12..HEADER + 17].copy_from_slice(b"Desk "); + assert_eq!( + light_state(&answer), + Some(("d073d5010203".to_string(), "Desk".to_string())) + ); + assert_eq!(light_state(&answer[..40]), None, "cut short"); + assert_eq!(target("d073"), None); + } +} diff --git a/src/integrations/lights/mod.rs b/src/integrations/lights/mod.rs new file mode 100644 index 0000000..e37a643 --- /dev/null +++ b/src/integrations/lights/mod.rs @@ -0,0 +1,487 @@ +//! Smart lights and plugs on the local network, found and driven with no +//! hub, account or cloud: WiZ, LIFX, Govee (with LAN Control on), Yeelight +//! (with LAN Control on), older TP-Link Kasa models, Shelly and WLED. +//! +//! Each brand is reached the way the open-source library Home Assistant +//! uses for it does: pywizlight, aiolifx, govee-local-api, python-yeelight, +//! python-kasa, aioshelly and python-wled. +//! +//! A step saves a device as a reference, `wiz:192.168.1.20#a8bb50a4f94d`: +//! the brand, the address it was last seen at, and its own id. A router can +//! hand a device a new address; when one stops answering, its brand is +//! scanned again for that id and the new address is used, and remembered +//! for this run. +//! +//! Everything here blocks: callers run it off the async threads. + +mod govee; +mod http; +mod kasa; +mod lifx; +mod net; +mod wiz; +mod yeelight; + +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::collections::BTreeMap; +use std::net::Ipv4Addr; +use std::time::Duration; + +/// How long a scan listens for answers. +pub const SCAN_WAIT: Duration = Duration::from_millis(2500); +/// A rescan for a device that moved: shorter, it only needs that one. +const FIND_WAIT: Duration = Duration::from_millis(1800); +/// Flashing: how many times, and how long each on and off lasts. +const FLASHES: usize = 3; +const FLASH_STEP: Duration = Duration::from_millis(350); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Brand { + Wiz, + Lifx, + Govee, + Yeelight, + KasaPlug, + KasaBulb, + Shelly1, + Shelly2, + Wled, +} + +const BRANDS: [Brand; 9] = [ + Brand::Wiz, + Brand::Lifx, + Brand::Govee, + Brand::Yeelight, + Brand::KasaPlug, + Brand::KasaBulb, + Brand::Shelly1, + Brand::Shelly2, + Brand::Wled, +]; + +impl Brand { + fn id(self) -> &'static str { + match self { + Brand::Wiz => "wiz", + Brand::Lifx => "lifx", + Brand::Govee => "govee", + Brand::Yeelight => "yeelight", + Brand::KasaPlug => "kasa_plug", + Brand::KasaBulb => "kasa_bulb", + Brand::Shelly1 => "shelly1", + Brand::Shelly2 => "shelly2", + Brand::Wled => "wled", + } + } + + fn from_id(id: &str) -> Option { + BRANDS.into_iter().find(|b| b.id() == id) + } + + fn label(self) -> &'static str { + match self { + Brand::Wiz => "WiZ", + Brand::Lifx => "LIFX", + Brand::Govee => "Govee", + Brand::Yeelight => "Yeelight", + Brand::KasaPlug | Brand::KasaBulb => "TP-Link Kasa", + Brand::Shelly1 | Brand::Shelly2 => "Shelly", + Brand::Wled => "WLED", + } + } + + /// Which scan finds this brand: one per way of finding devices. + fn family(self) -> Family { + match self { + Brand::Wiz => Family::Wiz, + Brand::Lifx => Family::Lifx, + Brand::Govee => Family::Govee, + Brand::Yeelight => Family::Yeelight, + Brand::KasaPlug | Brand::KasaBulb => Family::Kasa, + Brand::Shelly1 | Brand::Shelly2 | Brand::Wled => Family::Http, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Family { + Wiz, + Lifx, + Govee, + Yeelight, + Kasa, + Http, +} + +const FAMILIES: [Family; 6] = [ + Family::Wiz, + Family::Lifx, + Family::Govee, + Family::Yeelight, + Family::Kasa, + Family::Http, +]; + +/// A light or plug a scan found. +#[derive(Clone, Debug, PartialEq)] +pub struct Device { + pub brand: Brand, + pub address: Ipv4Addr, + /// The device's own id (a MAC address, mostly): it stays when the + /// address changes. + pub id: String, + pub name: String, + /// Only on and off, no color. + pub plug: bool, +} + +impl Device { + pub fn reference(&self) -> String { + format!("{}:{}#{}", self.brand.id(), self.address, self.id) + } + + pub fn to_json(&self) -> Value { + json::obj([ + ("ref", json::str(self.reference())), + ("name", json::str(&self.name)), + ("brand", json::str(self.brand.label())), + ("address", json::str(self.address.to_string())), + ("plug", Value::Bool(self.plug)), + ]) + } +} + +/// A saved reference, read back: brand, last address, id. +pub fn parse_ref(reference: &str) -> Option<(Brand, Ipv4Addr, String)> { + let (brand, rest) = reference.trim().split_once(':')?; + let (address, id) = rest.split_once('#')?; + let id = id.trim(); + (!id.is_empty()).then_some(())?; + Some(( + Brand::from_id(brand)?, + address.parse().ok()?, + id.to_string(), + )) +} + +/// What one command sets a device to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct State { + pub on: bool, + pub color: Option<[u8; 3]>, + /// Percent, 1 to 100. + pub brightness: Option, +} + +/// What a step asks a device to do. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Command { + On { + brightness: Option, + }, + Off, + Color { + rgb: [u8; 3], + brightness: Option, + }, + /// Blink the color a few times, then stay on in it. + Flash { + rgb: [u8; 3], + brightness: Option, + }, +} + +/// A color as people write it: `#ff2020`, `ff2020`, `#f22` or a name. +pub fn parse_color(text: &str) -> Option<[u8; 3]> { + let text = text.trim().to_ascii_lowercase(); + let named = match text.as_str() { + "red" => Some([255, 32, 32]), + "orange" => Some([255, 110, 0]), + "amber" => Some([255, 160, 0]), + "yellow" => Some([255, 220, 0]), + "green" => Some([0, 220, 70]), + "cyan" => Some([0, 210, 255]), + "blue" => Some([30, 80, 255]), + "purple" => Some([150, 60, 255]), + "pink" => Some([255, 70, 170]), + "white" => Some([255, 255, 255]), + "warm" | "warm white" => Some([255, 190, 120]), + _ => None, + }; + if named.is_some() { + return named; + } + let hex = text.strip_prefix('#').unwrap_or(&text); + let digit = |i: usize| u8::from_str_radix(hex.get(i..i + 1)?, 16).ok(); + match hex.len() { + 6 => { + let byte = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok(); + Some([byte(0)?, byte(2)?, byte(4)?]) + } + 3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]), + _ => None, + } +} + +/// Why a command didn't work. +#[derive(Debug, PartialEq)] +pub enum Failure { + /// Nothing answered at that address: the device may have moved. + Unreachable(String), + /// The device answered, and said no. + Refused(String), +} + +impl Failure { + fn text(self) -> String { + match self { + Failure::Unreachable(why) | Failure::Refused(why) => why, + } + } +} + +/// Where devices were last found this run, by id, when that isn't where +/// their step says. +static MOVED: Mutex> = Mutex::new(BTreeMap::new()); + +/// Look for every supported device on the network, all brands at once. +pub fn scan(wait: Duration) -> Vec { + let mut found: Vec = std::thread::scope(|scope| { + let scans: Vec<_> = FAMILIES + .into_iter() + .map(|family| scope.spawn(move || scan_family(family, wait))) + .collect(); + scans + .into_iter() + .flat_map(|scan| scan.join().unwrap_or_default()) + .collect() + }); + found.sort_by(|a, b| (a.brand, &a.name, a.address).cmp(&(b.brand, &b.name, b.address))); + found.dedup_by(|a, b| a.brand == b.brand && a.id == b.id); + { + let mut moved = MOVED.lock(); + for device in &found { + moved.insert(device.id.clone(), device.address); + } + } + found +} + +fn scan_family(family: Family, wait: Duration) -> Vec { + match family { + Family::Wiz => wiz::scan(wait), + Family::Lifx => lifx::scan(wait), + Family::Govee => govee::scan(wait), + Family::Yeelight => yeelight::scan(wait), + Family::Kasa => kasa::scan(wait), + Family::Http => http::scan(), + } +} + +/// Do what `command` says to the device a step saved. Returns a short +/// note when it had to find the device at a new address. +pub fn send(reference: &str, command: Command) -> Result, String> { + let (brand, saved, id) = parse_ref(reference).ok_or("pick the light or plug again")?; + let address = MOVED.lock().get(&id).copied().unwrap_or(saved); + match run(brand, address, &id, command) { + Ok(()) => Ok(None), + Err(Failure::Refused(why)) => Err(why), + Err(Failure::Unreachable(why)) => { + // Moved? Scan its brand for the same id and try there. + let found = scan_family(brand.family(), FIND_WAIT) + .into_iter() + .find(|d| d.brand == brand && d.id == id); + let Some(device) = found else { + return Err(format!( + "{why}. A scan didn't find it either: is it on, and on the same network as this PC?" + )); + }; + MOVED.lock().insert(id.clone(), device.address); + run(brand, device.address, &id, command).map_err(Failure::text)?; + Ok(Some(format!("found it at {}", device.address))) + } + } +} + +fn run(brand: Brand, address: Ipv4Addr, id: &str, command: Command) -> Result<(), Failure> { + let plug = matches!(brand, Brand::KasaPlug | Brand::Shelly1 | Brand::Shelly2); + match command { + Command::Off => set( + brand, + address, + id, + State { + on: false, + color: None, + brightness: None, + }, + ), + Command::On { brightness } => set( + brand, + address, + id, + State { + on: true, + color: None, + brightness, + }, + ), + Command::Color { rgb, brightness } => { + let color = (!plug).then_some(rgb); + set( + brand, + address, + id, + State { + on: true, + color, + brightness, + }, + ) + } + // A plug isn't flashed: switching what's plugged in on and off + // fast can harm it. It just turns on. + Command::Flash { brightness, .. } if plug => set( + brand, + address, + id, + State { + on: true, + color: None, + brightness, + }, + ), + Command::Flash { rgb, brightness } => { + let lit = State { + on: true, + color: Some(rgb), + brightness, + }; + let off = State { + on: false, + color: None, + brightness: None, + }; + let blink = || -> Result<(), Failure> { + for _ in 0..FLASHES { + set(brand, address, id, lit)?; + std::thread::sleep(FLASH_STEP); + set(brand, address, id, off)?; + std::thread::sleep(FLASH_STEP); + } + Ok(()) + }; + // A flash cut short still ends lit: a failure between "off" and + // "on" used to leave the light dark (an on-air light, say). + let flashed = blink(); + let lit_again = set(brand, address, id, lit); + flashed.and(lit_again) + } + } +} + +fn set(brand: Brand, address: Ipv4Addr, id: &str, state: State) -> Result<(), Failure> { + match brand { + Brand::Wiz => wiz::set((address, wiz::PORT).into(), state), + Brand::Lifx => lifx::set((address, lifx::PORT).into(), id, state), + Brand::Govee => govee::set((address, govee::COMMAND_PORT).into(), state), + Brand::Yeelight => yeelight::set((address, yeelight::PORT).into(), state), + Brand::KasaPlug => kasa::set((address, kasa::PORT).into(), true, state), + Brand::KasaBulb => kasa::set((address, kasa::PORT).into(), false, state), + Brand::Shelly1 => http::shelly(address, 1, state.on), + Brand::Shelly2 => http::shelly(address, 2, state.on), + Brand::Wled => http::wled(address, state), + } +} + +/// Red, green and blue as hue (degrees), saturation and value (0 to 1). +fn to_hsv(rgb: [u8; 3]) -> (f64, f64, f64) { + let [r, g, b] = rgb.map(|c| f64::from(c) / 255.0); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let hue = if delta == 0.0 { + 0.0 + } else if max == r { + 60.0 * ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + let saturation = if max == 0.0 { 0.0 } else { delta / max }; + (hue, saturation, max) +} + +/// The brightness a state asks for, in percent, or what its color says. +fn percent(state: &State) -> u8 { + state.brightness.unwrap_or_else(|| { + state + .color + .map_or(100, |rgb| (to_hsv(rgb).2 * 100.0).round() as u8) + .max(1) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn references_round_trip() { + let d = Device { + brand: Brand::KasaPlug, + address: Ipv4Addr::new(192, 168, 1, 20), + id: "8006ABCD".into(), + name: "Desk".into(), + plug: true, + }; + assert_eq!(d.reference(), "kasa_plug:192.168.1.20#8006ABCD"); + assert_eq!( + parse_ref(&d.reference()), + Some((Brand::KasaPlug, d.address, "8006ABCD".into())) + ); + assert_eq!(parse_ref("wiz:not-an-ip#x"), None); + assert_eq!(parse_ref("hue:192.168.1.2#x"), None, "unknown brand"); + assert_eq!(parse_ref("wiz:192.168.1.2#"), None, "no id"); + } + + #[test] + fn colors_read_hex_short_hex_and_names() { + assert_eq!(parse_color("#FF8000"), Some([255, 128, 0])); + assert_eq!(parse_color("ff8000"), Some([255, 128, 0])); + assert_eq!(parse_color("#f80"), Some([255, 136, 0])); + assert_eq!(parse_color(" Red "), Some([255, 32, 32])); + assert_eq!(parse_color("#ggg"), None); + assert_eq!(parse_color("#ff80"), None); + assert_eq!(parse_color(""), None); + } + + #[test] + fn hsv_matches_the_usual_conversion() { + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + let (h, s, v) = to_hsv([255, 0, 0]); + assert!(close(h, 0.0) && close(s, 1.0) && close(v, 1.0)); + let (h, ..) = to_hsv([0, 0, 255]); + assert!(close(h, 240.0)); + let (h, s, v) = to_hsv([128, 128, 128]); + assert!(close(h, 0.0) && close(s, 0.0) && close(v, 0.502)); + let (h, ..) = to_hsv([255, 0, 128]); + assert!(close(h, 329.88)); + } + + #[test] + fn brightness_is_asked_for_or_comes_from_the_color() { + let state = |color, brightness| State { + on: true, + color, + brightness, + }; + assert_eq!(percent(&state(Some([255, 0, 0]), Some(40))), 40); + assert_eq!(percent(&state(Some([128, 0, 0]), None)), 50); + assert_eq!(percent(&state(None, None)), 100); + assert_eq!(percent(&state(Some([0, 0, 0]), None)), 1, "never fully off"); + } +} diff --git a/src/integrations/lights/net.rs b/src/integrations/lights/net.rs new file mode 100644 index 0000000..b1704ec --- /dev/null +++ b/src/integrations/lights/net.rs @@ -0,0 +1,148 @@ +//! What the brands share: this PC's place on the network, broadcasting a +//! question and hearing every answer, and a UDP request sent again until +//! it is answered. + +use super::Failure; +use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; +use std::time::{Duration, Instant}; + +/// The largest datagram any of these devices sends. +const MAX_DATAGRAM: usize = 4096; + +/// This PC's address on the network it reaches the internet through. +/// Connecting a UDP socket only picks the route: nothing is sent. +pub fn local_ipv4() -> Option { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + socket.connect((Ipv4Addr::new(192, 0, 2, 1), 9)).ok()?; + match socket.local_addr().ok()? { + SocketAddr::V4(v4) if !v4.ip().is_loopback() && !v4.ip().is_unspecified() => Some(*v4.ip()), + _ => None, + } +} + +/// Where a broadcast goes: everywhere this PC's network reaches, and that +/// network's own broadcast address. Windows sends 255.255.255.255 out of +/// one adapter only, which with a VPN or a virtual network can be the +/// wrong one. Home networks are /24 almost always. +pub fn broadcast_addresses() -> Vec { + let mut out = vec![Ipv4Addr::BROADCAST]; + if let Some(ip) = local_ipv4() { + let [a, b, c, _] = ip.octets(); + out.push(Ipv4Addr::new(a, b, c, 255)); + } + out +} + +/// A socket for a scan: any port, allowed to broadcast, waiting `wait` at +/// most for each answer. +pub fn scan_socket() -> Option { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + socket.set_broadcast(true).ok()?; + socket.set_multicast_ttl_v4(2).ok()?; + Some(socket) +} + +/// Every datagram that arrives until `wait` has passed, with who sent it. +pub fn collect(socket: &UdpSocket, wait: Duration) -> Vec<(Ipv4Addr, Vec)> { + let deadline = Instant::now() + wait; + let mut out = Vec::new(); + let mut buf = [0u8; MAX_DATAGRAM]; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() || socket.set_read_timeout(Some(left)).is_err() { + return out; + } + match socket.recv_from(&mut buf) { + Ok((n, SocketAddr::V4(from))) => out.push((*from.ip(), buf[..n].to_vec())), + Ok(_) => {} + // A timeout, or Windows reporting an earlier send's ICMP + // "port unreachable" here: keep listening until the deadline. + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {} + Err(_) => return out, + } + } +} + +/// Send `message` to `to` until `answered` likes a reply: `tries` times, +/// waiting `each` for an answer every time. UDP gets lost now and then, +/// on Wi-Fi more than anywhere. +pub fn request( + to: SocketAddr, + message: &[u8], + tries: usize, + each: Duration, + answered: impl Fn(&[u8]) -> bool, +) -> Result, Failure> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .map_err(|e| Failure::Unreachable(format!("couldn't open a network socket: {e}")))?; + let mut buf = [0u8; MAX_DATAGRAM]; + for _ in 0..tries { + socket + .send_to(message, to) + .map_err(|e| Failure::Unreachable(format!("couldn't reach {}: {e}", to.ip())))?; + let deadline = Instant::now() + each; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() || socket.set_read_timeout(Some(left)).is_err() { + break; + } + match socket.recv_from(&mut buf) { + Ok((n, from)) if from.ip() == to.ip() && answered(&buf[..n]) => { + return Ok(buf[..n].to_vec()) + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {} + Err(_) => break, + } + } + } + Err(Failure::Unreachable(format!( + "nothing answered at {}", + to.ip() + ))) +} + +/// The last four characters of an id, for a name: "WiZ light ··f94d". +pub fn tail(id: &str) -> String { + let chars: Vec = id.chars().filter(char::is_ascii_alphanumeric).collect(); + let start = chars.len().saturating_sub(4); + chars[start..] + .iter() + .collect::() + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_request_is_sent_again_until_answered() { + let device = UdpSocket::bind("127.0.0.1:0").unwrap(); + let at = device.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let mut buf = [0u8; 64]; + // Drop the first one, as a lossy Wi-Fi might. + let _ = device.recv_from(&mut buf).unwrap(); + let (n, from) = device.recv_from(&mut buf).unwrap(); + device.send_to(&buf[..n], from).unwrap(); + }); + let answer = request(at, b"ping", 3, Duration::from_millis(300), |r| r == b"ping"); + assert_eq!(answer, Ok(b"ping".to_vec())); + fake.join().unwrap(); + } + + #[test] + fn silence_counts_as_unreachable() { + let quiet = UdpSocket::bind("127.0.0.1:0").unwrap(); + let at = quiet.local_addr().unwrap(); + let answer = request(at, b"hello", 2, Duration::from_millis(50), |_| true); + assert!(matches!(answer, Err(Failure::Unreachable(_))), "{answer:?}"); + } + + #[test] + fn names_end_with_the_id() { + assert_eq!(tail("a8:bb:50:A4:F9:4D"), "f94d"); + assert_eq!(tail("ab"), "ab"); + } +} diff --git a/src/integrations/lights/wiz.rs b/src/integrations/lights/wiz.rs new file mode 100644 index 0000000..5f3580c --- /dev/null +++ b/src/integrations/lights/wiz.rs @@ -0,0 +1,156 @@ +//! WiZ (Philips WiZ, and the many brands built on it): JSON over UDP on +//! port 38899, as pywizlight does. A scan broadcasts the app's +//! "registration" question; every bulb and plug answers with its MAC. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::net::SocketAddr; +use std::time::Duration; + +pub const PORT: u16 = 38899; +/// pywizlight's discovery message: a phone registering, which every WiZ +/// device answers. `register:false` keeps it from actually registering. +const REGISTRATION: &[u8] = br#"{"method":"registration","params":{"phoneMac":"AAAAAAAAAAAA","register":false,"phoneIp":"1.2.3.4","id":"1"}}"#; +const TRIES: usize = 4; +const EACH: Duration = Duration::from_millis(500); + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + for to in net::broadcast_addresses() { + let _ = socket.send_to(REGISTRATION, (to, PORT)); + } + let answers = net::collect(&socket, wait); + let mut found: Vec = answers + .into_iter() + .filter_map(|(address, data)| { + let mac = mac_of(&data)?; + Some(Device { + brand: Brand::Wiz, + address, + name: format!("WiZ ··{}", net::tail(&mac)), + id: mac, + plug: false, + }) + }) + .collect(); + // Each answers every broadcast address: ask each device once. + found.sort_by(|a, b| a.id.cmp(&b.id)); + found.dedup_by(|a, b| a.id == b.id); + // Which are plugs: their module name says "SOCKET". + for device in &mut found { + device.plug = + module_name((device.address, PORT).into()).is_some_and(|m| m.contains("SOCKET")); + } + found +} + +fn mac_of(data: &[u8]) -> Option { + let v = json::parse(&String::from_utf8_lossy(data)).ok()?; + let mac = v.path("result.mac").and_then(Value::as_str)?; + (!mac.is_empty()).then(|| mac.to_string()) +} + +fn module_name(to: SocketAddr) -> Option { + let ask = br#"{"method":"getSystemConfig","params":{}}"#; + let answer = net::request(to, ask, 2, Duration::from_millis(300), |r| { + String::from_utf8_lossy(r).contains("getSystemConfig") + }) + .ok()?; + let v = json::parse(&String::from_utf8_lossy(&answer)).ok()?; + v.path("result.moduleName") + .and_then(Value::as_str) + .map(str::to_string) +} + +/// `setPilot`, as the WiZ app sends it. Dimming below 10 isn't allowed. +pub fn message(state: State) -> String { + let mut params = vec![("state".to_string(), Value::Bool(state.on))]; + if state.on { + if let Some([r, g, b]) = state.color { + for (name, c) in [("r", r), ("g", g), ("b", b)] { + params.push((name.to_string(), Value::Num(f64::from(c)))); + } + } + if state.color.is_some() || state.brightness.is_some() { + let dimming = super::percent(&state).clamp(10, 100); + params.push(("dimming".to_string(), Value::Num(f64::from(dimming)))); + } + } + json::obj([ + ("method", json::str("setPilot")), + ("params", Value::Obj(params)), + ]) + .to_json() +} + +pub fn set(to: SocketAddr, state: State) -> Result<(), Failure> { + let answer = net::request(to, message(state).as_bytes(), TRIES, EACH, |r| { + String::from_utf8_lossy(r).contains("setPilot") + })?; + let v = json::parse(&String::from_utf8_lossy(&answer)).unwrap_or(Value::Null); + match v.path("error.message").and_then(Value::as_str) { + Some(why) => Err(Failure::Refused(format!("the WiZ light said no: {why}"))), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::UdpSocket; + + #[test] + fn colors_go_out_as_the_app_sends_them() { + let red = State { + on: true, + color: Some([255, 0, 0]), + // Under WiZ's 10% floor: it goes out as 10. + brightness: Some(5), + }; + assert_eq!( + message(red), + r#"{"method":"setPilot","params":{"state":true,"r":255,"g":0,"b":0,"dimming":10}}"# + ); + let off = State { + on: false, + color: None, + brightness: None, + }; + assert_eq!( + message(off), + r#"{"method":"setPilot","params":{"state":false}}"# + ); + } + + #[test] + fn a_bulb_answering_success_is_set() { + let bulb = UdpSocket::bind("127.0.0.1:0").unwrap(); + let at = bulb.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let mut buf = [0u8; 512]; + let (n, from) = bulb.recv_from(&mut buf).unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).contains("\"state\":true")); + bulb.send_to( + br#"{"method":"setPilot","env":"pro","result":{"success":true}}"#, + from, + ) + .unwrap(); + }); + let on = State { + on: true, + color: None, + brightness: None, + }; + assert_eq!(set(at, on), Ok(())); + fake.join().unwrap(); + } + + #[test] + fn discovery_answers_give_the_mac() { + let answer = br#"{"method":"registration","env":"pro","result":{"mac":"a8bb50a4f94d","success":true}}"#; + assert_eq!(mac_of(answer).as_deref(), Some("a8bb50a4f94d")); + assert_eq!(mac_of(b"{}"), None); + } +} diff --git a/src/integrations/lights/yeelight.rs b/src/integrations/lights/yeelight.rs new file mode 100644 index 0000000..7ed1613 --- /dev/null +++ b/src/integrations/lights/yeelight.rs @@ -0,0 +1,237 @@ +//! Yeelight, as python-yeelight drives it. Each light needs "LAN Control" +//! switched on in the Yeelight app first. +//! +//! A scan is an SSDP-style search on the multicast group, port 1982; +//! lights answer with headers (`Location: yeelight://ip:55443`, `id`, +//! `name`, `model`). Commands are JSON lines over TCP on port 55443. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::{Duration, Instant}; + +pub const PORT: u16 = 55443; +const GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); +const SEARCH_PORT: u16 = 1982; +const SEARCH: &[u8] = + b"M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1982\r\nMAN: \"ssdp:discover\"\r\nST: wifi_bulb\r\n"; +const TIMEOUT: Duration = Duration::from_secs(3); +/// The longest line read from a light. Real answers and state notices are +/// well under 1 KB; the cap keeps a device that never ends a line from +/// filling memory. +const MAX_LINE: u64 = 16 * 1024; +/// Changes fade in this long, as the app does. +const FADE_MS: f64 = 300.0; + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + let _ = socket.send_to(SEARCH, (GROUP, SEARCH_PORT)); + net::collect(&socket, wait) + .into_iter() + .filter_map(|(from, data)| device_of(from, &String::from_utf8_lossy(&data))) + .collect() +} + +/// A search answer's headers, as a device. +fn device_of(from: Ipv4Addr, answer: &str) -> Option { + let header = |name: &str| { + answer.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + }; + let location = header("Location")?; + let address = location + .strip_prefix("yeelight://") + .and_then(|rest| rest.split(':').next()) + .and_then(|ip| ip.parse().ok()) + .unwrap_or(from); + let id = header("id").filter(|id| !id.is_empty())?; + let name = match header("name").unwrap_or_default() { + name if !name.is_empty() => name, + _ => format!( + "Yeelight {} ··{}", + header("model").unwrap_or_default(), + net::tail(&id) + ) + .replace(" ", " "), + }; + Some(Device { + brand: Brand::Yeelight, + address, + id, + name, + plug: false, + }) +} + +/// The commands for a state: power first (a light that's off ignores a +/// color), then color, then brightness. +pub fn commands(state: State) -> Vec<(&'static str, Vec)> { + let smooth = || [json::str("smooth"), Value::Num(FADE_MS)]; + let power = if state.on { "on" } else { "off" }; + let mut out = vec![( + "set_power", + [vec![json::str(power)], smooth().to_vec()].concat(), + )]; + if !state.on { + return out; + } + if let Some([r, g, b]) = state.color { + let rgb = (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b); + out.push(( + "set_rgb", + [vec![Value::Num(f64::from(rgb))], smooth().to_vec()].concat(), + )); + } + if state.color.is_some() || state.brightness.is_some() { + let bright = Value::Num(f64::from(super::percent(&state))); + out.push(("set_bright", [vec![bright], smooth().to_vec()].concat())); + } + out +} + +pub fn set(to: SocketAddr, state: State) -> Result<(), Failure> { + let stream = TcpStream::connect_timeout(&to, TIMEOUT).map_err(|e| { + Failure::Unreachable(format!( + "the Yeelight at {} didn't answer ({e}). Is LAN Control on in the Yeelight app?", + to.ip() + )) + })?; + let lost = |e: std::io::Error| Failure::Unreachable(format!("lost the Yeelight: {e}")); + stream.set_read_timeout(Some(TIMEOUT)).map_err(lost)?; + let mut writer = stream.try_clone().map_err(lost)?; + let mut reader = BufReader::new(stream); + for (id, (method, params)) in commands(state).into_iter().enumerate() { + let id = id as f64 + 1.0; + let line = json::obj([ + ("id", Value::Num(id)), + ("method", json::str(method)), + ("params", Value::Arr(params)), + ]) + .to_json(); + writer + .write_all(format!("{line}\r\n").as_bytes()) + .map_err(lost)?; + // The light also sends its state as it changes: read on to the + // answer to this command, for as long as a silent light would get. + // A device that keeps talking but never answers is given up on too. + let deadline = Instant::now() + TIMEOUT; + loop { + if Instant::now() >= deadline { + return Err(Failure::Unreachable( + "the Yeelight never answered the command".to_string(), + )); + } + let mut answer = String::new(); + let line = (&mut reader).take(MAX_LINE).read_line(&mut answer); + if line.map_err(lost)? == 0 { + return Err(Failure::Unreachable( + "the Yeelight closed the connection".to_string(), + )); + } + let Ok(v) = json::parse(answer.trim()) else { + continue; + }; + if v.get("id").and_then(Value::as_f64) != Some(id) { + continue; + } + if let Some(why) = v.path("error.message").and_then(Value::as_str) { + return Err(Failure::Refused(format!("the Yeelight said no: {why}"))); + } + break; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn search_answers_become_devices() { + let answer = "HTTP/1.1 200 OK\r\nCache-Control: max-age=3600\r\nLocation: yeelight://192.168.1.239:55443\r\nid: 0x000000000015243f\r\nmodel: color\r\nname: \r\npower: on\r\n"; + let d = device_of(Ipv4Addr::new(10, 0, 0, 1), answer).unwrap(); + assert_eq!(d.address, Ipv4Addr::new(192, 168, 1, 239)); + assert_eq!(d.id, "0x000000000015243f"); + assert_eq!(d.name, "Yeelight color ··243f"); + assert!(device_of(d.address, "HTTP/1.1 200 OK\r\n").is_none()); + } + + #[test] + fn a_color_powers_on_first_and_waits_for_each_answer() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = listener.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + let mut seen = Vec::new(); + for id in 1..=3 { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + seen.push(line.trim().to_string()); + // A state notice first, then the answer. + writer + .write_all(b"{\"method\":\"props\",\"params\":{\"power\":\"on\"}}\r\n") + .unwrap(); + writer + .write_all(format!("{{\"id\":{id},\"result\":[\"ok\"]}}\r\n").as_bytes()) + .unwrap(); + } + seen + }); + let state = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(80), + }; + assert_eq!(set(at, state), Ok(())); + let seen = fake.join().unwrap(); + assert_eq!( + seen[0], + r#"{"id":1,"method":"set_power","params":["on","smooth",300]}"# + ); + assert_eq!( + seen[1], + r#"{"id":2,"method":"set_rgb","params":[16711680,"smooth",300]}"# + ); + assert_eq!( + seen[2], + r#"{"id":3,"method":"set_bright","params":[80,"smooth",300]}"# + ); + } + + /// A device that chatters without ever answering, in lines that never + /// end, used to hold the light's thread for as long as it kept talking. + #[test] + fn a_device_that_never_answers_is_given_up_on() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + // Until the app hangs up: a state notice, then an endless line. + while stream.write_all(b"{\"method\":\"props\"}\r\n").is_ok() + && stream.write_all(&[b'x'; 4096]).is_ok() + { + std::thread::sleep(Duration::from_millis(20)); + } + }); + let off = State { + on: false, + color: None, + brightness: None, + }; + let started = Instant::now(); + let result = set(at, off); + assert!(matches!(result, Err(Failure::Unreachable(_))), "{result:?}"); + assert!(started.elapsed() < TIMEOUT * 2, "{:?}", started.elapsed()); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 99eeb28..7c44bf0 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -19,8 +19,11 @@ //! - `timeline`: this stream's timeline and YouTube chapters. //! - `activity`: the chat activity trigger's sliding window. //! - `obsws`: the obs-websocket connection scene triggers listen on. +//! - `ws`: the small WebSocket client `obsws` and Twitch EventSub share. +//! - `lights`: smart lights and plugs on the local network, no hub needed. pub mod activity; +pub mod alert_media; pub mod alerts; pub mod api; pub mod botcmd; @@ -29,6 +32,7 @@ pub mod effects; pub mod engine; pub mod event; pub mod host; +pub mod lights; pub mod model; pub mod obsws; pub mod presets; @@ -39,6 +43,7 @@ pub mod store; pub mod template; pub mod timeline; pub mod twitch; +pub mod ws; pub use engine::Handle; /// Only the tray (hotkeys) and the MIDI listener press shortcuts: Windows. diff --git a/src/integrations/model.rs b/src/integrations/model.rs index dcc72b6..9b3e923 100644 --- a/src/integrations/model.rs +++ b/src/integrations/model.rs @@ -284,10 +284,13 @@ pub enum StepKind { Timeline, /// Switch a scene, show or hide a source, or set a text source in OBS. Obs, + /// Turn a light or plug on the local network on or off, or color it + /// (see `lights`). + Light, } impl StepKind { - pub const ALL: [StepKind; 19] = [ + pub const ALL: [StepKind; 20] = [ StepKind::Discord, StepKind::Chat, StepKind::Phone, @@ -307,6 +310,7 @@ impl StepKind { StepKind::EditText, StepKind::Timeline, StepKind::Obs, + StepKind::Light, ]; pub fn id(self) -> &'static str { @@ -330,6 +334,7 @@ impl StepKind { StepKind::EditText => "edit_text", StepKind::Timeline => "timeline", StepKind::Obs => "obs", + StepKind::Light => "light", } } @@ -359,6 +364,10 @@ impl StepKind { StepKind::EditText => &[("op", "pick how to change the text")], StepKind::Timeline => &[("text", "write the timeline line")], StepKind::Obs => &[("action", "pick what to do in OBS")], + StepKind::Light => &[ + ("device", "pick the light or plug"), + ("action", "pick what the light does"), + ], StepKind::WaitDelay | StepKind::Stop | StepKind::Marker | StepKind::Clip => &[], } } @@ -684,6 +693,10 @@ fn validate_steps( errors: &mut Vec, incomplete: &mut Vec, ) { + // An empty branch is no level: only steps that are there count. + if steps.is_empty() { + return; + } if depth > MAX_DEPTH { errors.push(format!("checks nest more than {MAX_DEPTH} deep")); return; @@ -707,6 +720,13 @@ fn validate_steps( missing.push(ask.to_string()); } } + if step.kind == StepKind::If + && condition_names().skip(1).any(|(left, op, _)| { + !step.param(&op).is_empty() && step.param(&left).trim().is_empty() + }) + { + missing.push("say what each check looks at".to_string()); + } let ms = step.param("ms").trim(); if step.kind == StepKind::Wait && !ms.is_empty() @@ -720,10 +740,16 @@ fn validate_steps( missing.push(ask.to_string()); } // Like a program's path: chat must never pick what OBS shows. - if step.param("scene").contains('{') || step.param("source").contains('{') { - errors.push("an OBS step's scene and source can't use variables".to_string()); + if ["scene", "source", "filter", "hotkey"] + .iter() + .any(|name| step.param(name).contains('{')) + { + errors.push("an OBS step's names can't use variables".to_string()); } } + if step.kind == StepKind::Light { + light_step_problems(step, missing, errors); + } // Where a program runs, a file is written or a request goes must be // fixed by the streamer, never filled in from chat or a web call: // otherwise a viewer's message could pick the program or the host. @@ -749,10 +775,25 @@ fn validate_steps( "a Discord card's link and images can't put variables in their site".to_string(), ); } + // What an alert plays and shows comes from the streamer: a viewer + // must not pick a picture or a sound for the stream. + if step.kind == StepKind::Overlay { + let image = step.param("image").trim(); + if !image.is_empty() && !super::alerts::is_picture(image) { + errors.push( + "an alert's picture must be an upload or a web link (https://...)".to_string(), + ); + } + if !super::alerts::is_sound(step.param("sound").trim()) { + errors.push( + "an alert's sound must be a built-in one, an upload or a web link".to_string(), + ); + } + } // The value's name is fixed: a viewer must not overwrite `{user}`. let named = match step.kind { StepKind::SetVar => step.param("name"), - StepKind::EditText => step.param("save_as"), + StepKind::EditText | StepKind::Program | StepKind::Obs => step.param("save_as"), _ => "", }; if named.contains('{') { @@ -787,19 +828,99 @@ fn discord_message_missing(step: &Step) -> Option<&'static str> { } /// The OBS actions a step can take, with what each needs. -pub const OBS_ACTIONS: [&str; 5] = ["scene", "show", "hide", "toggle", "text"]; +pub const OBS_ACTIONS: [&str; 19] = [ + "scene", + "show", + "hide", + "toggle", + "text", + "record_start", + "record_stop", + "replay_save", + "screenshot", + "mute", + "unmute", + "mute_toggle", + "filter_on", + "filter_off", + "filter_toggle", + "media_play", + "media_stop", + "hotkey", + "check", +]; + +/// How many checks one If step holds. The first is `left`, `op` and +/// `right`; the others add their number (`left2`…), and exist once their +/// `op` is set. +pub const MAX_CONDITIONS: usize = 4; + +/// Each check's (left, op, right) param names, the first one first. +pub fn condition_names() -> impl Iterator { + (1..=MAX_CONDITIONS).map(|n| { + let suffix = if n == 1 { String::new() } else { n.to_string() }; + ( + format!("left{suffix}"), + format!("op{suffix}"), + format!("right{suffix}"), + ) + }) +} fn obs_step_missing(step: &Step) -> Option<&'static str> { let blank = |name: &str| step.param(name).trim().is_empty(); match step.param("action") { "" => None, // `required` asks for it "scene" if blank("scene") => Some("pick the OBS scene to switch to"), - "show" | "hide" | "toggle" | "text" if blank("source") => Some("pick the OBS source"), + "show" | "hide" | "toggle" | "text" | "mute" | "unmute" | "mute_toggle" | "media_play" + | "media_stop" + if blank("source") => + { + Some("pick the OBS source") + } + "filter_on" | "filter_off" | "filter_toggle" if blank("source") => { + Some("pick the source or scene with the filter") + } + "filter_on" | "filter_off" | "filter_toggle" if blank("filter") => Some("pick the filter"), + "hotkey" if blank("hotkey") => Some("pick the OBS hotkey"), action if !OBS_ACTIONS.contains(&action) => Some("pick what to do in OBS"), _ => None, } } +pub const LIGHT_ACTIONS: [&str; 4] = ["on", "off", "color", "flash"]; + +/// What a light step lacks (`missing`) or gets wrong (`errors`). The +/// device is fixed, like a program's path: chat never picks which light. +/// A color or brightness from chat is checked when it runs. +fn light_step_problems(step: &Step, missing: &mut Vec, errors: &mut Vec) { + let device = step.param("device"); + if device.contains('{') { + errors.push("a light step's device can't use variables".to_string()); + } else if !device.trim().is_empty() && super::lights::parse_ref(device).is_none() { + missing.push("pick the light or plug again".to_string()); + } + let action = step.param("action"); + if !action.is_empty() && !LIGHT_ACTIONS.contains(&action) { + missing.push("pick what the light does".to_string()); + } + let color = step.param("color").trim(); + if matches!(action, "color" | "flash") { + if color.is_empty() { + missing.push("pick a color".to_string()); + } else if !color.contains('{') && super::lights::parse_color(color).is_none() { + errors.push(format!( + "\"{color}\" isn't a color: use one like #ff2020, or red" + )); + } + } + let brightness = step.param("brightness").trim(); + let fits = |b: &str| b.parse::().is_ok_and(|n| (1..=100).contains(&n)); + if !brightness.is_empty() && !brightness.contains('{') && !fits(brightness) { + errors.push("a light's brightness is 1 to 100".to_string()); + } +} + /// True when the scheme and host of a URL template contain no variable /// (the path and query may). fn host_is_fixed(url: &str) -> bool { @@ -1045,7 +1166,7 @@ fn handler_from_json(v: &Value) -> Result { Ok(Handler { enabled: v.bool_or("enabled", true), trigger, - steps: steps_from_json(v.get("steps"), 0)?, + steps: steps_from_json(v.get("steps"), 1)?, }) } @@ -1072,13 +1193,13 @@ fn steps_json(steps: &[Step]) -> Value { ) } +/// `depth` counts like `validate_steps`: the handler's own steps are 1. fn steps_from_json(v: Option<&Value>, depth: usize) -> Result, String> { - if depth > MAX_DEPTH { + let list = v.and_then(Value::as_array).unwrap_or(&[]); + if !list.is_empty() && depth > MAX_DEPTH { return Err(format!("checks nest more than {MAX_DEPTH} deep")); } - v.and_then(Value::as_array) - .unwrap_or(&[]) - .iter() + list.iter() .map(|s| { let id = s.str_or("type", ""); let kind = StepKind::from_id(id).ok_or_else(|| format!("unknown step \"{id}\""))?; @@ -1249,17 +1370,63 @@ mod tests { assert!(i.validate().is_empty(), "a switched-off step can wait"); } + /// Chat can fill in an alert's words, never what it plays or shows. #[test] - fn nesting_is_capped() { + fn an_alerts_picture_and_sound_are_the_streamers() { + let alert = |image: &str, sound: &str| { + let mut i = sample(); + i.handlers[0].steps = vec![Step::new( + StepKind::Overlay, + &[("text", "{user}"), ("image", image), ("sound", sound)], + )]; + i.validate() + }; + assert!(alert("https://media.example/party.gif", "chime").is_empty()); + assert!(alert("", "https://cdn.example/ding.mp3").is_empty()); + assert!(alert("https://media.example/{user}.gif", "") + .iter() + .any(|e| e.contains("picture"))); + assert!(alert("", "{message}").iter().any(|e| e.contains("sound"))); + } + + /// Every shipped preset survives save and load unchanged: that covers + /// each trigger and step shape a user can start from. + #[test] + fn every_preset_round_trips() { + for p in crate::integrations::presets::PRESETS { + let i = crate::integrations::presets::build(p.id, "x".into(), "c").unwrap(); + let back = Integration::from_json(&i.to_json()) + .unwrap_or_else(|e| panic!("{} doesn't load back: {e}", p.id)); + assert_eq!(back, i, "{}", p.id); + } + } + + /// `ifs` checks nested in each other, with a step inside the last. + fn nested_checks(ifs: usize) -> Integration { let mut deepest = Step::new(StepKind::Stop, &[]); - for _ in 0..MAX_DEPTH + 1 { + for _ in 0..ifs { let mut wrap = Step::new(StepKind::If, &[("left", "a"), ("op", "is")]); wrap.then.push(deepest); deepest = wrap; } let mut i = sample(); i.handlers[0].steps = vec![deepest]; - assert!(i.validate().iter().any(|e| e.contains("nest"))); + i + } + + /// The handler's steps and three checks inside each other make + /// MAX_DEPTH levels, as deep as the builder lets a step go: that saves + /// and loads. One check more is refused, by both. + #[test] + fn nesting_is_capped_where_the_builder_stops() { + let fits = nested_checks(MAX_DEPTH - 1); + assert!(fits.validate().is_empty(), "{:?}", fits.validate()); + let loaded = Integration::from_json(&fits.to_json()).expect("loads back"); + assert_eq!(loaded, fits); + + let too_deep = nested_checks(MAX_DEPTH); + assert!(too_deep.validate().iter().any(|e| e.contains("nest"))); + assert!(Integration::from_json(&too_deep.to_json()).is_err()); } #[test] @@ -1452,7 +1619,7 @@ mod tests { } #[test] - fn only_live_shortcuts_are_listened_for() { + fn only_enabled_integrations_register_shortcuts() { let mut i = sample(); let (keys, pads) = shortcuts(std::slice::from_ref(&i)); assert_eq!(keys, ["Ctrl+Alt+K"]); diff --git a/src/integrations/obsws.rs b/src/integrations/obsws.rs index 35e4820..b16ced5 100644 --- a/src/integrations/obsws.rs +++ b/src/integrations/obsws.rs @@ -1,25 +1,30 @@ //! The connection to OBS's own WebSocket server (obs-websocket 5, built //! into OBS 28 and later). Scene triggers hear from it which scene is on -//! air and when it switches; OBS steps ask it to switch a scene, show or -//! hide a source, or change a text source. +//! air and when it switches, and OBS event triggers when recording starts +//! or stops, a replay is saved or a source is muted. OBS steps ask it to +//! switch a scene, show or hide a source, change a text source, record, +//! save the replay buffer, mute, switch a filter, play media or press one +//! of OBS's hotkeys. //! -//! Only scene and source events are asked for, and OBS is only asked to do -//! what an OBS step says. The address, port and password are read from -//! OBS's own settings file, so the user only has to switch the server on in -//! OBS. +//! Only scene, source, filter and output events are asked for, and OBS is +//! only asked to do what an OBS step says. The address, port and password +//! are read from OBS's own settings file, so the user only has to switch +//! the server on in OBS. //! -//! A minimal WebSocket client (RFC 6455) lives here rather than a crate: -//! one local connection, text frames, no extensions. A reader task owns the -//! socket's read half, so waiting for a message can always be interrupted -//! (by a step's request, or the connection no longer being wanted) without -//! losing half a frame. +//! The WebSocket client is `ws`, shared with Twitch EventSub. A reader +//! task owns the socket's read half, so waiting for a message can always be +//! interrupted (by a step's request, or the connection no longer being +//! wanted) without losing half a frame. +use super::event::{Event, EventKind}; +use super::ws::{self, base64}; use crate::json::{self, Value}; use crate::sync::Mutex; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::tcp::OwnedWriteHalf; use tokio::net::TcpStream; use tokio::sync::{mpsc, watch}; @@ -29,12 +34,17 @@ const RETRY: Duration = Duration::from_secs(5); const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); /// How long a step's request may wait for OBS's answer. pub const CALL_TIMEOUT: Duration = Duration::from_secs(5); -/// A scene list is small; anything this big is not OBS talking. -const MAX_MESSAGE: usize = 4 * 1024 * 1024; -/// obs-websocket's event categories (`EventSubscription`): scenes, and -/// inputs (sources added, removed or renamed). +/// obs-websocket's event categories (`EventSubscription`): scenes, inputs +/// (sources added, removed, renamed or muted), filters (added, removed or +/// renamed) and outputs (recording, the replay buffer). const SCENE_EVENTS: u32 = 1 << 2; const INPUT_EVENTS: u32 = 1 << 3; +const FILTER_EVENTS: u32 = 1 << 5; +const OUTPUT_EVENTS: u32 = 1 << 6; +/// A filter list's request id is this and the source's name. +const FILTERS_ID: &str = "filters:"; +/// OBS's own Screenshot hotkey: it saves to OBS's recording folder. +pub const SCREENSHOT_HOTKEY: &str = "OBSBasic.Screenshot"; const DEFAULT_PORT: u16 = 4455; /// What the dashboard shows about the connection. @@ -47,6 +57,10 @@ pub struct Status { pub scenes: Vec, /// Every source (input) in OBS, for OBS steps to pick from. pub sources: Vec, + /// Each source's and scene's filters, by its name. + pub filters: BTreeMap>, + /// The hotkeys a step can press by name (see `pressable_hotkeys`). + pub hotkeys: Vec, pub current: String, } @@ -70,18 +84,38 @@ impl Status { "sources", Value::Arr(self.sources.iter().map(json::str).collect()), ), + ( + "filters", + Value::Obj( + self.filters + .iter() + .map(|(name, list)| { + ( + name.clone(), + Value::Arr(list.iter().map(json::str).collect()), + ) + }) + .collect(), + ), + ), + ( + "hotkeys", + Value::Arr(self.hotkeys.iter().map(json::str).collect()), + ), ("current", json::str(&self.current)), ]) } } /// What OBS tells the engine. -#[derive(Debug, PartialEq)] -pub enum SceneNews { +#[derive(Debug)] +pub enum ObsNews { /// The scene on air when the connection opened: not a switch. Current(String), /// The program scene switched. Switched(String), + /// Recording started or stopped, a replay was saved, a source muted. + Happened(Event), } /// Something an OBS step asks OBS to do. @@ -98,13 +132,88 @@ pub enum Action { }, /// Set what a text source says. Text { source: String, text: String }, + /// Start (`true`) or stop recording. + Record(bool), + /// Save what the replay buffer holds. + SaveReplay, + /// Mute (`Some(true)`), unmute (`Some(false)`) or flip (`None`) a + /// source's audio. + Mute { source: String, muted: Option }, + /// Switch a source's or scene's filter on, off, or flip it (`None`). + Filter { + source: String, + filter: String, + enabled: Option, + }, + /// Play a media source from the start (`true`), or stop it. + Media { source: String, play: bool }, + /// Press one of OBS's hotkeys by name, bound to keys or not. + Hotkey(String), + /// Show this page in the scene on air, as a browser source as big as + /// the canvas: the dashboard's "Add to OBS" for the alerts page. + BrowserSource { name: String, url: String }, + /// Read what OBS is doing (the scene on air, streaming, recording) and, + /// for each source, whether it is muted and showing in `scene` (blank: + /// the one on air). Changes nothing. + Check { scene: String, sources: Vec }, +} + +/// Longest key a checked source's values get, so `{obs2.key.visible}` +/// stays inside a variable name's 64 characters. +const MAX_SOURCE_KEY: usize = 24; + +/// The names an OBS step lists in one field, one per line: one mute or +/// show can take several sources. Blank lines and repeats are dropped. +pub fn listed_names(field: &str) -> Vec { + let mut names: Vec = Vec::new(); + for name in field.lines().map(str::trim).filter(|name| !name.is_empty()) { + if !names.iter().any(|known| known == name) { + names.push(name.to_string()); + } + } + names +} + +/// What a check that reads several sources names each one's values by: +/// `{obs.mic_aux.muted}`. Lowercase letters, digits and `_`; a clash gets +/// a number. The editor makes the same keys (`sourceKeys`). +pub fn source_keys(sources: &[String]) -> Vec { + let mut keys: Vec = Vec::new(); + for source in sources { + let mut key = String::new(); + for c in source.chars().flat_map(char::to_lowercase) { + if c.is_ascii_alphanumeric() { + key.push(c); + } else if !key.is_empty() && !key.ends_with('_') { + key.push('_'); + } + } + let key: String = key.chars().take(MAX_SOURCE_KEY).collect(); + let base = match key.trim_end_matches('_') { + "" => "source".to_string(), + key => key.to_string(), + }; + let mut key = base.clone(); + let mut n = 2; + while keys.contains(&key) { + key = format!("{base}{n}"); + n += 1; + } + keys.push(key); + } + keys } +/// What OBS said, as (field, value): the step keeps each one under its own +/// answer name, like `{obs.muted}`. Empty for an action that only does +/// something. +pub type Answer = Vec<(String, String)>; + /// An action and where its outcome goes. The answer channel is a std one: /// a step waits for it on a blocking thread. pub struct Command { pub action: Action, - pub reply: std::sync::mpsc::Sender>, + pub reply: std::sync::mpsc::Sender>, /// After this the step has given up: doing it then would switch a /// scene nobody asked for any more. pub deadline: std::time::Instant, @@ -130,7 +239,7 @@ enum Incoming { pub async fn run( mut want: watch::Receiver, status: Arc>, - news: mpsc::Sender, + news: mpsc::Sender, mut commands: mpsc::Receiver, ) { loop { @@ -149,7 +258,7 @@ pub async fn run( Ok(settings) => { let ended = session(&settings, &mut want, &status, &news, &mut commands).await; // No scene is known to be on air any more. - let _ = news.send(SceneNews::Current(String::new())).await; + let _ = news.send(ObsNews::Current(String::new())).await; match ended { Ok(()) => continue, Err(problem) => problem, @@ -191,15 +300,20 @@ fn set_state(status: &Mutex, state: &'static str, problem: &str) { const NO_PASSWORD: &str = "OBS wants a WebSocket password but none is saved. Set one in OBS: Tools > WebSocket Server Settings."; -/// OBS's WebSocket server settings, from the file OBS keeps them in. +/// OBS's WebSocket server settings, from the file OBS keeps them in. A +/// portable OBS keeps that file beside itself, out of reach: OBS's own +/// defaults (port 4455, no password) are tried then. fn read_settings() -> Result { - let path = crate::obs_register::obs_config_dirs() + let Some(path) = crate::obs_register::obs_config_dirs() .into_iter() .map(|dir| dir.join("plugin_config/obs-websocket/config.json")) .find(|p| p.exists()) - .ok_or( - "OBS's WebSocket settings weren't found. In OBS: Tools > WebSocket Server Settings.", - )?; + else { + return Ok(Settings { + port: DEFAULT_PORT, + password: None, + }); + }; let text = std::fs::read_to_string(&path) .map_err(|e| format!("couldn't read OBS's WebSocket settings: {e}"))?; parse_settings(&text) @@ -238,7 +352,7 @@ async fn session( settings: &Settings, want: &mut watch::Receiver, status: &Arc>, - news: &mpsc::Sender, + news: &mpsc::Sender, commands: &mut mpsc::Receiver, ) -> Result<(), String> { let addr = format!("127.0.0.1:{}", settings.port); @@ -293,7 +407,7 @@ struct Link<'a> { writer: &'a mut OwnedWriteHalf, inbox: &'a mut mpsc::Receiver>, status: &'a Arc>, - news: &'a mpsc::Sender, + news: &'a mpsc::Sender, } impl Link<'_> { @@ -316,7 +430,10 @@ impl Link<'_> { if std::time::Instant::now() >= command.deadline { continue; } - let outcome = self.perform(&command.action).await; + let outcome = match &command.action { + Action::Check { scene, sources } => self.check(scene, sources).await, + action => self.perform(action).await.map(|()| Answer::new()), + }; let _ = command.reply.send(outcome); } message = self.inbox.recv() => { @@ -331,18 +448,29 @@ impl Link<'_> { async fn take(&mut self, message: Incoming) -> Result<(), String> { match message { Incoming::Ping(payload) => write_frame(self.writer, 10, &payload).await, - Incoming::Json(v) => { - if handle_message(&v, self.status, self.news).await { - self.request_lists().await?; - } - Ok(()) - } + Incoming::Json(v) => match handle_message(&v, self.status, self.news).await { + Follow::Nothing => Ok(()), + Follow::Lists => self.request_lists().await, + Follow::Filters(names) => self.request_filters(&names).await, + }, } } async fn request_lists(&mut self) -> Result<(), String> { send_request(self.writer, "GetSceneList", "scenes", Value::Null).await?; - send_request(self.writer, "GetInputList", "inputs", Value::Null).await + send_request(self.writer, "GetInputList", "inputs", Value::Null).await?; + send_request(self.writer, "GetHotkeyList", "hotkeys", Value::Null).await + } + + /// Ask for the filters of each named source or scene; the answers + /// land in the status (see `handle_message`). + async fn request_filters(&mut self, names: &[String]) -> Result<(), String> { + for name in names { + let data = json::obj([("sourceName", json::str(name))]); + let id = format!("{FILTERS_ID}{name}"); + send_request(self.writer, "GetSourceFilterList", &id, data).await?; + } + Ok(()) } /// Ask OBS for one thing and wait for its answer, dealing with whatever @@ -387,9 +515,232 @@ impl Link<'_> { source, visible, } => self.show_source(scene, source, *visible).await, + Action::Record(start) => { + let request = if *start { "StartRecord" } else { "StopRecord" }; + self.call(request, Value::Null).await.map(drop) + } + Action::SaveReplay => self.call("SaveReplayBuffer", Value::Null).await.map(drop), + Action::Mute { source, muted } => { + let name = ("inputName", json::str(source)); + match muted { + Some(muted) => { + let data = json::obj([name, ("inputMuted", Value::Bool(*muted))]); + self.call("SetInputMute", data).await.map(drop) + } + None => self + .call("ToggleInputMute", json::obj([name])) + .await + .map(drop), + } + } + Action::Filter { + source, + filter, + enabled, + } => self.switch_filter(source, filter, *enabled).await, + Action::Media { source, play } => { + let action = if *play { + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART" + } else { + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP" + }; + let data = json::obj([ + ("inputName", json::str(source)), + ("mediaAction", json::str(action)), + ]); + self.call("TriggerMediaInputAction", data).await.map(drop) + } + Action::Hotkey(name) => { + let data = json::obj([("hotkeyName", json::str(name))]); + self.call("TriggerHotkeyByName", data).await.map(drop) + } + Action::BrowserSource { name, url } => self.add_browser_source(name, url).await, + // Answered by `check`, which has values to give back. + Action::Check { .. } => Ok(()), } } + /// What `Action::Check` reads. With sources, `muted` and `visible` say + /// whether all of them are; with several, each also has its own under + /// its key (`source_keys`). A source that has no sound gets no `muted`; + /// one OBS doesn't have at all fails the step, as a typo would + /// otherwise read as "not muted, not showing". + async fn check(&mut self, scene: &str, sources: &[String]) -> Result { + let yes_no = |on: bool| if on { "yes" } else { "no" }.to_string(); + let on_air = self.call("GetCurrentProgramScene", Value::Null).await?; + let current = on_air.str_or("currentProgramSceneName", "").to_string(); + let streaming = self.call("GetStreamStatus", Value::Null).await?; + let recording = self.call("GetRecordStatus", Value::Null).await?; + let mut answer = vec![ + ("scene".to_string(), current.clone()), + ( + "streaming".to_string(), + yes_no(streaming.bool_or("outputActive", false)), + ), + ( + "recording".to_string(), + yes_no(recording.bool_or("outputActive", false)), + ), + ]; + if sources.is_empty() { + return Ok(answer); + } + let inputs = self.call("GetInputList", Value::Null).await?; + let scenes = self.call("GetSceneList", Value::Null).await?; + let known: Vec = names(&inputs, "inputs", "inputName", false) + .into_iter() + .chain(names(&scenes, "scenes", "sceneName", false)) + .collect(); + if let Some(missing) = sources.iter().find(|source| !known.contains(source)) { + return Err(format!("OBS has no source \"{missing}\"")); + } + let scene = match scene.trim() { + "" => current, + name => name.to_string(), + }; + // (muted, if it has sound; showing) for each source. + let mut readings = Vec::new(); + for source in sources { + let asked = json::obj([("inputName", json::str(source))]); + let muted = match self.call("GetInputMute", asked).await { + Ok(mute) => Some(mute.bool_or("inputMuted", false)), + Err(_) => None, + }; + readings.push((muted, self.is_showing(&scene, source).await)); + } + let with_sound: Vec = readings.iter().filter_map(|(muted, _)| *muted).collect(); + if !with_sound.is_empty() { + answer.push(("muted".to_string(), yes_no(with_sound.iter().all(|m| *m)))); + } + let all_showing = readings.iter().all(|(_, showing)| *showing); + answer.push(("visible".to_string(), yes_no(all_showing))); + if sources.len() > 1 { + for (key, (muted, showing)) in source_keys(sources).iter().zip(readings) { + if let Some(muted) = muted { + answer.push((format!("{key}.muted"), yes_no(muted))); + } + answer.push((format!("{key}.visible"), yes_no(showing))); + } + } + Ok(answer) + } + + /// Whether `source` is in `scene` and switched on there. + async fn is_showing(&mut self, scene: &str, source: &str) -> bool { + let at = json::obj([ + ("sceneName", json::str(scene)), + ("sourceName", json::str(source)), + ]); + let Ok(found) = self.call("GetSceneItemId", at).await else { + return false; + }; + let Some(item) = found.get("sceneItemId").and_then(Value::as_f64) else { + return false; + }; + let item = json::obj([ + ("sceneName", json::str(scene)), + ("sceneItemId", Value::Num(item)), + ]); + self.call("GetSceneItemEnabled", item) + .await + .is_ok_and(|v| v.bool_or("sceneItemEnabled", false)) + } + + /// A browser source showing `url` in the scene on air. One already in + /// OBS (by that name, or any showing the same page) is pointed at + /// `url` and added to this scene if it isn't there yet; otherwise a + /// new one is made, canvas-sized, its sound going through OBS so it + /// reaches the stream. + async fn add_browser_source(&mut self, name: &str, url: &str) -> Result<(), String> { + let on_air = self.call("GetCurrentProgramScene", Value::Null).await?; + let scene = on_air.str_or("currentProgramSceneName", "").to_string(); + if let Some(existing) = self.browser_source_for(name, url).await? { + let source = json::str(&existing); + let point = json::obj([ + ("inputName", source.clone()), + ("inputSettings", json::obj([("url", json::str(url))])), + ]); + self.call("SetInputSettings", point).await?; + let item = json::obj([("sceneName", json::str(&scene)), ("sourceName", source)]); + if self.call("GetSceneItemId", item.clone()).await.is_ok() { + return Ok(()); + } + return self.call("CreateSceneItem", item).await.map(drop); + } + let source = json::str(name); + let video = self.call("GetVideoSettings", Value::Null).await?; + let settings = json::obj([ + ("url", json::str(url)), + ("width", Value::Num(video.u64_or("baseWidth", 1920) as f64)), + ( + "height", + Value::Num(video.u64_or("baseHeight", 1080) as f64), + ), + ("reroute_audio", Value::Bool(true)), + ]); + let create = json::obj([ + ("sceneName", json::str(&scene)), + ("inputName", source), + ("inputKind", json::str("browser_source")), + ("inputSettings", settings), + ("sceneItemEnabled", Value::Bool(true)), + ]); + self.call("CreateInput", create).await.map(drop) + } + + /// The browser source named `name`, or else one already showing the + /// page at `url`, so adding the page again never makes a second copy. + async fn browser_source_for( + &mut self, + name: &str, + url: &str, + ) -> Result, String> { + let kind = json::obj([("inputKind", json::str("browser_source"))]); + let list = self.call("GetInputList", kind).await?; + let sources = names(&list, "inputs", "inputName", false); + if sources.iter().any(|s| s == name) { + return Ok(Some(name.to_string())); + } + for source in sources { + let asked = json::obj([("inputName", json::str(&source))]); + let settings = self.call("GetInputSettings", asked).await?; + let shows = settings.path("inputSettings.url").and_then(Value::as_str); + if shows.is_some_and(|shows| same_page(shows, url)) { + return Ok(Some(source)); + } + } + Ok(None) + } + + async fn switch_filter( + &mut self, + source: &str, + filter: &str, + enabled: Option, + ) -> Result<(), String> { + let where_ = || { + vec![ + ("sourceName".to_string(), json::str(source)), + ("filterName".to_string(), json::str(filter)), + ] + }; + let on = match enabled { + Some(on) => on, + None => { + let now = self + .call("GetSourceFilter", Value::Obj(where_())) + .await + .map_err(|_| format!("\"{source}\" has no filter \"{filter}\""))?; + !now.bool_or("filterEnabled", true) + } + }; + let mut set = where_(); + set.push(("filterEnabled".to_string(), Value::Bool(on))); + self.call("SetSourceFilterEnabled", Value::Obj(set)) + .await + .map(drop) + } + async fn show_source( &mut self, scene: &str, @@ -472,36 +823,37 @@ async fn send_request( write_frame(writer, 1, message.as_bytes()).await } -/// Follow one message from OBS. True when the scene and source lists -/// should be asked for again (one was added, removed or renamed). +/// What a message from OBS asks for next. +#[derive(Debug, PartialEq)] +enum Follow { + Nothing, + /// The scene and source lists again: one was added, removed or renamed. + Lists, + /// The filters of these sources or scenes. + Filters(Vec), +} + +/// Follow one message from OBS. async fn handle_message( message: &Value, status: &Arc>, - news: &mpsc::Sender, -) -> bool { + news: &mpsc::Sender, +) -> Follow { let d = message.get("d").cloned().unwrap_or(Value::Null); match message.u64_or("op", 99) { - // Event - 5 => match d.str_or("eventType", "") { - "CurrentProgramSceneChanged" => { - let name = d - .get("eventData") - .map(|e| e.str_or("sceneName", "").to_string()) - .unwrap_or_default(); - status.lock().current = name.clone(); - let _ = news.send(SceneNews::Switched(name)).await; - false - } - "SceneListChanged" | "SceneNameChanged" | "SceneCreated" | "SceneRemoved" - | "InputCreated" | "InputRemoved" | "InputNameChanged" => true, - _ => false, - }, + 5 => handle_event(&d, status, news).await, // Request response: one of the lists. 7 => { let Some(data) = d.get("responseData") else { - return false; + return Follow::Nothing; }; - match d.str_or("requestId", "") { + let id = d.str_or("requestId", ""); + if let Some(name) = id.strip_prefix(FILTERS_ID) { + let list = names(data, "filters", "filterName", false); + status.lock().filters.insert(name.to_string(), list); + return Follow::Nothing; + } + match id { "scenes" => { let current = data.str_or("currentProgramSceneName", "").to_string(); // OBS lists scenes bottom to top; the dashboard shows them @@ -509,21 +861,125 @@ async fn handle_message( let scenes = names(data, "scenes", "sceneName", true); { let mut s = status.lock(); - s.scenes = scenes; + s.scenes = scenes.clone(); s.current = current.clone(); + forget_gone_filters(&mut s); } - let _ = news.send(SceneNews::Current(current)).await; + let _ = news.send(ObsNews::Current(current)).await; + Follow::Filters(scenes) } - "inputs" => status.lock().sources = names(data, "inputs", "inputName", false), - _ => {} + "inputs" => { + let sources = names(data, "inputs", "inputName", false); + let mut s = status.lock(); + s.sources = sources.clone(); + forget_gone_filters(&mut s); + Follow::Filters(sources) + } + "hotkeys" => { + status.lock().hotkeys = pressable_hotkeys(data); + Follow::Nothing + } + _ => Follow::Nothing, } - false } - _ => false, + _ => Follow::Nothing, } } +/// An event from OBS: a scene switch, a list that changed, or one of the +/// OBS events triggers can start on. +async fn handle_event( + d: &Value, + status: &Arc>, + news: &mpsc::Sender, +) -> Follow { + let data = d.get("eventData").cloned().unwrap_or(Value::Null); + let happened = match d.str_or("eventType", "") { + "CurrentProgramSceneChanged" => { + let name = data.str_or("sceneName", "").to_string(); + status.lock().current = name.clone(); + let _ = news.send(ObsNews::Switched(name)).await; + return Follow::Nothing; + } + "SceneListChanged" | "SceneNameChanged" | "SceneCreated" | "SceneRemoved" + | "InputCreated" | "InputRemoved" | "InputNameChanged" => return Follow::Lists, + "SourceFilterCreated" | "SourceFilterRemoved" | "SourceFilterNameChanged" => { + return Follow::Filters(vec![data.str_or("sourceName", "").to_string()]); + } + "RecordStateChanged" => match data.str_or("outputState", "") { + "OBS_WEBSOCKET_OUTPUT_STARTED" => Event::new(EventKind::ObsRecordingStarted), + "OBS_WEBSOCKET_OUTPUT_STOPPED" => Event::new(EventKind::ObsRecordingStopped) + .with("file", data.str_or("outputPath", "")), + _ => return Follow::Nothing, + }, + "ReplayBufferSaved" => { + Event::new(EventKind::ObsReplaySaved).with("file", data.str_or("savedReplayPath", "")) + } + "InputMuteStateChanged" => Event::new(EventKind::ObsMuteChanged) + .with("obs_source", data.str_or("inputName", "")) + .with( + "muted", + if data.bool_or("inputMuted", false) { + "yes" + } else { + "no" + }, + ), + _ => return Follow::Nothing, + }; + let _ = news.send(ObsNews::Happened(happened)).await; + Follow::Nothing +} + +/// Drop the filter lists of sources and scenes that are gone. +fn forget_gone_filters(s: &mut Status) { + let Status { + scenes, + sources, + filters, + .. + } = s; + filters.retain(|name, _| scenes.contains(name) || sources.contains(name)); +} + +/// The hotkeys a step can press by name alone: each listed once (a +/// hotkey every source or scene has, like mute or "switch to scene", +/// can't say which one by name), and none of libobs' per-source ones. +fn pressable_hotkeys(data: &Value) -> Vec { + let all: Vec<&str> = data + .get("hotkeys") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(Value::as_str) + .collect(); + let mut out: Vec = all + .iter() + .filter(|name| { + !name.starts_with("libobs.") && all.iter().filter(|n| n == name).count() == 1 + }) + .map(|name| name.to_string()) + .collect(); + out.sort(); + out +} + /// The `field` of each item in the `list` of an answer. +/// Two addresses of the same page on this PC: `localhost` and 127.0.0.1 +/// are one host, and the query (`?at=top`) doesn't make another page. +fn same_page(a: &str, b: &str) -> bool { + let page = |url: &str| { + let url = url.trim().to_ascii_lowercase(); + let url = url + .split(['?', '#']) + .next() + .unwrap_or("") + .trim_end_matches('/'); + url.replacen("://localhost:", "://127.0.0.1:", 1) + }; + page(a) == page(b) +} + fn names(data: &Value, list: &str, field: &str, reversed: bool) -> Vec { let mut out: Vec = data .get(list) @@ -549,7 +1005,9 @@ fn identify_message(hello: &Value, password: Option<&str>) -> Result String { } async fn handshake(stream: &mut TcpStream, port: u16) -> Result<(), String> { - let mut key = [0u8; 16]; - crate::crypto::os_random(&mut key); - let request = format!( - "GET / HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ - Sec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: obswebsocket.json\r\n\r\n", - base64(&key) - ); - stream - .write_all(request.as_bytes()) - .await - .map_err(|e| format!("lost OBS: {e}"))?; - // Read the answer's head, a byte at a time: it is short, and the first - // frame may follow right behind it. - let mut head = Vec::new(); - while !head.ends_with(b"\r\n\r\n") { - if head.len() > 8192 { - return Err("that isn't OBS's WebSocket server".to_string()); - } - head.push( - stream - .read_u8() - .await - .map_err(|e| format!("lost OBS: {e}"))?, - ); - } - let status_line = String::from_utf8_lossy(&head); - if !status_line.starts_with("HTTP/1.1 101") { - return Err("OBS's WebSocket server refused the connection".to_string()); - } - Ok(()) + let host = format!("127.0.0.1:{port}"); + ws::handshake(stream, (&host, "/"), Some("obswebsocket.json"), "OBS").await } /// The next whole text message during the handshake, as JSON, answering @@ -625,27 +1055,12 @@ async fn read_json(stream: &mut TcpStream) -> Result { /// The next whole message. A close or a broken connection is an error. async fn read_message(stream: &mut (impl AsyncRead + Unpin)) -> Result { - let mut message = Vec::new(); - loop { - let (fin, opcode, payload) = read_frame(stream).await?; - match opcode { - // Continuation, text, binary. - 0..=2 => { - if message.len() + payload.len() > MAX_MESSAGE { - return Err("OBS sent too much at once".to_string()); - } - message.extend_from_slice(&payload); - if fin { - let text = String::from_utf8_lossy(&message); - return json::parse(&text) - .map(Incoming::Json) - .map_err(|_| "OBS sent something unreadable".to_string()); - } - } - 8 => return Err(close_reason(&payload)), - 9 => return Ok(Incoming::Ping(payload)), - _ => {} - } + match ws::read_message(stream, "OBS").await? { + ws::Message::Text(text) => json::parse(&text) + .map(Incoming::Json) + .map_err(|_| "OBS sent something unreadable".to_string()), + ws::Message::Ping(payload) => Ok(Incoming::Ping(payload)), + ws::Message::Close(payload) => Err(close_reason(&payload)), } } @@ -661,104 +1076,18 @@ fn close_reason(payload: &[u8]) -> String { } } -async fn read_frame(stream: &mut (impl AsyncRead + Unpin)) -> Result<(bool, u8, Vec), String> { - let lost = |e: std::io::Error| format!("lost OBS: {e}"); - let b0 = stream.read_u8().await.map_err(lost)?; - let b1 = stream.read_u8().await.map_err(lost)?; - let len = match b1 & 0x7f { - 126 => u64::from(stream.read_u16().await.map_err(lost)?), - 127 => stream.read_u64().await.map_err(lost)?, - n => u64::from(n), - }; - if len > MAX_MESSAGE as u64 { - return Err("OBS sent too much at once".to_string()); - } - let mask = if b1 & 0x80 != 0 { - let mut m = [0u8; 4]; - stream.read_exact(&mut m).await.map_err(lost)?; - Some(m) - } else { - None - }; - let mut payload = vec![0u8; len as usize]; - stream.read_exact(&mut payload).await.map_err(lost)?; - if let Some(m) = mask { - payload - .iter_mut() - .enumerate() - .for_each(|(i, b)| *b ^= m[i % 4]); - } - Ok((b0 & 0x80 != 0, b0 & 0x0f, payload)) -} - -/// One frame from us: always final, always masked (clients must mask). async fn write_frame( stream: &mut (impl AsyncWrite + Unpin), opcode: u8, payload: &[u8], ) -> Result<(), String> { - stream - .write_all(&encode_frame(opcode, payload)) - .await - .map_err(|e| format!("lost OBS: {e}")) -} - -fn encode_frame(opcode: u8, payload: &[u8]) -> Vec { - let mut mask = [0u8; 4]; - crate::crypto::os_random(&mut mask); - let mut out = Vec::with_capacity(payload.len() + 14); - out.push(0x80 | opcode); - match payload.len() { - n if n < 126 => out.push(0x80 | n as u8), - n if n <= 0xffff => { - out.push(0x80 | 126); - out.extend_from_slice(&(n as u16).to_be_bytes()); - } - n => { - out.push(0x80 | 127); - out.extend_from_slice(&(n as u64).to_be_bytes()); - } - } - out.extend_from_slice(&mask); - out.extend(payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4])); - out -} - -/// Standard base64 with padding, as obs-websocket and the handshake use. -fn base64(data: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for chunk in data.chunks(3) { - let b = [ - chunk[0], - *chunk.get(1).unwrap_or(&0), - *chunk.get(2).unwrap_or(&0), - ]; - let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); - for i in 0..4 { - if i <= chunk.len() { - out.push(ALPHABET[(n >> (18 - 6 * i)) as usize & 63] as char); - } else { - out.push('='); - } - } - } - out + ws::write_frame(stream, opcode, payload, "OBS").await } #[cfg(test)] mod tests { use super::*; - #[test] - fn base64_matches_the_standard() { - assert_eq!(base64(b""), ""); - assert_eq!(base64(b"f"), "Zg=="); - assert_eq!(base64(b"fo"), "Zm8="); - assert_eq!(base64(b"foo"), "Zm9v"); - assert_eq!(base64(b"foobar"), "Zm9vYmFy"); - } - /// The worked example from the obs-websocket protocol document. #[test] fn the_password_proof_matches_obs_websockets_example() { @@ -772,6 +1101,45 @@ mod tests { ); } + #[test] + fn a_step_can_list_several_sources() { + assert_eq!( + listed_names(" Mic/Aux \n\nDesktop Audio\nMic/Aux\n"), + vec!["Mic/Aux".to_string(), "Desktop Audio".to_string()] + ); + assert!(listed_names(" \n ").is_empty()); + let sources = [ + "Mic/Aux", + "mic aux", + "Cámara 1", + "!!", + "A very long source name for the stream", + ] + .map(String::from); + assert_eq!( + source_keys(&sources), + vec![ + "mic_aux", + "mic_aux2", + "c_mara_1", + "source", + "a_very_long_source_name" + ] + ); + } + + #[test] + fn the_alerts_page_is_found_under_any_of_its_addresses() { + let ours = "http://127.0.0.1:7799/alerts"; + assert!(same_page("http://localhost:7799/alerts?at=top", ours)); + assert!(same_page("HTTP://127.0.0.1:7799/alerts/", ours)); + assert!(!same_page("http://127.0.0.1:7800/alerts", ours)); + assert!(!same_page( + "http://127.0.0.1:7799/overlay/pulse-dot.html", + ours + )); + } + #[test] fn settings_need_the_server_switched_on() { let on = r#"{"server_enabled":true,"server_port":4456,"auth_required":true,"server_password":"pw"}"#; @@ -796,8 +1164,8 @@ mod tests { assert!(msg.path("d.authentication").is_none()); assert_eq!( msg.path("d.eventSubscriptions").and_then(Value::as_f64), - Some(12.0), - "scenes and sources" + Some(108.0), + "scenes, sources, filters and outputs" ); let locked = json::parse( r#"{"op":0,"d":{"rpcVersion":1,"authentication":{"salt":"s","challenge":"c"}}}"#, @@ -811,18 +1179,6 @@ mod tests { assert!(identify_message(&no_challenge, Some("pw")).is_err_and(|e| e.contains("challenge"))); } - #[test] - fn frames_are_masked_and_sized() { - let small = encode_frame(1, b"hi"); - assert_eq!(small[0], 0x81); - assert_eq!(small[1], 0x80 | 2); - let mask = [small[2], small[3], small[4], small[5]]; - assert_eq!([small[6] ^ mask[0], small[7] ^ mask[1]], *b"hi"); - let mid = encode_frame(1, &[0u8; 300]); - assert_eq!(mid[1], 0x80 | 126); - assert_eq!(u16::from_be_bytes([mid[2], mid[3]]), 300); - } - #[test] fn answers_carry_their_data_or_why_obs_said_no() { let yes = json::parse( @@ -855,8 +1211,19 @@ mod tests { r#"{"op":7,"d":{"requestId":"inputs","requestStatus":{"result":true},"responseData":{"inputs":[{"inputName":"Cam"},{"inputName":"Title"}]}}}"#, ) .unwrap(); - assert!(!handle_message(&scenes, &status, &news).await); - assert!(!handle_message(&inputs, &status, &news).await); + assert_eq!( + handle_message(&scenes, &status, &news).await, + Follow::Filters(vec!["Lobby".into(), "Game".into()]) + ); + assert_eq!( + handle_message(&inputs, &status, &news).await, + Follow::Filters(vec!["Cam".into(), "Title".into()]) + ); + let blur = json::parse( + r#"{"op":7,"d":{"requestId":"filters:Cam","requestStatus":{"result":true},"responseData":{"filters":[{"filterName":"Blur"}]}}}"#, + ) + .unwrap(); + assert_eq!(handle_message(&blur, &status, &news).await, Follow::Nothing); let s = status.lock().clone(); assert_eq!( s.scenes, @@ -864,7 +1231,74 @@ mod tests { "as OBS's scene dock shows them" ); assert_eq!(s.sources, ["Cam", "Title"]); - assert_eq!(heard.recv().await, Some(SceneNews::Current("Game".into()))); + assert_eq!( + s.filters.get("Cam").map(Vec::as_slice), + Some(&["Blur".to_string()][..]) + ); + assert!(matches!(heard.recv().await, Some(ObsNews::Current(name)) if name == "Game")); + } + + #[tokio::test] + async fn obs_events_become_integration_events() { + let status = Arc::new(Mutex::new(Status::default())); + let (news, mut heard) = mpsc::channel(4); + let event = |kind: &str, data: &str| { + json::parse(&format!( + r#"{{"op":5,"d":{{"eventType":"{kind}","eventData":{data}}}}}"# + )) + .unwrap() + }; + let stopped = event( + "RecordStateChanged", + r#"{"outputActive":false,"outputState":"OBS_WEBSOCKET_OUTPUT_STOPPED","outputPath":"C:/v/a.mkv"}"#, + ); + handle_message(&stopped, &status, &news).await; + let Some(ObsNews::Happened(e)) = heard.recv().await else { + panic!("a recording event"); + }; + assert_eq!( + (e.kind, e.var("file")), + (EventKind::ObsRecordingStopped, Some("C:/v/a.mkv")) + ); + + let stopping = event( + "RecordStateChanged", + r#"{"outputState":"OBS_WEBSOCKET_OUTPUT_STOPPING"}"#, + ); + handle_message(&stopping, &status, &news).await; + let muted = event( + "InputMuteStateChanged", + r#"{"inputName":"Mic","inputMuted":true}"#, + ); + handle_message(&muted, &status, &news).await; + let Some(ObsNews::Happened(e)) = heard.recv().await else { + panic!("a mute event, and nothing for stopping"); + }; + assert_eq!( + (e.var("obs_source"), e.var("muted")), + (Some("Mic"), Some("yes")) + ); + + let filter_added = event( + "SourceFilterCreated", + r#"{"sourceName":"Cam","filterName":"Blur"}"#, + ); + assert_eq!( + handle_message(&filter_added, &status, &news).await, + Follow::Filters(vec!["Cam".into()]) + ); + } + + #[test] + fn only_hotkeys_a_name_can_press_are_offered() { + let data = json::parse( + r#"{"hotkeys":["OBSBasic.StartRecording","libobs.mute","libobs.mute","OBSBasic.SelectScene","OBSBasic.SelectScene","OBSBasic.Screenshot"]}"#, + ) + .unwrap(); + assert_eq!( + pressable_hotkeys(&data), + ["OBSBasic.Screenshot", "OBSBasic.StartRecording"] + ); } #[test] diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index 25f2806..728ab49 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -18,7 +18,8 @@ pub struct Preset { pub category: &'static str, pub description: &'static str, /// What it can't work without: `discord`, `twitch`, `phone`, `web` - /// (an address to send to), `file` or `obs`. The first is the main one. + /// (an address to send to), `file`, `obs` or `light` (one on the + /// network). The first is the main one. pub needs: &'static [&'static str], pub recommended: bool, /// What the catalog card previews: (kind, sample text, sample chat line). @@ -26,6 +27,15 @@ pub struct Preset { } pub const PRESETS: &[Preset] = &[ + Preset { + id: "twitch_alerts", + name: "Twitch alerts", + category: "alerts", + description: "Follows, subs, gifted subs, bits and raids thanked on stream, in the alerts page you add to OBS once. No other alert service needed.", + needs: &["twitch"], + recommended: true, + preview: ("overlay", "Nat is raiding with 42 viewers", ""), + }, Preset { id: "crash_alert", name: "OBS crash alert", @@ -186,16 +196,16 @@ pub const PRESETS: &[Preset] = &[ id: "on_air_light", name: "On-air light", category: "auto", - description: "A real light by your door: red when live, amber with the delay on, flashing if OBS crashed. Home Assistant, WLED, Hue.", - needs: &["web"], + description: "A real light by your door: red when live, amber with the delay on, flashing if OBS crashed. Finds WiZ, LIFX, Govee, Yeelight, Kasa, Shelly and WLED on your network, no hub needed.", + needs: &["light"], recommended: false, - preview: ("json", "", ""), + preview: ("light", "", ""), }, Preset { id: "scene_delay", name: "Scene delay", category: "auto", - description: "Your OBS scene runs the delay: on in game, cut back to live in the lobby. Waits a moment, so flicking through scenes does nothing.", + description: "Your OBS scene runs the delay: armed in game, back to live in the lobby. Turn on Auto-activate so it goes on air by itself. Waits a moment, so flicking through scenes does nothing.", needs: &["obs"], recommended: false, preview: ("action", "", ""), @@ -222,7 +232,7 @@ pub const PRESETS: &[Preset] = &[ id: "webhook", name: "Webhook", category: "auto", - description: "Every event as JSON to n8n, Home Assistant, Zapier or your own tool.", + description: "Every InstantClone event as JSON to n8n, Home Assistant, Zapier or your own tool.", needs: &["web"], recommended: false, preview: ("json", "", ""), @@ -405,6 +415,104 @@ fn check(left: &str, op: &str, right: &str, then: Vec, otherwise: Vec) -> Step { + check( + "{arg1}", + "greater", + "{delay_max}", + vec![reply( + "The buffer holds up to {delay_max}s right now. Usage: !setdelay 30", + )], + arm, + ) +} + +/// `!setdelay 0`: turns the delay off and says so, ahead of the checks +/// that would otherwise answer "Delay set to 0s". +fn setdelay_off() -> Step { + check( + "{arg1}", + "is", + "0", + vec![ + Step::new(StepKind::DelayAction, &[("action", "disarm")]), + reply("Delay turned off by {user}."), + Step::new(StepKind::Stop, &[]), + ], + Vec::new(), + ) +} + +/// The answer `!setdelay` shipped with up to 0.1.15, also for 0. +const SETDELAY_REPLY: &str = "Delay set to {arg1}s by {user}."; + +/// The limit check `!setdelay` shipped with up to 0.1.15: a fixed 600 s. +const OLD_SETDELAY_TOP: &str = "601"; +const OLD_SETDELAY_REPLY: &str = "The delay goes up to 600s. Usage: !setdelay 30"; + +/// Bring a `!setdelay` from up to 0.1.15 up to date, each part only while +/// it is still exactly as shipped: the fixed 600 s limit becomes the +/// buffer-aware one (the steps it runs when the delay fits are kept, so +/// edits there survive), and its answer says when 0 turned the delay +/// off. Returns whether anything changed. +pub fn upgrade_setdelay(integration: &mut Integration) -> bool { + if integration.preset != "mod_controls" { + return false; + } + let mut changed = false; + for handler in &mut integration.handlers { + changed |= upgrade_setdelay_steps(&mut handler.steps); + let is_setdelay = matches!(&handler.trigger, + Trigger::ChatCommand { command, .. } if command == "!setdelay"); + // Anywhere in the command counts: a check the user moved is theirs + // to keep where they put it, not to get a second copy of. + if is_setdelay + && !has_step(&handler.steps, &setdelay_off()) + && has_step(&handler.steps, &reply(SETDELAY_REPLY)) + { + handler.steps.insert(0, setdelay_off()); + changed = true; + } + } + changed +} + +/// Whether `wanted` is among `steps`, inside checks too. +fn has_step(steps: &[Step], wanted: &Step) -> bool { + steps.iter().any(|step| { + step == wanted || has_step(&step.then, wanted) || has_step(&step.otherwise, wanted) + }) +} + +fn upgrade_setdelay_steps(steps: &mut [Step]) -> bool { + let mut changed = false; + for step in steps { + if is_old_setdelay_limit(step) { + *step = setdelay_limit(std::mem::take(&mut step.then)); + changed = true; + } else { + changed |= upgrade_setdelay_steps(&mut step.then); + changed |= upgrade_setdelay_steps(&mut step.otherwise); + } + } + changed +} + +fn is_old_setdelay_limit(step: &Step) -> bool { + let refusal_as_shipped = match step.otherwise.as_slice() { + [only] => only.kind == StepKind::Chat && only.param("text") == OLD_SETDELAY_REPLY, + _ => false, + }; + step.kind == StepKind::If + && step.enabled + && step.param("left") == "{arg1}" + && step.param("op") == "less" + && step.param("right") == OLD_SETDELAY_TOP + && refusal_as_shipped +} + /// Build a catalog entry. `discord_channel` is the id of the Discord /// connection to post to (empty when none exists yet). pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option { @@ -524,27 +632,24 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option { @@ -615,8 +720,11 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option EventKind::ALL .iter() + .filter(|kind| kind.is_own()) .map(|kind| { let body = webhook_body(*kind); on( @@ -628,6 +736,7 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option twitch_alerts(), "crash_counter" => vec![on( EventKind::HoldOpened, vec![ @@ -1112,24 +1221,82 @@ fn hype_clip(d: &str) -> Vec { }] } -/// The on-air state as a light. Bodies follow Home Assistant's webhook -/// style; the editor's device presets rewrite them for WLED or Hue. +/// One "Show on stream" alert per Twitch event, each playing the alerts +/// page's theme sound. A gifted sub reaches each viewer as a sub too: +/// those are left to the one gift alert. +fn twitch_alerts() -> Vec { + let alert = |title: &str, text: &str| { + Step::new( + StepKind::Overlay, + &[ + ("title", title), + ("text", text), + ("seconds", "6"), + ("sound", super::alerts::THEME_SOUND), + ], + ) + }; + // Welcoming raiders in chat is the streamer's call: off to start. + let mut welcome = chat("Welcome, raiders from {user}!"); + welcome.enabled = false; + vec![ + on( + EventKind::TwitchFollow, + vec![alert("New follower", "{user}")], + ), + on_filtered( + EventKind::TwitchSub, + &[("gift", "no")], + vec![alert("New sub", "{user} just subscribed")], + ), + on( + EventKind::TwitchResub, + vec![alert("Resub", "{user}: {months#month#months}")], + ), + on( + EventKind::TwitchGift, + vec![alert("Gifted subs", "{user} gifted {count#sub#subs}")], + ), + on( + EventKind::TwitchCheer, + vec![alert("Bits", "{user} cheered {bits#bit#bits}")], + ), + on( + EventKind::TwitchRaid, + vec![ + alert("Raid", "{user} is raiding with {viewers#viewer#viewers}"), + welcome, + ], + ), + ] +} + +/// The on-air state as a light on the network, picked in the editor. +/// The editor can switch it to Home Assistant or Hue instead, which take +/// a web request (see `LIGHT_STARTS`). fn on_air_light() -> Vec { - ["live", "delay", "crash", "off"] - .iter() - .map(|state| { - let body = format!(r#"{{"state":"{state}"}}"#); - let mut h = on( - EventKind::OnAirChanged, - vec![Step::new( - StepKind::Http, - &[("method", "POST"), ("url", ""), ("body", &body)], - )], - ); - set_filter(&mut h, "state", state); - h - }) - .collect() + [ + ("live", "color", "red"), + ("delay", "color", "amber"), + ("crash", "flash", "red"), + ("off", "off", ""), + ] + .iter() + .map(|(state, action, color)| { + let light = Step::new( + StepKind::Light, + &[ + ("device", ""), + ("name", ""), + ("action", action), + ("color", color), + ], + ); + let mut h = on(EventKind::OnAirChanged, vec![light]); + set_filter(&mut h, "state", state); + h + }) + .collect() } /// Two scene rules to fill in: a delay for the game scene, live again for @@ -1466,6 +1633,104 @@ pub fn catalog_json() -> Value { mod tests { use super::*; + /// Mod controls with `limit` in place of `!setdelay`'s limit check. + fn mod_controls_with_limit(limit: Step) -> Integration { + let mut i = build("mod_controls", "mc".into(), "").expect("builds"); + let setdelay = i + .handlers + .iter_mut() + .find(|h| matches!(&h.trigger, Trigger::ChatCommand { command, .. } if command == "!setdelay")) + .expect("has !setdelay"); + // Its check for whole seconds; the limit sits inside. + let whole = setdelay + .steps + .iter_mut() + .find(|s| s.param("op") == "whole_number") + .expect("checks for whole seconds"); + whole.then = vec![limit]; + i + } + + /// The limit check as 0.1.15 shipped it, running `arm` when it passes. + fn old_limit(arm: Vec, refusal: &str) -> Step { + check( + "{arg1}", + "less", + OLD_SETDELAY_TOP, + arm, + vec![reply(refusal)], + ) + } + + #[test] + fn an_old_setdelay_limit_upgrades_and_keeps_its_arm_steps() { + let arm = vec![reply("Custom: delay is now {arg1}s")]; + let mut old = mod_controls_with_limit(old_limit(arm.clone(), OLD_SETDELAY_REPLY)); + assert!(upgrade_setdelay(&mut old)); + assert_eq!( + old, + mod_controls_with_limit(setdelay_limit(arm)), + "the new limit, with the edited arm steps kept" + ); + assert!( + !upgrade_setdelay(&mut old), + "running it again changes nothing" + ); + } + + #[test] + fn an_old_setdelay_learns_that_0_turns_the_delay_off() { + let fresh = build("mod_controls", "mc".into(), "").expect("builds"); + let mut old = fresh.clone(); + for h in &mut old.handlers { + if h.steps.first() == Some(&setdelay_off()) { + h.steps.remove(0); + } + } + assert_ne!(old, fresh); + assert!(upgrade_setdelay(&mut old)); + assert_eq!(old, fresh); + assert!( + !upgrade_setdelay(&mut old), + "running it again changes nothing" + ); + } + + /// The user moved "0 turns it off" below another step: it stays there, + /// and no second copy is added at the top. + #[test] + fn a_moved_off_check_is_kept_where_the_user_put_it() { + let mut moved = build("mod_controls", "mc".into(), "").expect("builds"); + for h in &mut moved.handlers { + if h.steps.first() == Some(&setdelay_off()) && h.steps.len() > 1 { + h.steps.swap(0, 1); + } + } + let before = moved.clone(); + assert!(!upgrade_setdelay(&mut moved)); + assert_eq!(moved, before); + } + + #[test] + fn an_edited_setdelay_limit_is_left_alone() { + let arm = vec![reply("Delay is {arg1}s now")]; + let mut edited = mod_controls_with_limit(old_limit(arm, "Max is 600, chat!")); + let before = edited.clone(); + assert!(!upgrade_setdelay(&mut edited)); + assert_eq!(edited, before); + } + + #[test] + fn a_fresh_setdelay_is_already_buffer_aware() { + let mut fresh = build("mod_controls", "mc".into(), "").expect("builds"); + assert!(!upgrade_setdelay(&mut fresh)); + let text = fresh.to_json().to_json(); + assert!( + text.contains("{delay_max}") && !text.contains("601"), + "{text}" + ); + } + #[test] fn every_preset_builds_and_validates_once_connected() { for p in PRESETS { @@ -1492,6 +1757,10 @@ mod tests { s.params.insert(key.into(), "https://example.com/x".into()); } } + if s.params.get("device").is_some_and(|v| v.is_empty()) { + s.params + .insert("device".into(), "wiz:192.168.1.20#a8bb50a4f94d".into()); + } fill_blank_paths(&mut s.then); fill_blank_paths(&mut s.otherwise); } @@ -1637,9 +1906,4 @@ mod tests { fn legacy_alerts_validate() { assert!(legacy_alerts("legacy".into(), "chan").validate().is_empty()); } - - #[test] - fn catalog_is_valid_json() { - assert!(crate::config::is_valid_json(&catalog_json().to_json())); - } } diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs index ed0d46b..3b0c237 100644 --- a/src/integrations/recipe.rs +++ b/src/integrations/recipe.rs @@ -10,22 +10,45 @@ //! - Everything imported arrives switched off. //! - Steps that run programs or write files are flagged, and the import //! needs an explicit yes for them. +//! - Alerts come with the sharer's alerts theme and the sounds and pictures +//! they uploaded for them (up to `MAX_SHARED_MEDIA`). The importer +//! chooses whether to use the theme; an upload that didn't come along +//! falls back to the theme sound, or no picture. //! //! Import cleans exactly what export does, so a recipe written by hand //! can't bring in a web address, a program or a hotkey the user never set. -use super::model::{visit_steps, Integration, StepKind, Trigger}; +use super::alert_media::{self, Kind, REF_PREFIX}; +use super::alerts::{is_web_link, Look, THEME_SOUND}; +use super::model::{visit_steps, Integration, Step, StepKind, Trigger}; use crate::json::{self, Value}; +use std::collections::BTreeMap; +use std::path::Path; const PREFIX: &str = "ic-recipe:1:"; /// Placeholder a shared Discord step points at until the importer maps it. pub const DISCORD_PLACEHOLDER: &str = "@discord"; -const MAX_RECIPE_LEN: usize = 256 * 1024; +/// Uploaded sounds and pictures make a recipe big: room for a few. Each +/// upload is encoded twice (in the recipe, then the recipe as a whole), so +/// `MAX_SHARED_MEDIA` of them grows by 16/9; 1 MB more is the integrations. +const MAX_RECIPE_LEN: usize = MAX_SHARED_MEDIA * 16 / 9 + 1024 * 1024; const MAX_RECIPE_INTEGRATIONS: usize = 20; +/// The most uploads a recipe brings along, all together. +const MAX_SHARED_MEDIA: usize = 4 * 1024 * 1024; pub struct Recipe { pub name: String, pub integrations: Vec, + pub alerts: Option, +} + +/// What alerts in a recipe bring: the sharer's theme and their uploads. +pub struct SharedAlerts { + pub look: Look, + /// (name, file) for each upload the shared alerts use. + pub media: Vec<(String, Vec)>, + /// Uploads they use that were too big to come along. + pub left_out: Vec, } impl Recipe { @@ -44,7 +67,14 @@ impl Recipe { } } -pub fn export(name: &str, integrations: &[Integration]) -> String { +/// The recipe text. With `look`, alerts among `integrations` bring that +/// theme and the uploads (from `media_folder`) they play or show. +pub fn export( + name: &str, + integrations: &[Integration], + look: Option<&Look>, + media_folder: &Path, +) -> String { let cleaned: Vec = integrations .iter() .map(|i| { @@ -53,11 +83,77 @@ pub fn export(name: &str, integrations: &[Integration]) -> String { i.to_json() }) .collect(); - let doc = json::obj([ - ("name", json::str(name)), - ("integrations", Value::Arr(cleaned)), - ]); - format!("{PREFIX}{}", base64url_encode(doc.to_json().as_bytes())) + let mut doc = vec![ + ("name".to_string(), json::str(name)), + ("integrations".to_string(), Value::Arr(cleaned)), + ]; + if let Some(look) = look.filter(|_| integrations.iter().any(shows_alerts)) { + doc.push(( + "alerts".to_string(), + alerts_json(integrations, look, media_folder), + )); + } + format!( + "{PREFIX}{}", + base64url_encode(Value::Obj(doc).to_json().as_bytes()) + ) +} + +fn shows_alerts(i: &Integration) -> bool { + let mut found = false; + for h in &i.handlers { + visit_steps(&h.steps, &mut |s| found |= s.kind == StepKind::Overlay); + } + found +} + +/// The uploads alerts among `integrations` use, and the theme's own sound. +fn media_used(integrations: &[Integration], look: &Look) -> Vec { + let mut names = Vec::new(); + let mut note = |value: &str| { + if let Some(name) = value.strip_prefix(REF_PREFIX) { + if !names.iter().any(|n| n == name) { + names.push(name.to_string()); + } + } + }; + note(&look.sound); + for i in integrations { + for h in &i.handlers { + visit_steps(&h.steps, &mut |s| { + if s.kind == StepKind::Overlay { + note(s.param("sound")); + note(s.param("image")); + } + }); + } + } + names +} + +fn alerts_json(integrations: &[Integration], look: &Look, media_folder: &Path) -> Value { + let mut room = MAX_SHARED_MEDIA; + let mut media = Vec::new(); + let mut left_out = Vec::new(); + for name in media_used(integrations, look) { + let file = + alert_media::locate(media_folder, &name).and_then(|(path, _)| std::fs::read(path).ok()); + match file { + Some(data) if data.len() <= room => { + room -= data.len(); + media.push(json::obj([ + ("name", json::str(&name)), + ("data", json::str(base64url_encode(&data))), + ])); + } + _ => left_out.push(json::str(&name)), + } + } + json::obj([ + ("look", look.to_json()), + ("media", Value::Arr(media)), + ("left_out", Value::Arr(left_out)), + ]) } /// Blank whatever a recipe must never carry: see `strip_private`, plus web @@ -119,6 +215,7 @@ fn strip_private(steps: &mut [super::model::Step]) { StepKind::Obs => { s.params.insert("scene".into(), String::new()); s.params.insert("source".into(), String::new()); + s.params.remove("filter"); } StepKind::Program => { s.params.insert("path".into(), String::new()); @@ -127,6 +224,23 @@ fn strip_private(steps: &mut [super::model::Step]) { StepKind::File => { s.params.insert("path".into(), String::new()); } + // A picture or sound linked from the web would load from that + // site on stream (the streamer's IP, and a file it can change + // later): it falls back to no picture, or the theme sound, as an + // upload too big to come along does. + StepKind::Overlay => { + if is_web_link(s.param("image")) { + s.params.insert("image".into(), String::new()); + } + if is_web_link(s.param("sound")) { + s.params.insert("sound".into(), THEME_SOUND.into()); + } + } + // A light on someone's own network: the color stays. + StepKind::Light => { + s.params.insert("device".into(), String::new()); + s.params.insert("name".into(), String::new()); + } _ => {} } strip_private(&mut s.then); @@ -186,6 +300,134 @@ pub fn parse(text: &str, mut new_id: impl FnMut() -> String) -> Result Result { + let damaged = || "the recipe's alerts are damaged; copy it again".to_string(); + let mut media = Vec::new(); + let mut total = 0; + for m in v.get("media").and_then(Value::as_array).unwrap_or(&[]) { + let name = m.str_or("name", ""); + let data = base64url_decode(m.str_or("data", "")).ok_or_else(damaged)?; + total += data.len(); + let fits = data.len() <= alert_media::MAX_BYTES && total <= MAX_SHARED_MEDIA; + if alert_media::type_of(name).is_none() || data.is_empty() || !fits { + return Err(damaged()); + } + media.push((name.to_string(), data)); + } + let left_out = v + .get("left_out") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(Value::as_str) + .filter(|n| alert_media::type_of(n).is_some()) + .take(50) + .map(str::to_string) + .collect(); + let mut look = Look::from_json(v.get("look").unwrap_or(&Value::Null)); + look.css = css_that_stays_here(&look.css); + // A theme sound linked from the web: as for an alert's own, see + // `strip_private`. + if is_web_link(&look.sound) { + look.sound = Look::default().sound; + } + Ok(SharedAlerts { + look, + media, + left_out, + }) +} + +/// A shared theme's custom CSS, kept only if it can't fetch anything. The +/// alerts page plays in OBS on stream: a `url(` or `@import` in someone +/// else's CSS would show that site the streamer's IP, and let it change +/// what goes on stream later. A backslash drops it too, as a CSS escape can +/// spell `url(` in a way a plain search can't see. The rest of the theme +/// (colors, font, place, motion) still comes along. +fn css_that_stays_here(css: &str) -> String { + let lower = css.to_ascii_lowercase(); + let fetches = ["url(", "@import", "image-set(", "\\"] + .iter() + .any(|token| lower.contains(token)); + if fetches { + String::new() + } else { + css.to_string() + } +} + +/// Point the imported alerts (and `look`, when the importer takes it) at +/// where their uploads were saved: `saved` maps the sharer's names to +/// this PC's. An upload that didn't come along falls back to the theme +/// sound, or no picture, so the alert still plays. +pub fn point_media(recipe: &mut Recipe, look: Option<&mut Look>, saved: &BTreeMap) { + let moved = |value: &str| -> Option { + let name = value.strip_prefix(REF_PREFIX)?; + Some( + saved + .get(name) + .map_or(String::new(), |n| format!("{REF_PREFIX}{n}")), + ) + }; + for i in &mut recipe.integrations { + for h in &mut i.handlers { + point_steps(&mut h.steps, &moved); + } + } + if let Some(look) = look { + if let Some(sound) = moved(&look.sound) { + look.sound = sound; + } + } +} + +fn point_steps(steps: &mut [Step], moved: &impl Fn(&str) -> Option) { + for s in steps { + if s.kind == StepKind::Overlay { + if let Some(sound) = moved(s.param("sound")) { + let sound = if sound.is_empty() { + THEME_SOUND.to_string() + } else { + sound + }; + s.params.insert("sound".into(), sound); + } + if let Some(image) = moved(s.param("image")) { + s.params.insert("image".into(), image); + } + } + point_steps(&mut s.then, moved); + point_steps(&mut s.otherwise, moved); + } +} + +/// Keep a shared upload: a file here with the same bytes (and kind) is +/// used as it is, so importing twice adds nothing; otherwise it's saved +/// like any upload. +pub fn keep_shared_media(folder: &Path, name: &str, data: &[u8]) -> Result { + let kind = alert_media::type_of(name).map(|(kind, _)| kind); + let same = alert_media::list(folder).into_iter().find(|m| { + Some(m.kind) == kind + && m.bytes == data.len() as u64 + && std::fs::read(folder.join(&m.name)).is_ok_and(|here| here == data) + }); + match same { + Some(m) => Ok(m.name), + None => alert_media::save(folder, name, data), + } +} + +/// Whether an upload is a sound or a picture, for the import preview. +pub fn media_kind(name: &str) -> &'static str { + alert_media::type_of(name).map_or("file", |(kind, _)| match kind { + Kind::Sound => "sound", + Kind::Picture => "picture", }) } @@ -221,7 +463,7 @@ fn base64url_encode(data: &[u8]) -> String { out } -fn base64url_decode(text: &str) -> Option> { +pub(super) fn base64url_decode(text: &str) -> Option> { let mut out = Vec::with_capacity(text.len() * 3 / 4); let mut buf = 0u32; let mut bits = 0; @@ -246,6 +488,140 @@ mod tests { use super::super::presets; use super::*; + /// An alert's picture or sound linked from the web would load from that + /// site on the importer's stream: it falls back as a missing upload does. + #[test] + fn alerts_linked_from_the_web_stay_behind() { + let mut steps = vec![Step::new( + StepKind::Overlay, + &[ + ("image", "https://x.example/y.gif"), + ("sound", "https://x.example/s.mp3"), + ("text", "{user} followed"), + ], + )]; + strip_private(&mut steps); + assert_eq!(steps[0].param("image"), ""); + assert_eq!(steps[0].param("sound"), THEME_SOUND); + assert_eq!(steps[0].param("text"), "{user} followed"); + let mut upload = vec![Step::new(StepKind::Overlay, &[("sound", "media:ding.mp3")])]; + strip_private(&mut upload); + assert_eq!( + upload[0].param("sound"), + "media:ding.mp3", + "an upload stays" + ); + } + + /// The largest recipe export writes must be one import takes. With the + /// uploads encoded twice, 4 MB of them used to come to 7.1 MB of text, + /// over the 6 MB import refused. + #[test] + fn the_largest_export_fits_the_import_limit() { + let encoded = |n: usize| n.div_ceil(3) * 4; + let integrations = 512 * 1024; + let largest = PREFIX.len() + encoded(encoded(MAX_SHARED_MEDIA) + integrations); + assert!(largest <= MAX_RECIPE_LEN, "{largest} > {MAX_RECIPE_LEN}"); + } + + /// Someone else's CSS could fetch from their server while the alerts + /// page is on stream: any way of fetching drops it, styling stays. + #[test] + fn shared_css_that_could_fetch_anything_is_left_out() { + let styling = ".card{border-radius:4px;color:#fff}"; + assert_eq!(css_that_stays_here(styling), styling); + for fetching in [ + ".card{background:url(https://x.example/y.png)}", + "@import 'https://x.example/f.css';", + ".card{background:URL(//x.example/y.png)}", + ".card{background-image:image-set('y.png' 1x)}", + r".card{background:\75rl(https://x.example/y.png)}", + ] { + assert_eq!(css_that_stays_here(fetching), "", "{fetching}"); + } + } + + struct Scratch(std::path::PathBuf); + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn scratch(tag: &str) -> Scratch { + Scratch(std::env::temp_dir().join(format!( + "ic-recipe-{tag}-{}", + &crate::crypto::random_token()[..8] + ))) + } + + fn first_alert(i: &mut Integration) -> &mut Step { + i.handlers[0] + .steps + .iter_mut() + .find(|s| s.kind == StepKind::Overlay) + .unwrap() + } + + /// A friend's alerts arrive looking and sounding like theirs: their + /// theme comes along, and so do their uploads, saved here (beside a + /// different file of the same name) and pointed at. One too big to + /// come along falls back instead of pointing at nothing. + #[test] + fn alerts_bring_their_theme_and_uploads() { + let (theirs, mine) = (scratch("theirs"), scratch("mine")); + alert_media::save(&theirs.0, "ding.mp3", b"ID3ding").unwrap(); + alert_media::save(&mine.0, "ding.mp3", b"ID3other").unwrap(); + let mut alert = presets::build("twitch_alerts", "a1".into(), "").unwrap(); + let step = first_alert(&mut alert); + step.params.insert("sound".into(), "media:ding.mp3".into()); + step.params.insert("image".into(), "media:gone.gif".into()); + let look = Look { + theme: "signal".into(), + sound: "media:ding.mp3".into(), + css: ".card{letter-spacing:1px}".into(), + ..Look::default() + }; + let text = export("Alerts", &[alert], Some(&look), &theirs.0); + let mut recipe = parse(&text, || "fresh1234567890ab".into()).unwrap(); + let shared = recipe.alerts.take().unwrap(); + assert_eq!(shared.look, look); + assert_eq!(shared.left_out, ["gone.gif"]); + let (name, data) = &shared.media[0]; + let kept = keep_shared_media(&mine.0, name, data).unwrap(); + assert_eq!(kept, "ding-2.mp3"); + assert_eq!( + keep_shared_media(&mine.0, name, data).unwrap(), + "ding-2.mp3" + ); + let saved = BTreeMap::from([(name.clone(), kept)]); + let mut theme = shared.look.clone(); + point_media(&mut recipe, Some(&mut theme), &saved); + let step = first_alert(&mut recipe.integrations[0]); + assert_eq!(step.param("sound"), "media:ding-2.mp3"); + assert_eq!(step.param("image"), ""); + assert_eq!(theme.sound, "media:ding-2.mp3"); + } + + /// A recipe can't smuggle in a page or a script as an "upload", and + /// one without alerts brings no theme. + #[test] + fn shared_uploads_must_be_sounds_or_pictures() { + let forged = json::obj([( + "media", + Value::Arr(vec![json::obj([ + ("name", json::str("page.html")), + ("data", json::str(base64url_encode(b" diff --git a/web/dock.js b/web/dock.js index 8b0268f..5d3752e 100644 --- a/web/dock.js +++ b/web/dock.js @@ -6,6 +6,9 @@ const $ = id => document.getElementById(id); const PHASE_TO_STATE = { idle: 'off', preparing: 'arming', ready: 'armed', active: 'active' }; const EASE = 'cubic-bezier(.32,.72,0,1)'; +// The longest delay anything can arm, config::MAX_DELAY_MS in the backend. +const DELAY_MAX_S = 3600; +const FORM = { 'content-type': 'application/x-www-form-urlencoded' }; let s = null, lastVal = '0.0', pending = null, pendingTimer = 0; // Guarded writes. The state stream lands about 4 times a second and most @@ -24,28 +27,78 @@ function fmtClock(ms) { const secs = Math.max(0, Math.ceil(ms / 1000)); return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`; } +// A delay in the dock's one unit, seconds: the number, the field and every +// label agree (Activate 900s under a 900), whatever the length. +function fmtDelay(ms) { + return Math.round(ms / 1000) + 's'; +} +// What a dock opened by a link without the access token sees once a +// dashboard password is on: every call answers 401. +const AUTH_MSG = "This dock link is missing its access token. Copy the dock URL again from the dashboard's OBS tab."; +let authBlocked = false; async function fetchJ(u, o) { try { const r = await fetch(u, o); - const j = r.headers.get('content-type')?.includes('json') ? await r.json() : null; + const j = r.headers.get('content-type')?.includes('json') ? await r.json().catch(() => null) : null; + noteAuth(r.status === 401); return { ok: r.ok, status: r.status, j }; } catch (_) { return { ok: false, status: 0, j: null }; } } -function toast(msg, kind) { - const t = $('toast'); t.className = 'show ' + (kind || ''); t.textContent = msg; - clearTimeout(t._h); t._h = setTimeout(() => t.className = '', 1800); +// The message for a failed call: the server's own reason when it gave one. +function failText(r, fallback) { + if (r.status === 401) return AUTH_MSG; + if (r.status === 0) return "Can't reach InstantClone. Is it running?"; + return r.j?.error || fallback; +} +function noteAuth(blocked) { + if (authBlocked === blocked) return; + authBlocked = blocked; + paintNote(); } +// One toast at a time. Errors stay long enough to read twice, and a hover +// holds any toast until the pointer leaves. +function toast(msg, kind) { + const t = $('toast'), k = kind || 'info'; + t.className = 'show ' + k; + t.firstElementChild.textContent = { ok: '✓', err: '!' }[k] || 'i'; + t.lastElementChild.textContent = msg; + t._ms = k === 'err' ? 4500 : 2200; + armToast(t); +} +function armToast(t) { + clearTimeout(t._h); + if (!t._hover) t._h = setTimeout(() => t.classList.remove('show'), t._ms); +} +$('toast').addEventListener('mouseenter', e => { e.currentTarget._hover = true; clearTimeout(e.currentTarget._h); }); +$('toast').addEventListener('mouseleave', e => { e.currentTarget._hover = false; armToast(e.currentTarget); }); +// Safe in text and in either kind of quoted attribute, as integrations.js's. function esc(v) { - return String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + return String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} +// OBS's dock browser can refuse the async clipboard (no focus, no +// permission); the older copy command still works there. +async function copyText(text) { + try { await navigator.clipboard.writeText(text); return true; } catch (_) {} + const ta = document.createElement('textarea'); + ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none'; + document.body.appendChild(ta); ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (_) {} + ta.remove(); + return ok; } // ---------------------------------------------------------------- config ---- +const params = new URLSearchParams(location.search); // The dock slot this page is (?dock=). Sanitised to match the server's // allowed key charset so a hand-typed slot can round-trip to storage. -const SLOT = ((new URLSearchParams(location.search).get('dock') || 'default') - .replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40)) || 'default'; +const SLOT = ((params.get('dock') || 'default').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40)) || 'default'; +// The access token this dock was opened with, carried into every dock link +// it copies so a password-protected dashboard lets those docks in too. +const TOKEN = (params.get('token') || '').replace(/[^a-zA-Z0-9]/g, ''); +const DOCKS_MAX = 64; // config::MAX_DOCKS // Widget catalog: id -> label + default options (on + per-widget knobs). const WIDGETS = { @@ -110,7 +163,8 @@ const OPTS = { ], profiles: [ { k: 'view', label: 'Layout', modes: [['chips', 'Chips'], ['list', 'List']] }, - { k: 'arm', label: 'Arm on tap' }, { k: 'value', label: 'Show seconds' }, + // Off, a tap only picks the delay and the main button confirms it. + { k: 'arm', label: 'Apply on tap' }, { k: 'value', label: 'Show delay' }, ], stats: [ { k: 'view', label: 'Layout', modes: [['row', 'Row'], ['tiles', 'Tiles']] }, @@ -126,7 +180,7 @@ const OPTS = { function defaultCfg() { const w = {}; for (const id of ALL) w[id] = { ...WIDGETS[id].def }; - return { v: 1, preset: 'full', density: 'comfy', accent: 'cyan', order: [...ROWS], w }; + return { v: 1, name: '', preset: 'full', density: 'comfy', accent: 'cyan', order: [...ROWS], w }; } // Accent presets: [--accent, --accent-ink]. Ink is the dark text drawn on // filled accent buttons, tuned per hue so it stays legible. @@ -138,6 +192,9 @@ const ACCENTS = { pink: ['#f472b6', '#24040f'], blue: ['#6aa8ff', '#04101c'], }; +// A dock's display name: what the Docks list calls it. The slot id in its +// URL never changes, so renaming can't strand the OBS dock that uses it. +function cleanName(v) { return String(v || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 40); } // Keep only known row ids, drop dupes, and append any rows a newer version // added so an old saved order still renders every widget. function sanitizeOrder(raw) { @@ -152,6 +209,7 @@ function sanitizeOrder(raw) { function mergeCfg(raw) { const base = defaultCfg(); if (!raw || typeof raw !== 'object') return base; + base.name = cleanName(raw.name); if (raw.preset) base.preset = raw.preset; if (raw.density === 'compact' || raw.density === 'comfy') base.density = raw.density; if (ACCENTS[raw.accent]) base.accent = raw.accent; @@ -167,56 +225,156 @@ let cfg = defaultCfg(); // -------------------------------------------------------- capacity gate ---- // The disk ring holds a finite amount of video. A delay bigger than it can -// fill at the current bitrate silently stalls in "arming" forever, which -// reads as a bug. Mirror the dashboard's estimate so the dock greys out (and -// refuses to arm) delays the buffer can't hold. Soft estimate: ~160 kbps -// audio, ~5% RTMP framing, a little headroom, and a peak-hold on bitrate so -// the gate doesn't flicker a chip in/out on a momentary dip. The server -// enforces the same wall on /arm, so this is UX, not the last line of defence. -const BUF_AUDIO_KBPS = 160, BUF_OVERHEAD_PCT = 5, BUF_HEADROOM_PCT = 3, BUF_REF_KBPS = 10000; -let bufferMB = 0; // persisted buffer size, from /config -let bitrateHoldKbps = 0; // worst-case recent bitrate (peak-hold, slow decay) -function updateBitrateHold() { - const m = s && s.stats && s.stats.bitrate_kbps; - if (!m || m <= 1000) return; // no signal - keep last hold - bitrateHoldKbps = Math.max(m, (bitrateHoldKbps || m) * 0.985); -} -function planningKbps() { - if (bitrateHoldKbps > 1000) return bitrateHoldKbps; - const m = s && s.stats && s.stats.bitrate_kbps; - return (m && m > 1000) ? m : BUF_REF_KBPS; // plan for 10 Mbps before we know -} -function capMsFor(mb, kbps) { - if (!mb || mb <= 0) return 0; - const total = (Math.max(1, kbps) + BUF_AUDIO_KBPS) * (1 + BUF_OVERHEAD_PCT / 100); - return Math.floor((mb * 8192) / total * (1 - BUF_HEADROOM_PCT / 100)) * 1000; -} -function currentCapMs() { return bufferMB > 0 ? capMsFor(bufferMB, planningKbps()) : 0; } +// fill at the current bitrate would only run at what the buffer holds. The +// dock greys out (and refuses to arm) delays the buffer can't hold, from the +// server's own estimate (state.buffer_capacity_ms_est, held at the recent +// peak bitrate), so it greys out exactly what /arm would refuse. +const BUF_SLACK_MS = 2000; // kept on top of the delay (BUFFER_SLACK_MS) +function currentCapMs() { return (s && s.buffer_capacity_ms_est) || 0; } +// The buffer size, in MB, that holds a delay of `ms` at the bitrate the +// server plans for, rounded up like the server's (buffer_mb_for). function requiredMB(ms) { - if (!ms || ms <= 0) return 0; - const total = (planningKbps() + BUF_AUDIO_KBPS) * (1 + BUF_OVERHEAD_PCT / 100); - return Math.ceil((ms / 1000) * total / (1 - BUF_HEADROOM_PCT / 100) / 8192); + if (!ms || ms <= 0 || !s || !s.capacity_kbps) return 0; + const bytes = Math.ceil((ms + BUF_SLACK_MS) * s.capacity_kbps / 8); + return Math.ceil(Math.ceil(bytes * 16 / 15) / 1048576); } function overCap(ms) { const c = currentCapMs(); return c > 0 && ms > c; } -async function fetchBufferMB() { - const r = await fetchJ('/config'); - if (r.ok && r.j && typeof r.j.buffer_mb === 'number') bufferMB = r.j.buffer_mb; - renderProfiles(); // re-gate chips now that capacity is known +// Why a delay doesn't fit: firm once OBS's bitrate is measured, an +// estimate at a typical rate before that. +function capAdvice(ms) { + return s && s.capacity_measured + ? `Buffer too small for ${fmtDelay(ms)}. Raise the buffer size to at least ${requiredMB(ms)} MB in System, then restart.` + : `Likely too long: at a typical rate the buffer holds about ${fmtDelay(currentCapMs())}. Your real bitrate decides once OBS connects.`; +} +function capToast(ms) { + toast(capAdvice(ms), 'err'); +} +// What the profile chips were last drawn for: which ones the buffer can't +// hold, the phase and the running delay. Chips redraw only when it moves. +let profileGate = ''; +function profileGateKey() { + const ds = s ? PHASE_TO_STATE[s.phase] || 'off' : 'off'; + return ds + ':' + (s?.armed_delay_ms || 0) + ':' + profileCache.map(p => (overCap(p.delay_ms || 0) ? 1 : 0)).join(''); } // ---------------------------------------------------------- delay controls -- +function phaseNow() { return s ? PHASE_TO_STATE[s.phase] || 'off' : 'off'; } +function isEngaged(ds) { return ds === 'armed' || ds === 'active'; } +// The delay in the field, in ms, held to what the server accepts so a label +// never offers a value the click would not send. +function inputMs() { + const sec = Math.round(+$('d').value || 0); + return Math.max(0, Math.min(DELAY_MAX_S, sec)) * 1000; +} + +// While a delay is armed or running the number is a readout. A tap on it, +// a +/- or a profile opens it for a new value, and the main button then +// offers Adjust / Re-arm. Esc, "Keep" or leaving it unchanged closes it. +let editing = false; +function startEdit(focus) { + const ds = phaseNow(); + if (!isEngaged(ds)) return; + if (!editing) { + editing = true; + $('d').value = Math.round((s.armed_delay_ms || 0) / 1000); + fitDelayInput(); + } + setNumberMode(ds); renderCta(); + if (focus) { $('d').focus(); $('d').select(); } +} +function endEdit() { + if (!editing) return; + editing = false; + if (!s) return; + const ds = phaseNow(); + setNumberMode(ds); renderCta(); + $('d').blur(); +} + function bump(n) { + if (isEngaged(phaseNow())) startEdit(false); const step = cfg.w.number.step || 1; const i = $('d'); - i.value = Math.max(0, Math.min(600, (+i.value || 0) + n * step)); + setDelayField(Math.max(0, Math.min(DELAY_MAX_S, Math.round(+i.value || 0) + n * step))); +} +// Put a new number in the field, rolling its digits like the dashboard's +// (not while someone is typing in it). +function setDelayField(sec) { + const i = $('d'), was = i.value || '0', now = String(sec); + i.value = now; fitDelayInput(); renderCta(); + if (was === now || !motionOK() || document.activeElement === i) return; + const cur = $('cur'), roll = $('d-roll'); + cur.classList.add('d-rolling'); + rollDigits(roll, was, now, () => cur.classList.remove('d-rolling')); +} +// Rolling digits, as on the dashboard: each changed digit rolls up (more) +// or down (less), the right-most first like a counter, and a digit that +// appears or goes away grows or shrinks its column instead of shoving the +// number sideways. Plays in `el`; `done` hands back to the plain text. +// Clicked again mid-roll, it runs at double speed so fast clicks keep up. +function rollDigits(el, from, to, done) { + const quick = el.classList.contains('rolling'); + const duration = quick ? 170 : 340, stagger = quick ? 20 : 40; + const n = Math.max(from.length, to.length); + const A = from.padStart(n, ' '), B = to.padStart(n, ' '); + const up = parseFloat(to) > parseFloat(from); + const glyph = ch => { const c = document.createElement('span'); c.textContent = ch === ' ' ? '\u00a0' : ch; return c; }; + const cols = [], starts = []; + let longest = 0; + for (let i = 0; i < n; i++) { + const col = document.createElement('span'); + cols.push(col); + if (A[i] === B[i]) { col.textContent = B[i]; continue; } + col.className = 'dg'; + const strip = document.createElement('span'); + strip.className = 'dg-strip'; + if (up) strip.append(glyph(A[i]), glyph(B[i])); else strip.append(glyph(B[i]), glyph(A[i])); + col.append(strip); + const delay = (n - 1 - i) * stagger; + longest = Math.max(longest, delay + duration); + starts.push(() => { + strip.animate([{ transform: `translateY(${up ? '0%' : '-50%'})` }, { transform: `translateY(${up ? '-50%' : '0%'})` }], + { duration, delay, easing: 'cubic-bezier(.3,1.3,.5,1)', fill: 'both' }); + if (A[i] === ' ') col.animate([{ width: '0px' }, { width: 'var(--dg-w)' }], { duration: duration * .7, delay, fill: 'both' }); + if (B[i] === ' ') col.animate([{ width: 'var(--dg-w)' }, { width: '0px' }], { duration: duration * .7, delay, fill: 'both' }); + }); + } + el.replaceChildren(...cols); + el.classList.add('rolling'); + starts.forEach(start => start()); + clearTimeout(el._roll); + el._rollDone = done; + el._roll = setTimeout(() => endRoll(el), longest + 20); +} +// Finish a roll now: typing, a focus or a fill tick takes over at once. +function endRoll(el) { + clearTimeout(el._roll); + if (!el.classList.contains('rolling')) return; + el.classList.remove('rolling'); + const done = el._rollDone; + el._rollDone = null; + if (done) done(); } // Size the idle setter to its digits so the unit sits right after them. function fitDelayInput() { const i = $('d'), w = Math.max(1, i.value.length) + 'ch'; if (i.style.width !== w) i.style.width = w; } +// A typed value past the range settles on the limit shortly after typing +// stops, or at once on Enter or leaving the field (as on the dashboard). +let settleTimer = 0; +function settleDelayInput() { + clearTimeout(settleTimer); + const i = $('d'); + if (i.value === '') return; + const settled = String(inputMs() / 1000); + if (settled === i.value) return; + i.value = settled; + fitDelayInput(); renderCta(); + if (motionOK()) $('cur').animate([0, -5, 5, -3, 3, 0].map(x => ({ transform: `translateX(${x}px)` })), { duration: 320, easing: 'ease-out' }); +} // Show the requested state until the server confirms it. One timer, so a // quick second click can't have the first click's timer cut it short. function setPending(p) { @@ -224,6 +382,7 @@ function setPending(p) { pendingTimer = setTimeout(() => { pending = null; renderCta(); }, 1500); tick(); } +function clearPending() { pending = null; clearTimeout(pendingTimer); } function fmtRate(k) { if (!k || k <= 0) return ''; return k >= 1000 ? (k / 1000).toFixed(2) + ' Mbps' : Math.round(k) + ' kbps'; } function fmt(ms) { if (ms < 100) return '0.0'; const x = Math.round(ms / 100) / 10; return x < 10 ? x.toFixed(1) : Math.round(x).toString(); } // Crossfade helper so the sub-tip swaps smoothly instead of snapping. @@ -233,36 +392,62 @@ function setTip(text) { el._t = setTimeout(() => { el.textContent = text; el.classList.remove('fade'); }, 140); } +function postArm(ms) { return fetchJ('/arm', { method: 'POST', headers: FORM, body: 'ms=' + ms }); } async function arm(ms) { - // Capacity guard, matched by the server's own /arm check. Skip for ms===0 - // (disarm). The toast names the buffer size the delay actually needs. - if (ms > 0 && overCap(ms)) { - toast(`Buffer too small - ${(ms / 1000).toFixed(0)}s needs about ${requiredMB(ms)} MB. Raise it in the dashboard.`, 'err'); - return; - } + // Capacity guard, matched by the server's own /arm check. The toast names + // the buffer size the delay actually needs. + if (overCap(ms)) { capToast(ms); return; } setPending('arming'); - const r = await fetchJ('/arm', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'ms=' + ms }); - if (!r.ok) { toast(r.j?.error || 'Failed to arm', 'err'); pending = null; } - else toast(`Arming ${(ms / 1000).toFixed(0)}s`, 'ok'); + const r = await postArm(ms); + if (!r.ok) { clearPending(); toast(failText(r, "Couldn't arm the delay"), 'err'); } + else toast(`Arming ${fmtDelay(ms)}`, 'ok'); tick(); } +// A new delay while one is engaged. /arm re-arms an armed delay, and moves +// a running one: shorter at once, longer once the buffer has the footage. +async function changeDelay(ms) { + if (overCap(ms)) { capToast(ms); return; } + const wasActive = phaseNow() === 'active'; + const r = await postArm(ms); + if (!r.ok) { toast(failText(r, "Couldn't change the delay"), 'err'); tick(); return; } + editing = false; + toast(wasActive ? `Delay moving to ${fmtDelay(ms)}` : `Re-armed at ${fmtDelay(ms)}`, 'ok'); + if (r.j && r.j.phase) applyState(r.j); else tick(); +} async function activate() { setPending('active'); const r = await fetchJ('/activate', { method: 'POST' }); - if (!r.ok) { toast(r.j?.error || 'Cannot activate yet', 'err'); pending = null; } + if (!r.ok) { clearPending(); toast(failText(r, "Can't activate yet"), 'err'); } else toast('Delay active', 'ok'); tick(); } // Cutting keeps the delay armed (the buffer keeps filling), so the dock // goes to Activate, not back to Arm. -async function stop() { setPending('armed'); await fetchJ('/stop', { method: 'POST' }); toast('Back to live', 'ok'); tick(); } -async function disarm() { setPending('off'); await fetchJ('/disarm', { method: 'POST' }); toast('Disarmed', 'ok'); tick(); } +async function stop() { + setPending('armed'); + const r = await fetchJ('/stop', { method: 'POST' }); + if (!r.ok) { clearPending(); toast(failText(r, "Couldn't cut the delay. It is still running."), 'err'); } + else toast('Back to live', 'ok'); + tick(); +} +async function disarm() { + setPending('off'); + const r = await fetchJ('/disarm', { method: 'POST' }); + if (!r.ok) { clearPending(); toast(failText(r, "Couldn't disarm"), 'err'); } + else toast('Disarmed', 'ok'); + tick(); +} function mainClick() { - const m = $('main'); if (m._busy || m.disabled) return; + const m = $('main'); if (m._busy || m.disabled || !s) return; m._busy = true; setTimeout(() => m._busy = false, 300); - if (!s) return; + settleDelayInput(); const ds = PHASE_TO_STATE[s.phase] || 'off'; + if (editing && isEngaged(ds)) { + const ms = inputMs(); + if (ms > 0 && ms !== (s.armed_delay_ms || 0)) changeDelay(ms); else endEdit(); + return; + } if (ds === 'active') { // Cutting a live delay drops you back to real time instantly - guard it // behind a two-tap confirm when the option is on. @@ -275,16 +460,16 @@ function mainClick() { } if (ds === 'armed') return activate(); if (ds === 'arming') return disarm(); // double-click safety: arming primary disarms too - const ms = (+$('d').value || 0) * 1000; - if (ms > 0) arm(ms); else toast('Enter a delay', 'err'); + const ms = inputMs(); + if (ms > 0) arm(ms); else toast('Enter a delay first', 'err'); } // Scheduled safe cut ("cut after this airs"): mark now, keep forwarding the // buffered footage, auto-cut to live once the mark has aired everywhere. // Same endpoints as the dashboard's Cut-after; state carries the countdown. async function cutAfter() { const r = await fetchJ('/cut-after', { method: 'POST' }); - if (!r.ok) toast(r.j?.error || 'Cannot schedule cut', 'err'); - else toast('Mark set - cuts once this airs', 'ok'); + if (!r.ok) toast(failText(r, "Couldn't schedule the cut"), 'err'); + else toast('Mark set: cuts once this airs', 'ok'); tick(); } // Ending the hold ends the stream everywhere: the first click arms, a second @@ -299,7 +484,8 @@ async function endHold() { disarmEndHold(); const r = await fetchJ('/crash-protection/end', { method: 'POST' }); if (r.status === 409) toast(r.j?.error || 'Nothing to end', 'info'); - else toast(r.ok ? 'Stream ended on every destination' : 'Could not end the stream', r.ok ? 'ok' : 'err'); + else if (r.ok) toast('Stream ended on every destination', 'ok'); + else toast(failText(r, "Couldn't end the stream"), 'err'); tick(); } function disarmEndHold() { @@ -308,8 +494,9 @@ function disarmEndHold() { clearTimeout(b._reset); b._armed = false; b.classList.remove('armed'); b.textContent = 'End now'; } async function cutAfterCancel() { - await fetchJ('/cut-after/cancel', { method: 'POST' }); - toast('Auto-cut cancelled', 'ok'); + const r = await fetchJ('/cut-after/cancel', { method: 'POST' }); + if (!r.ok) toast(failText(r, "Couldn't cancel the auto-cut. It will still cut."), 'err'); + else toast('Auto-cut cancelled', 'ok'); tick(); } // The secondary button next to the main CTA: cancels arming while the buffer @@ -323,14 +510,27 @@ function secondaryClick() { } // The big number is an editable setter while idle and a readout once a delay -// is engaged - the +/- retire so the screen reads clean (the feedback ask). +// is engaged, until a tap opens it for a new value (see startEdit). function setNumberMode(ds) { - const engaged = ds === 'arming' || ds === 'armed' || ds === 'active'; - const controls = cfg.w.number.controls && !engaged; - setHidden($('d'), engaged); - setHidden($('v'), !engaged); + if (editing && !isEngaged(ds)) editing = false; + const readout = (ds === 'arming' || isEngaged(ds)) && !editing; + const controls = cfg.w.number.controls && ds !== 'arming'; + setHidden($('d'), readout); + setHidden($('v'), !readout); setHidden($('minus'), !controls); setHidden($('plus'), !controls); + $('cur').classList.toggle('tap', readout && ds !== 'arming'); + $('w-screen').classList.toggle('editing', editing); + const cap = $('edit-cap'); + setHidden(cap, !editing); + // The label is set in capitals; the delay keeps its lowercase unit. + if (editing && cap._ms !== s.armed_delay_ms) { + cap._ms = s.armed_delay_ms; + const now = document.createElement('span'); + now.className = 'edit-cap-num'; + now.textContent = fmtDelay(s.armed_delay_ms || 0); + cap.replaceChildren('New delay · now ', now); + } } // ------------------------------------------------------------- state paint -- @@ -340,20 +540,40 @@ function setNumberMode(ds) { // counts (Ready in 0:23, Arm 30s) just updates its text. function mainLook(ds) { const m = $('main'); - if (ds === 'active') return { key: 'cut', cls: 'danger', label: m._cutArm ? 'Tap again to cut' : 'Cut delay' }; - if (ds === 'armed') return { key: 'armed', cls: 'go', icon: '▶', label: 'Activate' }; + const ms = inputMs(), armedMs = s.armed_delay_ms || 0; + // Over the buffer's reach: the button says so instead of failing on click. + const capLook = { key: 'cap', label: `Buffer holds only ${fmtDelay(currentCapMs())}`, disabled: true }; + if (editing && isEngaged(ds)) { + if (ms <= 0 || ms === armedMs) return { key: 'keep', cls: 'ghost', label: `Keep ${fmtDelay(armedMs)}` }; + if (overCap(ms)) return capLook; + return ds === 'active' + ? { key: 'adjust', cls: 'primary', icon: '↻', label: `Adjust to ${fmtDelay(ms)}` } + : { key: 'rearm', cls: 'primary', icon: '↻', label: `Re-arm at ${fmtDelay(ms)}` }; + } + if (ds === 'active') return { key: 'cut', cls: 'danger' + (m._cutArm ? ' armed' : ''), label: m._cutArm ? 'Tap again to cut' : 'Cut delay' }; + if (ds === 'armed') { + // Nothing arriving from OBS: /activate would only refuse, so wait. + if (!s.ingest_alive) return { key: 'wait-armed', label: 'Waiting for OBS', disabled: true }; + const runs = s.capacity_short ? Math.min(armedMs, s.capacity_short.holds_ms) : armedMs; + return { key: 'armed', cls: 'go', icon: '▶', label: `Activate ${fmtDelay(runs)}` }; + } if (ds === 'arming') { // The buffer fills in real time, so the wait is exactly what's missing. // Right after a click the server may not report the target yet. - const target = s.armed_delay_ms || (+$('d').value || 0) * 1000; + const target = armedMs || ms; const fill = Math.min(s.buffer_fill_ms || 0, target); const label = s.ingest_alive ? `Ready in ${fmtClock(target - fill)}` : 'Waiting for OBS'; return { key: 'arming', cls: 'filling', label, disabled: true, p: target > 0 ? fill / target : 0 }; } - const sec = +$('d').value || 0; + // Too long for the buffer is said now, not first when OBS connects. + // Before a bitrate is measured the reach is a typical one: "about". + if (!s.ingest_alive && ms > 0 && overCap(ms)) { + return { key: 'wait-cap', label: `Buffer holds about ${fmtDelay(currentCapMs())}`, disabled: true }; + } if (!s.ingest_alive) return { key: 'wait', label: 'Waiting for OBS', disabled: true }; - if (sec <= 0) return { key: 'set', label: 'Set a delay first', disabled: true }; - return { key: 'arm', cls: 'primary', icon: '⏱', label: `Arm ${sec.toFixed(0)}s` }; + if (ms <= 0) return { key: 'set', label: 'Set a delay first', disabled: true }; + if (overCap(ms)) return capLook; + return { key: 'arm', cls: 'primary', icon: '⏱', label: `Arm ${fmtDelay(ms)}` }; } function paintMain(look) { const m = $('main'); @@ -369,7 +589,7 @@ function paintMain(look) { m._label = document.createElement('span'); m._label.textContent = look.label; const parts = [m._label]; - if (look.icon) { const i = document.createElement('span'); i.textContent = look.icon; parts.unshift(i); } + if (look.icon) { const i = document.createElement('span'); i.textContent = look.icon; i.setAttribute('aria-hidden', 'true'); parts.unshift(i); } m.replaceChildren(...parts); if (first || !motionOK()) return; for (const p of parts) p.animate([{ opacity: 0, transform: 'translateY(5px)' }, { opacity: 1, transform: 'none' }], { duration: 220, easing: EASE }); @@ -384,7 +604,7 @@ function secondaryLook(ds) { if (ds !== 'active') return null; if (s.safe_cut_pending) { const cd = Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000)); - return { cls: 'btn ghost cutafter pending', text: `⏱ ~${cd}s ✕`, title: 'Auto-cut armed - tap to cancel' }; + return { cls: 'btn ghost cutafter pending', text: `⏱ ~${fmtDelay(cd * 1000)} ✕`, title: 'Auto-cut armed. Tap to cancel.' }; } if (cfg.w.activate.safecut) return { cls: 'btn ghost cutafter', text: '⏱ Cut after', title: 'Cut to live after everything buffered has aired' }; return null; @@ -403,12 +623,15 @@ function renderCta() { paintMain(mainLook(ds)); const sec = secondaryLook(ds); setHidden(cancel, !sec); - if (sec) { setClass(cancel, sec.cls); setText(cancel, sec.text); setTitle(cancel, sec.title); } - if (ds === 'off') { + if (sec) { + setClass(cancel, sec.cls); setText(cancel, sec.text); setTitle(cancel, sec.title); + if (cancel.getAttribute('aria-label') !== sec.title) cancel.setAttribute('aria-label', sec.title); + } + if (ds !== 'arming') { // Dead-end nudges read as disabled: can't go below 0 or above the cap. - const v = +$('d').value || 0; - setDisabled($('minus'), v <= 0); - setDisabled($('plus'), v >= 600); + const v = inputMs() / 1000; + setDisabled($('minus'), (editing || ds === 'off') && v <= 0); + setDisabled($('plus'), (editing || ds === 'off') && v >= DELAY_MAX_S); } } @@ -433,28 +656,45 @@ const PHASE_LOOK = { arming: ['arming', 'Buffering'], armed: ['armed', 'Armed'], function paintSource(alive) { setClass($('src'), 'pill ' + (alive ? 'on' : 'bad') + (cfg.w.source.style === 'dot' ? ' asdot' : '')); setText($('src-lbl'), alive ? 'Live' : 'Offline'); - setTitle($('src'), alive ? 'Live' : 'Offline'); + setTitle($('src'), alive ? 'OBS is sending' : 'Nothing from OBS'); } -// Readout: mirror the dashboard - show the armed target while engaged (or +// How far the delay can reach: the armed one, or what the buffer holds of +// it when it can't hold all of it (the server measured it: capacity_short). +function reachMs() { + const tgt = s.armed_delay_ms || 0, short = s.capacity_short; + return short && short.armed_ms === tgt ? Math.min(tgt, short.holds_ms) : tgt; +} + +// Readout: mirror the dashboard: show the armed target while engaged (or // the fill while arming) and 0 in passthrough. current_delay_ms includes // the pipeline's own ~1s transit, which would read as a phantom delay. +// Always in seconds, like the dashboard's big number: 900, never 15m. function paintReadout(ds) { - const tgtSec = (s.armed_delay_ms || 0) / 1000, fillSec = (s.buffer_fill_ms || 0) / 1000; - const showSec = ds === 'arming' ? Math.min(fillSec, tgtSec) : (ds === 'armed' || ds === 'active') ? tgtSec : 0; - const nv = fmt(showSec * 1000); + const tgt = s.armed_delay_ms || 0, fill = s.buffer_fill_ms || 0; + // Running short (the buffer can't hold the armed delay): the readout + // shows what really runs, in amber, like the dashboard. + const short = isEngaged(ds) && s.capacity_short ? s.capacity_short : null; + $('cur').classList.toggle('short', !!short); + const show = ds === 'arming' ? Math.min(fill, reachMs()) + : short ? Math.min(tgt, short.holds_ms) + : isEngaged(ds) ? tgt : 0; + const nv = fmt(show); if (nv === lastVal) return; - lastVal = nv; setText($('v'), nv); - // The pop marks a change you made (armed, activated, cut), not the - // buffer counting up while it fills. - if (ds === 'arming' || !motionOK()) return; + const was = lastVal; + lastVal = nv; + // The roll and the pop mark a change you made (armed, activated, cut), + // not the buffer counting up while it fills. + if (ds === 'arming' || !motionOK()) { endRoll($('v')); $('v').textContent = nv; return; } + rollDigits($('v'), was, nv, () => { $('v').textContent = lastVal; }); const cur = $('cur'); cur.classList.add('changing'); clearTimeout(cur._t); cur._t = setTimeout(() => cur.classList.remove('changing'), 350); } function paintBar(ds) { - const target = s.armed_delay_ms || s.buffer_target_ms || 0; + // Toward what the buffer can reach: a bar stuck halfway reads as broken. + const target = reachMs() || s.buffer_target_ms || 0; let pct = 0, cls = 'fill'; if (ds === 'active') { pct = 100; cls += ' active'; } else if (target > 0) { @@ -500,32 +740,88 @@ function paintHold(hold) { : 'Waiting for OBS'); } +// "4.2s" under a minute, whole seconds past it, for the live-behind hint. +function fmtBehind(ms) { return ms < 60000 ? (ms / 1000).toFixed(1) + 's' : fmtDelay(ms); } // Hint text: full sentences, or errors-only (encoder offline) if opted. function paintTip(ds, hold) { const tip = hold ? (hold.reason === 'crash' ? 'Reopen OBS in Normal Mode and start streaming to resume.' : 'Start streaming in OBS to resume.') : s.tail_ms > 0 ? 'End now cuts it short.' - : !s.ingest_alive ? 'Point your encoder at rtmp://…/live' - : ds === 'active' && s.safe_cut_pending ? `Auto-cut in ~${Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000))}s - marked footage still airs.` - : ds === 'active' ? `Live ${(s.current_delay_ms / 1000).toFixed(1)}s behind real time.` + : !s.ingest_alive ? 'Start streaming in OBS to InstantClone.' + : editing ? 'Pick a new delay, then confirm it below.' + : ds === 'active' && s.safe_cut_pending ? `Auto-cut in ~${fmtDelay(s.safe_cut_remaining_ms || 0)}. Marked footage still airs.` + : ds === 'active' ? `Live ${fmtBehind(s.current_delay_ms || 0)} behind real time.` + : ds === 'armed' && s.capacity_short ? `The buffer holds only ${fmtDelay(s.capacity_short.holds_ms)}. Activate runs that much.` : ds === 'armed' ? 'Buffer full. Hit Activate to go live with delay.' : ds === 'arming' ? 'Filling buffer… you can cancel any time.' - : 'Passthrough - no delay applied.'; + : 'Passthrough: no delay applied.'; const show = cfg.w.tips.on && (!cfg.w.tips.errorsOnly || !s.ingest_alive); setHidden($('w-tip'), !show); if (show) setTip(tip); } +// The notice line: a dock link the password turns away comes first, then +// an armed delay the buffer can't hold, then an encoder setting a +// destination won't take. The last two dismiss until they change. +let dismissedNote = ''; +function currentNote() { + return (s && s.capacity_short && s.capacity_short.message) || (s && s.compat_warning) || ''; +} +// Open or close the notice with a short height-and-fade, from wherever it +// is on screen, so a dismiss folds it away instead of snapping. +const NOTE_CLOSED = { height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px', paddingTop: '0px', paddingBottom: '0px' }; +function foldNote(box, hide) { + const goal = hide ? 'closed' : 'open'; + if ((box._anim ? box._goal : (box.hidden ? 'closed' : 'open')) === goal) return; + const snap = () => { const cs = getComputedStyle(box); return Object.fromEntries(Object.keys(NOTE_CLOSED).map(p => [p, cs[p]])); }; + const midway = box._anim ? snap() : null; + if (box._anim) box._anim.cancel(); + box._anim = null; + if (!motionOK()) { box.hidden = hide; return; } + box.hidden = false; + const open = snap(); + // The gap below it in the dock's column closes with it, so nothing snaps. + const ps = getComputedStyle(box.parentElement); + const gap = ps.display.includes('flex') || ps.display.includes('grid') ? parseFloat(ps.rowGap) || 0 : 0; + const closed = { ...NOTE_CLOSED, marginBottom: -gap + 'px' }; + box.style.overflow = 'hidden'; + box._goal = goal; + // Fade first, then close (open is the reverse). + const frames = midway ? [midway, hide ? closed : open] + : hide ? [open, { opacity: 0, offset: 0.45 }, closed] + : [closed, { opacity: 0, offset: 0.5 }, open]; + const anim = box.animate(frames, { duration: hide ? 280 : 340, easing: 'cubic-bezier(.4,0,.2,1)' }); + box._anim = anim; + anim.onfinish = () => { + if (box._anim !== anim) return; + box._anim = null; + box.style.overflow = ''; + box.hidden = hide; + }; +} +function paintNote() { + const box = $('w-note'); + const note = currentNote(); + const text = authBlocked ? AUTH_MSG : note !== dismissedNote ? note : ''; + foldNote(box, !text); + if (!text) return; + setClass(box, 'note' + (authBlocked ? ' bad' : '')); + setText($('note-text'), text); + setHidden($('note-x'), authBlocked); +} +$('note-x').onclick = () => { dismissedNote = currentNote(); paintNote(); }; + function applyState(j) { s = j; - updateBitrateHold(); // feed the capacity peak-hold before anything reads it + if (profileGateKey() !== profileGate) renderProfiles(); // phase, delay or buffer reach moved const ds = PHASE_TO_STATE[s.phase] || 'off'; - if (pending && pending === ds) { pending = null; clearTimeout(pendingTimer); } // the server caught up + if (pending && pending === ds) clearPending(); // the server caught up setNumberMode(ds); const [cls, text] = s.hold ? ['holding', 'Holding'] : s.tail_ms > 0 ? ['holding', 'Ending'] : PHASE_LOOK[ds] || (s.ingest_alive ? ['live', 'Live'] : ['off', 'Idle']); setPhase(cls, text); paintSource(!!s.ingest_alive); + paintNote(); paintReadout(ds); paintBar(ds); paintEgress(); @@ -612,6 +908,7 @@ function destRow() { row.innerHTML = ''; row._name = row.children[1]; row._meta = row.children[2]; row._sw = document.createElement('button'); + row._sw.setAttribute('role', 'switch'); row._sw.onclick = () => onDestToggle(row); row.appendChild(row._sw); return row; @@ -623,9 +920,16 @@ function patchDestRow(row, d) { setText(row._name, d.name || 'Destination'); const rate = cfg.w.dest.stats && st === 'alive' ? fmtRate(d.bitrate_kbps) : ''; setClass(row._meta, rate ? 'd-rate' : 'd-plat'); - setText(row._meta, st === 'held' ? 'Holding' : st === 'wait' ? 'Connecting…' : rate || platform); + // A failing destination says so; why, and the next try, on hover. The + // last reason is kept while it connects again, never once it is live. + const fail = st === 'wait' ? d.failure : null; + setText(row._meta, st === 'held' ? 'Holding' : st === 'wait' ? (fail ? 'Can\'t connect' : 'Connecting…') : rate || platform); + setTitle(row, fail ? fail.advice + (fail.retry_in_ms != null ? ` Next try in ${Math.ceil(fail.retry_in_ms / 1000)}s.` : '') : ''); setClass(row._sw, 'sw' + (d.enabled ? ' on' : '')); setTitle(row._sw, d.enabled ? 'Turn off' : 'Turn on'); + const checked = String(!!d.enabled); + if (row._sw.getAttribute('aria-checked') !== checked) row._sw.setAttribute('aria-checked', checked); + if (row._sw.getAttribute('aria-label') !== (d.name || 'Destination')) row._sw.setAttribute('aria-label', d.name || 'Destination'); } // Compact circular badge: platform initial, live-glow ring, hover shows the // name (native title). Tap arms it (confirm), tap again commits. @@ -642,6 +946,7 @@ function patchDestIcon(b, d) { setClass(b, 'dico ' + (d.enabled ? 'on' : 'off') + look + destColor(b, d)); setText(b, (destPlatforms[d.id] || d.name || '?').slice(0, 1).toUpperCase()); setTitle(b, `${d.name || 'Destination'} · ${d.enabled ? 'on' : 'off'}`); + if (b.getAttribute('aria-label') !== b.title) b.setAttribute('aria-label', b.title); } function onDestIcon(b) { const d = b._d; @@ -668,62 +973,79 @@ function onDestToggle(row) { const next = !d.enabled; const box = document.createElement('span'); box.className = 'confirm'; box.innerHTML = `${next ? 'Turn on?' : 'Turn off?'}`; - const yes = document.createElement('button'); yes.className = 'cbtn yes'; yes.innerHTML = '✓'; - const no = document.createElement('button'); no.className = 'cbtn no'; no.innerHTML = '×'; + const yes = document.createElement('button'); yes.className = 'cbtn yes'; yes.innerHTML = '✓'; yes.setAttribute('aria-label', 'Confirm'); + const no = document.createElement('button'); no.className = 'cbtn no'; no.innerHTML = '×'; no.setAttribute('aria-label', 'Cancel'); const cancel = () => { clearTimeout(row._ct); row._confirm = false; box.replaceWith(row._sw); }; yes.onclick = () => { cancel(); commitDest(d, next); }; no.onclick = cancel; box.append(yes, no); row._sw.replaceWith(box); row._confirm = true; row._ct = setTimeout(cancel, 3000); + yes.focus(); } async function commitDest(d, enabled) { const r = await fetchJ('/destinations/toggle', { - method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', headers: FORM, body: `id=${encodeURIComponent(d.id)}&enabled=${enabled ? 'on' : 'off'}`, }); - if (!r.ok) toast(r.j?.error || 'Toggle failed', 'err'); + if (!r.ok) toast(failText(r, `Couldn't turn ${d.name || 'it'} ${enabled ? 'on' : 'off'}`), 'err'); else toast(`${d.name || 'Destination'} ${enabled ? 'on' : 'off'}`, 'ok'); } // --------------------------------------------------------- extra widgets ---- // Delay profiles: one-tap chips from the user's saved profiles (same list as -// the dashboard). Tapping fills the delay and optionally arms it. +// the dashboard). Idle, a tap picks the delay (and arms it with "Apply on +// tap"); armed or running, it moves to that delay the same way. let profileCache = []; async function fetchProfiles() { const r = await fetchJ('/profiles'); if (r.ok && Array.isArray(r.j)) { profileCache = r.j; renderProfiles(); } } +function profileTitle(name, ms, ds, isCur) { + if (isCur) return `${name}: running now`; + if (ds === 'arming') return 'Wait for the buffer to fill, or cancel arming'; + const apply = cfg.w.profiles.arm; + if (ds === 'active') return apply ? `Adjust to ${fmtDelay(ms)}` : `Pick ${fmtDelay(ms)}, then Adjust`; + if (ds === 'armed') return apply ? `Re-arm at ${fmtDelay(ms)}` : `Pick ${fmtDelay(ms)}, then Re-arm`; + return apply ? `Arm ${fmtDelay(ms)}` : `Set ${fmtDelay(ms)}`; +} function renderProfiles() { + profileGate = profileGateKey(); const box = $('w-profiles'); if (box.hidden) return; const list = cfg.w.profiles.view === 'list'; box.className = 'profiles' + (list ? ' plist' : ''); if (!profileCache.length) { box.innerHTML = '
No profiles yet. Add them in the dashboard.
'; return; } + const ds = phaseNow(), target = s?.armed_delay_ms || 0; box.innerHTML = ''; for (const p of profileCache) { - const ms = p.delay_ms || 0, sec = Math.round(ms / 1000); + const ms = p.delay_ms || 0, name = p.name || fmtDelay(ms); // Too big for the buffer at the current bitrate: greyed + disabled, with // a tooltip naming the size it needs. Same wall the arm path enforces. const tooBig = overCap(ms); + const isCur = ds !== 'off' && ms === target; const b = document.createElement('button'); - b.className = (list ? 'prow' : 'pchip') + (tooBig ? ' toobig' : ''); - b.disabled = tooBig; - b.title = tooBig ? `Buffer too small - needs about ${requiredMB(ms)} MB. Raise it in the dashboard.` : `Set ${sec}s delay`; + b.className = (list ? 'prow' : 'pchip') + (tooBig ? ' toobig' : '') + (isCur ? ' on' : ''); + b.disabled = tooBig || isCur || ds === 'arming'; + b.title = tooBig ? capAdvice(ms) : profileTitle(name, ms, ds, isCur); + if (isCur) b.setAttribute('aria-current', 'true'); b.innerHTML = list - ? `${esc(p.name || sec + 's')}${sec}s` - : esc(p.name || sec + 's') + (cfg.w.profiles.value ? `${sec}s` : ''); - b.onclick = () => profileSet(sec); + ? `${esc(name)}${fmtDelay(ms)}` + : esc(name) + (cfg.w.profiles.value ? `${fmtDelay(ms)}` : ''); + b.onclick = () => profileSet(ms); box.appendChild(b); } } -function profileSet(sec) { - if (overCap(sec * 1000)) { - toast(`Buffer too small for ${sec}s - raise it in the dashboard.`, 'err'); - return; +function profileSet(ms) { + if (overCap(ms)) { capToast(ms); return; } + const ds = phaseNow(), sec = Math.round(ms / 1000); + if (ds === 'arming') return; + if (isEngaged(ds)) { + if (ms === (s.armed_delay_ms || 0)) return; + if (cfg.w.profiles.arm) { changeDelay(ms); return; } + startEdit(false); } - $('d').value = sec; fitDelayInput(); renderCta(); - const ds = s ? (PHASE_TO_STATE[s.phase] || 'off') : 'off'; - if (cfg.w.profiles.arm && ds === 'off' && sec > 0) arm(sec * 1000); + setDelayField(sec); + if (ds === 'off' && cfg.w.profiles.arm && sec > 0) arm(ms); } // Health stats: CPU / RAM / reconnects, plus a backpressure warning. All read @@ -745,49 +1067,60 @@ function renderStats() { setHtml(box, html || 'no metrics selected'); } -// Server settings mirrored on the dock (Auto behavior + Settings widgets). -// Read from /config; each boolean flip is a safe partial POST /config. -let srvCfg = { auto_arm_on_connect: false, auto_activate_when_ready: false, tracing_enabled: true }; +// The dock's own switches (Auto behavior + Settings widgets), read and +// written through /dock/settings: a fixed set of on/off settings the dock +// token may change even with a dashboard password on. auth_enabled tells +// the dock whether the links it copies need the token. +let srvCfg = { auto_arm_on_connect: false, auto_activate_when_ready: false, tracing_enabled: false, 'crash_protection.enabled': false, auth_enabled: false }; +let srvLoaded = false; async function fetchServerCfg() { - const r = await fetchJ('/config'); + const r = await fetchJ('/dock/settings'); if (!r.ok || !r.j) return; - srvCfg.auto_arm_on_connect = !!r.j.auto_arm_on_connect; - srvCfg.auto_activate_when_ready = !!r.j.auto_activate_when_ready; - srvCfg.tracing_enabled = !!r.j.tracing_enabled; - if (typeof r.j.buffer_mb === 'number') { bufferMB = r.j.buffer_mb; renderProfiles(); } + srvCfg = { ...srvCfg, ...r.j }; srvLoaded = true; renderBehavior(); renderSettings(); } -// A label + switch row bound to a boolean server-config key. -function toggleRow(label, key) { +function paintSwitch(sw, on) { + sw.className = 'sw sm' + (on ? ' on' : ''); + sw.setAttribute('aria-checked', String(on)); +} +// A label + switch row bound to one dock switch. +function toggleRow(label, key, hint) { const row = document.createElement('div'); row.className = 'brow'; + if (hint) row.title = hint; const lb = document.createElement('span'); lb.className = 'blbl'; lb.textContent = label; - const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'sw sm' + (srvCfg[key] ? ' on' : ''); - sw.onclick = () => setServer(key, !srvCfg[key], sw); + const sw = document.createElement('button'); sw.type = 'button'; sw.setAttribute('role', 'switch'); sw.setAttribute('aria-label', label); + paintSwitch(sw, !!srvCfg[key]); + sw.disabled = !srvLoaded; // never flip a switch whose real state is unknown + sw.onclick = () => setServer(key, !srvCfg[key], sw, label); row.append(lb, sw); return row; } -async function setServer(key, val, sw) { - srvCfg[key] = val; if (sw) sw.className = 'sw sm' + (val ? ' on' : ''); - const r = await fetchJ('/config', { - method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: `${key}=${val ? 'true' : 'false'}`, - }); - if (!r.ok) { toast('Save failed', 'err'); fetchServerCfg(); } - else toast(val ? 'On' : 'Off', 'ok'); +async function setServer(key, val, sw, label) { + srvCfg[key] = val; paintSwitch(sw, val); sw.disabled = true; + const r = await fetchJ('/dock/settings', { method: 'POST', headers: FORM, body: `${encodeURIComponent(key)}=${val}` }); + sw.disabled = false; + if (!r.ok) { + srvCfg[key] = !val; paintSwitch(sw, !val); + toast(failText(r, `Couldn't change ${label}`), 'err'); + return; + } + if (r.j) srvCfg = { ...srvCfg, ...r.j }; + toast(`${label} ${val ? 'on' : 'off'}`, 'ok'); } function renderBehavior() { const box = $('w-behavior'); if (box.hidden) return; - box.innerHTML = ''; - box.appendChild(toggleRow('Auto-arm on connect', 'auto_arm_on_connect')); - box.appendChild(toggleRow('Auto-activate when full', 'auto_activate_when_ready')); + box.replaceChildren( + toggleRow('Pre-arm on stream start', 'auto_arm_on_connect', 'Arms your Default delay (System › General) when OBS starts streaming'), + toggleRow('Auto-activate when ready', 'auto_activate_when_ready', 'Every arm goes on air once the buffer is full')); } // Settings: the live-safe knobs (things that don't need a restart). Buffer / // ports stay in the dashboard because changing them restarts the pipeline. function renderSettings() { const box = $('w-settings'); if (box.hidden) return; - box.innerHTML = ''; - box.appendChild(toggleRow('Wire trace log', 'tracing_enabled')); + box.replaceChildren( + toggleRow('Crash protection', 'crash_protection.enabled', 'If OBS drops, viewers see a reconnect screen instead of the stream ending'), + toggleRow('Trace log', 'tracing_enabled', 'A detailed log for bug reports. Leave it off otherwise')); // Discord, chat and phone alerts are integrations, set up in the // dashboard: they take secret links, awkward to paste into a dock. const ig = document.createElement('div'); ig.className = 'brow'; @@ -800,11 +1133,12 @@ function renderSettings() { function confirmOpenDashboard(btns) { btns.innerHTML = ''; const q = document.createElement('span'); q.className = 'cq'; q.textContent = 'Open dashboard?'; - q.title = 'Opens the Integrations tab of the InstantClone dashboard in a new tab.'; + q.title = 'Opens the Integrations tab of the InstantClone dashboard in a new tab. With a password on, sign in there first.'; const yes = document.createElement('button'); yes.className = 'minibtn'; yes.textContent = 'Yes'; yes.onclick = () => { window.open(location.origin + '/#integrations', '_blank'); toast('Opening dashboard…', 'ok'); renderSettings(); }; const no = document.createElement('button'); no.className = 'minibtn'; no.textContent = 'No'; no.onclick = renderSettings; btns.append(q, yes, no); + yes.focus(); } // Overlays: list the OBS browser-source overlays and copy their URLs, so a @@ -823,6 +1157,7 @@ function renderOverlays() { // re-renders on keystroke, so the input keeps focus + caret. if (cfg.w.overlays.search) { const inp = document.createElement('input'); inp.className = 'osearch'; inp.placeholder = 'Search overlays…'; inp.value = overlayQuery; + inp.setAttribute('aria-label', 'Search overlays'); inp.oninput = () => { overlayQuery = inp.value; renderOverlayList(); }; box.appendChild(inp); } @@ -852,7 +1187,8 @@ function overlayRow(o) { const row = document.createElement('div'); row.className = 'orow'; const nm = document.createElement('span'); nm.className = 'o-name'; nm.textContent = o.name || o.slug; if (o.studio && !cfg.w.overlays.group) { const tag = document.createElement('span'); tag.className = 'o-tag'; tag.textContent = 'studio'; nm.appendChild(tag); } - const copy = document.createElement('button'); copy.className = 'minibtn'; copy.innerHTML = '🔗'; copy.title = 'Copy browser-source URL'; copy.onclick = () => copyOverlay(o.slug); + const copy = document.createElement('button'); copy.className = 'minibtn'; copy.textContent = 'Copy URL'; + copy.title = 'Copy the browser-source URL'; copy.onclick = () => copyOverlay(o.slug); row.append(nm, copy); return row; } @@ -864,9 +1200,9 @@ function overlayUrl(slug) { return `${location.origin}/overlay/${encodeURIComponent(slug)}.html${q}`; } async function copyOverlay(slug) { + // Overlay URLs carry no secret, so a failed copy can show the URL itself. const url = overlayUrl(slug); - try { await navigator.clipboard.writeText(url); toast('Overlay URL copied', 'ok'); } - catch (_) { toast(url, 'ok'); } + if (await copyText(url)) toast('Overlay URL copied', 'ok'); else toast(url, 'info'); } // -------------------------------------------------------------- layout apply -- @@ -888,7 +1224,7 @@ function applyCfg(c) { // the number (its toggle lives under the Number row), so turning the number // off hides the whole screen, bar included. $('w-screen').hidden = !on('number'); - $('w-screen').className = 'screen sz-' + (c.w.number.size || 'md'); + $('w-screen').className = 'screen sz-' + (c.w.number.size || 'md') + (editing ? ' editing' : ''); $('cur').classList.toggle('tint', !!c.w.number.tint); $('w-bar').hidden = !on('bar'); $('w-bar').classList.toggle('thick', !!c.w.bar.thick); @@ -900,25 +1236,37 @@ function applyCfg(c) { for (const id of c.order) { const el = $(WEL[id]); if (el) wrap.appendChild(el); } // The crash hold card takes the place of the delay controls it stands in // for. A dock without them (destinations only, overlays) shows it on top, - // so every dock can end the stream without losing its own purpose. + // so every dock can end the stream without losing its own purpose. The + // notice line sits right below it, above the controls it is about. const holdAnchor = on('number') ? $('w-screen') : on('activate') ? $('w-cta') : wrap.firstElementChild; if (holdAnchor !== $('w-hold')) wrap.insertBefore($('w-hold'), holdAnchor); - if (on('profiles')) { fetchProfiles(); if (!bufferMB) fetchBufferMB(); } // capacity gate needs buffer size + wrap.insertBefore($('w-note'), $('w-hold').nextSibling); + if (on('profiles')) fetchProfiles(); if (on('behavior') || on('settings')) { renderBehavior(); renderSettings(); fetchServerCfg(); } if (on('overlays')) fetchOverlays(); if (s) applyState(s); else renderCta(); // re-render with the new options } -function persist() { +function dockKey(id) { return 'ic.dock.' + id; } +function saveLayout(id, json) { + return fetchJ('/docks/' + encodeURIComponent(id), { method: 'POST', headers: { 'content-type': 'application/json' }, body: json }); +} +// Mirror a layout into localStorage too: every OBS dock shares one browser +// profile, so a dock showing that slot adopts it live (the storage event). +function mirrorLayout(id, json) { + try { if (json) localStorage.setItem(dockKey(id), json); else localStorage.removeItem(dockKey(id)); } catch (_) {} +} +async function persist() { const json = JSON.stringify(cfg); - try { localStorage.setItem('ic.dock.' + SLOT, json); } catch (_) {} - fetchJ('/docks/' + encodeURIComponent(SLOT), { method: 'POST', headers: { 'content-type': 'application/json' }, body: json }); + mirrorLayout(SLOT, json); + const r = await saveLayout(SLOT, json); + if (!r.ok) toast(failText(r, "Couldn't save this layout"), 'err'); } function saveAndApply() { applyCfg(cfg); persist(); } async function loadCfg() { // 1) A ?cfg= blob (shared / imported URL) wins and is saved to this slot. - const p = new URLSearchParams(location.search).get('cfg'); + const p = params.get('cfg'); if (p) { try { cfg = mergeCfg(JSON.parse(decodeURIComponent(escape(atob(p))))); @@ -928,12 +1276,12 @@ async function loadCfg() { } catch (_) {} } // 2) localStorage mirror: instant paint before the server answers. - try { const ls = localStorage.getItem('ic.dock.' + SLOT); if (ls) { cfg = mergeCfg(JSON.parse(ls)); applyCfg(cfg); } } catch (_) {} + try { const ls = localStorage.getItem(dockKey(SLOT)); if (ls) { cfg = mergeCfg(JSON.parse(ls)); applyCfg(cfg); } } catch (_) {} // 3) Server copy is the durable source of truth; reconcile when it lands. const r = await fetchJ('/docks/' + encodeURIComponent(SLOT)); if (r.ok && r.j && typeof r.j === 'object') { cfg = mergeCfg(r.j); applyCfg(cfg); - try { localStorage.setItem('ic.dock.' + SLOT, JSON.stringify(cfg)); } catch (_) {} + mirrorLayout(SLOT, JSON.stringify(cfg)); } } @@ -943,18 +1291,12 @@ let dragId = null; // Which widget rows are expanded. Kept across the frequent buildEditor() // rebuilds so flipping an option doesn't snap its panel shut. const openRows = new Set(); -// Saved dock slots the server knows about (for the Docks switcher). -let knownSlots = []; - -async function fetchSlots() { - const r = await fetchJ('/docks'); - if (r.ok && Array.isArray(r.j)) { knownSlots = r.j; if (!$('editor').hidden) buildEditor(); } -} function ensureEditorRoot() { let el = $('editor'); if (!el) { el = document.createElement('div'); el.id = 'editor'; el.className = 'editor'; el.hidden = true; + el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-label', 'Customize dock'); el.innerHTML = '
'; el.onclick = e => { if (e.target === el) closeEditor(); }; document.body.appendChild(el); @@ -964,11 +1306,14 @@ function ensureEditorRoot() { function openEditor() { const root = ensureEditorRoot(); buildEditor(); root.hidden = false; requestAnimationFrame(() => root.classList.add('show')); - fetchSlots(); // populate the Docks switcher + fetchSlots(); // populate the Docks list + root.querySelector('.eclose').focus(); } function closeEditor() { const root = ensureEditorRoot(); root.classList.remove('show'); + dockUi = { open: '', form: null, confirm: '' }; setTimeout(() => root.hidden = true, 220); // let the slide-out finish + $('gear').focus(); } // A row of segmented buttons (the "mode" control). `current` is the selected @@ -977,7 +1322,9 @@ function segControl(current, items, onPick) { const seg = document.createElement('div'); seg.className = 'seg'; for (const [val, lab] of items) { const b = document.createElement('button'); b.type = 'button'; - b.className = 'segb' + (String(current) === String(val) ? ' sel' : ''); b.textContent = lab; + const sel = String(current) === String(val); + b.className = 'segb' + (sel ? ' sel' : ''); b.textContent = lab; + b.setAttribute('aria-pressed', String(sel)); b.onclick = () => onPick(val); seg.appendChild(b); } @@ -994,7 +1341,8 @@ function optionEls(id) { const items = sp.modes || sp.sel.map(v => [v, String(v)]); row.appendChild(segControl(cfg.w[t][sp.k], items, val => { cfg.w[t][sp.k] = val; cfg.preset = 'custom'; saveAndApply(); buildEditor(); })); } else { - const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'sw sm' + (cfg.w[t][sp.k] ? ' on' : ''); + const sw = document.createElement('button'); sw.type = 'button'; sw.setAttribute('role', 'switch'); sw.setAttribute('aria-label', sp.label); + paintSwitch(sw, !!cfg.w[t][sp.k]); sw.onclick = () => { cfg.w[t][sp.k] = !cfg.w[t][sp.k]; cfg.preset = 'custom'; saveAndApply(); buildEditor(); }; row.appendChild(sw); } @@ -1016,11 +1364,13 @@ function widgetRow(id) { row.ondrop = e => { e.preventDefault(); row.classList.remove('drop'); if (dragId) reorder(dragId, id); }; const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = WIDGETS[id].label; const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'sw' + (w.on ? ' on' : ''); + sw.setAttribute('role', 'switch'); sw.setAttribute('aria-checked', String(!!w.on)); sw.setAttribute('aria-label', WIDGETS[id].label); sw.onclick = e => { e.stopPropagation(); w.on = !w.on; cfg.preset = 'custom'; saveAndApply(); buildEditor(); }; const opts = optionEls(id); head.append(grip, nm); if (opts) { - const cv = document.createElement('span'); cv.className = 'cv'; cv.innerHTML = '›'; head.appendChild(cv); + row.classList.add('has-opts'); + const cv = document.createElement('span'); cv.className = 'cv'; cv.innerHTML = '›'; cv.setAttribute('aria-hidden', 'true'); head.appendChild(cv); if (openRows.has(id)) row.classList.add('open'); head.onclick = e => { if (e.target === sw || e.target === grip) return; @@ -1036,16 +1386,18 @@ function sectionLabel(text, inline) { const el = document.createElement('div'); el.className = 'lbl' + (inline ? ' inline' : ''); el.textContent = text; return el; } function buildEditor() { - const body = $('sheet-body'); body.innerHTML = ''; + const body = $('sheet-body'); if (!body) return; + body.innerHTML = ''; const head = document.createElement('div'); head.className = 'ehead'; - head.innerHTML = `

Customize dock

${esc(SLOT)}`; - const x = document.createElement('button'); x.className = 'eclose'; x.innerHTML = '×'; x.title = 'Done'; x.onclick = closeEditor; + head.innerHTML = `

Customize dock

${esc(slotName(SLOT))}`; + const x = document.createElement('button'); x.className = 'eclose'; x.innerHTML = '×'; x.title = 'Done'; x.setAttribute('aria-label', 'Close'); x.onclick = closeEditor; head.appendChild(x); body.appendChild(head); body.appendChild(sectionLabel('Preset')); const chips = document.createElement('div'); chips.className = 'chips'; for (const [k, p] of Object.entries(PRESETS)) { const c = document.createElement('button'); c.type = 'button'; c.className = 'chip' + (cfg.preset === k ? ' sel' : ''); c.textContent = p.label; + c.setAttribute('aria-pressed', String(cfg.preset === k)); c.onclick = () => applyPreset(k); chips.appendChild(c); } body.appendChild(chips); @@ -1061,7 +1413,8 @@ function buildEditor() { const sw = document.createElement('div'); sw.className = 'swatches'; for (const key of Object.keys(ACCENTS)) { const b = document.createElement('button'); b.type = 'button'; b.className = 'swatch' + (cfg.accent === key ? ' sel' : ''); - b.style.setProperty('--sw', ACCENTS[key][0]); b.title = key; + b.style.setProperty('--sw', ACCENTS[key][0]); b.title = key; b.setAttribute('aria-label', key + ' accent'); + b.setAttribute('aria-pressed', String(cfg.accent === key)); b.onclick = () => { cfg.accent = key; saveAndApply(); buildEditor(); }; sw.appendChild(b); } @@ -1072,27 +1425,14 @@ function buildEditor() { for (const id of cfg.order) list.appendChild(widgetRow(id)); body.appendChild(list); - // Docks switcher: surface that multiple docks exist (?dock=) and let - // the user copy a URL to paste into another OBS browser dock. body.appendChild(sectionLabel('Docks')); - const slots = document.createElement('div'); slots.className = 'chips'; - for (const id of Array.from(new Set([SLOT, ...knownSlots]))) { - const c = document.createElement('button'); c.type = 'button'; - c.className = 'chip' + (id === SLOT ? ' sel' : ''); c.textContent = id; - c.title = 'Copy this dock’s URL'; - c.onclick = () => copySlotUrl(id); - slots.appendChild(c); - } - const add = document.createElement('button'); add.type = 'button'; add.className = 'chip add'; add.innerHTML = '+ New'; - add.onclick = () => newDock(add); - slots.appendChild(add); - body.appendChild(slots); - const hint = document.createElement('div'); hint.className = 'hint'; - hint.textContent = 'Each OBS browser dock loads a layout by URL. Copy one, then add a new browser dock in OBS to run a second.'; - body.appendChild(hint); + const docks = document.createElement('div'); docks.id = 'docks-box'; + body.appendChild(docks); + renderDocks(); const act = document.createElement('div'); act.className = 'ed-actions'; - // Escape hatch for a messed-up layout: two-tap reset back to defaults. + // Escape hatch for a messed-up layout: two-tap reset back to defaults. The + // dock keeps its name, which is not part of the look. const reset = document.createElement('button'); reset.className = 'btn ghost reset'; reset.textContent = 'Reset'; reset.onclick = () => { if (!reset._arm) { @@ -1100,9 +1440,10 @@ function buildEditor() { setTimeout(() => { reset._arm = false; reset.textContent = 'Reset'; reset.classList.remove('warn'); }, 3000); return; } - openRows.clear(); cfg = defaultCfg(); saveAndApply(); buildEditor(); toast('Dock reset to defaults', 'ok'); + openRows.clear(); cfg = { ...defaultCfg(), name: cfg.name }; saveAndApply(); buildEditor(); toast('Dock reset to defaults', 'ok'); }; - const copy = document.createElement('button'); copy.className = 'btn ghost'; copy.innerHTML = '🔗 Copy dock URL'; copy.onclick = copyDockUrl; + const copy = document.createElement('button'); copy.className = 'btn ghost'; copy.innerHTML = 'Copy link'; + copy.onclick = () => copyDockLink(SLOT); const done = document.createElement('button'); done.className = 'btn primary'; done.textContent = 'Done'; done.onclick = closeEditor; act.append(reset, copy, done); body.appendChild(act); } @@ -1118,32 +1459,230 @@ function reorder(from, to) { o.splice(o.indexOf(to), 0, from); cfg.order = o; cfg.preset = 'custom'; saveAndApply(); buildEditor(); } -async function copyDockUrl() { - const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(cfg)))); - const url = `${location.origin}/dock?dock=${encodeURIComponent(SLOT)}&cfg=${b64}`; - try { await navigator.clipboard.writeText(url); toast('Dock URL copied', 'ok'); } - catch (_) { toast(url, 'ok'); } -} -// A slot's plain URL - it loads that slot's own saved layout on open. -async function copySlotUrl(id) { - const url = `${location.origin}/dock?dock=${encodeURIComponent(id)}`; - try { await navigator.clipboard.writeText(url); toast(id === SLOT ? 'This dock URL copied' : `Dock "${id}" URL copied`, 'ok'); } - catch (_) { toast(url, 'ok'); } -} -// Inline name entry (prompt() is unreliable inside OBS's browser dock). -function newDock(addChip) { - const inp = document.createElement('input'); inp.className = 'newdock'; inp.placeholder = 'name…'; inp.maxLength = 40; - const commit = () => { const id = inp.value.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40); if (id) copySlotUrl(id); buildEditor(); }; - inp.onkeydown = e => { if (e.key === 'Enter') commit(); else if (e.key === 'Escape') buildEditor(); }; - inp.onblur = () => setTimeout(() => { if (!$('editor').hidden) buildEditor(); }, 150); - addChip.replaceWith(inp); inp.focus(); + +// ------------------------------------------------------------------ docks ---- +// Every saved layout is a dock: one OBS Custom Browser Dock loads one by its +// link (?dock=). The id in a link never changes once OBS has it, so a +// rename only changes the display name stored in the layout. + +let knownSlots = []; // saved slot ids, from the server +let slotLayouts = new Map(); // other slot id -> its saved layout +// What the Docks list shows: which row is open, the New / Rename form, and a +// pending two-step confirm ("delete:" or "replace:"). +let dockUi = { open: '', form: null, confirm: '' }; + +async function fetchSlots() { + const r = await fetchJ('/docks'); + if (!r.ok || !Array.isArray(r.j)) return; + knownSlots = r.j; + const others = knownSlots.filter(id => id !== SLOT); + const got = await Promise.all(others.map(id => fetchJ('/docks/' + encodeURIComponent(id)))); + slotLayouts = new Map(others.map((id, i) => [id, got[i].ok && got[i].j && typeof got[i].j === 'object' ? got[i].j : null])); + renderDocks(); +} +function slotName(id) { + const name = id === SLOT ? cfg.name : cleanName(slotLayouts.get(id)?.name); + return name || (id === 'default' ? 'Main dock' : id); +} +function slotCfg(id) { return id === SLOT ? cfg : mergeCfg(slotLayouts.get(id)); } +function dockUrl(id) { + return `${location.origin}/dock?dock=${encodeURIComponent(id)}${TOKEN ? '&token=' + TOKEN : ''}`; +} +// A dock's own link. It carries the access token when this dock has it; a +// dock let in by its cookie alone hands out links that work in this OBS. +async function copyDockLink(id, done) { + if (!(await copyText(dockUrl(id)))) { toast("Couldn't copy the link. Click into the dock and try again.", 'err'); return; } + const tail = srvCfg.auth_enabled && !TOKEN ? ' It works in this OBS.' : ''; + toast((done || `Link to "${slotName(id)}" copied.`) + tail, 'ok'); +} +// A fresh slot id from a name: readable in the link, unique among saved docks. +function slotIdFor(name) { + const base = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9_-]/g, '').replace(/-+/g, '-') + .replace(/^-|-$/g, '').slice(0, 32) || 'dock'; + const taken = new Set([SLOT, ...knownSlots]); + let id = base, n = 2; + while (taken.has(id)) id = `${base}-${n++}`; + return id; +} +async function createDock(name, layout) { + name = cleanName(name); + if (!name) { toast('Give the dock a name', 'err'); return false; } + const id = slotIdFor(name); + const copy = { ...JSON.parse(JSON.stringify(layout)), name }; + const json = JSON.stringify(copy); + const r = await saveLayout(id, json); + if (!r.ok) { toast(failText(r, "Couldn't create the dock"), 'err'); return false; } + mirrorLayout(id, json); + knownSlots = [...knownSlots, id]; slotLayouts.set(id, copy); + dockUi = { open: id, form: null, confirm: '' }; + renderDocks(); + await copyDockLink(id, `"${name}" created, link copied. Add it in OBS: Docks > Custom Browser Docks.`); + return true; +} +async function renameDock(id, name) { + name = cleanName(name); + dockUi.form = null; + if (name === cleanName(slotCfg(id).name)) { renderDocks(); return; } + if (id === SLOT) { cfg.name = name; saveAndApply(); buildEditor(); toast(name ? `Renamed to "${name}"` : 'Name cleared', 'ok'); return; } + const next = { ...slotCfg(id), name }, json = JSON.stringify(next); + const r = await saveLayout(id, json); + if (!r.ok) { toast(failText(r, "Couldn't rename the dock"), 'err'); renderDocks(); return; } + mirrorLayout(id, json); slotLayouts.set(id, next); + renderDocks(); toast(name ? `Renamed to "${name}"` : 'Name cleared', 'ok'); +} +// Give another dock this dock's look, keeping its own name. +async function copyLayoutTo(id) { + const next = { ...JSON.parse(JSON.stringify(cfg)), name: cleanName(slotCfg(id).name) }, json = JSON.stringify(next); + const r = await saveLayout(id, json); + if (!r.ok) { toast(failText(r, "Couldn't copy the layout"), 'err'); return; } + mirrorLayout(id, json); slotLayouts.set(id, next); + renderDocks(); toast(`"${slotName(id)}" now uses this layout`, 'ok'); +} +async function deleteDock(id) { + const name = slotName(id); + const r = await saveLayout(id, ''); + if (!r.ok) { toast(failText(r, "Couldn't delete the dock"), 'err'); return; } + mirrorLayout(id, ''); + knownSlots = knownSlots.filter(x => x !== id); slotLayouts.delete(id); + dockUi = { open: '', form: null, confirm: '' }; + renderDocks(); toast(`Deleted "${name}". Remove its dock in OBS too.`, 'ok'); +} +// Two-step for anything that overwrites or deletes (OBS docks can't show a +// confirm dialog): the first tap turns the button red for 3 seconds. +function confirmStep(btn, key, label, run) { + if (dockUi.confirm === key) { dockUi.confirm = ''; clearTimeout(btn._t); run(); return; } + dockUi.confirm = key; + btn.textContent = label; btn.classList.add('warn'); + btn._t = setTimeout(() => { if (dockUi.confirm === key) { dockUi.confirm = ''; renderDocks(); } }, 3000); +} +function miniBtn(text, onClick, title) { + const b = document.createElement('button'); b.type = 'button'; b.className = 'minibtn'; b.textContent = text; + if (title) b.title = title; + b.onclick = onClick; + return b; +} +// An inline text field (prompt() is unreliable inside OBS's browser dock). +function nameField(value, placeholder, onEnter, onEscape) { + const inp = document.createElement('input'); inp.className = 'dfield'; inp.maxLength = 40; + inp.value = value; inp.placeholder = placeholder; inp.setAttribute('aria-label', placeholder); + inp.onkeydown = e => { + if (e.key === 'Enter') { e.preventDefault(); onEnter(inp.value); } + else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onEscape(); } + }; + return inp; +} +function dockRow(id) { + const isThis = id === SLOT, open = dockUi.open === id; + const row = document.createElement('div'); row.className = 'drow' + (isThis ? ' this' : ''); + const head = document.createElement('div'); head.className = 'dhead'; + const renaming = dockUi.form?.kind === 'rename' && dockUi.form.id === id; + if (renaming) { + const cancel = () => { dockUi.form = null; renderDocks(); }; + const inp = nameField(cleanName(slotCfg(id).name), slotName(id), v => renameDock(id, v), cancel); + head.append(inp, miniBtn('Save', () => renameDock(id, inp.value)), miniBtn('Cancel', cancel)); + row.appendChild(head); + requestAnimationFrame(() => { inp.focus(); inp.select(); }); + return row; + } + const nm = document.createElement('span'); nm.className = 'dname'; nm.title = `?dock=${id}`; + nm.innerHTML = `${esc(slotName(id))}${isThis ? 'This dock' : ''}`; + const more = document.createElement('button'); more.type = 'button'; more.className = 'dmore'; more.innerHTML = '⋯'; + more.title = 'More'; more.setAttribute('aria-label', `More for ${slotName(id)}`); more.setAttribute('aria-expanded', String(open)); + more.onclick = () => { dockUi = { open: open ? '' : id, form: null, confirm: '' }; renderDocks(); }; + head.append(nm, miniBtn('Copy link', () => copyDockLink(id), 'Copy the link for an OBS Custom Browser Dock'), more); + row.appendChild(head); + if (!open) return row; + const acts = document.createElement('div'); acts.className = 'dacts'; + const full = knownSlots.length >= DOCKS_MAX; + acts.appendChild(miniBtn('Rename', () => { dockUi.form = { kind: 'rename', id }; renderDocks(); })); + const dup = miniBtn('Duplicate', () => createDock(`${slotName(id)} copy`, slotCfg(id)), 'A new dock with the same layout'); + dup.disabled = full; + acts.appendChild(dup); + if (!isThis) { + const use = miniBtn('Use this layout', null, `Give "${slotName(id)}" the layout of this dock`); + use.onclick = () => confirmStep(use, 'replace:' + id, 'Replace its layout?', () => copyLayoutTo(id)); + const del = miniBtn('Delete', null); + del.onclick = () => confirmStep(del, 'delete:' + id, 'Tap again to delete', () => deleteDock(id)); + acts.append(use, del); + } + row.appendChild(acts); + return row; +} +function newDockForm() { + const f = dockUi.form; + const box = document.createElement('div'); box.className = 'dform'; + const cancel = () => { dockUi.form = null; renderDocks(); }; + const create = async () => { + const btn = box.querySelector('.minibtn.ok'); btn.disabled = true; + const ok = await createDock(inp.value, f.from === 'this' ? cfg : defaultCfg()); + if (!ok) btn.disabled = false; + }; + const inp = nameField(f.name, 'Dock name, like Scenes', create, cancel); + inp.oninput = () => { f.name = inp.value; }; + const r1 = document.createElement('div'); r1.className = 'frow'; r1.appendChild(inp); + const r2 = document.createElement('div'); r2.className = 'frow'; + r2.append(segControl(f.from, [['this', 'This layout'], ['defaults', 'Defaults']], v => { f.from = v; f.name = inp.value; renderDocks(); })); + const btns = document.createElement('span'); btns.className = 'srow-btns'; + const go = miniBtn('Create', create); go.classList.add('ok'); + btns.append(miniBtn('Cancel', cancel), go); + r2.appendChild(btns); + box.append(r1, r2); + requestAnimationFrame(() => inp.focus()); + return box; +} +function renderDocks() { + const box = $('docks-box'); if (!box) return; + const list = document.createElement('div'); list.className = 'dlist'; + for (const id of [SLOT, ...knownSlots.filter(x => x !== SLOT)]) list.appendChild(dockRow(id)); + const parts = [list]; + if (dockUi.form?.kind === 'new') parts.push(newDockForm()); + else { + const add = document.createElement('button'); add.type = 'button'; add.className = 'dnew'; add.textContent = '+ New dock'; + add.disabled = knownSlots.length >= DOCKS_MAX; + if (add.disabled) add.title = `${DOCKS_MAX} docks is the most InstantClone keeps. Delete one first.`; + add.onclick = () => { dockUi = { open: '', form: { kind: 'new', name: '', from: 'this' }, confirm: '' }; renderDocks(); }; + parts.push(add); + } + const hint = document.createElement('div'); hint.className = 'hint'; + hint.textContent = 'Each OBS browser dock shows one layout. Make a new dock here, then paste its link into OBS under Docks > Custom Browser Docks.'; + parts.push(hint); + box.replaceChildren(...parts); } // -------------------------------------------------------------------- boot -- -$('d').addEventListener('input', () => { fitDelayInput(); renderCta(); }); -// Enter in the delay field = press the main button (arm). Keyboard-first flow. -$('d').addEventListener('keydown', e => { if (e.key === 'Enter') mainClick(); }); +// Typing or focusing the field ends a roll at once, so it never hides +// what is being typed. +$('d').addEventListener('focus', () => endRoll($('d-roll'))); +$('d').addEventListener('input', () => { + endRoll($('d-roll')); + fitDelayInput(); renderCta(); + clearTimeout(settleTimer); settleTimer = setTimeout(settleDelayInput, 700); +}); +// Enter in the delay field = press the main button. Esc puts back the +// running delay. Keyboard-first flow. +$('d').addEventListener('keydown', e => { + if (e.key === 'Enter') { settleDelayInput(); mainClick(); } + else if (e.key === 'Escape' && editing) { e.preventDefault(); endEdit(); } +}); +// Leaving the field unchanged while picking a new delay closes the picker, +// unless focus went to the controls that finish the job. +$('d').addEventListener('blur', () => { + settleDelayInput(); + setTimeout(() => { + if (!editing || !s || inputMs() !== (s.armed_delay_ms || 0)) return; + const a = document.activeElement; + if (a && (a.id === 'minus' || a.id === 'plus' || a.id === 'main' || a.id === 'd')) return; + endEdit(); + }, 150); +}); +$('v').addEventListener('click', () => startEdit(true)); +$('v').addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startEdit(true); } }); +document.addEventListener('keydown', e => { + if (e.key !== 'Escape') return; + const ed = $('editor'); + if (ed && !ed.hidden) closeEditor(); else endEdit(); +}); // Staggered first paint (see body.boot CSS); the class comes off after one // pass so later saves/reorders never replay the entrance. document.body.classList.add('boot'); @@ -1151,6 +1690,7 @@ setTimeout(() => document.body.classList.remove('boot'), 800); fitDelayInput(); applyCfg(cfg); // paint defaults immediately loadCfg(); // then hydrate from url / localStorage / server +fetchServerCfg(); // the switches, and whether a password is on // Prefer SSE for state; fall back to 500ms polling if it is missing or drops. let _pollTimer = null; @@ -1171,20 +1711,20 @@ startSSE(); // another instance of this slot saves (persist writes localStorage), the // storage event fires here and we adopt the new layout live - no reload. window.addEventListener('storage', e => { - if (e.key !== 'ic.dock.' + SLOT || !e.newValue) return; + if (e.key !== dockKey(SLOT) || !e.newValue) return; try { const next = mergeCfg(JSON.parse(e.newValue)); if (JSON.stringify(next) !== JSON.stringify(cfg)) applyCfg(next); } catch (_) {} }); -// Aux data (profiles, overlays, server config, destinations) has no push +// Aux data (profiles, overlays, server switches, destinations) has no push // channel, so refresh it whenever the dock regains focus or visibility - // e.g. after editing profiles in the dashboard and clicking back into OBS. function refreshAux() { - if (cfg.w.profiles.on) { fetchProfiles(); fetchBufferMB(); } // buffer size may have changed in the dashboard + if (cfg.w.profiles.on) fetchProfiles(); if (cfg.w.overlays.on) fetchOverlays(); - if (cfg.w.behavior.on || cfg.w.settings.on) fetchServerCfg(); + fetchServerCfg(); if (cfg.w.dest.on) fetchDestPlatforms(); } window.addEventListener('focus', () => { if (!_es) tick(); refreshAux(); }); diff --git a/web/index.html b/web/index.html index 13a189a..9a935ff 100644 --- a/web/index.html +++ b/web/index.html @@ -2,7 +2,8 @@ -InstantClone - Dashboard +InstantClone Dashboard + @@ -113,6 +114,13 @@ .muted a:hover,.tour-body a:hover,#dest-form-format-help a:hover,#dest-form-twitch-native-note a:hover{ color:var(--fg);border-color:var(--fg)} .muted a:visited,.tour-body a:visited{color:var(--accent)} +/* Every other plain link in the copy gets the same look, never the + browser's blue or visited purple. :where() keeps this weaker than any + link that is styled on purpose. */ +:where(a:not([class])){color:var(--accent);text-decoration:none;font-weight:600; + border-bottom:1px solid color-mix(in oklch,var(--accent) 32%,transparent); + transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} +:where(a:not([class])):hover{color:var(--fg);border-color:var(--fg)} .ic-brand-dot{display:inline-block;width:5px;height:5px;border-radius:999px;background:var(--fg-4);margin:0 6px 2px} .ic-header-right{display:flex;gap:6px;align-items:center;margin-left:auto;flex-wrap:wrap;justify-content:flex-end} .ic-pill{display:inline-flex;align-items:center;gap:8px;padding:6px 11px;border-radius:999px; @@ -125,21 +133,21 @@ .ic-pill.warn{color:var(--warn);border-color:color-mix(in oklch,var(--warn) 35%,transparent); background:color-mix(in oklch,var(--warn) 8%,var(--surface-3))} -/* Encoder-compatibility strip. Deliberately ONE line and deliberately - absent whenever the stream is fine - the dashboard already carries a - lot, so this only earns its space when it has something actionable to - say. Long text ellipsises rather than wrapping to a second row; the - full sentence stays reachable via the title tooltip. */ -#compat-warn{display:flex;align-items:center;gap:10px;margin:0 0 14px;padding:9px 12px; +/* Encoder-compatibility strip. Deliberately absent whenever the stream is + fine - the dashboard already carries a lot, so this only earns its space + when it has something actionable to say. One line per problem, each + naming where the fix lives in OBS, readable on touch too. */ +#compat-warn{display:flex;align-items:flex-start;gap:10px;margin:0 0 14px;padding:9px 12px; border:1px solid color-mix(in oklch,var(--warn) 30%,transparent);border-radius:10px; background:color-mix(in oklch,var(--warn) 7%,var(--surface-2));font-size:12.5px} -#compat-warn .cw-ic{flex:none;color:var(--warn);font-size:13px;line-height:1} -#compat-warn .cw-text{flex:1;min-width:0;color:var(--fg-2); - overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#compat-warn .cw-ic{flex:none;color:var(--warn);font-size:13px;line-height:1.45} +#compat-warn .cw-text{flex:1;min-width:0;color:var(--fg-2);line-height:1.45} +#compat-warn .cw-line + .cw-line{margin-top:4px} +#compat-warn .cw-when{margin-top:4px;color:var(--fg-4);font-size:11.5px} #compat-warn .cw-x{flex:none;width:22px;height:22px;border-radius:6px;border:0;cursor:pointer; background:transparent;color:var(--fg-4);font-size:14px;line-height:1; transition:color .15s var(--ease-out),background .15s var(--ease-out)} -#compat-warn .cw-x:hover{color:var(--fg-1);background:var(--surface-3)} +#compat-warn .cw-x:hover{color:var(--fg);background:var(--surface-3)} /* LAN-exposure warning. Only rendered while the box is actually ticked, so the default (loopback-only) setup never sees it. */ @@ -165,14 +173,17 @@ the neighbouring header pills sideways on connect/disconnect. */ .ic-pill-rate{gap:0;min-width:86px;justify-content:center} .ic-pill-rate .ic-pill-v{font-family:'JetBrains Mono',monospace;font-weight:500} +/* "Idle" is a word, not a reading: it sets like the other pills. */ +.ic-pill-rate.idle .ic-pill-v{font-family:inherit} .ic-pill-rate .unit{color:var(--fg-3);font-size:11px;margin-left:1px} /* Conditional header pills (codec / EB / backpressure / cap / update) are toggled via [hidden]. When shown, display flips none->inline-flex, which replays this entrance animation once - so they fade+scale in instead of popping. (Exit stays instant; the appearance is the jarring part.) */ -#hdr-codec,#hdr-ebroadcast,#hdr-dualformat,#hdr-backpressure,#hdr-twitch-cap,#hdr-update{ +#hdr-codec,#hdr-ebroadcast,#hdr-dualformat,#hdr-backpressure,#hdr-twitch-cap,#hdr-update,#hdr-whats-new{ animation:pillIn .22s var(--ease-out)} @keyframes pillIn{from{opacity:0;transform:scale(.82)}to{opacity:1;transform:none}} +#hdr-update,#hdr-whats-new{text-decoration:none} @keyframes pulse-ring{ 0%{box-shadow:0 0 0 0 color-mix(in oklch,var(--live) 60%,transparent)} 70%{box-shadow:0 0 0 8px color-mix(in oklch,var(--live) 0%,transparent)} @@ -185,7 +196,7 @@ .ic-label{font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase; color:color-mix(in oklch,var(--fg-2) 45%,var(--fg-3))} -.ic-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:8px; +.ic-btn{appearance:none;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:8px; padding:10px 16px;border-radius:10px;border:1px solid var(--line-2);background:var(--surface-3); color:var(--fg);font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap; transition:transform .15s var(--ease-out),background .15s var(--ease-out),border-color .15s var(--ease-out), @@ -305,7 +316,7 @@ /* Toast */ /* Above every overlay (modals 1000, tour 300, studio 400) so a toast fired while the destination modal is open isn't hidden behind it. */ -.ic-toast-rail{position:fixed;right:24px;bottom:24px;z-index:2000;display:flex;flex-direction:column;gap:8px;pointer-events:none} +.ic-toast-rail{position:fixed;right:24px;bottom:24px;z-index:2000;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none} .ic-toast{background:var(--surface-2);border:1px solid var(--line-2);border-radius:10px; padding:10px 14px 10px 12px;display:flex;align-items:center;gap:10px; box-shadow:0 10px 30px rgba(0,0,0,.4);pointer-events:auto; @@ -316,6 +327,41 @@ .ic-toast.ok .ic-toast-icon{background:color-mix(in oklch,var(--live) 22%,transparent);color:var(--live)} @keyframes toast-in{from{transform:translateY(8px);opacity:0}to{transform:translateY(0);opacity:1}} .ic-toast.leaving{opacity:0;transform:translateX(12px);transition:opacity .25s var(--ease-out),transform .25s var(--ease-out)} +.ic-toast-text{flex:1;min-width:0;overflow-wrap:anywhere} +.ic-toast-act{margin-left:4px;padding:4px 11px;border-radius:7px;border:1px solid color-mix(in oklch,var(--accent) 45%,transparent); + background:color-mix(in oklch,var(--accent) 12%,transparent);color:var(--accent);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer; + transition:background-color .15s var(--ease-out)} +.ic-toast-act:hover{background:color-mix(in oklch,var(--accent) 20%,transparent)} +.ic-toast-x{flex:none;width:22px;height:22px;margin:-2px -6px -2px 0;border-radius:6px;border:0;background:transparent; + color:var(--fg-4);font-size:12px;line-height:1;cursor:pointer;transition:color .15s var(--ease-out),background-color .15s var(--ease-out)} +.ic-toast-x:hover,.ic-toast-x:focus-visible{color:var(--fg);background:var(--surface-3);outline:none} +@media (max-width:520px){.ic-toast-rail{left:12px;right:12px;bottom:12px;align-items:stretch}} +/* Phone delay bar (see the "Phone delay bar" script): slides up from the + bottom edge once the delay card's button has scrolled out of sight. */ +.mini-bar{display:none} +@media (max-width:720px){ + .mini-bar{position:fixed;left:10px;right:10px;bottom:calc(10px + env(safe-area-inset-bottom));z-index:900; + display:flex;align-items:center;gap:10px;padding:7px 7px 7px 14px;border-radius:16px; + background:color-mix(in oklch,var(--surface-2) 86%,transparent);backdrop-filter:blur(16px) saturate(1.4); + border:1px solid var(--line-2);box-shadow:0 12px 32px rgba(0,0,0,.5); + transform:translateY(calc(100% + 24px));opacity:0;pointer-events:none; + transition:transform .34s var(--ease-out),opacity .2s var(--ease-out)} + .mini-bar.on{transform:none;opacity:1;pointer-events:auto} + .mini-bar[hidden]{display:none} + body.mini-on{padding-bottom:76px} + body.mini-on .ic-toast-rail{bottom:calc(80px + env(safe-area-inset-bottom))} +} +.mini-read{appearance:none;border:0;background:none;color:var(--fg);font:inherit;cursor:pointer; + display:flex;align-items:baseline;gap:5px;flex:1;min-width:0;padding:4px 0;text-align:left} +.mini-dot{flex:none;align-self:center;width:7px;height:7px;border-radius:50%;background:var(--sc,var(--idle));margin-right:3px} +.mini-num{font-size:20px;font-weight:700;letter-spacing:-.02em} +.mini-unit{font-size:12px;color:var(--fg-3)} +.mini-state{min-width:0;margin-left:6px;font-size:11px;font-weight:600;letter-spacing:.08em;color:var(--fg-3); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.mini-cta{flex:none;max-width:58%;height:40px;padding:0 14px;font-size:13px; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +@media (prefers-reduced-motion:reduce){.mini-bar{transition:opacity .15s}} +@media (prefers-reduced-motion: reduce){.ic-toast{animation:none}.ic-toast.leaving{transition:none}} /* - Wizard (when !configured) --------─ */ .wizard-card{width:min(880px,92vw);max-width:880px;margin:40px auto;padding:38px 44px;display:flex;flex-direction:column;gap:18px} @@ -461,6 +507,26 @@ .ic-disclose-body p{margin:0} .ic-disclose-body p + p{margin-top:8px} .ic-disclose-body code.mono{background:var(--surface);padding:1px 5px;border-radius:4px;font-size:11px} +/* Every other
grows open and folds shut, in browsers that can + animate to `auto` (Chromium 131+, so OBS 33's docks too). Others snap + open as before. The spring-driven .ic-disclose animates itself. The clip + margin keeps focus rings and shadows at the edges whole once open. */ +@supports selector(::details-content){ + details:not(.disc-ready){interpolate-size:allow-keywords} + details:not(.disc-ready)::details-content{block-size:0;opacity:0;overflow:clip;overflow-clip-margin:6px; + transition:block-size .28s var(--ease-out),opacity .22s var(--ease-out),content-visibility .28s allow-discrete} + details:not(.disc-ready)[open]::details-content{block-size:auto;opacity:1} +} +/* Summaries that kept the browser's marker get a caret that turns. */ +.ig-more-fields>summary,.crash-advanced>summary{list-style:none;display:flex;align-items:center;gap:7px;width:fit-content;-webkit-user-select:none;user-select:none} +.ig-more-fields>summary::-webkit-details-marker,.crash-advanced>summary::-webkit-details-marker{display:none} +.ig-more-fields>summary::before,.crash-advanced>summary::before{content:"";flex:none;width:0;height:0; + border-left:5px solid currentColor;border-top:4px solid transparent;border-bottom:4px solid transparent; + transition:transform .2s var(--ease-out)} +.ig-more-fields[open]>summary::before,.crash-advanced[open]>summary::before{transform:rotate(90deg)} +@media (prefers-reduced-motion:reduce){ + details::details-content{transition:none!important} +} .wizard-card h2{font-size:22px;margin:0;letter-spacing:-.01em} .wizard-card .lead{color:var(--fg-3);font-size:13px;margin:0 0 8px} /* Field labels are uppercase eyebrow text. The `:not` guard is load-bearing: @@ -751,8 +817,10 @@ .hero-delay-num.d-arming{color:var(--warn)} .hero-delay-num.d-armed{color:var(--accent)} .hero-delay-num.d-active{color:var(--live)} +/* Less delay on air than was armed (the buffer can't hold it). */ +.hero-delay-num.d-short{color:var(--warn)} .hero-delay-unit{font-size:32px;color:var(--fg-3);font-weight:400} -.hero-delay-of{font-size:22px;color:var(--fg-3);margin-left:6px} +.hero-delay-of{font-size:22px;color:var(--fg-3);margin-left:6px;white-space:nowrap} .hero-delay-sub{color:var(--fg-3);font-size:13px;min-height:1.4em; transition:opacity .22s var(--ease-out),transform .22s var(--ease-out)} /* Message swap: old text slides down + fades, new text drops in from @@ -786,6 +854,11 @@ @keyframes cap-flash{0%,35%{color:var(--warn)}100%{color:var(--fg-4)}} .cp-cap-warn{margin-top:8px;padding:8px 10px;border:1px solid var(--danger);border-radius:6px;background:color-mix(in oklch,var(--danger) 8%,var(--bg-2));color:var(--fg-2);font-size:11.5px;line-height:1.45;animation:softDrop .2s var(--ease-out)} .cp-cap-warn b{color:var(--danger);font-weight:600} +.cap-show{appearance:none;border:0;background:none;padding:0;font:inherit;font-weight:600;color:var(--accent);cursor:pointer; + text-decoration:underline;text-underline-offset:2px;white-space:nowrap} +.cap-show:focus-visible{outline:1px solid var(--accent);outline-offset:2px;border-radius:3px} +.cp-profile-chip.cp-more{color:var(--fg-3)} +.stats-explain{margin-top:14px} /* Shared soft entrance for elements that appear on state change (capacity warning, restart banner) so they slide+fade in instead of popping. */ @keyframes softDrop{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:none}} @@ -890,8 +963,10 @@ changes with state. The hovercard is a pure-CSS tooltip (opacity+translate on :hover / :focus-within) so keyboard users get it too. It floats ABOVE the button, right-anchored with a fixed - width - the wrap itself is too narrow to host readable copy. */ -.cutafter-wrap{position:relative;flex:1;min-width:0;animation:pillIn .2s var(--ease-out)} + width - the wrap itself is too narrow to host readable copy. The + wrap never shrinks below its label: on a phone a quarter of the row + is narrower than "Cut after", so Cut delay gives up the room. */ +.cutafter-wrap{position:relative;flex:1;min-width:max-content;animation:pillIn .2s var(--ease-out)} /* display:none must survive the class's own display value - [hidden] loses to any author display rule otherwise. */ .cutafter-wrap[hidden]{display:none} @@ -933,8 +1008,10 @@ .tab.on{color:var(--fg)} .tab svg{opacity:.85;width:15px;height:15px;transition:color .2s var(--ease-out)} .tab.on svg{opacity:1;color:var(--accent)} +/* Indicators slide without overshoot: a spring's overshoot grows with the + distance, and a long slide (Destinations to Logs) ran into the strip's end. */ .tab-ind{position:absolute;top:4px;bottom:4px;background:var(--surface-3);border-radius:7px; - transition:left .28s var(--ease-spring),width .28s var(--ease-spring);z-index:1;border:1px solid var(--line-2)} + transition:left .28s var(--ease-out),width .28s var(--ease-out);z-index:1;border:1px solid var(--line-2)} /* - Sub-tabs (nested nav, currently used by System pane) -─ */ .sub-tabs{position:relative;display:flex;gap:2px;padding:4px;background:var(--surface-2); @@ -949,7 +1026,25 @@ .sub-tab svg{opacity:.7;width:12px;height:12px} .sub-tab.on svg{opacity:1} .sub-tab-ind{position:absolute;top:4px;bottom:4px;background:var(--surface-3);border-radius:6px; - transition:left .28s var(--ease-spring),width .28s var(--ease-spring);z-index:1;border:1px solid var(--line-2)} + transition:left .28s var(--ease-out),width .28s var(--ease-out);z-index:1;border:1px solid var(--line-2)} +/* A strip that scrolls sideways fades the edge that hides more tabs. */ +.tabs.more-r,.sub-tabs.more-r{mask-image:linear-gradient(90deg,#000 calc(100% - 28px),transparent)} +.tabs.more-l,.sub-tabs.more-l{mask-image:linear-gradient(90deg,transparent,#000 28px)} +.tabs.more-l.more-r,.sub-tabs.more-l.more-r{mask-image:linear-gradient(90deg,transparent,#000 28px,#000 calc(100% - 28px),transparent)} +/* On a phone the header is two calm rows: the name, then the live pills + in one line that scrolls sideways if it must. */ +@media (max-width:520px){ + .ic-header{flex-direction:column;align-items:stretch;gap:10px;padding:12px 14px} + .ic-brand{align-items:baseline;flex-wrap:nowrap} + .ic-brand-sub{overflow:visible} + .ic-header-right{margin-left:0;flex-wrap:nowrap;justify-content:flex-start;overflow-x:auto;scrollbar-width:none; + margin-right:-14px;padding-right:14px} + .ic-header-right::-webkit-scrollbar{display:none} + .ic-header-right>*{flex:none} + .ic-header-right.more-r{mask-image:linear-gradient(90deg,#000 calc(100% - 28px),transparent)} + .ic-header-right.more-l.more-r{mask-image:linear-gradient(90deg,transparent,#000 28px,#000 calc(100% - 28px),transparent)} + .ic-header-right.more-l:not(.more-r){mask-image:linear-gradient(90deg,transparent,#000 28px)} +} .sub-pane{display:flex;flex-direction:column;gap:14px;animation:paneIn .2s var(--ease-out) both} .sub-tab[hidden]{display:none} /* Save bar: appears only while there are unsaved changes, pinned to the @@ -1040,19 +1135,65 @@ .sys-callout.info{border-color:color-mix(in oklch,var(--accent) 32%,var(--line)); background:color-mix(in oklch,var(--accent) 8%,transparent)} .sys-callout.info .sys-callout-ic{color:var(--accent)} -/* Links inside the About status messages need explicit contrast - they */ +/* Links inside the About status messages need explicit contrast: they */ /* sit inside .muted, which would otherwise mute them into the body copy. */ #sys-about-msg a, -.sys-about-card a{color:var(--accent);text-decoration:none; +.sys-about-card a:not(.ic-btn){color:var(--accent);text-decoration:none; border-bottom:1px solid color-mix(in oklch,var(--accent) 35%,transparent); font-weight:600;transition:color .15s,border-color .15s,background .15s; padding:0 2px;border-radius:3px} #sys-about-msg a:hover, -.sys-about-card a:hover{color:#fff; +.sys-about-card a:not(.ic-btn):hover{color:#fff; background:color-mix(in oklch,var(--accent) 18%,transparent); border-bottom-color:var(--accent)} #sys-about-msg a:visited, -.sys-about-card a:visited{color:var(--accent)} +.sys-about-card a:not(.ic-btn):visited{color:var(--accent)} +/* What an available update brings: one line per change, its kind first + as a badge (Added cyan, Fixed green, Faster amber). */ +.sys-about-changes{margin-top:14px} +.sys-about-changes ul{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:7px;font-size:12.5px;line-height:1.45;color:var(--fg-2)} +.sys-about-changes li{display:flex;gap:10px;align-items:flex-start;min-width:0} +.sys-about-changes li b{flex:none;min-width:56px;margin-top:1px;padding:1px 0;border-radius:99px;text-align:center; + font-size:9.5px;font-weight:650;line-height:1.5;text-transform:uppercase;letter-spacing:.5px; + color:var(--accent);background:color-mix(in oklch,var(--accent) 14%,transparent)} +.sys-about-changes li b[data-kind="Fixed"]{color:var(--ok);background:color-mix(in oklch,var(--ok) 14%,transparent)} +.sys-about-changes li b[data-kind="Faster"]{color:var(--warn);background:color-mix(in oklch,var(--warn) 14%,transparent)} +/* The update itself: a quiet panel under the version row. */ +.upd-panel{margin-top:12px;padding:12px 14px 13px;border:1px solid var(--line);border-radius:10px; + background:color-mix(in oklch,var(--accent) 4%,var(--surface-2))} +.upd-panel-title{font-size:10.5px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;color:var(--fg-3)} +.sys-about-msg{font-size:12px;line-height:1.5;margin-top:10px} +.sys-about-msg:empty{display:none} +/* Update settings: title over a one-line note, like the other System + settings; the tray one hangs under the check it depends on. */ +.upd-opts{display:grid;gap:12px;margin-top:16px;padding-top:14px;border-top:1px solid var(--line)} +.upd-opt{display:flex;align-items:flex-start;gap:10px;cursor:pointer;transition:opacity .2s var(--ease-out)} +.upd-opt[hidden]{display:none} +.upd-opt input{flex:none;width:16px;height:16px;margin:2px 0 0;cursor:pointer} +.upd-opt-t{display:block;font-weight:600;color:var(--fg)} +.upd-opt-s{display:block;margin-top:1px;font-size:12px;color:var(--fg-3)} +.upd-opt-sub{margin-left:26px} +/* "Updated to vX": once after an update (in-app or by hand), until "Got it". */ +.whats-new{border-color:color-mix(in oklch,var(--accent) 32%,var(--line)); + background:color-mix(in oklch,var(--accent) 6%,var(--bg-2))} +.whats-new-head{display:flex;gap:12px;align-items:flex-start} +.whats-new-ic{flex:none;width:22px;height:22px;margin-top:1px;border-radius:99px;display:inline-flex; + align-items:center;justify-content:center;font-size:12px;font-weight:700; + background:color-mix(in oklch,var(--accent) 20%,transparent);color:var(--accent)} +.whats-new-text{flex:1;min-width:0} +.whats-new-title{font-size:14px;font-weight:600;color:var(--fg)} +.whats-new-sub{font-size:12.5px;line-height:1.5;color:var(--fg-2);margin-top:2px} +.whats-new-ok{flex:none;padding:6px 12px;font-size:12.5px} +.whats-new .sys-about-changes{margin:10px 0 0 34px} +.whats-new-link{display:inline-block;margin:10px 0 0 34px;font-size:12.5px;font-weight:600; + color:var(--accent);text-decoration:none} +.whats-new-link:hover{text-decoration:underline} +.whats-new-link:focus-visible,.whats-new-ok:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +/* Restart / update progress in the toast rail: stays put until the page + reloads, so it is seen on whatever tab the user is on. */ +#restart-notice{max-width:min(420px,calc(100vw - 48px));line-height:1.45} +#restart-notice .ic-toast-icon{flex:none} +#restart-notice a{color:var(--accent);font-weight:600;word-break:break-all} /* - Pane ------------------- */ .pane-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius); @@ -1079,9 +1220,27 @@ .tab-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap} .tab-title{font-size:17px;font-weight:650;color:var(--fg);letter-spacing:-.015em} .tab-sub{color:var(--fg-3);font-size:13px;margin-top:4px;max-width:60ch} +/* Tray icon color: one dot per color, ringed when it is the one chosen. */ +.tray-color-row{display:flex;align-items:center;gap:14px} +.tray-color-row[hidden]{display:none} +.tray-colors{display:flex;gap:12px;padding:4px} +.tray-color{position:relative;width:22px;height:22px;cursor:pointer} +.tray-color input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer} +.tray-color span{display:block;width:100%;height:100%;border-radius:50%;background:var(--sw); + box-shadow:0 0 0 2px var(--surface),0 0 0 4px transparent; + transition:transform .2s var(--ease-spring),box-shadow .2s var(--ease-out)} +.tray-color:hover span{transform:scale(1.1)} +.tray-color:active span{transform:scale(.94)} +.tray-color input:checked+span{box-shadow:0 0 0 2px var(--surface),0 0 0 4px var(--fg)} +.tray-color input:focus-visible+span{outline:2px solid var(--accent);outline-offset:5px} +@media (prefers-reduced-motion: reduce){.tray-color span{transition:none}} +#ov-gallery .tab-head>div:first-child{flex:1 1 340px;min-width:0} +#ov-gallery .tab-head .tab-sub{max-width:none;text-wrap:balance} /* - Destinations --------------- */ -.dest-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(330px,1fr))} +/* min(): on a phone the pane is narrower than 330px, and a fixed minimum + pushed every card past its right edge. */ +.dest-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(min(330px,100%),1fr))} .dcard{background:var(--bg-2);border:1px solid var(--line);border-radius:14px;padding:15px; display:flex;flex-direction:column;gap:13px; transition:border-color .2s var(--ease-out),box-shadow .2s var(--ease-out),opacity .25s var(--ease-out); @@ -1133,6 +1292,12 @@ transition:color .2s ease,background .2s ease,border-color .2s ease} .dcard-status .d-dot{width:5px;height:5px;border-radius:999px;background:var(--fg-4)} .dcard-status-l{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* On a phone the name needs the room: the pill keeps its coloured dot and + the card body below already says the state in words. */ +@media (max-width:420px){ + .dcard-status{min-width:0;width:22px;height:22px;padding:0;overflow:hidden} + .dcard-status .dcard-status-l{display:none} +} .dcard-status.s-ready{color:var(--accent);border-color:color-mix(in oklch,var(--accent) 30%,var(--line)); background:color-mix(in oklch,var(--accent) 8%,var(--surface-3))} .dcard-status.s-ready .d-dot{background:var(--accent)} @@ -1173,7 +1338,8 @@ /* Inset "screen": a darker panel holding the live bitrate + trace, exactly like the landing cards. The sparkline bleeds to the screen's edges. */ .dcard-screen{background:var(--bg-1);border:1px solid color-mix(in oklch,var(--line) 65%,transparent); - border-radius:11px;padding:12px 14px 0;overflow:hidden;position:relative} + border-radius:11px;padding:12px 14px 0;overflow:hidden;position:relative; + flex:1;display:flex;flex-direction:column} /* Wraps so a long idle message pushes the counters to their own line instead of being squeezed into a narrow column beside them. */ .dcard-body{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:2px 10px} @@ -1194,7 +1360,7 @@ /* A waiting vertical destination spells out its fix here, visible on touch and to keyboard users (the pill's tooltip alone isn't). */ .dcard-res.is-hint{color:var(--warn,#d6a23a);font-family:inherit;font-size:11px;letter-spacing:0} -.dcard-spark{display:block;width:calc(100% + 28px);height:40px;margin:6px -14px 0} +.dcard-spark{display:block;width:calc(100% + 28px);height:40px;margin:auto -14px 0;padding-top:6px;box-sizing:content-box} /* Edit / remove: subtle glass buttons over the trace, revealed on hover. */ /* Reveal via slide + visibility, NOT opacity. Animating opacity on a backdrop-filter element makes Chromium skip the blur mid-transition and @@ -1213,6 +1379,40 @@ transition:color .15s,background .15s,border-color .15s} .dcard-act:hover{color:var(--fg);border-color:var(--line-2);background:color-mix(in oklch,var(--bg-1) 78%,transparent)} .dcard-act.dcard-del:hover{color:var(--danger);border-color:color-mix(in oklch,var(--danger) 45%,var(--line))} +/* Touch screens have no hover: the actions stay in reach. */ +@media (hover:none){.dcard-actions{transform:none;pointer-events:auto}} +/* Why it isn't streaming: a calm reveal in place of the empty trace, and a + slim bar running down to the next attempt. */ +.dcard-why{display:grid;grid-template-rows:0fr;opacity:0; + transition:grid-template-rows .32s var(--ease-out),opacity .32s var(--ease-out)} +.dcard-why-in{overflow:hidden;min-height:0} +.dcard.failing .dcard-why{grid-template-rows:1fr;opacity:1} +.dcard.failing .dcard-spark{display:none} +.dcard-why-advice{margin-top:8px;font-size:12px;line-height:1.45;color:var(--fg-2);text-wrap:pretty} +.dcard-why-row{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;margin:7px 0 12px;font-size:11px;color:var(--fg-3)} +.dcard-why-next{font-variant-numeric:tabular-nums;margin-right:auto} +.dcard-why-btn{appearance:none;border:0;background:none;padding:2px 0;font:inherit;font-weight:600;color:var(--accent);cursor:pointer} +.dcard-why-btn.dcard-why-more{color:var(--fg-3);font-weight:500} +.dcard-why-btn:hover{text-decoration:underline;text-underline-offset:2px} +.dcard-why-btn:focus-visible{outline:1px solid var(--accent);outline-offset:2px;border-radius:3px} +.dcard-why-btn:disabled{opacity:.5;cursor:default;text-decoration:none} +.dcard-why-btn[hidden]{display:none} +.dcard-why-detail{margin:-6px 0 12px;font-size:10.5px;line-height:1.45;color:var(--fg-4);overflow-wrap:anywhere} +.dcard-why-bar{position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--warn);opacity:0; + transform-origin:left;transform:scaleX(0);pointer-events:none} +.dcard.failing .dcard-why-bar.run{opacity:.55;animation:whyDrain linear forwards} +@keyframes whyDrain{from{transform:scaleX(1)}to{transform:scaleX(0)}} +/* The in-card question (stop a live destination, remove one). */ +.dcard-ask{position:absolute;inset:auto 9px 9px 9px;z-index:3;padding:11px 12px;border-radius:10px; + background:var(--surface-2);border:1px solid var(--line-2);box-shadow:0 10px 30px rgba(0,0,0,.4); + animation:softDrop .2s var(--ease-out)} +.dcard-ask-q{font-size:12.5px;line-height:1.45;color:var(--fg)} +.dcard-ask-row{display:flex;justify-content:flex-end;gap:8px;margin-top:10px} +@media (prefers-reduced-motion: reduce){ + .dcard-why{transition:none} + .dcard-why-bar{display:none} + .dcard-ask{animation:none} +} /* Empty Destinations: a platform picker instead of a lone "+" tile. */ .dest-empty{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:26px 8px 10px} .dest-empty[hidden],.dest-grid[hidden]{display:none} @@ -1346,7 +1546,6 @@ .dest-vod details>div{margin-top:6px;font-size:11px;line-height:1.6;color:var(--fg-2)} .dest-eb{margin-top:13px;padding-top:13px;border-top:1px solid var(--line)} .dest-eb-title{font-weight:600;color:var(--fg);font-size:12.5px;text-transform:none;letter-spacing:normal} -.dest-eb-title .tag{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--warn,#d6a23a);margin-left:7px} .dest-eb-desc{font-size:11.5px;margin-top:4px;line-height:1.5;color:var(--fg-3);text-transform:none;letter-spacing:normal} .dest-form label{display:block;margin-top:6px;font-size:11px;color:var(--fg-3);text-transform:uppercase; letter-spacing:.12em;font-weight:600} @@ -1396,15 +1595,64 @@ .stat-spark path[class$="-line"],.stat-spark path[id$="-line"]{ transition:stroke .2s} +/* Every checkbox is drawn as the dashboard's switch (a destination's + Enabled), so a setting looks and works the same wherever it is. Drawn + with backgrounds alone: no extra markup, and an input can't carry + pseudo-elements in every browser. Sized here, over inline sizes. */ +input[type="checkbox"]{appearance:none;-webkit-appearance:none;flex:none;box-sizing:border-box; + width:34px!important;height:20px!important;margin:0;vertical-align:middle;cursor:pointer; + border:1px solid var(--line-2);border-radius:999px;background-color:var(--surface-3); + background-image:radial-gradient(circle,var(--fg-3) 0 6px,transparent 6.5px); + background-repeat:no-repeat;background-size:18px 18px;background-position:0 50%; + transition:background-color .2s var(--ease-out),border-color .2s var(--ease-out),background-position .25s var(--ease-spring)} +input[type="checkbox"]:checked{background-color:var(--accent);border-color:transparent; + background-image:radial-gradient(circle,#fff 0 6px,transparent 6.5px);background-position:14px 50%} +input[type="checkbox"]:hover:not(:disabled){border-color:var(--fg-4)} +input[type="checkbox"]:focus-visible{outline:2px solid color-mix(in oklch,var(--accent) 60%,transparent);outline-offset:2px} +input[type="checkbox"]:disabled{opacity:.45;cursor:default} +/* The destination form keeps its own drawn switch over a hidden input. */ +.dest-form-enable input[type="checkbox"]{width:1px!important;height:1px!important} + /* - OBS tab -----------------─ */ .obs-grid{display:grid;gap:12px;grid-template-columns:minmax(0,1fr) minmax(0,1fr)} -@media (max-width:880px){.obs-grid{grid-template-columns:1fr}} +@media (max-width:880px){.obs-grid{grid-template-columns:minmax(0,1fr)}} .obs-section{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:16px; display:flex;flex-direction:column;gap:6px} .obs-section.span-2{grid-column:1/-1} .obs-reg-card{background:linear-gradient(180deg,rgba(90,200,250,.07) 0%,var(--bg-2) 60%); border-color:rgba(90,200,250,.32)} .obs-manual-card{opacity:.94} +.obs-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px} +.obs-card-titles{min-width:0;display:flex;flex-direction:column;gap:6px} +.obs-reg-pill{flex:none;padding:4px 10px;font-size:11.5px;cursor:default;transition:color .2s var(--ease-out),border-color .2s var(--ease-out)} +.obs-reg-status{margin-top:8px;font-size:12.5px;line-height:1.5;color:var(--fg-2);text-wrap:pretty} +/* Registered: the steps say everything the intro would, so it steps aside. */ +.obs-reg-card[data-state="current"] .obs-reg-intro{display:none} +.obs-reg-card[data-state="current"] .obs-reg-status{margin-top:4px} +.obs-reg-msg{font-size:12px;line-height:1.5;color:var(--err);margin-top:4px} +.obs-reg-msg:empty{display:none} +/* Footer: how it works on the left, the quiet way out on the right, apart + from the main action so it is never clicked by mistake. */ +/* Footer: how it works on the left; the card's actions on the right, in + one place whatever the state. Adding is the primary button, taking + InstantClone out of OBS a plain one of the same size, so neither hides + and neither shouts. */ +.obs-reg-foot{display:flex;align-items:flex-start;gap:12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)} +.obs-reg-foot .ic-disclose{flex:1;min-width:0;margin-top:9px} +.obs-reg-btns{flex:none;display:flex;gap:8px;margin-left:auto} +.obs-reg-btns [hidden]{display:none} +.obs-unreg:hover:not(:disabled){color:var(--err);border-color:color-mix(in oklch,var(--err) 45%,var(--line-2))} +.obs-reg-card[data-state="current"] .obs-reg-status{color:var(--fg-3)} +#obs-reg-msg{font-size:11.5px} +.obs-steps{list-style:none;counter-reset:obs-step;margin:8px 0 0;padding:0;display:grid;gap:8px} +.obs-steps[hidden]{display:none} +.obs-steps li{counter-increment:obs-step;position:relative;padding-left:30px;min-height:20px;font-size:12.5px;line-height:20px;color:var(--fg-2)} +.obs-steps li::before{content:counter(obs-step);position:absolute;left:0;top:0;width:20px;height:20px;border-radius:999px; + display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:700;color:var(--fg-3); + background:var(--surface-3);border:1px solid var(--line);box-sizing:border-box} +.obs-reg-card[data-state="current"] .obs-steps li::before{color:var(--accent);border-color:color-mix(in oklch,var(--accent) 40%,var(--line))} +.obs-legacy>summary{flex-wrap:wrap} +#sys-eb-launch-note{font-weight:400;font-size:12px} .obs-manual-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:8px} @media (max-width:880px){.obs-manual-row{grid-template-columns:1fr}} .obs-manual-field{display:flex;flex-direction:column;gap:4px} @@ -1413,7 +1661,7 @@ .ic-badge-ok{background:rgba(90,200,250,.18);color:#5ac8fa;border:1px solid rgba(90,200,250,.4)} .copy-block{display:flex;align-items:center;gap:10px;padding:9px 12px;background:var(--surface); border:1px solid var(--line);border-radius:8px;margin-top:10px} -.copy-block code{flex:1;font-size:12.5px;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; +.copy-block code{flex:1;min-width:0;font-size:12.5px;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; font-family:'JetBrains Mono',monospace} .obs-recs{display:flex;flex-direction:column;gap:2px;margin-top:8px} .rec{display:grid;grid-template-columns:1fr auto 2fr;gap:16px;padding:10px 12px;border-radius:8px; @@ -1433,7 +1681,7 @@ /* - Overlay gallery --------------- */ .ovg-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:14px;margin-top:6px} -@media (max-width:980px){.ovg-grid{grid-template-columns:1fr}} +@media (max-width:980px){.ovg-grid{grid-template-columns:minmax(0,1fr)}} .ovg-stage{position:relative;width:100%;aspect-ratio:16/9;margin-top:10px;overflow:hidden; border:1px solid var(--line);border-radius:10px;background: linear-gradient(45deg,#0a0c10 25%,transparent 25%,transparent 75%,#0a0c10 75%), @@ -1695,7 +1943,8 @@ .profile-row.current .profile-bolt{color:var(--accent); border-color:color-mix(in oklch,var(--accent) 50%,transparent); background:color-mix(in oklch,var(--accent) 15%,var(--surface-3))} -.profile-name{flex:1;font-weight:500;color:var(--fg);font-size:13.5px} +.profile-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; + font-weight:500;color:var(--fg);font-size:13.5px} .profile-secs{color:var(--accent);font-size:13px;font-weight:500;font-family:'JetBrains Mono',monospace} /* Too-big = the current buffer can't hold this delay. Keep it quiet: muted label, not armable. A small inline "Raise buffer" link is the @@ -1710,6 +1959,12 @@ border-bottom:1px solid color-mix(in oklch,var(--accent) 32%,transparent); transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} .profile-warn a:hover{color:var(--fg);border-color:var(--fg)} +/* On a phone the buffer note takes its own line under the name; the + delete button stays on the first line. 40px = the bolt plus its gap. */ +@media (max-width:520px){ + .profile-row{flex-wrap:wrap;row-gap:6px} + .profile-warn{order:1;flex-basis:100%;margin-left:40px} +} .ic-input.flash{animation:inputflash 1.2s var(--ease-out)} @keyframes inputflash{ 0%{box-shadow:0 0 0 0 color-mix(in oklch,var(--accent) 55%,transparent);border-color:var(--accent)} @@ -1776,7 +2031,7 @@ .crash-count{position:absolute;right:11px;top:50%;transform:translateY(-50%);font-size:10.5px;color:var(--fg-4);pointer-events:none} .crash-count.full{color:var(--warn)} .crash-hold-value{font-size:12.5px;color:var(--accent);font-weight:600} -.crash-range{width:100%;accent-color:var(--accent);cursor:pointer} +.crash-range{width:100%} .crash-advanced>summary{cursor:pointer;color:var(--fg-3);font-size:12.5px} .crash-advanced>summary:hover{color:var(--fg)} .crash-advanced .crash-check{margin-top:10px} @@ -1837,7 +2092,7 @@ border:1px solid color-mix(in oklch,var(--warn) 30%,transparent); color:var(--warn);font-size:12.5px;display:flex;align-items:center;gap:10px;justify-content:space-between; animation:softDrop .25s var(--ease-out)} -.restart-banner.hidden{display:none} +.restart-banner[hidden]{display:none} /* Unsaved-changes cue on the Studio Save button. */ .st-save-dirty{box-shadow:0 0 0 2px color-mix(in oklch,var(--accent) 55%,transparent);font-weight:700} /* Brief "preview re-synced" pulse after a live edit. */ @@ -1845,47 +2100,158 @@ transition:opacity .2s ease,transform .2s ease;pointer-events:none;white-space:nowrap} .st-synced.on{opacity:.9;transform:none} +/* - Color picker ------------------- */ +/* The app's own picker for every (see ColorPick): + a field for shade, a hue bar, the hex value, recent and app colours. */ +.cp{position:fixed;z-index:1200;width:252px;padding:12px;border-radius:14px; + background:color-mix(in oklch,var(--surface-2) 94%,transparent);backdrop-filter:blur(18px) saturate(1.4); + border:1px solid var(--line-2);box-shadow:0 18px 50px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.35); + display:flex;flex-direction:column;gap:10px;color:var(--fg);font-size:12px; + animation:cpIn .2s var(--ease-out) both;transform-origin:var(--cp-origin,50% 0)} +.cp.closing{animation:cpOut .14s var(--ease-out) both;pointer-events:none} +@keyframes cpIn{from{opacity:0;transform:scale(.94) translateY(var(--cp-dy,-4px))}} +@keyframes cpOut{to{opacity:0;transform:scale(.96) translateY(var(--cp-dy,-4px))}} +.cp-sv{position:relative;height:150px;border-radius:9px;cursor:crosshair;touch-action:none;outline:none; + background:linear-gradient(to top,#000,transparent),linear-gradient(to right,#fff,hsl(var(--cp-h,200) 100% 50%)); + box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)} +.cp-sv:focus-visible{box-shadow:inset 0 0 0 1px rgba(255,255,255,.08),0 0 0 2px var(--accent)} +.cp-thumb{position:absolute;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;pointer-events:none; + background:var(--cp-c);border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5),inset 0 0 0 1px rgba(0,0,0,.15); + transition:scale .15s var(--ease-out)} +.cp-hue{position:relative;flex:1;height:12px;border-radius:999px;cursor:pointer;touch-action:none;outline:none; + background:linear-gradient(to right,#f00 0%,#ff0 16.66%,#0f0 33.33%,#0ff 50%,#00f 66.66%,#f0f 83.33%,#f00 100%); + box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)} +.cp-hue .cp-thumb{top:50%;background:hsl(var(--cp-h,200) 100% 50%)} +.cp-hue:focus-visible{box-shadow:0 0 0 2px var(--accent)} +.cp-sv.dragging .cp-thumb,.cp-hue.dragging .cp-thumb{scale:1.25} +.cp-row{display:flex;align-items:center;gap:8px} +.cp-now{flex:none;width:28px;height:28px;border-radius:50%;background:linear-gradient(90deg,var(--cp-was) 50%,var(--cp-c) 50%); + box-shadow:inset 0 0 0 1px rgba(255,255,255,.18)} +.cp-hex{flex:1;min-width:0;height:30px;padding:0 9px;font-size:12px;text-transform:uppercase} +.cp-icon{flex:none;width:30px;height:30px;display:grid;place-items:center;border-radius:8px;border:1px solid var(--line); + background:var(--surface-3);color:var(--fg-2);cursor:pointer;transition:color .15s var(--ease-out),transform .12s var(--ease-out)} +.cp-icon:hover{color:var(--fg)} +.cp-icon:active{transform:scale(.92)} +.cp-icon svg{width:15px;height:15px} +.cp-swatches{display:grid;grid-template-columns:repeat(8,1fr);gap:6px} +.cp-sw{aspect-ratio:1;border-radius:50%;border:0;padding:0;cursor:pointer;background:var(--sw); + box-shadow:inset 0 0 0 1px rgba(255,255,255,.16);transition:transform .15s var(--ease-spring)} +.cp-sw:hover{transform:scale(1.15)} +.cp-sw:active{transform:scale(.9)} +.cp-sw:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.cp-label{font-size:10.5px;color:var(--fg-4);letter-spacing:.04em;margin-bottom:-4px} +/* A phone gets it as a sheet along the bottom edge. */ +@media (max-width:520px){ + .cp{left:8px!important;right:8px;top:auto!important;bottom:calc(8px + env(safe-area-inset-bottom));width:auto; + --cp-origin:50% 100%;--cp-dy:12px} + .cp-sv{height:180px} +} +@media (prefers-reduced-motion:reduce){ + .cp,.cp.closing{animation-duration:.01s} +} /* - Logs ------------------- */ -.logs-wrap{position:relative;min-height:420px} -.logs{background:#050608;border:1px solid var(--line);border-radius:10px;padding:6px; - font-family:'JetBrains Mono',monospace;font-size:12px; - min-height:420px;max-height:420px;overflow-y:auto;color:var(--fg-2);line-height:1.55;margin:0} +/* A calm, scannable tail: each line is a time of day, a source tag in its + own colour, and the message tinted by how it went. On a phone the time + and tag sit above the message, which gets the full width. */ +.logs-wrap{position:relative} +.logs{--log-tw:8.5ch;background:#050608;border:1px solid var(--line);border-radius:12px;padding:6px; + font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--fg-2);line-height:1.55;margin:0; + height:clamp(420px,calc(100vh - 330px),900px);overflow-y:auto; + scrollbar-width:thin;scrollbar-color:var(--line-2) transparent} +/* Beside a phone's width the pane card is fitted to the window and scrolls + on its own. The Logs tab fills it instead: the console takes the room the + filters leave, so it is the one thing that scrolls. A fixed-height console + in a scrolling card meant two scrollbars, and the wheel over the console + never reached the card. */ +@media (min-width:561px){ + .pane-card:has(> .tab-pane[data-pane="logs"]:not([hidden])){height:var(--pane-max,70vh)} + .tab-pane[data-pane="logs"]{height:100%;min-height:0} + .tab-pane[data-pane="logs"] .logs-wrap{flex:1;min-height:160px} + /* "No events yet" fills the same room; its 420 px floor overflowed a + shorter card and gave it a scrollbar. */ + .tab-pane[data-pane="logs"] .logs, + .tab-pane[data-pane="logs"] .logs-empty{height:100%;min-height:0} +} .logs[hidden]{display:none} -/* One event per row: time, source, message; severity tints the row. */ -.log-row{display:grid;grid-template-columns:62px 118px minmax(0,1fr);gap:10px;align-items:baseline; - padding:3px 8px;border-radius:6px;border-left:2px solid transparent} +.log-row{position:relative;display:grid;grid-template-columns:var(--log-tw) 104px minmax(0,1fr);gap:12px;align-items:baseline; + padding:3px 10px 3px 14px;border-radius:7px;transition:background .15s var(--ease-out)} .log-row[hidden]{display:none} -.log-row:hover{background:rgba(255,255,255,.03)} -.log-t{color:var(--fg-4);text-align:right;font-variant-numeric:tabular-nums} -.log-src{justify-self:start;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; - font-size:10.5px;padding:1px 7px;border-radius:999px;background:var(--surface-3);color:var(--fg-3)} +.log-row:hover{background:rgba(255,255,255,.035)} +.log-row{cursor:copy} +.log-row.copied{animation:logCopied .9s var(--ease-out)} +.log-row.copied::after{content:"Copied";position:absolute;right:10px;top:3px;font:600 10.5px/1.55 'Inter',sans-serif; + color:var(--accent);animation:logCopiedTag .9s var(--ease-out) forwards} +@keyframes logCopied{from{background:color-mix(in oklch,var(--accent) 16%,transparent)}} +@keyframes logCopiedTag{0%{opacity:0;transform:translateY(2px)}15%,70%{opacity:1;transform:none}100%{opacity:0}} +/* How it went: a dot at the start of the line. */ +.log-row::before{content:"";position:absolute;left:5px;top:calc(.775em + 3px);width:4px;height:4px;margin-top:-2px; + border-radius:50%;background:transparent} +.log-row.lvl-err::before{background:var(--err);box-shadow:0 0 0 3px color-mix(in oklch,var(--err) 22%,transparent)} +.log-row.lvl-warn::before{background:var(--warn)} +.log-row.lvl-ok::before{background:var(--ok)} +.log-t{color:var(--fg-4);font-variant-numeric:tabular-nums;white-space:nowrap} +.log-src{--h:210;justify-self:start;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; + font-size:10.5px;padding:1px 7px;border-radius:6px; + color:oklch(80% .09 var(--h));background:oklch(80% .09 var(--h) / .1);transition:opacity .2s var(--ease-out)} +/* A run of lines from one source names it once. */ +.log-row.same-src .log-src{opacity:0} +.log-row.same-src:hover .log-src{opacity:.6} .log-msg{white-space:pre-wrap;overflow-wrap:anywhere} -.log-row.lvl-err{border-left-color:var(--err);background:color-mix(in oklch,var(--err) 7%,transparent)} +.log-msg mark{background:color-mix(in oklch,var(--accent) 30%,transparent);color:var(--fg);border-radius:3px;padding:0 1px} +.log-row.lvl-err{background:color-mix(in oklch,var(--err) 7%,transparent)} .log-row.lvl-err .log-msg{color:color-mix(in oklch,var(--err) 70%,var(--fg))} -.log-row.lvl-warn{border-left-color:var(--warn)} -.log-row.lvl-warn .log-msg{color:var(--warn)} +.log-row.lvl-warn .log-msg{color:color-mix(in oklch,var(--warn) 85%,var(--fg))} .log-row.lvl-ok .log-msg{color:color-mix(in oklch,var(--ok) 45%,var(--fg))} -.logs-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px} +/* Lines that arrive while you watch ease in; a problem flashes once. */ +/* Lines that come in together ease in one after another: --log-delay. */ +.log-row.log-new{animation:logIn .32s var(--ease-out) var(--log-delay,0ms) both} +.log-row.lvl-err{--flash:var(--err)} +.log-row.lvl-warn{--flash:var(--warn)} +.log-row.log-new.lvl-err,.log-row.log-new.lvl-warn{animation:logIn .32s var(--ease-out) var(--log-delay,0ms) both, + logFlash 1.6s var(--ease-out) var(--log-delay,0ms)} +@keyframes logIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}} +@keyframes logFlash{from{background:color-mix(in oklch,var(--flash) 24%,transparent)}} +.logs-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;animation:softDrop .25s var(--ease-out)} .logs-tools[hidden]{display:none} .logs-chips{display:inline-flex;gap:4px;padding:3px;border-radius:10px;background:var(--surface-2);border:1px solid var(--line)} .logs-chip{appearance:none;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:7px;padding:6px 11px; - background:transparent;color:var(--fg-3);font:inherit;font-size:12px;font-weight:600;cursor:pointer; - transition:background .15s var(--ease-out),color .15s var(--ease-out)} + background:transparent;color:var(--fg-3);font:inherit;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap; + transition:background .18s var(--ease-out),color .18s var(--ease-out),transform .12s var(--ease-out)} .logs-chip:hover{color:var(--fg)} -.logs-chip[aria-pressed="true"]{background:var(--surface-3);color:var(--fg)} +.logs-chip:active{transform:scale(.96)} +.logs-chip[aria-pressed="true"]{background:var(--surface-3);color:var(--fg);box-shadow:0 1px 2px rgba(0,0,0,.35)} .logs-n{font:600 10.5px/1 'JetBrains Mono',monospace;padding:2px 5px;border-radius:999px} .logs-n:empty{display:none} .logs-chip.lvl-warn .logs-n{background:color-mix(in oklch,var(--warn) 20%,transparent);color:var(--warn)} .logs-chip.lvl-err .logs-n{background:color-mix(in oklch,var(--err) 20%,transparent);color:var(--err)} .logs-source{width:auto;max-width:190px;height:36px;padding:0 10px} .logs-search{flex:1;min-width:160px;height:36px} -.logs-nomatch{position:absolute;inset:0;display:grid;place-items:center;color:var(--fg-3);font-size:13px;pointer-events:none} +.logs-copy{height:36px;padding:0 12px;font-size:12px;animation:softRise .2s var(--ease-out)} +.logs-copy[hidden]{display:none} +.logs-nomatch{position:absolute;inset:0;display:grid;place-items:center;color:var(--fg-3);font-size:13px;pointer-events:none; + animation:softRise .2s var(--ease-out)} .logs-nomatch[hidden]{display:none} -.logs-latest{position:absolute;right:14px;bottom:14px;appearance:none;border:1px solid var(--line-2);border-radius:999px; - padding:7px 13px;background:var(--surface-3);color:var(--fg);font:inherit;font-size:12px;font-weight:600;cursor:pointer; - box-shadow:0 8px 24px rgba(0,0,0,.45);animation:softRise .18s var(--ease-out);transition:transform .15s var(--ease-out)} +.logs-latest{position:absolute;left:50%;bottom:14px;translate:-50% 0;appearance:none;border:1px solid var(--line-2);border-radius:999px; + padding:7px 14px;background:color-mix(in oklch,var(--surface-3) 88%,transparent);backdrop-filter:blur(10px); + color:var(--fg);font:inherit;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap; + box-shadow:0 8px 24px rgba(0,0,0,.45);animation:softRise .2s var(--ease-out);transition:transform .15s var(--ease-out)} +.logs-latest:hover{border-color:color-mix(in oklch,var(--accent) 50%,var(--line-2))} .logs-latest:active{transform:scale(.96)} .logs-latest[hidden]{display:none} +@media (max-width:640px){ + .logs{height:calc(100dvh - 300px);min-height:320px;font-size:11.5px;padding:4px} + .log-row{grid-template-columns:auto minmax(0,1fr);gap:2px 8px;padding:6px 8px 6px 14px} + .log-row::before{top:calc(.775em + 6px)} + .log-msg{grid-column:1 / -1} + .log-row.same-src .log-src{opacity:.45} + .logs-chips{order:-1;flex:1 1 100%;overflow-x:auto;scrollbar-width:none} + .logs-chip{flex:1;justify-content:center} + .logs-source{flex:0 1 40%;max-width:none} + .logs-search{min-width:0} +} +@media (prefers-reduced-motion:reduce){ + .log-row.log-new,.log-row.log-new.lvl-err,.log-row.log-new.lvl-warn{animation:none} +} .logs-empty{background:var(--bg-2);border:1px dashed var(--line);border-radius:10px; min-height:420px;display:flex;flex-direction:column;align-items:center;justify-content:center; gap:8px;text-align:center;padding:24px;color:var(--fg-3)} @@ -2225,11 +2591,17 @@ .ig-seg{display:inline-flex;flex:0 0 auto;flex-wrap:wrap;max-width:100%} .ig-seg .sub-tab{flex:0 0 auto;padding:6px 13px;transition:color .2s var(--ease-out),transform .15s var(--ease-out)} .ig-seg .sub-tab:active{transform:scale(.96)} -.ig-seg .sub-tab-ind{bottom:auto;opacity:0;transition:left .28s var(--ease-spring),top .28s var(--ease-spring),width .28s var(--ease-spring),opacity .2s var(--ease-out)} +.ig-seg .sub-tab-ind{bottom:auto;opacity:0;transition:left .28s var(--ease-out),top .28s var(--ease-out),width .28s var(--ease-out),opacity .2s var(--ease-out)} .ig-views .sub-tab{padding:6px 9px} .ig-views svg{width:14px;height:14px;display:block} .ig-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap} .ig-toolbar .ig-search{margin-left:auto;width:220px;padding:7px 11px} +/* On a phone: the filters in one sideways row, then search beside the view + switch. */ +@media (max-width:520px){ + .ig-toolbar .ig-seg:not(.ig-views){flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none} + .ig-toolbar .ig-search{flex:1;width:auto;min-width:0;margin-left:0} +} /* Grid. */ .ig-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px} .ig-card{padding:8px;gap:8px;cursor:default} @@ -2284,6 +2656,8 @@ } /* First run. */ .ig-packs{width:min(900px,100%)} +/* A pack carries a list and its needs: on a phone it gets the full width. */ +@media (max-width:520px){.ig-packs{grid-template-columns:minmax(0,1fr)}} .ig-pack{gap:8px;padding:18px} .ig-pack .dest-starter-name{font-size:15px} .ig-pack .dest-starter-note{line-height:1.5;text-wrap:pretty} @@ -2305,13 +2679,33 @@ .ig-activity-box[open] summary svg{transform:rotate(180deg)} .ig-activity{display:flex;flex-direction:column;gap:2px;padding:0 12px 12px;font-size:12.5px} .ig-activity-box[open] .ig-activity{animation:igFadeUp .26s var(--ease-out)} -.ig-act-row{display:grid;grid-template-columns:62px minmax(0,200px) minmax(0,1fr) 64px;gap:10px;align-items:center; - padding:6px 4px;border-bottom:1px solid var(--line);background:var(--bg-2)} -.ig-act-row:last-child{border-bottom:0} +.ig-act-item{border-bottom:1px solid var(--line)} +.ig-act-item:last-child{border-bottom:0} +.ig-act-row{display:grid;grid-template-columns:62px minmax(0,200px) minmax(0,1fr) 64px 14px;gap:10px;align-items:center; + padding:6px 4px;background:var(--bg-2)} +.ig-act-row .c{width:12px;height:12px;color:var(--fg-4);transition:transform .25s var(--ease-out),color .15s var(--ease-out)} +.ig-act-row:hover .c{color:var(--fg-2)} +.ig-act-item.open .ig-act-row .c{transform:rotate(180deg)} +/* Open, a row says all of it and shows its steps. */ +.ig-act-item.open .ig-act-row .d{white-space:normal;overflow-wrap:anywhere} +.ig-act-more{padding:4px 4px 12px 76px;animation:igFadeUp .22s var(--ease-out)} +.ig-run-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px} +.ig-run-report{margin-left:auto;font-size:12px;color:var(--fg-3);text-decoration:underline;text-underline-offset:2px} +.ig-run-report:hover{color:var(--fg)} +@media (max-width:560px){ + /* Time, name and outcome on one line; what happened under it, whole. */ + .ig-act-row{grid-template-columns:auto minmax(0,1fr) auto 12px;grid-template-areas:"t n s c" "d d d d";row-gap:3px} + .ig-act-row .t{grid-area:t}.ig-act-row .n{grid-area:n}.ig-act-row .s{grid-area:s}.ig-act-row .c{grid-area:c} + .ig-act-row .d{grid-area:d;white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} + .ig-act-item.open .ig-act-row .d{display:block} + .ig-act-more{padding-left:4px} + .ig-run div{grid-template-columns:auto minmax(0,1fr);row-gap:2px} + .ig-run .d{grid-column:1/-1} +} .ig-activity .t{color:var(--fg-4);font-family:'JetBrains Mono',monospace;font-size:11.5px;font-variant-numeric:tabular-nums} -.ig-activity .n{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.ig-activity .d{color:var(--fg-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.ig-activity .s{text-align:right;font-size:11.5px} +.ig-act-row .n{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ig-act-row .d{color:var(--fg-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ig-act-row .s{text-align:right;font-size:11.5px} .ig-activity .ok{color:var(--live)}.ig-activity .failed{color:var(--danger)}.ig-activity .stopped,.ig-activity .busy{color:var(--fg-3)} .ig-act-empty{padding:6px 4px;color:var(--fg-3)} /* Modal shell. */ @@ -2334,6 +2728,36 @@ .ig-last{display:inline-flex;align-items:center;gap:6px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .ig-last i{width:6px;height:6px;flex:none;border-radius:50%;background:var(--fg-4)} .ig-last.ok i{background:var(--live)}.ig-last.bad i{background:var(--danger)} +/* The last run opens onto its steps, in a card over the footer. */ +.ig-modal .dest-form-foot{position:relative} +.ig-last-btn{padding:3px 7px;margin:-3px -7px;border:0;border-radius:7px;background:transparent;color:inherit;font:inherit;cursor:pointer; + transition:background-color .15s var(--ease-out),color .15s var(--ease-out)} +.ig-last-btn:hover,.ig-last-btn[aria-expanded="true"]{background:var(--surface-3);color:var(--fg)} +.ig-last-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.ig-last-btn svg{width:12px;height:12px;flex:none;transition:transform .25s var(--ease-out)} +.ig-last-btn[aria-expanded="true"] svg{transform:rotate(180deg)} +.ig-lastrun{position:absolute;left:12px;bottom:calc(100% + 8px);z-index:6;width:min(600px,calc(100% - 24px)); + max-height:min(52vh,380px);overflow-y:auto;overscroll-behavior:contain;padding:12px 14px;border-radius:12px; + background:var(--bg-2);border:1px solid var(--line-2);box-shadow:0 18px 40px -16px rgba(0,0,0,.7); + transform-origin:bottom left;animation:igLastRunIn .2s var(--ease-out)} +@keyframes igLastRunIn{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:none}} +.ig-lastrun-head{display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:13px} +.ig-lastrun-head .muted{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px} +.ig-lastrun .ig-run div:last-child{border-bottom:0} +/* A variable nothing fills: said calmly, with the fix one click away. */ +.ig-unknown{display:flex;flex-direction:column;gap:6px;padding:9px 11px;border-radius:9px;font-size:12.5px;line-height:1.5;color:var(--fg-2); + background:color-mix(in oklch,var(--warn) 7%,transparent);border:1px solid color-mix(in oklch,var(--warn) 22%,transparent); + animation:igFadeUp .22s var(--ease-out)} +.ig-unknown>div{display:flex;align-items:flex-start;gap:8px} +.ig-unknown svg{width:13px;height:13px;flex:none;margin-top:3px;color:var(--warn)} +.ig-unknown code{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--warn)} +.ig-fixvar{padding:0 6px;border:1px solid color-mix(in oklch,var(--accent) 40%,transparent);border-radius:6px; + background:color-mix(in oklch,var(--accent) 10%,transparent);color:var(--accent);font-family:'JetBrains Mono',monospace;font-size:11.5px; + cursor:pointer;transition:background-color .15s var(--ease-out)} +.ig-fixvar:hover{background:color-mix(in oklch,var(--accent) 20%,transparent)} +.ig-fixvar:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +@media (prefers-reduced-motion: reduce){.ig-lastrun,.ig-unknown,.ig-act-more{animation:none}.ig-last-btn svg,.ig-act-row .c{transition:none}} +.ig-discard{animation:igFadeUp .2s var(--ease-out)} .ig-unsaved{flex:none;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;color:var(--warn); background:color-mix(in oklch,var(--warn) 12%,transparent);border:1px solid color-mix(in oklch,var(--warn) 35%,transparent); animation:igPop .26s cubic-bezier(.2,0,0,1)} @@ -2411,16 +2835,212 @@ .ig-live-phone .ig-live-tag{color:rgba(255,255,255,.7);font-weight:500} .ig-live-phone .ic-input{background:rgba(255,255,255,.08)} .ig-live-plain{padding:14px 16px;display:flex;flex-direction:column;gap:10px;border-radius:12px} +/* An alert in the editor: the real alerts page on a short stage, then + what it says. */ +.ig-live-alert{padding:12px 14px 14px;display:flex;flex-direction:column;gap:10px;border:1px solid var(--line);background:var(--bg-1)} +.ig-live-row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px 12px} +.ig-live-alert .ig-live-tag{color:var(--fg-3)} +.ig-live-theme{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--fg-3)} +.ig-link{display:inline-flex;align-items:center;gap:4px;padding:0;border:0;background:none;color:var(--accent);font:inherit;font-size:12px;cursor:pointer} +.ig-link:hover{text-decoration:underline} +.ig-link .ig-btn-ic{width:12px;height:12px} +.ig-ed-stage{position:relative;height:190px;overflow:hidden;border-radius:10px; + background:radial-gradient(90% 140% at 30% 10%,#33415a 0%,#1a2130 55%,#0e1219 100%)} +.ig-alert-fields{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr);gap:10px} +@media (max-width:640px){.ig-alert-fields{grid-template-columns:1fr}} +.ig-alerts-status{display:flex;align-items:center;flex-wrap:wrap;gap:8px 10px;padding:9px 12px;border-radius:10px; + border:1px solid var(--line);background:var(--bg-1);font-size:12.5px} +.ig-alerts-line{flex:1;min-width:12em} +/* The real page, scaled into a box: the box sets the size, the frame + inside is laid out at the page's own width (see stageShow). Its + color-scheme matches the page, or Chrome paints an opaque backdrop. */ +.ig-stage{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:12px;border:1px solid var(--line); + background:radial-gradient(90% 120% at 30% 20%,#33415a 0%,#1a2130 50%,#0e1219 100%)} +.ig-stage-short{aspect-ratio:auto;height:180px} +.ig-stage-frame,.ig-theme-frame{position:absolute;left:0;top:0;border:0;transform-origin:0 0;pointer-events:none;background:transparent;color-scheme:normal} +/* Connections: one card each, opened in place. */ +.ig-cxs{display:flex;flex-direction:column;gap:8px} +.ig-cx{border:1px solid var(--line);border-radius:12px;background:var(--bg-1);overflow:hidden; + transition:border-color .2s var(--ease-out),background-color .2s var(--ease-out)} +.ig-cx.open{border-color:color-mix(in oklch,var(--accent) 35%,var(--line));background:var(--surface-2,var(--bg-1))} +.ig-cx-head{width:100%;display:flex;align-items:center;gap:12px;padding:12px 14px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer} +.ig-cx-head:hover{background:color-mix(in oklch,var(--fg) 3%,transparent)} +.ig-cx-head:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} +.ig-cx-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.ig-cx-id b{font-size:14px;font-weight:600} +.ig-cx-id span{font-size:12px;color:var(--fg-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-cx-chev{display:grid;color:var(--fg-3);transition:transform .25s var(--ease-out)} +.ig-cx-chev svg{width:16px;height:16px} +.ig-cx.open .ig-cx-chev{transform:rotate(180deg)} +.ig-cx-in{padding:0 14px 14px;display:flex;flex-direction:column;gap:10px} +.ig-cx-in .sys-section{margin:0;padding:0;border:0;background:transparent} +.ig-ic-phone{--dc:#f0a53a}.ig-ic-obs{--dc:#8b949e}.ig-ic-stream{--dc:#5ac8fa} +@media (max-width:560px){.ig-cx-id span{white-space:normal}} +.ig-look-sum{display:flex;align-items:center;flex-wrap:wrap;gap:8px 10px;padding:10px 12px;border-radius:10px;border:1px solid var(--line);font-size:13px} +.ig-look-dot{width:14px;height:14px;flex:none;border-radius:50%;background:var(--sw);box-shadow:0 0 0 3px color-mix(in oklch,var(--sw) 25%,transparent)} +.ig-look-sum-t{flex:1;min-width:12em} +/* The theme designer: the stage stays in view while the controls scroll. */ +.ig-look-body{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:20px;align-items:start} +.ig-look-stagecol{position:sticky;top:0;display:flex;flex-direction:column;gap:10px} +.ig-look-acts{display:flex;flex-wrap:wrap;gap:8px} +.ig-look-controls{display:flex;flex-direction:column;gap:18px;min-width:0} +.ig-look-sec{display:flex;flex-direction:column;gap:10px} +.ig-look-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px} +.ig-look-grid .ig-seg,.ig-look-sec>.dff .ig-seg{width:100%} +.ig-look-grid .sub-tab,.ig-look-sec>.dff .sub-tab{flex:1} +.ig-look-css{min-height:120px} +@media (max-width:860px){.ig-look-body{grid-template-columns:1fr}.ig-look-stagecol{position:static}} +.ig-themes{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:8px} +.ig-theme{position:relative;display:flex;flex-direction:column;gap:2px;padding:6px 6px 8px;border-radius:12px;border:1px solid var(--line); + background:var(--bg-1);color:inherit;font:inherit;text-align:left;cursor:pointer; + transition:border-color .2s var(--ease-out),transform .25s var(--ease-spring),box-shadow .25s var(--ease-out)} +.ig-theme:hover{border-color:var(--line-2);transform:translateY(-2px);box-shadow:0 10px 22px -14px rgba(0,0,0,.7)} +.ig-theme:active{transform:scale(.97);transition-duration:.08s} +.ig-theme.on{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in oklch,var(--accent) 30%,transparent)} +.ig-theme::after{content:"";position:absolute;top:12px;right:12px;width:18px;height:18px;border-radius:50%; + background:var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230b0d12' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E") center/11px no-repeat; + box-shadow:0 2px 8px rgba(0,0,0,.45);opacity:0;transform:scale(.4);transition:opacity .15s var(--ease-out),transform .3s var(--ease-spring)} +.ig-theme.on::after{opacity:1;transform:none} +.ig-theme-stage{position:relative;display:block;aspect-ratio:16/9;overflow:hidden;border-radius:8px;margin-bottom:4px; + background:radial-gradient(90% 140% at 30% 10%,#33415a 0%,#1a2130 55%,#0e1219 100%)} +.ig-theme-name{font-size:13px;font-weight:600;padding:0 2px} +.ig-theme-sub{font-size:11px;color:var(--fg-3);padding:0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-range{display:flex;align-items:center;gap:10px} +.ig-range input{flex:1;min-width:0} +.ig-range .mono{min-width:3.4em;text-align:right;font-size:12px;color:var(--fg-3)} +:is(.ig-modal input[type=range],.crash-range){-webkit-appearance:none;appearance:none;height:22px;margin:0;background:transparent;cursor:pointer;--p:50%} +:is(.ig-modal input[type=range],.crash-range)::-webkit-slider-runnable-track{height:6px;border-radius:99px; + background:linear-gradient(90deg,var(--accent) 0 var(--p),var(--surface-3) var(--p) 100%)} +:is(.ig-modal input[type=range],.crash-range)::-moz-range-track{height:6px;border-radius:99px;background:var(--surface-3)} +:is(.ig-modal input[type=range],.crash-range)::-moz-range-progress{height:6px;border-radius:99px;background:var(--accent)} +:is(.ig-modal input[type=range],.crash-range)::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;margin-top:-5px;border-radius:50%; + background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25),0 2px 6px rgba(0,0,0,.45); + transition:transform .2s var(--ease-spring),box-shadow .2s var(--ease-out)} +:is(.ig-modal input[type=range],.crash-range)::-moz-range-thumb{width:16px;height:16px;border:0;border-radius:50%;background:#fff; + box-shadow:0 0 0 1px rgba(0,0,0,.25),0 2px 6px rgba(0,0,0,.45);transition:transform .2s var(--ease-spring),box-shadow .2s var(--ease-out)} +:is(.ig-modal input[type=range],.crash-range):hover::-webkit-slider-thumb{transform:scale(1.12)} +:is(.ig-modal input[type=range],.crash-range):active::-webkit-slider-thumb{transform:scale(1.25);box-shadow:0 0 0 6px color-mix(in oklch,var(--accent) 25%,transparent),0 2px 6px rgba(0,0,0,.45)} +:is(.ig-modal input[type=range],.crash-range):hover::-moz-range-thumb{transform:scale(1.12)} +:is(.ig-modal input[type=range],.crash-range):active::-moz-range-thumb{transform:scale(1.25);box-shadow:0 0 0 6px color-mix(in oklch,var(--accent) 25%,transparent),0 2px 6px rgba(0,0,0,.45)} +:is(.ig-modal input[type=range],.crash-range):focus-visible{outline:0} +:is(.ig-modal input[type=range],.crash-range):focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 55%,transparent)} +/* Colors: a round swatch that opens the picker, its value, and a way back. */ +.ig-colors{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} +.ig-colorf{display:flex;align-items:center;gap:10px;min-width:0;padding:7px 8px 7px 7px;border-radius:12px;border:1px solid var(--line); + background:var(--bg-1);transition:border-color .2s var(--ease-out),background-color .2s var(--ease-out)} +.ig-colorf:hover{border-color:var(--line-2)} +.ig-colorf.set{border-color:color-mix(in oklch,var(--sw) 45%,var(--line))} +.ig-colorf .ig-swatch{position:relative;flex:none;width:30px;height:30px;margin:0;border:0;border-radius:50%;cursor:pointer;background:var(--sw); + box-shadow:inset 0 0 0 1px rgba(255,255,255,.18),0 0 0 3px color-mix(in oklch,var(--sw) 22%,transparent); + transition:transform .25s var(--ease-spring),background-color .2s var(--ease-out),box-shadow .2s var(--ease-out)} +.ig-colorf .ig-swatch:hover{transform:scale(1.08)} +.ig-colorf .ig-swatch:active{transform:scale(.94)} +.ig-colorf .ig-swatch:focus-within{box-shadow:inset 0 0 0 1px rgba(255,255,255,.18),0 0 0 3px color-mix(in oklch,var(--accent) 60%,transparent)} +.ig-colorf .ig-swatch input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;border:0;padding:0} +.ig-colorf-t{flex:1;min-width:0;display:flex;flex-direction:column;line-height:1.25} +.ig-colorf-t b{font-size:12.5px;font-weight:600;color:var(--fg)} +.ig-colorf-t .mono{font-size:11px;color:var(--fg-3);text-transform:lowercase} +.ig-colorf .ig-reset{display:grid;place-items:center;width:24px;height:24px;padding:0;animation:igPopIn .3s var(--ease-spring)} +.ig-colorf .ig-reset svg{width:12px;height:12px} +@keyframes igPopIn{from{opacity:0;transform:scale(.5)}} +/* Where alerts sit: a little screen with nine spots; the card slides. */ +.ig-place-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.ig-place{position:relative;flex:none;width:208px;aspect-ratio:16/9;display:grid;grid-template:repeat(3,1fr)/repeat(3,1fr); + border-radius:10px;border:1px solid var(--line);overflow:hidden; + background:radial-gradient(90% 120% at 30% 20%,#33415a 0%,#1a2130 50%,#0e1219 100%)} +.ig-spot{position:relative;z-index:1;display:grid;place-items:center;padding:0;border:0;background:none;cursor:pointer} +.ig-spot::before{content:"";width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,.28); + transition:transform .25s var(--ease-spring),background-color .2s var(--ease-out),opacity .2s var(--ease-out)} +.ig-spot:hover::before{transform:scale(1.9);background:rgba(255,255,255,.75)} +.ig-spot.on::before{opacity:0} +.ig-spot:focus-visible{outline:2px solid var(--accent);outline-offset:-4px;border-radius:6px} +.ig-place-card{--ax:.5;--ay:1;position:absolute;left:calc(7% + 86%*var(--ax));top:calc(9% + 82%*var(--ay)); + translate:calc(-100%*var(--ax)) calc(-100%*var(--ay));width:36%;height:22%;display:flex;flex-direction:column;justify-content:center;gap:3px; + padding:0 8px;box-sizing:border-box;border-radius:5px;background:rgba(10,12,16,.85);pointer-events:none; + box-shadow:inset 3px 0 0 var(--sw),0 4px 12px rgba(0,0,0,.5); + transition:left .45s var(--ease-spring),top .45s var(--ease-spring),translate .45s var(--ease-spring)} +.ig-place-card i{display:block;height:3px;border-radius:2px;background:rgba(255,255,255,.75)} +.ig-place-card i:first-child{width:45%;background:var(--sw)} +.ig-place-card i:last-child{width:80%} +.ig-place[data-x=left] .ig-place-card{--ax:0}.ig-place[data-x=right] .ig-place-card{--ax:1} +.ig-place[data-y=top] .ig-place-card{--ay:0}.ig-place[data-y=middle] .ig-place-card{--ay:.5} +.ig-place-side{flex:1;min-width:180px;display:flex;flex-direction:column;gap:10px} +.ig-place-now{display:flex;align-items:baseline;gap:6px;font-size:13px} +.ig-place-now b{font-weight:600} +/* Fonts: each tile written in its own font. */ +.ig-fonts{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px} +.ig-font{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0;padding:8px 4px 7px;border-radius:10px;border:1px solid var(--line); + background:var(--bg-1);color:var(--fg-2);font:inherit;cursor:pointer; + transition:border-color .2s var(--ease-out),color .2s var(--ease-out),transform .25s var(--ease-spring),background-color .2s var(--ease-out)} +.ig-font:hover{border-color:var(--line-2);color:var(--fg);transform:translateY(-1px)} +.ig-font:active{transform:scale(.95);transition-duration:.08s} +.ig-font.on{color:var(--fg);border-color:var(--accent);background:color-mix(in oklch,var(--accent) 10%,var(--bg-1))} +.ig-font-aa{font-size:22px;line-height:1.1;font-weight:600;color:var(--fg);transition:transform .3s var(--ease-spring)} +.ig-font:hover .ig-font-aa{transform:scale(1.1)} +.ig-font-n{max-width:100%;font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* The save line: a dot that breathes while saving, then settles. */ +.ig-saved{display:flex;align-items:center;gap:8px} +.ig-saved-dot{width:7px;height:7px;flex:none;border-radius:50%;background:var(--live,#3fcf8e); + box-shadow:0 0 0 3px color-mix(in oklch,var(--live,#3fcf8e) 22%,transparent);transition:background-color .2s var(--ease-out)} +.ig-saved.busy .ig-saved-dot{background:var(--accent);animation:igBreathe .8s ease-in-out infinite alternate} +@keyframes igBreathe{from{transform:scale(.7);opacity:.6}to{transform:scale(1.15);opacity:1}} +/* The designer's sections come in one after another. */ +.ig-look-controls>*{animation:igFadeUp .35s var(--ease-out) both} +.ig-look-controls>:nth-child(2){animation-delay:.04s}.ig-look-controls>:nth-child(3){animation-delay:.08s} +.ig-look-controls>:nth-child(4){animation-delay:.12s}.ig-look-controls>:nth-child(5){animation-delay:.16s} +.ig-look-controls>:nth-child(n+6){animation-delay:.2s} +.ig-reset{padding:0 6px;border-radius:999px;border:1px solid var(--line);background:transparent;color:var(--fg-3);font:inherit;font-size:11px;cursor:pointer} +.ig-reset:hover{color:var(--fg);border-color:var(--line-2)} +.ig-sounds,.ig-pics{display:flex;flex-wrap:wrap;gap:6px} +.ig-sound{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:999px;border:1px solid var(--line); + background:transparent;color:var(--fg-2);font:inherit;font-size:12.5px;cursor:pointer; + transition:border-color .15s var(--ease-out),color .15s var(--ease-out),background-color .15s var(--ease-out),transform .2s var(--ease-spring)} +.ig-sound:hover{color:var(--fg);border-color:var(--line-2)} +.ig-sound.on{color:var(--fg);border-color:color-mix(in oklch,var(--accent) 60%,transparent);background:color-mix(in oklch,var(--accent) 14%,transparent)} +.ig-sound .ig-btn-ic{width:11px;height:11px} +.ig-sound:active{transform:scale(.95)} +.ig-sound.ig-playing .ig-btn-ic{animation:igPlaying .3s ease-in-out 4 alternate} +.ig-sound.ig-playing{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 18%,transparent)} +@keyframes igPlaying{to{transform:scale(1.35)}} +.ig-upload{border-style:dashed} +.ig-media-item{position:relative;display:inline-flex} +.ig-media-x{position:absolute;top:-6px;right:-6px;width:18px;height:18px;display:grid;place-items:center;padding:0;border-radius:50%; + border:1px solid var(--line-2);background:var(--bg-2);color:var(--fg-3);cursor:pointer;opacity:0;transition:opacity .15s var(--ease-out)} +.ig-media-x svg{width:10px;height:10px} +.ig-media-item:hover .ig-media-x,.ig-media-x:focus-visible,.ig-media-x.ig-armed{opacity:1} +.ig-media-x.ig-armed{width:auto;padding:0 6px;border-radius:999px;color:var(--danger);font-size:11px} +.ig-pic{width:64px;height:64px;display:grid;place-items:center;gap:2px;padding:0;overflow:hidden;border-radius:10px;border:1px solid var(--line); + background:var(--bg-1);color:var(--fg-3);font:inherit;font-size:11.5px;cursor:pointer} +.ig-pic img{width:100%;height:100%;object-fit:cover} +.ig-pic svg{width:16px;height:16px} +.ig-pic.on{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in oklch,var(--accent) 30%,transparent)} +.ig-volume{display:flex;align-items:center;flex-wrap:wrap;gap:10px;font-size:12.5px} +.ig-volume input[type=range]{flex:1;min-width:120px} +.ig-volume .mono{min-width:3.2em;color:var(--fg-3)} +.ig-recipe-alerts{display:flex;flex-direction:column;gap:10px} +.ig-mhint{margin-top:8px;font-size:12px;color:var(--fg-3)} .ig-token{display:inline-flex;align-items:baseline;gap:4px;padding:2px 9px;border-radius:999px;cursor:pointer; border:1px solid var(--line);background:transparent;color:var(--fg-2);font:500 12px/1.6 inherit; transition:background-color .15s var(--ease-out),border-color .15s var(--ease-out),color .15s var(--ease-out),transform .15s var(--ease-out)} .ig-token:hover,.ig-token:focus-visible{border-color:color-mix(in oklch,var(--accent) 50%,transparent);color:color-mix(in oklch,var(--accent) 75%,#fff); background:color-mix(in oklch,var(--accent) 10%,transparent)} .ig-token code{font:500 10.5px/1.6 'JetBrains Mono',monospace;color:var(--fg-4)} -.ig-tokens-group{color:var(--fg-4);font-size:11px;margin-left:4px} .ig-token:active{transform:scale(.96)} .ig-token.more{border-style:dashed;border-color:var(--line-2);color:var(--fg-3);font-family:inherit} .ig-tokens{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11.5px;color:var(--fg-3)} +.ig-vals{display:flex;flex-direction:column;gap:12px} +.ig-vals-h{font-size:11.5px;color:var(--fg-3)} +.ig-vgroup{display:flex;flex-direction:column;gap:6px;min-width:0} +.ig-vgroup-h,.ig-vfold>summary{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--fg-4);min-width:0} +.ig-vgroup-h code{font:500 10.5px/1.6 'JetBrains Mono',monospace;color:var(--fg-2);background:var(--surface-3); + padding:0 6px;border-radius:4px;flex:none} +.ig-vfold>summary{cursor:pointer;list-style:none;border-radius:6px;padding:2px 0;transition:color .15s var(--ease-out)} +.ig-vfold>summary::-webkit-details-marker{display:none} +.ig-vfold>summary:hover,.ig-vfold[open]>summary{color:var(--fg-2)} +.ig-vfold>summary svg{width:11px;height:11px;flex:none;transform:rotate(-90deg);transition:transform .2s var(--ease-out)} +.ig-vfold[open]>summary svg{transform:none} +.ig-vfold[open]>summary{margin-bottom:6px} +.ig-vcount{font:500 10.5px/1.6 'JetBrains Mono',monospace;color:var(--fg-4)} .ig-msg{width:100%;box-sizing:border-box;resize:none;overflow:hidden;min-height:48px;padding:8px 10px;margin:0; border-radius:8px;border:1px dashed color-mix(in oklch,var(--accent) 55%,transparent); background:color-mix(in oklch,var(--accent) 5%,transparent);color:inherit;font:inherit;line-height:1.5;outline:none; @@ -2535,7 +3155,10 @@ .ig-cat-id .dcard-icon svg{width:14px;height:14px} .ig-cat-desc{margin-top:2px;font-size:12px;line-height:1.45;color:var(--fg-3);text-wrap:pretty} .ig-cat-tile .ig-cat-desc{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden} -.ig-cat-needs{margin-top:6px;display:flex;flex-wrap:wrap;gap:4px} +/* "Needs" stays beside its pills, and a pill that wraps lines up under + the first one rather than under the word. */ +.ig-cat-needs{margin-top:6px;display:flex;align-items:baseline;gap:6px} +.ig-cat-needs-list{display:flex;flex-wrap:wrap;gap:4px;min-width:0} .ig-need{display:inline-flex;align-items:center;gap:4px;padding:1px 7px;border-radius:999px;font-size:11px; border:1px solid var(--line);color:var(--fg-3)} .ig-need.ok{color:var(--live);border-color:color-mix(in oklch,var(--live) 35%,transparent)} @@ -2562,8 +3185,10 @@ .ig-empty-steps svg{width:16px;height:16px;flex:none;margin-top:2px;color:var(--accent)} .ig-empty-steps b{color:var(--fg)} .ig-exp-note{margin-top:6px;font-size:12.5px;line-height:1.5;color:var(--fg-3)} -.ig-exp-note a{color:var(--warn);font-weight:600;text-decoration:underline;text-underline-offset:2px} -.ig-exp{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;font-size:10.5px;font-weight:600; +.ig-exp-note a{color:var(--warn);border-bottom-color:color-mix(in oklch,var(--warn) 45%,transparent)} +/* The amber "Experimental" pill, the same wherever it sits: in a + heading, an uppercase section label or a card title. */ +.ig-exp,.tag{display:inline-block;margin-left:6px;text-transform:none;padding:1px 7px;border-radius:999px;font-size:10.5px;font-weight:600; letter-spacing:.02em;vertical-align:2px;color:var(--warn); background:color-mix(in oklch,var(--warn) 12%,transparent);border:1px solid color-mix(in oklch,var(--warn) 35%,transparent)} /* Add: the tile's colour, tinted, so a grid of them reads as cards and @@ -2586,16 +3211,59 @@ } /* Builder. */ .ig-bld{flex:1;min-height:0;display:flex} -.ig-palette{width:224px;flex:none;padding:12px;display:flex;flex-direction:column;gap:2px;border-right:1px solid var(--line);overflow-y:auto} -.ig-palette-hint{padding:2px 4px 4px;line-height:1.45} -.ig-palette .ic-label{margin:10px 4px 3px} -.ig-palette button{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;border:1px solid transparent; - background:transparent;color:var(--fg-2);font:inherit;font-size:12.5px;text-align:left;cursor:pointer; +/* While a dialog is open the page under it stays put, and each pane + keeps its own scrolling. */ +html:has(.ic-modal:not([hidden])){overflow:hidden;scrollbar-gutter:stable} +.ig-palette,.ig-canvas,.ig-inspector{overscroll-behavior:contain} +.ig-palette{width:236px;flex:none;padding:10px 12px 14px;display:flex;flex-direction:column;gap:1px;border-right:1px solid var(--line);overflow-y:auto} +.ig-palette .ic-label{margin:12px 6px 4px} +.ig-palette .ic-label:first-child{margin-top:4px} +.ig-palette button{display:flex;align-items:center;gap:10px;padding:5px 8px 5px 5px;border-radius:9px;border:1px solid transparent; + background:transparent;color:var(--fg-2);font:inherit;font-size:13px;text-align:left;cursor:pointer; transition:background-color .15s var(--ease-out),color .15s var(--ease-out),transform .15s var(--ease-out)} .ig-palette button:hover{background:var(--surface-3);color:var(--fg)} -.ig-palette button:active{transform:scale(.96)} -.ig-palette button i{width:8px;height:8px;border-radius:3px;flex:none;background:var(--c)} -.ig-canvas{flex:1;min-width:0;padding:16px 20px;overflow-y:auto;background:var(--bg);container-type:inline-size} +.ig-palette button:active{transform:scale(.97)} +/* Dragging a block or a step. */ +.ig-palette button:not(.ig-pal-locked),.ig-step-main{cursor:grab} +html.ig-dragging-on,html.ig-dragging-on *{cursor:grabbing !important;user-select:none} +.ig-ghost{position:fixed;left:0;top:0;z-index:3000;margin:0;pointer-events:none;will-change:transform} +.ig-ghost-in{display:flex;align-items:center;gap:9px;max-width:280px;padding:5px 12px 5px 5px;border-radius:12px; + background:color-mix(in oklch,var(--bg-2) 94%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px); + box-shadow:0 18px 36px -14px rgba(0,0,0,.8),0 0 0 1px color-mix(in oklch,var(--c) 45%,transparent); + transform:rotate(-2.5deg);transform-origin:18px 50%;animation:igGhostIn .24s cubic-bezier(.2,.9,.3,1.25) both; + transition:opacity .12s var(--ease-out),box-shadow .12s var(--ease-out)} +@keyframes igGhostIn{from{opacity:0;transform:rotate(0) scale(.82)}} +.ig-ghost.no .ig-ghost-in{opacity:.55;box-shadow:0 18px 36px -14px rgba(0,0,0,.8),0 0 0 1px color-mix(in oklch,var(--warn) 60%,transparent)} +.ig-ghost-t{display:flex;flex-direction:column;min-width:0;line-height:1.25} +.ig-ghost-t small{font-size:10.5px;font-weight:600;color:color-mix(in oklch,var(--c) 60%,var(--fg-3))} +.ig-ghost-t b{font-size:12.5px;font-weight:500;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-ghost-n{flex:none;padding:1px 7px;border-radius:999px;background:var(--surface-3);color:var(--fg-2);font-size:11px;font-weight:600} +/* The socket a dragged block would drop into, and the step in hand. */ +.ig-in-hand{display:none !important} +.ig-slot{flex:none;height:var(--h,56px);border-radius:14px;border:1.5px dashed color-mix(in oklch,var(--accent) 65%,transparent); + background:color-mix(in oklch,var(--accent) 6%,transparent);box-shadow:inset 0 0 22px -8px color-mix(in oklch,var(--accent) 50%,transparent); + animation:igSlotOpen .22s cubic-bezier(.2,.9,.3,1.1) both} +.ig-slot.still{animation:none} +@keyframes igSlotOpen{from{height:0;opacity:0;margin-top:-10px}} +/* Discard's badge while the blocks go back. */ +.ig-rewind-badge{position:fixed;z-index:1600;display:flex;align-items:center;gap:8px;padding:7px 14px 7px 10px;border-radius:999px;pointer-events:none; + background:color-mix(in oklch,var(--bg-2) 88%,transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px); + box-shadow:0 16px 40px -14px rgba(0,0,0,.8),0 0 0 1px color-mix(in oklch,var(--accent) 40%,transparent); + color:var(--fg);font-size:12.5px;font-weight:600;transform:translateX(-50%);animation:igBadgeIn .3s cubic-bezier(.2,.9,.3,1.2) both} +.ig-rewind-badge svg{width:15px;height:15px;color:var(--accent);animation:igRewindSpin .9s cubic-bezier(.4,0,.2,1)} +.ig-rewind-badge.fwd svg{animation-direction:reverse} +@keyframes igBadgeIn{from{opacity:0;transform:translate(-50%,-8px) scale(.9)}} +@keyframes igRewindSpin{to{transform:rotate(-360deg)}} +@media (prefers-reduced-motion: reduce){.ig-ghost-in{animation:none;transform:none;transition:none}.ig-slot,.ig-rewind-badge,.ig-rewind-badge svg{animation:none}} +.ig-pal-ic,.ig-step-ic,.ig-insp-ic{flex:none;display:grid;place-items:center;color:var(--c); + background:color-mix(in oklch,var(--c) 15%,transparent);box-shadow:inset 0 0 0 1px color-mix(in oklch,var(--c) 22%,transparent)} +.ig-pal-ic{width:26px;height:26px;border-radius:8px} +.ig-pal-ic svg{width:14px;height:14px} +/* The canvas: a quiet dot grid, the steps in a column that stays readable + on a wide screen. */ +.ig-canvas{flex:1;min-width:0;padding:20px 28px 28px;overflow-y:auto;container-type:inline-size; + background:radial-gradient(color-mix(in oklch,var(--fg) 7%,transparent) 1px,transparent 1.2px) 0 0/20px 20px,var(--bg)} +.ig-canvas>*{max-width:860px;margin-inline:auto} .ig-triggers{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px} .ig-trig{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--line); background:transparent;color:var(--fg-3);font:inherit;font-size:12.5px;font-weight:500;cursor:pointer; @@ -2605,10 +3273,11 @@ .ig-trig.on{background:var(--surface-3);color:var(--fg);border-color:color-mix(in oklch,var(--accent) 45%,var(--line-2))} .ig-trig.add{border-style:dashed} .ig-trig svg{width:12px;height:12px} -.ig-steps,.ig-branch-list{display:flex;flex-direction:column;gap:8px} -/* The spine that ties the steps together, showing between them. */ -.ig-steps::before{content:'';position:absolute;left:24px;top:18px;bottom:18px;width:2px;border-radius:2px;background:var(--line)} -.ig-step{position:relative;z-index:1;display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border-radius:12px; +.ig-steps,.ig-branch-list{display:flex;flex-direction:column;gap:10px} +.ig-steps{position:relative} +/* The spine that ties the steps together, through their icons. */ +.ig-steps::before{content:'';position:absolute;left:28px;top:28px;bottom:28px;width:2px;border-radius:2px;background:var(--line)} +.ig-step{position:relative;z-index:1;display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border-radius:14px; border:1px solid var(--line);background:var(--bg-2);color:inherit;font:inherit;text-align:left;cursor:pointer; transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out),box-shadow .2s var(--ease-out)} .ig-step:hover{border-color:var(--line-2)} @@ -2619,8 +3288,12 @@ background:color-mix(in oklch,var(--accent) 7%,var(--bg-2));box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 16%,transparent)} .ig-step.ig-when{--c:#5ac8fa} .ig-step.ig-when:not(.on){border-color:color-mix(in oklch,var(--accent) 35%,var(--line))} -.ig-step-kind{flex:0 0 62px;font-size:9.5px;font-weight:700;letter-spacing:.1em;color:var(--c)} -.ig-step-text{flex:1;min-width:0;font-size:13px;line-height:1.5;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-step-ic{width:34px;height:34px;border-radius:10px} +.ig-step-ic svg{width:16px;height:16px} +.ig-step-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.ig-step-kind{font-size:11px;font-weight:600;line-height:1.3;color:color-mix(in oklch,var(--c) 55%,var(--fg-3)); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-step-text{min-width:0;font-size:13.5px;line-height:1.45;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ig-step-text .v{font-family:'JetBrains Mono',monospace;font-size:12px;color:color-mix(in oklch,var(--accent) 70%,#fff)} .ig-step-tools{display:flex;gap:2px;opacity:0;transition:opacity .15s var(--ease-out)} .ig-step:hover .ig-step-tools,.ig-step.on .ig-step-tools,.ig-step:focus-within .ig-step-tools{opacity:1} @@ -2631,21 +3304,50 @@ them, on a row of their own. */ @container (max-width:380px){ .ig-step{flex-wrap:wrap;row-gap:6px} - .ig-step-kind{flex-basis:48px} .ig-step-tools{display:none} .ig-step.on .ig-step-tools{display:flex;flex-basis:100%;justify-content:flex-end} } -.ig-branch{position:relative;z-index:1;margin-left:30px;padding-left:14px;border-left:2px solid var(--line); - display:flex;flex-direction:column;gap:8px} -.ig-branch-label{font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--fg-3);text-transform:uppercase} -.ig-branch-label.else{color:var(--warn)} +/* An If's two branches: lanes side by side when there is room for both, + one over the other when not (an If inside an If, a narrow window). */ +.ig-branch{position:relative;z-index:1;margin-left:28px;container-type:inline-size} +.ig-lanes{display:grid;gap:12px} +@container (min-width:640px){.ig-lanes{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px}} +.ig-lane{display:flex;flex-direction:column;gap:8px;min-width:0;padding:8px 0 2px 14px;border-left:2px solid var(--lane);container-type:inline-size} +/* A step in a lane: what it still misses goes on its own line. */ +@container (max-width:520px){ + .ig-lane .ig-step{flex-wrap:wrap;row-gap:4px} + .ig-lane .ig-step-warn{order:3;flex:0 0 100%;max-width:none;padding-left:46px;white-space:normal} +} +.ig-lane.yes{--lane:color-mix(in oklch,var(--live) 50%,transparent)} +.ig-lane.no{--lane:color-mix(in oklch,var(--warn) 50%,transparent)} +.ig-branch-label{display:inline-flex;align-items:center;gap:5px;align-self:flex-start;padding:2px 9px 2px 7px;border-radius:999px; + font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase; + color:var(--live);background:color-mix(in oklch,var(--live) 13%,transparent)} +.ig-branch-label.else{color:var(--warn);background:color-mix(in oklch,var(--warn) 13%,transparent)} +.ig-branch-label svg{width:11px;height:11px} +/* An If's checks in the inspector. */ +.ig-conds{display:flex;flex-direction:column} +.ig-cond{display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid var(--line);border-radius:12px;background:var(--bg-2)} +.ig-cond-top{display:flex;align-items:center;gap:6px} +.ig-cond-top .ic-input{flex:1;min-width:0} +.ig-cond-top .ic-btn-tiny svg{width:13px;height:13px} +.ig-cond-cmp{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px} +.ig-cond-cmp.alone{grid-template-columns:minmax(0,1fr)} +.ig-cond-quick{display:flex;gap:6px} +.ig-cond-join{align-self:center;margin:6px 0;padding:1px 10px;border-radius:999px;font-size:10.5px;font-weight:700;letter-spacing:.08em; + text-transform:uppercase;color:var(--fg-3);background:var(--surface-3)} +.ig-cond-add{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;margin-top:8px;padding:5px 10px 5px 8px;border-radius:8px; + border:1px dashed var(--line-2);background:none;color:var(--fg-3);font:inherit;font-size:12.5px;cursor:pointer; + transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} +.ig-cond-add:hover{color:var(--fg);border-color:var(--fg-4)} +.ig-cond-add svg{width:12px;height:12px} .ig-drop{position:relative;z-index:1;min-height:34px;border-radius:10px;border:1px dashed var(--line-2);background:var(--bg); color:var(--fg-4);font:inherit;font-size:12px;cursor:pointer; transition:border-color .2s var(--ease-out),color .2s var(--ease-out),background-color .2s var(--ease-out)} .ig-drop:hover{color:var(--fg-2);border-color:var(--fg-4)} .ig-drop.on{border-color:var(--accent);color:var(--accent);background:color-mix(in oklch,var(--accent) 6%,var(--bg))} .ig-run-box{margin-top:16px;display:flex;flex-direction:column;gap:8px;animation:igFadeUp .26s var(--ease-out)} -.ig-inspector{width:320px;flex:none;padding:16px 18px;border-left:1px solid var(--line);overflow-y:auto} +.ig-inspector{width:clamp(340px,27vw,440px);flex:none;padding:18px 22px;border-left:1px solid var(--line);overflow-y:auto} /* A converted bot command. */ .ig-converted{display:flex;align-items:flex-start;gap:8px;padding:9px 11px;border-radius:9px;font-size:12.5px;line-height:1.5;color:var(--fg-2); background:color-mix(in oklch,var(--live) 9%,transparent);border:1px solid color-mix(in oklch,var(--live) 32%,transparent); @@ -2673,11 +3375,11 @@ @keyframes igDrain{from{transform:scaleX(1)}to{transform:scaleX(0)}} .ic-btn.ig-saved{gap:6px} .ic-btn.ig-saved svg{width:14px;height:14px;animation:igPop .3s cubic-bezier(.2,0,0,1)} -.ig-menu button{animation:igFadeUp .2s var(--ease-out) both;animation-delay:calc(var(--i,0) * 22ms)} +.ig-menu [role="menuitem"]{animation:igFadeUp .2s var(--ease-out) both;animation-delay:calc(var(--i,0) * 22ms)} .ig-run>div{animation:igFadeUp .24s var(--ease-out) both;animation-delay:calc(min(var(--i,0),12) * 40ms)} .ig-jtree>*{animation:igFadeUp .24s var(--ease-out) both;animation-delay:calc(min(var(--i,0),16) * 16ms)} @media (prefers-reduced-motion: reduce){ - .ig-menu button,.ig-run>div,.ig-jtree>*,.ic-btn.ig-saved svg{animation:none} + .ig-menu [role="menuitem"],.ig-run>div,.ig-jtree>*,.ic-btn.ig-saved svg{animation:none} .ig-undo-bar{animation:none;display:none} .ig-eye svg{transition:none} } @@ -2742,21 +3444,10 @@ .ig-ev-on svg{width:14px;height:14px;animation:igPop .25s cubic-bezier(.2,0,0,1)} .ig-evchange{flex:none;font-size:12px;font-weight:600;color:var(--accent)} @media (prefers-reduced-motion: reduce){.ig-kind,.ig-evs.big .ig-ev,.ig-ev-on svg{animation:none}} -/* Trigger kinds as tiles: every way to start is in sight. */ -.ig-ttypes{display:grid;grid-template-columns:1fr 1fr;gap:6px} -.ig-ttype{display:flex;flex-direction:column;align-items:flex-start;gap:3px;min-width:0;padding:9px 10px;border-radius:10px; - border:1px solid var(--line);background:var(--bg-2);color:var(--fg-2);font:inherit;text-align:left;cursor:pointer; - transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out),color .15s var(--ease-out),transform .15s var(--ease-out)} -.ig-ttype:last-child:nth-child(odd){grid-column:1/-1} -.ig-ttype:hover{border-color:var(--line-2);color:var(--fg)} -.ig-ttype:active{transform:scale(.96)} -.ig-ttype:focus-visible{outline:2px solid var(--accent);outline-offset:2px} -.ig-ttype svg{width:15px;height:15px;color:var(--fg-3);transition:color .15s var(--ease-out)} -.ig-ttype b{font-size:12.5px;font-weight:600;color:var(--fg)} -.ig-ttype small{font-size:11px;line-height:1.35;color:var(--fg-3);text-wrap:pretty} -.ig-ttype.on{border-color:color-mix(in oklch,var(--accent) 60%,transparent);background:color-mix(in oklch,var(--accent) 10%,var(--bg-2))} -.ig-ttype.on svg{color:var(--accent)} .ig-insp{display:flex;flex-direction:column;gap:12px;min-height:100%} +.ig-insp-title{display:flex;align-items:center;gap:12px} +.ig-insp-ic{width:38px;height:38px;border-radius:11px} +.ig-insp-ic svg{width:18px;height:18px} .ig-insp-title small{display:block;font-size:9.5px;font-weight:700;letter-spacing:.1em;color:var(--c)} .ig-insp-title b{display:block;margin-top:3px;font-size:15px;font-weight:600} .ig-insp-vars{margin-top:auto;padding-top:12px;display:flex;flex-direction:column;gap:8px} @@ -2770,12 +3461,10 @@ .ig-run div{display:grid;grid-template-columns:62px 190px minmax(0,1fr);gap:10px;padding:5px 0;border-bottom:1px solid var(--line)} .ig-run .t{color:var(--fg-4);font-family:'JetBrains Mono',monospace;font-size:11.5px;font-variant-numeric:tabular-nums} .ig-run .ok{color:var(--live)}.ig-run .failed{color:var(--danger)}.ig-run .skipped{color:var(--fg-3)} -.ig-run .d{color:var(--fg-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-run .d{color:var(--fg-3);overflow-wrap:anywhere} /* Connections. */ .ig-conn-body{padding:18px 22px 22px;gap:14px} .ig-conn-body>.ig-seg{align-self:flex-start} -.ig-conn-pane{display:flex;flex-direction:column} -.ig-conn-in{display:flex;flex-direction:column;gap:12px} .ig-accts{display:flex;flex-direction:column;gap:8px} .ig-acct{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;background:var(--bg-2);border:1px solid var(--line)} .ig-acct.bare{padding:0;border:0;background:none} @@ -2885,11 +3574,11 @@ display:flex;flex-direction:column;gap:1px;transform-origin:top right;animation:igMenuIn .16s var(--ease-out)} .ig-menu.closing{opacity:0;transform:scale(.97);transition:opacity .12s var(--ease-out),transform .12s var(--ease-out)} @keyframes igMenuIn{from{opacity:0;transform:scale(.96) translateY(-4px)}to{opacity:1;transform:none}} -.ig-menu button{display:flex;align-items:center;gap:10px;padding:8px 10px;border:0;border-radius:7px;background:transparent; - color:var(--fg-2);font:inherit;font-size:13px;text-align:left;cursor:pointer; +.ig-menu [role="menuitem"]{display:flex;align-items:center;gap:10px;padding:8px 10px;border:0;border-radius:7px;background:transparent; + color:var(--fg-2);font:inherit;font-size:13px;text-align:left;text-decoration:none;cursor:pointer; transition:background-color .12s var(--ease-out),color .12s var(--ease-out)} -.ig-menu button svg{width:14px;height:14px;flex:none;color:var(--fg-3)} -.ig-menu button:hover,.ig-menu button:focus-visible{background:var(--surface-3);color:var(--fg);outline:none} +.ig-menu [role="menuitem"] svg{width:14px;height:14px;flex:none;color:var(--fg-3)} +.ig-menu [role="menuitem"]:hover,.ig-menu [role="menuitem"]:focus-visible{background:var(--surface-3);color:var(--fg);outline:none} .ig-menu button.danger{color:var(--danger)} .ig-menu button.danger svg{color:var(--danger)} .ig-menu button.danger:hover,.ig-menu button.danger:focus-visible,.ig-menu button.ig-armed{background:color-mix(in oklch,var(--danger) 14%,transparent)} @@ -2997,7 +3686,7 @@ .ig-quiet{display:grid;grid-template-columns:1fr 1fr;gap:10px;max-width:320px} .ig-insp-quiet{display:flex;flex-direction:column;gap:8px} .ig-inline-ic{width:13px;height:13px;margin-right:5px;vertical-align:-2px;color:var(--fg-3)} -.ig-kind:disabled,.ig-ttype:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none} +.ig-kind:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none} /* Discord cards (embeds), drawn the way Discord's dark theme draws them. */ .ig-embed{display:grid;grid-template-columns:minmax(0,1fr);gap:6px;margin-top:4px;max-width:520px;padding:7px 10px 9px 10px;border-radius:4px; border-left:4px solid var(--ec,#5ac8fa);background:#2b2d31;color:#dbdee1;min-width:0} @@ -3079,6 +3768,12 @@ .ig-color-in{flex:1;min-width:120px} .ig-more-fields summary{cursor:pointer;color:var(--fg-3);font-size:12.5px;margin:2px 0 8px} .ig-more-fields[open] summary{color:var(--fg)} +.ig-forms{margin-top:8px} +.ig-forms dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin:0;font-size:12.5px} +.ig-forms dt{font-family:'JetBrains Mono',monospace;color:var(--fg)} +.ig-forms dd{margin:0;color:var(--fg-3)} +.ig-forms dd b{margin-left:8px;font-weight:500;color:var(--fg-2)} +@media (max-width:560px){.ig-forms dl{grid-template-columns:1fr}.ig-forms dd{margin-bottom:4px}} /* A timeline line, as {timeline} writes it. */ .ig-pv-timeline{display:flex;flex-direction:column;gap:7px;color:var(--fg-2)} .ig-pv-timeline code{margin-right:7px;padding:1px 6px;border-radius:5px;background:var(--surface-3);color:var(--accent);font:600 10.5px 'JetBrains Mono',monospace} @@ -3171,7 +3866,7 @@ .ig-modal .ig-foot-msg{order:-1;flex-basis:100%} .ig-modal .dest-form-foot .ic-btn{flex:1 1 auto;justify-content:center} .ig-palette{flex-direction:row;flex-wrap:nowrap;overflow-x:auto;gap:4px;flex:none} - .ig-palette-hint,.ig-palette .ic-label{display:none} + .ig-palette .ic-label{display:none} .ig-palette button{flex:none;white-space:nowrap} } /* A template field: shows what it will say, and turns back into what you @@ -3199,20 +3894,26 @@ .ig-live-head .hash.none+span,.ig-live-head button.none{color:var(--warn);font-style:italic} .ig-live-head button{padding:0;border:0;background:none;font:inherit;cursor:pointer;color:inherit} .ig-live-meta .ig-when{color:#949ba4;font-size:12px;font-weight:400;margin-left:2px} -/* Requirements under a pack. */ -.ig-pack-needs{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px} /* What still blocks switching on, under a card's name. */ .dcard-host.ig-host-warn{color:var(--warn)} .ig-foot-need{color:var(--warn);font-size:12px} /* Builder: a switched-off step, one that misses something, insert points. */ -.ig-step.off :is(.ig-step-kind,.ig-step-text){opacity:.6} +.ig-step.off :is(.ig-step-ic,.ig-step-kind,.ig-step-text){opacity:.6} .ig-step.off .ig-step-text{text-decoration:line-through;text-decoration-color:var(--fg-4)} .ig-step.warn{box-shadow:inset 3px 0 0 var(--warn)} .ig-step-warn{flex:none;color:var(--warn);font-size:11px;white-space:nowrap} -.ig-drop.after{margin:-2px 0 2px 18px;padding:4px 10px;font-size:11.5px} +.ig-step-warn.soft{color:color-mix(in srgb,var(--warn) 65%,var(--fg-3));max-width:45%;overflow:hidden;text-overflow:ellipsis} +/* Stacked, the settings are far below the steps: a way back up. */ +.ig-insp-back{display:none;align-items:center;gap:6px;margin:0 0 12px;padding:0;border:0;background:none;color:var(--accent); + font:inherit;font-size:12.5px;font-weight:600;cursor:pointer} +.ig-insp-back svg{width:13px;height:13px} +@media (max-width:760px){.ig-insp-back{display:inline-flex}} +.ig-drop.after{min-height:28px;margin:-3px 0 1px 46px;padding:3px 10px;font-size:11.5px} /* A moment that still misses something. */ .ig-moment-name.warn,.ig-mchip-name.warn{color:var(--warn)} -.ig-moment-name.warn::after,.ig-mchip-name.warn::after{content:' !';font-weight:700} +/* A moment that still misses something: a small amber dot after its name. */ +.ig-moment-name.warn::after,.ig-mchip-name.warn::after{content:'';display:inline-block;width:6px;height:6px;margin-left:6px; + border-radius:50%;background:currentColor;vertical-align:2px} /* Builder: a card's fields as rows, and the step's preview. */ .ig-bfields{display:flex;flex-direction:column;gap:6px} .ig-bfield{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto auto;gap:6px;align-items:center} @@ -3221,14 +3922,74 @@ .ig-field-w svg{width:12px;height:12px;vertical-align:-2px} /* Duration fields. */ .ig-dur[aria-invalid="true"],.ig-param[aria-invalid="true"]{border-color:var(--danger)} -.ig-field-hint{font-size:11.5px;color:var(--danger)} /* Test panel. */ .ig-testbox{display:flex;flex-direction:column;gap:10px} .ig-test-values{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:8px} .ig-test-values .dff label{font-family:inherit} .ig-test-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px} /* The OBS step. */ -.ig-obs-pick{display:flex;flex-wrap:wrap;gap:6px;max-height:140px;overflow-y:auto} +.ig-pick{min-width:0} +.ig-pick-h{display:flex;align-items:baseline;gap:8px;min-width:0} +.ig-pick-l{flex:1 1 0;min-width:0;font-size:12px;font-weight:600;color:var(--fg-2)} +.ig-pick-sum{flex:0 1 auto;max-width:45%;font-size:11.5px;color:var(--fg-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-pick-clear{flex:none;background:none;border:0;padding:0;font:inherit;font-size:11.5px;color:var(--accent);cursor:pointer} +.ig-pick-clear:hover{text-decoration:underline} +.ig-pick-h,.ig-pick-box{width:100%} +.ig-pick-box{min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--bg-2);overflow:hidden} +.ig-pick-box:focus-within{border-color:color-mix(in oklch,var(--accent) 45%,var(--line))} +.ig-pick-find{display:flex;align-items:center;gap:8px;padding:0 10px;border-bottom:1px solid var(--line)} +.ig-pick-find svg{width:14px;height:14px;flex:none;color:var(--fg-4)} +.ig-pick-find input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--fg);font:inherit;font-size:13px;padding:9px 0} +.ig-pick-find input::placeholder{color:var(--fg-4)} +.ig-pick-list{max-height:196px;overflow-y:auto;padding:4px;display:flex;flex-direction:column;gap:1px;overscroll-behavior:contain} +.ig-pick-row{display:flex;align-items:center;gap:9px;width:100%;min-width:0;min-height:30px;padding:5px 8px;border:0;border-radius:6px; + background:none;color:var(--fg-2);font:inherit;font-size:13px;text-align:left;cursor:pointer; + transition:background-color .12s var(--ease-out),color .12s var(--ease-out)} +.ig-pick-row:hover,.ig-pick-row:focus-visible{background:var(--surface-3);color:var(--fg);outline:0} +.ig-pick-row.on{color:var(--fg)} +.ig-pick-row.new{color:var(--accent)} +.ig-pick-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-pick-mark{width:15px;height:15px;flex:none;display:grid;place-items:center;border-radius:50%;color:var(--accent); + transition:background-color .18s var(--ease-out),border-color .18s var(--ease-out),transform .3s var(--ease-spring)} +.ig-pick-mark.box{border-radius:4px;border:1.5px solid var(--line-2);color:var(--accent-ink)} +.ig-pick-mark svg{width:10px;height:10px} +.ig-pick-mark svg path{stroke-dasharray:24;stroke-dashoffset:24;transition:stroke-dashoffset .25s var(--ease-out)} +.ig-pick-row:hover .ig-pick-mark.box{border-color:var(--fg-3)} +.ig-pick-row.on .ig-pick-mark svg path{stroke-dashoffset:0;transition-delay:.06s} +.ig-pick-row.on .ig-pick-mark.box{background:var(--accent);border-color:var(--accent);animation:igTick .35s var(--ease-spring)} +.ig-pick-row:active .ig-pick-mark{transform:scale(.85);transition-duration:.08s} +@keyframes igTick{0%{transform:scale(.7)}60%{transform:scale(1.15)}100%{transform:none}} +@media (prefers-reduced-motion: reduce){ + .ig-look-controls>*,.ig-colorf .ig-reset,.ig-saved.busy .ig-saved-dot,.ig-sound.ig-playing .ig-btn-ic, + .ig-pick-row.on .ig-pick-mark.box{animation:none} + .ig-place-card,.ig-theme,.ig-font,.ig-swatch,.ig-pick-mark svg path{transition:none} + .ig-theme:hover,.ig-font:hover{transform:none} +} +.ig-pick-none{padding:10px 8px;font-size:12px;color:var(--fg-4)} +/* The Smart light step: the device picker, a bulb in the step's color, + and color swatches. */ +.ig-light-pick{display:flex;flex-direction:column;align-items:flex-start;gap:8px} +.ig-light-pick>.ig-meter,.ig-light-list{align-self:stretch} +.ig-light-list{display:flex;flex-direction:column;gap:6px;max-height:240px;overflow-y:auto} +.ig-light-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:var(--bg-1)} +.ig-light-row.on{border-color:color-mix(in oklch,var(--accent) 55%,var(--line))} +.ig-light-id{flex:1;min-width:0;display:flex;flex-direction:column} +.ig-light-id b,.ig-light-id small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-light-id small{color:var(--fg-3)} +.ig-bulb{flex:none;display:inline-grid;place-items:center;width:26px;height:26px;border-radius:50%; + background:color-mix(in oklch,var(--bulb) 30%,transparent);color:var(--bulb);border:1px solid color-mix(in oklch,var(--bulb) 55%,var(--line))} +.ig-bulb svg{width:15px;height:15px} +.ig-bulb.off{color:var(--fg-3);border-color:var(--line)} +.ig-bulb.flash{animation:ig-bulb-flash 1.1s steps(2,jump-none) infinite} +@keyframes ig-bulb-flash{50%{opacity:.25}} +.ig-pv-action .ig-bulb{width:30px;height:30px;border-radius:50%;background:color-mix(in oklch,var(--bulb) 30%,transparent); + color:var(--bulb);border:1px solid color-mix(in oklch,var(--bulb) 55%,var(--line))} +.ig-pv-action .ig-bulb.off{color:var(--fg-3);border-color:var(--line)} +.ig-swatches{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:6px} +.ig-swatch{width:24px;height:24px;border-radius:50%;border:2px solid var(--line);background:var(--sw);cursor:pointer;padding:0} +.ig-swatch.on{border-color:var(--fg);box-shadow:0 0 0 2px var(--bg-1) inset} +.ig-swatch-pick{width:30px;height:26px;padding:0;border:1px solid var(--line);border-radius:8px;background:none;cursor:pointer} +@media (prefers-reduced-motion: reduce){.ig-bulb.flash{animation:none}} /* Touch screens have no hover: keep controls in sight, and big enough. */ @media (hover:none){ .ig-field-edit .ic-btn-tiny,.ig-field-w,.ig-card .ig-more,.ig-step-tools{opacity:1} @@ -3271,15 +4032,16 @@ EgressOff - + Idle - + + @@ -3310,7 +4072,7 @@

Welcome to InstantClone

How does OBS reach InstantClone?
-
Pick whichever fits your setup - you can switch later on the OBS tab.
+
Pick whichever fits your setup. You can switch later on the OBS tab.
- + @@ -3909,13 +4712,28 @@

New destination

enter your channel name above
-
- How to add it to OBS -
-

OBS → Docks → Custom Browser Docks…, give it a name, paste the URL above, click Apply.

-
-
+ +
+ Legacy: launch OBS with Enhanced Broadcasting +
Start OBS pre-wired for Twitch Enhanced Broadcasting (the multi-resolution video ladder), with no destination editing needed. Session-only: reopen OBS normally and it streams plain again. Close OBS first: a second OBS next to an open one never gets the flag.
+ +
+ + + +
+ + + +
The EB + VOD button is the legacy VOD path (OBS before 32.2): it turns on VOD audio mode on your Twitch destination and writes OBS's VOD-track unlock flag. Close OBS before pressing it or the flag write is blocked.
+ +
+
@@ -3924,12 +4742,15 @@

New destination

-
Paste this into OBS as a Browser Source.
+
Paste this into OBS as a Browser Source, sized 1920 × 1080 (Width 1920, Height 1080) so it lines up with your canvas.
@@ -3967,18 +4788,13 @@

New destination

-
OBS browser dock
-
Add as an OBS Custom Browser Dock to control delay from within OBS. Recommended size: ~290 × 360.
-
- /dock - -
-
Overlays folder
+
Want to control the delay from inside OBS? The control dock is on the OBS tab.
+
Overlays folder
Drop your own .html files here and they appear in the picker above.
- +
@@ -4056,14 +4872,14 @@

New destination

@@ -4072,6 +4888,7 @@

New destination

System
+
How InstantClone starts, protects your stream and connects. Press / to find any setting.
@@ -4083,8 +4900,8 @@

New destination

- +
@@ -4161,7 +4985,7 @@

New destination

Used by pre-arm and by the hotkey / MIDI arm and toggle actions. Also tracks the last value you manually armed at.
- + s
@@ -4171,12 +4995,12 @@

New destination

Buffer file
Disk-backed ring. Bigger buffer = longer max delay at a given bitrate.
-
Buffer size (MB)
+
Buffer size (MB)
Buffer file
- +
@@ -4207,7 +5031,7 @@

New destination

- Drawn by the same code that streams it. Some Enhanced Broadcasting qualities (HEVC or AV1) show your last frame instead. + Drawn by the same code that streams it. Some Enhanced Broadcasting qualities (AV1 or 10-bit HEVC) show your last frame instead.
@@ -4280,7 +5104,7 @@

New destination

(the MIDI events arrive at the backend) so the dashboard polls. -->