From ea800e49498710b7ce863e7f11a8b9e277bb69af Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Thu, 24 Sep 2026 20:00:02 +0200 Subject: [PATCH 01/55] feat: vertical without Twitch, and Enhanced Broadcasting on the GPU With no Twitch destination, InstantClone now builds OBS's Enhanced Broadcasting config itself from what OBS reports: one track for the main canvas at its output size, plus the Additional canvas (e.g. Aitum Vertical) when OBS sends one, so vertical destinations work without Twitch. The encoder is the H.264 one of the GPU OBS runs on, but only when OBS's log lists it as available (OBS won't start a stream on an unknown encoder); otherwise x264. The old fallback always asked for three x264 encodes, which overloaded CPUs. - The dashboard no longer locks Vertical behind a Twitch destination. A waiting vertical destination shows why on its card, and the log says so once per OBS session. - The old --launch-eb shortcut leaves OBS 32.2+ config alone. - Fixes: Twitch ingest reset on first edit, the Twitch mobile-risk pill that never showed, VOD + EB resetting the audio track, a stale Vertical pill after OBS stops, HTML-rendered save errors. - Polish: wrapping log with a tail that respects reading, aligned destination cards, a shorter destination editor, GPU encoder advice on the OBS tab. --- src/config.rs | 16 +- src/controller.rs | 14 +- src/local_eb_config.rs | 434 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 120 +++++++++++- src/obs_register.rs | 86 +++++++- src/web.rs | 347 +++++++++++++++++++++++++------- web/index.html | 222 +++++++++++---------- 7 files changed, 1041 insertions(+), 198 deletions(-) create mode 100644 src/local_eb_config.rs diff --git a/src/config.rs b/src/config.rs index daf6bb9..fc8b0ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -585,15 +585,15 @@ pub struct Destination { /// non-Twitch platforms (Twitch gets the native dual-canvas /// passthrough). One of: /// "horizontal" (default) - the primary 16:9 canvas (TrackId 0) - /// "vertical" - the 9:16 canvas Twitch Dual Format / - /// Enhanced Broadcasting produces, reused - /// for YouTube Shorts / Kick mobile / etc. + /// "vertical" - the 9:16 canvas OBS sends with Enhanced + /// Broadcasting (its Additional canvas, or + /// Twitch Dual Format), for TikTok / + /// YouTube Shorts / Kick mobile. /// - /// Vertical only has data on the wire while Twitch Dual Format (EB) - /// is active in OBS. With EB off there is no vertical canvas, so a - /// vertical destination waits (sends no video) until one appears - - /// see `h264::detect_vertical_primary_track`. Unknown / empty values - /// fall back to "horizontal". + /// Vertical only has data on the wire while OBS sends that canvas. + /// Without it a vertical destination waits (sends no video) until one + /// appears - see `h264::detect_vertical_primary_track`. Unknown / empty + /// values fall back to "horizontal". pub stream_format: String, /// Which OBS audio track(s) this destination receives. Only meaningful /// once OBS sends a second audio track (the VOD-unlocker script, or diff --git a/src/controller.rs b/src/controller.rs index e25e296..93ee197 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -204,9 +204,9 @@ pub struct DestinationState { /// The OneTrack TrackId of the vertical-canvas primary, discovered by /// `h264::detect_vertical_primary_track` from the per-track seq-header /// cache and refreshed whenever that cache changes. `0xFF` means - /// "not resolved yet" (Twitch Dual Format isn't active, or no portrait - /// track has been seen): a vertical destination then sends no video - /// and surfaces a "waiting for Dual Format" status, while every other + /// "not resolved yet" (OBS sends no 9:16 canvas, from its Additional + /// canvas or Twitch Dual Format): a vertical destination then sends no + /// video and the dashboard says what's missing, while every other /// destination is unaffected. pub vertical_primary_track: AtomicU8, /// Twitch only: when our /obs/multitrack-config proxy successfully @@ -308,9 +308,9 @@ impl DestinationState { /// The video egress policy for this destination right now. Read by /// both the live send path and the seq-header replay so they always /// agree on which canvas to forward. Returns `None` when a vertical - /// destination has no resolved canvas yet (Twitch Dual Format isn't - /// active): the caller drops all video and the dest waits, leaving - /// every other destination untouched. + /// destination has no resolved canvas yet (no 9:16 canvas on the wire): + /// the caller drops all video and the dest waits, leaving every other + /// destination untouched. pub fn video_egress(&self) -> Option { use crate::h264::VideoEgress; if self.pass_through_multitrack_video.load(Ordering::Relaxed) { @@ -2511,7 +2511,7 @@ async fn pace_and_send( match meta.kind { 8 => { // A vertical destination whose 9:16 canvas isn't on the wire yet - // (Dual Format off) has `video_egress() == None`. Drop its AUDIO + // has `video_egress() == None`. Drop its AUDIO // too - otherwise we'd feed the platform an audio-only stream // with no video, which reads as a broken/black broadcast. It // should send nothing until the canvas appears. diff --git a/src/local_eb_config.rs b/src/local_eb_config.rs new file mode 100644 index 0000000..4d65280 --- /dev/null +++ b/src/local_eb_config.rs @@ -0,0 +1,434 @@ +//! Enhanced Broadcasting config built locally, for streams with no Twitch +//! destination. +//! +//! OBS asks the service's config URL which tracks to encode. With a Twitch +//! destination `InstantClone` proxies Twitch's answer; without one there is +//! no one to ask, so the answer is built here from what OBS reports about +//! its own canvases (`preferences.canvases` in the request body): +//! +//! - one track for the main canvas, at the Output resolution the streamer +//! picked in OBS (Settings > Video), since only one horizontal track is +//! ever forwarded to a non-Twitch platform; +//! - one track for OBS's "Additional canvas" (Settings > Stream > Enhanced +//! Broadcasting), usually a vertical canvas such as Aitum Vertical, when +//! OBS sends one. This is what feeds destinations set to Vertical. +//! +//! A track must never name a canvas OBS didn't send: OBS refuses to start +//! the stream on an out-of-range `canvas_index`. + +/// One entry of OBS's `preferences.canvases`: the size OBS outputs that +/// canvas at, and its frame rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObsCanvas { + pub width: u32, + pub height: u32, + pub fps_num: u32, + pub fps_den: u32, +} + +impl ObsCanvas { + pub fn is_vertical(&self) -> bool { + self.height > self.width + } +} + +/// Share of the bandwidth budget the main track gets when a second canvas +/// is encoded too. Vertical feeds are smaller, so 60 / 40 keeps both sharp. +const MAIN_TRACK_SHARE_PCT: u64 = 60; +const MIN_TRACK_KBPS: u32 = 500; +/// Budget when nobody set one. The main track alone gets what a 1080p60 +/// stream usually runs at; with a vertical track the 60 / 40 split keeps +/// the main track at that same 6000. Every kbps here leaves the streamer's +/// upload once per destination, so higher is not better. +const DEFAULT_MAIN_ONLY_KBPS: u32 = 6000; +const DEFAULT_WITH_ADDITIONAL_KBPS: u32 = 10000; +/// Lowest cap honoured: split 60/40 it gives 750 + 500, so neither track +/// drops under `MIN_TRACK_KBPS` and the total never exceeds the cap. +const MIN_BUDGET_KBPS: u32 = 1250; +const MAX_BUDGET_KBPS: u32 = 50000; + +/// Default bandwidth budget for these canvases, in kbps. +pub fn default_budget_kbps(canvases: &[ObsCanvas]) -> u32 { + if canvases.len() > 1 { + DEFAULT_WITH_ADDITIONAL_KBPS + } else { + DEFAULT_MAIN_ONLY_KBPS + } +} + +/// The streamer's "Maximum Streaming Bandwidth" from OBS's Enhanced +/// Broadcasting settings, in kbps, when they set one (OBS sends null for +/// Auto). +pub fn requested_budget_kbps(body: &str) -> Option { + number_field(body, "maximum_aggregate_bitrate") + .filter(|kbps| *kbps > 0) + .map(|kbps| kbps.clamp(MIN_BUDGET_KBPS, MAX_BUDGET_KBPS)) +} + +/// Whether an Enhanced Broadcasting config asks OBS to encode anything +/// from a second canvas (`"canvas_index": 1` or higher, any spacing). +pub fn names_additional_canvas(config: &str) -> bool { + config + .match_indices("\"canvas_index\"") + .any(|(at, _)| number_field(&config[at..], "canvas_index").is_some_and(|i| i > 0)) +} + +/// Canvases OBS listed in its config request, in `canvas_index` order. +/// Empty when the body has none (older OBS, or a hand-made request). +pub fn parse_canvases(body: &str) -> Vec { + // A canvas's position is its canvas_index, so stop at the first one we + // can't read instead of skipping it and shifting every later one down. + array_objects(body, "canvases") + .into_iter() + .map_while(parse_canvas) + .collect() +} + +// PCI vendor ids, as OBS reports them in `capabilities.gpu[].vendor_id`. +const VENDOR_NVIDIA: u32 = 0x10DE; +const VENDOR_AMD: u32 = 0x1002; +const VENDOR_INTEL: u32 = 0x8086; + +/// Hardware encoder family ("nvenc", "amd" or "qsv") for the GPU OBS +/// composites on, from `capabilities.gpu` and +/// `preferences.composition_gpu_index`. None when OBS reported no GPU we +/// know an encoder for, and the caller falls back to x264. +/// +/// This matters: several tracks of x264 at 1080p60 overload most CPUs, +/// which is what Enhanced Broadcasting without Twitch did before. +pub fn gpu_encoder_family(body: &str) -> Option<&'static str> { + let gpus = array_objects(body, "gpu"); + let index = number_field(body, "composition_gpu_index").unwrap_or(0) as usize; + let gpu = gpus.get(index).or_else(|| gpus.first())?; + match number_field(gpu, "vendor_id")? { + VENDOR_NVIDIA => Some("nvenc"), + VENDOR_AMD => Some("amd"), + VENDOR_INTEL => Some("qsv"), + _ => None, + } +} + +/// The objects directly inside the array under `"key"`. Enough JSON for +/// OBS's request, whose arrays of interest hold flat numeric objects. +fn array_objects<'a>(body: &'a str, key: &str) -> Vec<&'a str> { + let needle = format!("\"{key}\""); + let Some(at) = body.find(&needle) else { + return Vec::new(); + }; + let rest = &body[at + needle.len()..]; + let Some(open) = rest.trim_start().strip_prefix(':').map(str::trim_start) else { + return Vec::new(); + }; + if !open.starts_with('[') { + return Vec::new(); + } + let mut objects = Vec::new(); + let mut depth = 0; + let mut object_start = None; + for (index, c) in open.char_indices() { + match c { + '[' | '{' => { + depth += 1; + if c == '{' && depth == 2 { + object_start = Some(index); + } + } + ']' | '}' => { + if c == '}' && depth == 2 { + if let Some(start) = object_start.take() { + objects.push(&open[start..=index]); + } + } + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + objects +} + +fn parse_canvas(object: &str) -> Option { + let canvas = ObsCanvas { + width: number_field(object, "width")?, + height: number_field(object, "height")?, + fps_num: number_field(object, "numerator").unwrap_or(30), + fps_den: number_field(object, "denominator").unwrap_or(1).max(1), + }; + (canvas.width > 0 && canvas.height > 0 && canvas.fps_num > 0).then_some(canvas) +} + +/// The unsigned number after `"key":`. The quotes keep `"width"` from +/// matching `"canvas_width"`. +fn number_field(object: &str, key: &str) -> Option { + let needle = format!("\"{key}\""); + let after = &object[object.find(&needle)? + needle.len()..]; + let value = after.trim_start().strip_prefix(':')?.trim_start(); + let digits_end = value + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(value.len()); + value[..digits_end].parse().ok() +} + +/// Encoder id plus its settings JSON for one track. +pub struct TrackEncoder { + pub encoder_type: &'static str, + pub settings_json: String, +} + +/// What the local config asks OBS to encode. +pub struct LocalPlan { + pub main: ObsCanvas, + pub additional: Option, +} + +impl LocalPlan { + /// Whether a destination set to Vertical has a 9:16 track to forward. + /// A portrait main canvas counts too. + pub fn feeds_vertical(&self) -> bool { + self.main.is_vertical() || self.additional.is_some_and(|c| c.is_vertical()) + } + + /// "1920x1080 from the main canvas + 1080x1920 from OBS's Additional + /// canvas", for the log. + pub fn describe(&self) -> String { + let main = format!( + "{}x{} from the main canvas", + self.main.width, self.main.height + ); + match self.additional { + Some(c) => format!( + "{main} + {}x{} from OBS's Additional canvas", + c.width, c.height + ), + None => main, + } + } +} + +/// The config JSON, or None when OBS sent no canvases (the caller then +/// keeps the static ladder). `encoder_for(kbps)` supplies the chosen +/// encoder's id and settings at a given bitrate. +pub fn build( + canvases: &[ObsCanvas], + bandwidth_kbps: u32, + ingest_port: u16, + encoder_for: impl Fn(u32) -> TrackEncoder, +) -> Option<(String, LocalPlan)> { + let main = *canvases.first()?; + let additional = canvases.get(1).copied(); + let main_kbps = match additional { + Some(_) => share(bandwidth_kbps, MAIN_TRACK_SHARE_PCT), + None => bandwidth_kbps.max(MIN_TRACK_KBPS), + }; + let mut tracks = vec![track_json(&main, 0, &encoder_for(main_kbps))]; + if let Some(canvas) = additional { + let kbps = share(bandwidth_kbps, 100 - MAIN_TRACK_SHARE_PCT); + tracks.push(track_json(&canvas, 1, &encoder_for(kbps))); + } + let config = format!( + r#"{{"meta":{{"service":"InstantClone","schema_version":"2024-06-04","config_id":"instantclone-local-{id}"}},"ingest_endpoints":[{{"protocol":"RTMP","url_template":"rtmp://localhost:{port}/live/{{stream_key}}"}}],"encoder_configurations":[{tracks}],"audio_configurations":{{"live":[{{"codec":"aac","track_id":0,"channels":2,"settings":{{"bitrate":160}}}}]}}}}"#, + id = unix_seconds(), + port = ingest_port, + tracks = tracks.join(","), + ); + Some((config, LocalPlan { main, additional })) +} + +fn track_json(canvas: &ObsCanvas, canvas_index: u32, encoder: &TrackEncoder) -> String { + format!( + r#"{{"type":"{enc}","width":{w},"height":{h},"framerate":{{"numerator":{num},"denominator":{den}}},"canvas_index":{idx},"settings":{s}}}"#, + enc = encoder.encoder_type, + w = canvas.width, + h = canvas.height, + num = canvas.fps_num, + den = canvas.fps_den, + idx = canvas_index, + s = encoder.settings_json, + ) +} + +fn share(total_kbps: u32, percent: u64) -> u32 { + // At most 100% of a u32, so it always fits back. + u32::try_from(u64::from(total_kbps) * percent / 100) + .unwrap_or(total_kbps) + .max(MIN_TRACK_KBPS) +} + +/// OBS treats each `config_id` as a fresh session, like Twitch's. +fn unix_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Trimmed from a real OBS 32.2 request: main canvas 2560x1440 output + /// at 1920x1080, plus an Aitum Vertical canvas at 1080x1920. + const REQUEST: &str = r#"{"service":"IVS","schema_version":"2025-01-25","authentication":"key", + "preferences":{"vod_track_audio":true,"composition_gpu_index":0,"audio_samples_per_sec":48000, + "canvases":[ + {"width":1920,"height":1080,"canvas_width":2560,"canvas_height":1440,"framerate":{"numerator":60,"denominator":1}}, + {"width":1080,"height":1920,"canvas_width":1080,"canvas_height":1920,"framerate":{"numerator":30,"denominator":1}} + ]}}"#; + + fn x264(kbps: u32) -> TrackEncoder { + TrackEncoder { + encoder_type: "obs_x264", + settings_json: format!(r#"{{"bitrate":{kbps}}}"#), + } + } + + #[test] + fn reads_output_size_and_frame_rate_of_each_canvas() { + let canvases = parse_canvases(REQUEST); + assert_eq!( + canvases, + vec![ + ObsCanvas { + width: 1920, + height: 1080, + fps_num: 60, + fps_den: 1 + }, + ObsCanvas { + width: 1080, + height: 1920, + fps_num: 30, + fps_den: 1 + }, + ] + ); + assert!(canvases[1].is_vertical()); + } + + #[test] + fn picks_the_hardware_encoder_of_the_composition_gpu() { + let nvidia = r#"{"capabilities":{"gpu":[{"model":"RTX 4070","vendor_id":4318,"device_id":10114}]},"preferences":{"composition_gpu_index":0}}"#; + assert_eq!(gpu_encoder_family(nvidia), Some("nvenc")); + // Laptop: Intel iGPU listed first, OBS composites on the NVIDIA one. + let hybrid = r#"{"capabilities":{"gpu":[{"vendor_id":32902},{"vendor_id":4318}]},"preferences":{"composition_gpu_index":1}}"#; + assert_eq!(gpu_encoder_family(hybrid), Some("nvenc")); + let amd = r#"{"capabilities":{"gpu":[{"vendor_id":4098}]}}"#; + assert_eq!(gpu_encoder_family(amd), Some("amd")); + } + + #[test] + fn unknown_or_missing_gpu_means_software() { + assert_eq!( + gpu_encoder_family(r#"{"capabilities":{"gpu":[{"vendor_id":5140}]}}"#), + None + ); + assert_eq!(gpu_encoder_family(r#"{"capabilities":{}}"#), None); + // An index past the list falls back to the first GPU. + let stale = r#"{"capabilities":{"gpu":[{"vendor_id":4318}]},"preferences":{"composition_gpu_index":3}}"#; + assert_eq!(gpu_encoder_family(stale), Some("nvenc")); + } + + #[test] + fn an_unreadable_canvas_never_shifts_the_ones_after_it() { + let body = r#"{"canvases":[{"width":-1,"height":1080},{"width":1080,"height":1920}]}"#; + assert!( + parse_canvases(body).is_empty(), + "canvas 0 unreadable: no local config" + ); + let body = r#"{"canvases":[{"width":1920,"height":1080},{"width":"x"},{"width":1080,"height":1920}]}"#; + assert_eq!(parse_canvases(body).len(), 1, "stops before the bad one"); + } + + #[test] + fn requests_without_canvases_parse_to_nothing() { + assert!(parse_canvases(r#"{"preferences":{}}"#).is_empty()); + assert!(parse_canvases(r#"{"preferences":{"canvases":[]}}"#).is_empty()); + assert!(parse_canvases("not json").is_empty()); + } + + #[test] + fn two_canvases_give_one_track_each() { + let (config, plan) = build(&parse_canvases(REQUEST), 10_000, 1935, x264).unwrap(); + assert_eq!(config.matches("\"canvas_index\"").count(), 2); + assert!(config.contains(r#""width":1920,"height":1080,"framerate":{"numerator":60,"denominator":1},"canvas_index":0,"settings":{"bitrate":6000}"#)); + assert!(config.contains(r#""width":1080,"height":1920,"framerate":{"numerator":30,"denominator":1},"canvas_index":1,"settings":{"bitrate":4000}"#)); + assert!(config.contains("rtmp://localhost:1935/live/{stream_key}")); + assert!(plan.additional.is_some()); + } + + #[test] + fn one_canvas_never_names_a_second() { + let only_main = &parse_canvases(REQUEST)[..1]; + let (config, plan) = build(only_main, 8_000, 1935, x264).unwrap(); + assert!( + !config.contains(r#""canvas_index":1"#), + "OBS would refuse to stream" + ); + assert!( + config.contains(r#""settings":{"bitrate":8000}"#), + "the one track gets the whole budget" + ); + assert!(plan.additional.is_none()); + } + + #[test] + fn default_budget_keeps_the_main_track_at_6000() { + let canvases = parse_canvases(REQUEST); + assert_eq!(default_budget_kbps(&canvases[..1]), 6000); + let (config, _) = build(&canvases, default_budget_kbps(&canvases), 1935, x264).unwrap(); + assert!(config.contains(r#""canvas_index":0,"settings":{"bitrate":6000}"#)); + } + + #[test] + fn obs_bandwidth_cap_is_read_and_auto_is_ignored() { + assert_eq!( + requested_budget_kbps(r#"{"preferences":{"maximum_aggregate_bitrate":8000}}"#), + Some(8000) + ); + assert_eq!( + requested_budget_kbps(r#"{"preferences":{"maximum_aggregate_bitrate":null}}"#), + None + ); + assert_eq!( + requested_budget_kbps(r#"{"preferences":{"maximum_aggregate_bitrate":100}}"#), + Some(1250) + ); + } + + #[test] + fn spots_a_second_canvas_in_any_config() { + assert!(names_additional_canvas( + r#"[{"canvas_index": 0},{"canvas_index" : 1}]"# + )); + assert!(!names_additional_canvas( + r#"[{"canvas_index":0},{"canvas_index":0}]"# + )); + assert!(!names_additional_canvas(r#"{"encoder_configurations":[]}"#)); + } + + #[test] + fn plan_says_what_it_encodes_and_whether_it_feeds_vertical() { + let canvases = parse_canvases(REQUEST); + let (_, both) = build(&canvases, 10_000, 1935, x264).unwrap(); + assert!(both.feeds_vertical()); + assert_eq!( + both.describe(), + "1920x1080 from the main canvas + 1080x1920 from OBS's Additional canvas" + ); + let (_, main_only) = build(&canvases[..1], 6_000, 1935, x264).unwrap(); + assert!(!main_only.feeds_vertical()); + assert_eq!(main_only.describe(), "1920x1080 from the main canvas"); + let (_, portrait_main) = build(&canvases[1..], 6_000, 1935, x264).unwrap(); + assert!( + portrait_main.feeds_vertical(), + "a portrait main canvas counts" + ); + } + + #[test] + fn no_canvases_means_no_local_config() { + assert!(build(&[], 8_000, 1935, x264).is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 3d56cb1..2573b95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,7 @@ mod controller; mod crypto; mod h264; mod https; +mod local_eb_config; mod midi; mod obs_register; mod portcheck; @@ -52,7 +53,7 @@ use crate::config::Settings; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::sync::watch; fn main() -> std::io::Result<()> { @@ -329,11 +330,28 @@ fn main() -> std::io::Result<()> { tokio::time::sleep(Duration::from_millis(600)).await; let _ = tokio::task::spawn_blocking(move || { - match obs_register::set_vod_audio_flag(true) { - Ok(true) => ctrl_eb.log("[--launch-eb] VOD-track flag written"), - Ok(false) => ctrl_eb - .log("[--launch-eb] OBS config not found - is OBS installed?"), - Err(e) => ctrl_eb.log(format!("[--launch-eb] flag write failed: {e}")), + // On OBS 32.2+ this shortcut's Custom RTMP path is dead: + // --config-url is no longer read for Custom RTMP, so the + // VOD flag would only switch on a track that never gets + // Enhanced Broadcasting. Leave OBS's config alone. + let modern_obs = + obs_register::obs_version().is_some_and(|v| v >= (32, 2, 0)); + if modern_obs { + ctrl_eb.log( + "[--launch-eb] this shortcut is the old VOD + Enhanced Broadcasting \ + path for OBS before 32.2, so on your OBS it only opens OBS. For \ + Enhanced Broadcasting pick the InstantClone service in OBS's Stream \ + settings; for VOD audio use the VOD unlocker script in System.", + ); + } else { + match obs_register::set_vod_audio_flag(true) { + Ok(true) => ctrl_eb.log("[--launch-eb] VOD-track flag written"), + Ok(false) => ctrl_eb + .log("[--launch-eb] OBS config not found - is OBS installed?"), + Err(e) => { + ctrl_eb.log(format!("[--launch-eb] flag write failed: {e}")); + } + } } match obs_register::launch_obs_with_eb_config(web_port, &dock_token) { Ok(exe) => ctrl_eb @@ -583,6 +601,45 @@ async fn supervise_ingest_leg(addr: String, ctrl: Arc, r } } +/// How long a vertical destination's 9:16 canvas must be missing, within +/// one OBS session, before the log says so. +const VERTICAL_WAIT_GRACE: Duration = Duration::from_secs(3); + +/// Decides when to log that a vertical destination has no 9:16 canvas: once +/// per OBS session, and only after `VERTICAL_WAIT_GRACE`, so a vertical +/// sequence header landing a moment after the main one (or a settings +/// change waking the supervisor early) never raises a false alarm. +#[derive(Default)] +struct VerticalWaitLog { + /// Destination id -> (publisher session, first seen missing, logged). + seen: std::collections::HashMap, +} + +impl VerticalWaitLog { + /// Note that `dest_id` has no canvas at `now` during `session`. True + /// exactly once per session: when the grace period has run out. + fn missing(&mut self, dest_id: &str, session: u64, now: Instant) -> bool { + match self.seen.get_mut(dest_id) { + Some((seen_session, since, logged)) if *seen_session == session => { + if *logged || now.duration_since(*since) < VERTICAL_WAIT_GRACE { + return false; + } + *logged = true; + true + } + _ => { + self.seen.insert(dest_id.to_string(), (session, now, false)); + false + } + } + } + + /// Forget destinations that no longer exist. + fn retain(&mut self, keep: impl Fn(&str) -> bool) { + self.seen.retain(|id, _| keep(id)); + } +} + /// One egress pump per destination. Owns a map { dest_id → (url, JoinHandle) } /// and diffs it against the active-destinations list whenever settings /// change. Adds/removes/restarts as needed. @@ -592,6 +649,7 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc>), > = std::collections::HashMap::new(); + let mut vertical_wait = VerticalWaitLog::default(); // Mirror webhook URL + ingest key into the controller on every settings // change (the ingest task enforces the key but has no settings handle). @@ -675,6 +733,7 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc, ctrl: Arc, ctrl: Arc Option<(u32, u32, u32)> { /// a note", not a hard block: this only gates the experimental /// VOD-unlocker script. pub fn obs_version() -> Option<(u32, u32, u32)> { + newest_obs_log_head()? + .lines() + .take(30) + .filter(|l| l.contains("OBS")) + .find_map(first_version_triple) +} + +/// Video encoder ids OBS registered in its most recent session, from the +/// "Available Encoders" block it logs at startup. None when there is no +/// readable log. Enhanced Broadcasting configs must only name encoders in +/// this list: OBS refuses to start the stream on one it doesn't have. +pub fn available_video_encoders() -> Option> { + let encoders = parse_available_video_encoders(&newest_obs_log_head()?); + (!encoders.is_empty()).then_some(encoders) +} + +/// Parse the block OBS logs at startup: +/// +/// ```text +/// 15:28:19.316: Available Encoders: +/// 15:28:19.316: Video Encoders: +/// 15:28:19.316: \t- obs_x264 (x264) +/// 15:28:19.316: Audio Encoders: +/// ``` +fn parse_available_video_encoders(log: &str) -> Vec { + let mut lines = log + .lines() + .skip_while(|l| !l.contains("Available Encoders:")) + .skip_while(|l| !l.contains("Video Encoders:")) + .skip(1); + let mut encoders = Vec::new(); + for line in lines.by_ref() { + // Drop the "HH:MM:SS.mmm: " timestamp, then expect "- ()". + let entry = line.split_once(": ").map_or(line, |(_, rest)| rest).trim(); + let Some(item) = entry.strip_prefix("- ") else { + break; + }; + if let Some(id) = item.split_whitespace().next() { + encoders.push(id.to_string()); + } + } + encoders +} + +/// Enough of an OBS log to hold its startup section: the version line and +/// the "Available Encoders" block come right after plugin loading. A log +/// grows for the whole OBS session (tens of MB on a long one), and this is +/// read on every stream start, so the rest is never loaded. +const OBS_LOG_HEAD_BYTES: u64 = 512 * 1024; + +/// The start of the most recently written OBS log, which is the running +/// session's while OBS is open. Read lossily: a plugin that writes one +/// non-UTF-8 byte must not hide the whole log (and with it the encoder +/// list and the OBS version). +fn newest_obs_log_head() -> Option { let logs_dir = obs_config_dirs() .into_iter() .map(|d| d.join("logs")) @@ -1006,12 +1061,13 @@ pub fn obs_version() -> Option<(u32, u32, u32)> { } } } - let contents = fs::read_to_string(newest?.1).ok()?; - contents - .lines() - .take(30) - .filter(|l| l.contains("OBS")) - .find_map(first_version_triple) + let mut head = Vec::new(); + fs::File::open(newest?.1) + .ok()? + .take(OBS_LOG_HEAD_BYTES) + .read_to_end(&mut head) + .ok()?; + Some(String::from_utf8_lossy(&head).into_owned()) } /// Whether an `obs64.exe` process is currently running. Used by the setup @@ -1375,6 +1431,22 @@ mod tests { use super::*; use std::sync::atomic::{AtomicU32 as TestUniq, Ordering as TestOrd}; + #[test] + fn available_video_encoders_are_read_from_the_startup_block() { + let log = "15:28:19.316: Available Encoders:\n\ + 15:28:19.316: Video Encoders:\n\ + 15:28:19.316: \t- ffmpeg_svt_av1 (SVT-AV1)\n\ + 15:28:19.316: \t- h264_texture_amf (AMD HW H.264 (AVC))\n\ + 15:28:19.316: \t- obs_x264 (x264)\n\ + 15:28:19.316: Audio Encoders:\n\ + 15:28:19.316: \t- ffmpeg_aac (FFmpeg AAC)\n"; + assert_eq!( + parse_available_video_encoders(log), + vec!["ffmpeg_svt_av1", "h264_texture_amf", "obs_x264"] + ); + assert!(parse_available_video_encoders("no block here").is_empty()); + } + // OBS resolves the server URL with the address family picked in // Settings -> Advanced -> IP Family. An IPv4 literal is not resolvable // under IPv6, so the single entry we register has to carry a hostname. diff --git a/src/web.rs b/src/web.rs index 075f432..7463b48 100644 --- a/src/web.rs +++ b/src/web.rs @@ -781,7 +781,7 @@ async fn route( ("GET", "/obs/multitrack-config") => ( "200 OK", "application/json", - obs_multitrack_config_static(query, settings), + obs_multitrack_config_static(query, body, settings), ), ("GET", "/obs/register-status") => { let s = settings.borrow(); @@ -1198,7 +1198,7 @@ fn video_readouts(ctrl: &Controller) -> std::collections::BTreeMap { + ProxyOutcome::HttpError(code, response) => { // Truncate the body so a verbose Twitch error page doesn't // flood the dashboard log line. - let snippet: String = body.chars().take(300).collect(); + let snippet: String = response.chars().take(300).collect(); ctrl.log(format!( "[OBS multitrack] Twitch API returned HTTP {code} - returning static \ config. Response body (first 300 chars): {snippet}" @@ -1660,7 +1656,7 @@ async fn obs_multitrack_config_proxy( "OBS_MULTITRACK", &format!("Twitch API HTTP {code} - static fallback. body={snippet}"), ); - return obs_multitrack_config_static(query, settings); + return obs_multitrack_config_static(query, body, settings); } ProxyOutcome::TransportError(e) => { ctrl.log(format!( @@ -1671,7 +1667,7 @@ async fn obs_multitrack_config_proxy( "OBS_MULTITRACK", &format!("Twitch API transport error: {e} - static fallback"), ); - return obs_multitrack_config_static(query, settings); + return obs_multitrack_config_static(query, body, settings); } ProxyOutcome::ReadError(e) => { ctrl.log(format!( @@ -1682,7 +1678,7 @@ async fn obs_multitrack_config_proxy( "OBS_MULTITRACK", &format!("Twitch API read error: {e} - static fallback"), ); - return obs_multitrack_config_static(query, settings); + return obs_multitrack_config_static(query, body, settings); } }; @@ -1832,6 +1828,13 @@ async fn obs_multitrack_config_proxy( "OBS_MULTITRACK", "Twitch config received + rewritten to localhost ingest", ); + if !crate::local_eb_config::names_additional_canvas(&rewritten) { + log_vertical_destinations_waiting( + ctrl, + settings, + "Twitch's config for this channel has no vertical track (Dual Format isn't on for it)", + ); + } rewritten } @@ -2057,60 +2060,33 @@ fn rewrite_url_templates(json: &str, new_value: &str) -> String { out } -fn obs_multitrack_config_static(query: &str, settings: &Arc>) -> String { +/// `body` is OBS's config request when there is one (empty for GET): it +/// names the GPU, so the ladder uses its hardware encoder. +fn obs_multitrack_config_static( + query: &str, + body: &str, + settings: &Arc>, +) -> String { let params = config::parse_form(query); - let encoder = params.get("encoder").map(|s| s.as_str()).unwrap_or("x264"); + let available = crate::obs_register::available_video_encoders(); + let encoder = choose_encoder(query, body, available.as_deref()); + let bandwidth = config_url_bandwidth(query).unwrap_or(10000); let tracks: u32 = params .get("tracks") .and_then(|s| s.parse().ok()) .unwrap_or(3) .clamp(1, 3); - let bandwidth: u32 = params - .get("bandwidth") - .and_then(|s| s.parse().ok()) - .unwrap_or(10000) - .clamp(1500, 50000); let ingest_port = settings.borrow().ingest_port; - let (enc_type, settings_json_1080, settings_json_720, settings_json_480): ( - &str, - String, - String, - String, + let track = |index: u32| encoder_for(&encoder, bitrate_for_track(bandwidth, tracks, index)); + let (top, middle, bottom) = (track(0), track(1), track(2)); + let enc_type = top.encoder_type; + let (settings_json_1080, settings_json_720, settings_json_480) = ( + top.settings_json, + middle.settings_json, + bottom.settings_json, ); - // Per-encoder presets: libobs encoder IDs + the settings keys each - // implementation understands. x264 uses `preset` strings like - // "veryfast"; nvenc uses "p1"-"p7"; AMD uses similar tier names. - // `profile=main` is what every Twitch / YouTube / Kick decoder - // accepts; baseline would drop B-frames entirely and main+high - // are functionally equivalent on the wire for ~1080p60. - match encoder { - "nvenc" => { - enc_type = "jim_nvenc"; - settings_json_1080 = encoder_settings_nvenc(bitrate_for_track(bandwidth, tracks, 0)); - settings_json_720 = encoder_settings_nvenc(bitrate_for_track(bandwidth, tracks, 1)); - settings_json_480 = encoder_settings_nvenc(bitrate_for_track(bandwidth, tracks, 2)); - } - "amd" => { - enc_type = "h264_texture_amf"; - settings_json_1080 = encoder_settings_amd(bitrate_for_track(bandwidth, tracks, 0)); - settings_json_720 = encoder_settings_amd(bitrate_for_track(bandwidth, tracks, 1)); - settings_json_480 = encoder_settings_amd(bitrate_for_track(bandwidth, tracks, 2)); - } - "qsv" => { - enc_type = "obs_qsv11"; - settings_json_1080 = encoder_settings_qsv(bitrate_for_track(bandwidth, tracks, 0)); - settings_json_720 = encoder_settings_qsv(bitrate_for_track(bandwidth, tracks, 1)); - settings_json_480 = encoder_settings_qsv(bitrate_for_track(bandwidth, tracks, 2)); - } - _ => { - enc_type = "obs_x264"; - settings_json_1080 = encoder_settings_x264(bitrate_for_track(bandwidth, tracks, 0)); - settings_json_720 = encoder_settings_x264(bitrate_for_track(bandwidth, tracks, 1)); - settings_json_480 = encoder_settings_x264(bitrate_for_track(bandwidth, tracks, 2)); - } - } // Per-call config_id: a monotonic-ish value derived from the // process clock means OBS treats each config-fetch as fresh @@ -2184,7 +2160,7 @@ fn encoder_settings_x264(bitrate: u32) -> String { fn encoder_settings_nvenc(bitrate: u32) -> String { format!( - r#"{{"bitrate":{b},"rate_control":"CBR","keyint_sec":2,"profile":"main","preset":"p5","tune":"hq","multipass":"qres"}}"#, + r#"{{"bitrate":{b},"rate_control":"CBR","keyint_sec":2,"profile":"main","preset":"p5","preset2":"p5","tune":"hq","multipass":"qres"}}"#, b = bitrate ) } @@ -2203,6 +2179,185 @@ fn encoder_settings_qsv(bitrate: u32) -> String { ) } +/// Encoder for InstantClone's own configs: the family (which settings +/// keys apply), the exact libobs id, and why, for the log. +struct EncoderChoice { + family: &'static str, + id: &'static str, + reason: String, +} + +/// libobs H.264 encoder ids per family, newest first. OBS 31 moved NVENC to +/// `obs_nvenc_h264_tex` and QSV to `obs_qsv11_v2`; OBS 30.2, the first with +/// Enhanced Broadcasting, only has `jim_nvenc` and `obs_qsv11`. +fn encoder_ids(family: &str) -> &'static [&'static str] { + match family { + "nvenc" => &["obs_nvenc_h264_tex", "jim_nvenc"], + "amd" => &["h264_texture_amf"], + "qsv" => &["obs_qsv11_v2", "obs_qsv11"], + _ => &["obs_x264"], + } +} + +/// Canonical family name for a config URL's `encoder=` value. +fn encoder_family(name: &str) -> &'static str { + match name { + "nvenc" => "nvenc", + "amd" => "amd", + "qsv" => "qsv", + _ => "x264", + } +} + +/// An explicit `encoder=` in the config URL wins, otherwise the GPU OBS +/// composites on. Either way the id must be one OBS's log lists as +/// available: naming an encoder OBS doesn't have stops the stream from +/// starting at all, which is worse than x264 being heavy. `available` is +/// None when no OBS log could be read; then an explicit choice is trusted +/// and a guessed one falls back to x264. +fn choose_encoder(query: &str, body: &str, available: Option<&[String]>) -> EncoderChoice { + let x264 = |reason: String| EncoderChoice { + family: "x264", + id: "obs_x264", + reason, + }; + let explicit = config::parse_form(query) + .get("encoder") + .map(|name| encoder_family(name)); + let (family, source) = match explicit { + Some(family) => (family, "set in the config URL"), + None => match crate::local_eb_config::gpu_encoder_family(body) { + Some(family) => (family, "the GPU OBS runs on"), + None => return x264("OBS reported no NVIDIA, AMD or Intel GPU".into()), + }, + }; + if family == "x264" { + return x264(source.into()); + } + let listed = |id: &str| available.is_some_and(|list| list.iter().any(|e| e == id)); + match ( + available, + encoder_ids(family).iter().copied().find(|id| listed(id)), + ) { + (_, Some(id)) => EncoderChoice { + family, + id, + reason: format!("{source}; {id} is available in OBS"), + }, + // Can't check: the oldest id, which every OBS with Enhanced + // Broadcasting still registers (newer ones as a compatibility alias). + (None, None) if explicit.is_some() => EncoderChoice { + family, + id: encoder_ids(family).last().copied().unwrap_or("obs_x264"), + reason: source.into(), + }, + (Some(_), None) => x264(format!("OBS lists no {family} encoder")), + (None, None) => x264("OBS's log couldn't be read to check GPU encoders".into()), + } +} + +/// `bandwidth` from the config URL's query, in kbps, when it has one. +fn config_url_bandwidth(query: &str) -> Option { + config::parse_form(query) + .get("bandwidth") + .and_then(|s| s.parse::().ok()) + .map(|kbps| kbps.clamp(1500, 50000)) +} + +/// Log, once per config request, which enabled vertical destinations will +/// get nothing this stream and why, so a streamer isn't left guessing. +fn log_vertical_destinations_waiting( + ctrl: &Arc, + settings: &Arc>, + why: &str, +) { + let names: Vec = settings + .borrow() + .destinations + .iter() + .filter(|d| d.enabled && d.wants_vertical()) + .map(|d| d.name.clone()) + .collect(); + if names.is_empty() { + return; + } + ctrl.log(format!( + "[OBS multitrack] {} set to Vertical, but {why}, so it gets nothing this stream.", + names.join(", ") + )); +} + +/// Encoder id and settings for one track. x264 uses `preset` strings like +/// "veryfast"; nvenc uses "p1"-"p7"; AMD uses similar tier names. +/// `profile=main` is what every Twitch / YouTube / Kick decoder accepts; +/// baseline would drop B-frames entirely and main+high are functionally +/// equivalent on the wire for ~1080p60. +fn encoder_for(choice: &EncoderChoice, kbps: u32) -> crate::local_eb_config::TrackEncoder { + let settings_json = match choice.family { + "nvenc" => encoder_settings_nvenc(kbps), + "amd" => encoder_settings_amd(kbps), + "qsv" => encoder_settings_qsv(kbps), + _ => encoder_settings_x264(kbps), + }; + crate::local_eb_config::TrackEncoder { + encoder_type: choice.id, + settings_json, + } +} + +/// No Twitch destination, so there is no Twitch config to proxy: build one +/// from the canvases OBS described (see `local_eb_config`). The main canvas +/// gets one track, and OBS's Additional canvas gets one when it sends one, +/// which is what feeds destinations set to Vertical. Falls back to the +/// static ladder when OBS didn't describe its canvases. +fn obs_multitrack_config_local( + body: &str, + query: &str, + ctrl: &Arc, + settings: &Arc>, +) -> String { + let canvases = crate::local_eb_config::parse_canvases(body); + let available = crate::obs_register::available_video_encoders(); + let choice = choose_encoder(query, body, available.as_deref()); + // Explicit URL value, then OBS's own bandwidth cap, then a default + // that keeps the main track at a normal 1080p60 bitrate. + let bandwidth = config_url_bandwidth(query) + .or_else(|| crate::local_eb_config::requested_budget_kbps(body)) + .unwrap_or_else(|| crate::local_eb_config::default_budget_kbps(&canvases)); + let ingest_port = settings.borrow().ingest_port; + let built = crate::local_eb_config::build(&canvases, bandwidth, ingest_port, |kbps| { + encoder_for(&choice, kbps) + }); + let Some((config, plan)) = built else { + ctrl.log( + "[OBS multitrack] no Twitch destination, and OBS didn't describe its canvases - \ + using the default 1080p ladder.", + ); + crate::trace::log( + "OBS_MULTITRACK", + "no twitch destination, no canvases - static fallback", + ); + return obs_multitrack_config_static(query, body, settings); + }; + ctrl.log(format!( + "[OBS multitrack] no Twitch destination - building the config here: {}, \ + {bandwidth} kbps total, encoded with {} ({})", + plan.describe(), + choice.family, + choice.reason + )); + crate::trace::log("OBS_MULTITRACK", "local config built from OBS's canvases"); + if !plan.feeds_vertical() { + log_vertical_destinations_waiting( + ctrl, + settings, + "OBS sent no 9:16 canvas (in OBS: Settings → Stream → Enhanced Broadcasting → \ + Additional canvas, pick your vertical canvas, for example Aitum Vertical)", + ); + } + config +} + fn twitch_ingests_json() -> String { let mut out = String::from("["); for (i, (slug, label)) in config::twitch_ingests().iter().enumerate() { @@ -5525,10 +5680,64 @@ mod tests { s.ingest_port = 1935; s.ingest_key = "abc123def".into(); let (tx, _rx) = watch::channel(s); - let cfg = obs_multitrack_config_static("encoder=x264&tracks=1", &Arc::new(tx)); + let cfg = obs_multitrack_config_static("encoder=x264&tracks=1", "", &Arc::new(tx)); assert!(cfg.contains("/live/{stream_key}")); } + #[test] + fn own_configs_use_the_gpu_encoder_only_when_obs_has_it() { + let amd_request = r#"{"capabilities":{"gpu":[{"vendor_id":4098}]}}"#; + let with_amf = vec!["h264_texture_amf".to_string(), "obs_x264".to_string()]; + let without_amf = vec!["obs_x264".to_string()]; + assert_eq!( + choose_encoder("", amd_request, Some(&with_amf)).family, + "amd" + ); + let nvidia_request = r#"{"capabilities":{"gpu":[{"vendor_id":4318}]}}"#; + let obs_30_2 = vec!["jim_nvenc".to_string(), "obs_x264".to_string()]; + assert_eq!( + choose_encoder("", nvidia_request, Some(&obs_30_2)).id, + "jim_nvenc", + "OBS 30.2 has only the old NVENC id" + ); + assert_eq!( + choose_encoder("encoder=nvenc", "", Some(&with_amf)).family, + "x264", + "an explicit encoder OBS doesn't list must not stop the stream" + ); + assert_eq!( + choose_encoder("", amd_request, Some(&without_amf)).family, + "x264", + "an AMD card without AMF in OBS must not get an encoder OBS can't create" + ); + assert_eq!(choose_encoder("", amd_request, None).family, "x264"); + assert_eq!(choose_encoder("", "", Some(&with_amf)).family, "x264"); + assert_eq!( + choose_encoder("encoder=qsv", amd_request, None).id, + "obs_qsv11", + "with no log to check, an explicit encoder is trusted" + ); + } + + #[test] + fn static_ladder_keeps_encoder_ids_and_bitrate_split() { + let (tx, _rx) = watch::channel(crate::config::Settings::defaults()); + let cfg = obs_multitrack_config_static( + "encoder=x264&tracks=3&bandwidth=10000", + "", + &Arc::new(tx), + ); + assert_eq!(cfg.matches(r#""type":"obs_x264""#).count(), 3); + assert!( + cfg.contains(r#""bitrate":6000"#), + "top rung keeps its 60% share" + ); + assert!( + !cfg.contains(r#""canvas_index":1"#), + "the ladder never names a second canvas" + ); + } + #[test] fn all_ingest_endpoint_auths_collects_every_endpoint() { // Real Twitch shape: an RTMP and an RTMPS endpoint. OBS picks one by its diff --git a/web/index.html b/web/index.html index 08ef58c..0f32e73 100644 --- a/web/index.html +++ b/web/index.html @@ -934,9 +934,10 @@ .dcard-switch.on>span{transform:translateX(15px);background:#fff} .dcard-status{display:inline-flex;align-items:center;justify-content:center;gap:5px;font-size:11px;font-weight:500; padding:3px 8px;border-radius:999px;background:var(--surface-3);border:1px solid var(--line); - color:var(--fg-3);flex:0 0 auto;min-width:84px; + color:var(--fg-3);flex:0 0 auto;min-width:84px;max-width:44%; 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} .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)} @@ -944,15 +945,15 @@ background:color-mix(in oklch,var(--live) 8%,var(--surface-3))} .dcard-status.s-live .d-dot{background:var(--live);box-shadow:0 0 6px var(--live); animation:hpulse 1.6s infinite;--sc:var(--live)} -/* "Waiting for Dual Format": a vertical destination that holds a - connection but deliberately sends no video until OBS produces the 9:16 - canvas. Amber + a slow pulse so it reads as "attention" not "error". */ +/* Vertical destination waiting on OBS's 9:16 canvas: it deliberately + sends no video until the canvas arrives. Amber + a slow pulse so it + reads as "attention" not "error". */ .dcard-status.s-wait{color:var(--warn,#d6a23a);border-color:color-mix(in oklch,var(--warn,#d6a23a) 32%,var(--line)); background:color-mix(in oklch,var(--warn,#d6a23a) 9%,var(--surface-3))} .dcard-status.s-wait .d-dot{background:var(--warn,#d6a23a);box-shadow:0 0 6px var(--warn,#d6a23a); animation:hpulse 2.4s infinite;--sc:var(--warn,#d6a23a)} /* Orientation indicator: small glyph in the head; hover/focus shows the - full format + Dual Format requirement via the native tooltip. */ + full format and, for vertical, what it needs, via the native tooltip. */ .dcard-format{display:inline-flex;align-items:center;justify-content:center; width:24px;height:24px;border-radius:7px;flex:0 0 auto;color:var(--fg-3); background:var(--surface-3);border:1px solid var(--line);cursor:help; @@ -975,7 +976,10 @@ .dcard-rate-u{font-size:11px;color:var(--fg-3);font-family:'JetBrains Mono',monospace} .dcard-foot-meta{font-size:10px;color:var(--fg-4);font-family:'JetBrains Mono',monospace;white-space:nowrap} /* Detected resolution + codec of the forwarded track (e.g. 1080x1920 · H.264). */ -.dcard-res{font-size:10px;color:var(--fg-4);font-family:'JetBrains Mono',monospace;margin-top:3px;letter-spacing:.02em} +.dcard-res{font-size:10px;color:var(--fg-4);font-family:'JetBrains Mono',monospace;margin-top:3px;letter-spacing:.02em;min-height:1.5em} +/* 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} /* Edit / remove: subtle glass buttons over the trace, revealed on hover. */ /* Reveal via slide + visibility, NOT opacity. Animating opacity on a @@ -1547,7 +1551,7 @@ .logs{background:#050608;border:1px solid var(--line);border-radius:10px;padding:10px 14px; font-family:'JetBrains Mono',monospace;font-size:12px; min-height:420px;max-height:420px;overflow-y:auto;color:var(--fg-2); - line-height:1.6;margin:0} + line-height:1.6;margin:0;white-space:pre-wrap;overflow-wrap:anywhere} .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)} @@ -1850,7 +1854,7 @@ - + @@ -2198,17 +2202,20 @@

New destination

- -
- Vertical reuses the 9:16 canvas that Twitch Dual Format produces. InstantClone standardizes on that one canvas as its vertical source: set Twitch Dual Format up once (Enhanced Broadcasting + the Aitum Vertical canvas, per Twitch's guide - it is not a single OBS toggle) and every vertical destination here reuses it - no second scene, no extra encoder. With Dual Format off there is no vertical canvas, so this destination just waits and sends nothing. How to set up Twitch Dual Format & Enhanced Broadcasting →
For YouTube, paste the vertical stream key from YouTube's Dual stream → Select key → Encoder. +
+
+
Crash protection
+
+ +
+ +
+ + + + + + - - + +
+
- + + +
-
X
+
No events yet
As soon as OBS connects, you'll see ingest, egress, and destination events tail here in real time.
@@ -3231,6 +3348,20 @@

New destination

+ + - - + diff --git a/web/integrations.js b/web/integrations.js new file mode 100644 index 0000000..887a966 --- /dev/null +++ b/web/integrations.js @@ -0,0 +1,1616 @@ +// Integrations tab: the module list, the catalog, the module editor, the +// step builder, connections (Discord, Twitch, phone) and recipes. +// +// Loaded after the dashboard script, so it reuses its helpers ($, toast, +// staggerIn, fitPaneCard) and its components: a module is a .dcard, every +// editor is the destination modal (.ic-modal + .dest-form). All user text +// goes through esc() before it touches innerHTML. +(function(){ +'use strict'; + +// ---------------------------------------------------------------- helpers + +const esc = v => String(v == null ? '' : v).replace(/[&<>"']/g, + c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const clone = v => JSON.parse(JSON.stringify(v)); +const store = { + get(k){ try { return localStorage.getItem(k); } catch(_){ return null; } }, + set(k, v){ try { localStorage.setItem(k, v); } catch(_){} }, +}; + +async function api(path, body){ + try { + const opts = body === undefined ? {} : { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), + }; + const r = await fetch(path, opts); + return await r.json(); + } catch(_){ + return {ok:false, error:'The app did not answer - is InstantClone still running?'}; + } +} + +const PATHS = { + shield:'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', + bubble:'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z', + phone:'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zM11 18h2', + link:'M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7', + bookmark:'M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z', + clock:'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2', + cut:'M6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM20 4L8.1 15.9M14.5 14.5L20 20M8.1 8.1L12 12', + signal:'M2 2l20 20M8.5 16.5a5 5 0 0 1 7 0M5 12.9a10 10 0 0 1 5.2-2.8M19 12.9a10 10 0 0 0-2-1.5M12 20h.01', + file:'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6', + steps:'M4 6h16M4 12h10M4 18h7', + plus:'M12 5v14M5 12h14', + check:'M20 6L9 17l-5-5', + chev:'M6 9l6 6 6-6', + up:'M18 15l-6-6-6 6', + down:'M6 9l6 6 6-6', + x:'M18 6L6 18M6 6l12 12', + copy:'M9 9h11v11H9zM5 15H4V4h11v1', + play:'M7 4l13 8-13 8z', + download:'M12 3v12M7 10l5 5 5-5M5 21h14', + film:'M3 5h18v14H3zM7 5v14M17 5v14M3 9h4M17 9h4M3 15h4M17 15h4', + terminal:'M4 17l6-5-6-5M12 19h8', + warn:'M12 9v4M12 17h.01M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z', + bolt:'M13 2L3 14h9l-1 8 10-12h-9l1-8z', +}; +const svg = (name, extra) => + ``; + +// What each step kind is, how it looks, and which parameter holds its text. +const KINDS = { + discord: {label:'Discord', tag:'SEND', c:'#a5b4fc', icon:'bubble', text:'text'}, + chat: {label:'Twitch chat', tag:'SEND', c:'#a78bfa', icon:'bubble', text:'text'}, + phone: {label:'Phone push', tag:'SEND', c:'#f9a8d4', icon:'phone', text:'text'}, + http: {label:'Web request', tag:'SEND', c:'#fcd34d', icon:'link', text:'body'}, + wait: {label:'Wait', tag:'WAIT', c:'#c3cad1', icon:'clock'}, + wait_delay: {label:'Wait for the delay', tag:'WAIT', c:'#c3cad1', icon:'clock'}, + if: {label:'If / otherwise',tag:'IF', c:'#f0a53a', icon:'bolt'}, + stop: {label:'Stop', tag:'STOP', c:'#f2665a', icon:'x'}, + delay_action: {label:'Delay action', tag:'STREAM', c:'#86efac', icon:'cut'}, + marker: {label:'VOD marker', tag:'TWITCH', c:'#a78bfa', icon:'bookmark', text:'description'}, + clip: {label:'Clip', tag:'TWITCH', c:'#a78bfa', icon:'film'}, + program: {label:'Run a program', tag:'RUN', c:'#fcd34d', icon:'terminal'}, + file: {label:'Write a file', tag:'FILE', c:'#fcd34d', icon:'file', text:'text'}, + set_var: {label:'Remember a value', tag:'VALUE', c:'#93c5fd', icon:'steps', text:'value'}, + counter: {label:'Counter', tag:'VALUE', c:'#93c5fd', icon:'steps'}, +}; +// Which step a card previews: the first one that says something. +const PREVIEW_ORDER = ['discord','chat','phone','marker','http','file','clip','delay_action','program']; +const CATEGORY = {alerts:'Alerts', chat:'Chat', auto:'Automation', own:'Your own'}; +const DELAY_ACTIONS = { + cut:'Cut the delay', arm:'Set the delay', activate:'Turn the delay on', toggle:'Toggle the delay', + cut_after:'Cut after this airs', end_hold:'End the reconnect screen', +}; +const OPS = { + is:'is', is_not:'is not', contains:'contains', not_contains:'does not contain', starts_with:'starts with', + greater:'is more than', less:'is less than', empty:'is empty', not_empty:'is not empty', +}; +// Known values for event filters, so "Only if" offers choices. +const FILTER_CHOICES = {reason:['crash','freeze'], stopped:['yes','no'], protected:['yes','no']}; + +function walk(steps, fn, path){ + (steps || []).forEach((s, i) => { + const here = (path || []).concat(i); + fn(s, here); + walk(s.then, fn, here.concat('then')); + walk(s.else, fn, here.concat('else')); + }); +} +function allSteps(integration){ + const out = []; + (integration.handlers || []).forEach(h => walk(h.steps, s => out.push(s))); + return out; +} +function primaryStep(handler){ + const found = []; + walk(handler && handler.steps, s => found.push(s)); + for (const kind of PREVIEW_ORDER){ + const s = found.find(x => x.type === kind); + if (s) return s; + } + return null; +} +function presetOf(i){ + const cat = S.data && S.data.catalog; + return cat ? cat.presets.find(p => p.id === i.preset) : null; +} +function categoryOf(i){ + const p = presetOf(i); + return p ? p.category : (i.preset === 'stream_alerts' ? 'alerts' : 'own'); +} +function eventOf(id){ return (S.data && S.data.events.find(e => e.id === id)) || null; } +function channelName(id){ + const c = S.data && S.data.connections.discord.find(x => x.id === id); + return c ? '#' + c.name : 'no channel'; +} +function triggerLabel(t){ + if (!t) return ''; + switch (t.type){ + case 'event': { const e = eventOf(t.event); return e ? e.label : t.event; } + case 'chat_command': return t.command || '!command'; + case 'chat_message': return 'Chat message'; + case 'timer': return `Every ${Math.round((t.every_ms||0)/60000)} min`; + case 'webhook': return 'Web call'; + } + return t.type; +} + +// Sample values for previews: the variables this handler can use. +function varsFor(handler){ + const d = S.data; + const list = d.vars.global.slice(); + const t = handler && handler.trigger; + if (t && t.type === 'event'){ const e = eventOf(t.event); if (e) list.unshift(...e.vars); } + if (t && (t.type === 'chat_command' || t.type === 'chat_message')) list.unshift(...d.vars.chat); + if (t && t.type === 'webhook') list.unshift(...d.vars.web); + walk(handler && handler.steps, s => { + if (s.type === 'http'){ + const n = (s.params.save_as || '').trim() || 'response'; + list.push({name:n + '.status', sample:'200'}, {name:n + '.ok', sample:'yes'}, {name:n + '.body', sample:'{...}'}); + } + if (s.type === 'clip') list.push({name:'clip.url', sample:'https://clips.twitch.tv/BraveSnipe'}); + if (s.type === 'counter' && s.params.name) list.push({name:'counter.' + s.params.name, sample:'2'}); + if (s.type === 'set_var' && s.params.name) list.push({name:s.params.name, sample:s.params.value || ''}); + }); + const seen = new Set(); + return list.filter(v => !seen.has(v.name) && seen.add(v.name)); +} +function sampleMap(handler){ + const m = {}; + varsFor(handler).forEach(v => { m[v.name] = v.sample; }); + return m; +} +// Mirrors src/integrations/template.rs, for previews. +function renderSample(text, vars){ + return String(text || '').replace(/\{\{([A-Za-z0-9_.]+)\}\}|\{([A-Za-z0-9_.]{1,64})(?:\|([^}]*))?\}/g, + (all, escaped, name, fallback) => { + if (escaped) return '{' + escaped + '}'; + const v = vars[name] == null ? '' : String(vars[name]); + if (fallback !== undefined && (v === '' || v === '0')) return fallback; + return v; + }); +} +function highlightVars(text){ + return esc(text).replace(/\{[A-Za-z0-9_.]{1,64}(?:\|[^}]*)?\}/g, m => `${m}`); +} +function fmtAgo(ms){ + if (!ms) return 'never'; + const s = Math.max(0, Math.round((Date.now() - ms) / 1000)); + if (s < 60) return 'just now'; + if (s < 3600) return Math.round(s / 60) + ' min ago'; + if (s < 86400) return Math.round(s / 3600) + ' h ago'; + return Math.round(s / 86400) + ' days ago'; +} +function fmtClock(ms){ + return new Date(ms).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); +} +function randomToken(){ + const b = new Uint8Array(16); + crypto.getRandomValues(b); + return Array.from(b, x => x.toString(16).padStart(2, '0')).join(''); +} + +// ---------------------------------------------------------------- previews + +// HTML for what a step sends, styled like where it lands. +function previewHtml(step, handler, big){ + const cls = 'ig-pv' + (big ? ' big' : ''); + if (!step){ + const n = handler ? (handler.steps || []).length : 0; + return `
${svg('steps')}${n} step${n === 1 ? '' : 's'}
`; + } + const vars = sampleMap(handler); + const k = KINDS[step.type] || {}; + const text = k.text ? renderSample(step.params[k.text], vars) : ''; + const ask = handler && handler.trigger && handler.trigger.type === 'chat_command' + ? (handler.trigger.command || '!command') + : handler && handler.trigger && handler.trigger.type === 'chat_message' ? (handler.trigger.pattern || 'hey') : ''; + switch (step.type){ + case 'discord': { + const ping = step.params.ping === 'here' ? '@here ' : step.params.ping === 'everyone' ? '@everyone ' : ''; + return `
${svg('shield')} + InstantCloneAPP
${esc(ping + text)}
`; + } + case 'chat': + return `
${ask ? `
viewer: ${esc(ask)}
` : ''} +
${esc((S.data.twitch.bot.login && step.params.as === 'bot') ? S.data.twitch.bot.login : (S.data.twitch.main.login || 'you'))}: ${esc(text)}
`; + case 'phone': + return `
+ INSTANTCLONE · now${esc(renderSample(step.params.title, vars) || 'InstantClone')}
${esc(text)}
`; + case 'marker': + return `
${esc(text || 'Marker')} · 1:02:14 +
`; + case 'http': { + const body = text.trim(); + let shown = body ? body : `${step.params.method || 'POST'} ${step.params.url || '(address to set)'}`; + try { shown = JSON.stringify(JSON.parse(body), null, 1).replace(/\n\s*/g, ' '); } catch(_){} + return `
${esc(shown).replace(/"([^&]*)":/g, '"$1":')}
`; + } + case 'file': + return `
${esc((step.params.path || 'file.txt').split(/[\\/]/).pop())}${esc(text)}
`; + case 'clip': + return `
${svg('film')}Clips the last moments, and hands you the link
`; + case 'delay_action': + return `
${svg('cut')}${esc(DELAY_ACTIONS[step.params.action] || 'Delay action')}
`; + case 'program': + return `
${svg('terminal')}${esc('Runs ' + ((step.params.path || '').split(/[\\/]/).pop() || 'a program'))}
`; + } + return `
${svg(k.icon || 'steps')}${esc(k.label || step.type)}
`; +} + +// ---------------------------------------------------------------- state + +const S = { + data:null, visible:false, filter:'all', search:'', peek:null, + view: store.get('ig-view') === 'list' ? 'list' : 'grid', + pollTimer:null, activityOpen:false, +}; + +async function load(){ + const d = await api('/integrations'); + if (!d || d.ok === false || !d.integrations){ + $('ig-root').innerHTML = `
⚠${esc((d && d.error) || 'Integrations are not available right now.')}
`; + return; + } + S.data = d; + render(); +} + +async function poll(){ + if (!S.visible && !modalOpen()) return; + const a = await api('/integrations/activity'); + if (!S.data || !a || !a.activity) return; + const loginWas = JSON.stringify(S.data.twitch.login); + const flowWas = !!S.data.twitch.login; + const changed = JSON.stringify(a.stats) !== JSON.stringify(S.data.stats) + || JSON.stringify(a.twitch) !== JSON.stringify(S.data.twitch); + S.data.activity = a.activity; + S.data.stats = a.stats; + S.data.twitch = a.twitch; + if (flowWas !== !!a.twitch.login) schedulePoll(); + if (S.visible && !modalOpen()){ + if (changed) render(); else renderActivity(); + } + if (Modal.kind === 'connections' && (changed || loginWas !== JSON.stringify(a.twitch.login))) Conn.render(); +} + +function schedulePoll(){ + clearInterval(S.pollTimer); + const fast = S.data && S.data.twitch && S.data.twitch.login; + S.pollTimer = setInterval(() => { poll(); }, fast ? 2000 : 3500); +} + +// ---------------------------------------------------------------- pane + +function stat(i){ return (S.data.stats || {})[i.id] || null; } + +function cardInfo(i){ + const h = (i.handlers || []).find(x => x.enabled) || (i.handlers || [])[0]; + const step = primaryStep(h); + const k = step ? KINDS[step.type] : null; + const st = stat(i); + let where = 'Your own · ' + allSteps(i).length + ' steps'; + if (step && step.type === 'discord') where = channelName(step.params.connection); + else if (step && step.type === 'chat') where = 'Twitch chat'; + else if (step && k) where = k.label; + const failing = i.enabled && st && st.failing > 0; + return { + h, step, + dc: k ? k.c : 'var(--accent)', + icon: k ? k.icon : 'steps', + where, failing, + last: st && st.last_ms ? fmtAgo(st.last_ms) : 'never', + summary: step && k && k.text ? step.params[k.text] : triggerLabel(h && h.trigger), + }; +} + +function visibleIntegrations(){ + const q = S.search.trim().toLowerCase(); + return S.data.integrations.filter(i => + (S.filter === 'all' || categoryOf(i) === S.filter) + && (!q || i.name.toLowerCase().includes(q) || JSON.stringify(i.handlers).toLowerCase().includes(q))); +} + +function render(){ + const root = $('ig-root'); + if (!root || !S.data) return; + const d = S.data; + const on = d.integrations.filter(i => i.enabled).length; + const failing = d.integrations.filter(i => cardInfo(i).failing); + const empty = d.integrations.length === 0; + root.innerHTML = ` +
+
+
+
Integrations
+
${empty ? 'Alerts, chat commands and automations. Each card shows exactly what it sends.' + : `${on} on${failing.length ? ` · ${failing.length} need${failing.length === 1 ? 's' : ''} attention` : ''}. Each card shows exactly what it sends.`}
+
+
+ + + +
+
+ ${noticesHtml(failing)} + ${connectionsHtml()} + ${empty ? emptyHtml() : `${toolbarHtml()}
${itemsHtml()}
`} +
+ Recent activity +
+
+
`; + renderActivity(); + if (typeof fitPaneCard === 'function') fitPaneCard(); +} + +function noticesHtml(failing){ + const d = S.data; + const out = []; + if (d.twitch.notice){ + out.push(`
⚠ + ${esc(d.twitch.notice)} +
`); + } + failing.slice(0, 2).forEach(i => { + const rec = (d.activity || []).find(r => r.integration_id === i.id && r.status === 'failed'); + const step = rec && rec.steps.find(s => s.status === 'failed'); + out.push(`
⚠ + ${esc(i.name)} ${step ? 'failed: ' + esc(step.detail) : 'is failing'}. Nothing else is affected. +
`); + }); + return out.join(''); +} + +function connectionsHtml(){ + const c = S.data.connections, t = S.data.twitch; + const discord = c.discord.length; + const twitchOn = !!t.main.login; + const twitchLive = t.main.chat === 'connected'; + const phoneOn = !!c.phone.topic; + const pill = (tab, cls, k, v) => + ``; + return `
Connections + ${pill('discord', discord ? 'ok' : '', 'Discord', discord ? `${discord} channel${discord === 1 ? '' : 's'}` : 'Add')} + ${pill('twitch', twitchOn ? (twitchLive ? 'ok' : 'warn') : '', 'Twitch', twitchOn ? (twitchLive ? t.main.login : t.main.login + ' · ' + t.main.chat) : (t.available ? 'Connect' : 'Unavailable'))} + ${pill('phone', phoneOn ? 'ok' : '', 'Phone', phoneOn ? c.phone.topic : 'Connect')} +
`; +} + +function toolbarHtml(){ + const counts = {all:S.data.integrations.length}; + S.data.integrations.forEach(i => { const c = categoryOf(i); counts[c] = (counts[c] || 0) + 1; }); + const tab = (id, label) => ``; + const view = (id, icon, label) => ``; + return `
+
+ ${tab('all', 'All')}${tab('alerts', 'Alerts')}${tab('chat', 'Chat')}${tab('auto', 'Automation')}${tab('own', 'Your own')} +
+ +
+ ${view('grid', 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', 'Grid')}${view('list', 'M4 6h16M4 12h16M4 18h16', 'List')} +
+
`; +} + +function itemsHtml(){ + const items = visibleIntegrations(); + if (!items.length){ + return `
Nothing here${S.search ? ' matches "' + esc(S.search) + '"' : ' yet'}.
`; + } + return S.view === 'grid' ? gridHtml(items) : listHtml(items); +} + +function switchHtml(i){ + return ``; +} + +function gridHtml(items){ + return `
${items.map(i => { + const c = cardInfo(i); + return `
+
+ ${previewHtml(c.step, c.h, false)} + ${c.failing ? 'Failing' : ''} +
+
+ ${svg(c.icon)} +
${esc(i.name)}
+
${esc(c.where)} · ${esc(c.last)}
+ ${switchHtml(i)} +
+
`; + }).join('')} +
`; +} + +function listHtml(items){ + return `
${items.map(i => { + const c = cardInfo(i); + const open = S.peek === i.id; + const status = c.failing ? ['s-wait', 'Failing'] : i.enabled ? ['s-ready', 'On'] : ['', 'Off']; + return `
+
+ ${svg(c.icon)} +
${esc(i.name)}
+
${esc(c.where)}
+ ${esc(c.summary || '')} + ${esc(c.last)} +
${status[1]}
+ ${switchHtml(i)} + +
+ ${open ? `
${previewHtml(c.step, c.h, true)}
+
+ + +
` : ''} +
`; + }).join('')} +
`; +} + +function emptyHtml(){ + const packs = S.data.catalog.packs; + const preset = id => S.data.catalog.presets.find(p => p.id === id); + return `
+
What should InstantClone do for you?
+
Pick a pack to switch on a few integrations at once. Each shows what it sends, and you can change or remove anything later.
+
+ ${packs.map((p, n) => ``).join('')} +
+ +
`; +} + +function renderActivity(){ + const box = $('ig-activity'); + if (!box || !S.data) return; + const rows = (S.data.activity || []).slice(0, 40); + const count = $('ig-activity-count'); + if (count) count.textContent = rows.length ? `· last run ${fmtAgo(rows[0].at_ms)}` : '· nothing has run yet'; + box.innerHTML = rows.length ? rows.map(r => { + const failed = r.steps.find(s => s.status === 'failed'); + const detail = failed ? failed.label + ': ' + failed.detail + : r.status === 'busy' ? 'skipped: already running' + : r.steps.filter(s => s.status === 'ok').map(s => s.label).join(' · ') || r.trigger; + return `
${esc(fmtClock(r.at_ms))}${esc(r.name)}${r.test ? ' (test)' : ''} + ${esc(r.trigger)} → ${esc(detail)}${esc(r.status)}
`; + }).join('') : '
Runs show up here, with every step and what it answered.
'; +} + +// ---------------------------------------------------------------- actions + +function find(id){ return S.data.integrations.find(i => i.id === id); } + +async function toggle(id){ + const i = find(id); + if (!i) return; + const r = await api('/integrations/toggle', {id, enabled:!i.enabled}); + if (r.ok){ i.enabled = !i.enabled; render(); } + else { toast(r.error || 'Could not switch it', 'err', 5000); if (!i.enabled) openEditor(i); } +} + +async function addFrom(body, openAfter){ + const r = await api('/integrations/add', body); + if (!r.ok){ toast(r.error || 'Could not add it', 'err', 5000); return; } + await load(); + const added = (r.ids || []).map(find).filter(Boolean); + toast(added.length > 1 ? `Added ${added.length} integrations` : `Added ${added[0] ? added[0].name : ''}`, 'ok'); + const unfinished = added.find(i => !i.enabled); + if (openAfter && unfinished){ Modal.close(true); openEditor(unfinished); return; } + if (unfinished) toast(`${unfinished.name} needs one more detail before it can switch on`, 'info', 4500); + if (Modal.kind === 'catalog') Catalog.render(); +} + +async function duplicate(id){ + const r = await api('/integrations/duplicate', {id}); + if (!r.ok){ toast(r.error || 'Could not duplicate it', 'err'); return; } + await load(); + toast('Duplicated', 'ok'); +} + +async function remove(id){ + const i = find(id); + if (!i || !confirm(`Delete "${i.name}"? This can't be undone.`)) return; + const r = await api('/integrations/delete', {id}); + if (!r.ok){ toast(r.error || 'Could not delete it', 'err'); return; } + Modal.close(); + await load(); + toast('Deleted', 'ok'); +} + +function openEditor(i){ + if (i.preset) Editor.open(i); else Builder.open(i); +} + +// ---------------------------------------------------------------- modal shell + +// One modal at a time, built on the destination editor's shell. Escape and +// the backdrop close it; `onClose` lets an editor ask before losing edits. +const Modal = { + el:null, kind:null, onClose:null, + open(kind, html, width){ + this.close(true); + const wrap = document.createElement('div'); + wrap.className = 'ic-modal'; + wrap.setAttribute('role', 'dialog'); + wrap.setAttribute('aria-modal', 'true'); + wrap.innerHTML = `
+
${html}
`; + document.body.appendChild(wrap); + wrap.addEventListener('click', onClick); + wrap.addEventListener('input', onInput); + wrap.addEventListener('change', onInput); + this.el = wrap; + this.kind = kind; + this.onClose = null; + document.addEventListener('keydown', onModalKey); + return wrap; + }, + body(html){ + if (this.el) this.el.querySelector('.dest-form').innerHTML = html; + }, + close(force){ + if (!this.el) return; + if (!force && this.onClose && this.onClose() === false) return; + this.el.remove(); + this.el = null; + this.kind = null; + document.removeEventListener('keydown', onModalKey); + }, +}; +function modalOpen(){ return !!Modal.el; } +function onModalKey(e){ + if (e.key === 'Escape' && !e.defaultPrevented){ e.preventDefault(); Modal.close(); } +} +function modalHead(icon, color, title, hint, extra){ + return `
+ ${svg(icon)} +

${title}

${hint}
+ ${extra || ''} + +
`; +} + +// ---------------------------------------------------------------- catalog + +const Catalog = { + cat:'rec', + open(){ + this.cat = 'rec'; + Modal.open('catalog', '', 1040); + this.render(); + }, + render(){ + const cat = S.data.catalog; + const counts = id => id === 'packs' ? cat.packs.length + : cat.presets.filter(p => id === 'rec' ? p.recommended : p.category === id).length; + const nav = [['rec', 'Recommended'], ['alerts', 'Alerts'], ['chat', 'Chat'], ['auto', 'Automation'], ['packs', 'Packs']] + .map(([id, label]) => ``).join(''); + const have = id => S.data.integrations.filter(i => i.preset === id).length; + let tiles; + if (this.cat === 'packs'){ + tiles = cat.packs.map(p => `
+
${esc(p.name)}
+
${esc(p.description)}
+
${p.presets.map(id => esc((cat.presets.find(x => x.id === id) || {}).name || id)).join(' · ')}
+ +
`).join(''); + } else { + const list = cat.presets.filter(p => this.cat === 'rec' ? p.recommended : p.category === this.cat); + tiles = `` + list.map(p => { + const fake = fakeStep(p); + const n = have(p.id); + const needs = needsNote(p.needs); + return `
+
${previewHtml(fake.step, fake.handler, false)}
+
+
${esc(p.name)}
${esc(p.description)}
+ ${needs ? `
${esc(needs)}
` : ''}
+
+ +
`; + }).join(''); + } + Modal.body(`${modalHead('plus', 'var(--accent)', 'Add an integration', 'Every card shows exactly what it sends. Add it, then tweak anything.')} +
+
${tiles}
`); + const form = Modal.el.querySelector('.dest-form'); + form.style.height = 'min(86vh,820px)'; + }, +}; + +// A catalog entry's preview without building it: its sample line. +function fakeStep(p){ + const pv = p.preview || {}; + const type = {discord:'discord', chat:'chat', phone:'phone', marker:'marker', json:'http', file:'file'}[pv.kind] || 'chat'; + const param = (KINDS[type] || {}).text || 'text'; + const step = {type, params:{[param]: pv.text || '', path:'crashes.txt', title:'OBS crashed', + body: type === 'http' ? '{"event":"hold_opened","delay_ms":30000}' : ''}, then:[], else:[]}; + const handler = {trigger: pv.ask ? {type:'chat_command', command:pv.ask} : {type:'event', event:'hold_opened'}, steps:[step]}; + return {step, handler}; +} +function needsNote(needs){ + const c = S.data.connections, t = S.data.twitch; + if (needs === 'discord' && !c.discord.length) return 'Needs a Discord channel (you can add it next)'; + if (needs === 'twitch' && !t.main.login) return 'Needs Twitch connected'; + if (needs === 'phone' && !c.phone.topic) return 'Needs your phone connected'; + if (needs === 'web') return 'You paste the address it sends to'; + if (needs === 'file') return 'You pick the file it writes'; + return ''; +} + +// ---------------------------------------------------------------- module editor + +// The simple editor for catalog modules: pick a moment, write on the +// preview itself, and every option is a chip that opens its own panel. +const Editor = { + d:null, sel:0, chip:null, run:null, dirty:false, moreVars:false, + open(i){ + this.d = clone(i); + this.sel = Math.max(0, this.d.handlers.findIndex(h => h.enabled)); + this.chip = null; + this.run = null; + this.dirty = false; + this.moreVars = false; + Modal.open('editor', '', 920); + Modal.onClose = () => !this.dirty || confirm('Close without saving your changes?'); + this.render(); + }, + handler(){ return this.d.handlers[this.sel]; }, + render(){ + const d = this.d, h = this.handler(); + const step = primaryStep(h); + const k = step ? KINDS[step.type] : null; + const p = presetOf(d); + const head = modalHead(k ? k.icon : 'steps', k ? k.c : 'var(--accent)', + ``, + esc(p ? p.description : 'Pick a moment, then write on the message itself. Everything else is one click away.'), + ``); + Modal.body(`${head} +
+ ${this.momentsHtml()} + ${this.previewBoxHtml(step, h)} +
+
${this.chips(step, h).map(c => ``).join('')}
+
${this.popHtml(step, h)}
+
+
+
+
${this.lastRunHtml()}
+ + + + +
`); + const msg = Modal.el.querySelector('.ig-msg'); + if (msg) autosize(msg); + }, + momentsHtml(){ + const hs = this.d.handlers; + if (hs.length === 1) return ''; + if (hs.length > 5){ + return `
${hs.map((h, n) => ``).join('')}
`; + } + return `
${hs.map((h, n) => `
+ + +
`).join('')}
`; + }, + previewBoxHtml(step, h){ + if (!step || !(KINDS[step.type] || {}).text){ + return `
${previewHtml(step, h, true)} +
This ${h.trigger.type === 'event' ? 'moment' : 'trigger'} has nothing to write. Open it in the builder to see every step.
`; + } + const k = KINDS[step.type]; + const value = step.params[k.text] || ''; + // The trigger's own values first; the always-there ones behind "more". + const all = varsFor(h); + const common = new Set(['delay', 'delay_state', 'hold_left', 'time']); + const globals = new Set(S.data.vars.global.map(v => v.name)); + const shown = this.moreVars ? all : all.filter(v => !globals.has(v.name) || common.has(v.name)); + const tokens = shown.map(v => ``).join('') + + (shown.length < all.length ? `` : ''); + const editor = ` +
${sampleLine(value, h)}
`; + let box; + if (step.type === 'discord'){ + const ping = step.params.ping === 'here' ? '@here ' : step.params.ping === 'everyone' ? '@everyone ' : ''; + box = `
+
+ #${esc(channelName(step.params.connection).replace(/^#/, ''))} + Live preview
+
+ ${svg('shield', ' style="width:18px;height:18px"')} +
+
InstantCloneAPP + ${ping ? `${esc(ping.trim())}` : ''}
+ ${editor}
`; + } else if (step.type === 'chat'){ + const t = h.trigger; + const ask = t.type === 'chat_command' ? (t.command || '!command') : t.type === 'chat_message' ? (t.pattern || 'hey') : ''; + const who = (step.params.as === 'bot' && S.data.twitch.bot.login) || S.data.twitch.main.login || 'you'; + box = `
+
STREAM CHAT · LIVE PREVIEW
+ ${ask ? `
viewer: ${esc(ask)}
` : ''} +
${esc(who)}:
${editor}
`; + } else if (step.type === 'phone'){ + box = `
+
INSTANTCLONE · now
+ + ${editor}
`; + } else { + box = `
+
${esc(k.label)}${step.type === 'http' ? ' · body' : ''}
${editor}
`; + } + return `
${box}
Insert ${tokens}
`; + }, + // The chips shown for this moment, with their current value. + chips(step, h){ + const d = this.d, out = []; + const discordSteps = allSteps(d).filter(s => s.type === 'discord'); + const t = h.trigger; + if (discordSteps.length){ + out.push({id:'where', k:'Post in', v:channelName(discordSteps[0].params.connection)}); + if (step && step.type === 'discord'){ + const ping = step.params.ping || ''; + out.push({id:'ping', k:'Ping', v:ping === 'here' ? '@here' : ping === 'everyone' ? '@everyone' : 'nobody'}); + } + } + if (t.type === 'event' || t.type === 'timer' || t.type === 'webhook') out.push({id:'send', k:'Send', v:this.sendValue(h).label}); + if (t.type === 'event' && this.filterVars(t).length){ + const set = Object.entries(t.filters || {}).filter(([, v]) => v); + out.push({id:'only', k:'Only if', v:set.length ? set.map(([k, v]) => `${k} is ${v}`).join(', ') : 'always'}); + } + if (t.type === 'chat_command'){ + out.push({id:'cmd', k:'Command', v:[t.command].concat(t.aliases || []).join(' ')}); + out.push({id:'who', k:'Who', v:rolesLabel(t.roles)}); + } + if (step && step.type === 'chat') out.push({id:'reply', k:'Reply as', v:step.params.as === 'bot' ? 'bot account' : 'your account'}); + if (step && step.type === 'phone') out.push({id:'priority', k:'Priority', v:step.params.priority || 'normal'}); + if (step && step.type === 'http') out.push({id:'url', k:'Send to', v:step.params.url || 'set the address'}); + if (step && step.type === 'file') out.push({id:'file', k:'File', v:step.params.path || 'pick a file'}); + out.push({id:'cooldown', k:'Wait between', v:fmtMs(d.cooldown_ms) || 'no limit'}); + return out; + }, + filterVars(t){ + const e = eventOf(t.event); + return e ? e.vars.filter(v => v.name !== 'hold' && v.name !== 'down_for' && v.name !== 'previous') : []; + }, + sendValue(h){ + const first = (h.steps || [])[0]; + if (first && first.type === 'wait_delay') return {id:'aired', label:'once the delay aired'}; + if (first && first.type === 'wait'){ + const ms = parseInt(first.params.ms, 10) || 0; + return {id:'w' + ms, label:'after ' + fmtMs(ms)}; + } + return {id:'now', label:'right away'}; + }, + popHtml(step, h){ + if (this.chip === '__test') return this.runHtml(); + const d = this.d, t = h.trigger; + const seg = (items, cur, act) => `
${items.map(([id, label]) => + ``).join('')}
`; + switch (this.chip){ + case 'where': { + const cur = (allSteps(d).find(s => s.type === 'discord') || {params:{}}).params.connection; + return `
Post in
+ ${S.data.connections.discord.map(c => ``).join('')} +
`; + } + case 'ping': + return `
Ping
${seg([['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']], step.params.ping || '', 'set-ping')} +
Text from chat or a web service can never ping anyone: only this option can.
`; + case 'send': + return `
When to send
${seg([['now', 'Right away'], ['w10000', '10 s'], ['w20000', '20 s'], ['w30000', '30 s'], ['w60000', '1 min'], ['aired', 'Once the delay aired']], + this.sendValue(h).id, 'set-send')} +
${t.type === 'event' && t.event === 'hold_opened' ? 'Waiting a bit means a 3-second blip never reaches anyone.' : '"Once the delay aired" waits until viewers have seen the moment.'}
`; + case 'only': + return `
Only if
${this.filterVars(t).map(v => { + const cur = (t.filters || {})[v.name] || ''; + const choices = FILTER_CHOICES[v.name]; + const input = choices + ? `` + : ``; + return `
${input}
`; + }).join('')}
`; + case 'cmd': + return `
+
+
${seg([['0', 'no limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min']], String(t.user_cooldown_ms || 0), 'set-ucd')}
`; + case 'who': + return `
Who can use it
${['everyone', 'subs', 'vips', 'mods'].map(r => + ``).join('')}
+
You can always use your own commands.
`; + case 'reply': + return `
Reply as
${seg([['', 'Your account'], ['bot', 'Bot account']], step.params.as || '', 'set-as')} + ${t.type.startsWith('chat') ? `` : ''} + ${S.data.twitch.bot.login ? '' : '
No bot account yet: connect one, or messages go out as you.
'}`; + case 'priority': + return `
Priority
${seg([['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']], step.params.priority || '', 'set-priority')}`; + case 'url': + return `
+
`; + case 'file': + return `
+
+
Point an OBS text source at this file to show it on stream.
`; + case 'cooldown': + return `
Wait between two runs
${seg([['0', 'No limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min'], ['300000', '5 min'], ['600000', '10 min']], String(d.cooldown_ms || 0), 'set-cooldown')}`; + } + return `
Every option lives in a chip above. Click one to change it.
`; + }, + runHtml(){ + if (!this.run) return '
Running the test…
'; + if (this.run.error) return `
⚠${esc(this.run.error)}
`; + return `
Test run: ${esc(this.run.status)}
${runLogHtml(this.run.steps)} +
Messages went out for real, marked [TEST]. Waits were skipped and the stream was never touched.
`; + }, + lastRunHtml(){ + const st = stat(this.d); + if (!st || !st.last_ms) return 'Hasn\'t run yet'; + const color = st.last_status === 'failed' ? 'var(--danger)' : 'var(--live)'; + return `Last ran ${esc(fmtAgo(st.last_ms))} · ${esc(st.last_status)}`; + }, + changed(rerender){ + this.dirty = true; + if (rerender) this.render(); + }, + input(el){ + const h = this.handler(), step = primaryStep(h), t = h.trigger; + const b = el.dataset.bind; + if (b === 'name') this.d.name = el.value; + else if (b === 'enabled') this.d.enabled = el.checked; + else if (b === 'text' && step){ + step.params[KINDS[step.type].text] = el.value; + autosize(el); + const s = $('ig-sample'); + if (s) s.innerHTML = sampleLine(el.value, h); + } + else if (b === 'title' && step) step.params.title = el.value; + else if (b === 'filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); } + else if (b === 'command') t.command = el.value.trim().toLowerCase(); + else if (b === 'aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); + else if (b === 'role'){ t.roles = t.roles || {}; t.roles[el.dataset.name] = el.checked; } + else if (b === 'reply' && step) step.params.reply = el.checked ? 'yes' : ''; + else if (['url', 'method', 'path', 'mode'].includes(b) && step) step.params[b] = el.value; + else return; + this.dirty = true; + if (el.tagName === 'SELECT' || el.type === 'checkbox') this.refreshChips(); + }, + refreshChips(){ + const h = this.handler(), step = primaryStep(h); + const chips = Modal.el && Modal.el.querySelector('.ig-chips'); + if (chips) chips.innerHTML = this.chips(step, h).map(c => ``).join(''); + }, + act(a, el){ + const h = this.handler(), step = primaryStep(h), v = el.dataset.v; + switch (a){ + case 'moment': this.sel = +el.dataset.n; if (this.chip === '__test') this.chip = null; this.render(); return true; + case 'moment-toggle': { const x = this.d.handlers[+el.dataset.n]; x.enabled = !x.enabled; this.changed(true); return true; } + case 'chip': this.chip = this.chip === el.dataset.chip ? null : el.dataset.chip; this.render(); return true; + case 'token': insertAtCursor(Modal.el.querySelector('.ig-msg'), '{' + el.dataset.token + '}'); return true; + case 'more-vars': this.moreVars = true; this.render(); return true; + case 'set-where': allSteps(this.d).filter(s => s.type === 'discord').forEach(s => { s.params.connection = v; }); this.changed(true); return true; + case 'set-ping': if (step) step.params.ping = v; this.changed(true); return true; + case 'set-send': setSend(h, v); this.changed(true); return true; + case 'set-ucd': h.trigger.user_cooldown_ms = +v; this.changed(true); return true; + case 'set-as': if (step) step.params.as = v; this.changed(true); return true; + case 'set-priority': if (step) step.params.priority = v; this.changed(true); return true; + case 'set-cooldown': this.d.cooldown_ms = +v; this.changed(true); return true; + case 'ed-builder': { const d = this.d; this.dirty = false; Modal.close(true); Builder.open(d, true); return true; } + case 'ed-test': this.test(); return true; + case 'ed-delete': remove(this.d.id); return true; + case 'ed-save': saveIntegration(this.d, () => { this.dirty = false; }); return true; + } + return false; + }, + async test(){ + this.chip = '__test'; + this.run = null; + this.render(); + const r = await api('/integrations/test', {integration:this.d, handler:this.sel}); + this.run = r.ok ? r : {error:r.error || 'The test failed'}; + if (Modal.kind === 'editor') this.render(); + }, +}; + +// How a message with variables will read, or nothing when it has none. +function sampleLine(text, h){ + if (!/\{[A-Za-z0-9_.]/.test(text || '')) return ''; + return 'Reads like: ' + esc(renderSample(text, sampleMap(h))) + ''; +} + +function setSend(h, v){ + const first = (h.steps || [])[0]; + if (first && (first.type === 'wait' || first.type === 'wait_delay')) h.steps.shift(); + if (v === 'aired') h.steps.unshift({type:'wait_delay', params:{}, then:[], else:[]}); + else if (v.startsWith('w')) h.steps.unshift({type:'wait', params:{ms:v.slice(1)}, then:[], else:[]}); +} +function rolesLabel(r){ + if (!r || r.everyone) return 'everyone'; + const who = ['subs', 'vips', 'mods'].filter(x => r[x]).map(x => x === 'vips' ? 'VIPs' : x); + return who.length ? who.join(', ') : 'only you'; +} +function fmtMs(ms){ + ms = +ms || 0; + if (!ms) return ''; + if (ms < 60000) return Math.round(ms / 1000) + ' s'; + return Math.round(ms / 60000) + ' min'; +} +function autosize(t){ t.style.height = 'auto'; t.style.height = (t.scrollHeight + 2) + 'px'; } +function insertAtCursor(field, text){ + if (!field) return; + const start = field.selectionStart == null ? field.value.length : field.selectionStart; + const end = field.selectionEnd == null ? start : field.selectionEnd; + field.value = field.value.slice(0, start) + text + field.value.slice(end); + field.focus(); + field.setSelectionRange(start + text.length, start + text.length); + field.dispatchEvent(new Event('input', {bubbles:true})); +} +function runLogHtml(steps){ + if (!steps || !steps.length) return '
No steps ran.
'; + return `
${steps.map(s => `
${(s.at_ms / 1000).toFixed(2)} s + ${s.status === 'ok' ? '✓' : s.status === 'failed' ? '✕' : '–'} ${esc(s.label)} + ${esc(s.detail)}
`).join('')}
`; +} + +async function saveIntegration(d, onSaved){ + const r = await api('/integrations/save', d); + if (!r.ok){ toast(r.error || 'Could not save', 'err', 6000); return false; } + if (onSaved) onSaved(); + if (r.warnings && r.warnings.length){ + toast(`Saved. Check these names, nothing fills them: ${r.warnings.map(w => '{' + w + '}').join(', ')}`, 'info', 7000); + } else toast('Saved', 'ok'); + Modal.close(true); + await load(); + return true; +} + +// ---------------------------------------------------------------- builder + +// The step editor: any trigger, any steps, checks with then/otherwise. +// Selection and insertion points are paths like [2,'then',0]. +const PALETTE = [ + ['Send', [['discord', 'Discord'], ['chat', 'Twitch chat'], ['phone', 'Phone'], ['http', 'Web request']]], + ['Flow', [['if', 'If / otherwise'], ['wait', 'Wait'], ['wait_delay', 'Wait for the delay'], ['stop', 'Stop']]], + ['Stream', [['delay_action', 'Delay action'], ['marker', 'VOD marker'], ['clip', 'Clip']]], + ['Anything', [['program', 'Run a program'], ['file', 'Write a file'], ['set_var', 'Remember a value'], ['counter', 'Counter']]], +]; +const TRIGGERS = [['event', 'An InstantClone event'], ['chat_command', 'A chat command'], ['chat_message', 'A chat message'], ['timer', 'Every few minutes'], ['webhook', 'A web call (URL)']]; + +const Builder = { + d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, + open(i, fromEditor){ + this.d = i ? clone(i) : {id:'', name:'My integration', enabled:true, preset:'', cooldown_ms:0, + handlers:[{enabled:true, trigger:{type:'event', event:'hold_opened', filters:{}}, steps:[]}]}; + this.h = 0; + this.sel = 'trigger'; + this.target = []; + this.run = null; + this.dirty = !!fromEditor; + Modal.open('builder', '', 1240); + Modal.onClose = () => !this.dirty || confirm('Close without saving your changes?'); + this.render(); + }, + handler(){ return this.d.handlers[this.h]; }, + render(){ + const d = this.d; + Modal.body(`${modalHead('steps', 'var(--accent)', + ``, + `Your own · runs on this PC · ${allSteps(d).length} steps`, + ` + `)} +
+ +
+
${d.handlers.map((h, n) => ``).join('')} +
+
+ + ${this.stepsHtml(this.handler().steps, [])} +
+ ${this.run ? `
Test run: ${esc(this.run.status || 'error')}
+ ${this.run.error ? `
⚠${esc(this.run.error)}
` : runLogHtml(this.run.steps)}
` : ''} +
+ +
+
+
${d.id ? Editor.lastRunHtml.call({d}) : 'New integration'}
+ ${d.id ? '' : ''} + +
`); + const f = Modal.el.querySelector('.dest-form'); + f.style.height = 'min(92vh,900px)'; + }, + stepsHtml(steps, base){ + const key = p => p.join('.'); + const tgt = key(this.target); + const out = (steps || []).map((s, n) => { + const path = base.concat(n); + const k = KINDS[s.type] || {label:s.type, tag:'?', c:'#888'}; + const selected = Array.isArray(this.sel) && key(this.sel) === key(path); + let html = `
+ ${k.tag}${stepSentence(s)} + + + + + +
`; + if (s.type === 'if'){ + html += `
Then
${this.stepsHtml(s.then, path.concat('then'))} +
Otherwise
${this.stepsHtml(s.else, path.concat('else'))}
`; + } + return html; + }).join(''); + return out + ``; + }, + resolve(pathStr){ + const parts = pathStr === '' ? [] : pathStr.split('.').map(p => /^\d+$/.test(p) ? +p : p); + return parts; + }, + // The list a path's last index lives in, and that index. + locate(path){ + let list = this.handler().steps; + for (let n = 0; n < path.length - 1; n++){ + const p = path[n]; + if (typeof p === 'number'){ + const s = list[p]; + const branch = path[n + 1]; + list = s[branch]; + n++; + } + } + return {list, index:path[path.length - 1]}; + }, + listAt(path){ + if (!path.length) return this.handler().steps; + const s = this.stepAt(path.slice(0, -1)); + return s[path[path.length - 1]]; + }, + stepAt(path){ + const {list, index} = this.locate(path); + return list[index]; + }, + inspectorHtml(){ + const h = this.handler(); + const tokens = varsFor(h).map(v => ``).join(''); + const varsBox = `
Variables here
+
${tokens}
Click one to insert it in the field you last used. {name|text} uses the text when the value is empty or 0.
`; + if (this.sel === 'trigger') return this.triggerInspector(h) + varsBox; + const s = this.stepAt(this.sel); + if (!s) return varsBox; + const k = KINDS[s.type]; + const field = (name, label, opts) => { + opts = opts || {}; + const v = s.params[name] || ''; + if (opts.select) return `
`; + if (opts.area) return `
`; + return `
`; + }; + const channels = S.data.connections.discord.map(c => [c.id, '#' + c.name]); + let body = ''; + switch (s.type){ + case 'discord': body = (channels.length ? field('connection', 'Channel', {select:[['', 'Pick a channel']].concat(channels)}) : '
No Discord channel yet. Add one.
') + + field('text', 'Message', {area:true}) + field('ping', 'Ping', {select:[['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']]}); break; + case 'chat': body = field('text', 'Message', {area:true}) + field('as', 'Send as', {select:[['', 'Your account'], ['bot', 'Bot account']]}) + + field('reply', 'Reply to the viewer', {select:[['', 'No'], ['yes', 'Yes, in their thread']]}); break; + case 'phone': body = field('title', 'Title') + field('text', 'Message', {area:true}) + field('priority', 'Priority', {select:[['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']]}); break; + case 'http': body = field('method', 'Method', {select:['POST', 'GET', 'PUT', 'PATCH', 'DELETE'].map(m => [m, m])}) + field('url', 'Address', {mono:true, ph:'https://…'}) + + field('headers', 'Headers (one per line, Name: value)', {area:true}) + field('body', 'Body', {area:true, ph:'{"event":"{delay}"}'}) + + field('save_as', 'Save the answer as', {mono:true, ph:'response'}) + `
Later steps can use {${esc(s.params.save_as || 'response')}.status}, .ok, .body and .json.field.
`; break; + case 'wait': body = field('ms', 'Wait (milliseconds)', {mono:true, ph:'20000'}) + `
${esc(fmtMs(s.params.ms) || '0 s')}. Up to an hour.
`; break; + case 'wait_delay': body = `

Waits until the moment this started has reached your viewers. Only InstantClone can do this: it knows your delay.

` + + field('extra_ms', 'Then wait a bit more (ms)', {mono:true, ph:'0'}) + field('follow', 'If the delay changes meanwhile', {select:[['', 'Follow it'], ['no', 'Keep the delay from the start']]}); break; + case 'if': body = field('left', 'Check', {mono:true, ph:'{user_role}'}) + field('op', 'Is', {select:Object.entries(OPS)}) + field('right', 'Value', {mono:true, ph:'mod'}) + + '
Text compares ignoring case; "is more than" compares numbers.
'; break; + case 'stop': body = '
Ends this run here.
'; break; + case 'delay_action': body = field('action', 'Action', {select:Object.entries(DELAY_ACTIONS)}) + + (s.params.action === 'arm' ? field('seconds', 'Delay (seconds)', {mono:true, ph:'{arg1}'}) : '') + + '
⚠This changes your stream. Tests never run it.
'; break; + case 'marker': body = field('description', 'Label', {ph:'Crash'}) + '
Only works while you are live on Twitch.
'; break; + case 'clip': body = '
Clips the last moments of your stream. Later steps can use {clip.url} and {clip.ok}.
'; break; + case 'program': body = field('path', 'Program', {mono:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) + + '
⚠Runs on your PC. Recipes you import can never add this without asking you first.
'; break; + case 'file': body = field('path', 'File', {mono:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); break; + case 'set_var': body = field('name', 'Name', {mono:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + '
Later steps use it as {name}. Lasts for this run.
'; break; + case 'counter': body = field('name', 'Name', {mono:true, ph:'crashes'}) + field('op', 'Do', {select:[['', 'Add'], ['subtract', 'Subtract'], ['set', 'Set to'], ['reset', 'Reset to 0']]}) + + field('by', 'By', {mono:true, ph:'1'}) + `
Kept between runs. Use it anywhere as {counter.${esc(s.params.name || 'name')}}.
`; break; + } + return `
${k.tag}
${esc(k.label)}
${body}${varsBox}`; + }, + triggerInspector(h){ + const t = h.trigger; + const typeSel = `
`; + let body = ''; + if (t.type === 'event'){ + const groups = {}; + S.data.events.forEach(e => { (groups[e.group] = groups[e.group] || []).push(e); }); + body = `
`; + const e = eventOf(t.event); + (e ? e.vars : []).filter(v => FILTER_CHOICES[v.name] || v.name === 'destination').forEach(v => { + const cur = (t.filters || {})[v.name] || ''; + const ch = FILTER_CHOICES[v.name]; + body += `
${ch + ? `` + : ``}
`; + }); + } else if (t.type === 'chat_command'){ + body = `
+
+ ${this.rolesHtml(t)} +
`; + } else if (t.type === 'chat_message'){ + body = `
+
+ ${this.rolesHtml(t)}`; + } else if (t.type === 'timer'){ + body = `
+ `; + } else if (t.type === 'webhook'){ + const url = `${location.origin}/hooks/${t.token || ''}`; + body = `
+
+
GET or POST from a Stream Deck, a script or any app on this network. The body is {body}; JSON fields are {body.field}.
+ `; + } + const h2 = this.d.handlers.length > 1 + ? `
+
` : ''; + return `
WHEN
${esc(triggerLabel(t))}
+ ${typeSel}${body} +
${h2}`; + }, + rolesHtml(t){ + return `
${['everyone', 'subs', 'vips', 'mods'].map(r => + ``).join('')}
`; + }, + input(el){ + const b = el.dataset.bind, h = this.handler(), t = h.trigger; + let rerender = false; + if (b === 'b-name') this.d.name = el.value; + else if (b === 'b-enabled') this.d.enabled = el.checked; + else if (b === 'b-cooldown') this.d.cooldown_ms = Math.max(0, (parseFloat(el.value) || 0) * 1000); + else if (b === 'h-enabled'){ h.enabled = el.checked; rerender = true; } + else if (b === 't-type'){ h.trigger = newTrigger(el.value); rerender = true; } + else if (b === 't-event'){ t.event = el.value; t.filters = {}; rerender = true; } + else if (b === 't-filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); rerender = true; } + else if (b === 't-command'){ t.command = el.value.trim().toLowerCase(); rerender = true; } + else if (b === 't-aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); + else if (b === 't-role'){ t.roles = t.roles || {}; t.roles[el.dataset.name] = el.checked; } + else if (b === 't-ucd') t.user_cooldown_ms = Math.max(0, (parseFloat(el.value) || 0) * 1000); + else if (b === 't-pattern') t.pattern = el.value; + else if (b === 't-mode') t.mode = el.value; + else if (b === 't-every') t.every_ms = Math.max(1, parseFloat(el.value) || 1) * 60000; + else if (b === 't-live') t.only_live = el.checked; + else if (b === 'p'){ + const s = this.stepAt(this.sel); + s.params[el.dataset.name] = el.value; + rerender = el.tagName === 'SELECT'; + const card = Modal.el.querySelector(`.ig-step[data-path="${this.sel.join('.')}"] .ig-step-text`); + if (card) card.innerHTML = stepSentence(s); + } else return; + this.dirty = true; + if (rerender) this.renderKeepScroll(); + }, + renderKeepScroll(){ + const c = Modal.el && Modal.el.querySelector('.ig-canvas'); + const top = c ? c.scrollTop : 0; + this.render(); + const c2 = Modal.el && Modal.el.querySelector('.ig-canvas'); + if (c2) c2.scrollTop = top; + }, + act(a, el){ + const d = this.d; + switch (a){ + case 'b-sel': this.sel = el.dataset.path === 'trigger' ? 'trigger' : this.resolve(el.dataset.path); this.renderKeepScroll(); return true; + case 'b-target': this.target = this.resolve(el.dataset.path); this.renderKeepScroll(); return true; + case 'b-add': { + let list; + try { list = this.listAt(this.target); } catch(_){ list = null; } + if (!list){ this.target = []; list = this.handler().steps; } + list.push(newStep(el.dataset.kind)); + this.sel = this.target.concat(list.length - 1); + if (el.dataset.kind === 'if') this.target = this.sel.concat('then'); + this.dirty = true; + this.renderKeepScroll(); + return true; + } + case 'b-move': { + const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); + const to = index + (+el.dataset.dir); + if (to < 0 || to >= list.length) return true; + [list[index], list[to]] = [list[to], list[index]]; + this.sel = path.slice(0, -1).concat(to); + this.dirty = true; this.renderKeepScroll(); return true; + } + case 'b-dup': { + const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); + list.splice(index + 1, 0, clone(list[index])); + this.dirty = true; this.renderKeepScroll(); return true; + } + case 'b-del': { + const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); + list.splice(index, 1); + this.sel = 'trigger'; + this.target = []; + this.dirty = true; this.renderKeepScroll(); return true; + } + case 'b-handler': this.h = +el.dataset.n; this.sel = 'trigger'; this.target = []; this.run = null; this.renderKeepScroll(); return true; + case 'b-add-handler': + d.handlers.push({enabled:true, trigger:newTrigger('event'), steps:[]}); + this.h = d.handlers.length - 1; this.sel = 'trigger'; this.target = []; this.dirty = true; this.render(); return true; + case 'b-del-handler': + if (d.handlers.length > 1 && confirm('Remove this trigger and its steps?')){ d.handlers.splice(this.h, 1); this.h = 0; this.sel = 'trigger'; this.dirty = true; this.render(); } + return true; + case 'b-new-token': this.handler().trigger.token = randomToken(); this.dirty = true; this.render(); return true; + case 'b-copy': navigator.clipboard.writeText(el.dataset.text); toast('Copied', 'ok'); return true; + case 'b-token': insertAtCursor(Builder.lastField, '{' + el.dataset.token + '}'); return true; + case 'b-test': this.test(); return true; + case 'b-delete': remove(d.id); return true; + case 'b-save': saveIntegration(d, () => { this.dirty = false; }); return true; + } + return false; + }, + async test(){ + this.run = {status:'running', steps:[]}; + this.renderKeepScroll(); + const r = await api('/integrations/test', {integration:this.d, handler:this.h}); + this.run = r.ok ? r : {error:r.error || 'The test failed'}; + if (Modal.kind === 'builder') this.renderKeepScroll(); + }, + lastField:null, +}; + +function newTrigger(type){ + switch (type){ + case 'chat_command': return {type, command:'!command', aliases:[], roles:{everyone:true, subs:true, vips:true, mods:true}, user_cooldown_ms:30000}; + case 'chat_message': return {type, pattern:'', mode:'contains', roles:{everyone:true, subs:true, vips:true, mods:true}}; + case 'timer': return {type, every_ms:600000, only_live:true}; + case 'webhook': return {type, token:randomToken()}; + } + return {type:'event', event:'hold_opened', filters:{}}; +} +function newStep(kind){ + const p = { + discord:{connection:(S.data.connections.discord[0] || {}).id || '', text:''}, + chat:{text:''}, phone:{title:'InstantClone', text:''}, http:{method:'POST', url:'', body:''}, + wait:{ms:'10000'}, wait_delay:{}, if:{left:'', op:'is', right:''}, stop:{}, + delay_action:{action:'cut'}, marker:{description:'Highlight'}, clip:{}, program:{path:'', args:''}, + file:{path:'', text:'', mode:''}, set_var:{name:'', value:''}, counter:{name:'count', op:'', by:'1'}, + }[kind] || {}; + return {type:kind, params:clone(p), then:[], else:[]}; +} +function triggerSentence(t){ + switch (t.type){ + case 'event': { const f = Object.entries(t.filters || {}).filter(([, v]) => v).map(([k, v]) => `${k} is ${v}`); + return triggerLabel(t) + (f.length ? ' · only if ' + f.join(', ') : ''); } + case 'chat_command': return `Someone types ${t.command || '!command'} · ${rolesLabel(t.roles)}`; + case 'chat_message': return `A chat message ${t.mode === 'exact' ? 'is' : t.mode === 'starts_with' ? 'starts with' : 'matches'} "${t.pattern || ''}"`; + case 'timer': return `Every ${Math.round((t.every_ms || 0) / 60000)} min${t.only_live !== false ? ' while streaming' : ''}`; + case 'webhook': return 'Something calls its secret address'; + } + return t.type; +} +function stepSentence(s){ + const p = s.params || {}; + const q = v => `“${highlightVars(v || '')}”`; + switch (s.type){ + case 'discord': return `Discord ${esc(channelName(p.connection))} ${q(p.text)}${p.ping ? ' · @' + esc(p.ping) : ''}`; + case 'chat': return `Chat ${q(p.text)}${p.as === 'bot' ? ' · as bot' : ''}${p.reply === 'yes' ? ' · as a reply' : ''}`; + case 'phone': return `Phone ${q(p.text)}`; + case 'http': return `${esc(p.method || 'POST')} ${esc(p.url || '(address)')}${p.save_as ? ' → ' + esc(p.save_as) : ''}`; + case 'wait': return `Wait ${esc(fmtMs(p.ms) || '0 s')}`; + case 'wait_delay': return 'Wait until it has aired for viewers {delay}'; + case 'if': return `If ${highlightVars(p.left || '…')} ${esc(OPS[p.op] || p.op || '')} ${highlightVars(p.right || '')}`; + case 'stop': return 'Stop here'; + case 'delay_action': return esc(DELAY_ACTIONS[p.action] || 'Delay action') + (p.action === 'arm' && p.seconds ? ' to ' + highlightVars(p.seconds) + ' s' : ''); + case 'marker': return `VOD marker ${q(p.description)}`; + case 'clip': return 'Create a clip'; + case 'program': return `Run ${esc((p.path || '(program)').split(/[\\/]/).pop())} ${highlightVars(p.args || '')}`; + case 'file': return `${p.mode === 'append' ? 'Add to' : 'Write'} ${esc((p.path || '(file)').split(/[\\/]/).pop())} ${q(p.text)}`; + case 'set_var': return `Remember ${esc(p.name || '…')} = ${highlightVars(p.value || '')}`; + case 'counter': return `Counter ${esc(p.name || '…')} ${p.op === 'reset' ? 'reset' : p.op === 'set' ? '= ' + esc(p.by || '0') : (p.op === 'subtract' ? '-' : '+') + esc(p.by || '1')}`; + } + return esc(s.type); +} + +// ---------------------------------------------------------------- connections + +const Conn = { + tab:'discord', editing:null, + open(tab){ + this.tab = tab || 'discord'; + this.editing = null; + Modal.open('connections', '', 760); + Modal.onClose = () => { if (S.data.twitch.login) api('/twitch/cancel', {}); return true; }; + this.render(); + }, + render(){ + const tabs = [['discord', 'Discord'], ['twitch', 'Twitch'], ['phone', 'Phone']].map(([id, l]) => + ``).join(''); + const focused = document.activeElement && Modal.el && Modal.el.contains(document.activeElement) ? document.activeElement.dataset.bind : null; + Modal.body(`${modalHead('link', 'var(--accent)', 'Connections', 'Where integrations post. Set each one up once.')} +
+
${tabs}
+ ${this.tab === 'discord' ? this.discordHtml() : this.tab === 'twitch' ? this.twitchHtml() : this.phoneHtml()} +
`); + if (focused){ const f = Modal.el.querySelector(`[data-bind="${focused}"]`); if (f) f.focus(); } + }, + discordHtml(){ + const list = S.data.connections.discord; + const e = this.editing; + const rows = list.map(c => `
${svg('bubble')} +
#${esc(c.name)}
${esc(c.hint)}
+ + +
`).join(''); + const form = e ? `
+
${e.id ? 'Edit channel' : 'New channel'}
+
+
+
In Discord: Server settings › Integrations › Webhooks › New webhook › Copy webhook URL.
+
+
` : ``; + return (rows || '
No Discord channel yet.
') + form; + }, + twitchHtml(){ + const t = S.data.twitch; + if (!t.available) return `
⚠This build has no Twitch app id, so it can't log in to Twitch. Official releases include one.
`; + const flow = t.login; + const acct = (which, a, title, blurb) => { + const active = flow && flow.which === which; + let right; + if (active){ + right = flow.error + ? `` + : 'Waiting for you on Twitch…'; + } else if (a.login){ + right = `chat ${esc(a.chat)} + `; + } else { + right = ``; + } + const code = active && !flow.error ? `
+ ${flow.user_code ? `
${esc(flow.user_code)}
+
+ Open twitch.tv/activate + +
+
Enter the code there and approve. This page updates on its own. The code works for ${Math.max(1, Math.round(flow.expires_in_s / 60))} more minutes.
` + : '
Asking Twitch for a code…
'}
` + : active && flow.error ? `
⚠${esc(flow.error)}
` : ''; + return `
+
${svg('bubble')} +
${a.login ? esc(a.login) : title}
${blurb}
${right}
${code}
`; + }; + return (t.notice ? `
⚠${esc(t.notice)}
` : '') + + acct('main', t.main, 'Your Twitch account', 'Chat, VOD markers and clips. Log in once: InstantClone keeps it fresh.') + + acct('bot', t.bot, 'Bot account (optional)', 'A second account that posts in chat instead of you.') + + '
No password ever goes through InstantClone: you approve it on twitch.tv, and can revoke it there any time.
'; + }, + phoneHtml(){ + const p = S.data.connections.phone; + return `
+
Phone pushes use ntfy, a free app for Android and iPhone. Install it, subscribe to a topic, and put the same topic here. Anyone who knows the topic can read it, so make it hard to guess.
+
+
+
+
+
`; + }, + async act(a, el){ + switch (a){ + case 'conn-tab': this.tab = el.dataset.tab; this.editing = null; this.render(); return true; + case 'd-new': this.editing = {id:'', name:''}; this.render(); return true; + case 'd-edit': { const c = S.data.connections.discord.find(x => x.id === el.dataset.id); this.editing = {id:c.id, name:c.name}; this.render(); return true; } + case 'd-cancel': this.editing = null; this.render(); return true; + case 'd-save': { + const name = Modal.el.querySelector('[data-bind="d-name"]').value, url = Modal.el.querySelector('[data-bind="d-url"]').value; + const r = await api('/connections/discord', {id:this.editing.id, name, url}); + if (!r.ok){ toast(r.error, 'err', 6000); return true; } + this.editing = null; await refreshData(); this.render(); toast('Channel saved', 'ok'); return true; + } + case 'd-del': + if (!confirm('Remove this channel? Integrations posting there switch off until you pick another.')) return true; + await api('/connections/discord/delete', {id:el.dataset.id}); await refreshData(); this.render(); return true; + case 'd-test': { const r = await api('/connections/discord/test', {id:el.dataset.id}); toast(r.ok ? 'Sent a test message, check Discord' : r.error, r.ok ? 'ok' : 'err', 5000); return true; } + case 't-login': await api('/twitch/login', {which:el.dataset.which}); await refreshData(); schedulePoll(); this.render(); return true; + case 't-cancel': await api('/twitch/cancel', {}); await refreshData(); this.render(); return true; + case 't-logout': + if (!confirm('Disconnect this Twitch account? Chat commands and chat messages stop until you connect again.')) return true; + await api('/twitch/logout', {which:el.dataset.which}); setTimeout(async () => { await refreshData(); this.render(); }, 400); return true; + case 'p-random': Modal.el.querySelector('[data-bind="p-topic"]').value = 'instantclone-' + randomToken().slice(0, 12); return true; + case 'p-save': { + const r = await api('/connections/phone', {topic:Modal.el.querySelector('[data-bind="p-topic"]').value, server:Modal.el.querySelector('[data-bind="p-server"]').value}); + if (!r.ok){ toast(r.error, 'err', 6000); return true; } + await refreshData(); this.render(); toast('Phone saved', 'ok'); return true; + } + case 'p-test': { const r = await api('/connections/phone/test', {}); toast(r.ok ? 'Sent, check your phone' : r.error, r.ok ? 'ok' : 'err', 5000); return true; } + } + return false; + }, +}; + +async function refreshData(){ + const d = await api('/integrations'); + if (d && d.integrations){ S.data = d; if (S.visible) render(); } +} + +// ---------------------------------------------------------------- recipes + +const Recipes = { + preview:null, text:'', + openImport(){ + this.preview = null; + this.text = ''; + Modal.open('import', '', 700); + this.render(); + }, + render(){ + const p = this.preview; + const channels = S.data.connections.discord; + Modal.body(`${modalHead('download', 'var(--accent)', 'Import a recipe', "Paste a recipe someone shared. You see everything it adds before anything changes.")} +
+ + ${p ? `
+
✓${esc(p.name)}${p.items.length} integration${p.items.length === 1 ? '' : 's'}
+ ${p.items.map(it => `
· ${esc(it.name)} ${esc(it.summary)}
`).join('')} +
+ ${p.uses_discord ? (channels.length ? `
` + : '
⚠It posts to Discord: add a Discord channel first.
') : ''} + ${p.local_effects ? `` : ''} +
🛡Recipes are settings, not code. They can't see your keys, tokens or webhook links, and they arrive switched off.
` : ''} +
+
+ + ${p ? `` : ''} +
`); + }, + async act(a){ + if (a === 'r-preview'){ + this.text = Modal.el.querySelector('[data-bind="r-text"]').value; + const r = await api('/integrations/import', {recipe:this.text}); + if (!r.ok){ toast(r.error, 'err', 6000); return true; } + this.preview = r; this.render(); return true; + } + if (a === 'r-apply'){ + const sel = Modal.el.querySelector('[data-bind="r-discord"]'), local = Modal.el.querySelector('[data-bind="r-local"]'); + const r = await api('/integrations/import', {recipe:this.text, apply:true, discord:sel ? sel.value : '', allow_local:!!(local && local.checked)}); + if (!r.ok){ toast(r.error, 'err', 6000); return true; } + Modal.close(true); await load(); toast(`Added ${r.added}. They're off until you switch them on.`, 'ok', 4500); return true; + } + return false; + }, + openShare(){ + Modal.open('share', '', 700); + Modal.body(`${modalHead('copy', 'var(--accent)', 'Share as a recipe', 'Pick what to share. Your connections, keys and secret links are never included.')} +
+
+
${S.data.integrations.map(i => + ``).join('')}
+ +
+
+
`); + }, + async share(){ + const ids = Array.from(Modal.el.querySelectorAll('[data-bind="s-pick"]:checked')).map(x => x.value); + const r = await api('/integrations/export', {ids, name:Modal.el.querySelector('[data-bind="s-name"]').value}); + if (!r.ok){ toast(r.error, 'err'); return; } + const out = Modal.el.querySelector('[data-bind="s-out"]'); + out.hidden = false; + out.value = r.recipe; + out.select(); + navigator.clipboard.writeText(r.recipe).then(() => toast('Recipe copied: paste it anywhere', 'ok', 3500), () => {}); + }, +}; + +// ---------------------------------------------------------------- events + +async function onClick(e){ + const el = e.target.closest('[data-act]'); + if (!el) return; + const a = el.dataset.act; + if (el.tagName === 'A' || a === 'catalog' || a === 'build' || a === 'import') e.preventDefault(); + if (Modal.kind === 'editor' && Editor.act(a, el)) return; + if (Modal.kind === 'builder' && Builder.act(a, el)) return; + if (Modal.kind === 'connections' && await Conn.act(a, el)) return; + if (Modal.kind === 'import' && await Recipes.act(a, el)) return; + switch (a){ + case 'modal-close': Modal.close(); break; + case 'catalog': Catalog.open(); break; + case 'cat': Catalog.cat = el.dataset.cat; Catalog.render(); break; + case 'add-preset': addFrom({preset:el.dataset.preset}, true); break; + case 'pack': Modal.close(true); addFrom({pack:el.dataset.pack}, false); break; + case 'build': Builder.open(null); break; + case 'import': Recipes.openImport(); break; + case 'share': Recipes.openShare(); break; + case 's-make': Recipes.share(); break; + case 'conn': Conn.open(el.dataset.tab); break; + case 'edit': { const i = find(el.dataset.id); if (i) openEditor(i); break; } + case 'toggle': toggle(el.dataset.id); break; + case 'duplicate': duplicate(el.dataset.id); break; + case 'peek': S.peek = S.peek === el.dataset.id ? null : el.dataset.id; render(); break; + case 'filter': S.filter = el.dataset.f; render(); break; + case 'view': S.view = el.dataset.v; store.set('ig-view', S.view); render(); break; + case 'b-copy': navigator.clipboard.writeText(el.dataset.text); toast('Copied', 'ok'); break; + } +} + +function onInput(e){ + const el = e.target; + if (el.id === 'ig-search'){ + S.search = el.value; + const items = $('ig-items'); + if (items) items.innerHTML = itemsHtml(); + return; + } + if (!el.dataset || !el.dataset.bind) return; + if (Modal.kind === 'editor') Editor.input(el); + else if (Modal.kind === 'builder'){ + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') Builder.lastField = el; + Builder.input(el); + } +} + +// Remember the last text field the builder used, for variable inserts. +document.addEventListener('focusin', e => { + if (Modal.kind === 'builder' && e.target.matches('.ig-inspector input:not([readonly]), .ig-inspector textarea')) Builder.lastField = e.target; +}); + +function wireRoot(){ + const root = $('ig-root'); + if (!root || root.dataset.wired) return; + root.dataset.wired = '1'; + root.addEventListener('click', onClick); + root.addEventListener('input', onInput); + root.addEventListener('toggle', e => { if (e.target.id === 'ig-activity-box') S.activityOpen = e.target.open; }, true); +} + +window.Integrations = { + async show(){ + S.visible = true; + wireRoot(); + await load(); + schedulePoll(); + }, + hide(){ + S.visible = false; + if (!modalOpen()) clearInterval(S.pollTimer); + }, +}; + +// A reload or a restored tab can land straight on Integrations. +const pane = document.querySelector('.tab-pane[data-pane="integrations"]'); +if (pane && !pane.hidden) window.Integrations.show(); +})(); From 6f123fd54c9cefc4557def342a880d683aa5d258 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Wed, 30 Sep 2026 16:45:11 +0200 Subject: [PATCH 38/55] fix(integrations): scan for bugs, edge cases and rough UX Bugs: - "Set the delay" (!setdelay) used the hotkey's arm action, which toggles: a repeat disarmed the delay and a delay on air refused it. It now sets the delay, changing it live when one is on air. - A refused Twitch token left chat down until the next 5-minute upkeep round; the chat connection now asks for a refresh right away. - A bot account set to the streamer's own account made every command the streamer typed get ignored as "our own bot". Safety: - Program and file paths can't use variables, and neither can a web request's host: a chat message could otherwise pick what runs, where a file lands, or which machine gets the request. Recipes carrying any of these are refused. - Test runs never ping (@here/@everyone) a Discord server. - A destination stuck reconnecting alerts at most once per 5 minutes for each of live, dropped and all-down, instead of on every round. - The delay on/off chat notice has a 30 s cooldown. UX: - A switched-off integration saves as a draft with what is still missing; switched on, it must be complete. Errors read as instructions ("Pick a Discord channel") instead of field names. - Adding a Discord channel from inside an editor (or an import) comes back to it with the work intact and the new channel picked; an editor that lacks a channel opens on the chip that fixes it. - Newly added modules open switched on, so finishing them turns them on. - A catalog webhook's address applies to all its events at once. - Live refreshes no longer steal the search caret or wipe the Discord and phone forms mid-typing; cards and list rows work from the keyboard. - Honest test-run wording, clearer delay action help, and a note in the catalog when a build can't log in to Twitch. --- src/controller.rs | 33 +++++++++ src/integrations/api.rs | 56 +++++++++----- src/integrations/effects.rs | 7 +- src/integrations/engine.rs | 80 +++++++++++++++++--- src/integrations/model.rs | 132 +++++++++++++++++++++++++++------ src/integrations/presets.rs | 12 ++- src/integrations/recipe.rs | 5 ++ src/integrations/runner.rs | 13 +++- src/integrations/twitch/mod.rs | 12 ++- web/index.html | 2 + web/integrations.js | 120 ++++++++++++++++++++++++------ 11 files changed, 390 insertions(+), 82 deletions(-) diff --git a/src/controller.rs b/src/controller.rs index ca08cb3..5a6b405 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -2523,6 +2523,18 @@ impl Controller { problem } + /// 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); + self.state_dirty.notify_one(); + } + fn dispatch_named_action(&self, action: &str, default_ms: u32, source: &str) -> Option { match action { "toggle" => self.action_toggle(default_ms, source), @@ -4567,6 +4579,27 @@ mod tests { assert_eq!(h.ctrl.phase(), "idle"); } + #[test] + fn setting_the_delay_twice_never_disarms_and_updates_a_live_delay() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + h.ctrl.set_delay_to(30_000, "integration"); + h.ctrl.set_delay_to(30_000, "integration"); + assert_eq!(h.ctrl.armed_delay_ms(), 30_000, "a repeat keeps it armed"); + + let h = harness(0); + h.ctrl.arm_delay(3_000); + feed_seconds(&h.ctrl, 0, 5, 30); + h.ctrl.activate_delay().unwrap(); + h.ctrl.set_delay_to(2_000, "integration"); + assert_eq!( + h.ctrl.target_delay_ms(), + 2_000, + "changed on air, not refused" + ); + assert_eq!(h.ctrl.phase(), "active"); + } + #[test] fn named_action_arm_never_disarms_a_delay_on_air() { // Disarming wipes the target as well, so an arm press while the diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 9f5abfe..34677a6 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -25,17 +25,22 @@ fn ok(v: Value) -> Reply { } fn fail(message: impl Into) -> Reply { + let message = capitalize(&message.into()); ( "400 Bad Request", "application/json", - json::obj([ - ("ok", Value::Bool(false)), - ("error", json::str(message.into())), - ]) - .to_json(), + json::obj([("ok", Value::Bool(false)), ("error", json::str(message))]).to_json(), ) } +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + fn done() -> Reply { ok(json::obj([("ok", Value::Bool(true))])) } @@ -215,16 +220,24 @@ fn save(req: &Value, settings: &Arc>, cfg_path: &Path) - if integration.id.is_empty() { integration.id = new_id(); } - let errors = integration.validate(); - if !errors.is_empty() { - return fail(errors.join("; ")); + let mut problems = integration.problems(); + if !problems.invalid.is_empty() { + return fail(problems.invalid.join("; ")); } - let known = check_connections(&integration, &settings.borrow()); - if let Err(e) = known { - return fail(e); + if let Err(e) = check_connections(&integration, &settings.borrow()) { + problems.incomplete.push(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 id = integration.id.clone(); let warnings = unknown_vars(&integration); + let missing = problems.incomplete; let reply = update(settings, cfg_path, move |s| { match s.integrations.iter().position(|i| i.id == integration.id) { Some(at) => s.integrations[at] = integration, @@ -245,6 +258,10 @@ fn save(req: &Value, settings: &Arc>, cfg_path: &Path) - "warnings", Value::Arr(warnings.into_iter().map(json::str).collect()), ), + ( + "missing", + Value::Arr(missing.into_iter().map(json::str).collect()), + ), ])) } @@ -317,21 +334,22 @@ fn with_id(reply: Reply, id: &str) -> Reply { /// Every Discord step must point at a connection that exists. fn check_connections(integration: &Integration, s: &Settings) -> Result<(), String> { - let mut missing = false; + let mut gone = false; for h in &integration.handlers { super::model::visit_steps(&h.steps, &mut |step| { + // A blank channel is reported by validation; this catches one + // that was set and has since been removed. + let id = step.param("connection"); if step.kind == super::model::StepKind::Discord - && !s - .discord_channels - .iter() - .any(|c| c.id == step.param("connection")) + && !id.is_empty() + && !s.discord_channels.iter().any(|c| c.id == id) { - missing = true; + gone = true; } }); } - if missing { - Err("pick a Discord channel for every Discord step".to_string()) + if gone { + Err("a Discord channel it posts to was removed; pick another".to_string()) } else { Ok(()) } diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index 17f056b..a682d95 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -178,7 +178,12 @@ impl Host for RealHost { self.default_delay_ms.load(Ordering::Relaxed) }; match action { - "toggle" | "arm" | "activate" | "cut" | "cut_after" | "end_hold" => { + // "Set the delay", not the hotkey's arm/disarm toggle. + "arm" => { + self.ctrl.set_delay_to(default_ms, "integration"); + Ok(()) + } + "toggle" | "activate" | "cut" | "cut_after" | "end_hold" => { match self .ctrl .run_named_action(action, default_ms, "integration") diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index c0dcc6a..322c313 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -38,6 +38,10 @@ const PER_INTEGRATION: usize = 4; const GLOBAL_RUNS: usize = 32; const ACTIVITY_KEEP: usize = 150; const TICK: Duration = Duration::from_millis(250); +/// A destination stuck in a connect-then-drop loop (a key the platform +/// accepts, then refuses) would otherwise alert on every round. Each +/// destination gets at most one "live" and one "dropped" per window. +const FLAP_WINDOW: Duration = Duration::from_secs(300); enum Input { Event(Event), @@ -280,6 +284,7 @@ struct Engine { limits: HashMap>, global: Arc, delay_seen: Option, + flap: FlapGuard, } impl Engine { @@ -303,6 +308,7 @@ impl Engine { limits: HashMap::new(), global: Arc::new(Semaphore::new(GLOBAL_RUNS)), delay_seen: None, + flap: FlapGuard::default(), } } @@ -372,6 +378,9 @@ impl Engine { } fn event(&mut self, event: Event) { + if self.flap.suppress(&event, Instant::now()) { + return; + } let matches: Vec<(Arc, usize)> = self .enabled_handlers() .filter(|(_, _, h)| match &h.trigger { @@ -393,15 +402,20 @@ impl Engine { } fn chat(&mut self, msg: ChatMessage) { - // Our own bot talking must never trigger us. - let bot = self - .store - .accounts - .lock() - .bot - .as_ref() - .map(|b| b.login.clone()); - if bot.is_some_and(|b| b.eq_ignore_ascii_case(&msg.user_login)) { + // Our own bot talking must never trigger us. (Unless the "bot" is + // the streamer's own account: then these are their own commands.) + let is_own_bot = { + let accounts = self.store.accounts.lock(); + let main = accounts + .main + .as_ref() + .map(|a| a.login.as_str()) + .unwrap_or(""); + accounts.bot.as_ref().is_some_and(|b| { + !b.login.eq_ignore_ascii_case(main) && b.login.eq_ignore_ascii_case(&msg.user_login) + }) + }; + if is_own_bot { return; } let text = msg.text.trim(); @@ -643,6 +657,35 @@ impl Engine { } } +/// See `FLAP_WINDOW`. +#[derive(Default)] +struct FlapGuard { + last: HashMap, +} + +impl FlapGuard { + fn suppress(&mut self, event: &Event, now: Instant) -> bool { + let key = match event.kind { + EventKind::DestinationLive | EventKind::DestinationDropped => format!( + "{}:{}", + event.kind.id(), + event.var("destination").unwrap_or("") + ), + EventKind::AllDestinationsDown => event.kind.id().to_string(), + _ => return false, + }; + if self + .last + .get(&key) + .is_some_and(|t| now.duration_since(*t) < FLAP_WINDOW) + { + return true; + } + self.last.insert(key, now); + false + } +} + fn status_id(status: &RunStatus) -> &'static str { match status { RunStatus::Ok => "ok", @@ -809,6 +852,25 @@ mod tests { } } + #[test] + fn a_flapping_destination_alerts_once_per_window() { + let mut guard = FlapGuard::default(); + let t0 = Instant::now(); + let drop = |d: &str| Event::new(EventKind::DestinationDropped).with("destination", d); + assert!(!guard.suppress(&drop("YouTube"), t0)); + assert!(guard.suppress(&drop("YouTube"), t0 + Duration::from_secs(30))); + assert!( + !guard.suppress(&drop("Kick"), t0 + Duration::from_secs(30)), + "another destination still alerts" + ); + assert!(!guard.suppress(&drop("YouTube"), t0 + FLAP_WINDOW + Duration::from_secs(1))); + assert!( + !guard.suppress(&Event::new(EventKind::HoldOpened), t0), + "other events pass" + ); + assert!(!guard.suppress(&Event::new(EventKind::HoldOpened), t0)); + } + #[test] fn wildcards_match_like_file_globs() { assert!(wildcard("gg wp everyone", "gg*")); diff --git a/src/integrations/model.rs b/src/integrations/model.rs index c63f904..eb5526c 100644 --- a/src/integrations/model.rs +++ b/src/integrations/model.rs @@ -182,18 +182,26 @@ impl StepKind { StepKind::ALL.into_iter().find(|k| k.id() == id) } - /// Parameters the step cannot run without. - fn required(self) -> &'static [&'static str] { + /// Parameters the step cannot run without, each with what to tell + /// the user when it is blank. + fn required(self) -> &'static [(&'static str, &'static str)] { match self { - StepKind::Discord => &["connection", "text"], - StepKind::Chat | StepKind::Phone => &["text"], - StepKind::Http => &["url"], - StepKind::Wait => &["ms"], - StepKind::If => &["left", "op"], - StepKind::DelayAction => &["action"], - StepKind::Program => &["path"], - StepKind::File => &["path"], - StepKind::SetVar | StepKind::Counter => &["name"], + StepKind::Discord => &[ + ("connection", "pick a Discord channel"), + ("text", "write the Discord message"), + ], + StepKind::Chat => &[("text", "write the chat message")], + StepKind::Phone => &[("text", "write the phone message")], + StepKind::Http => &[("url", "add the web address")], + StepKind::Wait => &[("ms", "set how long to wait")], + StepKind::If => &[ + ("left", "say what the check looks at"), + ("op", "pick how the check compares"), + ], + StepKind::DelayAction => &[("action", "pick a delay action")], + StepKind::Program => &[("path", "pick the program to run")], + StepKind::File => &[("path", "pick the file to write")], + StepKind::SetVar | StepKind::Counter => &[("name", "name the value")], StepKind::WaitDelay | StepKind::Stop | StepKind::Marker | StepKind::Clip => &[], } } @@ -251,8 +259,20 @@ pub fn visit_steps<'a>(steps: &'a [Step], f: &mut dyn FnMut(&'a Step)) { impl Integration { /// Problems that stop it being saved, worded for the dashboard. + /// Every problem, for switching it on: it must be complete and valid. pub fn validate(&self) -> Vec { + let problems = self.problems(); + let mut all = problems.invalid; + all.extend(problems.incomplete); + all + } + + /// Problems split by what they block. `invalid` ones stop any save; + /// `incomplete` ones only stop it running, so a switched-off + /// integration can be saved half-finished as a draft. + pub fn problems(&self) -> Problems { let mut errors = Vec::new(); + let mut incomplete = Vec::new(); if !valid_id(&self.id) { errors.push("invalid id".to_string()); } @@ -263,7 +283,7 @@ impl Integration { errors.push(format!("the name is longer than {MAX_NAME_LEN} characters")); } if self.handlers.is_empty() { - errors.push("add at least one trigger".to_string()); + incomplete.push("add at least one trigger".to_string()); } if self.handlers.len() > MAX_HANDLERS { errors.push(format!("more than {MAX_HANDLERS} triggers")); @@ -273,11 +293,15 @@ impl Integration { errors.push(format!("more than {MAX_STEPS} steps")); } for handler in &self.handlers { - validate_trigger(&handler.trigger, &mut errors); - validate_steps(&handler.steps, 1, &mut errors); + validate_trigger(&handler.trigger, &mut incomplete); + validate_steps(&handler.steps, 1, &mut errors, &mut incomplete); } errors.dedup(); - errors + incomplete.dedup(); + Problems { + invalid: errors, + incomplete, + } } /// Whether any step runs a program or writes a file. @@ -365,17 +389,36 @@ pub fn valid_command(c: &str) -> bool { && !c[1..].contains(|ch: char| ch.is_whitespace() || ch == '!') } -fn validate_steps(steps: &[Step], depth: usize, errors: &mut Vec) { +fn validate_steps( + steps: &[Step], + depth: usize, + errors: &mut Vec, + incomplete: &mut Vec, +) { if depth > MAX_DEPTH { errors.push(format!("checks nest more than {MAX_DEPTH} deep")); return; } for step in steps { - for name in step.kind.required() { + for (name, ask) in step.kind.required() { if step.param(name).trim().is_empty() { - errors.push(format!("a {} step is missing its {}", step.kind.id(), name)); + incomplete.push(ask.to_string()); } } + // 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. + if matches!(step.kind, StepKind::Program | StepKind::File) + && step.param("path").contains('{') + { + errors.push(format!( + "a {} step's path can't use variables", + step.kind.id() + )); + } + if step.kind == StepKind::Http && !host_is_fixed(step.param("url")) { + errors.push("a web request's address can't put variables in its host".to_string()); + } if step.params.values().any(|v| v.len() > MAX_PARAM_LEN) { errors.push(format!( "a {} step has a value that is too long", @@ -385,11 +428,26 @@ fn validate_steps(steps: &[Step], depth: usize, errors: &mut Vec) { if step.kind != StepKind::If && !(step.then.is_empty() && step.otherwise.is_empty()) { errors.push(format!("a {} step cannot hold other steps", step.kind.id())); } - validate_steps(&step.then, depth + 1, errors); - validate_steps(&step.otherwise, depth + 1, errors); + validate_steps(&step.then, depth + 1, errors, incomplete); + validate_steps(&step.otherwise, depth + 1, errors, incomplete); } } +/// 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 { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + let host = rest.split(['/', '?', '#']).next().unwrap_or(""); + let scheme = url.split_once("://").map_or("", |(s, _)| s); + !host.contains('{') && !scheme.contains('{') +} + +/// See `Integration::problems`. +pub struct Problems { + pub invalid: Vec, + pub incomplete: Vec, +} + fn roles_json(r: Roles) -> Value { json::obj([ ("everyone", Value::Bool(r.everyone)), @@ -685,7 +743,7 @@ mod tests { }; let errors = bad.validate(); assert!(errors.iter().any(|e| e.contains("name"))); - assert!(errors.iter().any(|e| e.contains("missing its connection"))); + assert!(errors.iter().any(|e| e == "pick a Discord channel")); assert!(errors.iter().any(|e| e.contains("\"clip\""))); } @@ -720,6 +778,38 @@ mod tests { assert!(i.has_local_effects()); } + #[test] + fn viewers_can_never_choose_a_program_file_or_host() { + let mut i = sample(); + i.handlers[0].steps = vec![Step::new(StepKind::Program, &[("path", "{message}")])]; + assert!(!i.problems().invalid.is_empty()); + i.handlers[0].steps = vec![Step::new( + StepKind::File, + &[("path", "C:/{arg1}.txt"), ("text", "x")], + )]; + assert!(!i.problems().invalid.is_empty()); + i.handlers[0].steps = vec![Step::new(StepKind::Http, &[("url", "https://{arg1}/x")])]; + assert!(!i.problems().invalid.is_empty()); + i.handlers[0].steps = vec![Step::new( + StepKind::Http, + &[("url", "https://api.example/{arg1}?q={user}")], + )]; + assert!( + i.problems().invalid.is_empty(), + "{:?}", + i.problems().invalid + ); + } + + #[test] + fn drafts_are_incomplete_not_invalid() { + let mut i = sample(); + i.handlers[0].steps = vec![Step::new(StepKind::Http, &[("url", "")])]; + let p = i.problems(); + assert!(p.invalid.is_empty()); + assert!(!p.incomplete.is_empty()); + } + #[test] fn commands_need_a_bang_and_no_spaces() { assert!(valid_command("!delay")); diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index 1bdce60..dc6c0dc 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -312,10 +312,14 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option vec![ - on(EventKind::DelayOn, vec![chat("Stream delay is now on.")]), - on(EventKind::DelayOff, vec![chat("Stream delay is now off.")]), - ], + "delay_notice" => { + // A burst of cuts must not become a burst of chat lines. + cooldown_ms = 30_000; + vec![ + on(EventKind::DelayOn, vec![chat("Stream delay is now on.")]), + on(EventKind::DelayOff, vec![chat("Stream delay is now off.")]), + ] + } "mod_controls" => vec![ command( "!cut", diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs index b628a72..bf1d182 100644 --- a/src/integrations/recipe.rs +++ b/src/integrations/recipe.rs @@ -95,6 +95,11 @@ pub fn parse(text: &str, mut new_id: impl FnMut() -> String) -> Result, /// Shown on the dashboard when Twitch logged us out. notice: Mutex>, - upkeep_now: Notify, + upkeep_now: Arc, /// Writes a line to the dashboard log. log: Box, } @@ -119,7 +119,7 @@ impl Twitch { bot: Conn::default(), incoming, notice: Mutex::new(None), - upkeep_now: Notify::new(), + upkeep_now: Arc::new(Notify::new()), log, } } @@ -186,8 +186,14 @@ impl Twitch { }; let incoming = (which == Which::Main).then(|| self.incoming.clone()); let state = conn.state.clone(); + let upkeep_now = self.upkeep_now.clone(); tokio::spawn(async move { - irc::run(login, incoming, out_rx, state, stop_rx).await; + irc::run(login, incoming, out_rx, state.clone(), stop_rx).await; + // Twitch refused the token: refresh now rather than leaving chat + // down until the next upkeep round. + if *state.lock() == ChatState::AuthFailed { + upkeep_now.notify_one(); + } }); } diff --git a/web/index.html b/web/index.html index a2a7fc0..9c461b5 100644 --- a/web/index.html +++ b/web/index.html @@ -2250,6 +2250,8 @@ .ig-chip:hover{border-color:var(--line-2)} .ig-chip span{color:var(--fg-3)}.ig-chip b{font-weight:600;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .ig-chip svg{width:11px;height:11px;color:var(--fg-3)} +.ig-chip.warn{border-color:color-mix(in oklch,var(--warn) 55%,transparent);background:color-mix(in oklch,var(--warn) 9%,var(--surface-3))} +.ig-chip.warn b{color:var(--warn)} .ig-chip.on{border-color:color-mix(in oklch,var(--accent) 55%,transparent);background:color-mix(in oklch,var(--accent) 12%,var(--surface-3))} .ig-pop{min-height:96px;padding:14px 16px;border-radius:12px;background:var(--bg-2);border:1px solid var(--line); display:flex;flex-direction:column;gap:10px} diff --git a/web/integrations.js b/web/integrations.js index 887a966..75c36ca 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -80,7 +80,7 @@ const KINDS = { const PREVIEW_ORDER = ['discord','chat','phone','marker','http','file','clip','delay_action','program']; const CATEGORY = {alerts:'Alerts', chat:'Chat', auto:'Automation', own:'Your own'}; const DELAY_ACTIONS = { - cut:'Cut the delay', arm:'Set the delay', activate:'Turn the delay on', toggle:'Toggle the delay', + cut:'Cut the delay', arm:'Set the delay to…', activate:'Turn the delay on', toggle:'Toggle the delay', cut_after:'Cut after this airs', end_hold:'End the reconnect screen', }; const OPS = { @@ -271,9 +271,13 @@ async function poll(){ S.data.twitch = a.twitch; if (flowWas !== !!a.twitch.login) schedulePoll(); if (S.visible && !modalOpen()){ - if (changed) render(); else renderActivity(); + // A full redraw would steal the caret from someone typing a search. + const typing = document.activeElement && document.activeElement.id === 'ig-search'; + if (changed && !typing) render(); else renderActivity(); } - if (Modal.kind === 'connections' && (changed || loginWas !== JSON.stringify(a.twitch.login))) Conn.render(); + // Only the Twitch page shows live state; redrawing the others would wipe + // what the user is typing into them. + if (Modal.kind === 'connections' && Conn.tab === 'twitch' && (changed || loginWas !== JSON.stringify(a.twitch.login))) Conn.render(); } function schedulePoll(){ @@ -414,7 +418,7 @@ function gridHtml(items){ return `
${items.map(i => { const c = cardInfo(i); return `
-
+
${previewHtml(c.step, c.h, false)} ${c.failing ? 'Failing' : ''}
@@ -437,7 +441,7 @@ function listHtml(items){ return `
${svg(c.icon)} -
${esc(i.name)}
+
${esc(i.name)}
${esc(c.where)}
${esc(c.summary || '')} ${esc(c.last)} @@ -463,7 +467,7 @@ function emptyHtml(){
Pick a pack to switch on a few integrations at once. Each shows what it sends, and you can change or remove anything later.
${packs.map((p, n) => ``).join('')}
${this.popHtml(step, h)}
@@ -770,7 +795,9 @@ const Editor = { const discordSteps = allSteps(d).filter(s => s.type === 'discord'); const t = h.trigger; if (discordSteps.length){ - out.push({id:'where', k:'Post in', v:channelName(discordSteps[0].params.connection)}); + const id = discordSteps[0].params.connection; + const set = S.data.connections.discord.some(c => c.id === id); + out.push({id:'where', k:'Post in', v:set ? channelName(id) : 'pick a channel', warn:!set}); if (step && step.type === 'discord'){ const ping = step.params.ping || ''; out.push({id:'ping', k:'Ping', v:ping === 'here' ? '@here' : ping === 'everyone' ? '@everyone' : 'nobody'}); @@ -864,7 +891,7 @@ const Editor = { if (!this.run) return '
Running the test…
'; if (this.run.error) return `
⚠${esc(this.run.error)}
`; return `
Test run: ${esc(this.run.status)}
${runLogHtml(this.run.steps)} -
Messages went out for real, marked [TEST]. Waits were skipped and the stream was never touched.
`; +
Messages and web requests went out for real, messages marked [TEST] and never pinging anyone. Waits were skipped; the stream, VOD, programs and files were left alone.
`; }, lastRunHtml(){ const st = stat(this.d); @@ -893,7 +920,11 @@ const Editor = { else if (b === 'aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); else if (b === 'role'){ t.roles = t.roles || {}; t.roles[el.dataset.name] = el.checked; } else if (b === 'reply' && step) step.params.reply = el.checked ? 'yes' : ''; - else if (['url', 'method', 'path', 'mode'].includes(b) && step) step.params[b] = el.value; + else if ((b === 'url' || b === 'method') && step){ + // A catalog webhook sends every event to one address: set them all. + allSteps(this.d).filter(s => s.type === 'http').forEach(s => { s.params[b] = el.value; }); + } + else if ((b === 'path' || b === 'mode') && step) step.params[b] = el.value; else return; this.dirty = true; if (el.tagName === 'SELECT' || el.type === 'checkbox') this.refreshChips(); @@ -901,7 +932,7 @@ const Editor = { refreshChips(){ const h = this.handler(), step = primaryStep(h); const chips = Modal.el && Modal.el.querySelector('.ig-chips'); - if (chips) chips.innerHTML = this.chips(step, h).map(c => ``).join(''); }, act(a, el){ @@ -942,6 +973,26 @@ function sampleLine(text, h){ return 'Reads like: ' + esc(renderSample(text, sampleMap(h))) + ''; } +// A draft saved with no trigger still opens: it gets a starting one. +function withHandler(d){ + if (!d.handlers || !d.handlers.length) d.handlers = [{enabled:true, trigger:newTrigger('event'), steps:[]}]; + return d; +} + +// Discord steps with no channel yet get the first one, e.g. right after +// the user added their first channel from inside an editor. True if any +// step changed. +function pickNewChannel(d){ + const first = S.data.connections.discord[0]; + if (!first) return false; + let changed = false; + allSteps(d).forEach(s => { + const known = S.data.connections.discord.some(c => c.id === s.params.connection); + if (s.type === 'discord' && !known){ s.params.connection = first.id; changed = true; } + }); + return changed; +} + function setSend(h, v){ const first = (h.steps || [])[0]; if (first && (first.type === 'wait' || first.type === 'wait_delay')) h.steps.shift(); @@ -980,7 +1031,9 @@ async function saveIntegration(d, onSaved){ const r = await api('/integrations/save', d); if (!r.ok){ toast(r.error || 'Could not save', 'err', 6000); return false; } if (onSaved) onSaved(); - if (r.warnings && r.warnings.length){ + if (r.missing && r.missing.length){ + toast(`Saved as a draft. Still to do: ${r.missing.join('; ')}`, 'info', 7000); + } else if (r.warnings && r.warnings.length){ toast(`Saved. Check these names, nothing fills them: ${r.warnings.map(w => '{' + w + '}').join(', ')}`, 'info', 7000); } else toast('Saved', 'ok'); Modal.close(true); @@ -1003,7 +1056,7 @@ const TRIGGERS = [['event', 'An InstantClone event'], ['chat_command', 'A chat c const Builder = { d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, open(i, fromEditor){ - this.d = i ? clone(i) : {id:'', name:'My integration', enabled:true, preset:'', cooldown_ms:0, + this.d = i ? withHandler(clone(i)) : {id:'', name:'My integration', enabled:true, preset:'', cooldown_ms:0, handlers:[{enabled:true, trigger:{type:'event', event:'hold_opened', filters:{}}, steps:[]}]}; this.h = 0; this.sel = 'trigger'; @@ -1015,6 +1068,12 @@ const Builder = { this.render(); }, handler(){ return this.d.handlers[this.h]; }, + resume(){ + if (pickNewChannel(this.d)) this.dirty = true; + Modal.open('builder', '', 1240); + Modal.onClose = () => !this.dirty || confirm('Close without saving your changes?'); + this.renderKeepScroll(); + }, render(){ const d = this.d; Modal.body(`${modalHead('steps', 'var(--accent)', @@ -1134,13 +1193,14 @@ const Builder = { + '
Text compares ignoring case; "is more than" compares numbers.
'; break; case 'stop': body = '
Ends this run here.
'; break; case 'delay_action': body = field('action', 'Action', {select:Object.entries(DELAY_ACTIONS)}) - + (s.params.action === 'arm' ? field('seconds', 'Delay (seconds)', {mono:true, ph:'{arg1}'}) : '') + + (s.params.action === 'arm' ? field('seconds', 'Delay (seconds)', {mono:true, ph:'{arg1}'}) + + '
Arms this delay, or changes it live when a delay is already on air. Never disarms.
' : '') + '
⚠This changes your stream. Tests never run it.
'; break; case 'marker': body = field('description', 'Label', {ph:'Crash'}) + '
Only works while you are live on Twitch.
'; break; case 'clip': body = '
Clips the last moments of your stream. Later steps can use {clip.url} and {clip.ok}.
'; break; - case 'program': body = field('path', 'Program', {mono:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) + case 'program': body = field('path', 'Program (a fixed path, no variables)', {mono:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) + '
⚠Runs on your PC. Recipes you import can never add this without asking you first.
'; break; - case 'file': body = field('path', 'File', {mono:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); break; + case 'file': body = field('path', 'File (a fixed path, no variables)', {mono:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); break; case 'set_var': body = field('name', 'Name', {mono:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + '
Later steps use it as {name}. Lasts for this run.
'; break; case 'counter': body = field('name', 'Name', {mono:true, ph:'crashes'}) + field('op', 'Do', {select:[['', 'Add'], ['subtract', 'Subtract'], ['set', 'Set to'], ['reset', 'Reset to 0']]}) + field('by', 'By', {mono:true, ph:'1'}) + `
Kept between runs. Use it anywhere as {counter.${esc(s.params.name || 'name')}}.
`; break; @@ -1349,10 +1409,13 @@ function stepSentence(s){ const Conn = { tab:'discord', editing:null, open(tab){ + // Opened from an editor or an import: go back to it, work intact. + const from = {editor:Editor, builder:Builder, import:Recipes}[Modal.kind] || null; this.tab = tab || 'discord'; this.editing = null; Modal.open('connections', '', 760); Modal.onClose = () => { if (S.data.twitch.login) api('/twitch/cancel', {}); return true; }; + if (from) Modal.after = () => from.resume(); this.render(); }, render(){ @@ -1403,7 +1466,7 @@ const Conn = { const code = active && !flow.error ? `
${flow.user_code ? `
${esc(flow.user_code)}
Enter the code there and approve. This page updates on its own. The code works for ${Math.max(1, Math.round(flow.expires_in_s / 60))} more minutes.
` @@ -1470,6 +1533,10 @@ async function refreshData(){ const Recipes = { preview:null, text:'', + resume(){ + Modal.open('import', '', 700); + this.render(); + }, openImport(){ this.preview = null; this.text = ''; @@ -1583,6 +1650,14 @@ function onInput(e){ } } +// Enter or Space on a clickable card acts like a click. +function onActivateKey(e){ + if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"][data-act]')){ + e.preventDefault(); + e.target.click(); + } +} + // Remember the last text field the builder used, for variable inserts. document.addEventListener('focusin', e => { if (Modal.kind === 'builder' && e.target.matches('.ig-inspector input:not([readonly]), .ig-inspector textarea')) Builder.lastField = e.target; @@ -1594,6 +1669,7 @@ function wireRoot(){ root.dataset.wired = '1'; root.addEventListener('click', onClick); root.addEventListener('input', onInput); + root.addEventListener('keydown', onActivateKey); root.addEventListener('toggle', e => { if (e.target.id === 'ig-activity-box') S.activityOpen = e.target.open; }, true); } From 473d63b266da2fe826a99526b09789ebb3453d79 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Thu, 1 Oct 2026 15:00:23 +0200 Subject: [PATCH 39/55] feat(integrations): smooth, polished UI and leak fixes UI - Redraws morph the live DOM instead of replacing innerHTML, so focus, carets, scroll and transitions survive. Keyed items slide to their new place, fade in and fade out; panels animate their height. - One modal shell that swaps content in place, closes with an animation, traps focus and gives it back. - Unsaved changes show a bar (Discard / Keep editing / Save) instead of confirm(); destructive buttons confirm on a second click. - Save is lit only with changes; Ctrl+S saves; spinners on every button that waits on the app; the toggle flips at once and rolls back on error. - Cards flag "Failing", "Finish setup", "Needs a fix" and "Needs Twitch" from the new /integrations issues map; per-module icons; skeleton on first load; sliding pills on segmented controls; toggle pills for roles. - Builder: steps joined by a spine, trigger tabs read "When the hold runs out", the insertion point can no longer drift into a hidden list. - Late replies never draw over another modal, and saves only close the editor that started them. Leaks - Engine prunes per-viewer and per-trigger cooldowns, flap records and stats of deleted integrations every minute. - Programs started by integrations are reaped without a thread each and capped at 16 running. - Failed step details lose URL paths and queries (webhook tokens). - Recipes never carry web addresses, headers or file and program paths. --- src/integrations/api.rs | 21 + src/integrations/effects.rs | 21 +- src/integrations/engine.rs | 43 + src/integrations/recipe.rs | 43 +- src/integrations/runner.rs | 97 +- web/index.html | 440 +++++++-- web/integrations.js | 1729 +++++++++++++++++++++++------------ 7 files changed, 1716 insertions(+), 678 deletions(-) diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 34677a6..5f9968a 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -193,6 +193,27 @@ fn overview(handle: &super::Handle, s: &Settings) -> Value { ), ]), ), + // What each integration still needs before it can run, so a card + // can say "Pick a Discord channel" without opening the editor. + ( + "issues", + Value::Obj( + s.integrations + .iter() + .map(|i| { + let mut p = i.problems(); + p.invalid.extend(p.incomplete); + if let Err(e) = check_connections(i, s) { + p.invalid.push(e); + } + ( + i.id.clone(), + Value::Arr(p.invalid.into_iter().map(json::str).collect()), + ) + }) + .collect(), + ), + ), ("twitch", handle.twitch.status_json()), ("catalog", presets::catalog_json()), ("events", event::catalog_json()), diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index a682d95..df3223e 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -16,11 +16,17 @@ const MAX_RESPONSE: u64 = 1024 * 1024; /// Largest text a file step writes. const MAX_FILE_TEXT: usize = 1024 * 1024; +/// Programs started by integrations that may still be running. Kept so +/// 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; + pub struct RealHost { pub ctrl: Arc, pub twitch: Arc, /// The streamer's usual delay, used when `arm` names none. pub default_delay_ms: AtomicU32, + pub programs: crate::sync::Mutex>, } impl Host for RealHost { @@ -200,17 +206,22 @@ impl Host for RealHost { if path.is_empty() { return Err("pick a program to run".to_string()); } - let mut child = std::process::Command::new(path) + let mut running = self.programs.lock(); + // Reap whatever has finished since last time. + running.retain_mut(|c| matches!(c.try_wait(), Ok(None))); + if running.len() >= MAX_RUNNING_PROGRAMS { + return Err(format!( + "{MAX_RUNNING_PROGRAMS} programs started by integrations are still running; not starting another" + )); + } + let child = std::process::Command::new(path) .args(args) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .map_err(|e| format!("couldn't start it: {e}"))?; - // Reap it whenever it ends, so nothing is left behind. - std::thread::spawn(move || { - let _ = child.wait(); - }); + running.push(child); Ok(()) } diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index 322c313..dd8ec80 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -38,6 +38,10 @@ const PER_INTEGRATION: usize = 4; const GLOBAL_RUNS: usize = 32; const ACTIVITY_KEEP: usize = 150; const TICK: Duration = Duration::from_millis(250); +/// 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. +const PRUNE_EVERY: Duration = Duration::from_secs(60); /// A destination stuck in a connect-then-drop loop (a key the platform /// accepts, then refuses) would otherwise alert on every round. Each /// destination gets at most one "live" and one "dropped" per window. @@ -216,6 +220,13 @@ impl Handle { ) } + /// Forget the stats of integrations that no longer exist. + fn retain_stats(&self, alive: &[&str]) { + self.stats + .lock() + .retain(|id, _| alive.contains(&id.as_str())); + } + pub fn dropped_events(&self) -> u64 { self.dropped.load(Ordering::Relaxed) } @@ -263,6 +274,7 @@ pub fn start( ctrl: ctrl.clone(), twitch: twitch.clone(), default_delay_ms: Default::default(), + programs: Default::default(), }); let mut engine = Engine::new(engine_handle, host, store, ctrl); engine.run(rx, chat_rx, settings).await; @@ -320,6 +332,7 @@ impl Engine { ) { self.apply_settings(&settings.borrow_and_update()); let mut tick = tokio::time::interval(TICK); + let mut last_prune = Instant::now(); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { @@ -338,6 +351,10 @@ impl Engine { _ = tick.tick() => { self.watch_delay(); self.run_timers(); + if last_prune.elapsed() >= PRUNE_EVERY { + last_prune = Instant::now(); + self.prune(); + } } } } @@ -360,6 +377,27 @@ impl Engine { self.viewer_last.retain(|k, _| keep(k)); self.timers.retain(|k, _| keep(k)); self.limits.retain(|id, _| alive.contains(&id.as_str())); + self.handle.retain_stats(&alive); + } + + /// Drop bookkeeping that can no longer affect anything: viewer and + /// trigger cooldowns that have run out, and old flap records. + fn prune(&mut self) { + let (mut longest_viewer, mut longest_run) = (Duration::ZERO, Duration::ZERO); + for i in &self.integrations { + longest_run = longest_run.max(Duration::from_millis(i.cooldown_ms)); + for h in &i.handlers { + if let Trigger::ChatCommand { + user_cooldown_ms, .. + } = h.trigger + { + longest_viewer = longest_viewer.max(Duration::from_millis(user_cooldown_ms)); + } + } + } + self.viewer_last.retain(|_, t| t.elapsed() < longest_viewer); + self.last_run.retain(|_, t| t.elapsed() < longest_run); + self.flap.prune(Instant::now()); } fn input(&mut self, input: Input) { @@ -664,6 +702,11 @@ struct FlapGuard { } impl FlapGuard { + fn prune(&mut self, now: Instant) { + self.last + .retain(|_, t| now.duration_since(*t) < FLAP_WINDOW); + } + fn suppress(&mut self, event: &Event, now: Instant) -> bool { let key = match event.kind { EventKind::DestinationLive | EventKind::DestinationDropped => format!( diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs index bf1d182..d81b96f 100644 --- a/src/integrations/recipe.rs +++ b/src/integrations/recipe.rs @@ -59,11 +59,24 @@ pub fn export(name: &str, integrations: &[Integration]) -> String { format!("{PREFIX}{}", base64url_encode(doc.to_json().as_bytes())) } +/// Blank whatever a recipe must never carry: the Discord connection, +/// web addresses and headers (they hold API keys and webhook secrets), and +/// file and program paths (they name the user's folders). fn strip_private(steps: &mut [super::model::Step]) { for s in steps { - if s.kind == StepKind::Discord { - s.params - .insert("connection".into(), DISCORD_PLACEHOLDER.into()); + match s.kind { + StepKind::Discord => { + s.params + .insert("connection".into(), DISCORD_PLACEHOLDER.into()); + } + StepKind::Http => { + s.params.insert("url".into(), String::new()); + s.params.remove("headers"); + } + StepKind::Program | StepKind::File => { + s.params.insert("path".into(), String::new()); + } + _ => {} } strip_private(&mut s.then); strip_private(&mut s.otherwise); @@ -212,6 +225,30 @@ mod tests { ); } + #[test] + fn addresses_headers_and_paths_are_never_shared() { + use super::super::model::Step; + let mut i = presets::build("webhook", "w".into(), "").unwrap(); + i.handlers[0].steps = vec![ + Step::new( + StepKind::Http, + &[ + ("url", "https://api.example/secret-key"), + ("headers", "Authorization: Bearer s3cret"), + ], + ), + Step::new( + StepKind::File, + &[("path", r"C:\Users\oriol\stream.txt"), ("text", "x")], + ), + ]; + let text = export("x", &[i]); + let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); + for secret in ["secret-key", "s3cret", "oriol"] { + assert!(!decoded.contains(secret), "{secret} leaked"); + } + } + #[test] fn web_call_tokens_are_never_shared() { let mut i = presets::build("delay_command", "x".into(), "").unwrap(); diff --git a/src/integrations/runner.rs b/src/integrations/runner.rs index 032ffd5..ca9aada 100644 --- a/src/integrations/runner.rs +++ b/src/integrations/runner.rs @@ -133,14 +133,18 @@ impl Runner { } fn record(&mut self, label: impl Into, status: StepStatus, detail: impl Into) { + let mut detail = detail.into(); if status == StepStatus::Failed { self.failed = true; + // Errors can quote the address they failed on, and webhook and + // API addresses carry their secret in the path or query. + detail = redact_urls(&detail); } self.log.push(StepLog { label: label.into(), status, at_ms: self.started.elapsed().as_millis() as u64, - detail: detail.into(), + detail, }); } @@ -650,6 +654,40 @@ fn parse_headers(text: &str) -> Vec<(String, String)> { .collect() } +/// Cut every URL in `text` down to its scheme and host, so error messages +/// that quote an address never show the token in its path or query. +pub fn redact_urls(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = ["https://", "http://"] + .iter() + .filter_map(|p| rest.find(p)) + .min() + { + out.push_str(&rest[..at]); + let url = &rest[at..]; + let end = url + .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ')' | '>' | ',')) + .unwrap_or(url.len()); + // Sentence punctuation after an address is not part of it. + let end = url[..end] + .trim_end_matches(['.', ',', ':', ';', '!', '?']) + .len(); + let (whole, tail) = url.split_at(end); + let scheme_end = whole.find("://").map_or(0, |i| i + 3); + let host_end = whole[scheme_end..] + .find(['/', '?', '#']) + .map_or(whole.len(), |i| scheme_end + i); + out.push_str(&whole[..host_end]); + if host_end < whole.len() { + out.push_str("/…"); + } + rest = tail; + } + out.push_str(rest); + out +} + /// Split program arguments on spaces, keeping "quoted parts" together. pub fn split_args(text: &str) -> Vec { let mut args = Vec::new(); @@ -991,6 +1029,63 @@ mod tests { assert!(!compare("x", "bogus_op", "x")); } + #[test] + fn urls_in_errors_lose_their_secrets() { + assert_eq!( + redact_urls("request failed: https://discord.com/api/webhooks/1/tok3n: 404"), + "request failed: https://discord.com/…: 404" + ); + assert_eq!( + redact_urls("see http://host?key=1, ok"), + "see http://host/…, ok" + ); + assert_eq!(redact_urls("no urls here"), "no urls here"); + assert_eq!(redact_urls("https://bare.host"), "https://bare.host"); + } + + #[tokio::test] + async fn failed_steps_never_log_a_webhook_token() { + struct Fails; + impl Host for Fails { + fn live(&self) -> LiveState { + LiveState::default() + } + fn discord(&self, url: &str, _: &str, _: &str) -> Result<(), String> { + Err(format!("couldn't reach {url}")) + } + fn http(&self, _: HttpRequest) -> Result { + Err("x".into()) + } + fn phone(&self, _: &str, _: &str, _: &str, _: &str, _: &str) -> Result<(), String> { + Ok(()) + } + fn chat(&self, _: &str, _: bool, _: Option<&str>) -> Result<(), String> { + Ok(()) + } + fn marker(&self, _: &str) -> Result<(), String> { + Ok(()) + } + fn clip(&self) -> Result { + Err("x".into()) + } + fn delay_action(&self, _: &str, _: u32) -> Result<(), String> { + Ok(()) + } + fn program(&self, _: &str, _: &[String]) -> Result<(), String> { + Ok(()) + } + fn file(&self, _: &str, _: &str, _: bool) -> Result<(), String> { + Ok(()) + } + } + let steps = [Step::new( + StepKind::Discord, + &[("connection", "mods"), ("text", "x")], + )]; + let (_, log) = run(&steps, ctx(&[], false), Arc::new(Fails), env()).await; + assert!(!log[0].detail.contains("/1/x"), "{}", log[0].detail); + } + #[test] fn chat_text_cannot_run_commands_or_span_lines() { assert_eq!(safe_chat_text("/ban someone"), "\u{200B}/ban someone"); diff --git a/web/index.html b/web/index.html index 9c461b5..8caa850 100644 --- a/web/index.html +++ b/web/index.html @@ -2151,32 +2151,188 @@ /* - Integrations ------------------- Built from the Destinations pieces: a module is a .dcard whose screen shows exactly what it sends, and every editor is the destination modal - (.ic-modal + .dest-form). Only the new shapes live here. */ + (.ic-modal + .dest-form). Only the new shapes live here. Items entering, + leaving and moving are animated from integrations.js (see morph). */ +.ig-pane{display:flex;flex-direction:column;gap:14px} +[data-flip]{position:relative} +.ig-sub{text-wrap:pretty} +.ig-num,.ig-count{font-variant-numeric:tabular-nums} +.ig-count{color:var(--fg-4)} +.ig-head-actions{display:flex;gap:8px;flex-wrap:wrap} +.ic-btn.ig-small{padding:6px 11px;font-size:12px;border-radius:8px} +.ig-self-start{align-self:flex-start} +.ig-dim{opacity:.5} +.ig-btn-ic{width:13px;height:13px;flex:none} +/* Waiting on the app: same size, a spinner instead of the label. */ +.ic-btn.is-busy,.ic-btn-tiny.is-busy{position:relative;color:transparent !important;pointer-events:none} +.ic-btn.is-busy>*,.ic-btn-tiny.is-busy>*{visibility:hidden} +.ic-btn.is-busy::after,.ic-btn-tiny.is-busy::after{content:'';position:absolute;inset:0;margin:auto;width:14px;height:14px; + border-radius:50%;border:2px solid color-mix(in oklch,var(--fg) 25%,transparent);border-top-color:var(--fg); + animation:igSpin .7s linear infinite} +.ic-btn-primary.is-busy::after{border-color:color-mix(in oklch,var(--accent-ink) 30%,transparent);border-top-color:var(--accent-ink)} +.ig-spin{width:12px;height:12px;flex:none;border-radius:50%;border:2px solid color-mix(in oklch,var(--accent) 25%,transparent); + border-top-color:var(--accent);animation:igSpin .7s linear infinite} +@keyframes igSpin{to{transform:rotate(360deg)}} +.ig-running{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--fg-3)} +/* A destructive button waiting for its second click. */ +.ic-btn.ig-armed,.ic-btn-tiny.ig-armed{color:var(--danger) !important; + border-color:color-mix(in oklch,var(--danger) 45%,transparent); + background:color-mix(in oklch,var(--danger) 12%,var(--surface-3));animation:igArm .3s var(--ease-spring)} +.ic-btn-tiny.ig-armed{width:auto;padding:0 9px;font-size:11.5px;font-weight:600;white-space:nowrap} +@keyframes igArm{from{transform:scale(.96)}to{transform:none}} +@keyframes igFadeUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} +@keyframes igPop{from{opacity:0;transform:scale(.25);filter:blur(4px)}to{opacity:1;transform:none;filter:blur(0)}} +/* Loading. */ +.ig-skel{position:relative;overflow:hidden;height:150px;padding:8px;border-radius:14px;background:var(--bg-2); + border:1px solid var(--line);display:flex;flex-direction:column;gap:10px} +.ig-skel i{display:block;height:92px;border-radius:6px;background:var(--bg-1)} +.ig-skel b{display:block;height:12px;width:55%;margin-left:4px;border-radius:4px;background:var(--surface-3)} +.ig-skel u{display:block;height:9px;width:35%;margin:-4px 0 0 4px;border-radius:4px;background:var(--surface-3)} +.ig-skel::after{content:'';position:absolute;inset:0;transform:translateX(-100%); + background:linear-gradient(90deg,transparent,color-mix(in oklch,var(--fg) 6%,transparent),transparent); + animation:igShimmer 1.4s ease-in-out infinite} +@keyframes igShimmer{to{transform:translateX(100%)}} +/* Notices and connections. */ +.ig-notices{display:flex;flex-direction:column;gap:8px} +.ig-notices:empty{display:none} +.ig-notice{margin-top:0;align-items:center} +.ig-notice-text{flex:1;min-width:0;text-wrap:pretty} +.ig-notice-text b{color:var(--fg)} +.ig-inline-warn{margin-top:0} +.ig-conns{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.ig-conns .ic-label{margin-right:4px} +.ig-conns .ic-pill{cursor:pointer;font:inherit;font-size:12px; + transition:border-color .2s var(--ease-out),background-color .2s var(--ease-out),color .2s var(--ease-out),transform .15s var(--ease-out)} +.ig-conns .ic-pill:hover{border-color:var(--line-2);color:var(--fg)} +.ig-conns .ic-pill:active{transform:scale(.96)} +/* Segmented controls, with the dashboard's sliding pill. */ +.ig-seg{display:inline-flex;flex:0 0 auto;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{opacity:0;transition:left .28s var(--ease-spring),width .28s var(--ease-spring),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} -.ig-conns{display:flex;align-items:center;gap:8px;flex-wrap:wrap} -.ig-conns .ic-pill{cursor:pointer;font:inherit;font-size:12px} +/* Grid. */ .ig-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px} -.ig-card{padding:10px;gap:10px;cursor:default} -.ig-card .dcard-screen{position:relative;display:flex;align-items:center;height:92px;padding:10px;cursor:pointer; +.ig-card{padding:8px;gap:8px;cursor:default} +.ig-card .dcard-screen{display:flex;align-items:center;height:96px;padding:10px;border-radius:6px;cursor:pointer; transition:border-color .15s var(--ease-out)} .ig-card .dcard-screen:hover{border-color:color-mix(in oklch,var(--dc,var(--accent)) 45%,var(--line))} -.ig-card-foot{display:flex;align-items:center;gap:9px} -.ig-card-foot .dcard-icon{width:28px;height:28px;border-radius:8px} -.ig-card-foot .dcard-icon svg{width:14px;height:14px} -.ig-card .ig-flag{position:absolute;right:6px;top:6px;min-width:0} -.ig-add-tile{min-height:160px} +.ig-card .dcard-screen:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.ig-card-foot{display:flex;align-items:center;gap:9px;padding:2px 4px 4px} +.ig-card-foot .dcard-icon,.ig-row .dcard-icon{width:28px;height:28px;border-radius:8px} +.ig-card-foot .dcard-icon svg,.ig-row .dcard-icon svg{width:14px;height:14px} +.ig-card .dcard-name{font-size:13px} +.ig-card .dcard-host{font-size:10.5px;font-family:inherit;font-variant-numeric:tabular-nums} +.ig-flag{min-width:0;max-width:none} +.ig-card .ig-flag{position:absolute;right:6px;top:6px;backdrop-filter:blur(6px)} +.ig-flag.warn{color:var(--warn);border-color:color-mix(in oklch,var(--warn) 35%,var(--line)); + background:color-mix(in oklch,var(--warn) 10%,var(--surface-3))} +.ig-flag.warn .d-dot{background:var(--warn)} +.ig-flag.bad{color:var(--danger);border-color:color-mix(in oklch,var(--danger) 40%,var(--line)); + background:color-mix(in oklch,var(--danger) 10%,var(--surface-3))} +.ig-flag.bad .d-dot{background:var(--danger);box-shadow:0 0 6px var(--danger)} +.ig-add-tile{min-height:150px} +/* Switches: a bigger hit area and a knob that stretches on press. */ +.ig-pane .dcard-switch::after{content:'';position:absolute;inset:-11px -4px} +.ig-pane .dcard-switch>span{transition:transform .22s var(--ease-spring),width .15s var(--ease-out),background .2s var(--ease-out)} +.ig-pane .dcard-switch:active>span{width:18px} +.ig-pane .dcard-switch.on:active>span{transform:translateX(12px)} +.ig-pane .dcard-switch:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +/* List. */ .ig-list{display:flex;flex-direction:column;gap:8px} .ig-row{padding:0;gap:0} -.ig-row-main{display:flex;align-items:center;gap:12px;padding:9px 12px 9px 16px} -.ig-row-main .dcard-icon{width:30px;height:30px} -.ig-row-name{flex:0 0 240px;min-width:0;cursor:pointer} -.ig-row-sum{flex:1;min-width:0;font-size:12px;color:var(--fg-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.ig-row-last{flex:0 0 96px;text-align:right;font-size:11.5px;color:var(--fg-4)} -.ig-row-peek{display:flex;gap:12px;padding:0 12px 12px 58px} -.ig-row-peek .dcard-screen{flex:1;padding:12px} +.ig-row-main{display:flex;align-items:center;gap:12px;min-height:52px;padding:8px 10px 8px 14px} +.ig-row-name{flex:0 0 220px;min-width:0;display:flex;flex-direction:column;gap:1px;padding:4px 6px;margin:-4px -6px; + border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer; + transition:background-color .15s var(--ease-out)} +.ig-row-name:hover{background:var(--surface-3)} +.ig-row-sum{flex:1;min-width:0;font-size:12.5px;color:var(--fg-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ig-row-last{flex:0 0 auto;min-width:84px;text-align:right;font-size:11.5px;color:var(--fg-4);font-variant-numeric:tabular-nums} +.ig-row .ic-btn-tiny svg{width:14px;height:14px} +.ig-row-peek{display:flex;gap:12px;padding:0 12px 12px 56px;animation:igFadeUp .26s var(--ease-out)} +.ig-row-peek .dcard-screen{flex:1;padding:12px;border-radius:8px} +.ig-row-peek-actions{width:150px;display:flex;flex-direction:column;gap:6px} +.ig-row-peek-actions .ic-btn{padding:8px 12px} +.ig-add-row{min-height:52px;flex-direction:row} +.ig-add-row .dest-add-icon{width:28px;height:28px} +.ig-add-row .dest-add-icon svg{width:15px;height:15px} +.ig-none{padding:18px 4px;color:var(--fg-3);grid-column:1/-1} +@media (max-width:760px){ + .ig-row-sum{display:none} + .ig-row-name{flex:1} + .ig-row-peek{padding-left:12px;flex-direction:column} + .ig-row-peek-actions{width:auto;flex-direction:row} +} +/* First run. */ +.ig-packs{width:min(900px,100%)} +.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} +.ig-pack.ig-pack-rec{border-color:color-mix(in oklch,var(--accent) 45%,var(--line))} +.ig-rec{min-width:0;max-width:none;margin-left:6px;padding:1px 7px;font-size:10.5px;vertical-align:1px} +.ig-pack-list{display:flex;flex-direction:column;gap:5px;margin-top:4px;font-size:12.5px;color:var(--fg-2)} +.ig-pack-list span{display:flex;align-items:center;gap:7px} +.ig-pack-list svg{width:13px;height:13px;flex:none;color:var(--fg-3)} +.ig-empty-more{margin-top:14px} +/* Activity. */ +.ig-activity-box{padding:0} +.ig-activity-box summary{display:flex;align-items:center;gap:10px;padding:12px 16px;border-radius:10px;cursor:pointer; + list-style:none;transition:background-color .15s var(--ease-out)} +.ig-activity-box summary::-webkit-details-marker{display:none} +.ig-activity-box summary:hover{background:color-mix(in oklch,var(--fg) 3%,transparent)} +.ig-act-title{font-weight:600} +.ig-activity-box summary .muted{flex:1;font-variant-numeric:tabular-nums} +.ig-activity-box summary svg{width:14px;height:14px;color:var(--fg-3);transition:transform .25s var(--ease-out)} +.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-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-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. */ +.ig-modal .dest-form:focus{outline:none} +.ig-modal.closing .ic-modal-backdrop{animation:igFadeOut .18s var(--ease-out) forwards} +.ig-modal.closing .dest-form{animation:igModalOut .18s var(--ease-out) forwards} +@keyframes igFadeOut{to{opacity:0}} +@keyframes igModalOut{to{opacity:0;transform:translateY(6px) scale(.98)}} +.ig-modal .ic-btn:disabled:not(.is-busy){opacity:.45;cursor:not-allowed;box-shadow:none} +.ig-modal .dest-form-x svg{width:14px;height:14px} +.ig-modal .dest-form-x{display:grid;place-items:center} +.ig-title-input{width:100%;max-width:420px;padding:2px 6px;margin:-2px -6px;border:1px solid transparent;border-radius:7px; + background:transparent;color:var(--fg);font:inherit;font-weight:600;outline:none; + transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out),box-shadow .2s var(--ease-out)} +.ig-title-input:hover{background:color-mix(in oklch,var(--fg) 4%,transparent)} +.ig-title-input:focus{border-color:color-mix(in oklch,var(--accent) 55%,transparent);background:var(--bg-1); + box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 16%,transparent)} +.ig-head-btn{padding:8px 14px} +.ig-foot-msg{display:flex;align-items:center;gap:10px;min-width:0} +.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)} +.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)} +.ig-del{color:var(--fg-3)} +.ig-del:hover:not(:disabled){color:var(--danger)} +.ig-guard{position:absolute;left:12px;right:12px;bottom:10px;z-index:5;display:flex;align-items:center;gap:8px; + padding:10px 10px 10px 14px;border-radius:12px;background:color-mix(in oklch,var(--warn) 10%,var(--bg-2)); + border:1px solid color-mix(in oklch,var(--warn) 40%,var(--line));box-shadow:0 18px 40px -16px rgba(0,0,0,.7); + animation:igGuardIn .24s var(--ease-out)} +.ig-guard-text{flex:1;display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600} +.ig-guard-text svg{width:16px;height:16px;flex:none;color:var(--warn)} +@keyframes igGuardIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}} /* Message previews, styled like where they land. */ .ig-pv{flex:1;min-width:0;font-size:11.5px;line-height:1.4} +.ig-pv-col{min-width:0} .ig-pv-discord{display:flex;gap:8px;padding:8px 9px;border-radius:8px;background:#313338} .ig-pv-discord .ig-av{width:22px;height:22px;flex:none;border-radius:50%;background:var(--accent); display:grid;place-items:center;color:#051018} @@ -2191,8 +2347,9 @@ .ig-pv-phone{display:flex;gap:8px;padding:8px 9px;border-radius:12px;background:rgba(255,255,255,.08)} .ig-pv-phone .ig-av{width:22px;height:22px;flex:none;border-radius:6px;background:var(--accent)} .ig-pv-phone small{display:block;font-size:9.5px;letter-spacing:.04em;color:var(--fg-3)} +.ig-pv-phone b{font-weight:600} .ig-pv-json{font-family:'JetBrains Mono',monospace;font-size:10.5px;color:var(--fg-2);white-space:pre;overflow:hidden} -.ig-pv-json .k{color:var(--accent)}.ig-pv-json .v{color:var(--live)} +.ig-pv-json .k{color:var(--accent)} .ig-pv-marker{display:flex;flex-direction:column;gap:7px} .ig-pv-marker .tag{align-self:center;padding:2px 7px;border-radius:5px;background:#9146ff;color:#fff;font-size:10px;font-weight:600; max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -2206,118 +2363,265 @@ .ig-pv-action span{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:none; background:color-mix(in oklch,var(--accent) 14%,var(--surface-3));color:var(--accent)} .ig-pv-action svg{width:15px;height:15px} -.ig-pv-steps{display:flex;flex-direction:column;gap:4px;font-size:11.5px;color:var(--fg-2)} -.ig-pv-steps div{display:flex;gap:7px;align-items:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.ig-pv-steps i{width:6px;height:6px;border-radius:2px;flex:none;background:var(--accent)} -/* Big previews inside editors. */ .ig-pv.big{font-size:14px} .ig-pv.big.ig-pv-discord,.ig-pv.big.ig-pv-chat,.ig-pv.big.ig-pv-phone{padding:14px;gap:12px} .ig-pv.big .ig-av{width:40px;height:40px} .ig-pv.big .ig-av svg{width:18px;height:18px} .ig-pv.big .ig-txt{display:block;-webkit-line-clamp:unset} .ig-pv.big.ig-pv-chat div{white-space:normal} -/* Tokens and the in-place message editor. */ +/* Module editor: the live preview you write on. */ +.ig-ed-body{padding:20px 24px 22px;gap:18px} +.ig-ed-preview{display:flex;flex-direction:column} +.ig-ed-pv{display:flex;flex-direction:column;gap:10px} +.ig-ed-options{display:flex;flex-direction:column;gap:10px} +.ig-live{border-radius:14px;overflow:hidden} +.ig-live-tag{font-size:10.5px;font-weight:700;letter-spacing:.06em;color:#adadb8} +.ig-live-discord{background:#313338;color:#dbdee1} +.ig-live-head{height:40px;display:flex;align-items:center;gap:8px;padding:0 16px;border-bottom:1px solid rgba(0,0,0,.3); + color:#f2f3f5;font-size:14px;font-weight:600} +.ig-live-head .hash{color:#80848e;font-size:18px;font-weight:400} +.ig-live-head .ig-live-tag{margin-left:auto;font-weight:500;letter-spacing:0;color:#949ba4} +.ig-live-msg{display:flex;gap:14px;padding:16px} +.ig-live-av{width:40px;height:40px;flex:none;border-radius:50%;background:var(--accent);display:grid;place-items:center;color:#051018} +.ig-live-av svg{width:18px;height:18px} +.ig-live-col{flex:1;min-width:0;font-size:14.5px} +.ig-live-meta{display:flex;align-items:center;gap:6px;margin-bottom:6px} +.ig-live-meta b{color:#f2f3f5} +.ig-live-meta .ig-app{padding:0 4px;border-radius:3px;background:#5865f2;color:#fff;font-size:10px;font-weight:700} +.ig-ping{padding:0 4px;border-radius:3px;background:rgba(88,101,242,.3);color:#c9cdfb;font-size:12px;animation:igPop .26s cubic-bezier(.2,0,0,1)} +.ig-live-chat{background:#18181b;padding:14px 16px;color:#efeff1;font-size:14px;display:flex;flex-direction:column;gap:8px} +.ig-live-chat .u1{color:#ff7eb6}.ig-live-chat .u2{color:#5ac8fa} +.ig-live-phone{border-radius:22px;background:#1d2530;padding:18px;display:flex;flex-direction:column;gap:10px;color:#fff} +.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} .ig-token{display:inline-flex;align-items:baseline;gap:4px;padding:1px 8px;border-radius:999px;cursor:pointer; border:1px solid color-mix(in oklch,var(--accent) 40%,transparent);background:transparent; color:color-mix(in oklch,var(--accent) 70%,#fff);font:500 11px/1.6 'JetBrains Mono',monospace; - transition:background .15s var(--ease-out)} + transition:background-color .15s var(--ease-out),transform .15s var(--ease-out)} .ig-token:hover{background:color-mix(in oklch,var(--accent) 14%,transparent)} +.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-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} + background:color-mix(in oklch,var(--accent) 5%,transparent);color:inherit;font:inherit;line-height:1.5;outline:none; + transition:border-color .15s var(--ease-out),box-shadow .2s var(--ease-out),background-color .15s var(--ease-out)} +.ig-msg:hover{background:color-mix(in oklch,var(--accent) 8%,transparent)} .ig-msg:focus{border-style:solid;box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 18%,transparent)} .ig-sample{margin-top:6px;font-size:12px;color:#949ba4} +.ig-sample.empty{display:none} .ig-sample b{color:#b5bac1;font-weight:500} /* Moments timeline. */ .ig-moments{position:relative;display:grid;grid-template-columns:repeat(var(--n,4),minmax(0,1fr));gap:4px} .ig-moments::before{content:'';position:absolute;left:calc(50% / var(--n,4));right:calc(50% / var(--n,4));top:11px;height:2px;background:var(--line-2)} .ig-moment{position:relative;display:flex;flex-direction:column;align-items:center;gap:8px;min-width:0} -.ig-dot{width:24px;height:24px;padding:0;border-radius:50%;border:2px solid var(--line-2);background:var(--surface-3); - box-shadow:0 0 0 4px var(--bg-2);cursor:pointer;display:grid;place-items:center;color:transparent; - transition:background .2s var(--ease-out),border-color .2s var(--ease-out)} -.ig-dot svg{width:12px;height:12px} -.ig-dot.on{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)} +.ig-dot{position:relative;width:24px;height:24px;padding:0;border-radius:50%;border:2px solid var(--line-2);background:var(--surface-3); + box-shadow:0 0 0 4px var(--bg-2);cursor:pointer;display:grid;place-items:center;color:var(--accent-ink); + transition:background-color .2s var(--ease-out),border-color .2s var(--ease-out),transform .15s var(--ease-out)} +.ig-dot::after{content:'';position:absolute;inset:-9px} +.ig-dot:active{transform:scale(.96)} +.ig-dot svg{width:12px;height:12px;opacity:0;transform:scale(.25);filter:blur(4px); + transition:opacity .2s cubic-bezier(.2,0,0,1),transform .2s cubic-bezier(.2,0,0,1),filter .2s cubic-bezier(.2,0,0,1)} +.ig-dot.on{background:var(--accent);border-color:var(--accent)} +.ig-dot.on svg{opacity:1;transform:none;filter:blur(0)} .ig-moment-name{max-width:100%;padding:5px 12px;border-radius:999px;border:1px solid transparent;background:transparent; - color:var(--fg-3);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + color:var(--fg-3);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; + transition:background-color .2s var(--ease-out),border-color .2s var(--ease-out),color .2s var(--ease-out)} +.ig-moment-name:hover{color:var(--fg)} .ig-moment-name.on{background:color-mix(in oklch,var(--accent) 16%,var(--surface-3)); border-color:color-mix(in oklch,var(--accent) 55%,transparent);color:var(--fg)} /* Option chips and their panel. */ .ig-chips{display:flex;flex-wrap:wrap;gap:8px} .ig-chip{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border-radius:999px;border:1px solid var(--line); background:var(--surface-3);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer; - transition:border-color .15s var(--ease-out),background .15s var(--ease-out)} + transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out),transform .15s var(--ease-out)} .ig-chip:hover{border-color:var(--line-2)} -.ig-chip span{color:var(--fg-3)}.ig-chip b{font-weight:600;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.ig-chip svg{width:11px;height:11px;color:var(--fg-3)} +.ig-chip:active{transform:scale(.96)} +.ig-chip span{color:var(--fg-3)} +.ig-chip b{font-weight:600;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ig-chip svg{width:11px;height:11px;color:var(--fg-3);transition:transform .25s var(--ease-out),color .15s var(--ease-out)} .ig-chip.warn{border-color:color-mix(in oklch,var(--warn) 55%,transparent);background:color-mix(in oklch,var(--warn) 9%,var(--surface-3))} .ig-chip.warn b{color:var(--warn)} .ig-chip.on{border-color:color-mix(in oklch,var(--accent) 55%,transparent);background:color-mix(in oklch,var(--accent) 12%,var(--surface-3))} -.ig-pop{min-height:96px;padding:14px 16px;border-radius:12px;background:var(--bg-2);border:1px solid var(--line); - display:flex;flex-direction:column;gap:10px} -.ig-seg{display:inline-flex;flex-wrap:wrap} -.ig-seg .sub-tab{flex:0 0 auto;padding:6px 13px} -.ig-seg .sub-tab.on{background:var(--surface-3)} +.ig-chip.on svg{transform:rotate(180deg);color:var(--accent)} +.ig-pop{overflow:hidden;box-sizing:border-box;border-radius:12px;background:var(--bg-2);border:1px solid var(--line); + transition:border-color .2s var(--ease-out),background-color .2s var(--ease-out)} +.ig-pop.closed{border-color:transparent;background:transparent} +.ig-pop-in{display:flex;flex-direction:column;gap:10px;padding:14px 16px} +.ig-pop-in .ig-seg{align-self:flex-start} .ig-pick{display:flex;flex-wrap:wrap;gap:8px} -.ig-pick .dest-starter{padding:10px 14px;gap:2px} +.ig-pick .dest-starter{--dc:#a5b4fc;padding:10px 14px;gap:2px} +.ig-pick .dest-starter-name{font-size:13px} .ig-pick .dest-starter.on{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in oklch,var(--accent) 20%,transparent)} +.ig-pick-add{min-height:0;padding:10px 16px;flex-direction:row;gap:6px} +.ig-pick-add svg{width:14px;height:14px} +/* Checkbox rows: undo the destination form's uppercase field labels. */ +.ig-modal .crash-check{display:flex;margin:0;font-size:13px;font-weight:400;color:var(--fg);text-transform:none;letter-spacing:0} +.ig-modal .crash-check .muted{font-size:12px} +.ig-pack.is-busy{opacity:.6;pointer-events:none;cursor:progress} +/* Who can use it: toggle pills. */ +.ig-roles{display:flex;flex-wrap:wrap;gap:6px} +.dest-form .ig-role{position:relative;display:inline-flex;margin:0;cursor:pointer;font-size:inherit;color:inherit; + text-transform:none;letter-spacing:0} +.ig-role input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none} +.ig-role>span{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--line); + background:var(--surface-3);font-size:12.5px;font-weight:500;color:var(--fg-2); + transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out),color .15s var(--ease-out),transform .15s var(--ease-out)} +.ig-role>span svg{width:12px;height:12px;margin-left:-18px;opacity:0;transform:scale(.25);filter:blur(4px); + transition:opacity .2s cubic-bezier(.2,0,0,1),transform .2s cubic-bezier(.2,0,0,1),filter .2s cubic-bezier(.2,0,0,1),margin .2s cubic-bezier(.2,0,0,1)} +.ig-role input:checked+span{border-color:color-mix(in oklch,var(--accent) 55%,transparent); + background:color-mix(in oklch,var(--accent) 12%,var(--surface-3));color:var(--fg)} +.ig-role input:checked+span svg{margin-left:0;opacity:1;transform:none;filter:blur(0);color:var(--accent)} +.ig-role input:focus-visible+span{outline:2px solid var(--accent);outline-offset:2px} +.ig-role:active>span{transform:scale(.96)} /* Catalog. */ .ig-cat{flex:1;min-height:0;display:flex} .ig-cat-nav{width:200px;flex:none;padding:16px 12px;display:flex;flex-direction:column;gap:2px;border-right:1px solid var(--line)} .ig-cat-nav button{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;border-radius:8px; - border:1px solid transparent;background:transparent;color:var(--fg-3);font:inherit;font-size:13px;font-weight:500;cursor:pointer;text-align:left} + border:1px solid transparent;background:transparent;color:var(--fg-3);font:inherit;font-size:13px;font-weight:500;cursor:pointer;text-align:left; + transition:background-color .15s var(--ease-out),color .15s var(--ease-out),border-color .15s var(--ease-out),transform .15s var(--ease-out)} +.ig-cat-nav button:hover{color:var(--fg);background:color-mix(in oklch,var(--fg) 4%,transparent)} +.ig-cat-nav button:active{transform:scale(.96)} .ig-cat-nav button.on{background:var(--surface-3);border-color:var(--line-2);color:var(--fg)} -.ig-cat-nav button small{color:var(--fg-4)} +.ig-cat-nav button small{color:var(--fg-4);font-variant-numeric:tabular-nums} +.ig-cat-import{margin-top:auto;padding:10px;border-radius:10px;border:1px dashed var(--line-2);font-size:12px;line-height:1.5;color:var(--fg-3)} +.ig-cat-import a{color:var(--accent);font-weight:600} .ig-cat-grid{flex:1;min-width:0;overflow-y:auto;padding:16px 18px 20px;display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px;align-content:start} +/* A category's tiles fade in one after another; tiles already shown stay. */ +.ig-cat-grid>*{animation:igTileIn .32s var(--ease-out) both;animation-delay:calc(min(var(--i,0),10) * 26ms)} +@keyframes igTileIn{from{opacity:0;transform:translateY(8px) scale(.98);filter:blur(3px)}to{opacity:1;transform:none;filter:blur(0)}} +.ig-cat-tile{padding:8px;gap:10px} +.ig-cat-tile .dcard-screen{min-height:84px;padding:10px;border-radius:6px;display:flex;align-items:center} +.ig-cat-id{display:flex;gap:10px;align-items:flex-start;padding:0 4px} +.ig-cat-id .dcard-icon{width:28px;height:28px;border-radius:8px} +.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-needs{margin-top:4px;font-size:11px;color:var(--warn)} +.ig-cat-tile>.ic-btn{margin:auto 4px 4px} +.ig-cat-pack{padding:14px;gap:10px} +.ig-cat-pack>.ic-btn{align-self:flex-start;margin-top:auto} +.ig-cat-build{min-height:214px;padding:16px;text-align:center} +.ig-cat-build-t{font-size:14px;font-weight:600;color:var(--fg)} +.ig-cat-build-d{max-width:220px;font-size:12px;line-height:1.5;color:var(--fg-3);text-wrap:pretty} +.ic-btn.ig-added{color:var(--live);border-color:color-mix(in oklch,var(--live) 40%,transparent); + background:color-mix(in oklch,var(--live) 10%,transparent)} +.ig-added svg{width:13px;height:13px;animation:igPop .3s cubic-bezier(.2,0,0,1)} +@media (max-width:720px){ + .ig-cat{flex-direction:column} + .ig-cat-nav{width:auto;flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line)} + .ig-cat-import{display:none} +} /* 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:5px 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} +.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; + 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)} -.ig-triggers{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px} -.ig-steps{display:flex;flex-direction:column;gap:8px} -.ig-step{display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border-radius:12px;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)} +.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; + transition:background-color .15s var(--ease-out),color .15s var(--ease-out),border-color .15s var(--ease-out),transform .15s var(--ease-out)} +.ig-trig:hover{color:var(--fg);border-color:var(--line-2)} +.ig-trig:active{transform:scale(.96)} +.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; + 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)} +.ig-step:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .ig-step.on{border-color:color-mix(in oklch,var(--accent) 60%,transparent); 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-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} -.ig-step:hover .ig-step-tools,.ig-step.on .ig-step-tools{opacity:1} -.ig-branch{margin-left:30px;padding-left:12px;border-left:2px solid var(--line);display:flex;flex-direction:column;gap:8px} +.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} +.ig-step-tools svg{width:13px;height:13px} +.ig-step-tools .ic-btn-tiny:disabled{opacity:.3;cursor:default} +.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-drop{min-height:34px;border-radius:10px;border:1px dashed var(--line-2);background:transparent;color:var(--fg-4); - font:inherit;font-size:12px;cursor:pointer} -.ig-drop.on{border-color:var(--accent);color:var(--accent);background:color-mix(in oklch,var(--accent) 6%,transparent)} -.ig-inspector{width:320px;flex:none;padding:16px 18px;display:flex;flex-direction:column;gap:12px;border-left:1px solid var(--line);overflow-y:auto} +.ig-branch-label.else{color:var(--warn)} +.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-insp{display:flex;flex-direction:column;gap:12px;min-height:100%} +.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} +.ig-insp-vars .muted{font-size:11.5px} +.ig-insp-p{margin:0;font-size:13px;line-height:1.6;color:var(--fg-2);text-wrap:pretty} +.ig-insp-row{display:flex;align-items:center;justify-content:space-between;gap:8px} .ig-inspector .dff>label{font-size:12px;font-weight:600;color:var(--fg-2)} .ig-inspector textarea.ic-input{resize:vertical;min-height:70px;font-family:'JetBrains Mono',monospace;font-size:12.5px} +.ig-copy-row{display:flex;gap:6px} +.ig-copy-row .ic-input{flex:1;min-width:0;font-size:11.5px} .ig-run{display:flex;flex-direction:column;gap:2px;font-size:12.5px} .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} +.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} -/* Twitch device code. */ -.ig-code{font:600 30px/1 'JetBrains Mono',monospace;letter-spacing:.18em;color:var(--fg);text-align:center;padding:14px; - border-radius:12px;background:var(--bg-2);border:1px dashed color-mix(in oklch,var(--accent) 45%,var(--line))} +/* 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 .dcard-icon{--dc:#a78bfa} -.ig-activity{display:flex;flex-direction:column;gap:2px;font-size:12.5px} -.ig-activity > div{display:grid;grid-template-columns:54px minmax(0,220px) minmax(0,1fr) 76px;gap:10px;align-items:center; - padding:6px 4px;border-bottom:1px solid var(--line)} -.ig-activity .t{color:var(--fg-4);font-family:'JetBrains Mono',monospace;font-size:11.5px} -.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-activity .ok{color:var(--live)}.ig-activity .failed{color:var(--danger)}.ig-activity .stopped,.ig-activity .busy{color:var(--fg-3)} +.ig-acct.bare{padding:0;border:0;background:none} +.ig-acct .dcard-icon{width:32px;height:32px} +.ig-acct .ic-btn-tiny svg{width:13px;height:13px} +.ig-ic-discord{--dc:#a5b4fc} +.ig-ic-twitch{--dc:#a78bfa} +.ig-blurb{font-family:inherit;white-space:normal;line-height:1.45} +.ig-account,.ig-form{display:flex;flex-direction:column;gap:12px} +.ig-form-actions{display:flex;gap:8px;justify-content:flex-end} +.ig-device{display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center;animation:igFadeUp .3s var(--ease-out)} +.ig-device-actions{display:flex;flex-wrap:wrap;justify-content:center;gap:8px} +.ig-device-actions a{text-decoration:none} +.ig-code{align-self:stretch;padding:14px;border-radius:12px;background:var(--bg-2);border:1px dashed color-mix(in oklch,var(--accent) 45%,var(--line)); + font:600 30px/1 'JetBrains Mono',monospace;letter-spacing:.18em;color:var(--fg);text-align:center;font-variant-numeric:tabular-nums; + animation:igPop .35s cubic-bezier(.2,0,0,1)} +/* Recipes. */ .ig-textarea-code{width:100%;min-height:90px;resize:vertical;font-family:'JetBrains Mono',monospace;font-size:12px} +.ig-recipe{display:flex;flex-direction:column;gap:12px;animation:igFadeUp .3s var(--ease-out)} +.ig-recipe-list{display:flex;flex-direction:column;gap:8px} +.ig-recipe-head{display:flex;align-items:center;gap:8px} +.ig-ok{width:20px;height:20px;flex:none;border-radius:50%;display:grid;place-items:center; + background:color-mix(in oklch,var(--live) 18%,transparent);color:var(--live)} +.ig-ok svg{width:12px;height:12px;animation:igPop .3s cubic-bezier(.2,0,0,1)} +.ig-recipe-item{padding-left:28px;font-size:13px;color:var(--fg-2)} +.ig-recipe-item b{color:var(--fg)} +.ig-recipe-safe{display:flex;align-items:flex-start;gap:8px} +.ig-recipe-safe svg{width:14px;height:14px;flex:none;margin-top:2px;color:var(--accent)} +.ig-share-list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow:auto} +@media (prefers-reduced-motion: reduce){ + .ig-skel::after{animation:none} + .ig-spin,.ic-btn.is-busy::after,.ic-btn-tiny.is-busy::after{animation-duration:1.6s} + .ig-row-peek,.ig-run-box,.ig-device,.ig-code,.ig-recipe,.ig-unsaved,.ig-guard,.ig-ping,.ig-added svg,.ig-ok svg, + .ig-activity-box[open] .ig-activity,.ig-modal.closing .dest-form,.ig-modal.closing .ic-modal-backdrop, + .ic-btn.ig-armed,.ic-btn-tiny.ig-armed,.ig-cat-grid>*{animation:none} + .ig-dot svg,.ig-role>span svg,.ig-chip svg,.ig-seg .sub-tab-ind{transition:none} +} @media (max-width:760px){ .ig-bld,.ig-cat{flex-direction:column} .ig-palette,.ig-inspector,.ig-cat-nav{width:auto;border:0;border-bottom:1px solid var(--line)} diff --git a/web/integrations.js b/web/integrations.js index 75c36ca..21843cd 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -2,9 +2,10 @@ // step builder, connections (Discord, Twitch, phone) and recipes. // // Loaded after the dashboard script, so it reuses its helpers ($, toast, -// staggerIn, fitPaneCard) and its components: a module is a .dcard, every -// editor is the destination modal (.ic-modal + .dest-form). All user text -// goes through esc() before it touches innerHTML. +// staggerIn, fitPaneCard, flashCopied, prefersReducedMotion) and its +// components: a module is a .dcard, every editor is the destination modal +// (.ic-modal + .dest-form). All user text goes through esc() before it +// touches the DOM, and every redraw goes through morph() (see Rendering). (function(){ 'use strict'; @@ -54,6 +55,10 @@ const PATHS = { terminal:'M4 17l6-5-6-5M12 19h8', warn:'M12 9v4M12 17h.01M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z', bolt:'M13 2L3 14h9l-1 8 10-12h-9l1-8z', + megaphone:'M3 11v2a1 1 0 0 0 1 1h3l5 4V6L7 10H4a1 1 0 0 0-1 1zM16 8.5a5 5 0 0 1 0 7M19 5.5a9 9 0 0 1 0 13', + hash:'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', + bell:'M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0', + pencil:'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z', }; const svg = (name, extra) => ``; @@ -76,9 +81,14 @@ const KINDS = { set_var: {label:'Remember a value', tag:'VALUE', c:'#93c5fd', icon:'steps', text:'value'}, counter: {label:'Counter', tag:'VALUE', c:'#93c5fd', icon:'steps'}, }; +// The icon a catalog module wears: what it is about, not how it sends. +const PRESET_ICON = { + crash_alert:'shield', destination_down:'signal', going_live:'megaphone', phone_crash:'phone', + tell_chat:'bubble', delay_command:'hash', delay_notice:'clock', mod_controls:'cut', socials:'hash', + vod_markers:'bookmark', webhook:'link', crash_counter:'file', stream_alerts:'bell', +}; // Which step a card previews: the first one that says something. const PREVIEW_ORDER = ['discord','chat','phone','marker','http','file','clip','delay_action','program']; -const CATEGORY = {alerts:'Alerts', chat:'Chat', auto:'Automation', own:'Your own'}; const DELAY_ACTIONS = { cut:'Cut the delay', arm:'Set the delay to…', activate:'Turn the delay on', toggle:'Toggle the delay', cut_after:'Cut after this airs', end_hold:'End the reconnect screen', @@ -136,6 +146,10 @@ function triggerLabel(t){ } return t.type; } +// "The hold runs out" reads "When the hold runs out"; "OBS crashed" stays. +function lowerFirst(s){ + return /^[A-Z][a-z]/.test(s || '') ? s[0].toLowerCase() + s.slice(1) : s; +} // Sample values for previews: the variables this handler can use. function varsFor(handler){ @@ -186,11 +200,284 @@ function fmtAgo(ms){ function fmtClock(ms){ return new Date(ms).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); } +function fmtMs(ms){ + ms = +ms || 0; + if (!ms) return ''; + if (ms < 60000) return Math.round(ms / 1000) + ' s'; + return Math.round(ms / 60000) + ' min'; +} +function plural(n, word){ return `${n} ${word}${n === 1 ? '' : 's'}`; } function randomToken(){ const b = new Uint8Array(16); crypto.getRandomValues(b); return Array.from(b, x => x.toString(16).padStart(2, '0')).join(''); } +// Stable ids for objects with none (steps, handlers), so a redraw knows a +// moved step is the same step and slides it instead of rebuilding it. +const UIDS = new WeakMap(); +let lastUid = 0; +function uidOf(o){ + if (!UIDS.has(o)) UIDS.set(o, ++lastUid); + return UIDS.get(o); +} + +// ---------------------------------------------------------------- rendering + +// Redraws never replace innerHTML: morph() patches the live DOM to match +// the new HTML, so focus, carets, scroll and CSS transitions survive. A +// child with data-key is matched by key. Inside a [data-flip] parent, +// keyed children slide to their new place, fade in when new and fade out +// when gone; a [data-autoh] element animates to its new height. +const EASE = 'cubic-bezier(.2,.7,.2,1)'; +const reduced = () => typeof prefersReducedMotion === 'function' && prefersReducedMotion(); +// Buttons waiting on the app keep their spinner through redraws. +const busyEls = new WeakSet(); + +function morph(root, html){ + if (!root) return; + const tpl = document.createElement('template'); + tpl.innerHTML = html; + const motion = !reduced() && root.isConnected && root.getClientRects().length > 0; + const ctx = {before: motion ? measure(root) : null, entered:[], left:[]}; + const focused = document.activeElement; + morphChildren(root, tpl.content, ctx); + // Moving a node drops its focus; give it back. + if (focused && focused !== document.activeElement && focused.isConnected && root.contains(focused) + && !focused.closest('[data-leaving]')) focused.focus({preventScroll:true}); + if (motion) animateMorph(ctx); + placeIndicators(root); +} + +function measure(root){ + const rects = new Map(), heights = new Map(); + root.querySelectorAll('[data-flip]').forEach(p => { + rects.set(p, p.getBoundingClientRect()); + for (const c of p.children){ + if (c.hasAttribute('data-key') && !c.hasAttribute('data-leaving')) rects.set(c, c.getBoundingClientRect()); + } + }); + root.querySelectorAll('[data-autoh]').forEach(el => heights.set(el, el.getBoundingClientRect().height)); + return {rects, heights}; +} + +const keyOf = n => n.nodeType === 1 ? n.getAttribute('data-key') : null; +// Leaving nodes are fading out; persistent ones (the unsaved-changes bar) +// were added outside the template. Morph leaves both alone. +const isLeaving = n => n.nodeType === 1 && (n.hasAttribute('data-leaving') || n.hasAttribute('data-persist')); +function nextLive(n){ + while (n && isLeaving(n)) n = n.nextSibling; + return n; +} + +function morphChildren(parent, src, ctx){ + const olds = [...parent.childNodes].filter(n => !isLeaving(n)); + const byKey = new Map(); + olds.forEach(n => { const k = keyOf(n); if (k) byKey.set(k, n); }); + const used = new Set(); + let oi = 0; + let ref = nextLive(parent.firstChild); + for (const next of [...src.childNodes]){ + const k = keyOf(next); + let match = null; + if (k){ + const c = byKey.get(k); + if (c && c.nodeName === next.nodeName && !used.has(c)) match = c; + } else { + while (oi < olds.length && (used.has(olds[oi]) || keyOf(olds[oi]))) oi++; + const c = olds[oi]; + if (c && c.nodeName === next.nodeName){ match = c; oi++; } + } + let node = next; + if (match){ used.add(match); patch(match, next, ctx); node = match; } + else if (k) ctx.entered.push(next); + if (node === ref) ref = nextLive(ref.nextSibling); + else parent.insertBefore(node, ref); + } + olds.forEach(n => { if (!used.has(n)) removeNode(n, parent, ctx); }); +} + +function patch(a, b, ctx){ + if (a.nodeType !== 1){ + if (a.nodeValue !== b.nodeValue) a.nodeValue = b.nodeValue; + return; + } + const tag = a.tagName; + // Form fields: only push a value the model changed, so whatever the user + // typed into a field the model doesn't track yet survives a redraw. + const oldValue = a.getAttribute('value'), oldChecked = a.hasAttribute('checked'); + const oldSelected = tag === 'SELECT' ? selectedOf(a) : null; + syncAttrs(a, b); + if (busyEls.has(a)){ a.classList.add('is-busy'); a.disabled = true; a.setAttribute('aria-busy', 'true'); } + if (tag === 'INPUT'){ + if (a.type === 'checkbox' || a.type === 'radio'){ + if (b.hasAttribute('checked') !== oldChecked) a.checked = b.hasAttribute('checked'); + } else { + const v = b.getAttribute('value') || ''; + if (v !== (oldValue || '') && a.value !== v && a !== document.activeElement) a.value = v; + } + return; + } + if (tag === 'TEXTAREA'){ + const v = b.textContent; + if (v !== a.defaultValue){ + a.defaultValue = v; + if (a.value !== v && a !== document.activeElement) a.value = v; + } + return; + } + morphChildren(a, b, ctx); + if (tag === 'SELECT'){ + const now = selectedOf(a); + if (now !== oldSelected && a.value !== now) a.value = now; + } +} + +function selectedOf(select){ + const o = select.querySelector('option[selected]') || select.querySelector('option'); + return o ? o.value : ''; +} + +function syncAttrs(a, b){ + // A
belongs to the user once drawn; textareas keep the height + // autosize gave them. + const keep = name => (name === 'open' && a.tagName === 'DETAILS') + || (name === 'style' && a.hasAttribute('data-keep-style')); + for (const {name} of [...a.attributes]){ + if (!b.hasAttribute(name) && !keep(name)) a.removeAttribute(name); + } + for (const {name, value} of b.attributes){ + if (a.getAttribute(name) !== value && !keep(name)) a.setAttribute(name, value); + } +} + +function removeNode(n, parent, ctx){ + const r = ctx.before && ctx.before.rects.get(n); + const pr = ctx.before && ctx.before.rects.get(parent); + if (!r || !pr || !r.width){ n.remove(); return; } + // Out of the flow at once, so the rest slides into place while it fades. + n.setAttribute('data-leaving', ''); + n.inert = true; + Object.assign(n.style, { + position:'absolute', margin:'0', pointerEvents:'none', + left:(r.left - pr.left - parent.clientLeft + parent.scrollLeft) + 'px', + top:(r.top - pr.top - parent.clientTop + parent.scrollTop) + 'px', + width:r.width + 'px', height:r.height + 'px', + }); + ctx.left.push(n); +} + +function animateMorph(ctx){ + const {rects, heights} = ctx.before; + // Slide what moved, measured against its parent so nested lists don't + // move twice. + rects.forEach((old, el) => { + if (!el.hasAttribute('data-key') || !el.isConnected || el.hasAttribute('data-leaving') || !old.width) return; + const parent = el.parentElement, pOld = rects.get(parent); + if (!pOld) return; + // A slide still running restarts from where it is; entrance animations + // are the same in both measurements, so they are left alone. + el.getAnimations().forEach(x => { if (x.id === 'flip') x.cancel(); }); + const now = el.getBoundingClientRect(), pNow = parent.getBoundingClientRect(); + const dx = (old.left - pOld.left) - (now.left - pNow.left); + const dy = (old.top - pOld.top) - (now.top - pNow.top); + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; + el.animate([{transform:`translate(${dx}px,${dy}px)`}, {transform:'none'}], {id:'flip', duration:300, easing:EASE}); + }); + let n = 0; + ctx.entered.forEach(el => { + const parent = el.parentElement; + if (!parent || !rects.has(parent) || !parent.hasAttribute('data-flip')) return; + el.animate([ + {opacity:0, transform:'translateY(6px) scale(.98)', filter:'blur(3px)'}, + {opacity:1, transform:'none', filter:'blur(0)'}, + ], {duration:280, easing:EASE, delay:Math.min(n++, 8) * 28, fill:'backwards'}); + }); + ctx.left.forEach(el => { + const a = el.animate([{opacity:1, transform:'none'}, {opacity:0, transform:'translateY(-4px) scale(.98)'}], + {duration:170, easing:EASE, fill:'forwards'}); + a.onfinish = () => el.remove(); + a.oncancel = () => el.remove(); + }); + heights.forEach((h, el) => { + if (!el.isConnected) return; + const now = el.getBoundingClientRect().height; + if (Math.abs(now - h) < 1) return; + el.animate([{height:h + 'px'}, {height:now + 'px'}], {duration:260, easing:EASE}); + }); +} + +// Cards ease in one after another when the tab opens. Animations, not a +// class, so the redraw that lands mid-way can't cut them short. +function enterStagger(root){ + if (!root || reduced()) return; + [...root.querySelectorAll('.ig-grid > [data-key], .ig-list > [data-key], .ig-pack')].slice(0, 24).forEach((el, n) => { + el.animate([{opacity:0, transform:'translateY(8px)', filter:'blur(3px)'}, {opacity:1, transform:'none', filter:'blur(0)'}], + {duration:340, easing:EASE, delay:Math.min(n, 12) * 30, fill:'backwards'}); + }); +} + +// Segmented controls carry the dashboard's sliding pill; it is placed +// under the active tab after every redraw, and glides when that changes. +const SEG_IND = ''; +function placeIndicators(root){ + root.querySelectorAll('.ig-seg').forEach(seg => { + const ind = seg.querySelector(':scope > .sub-tab-ind'); + const on = seg.querySelector(':scope > .sub-tab.on'); + if (!ind) return; + if (!on || !on.offsetWidth){ ind.style.opacity = '0'; return; } + ind.style.left = on.offsetLeft + 'px'; + ind.style.width = on.offsetWidth + 'px'; + ind.style.opacity = '1'; + }); +} + +// Spinner on a button while `fn` runs; a second click is ignored. +async function busy(btn, fn){ + if (!btn || busyEls.has(btn)) return undefined; + busyEls.add(btn); + btn.classList.add('is-busy'); + btn.disabled = true; + btn.setAttribute('aria-busy', 'true'); + try { + return await fn(); + } finally { + busyEls.delete(btn); + btn.classList.remove('is-busy'); + btn.removeAttribute('aria-busy'); + btn.disabled = false; + } +} + +// Destructive buttons ask on the button itself: the first click arms it, +// a second within three seconds confirms. True when confirmed. +function armConfirm(btn, label){ + if (btn.hasAttribute('data-armed')) return true; + const before = btn.innerHTML; + btn.setAttribute('data-armed', ''); + btn.classList.add('ig-armed'); + btn.innerHTML = esc(label); + setTimeout(() => { + if (!btn.isConnected || !btn.hasAttribute('data-armed')) return; + btn.removeAttribute('data-armed'); + btn.classList.remove('ig-armed'); + btn.innerHTML = before; + }, 3000); + return false; +} + +// The live element for a selector inside the open modal, never one that is +// fading out. +function q(sel){ + if (!Modal.el) return null; + return [...Modal.el.querySelectorAll(sel)].find(el => !el.closest('[data-leaving]')) || null; +} + +function copyText(btn){ + const text = btn.dataset.text || ''; + navigator.clipboard.writeText(text).then( + () => { if (typeof flashCopied === 'function' && btn.classList.contains('ic-btn')) flashCopied(btn); else toast('Copied', 'ok'); }, + () => toast('Could not copy: select it and copy it by hand', 'err')); +} // ---------------------------------------------------------------- previews @@ -199,7 +486,7 @@ function previewHtml(step, handler, big){ const cls = 'ig-pv' + (big ? ' big' : ''); if (!step){ const n = handler ? (handler.steps || []).length : 0; - return `
${svg('steps')}${n} step${n === 1 ? '' : 's'}
`; + return `
${svg('steps')}${plural(n, 'step')}
`; } const vars = sampleMap(handler); const k = KINDS[step.type] || {}; @@ -210,15 +497,15 @@ function previewHtml(step, handler, big){ switch (step.type){ case 'discord': { const ping = step.params.ping === 'here' ? '@here ' : step.params.ping === 'everyone' ? '@everyone ' : ''; - return `
${svg('shield')} + return `
${svg('shield')} InstantCloneAPP
${esc(ping + text)}
`; } case 'chat': return `
${ask ? `
viewer: ${esc(ask)}
` : ''} -
${esc((S.data.twitch.bot.login && step.params.as === 'bot') ? S.data.twitch.bot.login : (S.data.twitch.main.login || 'you'))}: ${esc(text)}
`; +
${esc(chatAuthor(step))}: ${esc(text)}
`; case 'phone': - return `
- INSTANTCLONE · now${esc(renderSample(step.params.title, vars) || 'InstantClone')}
${esc(text)}
`; + return `
+ INSTANTCLONE · now${esc(renderSample(step.params.title, vars) || 'InstantClone')}
${esc(text)}
`; case 'marker': return `
${esc(text || 'Marker')} · 1:02:14
`; @@ -239,6 +526,10 @@ function previewHtml(step, handler, big){ } return `
${svg(k.icon || 'steps')}${esc(k.label || step.type)}
`; } +function chatAuthor(step){ + const t = S.data.twitch; + return (step.params.as === 'bot' && t.bot.login) || t.main.login || 'you'; +} // ---------------------------------------------------------------- state @@ -251,33 +542,31 @@ const S = { async function load(){ const d = await api('/integrations'); if (!d || d.ok === false || !d.integrations){ - $('ig-root').innerHTML = `
⚠${esc((d && d.error) || 'Integrations are not available right now.')}
`; - return; + const why = (d && d.error) || 'Integrations are not available right now.'; + // A failed refresh keeps what is on screen; only a first load says so. + if (S.data) toast(why, 'err', 5000); + else morph($('ig-root'), `
⚠${esc(why)} +
`); + return false; } S.data = d; render(); + return true; } async function poll(){ - if (!S.visible && !modalOpen()) return; + if (document.hidden || !S.data || (!S.visible && !modalOpen())) return; const a = await api('/integrations/activity'); if (!S.data || !a || !a.activity) return; - const loginWas = JSON.stringify(S.data.twitch.login); - const flowWas = !!S.data.twitch.login; - const changed = JSON.stringify(a.stats) !== JSON.stringify(S.data.stats) - || JSON.stringify(a.twitch) !== JSON.stringify(S.data.twitch); + const fastBefore = !!S.data.twitch.login; + const twitchChanged = JSON.stringify(a.twitch) !== JSON.stringify(S.data.twitch); S.data.activity = a.activity; S.data.stats = a.stats; S.data.twitch = a.twitch; - if (flowWas !== !!a.twitch.login) schedulePoll(); - if (S.visible && !modalOpen()){ - // A full redraw would steal the caret from someone typing a search. - const typing = document.activeElement && document.activeElement.id === 'ig-search'; - if (changed && !typing) render(); else renderActivity(); - } - // Only the Twitch page shows live state; redrawing the others would wipe - // what the user is typing into them. - if (Modal.kind === 'connections' && Conn.tab === 'twitch' && (changed || loginWas !== JSON.stringify(a.twitch.login))) Conn.render(); + if (fastBefore !== !!a.twitch.login) schedulePoll(); + if (S.visible) render(); + // Only the Twitch page shows live state. + if (Modal.kind === 'connections' && Conn.tab === 'twitch' && twitchChanged) Conn.render(); } function schedulePoll(){ @@ -286,27 +575,47 @@ function schedulePoll(){ S.pollTimer = setInterval(() => { poll(); }, fast ? 2000 : 3500); } +async function refreshData(){ + const d = await api('/integrations'); + if (d && d.integrations){ S.data = d; if (S.visible) render(); } +} + // ---------------------------------------------------------------- pane function stat(i){ return (S.data.stats || {})[i.id] || null; } +function issuesOf(i){ return ((S.data && S.data.issues) || {})[i.id] || []; } +function find(id){ return S.data.integrations.find(i => i.id === id); } + +// On, but nothing to talk to: it uses chat, markers or clips while no +// Twitch account is connected. +function needsTwitch(i){ + if (S.data.twitch.main.login) return false; + return (i.handlers || []).some(h => h.trigger && h.trigger.type.startsWith('chat')) + || allSteps(i).some(s => s.type === 'chat' || s.type === 'marker' || s.type === 'clip'); +} function cardInfo(i){ const h = (i.handlers || []).find(x => x.enabled) || (i.handlers || [])[0]; const step = primaryStep(h); const k = step ? KINDS[step.type] : null; const st = stat(i); - let where = 'Your own · ' + allSteps(i).length + ' steps'; + let where = 'Your own · ' + plural(allSteps(i).length, 'step'); if (step && step.type === 'discord') where = channelName(step.params.connection); else if (step && step.type === 'chat') where = 'Twitch chat'; else if (step && k) where = k.label; - const failing = i.enabled && st && st.failing > 0; + const failing = !!(i.enabled && st && st.failing > 0); + const issues = issuesOf(i); + let flag = null; + if (failing) flag = {cls:'bad', label:'Failing', tip:'The last run failed. Open it to see why.'}; + else if (issues.length) flag = {cls:'warn', label:i.enabled ? 'Needs a fix' : 'Finish setup', tip:issues.join('; ')}; + else if (i.enabled && needsTwitch(i)) flag = {cls:'warn', label:'Needs Twitch', tip:'Connect Twitch in Connections'}; + const text = step && k && k.text ? step.params[k.text] : ''; return { - h, step, + h, step, flag, failing, where, dc: k ? k.c : 'var(--accent)', - icon: k ? k.icon : 'steps', - where, failing, - last: st && st.last_ms ? fmtAgo(st.last_ms) : 'never', - summary: step && k && k.text ? step.params[k.text] : triggerLabel(h && h.trigger), + icon: PRESET_ICON[i.preset] || (k ? k.icon : 'steps'), + last: st && st.last_ms ? fmtAgo(st.last_ms) : 'never ran', + summary: text ? renderSample(text, sampleMap(h)) : (h ? triggerSentence(h.trigger) : ''), }; } @@ -317,6 +626,12 @@ function visibleIntegrations(){ && (!q || i.name.toLowerCase().includes(q) || JSON.stringify(i.handlers).toLowerCase().includes(q))); } +function skeletonHtml(){ + return `
Integrations
+
Loading…
+
`; +} + function render(){ const root = $('ig-root'); if (!root || !S.data) return; @@ -324,29 +639,22 @@ function render(){ const on = d.integrations.filter(i => i.enabled).length; const failing = d.integrations.filter(i => cardInfo(i).failing); const empty = d.integrations.length === 0; - root.innerHTML = ` -
-
-
-
Integrations
-
${empty ? 'Alerts, chat commands and automations. Each card shows exactly what it sends.' - : `${on} on${failing.length ? ` · ${failing.length} need${failing.length === 1 ? 's' : ''} attention` : ''}. Each card shows exactly what it sends.`}
-
-
- + const sub = empty ? 'Alerts, chat commands and automations. Each card shows exactly what it sends.' + : `${on} on${failing.length ? ` · ${failing.length} need${failing.length === 1 ? 's' : ''} attention` : ''}. Each card shows exactly what it sends.`; + morph(root, `
+
+
Integrations
${sub}
+
+ ${empty ? '' : ''}
- ${noticesHtml(failing)} +
${noticesHtml(failing)}
${connectionsHtml()} - ${empty ? emptyHtml() : `${toolbarHtml()}
${itemsHtml()}
`} -
- Recent activity -
-
-
`; - renderActivity(); + ${empty ? emptyHtml() : toolbarHtml() + `
${itemsHtml()}
`} + ${activityHtml()} +
`); if (typeof fitPaneCard === 'function') fitPaneCard(); } @@ -354,16 +662,16 @@ function noticesHtml(failing){ const d = S.data; const out = []; if (d.twitch.notice){ - out.push(`
⚠ - ${esc(d.twitch.notice)} -
`); + out.push(`
⚠ + ${esc(d.twitch.notice)} +
`); } failing.slice(0, 2).forEach(i => { const rec = (d.activity || []).find(r => r.integration_id === i.id && r.status === 'failed'); const step = rec && rec.steps.find(s => s.status === 'failed'); - out.push(`
⚠ - ${esc(i.name)} ${step ? 'failed: ' + esc(step.detail) : 'is failing'}. Nothing else is affected. -
`); + out.push(`
⚠ + ${esc(i.name)} ${step ? 'failed: ' + esc(step.detail) : 'is failing'}. Nothing else is affected. +
`); }); return out.join(''); } @@ -376,8 +684,8 @@ function connectionsHtml(){ const phoneOn = !!c.phone.topic; const pill = (tab, cls, k, v) => ``; - return `
Connections - ${pill('discord', discord ? 'ok' : '', 'Discord', discord ? `${discord} channel${discord === 1 ? '' : 's'}` : 'Add')} + return `
Connections + ${pill('discord', discord ? 'ok' : '', 'Discord', discord ? plural(discord, 'channel') : 'Add')} ${pill('twitch', twitchOn ? (twitchLive ? 'ok' : 'warn') : '', 'Twitch', twitchOn ? (twitchLive ? t.main.login : t.main.login + ' · ' + t.main.chat) : (t.available ? 'Connect' : 'Unavailable'))} ${pill('phone', phoneOn ? 'ok' : '', 'Phone', phoneOn ? c.phone.topic : 'Connect')}
`; @@ -386,123 +694,138 @@ function connectionsHtml(){ function toolbarHtml(){ const counts = {all:S.data.integrations.length}; S.data.integrations.forEach(i => { const c = categoryOf(i); counts[c] = (counts[c] || 0) + 1; }); - const tab = (id, label) => ``; - const view = (id, icon, label) => ``; - return `
-
- ${tab('all', 'All')}${tab('alerts', 'Alerts')}${tab('chat', 'Chat')}${tab('auto', 'Automation')}${tab('own', 'Your own')} + const tab = (id, label) => ``; + const view = (id, icon, label) => ``; + return `
+
+ ${tab('all', 'All')}${tab('alerts', 'Alerts')}${tab('chat', 'Chat')}${tab('auto', 'Automation')}${tab('own', 'Your own')}${SEG_IND}
- -
- ${view('grid', 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', 'Grid')}${view('list', 'M4 6h16M4 12h16M4 18h16', 'List')} + +
+ ${view('grid', 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', 'Grid')}${view('list', 'M4 6h16M4 12h16M4 18h16', 'List')}${SEG_IND}
`; } function itemsHtml(){ const items = visibleIntegrations(); - if (!items.length){ - return `
Nothing here${S.search ? ' matches "' + esc(S.search) + '"' : ' yet'}.
`; - } - return S.view === 'grid' ? gridHtml(items) : listHtml(items); + const none = items.length ? '' : `
Nothing here${S.search ? ' matches "' + esc(S.search) + '"' : ' yet'}.
`; + return S.view === 'grid' ? gridHtml(items, none) : listHtml(items, none); } function switchHtml(i){ return ``; } +function flagHtml(flag){ + if (!flag) return ''; + return `${flag.label}`; +} -function gridHtml(items){ - return `
${items.map(i => { +function gridHtml(items, none){ + return `
${none}${items.map(i => { const c = cardInfo(i); - return `
+ return `
- ${previewHtml(c.step, c.h, false)} - ${c.failing ? 'Failing' : ''} + ${previewHtml(c.step, c.h, false)}${flagHtml(c.flag)}
${svg(c.icon)} -
${esc(i.name)}
-
${esc(c.where)} · ${esc(c.last)}
+
${esc(i.name)}
+
${esc(c.where)} · ${esc(c.last)}
${switchHtml(i)}
`; }).join('')} -
`; +
`; } -function listHtml(items){ - return `
${items.map(i => { +function listHtml(items, none){ + return `
${none}${items.map(i => { const c = cardInfo(i); const open = S.peek === i.id; - const status = c.failing ? ['s-wait', 'Failing'] : i.enabled ? ['s-ready', 'On'] : ['', 'Off']; - return `
+ return `
${svg(c.icon)} -
${esc(i.name)}
-
${esc(c.where)}
- ${esc(c.summary || '')} - ${esc(c.last)} -
${status[1]}
+ + ${esc(c.summary)} + ${c.flag ? flagHtml(c.flag) : `${esc(c.last)}`} ${switchHtml(i)} - +
${open ? `
${previewHtml(c.step, c.h, true)}
-
- - +
+ +
` : ''}
`; }).join('')} -
`; +
`; } function emptyHtml(){ const packs = S.data.catalog.packs; const preset = id => S.data.catalog.presets.find(p => p.id === id); - return `
+ return `
What should InstantClone do for you?
Pick a pack to switch on a few integrations at once. Each shows what it sends, and you can change or remove anything later.
-
- ${packs.map((p, n) => ``).join('')}
- +
`; } -function renderActivity(){ - const box = $('ig-activity'); - if (!box || !S.data) return; +function activityHtml(){ const rows = (S.data.activity || []).slice(0, 40); - const count = $('ig-activity-count'); - if (count) count.textContent = rows.length ? `· last run ${fmtAgo(rows[0].at_ms)}` : '· nothing has run yet'; - box.innerHTML = rows.length ? rows.map(r => { + const seen = new Map(); + const body = rows.length ? rows.map(r => { + let key = `${r.at_ms}-${r.integration_id}`; + const dup = seen.get(key) || 0; + seen.set(key, dup + 1); + if (dup) key += '-' + dup; const failed = r.steps.find(s => s.status === 'failed'); const detail = failed ? failed.label + ': ' + failed.detail : r.status === 'busy' ? 'skipped: already running' : r.steps.filter(s => s.status === 'ok').map(s => s.label).join(' · ') || r.trigger; - return `
${esc(fmtClock(r.at_ms))}${esc(r.name)}${r.test ? ' (test)' : ''} + return `
${esc(fmtClock(r.at_ms))} + ${esc(r.name)}${r.test ? ' (test)' : ''} ${esc(r.trigger)} → ${esc(detail)}${esc(r.status)}
`; - }).join('') : '
Runs show up here, with every step and what it answered.
'; + }).join('') : '
Runs show up here, with every step and what it answered.
'; + return `
+ Recent activity${rows.length ? 'last run ' + esc(fmtAgo(rows[0].at_ms)) : 'nothing has run yet'}${svg('chev')} +
${body}
+
`; } // ---------------------------------------------------------------- actions -function find(id){ return S.data.integrations.find(i => i.id === id); } - async function toggle(id){ const i = find(id); if (!i) return; - const r = await api('/integrations/toggle', {id, enabled:!i.enabled}); - if (r.ok){ i.enabled = !i.enabled; render(); } - else { toast(r.error || 'Could not switch it', 'err', 5000); if (!i.enabled) openEditor(i); } + const want = !i.enabled; + const issues = issuesOf(i); + if (want && issues.length){ + toast(`${i.name}: ${issues[0]} first`, 'info', 4500); + openEditor(i, true); + return; + } + // Flip at once; put it back if the app says no. + i.enabled = want; + render(); + const r = await api('/integrations/toggle', {id, enabled:want}); + if (r.ok) return; + i.enabled = !want; + render(); + toast(r.error || 'Could not switch it', 'err', 5000); + if (want) openEditor(i, true); } async function addFrom(body, openAfter){ @@ -510,15 +833,14 @@ async function addFrom(body, openAfter){ if (!r.ok){ toast(r.error || 'Could not add it', 'err', 5000); return; } await load(); const added = (r.ids || []).map(find).filter(Boolean); - toast(added.length > 1 ? `Added ${added.length} integrations` : `Added ${added[0] ? added[0].name : ''}`, 'ok'); const unfinished = added.find(i => !i.enabled); if (openAfter && unfinished){ - Modal.close(true); - if (unfinished.preset) Editor.open(unfinished, true); else Builder.open(unfinished); + openEditor(unfinished, true); return; } + toast(added.length > 1 ? `Added ${added.length} integrations` : `Added ${added[0] ? added[0].name : ''}`, 'ok'); if (unfinished) toast(`${unfinished.name} needs one more detail before it can switch on`, 'info', 4500); - if (Modal.kind === 'catalog') Catalog.render(); + if (Modal.kind === 'catalog' && body.preset) Catalog.flashAdded(body.preset); } async function duplicate(id){ @@ -529,80 +851,204 @@ async function duplicate(id){ } async function remove(id){ - const i = find(id); - if (!i || !confirm(`Delete "${i.name}"? This can't be undone.`)) return; + const kind = Modal.kind; const r = await api('/integrations/delete', {id}); if (!r.ok){ toast(r.error || 'Could not delete it', 'err'); return; } - Modal.close(); + // Only close the editor that asked; the user may have moved on. + if (Modal.kind === kind) Modal.close(true); await load(); toast('Deleted', 'ok'); } -function openEditor(i){ - if (i.preset) Editor.open(i); else Builder.open(i); +// `switchOn`: it should end up on, and waits for a detail first. +function openEditor(i, switchOn){ + if (i.preset) Editor.open(i, switchOn); + else Builder.open(i, {switchOn}); } // ---------------------------------------------------------------- modal shell -// One modal at a time, built on the destination editor's shell. Escape and -// the backdrop close it; `onClose` lets an editor ask before losing edits. +// One modal shell at a time, built on the destination editor's. Opening +// another while one is up swaps the content in place (no second backdrop). +// Escape, the backdrop and ✕ close it; `onClose` lets an editor hold the +// close to ask about unsaved changes, `after` runs once it really closes. const Modal = { - el:null, kind:null, onClose:null, after:null, - open(kind, html, width){ - this.close(true); - const wrap = document.createElement('div'); - wrap.className = 'ic-modal'; - wrap.setAttribute('role', 'dialog'); - wrap.setAttribute('aria-modal', 'true'); - wrap.innerHTML = `
-
${html}
`; - document.body.appendChild(wrap); - wrap.addEventListener('click', onClick); - wrap.addEventListener('input', onInput); - wrap.addEventListener('change', onInput); - wrap.addEventListener('keydown', onActivateKey); - this.el = wrap; + el:null, form:null, kind:null, onClose:null, after:null, opener:null, swapFrom:null, + open(kind, width, height){ + if (this.el){ + this.swapFrom = this.form.getBoundingClientRect(); + this.hideGuard(); + } else { + this.opener = document.activeElement; + const wrap = document.createElement('div'); + wrap.className = 'ic-modal ig-modal'; + wrap.setAttribute('role', 'dialog'); + wrap.setAttribute('aria-modal', 'true'); + wrap.innerHTML = '
'; + document.body.appendChild(wrap); + wrap.addEventListener('click', onClick); + wrap.addEventListener('input', onInput); + wrap.addEventListener('keydown', onActivateKey); + wrap.addEventListener('focusout', onFieldLeave); + document.addEventListener('keydown', onModalKey); + this.el = wrap; + this.form = wrap.querySelector('.dest-form'); + this.form.focus({preventScroll:true}); + } this.kind = kind; this.onClose = null; this.after = null; - document.addEventListener('keydown', onModalKey); - return wrap; + this.form.style.width = `min(${width || 820}px,100%)`; + this.form.style.height = height || ''; + return this.el; }, body(html){ - if (this.el) this.el.querySelector('.dest-form').innerHTML = html; + if (!this.form) return; + if (!this.swapFrom){ morph(this.form, html); return; } + // A different modal in the same shell: resize to it and fade it in. + const from = this.swapFrom; + this.swapFrom = null; + this.form.innerHTML = html; + this.form.focus({preventScroll:true}); + placeIndicators(this.form); + if (reduced()) return; + const to = this.form.getBoundingClientRect(); + this.form.animate([{width:from.width + 'px', height:from.height + 'px'}, {width:to.width + 'px', height:to.height + 'px'}], + {duration:280, easing:EASE}); + for (const c of this.form.children){ + c.animate([{opacity:0, transform:'translateY(4px)'}, {opacity:1, transform:'none'}], + {duration:240, easing:EASE, delay:60, fill:'backwards'}); + } }, close(force){ if (!this.el) return; if (!force && this.onClose && this.onClose() === false) return; const after = this.after; - this.el.remove(); - this.el = null; - this.kind = null; this.after = null; + this.onClose = null; + this.kind = null; + if (after){ + after(); + if (this.kind) return; // it reopened something in this shell + } + const el = this.el, opener = this.opener; + this.el = this.form = this.opener = this.swapFrom = null; document.removeEventListener('keydown', onModalKey); - if (after) after(); + el.classList.add('closing'); + el.inert = true; + setTimeout(() => el.remove(), reduced() ? 0 : 180); + if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus({preventScroll:true}); + if (!S.visible) clearInterval(S.pollTimer); + }, + // The "unsaved changes" bar, over the footer. + guard(){ + const shown = this.form.querySelector('.ig-guard'); + if (shown){ shown.querySelector('[data-act="guard-keep"]').focus(); return; } + const bar = document.createElement('div'); + bar.className = 'ig-guard'; + bar.setAttribute('data-persist', ''); + bar.setAttribute('role', 'alert'); + bar.innerHTML = `${svg('warn')}You have unsaved changes + + + `; + this.form.appendChild(bar); + bar.querySelector('[data-act="guard-keep"]').focus(); + }, + hideGuard(){ + const g = this.form && this.form.querySelector('.ig-guard'); + if (g) g.remove(); }, }; function modalOpen(){ return !!Modal.el; } + function onModalKey(e){ - if (e.key === 'Escape' && !e.defaultPrevented){ e.preventDefault(); Modal.close(); } + if (!Modal.el) return; + if (e.key === 'Escape' && !e.defaultPrevented){ + e.preventDefault(); + if (Modal.form.querySelector('.ig-guard')) Modal.hideGuard(); else Modal.close(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's' && (Modal.kind === 'editor' || Modal.kind === 'builder')){ + e.preventDefault(); + const save = q('.dest-form-foot [data-act="save"]'); + if (save && !save.disabled) save.click(); + return; + } + if (e.key === 'Tab') trapFocus(e); +} + +// Tab cycles inside the modal instead of wandering into the page behind. +function trapFocus(e){ + const list = [...Modal.form.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')] + .filter(x => !x.disabled && x.type !== 'hidden' && x.getClientRects().length && !x.closest('[data-leaving]')); + if (!list.length) return; + const first = list[0], last = list[list.length - 1], at = document.activeElement; + if (e.shiftKey && (at === first || !Modal.form.contains(at))){ e.preventDefault(); last.focus(); } + else if (!e.shiftKey && (at === last || !Modal.form.contains(at))){ e.preventDefault(); first.focus(); } } + function modalHead(icon, color, title, hint, extra){ return `
${svg(icon)}

${title}

${hint}
${extra || ''} - + +
`; +} +function titleInput(value){ + return ``; +} +function enableSwitch(bind, on, label){ + return ``; +} +function lastRunHtml(d){ + const st = stat(d); + if (!st || !st.last_ms) return 'Hasn\'t run yet'; + return `Last ran ${esc(fmtAgo(st.last_ms))} · ${esc(st.last_status)}`; +} +// The editors' footer: Delete away from Save, Save lit only with changes. +function footHtml(d, dirty, extra){ + return `
+ ${d.id ? '' : ''} +
${d.id ? lastRunHtml(d) : 'New integration'}${dirty ? 'Unsaved' : ''}
+ ${extra || ''} +
`; } +function runLogHtml(steps){ + if (!steps || !steps.length) return '
No steps ran.
'; + return `
${steps.map(s => `
${(s.at_ms / 1000).toFixed(2)} s + ${s.status === 'ok' ? '✓' : s.status === 'failed' ? '✕' : '–'} ${esc(s.label)} + ${esc(s.detail)}
`).join('')}
`; +} +function warnHtml(text){ + return `
⚠${text}
`; +} + +async function saveIntegration(d, onSaved){ + const kind = Modal.kind; + const r = await api('/integrations/save', d); + if (!r.ok){ toast(r.error || 'Could not save', 'err', 6000); return false; } + if (onSaved) onSaved(); + if (r.missing && r.missing.length){ + toast(`Saved as a draft. Still to do: ${r.missing.join('; ')}`, 'info', 7000); + } else if (r.warnings && r.warnings.length){ + toast(`Saved. Check these names, nothing fills them: ${r.warnings.map(w => '{' + w + '}').join(', ')}`, 'info', 7000); + } else toast('Saved', 'ok'); + if (Modal.kind === kind) Modal.close(true); + await load(); + return true; +} // ---------------------------------------------------------------- catalog const Catalog = { - cat:'rec', + cat:'rec', added:null, open(){ this.cat = 'rec'; - Modal.open('catalog', '', 1040); + this.added = null; + Modal.open('catalog', 1040, 'min(86vh,820px)'); this.render(); }, render(){ @@ -610,43 +1056,66 @@ const Catalog = { const counts = id => id === 'packs' ? cat.packs.length : cat.presets.filter(p => id === 'rec' ? p.recommended : p.category === id).length; const nav = [['rec', 'Recommended'], ['alerts', 'Alerts'], ['chat', 'Chat'], ['auto', 'Automation'], ['packs', 'Packs']] - .map(([id, label]) => ``).join(''); - const have = id => S.data.integrations.filter(i => i.preset === id).length; - let tiles; - if (this.cat === 'packs'){ - tiles = cat.packs.map(p => `
-
${esc(p.name)}
-
${esc(p.description)}
-
${p.presets.map(id => esc((cat.presets.find(x => x.id === id) || {}).name || id)).join(' · ')}
- -
`).join(''); - } else { - const list = cat.presets.filter(p => this.cat === 'rec' ? p.recommended : p.category === this.cat); - tiles = `` + list.map(p => { - const fake = fakeStep(p); - const n = have(p.id); - const needs = needsNote(p.needs); - return `
-
${previewHtml(fake.step, fake.handler, false)}
-
-
${esc(p.name)}
${esc(p.description)}
- ${needs ? `
${esc(needs)}
` : ''}
-
- -
`; - }).join(''); - } + .map(([id, label]) => ``).join(''); + const tiles = this.cat === 'packs' ? this.packsHtml() : this.presetsHtml(); Modal.body(`${modalHead('plus', 'var(--accent)', 'Add an integration', 'Every card shows exactly what it sends. Add it, then tweak anything.')}
+
Got a recipe from a friend? Import it
${tiles}
`); - const form = Modal.el.querySelector('.dest-form'); - form.style.height = 'min(86vh,820px)'; + }, + packsHtml(){ + const cat = S.data.catalog; + return cat.packs.map((p, n) => `
+
${esc(p.name)}
+
${esc(p.description)}
+
${p.presets.map(id => `${svg(PRESET_ICON[id] || 'steps')}${esc((cat.presets.find(x => x.id === id) || {}).name || id)}`).join('')}
+ +
`).join(''); + }, + presetsHtml(){ + const list = S.data.catalog.presets.filter(p => this.cat === 'rec' ? p.recommended : p.category === this.cat); + const have = id => S.data.integrations.filter(i => i.preset === id).length; + return `` + list.map((p, n) => { + const fake = fakeStep(p); + const needs = needsNote(p.needs); + const added = this.added === p.id; + const button = added + ? `` + : ``; + return `
+
${previewHtml(fake.step, fake.handler, false)}
+
${svg(PRESET_ICON[p.id] || 'steps')} +
${esc(p.name)}
${esc(p.description)}
+ ${needs ? `
${esc(needs)}
` : ''}
+ ${button} +
`; + }).join(''); + }, + flashAdded(id){ + this.added = id; + this.render(); + setTimeout(() => { + if (this.added !== id) return; + this.added = null; + if (Modal.kind === 'catalog') this.render(); + }, 1600); + }, + act(a, el){ + switch (a){ + case 'cat': { + this.cat = el.dataset.cat; + this.render(); + const grid = q('.ig-cat-grid'); + if (grid) grid.scrollTop = 0; + return true; + } + case 'add-preset': busy(el, () => addFrom({preset:el.dataset.preset}, true)); return true; + } + return false; }, }; @@ -677,64 +1146,63 @@ function needsNote(needs){ // preview itself, and every option is a chip that opens its own panel. const Editor = { d:null, sel:0, chip:null, run:null, dirty:false, moreVars:false, - // `switchOn`: freshly added and waiting for one detail; shown switched on - // so saving once it is complete also turns it on. open(i, switchOn){ this.d = withHandler(clone(i)); + // Freshly added and waiting for one detail: shown switched on, so + // saving once it is complete also turns it on. + this.dirty = !!switchOn && !this.d.enabled; if (switchOn) this.d.enabled = true; this.sel = Math.max(0, this.d.handlers.findIndex(h => h.enabled)); // Missing a channel: open straight on the chip that fixes it. const known = id => S.data.connections.discord.some(c => c.id === id); this.chip = allSteps(this.d).some(s => s.type === 'discord' && !known(s.params.connection)) ? 'where' : null; this.run = null; - this.dirty = false; this.moreVars = false; - Modal.open('editor', '', 920); - Modal.onClose = () => !this.dirty || confirm('Close without saving your changes?'); - this.render(); + this.show(); }, - handler(){ return this.d.handlers[this.sel]; }, resume(){ - if (pickNewChannel(this.d)) this.dirty = true; - Modal.open('editor', '', 920); - Modal.onClose = () => !this.dirty || confirm('Close without saving your changes?'); + if (pickNewChannel(this.d)){ + this.dirty = true; + if (this.chip === 'where') this.chip = null; + } + this.show(); + }, + show(){ + Modal.open('editor', 920); + Modal.onClose = () => !this.dirty || (Modal.guard(), false); this.render(); }, + handler(){ return this.d.handlers[this.sel]; }, render(){ const d = this.d, h = this.handler(); const step = primaryStep(h); const k = step ? KINDS[step.type] : null; const p = presetOf(d); - const head = modalHead(k ? k.icon : 'steps', k ? k.c : 'var(--accent)', - ``, + const head = modalHead(PRESET_ICON[d.preset] || (k ? k.icon : 'steps'), k ? k.c : 'var(--accent)', titleInput(d.name), esc(p ? p.description : 'Pick a moment, then write on the message itself. Everything else is one click away.'), - ``); + enableSwitch('enabled', d.enabled)); + const chips = this.chips(step, h).map(c => ``).join(''); Modal.body(`${head} -
+
${this.momentsHtml()} - ${this.previewBoxHtml(step, h)} -
-
${this.chips(step, h).map(c => ``).join('')}
-
${this.popHtml(step, h)}
+
${this.previewBoxHtml(step, h)}
+
+
${chips}
+
${this.chip + ? `
${this.popHtml(step, h)}
` : ''}
-
-
${this.lastRunHtml()}
- - - - -
`); - const msg = Modal.el.querySelector('.ig-msg'); - if (msg) autosize(msg); + ${footHtml(d, this.dirty, ` + `)}`); + Modal.form.querySelectorAll('.ig-msg').forEach(autosize); }, momentsHtml(){ const hs = this.d.handlers; if (hs.length === 1) return ''; if (hs.length > 5){ - return `
${hs.map((h, n) => ``).join('')}
`; + return `
${hs.map((h, n) => ``).join('')}${SEG_IND}
`; } return `
${hs.map((h, n) => `
@@ -743,8 +1211,8 @@ const Editor = { }, previewBoxHtml(step, h){ if (!step || !(KINDS[step.type] || {}).text){ - return `
${previewHtml(step, h, true)} -
This ${h.trigger.type === 'event' ? 'moment' : 'trigger'} has nothing to write. Open it in the builder to see every step.
`; + return `
${previewHtml(step, h, true)} +
This ${h.trigger.type === 'event' ? 'moment' : 'trigger'} has nothing to write. Open it in the builder to see every step.
`; } const k = KINDS[step.type]; const value = step.params[k.text] || ''; @@ -754,40 +1222,35 @@ const Editor = { const globals = new Set(S.data.vars.global.map(v => v.name)); const shown = this.moreVars ? all : all.filter(v => !globals.has(v.name) || common.has(v.name)); const tokens = shown.map(v => ``).join('') - + (shown.length < all.length ? `` : ''); - const editor = ` -
${sampleLine(value, h)}
`; + + (shown.length < all.length ? `` : ''); + const sample = sampleLine(value, h); + const editor = ` +
${sample}
`; let box; if (step.type === 'discord'){ - const ping = step.params.ping === 'here' ? '@here ' : step.params.ping === 'everyone' ? '@everyone ' : ''; - box = `
-
- #${esc(channelName(step.params.connection).replace(/^#/, ''))} - Live preview
-
- ${svg('shield', ' style="width:18px;height:18px"')} -
-
InstantCloneAPP - ${ping ? `${esc(ping.trim())}` : ''}
- ${editor}
`; + const ping = step.params.ping === 'here' ? '@here' : step.params.ping === 'everyone' ? '@everyone' : ''; + box = `
+
#${esc(channelName(step.params.connection).replace(/^#/, ''))}Live preview
+
${svg('shield')} +
InstantCloneAPP${ping ? `${esc(ping)}` : ''}
+ ${editor}
`; } else if (step.type === 'chat'){ const t = h.trigger; const ask = t.type === 'chat_command' ? (t.command || '!command') : t.type === 'chat_message' ? (t.pattern || 'hey') : ''; - const who = (step.params.as === 'bot' && S.data.twitch.bot.login) || S.data.twitch.main.login || 'you'; - box = `
-
STREAM CHAT · LIVE PREVIEW
- ${ask ? `
viewer: ${esc(ask)}
` : ''} -
${esc(who)}:
${editor}
`; + box = `
+
STREAM CHAT · LIVE PREVIEW
+ ${ask ? `
viewer: ${esc(ask)}
` : ''} +
${esc(chatAuthor(step))}:
${editor}
`; } else if (step.type === 'phone'){ - box = `
-
INSTANTCLONE · now
- + box = `
+
INSTANTCLONE · now
+ ${editor}
`; } else { - box = `
-
${esc(k.label)}${step.type === 'http' ? ' · body' : ''}
${editor}
`; + box = `
+
${esc(k.label)}${step.type === 'http' ? ' · body' : ''}
${editor}
`; } - return `
${box}
Insert ${tokens}
`; + return `${box}
Insert${tokens}
`; }, // The chips shown for this moment, with their current value. chips(step, h){ @@ -814,8 +1277,8 @@ const Editor = { } if (step && step.type === 'chat') out.push({id:'reply', k:'Reply as', v:step.params.as === 'bot' ? 'bot account' : 'your account'}); if (step && step.type === 'phone') out.push({id:'priority', k:'Priority', v:step.params.priority || 'normal'}); - if (step && step.type === 'http') out.push({id:'url', k:'Send to', v:step.params.url || 'set the address'}); - if (step && step.type === 'file') out.push({id:'file', k:'File', v:step.params.path || 'pick a file'}); + if (step && step.type === 'http') out.push({id:'url', k:'Send to', v:step.params.url || 'set the address', warn:!step.params.url}); + if (step && step.type === 'file') out.push({id:'file', k:'File', v:step.params.path || 'pick a file', warn:!step.params.path}); out.push({id:'cooldown', k:'Wait between', v:fmtMs(d.cooldown_ms) || 'no limit'}); return out; }, @@ -836,14 +1299,14 @@ const Editor = { if (this.chip === '__test') return this.runHtml(); const d = this.d, t = h.trigger; const seg = (items, cur, act) => `
${items.map(([id, label]) => - ``).join('')}
`; + ``).join('')}${SEG_IND}
`; switch (this.chip){ case 'where': { const cur = (allSteps(d).find(s => s.type === 'discord') || {params:{}}).params.connection; return `
Post in
- ${S.data.connections.discord.map(c => ``).join('')} -
`; + ${S.data.connections.discord.map(c => ``).join('')} +
`; } case 'ping': return `
Ping
${seg([['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']], step.params.ping || '', 'set-ping')} @@ -862,58 +1325,41 @@ const Editor = { return `
${input}
`; }).join('')}
`; case 'cmd': - return `
-
-
${seg([['0', 'no limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min']], String(t.user_cooldown_ms || 0), 'set-ucd')}
`; + return `
+
+
${seg([['0', 'no limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min']], String(t.user_cooldown_ms || 0), 'set-ucd')}
`; case 'who': - return `
Who can use it
${['everyone', 'subs', 'vips', 'mods'].map(r => - ``).join('')}
-
You can always use your own commands.
`; + return `
Who can use it
${rolesHtml(t, 'role')}
You can always use your own commands.
`; case 'reply': return `
Reply as
${seg([['', 'Your account'], ['bot', 'Bot account']], step.params.as || '', 'set-as')} - ${t.type.startsWith('chat') ? `` : ''} + ${t.type.startsWith('chat') ? `` : ''} ${S.data.twitch.bot.login ? '' : '
No bot account yet: connect one, or messages go out as you.
'}`; case 'priority': return `
Priority
${seg([['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']], step.params.priority || '', 'set-priority')}`; case 'url': - return `
+ return `
`; case 'file': - return `
+ return `
Point an OBS text source at this file to show it on stream.
`; case 'cooldown': return `
Wait between two runs
${seg([['0', 'No limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min'], ['300000', '5 min'], ['600000', '10 min']], String(d.cooldown_ms || 0), 'set-cooldown')}`; } - return `
Every option lives in a chip above. Click one to change it.
`; + return ''; }, runHtml(){ - if (!this.run) return '
Running the test…
'; - if (this.run.error) return `
⚠${esc(this.run.error)}
`; + if (!this.run) return '
Running the test…
'; + if (this.run.error) return warnHtml(esc(this.run.error)); return `
Test run: ${esc(this.run.status)}
${runLogHtml(this.run.steps)} -
Messages and web requests went out for real, messages marked [TEST] and never pinging anyone. Waits were skipped; the stream, VOD, programs and files were left alone.
`; - }, - lastRunHtml(){ - const st = stat(this.d); - if (!st || !st.last_ms) return 'Hasn\'t run yet'; - const color = st.last_status === 'failed' ? 'var(--danger)' : 'var(--live)'; - return `Last ran ${esc(fmtAgo(st.last_ms))} · ${esc(st.last_status)}`; - }, - changed(rerender){ - this.dirty = true; - if (rerender) this.render(); +
Messages and web requests went out for real, marked [TEST] and never pinging anyone. Waits were skipped; the stream, VOD, programs and files were left alone.
`; }, input(el){ const h = this.handler(), step = primaryStep(h), t = h.trigger; const b = el.dataset.bind; if (b === 'name') this.d.name = el.value; else if (b === 'enabled') this.d.enabled = el.checked; - else if (b === 'text' && step){ - step.params[KINDS[step.type].text] = el.value; - autosize(el); - const s = $('ig-sample'); - if (s) s.innerHTML = sampleLine(el.value, h); - } + else if (b === 'text' && step){ step.params[KINDS[step.type].text] = el.value; autosize(el); } else if (b === 'title' && step) step.params.title = el.value; else if (b === 'filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); } else if (b === 'command') t.command = el.value.trim().toLowerCase(); @@ -926,34 +1372,31 @@ const Editor = { } else if ((b === 'path' || b === 'mode') && step) step.params[b] = el.value; else return; - this.dirty = true; - if (el.tagName === 'SELECT' || el.type === 'checkbox') this.refreshChips(); + this.changed(); }, - refreshChips(){ - const h = this.handler(), step = primaryStep(h); - const chips = Modal.el && Modal.el.querySelector('.ig-chips'); - if (chips) chips.innerHTML = this.chips(step, h).map(c => ``).join(''); + changed(){ + this.dirty = true; + this.render(); }, act(a, el){ const h = this.handler(), step = primaryStep(h), v = el.dataset.v; switch (a){ case 'moment': this.sel = +el.dataset.n; if (this.chip === '__test') this.chip = null; this.render(); return true; - case 'moment-toggle': { const x = this.d.handlers[+el.dataset.n]; x.enabled = !x.enabled; this.changed(true); return true; } + case 'moment-toggle': { const x = this.d.handlers[+el.dataset.n]; x.enabled = !x.enabled; this.changed(); return true; } case 'chip': this.chip = this.chip === el.dataset.chip ? null : el.dataset.chip; this.render(); return true; - case 'token': insertAtCursor(Modal.el.querySelector('.ig-msg'), '{' + el.dataset.token + '}'); return true; + case 'token': insertAtCursor(q('.ig-msg'), '{' + el.dataset.token + '}'); return true; case 'more-vars': this.moreVars = true; this.render(); return true; - case 'set-where': allSteps(this.d).filter(s => s.type === 'discord').forEach(s => { s.params.connection = v; }); this.changed(true); return true; - case 'set-ping': if (step) step.params.ping = v; this.changed(true); return true; - case 'set-send': setSend(h, v); this.changed(true); return true; - case 'set-ucd': h.trigger.user_cooldown_ms = +v; this.changed(true); return true; - case 'set-as': if (step) step.params.as = v; this.changed(true); return true; - case 'set-priority': if (step) step.params.priority = v; this.changed(true); return true; - case 'set-cooldown': this.d.cooldown_ms = +v; this.changed(true); return true; - case 'ed-builder': { const d = this.d; this.dirty = false; Modal.close(true); Builder.open(d, true); return true; } - case 'ed-test': this.test(); return true; - case 'ed-delete': remove(this.d.id); return true; - case 'ed-save': saveIntegration(this.d, () => { this.dirty = false; }); return true; + case 'set-where': allSteps(this.d).filter(s => s.type === 'discord').forEach(s => { s.params.connection = v; }); this.changed(); return true; + case 'set-ping': if (step) step.params.ping = v; this.changed(); return true; + case 'set-send': setSend(h, v); this.changed(); return true; + case 'set-ucd': h.trigger.user_cooldown_ms = +v; this.changed(); return true; + case 'set-as': if (step) step.params.as = v; this.changed(); return true; + case 'set-priority': if (step) step.params.priority = v; this.changed(); return true; + case 'set-cooldown': this.d.cooldown_ms = +v; this.changed(); return true; + case 'ed-builder': Builder.open(this.d, {dirty:this.dirty}); return true; + case 'ed-test': busy(el, () => this.test()); return true; + case 'delete': if (armConfirm(el, 'Delete for good?')) busy(el, () => remove(this.d.id)); return true; + case 'save': busy(el, () => saveIntegration(this.d, () => { this.dirty = false; })); return true; } return false; }, @@ -1004,15 +1447,17 @@ function rolesLabel(r){ const who = ['subs', 'vips', 'mods'].filter(x => r[x]).map(x => x === 'vips' ? 'VIPs' : x); return who.length ? who.join(', ') : 'only you'; } -function fmtMs(ms){ - ms = +ms || 0; - if (!ms) return ''; - if (ms < 60000) return Math.round(ms / 1000) + ' s'; - return Math.round(ms / 60000) + ' min'; +function rolesHtml(t, bind){ + const label = r => r === 'vips' ? 'VIPs' : r[0].toUpperCase() + r.slice(1); + return `
${['everyone', 'subs', 'vips', 'mods'].map(r => + ``).join('')}
`; +} +function autosize(t){ + t.style.height = 'auto'; + t.style.height = (t.scrollHeight + 2) + 'px'; } -function autosize(t){ t.style.height = 'auto'; t.style.height = (t.scrollHeight + 2) + 'px'; } function insertAtCursor(field, text){ - if (!field) return; + if (!field || !field.isConnected || field.closest('[data-leaving]')) return; const start = field.selectionStart == null ? field.value.length : field.selectionStart; const end = field.selectionEnd == null ? start : field.selectionEnd; field.value = field.value.slice(0, start) + text + field.value.slice(end); @@ -1020,26 +1465,6 @@ function insertAtCursor(field, text){ field.setSelectionRange(start + text.length, start + text.length); field.dispatchEvent(new Event('input', {bubbles:true})); } -function runLogHtml(steps){ - if (!steps || !steps.length) return '
No steps ran.
'; - return `
${steps.map(s => `
${(s.at_ms / 1000).toFixed(2)} s - ${s.status === 'ok' ? '✓' : s.status === 'failed' ? '✕' : '–'} ${esc(s.label)} - ${esc(s.detail)}
`).join('')}
`; -} - -async function saveIntegration(d, onSaved){ - const r = await api('/integrations/save', d); - if (!r.ok){ toast(r.error || 'Could not save', 'err', 6000); return false; } - if (onSaved) onSaved(); - if (r.missing && r.missing.length){ - toast(`Saved as a draft. Still to do: ${r.missing.join('; ')}`, 'info', 7000); - } else if (r.warnings && r.warnings.length){ - toast(`Saved. Check these names, nothing fills them: ${r.warnings.map(w => '{' + w + '}').join(', ')}`, 'info', 7000); - } else toast('Saved', 'ok'); - Modal.close(true); - await load(); - return true; -} // ---------------------------------------------------------------- builder @@ -1052,102 +1477,102 @@ const PALETTE = [ ['Anything', [['program', 'Run a program'], ['file', 'Write a file'], ['set_var', 'Remember a value'], ['counter', 'Counter']]], ]; const TRIGGERS = [['event', 'An InstantClone event'], ['chat_command', 'A chat command'], ['chat_message', 'A chat message'], ['timer', 'Every few minutes'], ['webhook', 'A web call (URL)']]; +const pathKey = p => p.join('.'); const Builder = { - d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, - open(i, fromEditor){ + d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, lastField:null, + // opts: {dirty} carries the editor's unsaved state over, {switchOn} as in Editor.open. + open(i, opts){ + opts = opts || {}; this.d = i ? withHandler(clone(i)) : {id:'', name:'My integration', enabled:true, preset:'', cooldown_ms:0, handlers:[{enabled:true, trigger:{type:'event', event:'hold_opened', filters:{}}, steps:[]}]}; + this.dirty = !!opts.dirty || (!!opts.switchOn && !this.d.enabled); + if (opts.switchOn) this.d.enabled = true; this.h = 0; this.sel = 'trigger'; this.target = []; this.run = null; - this.dirty = !!fromEditor; - Modal.open('builder', '', 1240); - Modal.onClose = () => !this.dirty || confirm('Close without saving your changes?'); - this.render(); + this.lastField = null; + this.show(); }, - handler(){ return this.d.handlers[this.h]; }, resume(){ if (pickNewChannel(this.d)) this.dirty = true; - Modal.open('builder', '', 1240); - Modal.onClose = () => !this.dirty || confirm('Close without saving your changes?'); - this.renderKeepScroll(); + this.show(); + }, + show(){ + Modal.open('builder', 1240, 'min(92vh,900px)'); + Modal.onClose = () => !this.dirty || (Modal.guard(), false); + this.render(); }, + handler(){ return this.d.handlers[this.h]; }, render(){ - const d = this.d; - Modal.body(`${modalHead('steps', 'var(--accent)', - ``, - `Your own · runs on this PC · ${allSteps(d).length} steps`, - ` - `)} + const d = this.d, h = this.handler(); + const n = allSteps(d).length; + Modal.body(`${modalHead('steps', 'var(--accent)', titleInput(d.name), + `Your own · runs on this PC · ${plural(n, 'step')}`, + `${enableSwitch('enabled', d.enabled)}`)}
-
${d.handlers.map((h, n) => ``).join('')} -
-
- - ${this.stepsHtml(this.handler().steps, [])} +
${d.handlers.map((x, i) => ``).join('')} +
+
+ + ${this.stepsHtml(h.steps, [], 'root')}
- ${this.run ? `
Test run: ${esc(this.run.status || 'error')}
- ${this.run.error ? `
⚠${esc(this.run.error)}
` : runLogHtml(this.run.steps)}
` : ''} + ${this.run ? `
Test run: ${esc(this.run.status || 'error')}
+ ${this.run.error ? warnHtml(esc(this.run.error)) : this.run.status === 'running' + ? '
Running the test…
' : runLogHtml(this.run.steps)}
` : ''}
- +
-
-
${d.id ? Editor.lastRunHtml.call({d}) : 'New integration'}
- ${d.id ? '' : ''} - -
`); - const f = Modal.el.querySelector('.dest-form'); - f.style.height = 'min(92vh,900px)'; + ${footHtml(d, this.dirty)}`); + }, + // A different selection is a different panel (fresh fields, crossfade). + inspectorKey(){ + const s = this.sel === 'trigger' ? null : this.stepAt(this.sel); + return `insp-${uidOf(this.handler())}-${s ? uidOf(s) : 'trigger'}`; }, - stepsHtml(steps, base){ - const key = p => p.join('.'); - const tgt = key(this.target); + stepsHtml(steps, base, owner){ + const here = pathKey(base); + const lit = pathKey(this.target) === here; + const selKey = Array.isArray(this.sel) ? pathKey(this.sel) : null; + const tool = (act, path, icon, label, extra) => + ``; const out = (steps || []).map((s, n) => { - const path = base.concat(n); + const path = base.concat(n), pk = pathKey(path), id = uidOf(s); const k = KINDS[s.type] || {label:s.type, tag:'?', c:'#888'}; - const selected = Array.isArray(this.sel) && key(this.sel) === key(path); - let html = `
+ let html = `
${k.tag}${stepSentence(s)} - - - - + ${tool('b-move', pk, 'up', 'Move up', ` data-dir="-1"${n === 0 ? ' disabled' : ''}`)} + ${tool('b-move', pk, 'down', 'Move down', ` data-dir="1"${n === steps.length - 1 ? ' disabled' : ''}`)} + ${tool('b-dup', pk, 'copy', 'Duplicate')} + ${tool('b-del', pk, 'x', 'Remove')}
`; if (s.type === 'if'){ - html += `
Then
${this.stepsHtml(s.then, path.concat('then'))} -
Otherwise
${this.stepsHtml(s.else, path.concat('else'))}
`; + html += `
+
Then
${this.stepsHtml(s.then, path.concat('then'), id + 't')}
+
Otherwise
${this.stepsHtml(s.else, path.concat('else'), id + 'e')}
+
`; } return html; }).join(''); - return out + ``; + return out + ``; }, resolve(pathStr){ - const parts = pathStr === '' ? [] : pathStr.split('.').map(p => /^\d+$/.test(p) ? +p : p); - return parts; + return pathStr === '' ? [] : pathStr.split('.').map(p => /^\d+$/.test(p) ? +p : p); }, // The list a path's last index lives in, and that index. locate(path){ let list = this.handler().steps; - for (let n = 0; n < path.length - 1; n++){ - const p = path[n]; - if (typeof p === 'number'){ - const s = list[p]; - const branch = path[n + 1]; - list = s[branch]; - n++; - } - } + for (let n = 0; n < path.length - 1; n += 2) list = list[path[n]][path[n + 1]]; return {list, index:path[path.length - 1]}; }, listAt(path){ @@ -1162,50 +1587,54 @@ const Builder = { inspectorHtml(){ const h = this.handler(); const tokens = varsFor(h).map(v => ``).join(''); - const varsBox = `
Variables here
-
${tokens}
Click one to insert it in the field you last used. {name|text} uses the text when the value is empty or 0.
`; + const varsBox = `
Variables here
+
${tokens}
Click one to insert it in the field you last used. {name|text} uses the text when the value is empty or 0.
`; if (this.sel === 'trigger') return this.triggerInspector(h) + varsBox; const s = this.stepAt(this.sel); if (!s) return varsBox; const k = KINDS[s.type]; + return `
${k.tag}${esc(k.label)}
${this.stepFields(s)}${varsBox}`; + }, + stepFields(s){ const field = (name, label, opts) => { opts = opts || {}; const v = s.params[name] || ''; if (opts.select) return `
`; if (opts.area) return `
`; - return `
`; + return `
`; }; + const note = text => `
${text}
`; const channels = S.data.connections.discord.map(c => [c.id, '#' + c.name]); - let body = ''; switch (s.type){ - case 'discord': body = (channels.length ? field('connection', 'Channel', {select:[['', 'Pick a channel']].concat(channels)}) : '
No Discord channel yet. Add one.
') - + field('text', 'Message', {area:true}) + field('ping', 'Ping', {select:[['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']]}); break; - case 'chat': body = field('text', 'Message', {area:true}) + field('as', 'Send as', {select:[['', 'Your account'], ['bot', 'Bot account']]}) - + field('reply', 'Reply to the viewer', {select:[['', 'No'], ['yes', 'Yes, in their thread']]}); break; - case 'phone': body = field('title', 'Title') + field('text', 'Message', {area:true}) + field('priority', 'Priority', {select:[['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']]}); break; - case 'http': body = field('method', 'Method', {select:['POST', 'GET', 'PUT', 'PATCH', 'DELETE'].map(m => [m, m])}) + field('url', 'Address', {mono:true, ph:'https://…'}) + case 'discord': return (channels.length ? field('connection', 'Channel', {select:[['', 'Pick a channel']].concat(channels)}) : note('No Discord channel yet. Add one.')) + + field('text', 'Message', {area:true}) + field('ping', 'Ping', {select:[['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']]}); + case 'chat': return field('text', 'Message', {area:true}) + field('as', 'Send as', {select:[['', 'Your account'], ['bot', 'Bot account']]}) + + field('reply', 'Reply to the viewer', {select:[['', 'No'], ['yes', 'Yes, in their thread']]}); + case 'phone': return field('title', 'Title') + field('text', 'Message', {area:true}) + field('priority', 'Priority', {select:[['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']]}); + case 'http': return field('method', 'Method', {select:['POST', 'GET', 'PUT', 'PATCH', 'DELETE'].map(m => [m, m])}) + field('url', 'Address', {mono:true, ph:'https://…'}) + field('headers', 'Headers (one per line, Name: value)', {area:true}) + field('body', 'Body', {area:true, ph:'{"event":"{delay}"}'}) - + field('save_as', 'Save the answer as', {mono:true, ph:'response'}) + `
Later steps can use {${esc(s.params.save_as || 'response')}.status}, .ok, .body and .json.field.
`; break; - case 'wait': body = field('ms', 'Wait (milliseconds)', {mono:true, ph:'20000'}) + `
${esc(fmtMs(s.params.ms) || '0 s')}. Up to an hour.
`; break; - case 'wait_delay': body = `

Waits until the moment this started has reached your viewers. Only InstantClone can do this: it knows your delay.

` - + field('extra_ms', 'Then wait a bit more (ms)', {mono:true, ph:'0'}) + field('follow', 'If the delay changes meanwhile', {select:[['', 'Follow it'], ['no', 'Keep the delay from the start']]}); break; - case 'if': body = field('left', 'Check', {mono:true, ph:'{user_role}'}) + field('op', 'Is', {select:Object.entries(OPS)}) + field('right', 'Value', {mono:true, ph:'mod'}) - + '
Text compares ignoring case; "is more than" compares numbers.
'; break; - case 'stop': body = '
Ends this run here.
'; break; - case 'delay_action': body = field('action', 'Action', {select:Object.entries(DELAY_ACTIONS)}) + + field('save_as', 'Save the answer as', {mono:true, ph:'response'}) + + note(`Later steps can use {${esc(s.params.save_as || 'response')}.status}, .ok, .body and .json.field.`); + case 'wait': return field('ms', 'Wait (milliseconds)', {mono:true, ph:'20000'}) + note(`${esc(fmtMs(s.params.ms) || '0 s')}. Up to an hour.`); + case 'wait_delay': return '

Waits until the moment this started has reached your viewers. Only InstantClone can do this: it knows your delay.

' + + field('extra_ms', 'Then wait a bit more (ms)', {mono:true, ph:'0'}) + field('follow', 'If the delay changes meanwhile', {select:[['', 'Follow it'], ['no', 'Keep the delay from the start']]}); + case 'if': return field('left', 'Check', {mono:true, ph:'{user_role}'}) + field('op', 'Is', {select:Object.entries(OPS)}) + field('right', 'Value', {mono:true, ph:'mod'}) + + note('Text compares ignoring case; "is more than" compares numbers.'); + case 'stop': return note('Ends this run here.'); + case 'delay_action': return field('action', 'Action', {select:Object.entries(DELAY_ACTIONS)}) + (s.params.action === 'arm' ? field('seconds', 'Delay (seconds)', {mono:true, ph:'{arg1}'}) - + '
Arms this delay, or changes it live when a delay is already on air. Never disarms.
' : '') - + '
⚠This changes your stream. Tests never run it.
'; break; - case 'marker': body = field('description', 'Label', {ph:'Crash'}) + '
Only works while you are live on Twitch.
'; break; - case 'clip': body = '
Clips the last moments of your stream. Later steps can use {clip.url} and {clip.ok}.
'; break; - case 'program': body = field('path', 'Program (a fixed path, no variables)', {mono:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) - + '
⚠Runs on your PC. Recipes you import can never add this without asking you first.
'; break; - case 'file': body = field('path', 'File (a fixed path, no variables)', {mono:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); break; - case 'set_var': body = field('name', 'Name', {mono:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + '
Later steps use it as {name}. Lasts for this run.
'; break; - case 'counter': body = field('name', 'Name', {mono:true, ph:'crashes'}) + field('op', 'Do', {select:[['', 'Add'], ['subtract', 'Subtract'], ['set', 'Set to'], ['reset', 'Reset to 0']]}) - + field('by', 'By', {mono:true, ph:'1'}) + `
Kept between runs. Use it anywhere as {counter.${esc(s.params.name || 'name')}}.
`; break; + + note('Arms this delay, or changes it live when a delay is already on air. Never disarms.') : '') + + warnHtml('This changes your stream. Tests never run it.'); + case 'marker': return field('description', 'Label', {ph:'Crash'}) + note('Only works while you are live on Twitch.'); + case 'clip': return note('Clips the last moments of your stream. Later steps can use {clip.url} and {clip.ok}.'); + case 'program': return field('path', 'Program (a fixed path, no variables)', {mono:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) + + warnHtml('Runs on your PC. Recipes you import can never add this without asking you first.'); + case 'file': return field('path', 'File (a fixed path, no variables)', {mono:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); + case 'set_var': return field('name', 'Name', {mono:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + note('Later steps use it as {name}. Lasts for this run.'); + case 'counter': return field('name', 'Name', {mono:true, ph:'crashes'}) + field('op', 'Do', {select:[['', 'Add'], ['subtract', 'Subtract'], ['set', 'Set to'], ['reset', 'Reset to 0']]}) + + field('by', 'By', {mono:true, ph:'1'}) + note(`Kept between runs. Use it anywhere as {counter.${esc(s.params.name || 'name')}}.`); } - return `
${k.tag}
${esc(k.label)}
${body}${varsBox}`; + return ''; }, triggerInspector(h){ const t = h.trigger; @@ -1225,46 +1654,41 @@ const Builder = { : ``}
`; }); } else if (t.type === 'chat_command'){ - body = `
-
- ${this.rolesHtml(t)} -
`; + body = `
+
+
${rolesHtml(t, 't-role')}
+
`; } else if (t.type === 'chat_message'){ body = `
- ${this.rolesHtml(t)}`; +
${rolesHtml(t, 't-role')}
`; } else if (t.type === 'timer'){ - body = `
+ body = `
`; } else if (t.type === 'webhook'){ const url = `${location.origin}/hooks/${t.token || ''}`; - body = `
-
+ body = `
+
GET or POST from a Stream Deck, a script or any app on this network. The body is {body}; JSON fields are {body.field}.
- `; + `; } - const h2 = this.d.handlers.length > 1 - ? `
-
` : ''; - return `
WHEN
${esc(triggerLabel(t))}
+ const more = this.d.handlers.length > 1 + ? `
${enableSwitch('h-enabled', h.enabled, 'This trigger is on')} +
` : ''; + return `
WHEN${esc(triggerLabel(t))}
${typeSel}${body} -
${h2}`; - }, - rolesHtml(t){ - return `
${['everyone', 'subs', 'vips', 'mods'].map(r => - ``).join('')}
`; +
${more}`; }, input(el){ const b = el.dataset.bind, h = this.handler(), t = h.trigger; - let rerender = false; - if (b === 'b-name') this.d.name = el.value; - else if (b === 'b-enabled') this.d.enabled = el.checked; + if (b === 'name') this.d.name = el.value; + else if (b === 'enabled') this.d.enabled = el.checked; else if (b === 'b-cooldown') this.d.cooldown_ms = Math.max(0, (parseFloat(el.value) || 0) * 1000); - else if (b === 'h-enabled'){ h.enabled = el.checked; rerender = true; } - else if (b === 't-type'){ h.trigger = newTrigger(el.value); rerender = true; } - else if (b === 't-event'){ t.event = el.value; t.filters = {}; rerender = true; } - else if (b === 't-filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); rerender = true; } - else if (b === 't-command'){ t.command = el.value.trim().toLowerCase(); rerender = true; } + else if (b === 'h-enabled') h.enabled = el.checked; + else if (b === 't-type') h.trigger = newTrigger(el.value); + else if (b === 't-event'){ t.event = el.value; t.filters = {}; } + else if (b === 't-filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); } + else if (b === 't-command') t.command = el.value.trim().toLowerCase(); else if (b === 't-aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); else if (b === 't-role'){ t.roles = t.roles || {}; t.roles[el.dataset.name] = el.checked; } else if (b === 't-ucd') t.user_cooldown_ms = Math.max(0, (parseFloat(el.value) || 0) * 1000); @@ -1272,37 +1696,33 @@ const Builder = { else if (b === 't-mode') t.mode = el.value; else if (b === 't-every') t.every_ms = Math.max(1, parseFloat(el.value) || 1) * 60000; else if (b === 't-live') t.only_live = el.checked; - else if (b === 'p'){ - const s = this.stepAt(this.sel); - s.params[el.dataset.name] = el.value; - rerender = el.tagName === 'SELECT'; - const card = Modal.el.querySelector(`.ig-step[data-path="${this.sel.join('.')}"] .ig-step-text`); - if (card) card.innerHTML = stepSentence(s); - } else return; + else if (b === 'p') this.stepAt(this.sel).params[el.dataset.name] = el.value; + else return; this.dirty = true; - if (rerender) this.renderKeepScroll(); + this.render(); }, - renderKeepScroll(){ - const c = Modal.el && Modal.el.querySelector('.ig-canvas'); - const top = c ? c.scrollTop : 0; + edited(){ + this.dirty = true; this.render(); - const c2 = Modal.el && Modal.el.querySelector('.ig-canvas'); - if (c2) c2.scrollTop = top; }, act(a, el){ const d = this.d; switch (a){ - case 'b-sel': this.sel = el.dataset.path === 'trigger' ? 'trigger' : this.resolve(el.dataset.path); this.renderKeepScroll(); return true; - case 'b-target': this.target = this.resolve(el.dataset.path); this.renderKeepScroll(); return true; + case 'b-sel': this.sel = el.dataset.path === 'trigger' ? 'trigger' : this.resolve(el.dataset.path); this.render(); return true; + case 'b-target': this.target = this.resolve(el.dataset.path); this.render(); return true; case 'b-add': { - let list; - try { list = this.listAt(this.target); } catch(_){ list = null; } + let list = null; + try { + const owner = this.target.length ? this.stepAt(this.target.slice(0, -1)) : null; + if (!this.target.length || (owner && owner.type === 'if')) list = this.listAt(this.target); + } catch(_){ list = null; } if (!list){ this.target = []; list = this.handler().steps; } list.push(newStep(el.dataset.kind)); this.sel = this.target.concat(list.length - 1); if (el.dataset.kind === 'if') this.target = this.sel.concat('then'); - this.dirty = true; - this.renderKeepScroll(); + this.edited(); + const added = q('.ig-step.on'); + if (added) added.scrollIntoView({block:'nearest', behavior:reduced() ? 'auto' : 'smooth'}); return true; } case 'b-move': { @@ -1311,44 +1731,53 @@ const Builder = { if (to < 0 || to >= list.length) return true; [list[index], list[to]] = [list[to], list[index]]; this.sel = path.slice(0, -1).concat(to); - this.dirty = true; this.renderKeepScroll(); return true; + this.target = []; + this.edited(); + return true; } case 'b-dup': { const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); list.splice(index + 1, 0, clone(list[index])); - this.dirty = true; this.renderKeepScroll(); return true; + this.sel = path.slice(0, -1).concat(index + 1); + this.target = []; + this.edited(); + return true; } case 'b-del': { const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); list.splice(index, 1); this.sel = 'trigger'; this.target = []; - this.dirty = true; this.renderKeepScroll(); return true; + this.edited(); + return true; } - case 'b-handler': this.h = +el.dataset.n; this.sel = 'trigger'; this.target = []; this.run = null; this.renderKeepScroll(); return true; + case 'b-handler': this.h = +el.dataset.n; this.sel = 'trigger'; this.target = []; this.run = null; this.render(); return true; case 'b-add-handler': d.handlers.push({enabled:true, trigger:newTrigger('event'), steps:[]}); - this.h = d.handlers.length - 1; this.sel = 'trigger'; this.target = []; this.dirty = true; this.render(); return true; + this.h = d.handlers.length - 1; this.sel = 'trigger'; this.target = []; this.run = null; + this.edited(); + return true; case 'b-del-handler': - if (d.handlers.length > 1 && confirm('Remove this trigger and its steps?')){ d.handlers.splice(this.h, 1); this.h = 0; this.sel = 'trigger'; this.dirty = true; this.render(); } + if (d.handlers.length < 2 || !armConfirm(el, 'Remove it and its steps?')) return true; + d.handlers.splice(this.h, 1); + this.h = 0; this.sel = 'trigger'; this.target = []; this.run = null; + this.edited(); return true; - case 'b-new-token': this.handler().trigger.token = randomToken(); this.dirty = true; this.render(); return true; - case 'b-copy': navigator.clipboard.writeText(el.dataset.text); toast('Copied', 'ok'); return true; - case 'b-token': insertAtCursor(Builder.lastField, '{' + el.dataset.token + '}'); return true; - case 'b-test': this.test(); return true; - case 'b-delete': remove(d.id); return true; - case 'b-save': saveIntegration(d, () => { this.dirty = false; }); return true; + case 'b-new-token': this.handler().trigger.token = randomToken(); this.edited(); return true; + case 'b-token': insertAtCursor(this.lastField, '{' + el.dataset.token + '}'); return true; + case 'b-test': busy(el, () => this.test()); return true; + case 'delete': if (armConfirm(el, 'Delete for good?')) busy(el, () => remove(d.id)); return true; + case 'save': busy(el, () => saveIntegration(d, () => { this.dirty = false; })); return true; } return false; }, async test(){ this.run = {status:'running', steps:[]}; - this.renderKeepScroll(); + this.render(); const r = await api('/integrations/test', {integration:this.d, handler:this.h}); this.run = r.ok ? r : {error:r.error || 'The test failed'}; - if (Modal.kind === 'builder') this.renderKeepScroll(); + if (Modal.kind === 'builder') this.render(); }, - lastField:null, }; function newTrigger(type){ @@ -1413,120 +1842,169 @@ const Conn = { const from = {editor:Editor, builder:Builder, import:Recipes}[Modal.kind] || null; this.tab = tab || 'discord'; this.editing = null; - Modal.open('connections', '', 760); + Modal.open('connections', 760); Modal.onClose = () => { if (S.data.twitch.login) api('/twitch/cancel', {}); return true; }; if (from) Modal.after = () => from.resume(); this.render(); }, render(){ + // A slow reply can land after the user left this page. + if (Modal.kind !== 'connections') return; const tabs = [['discord', 'Discord'], ['twitch', 'Twitch'], ['phone', 'Phone']].map(([id, l]) => - ``).join(''); - const focused = document.activeElement && Modal.el && Modal.el.contains(document.activeElement) ? document.activeElement.dataset.bind : null; + ``).join(''); + const pane = this.tab === 'discord' ? this.discordHtml() : this.tab === 'twitch' ? this.twitchHtml() : this.phoneHtml(); Modal.body(`${modalHead('link', 'var(--accent)', 'Connections', 'Where integrations post. Set each one up once.')} -
-
${tabs}
- ${this.tab === 'discord' ? this.discordHtml() : this.tab === 'twitch' ? this.twitchHtml() : this.phoneHtml()} +
+
${tabs}${SEG_IND}
+
${pane}
`); - if (focused){ const f = Modal.el.querySelector(`[data-bind="${focused}"]`); if (f) f.focus(); } }, discordHtml(){ - const list = S.data.connections.discord; const e = this.editing; - const rows = list.map(c => `
${svg('bubble')} + const rows = S.data.connections.discord.map(c => `
${svg('bubble')}
#${esc(c.name)}
${esc(c.hint)}
- - -
`).join(''); - const form = e ? `
+ + +
`).join(''); + const form = e ? `
${e.id ? 'Edit channel' : 'New channel'}
-
-
+
+
In Discord: Server settings › Integrations › Webhooks › New webhook › Copy webhook URL.
-
-
` : ``; - return (rows || '
No Discord channel yet.
') + form; +
+
` : ``; + return `
${rows || '
No Discord channel yet.
'}${form}
`; }, twitchHtml(){ const t = S.data.twitch; - if (!t.available) return `
⚠This build has no Twitch app id, so it can't log in to Twitch. Official releases include one.
`; - const flow = t.login; - const acct = (which, a, title, blurb) => { - const active = flow && flow.which === which; - let right; - if (active){ - right = flow.error - ? `` - : 'Waiting for you on Twitch…'; - } else if (a.login){ - right = `chat ${esc(a.chat)} - `; - } else { - right = ``; - } - const code = active && !flow.error ? `
- ${flow.user_code ? `
${esc(flow.user_code)}
-
- Open twitch.tv/activate - -
-
Enter the code there and approve. This page updates on its own. The code works for ${Math.max(1, Math.round(flow.expires_in_s / 60))} more minutes.
` - : '
Asking Twitch for a code…
'}
` - : active && flow.error ? `
⚠${esc(flow.error)}
` : ''; - return `
-
${svg('bubble')} -
${a.login ? esc(a.login) : title}
${blurb}
${right}
${code}
`; - }; - return (t.notice ? `
⚠${esc(t.notice)}
` : '') - + acct('main', t.main, 'Your Twitch account', 'Chat, VOD markers and clips. Log in once: InstantClone keeps it fresh.') - + acct('bot', t.bot, 'Bot account (optional)', 'A second account that posts in chat instead of you.') + if (!t.available) return warnHtml('This build has no Twitch app id, so it can\'t log in to Twitch. Official releases include one.'); + return (t.notice ? warnHtml(esc(t.notice)) : '') + + this.accountHtml('main', t.main, 'Your Twitch account', 'Chat, VOD markers and clips. Log in once: InstantClone keeps it fresh.') + + this.accountHtml('bot', t.bot, 'Bot account (optional)', 'A second account that posts in chat instead of you.') + '
No password ever goes through InstantClone: you approve it on twitch.tv, and can revoke it there any time.
'; }, + accountHtml(which, a, title, blurb){ + const flow = S.data.twitch.login; + const active = flow && flow.which === which; + let right; + if (active){ + right = flow.error + ? `` + : 'Waiting for you on Twitch…'; + } else if (a.login){ + right = `chat ${esc(a.chat)} + `; + } else { + right = ``; + } + let code = ''; + if (active && flow.error) code = warnHtml(esc(flow.error)); + else if (active && flow.user_code){ + const uri = /^https:\/\//.test(flow.uri || '') ? flow.uri : 'https://www.twitch.tv/activate'; + code = `
+
${esc(flow.user_code)}
+
+ Open twitch.tv/activate + +
+
Enter the code there and approve. This page updates on its own. The code works for ${plural(Math.max(1, Math.round(flow.expires_in_s / 60)), 'more minute')}.
`; + } else if (active) code = '
Asking Twitch for a code…
'; + return ``; + }, phoneHtml(){ const p = S.data.connections.phone; - return `
-
Phone pushes use ntfy, a free app for Android and iPhone. Install it, subscribe to a topic, and put the same topic here. Anyone who knows the topic can read it, so make it hard to guess.
-
-
-
-
+ return `
+
Phone pushes use ntfy, a free app for Android and iPhone. Install it, subscribe to a topic, and put the same topic here. Anyone who knows the topic can read it, so make it hard to guess.
+
+
+
+
`; }, + value(bind){ + const el = q(`[data-bind="${bind}"]`); + return el ? el.value : ''; + }, async act(a, el){ switch (a){ case 'conn-tab': this.tab = el.dataset.tab; this.editing = null; this.render(); return true; - case 'd-new': this.editing = {id:'', name:''}; this.render(); return true; - case 'd-edit': { const c = S.data.connections.discord.find(x => x.id === el.dataset.id); this.editing = {id:c.id, name:c.name}; this.render(); return true; } - case 'd-cancel': this.editing = null; this.render(); return true; - case 'd-save': { - const name = Modal.el.querySelector('[data-bind="d-name"]').value, url = Modal.el.querySelector('[data-bind="d-url"]').value; - const r = await api('/connections/discord', {id:this.editing.id, name, url}); - if (!r.ok){ toast(r.error, 'err', 6000); return true; } - this.editing = null; await refreshData(); this.render(); toast('Channel saved', 'ok'); return true; + case 'd-new': this.editing = {id:'', name:''}; this.render(); focusField('d-name'); return true; + case 'd-edit': { + const c = S.data.connections.discord.find(x => x.id === el.dataset.id); + if (c){ this.editing = {id:c.id, name:c.name}; this.render(); focusField('d-name'); } + return true; } + case 'd-cancel': this.editing = null; this.render(); return true; + case 'd-save': + await busy(el, async () => { + const r = await api('/connections/discord', {id:this.editing.id, name:this.value('d-name'), url:this.value('d-url')}); + if (!r.ok){ toast(r.error, 'err', 6000); return; } + this.editing = null; + await refreshData(); + this.render(); + toast('Channel saved', 'ok'); + }); + return true; case 'd-del': - if (!confirm('Remove this channel? Integrations posting there switch off until you pick another.')) return true; - await api('/connections/discord/delete', {id:el.dataset.id}); await refreshData(); this.render(); return true; - case 'd-test': { const r = await api('/connections/discord/test', {id:el.dataset.id}); toast(r.ok ? 'Sent a test message, check Discord' : r.error, r.ok ? 'ok' : 'err', 5000); return true; } - case 't-login': await api('/twitch/login', {which:el.dataset.which}); await refreshData(); schedulePoll(); this.render(); return true; - case 't-cancel': await api('/twitch/cancel', {}); await refreshData(); this.render(); return true; + if (!armConfirm(el, 'Remove?')) return true; + await busy(el, async () => { + await api('/connections/discord/delete', {id:el.dataset.id}); + await refreshData(); + this.render(); + toast('Channel removed. Integrations posting there are off until you pick another.', 'info', 5000); + }); + return true; + case 'd-test': + await busy(el, async () => { + const r = await api('/connections/discord/test', {id:el.dataset.id}); + toast(r.ok ? 'Sent a test message, check Discord' : r.error, r.ok ? 'ok' : 'err', 5000); + }); + return true; + case 't-login': + await busy(el, async () => { await api('/twitch/login', {which:el.dataset.which}); await refreshData(); schedulePoll(); this.render(); }); + return true; + case 't-cancel': + await busy(el, async () => { await api('/twitch/cancel', {}); await refreshData(); this.render(); }); + return true; case 't-logout': - if (!confirm('Disconnect this Twitch account? Chat commands and chat messages stop until you connect again.')) return true; - await api('/twitch/logout', {which:el.dataset.which}); setTimeout(async () => { await refreshData(); this.render(); }, 400); return true; - case 'p-random': Modal.el.querySelector('[data-bind="p-topic"]').value = 'instantclone-' + randomToken().slice(0, 12); return true; - case 'p-save': { - const r = await api('/connections/phone', {topic:Modal.el.querySelector('[data-bind="p-topic"]').value, server:Modal.el.querySelector('[data-bind="p-server"]').value}); - if (!r.ok){ toast(r.error, 'err', 6000); return true; } - await refreshData(); this.render(); toast('Phone saved', 'ok'); return true; + if (!armConfirm(el, 'Chat stops. Sure?')) return true; + await busy(el, async () => { + await api('/twitch/logout', {which:el.dataset.which}); + await new Promise(r => setTimeout(r, 400)); + await refreshData(); + this.render(); + }); + return true; + case 'p-random': { + const f = q('[data-bind="p-topic"]'); + if (f){ f.value = 'instantclone-' + randomToken().slice(0, 12); f.focus(); } + return true; } - case 'p-test': { const r = await api('/connections/phone/test', {}); toast(r.ok ? 'Sent, check your phone' : r.error, r.ok ? 'ok' : 'err', 5000); return true; } + case 'p-save': + await busy(el, async () => { + const r = await api('/connections/phone', {topic:this.value('p-topic'), server:this.value('p-server')}); + if (!r.ok){ toast(r.error, 'err', 6000); return; } + await refreshData(); + this.render(); + toast('Phone saved', 'ok'); + }); + return true; + case 'p-test': + await busy(el, async () => { + const r = await api('/connections/phone/test', {}); + toast(r.ok ? 'Sent, check your phone' : r.error, r.ok ? 'ok' : 'err', 5000); + }); + return true; } return false; }, }; -async function refreshData(){ - const d = await api('/integrations'); - if (d && d.integrations){ S.data = d; if (S.visible) render(); } +function focusField(bind){ + const f = q(`[data-bind="${bind}"]`); + if (f) f.focus({preventScroll:true}); } // ---------------------------------------------------------------- recipes @@ -1534,67 +2012,83 @@ async function refreshData(){ const Recipes = { preview:null, text:'', resume(){ - Modal.open('import', '', 700); + Modal.open('import', 700); this.render(); }, openImport(){ this.preview = null; this.text = ''; - Modal.open('import', '', 700); + Modal.open('import', 700); this.render(); + focusField('r-text'); }, render(){ + if (Modal.kind !== 'import') return; const p = this.preview; const channels = S.data.connections.discord; - Modal.body(`${modalHead('download', 'var(--accent)', 'Import a recipe', "Paste a recipe someone shared. You see everything it adds before anything changes.")} -
- - ${p ? `
-
✓${esc(p.name)}${p.items.length} integration${p.items.length === 1 ? '' : 's'}
- ${p.items.map(it => `
· ${esc(it.name)} ${esc(it.summary)}
`).join('')} -
- ${p.uses_discord ? (channels.length ? `
` - : '
⚠It posts to Discord: add a Discord channel first.
') : ''} - ${p.local_effects ? `` : ''} -
🛡Recipes are settings, not code. They can't see your keys, tokens or webhook links, and they arrive switched off.
` : ''} + Modal.body(`${modalHead('download', 'var(--accent)', 'Import a recipe', 'Paste a recipe someone shared. You see everything it adds before anything changes.')} +
+ + ${p ? `
+
+
${svg('check')}${esc(p.name)}${plural(p.items.length, 'integration')}
+ ${p.items.map(it => `
${esc(it.name)} ${esc(it.summary)}
`).join('')} +
+ ${p.uses_discord ? (channels.length ? `
` + : warnHtml('It posts to Discord: add a Discord channel first.')) : ''} + ${p.local_effects ? `` : ''} +
${svg('shield')}Recipes are settings, not code. They can't see your keys, tokens or webhook links, and they arrive switched off.
+
` : ''}
-
+
${p ? `` : ''}
`); }, - async act(a){ + async act(a, el){ if (a === 'r-preview'){ - this.text = Modal.el.querySelector('[data-bind="r-text"]').value; - const r = await api('/integrations/import', {recipe:this.text}); - if (!r.ok){ toast(r.error, 'err', 6000); return true; } - this.preview = r; this.render(); return true; + await busy(el, async () => { + this.text = Conn.value('r-text'); + const r = await api('/integrations/import', {recipe:this.text}); + if (!r.ok){ toast(r.error, 'err', 6000); return; } + this.preview = r; + this.render(); + }); + return true; } if (a === 'r-apply'){ - const sel = Modal.el.querySelector('[data-bind="r-discord"]'), local = Modal.el.querySelector('[data-bind="r-local"]'); - const r = await api('/integrations/import', {recipe:this.text, apply:true, discord:sel ? sel.value : '', allow_local:!!(local && local.checked)}); - if (!r.ok){ toast(r.error, 'err', 6000); return true; } - Modal.close(true); await load(); toast(`Added ${r.added}. They're off until you switch them on.`, 'ok', 4500); return true; + await busy(el, async () => { + const sel = q('[data-bind="r-discord"]'), local = q('[data-bind="r-local"]'); + const r = await api('/integrations/import', {recipe:this.text, apply:true, discord:sel ? sel.value : '', allow_local:!!(local && local.checked)}); + if (!r.ok){ toast(r.error, 'err', 6000); return; } + if (Modal.kind === 'import') Modal.close(true); + await load(); + toast(`Added ${r.added}. They're off until you switch them on.`, 'ok', 4500); + }); + return true; } + if (a === 's-make'){ await busy(el, () => this.share()); return true; } return false; }, openShare(){ - Modal.open('share', '', 700); + Modal.open('share', 700); Modal.body(`${modalHead('copy', 'var(--accent)', 'Share as a recipe', 'Pick what to share. Your connections, keys and secret links are never included.')} -
+
-
${S.data.integrations.map(i => +
${S.data.integrations.map(i => ``).join('')}
-
+
`); + focusField('s-name'); }, async share(){ - const ids = Array.from(Modal.el.querySelectorAll('[data-bind="s-pick"]:checked')).map(x => x.value); - const r = await api('/integrations/export', {ids, name:Modal.el.querySelector('[data-bind="s-name"]').value}); + const ids = [...Modal.el.querySelectorAll('[data-bind="s-pick"]:checked')].map(x => x.value); + if (!ids.length){ toast('Tick at least one integration to share', 'info'); return; } + const r = await api('/integrations/export', {ids, name:Conn.value('s-name')}); if (!r.ok){ toast(r.error, 'err'); return; } - const out = Modal.el.querySelector('[data-bind="s-out"]'); + const out = q('[data-bind="s-out"]'); out.hidden = false; out.value = r.recipe; out.select(); @@ -1604,33 +2098,44 @@ const Recipes = { // ---------------------------------------------------------------- events +const OWNERS = {editor:Editor, builder:Builder, connections:Conn, import:Recipes, share:Recipes, catalog:Catalog}; + async function onClick(e){ const el = e.target.closest('[data-act]'); - if (!el) return; + if (!el || el.disabled) return; const a = el.dataset.act; - if (el.tagName === 'A' || a === 'catalog' || a === 'build' || a === 'import') e.preventDefault(); - if (Modal.kind === 'editor' && Editor.act(a, el)) return; - if (Modal.kind === 'builder' && Builder.act(a, el)) return; - if (Modal.kind === 'connections' && await Conn.act(a, el)) return; - if (Modal.kind === 'import' && await Recipes.act(a, el)) return; + if (el.tagName === 'A') e.preventDefault(); + const owner = el.closest('.ig-modal') && OWNERS[Modal.kind]; + if (owner && await owner.act(a, el)) return; switch (a){ case 'modal-close': Modal.close(); break; + case 'guard-keep': Modal.hideGuard(); break; + case 'guard-discard': Modal.close(true); break; + case 'guard-save': { + const save = q('.dest-form-foot [data-act="save"]'); + Modal.hideGuard(); + if (save) save.click(); + break; + } case 'catalog': Catalog.open(); break; - case 'cat': Catalog.cat = el.dataset.cat; Catalog.render(); break; - case 'add-preset': addFrom({preset:el.dataset.preset}, true); break; - case 'pack': Modal.close(true); addFrom({pack:el.dataset.pack}, false); break; case 'build': Builder.open(null); break; case 'import': Recipes.openImport(); break; case 'share': Recipes.openShare(); break; - case 's-make': Recipes.share(); break; case 'conn': Conn.open(el.dataset.tab); break; + case 'retry': busy(el, load); break; case 'edit': { const i = find(el.dataset.id); if (i) openEditor(i); break; } case 'toggle': toggle(el.dataset.id); break; - case 'duplicate': duplicate(el.dataset.id); break; + case 'duplicate': busy(el, () => duplicate(el.dataset.id)); break; case 'peek': S.peek = S.peek === el.dataset.id ? null : el.dataset.id; render(); break; case 'filter': S.filter = el.dataset.f; render(); break; case 'view': S.view = el.dataset.v; store.set('ig-view', S.view); render(); break; - case 'b-copy': navigator.clipboard.writeText(el.dataset.text); toast('Copied', 'ok'); break; + case 'copy': copyText(el); break; + case 'pack': + busy(el, async () => { + if (Modal.kind === 'catalog') Modal.close(true); + await addFrom({pack:el.dataset.pack}, false); + }); + break; } } @@ -1638,15 +2143,23 @@ function onInput(e){ const el = e.target; if (el.id === 'ig-search'){ S.search = el.value; - const items = $('ig-items'); - if (items) items.innerHTML = itemsHtml(); + render(); return; } - if (!el.dataset || !el.dataset.bind) return; + if (!el.dataset || !el.dataset.bind || !Modal.el || !Modal.el.contains(el)) return; if (Modal.kind === 'editor') Editor.input(el); - else if (Modal.kind === 'builder'){ - if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') Builder.lastField = el; - Builder.input(el); + else if (Modal.kind === 'builder') Builder.input(el); +} + +// Editors normalise some fields (commands lowercased, numbers clamped) +// without touching the field being typed in; once it is left, show the +// value that was kept. The last redraw rendered it into the attribute. +function onFieldLeave(e){ + const el = e.target; + if (!el.dataset || !el.dataset.bind || (Modal.kind !== 'editor' && Modal.kind !== 'builder')) return; + if (el.tagName === 'INPUT' && el.type !== 'checkbox' && el.type !== 'radio' && el.hasAttribute('value')){ + const kept = el.getAttribute('value'); + if (el.value !== kept) el.value = kept; } } @@ -1662,6 +2175,10 @@ function onActivateKey(e){ document.addEventListener('focusin', e => { if (Modal.kind === 'builder' && e.target.matches('.ig-inspector input:not([readonly]), .ig-inspector textarea')) Builder.lastField = e.target; }); +// A hidden page has nobody to show activity to; catch up when it's back. +document.addEventListener('visibilitychange', () => { + if (!document.hidden && (S.visible || modalOpen())) poll(); +}); function wireRoot(){ const root = $('ig-root'); @@ -1677,8 +2194,18 @@ window.Integrations = { async show(){ S.visible = true; wireRoot(); - await load(); - schedulePoll(); + const root = $('ig-root'); + // Known data paints at once and refreshes quietly; a first visit shows + // the skeleton until the app answers. + if (S.data){ + render(); + enterStagger(root); + load(); + } else if (root){ + root.innerHTML = skeletonHtml(); + if (await load() && S.visible) enterStagger(root); + } + if (S.visible) schedulePoll(); }, hide(){ S.visible = false; From d76504d3cfd1ee71f02f1d266a9048d1c1ee873e Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Thu, 1 Oct 2026 15:20:49 +0200 Subject: [PATCH 40/55] feat(integrations): use your own Twitch app from System System > Twitch & OBS gets a "Twitch login app" section: paste the Client ID of your own Twitch app and Integrations log in through it instead of the one built into releases. The five steps to make one are right there, with the redirect URL to copy and the one setting that matters (Public). - New `twitch_client_id` setting, saved with the System save bar and checked before saving (letters and digits only, so a stray space or a pasted secret never reaches Twitch). A settings reset keeps it. - The Twitch client id is switchable at run time. A real change cancels a login in progress and logs both accounts out, revoking them with the app that made them, then tells the user to connect again. A token refresh that finishes after the switch is dropped instead of bringing the old login back. - Precedence: your own app, then INSTANTCLONE_TWITCH_CLIENT_ID, then the release's built-in app. - A copy with no app at all opens the section by itself, and Integrations > Connections > Twitch links straight to it instead of a dead end. --- CHANGELOG.md | 3 +- src/config.rs | 51 ++++++++++++++++- src/integrations/engine.rs | 2 + src/integrations/twitch/mod.rs | 100 +++++++++++++++++++++++++++------ src/web.rs | 11 ++++ web/index.html | 49 ++++++++++++++++ web/integrations.js | 25 ++++++++- 7 files changed, 221 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1562f1b..2888eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,8 @@ know what each one does before you switch it on. twitch.tv with a code; no password goes through the app. The login refreshes itself, so as long as you open InstantClone at least once a month you never log in again. An optional bot account can post instead - of you. + of you. Prefer your own Twitch app? Paste its Client ID in **System > + Twitch & OBS > Twitch login app**; the steps to make one are right there. - **Share integrations as recipes**: a line of text you can paste in Discord. Recipes never include your connections, keys or secret links, arrive switched off, and anything that runs a program or writes a file diff --git a/src/config.rs b/src/config.rs index e9146f4..eb91673 100644 --- a/src/config.rs +++ b/src/config.rs @@ -156,6 +156,10 @@ pub struct Settings { /// Discord connection plus a "Stream alerts" integration. False only in /// configs written before integrations existed. pub integrations_migrated: bool, + /// The user's own Twitch app (Client ID from dev.twitch.tv), used to log + /// in to Twitch instead of the one built into releases. Empty uses the + /// built-in app. Not a secret: public apps have no client secret. + pub twitch_client_id: String, } #[derive(Debug, Clone, PartialEq)] @@ -922,6 +926,7 @@ impl Settings { // A fresh install has no old webhook to migrate; `load` sets // this after its one-time pass either way. integrations_migrated: false, + twitch_client_id: String::new(), } } @@ -1230,6 +1235,9 @@ impl Settings { } } // MIDI bindings, same only-when-bound rule. + if !self.twitch_client_id.is_empty() { + writeln!(f, "twitch_client_id={}", one_line(&self.twitch_client_id))?; + } if !self.midi_device.is_empty() { writeln!(f, "midi_device={}", one_line(&self.midi_device))?; } @@ -1382,6 +1390,7 @@ impl Settings { self.hotkeys.set(&k["hotkey.".len()..], value); } "midi_device" => self.midi_device = sanitize_device_name(value), + "twitch_client_id" => self.twitch_client_id = value.trim().to_string(), // midi.=. `set` validates and drops anything // malformed, so a hand-edited config can't load a bad signature. k if k.starts_with("midi.") => { @@ -1673,7 +1682,7 @@ impl Settings { "" }; 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},"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},"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), @@ -1702,6 +1711,8 @@ impl Settings { hotkeys = hotkeys, midi = midi, midid = json_str(&self.midi_device), + tci = json_str(&self.twitch_client_id), + tbi = !crate::integrations::twitch::default_client_id().is_empty(), cp = self.crash_protection.to_json(), dests = dests, ) @@ -1737,6 +1748,12 @@ impl Settings { .into(), ); } + if !is_twitch_client_id(&self.twitch_client_id) { + errs.push( + "That Twitch Client ID doesn't look right: copy the Client ID (letters and numbers) from your app at dev.twitch.tv/console" + .into(), + ); + } if self.destinations.len() > MAX_DESTINATIONS { errs.push(format!("too many destinations (max {})", MAX_DESTINATIONS)); } @@ -2014,6 +2031,14 @@ fn os_name() -> &'static str { } } +/// Empty (use the built-in app) or a Twitch Client ID: Twitch issues 30 +/// lowercase letters and digits; a little slack either way in case that +/// ever changes, but never spaces, quotes or anything else. +fn is_twitch_client_id(id: &str) -> bool { + id.is_empty() + || ((20..=64).contains(&id.len()) && id.chars().all(|c| c.is_ascii_alphanumeric())) +} + /// Strip control characters (notably CR/LF) from a value bound for the /// line-based config file. The format is `key=value` per line, so a newline in /// a value would inject a spurious second setting on the next load - e.g. a @@ -3851,6 +3876,30 @@ Name@x" topic: "texaz-alerts".into(), }, integrations_migrated: true, + twitch_client_id: "abcdefghij0123456789klmnopqrst".into(), + } + } + + /// A pasted Client ID is checked before it is saved, so a stray space + /// or the client secret by mistake never reaches Twitch. + #[test] + fn a_twitch_client_id_must_look_like_one() { + let twitch_errors = |id: &str| { + let mut s = Settings::defaults(); + s.twitch_client_id = id.into(); + s.validate() + .iter() + .filter(|e| e.contains("Twitch Client ID")) + .count() + }; + assert_eq!(twitch_errors(""), 0); + assert_eq!(twitch_errors("abcdefghij0123456789klmnopqrst"), 0); + for bad in [ + "short", + "has space in it 0123456789abc", + "abc\"defghij0123456789klmnopq", + ] { + assert_eq!(twitch_errors(bad), 1, "{bad}"); } } diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index dd8ec80..978defd 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -246,6 +246,7 @@ pub fn start( store.clone(), chat_tx, Box::new(move |line| log_ctrl.log(line)), + &settings.borrow().twitch_client_id, )); let handle = Arc::new(Handle { tx, @@ -370,6 +371,7 @@ impl Engine { self.host .default_delay_ms .store(s.auto_arm_delay_ms.max(1000), Ordering::Relaxed); + self.host.twitch.use_client_id(&s.twitch_client_id); // Forget per-trigger state for integrations that are gone. let alive: Vec<&str> = self.integrations.iter().map(|i| i.id.as_str()).collect(); let keep = |key: &String| alive.iter().any(|id| key.starts_with(&format!("{id}#"))); diff --git a/src/integrations/twitch/mod.rs b/src/integrations/twitch/mod.rs index 4ce07bb..6108437 100644 --- a/src/integrations/twitch/mod.rs +++ b/src/integrations/twitch/mod.rs @@ -29,6 +29,27 @@ use tokio::sync::{mpsc, watch, Notify}; const BUILT_IN_CLIENT_ID: Option<&str> = option_env!("INSTANTCLONE_TWITCH_CLIENT_ID"); const CLIENT_ID_ENV: &str = "INSTANTCLONE_TWITCH_CLIENT_ID"; +/// The Twitch app used when the user hasn't set their own: the environment +/// variable, else the one built into releases. Empty when there is none. +pub fn default_client_id() -> String { + std::env::var(CLIENT_ID_ENV) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| BUILT_IN_CLIENT_ID.map(str::to_string)) + .unwrap_or_default() +} + +/// The user's own app (System settings) wins over the default. +fn effective_client_id(custom: &str) -> String { + let custom = custom.trim(); + if custom.is_empty() { + default_client_id() + } else { + custom.to_string() + } +} + /// Refresh an access token when it has less than this left. const REFRESH_MARGIN: Duration = Duration::from_secs(20 * 60); const UPKEEP_EVERY: Duration = Duration::from_secs(5 * 60); @@ -85,7 +106,8 @@ impl Conn { } pub struct Twitch { - client_id: String, + /// Changes when the user sets or clears their own app in System. + client_id: Mutex, store: Arc, login: Mutex>, login_attempts: std::sync::atomic::AtomicU64, @@ -104,14 +126,10 @@ impl Twitch { store: Arc, incoming: mpsc::Sender, log: Box, + custom_client_id: &str, ) -> Twitch { - let client_id = std::env::var(CLIENT_ID_ENV) - .ok() - .filter(|v| !v.trim().is_empty()) - .or_else(|| BUILT_IN_CLIENT_ID.map(str::to_string)) - .unwrap_or_default(); Twitch { - client_id, + client_id: Mutex::new(effective_client_id(custom_client_id)), store, login: Mutex::new(None), login_attempts: Default::default(), @@ -125,7 +143,36 @@ impl Twitch { } pub fn available(&self) -> bool { - !self.client_id.is_empty() + !self.client_id.lock().is_empty() + } + + fn client_id(&self) -> String { + self.client_id.lock().clone() + } + + /// Switch to the user's own Twitch app, or back to the default one. + /// Logins belong to the app that made them, so a real change logs both + /// accounts out (revoking them with the old app) and says so. + pub fn use_client_id(&self, custom: &str) { + let next = effective_client_id(custom); + if next == self.client_id() { + return; + } + self.cancel_login(); + let had_accounts = + self.account(Which::Main).is_some() || self.account(Which::Bot).is_some(); + self.logout(Which::Main); + self.logout(Which::Bot); + *self.client_id.lock() = next; + let which_app = if custom.trim().is_empty() { + "InstantClone's Twitch app" + } else { + "your own Twitch app" + }; + (self.log)(format!("[twitch] now using {which_app}")); + *self.notice.lock() = had_accounts.then(|| { + format!("InstantClone now uses {which_app}. Connect your Twitch accounts again.") + }); } fn conn(&self, which: Which) -> &Conn { @@ -209,7 +256,10 @@ impl Twitch { user_code: String::new(), uri: String::new(), expires_at: Instant::now() + Duration::from_secs(600), - error: (!self.available()).then(|| "This build has no Twitch app id.".to_string()), + error: (!self.available()).then(|| { + "This copy of InstantClone has no Twitch app. Add your own in System > Twitch & OBS." + .to_string() + }), attempt, }); if !self.available() { @@ -239,7 +289,7 @@ impl Twitch { } async fn run_login(self: Arc, which: Which, attempt: u64) { - let client_id = self.client_id.clone(); + let client_id = self.client_id(); let started = tokio::task::spawn_blocking({ let client_id = client_id.clone(); move || auth::start_device(&client_id, which.scopes()) @@ -318,7 +368,7 @@ impl Twitch { pub fn logout(&self, which: Which) { self.conn(which).stop(); if let Some(account) = self.account(which) { - let client_id = self.client_id.clone(); + let client_id = self.client_id(); std::thread::spawn(move || { let form = [ ("client_id", client_id.as_str()), @@ -369,10 +419,18 @@ impl Twitch { } else if !expiring && !auth_failed { return; } - let (id, refresh) = (self.client_id.clone(), account.refresh.clone()); - let result = tokio::task::spawn_blocking(move || auth::refresh(&id, &refresh)) - .await - .unwrap_or_else(|_| Err(auth::TokenError::Temporary("refresh crashed".into()))); + let (id, refresh) = (self.client_id(), account.refresh.clone()); + let result = tokio::task::spawn_blocking({ + let id = id.clone(); + move || auth::refresh(&id, &refresh) + }) + .await + .unwrap_or_else(|_| Err(auth::TokenError::Temporary("refresh crashed".into()))); + // The user switched apps meanwhile: that logged this account out, + // and saving the old app's tokens would bring it back. + if self.client_id() != id { + return; + } match result { Ok(tokens) => { self.set_account( @@ -448,8 +506,9 @@ impl Twitch { call: impl FnOnce(&helix::Auth) -> Result, ) -> Result { let account = self.account(Which::Main).ok_or("connect Twitch first")?; + let client_id = self.client_id(); let auth = helix::Auth { - client_id: &self.client_id, + client_id: &client_id, token: &account.access, user_id: &account.user_id, }; @@ -507,8 +566,17 @@ impl Twitch { ]), None => Value::Null, }; + let id = self.client_id(); + let app = if id.is_empty() { + "none" + } else if id == default_client_id() { + "built_in" + } else { + "custom" + }; json::obj([ ("available", Value::Bool(self.available())), + ("app", json::str(app)), ("main", conn_json(Which::Main)), ("bot", conn_json(Which::Bot)), ("login", flow), diff --git a/src/web.rs b/src/web.rs index 43ab772..1d4d025 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2700,6 +2700,7 @@ const SETTABLE_KEYS: &[&str] = &[ "update_check_enabled", "open_dashboard_on_launch", "midi_device", + "twitch_client_id", ]; /// Whether `POST /config` may write key `k`. See `SETTABLE_KEYS`. @@ -2882,6 +2883,8 @@ async fn post_config_reset( next.discord_channels = prev.discord_channels; next.phone = prev.phone; next.integrations_migrated = prev.integrations_migrated; + // Resetting it would silently log out of Twitch. + next.twitch_client_id = prev.twitch_client_id; } else if scope != "all" { return ( "400 Bad Request", @@ -4292,6 +4295,7 @@ fn apply_field_str(s: &mut Settings, key: &str, value: &str) { // matches no device, and the user sees a pick that silently does // nothing until the next restart tidies it up. "midi_device" => s.midi_device = config::sanitize_device_name(value), + "twitch_client_id" => s.twitch_client_id = value.trim().to_string(), _ => {} } } @@ -5628,6 +5632,13 @@ mod tests { reset: "", read: |s| s.midi_device.clone(), }, + SettableRow { + key: "twitch_client_id", + set: " abcdefghij0123456789klmnopqrst ", + reads_as: "abcdefghij0123456789klmnopqrst", + reset: "", + read: |s| s.twitch_client_id.clone(), + }, ]; // The table and the whitelist must list the same keys, so a new diff --git a/web/index.html b/web/index.html index 8caa850..b2c7810 100644 --- a/web/index.html +++ b/web/index.html @@ -2148,6 +2148,16 @@ .hk-row{flex-wrap:wrap;gap:8px} .hk-capture{min-width:0;flex:1} } +/* System > Twitch login app. */ +.tw-app-status{margin-top:10px;padding:8px 11px;border-radius:9px;font-size:12.5px;color:var(--fg-2); + background:var(--surface-3);border:1px solid var(--line)} +.tw-app-status:empty{display:none} +.tw-app-status.warn{color:var(--warn);border-color:color-mix(in oklch,var(--warn) 35%,var(--line)); + background:color-mix(in oklch,var(--warn) 8%,var(--surface-3))} +.tw-app-steps{margin:12px 0 0;padding-left:20px;font-size:12.5px;line-height:1.75;color:var(--fg-2)} +.tw-app-steps a,.tw-app-warn a{color:var(--accent)} +.tw-app-copy{padding:2px 9px;font-size:11.5px;border-radius:7px;vertical-align:1px} +.tw-app-warn{margin-top:10px} /* - Integrations ------------------- Built from the Destinations pieces: a module is a .dcard whose screen shows exactly what it sends, and every editor is the destination modal @@ -2199,6 +2209,7 @@ .ig-notice-text{flex:1;min-width:0;text-wrap:pretty} .ig-notice-text b{color:var(--fg)} .ig-inline-warn{margin-top:0} +.ig-modal .lan-warn a{color:var(--accent);font-weight:600} .ig-conns{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .ig-conns .ic-label{margin-right:4px} .ig-conns .ic-pill{cursor:pointer;font:inherit;font-size:12px; @@ -3722,6 +3733,28 @@

New destination

+ + +
+ Twitch login app +
Integrations log in to Twitch through an app registered at Twitch. InstantClone brings its own, so most people never touch this. Use your own if you prefer to, or if Twitch shows as unavailable.
+
+
+ Your app's Client ID + +
+
    +
  1. Open dev.twitch.tv/console and log in. Twitch asks you to turn on two-factor authentication first.
  2. +
  3. Name: anything, for example My InstantClone. It can't contain the word "Twitch".
  4. +
  5. OAuth Redirect URL: http://localhost It isn't used, but Twitch requires one.
  6. +
  7. Category: Broadcaster Suite. Client Type: Public. This one matters: a Confidential app can't stay logged in.
  8. +
  9. Click Create, then Manage, copy the Client ID and paste it above. You don't need a secret.
  10. +
+
Changing the app logs your Twitch accounts out. After saving, connect them again in Integrations.
+
@@ -6005,6 +6038,8 @@

New destination

renderLanWarning(); $('s-ovdir').value = c.overlays_dir || ''; $('s-trace').checked = c.tracing_enabled !== false; + $('s-twitch-client').value = c.twitch_client_id || ''; + renderTwitchApp(c); // Reflects the actual Run-key entry, not a stored preference - so if // the user removed it via Task Manager's Startup tab, this shows off. $('s-startup').checked = !!c.start_with_windows; @@ -9591,6 +9626,7 @@

New destination

ingest_bind_all:$('s-ibind').checked?'on':'off', web_bind_all:$('s-wbind').checked?'on':'off', tracing_enabled:$('s-trace').checked?'true':'false', + twitch_client_id:$('s-twitch-client').value.trim(), start_with_windows:$('s-startup').checked?'on':'off', open_dashboard_on_launch:$('s-open-dash').checked?'true':'false', update_check_enabled:$('s-update-check').checked?'true':'false', @@ -9601,6 +9637,19 @@

New destination

Object.entries(cpFields()).forEach(([k, v]) => body.append(k, v)); return body; } +// Which Twitch app Integrations use, from what was last saved. With no app +// at all the section opens by itself: it is the one way to get Twitch. +function renderTwitchApp(c){ + const own = !!c.twitch_client_id, builtIn = !!c.twitch_app_built_in; + const status = $('sys-twitch-app-status'), note = $('sys-twitch-app-note'); + if (!status) return; + status.textContent = own ? 'Using your own Twitch app.' + : builtIn ? 'Using InstantClone\'s Twitch app.' + : 'No Twitch app in this copy: Twitch login is off until you add one below.'; + status.classList.toggle('warn', !own && !builtIn); + note.textContent = own ? 'your own app' : builtIn ? 'built in' : 'needs an app'; + if (!own && !builtIn) $('sys-twitch-app').open = true; +} function settingsMarkClean(){ settingsSnapshot = settingsBody().toString(); updateSaveBar(); diff --git a/web/integrations.js b/web/integrations.js index 21843cd..debec26 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -1877,11 +1877,15 @@ const Conn = { }, twitchHtml(){ const t = S.data.twitch; - if (!t.available) return warnHtml('This build has no Twitch app id, so it can\'t log in to Twitch. Official releases include one.'); + if (!t.available){ + return warnHtml('This copy of InstantClone has no Twitch app, so it can\'t log in to Twitch yet. ' + + 'You can add your own in a couple of minutes: System › Twitch login app.'); + } return (t.notice ? warnHtml(esc(t.notice)) : '') + this.accountHtml('main', t.main, 'Your Twitch account', 'Chat, VOD markers and clips. Log in once: InstantClone keeps it fresh.') + this.accountHtml('bot', t.bot, 'Bot account (optional)', 'A second account that posts in chat instead of you.') - + '
No password ever goes through InstantClone: you approve it on twitch.tv, and can revoke it there any time.
'; + + '
No password ever goes through InstantClone: you approve it on twitch.tv, and can revoke it there any time.' + + (t.app === 'custom' ? ' Logging in through your own Twitch app (change).' : '') + '
'; }, accountHtml(which, a, title, blurb){ const flow = S.data.twitch.login; @@ -2002,6 +2006,22 @@ const Conn = { }, }; +// System > Twitch & OBS, with the Twitch login app section open. +function openTwitchAppSettings(){ + Modal.close(true); + if (typeof showTab === 'function') showTab('system'); + if (typeof showSubTab === 'function') showSubTab('system', 'twitch'); + const box = document.getElementById('sys-twitch-app'); + if (!box) return; + box.open = true; + // After the sub-tab has laid out and started its entrance. + setTimeout(() => { + box.scrollIntoView({block:'start', behavior:reduced() ? 'auto' : 'smooth'}); + const field = document.getElementById('s-twitch-client'); + if (field) setTimeout(() => field.focus({preventScroll:true}), 300); + }, 80); +} + function focusField(bind){ const f = q(`[data-bind="${bind}"]`); if (f) f.focus({preventScroll:true}); @@ -2130,6 +2150,7 @@ async function onClick(e){ case 'filter': S.filter = el.dataset.f; render(); break; case 'view': S.view = el.dataset.v; store.set('ig-view', S.view); render(); break; case 'copy': copyText(el); break; + case 'twitch-app': openTwitchAppSettings(); break; case 'pack': busy(el, async () => { if (Modal.kind === 'catalog') Modal.close(true); From b14c32b0ffe4bc6abaff68b7628edf93d2e48d61 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Thu, 1 Oct 2026 15:51:43 +0200 Subject: [PATCH 41/55] feat(integrations): try API requests in the builder, undo, back, hidden secrets API commands - "Try it" under a selected web request: values for the variables it uses, Send request (new POST /integrations/fetch, same code path as a step), and the answer as a tree. Click a value, then "Reply in chat with it" fills the chat step after the request (or adds one). The real answer's fields join "Variables here" with their real values. - Security: values inserted into a request are escaped for where they land. URL values are percent-encoded (a viewer can't add parameters or change the path), JSON bodies get JSON-string escaping, and headers lose control characters (no injected header lines). Covered by tests. UX - Card and row menu (Edit, Duplicate, Share as a recipe, Delete), so deleting no longer means opening the integration first. - Undo toasts after adding and deleting; a deleted integration comes back in its old place. - Back button for views opened from another: the catalog (from Build your own, Import, or a module that needs a detail, where Back also removes the just-added draft), the simple editor (from Open in builder, keeping the edits) and the editor that opened Connections. - Recent activity rows open their integration. Privacy - Phone topic, Discord webhook link, request headers and the secret web call address are masked until Show, and hide again when the modal closes. Connection pills say "connected" instead of the topic or login. Addresses in cards, chips and steps show only scheme and host. --- CHANGELOG.md | 11 + src/integrations/api.rs | 53 +++- src/integrations/effects.rs | 117 +++++---- src/integrations/runner.rs | 72 +++++- src/integrations/template.rs | 55 +++- web/index.html | 81 +++++- web/integrations.js | 481 +++++++++++++++++++++++++++++++---- 7 files changed, 762 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2888eb3..55a0877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,17 @@ know what each one does before you switch it on. captures what they actually saw. - **Test runs** send the real messages marked `[TEST]`, skip the waits and never touch the stream, then show every step and what it answered. +- **Chat commands that call any API.** In the builder, a web request has + **Try it**: send it for real, see the answer as a tree, click the value + you want and **Reply in chat with it**. What viewers type is encoded into + its own spot of the request, so `!rank` can't be bent into calling + something else. +- **Everything can be taken back.** Every card has a menu (edit, + duplicate, share, delete), adding and deleting come with **Undo**, and + views opened from the catalog or the simple editor have a back button. +- **Nothing private on screen by default.** Your phone topic, webhook + links, API headers and secret addresses are hidden until you click + Show, and addresses elsewhere only show their site. - **Twitch: log in once and forget it.** You approve InstantClone on twitch.tv with a code; no password goes through the app. The login refreshes itself, so as long as you open InstantClone at least once a diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 5f9968a..d1cef1c 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -80,6 +80,7 @@ pub async fn route( } ("POST", "/integrations/duplicate") => duplicate(&req(), settings, cfg_path), ("POST", "/integrations/test") => test(&req(), &handle).await, + ("POST", "/integrations/fetch") => fetch(&req()).await, ("POST", "/integrations/export") => export(&req(), &settings.borrow()), ("POST", "/integrations/import") => import(&req(), settings, cfg_path), ("POST", "/connections/discord") => save_discord(&req(), settings, cfg_path), @@ -259,13 +260,18 @@ fn save(req: &Value, settings: &Arc>, cfg_path: &Path) - let id = integration.id.clone(); let warnings = unknown_vars(&integration); let missing = problems.incomplete; + // Undoing a delete puts it back where it was. + let restore_at = req.get("at").and_then(Value::as_f64).map(|n| n as usize); let reply = update(settings, cfg_path, move |s| { match s.integrations.iter().position(|i| i.id == integration.id) { Some(at) => s.integrations[at] = integration, None if s.integrations.len() >= MAX_INTEGRATIONS => { return Err(format!("you already have {MAX_INTEGRATIONS} integrations")) } - None => s.integrations.push(integration), + None => { + let at = restore_at.unwrap_or(usize::MAX).min(s.integrations.len()); + s.integrations.insert(at, integration); + } } Ok(()) }); @@ -501,6 +507,51 @@ async fn test(req: &Value, handle: &super::Handle) -> Reply { } } +/// Longest answer the editor's "Send request" shows. +const FETCH_SHOWN: usize = 200_000; + +/// 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 { + let headers = req + .str_or("headers", "") + .lines() + .filter_map(|line| { + let (name, value) = line.split_once(':')?; + let name = name.trim(); + (!name.is_empty()).then(|| (name.to_string(), value.trim().to_string())) + }) + .collect(); + let request = super::host::HttpRequest { + method: match req.str_or("method", "GET").trim() { + "" => "GET".to_string(), + m => m.to_ascii_uppercase(), + }, + url: req.str_or("url", "").trim().to_string(), + headers, + body: req.str_or("body", "").to_string(), + }; + let started = std::time::Instant::now(); + let result = tokio::task::spawn_blocking(move || super::effects::send_http(request)) + .await + .unwrap_or_else(|_| Err("the request crashed".to_string())); + let ms = started.elapsed().as_millis() as f64; + match result { + Ok(resp) => { + let truncated = resp.body.chars().count() > FETCH_SHOWN; + let body: String = resp.body.chars().take(FETCH_SHOWN).collect(); + ok(json::obj([ + ("ok", Value::Bool(true)), + ("status", Value::Num(f64::from(resp.status))), + ("ms", Value::Num(ms)), + ("body", json::str(body)), + ("truncated", Value::Bool(truncated)), + ])) + } + Err(e) => fail(e), + } +} + fn export(req: &Value, s: &Settings) -> Reply { let ids: Vec<&str> = req .get("ids") diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index df3223e..35e55a7 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -21,6 +21,66 @@ const MAX_FILE_TEXT: usize = 1024 * 1024; /// capped so a runaway trigger can't fork the machine to its knees. const MAX_RUNNING_PROGRAMS: usize = 16; +/// Make a web request for a step, or for the editor's "Send request". +pub fn send_http(r: HttpRequest) -> Result { + if !(r.url.starts_with("http://") || r.url.starts_with("https://")) { + return Err("the address must start with http:// or https://".to_string()); + } + let agent = crate::https::https_agent(); + let result = match r.method.as_str() { + "GET" | "DELETE" | "HEAD" => { + let mut req = match r.method.as_str() { + "GET" => agent.get(&r.url), + "HEAD" => agent.head(&r.url), + _ => agent.delete(&r.url), + } + .config() + .timeout_global(Some(HTTP_TIMEOUT)) + .build(); + for (name, value) in &r.headers { + req = req.header(name.as_str(), value.as_str()); + } + req.call() + } + "POST" | "PUT" | "PATCH" => { + let mut req = match r.method.as_str() { + "PUT" => agent.put(&r.url), + "PATCH" => agent.patch(&r.url), + _ => agent.post(&r.url), + } + .config() + .timeout_global(Some(HTTP_TIMEOUT)) + .build(); + let has_type = r + .headers + .iter() + .any(|(n, _)| n.eq_ignore_ascii_case("content-type")); + if !has_type && !r.body.is_empty() { + let kind = if json::parse(&r.body).is_ok() { + "application/json" + } else { + "text/plain; charset=utf-8" + }; + req = req.header("Content-Type", kind); + } + for (name, value) in &r.headers { + req = req.header(name.as_str(), value.as_str()); + } + req.send(r.body.as_str()) + } + other => return Err(format!("{other} is not a supported method")), + }; + let mut resp = result.map_err(|e| format!("request failed: {e}"))?; + let status = resp.status().as_u16(); + let body = resp + .body_mut() + .with_config() + .limit(MAX_RESPONSE) + .read_to_string() + .unwrap_or_default(); + Ok(HttpResponse { status, body }) +} + pub struct RealHost { pub ctrl: Arc, pub twitch: Arc, @@ -68,62 +128,7 @@ impl Host for RealHost { } fn http(&self, r: HttpRequest) -> Result { - if !(r.url.starts_with("http://") || r.url.starts_with("https://")) { - return Err("the address must start with http:// or https://".to_string()); - } - let agent = crate::https::https_agent(); - let result = match r.method.as_str() { - "GET" | "DELETE" | "HEAD" => { - let mut req = match r.method.as_str() { - "GET" => agent.get(&r.url), - "HEAD" => agent.head(&r.url), - _ => agent.delete(&r.url), - } - .config() - .timeout_global(Some(HTTP_TIMEOUT)) - .build(); - for (name, value) in &r.headers { - req = req.header(name.as_str(), value.as_str()); - } - req.call() - } - "POST" | "PUT" | "PATCH" => { - let mut req = match r.method.as_str() { - "PUT" => agent.put(&r.url), - "PATCH" => agent.patch(&r.url), - _ => agent.post(&r.url), - } - .config() - .timeout_global(Some(HTTP_TIMEOUT)) - .build(); - let has_type = r - .headers - .iter() - .any(|(n, _)| n.eq_ignore_ascii_case("content-type")); - if !has_type && !r.body.is_empty() { - let kind = if json::parse(&r.body).is_ok() { - "application/json" - } else { - "text/plain; charset=utf-8" - }; - req = req.header("Content-Type", kind); - } - for (name, value) in &r.headers { - req = req.header(name.as_str(), value.as_str()); - } - req.send(r.body.as_str()) - } - other => return Err(format!("{other} is not a supported method")), - }; - let mut resp = result.map_err(|e| format!("request failed: {e}"))?; - let status = resp.status().as_u16(); - let body = resp - .body_mut() - .with_config() - .limit(MAX_RESPONSE) - .read_to_string() - .unwrap_or_default(); - Ok(HttpResponse { status, body }) + send_http(r) } fn phone( diff --git a/src/integrations/runner.rs b/src/integrations/runner.rs index ca9aada..2d1f9cb 100644 --- a/src/integrations/runner.rs +++ b/src/integrations/runner.rs @@ -159,6 +159,17 @@ impl Runner { template::render(step.param(name), &lookup) } + /// `fill`, escaping inserted values with `escape`. + fn fill_escaped(&self, step: &Step, name: &str, escape: &dyn Fn(&str) -> String) -> String { + let live = self.host.live(); + let lookup = Lookup { + vars: &self.ctx.vars, + counters: &self.env.counters, + live: &live, + }; + template::render_escaped(step.param(name), &lookup, escape) + } + fn set(&mut self, name: impl Into, value: impl Into) { self.ctx.vars.insert(name.into(), value.into()); } @@ -371,14 +382,27 @@ impl Runner { "" => "response".to_string(), name => name.to_string(), }; + // Values can come from chat, so each lands escaped for where it goes: + // one URL component, one JSON string, one header line. + let json_body = matches!( + step.param("body").trim_start().chars().next(), + Some('{' | '[') + ); let request = HttpRequest { method: match step.param("method").trim() { "" => "POST".to_string(), m => m.to_ascii_uppercase(), }, - url: self.fill(step, "url").trim().to_string(), - headers: parse_headers(&self.fill(step, "headers")), - body: self.fill(step, "body"), + url: self + .fill_escaped(step, "url", &template::url_component) + .trim() + .to_string(), + headers: parse_headers(&self.fill_escaped(step, "headers", &template::one_line)), + body: if json_body { + self.fill_escaped(step, "body", &template::json_string_content) + } else { + self.fill(step, "body") + }, }; let label = format!("{} {}", request.method, short_url(&request.url)); match self.blocking(move |h| h.http(request)).await { @@ -967,6 +991,48 @@ mod tests { ); } + /// What a viewer types fills its own part of a request and nothing + /// more: one URL component, one JSON string. + #[tokio::test] + async fn chat_values_cannot_escape_their_place_in_a_request() { + let host = Arc::new(FakeHost::default()); + let steps = [ + Step::new( + StepKind::Http, + &[ + ("method", "GET"), + ("url", "https://api.example/rank/{arg1}?q={args}"), + ], + ), + Step::new( + StepKind::Http, + &[ + ("url", "https://api.example/log"), + ("body", r#"{"who":"{args}"}"#), + ], + ), + ]; + let typed = r#"ana/../admin?key=1&x="#; + go( + &host, + &steps, + ctx( + &[("arg1", typed), ("args", r#"a","admin":true,"b":""#)], + false, + ), + ) + .await; + let calls = host.calls.lock(); + assert_eq!( + calls[0].trim_end(), + "http GET https://api.example/rank/ana%2F..%2Fadmin%3Fkey%3D1%26x%3D?q=a%22%2C%22admin%22%3Atrue%2C%22b%22%3A%22" + ); + assert!( + calls[1].ends_with(r#"{"who":"a\",\"admin\":true,\"b\":\""}"#), + "{calls:?}" + ); + } + #[tokio::test] async fn test_runs_mark_messages_and_leave_the_stream_alone() { let host = Arc::new(FakeHost::default()); diff --git a/src/integrations/template.rs b/src/integrations/template.rs index b8b3aa8..ccf32d2 100644 --- a/src/integrations/template.rs +++ b/src/integrations/template.rs @@ -24,6 +24,13 @@ impl Option> Vars for F { const MAX_NAME: usize = 64; pub fn render(template: &str, vars: &dyn Vars) -> String { + render_escaped(template, vars, &|value| value.to_string()) +} + +/// `render`, passing every inserted value through `escape` first. Text the +/// user wrote, fallbacks included, is left as written: only values (which +/// can come from chat or a web answer) are escaped. +pub fn render_escaped(template: &str, vars: &dyn Vars, escape: &dyn Fn(&str) -> String) -> String { let mut out = String::with_capacity(template.len()); let mut rest = template; while let Some(i) = rest.find('{') { @@ -39,7 +46,7 @@ pub fn render(template: &str, vars: &dyn Vars) -> String { let value = vars.lookup(name).unwrap_or_default(); match fallback { Some(text) if is_blank(&value) => out.push_str(text), - _ => out.push_str(&value), + _ => out.push_str(&escape(&value)), } rest = &tail[len..]; } @@ -53,6 +60,37 @@ pub fn render(template: &str, vars: &dyn Vars) -> String { out } +/// Percent-encode a value for a URL path or query: everything but the +/// unreserved characters, so `!rank ana&key=x` stays one value. +pub fn url_component(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for b in value.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// Escape a value for the inside of a JSON string, so a quote typed in chat +/// can't add fields to a JSON body. +pub fn json_string_content(value: &str) -> String { + let mut quoted = String::new(); + crate::json::write_string(value, &mut quoted); + quoted[1..quoted.len() - 1].to_string() +} + +/// One line: no control characters, so a value can't start a new header. +pub fn one_line(value: &str) -> String { + value + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect() +} + /// Every variable name a template uses, in order, without repeats. pub fn names(template: &str) -> Vec { let mut found: Vec = Vec::new(); @@ -161,6 +199,21 @@ mod tests { assert_eq!(render("{ spaced }", &vars), "{ spaced }"); } + #[test] + fn escaping_touches_values_only() { + let vars = |name: &str| (name == "q").then(|| "a b&c=d/é".to_string()); + assert_eq!( + render_escaped("x?q={q}&r={none|a b}", &vars, &url_component), + "x?q=a%20b%26c%3Dd%2F%C3%A9&r=a b" + ); + let quote = |name: &str| (name == "q").then(|| "a\",\"admin\":true".to_string()); + assert_eq!( + render_escaped(r#"{"n":"{q}"}"#, "e, &json_string_content), + r#"{"n":"a\",\"admin\":true"}"# + ); + assert_eq!(one_line("a\r\nX-Evil: 1"), "a X-Evil: 1"); + } + #[test] fn lists_names_once_in_order() { assert_eq!( diff --git a/web/index.html b/web/index.html index b2c7810..52533b2 100644 --- a/web/index.html +++ b/web/index.html @@ -2625,12 +2625,91 @@ .ig-recipe-safe{display:flex;align-items:flex-start;gap:8px} .ig-recipe-safe svg{width:14px;height:14px;flex:none;margin-top:2px;color:var(--accent)} .ig-share-list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow:auto} +/* Back, menu and undo: every step can be taken back. */ +.ig-modal .dest-form-head .ig-back{margin-right:-4px} +.ig-card .ig-more{opacity:.7;transition:opacity .15s var(--ease-out),background-color .15s var(--ease-out)} +.ig-card:hover .ig-more,.ig-more[aria-expanded="true"],.ig-more:focus-visible{opacity:1} +.ig-more svg{width:16px;height:16px} +.ig-menu{position:fixed;z-index:1100;min-width:200px;padding:5px;border-radius:12px;background:var(--bg-2); + border:1px solid var(--line-2);box-shadow:0 18px 40px -14px rgba(0,0,0,.7),0 2px 6px rgba(0,0,0,.3); + 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; + 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 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)} +.ig-menu-sep{height:1px;margin:4px 6px;background:var(--line)} +.ic-toast.ig-undo{pointer-events:auto;align-items:center} +.ig-undo-btn{margin-left:10px;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),transform .15s var(--ease-out)} +.ig-undo-btn:hover{background:color-mix(in oklch,var(--accent) 20%,transparent)} +.ig-undo-btn:active{transform:scale(.96)} +.ig-act-row.link{cursor:pointer;border-radius:6px;transition:background-color .15s var(--ease-out)} +.ig-act-row.link:hover{background:var(--surface-3)} +.ig-act-row.link:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} +/* Secrets stay hidden until asked: a dashboard is often on stream. */ +.dest-form .ig-secret-field>label{display:flex;align-items:center;justify-content:space-between;gap:8px} +.ig-reveal{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:6px;border:1px solid var(--line); + background:transparent;color:var(--fg-3);font:inherit;font-size:11.5px;font-weight:500;text-transform:none;letter-spacing:0;cursor:pointer; + transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} +.ig-reveal:hover{color:var(--fg);border-color:var(--line-2)} +.ig-reveal svg{width:12px;height:12px} +.ig-secret-field:not(.shown) .ig-secret{-webkit-text-security:disc} +/* Try a web request, then pick a value from the answer. */ +.ig-try-wrap{margin-top:16px;animation:igFadeUp .26s var(--ease-out)} +.ig-try{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:12px;background:var(--bg-1);border:1px solid var(--line)} +.ig-try-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +.ig-try-inputs{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px} +.ig-try-meta{display:flex;align-items:center;gap:8px} +.ig-status-pill{padding:2px 8px;border-radius:999px;font:600 11.5px/1.5 'JetBrains Mono',monospace;font-variant-numeric:tabular-nums} +.ig-status-pill.ok{color:var(--live);background:color-mix(in oklch,var(--live) 14%,transparent)} +.ig-status-pill.bad{color:var(--danger);background:color-mix(in oklch,var(--danger) 14%,transparent)} +.ig-try-text{max-height:220px;overflow:auto;padding:10px;border-radius:8px;background:var(--bg-2); + font:12px/1.5 'JetBrains Mono',monospace;color:var(--fg-2);white-space:pre-wrap;word-break:break-word} +.ig-picked{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:10px; + border:1px solid color-mix(in oklch,var(--accent) 45%,transparent);background:color-mix(in oklch,var(--accent) 8%,transparent); + animation:igFadeUp .24s var(--ease-out)} +.ig-picked-what{display:flex;align-items:baseline;gap:8px;min-width:0;font-size:12.5px} +.ig-picked-what code{font-family:'JetBrains Mono',monospace;color:color-mix(in oklch,var(--accent) 70%,#fff);flex:none;max-width:60%; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-picked-what span{min-width:0;color:var(--fg-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-picked-actions{display:flex;gap:6px;flex-wrap:wrap} +.ig-jtree{max-height:420px;overflow:auto;padding:6px;border-radius:8px;background:var(--bg-2);font:12px/1.5 'JetBrains Mono',monospace} +.ig-jnode>summary{display:flex;align-items:center;gap:6px;padding:3px 6px;border-radius:6px;cursor:pointer;list-style:none;color:var(--fg-2)} +.ig-jnode>summary::-webkit-details-marker{display:none} +.ig-jnode>summary:hover{background:var(--surface-3)} +.ig-jnode>summary svg{width:11px;height:11px;flex:none;color:var(--fg-4);transform:rotate(-90deg);transition:transform .2s var(--ease-out)} +.ig-jnode[open]>summary svg{transform:none} +.ig-jkids{margin-left:11px;padding-left:8px;border-left:1px solid var(--line)} +.ig-jcount{color:var(--fg-4)} +.ig-jleaf{display:flex;align-items:baseline;gap:8px;width:100%;padding:3px 6px;border:0;border-radius:6px;background:transparent; + color:inherit;font:inherit;text-align:left;cursor:pointer;transition:background-color .12s var(--ease-out)} +.ig-jleaf:hover,.ig-jleaf:focus-visible{background:color-mix(in oklch,var(--accent) 12%,transparent);outline:none} +.ig-jleaf .k,.ig-jnode .k{color:var(--fg-3);flex:none} +.ig-jleaf .k::after{content:':'} +.ig-jleaf .v{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ig-jleaf .v.string{color:var(--live)} +.ig-jleaf .v.number{color:#fcd34d} +.ig-jleaf .v.boolean{color:#a78bfa} +.ig-jleaf .v.null{color:var(--fg-4)} +.ig-juse{flex:none;font:600 11px/1.5 Inter,system-ui,sans-serif;color:var(--accent);opacity:0;transition:opacity .12s var(--ease-out)} +.ig-jleaf:hover .ig-juse,.ig-jleaf:focus-visible .ig-juse{opacity:1} +.ig-jleaf.off{cursor:default;opacity:.55} +.ig-jleaf.off:hover{background:transparent} +.ig-jmore{padding:3px 6px;color:var(--fg-4)} @media (prefers-reduced-motion: reduce){ .ig-skel::after{animation:none} .ig-spin,.ic-btn.is-busy::after,.ic-btn-tiny.is-busy::after{animation-duration:1.6s} .ig-row-peek,.ig-run-box,.ig-device,.ig-code,.ig-recipe,.ig-unsaved,.ig-guard,.ig-ping,.ig-added svg,.ig-ok svg, .ig-activity-box[open] .ig-activity,.ig-modal.closing .dest-form,.ig-modal.closing .ic-modal-backdrop, - .ic-btn.ig-armed,.ic-btn-tiny.ig-armed,.ig-cat-grid>*{animation:none} + .ic-btn.ig-armed,.ic-btn-tiny.ig-armed,.ig-cat-grid>*,.ig-menu,.ig-picked,.ig-try-wrap{animation:none} + .ig-menu.closing{transition:none} .ig-dot svg,.ig-role>span svg,.ig-chip svg,.ig-seg .sub-tab-ind{transition:none} } @media (max-width:760px){ diff --git a/web/integrations.js b/web/integrations.js index debec26..6be2a92 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -59,6 +59,9 @@ const PATHS = { hash:'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', bell:'M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0', pencil:'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z', + back:'M15 18l-6-6 6-6', + dots:'M5 12h.01M12 12h.01M19 12h.01', + eye:'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', }; const svg = (name, extra) => ``; @@ -162,7 +165,16 @@ function varsFor(handler){ walk(handler && handler.steps, s => { if (s.type === 'http'){ const n = (s.params.save_as || '').trim() || 'response'; - list.push({name:n + '.status', sample:'200'}, {name:n + '.ok', sample:'yes'}, {name:n + '.body', sample:'{...}'}); + // Once "Send request" ran, the real answer is the sample. + const tried = Builder.tries.get(uidOf(s)); + const got = tried && tried.status ? tried : null; + list.push({name:n + '.status', sample:got ? String(got.status) : '200'}, + {name:n + '.ok', sample:got ? (got.status >= 200 && got.status < 300 ? 'yes' : 'no') : 'yes'}, + {name:n + '.body', sample:got ? got.body.slice(0, 200) : '{...}'}); + if (got && got.json !== undefined){ + jsonLeaves(got.json).filter(l => l.usable && (n + '.json.' + l.path).length <= 64).slice(0, 60) + .forEach(l => list.push({name:`${n}.json.${l.path}`, sample:l.text})); + } } if (s.type === 'clip') list.push({name:'clip.url', sample:'https://clips.twitch.tv/BraveSnipe'}); if (s.type === 'counter' && s.params.name) list.push({name:'counter.' + s.params.name, sample:'2'}); @@ -177,15 +189,28 @@ function sampleMap(handler){ return m; } // Mirrors src/integrations/template.rs, for previews. -function renderSample(text, vars){ +// `escape`, when given, is applied to inserted values only, like the +// server does for web requests (see template::render_escaped). +function renderSample(text, vars, escape){ return String(text || '').replace(/\{\{([A-Za-z0-9_.]+)\}\}|\{([A-Za-z0-9_.]{1,64})(?:\|([^}]*))?\}/g, (all, escaped, name, fallback) => { if (escaped) return '{' + escaped + '}'; const v = vars[name] == null ? '' : String(vars[name]); if (fallback !== undefined && (v === '' || v === '0')) return fallback; - return v; + return escape ? escape(v) : v; }); } +// Mirrors template::url_component: only unreserved characters stay. +const urlComponent = v => encodeURIComponent(v).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +const jsonStringContent = v => JSON.stringify(v).slice(1, -1); +const oneLine = v => v.replace(/[\u0000-\u001f\u007f]/g, ' '); +// An address as safe to show on stream: scheme and host only, since the +// path and query often carry an API key or a webhook secret. +function maskUrl(url){ + const m = /^(https?:\/\/[^/?#\s]+)(\S*)/i.exec(String(url || '').trim()); + if (!m) return String(url || '').trim() ? 'an address' : ''; + return m[1] + (m[2] && m[2] !== '/' ? '/…' : ''); +} function highlightVars(text){ return esc(text).replace(/\{[A-Za-z0-9_.]{1,64}(?:\|[^}]*)?\}/g, m => `${m}`); } @@ -472,6 +497,146 @@ function q(sel){ return [...Modal.el.querySelectorAll(sel)].find(el => !el.closest('[data-leaving]')) || null; } +// A toast with Undo, for actions that are easy to regret. +function undoToast(text, undo){ + const rail = $('toast-rail'); + if (!rail){ toast(text, 'ok'); return; } + const el = document.createElement('div'); + el.className = 'ic-toast ok ig-undo'; + el.innerHTML = `✓`; + el.querySelector('.ig-undo-text').textContent = text; + let used = false; + const leave = () => { + if (!el.isConnected || el.classList.contains('leaving')) return; + el.classList.add('leaving'); + setTimeout(() => el.remove(), 350); + }; + el.querySelector('button').addEventListener('click', async () => { + if (used) return; + used = true; + leave(); + await undo(); + }); + rail.appendChild(el); + setTimeout(leave, 8000); +} + +// A secret field (topic, webhook link, headers, secret address): hidden +// like a password until the user asks to see it. +function secretField(id, control, label){ + const shown = S.revealed.has(id); + return `
${control}
`; +} + +function parseJson(text){ + try { + const v = JSON.parse(text); + return v !== null && typeof v === 'object' ? v : undefined; + } catch(_){ return undefined; } +} +// Every value in a JSON answer, with the path a variable uses to reach it. +// `usable`: the path fits a variable name (letters, digits, _). +function jsonLeaves(root){ + const out = []; + const visit = (v, path) => { + if (out.length >= 200) return; + if (v !== null && typeof v === 'object'){ + (Array.isArray(v) ? v.slice(0, 25).map((x, i) => [i, x]) : Object.entries(v)).forEach(([k, x]) => visit(x, path.concat(k))); + return; + } + out.push({path:path.join('.'), text:v === null ? '' : String(v), usable:path.length > 0 && path.every(k => /^[A-Za-z0-9_]+$/.test(String(k)))}); + }; + visit(root, []); + return out; +} +// The answer as a tree; every value is a button that picks its variable. +function jsonTreeHtml(root, saveAs){ + let budget = 400; + const node = (v, path, key, depth) => { + if (budget-- <= 0) return ''; + const label = key === null ? '' : `${esc(key)}`; + if (v !== null && typeof v === 'object'){ + const all = Array.isArray(v) ? v.length : Object.keys(v).length; + const entries = Array.isArray(v) ? v.slice(0, 25).map((x, i) => [i, x]) : Object.entries(v).slice(0, 60); + const kids = entries.map(([k, x]) => node(x, path.concat(k), String(k), depth + 1)).join('') + + (all > entries.length ? `
… ${all - entries.length} more
` : ''); + if (key === null) return kids || '
(empty)
'; + return `
${svg('chev')}${label}${Array.isArray(v) ? `[${all}]` : `{${all}}`}
${kids}
`; + } + const name = `${saveAs}.json.${path.join('.')}`; + const usable = path.every(k => /^[A-Za-z0-9_]+$/.test(String(k))) && name.length <= 64; + const text = v === null ? 'null' : String(v); + const shown = typeof v === 'string' ? `"${text.length > 80 ? text.slice(0, 80) + '…' : text}"` : text; + const kind = v === null ? 'null' : typeof v; + return usable + ? `` + : `
${label}${esc(shown)}
`; + }; + return node(root, [], null, 0); +} + +// The "⋯" menu of a card or row: everything you can do to it, Delete +// included, without opening it. Lives on so no card clips it. +const Menu = { + el:null, id:null, btn:null, + toggle(btn, id){ + if (this.id === id){ this.close(); return; } + this.open(btn, id); + }, + open(btn, id){ + this.close(); + const i = find(id); + if (!i) return; + const el = document.createElement('div'); + el.className = 'ig-menu'; + el.setAttribute('role', 'menu'); + el.innerHTML = ` + + + + `; + document.body.appendChild(el); + el.addEventListener('click', onClick); + el.addEventListener('keydown', e => this.keys(e)); + const r = btn.getBoundingClientRect(), w = el.offsetWidth, h = el.offsetHeight; + el.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px'; + el.style.top = (r.bottom + 6 + h > innerHeight - 8 ? r.top - h - 6 : r.bottom + 6) + 'px'; + this.el = el; + this.id = id; + this.btn = btn; + btn.setAttribute('aria-expanded', 'true'); + el.querySelector('button').focus({preventScroll:true}); + document.addEventListener('pointerdown', this.outside, true); + window.addEventListener('scroll', this.dismiss, true); + window.addEventListener('resize', this.dismiss); + }, + close(){ + if (!this.el) return; + const el = this.el, btn = this.btn, hadFocus = el.contains(document.activeElement); + this.el = this.id = this.btn = null; + document.removeEventListener('pointerdown', this.outside, true); + window.removeEventListener('scroll', this.dismiss, true); + window.removeEventListener('resize', this.dismiss); + el.classList.add('closing'); + setTimeout(() => el.remove(), reduced() ? 0 : 120); + if (btn && btn.isConnected){ + btn.setAttribute('aria-expanded', 'false'); + if (hadFocus) btn.focus({preventScroll:true}); + } + }, + keys(e){ + const items = [...this.el.querySelectorAll('[role="menuitem"]')]; + const at = items.indexOf(document.activeElement); + if (e.key === 'Escape'){ e.preventDefault(); this.close(); } + else if (e.key === 'ArrowDown'){ e.preventDefault(); items[(at + 1) % items.length].focus(); } + else if (e.key === 'ArrowUp'){ e.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); } + else if (e.key === 'Tab') this.close(); + }, + outside: e => { if (Menu.el && !Menu.el.contains(e.target) && !e.target.closest('[data-act="menu"]')) Menu.close(); }, + dismiss: e => { if (Menu.el && !(e && e.target && Menu.el.contains(e.target))) Menu.close(); }, +}; + function copyText(btn){ const text = btn.dataset.text || ''; navigator.clipboard.writeText(text).then( @@ -511,7 +676,7 @@ function previewHtml(step, handler, big){
`; case 'http': { const body = text.trim(); - let shown = body ? body : `${step.params.method || 'POST'} ${step.params.url || '(address to set)'}`; + let shown = body ? body : `${step.params.method || 'POST'} ${maskUrl(step.params.url) || '(address to set)'}`; try { shown = JSON.stringify(JSON.parse(body), null, 1).replace(/\n\s*/g, ' '); } catch(_){} return `
${esc(shown).replace(/"([^&]*)":/g, '"$1":')}
`; } @@ -537,6 +702,9 @@ const S = { data:null, visible:false, filter:'all', search:'', peek:null, view: store.get('ig-view') === 'list' ? 'list' : 'grid', pollTimer:null, activityOpen:false, + // Secret fields the user chose to show (by field id). Hidden by default: + // a dashboard is often on a stream. + revealed:new Set(), }; async function load(){ @@ -686,8 +854,8 @@ function connectionsHtml(){ ``; return `
Connections ${pill('discord', discord ? 'ok' : '', 'Discord', discord ? plural(discord, 'channel') : 'Add')} - ${pill('twitch', twitchOn ? (twitchLive ? 'ok' : 'warn') : '', 'Twitch', twitchOn ? (twitchLive ? t.main.login : t.main.login + ' · ' + t.main.chat) : (t.available ? 'Connect' : 'Unavailable'))} - ${pill('phone', phoneOn ? 'ok' : '', 'Phone', phoneOn ? c.phone.topic : 'Connect')} + ${pill('twitch', twitchOn ? (twitchLive ? 'ok' : 'warn') : '', 'Twitch', twitchOn ? (twitchLive ? 'connected' : 'chat ' + t.main.chat) : (t.available ? 'Connect' : 'Unavailable'))} + ${pill('phone', phoneOn ? 'ok' : '', 'Phone', phoneOn ? 'connected' : 'Connect')}
`; } @@ -719,6 +887,10 @@ function switchHtml(i){ return ``; } +function moreHtml(i){ + return ``; +} function flagHtml(flag){ if (!flag) return ''; return `${flag.label}`; @@ -735,7 +907,7 @@ function gridHtml(items, none){ ${svg(c.icon)}
${esc(i.name)}
${esc(c.where)} · ${esc(c.last)}
- ${switchHtml(i)} + ${moreHtml(i)}${switchHtml(i)}
`; }).join('')} @@ -755,6 +927,7 @@ function listHtml(items, none){ ${c.flag ? flagHtml(c.flag) : `${esc(c.last)}`} ${switchHtml(i)} + ${moreHtml(i)}
${open ? `
${previewHtml(c.step, c.h, true)}
@@ -795,7 +968,9 @@ function activityHtml(){ const detail = failed ? failed.label + ': ' + failed.detail : r.status === 'busy' ? 'skipped: already running' : r.steps.filter(s => s.status === 'ok').map(s => s.label).join(' · ') || r.trigger; - return `
${esc(fmtClock(r.at_ms))} + const open = find(r.integration_id) + ? ` data-act="edit" data-id="${esc(r.integration_id)}" role="button" tabindex="0" title="Open ${esc(r.name)}"` : ''; + return `
${esc(fmtClock(r.at_ms))} ${esc(r.name)}${r.test ? ' (test)' : ''} ${esc(r.trigger)} → ${esc(detail)}${esc(r.status)}
`; }).join('') : '
Runs show up here, with every step and what it answered.
'; @@ -829,16 +1004,26 @@ async function toggle(id){ } async function addFrom(body, openAfter){ + const fromCatalog = Modal.kind === 'catalog'; const r = await api('/integrations/add', body); if (!r.ok){ toast(r.error || 'Could not add it', 'err', 5000); return; } await load(); const added = (r.ids || []).map(find).filter(Boolean); + const ids = added.map(i => i.id); const unfinished = added.find(i => !i.enabled); if (openAfter && unfinished){ - openEditor(unfinished, true); + // Going back from here means "not this one": the draft goes again. + const back = fromCatalog ? {title:'Back to the catalog, without adding it', go:async () => { + await deleteIds([unfinished.id]); + Catalog.reopen(); + }} : null; + openEditor(unfinished, true, {justAdded:true, back}); return; } - toast(added.length > 1 ? `Added ${added.length} integrations` : `Added ${added[0] ? added[0].name : ''}`, 'ok'); + undoToast(added.length > 1 ? `Added ${added.length} integrations` : `Added ${added[0] ? added[0].name : ''}`, async () => { + await deleteIds(ids); + if (Modal.kind === 'catalog') Catalog.render(); + }); if (unfinished) toast(`${unfinished.name} needs one more detail before it can switch on`, 'info', 4500); if (Modal.kind === 'catalog' && body.preset) Catalog.flashAdded(body.preset); } @@ -852,18 +1037,32 @@ async function duplicate(id){ async function remove(id){ const kind = Modal.kind; + const i = find(id); + // Kept so Undo can put it back exactly, in the same place. + const snapshot = i ? clone(i) : null; + const at = i ? S.data.integrations.indexOf(i) : -1; const r = await api('/integrations/delete', {id}); if (!r.ok){ toast(r.error || 'Could not delete it', 'err'); return; } // Only close the editor that asked; the user may have moved on. - if (Modal.kind === kind) Modal.close(true); + if (kind && Modal.kind === kind) Modal.close(true); + await load(); + if (!snapshot){ toast('Deleted', 'ok'); return; } + undoToast(`Deleted ${snapshot.name}`, async () => { + const back = await api('/integrations/save', Object.assign(snapshot, {at})); + if (!back.ok) toast(back.error || 'Could not bring it back', 'err', 5000); + await load(); + }); +} + +async function deleteIds(ids){ + for (const id of ids) await api('/integrations/delete', {id}); await load(); - toast('Deleted', 'ok'); } // `switchOn`: it should end up on, and waits for a detail first. -function openEditor(i, switchOn){ - if (i.preset) Editor.open(i, switchOn); - else Builder.open(i, {switchOn}); +function openEditor(i, switchOn, opts){ + if (i.preset) Editor.open(i, switchOn, opts); + else Builder.open(i, Object.assign({switchOn}, opts)); } // ---------------------------------------------------------------- modal shell @@ -874,7 +1073,11 @@ function openEditor(i, switchOn){ // close to ask about unsaved changes, `after` runs once it really closes. const Modal = { el:null, form:null, kind:null, onClose:null, after:null, opener:null, swapFrom:null, + // `back`: {go, title, keepsEdits} for a view opened from another one. + // `pending`: what the unsaved-changes bar's Discard should finish. + back:null, pending:null, open(kind, width, height){ + Menu.close(); if (this.el){ this.swapFrom = this.form.getBoundingClientRect(); this.hideGuard(); @@ -898,6 +1101,8 @@ const Modal = { this.kind = kind; this.onClose = null; this.after = null; + this.back = null; + this.pending = null; this.form.style.width = `min(${width || 820}px,100%)`; this.form.style.height = height || ''; return this.el; @@ -922,7 +1127,10 @@ const Modal = { }, close(force){ if (!this.el) return; - if (!force && this.onClose && this.onClose() === false) return; + if (!force){ + this.pending = 'close'; + if (this.onClose && this.onClose() === false) return; + } const after = this.after; this.after = null; this.onClose = null; @@ -933,6 +1141,8 @@ const Modal = { } const el = this.el, opener = this.opener; this.el = this.form = this.opener = this.swapFrom = null; + // Shown secrets hide again: the next time could be on stream. + S.revealed.clear(); document.removeEventListener('keydown', onModalKey); el.classList.add('closing'); el.inert = true; @@ -940,6 +1150,23 @@ const Modal = { if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus({preventScroll:true}); if (!S.visible) clearInterval(S.pollTimer); }, + // Back to the view this one was opened from. Edits that travel back + // with it skip the unsaved-changes question; with nowhere to go back to, + // it closes (and `after` returns to the opener). + goBack(){ + if (!this.back){ this.close(); return; } + if (!this.back.keepsEdits){ + this.pending = 'back'; + if (this.onClose && this.onClose() === false) return; + } + this.leaveBack(); + }, + leaveBack(){ + const go = this.back.go; + this.back = null; + this.pending = null; + go(); + }, // The "unsaved changes" bar, over the footer. guard(){ const shown = this.form.querySelector('.ig-guard'); @@ -989,7 +1216,9 @@ function trapFocus(e){ } function modalHead(icon, color, title, hint, extra){ - return `
+ const back = Modal.back || Modal.after + ? `` : ''; + return `
${back} ${svg(icon)}

${title}

${hint}
${extra || ''} @@ -1008,12 +1237,12 @@ function lastRunHtml(d){ return `Last ran ${esc(fmtAgo(st.last_ms))} · ${esc(st.last_status)}`; } // The editors' footer: Delete away from Save, Save lit only with changes. -function footHtml(d, dirty, extra){ +function footHtml(d, dirty, extra, canSave){ return `
${d.id ? '' : ''}
${d.id ? lastRunHtml(d) : 'New integration'}${dirty ? 'Unsaved' : ''}
${extra || ''} - +
`; } function runLogHtml(steps){ @@ -1048,6 +1277,10 @@ const Catalog = { open(){ this.cat = 'rec'; this.added = null; + this.reopen(); + }, + // Back from something opened here: same category as before. + reopen(){ Modal.open('catalog', 1040, 'min(86vh,820px)'); this.render(); }, @@ -1145,12 +1378,17 @@ function needsNote(needs){ // The simple editor for catalog modules: pick a moment, write on the // preview itself, and every option is a chip that opens its own panel. const Editor = { - d:null, sel:0, chip:null, run:null, dirty:false, moreVars:false, - open(i, switchOn){ + d:null, sel:0, chip:null, run:null, dirty:false, moreVars:false, back:null, justAdded:false, + // opts: {back} (see Modal.back); {justAdded} when the catalog just made + // it as a draft, which closing keeps and Back removes again. + open(i, switchOn, opts){ + opts = opts || {}; this.d = withHandler(clone(i)); - // Freshly added and waiting for one detail: shown switched on, so - // saving once it is complete also turns it on. - this.dirty = !!switchOn && !this.d.enabled; + this.back = opts.back || null; + this.justAdded = !!opts.justAdded; + // Waiting for one detail: shown switched on, so saving once it is + // complete also turns it on. + this.dirty = !!switchOn && !this.d.enabled && !this.justAdded; if (switchOn) this.d.enabled = true; this.sel = Math.max(0, this.d.handlers.findIndex(h => h.enabled)); // Missing a channel: open straight on the chip that fixes it. @@ -1169,9 +1407,23 @@ const Editor = { }, show(){ Modal.open('editor', 920); - Modal.onClose = () => !this.dirty || (Modal.guard(), false); + Modal.back = this.back; + Modal.onClose = () => { + if (this.dirty){ Modal.guard(); return false; } + if (this.justAdded && Modal.pending === 'close') toast(`${this.d.name} is kept as a draft, switched off`, 'info', 4000); + return true; + }; this.render(); }, + // Back from "Open in builder", with whatever was changed there. + fromBuilder(d, dirty){ + this.d = withHandler(d); + this.dirty = dirty; + this.sel = Math.min(this.sel, this.d.handlers.length - 1); + this.chip = null; + this.run = null; + this.show(); + }, handler(){ return this.d.handlers[this.sel]; }, render(){ const d = this.d, h = this.handler(); @@ -1194,7 +1446,7 @@ const Editor = {
${footHtml(d, this.dirty, ` - `)}`); + `, this.justAdded)}`); Modal.form.querySelectorAll('.ig-msg').forEach(autosize); }, momentsHtml(){ @@ -1277,7 +1529,7 @@ const Editor = { } if (step && step.type === 'chat') out.push({id:'reply', k:'Reply as', v:step.params.as === 'bot' ? 'bot account' : 'your account'}); if (step && step.type === 'phone') out.push({id:'priority', k:'Priority', v:step.params.priority || 'normal'}); - if (step && step.type === 'http') out.push({id:'url', k:'Send to', v:step.params.url || 'set the address', warn:!step.params.url}); + if (step && step.type === 'http') out.push({id:'url', k:'Send to', v:maskUrl(step.params.url) || 'set the address', warn:!step.params.url}); if (step && step.type === 'file') out.push({id:'file', k:'File', v:step.params.path || 'pick a file', warn:!step.params.path}); out.push({id:'cooldown', k:'Wait between', v:fmtMs(d.cooldown_ms) || 'no limit'}); return out; @@ -1393,7 +1645,10 @@ const Editor = { case 'set-as': if (step) step.params.as = v; this.changed(); return true; case 'set-priority': if (step) step.params.priority = v; this.changed(); return true; case 'set-cooldown': this.d.cooldown_ms = +v; this.changed(); return true; - case 'ed-builder': Builder.open(this.d, {dirty:this.dirty}); return true; + case 'ed-builder': + Builder.open(this.d, {dirty:this.dirty, back:{title:'Back to the simple editor', keepsEdits:true, + go:() => Editor.fromBuilder(Builder.d, Builder.dirty)}}); + return true; case 'ed-test': busy(el, () => this.test()); return true; case 'delete': if (armConfirm(el, 'Delete for good?')) busy(el, () => remove(this.d.id)); return true; case 'save': busy(el, () => saveIntegration(this.d, () => { this.dirty = false; })); return true; @@ -1480,10 +1735,16 @@ const TRIGGERS = [['event', 'An InstantClone event'], ['chat_command', 'A chat c const pathKey = p => p.join('.'); const Builder = { - d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, lastField:null, - // opts: {dirty} carries the editor's unsaved state over, {switchOn} as in Editor.open. + d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, lastField:null, back:null, + // "Send request" results per web request step (by uidOf), this session. + tries:new Map(), + // opts: {dirty} carries the editor's unsaved state over, {switchOn} as in + // Editor.open, {back} as in Modal.back (default: the catalog, if open). open(i, opts){ opts = opts || {}; + this.back = opts.back !== undefined ? opts.back + : Modal.kind === 'catalog' ? {title:'Back to the catalog', go:() => Catalog.reopen()} : null; + this.tries = new Map(); this.d = i ? withHandler(clone(i)) : {id:'', name:'My integration', enabled:true, preset:'', cooldown_ms:0, handlers:[{enabled:true, trigger:{type:'event', event:'hold_opened', filters:{}}, steps:[]}]}; this.dirty = !!opts.dirty || (!!opts.switchOn && !this.d.enabled); @@ -1501,6 +1762,7 @@ const Builder = { }, show(){ Modal.open('builder', 1240, 'min(92vh,900px)'); + Modal.back = this.back; Modal.onClose = () => !this.dirty || (Modal.guard(), false); this.render(); }, @@ -1526,6 +1788,7 @@ const Builder = { WHEN${esc(triggerSentence(h.trigger))} ${this.stepsHtml(h.steps, [], 'root')}
+ ${this.selectedHttp() ? `
${this.tryHtml(this.selectedHttp())}
` : ''} ${this.run ? `
Test run: ${esc(this.run.status || 'error')}
${this.run.error ? warnHtml(esc(this.run.error)) : this.run.status === 'running' ? '
Running the test…
' : runLogHtml(this.run.steps)}
` : ''} @@ -1611,10 +1874,14 @@ const Builder = { case 'chat': return field('text', 'Message', {area:true}) + field('as', 'Send as', {select:[['', 'Your account'], ['bot', 'Bot account']]}) + field('reply', 'Reply to the viewer', {select:[['', 'No'], ['yes', 'Yes, in their thread']]}); case 'phone': return field('title', 'Title') + field('text', 'Message', {area:true}) + field('priority', 'Priority', {select:[['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']]}); - case 'http': return field('method', 'Method', {select:['POST', 'GET', 'PUT', 'PATCH', 'DELETE'].map(m => [m, m])}) + field('url', 'Address', {mono:true, ph:'https://…'}) - + field('headers', 'Headers (one per line, Name: value)', {area:true}) + field('body', 'Body', {area:true, ph:'{"event":"{delay}"}'}) + case 'http': return field('method', 'Method', {select:['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map(m => [m, m])}) + + field('url', 'Address', {mono:true, ph:'https://api.example/rank/{arg1}'}) + + note('Values from chat are encoded for you, so a viewer can only fill in their part. Keep API keys in Headers: they stay hidden here and never go into recipes.') + + secretField('headers-' + uidOf(s), ``, 'Headers (one per line, Name: value)') + + field('body', 'Body', {area:true, ph:'{"event":"{delay}"}'}) + field('save_as', 'Save the answer as', {mono:true, ph:'response'}) - + note(`Later steps can use {${esc(s.params.save_as || 'response')}.status}, .ok, .body and .json.field.`); + + note(`Later steps can use {${esc(s.params.save_as || 'response')}.status}, .ok, .body and .json.field.`) + + note('Try it under the steps: send the request for real and pick what to use from the answer.'); case 'wait': return field('ms', 'Wait (milliseconds)', {mono:true, ph:'20000'}) + note(`${esc(fmtMs(s.params.ms) || '0 s')}. Up to an hour.`); case 'wait_delay': return '

Waits until the moment this started has reached your viewers. Only InstantClone can do this: it knows your delay.

' + field('extra_ms', 'Then wait a bit more (ms)', {mono:true, ph:'0'}) + field('follow', 'If the delay changes meanwhile', {select:[['', 'Follow it'], ['no', 'Keep the delay from the start']]}); @@ -1667,8 +1934,8 @@ const Builder = { `; } else if (t.type === 'webhook'){ const url = `${location.origin}/hooks/${t.token || ''}`; - body = `
-
+ body = secretField('hook', `
+
`, 'Call this address (keep it secret)') + `
GET or POST from a Stream Deck, a script or any app on this network. The body is {body}; JSON fields are {body.field}.
`; } @@ -1697,6 +1964,11 @@ const Builder = { else if (b === 't-every') t.every_ms = Math.max(1, parseFloat(el.value) || 1) * 60000; else if (b === 't-live') t.only_live = el.checked; else if (b === 'p') this.stepAt(this.sel).params[el.dataset.name] = el.value; + else if (b === 'try'){ + // Values to try the request with; not part of the integration. + this.tryEntry(this.stepAt(this.sel)).inputs[el.dataset.name] = el.value; + return; + } else return; this.dirty = true; this.render(); @@ -1766,11 +2038,107 @@ const Builder = { case 'b-new-token': this.handler().trigger.token = randomToken(); this.edited(); return true; case 'b-token': insertAtCursor(this.lastField, '{' + el.dataset.token + '}'); return true; case 'b-test': busy(el, () => this.test()); return true; + case 'b-try': busy(el, () => this.tryRequest()); return true; + case 'b-pick': this.pick(el.dataset.token, el.dataset.sample || ''); return true; + case 'b-use-reply': this.replyWithPick(); return true; case 'delete': if (armConfirm(el, 'Delete for good?')) busy(el, () => remove(d.id)); return true; case 'save': busy(el, () => saveIntegration(d, () => { this.dirty = false; })); return true; } return false; }, + // The selected step, when it is a web request (it gets "Try it"). + selectedHttp(){ + const s = Array.isArray(this.sel) ? this.stepAt(this.sel) : null; + return s && s.type === 'http' ? s : null; + }, + // Bring a just-drawn part of the canvas into view. + reveal(selector){ + const el = q(selector); + if (el) el.scrollIntoView({block:'nearest', behavior:reduced() ? 'auto' : 'smooth'}); + }, + tryEntry(s){ + let e = this.tries.get(uidOf(s)); + if (!e){ e = {inputs:{}}; this.tries.set(uidOf(s), e); } + return e; + }, + // "Try it": values for the variables the request uses, the button, and + // what came back, where every value can be picked. + tryHtml(s){ + const t = this.tryEntry(s); + const used = [...new Set(['url', 'headers', 'body'].flatMap(f => + [...String(s.params[f] || '').matchAll(/\{([A-Za-z0-9_.]{1,64})(?:\|[^}]*)?\}/g)].map(m => m[1])))]; + const samples = sampleMap(this.handler()); + const inputs = used.map(n => `
`).join(''); + return `
+
Try it +
+ ${used.length ? `
Try it with:
${inputs}
` : ''} + ${this.tryResultHtml(s, t)} +
`; + }, + tryResultHtml(s, t){ + if (t.error) return warnHtml(esc(t.error)); + if (!t.status) return '
Sends the request for real and shows the answer. Then pick what to use from it.
'; + const n = (s.params.save_as || '').trim() || 'response'; + const ok = t.status >= 200 && t.status < 300; + const head = `
${t.status} + ${t.ms} ms${t.truncated ? ' · long answer, cut for display' : ''}
`; + const picked = t.picked ? `
+
{${esc(t.picked.token)}}${esc(t.picked.sample) || 'empty'}
+
+
` : ''; + if (t.json === undefined){ + return `${head}${picked}
${t.body ? esc(t.body.slice(0, 3000)) : 'The answer was empty.'}
+ ${t.body ? `` : ''}`; + } + return `${head}${picked}
Click the value you want to use.
${jsonTreeHtml(t.json, n)}
`; + }, + async tryRequest(){ + const s = Array.isArray(this.sel) ? this.stepAt(this.sel) : null; + if (!s || s.type !== 'http') return; + const t = this.tryEntry(s); + const vars = Object.assign(sampleMap(this.handler()), t.inputs); + const jsonBody = /^\s*[{[]/.test(s.params.body || ''); + const r = await api('/integrations/fetch', { + method: s.params.method || 'POST', + url: renderSample(s.params.url, vars, urlComponent).trim(), + headers: renderSample(s.params.headers, vars, oneLine), + body: renderSample(s.params.body, vars, jsonBody ? jsonStringContent : null), + }); + t.picked = null; + if (!r.ok){ t.error = r.error || 'The request failed'; t.status = 0; } + else Object.assign(t, {error:'', status:r.status, ms:Math.round(r.ms), body:r.body, truncated:r.truncated, json:parseJson(r.body)}); + if (Modal.kind !== 'builder') return; + this.render(); + this.reveal('.ig-try-wrap'); + }, + pick(token, sample){ + const s = this.stepAt(this.sel); + this.tryEntry(s).picked = {token, sample}; + this.render(); + this.reveal('.ig-picked'); + }, + // The picked value goes into the chat reply right after the request: the + // one already there, or a new one. + replyWithPick(){ + const s = this.stepAt(this.sel), t = this.tryEntry(s); + if (!t.picked) return; + const token = '{' + t.picked.token + '}'; + const {list, index} = this.locate(this.sel); + const next = list[index + 1]; + if (next && next.type === 'chat'){ + next.params.text = ((next.params.text || '').trimEnd() + ' ' + token).trim(); + } else { + const chat = newStep('chat'); + const trig = this.handler().trigger; + chat.params.text = trig.type.startsWith('chat') ? `{user}: ${token}` : token; + list.splice(index + 1, 0, chat); + } + this.sel = this.sel.slice(0, -1).concat(index + 1); + this.target = []; + this.edited(); + }, async test(){ this.run = {status:'running', steps:[]}; this.render(); @@ -1817,7 +2185,7 @@ function stepSentence(s){ case 'discord': return `Discord ${esc(channelName(p.connection))} ${q(p.text)}${p.ping ? ' · @' + esc(p.ping) : ''}`; case 'chat': return `Chat ${q(p.text)}${p.as === 'bot' ? ' · as bot' : ''}${p.reply === 'yes' ? ' · as a reply' : ''}`; case 'phone': return `Phone ${q(p.text)}`; - case 'http': return `${esc(p.method || 'POST')} ${esc(p.url || '(address)')}${p.save_as ? ' → ' + esc(p.save_as) : ''}`; + case 'http': return `${esc(p.method || 'POST')} ${esc(maskUrl(p.url) || '(address)')}${p.save_as ? ' → ' + esc(p.save_as) : ''}`; case 'wait': return `Wait ${esc(fmtMs(p.ms) || '0 s')}`; case 'wait_delay': return 'Wait until it has aired for viewers {delay}'; case 'if': return `If ${highlightVars(p.left || '…')} ${esc(OPS[p.op] || p.op || '')} ${highlightVars(p.right || '')}`; @@ -1869,7 +2237,7 @@ const Conn = { const form = e ? `
${e.id ? 'Edit channel' : 'New channel'}
-
+ ${secretField('d-url', ``, 'Webhook link')}
In Discord: Server settings › Integrations › Webhooks › New webhook › Copy webhook URL.
` : ``; @@ -1921,8 +2289,8 @@ const Conn = { const p = S.data.connections.phone; return `
Phone pushes use ntfy, a free app for Android and iPhone. Install it, subscribe to a topic, and put the same topic here. Anyone who knows the topic can read it, so make it hard to guess.
-
-
+
${secretField('p-topic', `
+
`, 'Topic')}
`; @@ -1983,7 +2351,14 @@ const Conn = { return true; case 'p-random': { const f = q('[data-bind="p-topic"]'); - if (f){ f.value = 'instantclone-' + randomToken().slice(0, 12); f.focus(); } + if (f){ + // A new topic has to be typed into the phone app, so show it. + S.revealed.add('p-topic'); + this.render(); + const fresh = q('[data-bind="p-topic"]'); + fresh.value = 'instantclone-' + randomToken().slice(0, 12); + fresh.focus(); + } return true; } case 'p-save': @@ -2030,16 +2405,17 @@ function focusField(bind){ // ---------------------------------------------------------------- recipes const Recipes = { - preview:null, text:'', + preview:null, text:'', back:null, resume(){ Modal.open('import', 700); + Modal.back = this.back; this.render(); }, openImport(){ this.preview = null; this.text = ''; - Modal.open('import', 700); - this.render(); + this.back = Modal.kind === 'catalog' ? {title:'Back to the catalog', go:() => Catalog.reopen()} : null; + this.resume(); focusField('r-text'); }, render(){ @@ -2090,13 +2466,13 @@ const Recipes = { if (a === 's-make'){ await busy(el, () => this.share()); return true; } return false; }, - openShare(){ + openShare(only){ Modal.open('share', 700); Modal.body(`${modalHead('copy', 'var(--accent)', 'Share as a recipe', 'Pick what to share. Your connections, keys and secret links are never included.')}
${S.data.integrations.map(i => - ``).join('')}
+ ``).join('')}
@@ -2125,12 +2501,25 @@ async function onClick(e){ if (!el || el.disabled) return; const a = el.dataset.act; if (el.tagName === 'A') e.preventDefault(); + if (el.closest('.ig-menu') && a !== 'menu-delete') Menu.close(); const owner = el.closest('.ig-modal') && OWNERS[Modal.kind]; if (owner && await owner.act(a, el)) return; switch (a){ case 'modal-close': Modal.close(); break; + case 'modal-back': Modal.goBack(); break; case 'guard-keep': Modal.hideGuard(); break; - case 'guard-discard': Modal.close(true); break; + case 'guard-discard': + if (Modal.pending === 'back' && Modal.back) Modal.leaveBack(); else Modal.close(true); + break; + case 'menu': Menu.toggle(el, el.dataset.id); break; + case 'menu-delete': + if (armConfirm(el, 'Click again to delete')){ Menu.close(); remove(el.dataset.id); } + break; + case 'share-one': Recipes.openShare(el.dataset.id); break; + case 'reveal': + if (S.revealed.has(el.dataset.field)) S.revealed.delete(el.dataset.field); else S.revealed.add(el.dataset.field); + if (OWNERS[Modal.kind] && OWNERS[Modal.kind].render) OWNERS[Modal.kind].render(); + break; case 'guard-save': { const save = q('.dest-form-foot [data-act="save"]'); Modal.hideGuard(); From cfb3f972d0eaf46dc1725efddfd89e7be5aea362 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Thu, 1 Oct 2026 16:04:53 +0200 Subject: [PATCH 42/55] feat(integrations): pick how an integration starts, visually - A new integration (and "Another trigger") opens on "How does it start?": five big tiles with what each kind is and an example, instead of a dropdown that quietly preselected "OBS crashes". Saving or testing before picking shows the question again. - "Something happens" continues to "What happens?": every event as a tile with an icon and one line on what it means, grouped like before. An existing event trigger shows its event as a card with Change, which opens the same tiles and can go back without changing anything. - The inspector's trigger kind is tiles too; picking a chat command or a timer focuses its first field with the placeholder selected. - Variable chips show a card on hover or focus: what the variable holds, an example value and "Click to insert". Descriptions cover every built-in variable plus web answers, counters and remembered values. --- web/index.html | 75 ++++++++++++ web/integrations.js | 273 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 329 insertions(+), 19 deletions(-) diff --git a/web/index.html b/web/index.html index 52533b2..83683fc 100644 --- a/web/index.html +++ b/web/index.html @@ -2575,6 +2575,81 @@ .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} +/* The card over a variable chip. */ +.ig-vtip{position:fixed;z-index:2100;width:max-content;max-width:260px;padding:10px 12px;border-radius:10px; + background:var(--bg-2);border:1px solid var(--line-2);box-shadow:0 14px 34px -12px rgba(0,0,0,.75); + display:flex;flex-direction:column;gap:6px;pointer-events:none;opacity:0;transform:translateY(4px); + transition:opacity .14s var(--ease-out),transform .14s var(--ease-out)} +.ig-vtip.below{transform:translateY(-4px)} +.ig-vtip.on{opacity:1;transform:none} +.ig-vtip code{font:600 12px/1.4 'JetBrains Mono',monospace;color:color-mix(in oklch,var(--accent) 70%,#fff)} +.ig-vtip p{margin:0;font-size:12.5px;line-height:1.45;color:var(--fg-2);text-wrap:pretty} +.ig-vtip-eg{display:flex;align-items:baseline;gap:6px;min-width:0;font-size:12px} +.ig-vtip-eg span{color:var(--fg-4);flex:none} +.ig-vtip-eg b{min-width:0;padding:1px 6px;border-radius:5px;background:var(--surface-3);color:var(--fg);font:500 11.5px/1.5 'JetBrains Mono',monospace; + overflow-wrap:anywhere} +.ig-vtip small{font-size:11px;color:var(--fg-4)} +@media (prefers-reduced-motion: reduce){.ig-vtip,.ig-vtip.below{transform:none;transition:opacity .1s linear}} +/* "How does it start?": the first decision of a new integration. */ +.ig-bld.picking .ig-palette{opacity:.4;pointer-events:none;transition:opacity .2s var(--ease-out)} +.ig-chooser{display:flex;flex-direction:column;gap:16px;padding:6px 2px 10px} +.ig-chooser-head h3{margin:0;font-size:18px;font-weight:650;letter-spacing:-.01em;color:var(--fg);text-wrap:balance} +.ig-chooser-head p{margin:4px 0 0;font-size:13px;color:var(--fg-3)} +.ig-chooser-back{display:inline-flex;align-items:center;gap:6px;margin:0 0 10px -6px;padding:5px 10px 5px 6px;border:0;border-radius:8px; + background:transparent;color:var(--fg-3);font:inherit;font-size:12.5px;cursor:pointer; + transition:background-color .15s var(--ease-out),color .15s var(--ease-out)} +.ig-chooser-back:hover{background:var(--surface-3);color:var(--fg)} +.ig-chooser-back svg{width:14px;height:14px} +.ig-kinds{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px} +.ig-kind{display:flex;flex-direction:column;align-items:flex-start;gap:4px;padding:16px;border-radius:14px;border:1px solid var(--line); + background:var(--bg-2);color:var(--fg-2);font:inherit;text-align:left;cursor:pointer; + animation:igTileIn .3s var(--ease-out) both;animation-delay:calc(var(--i,0) * 35ms); + transition:border-color .2s var(--ease-out),box-shadow .2s var(--ease-out),transform .2s var(--ease-out)} +.ig-kind:hover{border-color:color-mix(in oklch,var(--accent) 45%,var(--line-2));transform:translateY(-2px); + box-shadow:0 18px 40px -24px color-mix(in oklch,var(--accent) 80%,transparent)} +.ig-kind:active{transform:scale(.96)} +.ig-kind:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.ig-kind-ic{width:36px;height:36px;margin-bottom:6px;border-radius:10px;display:grid;place-items:center; + background:color-mix(in oklch,var(--accent) 14%,var(--surface-3));color:var(--accent)} +.ig-kind-ic svg{width:18px;height:18px} +.ig-kind b{font-size:14px;font-weight:600;color:var(--fg)} +.ig-kind small{font-size:12px;line-height:1.45;color:var(--fg-3);text-wrap:pretty} +.ig-kind-eg{margin-top:6px;padding:4px 8px;border-radius:7px;background:var(--bg-1);font-size:11.5px;color:var(--fg-3);text-wrap:pretty} +.ig-evgroup{display:flex;flex-direction:column;gap:8px} +.ig-evs{display:flex;flex-direction:column;gap:6px} +.ig-evs.big{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:8px} +.ig-ev,.ig-evcurrent{display:flex;align-items:center;gap:10px;width:100%;padding:10px 12px;border-radius:12px;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),transform .15s var(--ease-out)} +.ig-evs.big .ig-ev{animation:igTileIn .3s var(--ease-out) both;animation-delay:calc(min(var(--i,0),12) * 22ms)} +.ig-ev:hover,.ig-evcurrent:hover{border-color:color-mix(in oklch,var(--accent) 45%,var(--line-2))} +.ig-ev:active,.ig-evcurrent:active{transform:scale(.98)} +.ig-ev:focus-visible,.ig-evcurrent:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.ig-ev.on{border-color:color-mix(in oklch,var(--accent) 60%,transparent);background:color-mix(in oklch,var(--accent) 10%,var(--bg-2))} +.ig-ev-ic{width:30px;height:30px;flex:none;border-radius:9px;display:grid;place-items:center; + background:color-mix(in oklch,var(--accent) 12%,var(--surface-3));color:var(--accent)} +.ig-ev-ic svg{width:15px;height:15px} +.ig-ev-t{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +.ig-ev-t b{font-size:13px;font-weight:600;color:var(--fg)} +.ig-ev-t small{font-size:11.5px;line-height:1.4;color:var(--fg-3);text-wrap:pretty} +.ig-ev-on{color:var(--accent);flex:none} +.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 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} diff --git a/web/integrations.js b/web/integrations.js index 6be2a92..b253d8f 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -154,6 +154,101 @@ function lowerFirst(s){ return /^[A-Z][a-z]/.test(s || '') ? s[0].toLowerCase() + s.slice(1) : s; } +// What each variable holds, for the card shown over a variable chip. +const VAR_HELP = { + reason:'Why it happened. For OBS: crash or freeze. For a destination: what went wrong.', + hold:'How long the reconnect screen can stay on air.', + down_for:'How long OBS was gone.', + destination:'The destination\'s name.', + platform:'The destination\'s platform, like youtube or twitch.', + previous:'The delay before this change.', + stopped:'yes when you stopped the stream yourself, no when OBS dropped.', + protected:'yes when crash protection kept your destinations up.', + delay:'The delay, written for people. On delay events, the new one.', + delay_ms:'The delay in milliseconds, for maths and comparisons.', + delay_state:'on or off.', + phase:'What the delay is doing: idle, preparing, ready or active.', + hold_active:'yes while the reconnect screen is on air.', + hold_left:'Time left on the reconnect screen.', + destinations_live:'How many destinations are live right now.', + destinations_total:'How many destinations are switched on.', + obs_live:'yes while OBS is sending to InstantClone.', + bitrate:'What OBS is sending, in kbps.', + channel:'Your Twitch channel.', + time:'The time now, on this PC.', + date:'Today\'s date.', + user:'The viewer\'s display name.', + user_login:'The viewer\'s login name, in lowercase.', + user_role:'The viewer\'s role: broadcaster, mod, vip, sub or viewer.', + message:'The whole chat message.', + args:'Everything typed after the command.', + arg1:'The first word after the command.', + arg2:'The second word after the command.', + arg3:'The third word after the command.', + body:'What the caller sent. If it is JSON, use {body.field} for one field.', + query:'The part of the address after the ?.', + 'clip.url':'The link to the clip the Clip step made.', + 'clip.ok':'yes when the Clip step made a clip.', +}; +// A variable's description, including the ones named by the user's own +// steps (web answers, counters, remembered values). +function varHelp(name){ + if (VAR_HELP[name]) return VAR_HELP[name]; + let m; + if ((m = /^(.+)\.json\.(.+)$/.exec(name))) return `The value at ${m[2].split('.').join(' › ')} in the "${m[1]}" web answer.`; + if ((m = /^(.+)\.status$/.exec(name))) return `The status the "${m[1]}" web request got back, like 200.`; + if ((m = /^(.+)\.ok$/.exec(name))) return `yes when the "${m[1]}" web request worked, otherwise no.`; + if ((m = /^(.+)\.body$/.exec(name))) return `Everything the "${m[1]}" web request got back.`; + if ((m = /^counter\.(.+)$/.exec(name))) return `The "${m[1]}" counter, kept between runs.`; + if ((m = /^body\.(.+)$/.exec(name))) return `The ${m[1].split('.').join(' › ')} field of what the caller sent.`; + return 'A value a Remember step set earlier in this run.'; +} + +// The card over a hovered or focused variable chip: what it holds and an +// example. One element, reused; a short delay so sweeping across chips +// doesn't flash cards. +const VarTip = { + el:null, target:null, timer:null, + want(target){ + if (target === this.target) return; + clearTimeout(this.timer); + this.target = target; + if (!target){ this.hide(); return; } + const showing = this.el && this.el.classList.contains('on'); + this.timer = setTimeout(() => this.show(target), showing ? 0 : 260); + }, + show(target){ + if (!target.isConnected) return; + if (!this.el){ + this.el = document.createElement('div'); + this.el.className = 'ig-vtip'; + this.el.setAttribute('role', 'tooltip'); + this.el.innerHTML = '

e.g.
Click to insert'; + document.body.appendChild(this.el); + } + const name = target.dataset.token, sample = target.dataset.sample || ''; + this.el.querySelector('code').textContent = '{' + name + '}'; + this.el.querySelector('p').textContent = varHelp(name); + this.el.querySelector('.ig-vtip-eg b').textContent = sample.length > 120 ? sample.slice(0, 120) + '…' : (sample || '(empty)'); + const r = target.getBoundingClientRect(), w = this.el.offsetWidth, h = this.el.offsetHeight; + const above = r.top - h - 8 >= 8; + this.el.style.left = Math.max(8, Math.min(r.left + r.width / 2 - w / 2, innerWidth - w - 8)) + 'px'; + this.el.style.top = (above ? r.top - h - 8 : r.bottom + 8) + 'px'; + this.el.classList.toggle('below', !above); + this.el.classList.add('on'); + }, + hide(){ + clearTimeout(this.timer); + this.target = null; + if (this.el) this.el.classList.remove('on'); + }, +}; +const tokenAt = e => (e.target && e.target.closest) ? e.target.closest('.ig-modal .ig-token[data-token]') : null; +document.addEventListener('pointerover', e => VarTip.want(tokenAt(e))); +document.addEventListener('focusin', e => VarTip.want(tokenAt(e))); +document.addEventListener('pointerdown', () => VarTip.hide(), true); +window.addEventListener('scroll', () => VarTip.hide(), true); + // Sample values for previews: the variables this handler can use. function varsFor(handler){ const d = S.data; @@ -1143,6 +1238,7 @@ const Modal = { this.el = this.form = this.opener = this.swapFrom = null; // Shown secrets hide again: the next time could be on stream. S.revealed.clear(); + VarTip.hide(); document.removeEventListener('keydown', onModalKey); el.classList.add('closing'); el.inert = true; @@ -1473,7 +1569,7 @@ const Editor = { const common = new Set(['delay', 'delay_state', 'hold_left', 'time']); const globals = new Set(S.data.vars.global.map(v => v.name)); const shown = this.moreVars ? all : all.filter(v => !globals.has(v.name) || common.has(v.name)); - const tokens = shown.map(v => ``).join('') + const tokens = shown.map(v => ``).join('') + (shown.length < all.length ? `` : ''); const sample = sampleLine(value, h); const editor = ` @@ -1731,7 +1827,47 @@ const PALETTE = [ ['Stream', [['delay_action', 'Delay action'], ['marker', 'VOD marker'], ['clip', 'Clip']]], ['Anything', [['program', 'Run a program'], ['file', 'Write a file'], ['set_var', 'Remember a value'], ['counter', 'Counter']]], ]; -const TRIGGERS = [['event', 'An InstantClone event'], ['chat_command', 'A chat command'], ['chat_message', 'A chat message'], ['timer', 'Every few minutes'], ['webhook', 'A web call (URL)']]; +// What can start an integration, shown as tiles so every kind is in sight. +const TRIGGERS = [ + {id:'event', icon:'bolt', label:'Something happens', hint:'OBS crashes, a destination drops, the delay changes…', + eg:'OBS crashes: tell the mods on Discord'}, + {id:'chat_command', icon:'hash', label:'A chat command', hint:'!rank, !delay, anything', chat:true, + eg:'!rank answers with your rank'}, + {id:'chat_message', icon:'bubble', label:'A chat message', hint:'When chat says something', chat:true, + eg:'Someone says "gg": thank them'}, + {id:'timer', icon:'clock', label:'On a timer', hint:'Every few minutes', eg:'Every 15 min: remind chat to follow'}, + {id:'webhook', icon:'link', label:'A web call', hint:'Stream Deck, scripts, other apps', eg:'A Stream Deck button cuts the delay'}, +]; +// How each event looks when picking one. +const EVENT_INFO = { + obs_connected:{icon:'play', desc:'OBS starts sending to InstantClone.'}, + obs_disconnected:{icon:'signal', desc:'OBS stops sending, on purpose or not.'}, + eb_detected:{icon:'film', desc:'OBS starts Twitch Enhanced Broadcasting.'}, + hold_opened:{icon:'shield', desc:'OBS dropped; the reconnect screen holds your stream.'}, + obs_back:{icon:'check', desc:'OBS reconnects and the stream carries on.'}, + hold_expired:{icon:'clock', desc:'OBS didn\'t come back in time.'}, + hold_ended:{icon:'cut', desc:'You end the reconnect screen yourself.'}, + destination_live:{icon:'megaphone', desc:'A destination starts showing your stream.'}, + destination_dropped:{icon:'warn', desc:'A destination loses your stream.'}, + all_destinations_down:{icon:'signal', desc:'No destination is live any more.'}, + delay_on:{icon:'clock', desc:'The delay starts.'}, + delay_off:{icon:'clock', desc:'The delay stops: viewers see you live.'}, + delay_changed:{icon:'clock', desc:'The delay gets longer or shorter.'}, +}; +// Every event as a tile, grouped; `big` for the canvas chooser. +function eventTilesHtml(selected, big){ + const groups = {}; + S.data.events.forEach(e => { (groups[e.group] = groups[e.group] || []).push(e); }); + let n = 0; + return Object.entries(groups).map(([g, list]) => `
${esc(g)}
+
${list.map(e => { + const info = EVENT_INFO[e.id] || {icon:'bolt', desc:''}; + const on = e.id === selected; + return ``; + }).join('')}
`).join(''); +} const pathKey = p => p.join('.'); const Builder = { @@ -1747,6 +1883,12 @@ const Builder = { this.tries = new Map(); this.d = i ? withHandler(clone(i)) : {id:'', name:'My integration', enabled:true, preset:'', cooldown_ms:0, handlers:[{enabled:true, trigger:{type:'event', event:'hold_opened', filters:{}}, steps:[]}]}; + // A trigger nobody picked yet: the canvas asks how it starts instead of + // quietly assuming "OBS crashes". + this.unpicked = new WeakSet(); + if (!i) this.unpicked.add(this.d.handlers[0]); + this.choosing = i ? null : 'kind'; + this.cancelTrigger = null; this.dirty = !!opts.dirty || (!!opts.switchOn && !this.d.enabled); if (opts.switchOn) this.d.enabled = true; this.h = 0; @@ -1773,7 +1915,7 @@ const Builder = { Modal.body(`${modalHead('steps', 'var(--accent)', titleInput(d.name), `Your own · runs on this PC · ${plural(n, 'step')}`, `${enableSwitch('enabled', d.enabled)}`)} -
+
${d.handlers.map((x, i) => ``).join('')} + ${this.unpicked.has(x) ? 'New trigger' : 'When ' + esc(lowerFirst(triggerLabel(x.trigger)))}`).join('')}
-
+ ${this.choosing ? this.chooserHtml(h) : `
${this.stepsHtml(h.steps, [], 'root')} @@ -1791,12 +1933,30 @@ const Builder = { ${this.selectedHttp() ? `
${this.tryHtml(this.selectedHttp())}
` : ''} ${this.run ? `
Test run: ${esc(this.run.status || 'error')}
${this.run.error ? warnHtml(esc(this.run.error)) : this.run.status === 'running' - ? '
Running the test…
' : runLogHtml(this.run.steps)}
` : ''} + ? '
Running the test…
' : runLogHtml(this.run.steps)}
` : ''}`}
${footHtml(d, this.dirty)}`); }, + // "How does it start?" then, for events, "What happens?": big tiles in + // the canvas, so the first decision is visible and can't be skipped. + chooserHtml(h){ + const picked = !this.unpicked.has(h); + if (this.choosing === 'event'){ + return `
+
+ +

What happens?

Pick the moment it reacts to.

+ ${eventTilesHtml(picked ? h.trigger.event : '', true)}
`; + } + const noChat = !S.data.twitch.main.login; + return `
+

How does it start?

Pick what sets it off. You can change it any time.

+
${TRIGGERS.map((k, n) => ``).join('')}
`; + }, // A different selection is a different panel (fresh fields, crossfade). inspectorKey(){ const s = this.sel === 'trigger' ? null : this.stepAt(this.sel); @@ -1849,9 +2009,13 @@ const Builder = { }, inspectorHtml(){ const h = this.handler(); - const tokens = varsFor(h).map(v => ``).join(''); + const tokens = varsFor(h).map(v => ``).join(''); const varsBox = `
Variables here
${tokens}
Click one to insert it in the field you last used. {name|text} uses the text when the value is empty or 0.
`; + if (this.choosing){ + return `
WHEN${this.choosing === 'event' ? 'What happens?' : 'How does it start?'}
+

Pick it in the middle. Then add steps from the list on the left: each one runs in order.

`; + } if (this.sel === 'trigger') return this.triggerInspector(h) + varsBox; const s = this.stepAt(this.sel); if (!s) return varsBox; @@ -1905,13 +2069,16 @@ const Builder = { }, triggerInspector(h){ const t = h.trigger; - const typeSel = `
`; + const noChat = !S.data.twitch.main.login; + const typeSel = `
${TRIGGERS.map(k => + ``).join('')}
`; let body = ''; if (t.type === 'event'){ - const groups = {}; - S.data.events.forEach(e => { (groups[e.group] = groups[e.group] || []).push(e); }); - body = `
`; + const current = eventOf(t.event), info = EVENT_INFO[t.event] || {icon:'bolt', desc:''}; + body = `
`; const e = eventOf(t.event); (e ? e.vars : []).filter(v => FILTER_CHOICES[v.name] || v.name === 'destination').forEach(v => { const cur = (t.filters || {})[v.name] || ''; @@ -1952,8 +2119,6 @@ const Builder = { else if (b === 'enabled') this.d.enabled = el.checked; else if (b === 'b-cooldown') this.d.cooldown_ms = Math.max(0, (parseFloat(el.value) || 0) * 1000); else if (b === 'h-enabled') h.enabled = el.checked; - else if (b === 't-type') h.trigger = newTrigger(el.value); - else if (b === 't-event'){ t.event = el.value; t.filters = {}; } else if (b === 't-filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); } else if (b === 't-command') t.command = el.value.trim().toLowerCase(); else if (b === 't-aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); @@ -2023,29 +2188,99 @@ const Builder = { this.edited(); return true; } - case 'b-handler': this.h = +el.dataset.n; this.sel = 'trigger'; this.target = []; this.run = null; this.render(); return true; - case 'b-add-handler': - d.handlers.push({enabled:true, trigger:newTrigger('event'), steps:[]}); + case 'b-handler': + this.h = +el.dataset.n; this.sel = 'trigger'; this.target = []; this.run = null; + this.choosing = this.unpicked.has(this.handler()) ? 'kind' : null; + this.render(); + return true; + case 'b-add-handler': { + const added = {enabled:true, trigger:newTrigger('event'), steps:[]}; + d.handlers.push(added); + this.unpicked.add(added); + this.choosing = 'kind'; this.h = d.handlers.length - 1; this.sel = 'trigger'; this.target = []; this.run = null; this.edited(); return true; + } case 'b-del-handler': if (d.handlers.length < 2 || !armConfirm(el, 'Remove it and its steps?')) return true; d.handlers.splice(this.h, 1); this.h = 0; this.sel = 'trigger'; this.target = []; this.run = null; this.edited(); return true; + case 'b-trigger-type': { + const h = this.handler(), v = el.dataset.v; + if (v === 'event'){ + // Which event is the next question, asked in the canvas. + this.cancelTrigger = this.unpicked.has(h) ? null : clone(h.trigger); + if (h.trigger.type !== 'event') h.trigger = newTrigger('event'); + this.choosing = 'event'; + this.render(); + return true; + } + if (h.trigger.type !== v || this.unpicked.has(h)) h.trigger = newTrigger(v); + this.unpicked.delete(h); + this.choosing = null; + this.sel = 'trigger'; + this.edited(); + const first = {chat_command:'t-command', chat_message:'t-pattern', timer:'t-every'}[v]; + if (first){ + // The starting value is a placeholder: typing replaces it. + focusField(first); + const f = q(`[data-bind="${first}"]`); + if (f) f.select(); + } + return true; + } + case 'b-change-event': + this.cancelTrigger = clone(this.handler().trigger); + this.choosing = 'event'; + this.render(); + return true; + case 'b-event': { + const h = this.handler(); + h.trigger = {type:'event', event:el.dataset.v, filters:{}}; + this.unpicked.delete(h); + this.choosing = null; + this.cancelTrigger = null; + this.sel = 'trigger'; + this.edited(); + return true; + } + case 'b-pick-back': { + const h = this.handler(); + if (this.unpicked.has(h)) this.choosing = 'kind'; + else { + if (this.cancelTrigger) h.trigger = this.cancelTrigger; + this.cancelTrigger = null; + this.choosing = null; + } + this.render(); + return true; + } case 'b-new-token': this.handler().trigger.token = randomToken(); this.edited(); return true; case 'b-token': insertAtCursor(this.lastField, '{' + el.dataset.token + '}'); return true; - case 'b-test': busy(el, () => this.test()); return true; + case 'b-test': if (this.askToPick()) busy(el, () => this.test()); return true; case 'b-try': busy(el, () => this.tryRequest()); return true; case 'b-pick': this.pick(el.dataset.token, el.dataset.sample || ''); return true; case 'b-use-reply': this.replyWithPick(); return true; case 'delete': if (armConfirm(el, 'Delete for good?')) busy(el, () => remove(d.id)); return true; - case 'save': busy(el, () => saveIntegration(d, () => { this.dirty = false; })); return true; + case 'save': if (this.askToPick()) busy(el, () => saveIntegration(d, () => { this.dirty = false; })); return true; } return false; }, + // A trigger still waiting for "How does it start?" can't be saved or run. + // Shows it and says so; true when every trigger is picked. + askToPick(){ + const at = this.d.handlers.findIndex(x => this.unpicked.has(x)); + if (at < 0) return true; + this.h = at; + this.choosing = 'kind'; + this.sel = 'trigger'; + this.render(); + toast('Pick how it starts first', 'info'); + return false; + }, // The selected step, when it is a web request (it gets "Try it"). selectedHttp(){ const s = Array.isArray(this.sel) ? this.stepAt(this.sel) : null; From 0597416c675a674af277f98893a8502b5c9fe85d Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Thu, 1 Oct 2026 16:16:15 +0200 Subject: [PATCH 43/55] feat(integrations): premium motion details - Secret fields decode letter by letter when shown: each character scrambles briefly in the accent colour, then settles, one after another; hiding folds them back into dots from the end. Drawn on a layer over the field with one animation frame loop that writes only what changed; typing ends it at once. A new random ntfy topic decodes in the same way. - The Show/Hide eye swaps icons with the scale, opacity and blur cross-fade. - Counts and chip values roll in when they change (up when a number grew, down when it shrank), from morph's text patch, on data-tick elements only. - Undo toasts drain a thin countdown bar; pointing at one holds both the bar and the timer. - Save shows "Saved" with its check popping in for a beat before the editor closes. - A card that switches on gives one soft pulse in its own colour. - Menu items, test run rows and a request's answer tree ease in, staggered; a new answer is a new tree, so it eases in again. - All of it is skipped under prefers-reduced-motion. --- web/index.html | 28 +++++++ web/integrations.js | 188 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 189 insertions(+), 27 deletions(-) diff --git a/web/index.html b/web/index.html index 83683fc..fc8c763 100644 --- a/web/index.html +++ b/web/index.html @@ -2575,6 +2575,34 @@ .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} +/* Motion details. */ +.ig-pane [data-tick],.ig-modal [data-tick]{display:inline-block} +.ig-secret-field{position:relative} +.ig-decode{position:absolute;z-index:1;box-sizing:border-box;border-style:solid;border-color:transparent;pointer-events:none; + overflow:hidden;display:flex;align-items:center;white-space:pre;color:var(--fg)} +.ig-decode.area{display:block;white-space:pre-wrap;overflow-wrap:anywhere} +.ig-decode span{transition:color .2s var(--ease-out)} +.ig-decode .s0{color:var(--fg-3)} +.ig-decode .s1{color:var(--accent)} +.ig-secret.ig-decoding{color:transparent;-webkit-text-fill-color:transparent} +.ig-eye{position:relative;display:inline-grid;width:12px;height:12px} +.ig-eye svg{grid-area:1/1;width:12px;height:12px;transition:opacity .2s cubic-bezier(.2,0,0,1),transform .2s cubic-bezier(.2,0,0,1),filter .2s cubic-bezier(.2,0,0,1)} +.ig-secret-field:not(.shown) .ig-eye svg:last-child,.ig-secret-field.shown .ig-eye svg:first-child{opacity:0;transform:scale(.25);filter:blur(4px)} +.ic-toast.ig-undo{position:relative;overflow:hidden} +.ig-undo-bar{position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--accent);opacity:.6;transform-origin:left; + animation:igDrain linear forwards} +.ig-undo:hover .ig-undo-bar{animation-play-state:paused} +@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-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-undo-bar{animation:none;display:none} + .ig-eye svg{transition:none} +} /* The card over a variable chip. */ .ig-vtip{position:fixed;z-index:2100;width:max-content;max-width:260px;padding:10px 12px;border-radius:10px; background:var(--bg-2);border:1px solid var(--line-2);box-shadow:0 14px 34px -12px rgba(0,0,0,.75); diff --git a/web/integrations.js b/web/integrations.js index b253d8f..214c216 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -62,6 +62,7 @@ const PATHS = { back:'M15 18l-6-6 6-6', dots:'M5 12h.01M12 12h.01M19 12h.01', eye:'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', + eyeOff:'M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.9 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3 3.9M6.6 6.6C3.8 8.4 2 12 2 12s3.6 7 10 7c1.9 0 3.6-.6 5-1.5', }; const svg = (name, extra) => ``; @@ -418,7 +419,11 @@ function morphChildren(parent, src, ctx){ function patch(a, b, ctx){ if (a.nodeType !== 1){ - if (a.nodeValue !== b.nodeValue) a.nodeValue = b.nodeValue; + if (a.nodeValue !== b.nodeValue){ + const was = a.nodeValue; + a.nodeValue = b.nodeValue; + if (ctx.before) tick(a.parentElement, was, b.nodeValue); + } return; } const tag = a.tagName; @@ -428,6 +433,7 @@ function patch(a, b, ctx){ const oldSelected = tag === 'SELECT' ? selectedOf(a) : null; syncAttrs(a, b); if (busyEls.has(a)){ a.classList.add('is-busy'); a.disabled = true; a.setAttribute('aria-busy', 'true'); } + if (decoders.has(a)) a.classList.add('ig-decoding'); if (tag === 'INPUT'){ if (a.type === 'checkbox' || a.type === 'radio'){ if (b.hasAttribute('checked') !== oldChecked) a.checked = b.hasAttribute('checked'); @@ -551,6 +557,94 @@ function placeIndicators(root){ }); } +// ---------------------------------------------------------------- motion details + +// A changed number or short value rolls in instead of snapping: up when a +// number grew, down when it shrank. Only for elements marked data-tick. +function tick(el, was, now){ + if (!el || !el.hasAttribute('data-tick')) return; + const a = parseFloat(was), b = parseFloat(now); + const from = !isNaN(a) && !isNaN(b) && b < a ? '-45%' : '45%'; + el.animate([ + {transform:`translateY(${from})`, opacity:0, filter:'blur(2px)'}, + {transform:'none', opacity:1, filter:'blur(0)'}, + ], {duration:260, easing:EASE}); +} + +// A secret field decodes letter by letter when shown and folds back into +// dots, from the end, when hidden. Each letter scrambles for a moment, then +// settles. It is drawn on a layer over the field (which morph owns and +// leaves untouched); one animation frame loop, writing only what changed, +// and typing into the field ends it at once. +const SCRAMBLE = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789#%&*+=?'; +const DECODE_MAX = 300; // longer values just switch +const decoders = new WeakMap(); // field -> stop() + +function decodeField(control, reveal){ + if (!control || reduced()) return; + const host = control.closest('.ig-secret-field'); + const chars = [...(control.value || '')]; + if (!host || !chars.length || chars.length > DECODE_MAX) return; + if (decoders.has(control)) decoders.get(control)(); + const layer = decodeLayer(control, host); + const cells = []; + for (const ch of chars){ + if (ch === '\n'){ layer.appendChild(document.createElement('br')); continue; } + const span = layer.appendChild(document.createElement('span')); + cells.push({span, ch, state:-1}); + } + layer.scrollTop = control.scrollTop; + layer.scrollLeft = control.scrollLeft; + const n = cells.length; + const step = Math.min(24, 480 / n), settle = 130; + const total = (n - 1) * step + settle; + const started = performance.now(); + let frame = 0; + const stop = () => { + cancelAnimationFrame(frame); + control.removeEventListener('input', stop); + control.classList.remove('ig-decoding'); + decoders.delete(control); + layer.remove(); + }; + const draw = now => { + const t = now - started; + // Scrambled letters change 20 times a second, not every frame. + const beat = Math.floor(t / 50); + cells.forEach((c, i) => { + const at = (reveal ? i : n - 1 - i) * step; + const state = t < at ? (reveal ? 0 : 2) : t < at + settle ? 1 : (reveal ? 2 : 0); + const text = state === 0 ? '•' : state === 2 ? c.ch : SCRAMBLE[(i * 7 + beat * 13) % SCRAMBLE.length]; + if (c.span.textContent !== text) c.span.textContent = text; + if (c.state !== state){ c.state = state; c.span.className = 's' + state; } + }); + if (t < total) frame = requestAnimationFrame(draw); else stop(); + }; + control.classList.add('ig-decoding'); + control.addEventListener('input', stop); + decoders.set(control, stop); + frame = requestAnimationFrame(draw); +} + +// The layer the decode draws on: the field's exact box and type metrics, +// so letters land where the field's own letters are. +function decodeLayer(control, host){ + const cs = getComputedStyle(control); + const box = control.getBoundingClientRect(), at = host.getBoundingClientRect(); + const layer = document.createElement('div'); + layer.className = 'ig-decode' + (control.tagName === 'TEXTAREA' ? ' area' : ''); + layer.setAttribute('aria-hidden', 'true'); + layer.setAttribute('data-persist', ''); + Object.assign(layer.style, { + left:(box.left - at.left) + 'px', top:(box.top - at.top) + 'px', width:box.width + 'px', height:box.height + 'px', + }); + for (const prop of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', + 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth']){ + layer.style[prop] = cs[prop]; + } + return host.appendChild(layer); +} + // Spinner on a button while `fn` runs; a second click is ignored. async function busy(btn, fn){ if (!btn || busyEls.has(btn)) return undefined; @@ -598,7 +692,8 @@ function undoToast(text, undo){ if (!rail){ toast(text, 'ok'); return; } const el = document.createElement('div'); el.className = 'ic-toast ok ig-undo'; - el.innerHTML = `✓`; + el.innerHTML = `✓ + `; el.querySelector('.ig-undo-text').textContent = text; let used = false; const leave = () => { @@ -613,15 +708,20 @@ function undoToast(text, undo){ await undo(); }); rail.appendChild(el); - setTimeout(leave, 8000); + // Pointing at it holds the countdown (the bar pauses in CSS too). + let left = UNDO_MS, since = Date.now(), timer = setTimeout(leave, left); + el.addEventListener('pointerenter', () => { clearTimeout(timer); left -= Date.now() - since; }); + el.addEventListener('pointerleave', () => { since = Date.now(); timer = setTimeout(leave, Math.max(left, 600)); }); } +const UNDO_MS = 8000; // A secret field (topic, webhook link, headers, secret address): hidden // like a password until the user asks to see it. function secretField(id, control, label){ const shown = S.revealed.has(id); - return `
${control}
`; + return `
${control}
`; } function parseJson(text){ @@ -648,16 +748,18 @@ function jsonLeaves(root){ // The answer as a tree; every value is a button that picks its variable. function jsonTreeHtml(root, saveAs){ let budget = 400; - const node = (v, path, key, depth) => { + const node = (v, path, key, depth, index) => { if (budget-- <= 0) return ''; const label = key === null ? '' : `${esc(key)}`; + // The first level eases in one row after another. + const order = depth === 1 ? ` style="--i:${index}"` : ''; if (v !== null && typeof v === 'object'){ const all = Array.isArray(v) ? v.length : Object.keys(v).length; const entries = Array.isArray(v) ? v.slice(0, 25).map((x, i) => [i, x]) : Object.entries(v).slice(0, 60); - const kids = entries.map(([k, x]) => node(x, path.concat(k), String(k), depth + 1)).join('') + const kids = entries.map(([k, x], i) => node(x, path.concat(k), String(k), depth + 1, i)).join('') + (all > entries.length ? `
… ${all - entries.length} more
` : ''); if (key === null) return kids || '
(empty)
'; - return `
${svg('chev')}${label}${Array.isArray(v) ? `[${all}]` : `{${all}}`}
${kids}
`; + return `
${svg('chev')}${label}${Array.isArray(v) ? `[${all}]` : `{${all}}`}
${kids}
`; } const name = `${saveAs}.json.${path.join('.')}`; const usable = path.every(k => /^[A-Za-z0-9_]+$/.test(String(k))) && name.length <= 64; @@ -665,10 +767,10 @@ function jsonTreeHtml(root, saveAs){ const shown = typeof v === 'string' ? `"${text.length > 80 ? text.slice(0, 80) + '…' : text}"` : text; const kind = v === null ? 'null' : typeof v; return usable - ? `` - : `
${label}${esc(shown)}
`; + ? `` + : `
${label}${esc(shown)}
`; }; - return node(root, [], null, 0); + return node(root, [], null, 0, 0); } // The "⋯" menu of a card or row: everything you can do to it, Delete @@ -686,11 +788,11 @@ const Menu = { const el = document.createElement('div'); el.className = 'ig-menu'; el.setAttribute('role', 'menu'); - el.innerHTML = ` - - + el.innerHTML = ` + + - `; + `; document.body.appendChild(el); el.addEventListener('click', onClick); el.addEventListener('keydown', e => this.keys(e)); @@ -903,7 +1005,7 @@ function render(){ const failing = d.integrations.filter(i => cardInfo(i).failing); const empty = d.integrations.length === 0; const sub = empty ? 'Alerts, chat commands and automations. Each card shows exactly what it sends.' - : `${on} on${failing.length ? ` · ${failing.length} need${failing.length === 1 ? 's' : ''} attention` : ''}. Each card shows exactly what it sends.`; + : `${on} on${failing.length ? ` · ${failing.length} need${failing.length === 1 ? 's' : ''} attention` : ''}. Each card shows exactly what it sends.`; morph(root, `
Integrations
${sub}
@@ -958,7 +1060,7 @@ function toolbarHtml(){ const counts = {all:S.data.integrations.length}; S.data.integrations.forEach(i => { const c = categoryOf(i); counts[c] = (counts[c] || 0) + 1; }); const tab = (id, label) => ``; + data-act="filter" data-f="${id}">${label} ${counts[id] || 0}`; const view = (id, icon, label) => ``; return `
@@ -1090,6 +1192,7 @@ async function toggle(id){ // Flip at once; put it back if the app says no. i.enabled = want; render(); + if (want) glow(id); const r = await api('/integrations/toggle', {id, enabled:want}); if (r.ok) return; i.enabled = !want; @@ -1098,6 +1201,17 @@ async function toggle(id){ if (want) openEditor(i, true); } +// A card that just switched on gives one soft pulse of its colour. +function glow(id){ + const card = document.querySelector(`[data-key="c-${CSS.escape(id)}"], [data-key="r-${CSS.escape(id)}"]`); + if (!card || reduced()) return; + const color = getComputedStyle(card).getPropertyValue('--dc').trim() || 'currentColor'; + card.animate([ + {boxShadow:`0 0 0 0 color-mix(in oklch, ${color} 55%, transparent)`}, + {boxShadow:`0 0 0 10px color-mix(in oklch, ${color} 0%, transparent)`}, + ], {duration:620, easing:'cubic-bezier(.2,.7,.2,1)'}); +} + async function addFrom(body, openAfter){ const fromCatalog = Modal.kind === 'catalog'; const r = await api('/integrations/add', body); @@ -1343,7 +1457,7 @@ function footHtml(d, dirty, extra, canSave){ } function runLogHtml(steps){ if (!steps || !steps.length) return '
No steps ran.
'; - return `
${steps.map(s => `
${(s.at_ms / 1000).toFixed(2)} s + return `
${steps.map((s, i) => `
${(s.at_ms / 1000).toFixed(2)} s ${s.status === 'ok' ? '✓' : s.status === 'failed' ? '✕' : '–'} ${esc(s.label)} ${esc(s.detail)}
`).join('')}
`; } @@ -1351,6 +1465,18 @@ function warnHtml(text){ return `
⚠${text}
`; } +// The Save button turns into "Saved" with its check popping in, for a +// beat, before the editor closes. +function savedBeat(){ + const btn = q('.dest-form-foot [data-act="save"]'); + if (!btn || reduced()) return Promise.resolve(); + busyEls.delete(btn); + btn.classList.remove('is-busy'); + btn.classList.add('ig-saved'); + btn.innerHTML = `${svg('check', ' stroke-width="3"')}Saved`; + return new Promise(done => setTimeout(done, 380)); +} + async function saveIntegration(d, onSaved){ const kind = Modal.kind; const r = await api('/integrations/save', d); @@ -1361,7 +1487,10 @@ async function saveIntegration(d, onSaved){ } else if (r.warnings && r.warnings.length){ toast(`Saved. Check these names, nothing fills them: ${r.warnings.map(w => '{' + w + '}').join(', ')}`, 'info', 7000); } else toast('Saved', 'ok'); - if (Modal.kind === kind) Modal.close(true); + if (Modal.kind === kind){ + await savedBeat(); + if (Modal.kind === kind) Modal.close(true); + } await load(); return true; } @@ -1385,7 +1514,7 @@ const Catalog = { const counts = id => id === 'packs' ? cat.packs.length : cat.presets.filter(p => id === 'rec' ? p.recommended : p.category === id).length; const nav = [['rec', 'Recommended'], ['alerts', 'Alerts'], ['chat', 'Chat'], ['auto', 'Automation'], ['packs', 'Packs']] - .map(([id, label]) => ``).join(''); + .map(([id, label]) => ``).join(''); const tiles = this.cat === 'packs' ? this.packsHtml() : this.presetsHtml(); Modal.body(`${modalHead('plus', 'var(--accent)', 'Add an integration', 'Every card shows exactly what it sends. Add it, then tweak anything.')}
-
Works while you're in a game. Use either one, or both. Keys and pads that run the delay (Controls tab) stay theirs.
`; + ${refused}
Works while you're in a game. Use either one, or both. Keys and pads that run the delay (Controls tab) stay theirs.
`; } function focusAct(act){ const el = q(`[data-act="${act}"]`); @@ -2480,8 +2508,6 @@ const pathKey = p => p.join('.'); const Builder = { d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, lastField:null, back:null, - // "Send request" results per web request step (by uidOf), this session. - tries:new Map(), // opts: {dirty} carries the editor's unsaved state over, {switchOn} as in // Editor.open, {back} as in Modal.back (default: the catalog, if open). open(i, opts){ From b1e23469a63cf92fe1be8b42c5b0f0ceb929f2d5 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Fri, 2 Oct 2026 01:53:13 +0200 Subject: [PATCH 47/55] fix(integrations): Twitch refusals, late flap alerts, MIDI pads on any deck - Chat steps wait for Twitch's answer (USERSTATE or a msg_* NOTICE), so a message refused for slow mode, a duplicate or a ban fails its step with Twitch's reason instead of passing. - The flap guard keeps each destination's newest held-back event and sends it once the window ends if it changes what was last reported. - The MIDI listener also opens the devices integration pads were learned on, and every device during an integration learn; presses from those devices never reach a delay action. - The Controls MIDI learn refuses a pad an integration uses and says which; pad conflicts are checked by overlap (any-device bindings included) through model::shortcut_owner and pads_overlap. --- CHANGELOG.md | 9 +++ src/integrations/api.rs | 26 ++++--- src/integrations/engine.rs | 126 ++++++++++++++++++++++++--------- src/integrations/model.rs | 61 ++++++++++++++++ src/integrations/twitch/irc.rs | 58 ++++++++++++++- src/integrations/twitch/mod.rs | 25 ++++++- src/midi.rs | 120 +++++++++++++++++++++++++------ src/web.rs | 48 ++++++++----- web/index.html | 7 +- 9 files changed, 393 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13a1ec..c8031b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,15 @@ messages as before. uses. - A key another app holds shows "Key taken" on its card. - A knob or fader fires once per turn, not dozens of times. + - An integration's pad works on any controller, not just the one + picked in Controls, and learning one can use any controller. + - Learning a delay pad in Controls refuses a pad an integration uses, + and says which one. +- **A chat message Twitch refuses fails its step**, with Twitch's reason + (slow mode, a duplicate, a ban), instead of showing as sent. +- **A destination that drops again during the alert cooldown still gets + its "dropped" alert** once the 5 minutes are up, so "live" is never the + last word on a dead one. - **Duplicate makes a copy that starts switched off**, with its own web call link and no hotkey, so one press never runs both. - **Cooldowns only start when something ran.** A command skipped during diff --git a/src/integrations/api.rs b/src/integrations/api.rs index edfd66d..6604136 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -144,7 +144,7 @@ pub async fn route( /// What a MIDI learn for an integration's shortcut is filed under, apart /// from the delay actions' own learns. -const MIDI_LEARN: &str = "integration"; +const MIDI_LEARN: &str = crate::midi::INTEGRATION_LEARN; /// Arm a MIDI learn: the next pad pressed becomes the shortcut. The /// dashboard polls `/integrations/midi/poll` for it. @@ -152,7 +152,9 @@ fn midi_learn(ctrl: &Arc) -> Reply { if !cfg!(windows) { return fail("MIDI shortcuts work on Windows only"); } - if !ctrl.midi().connected() { + // Any device will do: the learn opens them all, not only the one + // picked in Controls. + if ctrl.midi().devices().is_empty() { return fail("no MIDI controller found; plug one in and try again"); } ctrl.midi().start_learn(MIDI_LEARN); @@ -446,19 +448,23 @@ fn shortcut_taken(integration: &Integration, s: &Settings) -> Option { let Trigger::Shortcut { hotkey, midi } = &h.trigger else { return None; }; - let owner = |entries: [(&'static str, &str); 6], wanted: &str| { - entries - .into_iter() - .find(|(_, bound)| !wanted.is_empty() && *bound == wanted) - .map(|(action, _)| action) - }; - if let Some(action) = owner(s.hotkeys.entries(), hotkey) { + let key_owner = s + .hotkeys + .entries() + .into_iter() + .find(|(_, bound)| !hotkey.is_empty() && *bound == hotkey.as_str()); + if let Some((action, _)) = key_owner { return Some(format!( "{hotkey} already runs the delay's {} action (Controls tab); pick another key", action.replace('_', " ") )); } - owner(s.midi.entries(), midi).map(|action| { + let pad_owner = s + .midi + .entries() + .into_iter() + .find(|(_, bound)| super::model::pads_overlap(bound, midi)); + pad_owner.map(|(action, _)| { format!( "that MIDI pad already runs the delay's {} action (Controls tab); pick another", action.replace('_', " ") diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index 47f8f30..b26a91e 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -19,7 +19,9 @@ use super::alerts::Board; use super::effects::RealHost; use super::event::{Event, EventKind}; -use super::model::{DiscordChannel, Integration, MatchMode, PhoneConnection, Roles, Trigger}; +use super::model::{ + pad_matches, DiscordChannel, Integration, MatchMode, PhoneConnection, Roles, Trigger, +}; use super::runner::{self, RunContext, RunEnv, RunStatus, StepLog}; use super::store::Store; use super::twitch::irc::ChatMessage; @@ -445,6 +447,9 @@ impl Engine { self.apply_settings(&snapshot); } _ = tick.tick() => { + for event in self.flap.release(Instant::now()) { + self.event(event); + } self.watch_delay(); self.run_timers(); self.save_uses_if_due(); @@ -635,7 +640,7 @@ impl Engine { (Trigger::Shortcut { hotkey, .. }, Shortcut::Hotkey) => { !hotkey.is_empty() && hotkey == signature } - (Trigger::Shortcut { midi, .. }, Shortcut::Midi) => midi_matches(midi, signature), + (Trigger::Shortcut { midi, .. }, Shortcut::Midi) => pad_matches(midi, signature), _ => false, }) .map(|(i, idx, _)| (i, idx)) @@ -879,10 +884,17 @@ impl Engine { } } -/// See `FLAP_WINDOW`. +/// See `FLAP_WINDOW`. What it holds back isn't lost: a destination whose +/// last held event differs from what was last said about it gets that event +/// once the window ends, so "live" is never the final word on a dead one. #[derive(Default)] struct FlapGuard { + /// When each event (kind and destination) last went out. last: HashMap, + /// What each destination was last reported as: live or dropped. + reported: HashMap, + /// Each destination's newest held-back event. + held: HashMap, } impl FlapGuard { @@ -891,26 +903,63 @@ impl FlapGuard { .retain(|_, t| now.duration_since(*t) < FLAP_WINDOW); } + /// The window key of an event the guard watches, and its destination. + fn key(event: &Event) -> Option<(String, Option)> { + match event.kind { + EventKind::DestinationLive | EventKind::DestinationDropped => { + let destination = event.var("destination").unwrap_or("").to_string(); + Some(( + format!("{}:{destination}", event.kind.id()), + Some(destination), + )) + } + EventKind::AllDestinationsDown => Some((event.kind.id().to_string(), None)), + _ => None, + } + } + + fn in_window(&self, key: &str, now: Instant) -> bool { + self.last + .get(key) + .is_some_and(|t| now.duration_since(*t) < FLAP_WINDOW) + } + fn suppress(&mut self, event: &Event, now: Instant) -> bool { - let key = match event.kind { - EventKind::DestinationLive | EventKind::DestinationDropped => format!( - "{}:{}", - event.kind.id(), - event.var("destination").unwrap_or("") - ), - EventKind::AllDestinationsDown => event.kind.id().to_string(), - _ => return false, + let Some((key, destination)) = Self::key(event) else { + return false; }; - if self - .last - .get(&key) - .is_some_and(|t| now.duration_since(*t) < FLAP_WINDOW) - { + if self.in_window(&key, now) { + if let Some(destination) = destination { + self.held.insert(destination, event.clone()); + } return true; } self.last.insert(key, now); + if let Some(destination) = destination { + self.reported.insert(destination.clone(), event.kind); + self.held.remove(&destination); + } false } + + /// Held events whose window is over and that still change what was + /// last reported: the destination's real state, said late, not never. + fn release(&mut self, now: Instant) -> Vec { + let due: Vec = self + .held + .iter() + .filter(|(_, event)| { + Self::key(event).is_some_and(|(key, _)| !self.in_window(&key, now)) + }) + .map(|(destination, _)| destination.clone()) + .collect(); + due.into_iter() + .filter_map(|destination| { + let event = self.held.remove(&destination)?; + (self.reported.get(&destination) != Some(&event.kind)).then_some(event) + }) + .collect() + } } fn status_id(status: &RunStatus) -> &'static str { @@ -939,13 +988,6 @@ fn make_env( } } -/// Whether a pad a trigger is bound to is the one pressed. The press always -/// names its device; a binding that names none matches that control on any -/// device (the same rule as the delay actions' MIDI bindings). -fn midi_matches(bound: &str, pressed: &str) -> bool { - !bound.is_empty() && (bound == pressed || pressed.split('@').next() == Some(bound)) -} - fn filters_match(filters: &BTreeMap, event: &Event) -> bool { filters.iter().all(|(name, want)| { want.trim().is_empty() @@ -1125,6 +1167,35 @@ mod tests { assert!(!guard.suppress(&Event::new(EventKind::HoldOpened), t0)); } + #[test] + fn a_drop_held_back_is_still_said_once_the_window_ends() { + let mut guard = FlapGuard::default(); + let t0 = Instant::now(); + let at = |s: u64| t0 + Duration::from_secs(s); + let ev = |kind| Event::new(kind).with("destination", "YouTube"); + assert!(!guard.suppress(&ev(EventKind::DestinationDropped), at(0))); + assert!(!guard.suppress(&ev(EventKind::DestinationLive), at(10))); + assert!(guard.suppress(&ev(EventKind::DestinationDropped), at(180))); + assert!(guard.release(at(200)).is_empty(), "still inside the window"); + let late = guard.release(at(301)); + assert_eq!(late.len(), 1); + assert_eq!(late[0].kind, EventKind::DestinationDropped); + assert!(guard.release(at(302)).is_empty(), "said once"); + } + + #[test] + fn a_flap_that_ends_live_says_nothing_more() { + let mut guard = FlapGuard::default(); + let t0 = Instant::now(); + let at = |s: u64| t0 + Duration::from_secs(s); + let ev = |kind| Event::new(kind).with("destination", "YouTube"); + assert!(!guard.suppress(&ev(EventKind::DestinationDropped), at(0))); + assert!(!guard.suppress(&ev(EventKind::DestinationLive), at(10))); + assert!(guard.suppress(&ev(EventKind::DestinationDropped), at(20))); + assert!(guard.suppress(&ev(EventKind::DestinationLive), at(30))); + assert!(guard.release(at(400)).is_empty(), "live was already said"); + } + #[test] fn wildcards_match_like_file_globs() { assert!(wildcard("gg wp everyone", "gg*")); @@ -1162,15 +1233,6 @@ mod tests { assert_eq!(v["user_role"], "viewer"); } - #[test] - fn pads_match_on_their_device_or_any() { - assert!(midi_matches("note:1:36@Deck A", "note:1:36@Deck A")); - assert!(!midi_matches("note:1:36@Deck A", "note:1:36@Deck B")); - assert!(midi_matches("note:1:36", "note:1:36@Deck B")); - assert!(!midi_matches("note:1:37", "note:1:36@Deck B")); - assert!(!midi_matches("", "note:1:36@Deck B")); - } - #[test] fn filters_compare_event_values() { let crash = Event::new(EventKind::HoldOpened).with("reason", "crash"); diff --git a/src/integrations/model.rs b/src/integrations/model.rs index 0581f9b..7b3dc68 100644 --- a/src/integrations/model.rs +++ b/src/integrations/model.rs @@ -439,6 +439,45 @@ pub fn shortcuts(integrations: &[Integration]) -> (Vec, Vec) { (keys, pads) } +/// Whether a pad a trigger is bound to is the one pressed. The press always +/// names its device; a binding that names none matches that control on any +/// device (the same rule as the delay actions' MIDI bindings). +pub fn pad_matches(bound: &str, pressed: &str) -> bool { + !bound.is_empty() && (bound == pressed || pressed.split('@').next() == Some(bound)) +} + +/// Whether one press could set off both pad bindings: the same control, +/// on the same device or with either one bound to any device. +pub fn pads_overlap(a: &str, b: &str) -> bool { + let (control_a, device_a) = a.split_once('@').map_or((a, None), |(c, d)| (c, Some(d))); + let (control_b, device_b) = b.split_once('@').map_or((b, None), |(c, d)| (c, Some(d))); + !a.is_empty() + && control_a == control_b + && (device_a.is_none() || device_b.is_none() || device_a == device_b) +} + +/// The switched-on integration that already starts from `hotkey` or `pad` +/// (either may be empty), by name. +pub fn shortcut_owner<'a>( + integrations: &'a [Integration], + hotkey: &str, + pad: &str, +) -> Option<&'a str> { + integrations.iter().filter(|i| i.enabled).find_map(|i| { + let owns = i + .handlers + .iter() + .filter(|h| h.enabled) + .any(|h| match &h.trigger { + Trigger::Shortcut { hotkey: key, midi } => { + (!hotkey.is_empty() && key == hotkey) || pads_overlap(midi, pad) + } + _ => false, + }); + owns.then_some(i.name.as_str()) + }) +} + pub fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= MAX_ID_LEN @@ -978,6 +1017,28 @@ mod tests { assert!(read(r#"{"from":"10:00","to":""}"#).is_err()); } + #[test] + fn pads_match_on_their_device_or_any() { + assert!(pad_matches("note:1:36@Deck A", "note:1:36@Deck A")); + assert!(!pad_matches("note:1:36@Deck A", "note:1:36@Deck B")); + assert!(pad_matches("note:1:36", "note:1:36@Deck B")); + assert!(!pad_matches("note:1:37", "note:1:36@Deck B")); + assert!(!pad_matches("", "note:1:36@Deck B")); + } + + #[test] + fn pads_overlap_when_one_press_sets_off_both() { + assert!(pads_overlap("note:1:36@Deck A", "note:1:36@Deck A")); + assert!(pads_overlap("note:1:36", "note:1:36@Deck B"), "any device"); + assert!( + pads_overlap("note:1:36@Deck B", "note:1:36"), + "either way round" + ); + assert!(!pads_overlap("note:1:36@Deck A", "note:1:36@Deck B")); + assert!(!pads_overlap("note:1:36", "cc:1:36")); + assert!(!pads_overlap("", "")); + } + #[test] fn shortcuts_need_a_real_key_or_pad() { let mut i = sample(); diff --git a/src/integrations/twitch/irc.rs b/src/integrations/twitch/irc.rs index 3822051..5f03555 100644 --- a/src/integrations/twitch/irc.rs +++ b/src/integrations/twitch/irc.rs @@ -40,10 +40,23 @@ pub struct ChatMessage { pub is_sub: bool, } -/// A line to post, optionally as a reply. +/// A line to post, optionally as a reply. `delivery` hears what became of +/// it: Twitch confirms a posted message (USERSTATE) and says why it refused +/// one (a NOTICE: slow mode, a duplicate, a ban...). pub struct Outgoing { pub text: String, pub reply_to: Option, + pub delivery: Option>, +} + +/// What became of an outgoing message, in the order it happens. +#[derive(Debug, PartialEq, Eq)] +pub enum Delivery { + /// Written to the connection, past the rate limit. + Written, + Posted, + /// Twitch refused it; its own words for why. + Refused(String), } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -131,6 +144,9 @@ async fn session( sent: &mut VecDeque, ) -> Result<(), SessionEnd> { let io = |e: std::io::Error| SessionEnd::Io(e.to_string()); + // Messages written and not yet confirmed or refused, oldest first: + // Twitch answers each in the order it was sent. + let mut awaiting: VecDeque> = VecDeque::new(); let tcp = tokio::time::timeout(Duration::from_secs(10), TcpStream::connect((HOST, PORT))) .await .map_err(|_| SessionEnd::Io("Twitch chat did not answer".to_string()))? @@ -170,6 +186,13 @@ async fn session( "001" => *state.lock() = ChatState::Connected, "NOTICE" if is_auth_failure(msg.trailing) => return Err(SessionEnd::AuthFailed), "RECONNECT" => return Ok(()), + "USERSTATE" | "NOTICE" => { + if let Some(outcome) = delivery_for(&msg) { + if let Some(waiting) = awaiting.pop_front() { + let _ = waiting.try_send(outcome); + } + } + } "PRIVMSG" => { if let (Some(tx), Some(chat)) = (incoming, chat_message(&msg)) { // A full queue drops the message: chat keeps @@ -185,6 +208,10 @@ async fn session( pace(sent).await; let line = privmsg_line(&channel, &out); writer.write_all(line.as_bytes()).await.map_err(io)?; + if let Some(delivery) = out.delivery { + let _ = delivery.try_send(Delivery::Written); + awaiting.push_back(delivery); + } } _ = stop.changed() => return Ok(()), } @@ -224,6 +251,18 @@ fn valid_msg_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') } +/// The answer to a message we sent, if this line is one: USERSTATE follows +/// a posted message, and a NOTICE with a `msg_*` id refuses one. +fn delivery_for(line: &Line) -> Option { + match line.command { + "USERSTATE" => Some(Delivery::Posted), + "NOTICE" if line.tag("msg-id").starts_with("msg_") => { + Some(Delivery::Refused(line.trailing.to_string())) + } + _ => None, + } +} + fn is_auth_failure(text: &str) -> bool { let t = text.to_ascii_lowercase(); t.contains("login authentication failed") || t.contains("improperly formatted auth") @@ -371,11 +410,27 @@ mod tests { assert!(is_auth_failure(line.trailing)); } + #[test] + fn twitch_answers_say_whether_a_message_was_posted() { + let posted = parse_line("@badges=;mod=0 :tmi.twitch.tv USERSTATE #texaz"); + assert_eq!(delivery_for(&posted), Some(Delivery::Posted)); + let slow = parse_line( + "@msg-id=msg_slowmode :tmi.twitch.tv NOTICE #texaz :This room is in slow mode.", + ); + assert_eq!( + delivery_for(&slow), + Some(Delivery::Refused("This room is in slow mode.".into())) + ); + let other = parse_line("@msg-id=host_on :tmi.twitch.tv NOTICE #texaz :Now hosting."); + assert_eq!(delivery_for(&other), None, "not about a message"); + } + #[test] fn outgoing_lines_are_single_line_and_reply_safely() { let out = Outgoing { text: "hi\r\nPRIVMSG #x :spam".into(), reply_to: Some("abc-123".into()), + delivery: None, }; assert_eq!( privmsg_line("texaz", &out), @@ -384,6 +439,7 @@ mod tests { let hostile = Outgoing { text: "x".into(), reply_to: Some("1; evil".into()), + delivery: None, }; assert_eq!(privmsg_line("texaz", &hostile), "PRIVMSG #texaz :x\r\n"); } diff --git a/src/integrations/twitch/mod.rs b/src/integrations/twitch/mod.rs index c01d7f5..ad0e6d0 100644 --- a/src/integrations/twitch/mod.rs +++ b/src/integrations/twitch/mod.rs @@ -19,7 +19,7 @@ pub mod irc; use super::store::{Store, TwitchAccount}; use crate::json::{self, Value}; use crate::sync::Mutex; -use irc::{ChatMessage, ChatState, Outgoing}; +use irc::{ChatMessage, ChatState, Delivery, Outgoing}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -58,6 +58,11 @@ const VALIDATE_EVERY: Duration = Duration::from_secs(55 * 60); /// Every call to Twitch gives up after this: the token upkeep runs them in /// one loop, and a stalled one would stop every later refresh. const HTTP_TIMEOUT: Duration = Duration::from_secs(15); +/// How long a chat message may wait behind Twitch's rate limit (20 per 30 s) +/// before its step stops waiting; it still goes out. +const SEND_WAIT: Duration = Duration::from_secs(35); +/// How long Twitch gets to confirm or refuse a message once it's sent. +const ANSWER_WAIT: Duration = Duration::from_secs(3); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -515,6 +520,9 @@ impl Twitch { } /// Queue a chat message. Blocking-safe: only a channel send. + /// Post in chat and wait for Twitch's answer, so a message it refused + /// (slow mode, a duplicate, a ban) fails its step instead of passing. + /// Blocks: call from a blocking thread. pub fn send(&self, text: &str, as_bot: bool, reply_to: Option<&str>) -> Result<(), String> { let connected = |c: &Conn| c.state() == ChatState::Connected; let conn = if as_bot && connected(&self.bot) { @@ -533,11 +541,24 @@ impl Twitch { .lock() .clone() .ok_or("Twitch chat is not running")?; + let (delivery, outcome) = std::sync::mpsc::sync_channel(2); out.try_send(Outgoing { text: text.to_string(), reply_to: reply_to.map(str::to_string), + delivery: Some(delivery), }) - .map_err(|_| "too many chat messages queued; slow down".to_string()) + .map_err(|_| "too many chat messages queued; slow down".to_string())?; + // Queued behind the rate limit at worst, then answered within a + // second or so. No answer in time (or a reconnect) isn't a refusal. + match outcome.recv_timeout(SEND_WAIT) { + Ok(Delivery::Written) => {} + Ok(Delivery::Refused(why)) => return Err(format!("Twitch didn't post it: {why}")), + _ => return Ok(()), + } + match outcome.recv_timeout(ANSWER_WAIT) { + Ok(Delivery::Refused(why)) => Err(format!("Twitch didn't post it: {why}")), + _ => Ok(()), + } } fn helix_call( diff --git a/src/midi.rs b/src/midi.rs index 9551421..5f90d37 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -33,6 +33,9 @@ use crate::sync::Mutex; /// someone next opened the dashboard. const LEARN_WINDOW: Duration = Duration::from_secs(30); +/// The learn the Integrations tab arms. Its pad may be on any device. +pub const INTEGRATION_LEARN: &str = "integration"; + /// Shared MIDI state between the listener thread and the web layer. #[derive(Default)] pub struct MidiState { @@ -157,6 +160,27 @@ impl MidiState { } } + /// A press from a device opened only for integrations (not the one + /// picked in Controls): it never runs a delay action, and only the + /// Integrations tab's learn may capture it. + #[cfg(windows)] + pub fn on_integration_signature(&self, ctrl: &Controller, signature: &str) { + { + let mut learn = self.learn.lock(); + if let Some((action, deadline)) = learn.take() { + if action == INTEGRATION_LEARN && Instant::now() < deadline { + *self.captured.lock() = Some((action, signature.to_string(), deadline)); + return; + } + // Not ours: the Controls learn keeps waiting for its device. + *learn = Some((action, deadline)); + } + } + if let Some(integrations) = ctrl.integrations() { + integrations.shortcut(crate::integrations::Shortcut::Midi, signature); + } + } + /// Called by the listener on each press-edge message. In learn mode it /// records the signature for the pending action; otherwise it routes to /// the bound action via the shared controller path. `default_ms` is the @@ -192,7 +216,9 @@ impl MidiState { /// listening, the device names, which action (if any) is learning, and /// the current bindings (so a just-committed one shows without a full /// config refetch). - pub fn to_json(&self) -> String { + /// The state the dashboard renders. `refused` says why a press that + /// was just learned wasn't bound. + pub fn to_json(&self, refused: Option<&str>) -> String { let learning = match self.learning() { Some(a) => json_string(&a), None => "null".to_string(), @@ -212,7 +238,7 @@ impl MidiState { ) }; format!( - r#"{{"available":{a},"connected":{c},"learning":{l},"devices":[{d}],"listening":[{li}],"device":{sel},"bindings":{b}}}"#, + r#"{{"available":{a},"connected":{c},"learning":{l},"devices":[{d}],"listening":[{li}],"device":{sel},"bindings":{b},"refused":{r}}}"#, a = self.available(), c = self.connected(), l = learning, @@ -220,6 +246,7 @@ impl MidiState { li = listening.join(","), sel = json_string(&self.selected_device()), b = bindings, + r = refused.map_or("null".to_string(), json_string), ) } } @@ -304,6 +331,14 @@ pub fn spawn( ) { } +/// Whether the listener opens `device`: the one picked in Controls (every +/// one when none is), any device an integration's pad was learned on, and +/// every device while the Integrations tab learns a pad. +#[cfg_attr(not(windows), allow(dead_code))] +fn should_open(device: &str, selected: &str, pad_devices: &[&str], learning_pad: bool) -> bool { + selected.is_empty() || device == selected || learning_pad || pad_devices.contains(&device) +} + /// Whether the listener should be holding MIDI inputs open. /// /// Opening one claims it exclusively: for as long as we hold a device, no @@ -371,7 +406,7 @@ mod win { let mut handles: Vec = Vec::new(); let mut last_present: Vec = Vec::new(); - let mut last_selected = String::new(); + let mut last_targets: Vec = Vec::new(); let mut last_listening = false; loop { // winmm gives no hot-plug event, so a periodic sweep is the only @@ -382,20 +417,17 @@ mod win { // means the open failed - another app had it exclusively - so // that retries every tick until it is handed back. let present = device_names(); - let selected = ctx.state.selected_device(); + let targets = devices_to_open(ctx, &present); let listening = wants_to_listen(ctx); - let wanted = listening - && present - .iter() - .any(|d| selected.is_empty() || *d == selected); + let wanted = listening && !targets.is_empty(); if present != last_present - || selected != last_selected + || targets != last_targets || listening != last_listening || (handles.is_empty() && wanted) { close_all(&mut handles); let opened = if listening { - open_matching(&present, &selected, instance, &mut handles) + open_matching(&present, &targets, instance, &mut handles) } else { Vec::new() }; @@ -412,7 +444,7 @@ mod win { // while we are holding none of them. ctx.state.set_devices(present.clone(), opened); last_present = present; - last_selected = selected; + last_targets = targets; last_listening = listening; } sleep_until_worth_another_look(ctx, listening); @@ -435,6 +467,26 @@ mod win { super::should_hold_devices(learning, &settings) } + /// The present devices `should_open` picks. + fn devices_to_open(ctx: &CallbackCtx, present: &[String]) -> Vec { + let selected = ctx.state.selected_device(); + let learning_pad = ctx.state.learning().as_deref() == Some(super::INTEGRATION_LEARN); + let pads = { + let settings = ctx.settings.borrow(); + crate::integrations::model::shortcuts(&settings.integrations).1 + }; + let pad_devices: Vec<&str> = pads + .iter() + .filter_map(|p| p.split_once('@')) + .map(|(_, d)| d) + .collect(); + present + .iter() + .filter(|d| super::should_open(d, &selected, &pad_devices, learning_pad)) + .cloned() + .collect() + } + /// Idle between sweeps, but notice a learn starting sooner than the /// sweep would. A learn window is short and the press that ends it comes /// from a human finger, so waiting a full tick to open the device would @@ -456,19 +508,18 @@ mod win { .collect() } - /// Open and start the input devices the user asked for, returning the - /// names actually opened. An empty `selected` means every device. - /// Devices that fail to open are skipped (another app may hold one - /// exclusively) and retried on a later sweep. + /// Open and start the `targets` among the present devices, returning + /// the names actually opened. Devices that fail to open are skipped + /// (another app may hold one exclusively) and retried on a later sweep. fn open_matching( present: &[String], - selected: &str, + targets: &[String], instance: usize, handles: &mut Vec, ) -> Vec { let mut names = Vec::new(); for (dev, name) in present.iter().enumerate() { - if !selected.is_empty() && name != selected { + if !targets.contains(name) { continue; } let dev = dev as u32; @@ -557,15 +608,23 @@ mod win { // Name the device the press came from. A deck we somehow have no // name for still works as an any-device signature rather than // going silent. - let sig = match ctx + let device = ctx .by_handle .lock() .iter() .find(|(handle, _)| *handle == hmi as usize) - { - Some((_, name)) => format!("{sig}@{name}"), + .map(|(_, name)| name.clone()); + let sig = match &device { + Some(name) => format!("{sig}@{name}"), None => sig, }; + // A device outside the Controls pick is open only for integrations, + // so its presses never reach a delay action. + let selected = ctx.state.selected_device(); + if !selected.is_empty() && device.is_some_and(|name| name != selected) { + ctx.state.on_integration_signature(&ctx.ctrl, &sig); + return; + } // Read the default delay live so a mid-session change in the // dashboard is reflected without restarting the listener. let default_ms = ctx.settings.borrow().auto_arm_delay_ms; @@ -604,6 +663,25 @@ mod tests { assert!(signature_for(0xE0, 0, 0).is_none(), "pitch bend ignored"); } + #[test] + fn devices_open_for_controls_and_for_integration_pads() { + use super::should_open; + assert!( + should_open("Deck A", "", &[], false), + "no pick: every device" + ); + assert!(should_open("Deck A", "Deck A", &[], false)); + assert!( + !should_open("Deck B", "Deck A", &[], false), + "not picked, not used" + ); + assert!( + should_open("Deck B", "Deck A", &["Deck B"], false), + "an integration's pad" + ); + assert!(should_open("Deck B", "Deck A", &[], true), "learning a pad"); + } + #[test] fn a_knob_turned_up_presses_once() { let mut high = HashSet::new(); @@ -902,7 +980,7 @@ mod tests { state.update_from_settings(&s); assert_eq!(state.selected_device(), "Launchpad MK2"); - let json = state.to_json(); + let json = state.to_json(None); assert!(json.contains(r#""device":"Launchpad MK2""#), "{json}"); assert!(json.contains(r#""devices":[]"#), "{json}"); assert!(json.contains(r#""listening":[]"#), "{json}"); diff --git a/src/web.rs b/src/web.rs index 7a76966..002ae1f 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2899,20 +2899,9 @@ fn binding_taken_by_integration( form: &std::collections::HashMap, s: &Settings, ) -> Option { - use crate::integrations::model::Trigger; let owner = |wanted: &str, is_pad: bool| { - s.integrations.iter().filter(|i| i.enabled).find_map(|i| { - i.handlers - .iter() - .filter(|h| h.enabled) - .find_map(|h| match &h.trigger { - Trigger::Shortcut { hotkey, midi } => { - let bound = if is_pad { midi } else { hotkey }; - (bound == wanted).then_some(i.name.as_str()) - } - _ => None, - }) - }) + let (key, pad) = if is_pad { ("", wanted) } else { (wanted, "") }; + crate::integrations::model::shortcut_owner(&s.integrations, key, pad) }; let bindings = [ ("hotkey.", s.hotkeys.entries(), false), @@ -3309,16 +3298,31 @@ async fn post_midi_poll( settings: &Arc>, cfg_path: &Path, ) -> (&'static str, &'static str, String) { + let mut refused = None; if let Some((action, signature)) = ctrl.midi().take_captured_for(&config::ACTIONS) { let _wl = settings_write_guard(); let mut new_settings = settings.borrow().clone(); - new_settings.midi.set(&action, &signature); - if new_settings.save(cfg_path).is_ok() { - ctrl.midi().update_from_settings(&new_settings); - let _ = settings.send(new_settings); + // The pad an integration starts from: taking it for the delay would + // leave that integration deaf without a word. + let owner = + crate::integrations::model::shortcut_owner(&new_settings.integrations, "", &signature); + if let Some(name) = owner { + refused = Some(format!( + "That pad already starts the integration \"{name}\". Press another, or change it there." + )); + } else { + new_settings.midi.set(&action, &signature); + if new_settings.save(cfg_path).is_ok() { + ctrl.midi().update_from_settings(&new_settings); + let _ = settings.send(new_settings); + } } } - ("200 OK", "application/json", ctrl.midi().to_json()) + ( + "200 OK", + "application/json", + ctrl.midi().to_json(refused.as_deref()), + ) } /// Write the current delay state (armed / target, and the "last manually @@ -5921,6 +5925,14 @@ mod tests { assert!(refused.contains("Clip button"), "{refused}"); // A form that doesn't touch that binding isn't refused for it. assert!(binding_taken_by_integration(&config::parse_form("buffer_mb=300"), &s).is_none()); + // A pad bound for any device overlaps the same pad on one deck. + s.integrations[0].handlers[0].trigger = Trigger::Shortcut { + hotkey: String::new(), + midi: "note:1:36".into(), + }; + apply_field_str(&mut s, "midi.arm", "note:1:36@Deck A"); + let form = config::parse_form("midi.arm=note%3A1%3A36%40Deck%20A"); + assert!(binding_taken_by_integration(&form, &s).is_some()); } #[test] diff --git a/web/index.html b/web/index.html index 7642682..a78f7d6 100644 --- a/web/index.html +++ b/web/index.html @@ -7930,9 +7930,10 @@

New destination

// actually worked. if (got) setTimeout(midiPollOnce, 700); if (was && !got){ - // The listener drops a learn nobody finished, so the field going back - // to normal on its own needs a reason attached to it. - midiSetWarn('Learn timed out. Click the field and press a control within 30 seconds.'); + // The listener drops a learn nobody finished, and the app refuses a + // pad an integration uses: either way the field going back to normal + // on its own needs a reason attached to it. + midiSetWarn(j.refused || 'Learn timed out. Click the field and press a control within 30 seconds.'); } if (was && got){ // One control drives one action, so learning a pad that was already From fe6cbf2331a3b30357c85815d3f6fd82bcf9ef6f Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Fri, 2 Oct 2026 03:52:04 +0200 Subject: [PATCH 48/55] fix(integrations): catalog tiles no longer clip, quieter Add buttons Catalog rows sized to their tiles (auto rows shrank to share the grid's height and cut off the buttons), Add tinted in each tile's colour instead of solid cyan, descriptions clamped to three lines, and shorter copy for Command from a website, Clip button and the Twitch note. --- src/integrations/presets.rs | 4 ++-- web/index.html | 12 +++++++++++- web/integrations.js | 6 +++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index bca25ab..6a3ab14 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -122,7 +122,7 @@ pub const PRESETS: &[Preset] = &[ id: "api_command", name: "Command from a website", category: "chat", - description: "Answers with anything a website returns: ranks, stats, quotes. Paste a Nightbot or StreamElements command, or start from !uptime and friends.", + description: "Replies with anything a website returns: a rank, a stat, a quote.", needs: "twitch", recommended: false, preview: ("chat", "Radiant 450RR", "!rank"), @@ -131,7 +131,7 @@ pub const PRESETS: &[Preset] = &[ id: "clip_button", name: "Clip button", category: "auto", - description: "A key or a MIDI pad clips the last moments, drops the link in chat and says so on stream.", + description: "One key or MIDI pad clips the moment and posts the link in chat.", needs: "twitch", recommended: false, preview: ("chat", "🎬 Clipped: https://clips.twitch.tv/BraveSnipe", ""), diff --git a/web/index.html b/web/index.html index a78f7d6..77f9d51 100644 --- a/web/index.html +++ b/web/index.html @@ -2504,7 +2504,11 @@ .ig-cat-import{margin-top:auto;padding:10px;border-radius:10px;border:1px dashed var(--line-2);font-size:12px;line-height:1.5;color:var(--fg-3)} .ig-cat-import a{color:var(--accent);font-weight:600} .ig-cat-grid{flex:1;min-width:0;overflow-y:auto;padding:16px 18px 20px;display:grid; - grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px;align-content:start} + grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px;align-content:start; + /* Rows as tall as their tiles. Plain auto rows shrink to share the + grid's fixed height once there are more tiles than fit, and the + tiles (overflow hidden) lose their buttons. */ + grid-auto-rows:max-content} /* A category's tiles fade in one after another; tiles already shown stay. */ .ig-cat-grid>*{animation:igTileIn .32s var(--ease-out) both;animation-delay:calc(min(var(--i,0),10) * 26ms)} @keyframes igTileIn{from{opacity:0;transform:translateY(8px) scale(.98);filter:blur(3px)}to{opacity:1;transform:none;filter:blur(0)}} @@ -2514,8 +2518,14 @@ .ig-cat-id .dcard-icon{width:28px;height:28px;border-radius:8px} .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:4px;font-size:11px;color:var(--warn)} .ig-cat-tile>.ic-btn{margin:auto 4px 4px} +/* Add: the tile's colour, tinted, so a grid of them reads as cards and + not as a wall of solid buttons. It fills on hover. */ +.ig-cat-tile>.ig-cat-add{color:var(--dc);background:color-mix(in oklch,var(--dc) 12%,transparent); + border-color:color-mix(in oklch,var(--dc) 32%,transparent)} +.ig-cat-tile>.ig-cat-add:hover{color:var(--bg);background:var(--dc);border-color:transparent} .ig-cat-pack{padding:14px;gap:10px} .ig-cat-pack>.ic-btn{align-self:flex-start;margin-top:auto} .ig-cat-build{min-height:214px;padding:16px;text-align:center} diff --git a/web/integrations.js b/web/integrations.js index 44f189e..4eb0275 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -1774,11 +1774,11 @@ const Catalog = { const added = this.added === p.id; const button = added ? `` - : ``; + : ``; return `
${previewHtml(fake.step, fake.handler, false)}
${svg(PRESET_ICON[p.id] || 'steps')} -
${esc(p.name)}
${esc(p.description)}
+
${esc(p.name)}
${esc(p.description)}
${needs ? `
${esc(needs)}
` : ''}
${button}
`; @@ -1821,7 +1821,7 @@ function fakeStep(p){ function needsNote(needs){ const c = S.data.connections, t = S.data.twitch; if (needs === 'discord' && !c.discord.length) return 'Needs a Discord channel (you can add it next)'; - if (needs === 'twitch' && !t.available) return 'Needs Twitch, which this build can\'t log in to'; + if (needs === 'twitch' && !t.available) return 'Needs Twitch, not in this build'; if (needs === 'twitch' && !t.main.login) return 'Needs Twitch connected'; if (needs === 'phone' && !c.phone.topic) return 'Needs your phone connected'; if (needs === 'web') return 'You paste the address it sends to'; From 91fda608862e90acb8285f3e3c6474eeb4e22458 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Fri, 2 Oct 2026 04:33:20 +0200 Subject: [PATCH 49/55] feat(integrations): delay arming events, clearer builder, experimental label - Events for the delay's whole life: armed, ready, cancelled (on top of on, changed and off), from Controller::phase each engine tick. - New "Delay status" alert: one Discord message that follows them all. - Delay steps can disarm; a delay changed by an integration toasts as "Integration", not "Hotkey". - Builder: a trigger's switch and Remove sit under its name (the confirm no longer overflows), alike triggers are numbered, integration-wide settings are grouped apart, every block says what it does, and an empty trigger says how to start. - Simple editor: many moments wrap as chips that keep their switches. - Segmented controls wrap with a row-aware indicator; message boxes and indicators follow window resizes. - Integrations are labelled experimental in the tab and the changelog. --- CHANGELOG.md | 24 ++++++++- src/controller.rs | 9 ++++ src/integrations/effects.rs | 4 ++ src/integrations/engine.rs | 74 ++++++++++++++++++++++++++ src/integrations/event.rs | 31 +++++++++-- src/integrations/presets.rs | 18 +++++++ web/index.html | 45 ++++++++++++++-- web/integrations.js | 100 ++++++++++++++++++++++++------------ 8 files changed, 261 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8031b0..fc3f609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,16 @@ All notable changes will land here. Format loosely follows ## [Unreleased] -### Integrations: InstantClone talks to Discord, Twitch chat, your phone and your tools +### Integrations (experimental): InstantClone talks to Discord, Twitch chat, your phone and your tools A new **Integrations** tab. Every card shows exactly what it sends (the Discord message, the chat reply, the phone push, the VOD marker), so you know what each one does before you switch it on. +Integrations are **experimental**: they work, but how they look and how +you set them up (mostly the screens) may still change between versions. +The tab says so too. + - **A catalog of ready-made integrations**, one click to add: a crash alert on Discord, a `!delay` command that always answers with your current delay, "tell chat" when OBS drops (only after 20 s, so short @@ -55,6 +59,10 @@ know what each one does before you switch it on. - **Edit text** shapes an answer before it goes out: its first line, the part between two texts, find and replace, round a number, group digits, or pick one line at random (an 8-ball in two steps). +- **React to every step of the delay**: you arm one, it's ready to go on + air, it goes on, changes, goes off, or you cancel it before it went on. + The new **Delay status** alert keeps one Discord message in step with + all of it, and a delay step can now also **disarm** the delay. - **Quiet hours** keep an integration silent overnight, `{uses}` counts how often one ran, and every card shows its last runs as a row of dots. - **Everything can be taken back.** Every card has a menu (edit, @@ -148,6 +156,20 @@ messages as before. dashboard scrolls normally instead of in a small box. - **Modals stay put.** The Connections tabs and opening a chip no longer shift the window, and Esc closes an open chip before the whole editor. +- **The builder is easier to follow:** + - Each trigger's on/off switch and Remove sit right under its name, + instead of at the end of the panel, where the confirm overflowed it. + - Two triggers alike are numbered, so their tabs never read the same. + - Settings for the whole integration (wait between runs, quiet hours) + are grouped apart from the trigger's own. + - Every block says what it does, and an empty trigger says how to + start. + - The catalog's tiles no longer cut off their Add button. + - Integrations with many moments show them as chips that wrap, each + with its switch, instead of a bar running off the edge. + - Choices that don't fit a phone's width wrap instead of sticking out. +- **A delay changed by an integration says "Integration"** in its toast, + not "Hotkey". - **Smaller fixes:** - A timer switched back on waits a full period. - "Wait for the delay" can't crash on an unlucky tick. diff --git a/src/controller.rs b/src/controller.rs index 862d835..812b70d 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -2540,6 +2540,15 @@ impl Controller { self.state_dirty.notify_one(); } + /// Back to no delay at all (an integration's "Disarm"): live, and + /// nothing armed. What the dashboard's "Cancel arming" does. + pub fn disarm(&self, source: &str) { + self.arm_delay(0); + self.log(format!("[{source}] delay disarmed")); + self.record_fired_action("disarm", source, None); + self.state_dirty.notify_one(); + } + fn dispatch_named_action(&self, action: &str, default_ms: u32, source: &str) -> Option { match action { "toggle" => self.action_toggle(default_ms, source), diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index 386aff2..013de91 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -250,6 +250,10 @@ impl Host for RealHost { self.ctrl.set_delay_to(default_ms, "integration"); Ok(()) } + "disarm" => { + self.ctrl.disarm("integration"); + Ok(()) + } "toggle" | "activate" | "cut" | "cut_after" | "end_hold" => { match self .ctrl diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index b26a91e..f81b8b9 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -381,6 +381,8 @@ struct Engine { limits: HashMap>, global: Arc, delay_seen: Option, + /// The delay's phase and armed delay at the last tick: see `watch_phase`. + phase_seen: Option<(&'static str, u32)>, flap: FlapGuard, discord_messages: Arc>>, /// `{uses}` changed since the data file was last written. @@ -415,6 +417,7 @@ impl Engine { limits: HashMap::new(), global: Arc::new(Semaphore::new(GLOBAL_RUNS)), delay_seen: None, + phase_seen: None, flap: FlapGuard::default(), discord_messages, uses_dirty: false, @@ -450,6 +453,7 @@ impl Engine { for event in self.flap.release(Instant::now()) { self.event(event); } + self.watch_phase(); self.watch_delay(); self.run_timers(); self.save_uses_if_due(); @@ -705,6 +709,18 @@ impl Engine { } } + /// Turn arming a delay into events: armed, ready, cancelled. Going on + /// air and off it are `watch_delay`'s. + fn watch_phase(&mut self) { + let now = (self.ctrl.phase(), self.ctrl.armed_delay_ms()); + let Some(before) = self.phase_seen.replace(now) else { + return; + }; + for event in phase_events(before, now) { + self.event(event); + } + } + /// Turn changes in the applied delay into delay events. fn watch_delay(&mut self) { let now = self.ctrl.target_delay_ms(); @@ -884,6 +900,30 @@ impl Engine { } } +/// The events a change of the delay's phase (`Controller::phase`) makes, +/// from `(phase, armed ms)` before to after. "Ready" is the armed delay +/// becoming ready to go on, never the delay coming back to ready after a +/// cut; a delay switched on as soon as it was ready was ready too. +fn phase_events(before: (&str, u32), now: (&str, u32)) -> Vec { + let fmt = super::host::fmt_delay; + let ((was, was_armed), (is, armed)) = (before, now); + let mut events = Vec::new(); + if was == is { + return events; + } + if was == "idle" { + events.push(Event::new(EventKind::DelayArmed).with("delay", fmt(armed))); + } + let became_ready = is == "ready" || (was == "preparing" && is == "active"); + if became_ready && matches!(was, "idle" | "preparing") { + events.push(Event::new(EventKind::DelayReady).with("delay", fmt(armed))); + } + if is == "idle" && matches!(was, "preparing" | "ready") { + events.push(Event::new(EventKind::DelayDisarmed).with("previous", fmt(was_armed))); + } + events +} + /// See `FLAP_WINDOW`. What it holds back isn't lost: a destination whose /// last held event differs from what was last said about it gets that event /// once the window ends, so "live" is never the final word on a dead one. @@ -1167,6 +1207,40 @@ mod tests { assert!(!guard.suppress(&Event::new(EventKind::HoldOpened), t0)); } + #[test] + fn arming_a_delay_says_armed_then_ready_or_cancelled() { + let kinds = |before, now| -> Vec { + phase_events(before, now).iter().map(|e| e.kind).collect() + }; + use EventKind::*; + assert_eq!(kinds(("idle", 0), ("preparing", 30_000)), [DelayArmed]); + assert_eq!( + kinds(("preparing", 30_000), ("ready", 30_000)), + [DelayReady] + ); + assert_eq!( + kinds(("idle", 0), ("ready", 30_000)), + [DelayArmed, DelayReady], + "buffer already full" + ); + assert_eq!( + kinds(("preparing", 30_000), ("active", 30_000)), + [DelayReady], + "switched on as it got ready" + ); + assert_eq!(kinds(("ready", 30_000), ("idle", 0)), [DelayDisarmed]); + assert!( + kinds(("active", 30_000), ("ready", 30_000)).is_empty(), + "a cut isn't ready news" + ); + assert!( + kinds(("active", 30_000), ("idle", 0)).is_empty(), + "turning off is watch_delay's" + ); + let armed = &phase_events(("idle", 0), ("preparing", 30_000))[0]; + assert_eq!(armed.var("delay"), Some("30s")); + } + #[test] fn a_drop_held_back_is_still_said_once_the_window_ends() { let mut guard = FlapGuard::default(); diff --git a/src/integrations/event.rs b/src/integrations/event.rs index b698361..5fe45c5 100644 --- a/src/integrations/event.rs +++ b/src/integrations/event.rs @@ -22,13 +22,16 @@ pub enum EventKind { DelayOn, DelayOff, DelayChanged, + DelayArmed, + DelayReady, + DelayDisarmed, } /// One variable an event carries: its name and a sample value. pub type VarSpec = (&'static str, &'static str); impl EventKind { - pub const ALL: [EventKind; 13] = [ + pub const ALL: [EventKind; 16] = [ EventKind::ObsConnected, EventKind::ObsDisconnected, EventKind::EbDetected, @@ -39,9 +42,13 @@ impl EventKind { EventKind::DestinationLive, EventKind::DestinationDropped, EventKind::AllDestinationsDown, + // The delay's life, in order: armed, ready, on air, changed, off. + EventKind::DelayArmed, + EventKind::DelayReady, EventKind::DelayOn, - EventKind::DelayOff, EventKind::DelayChanged, + EventKind::DelayOff, + EventKind::DelayDisarmed, ]; pub fn id(self) -> &'static str { @@ -59,6 +66,9 @@ impl EventKind { EventKind::DelayOn => "delay_on", EventKind::DelayOff => "delay_off", EventKind::DelayChanged => "delay_changed", + EventKind::DelayArmed => "delay_armed", + EventKind::DelayReady => "delay_ready", + EventKind::DelayDisarmed => "delay_disarmed", } } @@ -68,8 +78,8 @@ impl EventKind { pub fn label(self) -> &'static str { match self { - EventKind::ObsConnected => "OBS connects", - EventKind::ObsDisconnected => "OBS disconnects", + EventKind::ObsConnected => "OBS starts streaming", + EventKind::ObsDisconnected => "OBS stops or drops", EventKind::EbDetected => "Enhanced Broadcasting detected", EventKind::HoldOpened => "OBS crashes", EventKind::ObsBack => "OBS is back", @@ -81,6 +91,9 @@ impl EventKind { EventKind::DelayOn => "The delay turns on", EventKind::DelayOff => "The delay turns off", EventKind::DelayChanged => "The delay changes", + EventKind::DelayArmed => "You arm a delay", + EventKind::DelayReady => "The armed delay is ready", + EventKind::DelayDisarmed => "You cancel an armed delay", } } @@ -96,7 +109,12 @@ impl EventKind { EventKind::DestinationLive | EventKind::DestinationDropped | EventKind::AllDestinationsDown => "Destinations", - EventKind::DelayOn | EventKind::DelayOff | EventKind::DelayChanged => "Delay", + EventKind::DelayOn + | EventKind::DelayOff + | EventKind::DelayChanged + | EventKind::DelayArmed + | EventKind::DelayReady + | EventKind::DelayDisarmed => "Delay", } } @@ -125,6 +143,9 @@ impl EventKind { EventKind::DelayOn => &[("delay", "30s"), ("previous", "0s")], EventKind::DelayOff => &[("previous", "30s")], EventKind::DelayChanged => &[("delay", "30s"), ("previous", "20s")], + // Armed: the delay that will go on air once the buffer holds it. + EventKind::DelayArmed | EventKind::DelayReady => &[("delay", "30s")], + EventKind::DelayDisarmed => &[("previous", "30s")], EventKind::ObsConnected | EventKind::EbDetected | EventKind::AllDestinationsDown => &[], } } diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index 6a3ab14..a368058 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -56,6 +56,15 @@ pub const PRESETS: &[Preset] = &[ recommended: false, preview: ("discord", "We're live! Come hang out.", ""), }, + Preset { + id: "delay_status", + name: "Delay status", + category: "alerts", + description: "One Discord message that follows your delay: armed, ready, on, off.", + needs: "discord", + recommended: false, + preview: ("discord", "✅ Delay ready: 30s. It can go on air.", ""), + }, Preset { id: "phone_crash", name: "Phone push on crash", @@ -284,6 +293,15 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option vec![ + on(EventKind::DelayArmed, vec![discord(d, "⏳ Delay armed: {delay}. Filling the buffer…")]), + on(EventKind::DelayReady, vec![discord_update(d, "✅ Delay ready: {delay}. It can go on air.")]), + on(EventKind::DelayOn, vec![discord_update(d, "🟢 Delay on air: {delay}.")]), + on(EventKind::DelayChanged, vec![discord_update(d, "🔁 Delay changed to {delay}.")]), + on(EventKind::DelayOff, vec![discord_update(d, "⚪ Delay off: viewers see you live.")]), + on(EventKind::DelayDisarmed, vec![discord_update(d, "⚪ Delay cancelled before it went on air.")]), + ], "destination_down" => vec![on( EventKind::DestinationDropped, vec![discord(d, "{destination} dropped: {reason}")], diff --git a/web/index.html b/web/index.html index 77f9d51..18ce229 100644 --- a/web/index.html +++ b/web/index.html @@ -2220,10 +2220,10 @@ .ig-conns .ic-pill:hover{border-color:var(--line-2);color:var(--fg)} .ig-conns .ic-pill:active{transform:scale(.96)} /* Segmented controls, with the dashboard's sliding pill. */ -.ig-seg{display:inline-flex;flex:0 0 auto;max-width:100%} +.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{opacity:0;transition:left .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-spring),top .28s var(--ease-spring),width .28s var(--ease-spring),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} @@ -2447,6 +2447,17 @@ .ig-moment-name:hover{color:var(--fg)} .ig-moment-name.on{background:color-mix(in oklch,var(--accent) 16%,var(--surface-3)); border-color:color-mix(in oklch,var(--accent) 55%,transparent);color:var(--fg)} +.ig-mchips{display:flex;flex-wrap:wrap;gap:6px} +.ig-mchip{display:inline-flex;align-items:center;gap:4px;padding:3px 3px 3px 4px;border-radius:999px;border:1px solid var(--line);background:var(--bg-1); + transition:background-color .2s var(--ease-out),border-color .2s var(--ease-out)} +.ig-mchip .ig-dot{width:20px;height:20px;box-shadow:none} +.ig-mchip .ig-dot::after{inset:-6px} +.ig-mchip .ig-dot svg{width:10px;height:10px} +.ig-mchip-name{padding:4px 10px 4px 4px;border:0;background:transparent;color:var(--fg-3);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer; + transition:color .2s var(--ease-out)} +.ig-mchip-name:hover{color:var(--fg)} +.ig-mchip.on{background:color-mix(in oklch,var(--accent) 16%,var(--surface-3));border-color:color-mix(in oklch,var(--accent) 55%,transparent)} +.ig-mchip.on .ig-mchip-name{color:var(--fg)} /* Option chips and their panel. */ .ig-chips{display:flex;flex-wrap:wrap;gap:8px} .ig-chip{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border-radius:999px;border:1px solid var(--line); @@ -2521,6 +2532,28 @@ .ig-cat-tile .ig-cat-desc{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden} .ig-cat-needs{margin-top:4px;font-size:11px;color:var(--warn)} .ig-cat-tile>.ic-btn{margin:auto 4px 4px} +/* A confirm label ("Click again: …") is longer than the button it + replaces: it wraps inside its row instead of pushing out of it. */ +.ig-modal .ic-btn.ig-armed{max-width:100%;white-space:normal;text-align:center;height:auto;min-height:30px} +/* Builder: the trigger's own switch and remove, under its name. */ +.ig-trig-manage{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px; + padding:8px 10px;margin-bottom:4px;border:1px solid var(--line);border-radius:10px;background:var(--bg-1)} +.ig-trig-manage .dest-form-enable{font-size:12.5px} +.ig-trig-del{color:var(--fg-3)} +.ig-trig-del svg{width:12px;height:12px} +.ig-trig-del:hover,.ig-trig-del.ig-armed{color:var(--danger);border-color:color-mix(in oklch,var(--danger) 45%,transparent)} +.ig-insp-hint{margin:-4px 0 4px;font-size:12px;line-height:1.5;text-wrap:pretty} +.ig-insp-group{display:flex;flex-direction:column;gap:8px;margin-top:6px;padding-top:14px;border-top:1px solid var(--line)} +.ig-insp-group>.muted{margin-top:-6px;font-size:12px} +/* Opaque and above the spine, like a step, so the line doesn't run through. */ +.ig-empty-steps{position:relative;z-index:1;display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border-radius:10px; + background:color-mix(in oklch,var(--accent) 6%,var(--bg-2));border:1px solid color-mix(in oklch,var(--accent) 18%,var(--line)); + font-size:12.5px;line-height:1.5;color:var(--fg-2);text-wrap:pretty} +.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{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;font-size:10.5px;font-weight:600; + letter-spacing:.02em;vertical-align:2px;color:var(--warn);cursor:help; + 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 not as a wall of solid buttons. It fills on hover. */ .ig-cat-tile>.ig-cat-add{color:var(--dc);background:color-mix(in oklch,var(--dc) 12%,transparent); @@ -2706,7 +2739,6 @@ .ig-insp-vars{margin-top:auto;padding-top:12px;display:flex;flex-direction:column;gap:8px} .ig-insp-vars .muted{font-size:11.5px} .ig-insp-p{margin:0;font-size:13px;line-height:1.6;color:var(--fg-2);text-wrap:pretty} -.ig-insp-row{display:flex;align-items:center;justify-content:space-between;gap:8px} .ig-inspector .dff>label{font-size:12px;font-weight:600;color:var(--fg-2)} .ig-inspector textarea.ic-input{resize:vertical;min-height:70px;font-family:'JetBrains Mono',monospace;font-size:12.5px} .ig-copy-row{display:flex;gap:6px} @@ -7484,6 +7516,9 @@

New destination

let hkWarnTimer = null; const hkTitleOf = a => (HOTKEY_ACTIONS.find(x => x[0] === a) || [,''])[1]; +// What an integration's delay step did, for the toast (its "arm" sets the +// delay rather than toggling it). +const FIRED_BY_INTEGRATION = {arm:'Set the delay', disarm:'Disarm the delay'}; // Named keys beyond letters/digits/F-keys, keyed by KeyboardEvent.code and // mapped to the exact tokens the server's NAMED_KEYS table expects. @@ -7593,7 +7628,9 @@

New destination

if (fired.seq < lastFiredSeq){ lastFiredSeq = fired.seq; return; } lastFiredSeq = fired.seq; const where = fired.source === 'midi' ? '#midi-list' : '#hk-list'; - const label = (fired.source === 'midi' ? 'MIDI' : 'Hotkey') + ' · ' + hkTitleOf(fired.action); + const via = {midi:'MIDI', integration:'Integration'}[fired.source] || 'Hotkey'; + const what = (fired.source === 'integration' && FIRED_BY_INTEGRATION[fired.action]) || hkTitleOf(fired.action) || fired.action; + const label = `${via} · ${what}`; toast(fired.problem ? `${label}: ${fired.problem}` : label, fired.problem ? 'err' : 'ok'); const cap = document.querySelector(`${where} .hk-capture[data-action="${fired.action}"]`); if (!cap) return; diff --git a/web/integrations.js b/web/integrations.js index 4eb0275..015c979 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -74,36 +74,36 @@ const svg = (name, extra) => // What each step kind is, how it looks, and which parameter holds its text. const KINDS = { - discord: {label:'Discord', tag:'SEND', c:'#a5b4fc', icon:'bubble', text:'text'}, - chat: {label:'Twitch chat', tag:'SEND', c:'#a78bfa', icon:'bubble', text:'text'}, - phone: {label:'Phone push', tag:'SEND', c:'#f9a8d4', icon:'phone', text:'text'}, - http: {label:'Web request', tag:'SEND', c:'#fcd34d', icon:'link', text:'body'}, - wait: {label:'Wait', tag:'WAIT', c:'#c3cad1', icon:'clock'}, - wait_delay: {label:'Wait for the delay', tag:'WAIT', c:'#c3cad1', icon:'clock'}, - if: {label:'If / otherwise',tag:'IF', c:'#f0a53a', icon:'bolt'}, - stop: {label:'Stop', tag:'STOP', c:'#f2665a', icon:'x'}, - delay_action: {label:'Delay action', tag:'STREAM', c:'#86efac', icon:'cut'}, - marker: {label:'VOD marker', tag:'TWITCH', c:'#a78bfa', icon:'bookmark', text:'description'}, - clip: {label:'Clip', tag:'TWITCH', c:'#a78bfa', icon:'film'}, - program: {label:'Run a program', tag:'RUN', c:'#fcd34d', icon:'terminal'}, - file: {label:'Write a file', tag:'FILE', c:'#fcd34d', icon:'file', text:'text'}, - set_var: {label:'Remember a value', tag:'VALUE', c:'#93c5fd', icon:'steps', text:'value'}, - counter: {label:'Counter', tag:'VALUE', c:'#93c5fd', icon:'steps'}, - overlay: {label:'Show on stream', tag:'STREAM', c:'#5ac8fa', icon:'screen', text:'text'}, - edit_text: {label:'Edit text', tag:'VALUE', c:'#93c5fd', icon:'type'}, + discord: {label:'Discord', tag:'SEND', c:'#a5b4fc', icon:'bubble', text:'text', hint:'Posts a message in one of your Discord channels.'}, + chat: {label:'Twitch chat', tag:'SEND', c:'#a78bfa', icon:'bubble', text:'text', hint:'Says something in your Twitch chat.'}, + phone: {label:'Phone push', tag:'SEND', c:'#f9a8d4', icon:'phone', text:'text', hint:'A notification on your phone.'}, + http: {label:'Web request', tag:'SEND', c:'#fcd34d', icon:'link', text:'body', hint:'Calls a website or an API, and keeps its answer for later steps.'}, + wait: {label:'Wait', tag:'WAIT', c:'#c3cad1', icon:'clock', hint:'Pauses before the next step.'}, + wait_delay: {label:'Wait for the delay', tag:'WAIT', c:'#c3cad1', icon:'clock', hint:'Waits until your viewers see this moment.'}, + if: {label:'If / otherwise',tag:'IF', c:'#f0a53a', icon:'bolt', hint:'Runs some steps only when a check is true, others when it isn\'t.'}, + stop: {label:'Stop', tag:'STOP', c:'#f2665a', icon:'x', hint:'Ends the run here.'}, + delay_action: {label:'Delay action', tag:'STREAM', c:'#86efac', icon:'cut', hint:'Sets, switches on, cuts or disarms your delay.'}, + marker: {label:'VOD marker', tag:'TWITCH', c:'#a78bfa', icon:'bookmark', text:'description', hint:'Marks this moment in your Twitch VOD.'}, + clip: {label:'Clip', tag:'TWITCH', c:'#a78bfa', icon:'film', hint:'Clips the last moments of your Twitch stream.'}, + program: {label:'Run a program', tag:'RUN', c:'#fcd34d', icon:'terminal', hint:'Starts a program on this PC.'}, + file: {label:'Write a file', tag:'FILE', c:'#fcd34d', icon:'file', text:'text', hint:'Writes text to a file, for an OBS text source for example.'}, + set_var: {label:'Remember a value', tag:'VALUE', c:'#93c5fd', icon:'steps', text:'value', hint:'Keeps a value for the next steps of this run.'}, + counter: {label:'Counter', tag:'VALUE', c:'#93c5fd', icon:'steps', hint:'Counts up or down, and keeps the count between runs.'}, + overlay: {label:'Show on stream', tag:'STREAM', c:'#5ac8fa', icon:'screen', text:'text', hint:'Shows a card on your stream, through the alerts browser source.'}, + edit_text: {label:'Edit text', tag:'VALUE', c:'#93c5fd', icon:'type', hint:'Reshapes text: a line, a part, a rounded number, a random pick.'}, }; // The icon a catalog module wears: what it is about, not how it sends. const PRESET_ICON = { crash_alert:'shield', destination_down:'signal', going_live:'megaphone', phone_crash:'phone', tell_chat:'bubble', delay_command:'hash', delay_notice:'clock', mod_controls:'cut', socials:'hash', vod_markers:'bookmark', webhook:'link', crash_counter:'file', stream_alerts:'bell', api_command:'globe', - clip_button:'film', + clip_button:'film', delay_status:'clock', }; // Which step a card previews: the first one that says something. const PREVIEW_ORDER = ['discord','chat','phone','overlay','marker','http','file','clip','delay_action','program']; const DELAY_ACTIONS = { cut:'Cut the delay', arm:'Set the delay to…', activate:'Turn the delay on', toggle:'Toggle the delay', - cut_after:'Cut after this airs', end_hold:'End the reconnect screen', + cut_after:'Cut after this airs', end_hold:'End the reconnect screen', disarm:'Disarm (no delay at all)', }; const OPS = { is:'is', is_not:'is not', contains:'contains', not_contains:'does not contain', starts_with:'starts with', @@ -670,6 +670,7 @@ function enterStagger(root){ // Segmented controls carry the dashboard's sliding pill; it is placed // under the active tab after every redraw, and glides when that changes. +// Its row too: a narrow window wraps a control onto two lines. const SEG_IND = ''; function placeIndicators(root){ root.querySelectorAll('.ig-seg').forEach(seg => { @@ -679,6 +680,8 @@ function placeIndicators(root){ if (!on || !on.offsetWidth){ ind.style.opacity = '0'; return; } ind.style.left = on.offsetLeft + 'px'; ind.style.width = on.offsetWidth + 'px'; + ind.style.top = on.offsetTop + 'px'; + ind.style.height = on.offsetHeight + 'px'; ind.style.opacity = '1'; }); } @@ -1234,7 +1237,7 @@ function render(){ : `${on} on${failing.length ? ` · ${failing.length} need${failing.length === 1 ? 's' : ''} attention` : ''}. Each card shows exactly what it sends.`; morph(root, `
-
Integrations
${sub}
+
Integrations Experimental
${sub}
${empty ? '' : ''} @@ -1994,9 +1997,12 @@ const Editor = { momentsHtml(){ const hs = this.d.handlers; if (hs.length === 1) return ''; + // Too many for a timeline: chips that wrap, each keeping its switch. if (hs.length > 5){ - return `
${hs.map((h, n) => ``).join('')}${SEG_IND}
`; + return `
${hs.map((h, n) => `
+ + +
`).join('')}
`; } return `
${hs.map((h, n) => `
@@ -2436,6 +2442,15 @@ function autosize(t){ t.style.height = 'auto'; t.style.height = (t.scrollHeight + 2) + 'px'; } +// A narrower window wraps messages and segmented controls onto more lines: +// grow with it, and keep each sliding pill under its tab. +window.addEventListener('resize', () => { + const root = $('ig-root'); + if (root) placeIndicators(root); + if (!Modal.form) return; + Modal.form.querySelectorAll('.ig-msg').forEach(autosize); + placeIndicators(Modal.form); +}); function insertAtCursor(field, text){ if (!field || !field.isConnected || field.closest('[data-leaving]')) return; const start = field.selectionStart == null ? field.value.length : field.selectionStart; @@ -2489,6 +2504,9 @@ const EVENT_INFO = { delay_on:{icon:'clock', desc:'The delay starts.'}, delay_off:{icon:'clock', desc:'The delay stops: viewers see you live.'}, delay_changed:{icon:'clock', desc:'The delay gets longer or shorter.'}, + delay_armed:{icon:'clock', desc:'You arm one: the buffer starts filling.'}, + delay_ready:{icon:'check', desc:'The buffer holds it: it can go on air.'}, + delay_disarmed:{icon:'x', desc:'You cancel it before it goes on air.'}, }; // Every event as a tile, grouped; `big` for the canvas chooser. function eventTilesHtml(selected, big){ @@ -2553,11 +2571,11 @@ const Builder = {
-
${d.handlers.map((x, i) => ``).join('')} +
${this.triggerTabs().map((label, i) => ``).join('')}
${this.choosing ? this.chooserHtml(h) : `
${footHtml(d, this.dirty)}`); }, + // Each trigger's tab label. Two alike get a number, so no two tabs read + // the same and the one being edited is never in doubt. + triggerTabs(){ + const labels = this.d.handlers.map(x => this.unpicked.has(x) ? 'New trigger' : 'When ' + lowerFirst(triggerLabel(x.trigger))); + return labels.map((l, i) => labels.filter(x => x === l).length > 1 + ? `${l} · ${labels.slice(0, i + 1).filter(x => x === l).length}` : l); + }, // "How does it start?" then, for events, "What happens?": big tiles in // the canvas, so the first decision is visible and can't be skipped. chooserHtml(h){ @@ -2623,7 +2648,9 @@ const Builder = { } return html; }).join(''); - return out + ``; + const empty = !base.length && !(steps || []).length + ? `
${svg('steps')}Nothing happens yet. Pick what it does from the blocks on the left: post in Discord, answer in chat, show it on stream… Steps run from top to bottom.
` : ''; + return out + empty + ``; }, resolve(pathStr){ return pathStr === '' ? [] : pathStr.split('.').map(p => /^\d+$/.test(p) ? +p : p); @@ -2656,7 +2683,8 @@ const Builder = { const s = this.stepAt(this.sel); if (!s) return varsBox; const k = KINDS[s.type]; - return `
${k.tag}${esc(k.label)}
${this.stepFields(s)}${varsBox}`; + return `
${k.tag}${esc(k.label)}
+
${esc(k.hint)}
${this.stepFields(s)}${varsBox}`; }, stepFields(s){ const field = (name, label, opts) => { @@ -2762,13 +2790,17 @@ const Builder = { } else if (t.type === 'shortcut'){ body = shortcutHtml(t); } - const more = this.d.handlers.length > 1 - ? `
${enableSwitch('h-enabled', h.enabled, 'This trigger is on')} -
` : ''; + // With more than one trigger, each can be switched off or removed, + // right under its name rather than at the end of a long panel. + const manage = this.d.handlers.length > 1 + ? `
${enableSwitch('h-enabled', h.enabled, h.enabled ? 'This trigger is on' : 'This trigger is off')} +
` : ''; return `
WHEN${esc(triggerLabel(t))}
- ${typeSel}${body} -
-
${quietHtml(this.d)}
${more}`; + ${manage}${typeSel}${body} +
For the whole integration
+
Every trigger shares these.
+
+
${quietHtml(this.d)}
`; }, input(el){ const b = el.dataset.bind, h = this.handler(), t = h.trigger; @@ -2859,7 +2891,7 @@ const Builder = { return true; } case 'b-del-handler': - if (d.handlers.length < 2 || !armConfirm(el, 'Remove it and its steps?')) return true; + if (d.handlers.length < 2 || !armConfirm(el, 'Click again: removes it and its steps')) return true; d.handlers.splice(this.h, 1); this.h = 0; this.sel = 'trigger'; this.target = []; this.run = null; this.changed(); From 8470ee322fab565feb74b5fb503d5c203b3d8292 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 4 Oct 2026 16:21:17 +0200 Subject: [PATCH 50/55] release: 0.1.15 - integrations ready to ship, delay tail, release review fixes Stream path: - Stopping in OBS airs the rest of the delay, then ends (also an unprotected crash); End now and Cut delay cut it short; restarting OBS mid-tail reconnects at the delay. Supervisor waits for the tail. - OBS ends Enhanced Broadcasting streams with SequenceEnd tags stamped 0: expand_ts pins steps back over 10 s instead of rewinding the timeline. - After a freeze hold ended (End now or expiry), a later pump or cut never seeds on a pre-freeze keyframe (fresh_from_seq), so nothing re-airs. - A 9:16 destination gets a grace period before teardown and is never torn down during a hold, so OBS coming back doesn't drop it. - Egress backoff resets after a clean session of 5 s or more; one that ends at once backs off instead of redialling every second. Switching protection off clears the freeze latch. Security: - Program and File steps can only be saved, switched on or imported from the streaming PC itself, or with a dashboard password (UI shows them as "Streaming PC only" with the reason). - Chat values in program arguments keep only letters, digits, spaces and _ . , : # + = / -, collapse "..", and can't start with an option. - Web request addresses are masked like webhook links. Integrations: - Import preview lists each trigger with who can fire it, and what it reaches (programs, files, delay changes from anyone in chat in red). - !setdelay takes whole seconds only (new "is a whole number" check); 0 turns the delay off. - Shared Chat partners' messages are ignored unless the new Connections > Twitch option is on. - Dock's dead Discord webhook row now opens the Integrations tab (/#integrations); End now answers 409 when nothing is left to end. - Message boxes keep their height across redraws; busy buttons stay disabled when a redraw asked for it. Also: 10 streamer-profile pump sims, stream-day session tests, issue form for integrations, experimental note with a Report it link, CHANGELOG and README for 0.1.15, version 0.1.15. 843 tests. --- .github/ISSUE_TEMPLATE/integrations.yml | 58 + CHANGELOG.md | 373 ++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.es.md | 12 +- README.md | 12 +- src/config.rs | 11 +- src/controller.rs | 204 ++- src/controller/hold_sim.rs | 145 ++ src/controller/user_sim.rs | 291 ++++ src/crash_hold.rs | 3 + src/integrations/activity.rs | 407 +++++ src/integrations/api.rs | 379 ++++- src/integrations/clock.rs | 30 + src/integrations/effects.rs | 396 ++++- src/integrations/engine.rs | 1006 ++++++++++- src/integrations/event.rs | 176 +- src/integrations/host.rs | 71 +- src/integrations/mod.rs | 7 + src/integrations/model.rs | 457 ++++- src/integrations/obsws.rs | 859 ++++++++++ src/integrations/presets.rs | 1017 ++++++++++- src/integrations/recipe.rs | 99 +- src/integrations/runner.rs | 923 +++++++++- src/integrations/session.rs | 627 +++++++ src/integrations/store.rs | 3 + src/integrations/template.rs | 44 + src/integrations/timeline.rs | 246 +++ src/integrations/twitch/irc.rs | 18 + src/integrations/twitch/mod.rs | 7 + src/main.rs | 37 +- src/midi.rs | 2 + src/obs_register.rs | 86 +- src/web.rs | 102 +- web/dock.html | 2 +- web/dock.js | 56 +- web/index.html | 347 +++- web/integrations.js | 2036 +++++++++++++++++++---- 38 files changed, 9746 insertions(+), 807 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/integrations.yml create mode 100644 src/controller/user_sim.rs create mode 100644 src/integrations/activity.rs create mode 100644 src/integrations/obsws.rs create mode 100644 src/integrations/session.rs create mode 100644 src/integrations/timeline.rs diff --git a/.github/ISSUE_TEMPLATE/integrations.yml b/.github/ISSUE_TEMPLATE/integrations.yml new file mode 100644 index 0000000..9adc3df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/integrations.yml @@ -0,0 +1,58 @@ +name: Integrations (experimental) +description: A bug, a rough edge or an idea for the Integrations tab. +title: "[integrations] " +labels: ["integrations"] +body: + - type: markdown + attributes: + value: | + Integrations are new and experimental, so every report helps shape them. Thanks for testing. + + - type: input + id: version + attributes: + label: InstantClone version + description: Top-right of the dashboard, or the `v0.1.15` in the .exe name. + placeholder: "v0.1.15" + validations: + required: true + + - type: dropdown + id: kind + attributes: + label: What is it? + options: + - Something doesn't work + - Something is confusing + - An idea or a missing feature + validations: + required: true + + - type: input + id: integration + attributes: + label: Which integration + description: Its name on the card, like "Smart platform alerts" or one you built. + placeholder: "Highlight button" + + - type: textarea + id: what-happened + attributes: + label: What happened, or what you'd like + description: In your words. Screenshots help a lot. + validations: + required: true + + - type: textarea + id: run + attributes: + label: The run log (if something failed) + description: Open the integration and look at its last runs. Paste the steps and what each one answered. Check it first for links, keys or tokens and remove them. + render: text + + - type: textarea + id: recipe + attributes: + label: The integration as a recipe (optional) + description: On the card, use Share and paste it here. Recipes never contain your webhooks, keys or tokens. + render: text diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3f609..3fb8649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,69 @@ All notable changes will land here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.1.15] - Crash protection, Integrations (experimental), vertical without Twitch, and Enhanced Broadcasting on your GPU + +### Crash protection: OBS crashes, the stream stays up + +When OBS crashed, froze or lost its connection, every destination went +offline with it, and viewers were gone by the time OBS was back. Turn on +**Settings -> System -> Crash protection** and they stay live on a +reconnect screen instead, for up to the time you pick (30 s to 5 min). + +- **A reconnect screen, not a black frame.** Five styles: **Whisper** + (pulsing dots), **Beacon** (radar rings), **Orbit** (a spinner), + **Studio** (a lower third, lifted clear of TikTok's buttons on vertical + canvases) and **Arcade** (pixel art). Pick your own accent, background, + headline and subline. Settings shows every style as a card with your + colours and text on it, plus an animated preview in 16:9 and 9:16, + drawn by the same code that streams it. Every style animates in small + steps over a small area, so the loop stays light (under 1 Mbps at + 1080p). It plays at the resolution each destination receives, with + silent audio, after any buffered delay has aired, and timestamps carry + on from the last real frame, so platforms see one unbroken stream. +- **OBS coming back resumes on the same connection.** No reconnect on the + platform side: the stream picks up at OBS's first keyframe. With a delay + armed, the screen stays up until the delay has rebuilt, so viewers never + see live. +- **Frozen OBS counts too.** OBS still connected but sending no video for + 3 seconds opens the same screen, and its next frame closes it. +- **Enhanced Broadcasting is covered.** Every track of a Twitch multitrack + stream gets its own screen at its own resolution, the VOD audio track + gets its own silence, and OBS reconnecting is handed the same Twitch + session, so the destination carries on instead of restarting. Twitch + VOD-audio sessions are kept the same way. +- **HEVC and AV1 hold the last frame.** The screen is H.264, so those + tracks re-send their last keyframe once a second instead. +- **Stopping in OBS never opens the screen,** even while it is up for a + frozen OBS: the stream ends once your delay has aired. Only a drop + without a goodbye opens it. **Protect every disconnect** (under + Advanced) covers encoders that fail cleanly, like an NVENC error. +- **End it early from anywhere.** A banner on the dashboard and a strip in + the OBS dock show the countdown with **End now** (click twice), the tray + menu has **End crash protection** while it is on air, and it can be + bound to a global hotkey or a MIDI pad. `POST /crash-protection/end` + does the same for a Stream Deck. +- **You hear about it.** The destination card reads **Reconnect screen**, + the tray status shows the time left, and the "Stream alerts" integration + posts on Discord when the screen goes on air, when OBS is back, and when + it ends. +- **Good to know.** After a crash, OBS asks whether to run in Safe Mode: + pick **Run in Normal Mode**, or your scripts and plugins (the VOD track + script too) stay off. The banner says so while it waits. The hold time + counts from the crash, so a delay still airing uses part of it. A delay + as long as the hold time or longer airs in full, and then the stream + ends. ### Integrations (experimental): InstantClone talks to Discord, Twitch chat, your phone and your tools -A new **Integrations** tab. Every card shows exactly what it sends (the -Discord message, the chat reply, the phone push, the VOD marker), so you -know what each one does before you switch it on. +A new **Integrations** tab. Each card previews its Discord message, chat +reply, phone push or VOD marker before you switch it on. Integrations are **experimental**: they work, but how they look and how you set them up (mostly the screens) may still change between versions. -The tab says so too. +Please try them and tell us what breaks or confuses you: the tab's +**Report it** link opens an integrations issue form, and a recipe from +**Share** shows what you built without any of your keys. - **A catalog of ready-made integrations**, one click to add: a crash alert on Discord, a `!delay` command that always answers with your @@ -36,6 +88,10 @@ The tab says so too. captures what they actually saw. - **Test runs** send the real messages marked `[TEST]`, skip the waits and never touch the stream, then show every step and what it answered. + A **dry run** sends nothing at all and shows what each step would have + sent, and you can test with your own values ("a Kick drop", "a mod + typing !chapter Intro") instead of the samples. A test of a "Kick only" + alert reads Kick, not YouTube. - **Chat commands that call any API.** Add **Command from a website** and paste the command a site gives you for Nightbot, StreamElements, Fossabot or Streamlabs: the address, the reply and the command name fill @@ -51,8 +107,9 @@ The tab says so too. message to "back after 38 s" instead of adding another. Any Discord step can "edit the last one". - **Start an integration from a hotkey or a MIDI pad**, even in game. - The new **Clip button** clips the last moments, drops the link in chat - and says so on stream, from one key. + The new **Clip button** clips the moment (once it has aired, so the delay + never makes it clip the wrong seconds), drops the link in chat and says + so on stream, from one key. - **Show on stream.** Add InstantClone's alerts page to OBS once as a browser source; "Show on stream" steps put a card on it, queued so two never overlap. Tests never put anything on stream. @@ -85,35 +142,144 @@ The tab says so too. own thread; a slow webhook or a busy chat never delays a frame, and a failing integration shows up in the tab with the reason instead of failing quietly. - -Your existing Discord webhook moves over by itself: it becomes a Discord -connection plus a "Stream alerts" integration that sends the same -messages as before. - -### Security - -- **Websites can't reach the dashboard any more.** With no dashboard - password, a web page could reach InstantClone through DNS rebinding: a - domain that re-resolves to your own PC. It could then save an - integration that runs a program. The dashboard now answers only when - it's opened by its address (`127.0.0.1`, `localhost` or a LAN IP). - Opening it by a name, such as behind a reverse proxy, needs a password. - Overlays, the alerts page and web call links keep working from - anywhere. -- **Your settings can't be read by other websites.** Every page used to - let any website read it. Now only the overlay, alerts and delay-status - pages can be read from another site. The config (stream keys), - destinations, integrations and logs can't. -- **Chat can't add arguments to a program.** A value typed in chat stays - one argument, whatever spaces or quotes it contains. -- **Recipes leave out more**: request bodies and program arguments too. - An import is now cleaned the same way, so a hand-made recipe can't - bring in a web address, a program or a hotkey. -- **Web requests don't follow redirects.** A redirect could have pointed - a request at the dashboard itself and posted its answer to chat. The - step now says where the server moved instead. - -### Fixes +- **Discord cards.** Any Discord step can send a card: a colored edge, a + title, fields side by side, a footer with the time, images, and a line + above it where a clip link unfurls into a player. You write on the card + itself in the editor, pick its color from swatches, and add or resize + fields in place. The catalog's Discord alerts are cards now. +- **Smart platform alerts.** Quiet on blips: one card when a platform + is really down (still down after 60 s), turning green when it's back; + one card when it keeps dropping (3 drops in 10 min), updating its count + live and turning green once it's steady again; and a health report + when the stream ends. Every threshold is yours to change. +- **Stream timeline.** One Discord card per stream that writes itself: + crashes, delay changes, platform outages, highlights and chapters, each + with its place in the VOD (the delay is taken into account). Any + integration can add a line with the new **Timeline** step. +- **Highlight button.** A hotkey, MIDI pad or Stream Deck button clips the + moment once it has aired, marks the VOD, adds it to the timeline and + posts it to Discord. When Twitch can't clip, the moment is still marked + and the stream shows "Marked, but Twitch couldn't clip it". A + `!highlight` for mods is one switch away, and answers the mod either way. + Presses during a long delay queue up instead of being turned away. +- **Hype clip.** Chat decides when to clip. The new **Chat gets busy** + trigger takes rules you stack (busier than normal, how many people, + the words they use), all or any of them, over a window you pick. + "Busier than normal" is measured against your own chat, so one setting + fits a small and a huge channel. A live meter shows where chat is + against each rule while you tune it. It fires once per burst, then waits + for chat to calm down, so one hype moment is one clip. +- **Chapters.** Mods type `!chapter Boss fight` to mark the VOD; when the + stream ends you get a YouTube chapter list ready to paste, with + YouTube's rules applied (the first at 0:00, each at least 10 s, three or + more). An optional "Technical difficulties" chapter for crashes goes + back to the chapter before once OBS is back. +- **On-air light.** A light by your door: red when live, amber with the + delay on, flashing if OBS crashed, off at the end. Pick Home Assistant, + WLED or Philips Hue and paste its address. The light hears the current + state as soon as the app starts or the integration is switched on. +- **Scene delay.** Your OBS scene runs the delay: on in game, back to live + in the lobby. It only acts once a scene has stayed on air (3 s by + default), so flicking through scenes does nothing, and never during the + reconnect screen. Scenes come straight from OBS's own WebSocket server, + whose settings InstantClone reads itself: switch the server on in OBS + and pick from your scene list. The "back to live" rule is "any other + scene", which never counts the game scene, and starting the stream + already in game puts the delay on too. Scene and button triggers can be + limited to while you stream. +- **OBS steps.** Switch to a scene, show or hide a source, or change what + a text source says, from any integration. Scenes and sources are picked + from OBS's own lists, and chat can never choose them. +- **A builder for real flows.** Steps move into and out of checks with + the arrow buttons, any step can be switched off without deleting it, + removing a step can be undone, and new blocks land right after the step + you picked. Each step only offers the values it can really use, grouped + by where they come from, and a step that misses something says what. + Saving keeps the builder open. +- **Times read like times.** Every wait, cooldown and timer takes "10s", + "2m" or "1m 30s". +- **Buttons work everywhere.** A hotkey or MIDI pad trigger is now a + **button**: it also gets its own address a Stream Deck, Bitfocus + Companion or any program can press. That works on any system, not just + Windows. +- **New moments to react to:** a stream starts or ends (a crash OBS comes + back from is the same stream), the on-air state changes, a destination + comes back, stays down, keeps dropping or is steady again, and a line + is added to the timeline. New variables: `{uptime}`, `{vod_time}`, + `{onair}`, `{timeline}` and `{chapters}`. A Discord message can now also + be **finished**: edited one last time, and the next alert starts fresh. +- **New packs:** Stream pro, Highlights and Studio. Every catalog entry and + pack shows everything it needs (Discord, Twitch, OBS…) and what's + already connected. +- **Recipes say what they do before you add them:** who can start each + integration (anyone in chat, only mods…) and everything it reaches. A + program or file on your PC, or a delay anyone in chat can change, shows + in red; the delay and OBS in amber. +- **Shared Chat is your choice.** While you share chat with other + streamers, their viewers can't use your commands or count toward "chat + gets busy" unless you switch on "listen to partner channels" in + Connections › Twitch. +- **Clearer editors.** Fields show what they will say (sample values + filled in, links and bold drawn like Discord) and turn back into what + you wrote while you edit them. Insert buttons say what they hold ("how + long it was down") instead of variable names, options read as plain + sentences ("Repeat at most: once every 10 s", "Each time: updates the + same message"), a card that misses something says what under its name, + and the editor's switch says On or Off (draft). + +Fixes in the new integrations, found before release: + +- A network blip with crash protection off no longer ends the stream: + OBS has a minute to come back before the health report, the chapters + and a new timeline go out. +- Stopping on purpose with "hold on every disconnect" is no longer counted + as a crash, and doesn't make the on-air light flash. +- A platform that keeps reconnecting no longer floods the timeline, and a + full timeline drops plain lines before chapters and highlights. +- A "down" or "keeps dropping" card left open when a stream ended is no + longer edited from the next stream, far up the channel. +- Timeline card updates arrive in order, wait out Discord's rate limit + instead of failing, and a highlight that finished after the stream + ended no longer adds a `0:00` line. +- A card with only a title or fields can be switched on; the "Ping" option + shows when it can actually ping; each words rule counts its own words; + OBS's crash reasons are only offered where they can match, and filters + take lists and wildcards (`YouTube, Kick`, `*timed out*`). +- Shared recipes keep the on-air light's states and give buttons a fresh + Stream Deck address, and a switched-off trigger or step no longer stops + the rest from being switched on. +- Recipes can never pick an OBS scene or source, or keep a web request's + body by claiming to be a light; a card's link and images can't come + from chat; a wait filled in from chat lasts a minute at most. +- An OBS request that timed out is dropped instead of being done late, and + a busy chat (a raid) is judged a few times a second instead of on every + message. Counters are saved with the rest every few seconds. +- An OBS drop without crash protection shows in the health report and + lights the on-air light, and a moment's reconnect of OBS's WebSocket no + longer re-runs the scene delay. +- Adding a pack before any Discord channel opens the Discord setup right + away, and the pack's integrations switch on once it's saved. +- In the builder, Alt+Up and Alt+Down move the picked step, Ctrl+D copies + it and Delete removes it. +- On a smaller window, the builder's steps keep their text readable: only + the picked step shows its move, copy and delete buttons, on a row of + their own. Chat-activity rules fit the side panel too. +- A switched-on integration that pushes to your phone says "Needs your + phone" on its card until a phone is connected. +- `!setdelay 0` turns the delay off. It used to set your default delay + instead, while chat said "Delay set to 0s". A "Set the delay" step left + blank still uses your default, and a new Mod controls answers + `!setdelay 9999` with the 600 s limit instead of claiming it. +- `!setdelay` takes whole seconds only. `!setdelay 0.5` used to set 1 s + and `!setdelay 0.0004` your default delay, while chat said otherwise; a + new Mod controls now answers with how to use it, and checks have a new + "is a whole number". +- Removing a Discord channel that couldn't be removed says so, instead of + "Channel removed". +- A card's message box no longer shrinks and cuts its text when the + editor redraws while you type in it (most visible on a phone). + +Also fixed before release: - **InstantClone no longer crashes in the first hour after Windows starts.** The Twitch login check worked out "an hour ago" from the @@ -176,58 +342,18 @@ messages as before. - A chat reply starting with `/` stays within Twitch's 500 characters. - Logins in web addresses (`user:pass@`) are hidden like the rest. - Counters named after viewers stop at 5,000. -- **Discord webhooks work.** The HTTPS client checked certificates - against a bundled list instead of Windows' own store, and refused - Discord's certificate chain, so every Discord alert failed silently. It - now uses the system store. +- When the Twitch account you logged in with isn't the channel your + Twitch destination streams to, Connections says so, and markers and + clips show "Other channel" instead of failing with Twitch's "offline". -## [0.1.15] - Crash protection, vertical without Twitch, and Enhanced Broadcasting on your GPU +Your existing Discord webhook moves over by itself: it becomes a Discord +connection plus a "Stream alerts" integration that sends the same +messages as before. -### Crash protection: OBS crashes, the stream stays up - -When OBS crashed, froze or lost its connection, every destination went -offline with it, and viewers were gone by the time OBS was back. Turn on -**Settings -> System -> Crash protection** and they stay live on a -reconnect screen instead, for up to the time you pick (30 s to 5 min). - -- **A reconnect screen, not a black frame.** Five styles: **Whisper** - (pulsing dots), **Beacon** (radar rings), **Orbit** (a spinner), - **Studio** (a lower third, lifted clear of TikTok's buttons on vertical - canvases) and **Arcade** (pixel art). Pick your own accent, background, - headline and subline. Settings shows every style as a card with your - colours and text on it, plus an animated preview in 16:9 and 9:16, - drawn by the same code that streams it. Every style animates in small - steps over a small area, so the loop stays light (under 1 Mbps at - 1080p). It plays at the resolution each destination receives, with - silent audio, after any buffered delay has aired, and timestamps carry - on from the last real frame, so platforms see one unbroken stream. -- **OBS coming back resumes on the same connection.** No reconnect on the - platform side: the stream picks up at OBS's first keyframe. With a delay - armed, the screen stays up until the delay has rebuilt, so viewers never - see live. -- **Frozen OBS counts too.** OBS still connected but sending no video for - 3 seconds opens the same screen, and its next frame closes it. -- **Enhanced Broadcasting is covered.** Every track of a Twitch multitrack - stream gets its own screen at its own resolution, the VOD audio track - gets its own silence, and OBS reconnecting is handed the same Twitch - session, so the destination carries on instead of restarting. Twitch - VOD-audio sessions are kept the same way. -- **HEVC and AV1 hold the last frame.** The screen is H.264, so those - tracks re-send their last keyframe once a second instead. -- **Stopping in OBS still ends the stream right away,** even while the - screen is up for a frozen OBS. Only a drop without a goodbye opens it. **Protect every disconnect** (under - Advanced) covers encoders that fail cleanly, like an NVENC error. -- **End it early from anywhere.** A banner on the dashboard and a strip in - the OBS dock show the countdown with **End now** (click twice), the tray - menu has **End crash protection** while it is on air, and it can be - bound to a global hotkey or a MIDI pad. `POST /crash-protection/end` - does the same for a Stream Deck. -- **You hear about it.** The destination card reads **Reconnect screen**, - the tray status shows the time left, and the Discord webhook posts when - the screen goes on air, when OBS is back, and when it ends. -- **Good to know.** The hold time counts from the crash, so a delay still - airing uses part of it. A delay as long as the hold time or longer airs - in full, and then the stream ends. +Going back to 0.1.14 afterwards: 0.1.14 doesn't know integrations, so it +stops posting to Discord and drops your integrations the first time it +saves its settings. Copy `instantclone.config.json` first if you might go +back. ### Vertical (9:16) no longer needs a Twitch destination @@ -305,8 +431,83 @@ Now it copies you instead: The event log says which one you got, for example `main track uses your OBS stream settings: h264_texture_amf, 6000 kbps`. +### Security + +- **Websites can't reach the dashboard any more.** With no dashboard + password, a web page could reach InstantClone through DNS rebinding: a + domain that re-resolves to your own PC. It could then save an + integration that runs a program. The dashboard now answers only when + it's opened by its address (`127.0.0.1`, `localhost` or a LAN IP). + Opening it by a name, such as behind a reverse proxy, needs a password. + Overlays, the alerts page and web call links keep working from + anywhere. +- **Your settings can't be read by other websites.** Every page used to + let any website read it. Now only the overlay, alerts and delay-status + pages can be read from another site. The config (your ingest key and webhooks), + destinations, integrations and logs can't. +- **Programs and files are set up on the streaming PC.** With the + dashboard open to your network and no password, anyone on the network + could add a step that runs a program and start it from chat. Run a + program and Write a file can now only be added, changed or switched on + from the streaming PC itself, or from anywhere once the dashboard has a + password. Elsewhere they show as locked and say why. +- **Chat can't run commands through a program.** A value typed in chat + stays one argument, and keeps only letters, numbers, spaces and simple + punctuation: a step like `cmd /c echo {arg1}` can't be talked into + running something else, and a value can't pass itself off as an option + (`--config`, `/s`). +- **Web request addresses are hidden** until you click Show, like webhook + links: they can hold keys (`?key=`, a Hue bridge username). +- **A stream key under six characters is no longer logged in the clear** + when a destination drops. Platform keys are never that short, but a + custom RTMP destination takes whatever you paste. +- **Recipes leave out more**: request bodies and program arguments too. + An import is now cleaned the same way, so a hand-made recipe can't + bring in a web address, a program or a hotkey. +- **Web requests don't follow redirects.** A redirect could have pointed + a request at the dashboard itself and posted its answer to chat. The + step now says where the server moved instead. + ### Fixes +- **Stopping in OBS no longer cuts the end of a delayed stream.** With a + delay on and no crash protection hold (protection off, or a stop it + lets through), the last seconds of the delay used to vanish: viewers + never saw your goodbye. Now the rest of the delay airs, then the stream + ends. A crash without protection airs what OBS sent before it, too. The + dashboard and dock show "Your delay is still airing" with a countdown, + and **End now** or **Cut delay** cuts it short. Starting OBS again before + it finishes reconnects the destinations at your delay, as before. +- **Restarting OBS no longer leaves a destination offline for up to 30 s.** + After a platform had refused a few connections, the wait between + retries stayed long even once it worked, so a quick OBS restart had to + sit it out. A stream that ended because of OBS now resets that wait, + and one that ends the moment it starts backs off instead of redialling + the platform every second. +- **Nothing airs twice after a freeze you ended.** When you ended the + reconnect screen of a frozen OBS (or it ran out) and OBS then recovered, + a destination starting afterwards could join on video from before the + freeze and replay it, sometimes over and over. It now joins OBS's new + video at your delay. +- **A 9:16 destination stays on when OBS comes back from a crash.** It was + ended if its vertical canvas arrived a moment after the main one; it now + gets a few seconds' grace. +- **End now when there's nothing left to end** says so, instead of "Stream + ended on every destination". +- **Discord webhooks work.** The HTTPS client checked certificates + against a bundled list instead of Windows' own store, and refused + Discord's certificate chain, so every Discord alert failed silently. It + now uses the system store. +- **The "Twitch · mobile risk" chip no longer flickers.** It read the + total bitrate one second at a time, so a stream at 8 Mbps bounced + across the line with every keyframe, and audio plus the VOD track + pushed a 7.8 Mbps video over it. It now reads the video alone, + averaged over 10 s, and clears only once it drops back under 7.7 Mbps. +- **A warning when OBS ignores Enhanced Broadcasting.** OBS only applies + Stream settings while nothing is running: tick EB with the Replay + Buffer on and the next stream still goes out single-track, with no + vertical canvas. When OBS's saved settings have EB on but the stream + arrives single-track, the dashboard says so and how to fix it. - **Crash protection keeps a new Twitch session when the hold ends first.** If OBS came back and asked for a fresh Enhanced Broadcasting session just as the hold ran out (or you pressed End now), the session was thrown away diff --git a/Cargo.lock b/Cargo.lock index 6221fb6..03ed2d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "instantclone" -version = "0.1.15-rc.1" +version = "0.1.15" dependencies = [ "bytes", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 0e3ced5..e280172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "instantclone" -version = "0.1.15-rc.1" +version = "0.1.15" edition = "2021" authors = ["s1moscs"] license = "GPL-3.0-only" diff --git a/README.es.md b/README.es.md index 0fd9fc2..b9b0fb1 100644 --- a/README.es.md +++ b/README.es.md @@ -207,6 +207,14 @@ Mapea las mismas acciones a un pad o un knob, aprendidos pulsando el control en **🛡 Protección contra crasheos** Si OBS se cuelga o se congela, todos los destinos siguen en directo con una pantalla de reconexión (cinco estilos, con tus colores y tu texto) y audio en silencio, hasta 5 minutos. Cuando OBS vuelve, retoma en la misma conexión desde su primer keyframe, Enhanced Broadcasting incluido: cada pista recibe su propia pantalla y OBS recibe la misma sesión de Twitch. Las pistas HEVC y AV1 mantienen su último fotograma. Termínala antes desde el panel, el dock de OBS, la bandeja, un atajo o un pad MIDI. + + + + + +**🧩 Integraciones (experimental)** +InstantClone habla con Discord, el chat de Twitch, tu móvil y tus propias herramientas. Con un clic añades un aviso de crasheo, un comando `!delay`, controles para mods, un botón de highlight que clipea lo que los espectadores vieron de verdad, una tarjeta de Discord que escribe la línea de tiempo de tu stream, capítulos de YouTube, una luz de "en directo" y más, y un editor por pasos cubre el resto. Son nuevas y aún pueden cambiar: cuéntanos lo que falle o confunda con el enlace **Report it** de la pestaña. + @@ -345,7 +353,7 @@ El HTML del panel se minifica + gzipea en tiempo de compilación con `build.rs` **E/S de disco síncrona en la ruta caliente de escritura al anillo, por elección.** La escritura con buffer aterriza en la caché de páginas del SO en microsegundos y el kernel vacía en segundo plano, así que la caché de páginas ya es el buffer asíncrono; el índice y los bytes avanzan bajo un solo lock para que un lector nunca vea una etiqueta cuyos bytes aún no están en disco. -**Tests.** `cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR de AVC + Enhanced-RTMP, AMF0 (incluido Strict Array + guardia de recursión), round-trip de settings, expulsión del buffer en anillo con protección de lecturas en vuelo, parseo HTTP, política CSRF, pre-flight de puerto, negociación de contenido, caché de cabeceras de secuencia por pista de Enhanced Broadcasting + selección de etiquetas por TrackId, audio multipista + ruteo por destino, parseo de orientación SPS para la selección vertical, la configuración de Enhanced Broadcasting construida a partir de los lienzos y codificadores GPU de OBS, el parcheador de `services.json`, el parser del check de actualizaciones, el SHA-256 hecho a mano (vectores NIST), el lector/escritor de chunk-stream RTMP, la máquina del corte programado, las tablas de atajos y de mapeos MIDI (incluido el dispositivo que distingue dos controladoras), la protección contra crasheos (distinguir crasheo, parada y congelado, el codificador sin pérdidas de la pantalla de reconexión comprobado con ffmpeg, el empaquetado por pista de Enhanced Broadcasting, el AAC en silencio), y la descarga de autoactualización + verificación de checksum + intercambio del exe. **474 tests, todos en verde.** +**Tests.** `cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR de AVC + Enhanced-RTMP, AMF0 (incluido Strict Array + guardia de recursión), round-trip de settings, expulsión del buffer en anillo con protección de lecturas en vuelo, parseo HTTP, política CSRF, pre-flight de puerto, negociación de contenido, caché de cabeceras de secuencia por pista de Enhanced Broadcasting + selección de etiquetas por TrackId, audio multipista + ruteo por destino, parseo de orientación SPS para la selección vertical, la configuración de Enhanced Broadcasting construida a partir de los lienzos y codificadores GPU de OBS, el parcheador de `services.json`, el parser del check de actualizaciones, el SHA-256 hecho a mano (vectores NIST), el lector/escritor de chunk-stream RTMP, la máquina del corte programado, las tablas de atajos y de mapeos MIDI (incluido el dispositivo que distingue dos controladoras), la protección contra crasheos (distinguir crasheo, parada y congelado, el codificador sin pérdidas de la pantalla de reconexión comprobado con ffmpeg, el empaquetado por pista de Enhanced Broadcasting, el AAC en silencio), el motor de integraciones (pasos, plantillas, recetas, chat, tarjetas de Discord, la línea de tiempo del stream), y la descarga de autoactualización + verificación de checksum + intercambio del exe. **843 tests, todos en verde.** @@ -355,7 +363,7 @@ El HTML del panel se minifica + gzipea en tiempo de compilación con `build.rs` ## Estado -**Listo para uso diario en Windows.** Lo uso en mis propios streams, y un grupo creciente de streamers lo corre a diario también. CI ejecuta fmt + clippy (`-D warnings`) + 474 tests en cada push, y un commit etiquetado compila y publica una release con un `SHA256SUMS.txt` al lado (todavía sin certificado de firma de código, así que el SO puede avisar al primer arranque). +**Listo para uso diario en Windows.** Lo uso en mis propios streams, y un grupo creciente de streamers lo corre a diario también. CI ejecuta fmt + clippy (`-D warnings`) + 843 tests en cada push, y un commit etiquetado compila y publica una release con un `SHA256SUMS.txt` al lado (todavía sin certificado de firma de código, así que el SO puede avisar al primer arranque). **Lo áspero, con honestidad** diff --git a/README.md b/README.md index b3370b5..1a0cd90 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,14 @@ Map the same actions to a pad or knob, learned by pressing the control rather th **🛡 Crash protection** If OBS crashes or freezes, every destination stays live on a reconnect screen (five styles, your colours and text) with silent audio, for up to 5 minutes. OBS coming back resumes on the same connection at its first keyframe, Enhanced Broadcasting included: each track gets its own screen and OBS is handed the same Twitch session. HEVC and AV1 tracks hold their last frame. End it early from the dashboard, the OBS dock, the tray, a hotkey or a MIDI pad. + + + + + +**🧩 Integrations (experimental)** +InstantClone talks to Discord, Twitch chat, your phone and your own tools. One click adds a crash alert, a `!delay` command, mod controls, a highlight button that clips what viewers actually saw, a Discord card that writes your stream's timeline, YouTube chapters, an on-air light and more, and a step builder covers the rest. They're new and may still change: please report what breaks or confuses you with the tab's **Report it** link. + @@ -345,7 +353,7 @@ The dashboard HTML is minified + gzipped at build time by `build.rs` (`flate2`, **Sync disk I/O on the ring-append hot path, by choice.** The buffered write lands in the OS page cache in microseconds and the kernel flushes in the background, so the page cache is already the async buffer; the index and the bytes advance under one lock so a reader never sees a tag whose bytes aren't on disk yet. -**Tests.** `cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced-RTMP IDR detection, AMF0 (including Strict Array + recursion guard), settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, multi-track audio + per-destination routing, SPS orientation parsing for vertical selection, the Enhanced Broadcasting config built from OBS's canvases and GPU encoders, the OBS `services.json` patcher, the update-check parser, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer, the scheduled safe-cut state machine, the hotkey and MIDI binding tables (including the device that tells two controllers apart), crash protection (crash vs. stop vs. freeze detection, the lossless reconnect-screen encoder checked against ffmpeg, per-track framing for Enhanced Broadcasting, silent AAC), and the self-update download + checksum-verify + exe swap. **474 tests, all green.** +**Tests.** `cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced-RTMP IDR detection, AMF0 (including Strict Array + recursion guard), settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, multi-track audio + per-destination routing, SPS orientation parsing for vertical selection, the Enhanced Broadcasting config built from OBS's canvases and GPU encoders, the OBS `services.json` patcher, the update-check parser, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer, the scheduled safe-cut state machine, the hotkey and MIDI binding tables (including the device that tells two controllers apart), crash protection (crash vs. stop vs. freeze detection, the lossless reconnect-screen encoder checked against ffmpeg, per-track framing for Enhanced Broadcasting, silent AAC), the integrations engine (steps, templates, recipes, chat, Discord cards, the stream timeline), and the self-update download + checksum-verify + exe swap. **843 tests, all green.** @@ -355,7 +363,7 @@ The dashboard HTML is minified + gzipped at build time by `build.rs` (`flate2`, ## Status -**Daily-driver ready on Windows.** I use it on my own streams, and a growing group of streamers now run it daily too. CI runs fmt + clippy (`-D warnings`) + 474 tests on every push, and a tagged commit auto-builds and publishes a release with a `SHA256SUMS.txt` alongside (no code-signing certificate yet, so the OS may warn on first launch). +**Daily-driver ready on Windows.** I use it on my own streams, and a growing group of streamers now run it daily too. CI runs fmt + clippy (`-D warnings`) + 843 tests on every push, and a tagged commit auto-builds and publishes a release with a `SHA256SUMS.txt` alongside (no code-signing certificate yet, so the OS may warn on first launch). **What's rough, honestly** diff --git a/src/config.rs b/src/config.rs index eb91673..4d09ff7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,6 +160,9 @@ pub struct Settings { /// in to Twitch instead of the one built into releases. Empty uses the /// built-in app. Not a secret: public apps have no client secret. pub twitch_client_id: String, + /// During Twitch Shared Chat, messages from the partner channels' + /// viewers trigger chat integrations too. Off: only this channel's. + pub twitch_shared_chat: bool, } #[derive(Debug, Clone, PartialEq)] @@ -927,6 +930,7 @@ impl Settings { // this after its one-time pass either way. integrations_migrated: false, twitch_client_id: String::new(), + twitch_shared_chat: false, } } @@ -1234,10 +1238,13 @@ impl Settings { writeln!(f, "hotkey.{}={}", action, combo)?; } } - // MIDI bindings, same only-when-bound rule. if !self.twitch_client_id.is_empty() { writeln!(f, "twitch_client_id={}", one_line(&self.twitch_client_id))?; } + if self.twitch_shared_chat { + writeln!(f, "twitch_shared_chat=true")?; + } + // MIDI bindings, same only-when-bound rule. if !self.midi_device.is_empty() { writeln!(f, "midi_device={}", one_line(&self.midi_device))?; } @@ -1391,6 +1398,7 @@ impl Settings { } "midi_device" => self.midi_device = sanitize_device_name(value), "twitch_client_id" => self.twitch_client_id = value.trim().to_string(), + "twitch_shared_chat" => self.twitch_shared_chat = value == "true", // midi.=. `set` validates and drops anything // malformed, so a hand-edited config can't load a bad signature. k if k.starts_with("midi.") => { @@ -3877,6 +3885,7 @@ Name@x" }, integrations_migrated: true, twitch_client_id: "abcdefghij0123456789klmnopqrst".into(), + twitch_shared_chat: true, } } diff --git a/src/controller.rs b/src/controller.rs index 812b70d..bd1c8ff 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -576,9 +576,10 @@ pub struct Controller { /// OBS is back): the delay tail ends there. A frozen OBS can keep /// sending audio, so the ring's own newest seq would keep moving. hold_tail_end_seq: AtomicU64, - /// The last hold ran out while OBS was still gone: a destination still - /// airing the delay tail finishes it, then ends (a delay as long as the - /// hold time or longer airs in full). Not when the streamer ended it. + /// OBS is gone with no hold covering it (crash protection off, OBS + /// stopped on purpose, or the hold ran out): a destination still airing + /// the delay tail finishes it, then ends, as if OBS had sent its end + /// through the delay too. Not when the streamer ended it. tail_plays_out: AtomicBool, /// "The buffer holds only N s" was logged for the armed delay; cleared /// on the next arm so each new value is checked once. @@ -626,6 +627,12 @@ pub struct Controller { // Ingest-side stats ingest_disconnects: AtomicU32, rate_in: RateMeter, // inbound (from OBS) + // 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, + // The current publisher connected over loopback, so the OBS profile on + // this machine is the one streaming. Set per publisher in `begin_publish`. + publisher_local: AtomicBool, // The integrations engine, attached once at startup. Events reach it // through `emit`, which never blocks and works from any thread. @@ -815,6 +822,8 @@ impl Controller { destinations: crate::sync::RwLock::new(HashMap::new()), ingest_disconnects: AtomicU32::new(0), rate_in: RateMeter::default(), + rate_video_in: RateMeter::default(), + publisher_local: AtomicBool::new(false), integrations: std::sync::OnceLock::new(), ingest_key: crate::sync::Mutex::new(String::new()), // 5 wrong keys then a short exponential lockout. A legit OBS uses @@ -1170,6 +1179,14 @@ impl Controller { return last as u64; } wrap_high -= 1; + } else if last - wire_ts > MAX_TS_STEP_BACK_MS { + // Far behind the timeline, and not a wrap: no interleaving is + // seconds long. OBS ends an Enhanced Broadcasting stream with + // SequenceEnd tags stamped 0, and adopting that stamp made the + // buffer's newest moment read 0: the delay still to air looked + // like none, and the destination was restarted mid-tail. Pin it + // to where we are, as for a stamp from before the session. + return ((wrap_high as u64) << 32) | (last as u64); } self.input_ts_wrap_high.store(wrap_high, Ordering::Relaxed); self.last_input_ts_u32.store(wire_ts, Ordering::Relaxed); @@ -1334,6 +1351,12 @@ impl Controller { // A manual cut supersedes any scheduled "cut after this airs" - // the streamer chose "now" over "when the mark airs". self.safe_cut_input_ts.store(0, Ordering::Relaxed); + // OBS already left and its delay is still airing: there is no live + // to cut to, so the cut ends the stream. Jumping to the newest + // keyframe would still air the stream's last moments. + if self.tail_left().is_some() { + self.end_tail_now(); + } } // --- "Cut after this airs" (scheduled safe cut) ------------------- @@ -1569,6 +1592,14 @@ impl Controller { pub fn bitrate_kbps(&self) -> u32 { self.rate_in.kbps() } + /// Inbound video bitrate alone (no audio tracks, no sequence headers). + pub fn video_bitrate_kbps(&self) -> u32 { + self.rate_video_in.kbps() + } + /// The current publisher connected from this machine. + pub fn publisher_local(&self) -> bool { + self.publisher_local.load(Ordering::Relaxed) + } // ---- Internal: ingest counters ---- @@ -1610,6 +1641,11 @@ impl Controller { // stream-key identity, so strip it here - otherwise the exact ingest key // arrives as `mykey?clientConfigId=...` and gets rejected as a wrong key. let stream_key = stream_key.split('?').next().unwrap_or(stream_key); + // An unparseable IP is treated as remote (fail-safe). + let local = peer_ip + .parse::() + .map(|a| a.is_loopback()) + .unwrap_or(false); // Ingest auth: when a key is configured, only a publisher using that // exact key gets in. Empty key (the default) accepts anyone, which is // the right behaviour on a local machine. Checked before the slot lock @@ -1622,11 +1658,8 @@ impl Controller { // behind an HTTP reverse proxy that could mask its IP - so it is // no brute-force threat and must not be locked out of its own // machine for a mistyped key. Apply the limiter to remote peers - // only; an unparseable IP is treated as remote (fail-safe). - let remote = !peer_ip - .parse::() - .map(|a| a.is_loopback()) - .unwrap_or(false); + // only. + let remote = !local; // Throttle first so a locked-out guesser burns no work. if remote && self.ingest_limiter.check(peer_ip).is_err() { self.log_rejected_publish("ingest: rejected publisher (rate limited)"); @@ -1733,6 +1766,7 @@ impl Controller { } // Bump token so any prior egress reader knows it's stale. let token = self.publisher_token.fetch_add(1, Ordering::SeqCst) + 1; + self.publisher_local.store(local, Ordering::Relaxed); self.ingest_alive.store(true, Ordering::Relaxed); self.resume_from_hold(); if resuming { @@ -1761,6 +1795,9 @@ impl Controller { // which are tiny and one-shot). if !is_seq { self.note_inbound_bytes(payload.len()); + if kind == 9 { + self.rate_video_in.note(payload.len()); + } } // Sample the encoder parameters the compatibility check compares // against the enabled destinations. Both are cheap: the dimension @@ -1987,12 +2024,29 @@ impl Controller { // Decide the hold before ingest reads as dead: a pump that sees // ingest gone with no hold open ends its destination. let stopped = self.unpublish_received.swap(false, Ordering::Relaxed); - if !stopped || self.crash_protection.lock().every_disconnect { + if !stopped { self.start_hold(crate::crash_hold::HoldReason::Crash); + } else if self.crash_protection.lock().every_disconnect { + self.start_hold(crate::crash_hold::HoldReason::Stopped); } else if self.close_hold(HOLD_END_ENDED).is_some() { // Stopped on purpose while a freeze hold was up: that ends it. self.log("crash protection: OBS stopped the stream - destinations ended"); } + // No hold took over: what the delay still holds airs, then the + // destinations end. Viewers see the stream end when they would have + // seen everything else, not the delay's length early. + if !self.hold_active() { + self.hold_tail_end_seq + .store(self.ring.latest_seq().unwrap_or(NO_SEQ), Ordering::Relaxed); + self.tail_plays_out.store(true, Ordering::Relaxed); + let behind = self.current_delay_ms(); + if behind >= 1_000 { + self.log(format!( + "delay: airing the last {} s before the destinations end (End now cuts it)", + behind / 1000 + )); + } + } // Only count when transitioning alive → dead, so a stray call // doesn't inflate the counter. if self.ingest_alive.swap(false, Ordering::Relaxed) { @@ -2109,6 +2163,15 @@ impl Controller { Some(self.resumed_after_seq.load(Ordering::Relaxed)).filter(|seq| *seq != NO_SEQ) } + /// The first ring seq a destination may start from. After a frozen OBS + /// recovers, everything it sent before the freeze has already aired + /// (as the delay tail of the hold), so a pump that starts later, or a + /// cut back to restore the delay, must never land there and replay it. + /// 0 when OBS never froze in this session. + fn fresh_from_seq(&self) -> u64 { + self.resumed_after_seq().map_or(0, |seq| seq + 1) + } + /// The latest keyframe of a track the hold re-sends instead of the /// reconnect screen. pub fn held_keyframe(&self, track: u8) -> Option> { @@ -2129,6 +2192,7 @@ impl Controller { // Off means no bookkeeping, and nothing stale left behind: an // old video stamp would read as a freeze and hold back egress. self.last_video_tag_ms.store(0, Ordering::Relaxed); + self.ingest_frozen.store(false, Ordering::Relaxed); self.held_keyframes.lock().clear(); self.slate_cache.clear(); } @@ -2218,6 +2282,7 @@ impl Controller { let what = match reason { crate::crash_hold::HoldReason::Crash => "OBS dropped", crate::crash_hold::HoldReason::Freeze => "OBS stopped sending video", + crate::crash_hold::HoldReason::Stopped => "OBS stopped the stream", }; self.log(format!( "crash protection: {what} - destinations stay live on the reconnect screen for up to {window}" @@ -2229,6 +2294,7 @@ impl Controller { match reason { crate::crash_hold::HoldReason::Crash => "crash", crate::crash_hold::HoldReason::Freeze => "freeze", + crate::crash_hold::HoldReason::Stopped => "stopped", }, ) .with("hold", fmt_duration(hold_for.as_millis() as u64)) @@ -2359,11 +2425,11 @@ impl Controller { )); } - /// End the hold now (dashboard, dock, tray or hotkey). True when a hold - /// was open. + /// End the hold now (dashboard, dock, tray or hotkey), or the delay + /// tail still airing after OBS left. True when either was on. pub fn end_hold_now(&self) -> bool { let Some(hold) = self.close_hold(HOLD_END_ENDED) else { - return false; + return self.end_tail_now(); }; self.tail_plays_out.store(false, Ordering::Relaxed); let lasted = crate::crash_hold::minutes_seconds(hold.started.elapsed()); @@ -2377,6 +2443,30 @@ impl Controller { true } + /// Stop airing the delay tail: the destinations end at once. + fn end_tail_now(&self) -> bool { + if self.tail_left().is_none() { + return false; + } + self.tail_plays_out.store(false, Ordering::Relaxed); + self.log("delay: ended now - the rest of the delay didn't air"); + true + } + + /// How much of the delay is still to air after OBS left with no hold + /// covering it: `None` once no destination is airing it. + pub fn tail_left(&self) -> Option { + if !self.obs_gone() || self.hold_active() || !self.tail_plays_out() { + return None; + } + let airing = self + .all_destination_states() + .iter() + .any(|(_, state)| state.egress_alive.load(Ordering::Relaxed)); + let left = self.current_delay_ms(); + (airing && left > 0).then(|| Duration::from_millis(u64::from(left))) + } + /// Attach the integrations engine. Called once at startup; events /// emitted before it is attached are dropped. pub fn attach_integrations(&self, handle: Arc) { @@ -2802,17 +2892,21 @@ pub async fn run_egress( // Not reading any more: its position must not hold back the // buffer's trim while it reconnects or waits. dest.consumer_seq.store(u64::MAX, Ordering::Relaxed); - // Only a session that stayed up proves the endpoint works. - // A platform that accepts the publish and drops it at once - // (a bad key, a refusing edge) backs off like a failed - // connect instead of being redialled every second forever. - if connected_at.elapsed() >= STABLE_SESSION { - backoff = Duration::from_secs(1); - } // Only a platform connection that failed counts as a // reconnect. The pump also ends cleanly when OBS goes away // or the destination is switched off; neither is one. let dropped = pump_result.is_err(); + // A session that stayed up, or ended because of OBS rather + // than the platform, proves the endpoint works: an OBS + // restart must not wait out backoff left from old failures. + // A platform that accepts the publish and drops it at once + // (a bad key, a refusing edge), or a session that ends + // cleanly the moment it starts, backs off like a failed + // connect instead of being redialled every second forever. + let lasted = connected_at.elapsed(); + if (!dropped && lasted >= MIN_CLEAN_SESSION) || lasted >= STABLE_SESSION { + backoff = Duration::from_secs(1); + } if let Err(e) = pump_result { // Twitch/etc. sometimes echo the stream key in error // descriptions ("Authentication failed for live_…"). @@ -2996,6 +3090,8 @@ async fn pump_dest( // Always lead with sequence headers + the IDR itself. send_sequence_headers(ctrl, dest, &mut sink, state.output_ts_base).await?; + // Airing what the delay held when OBS left with no hold covering it. + let mut airing_tail = false; loop { // Cooperative shutdown: when the supervisor flips this, we end // the session cleanly (sending deleteStream) instead of dropping @@ -3039,6 +3135,7 @@ async fn pump_dest( let _ = sink.send_delete_stream().await; return Ok(()); } + airing_tail = true; } else if delay_tail_sent(ctrl, &state) { let outcome = crate::crash_hold::play(ctrl, dest, &mut sink, last_output_ts(&state)).await?; @@ -3072,6 +3169,18 @@ async fn pump_dest( // tag - the upstream stream would freeze forever even though // ingest is happily receiving bytes. let current_token = ctrl.publisher_token(); + if current_token != state.last_publisher_token && airing_tail { + // OBS started again before the old stream's delay finished + // airing. The new session wiped the buffer, and rejoining here + // would air its video live: end, and the supervisor reconnects + // at the delay, as it would after a stop. + ctrl.log(format!( + "[{}] OBS started again while the delay was airing - reconnecting", + dest.id + )); + let _ = sink.send_delete_stream().await; + return Ok(()); + } if current_token != state.last_publisher_token { ctrl.log(format!("[{}] publisher reconnect - re-anchoring", dest.id)); let watermark = ctrl.ring.latest_seq().unwrap_or(0); @@ -3558,9 +3667,11 @@ fn idr_search_tolerance_ms(keyframe_interval_ms: u32) -> u32 { /// buffer reaches back that far. fn delayed_idr(ctrl: &Controller, target_ms: u64) -> Option { let latest = ctrl.ring.latest_ts()?; + let fresh = ctrl.fresh_from_seq(); let at_delay = latest .checked_sub(target_ms) - .and_then(|desired| ctrl.ring.newest_idr_at_or_before(desired)); + .and_then(|desired| ctrl.ring.newest_idr_at_or_before(desired)) + .filter(|idr| idr.seq >= fresh); // A full ring reaches back no further at this bitrate, so a longer // delay could never fill: it delays by as much as the ring holds // instead of waiting forever (`is_saturated`). Not from its very oldest @@ -3573,7 +3684,8 @@ fn delayed_idr(ctrl: &Controller, target_ms: u64) -> Option { let oldest = ctrl.ring.oldest_ts()?; ctrl.ring .newest_idr_at_or_before(oldest + FULL_RING_MARGIN_MS) - .or_else(|| ctrl.ring.oldest_idr_at_or_after(0)) + .filter(|idr| idr.seq >= fresh) + .or_else(|| ctrl.ring.oldest_idr_at_or_after(fresh)) }) } @@ -3589,6 +3701,16 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); /// from the shortest backoff again. const STABLE_SESSION: Duration = Duration::from_secs(30); +/// A session that ends cleanly (OBS stopped, the destination was switched +/// off) resets the backoff only after this long. One that ends the moment +/// it starts would otherwise redial the platform every second. +const MIN_CLEAN_SESSION: Duration = Duration::from_secs(5); + +/// How far a tag may be stamped behind the last one and still be read as +/// audio and video interleaving (a few ms in practice). Anything further +/// back is pinned to the current moment (see `Controller::expand_ts`). +const MAX_TS_STEP_BACK_MS: u32 = 10_000; + /// A delay lands at or above its target (see `delayed_idr`); this much /// below it still counts as on target, for send jitter. const DELAY_JITTER_MS: u64 = 500; @@ -3963,7 +4085,10 @@ async fn wait_for_idr( let notified = ctrl.ring.on_append.notified(); let found = match min_seq { Some(seq) => ctrl.ring.newest_idr_after(seq), - None => ctrl.ring.newest_idr(), + None => ctrl + .ring + .newest_idr() + .filter(|idr| idr.seq >= ctrl.fresh_from_seq()), }; if let Some(m) = found { return Some(m); @@ -4065,6 +4190,9 @@ mod hold_sim; #[cfg(test)] mod delay_sim; +#[cfg(test)] +mod user_sim; + #[cfg(test)] mod tests { use super::*; @@ -5044,6 +5172,38 @@ mod tests { /// 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); + let v = [0x27u8; 64]; + h.ctrl.on_tag(9, 200_000, &v, false, false); + h.ctrl.on_tag(9, 200_033, &v, false, false); + + h.ctrl.on_tag(9, 0, &v, false, false); + assert_eq!( + h.ctrl.ring.latest_ts(), + Some(200_033), + "pinned, not rewound" + ); + + h.ctrl.on_tag(8, 200_020, &v, false, false); + assert_eq!( + h.ctrl.ring.latest_ts(), + Some(200_020), + "ordinary interleaving still reads as stamped" + ); + h.ctrl.on_tag(9, 200_066, &v, false, false); + assert_eq!( + h.ctrl.ring.latest_ts(), + Some(200_066), + "and the timeline goes on" + ); + } + #[test] fn a_late_tag_at_the_wrap_stays_in_the_epoch_it_came_from() { let h = harness(0); diff --git a/src/controller/hold_sim.rs b/src/controller/hold_sim.rs index 8728dec..f29f674 100644 --- a/src/controller/hold_sim.rs +++ b/src/controller/hold_sim.rs @@ -378,6 +378,107 @@ async fn end_now_mid_tail_ends_the_stream_at_once() { ); } +/// OBS leaves with a 2 s delay on air and no hold to cover it. Returns the +/// last frame OBS made and when it left. +async fn obs_leaves_mid_delay(sim: &mut Sim, stopped: bool) -> (u32, Instant) { + sim.ctrl.arm_delay(2_000); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(2_500).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(1_500).await; + let last_live = sim.next_frame - 1; + if stopped { + sim.obs_stops(); + } else { + sim.obs_crashes(); + } + (last_live, Instant::now()) +} + +/// 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(); + assert_eq!( + tail.last().map(|f| f.number), + Some(last_live), + "the whole delay aired" + ); + assert_strictly_increasing(&tail, "the tail"); + let took = tail.last().unwrap().at.duration_since(left).as_millis(); + assert!( + took >= 1_500, + "the tail took the delay to air, not {took} ms" + ); + assert!( + sim.commands(0).contains(&"deleteStream".into()), + "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); +} + +/// Crash protection off, OBS stops on purpose with a delay on: the end of +/// the stream reaches viewers through the delay like everything else. It +/// used to cut the last 2 s off. +#[tokio::test] +async fn protection_off_a_stop_airs_the_rest_of_the_delay() { + let mut sim = Sim::new(false).await; + let (last_live, left) = obs_leaves_mid_delay(&mut sim, true).await; + wait(1_000).await; + assert!( + sim.ctrl + .tail_left() + .is_some_and(|left| left > Duration::ZERO), + "the dashboard shows the delay still airing" + ); + wait(2_000).await; + assert_tail_aired_then_ended(&mut sim, last_live, left); + assert_eq!(sim.ctrl.tail_left(), None, "nothing left to air"); +} + +/// Crash protection off, OBS crashes with a delay on: what it sent before +/// the crash airs too. +#[tokio::test] +async fn protection_off_a_crash_airs_the_rest_of_the_delay() { + let mut sim = Sim::new(false).await; + let (last_live, left) = obs_leaves_mid_delay(&mut sim, false).await; + wait(3_000).await; + assert_tail_aired_then_ended(&mut sim, last_live, left); +} + +/// Crash protection on but only for crashes: a stop on purpose opens no +/// hold, and the delay still airs in full before the end. +#[tokio::test] +async fn a_stop_crash_protection_lets_through_airs_the_rest_of_the_delay() { + let mut sim = Sim::new(true).await; + let (last_live, left) = obs_leaves_mid_delay(&mut sim, true).await; + assert!(!sim.ctrl.hold_active(), "no hold for a stop"); + wait(3_000).await; + assert_tail_aired_then_ended(&mut sim, last_live, left); +} + +/// End now while the delay airs after a stop: the stream ends at once. +#[tokio::test] +async fn end_now_after_a_stop_cuts_the_rest_of_the_delay() { + let mut sim = Sim::new(false).await; + let (last_live, _) = obs_leaves_mid_delay(&mut sim, true).await; + wait(500).await; + 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.frames(0).last().is_some_and(|f| f.number < last_live), + "without airing the rest of the delay" + ); + assert!(!sim.ctrl.end_hold_now(), "nothing left to end"); +} + /// OBS comes back from a freeze on a P-frame, mid-GOP, with a delay on. /// The rejoin lands at least the delay back: the rebuild counts from OBS's /// first keyframe, not its first frame, and the rejoin takes the newest @@ -418,6 +519,50 @@ async fn a_freeze_rejoin_lands_at_least_the_delay_back() { sim.assert_timestamps_monotonic(0); } +/// The streamer ends a freeze hold, then OBS recovers on the same +/// connection. A destination that starts afterwards joins OBS's new video +/// at the delay; it used to seed on a keyframe from before the freeze and +/// air the delay tail a second time. +#[tokio::test] +async fn a_destination_after_an_ended_freeze_never_replays_what_already_aired() { + const DELAY_MS: u32 = 1_500; + let mut sim = Sim::new(true).await; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(2_500).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(2_000).await; + let last_before_freeze = sim.next_frame - 1; + sim.obs_stalls(300).await; + sim.freeze_detected(); + assert!(sim.ctrl.end_hold_now(), "a hold was on"); + sim.obs_stalls(2_000).await; + let back = Instant::now(); + sim.obs_sends(500).await; + sim.destination_connects("platform").await; + sim.obs_sends(3_000).await; + + let frames = sim.frames(1); + let first = frames.first().expect("the new session airs"); + assert!(first.keyframe, "starts at a keyframe"); + assert!( + 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; + assert!( + sim.produced[&first.number] > back && delayed_by + 50 >= DELAY_MS, + "joins OBS's new video {delayed_by} ms behind, at least the delay" + ); + assert_strictly_increasing(&frames, "after an ended freeze"); +} + /// A pump that ends (OBS gone, protection off) stops holding its place in /// the buffer. A stale place pinned the buffer's trim, so it grew to the /// whole disk allowance while the destination waited to reconnect. diff --git a/src/controller/user_sim.rs b/src/controller/user_sim.rs new file mode 100644 index 0000000..42dafdb --- /dev/null +++ b/src/controller/user_sim.rs @@ -0,0 +1,291 @@ +//! Streamer profiles played end to end (see `sim` for the harness): how +//! different people end, restart and cut a delayed stream, and what their +//! viewers see. The rules every profile keeps: nothing airs twice, nothing +//! 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 std::collections::HashSet; +use std::time::{Duration, Instant}; + +const DELAY_MS: u32 = 2_000; + +/// OBS live with the delay on air, then it sends `ms` more. +async fn delayed_stream(sim: &mut Sim, ms: u32) { + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.obs_sends(2_500).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + 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] +async fn multistreamer_stop_airs_the_delay_on_every_platform() { + let mut sim = Sim::new(false).await; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("twitch").await; + sim.destination_connects("youtube").await; + sim.obs_sends(2_500).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(1_500).await; + let last_live = sim.next_frame - 1; + sim.obs_stops(); + let stopped = Instant::now(); + wait(3_000).await; + + for conn in 0..2 { + let tail = frames_after(&mut sim, 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"); + sim.assert_timestamps_monotonic(conn); + } + assert_eq!(sim.ctrl.tail_left(), None); +} + +/// A streamer armed a delay but stopped before it went on air: nothing is +/// held back, so the stream ends at once. +#[tokio::test] +async fn armed_but_not_on_yet_a_stop_ends_at_once() { + let mut sim = Sim::new(false).await; + sim.ctrl.arm_delay(5_000); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(1_500).await; + sim.obs_stops(); + wait(800).await; + + assert!(ended(&mut sim, 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 +/// 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. +#[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(); + } + 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"); + let new: Vec = sim + .frames(1) + .into_iter() + .filter(|f| f.number >= first_new) + .collect(); + let first = new.first().expect("the destination came back"); + assert!(first.keyframe, "starts on a keyframe"); + let delayed_by = delay_of(&sim, first); + assert!( + delayed_by >= Duration::from_millis(1_500), + "the new stream airs delayed, not live ({delayed_by:?})" + ); + sim.assert_timestamps_monotonic(1); +} + +/// Stops, then panics and hits Cut to live while the delay still airs: +/// the cut wins and the stream ends without the rest. +#[tokio::test] +async fn cut_to_live_during_the_tail_ends_the_stream() { + let mut sim = Sim::new(false).await; + sim.destination_connects("platform").await; + delayed_stream(&mut sim, 1_500).await; + let last_live = sim.next_frame - 1; + sim.obs_stops(); + wait(300).await; + sim.ctrl.stop_delay(); + wait(1_200).await; + + assert!(ended(&mut sim, 0), "it ended"); + assert!( + sim.frames(0).last().is_some_and(|f| f.number < last_live), + "without airing the rest" + ); + sim.assert_timestamps_monotonic(0); +} + +/// Marks "cut after this airs", then stops before the mark has aired: +/// the mark airs, nothing airs twice, and the stream ends. +#[tokio::test] +async fn cut_after_this_airs_then_a_stop() { + let mut sim = Sim::new(false).await; + sim.destination_connects("platform").await; + delayed_stream(&mut sim, 1_000).await; + let mark = sim.next_frame - 1; + assert_eq!( + sim.ctrl.run_named_action("cut_after", DELAY_MS, "sim"), + None, + "the cut is scheduled" + ); + sim.obs_sends(500).await; + sim.obs_stops(); + let stopped = Instant::now(); + wait(3_000).await; + + let frames = sim.frames(0); + assert!( + 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"); + sim.assert_timestamps_monotonic(0); +} + +/// Stops, hits End now, then goes live again: the new stream starts +/// delayed like any other. +#[tokio::test] +async fn end_now_then_a_new_stream_starts_delayed() { + let mut sim = Sim::new(false).await; + sim.destination_runs("platform"); + delayed_stream(&mut sim, 1_000).await; + sim.obs_stops(); + wait(300).await; + assert!(sim.ctrl.end_hold_now(), "the delay was airing"); + wait(500).await; + assert!(ended(&mut sim, 0), "ended at once"); + sim.obs_connects().await; + let first_new = sim.next_frame; + sim.obs_sends(4_500).await; + + let first = sim + .frames(1) + .into_iter() + .find(|f| f.number >= first_new) + .expect("the new stream aired"); + let delayed_by = delay_of(&sim, &first); + assert!( + delayed_by >= Duration::from_millis(1_500), + "delayed, not live ({delayed_by:?})" + ); +} + +/// The dashboard's countdown while the delay airs goes down, then away. +#[tokio::test] +async fn the_countdown_goes_down_then_away() { + let mut sim = Sim::new(false).await; + sim.destination_connects("platform").await; + delayed_stream(&mut sim, 1_000).await; + sim.obs_stops(); + wait(300).await; + let early = sim.ctrl.tail_left().expect("counting down"); + wait(800).await; + let later = sim.ctrl.tail_left().expect("still counting down"); + assert!(later < early, "{later:?} after {early:?}"); + assert!( + early <= Duration::from_millis(u64::from(DELAY_MS) + 600), + "never more than the delay ({early:?})" + ); + wait(2_000).await; + 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. +#[tokio::test] +async fn three_stops_and_starts_in_a_row() { + const SHORT_DELAY_MS: u32 = 1_000; + let mut sim = Sim::new(false).await; + sim.destination_runs("platform"); + sim.ctrl.arm_delay(SHORT_DELAY_MS); + sim.obs_connects().await; + sim.obs_sends(1_500).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + // Turning the delay on replays its own stretch once, by design: count + // replays from then on. + let activated = Instant::now(); + let mut last_lives = Vec::new(); + for round in 0..3 { + sim.obs_sends(1_500).await; + last_lives.push(sim.next_frame - 1); + sim.obs_stops(); + wait(1_800).await; + if round < 2 { + sim.obs_connects().await; + } + } + wait(500).await; + + let mut seen = HashSet::new(); + for (conn, last_live) in last_lives.into_iter().enumerate() { + let frames = sim.frames(conn); + assert!(!frames.is_empty(), "stream {conn} aired"); + assert_eq!( + frames.last().map(|f| f.number), + Some(last_live), + "stream {conn} aired its whole delay" + ); + for f in frames.iter().filter(|f| f.at > activated) { + assert!(seen.insert(f.number), "frame {} aired twice", f.number); + } + sim.assert_timestamps_monotonic(conn); + } +} + +/// Asked for a longer delay than the buffer holds, then stops: what the +/// buffer holds airs, nothing twice, then the end. +#[tokio::test] +async fn a_delay_longer_than_the_buffer_then_a_stop() { + // About 3 s of stream fits (see `Sim::with_ring_bytes`). + let mut sim = Sim::with_ring_bytes(false, 720).await; + sim.ctrl.arm_delay(6_000); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(4_000).await; + let _ = sim.ctrl.activate_delay(); + 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"); + sim.assert_timestamps_monotonic(0); +} diff --git a/src/crash_hold.rs b/src/crash_hold.rs index 7237f21..346b13b 100644 --- a/src/crash_hold.rs +++ b/src/crash_hold.rs @@ -100,6 +100,9 @@ pub enum HoldReason { Crash, /// OBS is still connected but stopped sending video. Freeze, + /// OBS stopped the stream on purpose, and crash protection holds every + /// disconnect. + Stopped, } /// Where the hold stands, as the pumps see it. diff --git a/src/integrations/activity.rs b/src/integrations/activity.rs new file mode 100644 index 0000000..5411b8b --- /dev/null +++ b/src/integrations/activity.rs @@ -0,0 +1,407 @@ +//! The chat activity trigger's view of chat: the last few minutes of +//! messages, and how a trigger's rules read them right now. +//! +//! "Busier than normal" compares against this channel's own chat, so one +//! setting works for twenty viewers and for twenty thousand: normal is the +//! average speed over the last ten minutes, leaving out the window being +//! judged, and never below `MIN_NORMAL` so three lines in a quiet chat +//! don't count as a hype moment. + +use super::model::{ChatActivity, RuleKind}; +use super::twitch::irc::ChatMessage; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +/// How far back "normal" looks. +const HISTORY: Duration = Duration::from_secs(600); +/// "Normal" needs this much chat history before it means anything. +const MIN_HISTORY: Duration = Duration::from_secs(60); +/// Messages kept at most: a very busy chat keeps less than ten minutes. +const MAX_KEPT: usize = 6000; +/// The least "normal" can be, in messages per window. +const MIN_NORMAL: f64 = 2.0; +/// A share of messages needs a few messages to be a share at all. +const MIN_FOR_SHARE: usize = 3; +/// Characters of a message kept for word matching. +const MAX_TEXT: usize = 200; + +struct Msg { + at: Instant, + login: String, + is_sub: bool, + is_vip: bool, + is_mod: bool, + is_broadcaster: bool, + text: String, +} + +/// Recent chat, shared by every chat activity trigger. +#[derive(Default)] +pub struct ChatWindow { + msgs: VecDeque, + /// When chat was first seen: until `MIN_HISTORY` later, "normal" is + /// unknown. + since: Option, +} + +/// How chat reads against one trigger's rules. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Reading { + pub messages: usize, + /// Messages per window on a normal stretch. + pub normal: f64, + /// `messages` over `normal`. + pub busier: f64, + pub chatters: usize, + /// Percent of messages containing one of a words rule's words: the + /// highest of the rules, for `{word_share}`. + pub word_share: f64, + /// Per rule, in order: its own share (0 for rules that aren't words). + pub shares: Vec, + /// The most used word of any words rule, or empty. + pub top_word: String, + /// Whether "normal" is known yet. + pub ready: bool, + /// One per rule, in order. + pub passing: Vec, + pub fires: bool, +} + +impl ChatWindow { + pub fn push(&mut self, msg: &ChatMessage, now: Instant) { + self.since.get_or_insert(now); + if self.msgs.len() >= MAX_KEPT { + self.msgs.pop_front(); + } + self.msgs.push_back(Msg { + at: now, + login: msg.user_login.clone(), + is_sub: msg.is_sub, + is_vip: msg.is_vip, + is_mod: msg.is_mod, + is_broadcaster: msg.is_broadcaster, + text: msg.text.to_lowercase().chars().take(MAX_TEXT).collect(), + }); + self.trim(now); + } + + fn trim(&mut self, now: Instant) { + while self + .msgs + .front() + .is_some_and(|m| now.duration_since(m.at) > HISTORY) + { + self.msgs.pop_front(); + } + } + + /// How chat reads against `rules` at `now`. The window is kept within + /// what can be judged: the dashboard's meter sends rules nobody + /// validated yet. + pub fn read(&self, rules: &ChatActivity, now: Instant) -> Reading { + let window = + Duration::from_millis(rules.window_ms.clamp(1_000, HISTORY.as_millis() as u64)); + let counts = |m: &Msg| { + m.is_broadcaster + || rules.roles.everyone + || (rules.roles.mods && m.is_mod) + || (rules.roles.vips && m.is_vip) + || (rules.roles.subs && m.is_sub) + }; + let recent: Vec<&Msg> = self + .msgs + .iter() + .filter(|m| now.duration_since(m.at) <= window && counts(m)) + .collect(); + let older = self + .msgs + .iter() + .filter(|m| now.duration_since(m.at) > window && counts(m)) + .count(); + let history = self + .since + .map_or(Duration::ZERO, |s| now.duration_since(s)) + .min(HISTORY); + let ready = history >= MIN_HISTORY + window; + let normal = if ready { + let windows = (history - window).as_secs_f64() / window.as_secs_f64(); + (older as f64 / windows).max(MIN_NORMAL) + } else { + 0.0 + }; + let messages = recent.len(); + let mut chatters: Vec<&str> = recent.iter().map(|m| m.login.as_str()).collect(); + chatters.sort_unstable(); + chatters.dedup(); + // Each words rule counts its own words; the top word is chat's + // favourite out of all of them. + let shares: Vec = rules + .rules + .iter() + .map(|r| match r.kind { + RuleKind::Words => word_stats(&recent, &word_list(&r.words)).0, + _ => 0.0, + }) + .collect(); + let all_words = word_list( + &rules + .rules + .iter() + .filter(|r| r.kind == RuleKind::Words) + .map(|r| r.words.as_str()) + .collect::>() + .join(","), + ); + let top_word = word_stats(&recent, &all_words).1; + let word_share = shares.iter().copied().fold(0.0, f64::max); + let busier = if normal > 0.0 { + messages as f64 / normal + } else { + 0.0 + }; + let passing: Vec = rules + .rules + .iter() + .zip(&shares) + .map(|(r, share)| match r.kind { + RuleKind::Messages => messages as f64 >= r.value, + RuleKind::Busier => ready && busier >= r.value, + RuleKind::Chatters => chatters.len() as f64 >= r.value, + RuleKind::Words => *share >= r.value, + }) + .collect(); + let fires = !passing.is_empty() + && if rules.match_all { + passing.iter().all(|p| *p) + } else { + passing.iter().any(|p| *p) + }; + Reading { + messages, + normal, + busier, + chatters: chatters.len(), + word_share, + shares, + top_word, + ready, + passing, + fires, + } + } +} + +/// The comma separated words of a words rule, lowercase, each once. +fn word_list(words: &str) -> Vec { + let mut list: Vec = Vec::new(); + for word in words.split(',').map(|w| w.trim().to_lowercase()) { + if !word.is_empty() && !list.contains(&word) { + list.push(word); + } + } + list +} + +/// The share of messages holding any of `words`, and the most used one. +fn word_stats(recent: &[&Msg], words: &[String]) -> (f64, String) { + if words.is_empty() || recent.len() < MIN_FOR_SHARE { + return (0.0, String::new()); + } + let mut hits = vec![0usize; words.len()]; + let mut matching = 0; + for m in recent { + let tokens: Vec<&str> = m + .text + .split(|c: char| !c.is_alphanumeric()) + .filter(|t| !t.is_empty()) + .collect(); + let mut any = false; + for (i, w) in words.iter().enumerate() { + if says(&m.text, &tokens, w) { + hits[i] += 1; + any = true; + } + } + matching += usize::from(any); + } + // Reversed, so a tie goes to the word listed first. + let top = hits + .iter() + .enumerate() + .rev() + .filter(|(_, n)| **n > 0) + .max_by_key(|(_, n)| **n) + .map(|(i, _)| words[i].clone()) + .unwrap_or_default(); + (matching as f64 * 100.0 / recent.len() as f64, top) +} + +/// Whether a message says `word`. A phrase ("let's go") is looked for as +/// written; a single word must be a whole word, or start one when it has +/// three letters or more ("pog" in "poggers", "clip" in "clipped"), so +/// "w" never matches "wow". +fn says(text: &str, tokens: &[&str], word: &str) -> bool { + if word.contains(char::is_whitespace) { + return text.contains(word); + } + let prefix_ok = word.chars().count() >= 3; + tokens + .iter() + .any(|t| *t == word || (prefix_ok && t.starts_with(word))) +} + +#[cfg(test)] +mod tests { + use super::super::model::{ActivityRule, Roles}; + use super::*; + + fn msg(user: &str, text: &str) -> ChatMessage { + ChatMessage { + user_login: user.into(), + text: text.into(), + ..ChatMessage::default() + } + } + + fn rules(kind: RuleKind, value: f64, words: &str) -> ChatActivity { + ChatActivity { + window_ms: 10_000, + match_all: true, + rules: vec![ActivityRule { + kind, + value, + words: words.into(), + }], + roles: Roles::EVERYONE, + only_live: true, + } + } + + /// A steady chat for `minutes`, one message every 10 s, then a burst. + fn chat_then_burst(minutes: u64, burst: usize) -> (ChatWindow, Instant) { + let mut w = ChatWindow::default(); + let t0 = Instant::now(); + for s in (0..minutes * 60).step_by(10) { + w.push(&msg("regular", "hello"), t0 + Duration::from_secs(s)); + } + let now = t0 + Duration::from_secs(minutes * 60); + for n in 0..burst { + w.push(&msg(&format!("v{n}"), "CLIP IT pog"), now); + } + (w, now) + } + + #[test] + fn busier_compares_with_the_channels_own_pace() { + let (w, now) = chat_then_burst(5, 12); + let r = w.read(&rules(RuleKind::Busier, 3.0, ""), now); + assert!(r.ready); + assert_eq!(r.normal, MIN_NORMAL, "one a window is below the floor"); + assert!(r.busier >= 6.0, "{r:?}"); + assert!(r.fires); + } + + #[test] + fn busier_waits_for_enough_history() { + let (w, now) = chat_then_burst(0, 30); + let r = w.read(&rules(RuleKind::Busier, 2.0, ""), now); + assert!(!r.ready); + assert!(!r.fires); + } + + #[test] + fn words_count_a_share_of_messages_and_name_the_top_one() { + let (w, now) = chat_then_burst(2, 6); + let r = w.read(&rules(RuleKind::Words, 50.0, "clip, pog, lul"), now); + assert_eq!(r.messages, 7, "the burst plus one regular line"); + assert!(r.word_share > 80.0, "{r:?}"); + assert_eq!(r.top_word, "clip"); + assert!(r.fires); + } + + #[test] + fn short_words_match_whole_words_only() { + let tokens = |t: &'static str| t.split(' ').collect::>(); + assert!(says("w", &tokens("w"), "w")); + assert!(!says("wow what", &tokens("wow what"), "w")); + assert!(says("poggers", &tokens("poggers"), "pog")); + assert!(says("lets go boys", &tokens("lets go boys"), "lets go")); + assert!(!says("clap", &tokens("clap"), "clip")); + } + + #[test] + fn any_window_the_meter_sends_is_safe() { + let (w, now) = chat_then_burst(1, 3); + for window_ms in [0, u64::MAX] { + let mut r = rules(RuleKind::Busier, 2.0, ""); + r.window_ms = window_ms; + let _ = w.read(&r, now); + } + } + + #[test] + fn each_words_rule_counts_its_own_words() { + let (w, now) = chat_then_burst(2, 6); + let mut both = rules(RuleKind::Words, 50.0, "clip"); + both.rules.push(ActivityRule { + kind: RuleKind::Words, + value: 50.0, + words: "lul".into(), + }); + let r = w.read(&both, now); + assert_eq!(r.passing, vec![true, false], "nobody said lul"); + assert!(!r.fires, "all rules must pass"); + assert_eq!(r.top_word, "clip"); + } + + #[test] + fn one_message_is_never_a_share() { + let mut w = ChatWindow::default(); + let now = Instant::now(); + w.push(&msg("a", "clip"), now); + let r = w.read(&rules(RuleKind::Words, 10.0, "clip"), now); + assert_eq!(r.word_share, 0.0); + assert!(!r.fires); + } + + #[test] + fn chatters_are_people_not_lines() { + let mut w = ChatWindow::default(); + let now = Instant::now(); + for _ in 0..20 { + w.push(&msg("spammer", "pog"), now); + } + let r = w.read(&rules(RuleKind::Chatters, 3.0, ""), now); + assert_eq!((r.messages, r.chatters), (20, 1)); + assert!(!r.fires); + } + + #[test] + fn any_fires_on_one_rule_all_needs_every_rule() { + let (w, now) = chat_then_burst(2, 5); + let mut both = rules(RuleKind::Messages, 3.0, ""); + both.rules.push(ActivityRule { + kind: RuleKind::Chatters, + value: 50.0, + words: String::new(), + }); + assert!(!w.read(&both, now).fires); + both.match_all = false; + let r = w.read(&both, now); + assert_eq!(r.passing, vec![true, false]); + assert!(r.fires); + } + + #[test] + fn only_the_chosen_roles_count() { + let mut w = ChatWindow::default(); + let now = Instant::now(); + let mut m = msg("mod1", "x"); + m.is_mod = true; + w.push(&m, now); + w.push(&msg("viewer", "x"), now); + let mut mods = rules(RuleKind::Messages, 2.0, ""); + mods.roles = Roles::MODS; + assert_eq!(w.read(&mods, now).messages, 1); + } +} diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 6604136..2b9b548 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -8,10 +8,10 @@ use super::event; use super::model::{ - valid_discord_webhook, valid_id, DiscordChannel, Integration, PhoneConnection, Trigger, + valid_discord_webhook, valid_id, DiscordChannel, Integration, PhoneConnection, Roles, Trigger, MAX_INTEGRATIONS, }; -use super::{presets, recipe, runner, twitch::Which}; +use super::{engine, presets, recipe, runner, twitch::Which}; use crate::config::Settings; use crate::controller::Controller; use crate::json::{self, Value}; @@ -46,12 +46,21 @@ fn done() -> Reply { ok(json::obj([("ok", Value::Bool(true))])) } +/// Why a Program or File step can't be set up from this request. +const LOCAL_STEPS_LOCKED: &str = "running programs and writing files can only be set up on the \ + streaming PC itself, or from here once the dashboard has a password (System > Security)"; + pub fn new_id() -> String { format!("i{}", &crate::crypto::random_token()[..12]) } /// Handle `/integrations*`, `/connections/*`, `/twitch/*`. `None` when the /// path is not ours. +/// +/// `local_steps_allowed` is false for a request from another device while +/// the dashboard has no password. Anyone on the network could make such a +/// request, so it may not add, change or switch on a step that runs a +/// program or writes a file: from chat, that would run anything on the PC. pub async fn route( method: &str, path: &str, @@ -59,19 +68,20 @@ pub async fn route( ctrl: &Arc, settings: &Arc>, cfg_path: &Path, + local_steps_allowed: bool, ) -> Option { 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())), + ("GET", "/integrations") => ok(overview(&handle, &settings.borrow(), local_steps_allowed)), ("GET", "/integrations/activity") => ok(json::obj([ ("activity", handle.activity_json()), ("stats", handle.stats_json()), ("twitch", handle.twitch.status_json()), ])), - ("POST", "/integrations/save") => save(&req(), settings, cfg_path), + ("POST", "/integrations/save") => save(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/add") => add(&req(), settings, cfg_path), - ("POST", "/integrations/toggle") => toggle(&req(), settings, cfg_path), + ("POST", "/integrations/toggle") => toggle(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/delete") => { let id = req().str_or("id", "").to_string(); update(settings, cfg_path, |s| { @@ -81,6 +91,13 @@ pub async fn route( } ("POST", "/integrations/duplicate") => duplicate(&req(), settings, cfg_path), ("POST", "/integrations/test") => test(&req(), &handle).await, + // The live meter of a chat activity trigger being tuned. + ("POST", "/integrations/meter") => match req().get("trigger").map(meter_rules) { + Some(Ok(rules)) => ok(handle.chat_meter(&rules)), + Some(Err(e)) => fail(e), + None => fail("nothing to measure"), + }, + ("GET", "/integrations/obs") => ok(handle.obs_status()), ("POST", "/integrations/fetch") => fetch(&req()).await, ("POST", "/integrations/convert") => convert(&req()), ("POST", "/integrations/alerts/test") => { @@ -112,7 +129,7 @@ pub async fn route( done() } ("POST", "/integrations/export") => export(&req(), &settings.borrow()), - ("POST", "/integrations/import") => import(&req(), settings, 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), ("POST", "/connections/discord/test") => { @@ -133,6 +150,13 @@ pub async fn route( handle.twitch_cancel_login(); done() } + ("POST", "/twitch/shared-chat") => { + let on = req().bool_or("on", false); + update(settings, cfg_path, |s| { + s.twitch_shared_chat = on; + Ok(()) + }) + } ("POST", "/twitch/logout") => { handle.twitch_logout(Which::from_id(req().str_or("which", "main"))); done() @@ -142,6 +166,20 @@ pub async fn route( Some(reply) } +/// The rules of a chat activity trigger sent for the live meter. +fn meter_rules(trigger: &Value) -> Result { + let handler = json::obj([ + ("trigger", trigger.clone()), + ("steps", Value::Arr(Vec::new())), + ]); + let probe = json::obj([("handlers", Value::Arr(vec![handler]))]); + let integration = Integration::from_json(&probe)?; + match integration.handlers.into_iter().next().map(|h| h.trigger) { + Some(Trigger::ChatActivity(rules)) => Ok(rules), + _ => Err("that isn't a chat activity trigger".to_string()), + } +} + /// What a MIDI learn for an integration's shortcut is filed under, apart /// from the delay actions' own learns. const MIDI_LEARN: &str = crate::midi::INTEGRATION_LEARN; @@ -195,7 +233,7 @@ fn update( done() } -fn overview(handle: &super::Handle, s: &Settings) -> Value { +fn overview(handle: &super::Handle, s: &Settings, 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( @@ -226,8 +264,14 @@ fn overview(handle: &super::Handle, s: &Settings) -> Value { ("chat", event::vars_json(runner::CHAT_VARS)), ( "web", - event::vars_json(&[("body", "{\"x\":1}"), ("query", "a=1")]), + event::vars_json(&[ + ("body", "{\"x\":1}"), + ("query", "team=red"), + ("source", "stream_deck"), + ]), ), + ("activity", event::vars_json(engine::ACTIVITY_SAMPLES)), + ("scene", event::vars_json(engine::SCENE_SAMPLES)), ]); json::obj([ ( @@ -269,6 +313,19 @@ fn overview(handle: &super::Handle, s: &Settings) -> Value { ), ), ("twitch", handle.twitch.status_json()), + ("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. + ( + "twitch_other_channel", + Value::Bool(streams_to_other_channel( + &handle.twitch.main_user_id(), + &s.destinations, + )), + ), + // False on another device while the dashboard has no password: + // Run a program and Write a file are locked there (see `route`). + ("local_steps", Value::Bool(local_steps_allowed)), ("catalog", presets::catalog_json()), // Global hotkeys and MIDI pads only exist on Windows, and a combo // or pad that runs a delay action can't start an integration too. @@ -316,11 +373,19 @@ fn mask_webhook(url: &str) -> String { } } -fn save(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { +fn save( + req: &Value, + settings: &Arc>, + cfg_path: &Path, + local_steps_allowed: bool, +) -> Reply { let mut integration = match Integration::from_json(req) { Ok(i) => i, Err(e) => return fail(e), }; + if !local_steps_allowed && integration.has_local_effects() { + return fail(LOCAL_STEPS_LOCKED); + } if integration.id.is_empty() { integration.id = new_id(); } @@ -398,11 +463,20 @@ pub fn unknown_vars(integration: &Integration) -> Vec { } known.extend(runner::CHAT_VARS.iter().map(|(n, _)| n.to_string())); } - Trigger::Webhook { .. } => { - known.extend(["body", "query"].map(String::from)); + // A button pressed through its Stream Deck address can carry a + // body too. + Trigger::Webhook { .. } | Trigger::Shortcut { .. } => { + known.extend(["body", "query", "source"].map(String::from)); prefixes.push("body.".to_string()); + prefixes.push("query.".to_string()); + } + Trigger::ChatActivity(_) => { + known.extend(engine::ACTIVITY_SAMPLES.iter().map(|(n, _)| n.to_string())) } - Trigger::Timer { .. } | Trigger::Shortcut { .. } => {} + Trigger::Scene { .. } => { + known.extend(engine::SCENE_SAMPLES.iter().map(|(n, _)| n.to_string())) + } + Trigger::Timer { .. } => {} } visit_steps(&h.steps, &mut |s| match s.kind { StepKind::Http => prefixes.push(match s.param("save_as").trim() { @@ -445,7 +519,7 @@ fn with_id(reply: Reply, id: &str) -> Reply { /// Windows gives a hotkey to one owner, and a pad would do both. fn shortcut_taken(integration: &Integration, s: &Settings) -> Option { integration.handlers.iter().find_map(|h| { - let Trigger::Shortcut { hotkey, midi } = &h.trigger else { + let Trigger::Shortcut { hotkey, midi, .. } = &h.trigger else { return None; }; let key_owner = s @@ -474,6 +548,24 @@ fn shortcut_taken(integration: &Integration, s: &Settings) -> Option { } /// 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 +/// key (`live__…`); keys in another shape say nothing either way. +fn streams_to_other_channel(user_id: &str, destinations: &[crate::config::Destination]) -> bool { + let channels: Vec<&str> = destinations + .iter() + .filter(|d| d.enabled && d.platform == "twitch") + .filter_map(|d| key_channel(&d.stream_key)) + .collect(); + !user_id.is_empty() && !channels.is_empty() && !channels.contains(&user_id) +} + +/// The channel id in a Twitch stream key, `live__`. +fn key_channel(key: &str) -> Option<&str> { + let (id, _) = key.trim().strip_prefix("live_")?.split_once('_')?; + (!id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())).then_some(id) +} + fn check_connections(integration: &Integration, s: &Settings) -> Result<(), String> { let mut gone = false; for h in &integration.handlers { @@ -536,7 +628,12 @@ fn add(req: &Value, settings: &Arc>, cfg_path: &Path) -> ])) } -fn toggle(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { +fn toggle( + req: &Value, + settings: &Arc>, + cfg_path: &Path, + local_steps_allowed: bool, +) -> Reply { let id = req.str_or("id", "").to_string(); let enabled = req.bool_or("enabled", true); update(settings, cfg_path, |s| { @@ -547,6 +644,9 @@ fn toggle(req: &Value, settings: &Arc>, cfg_path: &Path) .ok_or("that integration no longer exists")?; if enabled { let integration = &s.integrations[at]; + if !local_steps_allowed && integration.has_local_effects() { + return Err(LOCAL_STEPS_LOCKED.to_string()); + } let errors = integration.validate(); if !errors.is_empty() { return Err(format!("finish setting it up first: {}", errors.join("; "))); @@ -586,9 +686,17 @@ fn duplicate(req: &Value, settings: &Arc>, cfg_path: &Pa for h in &mut copy.handlers { match &mut h.trigger { Trigger::Webhook { token } => *token = new_id() + &new_id(), - Trigger::Shortcut { hotkey, midi } => { + Trigger::Shortcut { + hotkey, + midi, + token, + .. + } => { hotkey.clear(); midi.clear(); + if !token.is_empty() { + *token = new_id() + &new_id(); + } } _ => {} } @@ -609,7 +717,18 @@ async fn test(req: &Value, handle: &super::Handle) -> Reply { if handler >= integration.handlers.len() { return fail("that trigger doesn't exist"); } - match handle.test(integration, handler).await { + let values = match req.get("values") { + Some(Value::Obj(fields)) => fields + .iter() + .map(|(k, v)| (k.clone(), v.to_display())) + .collect(), + _ => Default::default(), + }; + let run = engine::TestRun { + values, + dry: req.bool_or("dry", false), + }; + match handle.test(integration, handler, run).await { Some(record) => ok(json::obj([ ("ok", Value::Bool(true)), ("status", json::str(record.status)), @@ -727,7 +846,12 @@ fn export(req: &Value, s: &Settings) -> Reply { } /// Preview a recipe (`apply` false) or add it (`apply` true). -fn import(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { +fn import( + req: &Value, + settings: &Arc>, + cfg_path: &Path, + local_steps_allowed: bool, +) -> Reply { let mut parsed = match recipe::parse(req.str_or("recipe", ""), new_id) { Ok(r) => r, Err(e) => return fail(e), @@ -740,6 +864,8 @@ fn import(req: &Value, settings: &Arc>, cfg_path: &Path) json::obj([ ("name", json::str(&i.name)), ("summary", json::str(summary(i))), + ("triggers", Value::Arr(trigger_review(i))), + ("effects", Value::Arr(effect_review(i))), ]) }) .collect(); @@ -751,6 +877,9 @@ fn import(req: &Value, settings: &Arc>, cfg_path: &Path) ("local_effects", Value::Bool(parsed.has_local_effects())), ])); } + if parsed.has_local_effects() && !local_steps_allowed { + return fail(LOCAL_STEPS_LOCKED); + } if parsed.has_local_effects() && !req.bool_or("allow_local", false) { return fail("this recipe runs programs or writes files; confirm that you trust it"); } @@ -785,6 +914,90 @@ fn import(req: &Value, settings: &Arc>, cfg_path: &Path) ])) } +/// 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) { + (true, ..) => "anyone in chat", + (false, false, false, false) => "only you", + (false, false, false, true) => "only mods", + (false, false, true, true) => "mods and VIPs", + (false, true, _, _) => "subs and up", + _ => "chosen roles", + } +} + +/// Every trigger of an imported integration and who can set it off, for +/// the import preview. +fn trigger_review(i: &Integration) -> Vec { + i.handlers + .iter() + .map(|h| { + let (what, who) = match &h.trigger { + Trigger::ChatCommand { command, roles, .. } => { + (format!("{command} in chat"), who_can(*roles)) + } + Trigger::ChatMessage { roles, .. } => ("a chat message".into(), who_can(*roles)), + Trigger::ChatActivity(a) => ("chat gets busy".into(), who_can(a.roles)), + Trigger::Webhook { .. } => ("a web call".into(), "apps with its secret link"), + Trigger::Shortcut { .. } => ("a button".into(), "you"), + Trigger::Event { kind, .. } => (kind.label().to_string(), ""), + Trigger::Timer { every_ms, .. } => (format!("every {} min", every_ms / 60_000), ""), + Trigger::Scene { scene, .. } => (format!("OBS switches to {scene}"), ""), + }; + json::obj([("what", json::str(what)), ("who", json::str(who))]) + }) + .collect() +} + +/// What an imported integration reaches, most serious first: `danger` for +/// anything that runs on this PC or lets anyone in chat change the delay, +/// `caution` for the stream and OBS, `info` for messages it sends. +fn effect_review(i: &Integration) -> Vec { + use super::model::{visit_steps, StepKind}; + let mut found: Vec<(&'static str, &'static str)> = Vec::new(); + let mut add = |level, text| { + if !found.contains(&(level, text)) { + found.push((level, text)); + } + }; + for h in &i.handlers { + let from_anyone = match &h.trigger { + Trigger::ChatCommand { roles, .. } | Trigger::ChatMessage { roles, .. } => { + roles.everyone + } + Trigger::ChatActivity(a) => a.roles.everyone, + _ => false, + }; + visit_steps(&h.steps, &mut |s| match s.kind { + StepKind::Program => add("danger", "Runs a program on this PC"), + StepKind::File => add("danger", "Writes a file on this PC"), + StepKind::DelayAction if from_anyone => { + 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::Http => add("caution", "Calls a web address you fill in"), + StepKind::Clip => add("info", "Makes clips"), + StepKind::Marker => add("info", "Adds VOD markers"), + StepKind::Discord => add("info", "Posts on Discord"), + StepKind::Chat => add("info", "Writes in your Twitch chat"), + StepKind::Phone => add("info", "Sends a push to your phone"), + StepKind::Overlay => add("info", "Shows an alert on stream"), + _ => {} + }); + } + let rank = |level: &str| match level { + "danger" => 0, + "caution" => 1, + _ => 2, + }; + found.sort_by_key(|(level, _)| rank(level)); + found + .into_iter() + .map(|(level, text)| json::obj([("level", json::str(level)), ("text", json::str(text))])) + .collect() +} + /// One line describing an integration, for the import preview. fn summary(i: &Integration) -> String { let triggers: Vec = i @@ -798,7 +1011,9 @@ fn summary(i: &Integration) -> String { format!("every {} min", every_ms / 60_000) } Trigger::Webhook { .. } => "a web call".to_string(), - Trigger::Shortcut { .. } => "a hotkey or MIDI pad".to_string(), + Trigger::Shortcut { .. } => "a button (hotkey, MIDI pad or Stream Deck)".to_string(), + Trigger::ChatActivity(_) => "chat gets busy".to_string(), + Trigger::Scene { scene, .. } => format!("OBS switches to {scene}"), }) .collect(); let steps: usize = i @@ -883,7 +1098,18 @@ async fn test_discord(req: &Value, channels: Vec) -> Reply { return fail("that channel no longer exists"); }; let sent = tokio::task::spawn_blocking(move || { - let body = super::effects::discord_body("🧪 Test message: InstantClone can post here.", ""); + let body = super::effects::discord_body(&super::host::DiscordMessage { + content: String::new(), + card: Some(super::host::DiscordCard { + title: "🧪 Connected".into(), + description: "InstantClone can post here. Alerts, highlights and your stream timeline will show up like this.".into(), + color: runner::DEFAULT_CARD_COLOR, + footer: "InstantClone".into(), + timestamp: true, + ..Default::default() + }), + ping: String::new(), + }); crate::https::https_agent_with_timeout(super::effects::HTTP_TIMEOUT) .post(&channel.url) .header("Content-Type", "application/json") @@ -960,6 +1186,48 @@ async fn test_phone(phone: PhoneConnection) -> Reply { mod tests { use super::*; + #[test] + fn a_stream_key_names_its_channel() { + assert_eq!(key_channel("live_123456789_AbCdEf"), Some("123456789")); + assert_eq!(key_channel("live_abc_AbCdEf"), None); + assert_eq!(key_channel("sk_us-west_123"), None); + assert_eq!(key_channel("live_123"), None); + } + + #[test] + fn streaming_to_another_channel_is_noticed() { + let twitch = |key: &str, enabled: bool| crate::config::Destination { + id: key.into(), + name: "Twitch".into(), + enabled, + platform: "twitch".into(), + stream_key: key.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 mut list = vec![twitch("live_222_x", true)]; + assert!(streams_to_other_channel("111", &list)); + assert!( + !streams_to_other_channel("", &list), + "logged out says nothing" + ); + list.push(twitch("live_111_y", true)); + assert!( + !streams_to_other_channel("111", &list), + "one is the login's" + ); + let off = vec![twitch("live_222_x", false)]; + assert!( + !streams_to_other_channel("111", &off), + "only enabled ones count" + ); + } + #[test] fn webhooks_are_masked() { assert_eq!( @@ -1004,6 +1272,8 @@ mod tests { trigger: Trigger::Shortcut { hotkey: hotkey.into(), midi: midi.into(), + token: String::new(), + only_live: false, }, steps: vec![Step::new(StepKind::Clip, &[])], }]; @@ -1014,12 +1284,81 @@ mod tests { assert_eq!(shortcut("Ctrl+Alt+K", "note:1:37"), None); } + #[test] + fn programs_and_files_are_set_up_only_from_this_pc_without_a_password() { + 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 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(); + i.enabled = false; + i.handlers[0].steps.push(Step::new( + StepKind::Program, + &[("path", r"C:\Tools\thing.exe"), ("args", "")], + )); + let locked = |reply: Reply| reply.0 != "200 OK" && reply.2.contains("streaming PC"); + + assert!(locked(save(&i.to_json(), &settings, &cfg, false))); + assert_eq!(save(&i.to_json(), &settings, &cfg, true).0, "200 OK"); + let switch = |on: bool| json::obj([("id", json::str(&i.id)), ("enabled", Value::Bool(on))]); + assert!(locked(toggle(&switch(true), &settings, &cfg, false))); + assert_eq!( + toggle(&switch(false), &settings, &cfg, false).0, + "200 OK", + "switching it off is always allowed" + ); + let shared = json::obj([ + ( + "recipe", + json::str(recipe::export("kit", std::slice::from_ref(&i))), + ), + ("apply", Value::Bool(true)), + ("allow_local", Value::Bool(true)), + ]); + assert!(locked(import(&shared, &settings, &cfg, false))); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_import_preview_says_who_can_start_it_and_what_it_reaches() { + use super::super::model::{Step, StepKind}; + let mut i = presets::build("mod_controls", "m".into(), "").unwrap(); + let levels = |i: &Integration| -> Vec<(String, String)> { + effect_review(i) + .iter() + .map(|e| (e.str_or("level", "").into(), e.str_or("text", "").into())) + .collect() + }; + assert_eq!( + trigger_review(&i)[0].str_or("who", ""), + "only mods", + "mod controls stay with mods" + ); + assert!(levels(&i).contains(&("caution".into(), "Changes your delay".into()))); + + if let Trigger::ChatCommand { roles, .. } = &mut i.handlers[0].trigger { + *roles = Roles::EVERYONE; + } + i.handlers[0] + .steps + .push(Step::new(StepKind::Program, &[("path", ""), ("args", "")])); + let effects = levels(&i); + assert_eq!(effects[0].0, "danger", "the worst comes first"); + assert!(effects.contains(&( + "danger".into(), + "Anyone in chat can change your delay".into() + ))); + assert!(effects.contains(&("danger".into(), "Runs a program on this PC".into()))); + } + #[test] fn summaries_name_the_triggers() { let i = presets::build("mod_controls", "m".into(), "").unwrap(); assert_eq!( summary(&i), - "When !cut in chat, !setdelay in chat · 6 steps" + "When !cut in chat, !setdelay in chat · 8 steps" ); } } diff --git a/src/integrations/clock.rs b/src/integrations/clock.rs index 0e40d53..456f104 100644 --- a/src/integrations/clock.rs +++ b/src/integrations/clock.rs @@ -59,8 +59,38 @@ pub fn now() -> LocalTime { } } +/// `2026-10-02T18:36:46Z` for a Unix time in seconds: what a Discord card's +/// timestamp wants (Discord shows it in each reader's own time zone). +pub fn iso_utc(unix_secs: u64) -> String { + let days = (unix_secs / 86_400) as i64; + let secs = unix_secs % 86_400; + // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + secs / 3600, + secs / 60 % 60, + secs % 60 + ) +} + #[cfg(test)] mod tests { + #[test] + fn utc_timestamps_are_iso_8601() { + assert_eq!(super::iso_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(super::iso_utc(1_790_966_206), "2026-10-02T18:36:46Z"); + assert_eq!(super::iso_utc(951_782_400), "2000-02-29T00:00:00Z"); + } + #[test] fn formats_are_zero_padded_and_plausible() { let t = super::now(); diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index 013de91..ce34007 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -1,16 +1,24 @@ //! The real `Host`: reads the controller, and does the I/O each step asks //! for. Every method may block and is only called from `spawn_blocking`. -use super::host::{ClipInfo, DiscordPosted, Host, HttpRequest, HttpResponse, LiveState}; +use super::host::{ + ClipInfo, DiscordCard, DiscordMessage, DiscordPosted, Host, HttpRequest, HttpResponse, + LiveState, +}; use super::twitch::Twitch; use crate::controller::Controller; use crate::json::{self, Value}; use std::io::Write as _; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; pub(super) const HTTP_TIMEOUT: Duration = Duration::from_secs(10); +/// Discord's rate limit says how long to wait before trying again. A wait +/// this long or less is waited out; a longer one fails the step. +const DISCORD_RETRY_MAX: Duration = Duration::from_secs(10); +/// Tries per Discord call, rate limits included. +const DISCORD_TRIES: usize = 3; /// Largest response body a step keeps. const MAX_RESPONSE: u64 = 1024 * 1024; /// Largest text a file step writes. @@ -94,26 +102,23 @@ pub fn send_http(r: HttpRequest) -> Result { } /// Post a Discord message, or edit message `edit` (see `Host::discord`). -/// An edit never pings; `ping` is for when it has to be posted anew. +/// An edit never pings; the ping is for when it has to be posted anew. pub fn send_discord( webhook_url: &str, - content: &str, - ping: &str, + message: &DiscordMessage, edit: Option<&str>, ) -> Result { if let Some(id) = edit { - let resp = crate::https::https_agent() - .patch(&webhook_message_url(webhook_url, id)) - .config() - .timeout_global(Some(HTTP_TIMEOUT)) - .build() - .header("Content-Type", "application/json") - .send(discord_body(content, "")) - .map_err(|e| format!("couldn't reach Discord: {e}"))?; + let quiet = DiscordMessage { + ping: String::new(), + ..message.clone() + }; + let url = webhook_message_url(webhook_url, id); + let (status, _) = discord_call(true, &url, &discord_body(&quiet))?; // 404 here is the message, not the webhook: someone deleted it. // The update still has to reach the channel, as a new message. - if resp.status().as_u16() != 404 { - discord_status(resp.status().as_u16())?; + if status != 404 { + discord_status(status)?; return Ok(DiscordPosted { message_id: id.to_string(), edited: true, @@ -121,21 +126,9 @@ pub fn send_discord( } } // `wait=true` makes Discord answer with the message, and its id. - let mut resp = crate::https::https_agent() - .post(&with_query(webhook_url, "wait=true")) - .config() - .timeout_global(Some(HTTP_TIMEOUT)) - .build() - .header("Content-Type", "application/json") - .send(discord_body(content, ping)) - .map_err(|e| format!("couldn't reach Discord: {e}"))?; - discord_status(resp.status().as_u16())?; - let answer = resp - .body_mut() - .with_config() - .limit(64 * 1024) - .read_to_string() - .unwrap_or_default(); + let url = with_query(webhook_url, "wait=true"); + let (status, answer) = discord_call(false, &url, &discord_body(message))?; + discord_status(status)?; let message_id = json::parse(&answer) .ok() .and_then(|v| v.get("id").and_then(Value::as_str).map(str::to_string)) @@ -147,6 +140,64 @@ pub fn send_discord( }) } +/// One call to Discord (an edit, or a new message), waiting out its rate +/// limit instead of failing: a burst of timeline updates must not lose the +/// last one. The status and the answer's body. +fn discord_call(edit: bool, url: &str, body: &str) -> Result<(u16, String), String> { + let mut tries = 0; + loop { + tries += 1; + let agent = crate::https::https_agent(); + let request = if edit { + agent.patch(url) + } else { + agent.post(url) + }; + let mut resp = request + .config() + .timeout_global(Some(HTTP_TIMEOUT)) + .build() + .header("Content-Type", "application/json") + .send(body) + .map_err(|e| format!("couldn't reach Discord: {e}"))?; + let status = resp.status().as_u16(); + let header_wait = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()); + let answer = resp + .body_mut() + .with_config() + .limit(64 * 1024) + .read_to_string() + .unwrap_or_default(); + if status != 429 || tries >= DISCORD_TRIES { + return Ok((status, answer)); + } + match retry_wait(&answer, header_wait) { + Some(wait) => std::thread::sleep(wait), + None => return Ok((status, answer)), + } + } +} + +/// How long Discord's rate limit asks to wait (its JSON `retry_after`, or +/// the Retry-After header), when that is short enough to wait out. +fn retry_wait(answer: &str, header: Option) -> Option { + let seconds = json::parse(answer) + .ok() + .and_then(|v| v.get("retry_after").and_then(Value::as_f64)) + .or(header) + .unwrap_or(1.0); + if !seconds.is_finite() || seconds < 0.0 { + return None; + } + // A little extra, so the retry doesn't land a hair too early. + let wait = Duration::from_secs_f64(seconds.min(3600.0)) + Duration::from_millis(50); + (wait <= DISCORD_RETRY_MAX).then_some(wait) +} + pub struct RealHost { pub ctrl: Arc, pub twitch: Arc, @@ -154,13 +205,28 @@ pub struct RealHost { pub default_delay_ms: AtomicU32, pub programs: crate::sync::Mutex>, pub alerts: Arc, + /// When this stream started (Unix ms), 0 between streams. The engine + /// keeps it, from the stream's own events. + pub stream_started_ms: AtomicU64, + /// What the engine last worked out the on-air state to be. + pub onair: crate::sync::Mutex<&'static str>, + /// Where OBS steps send what they ask OBS to do. + pub obs: tokio::sync::mpsc::Sender, } impl Host for RealHost { fn live(&self) -> LiveState { let (alive, total) = self.ctrl.destination_alive_summary(); let hold = self.ctrl.hold_status(); + let started = self.stream_started_ms.load(Ordering::Relaxed); LiveState { + streaming: started != 0, + uptime_ms: if started == 0 { + 0 + } else { + super::twitch::unix_ms().saturating_sub(started) + }, + onair: self.onair.lock().to_string(), delay_ms: self.ctrl.current_delay_ms(), phase: self.ctrl.phase().to_string(), hold_active: self.ctrl.hold_active(), @@ -176,11 +242,10 @@ impl Host for RealHost { fn discord( &self, webhook_url: &str, - content: &str, - ping: &str, + message: &DiscordMessage, edit: Option<&str>, ) -> Result { - send_discord(webhook_url, content, ping, edit) + send_discord(webhook_url, message, edit) } fn http(&self, r: HttpRequest) -> Result { @@ -316,6 +381,23 @@ impl Host for RealHost { fn overlay(&self, title: &str, text: &str, seconds: u64) { self.alerts.show(title, text, seconds); } + + fn obs(&self, action: super::obsws::Action) -> Result<(), String> { + let (reply, outcome) = std::sync::mpsc::channel(); + let wait = super::obsws::CALL_TIMEOUT + Duration::from_secs(1); + let deadline = std::time::Instant::now() + wait; + self.obs + .try_send(super::obsws::Command { + action, + reply, + deadline, + }) + .map_err(|_| "too many OBS requests at once; slow down".to_string())?; + // The connection answers every request, within its own timeout. + outcome + .recv_timeout(wait) + .unwrap_or_else(|_| Err("OBS didn't answer in time".to_string())) + } } fn discord_status(status: u16) -> Result<(), String> { @@ -350,11 +432,158 @@ 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. +const AVATAR_URL: &str = + "https://raw.githubusercontent.com/Soulhackzlol/InstantClone/main/docs/icon.png"; + +/// Discord's own limits for a card. A longer value is cut, never refused. +const CARD_TITLE_MAX: usize = 256; +const CARD_DESCRIPTION_MAX: usize = 4096; +const CARD_FIELDS_MAX: usize = 25; +const CARD_FIELD_NAME_MAX: usize = 256; +const CARD_FIELD_VALUE_MAX: usize = 1024; +const CARD_FOOTER_MAX: usize = 2048; +/// What all of a card's text together may add up to. +const CARD_TOTAL_MAX: usize = 6000; + /// A Discord message that can only ping what the streamer chose. Text from /// chat or a web service can contain `@everyone`; `allowed_mentions` keeps /// that from pinging anyone unless the ping option asked for it. -pub fn discord_body(content: &str, ping: &str) -> String { - let (prefix, allowed) = match ping { +pub fn discord_body(message: &DiscordMessage) -> String { + let (prefix, allowed) = mention_rules(&message.ping); + let content: String = format!("{prefix}{}", message.content) + .chars() + .take(2000) + .collect(); + let embeds = message + .card + .as_ref() + .and_then(card_json) + .map_or_else(Vec::new, |card| vec![card]); + json::obj([ + ("content", json::str(content)), + ("embeds", Value::Arr(embeds)), + ("username", json::str("InstantClone")), + ("avatar_url", json::str(AVATAR_URL)), + ("allowed_mentions", allowed), + ]) + .to_json() +} + +/// Whether a filled-in message has anything Discord would show. An empty +/// one is refused by Discord, so the runner skips it instead. +pub fn discord_has_body(message: &DiscordMessage) -> bool { + !message.content.trim().is_empty() || message.card.as_ref().and_then(card_json).is_some() +} + +/// The card as Discord's embed JSON, or None when nothing in it would show. +fn card_json(card: &DiscordCard) -> Option { + let title = cut(card.title.trim(), CARD_TITLE_MAX); + let fields: Vec<(String, String, bool)> = card + .fields + .iter() + .filter(|(name, value, _)| !name.trim().is_empty() && !value.trim().is_empty()) + .take(CARD_FIELDS_MAX) + .map(|(name, value, inline)| { + ( + cut(name.trim(), CARD_FIELD_NAME_MAX), + cut(value.trim(), CARD_FIELD_VALUE_MAX), + *inline, + ) + }) + .collect(); + let footer = cut(card.footer.trim(), CARD_FOOTER_MAX); + let used = title.chars().count() + + footer.chars().count() + + fields + .iter() + .map(|(n, v, _)| n.chars().count() + v.chars().count()) + .sum::(); + let room = CARD_TOTAL_MAX + .saturating_sub(used) + .min(CARD_DESCRIPTION_MAX); + let description = cut(card.description.trim(), room); + let image = web_address(&card.image); + if title.is_empty() && description.is_empty() && fields.is_empty() && image.is_none() { + return None; + } + let mut out = vec![("color".to_string(), Value::Num(f64::from(card.color)))]; + if !title.is_empty() { + out.push(("title".to_string(), json::str(title))); + if let Some(url) = web_address(&card.url) { + out.push(("url".to_string(), json::str(url))); + } + } + if !description.is_empty() { + out.push(("description".to_string(), json::str(description))); + } + if !fields.is_empty() { + let list = fields + .into_iter() + .map(|(name, value, inline)| { + json::obj([ + ("name", json::str(name)), + ("value", json::str(value)), + ("inline", Value::Bool(inline)), + ]) + }) + .collect(); + out.push(("fields".to_string(), Value::Arr(list))); + } + if !footer.is_empty() { + out.push(( + "footer".to_string(), + json::obj([ + ("text", json::str(footer)), + ("icon_url", json::str(AVATAR_URL)), + ]), + )); + } + if card.timestamp { + let now = super::twitch::unix_ms() / 1000; + out.push(( + "timestamp".to_string(), + json::str(super::clock::iso_utc(now)), + )); + } + if let Some(url) = image { + out.push(("image".to_string(), json::obj([("url", json::str(url))]))); + } + if let Some(url) = web_address(&card.thumbnail) { + out.push(( + "thumbnail".to_string(), + json::obj([("url", json::str(url))]), + )); + } + Some(Value::Obj(out)) +} + +/// `text` cut to `max` characters, with an ellipsis when it was longer. +fn cut(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + let mut out: String = text.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out +} + +/// An http(s) address Discord will take, or None: Discord refuses the whole +/// message over one malformed link, so a bad one is left out instead. +fn web_address(text: &str) -> Option<&str> { + let text = text.trim(); + let ok = (text.starts_with("https://") || text.starts_with("http://")) + && text.len() > 10 + && text.len() <= 2048 + && !text.contains(char::is_whitespace); + ok.then_some(text) +} + +/// The text a ping puts in front of the message, and the mentions Discord +/// may act on. +fn mention_rules(ping: &str) -> (String, Value) { + match ping { "here" => ( "@here ".to_string(), json::obj([("parse", Value::Arr(vec![json::str("everyone")]))]), @@ -373,23 +602,84 @@ pub fn discord_body(content: &str, ping: &str) -> String { ) } _ => (String::new(), json::obj([("parse", Value::Arr(vec![]))])), - }; - let content: String = format!("{prefix}{content}").chars().take(2000).collect(); - json::obj([ - ("content", json::str(content)), - ("username", json::str("InstantClone")), - ("allowed_mentions", allowed), - ]) - .to_json() + } } #[cfg(test)] mod tests { use super::*; + fn text(content: &str, ping: &str) -> String { + discord_body(&DiscordMessage { + content: content.into(), + card: None, + ping: ping.into(), + }) + } + + fn plain(content: &str) -> DiscordMessage { + DiscordMessage { + content: content.into(), + ..DiscordMessage::default() + } + } + + #[test] + fn a_card_becomes_an_embed_within_discords_limits() { + let card = DiscordCard { + title: "🔴 YouTube is down".into(), + url: "not a link".into(), + description: "x".repeat(9000), + color: 0xf2665a, + fields: vec![ + ("Reason".into(), "timed out".into(), true), + ("Empty".into(), " ".into(), true), + ], + footer: "InstantClone".into(), + timestamp: true, + image: "https://example.com/a.png".into(), + thumbnail: String::new(), + }; + let body = json::parse(&discord_body(&DiscordMessage { + content: "https://clips.twitch.tv/Abc".into(), + card: Some(card), + ping: String::new(), + })) + .unwrap(); + let embed = body.path("embeds.0").unwrap(); + assert_eq!(embed.str_or("title", ""), "🔴 YouTube is down"); + assert!(embed.get("url").is_none(), "a broken link is left out"); + assert_eq!(embed.path("fields").unwrap().as_array().unwrap().len(), 1); + assert_eq!(embed.str_or("description", "").chars().count(), 4096); + assert_eq!( + embed.path("color").and_then(Value::as_f64), + Some(f64::from(0xf2665a_u32)) + ); + assert!(embed.str_or("timestamp", "").ends_with('Z')); + assert_eq!( + embed.path("image.url").and_then(Value::as_str), + Some("https://example.com/a.png") + ); + assert_eq!(body.str_or("content", ""), "https://clips.twitch.tv/Abc"); + assert!(body.str_or("avatar_url", "").starts_with("https://")); + } + + #[test] + fn an_empty_card_is_no_message() { + let empty = DiscordMessage { + content: " ".into(), + card: Some(DiscordCard::default()), + ping: String::new(), + }; + assert!(!discord_has_body(&empty)); + let body = json::parse(&discord_body(&empty)).unwrap(); + assert!(body.path("embeds").unwrap().as_array().unwrap().is_empty()); + assert!(discord_has_body(&plain("hi"))); + } + #[test] fn discord_never_pings_unless_asked() { - let quiet = json::parse(&discord_body("@everyone look", "")).unwrap(); + let quiet = json::parse(&text("@everyone look", "")).unwrap(); assert_eq!( quiet .path("allowed_mentions.parse") @@ -399,15 +689,15 @@ mod tests { .len(), 0 ); - let here = json::parse(&discord_body("OBS dropped", "here")).unwrap(); + let here = json::parse(&text("OBS dropped", "here")).unwrap(); assert_eq!(here.str_or("content", ""), "@here OBS dropped"); - let role = json::parse(&discord_body("x", "role:123")).unwrap(); + let role = json::parse(&text("x", "role:123")).unwrap(); assert_eq!(role.str_or("content", ""), "<@&123> x"); assert_eq!( role.path("allowed_mentions.roles.0").unwrap().as_str(), Some("123") ); - let bad = json::parse(&discord_body("x", "role:1 OR 1")).unwrap(); + let bad = json::parse(&text("x", "role:1 OR 1")).unwrap(); assert_eq!(bad.str_or("content", ""), "x"); } @@ -466,13 +756,17 @@ mod tests { #[test] fn a_message_is_posted_edited_and_reposted_once_deleted() { let (url, seen) = fake_discord(4); - let first = send_discord(&url, "down", "", None).unwrap(); + let back_here = DiscordMessage { + ping: "here".into(), + ..plain("back") + }; + let first = send_discord(&url, &plain("down"), None).unwrap(); assert_eq!((first.message_id.as_str(), first.edited), ("111", false)); - let edit = send_discord(&url, "back", "here", Some("111")).unwrap(); + let edit = send_discord(&url, &back_here, Some("111")).unwrap(); assert_eq!((edit.message_id.as_str(), edit.edited), ("111", true)); // Someone deleted message 999: the update still goes out, as new, // and pings like a new message does. - let gone = send_discord(&url, "back", "here", Some("999")).unwrap(); + let gone = send_discord(&url, &back_here, Some("999")).unwrap(); assert_eq!((gone.message_id.as_str(), gone.edited), ("111", false)); assert_eq!( seen.join().unwrap(), @@ -506,7 +800,7 @@ mod tests { #[test] fn discord_messages_are_capped() { - let long = json::parse(&discord_body(&"a".repeat(3000), "")).unwrap(); + let long = json::parse(&text(&"a".repeat(3000), "")).unwrap(); assert_eq!(long.str_or("content", "").len(), 2000); } } diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index f81b8b9..3b725a8 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -9,21 +9,28 @@ //! falls behind, events are dropped and counted, never queued without end. //! //! Limits that keep a runaway integration harmless: -//! - each integration runs at most `PER_INTEGRATION` copies at once, all of -//! them at most `GLOBAL_RUNS`; +//! - each integration has at most `PER_INTEGRATION` runs doing something at +//! once (`GLOBAL_RUNS` for all of them), and at most `PER_INTEGRATION_RUNS` +//! started and not finished (`GLOBAL_STARTED`): a run waiting for the +//! delay gives its place back, so presses during a long delay queue up; //! - `cooldown_ms` spaces runs of the same trigger; chat commands also have //! a per-viewer cooldown; //! - quiet hours keep an integration silent for part of the day; //! - the runner caps waits, nesting and message sizes. +use super::activity::ChatWindow; use super::alerts::Board; use super::effects::RealHost; use super::event::{Event, EventKind}; use super::model::{ - pad_matches, DiscordChannel, Integration, MatchMode, PhoneConnection, Roles, Trigger, + pad_matches, ChatActivity, DiscordChannel, Integration, MatchMode, PhoneConnection, Roles, + Trigger, }; +use super::obsws::{self, SceneNews}; use super::runner::{self, RunContext, RunEnv, RunStatus, StepLog}; +use super::session::Session; use super::store::Store; +use super::timeline::{Kind as LineKind, Timeline}; use super::twitch::irc::ChatMessage; use super::twitch::{unix_ms, Twitch, Which}; use crate::config::Settings; @@ -40,6 +47,8 @@ use tokio::sync::{mpsc, oneshot, watch, Semaphore}; const QUEUE: usize = 512; const PER_INTEGRATION: usize = 4; const GLOBAL_RUNS: usize = 32; +const PER_INTEGRATION_RUNS: usize = 16; +const GLOBAL_STARTED: usize = 64; const ACTIVITY_KEEP: usize = 150; const TICK: Duration = Duration::from_millis(250); /// How often per-viewer and per-trigger bookkeeping is trimmed. A busy @@ -55,6 +64,14 @@ const RECENT_KEEP: usize = 12; /// accepts, then refuses) would otherwise alert on every round. Each /// destination gets at most one "live" and one "dropped" per window. const FLAP_WINDOW: Duration = Duration::from_secs(300); +/// A chat activity trigger stays quiet this long after a stream starts: +/// the first seconds of chat ("hi!", "hello") are a burst by nature. +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. +const OBS_FOR_DASHBOARD: Duration = Duration::from_secs(60); enum Input { Event(Event), @@ -66,15 +83,25 @@ enum Input { Test { integration: Box, handler: usize, + run: TestRun, reply: oneshot::Sender, }, #[cfg_attr(not(windows), allow(dead_code))] Shortcut(Shortcut, String), + Scene(SceneNews), Login(Which), CancelLogin, Logout(Which), } +/// How a test runs: values to use instead of the samples, and whether it +/// may send anything. +#[derive(Clone, Debug, Default)] +pub struct TestRun { + pub values: BTreeMap, + pub dry: bool, +} + /// What a shortcut press came from. Only Windows has hotkeys and MIDI. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(not(windows), allow(dead_code))] @@ -149,6 +176,17 @@ pub struct Handle { /// them, or there are too many), for the dashboard to point out. refused_keys: Mutex>, dropped: AtomicU64, + /// The last minutes of chat, read by chat activity triggers and by the + /// dashboard's live meter while one is being tuned. + chat: Arc>, + /// The OBS WebSocket connection, as scene triggers and the dashboard + /// see it. + obs: Arc>, + /// Until when (Unix ms) the dashboard wants OBS connected. + obs_wanted_until: AtomicU64, + /// 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>, } impl Handle { @@ -195,13 +233,20 @@ impl Handle { let _ = self.store.save(); } - /// Run one handler now with sample values, messages marked `[TEST]`. - pub async fn test(&self, integration: Integration, handler: usize) -> Option { + /// Run one handler now with sample values (`values` override them), + /// messages marked `[TEST]`; a `dry` test sends nothing at all. + pub async fn test( + &self, + integration: Integration, + handler: usize, + run: TestRun, + ) -> Option { let (reply, rx) = oneshot::channel(); self.tx .try_send(Input::Test { integration: Box::new(integration), handler, + run, reply, }) .ok()?; @@ -224,14 +269,18 @@ impl Handle { } fn push(&self, record: Record) { - // "busy" isn't a run: it must not clear a failing streak or fill the - // history dots, and a flood of them is one line in the log. - let busy = record.status == "busy"; + // "busy" and "cooldown" aren't runs: they must not clear a failing + // streak or fill the history dots, and a flood of them is one line + // in the log. + let busy = matches!(record.status, "busy" | "cooldown"); if busy { let mut activity = self.activity.lock(); - if let Some(last) = activity - .back_mut() - .filter(|r| r.status == "busy" && r.integration_id == record.integration_id) + // Among the last few, so two integrations turned away in turn + // can't push real runs out of the log. + if let Some(last) = + activity.iter_mut().rev().take(8).find(|r| { + r.status == record.status && r.integration_id == record.integration_id + }) { last.at_ms = record.at_ms; return; @@ -309,6 +358,47 @@ impl Handle { pub fn dropped_events(&self) -> u64 { self.dropped.load(Ordering::Relaxed) } + + /// How chat reads right now against `rules`: the live meter of a chat + /// activity trigger being tuned, saved or not. + pub fn chat_meter(&self, rules: &ChatActivity) -> Value { + let r = self.chat.lock().read(rules, Instant::now()); + // Not streaming and the first seconds only hold back a trigger that + // only runs while streaming; the reconnect screen holds back all. + let held = match *self.activity_held.lock() { + "offline" | "warmup" if !rules.only_live => "", + held => held, + }; + json::obj([ + ("held", json::str(held)), + ( + "shares", + Value::Arr(r.shares.iter().map(|s| Value::Num(s.round())).collect()), + ), + ("messages", Value::Num(r.messages as f64)), + ("normal", Value::Num((r.normal * 10.0).round() / 10.0)), + ("busier", Value::Num((r.busier * 10.0).round() / 10.0)), + ("chatters", Value::Num(r.chatters as f64)), + ("word_share", Value::Num(r.word_share.round())), + ("top_word", json::str(&r.top_word)), + ("ready", Value::Bool(r.ready)), + ( + "passing", + Value::Arr(r.passing.iter().map(|p| Value::Bool(*p)).collect()), + ), + ("fires", Value::Bool(r.fires)), + ]) + } + + /// 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. + pub fn obs_status(&self) -> Value { + self.obs_wanted_until.store( + unix_ms() + OBS_FOR_DASHBOARD.as_millis() as u64, + Ordering::Relaxed, + ); + self.obs.lock().to_json() + } } /// Start the engine thread. `data_dir` holds the integrations data file. @@ -328,6 +418,7 @@ pub fn start( &settings.borrow().twitch_client_id, )); let alerts = Arc::new(Board::new()); + let obs = Arc::new(Mutex::new(obsws::Status::default())); let handle = Arc::new(Handle { tx, activity: Mutex::new(VecDeque::new()), @@ -337,6 +428,10 @@ pub fn start( store: store.clone(), refused_keys: Mutex::new(Vec::new()), dropped: AtomicU64::new(0), + chat: Arc::new(Mutex::new(ChatWindow::default())), + obs: obs.clone(), + obs_wanted_until: AtomicU64::new(0), + activity_held: Mutex::new("offline"), }); let engine_handle = handle.clone(); std::thread::Builder::new() @@ -354,14 +449,30 @@ 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(), default_delay_ms: Default::default(), programs: Default::default(), alerts, + stream_started_ms: AtomicU64::new(0), + onair: Mutex::new("off"), + obs: obs_tx, + }); + // OBS's scene news 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(); + tokio::spawn(async move { + while let Some(news) = news_rx.recv().await { + if scene_tx.send(Input::Scene(news)).await.is_err() { + return; + } + } }); - let mut engine = Engine::new(engine_handle, host, store, ctrl); + let mut engine = Engine::new(engine_handle, host, store, ctrl, want_obs); engine.run(rx, chat_rx, settings).await; }); })?; @@ -375,19 +486,61 @@ struct Engine { ctrl: Arc, integrations: Vec>, env: Arc, + /// Messages from Shared Chat partners count too (`twitch_shared_chat`). + shared_chat: bool, last_run: HashMap, viewer_last: HashMap, timers: HashMap, - limits: HashMap>, - global: Arc, + /// Per integration: (started, doing something). See the module comment. + limits: HashMap, Arc)>, + global: (Arc, Arc), delay_seen: Option, /// The delay's phase and armed delay at the last tick: see `watch_phase`. phase_seen: Option<(&'static str, u32)>, flap: FlapGuard, - discord_messages: Arc>>, + discord_messages: DiscordMessages, + discord_turns: Arc, /// `{uses}` changed since the data file was last written. uses_dirty: bool, uses_saved: Instant, + session: Session, + timeline: Arc>, + /// The on-air state at the last tick. + onair_seen: Option<&'static str>, + /// The on-air triggers that heard the state last (`id#idx`): one that + /// is new hears it at once, so a light is never out of date. + onair_told: Vec, + /// Per trigger and destination (`id#idx#name`): the drop a "stays + /// down" already fired for, and the drops a "steady again" counted. + still_down_fired: HashMap, + steady_fired: HashMap, + /// Each destination's drop count when "keeps dropping" last looked. + drops_seen: HashMap, + /// When each chat activity trigger last fired. Once fired, it waits for + /// chat to calm down (its rules no longer met) before it can again. + activity_fired: HashMap, + activity_calm: HashMap, + /// When chat was last judged for activity triggers: a raid's hundreds + /// of messages a second are judged a few times a second, not each. + activity_read: Option, + scene_now: String, + /// The scene on air when the OBS connection was lost: the same scene + /// once it's back is no switch. + scene_lost: String, + /// Scene switches waiting out a trigger's settle time, per trigger. + scene_pending: HashMap, + want_obs: watch::Sender, +} + +/// The last Discord message each integration posted, by key (see +/// `RunEnv::discord_messages`). +type DiscordMessages = Arc>>; + +/// A switch to a scene a trigger wants, not yet settled. +struct PendingScene { + since: Instant, + scene: String, + previous: String, } impl Engine { @@ -396,13 +549,17 @@ impl Engine { host: Arc, store: Arc, ctrl: Arc, + want_obs: watch::Sender, ) -> Engine { let discord_messages = Arc::new(Mutex::new(HashMap::new())); + let discord_turns = Arc::new(runner::DiscordTurns::default()); + let timeline = Arc::new(Mutex::new(Timeline::default())); let env = Arc::new(make_env( Vec::new(), PhoneConnection::default(), &store, - &discord_messages, + (&discord_messages, &discord_turns), + (&timeline, &handle), )); Engine { handle, @@ -411,17 +568,36 @@ impl Engine { ctrl, integrations: Vec::new(), env, + shared_chat: false, last_run: HashMap::new(), viewer_last: HashMap::new(), timers: HashMap::new(), limits: HashMap::new(), - global: Arc::new(Semaphore::new(GLOBAL_RUNS)), + global: ( + Arc::new(Semaphore::new(GLOBAL_STARTED)), + Arc::new(Semaphore::new(GLOBAL_RUNS)), + ), delay_seen: None, phase_seen: None, flap: FlapGuard::default(), discord_messages, + discord_turns, uses_dirty: false, uses_saved: Instant::now(), + session: Session::default(), + timeline, + onair_seen: None, + onair_told: Vec::new(), + still_down_fired: HashMap::new(), + steady_fired: HashMap::new(), + drops_seen: HashMap::new(), + activity_fired: HashMap::new(), + activity_calm: HashMap::new(), + activity_read: None, + scene_now: String::new(), + scene_lost: String::new(), + scene_pending: HashMap::new(), + want_obs, } } @@ -450,11 +626,20 @@ impl Engine { self.apply_settings(&snapshot); } _ = tick.tick() => { + // Already followed when they happened (`event` + // observes before the guard): only the integrations + // hear them late. for event in self.flap.release(Instant::now()) { - self.event(event); + self.dispatch(&event); } self.watch_phase(); self.watch_delay(); + self.watch_onair(); + self.watch_session(); + self.watch_destinations(); + self.update_activity_gate(); + self.settle_scenes(); + self.update_obs_want(); self.run_timers(); self.save_uses_if_due(); if last_prune.elapsed() >= PRUNE_EVERY { @@ -472,18 +657,25 @@ impl Engine { s.discord_channels.clone(), s.phone.clone(), &self.store, - &self.discord_messages, + (&self.discord_messages, &self.discord_turns), + (&self.timeline, &self.handle), )); self.host .default_delay_ms .store(s.auto_arm_delay_ms.max(1000), Ordering::Relaxed); self.host.twitch.use_client_id(&s.twitch_client_id); + self.shared_chat = s.twitch_shared_chat; // Forget per-trigger state for integrations that are gone. let alive: Vec<&str> = self.integrations.iter().map(|i| i.id.as_str()).collect(); let keep = |key: &String| alive.iter().any(|id| key.starts_with(&format!("{id}#"))); self.last_run.retain(|k, _| keep(k)); self.viewer_last.retain(|k, _| keep(k)); self.timers.retain(|k, _| keep(k)); + self.still_down_fired.retain(|k, _| keep(k)); + self.steady_fired.retain(|k, _| keep(k)); + self.activity_fired.retain(|k, _| keep(k)); + self.activity_calm.retain(|k, _| keep(k)); + self.scene_pending.retain(|k, _| keep(k)); self.limits.retain(|id, _| alive.contains(&id.as_str())); self.handle.retain_stats(&alive); } @@ -527,6 +719,7 @@ impl Engine { } fn save_uses_if_due(&mut self) { + self.uses_dirty |= self.store.counters_dirty.swap(false, Ordering::Relaxed); if self.uses_dirty && self.uses_saved.elapsed() >= USES_SAVE_EVERY { self.uses_saved = Instant::now(); // A failed write stays due, for the next round. @@ -541,9 +734,11 @@ impl Engine { Input::Test { integration, handler, + run, reply, - } => self.test(*integration, handler, reply), + } => self.test(*integration, handler, run, reply), Input::Shortcut(from, signature) => self.shortcut(from, &signature), + Input::Scene(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), @@ -551,30 +746,321 @@ impl Engine { } fn event(&mut self, event: Event) { - if self.flap.suppress(&event, Instant::now()) { - return; + // The session sees every event, flapping or not: smart destination + // alerts are built on the drops the flap guard holds back. + let made = self.observe(&event); + if !self.flap.suppress(&event, Instant::now()) { + self.dispatch(&event); + } + if event.kind == EventKind::DestinationDropped { + self.check_unstable(&event); } + for next in made { + self.event(next); + } + } + + /// Run every integration waiting for `event`. + fn dispatch(&mut self, event: &Event) { let matches: Vec<(Arc, usize)> = self .enabled_handlers() .filter(|(_, _, h)| match &h.trigger { Trigger::Event { kind, filters } => { - *kind == event.kind && filters_match(filters, &event) + *kind == event.kind && filters_match(filters, event) } _ => false, }) .map(|(i, idx, _)| (i, idx)) .collect(); for (integration, idx) in matches { - let vars = event - .vars - .iter() - .map(|(k, v)| (k.to_string(), v.clone())) - .collect(); - self.start_run(integration, idx, vars, None, event.kind.label().to_string()); + self.start_run( + integration, + idx, + event_vars(event), + None, + event.kind.label().to_string(), + ); + } + } + + /// Follow the stream's life. Returns the events it makes, after + /// setting up for them: a new stream gets a fresh timeline. + fn observe(&mut self, event: &Event) -> Vec { + let ending = matches!( + event.kind, + EventKind::ObsDisconnected | EventKind::HoldExpired | EventKind::HoldEnded + ); + let timeline = if ending { + self.timeline_summary() + } else { + (0, String::new()) + }; + let made = self + .session + .observe(event, Instant::now(), unix_ms(), timeline); + self.follow_session(&made); + made + } + + /// What the end-of-stream report says about the timeline. + fn timeline_summary(&self) -> (usize, String) { + let t = self.timeline.lock(); + (t.count(LineKind::Highlight), t.chapters()) + } + + /// Set up for the stream events the session just made: a new stream + /// gets a fresh timeline, fresh Discord messages, and its scene looked + /// at. + fn follow_session(&mut self, made: &[Event]) { + for e in made { + match e.kind { + EventKind::StreamStarted => { + self.timeline.lock().clear(); + self.still_down_fired.clear(); + self.steady_fired.clear(); + self.drops_seen.clear(); + forget_subject_messages(&mut self.discord_messages.lock()); + self.host + .stream_started_ms + .store(self.session.started_ms(), Ordering::Relaxed); + let scene = self.scene_now.clone(); + if !scene.is_empty() { + self.scene_entered(&scene, "", true); + } + } + EventKind::StreamEnded => self.host.stream_started_ms.store(0, Ordering::Relaxed), + _ => {} + } } } + /// Each tick: a stream whose OBS dropped unprotected and stayed away + /// ends (see `session::END_GRACE`). + fn watch_session(&mut self) { + if !self.session.is_ending() { + return; + } + let timeline = self.timeline_summary(); + let made = self.session.expire(Instant::now(), timeline); + self.follow_session(&made); + for event in made { + self.event(event); + } + } + + /// A destination dropped: tell the "keeps dropping" triggers whose + /// threshold it reached, each with its own count and window. + fn check_unstable(&mut self, dropped: &Event) { + let name = dropped.var("destination").unwrap_or(""); + let Some(health) = self.session.destinations().get(name).cloned() else { + return; + }; + // A retry failing while it is still down isn't a new drop. + if self.drops_seen.insert(name.to_string(), health.drop_count) == Some(health.drop_count) { + return; + } + let now = Instant::now(); + let kind = EventKind::DestinationUnstable; + let due: Vec<(Arc, usize, Event)> = self + .enabled_handlers() + .filter_map(|(i, idx, h)| { + let Trigger::Event { kind: k, filters } = &h.trigger else { + return None; + }; + if *k != kind { + return None; + } + let within_min = kind.param(filters, "within_min"); + let window = Duration::from_secs_f64(within_min * 60.0); + let drops = health.drops_within(window, now); + let event = Event::new(kind) + .with("destination", name) + .with("platform", health.platform.clone()) + .with("reason", health.reason.clone()) + .with("drops", drops.to_string()) + .with("within", format!("{} min", within_min.round())); + let reached = drops as f64 >= kind.param(filters, "drops"); + (reached && filters_match(filters, &event)).then_some((i, idx, event)) + }) + .collect(); + for (integration, idx, event) in due { + self.start_run( + integration, + idx, + event_vars(&event), + None, + kind.label().to_string(), + ); + } + } + + /// Each tick: destinations that stayed down long enough, or have been + /// steady long enough after dropping, for the triggers that wait for it. + fn watch_destinations(&mut self) { + if !self.session.is_on() { + return; + } + let now = Instant::now(); + let mut due: Vec<(Arc, usize, Event, String, usize)> = Vec::new(); + for (i, idx, h) in self.enabled_handlers() { + let Trigger::Event { kind, filters } = &h.trigger else { + continue; + }; + for (name, health) in self.session.destinations() { + let key = format!("{}#{idx}#{name}", i.id); + let drops = health.drop_count; + let event = match kind { + EventKind::DestinationStillDown => { + let Some(since) = health.down_since else { + continue; + }; + let down = now.duration_since(since); + let after = Duration::from_secs_f64(kind.param(filters, "after_s")); + if down < after || self.still_down_fired.get(&key) == Some(&drops) { + continue; + } + Event::new(*kind) + .with("reason", health.reason.clone()) + .with( + "down_for", + super::host::fmt_duration(down.as_millis() as u64), + ) + } + EventKind::DestinationSteady => { + let Some(since) = health.live_since else { + continue; + }; + let after = Duration::from_secs_f64(kind.param(filters, "for_min") * 60.0); + let counted = self.steady_fired.get(&key).copied().unwrap_or(0); + if drops == 0 || drops <= counted || now.duration_since(since) < after { + continue; + } + Event::new(*kind).with( + "down_total", + super::host::fmt_duration(health.down_total.as_millis() as u64), + ) + } + _ => continue, + }; + let event = event + .with("destination", name.clone()) + .with("platform", health.platform.clone()) + .with("drops", drops.to_string()); + // Marked whether or not a filter lets it through: either way + // this outage has been looked at. + due.push((i.clone(), idx, event, key, drops)); + } + } + for (integration, idx, event, key, drops) in due { + let map = if event.kind == EventKind::DestinationStillDown { + &mut self.still_down_fired + } else { + &mut self.steady_fired + }; + map.insert(key, drops); + let Trigger::Event { filters, .. } = &integration.handlers[idx].trigger else { + continue; + }; + if filters_match(filters, &event) { + let label = event.kind.label().to_string(); + self.start_run(integration.clone(), idx, event_vars(&event), None, label); + } + } + } + + /// Each tick: the on-air state, and an event when it changes. On-air + /// triggers that haven't heard it yet (the app just started, or one was + /// just switched on) hear it at once, with `previous` the same as + /// `state`: a light by the door is never out of date. + fn watch_onair(&mut self) { + let stopped_on_purpose = self + .ctrl + .hold_status() + .is_some_and(|h| h.reason == crate::crash_hold::HoldReason::Stopped); + let now = if self.ctrl.hold_active() && !stopped_on_purpose { + "crash" + } else if self.ctrl.hold_active() && self.ctrl.target_delay_ms() > 0 { + // Stopped on purpose, held by crash protection: still on air. + "delay" + } else if self.ctrl.hold_active() { + "live" + } else if self.session.is_ending() { + // OBS dropped without crash protection and may be back any + // second: not "off", nobody should walk in. + "crash" + } else if self.ctrl.ingest_alive() && self.ctrl.target_delay_ms() > 0 { + "delay" + } else if self.ctrl.ingest_alive() { + "live" + } else { + "off" + }; + *self.host.onair.lock() = now; + let before = self.onair_seen.replace(now); + if before.is_some_and(|b| b != now) { + self.onair_told = self.onair_keys(); + self.event( + Event::new(EventKind::OnAirChanged) + .with("state", now) + .with("previous", before.unwrap_or(now)), + ); + return; + } + let keys = self.onair_keys(); + let newcomers: Vec<(Arc, usize)> = self + .enabled_handlers() + .filter(|(i, idx, h)| { + matches!( + &h.trigger, + Trigger::Event { + kind: EventKind::OnAirChanged, + .. + } + ) && !self.onair_told.contains(&format!("{}#{idx}", i.id)) + }) + .map(|(i, idx, _)| (i, idx)) + .collect(); + // Gone ones are forgotten; a new one is told once a run started. + self.onair_told.retain(|k| keys.contains(k)); + let event = Event::new(EventKind::OnAirChanged) + .with("state", now) + .with("previous", now); + for (integration, idx) in newcomers { + let Trigger::Event { filters, .. } = &integration.handlers[idx].trigger else { + continue; + }; + let key = format!("{}#{idx}", integration.id); + let label = event.kind.label().to_string(); + let told = !filters_match(filters, &event) + || self.start_run(integration.clone(), idx, event_vars(&event), None, label); + if told { + self.onair_told.push(key); + } + } + } + + /// The switched-on on-air triggers, as `id#idx`. + fn onair_keys(&self) -> Vec { + self.enabled_handlers() + .filter(|(_, _, h)| { + matches!( + &h.trigger, + Trigger::Event { + kind: EventKind::OnAirChanged, + .. + } + ) + }) + .map(|(i, idx, _)| format!("{}#{idx}", i.id)) + .collect() + } + fn chat(&mut self, msg: ChatMessage) { + // A Shared Chat partner's viewers only count when the streamer said + // so: otherwise anyone there could use this channel's commands. + if msg.from_shared_chat && !self.shared_chat { + return; + } // Our own bot talking must never trigger us. (Unless the "bot" is // the streamer's own account: then these are their own commands.) let is_own_bot = { @@ -591,6 +1077,8 @@ impl Engine { if is_own_bot { return; } + self.handle.chat.lock().push(&msg, Instant::now()); + self.check_activity(&msg); let text = msg.text.trim(); let mut words = text.split_whitespace(); let first = words.next().unwrap_or("").to_lowercase(); @@ -637,10 +1125,228 @@ impl Engine { } } + /// Why chat activity triggers that only run while streaming can't fire + /// now, or empty; "hold" holds back every one. For the meter. + fn activity_gate(&self, now: Instant) -> &'static str { + if self.ctrl.hold_active() { + "hold" + } else if !self.ctrl.ingest_alive() || !self.session.is_on() { + "offline" + } else if self.session.uptime(now) < ACTIVITY_WARMUP { + "warmup" + } else { + "" + } + } + + fn update_activity_gate(&mut self) { + *self.handle.activity_held.lock() = self.activity_gate(Instant::now()); + } + + /// After each chat message: chat activity triggers whose rules chat now + /// meets. Each fires once per burst: then it waits for chat to calm + /// down (and at least one window) before it can fire again. Never + /// during the reconnect screen, and (when it only runs live) not in the + /// first seconds of a stream, when everyone says hi at once. + fn check_activity(&mut self, latest: &ChatMessage) { + let now = Instant::now(); + if self + .activity_read + .is_some_and(|at| now.duration_since(at) < ACTIVITY_READ_EVERY) + { + return; + } + self.activity_read = Some(now); + let gate = self.activity_gate(now); + if gate == "hold" { + return; + } + let streaming = gate.is_empty(); + let candidates: Vec<(Arc, usize, ChatActivity)> = self + .enabled_handlers() + .filter_map(|(i, idx, h)| match &h.trigger { + Trigger::ChatActivity(a) if streaming || !a.only_live => Some((i, idx, a.clone())), + _ => None, + }) + .collect(); + for (integration, idx, rules) in candidates { + let key = format!("{}#{idx}", integration.id); + let window = Duration::from_millis(rules.window_ms); + let reading = self.handle.chat.lock().read(&rules, now); + if !reading.fires { + self.activity_calm.insert(key, true); + continue; + } + let spaced = self + .activity_fired + .get(&key) + .is_none_or(|t| now.duration_since(*t) >= window); + let calmed = self.activity_calm.get(&key).copied().unwrap_or(true); + if !spaced || !calmed { + continue; + } + let vars = BTreeMap::from([ + ("messages".to_string(), reading.messages.to_string()), + ("normal".to_string(), format!("{:.1}", reading.normal)), + ("busier".to_string(), format!("{:.1}", reading.busier)), + ("chatters".to_string(), reading.chatters.to_string()), + ( + "word_share".to_string(), + format!("{:.0}", reading.word_share), + ), + ("top_word".to_string(), reading.top_word.clone()), + ("user".to_string(), latest.display_name.clone()), + ("message".to_string(), latest.text.clone()), + ]); + if self.start_run(integration, idx, vars, None, "Chat got busy".to_string()) { + self.activity_fired.insert(key.clone(), now); + self.activity_calm.insert(key, false); + } + } + } + + /// News from OBS about its scenes. A switch to a scene a trigger wants + /// 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) { + let (name, switched) = match news { + SceneNews::Current(name) => (name, false), + SceneNews::Switched(name) => (name, true), + }; + let previous = std::mem::replace(&mut self.scene_now, name.clone()); + if previous == name { + return; + } + if name.is_empty() { + self.scene_lost = previous; + return; + } + let lost = std::mem::take(&mut self.scene_lost); + if switched { + self.scene_entered(&name, &previous, false); + } else if previous.is_empty() && self.session.is_on() && name != lost { + self.scene_entered(&name, "", true); + } + } + + /// `name` went on air after `previous` (empty: not known). `starting`: + /// a stream started on it, rather than OBS switching to it; a trigger + /// that only acts when coming from a certain scene then doesn't. + fn scene_entered(&mut self, name: &str, previous: &str, starting: bool) { + let now = Instant::now(); + let streaming = self.ctrl.ingest_alive() || starting; + let keys: Vec<(String, bool)> = self + .enabled_handlers() + .filter_map(|(i, idx, h)| match &h.trigger { + Trigger::Scene { + scene, + from, + except, + only_live, + .. + } => { + let from_ok = if from.trim().is_empty() { + true + } else { + !starting && matches_any(previous, from) + }; + let excepted = !except.trim().is_empty() && matches_any(name, except); + let wanted = matches_any(name, scene) + && !excepted + && from_ok + && (streaming || !only_live); + Some((format!("{}#{idx}", i.id), wanted)) + } + _ => None, + }) + .collect(); + for (key, wanted) in keys { + if wanted { + let pending = PendingScene { + since: now, + scene: name.to_string(), + previous: previous.to_string(), + }; + self.scene_pending.insert(key, pending); + } else { + // Moved on before it settled: that switch never happened. + self.scene_pending.remove(&key); + } + } + } + + /// Each tick: scene switches that have settled. The reconnect screen + /// cancels them: a crash isn't a scene change. + fn settle_scenes(&mut self) { + if self.scene_pending.is_empty() { + return; + } + if self.ctrl.hold_active() { + self.scene_pending.clear(); + return; + } + let due: Vec<(Arc, usize, String)> = self + .enabled_handlers() + .filter_map(|(i, idx, h)| { + let Trigger::Scene { settle_ms, .. } = &h.trigger else { + return None; + }; + let key = format!("{}#{idx}", i.id); + let pending = self.scene_pending.get(&key)?; + let settled = pending.since.elapsed() >= Duration::from_millis(*settle_ms); + (settled && pending.scene == self.scene_now).then_some((i, idx, key)) + }) + .collect(); + for (integration, idx, key) in due { + let Some(pending) = self.scene_pending.remove(&key) else { + continue; + }; + let vars = BTreeMap::from([ + ("scene".to_string(), pending.scene.clone()), + ("previous_scene".to_string(), pending.previous), + ]); + self.start_run( + integration, + idx, + vars, + None, + format!("Scene {}", pending.scene), + ); + } + // Whatever is left over for a scene no longer on air is stale. + let now_scene = self.scene_now.clone(); + 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. + fn update_obs_want(&mut self) { + let uses_obs = |h: &super::model::Handler| { + let mut found = matches!(h.trigger, Trigger::Scene { .. }); + 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); + if *self.want_obs.borrow() != wanted { + self.want_obs.send_replace(wanted); + } + } + fn shortcut(&mut self, from: Shortcut, signature: &str) { + let live = self.ctrl.ingest_alive(); let matches: Vec<(Arc, usize)> = self .enabled_handlers() .filter(|(_, _, h)| match (&h.trigger, from) { + ( + Trigger::Shortcut { + only_live: true, .. + }, + _, + ) if !live => false, (Trigger::Shortcut { hotkey, .. }, Shortcut::Hotkey) => { !hotkey.is_empty() && hotkey == signature } @@ -649,30 +1355,47 @@ impl Engine { }) .map(|(i, idx, _)| (i, idx)) .collect(); - let label = match from { - Shortcut::Hotkey => format!("Hotkey {signature}"), - Shortcut::Midi => "MIDI pad".to_string(), + let (label, source) = match from { + Shortcut::Hotkey => (format!("Hotkey {signature}"), "hotkey"), + Shortcut::Midi => ("MIDI pad".to_string(), "midi"), }; for (integration, idx) in matches { - self.start_run(integration, idx, BTreeMap::new(), None, label.clone()); + let vars = BTreeMap::from([("source".to_string(), source.to_string())]); + self.start_run(integration, idx, vars, None, label.clone()); } } + /// A call to a secret address: a web call trigger, or a button's + /// Stream Deck address. fn hook(&mut self, token: &str, body: String, query: String) { - let matches: Vec<(Arc, usize)> = self + let same = |t: &str| { + !t.is_empty() && crate::crypto::constant_time_eq(t.as_bytes(), token.as_bytes()) + }; + let live = self.ctrl.ingest_alive(); + let matches: Vec<(Arc, usize, &'static str)> = self .enabled_handlers() - .filter(|(_, _, h)| { - matches!(&h.trigger, Trigger::Webhook { token: t } - if crate::crypto::constant_time_eq(t.as_bytes(), token.as_bytes())) + .filter_map(|(i, idx, h)| match &h.trigger { + Trigger::Webhook { token: t } if same(t) => Some((i, idx, "Web call")), + Trigger::Shortcut { + token: t, + only_live, + .. + } if same(t) && (live || !only_live) => Some((i, idx, "Stream Deck or app")), + _ => None, }) - .map(|(i, idx, _)| (i, idx)) .collect(); - for (integration, idx) in matches { + for (integration, idx, label) in matches { + let source = if label == "Web call" { + "web" + } else { + "stream_deck" + }; let vars = BTreeMap::from([ ("body".to_string(), body.clone()), ("query".to_string(), query.clone()), + ("source".to_string(), source.to_string()), ]); - self.start_run(integration, idx, vars, None, "Web call".to_string()); + self.start_run(integration, idx, vars, None, label.to_string()); } } @@ -786,27 +1509,35 @@ impl Engine { .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; } - let limit = self + let (started_limit, active_limit) = self .limits .entry(integration.id.clone()) - .or_insert_with(|| Arc::new(Semaphore::new(PER_INTEGRATION))) + .or_insert_with(|| { + ( + Arc::new(Semaphore::new(PER_INTEGRATION_RUNS)), + Arc::new(Semaphore::new(PER_INTEGRATION)), + ) + }) .clone(); + let places = vec![active_limit.clone(), self.global.1.clone()]; let permits = ( - limit.try_acquire_owned(), - self.global.clone().try_acquire_owned(), + started_limit.try_acquire_owned(), + self.global.0.clone().try_acquire_owned(), + active_limit.try_acquire_owned(), + self.global.1.clone().try_acquire_owned(), ); - let (Ok(own), Ok(global)) = permits else { - self.handle.push(Record { - at_ms: unix_ms(), - integration_id: integration.id.clone(), - name: integration.name.clone(), - trigger, - status: "busy", - test: false, - steps: Vec::new(), - }); + let (Ok(started), Ok(started_all), Ok(active), Ok(active_all)) = permits else { + self.turned_away(&integration, trigger, "busy"); return false; }; self.last_run.insert(key, Instant::now()); @@ -823,13 +1554,15 @@ impl Engine { vars, reply_to, test: false, + dry: false, + gate: Some(runner::Gate::new(places, vec![active, active_all])), }; let handle = self.handle.clone(); let ctrl = self.ctrl.clone(); let host: Arc = self.host.clone(); let env = self.env.clone(); tokio::spawn(async move { - let _permits = (own, global); + let _started = (started, started_all); let at_ms = unix_ms(); let (status, steps) = runner::run(&integration.handlers[idx].steps, ctx, host, env).await; @@ -858,11 +1591,37 @@ impl Engine { true } - fn test(&mut self, integration: Integration, idx: usize, reply: oneshot::Sender) { + /// A run that didn't start, in the activity log: `busy` (too many at + /// once) or `cooldown` (too soon after the last). + fn turned_away(&self, integration: &Integration, trigger: String, status: &'static str) { + self.handle.push(Record { + at_ms: unix_ms(), + integration_id: integration.id.clone(), + name: integration.name.clone(), + trigger, + status, + test: false, + steps: Vec::new(), + }); + } + + fn test( + &mut self, + integration: Integration, + idx: usize, + run: TestRun, + reply: oneshot::Sender, + ) { let Some(handler) = integration.handlers.get(idx).cloned() else { return; }; let (mut vars, trigger) = sample_vars(&handler.trigger); + // The values the dashboard was given to test with. + for (name, value) in run.values.into_iter().take(MAX_TEST_VALUES) { + if !name.is_empty() && name.len() <= 64 { + vars.insert(name, value.chars().take(1000).collect()); + } + } // What it would read on its next real run. let next = self .store @@ -878,6 +1637,8 @@ impl Engine { vars, reply_to: None, test: true, + dry: run.dry, + gate: None, }; let host: Arc = self.host.clone(); let env = self.env.clone(); @@ -1010,33 +1771,85 @@ fn status_id(status: &RunStatus) -> &'static str { } } +/// Forget which Discord message each "one message per subject" is about +/// (keys `[test:]integration:channel:subject`): a "down" card still open +/// when the last stream ended must not be edited from the next one, far up +/// the channel. Messages kept per channel (a delay status, say) stay. +fn forget_subject_messages(messages: &mut HashMap) { + messages.retain(|key, _| key.trim_start_matches("test:").matches(':').count() < 2); +} + fn make_env( discord: Vec, phone: PhoneConnection, store: &Arc, - discord_messages: &Arc>>, + (discord_messages, discord_turns): (&DiscordMessages, &Arc), + (timeline, handle): (&Arc>, &Arc), ) -> RunEnv { let saver = store.clone(); + // Weak: the env lives inside the engine the handle owns a channel to. + let events = Arc::downgrade(handle); RunEnv { discord, phone, counters: store.counters.clone(), + // Written with the run counts a moment later (`save_uses_if_due`): + // a chat command counting every message must not rewrite the file + // each time, on the engine's own thread. counters_changed: Box::new(move || { - let _ = saver.save(); + saver.counters_dirty.store(true, Ordering::Relaxed); }), discord_messages: discord_messages.clone(), + discord_turns: discord_turns.clone(), + timeline: timeline.clone(), + emit: Box::new(move |event| { + if let Some(handle) = events.upgrade() { + handle.emit(event); + } + }), } } +fn event_vars(event: &Event) -> BTreeMap { + event + .vars + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() +} + +/// Whether an event passes a trigger's filters. A trigger's settings +/// (thresholds like `after_s`) live among them but aren't filters. A +/// filter can list several values (`YouTube, Kick`) and use wildcards +/// (`*timed out*`); case doesn't matter. fn filters_match(filters: &BTreeMap, event: &Event) -> bool { filters.iter().all(|(name, want)| { want.trim().is_empty() - || event - .var(name) - .is_some_and(|have| have.trim().eq_ignore_ascii_case(want.trim())) + || event.kind.is_param(name) + || event.var(name).is_some_and(|have| matches_any(have, want)) }) } +/// Whether `text` matches any of the comma separated `patterns`, each +/// with `*` and `?` wildcards, ignoring case. +pub fn matches_any(text: &str, patterns: &str) -> bool { + patterns + .split(',') + .any(|p| text_matches(text, p, MatchMode::Wildcard)) +} + +/// A trigger's filters as sample values, where a filter names one value: +/// a test of a "Kick only" alert should read Kick, not YouTube. +fn filter_samples(filters: &BTreeMap, vars: &mut BTreeMap) { + for (name, want) in filters { + let want = want.trim(); + let plain = !want.is_empty() && !want.contains([',', '*', '?']); + if plain && vars.contains_key(name) { + vars.insert(name.clone(), want.to_string()); + } + } +} + fn allowed(roles: Roles, msg: &ChatMessage) -> bool { msg.is_broadcaster || roles.everyone @@ -1090,12 +1903,13 @@ fn sample_vars(trigger: &Trigger) -> (BTreeMap, String) { .collect::>() }; match trigger { - Trigger::Event { kind, .. } => { + Trigger::Event { kind, filters } => { let mut vars: BTreeMap = Event::sample(*kind) .vars .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect(); + filter_samples(filters, &mut vars); // A countdown in a test message should count down from now. if let Some(ends) = vars.get_mut("hold_ends_at") { *ends = (unix_ms() / 1000 + 120).to_string(); @@ -1128,10 +1942,40 @@ fn sample_vars(trigger: &Trigger) -> (BTreeMap, String) { owned(&[("body", r#"{"test":true}"#), ("query", "")]), "Test: web call".to_string(), ), - Trigger::Shortcut { .. } => (BTreeMap::new(), "Test: shortcut".to_string()), + Trigger::Shortcut { .. } => ( + owned(&[("body", ""), ("query", ""), ("source", "hotkey")]), + "Test: button".to_string(), + ), + Trigger::ChatActivity(_) => (owned(ACTIVITY_SAMPLES), "Test: chat got busy".to_string()), + Trigger::Scene { scene, .. } => ( + owned(&[ + ("scene", scene.trim_matches('*')), + ("previous_scene", "Just chatting"), + ]), + "Test: scene".to_string(), + ), } } +/// What a chat activity trigger hands its steps, with sample values. +pub const ACTIVITY_SAMPLES: &[(&str, &str)] = &[ + ("messages", "24"), + ("normal", "4.0"), + ("busier", "6.0"), + ("chatters", "15"), + ("word_share", "58"), + ("top_word", "pog"), + ("user", "Ana"), + ("message", "CLIP THAT"), +]; + +/// Most values a test may bring. +const MAX_TEST_VALUES: usize = 64; + +/// What a scene trigger hands its steps, with sample values. +pub const SCENE_SAMPLES: &[(&str, &str)] = + &[("scene", "Game"), ("previous_scene", "Just chatting")]; + /// Match chat text against a pattern, ignoring case. pub fn text_matches(text: &str, pattern: &str, mode: MatchMode) -> bool { let text = text.to_lowercase(); @@ -1315,5 +2159,41 @@ mod tests { assert!(!filters_match(&want("freeze"), &crash)); assert!(filters_match(&want(""), &crash)); assert!(filters_match(&BTreeMap::new(), &crash)); + assert!(filters_match(&want("crash, freeze"), &crash), "a list"); + let drop = Event::new(EventKind::DestinationDropped).with("reason", "connection timed out"); + assert!(filters_match(&want("*timed out*"), &drop), "a wildcard"); + assert!(!filters_match(&want("crash"), &drop)); + } + + #[test] + fn tests_read_like_the_filtered_event() { + let filters = BTreeMap::from([ + ("destination".to_string(), "Kick".to_string()), + ("reason".to_string(), "*timeout*".to_string()), + ]); + let trigger = Trigger::Event { + kind: EventKind::DestinationDropped, + filters, + }; + let (vars, _) = sample_vars(&trigger); + assert_eq!(vars["destination"], "Kick"); + assert_eq!( + vars["reason"], "connection timed out", + "a pattern isn't a value" + ); + } + + #[test] + fn a_new_stream_forgets_per_subject_messages_only() { + let mut messages = HashMap::from([ + ("i1:ch".to_string(), "1".to_string()), + ("i1:ch:YouTube".to_string(), "2".to_string()), + ("test:i1:ch".to_string(), "3".to_string()), + ("test:i1:ch:flap Kick".to_string(), "4".to_string()), + ]); + forget_subject_messages(&mut messages); + let mut kept: Vec<_> = messages.into_keys().collect(); + kept.sort(); + assert_eq!(kept, ["i1:ch", "test:i1:ch"]); } } diff --git a/src/integrations/event.rs b/src/integrations/event.rs index 5fe45c5..f7d58ce 100644 --- a/src/integrations/event.rs +++ b/src/integrations/event.rs @@ -25,22 +25,46 @@ pub enum EventKind { DelayArmed, DelayReady, DelayDisarmed, + // Made by the engine from the events above (see `engine::Session`). + StreamStarted, + StreamEnded, + OnAirChanged, + TimelineUpdated, + DestinationRecovered, + DestinationStillDown, + DestinationUnstable, + DestinationSteady, } /// One variable an event carries: its name and a sample value. pub type VarSpec = (&'static str, &'static str); +/// A setting of the trigger rather than a value to match: (name, default, +/// label). Saved among the trigger's filters, read by the engine. +pub type ParamSpec = (&'static str, &'static str, &'static str); + +/// The most any trigger setting can be. See `EventKind::param`. +const MAX_PARAM: f64 = 1_000_000.0; + impl EventKind { - pub const ALL: [EventKind; 16] = [ + pub const ALL: [EventKind; 24] = [ EventKind::ObsConnected, EventKind::ObsDisconnected, EventKind::EbDetected, + EventKind::StreamStarted, + EventKind::StreamEnded, + EventKind::OnAirChanged, + EventKind::TimelineUpdated, EventKind::HoldOpened, EventKind::ObsBack, EventKind::HoldExpired, EventKind::HoldEnded, EventKind::DestinationLive, EventKind::DestinationDropped, + EventKind::DestinationRecovered, + EventKind::DestinationStillDown, + EventKind::DestinationUnstable, + EventKind::DestinationSteady, EventKind::AllDestinationsDown, // The delay's life, in order: armed, ready, on air, changed, off. EventKind::DelayArmed, @@ -69,6 +93,14 @@ impl EventKind { EventKind::DelayArmed => "delay_armed", EventKind::DelayReady => "delay_ready", EventKind::DelayDisarmed => "delay_disarmed", + EventKind::StreamStarted => "stream_started", + EventKind::StreamEnded => "stream_ended", + EventKind::OnAirChanged => "onair_changed", + EventKind::TimelineUpdated => "timeline_updated", + EventKind::DestinationRecovered => "destination_recovered", + EventKind::DestinationStillDown => "destination_still_down", + EventKind::DestinationUnstable => "destination_unstable", + EventKind::DestinationSteady => "destination_steady", } } @@ -85,30 +117,46 @@ impl EventKind { EventKind::ObsBack => "OBS is back", EventKind::HoldExpired => "The hold runs out", EventKind::HoldEnded => "You end the hold early", - EventKind::DestinationLive => "A destination goes live", - EventKind::DestinationDropped => "A destination drops", - EventKind::AllDestinationsDown => "Every destination is down", + EventKind::DestinationLive => "A platform goes live", + EventKind::DestinationDropped => "A platform drops", + EventKind::AllDestinationsDown => "Every platform is down", EventKind::DelayOn => "The delay turns on", EventKind::DelayOff => "The delay turns off", EventKind::DelayChanged => "The delay changes", EventKind::DelayArmed => "You arm a delay", EventKind::DelayReady => "The armed delay is ready", EventKind::DelayDisarmed => "You cancel an armed delay", + EventKind::StreamStarted => "A stream starts", + EventKind::StreamEnded => "A stream ends", + EventKind::OnAirChanged => "The on-air state changes", + EventKind::TimelineUpdated => "The timeline gets a line", + EventKind::DestinationRecovered => "A platform comes back", + EventKind::DestinationStillDown => "A platform stays down", + EventKind::DestinationUnstable => "A platform keeps dropping", + EventKind::DestinationSteady => "A platform is steady again", } } pub fn group(self) -> &'static str { match self { - EventKind::ObsConnected | EventKind::ObsDisconnected | EventKind::EbDetected => { - "Stream" - } + EventKind::ObsConnected + | EventKind::ObsDisconnected + | EventKind::EbDetected + | EventKind::StreamStarted + | EventKind::StreamEnded + | EventKind::OnAirChanged + | EventKind::TimelineUpdated => "Stream", EventKind::HoldOpened | EventKind::ObsBack | EventKind::HoldExpired | EventKind::HoldEnded => "Crash protection", EventKind::DestinationLive | EventKind::DestinationDropped - | EventKind::AllDestinationsDown => "Destinations", + | EventKind::DestinationRecovered + | EventKind::DestinationStillDown + | EventKind::DestinationUnstable + | EventKind::DestinationSteady + | EventKind::AllDestinationsDown => "Platforms", EventKind::DelayOn | EventKind::DelayOff | EventKind::DelayChanged @@ -124,6 +172,8 @@ impl EventKind { match self { // `protected`: crash protection took over (a hold opened). EventKind::ObsDisconnected => &[("stopped", "no"), ("protected", "no")], + // `reason`: crash, freeze, or stopped (OBS stopped on purpose and + // crash protection holds every disconnect). // `hold_ends_at`: when the hold runs out, in Unix seconds. Discord // shows `` as a live countdown. EventKind::HoldOpened => &[ @@ -146,9 +196,87 @@ impl EventKind { // Armed: the delay that will go on air once the buffer holds it. EventKind::DelayArmed | EventKind::DelayReady => &[("delay", "30s")], EventKind::DelayDisarmed => &[("previous", "30s")], - EventKind::ObsConnected | EventKind::EbDetected | EventKind::AllDestinationsDown => &[], + EventKind::StreamEnded => &[ + ("duration", "3h 12m"), + ("crashes", "1"), + ("drops", "2"), + ("highlights", "4"), + ("report", "YouTube: 2 drops, down 1m 12s\nKick: steady"), + ("chapters", "0:00 Start\n12:31 Ranked\n1:04:10 Boss fight"), + ], + // `state` is crash, delay, live or off: the most important one + // that is true, in that order. + EventKind::OnAirChanged => &[("state", "live"), ("previous", "off")], + EventKind::TimelineUpdated => &[("line", "⭐ Highlight"), ("kind", "highlight")], + EventKind::DestinationRecovered => &[ + ("destination", "YouTube"), + ("platform", "youtube"), + ("down_for", "1m 12s"), + ("down_s", "72"), + ], + EventKind::DestinationStillDown => &[ + ("destination", "YouTube"), + ("platform", "youtube"), + ("reason", "connection timed out"), + ("down_for", "1m 00s"), + ("drops", "1"), + ], + EventKind::DestinationUnstable => &[ + ("destination", "YouTube"), + ("platform", "youtube"), + ("reason", "connection timed out"), + ("drops", "3"), + ("within", "10 min"), + ], + EventKind::DestinationSteady => &[ + ("destination", "YouTube"), + ("platform", "youtube"), + ("drops", "4"), + ("down_total", "2m 30s"), + ], + EventKind::ObsConnected + | EventKind::EbDetected + | EventKind::AllDestinationsDown + | EventKind::StreamStarted => &[], + } + } + + /// Settings of a trigger on this event: thresholds the engine applies + /// per trigger, so two integrations can tune them differently. + pub fn params(self) -> &'static [ParamSpec] { + match self { + EventKind::DestinationStillDown => &[("after_s", "60", "Still down after (seconds)")], + EventKind::DestinationUnstable => &[ + ("drops", "3", "Drops"), + ("within_min", "10", "Within (minutes)"), + ], + EventKind::DestinationSteady => &[("for_min", "10", "Live again for (minutes)")], + _ => &[], } } + + /// A trigger's setting `name`, or its default when unset or not a + /// positive number. Capped at a million (11 days in seconds): the + /// engine turns these into durations, and an absurd one typed in the + /// dashboard must not overflow one. + pub fn param(self, filters: &std::collections::BTreeMap, name: &str) -> f64 { + let default = self + .params() + .iter() + .find(|(n, ..)| *n == name) + .and_then(|(_, d, _)| d.parse().ok()) + .unwrap_or(0.0); + filters + .get(name) + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| v.is_finite() && *v > 0.0) + .unwrap_or(default) + .min(MAX_PARAM) + } + + pub fn is_param(self, name: &str) -> bool { + self.params().iter().any(|(n, ..)| *n == name) + } } /// Something that happened, with its values. @@ -202,6 +330,21 @@ pub fn catalog_json() -> Value { ("label", json::str(k.label())), ("group", json::str(k.group())), ("vars", vars_json(k.vars())), + ( + "params", + Value::Arr( + k.params() + .iter() + .map(|(name, default, label)| { + json::obj([ + ("name", json::str(*name)), + ("default", json::str(*default)), + ("label", json::str(*label)), + ]) + }) + .collect(), + ), + ), ]) }) .collect(), @@ -221,6 +364,7 @@ pub fn vars_json(vars: &[VarSpec]) -> Value { #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeMap; #[test] fn ids_round_trip_and_are_unique() { @@ -240,6 +384,20 @@ mod tests { assert_eq!(e.var("reason"), Some("connection timed out")); } + #[test] + fn trigger_settings_fall_back_and_stay_bounded() { + let kind = EventKind::DestinationStillDown; + let set = |v: &str| BTreeMap::from([("after_s".to_string(), v.to_string())]); + assert_eq!(kind.param(&BTreeMap::new(), "after_s"), 60.0); + assert_eq!(kind.param(&set("90"), "after_s"), 90.0); + assert_eq!(kind.param(&set("-5"), "after_s"), 60.0); + assert_eq!(kind.param(&set("soon"), "after_s"), 60.0); + assert_eq!(kind.param(&set("1e30"), "after_s"), MAX_PARAM); + // What the engine does with it can't overflow a Duration. + 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 9be7873..4812b55 100644 --- a/src/integrations/host.rs +++ b/src/integrations/host.rs @@ -23,6 +23,13 @@ pub struct LiveState { pub bitrate_kbps: u64, /// The connected Twitch channel's login, or empty. pub channel: String, + /// Whether a stream is on (see `session::Session`): between streams + /// there is no timeline to add to. + pub streaming: bool, + /// How long this stream has run, 0 when none is on. + pub uptime_ms: u64, + /// crash, delay, live or off: see `EventKind::OnAirChanged`. + pub onair: String, } pub struct HttpRequest { @@ -42,6 +49,33 @@ pub struct ClipInfo { pub url: 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)] +pub struct DiscordMessage { + pub content: String, + pub card: Option, + /// `here`, `everyone`, `role:` or empty. + pub ping: String, +} + +/// A Discord embed: the colored card. Every field is already filled in; +/// `effects::discord_body` drops what Discord would refuse. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DiscordCard { + pub title: String, + /// The title links here when it is a web address. + pub url: String, + pub description: String, + pub color: u32, + /// (name, value, inline) + pub fields: Vec<(String, String, bool)>, + pub footer: String, + pub timestamp: bool, + pub image: String, + pub thumbnail: String, +} + /// A Discord message that went out. pub struct DiscordPosted { /// Discord's id for it, for editing it later; empty if none came back. @@ -57,8 +91,7 @@ pub trait Host: Send + Sync { fn discord( &self, webhook_url: &str, - content: &str, - ping: &str, + message: &DiscordMessage, edit: Option<&str>, ) -> Result; fn http(&self, request: HttpRequest) -> Result; @@ -82,6 +115,8 @@ pub trait Host: Send + Sync { 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>; } /// `30s`, `1m 30s`, or empty for no delay, so `{delay|off}` reads naturally. @@ -97,6 +132,28 @@ pub fn fmt_delay(ms: u32) -> String { } } +/// A stream position, the way YouTube chapters and VOD players write it: +/// `12:31`, or `1:04:10` past the hour. +pub fn fmt_clock(ms: u64) -> String { + let total = ms / 1000; + let (h, m, s) = (total / 3600, total / 60 % 60, total % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + +/// `3h 12m`, `12m`, `40 s`: how long a whole stream ran. +pub fn fmt_span(ms: u64) -> String { + let total = ms / 1000; + match (total / 3600, total / 60 % 60) { + (0, 0) => format!("{total} s"), + (0, m) => format!("{m}m"), + (h, m) => format!("{h}h {m:02}m"), + } +} + /// `2m 00s` style durations, matching the crash protection messages. pub fn fmt_duration(ms: u64) -> String { let total = ms / 1000; @@ -120,6 +177,16 @@ mod tests { assert_eq!(fmt_delay(29_600), "30s"); } + #[test] + fn stream_positions_read_like_a_player() { + assert_eq!(fmt_clock(0), "0:00"); + assert_eq!(fmt_clock(751_000), "12:31"); + assert_eq!(fmt_clock(3_850_000), "1:04:10"); + assert_eq!(fmt_span(40_000), "40 s"); + assert_eq!(fmt_span(720_000), "12m"); + assert_eq!(fmt_span(11_520_000), "3h 12m"); + } + #[test] fn durations_pad_seconds() { assert_eq!(fmt_duration(38_000), "38 s"); diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 685244d..99eeb28 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -16,7 +16,11 @@ //! - `engine`: the isolated thread that matches events to integrations. //! - `twitch`: login, token upkeep, chat, markers and clips. //! - `store`: Twitch logins and counters, kept apart from the settings. +//! - `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. +pub mod activity; pub mod alerts; pub mod api; pub mod botcmd; @@ -26,11 +30,14 @@ pub mod engine; pub mod event; pub mod host; pub mod model; +pub mod obsws; pub mod presets; pub mod recipe; pub mod runner; +pub mod session; pub mod store; pub mod template; +pub mod timeline; pub mod twitch; pub use engine::Handle; diff --git a/src/integrations/model.rs b/src/integrations/model.rs index 7b3dc68..dcc72b6 100644 --- a/src/integrations/model.rs +++ b/src/integrations/model.rs @@ -14,7 +14,8 @@ use crate::json::{self, Value}; use std::collections::BTreeMap; pub const MAX_INTEGRATIONS: usize = 100; -pub const MAX_HANDLERS: usize = 16; +/// Room for the "every event" webhook, one trigger per event. +pub const MAX_HANDLERS: usize = 32; pub const MAX_STEPS: usize = 64; pub const MAX_DEPTH: usize = 4; const MAX_NAME_LEN: usize = 80; @@ -119,11 +120,91 @@ pub enum Trigger { Timer { every_ms: u64, only_live: bool }, /// A call to `/hooks/`, from a Stream Deck, a script or any app. Webhook { token: String }, - /// A global hotkey (`Ctrl+Alt+K`) or a MIDI pad (`note:1:36@Device`) - /// on this PC. Either may be blank, not both. - Shortcut { hotkey: String, midi: String }, + /// A button: a global hotkey (`Ctrl+Alt+K`), a MIDI pad + /// (`note:1:36@Device`) on this PC, or a secret `/hooks/` + /// address a Stream Deck or any program presses. Any may be blank, not + /// all three. `only_live`: presses while OBS isn't streaming do nothing. + Shortcut { + hotkey: String, + midi: String, + token: String, + only_live: bool, + }, + /// Chat gets busy in a way the rules describe (see `ChatActivity`). + ChatActivity(ChatActivity), + /// OBS switches its program scene to one matching `scene` (`*` and `?` + /// wildcards) but not `except` (blank: none), coming from one matching + /// `from` (blank: any), and stays there `settle_ms`. `only_live`: only + /// while OBS is streaming; a stream that starts on a matching scene + /// counts as switching to it. Needs obs-websocket. + Scene { + scene: String, + from: String, + except: String, + settle_ms: u64, + only_live: bool, + }, +} + +/// A chat activity trigger: rules over the last `window_ms` of chat, all of +/// them or any one of them. Only messages from `roles` count. +#[derive(Clone, Debug, PartialEq)] +pub struct ChatActivity { + pub window_ms: u64, + pub match_all: bool, + pub rules: Vec, + pub roles: Roles, + pub only_live: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ActivityRule { + pub kind: RuleKind, + pub value: f64, + /// For `Words`: what counts, comma separated. + pub words: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuleKind { + /// At least `value` messages in the window. + Messages, + /// At least `value` times the channel's normal speed. + Busier, + /// At least `value` different people talking. + Chatters, + /// At least `value` percent of messages contain one of `words`. + Words, +} + +impl RuleKind { + pub fn id(self) -> &'static str { + match self { + RuleKind::Messages => "messages", + RuleKind::Busier => "busier", + RuleKind::Chatters => "chatters", + RuleKind::Words => "words", + } + } + + fn from_id(id: &str) -> Option { + [ + RuleKind::Messages, + RuleKind::Busier, + RuleKind::Chatters, + RuleKind::Words, + ] + .into_iter() + .find(|k| k.id() == id) + } } +const MIN_ACTIVITY_WINDOW_MS: u64 = 5_000; +const MAX_ACTIVITY_WINDOW_MS: u64 = 120_000; +const MAX_ACTIVITY_RULES: usize = 8; +/// Longest a scene trigger may wait for the scene to settle. +pub const MAX_SETTLE_MS: u64 = 60_000; + /// Who may fire a chat trigger. The broadcaster always may. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Roles { @@ -199,10 +280,14 @@ pub enum StepKind { /// Reshape a value (first line, between two texts, round...) into a /// new variable. EditText, + /// Add a line, a chapter or a highlight to this stream's timeline. + Timeline, + /// Switch a scene, show or hide a source, or set a text source in OBS. + Obs, } impl StepKind { - pub const ALL: [StepKind; 17] = [ + pub const ALL: [StepKind; 19] = [ StepKind::Discord, StepKind::Chat, StepKind::Phone, @@ -220,6 +305,8 @@ impl StepKind { StepKind::Counter, StepKind::Overlay, StepKind::EditText, + StepKind::Timeline, + StepKind::Obs, ]; pub fn id(self) -> &'static str { @@ -241,6 +328,8 @@ impl StepKind { StepKind::Counter => "counter", StepKind::Overlay => "overlay", StepKind::EditText => "edit_text", + StepKind::Timeline => "timeline", + StepKind::Obs => "obs", } } @@ -252,10 +341,8 @@ impl StepKind { /// the user when it is blank. fn required(self) -> &'static [(&'static str, &'static str)] { match self { - StepKind::Discord => &[ - ("connection", "pick a Discord channel"), - ("text", "write the Discord message"), - ], + // A card needs any one of its parts: see `validate_steps`. + StepKind::Discord => &[("connection", "pick a Discord channel")], StepKind::Chat => &[("text", "write the chat message")], StepKind::Phone => &[("text", "write the phone message")], StepKind::Http => &[("url", "add the web address")], @@ -270,6 +357,8 @@ impl StepKind { StepKind::SetVar | StepKind::Counter => &[("name", "name the value")], StepKind::Overlay => &[("text", "write what shows on stream")], 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::WaitDelay | StepKind::Stop | StepKind::Marker | StepKind::Clip => &[], } } @@ -284,6 +373,8 @@ impl StepKind { #[derive(Clone, Debug, PartialEq)] pub struct Step { pub kind: StepKind, + /// A switched-off step is skipped, with everything inside it. + pub enabled: bool, pub params: BTreeMap, /// For `If`: the steps run when the check passes / fails. pub then: Vec, @@ -294,6 +385,7 @@ impl Step { pub fn new(kind: StepKind, params: &[(&str, &str)]) -> Self { Self { kind, + enabled: true, params: params .iter() .map(|(k, v)| (k.to_string(), v.to_string())) @@ -361,8 +453,16 @@ impl Integration { errors.push(format!("more than {MAX_STEPS} steps")); } for handler in &self.handlers { - validate_trigger(&handler.trigger, &mut incomplete); - validate_steps(&handler.steps, 1, &mut errors, &mut incomplete); + // A switched-off trigger can stay half set up: it never runs, so + // only what makes it unsafe to save counts. + let mut unused = Vec::new(); + let missing = if handler.enabled { + &mut incomplete + } else { + &mut unused + }; + validate_trigger(&handler.trigger, missing); + validate_steps(&handler.steps, 1, &mut errors, missing); } errors.dedup(); incomplete.dedup(); @@ -427,7 +527,7 @@ pub fn shortcuts(integrations: &[Integration]) -> (Vec, Vec) { .filter(|i| i.enabled) .flat_map(|i| i.handlers.iter().filter(|h| h.enabled)); for handler in live { - if let Trigger::Shortcut { hotkey, midi } = &handler.trigger { + if let Trigger::Shortcut { hotkey, midi, .. } = &handler.trigger { if !hotkey.is_empty() && !keys.contains(hotkey) { keys.push(hotkey.clone()); } @@ -469,9 +569,9 @@ pub fn shortcut_owner<'a>( .iter() .filter(|h| h.enabled) .any(|h| match &h.trigger { - Trigger::Shortcut { hotkey: key, midi } => { - (!hotkey.is_empty() && key == hotkey) || pads_overlap(midi, pad) - } + Trigger::Shortcut { + hotkey: key, midi, .. + } => (!hotkey.is_empty() && key == hotkey) || pads_overlap(midi, pad), _ => false, }); owns.then_some(i.name.as_str()) @@ -508,9 +608,19 @@ fn validate_trigger(trigger: &Trigger, errors: &mut Vec) { Trigger::Webhook { token } if token.len() < 16 || !valid_id(token) => { errors.push("the web call link is invalid; make a new one".to_string()); } - Trigger::Shortcut { hotkey, midi } => { - if hotkey.is_empty() && midi.is_empty() { - errors.push("press a key or a MIDI pad for the shortcut".to_string()); + Trigger::Shortcut { + hotkey, + midi, + token, + .. + } => { + if hotkey.is_empty() && midi.is_empty() && token.is_empty() { + errors.push( + "set a hotkey, a MIDI pad or a Stream Deck address for the button".to_string(), + ); + } + if !token.is_empty() && (token.len() < 16 || !valid_id(token)) { + errors.push("the button's web address is invalid; make a new one".to_string()); } if !hotkey.is_empty() && crate::config::canonicalize_hotkey(hotkey).as_ref() != Some(hotkey) @@ -523,10 +633,44 @@ fn validate_trigger(trigger: &Trigger, errors: &mut Vec) { errors.push("that MIDI pad isn't valid; learn it again".to_string()); } } + Trigger::ChatActivity(activity) => validate_activity(activity, errors), + Trigger::Scene { + scene, settle_ms, .. + } => { + if scene.trim().is_empty() { + errors.push("pick the OBS scene it reacts to".to_string()); + } + if *settle_ms > MAX_SETTLE_MS { + errors.push("a scene can settle for at most a minute".to_string()); + } + } _ => {} } } +fn validate_activity(activity: &ChatActivity, errors: &mut Vec) { + if !(MIN_ACTIVITY_WINDOW_MS..=MAX_ACTIVITY_WINDOW_MS).contains(&activity.window_ms) { + errors.push("watch chat for between 5 seconds and 2 minutes".to_string()); + } + if activity.rules.is_empty() { + errors.push("add at least one chat rule".to_string()); + } + if activity.rules.len() > MAX_ACTIVITY_RULES { + errors.push(format!("at most {MAX_ACTIVITY_RULES} chat rules")); + } + for rule in &activity.rules { + if !(rule.value.is_finite() && rule.value > 0.0) { + errors.push("every chat rule needs a number above 0".to_string()); + } + if rule.kind == RuleKind::Words && rule.words.trim().is_empty() { + errors.push("the words rule needs words to look for".to_string()); + } + if rule.kind == RuleKind::Words && rule.value > 100.0 { + errors.push("the words rule is a percentage, 100 at most".to_string()); + } + } +} + pub fn valid_command(c: &str) -> bool { c.len() >= 2 && c.len() <= 32 @@ -545,9 +689,39 @@ fn validate_steps( return; } for step in steps { + // A switched-off step never runs: half-finished is fine. What would + // make it unsafe (below) still counts, as it can be switched on. + let mut unused = Vec::new(); + let missing = if step.enabled { + &mut *incomplete + } else { + &mut unused + }; for (name, ask) in step.kind.required() { if step.param(name).trim().is_empty() { - incomplete.push(ask.to_string()); + missing.push(ask.to_string()); + } + } + if step.kind == StepKind::Discord { + if let Some(ask) = discord_message_missing(step) { + missing.push(ask.to_string()); + } + } + let ms = step.param("ms").trim(); + if step.kind == StepKind::Wait + && !ms.is_empty() + && !ms.contains('{') + && ms.parse::().is_err() + { + missing.push("set how long to wait, in milliseconds".to_string()); + } + if step.kind == StepKind::Obs { + if let Some(ask) = obs_step_missing(step) { + 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()); } } // Where a program runs, a file is written or a request goes must be @@ -564,6 +738,26 @@ fn validate_steps( if step.kind == StepKind::Http && !host_is_fixed(step.param("url")) { errors.push("a web request's address can't put variables in its host".to_string()); } + // A card's link and pictures are fetched and shown by Discord: + // chat must not pick the site they come from. + if step.kind == StepKind::Discord + && ["url", "image", "thumbnail"] + .iter() + .any(|name| !host_is_fixed(step.param(name))) + { + errors.push( + "a Discord card's link and images can't put variables in their site".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"), + _ => "", + }; + if named.contains('{') { + errors.push("a value's name can't use variables".to_string()); + } if step.params.values().any(|v| v.len() > MAX_PARAM_LEN) { errors.push(format!( "a {} step has a value that is too long", @@ -573,8 +767,36 @@ fn validate_steps( if step.kind != StepKind::If && !(step.then.is_empty() && step.otherwise.is_empty()) { errors.push(format!("a {} step cannot hold other steps", step.kind.id())); } - validate_steps(&step.then, depth + 1, errors, incomplete); - validate_steps(&step.otherwise, depth + 1, errors, incomplete); + validate_steps(&step.then, depth + 1, errors, missing); + validate_steps(&step.otherwise, depth + 1, errors, missing); + } +} + +/// What a Discord step still needs to say anything. A plain message needs +/// its text; a card needs any one of its parts, since a title or fields +/// alone make a whole card. +fn discord_message_missing(step: &Step) -> Option<&'static str> { + let has = |name: &str| !step.param(name).trim().is_empty(); + if step.param("style") != "card" { + return (!has("text")).then_some("write the Discord message"); + } + let any = ["title", "text", "fields", "image", "above"] + .iter() + .any(|name| has(name)); + (!any).then_some("give the Discord card a title or some text") +} + +/// The OBS actions a step can take, with what each needs. +pub const OBS_ACTIONS: [&str; 5] = ["scene", "show", "hide", "toggle", "text"]; + +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"), + action if !OBS_ACTIONS.contains(&action) => Some("pick what to do in OBS"), + _ => None, } } @@ -671,14 +893,81 @@ fn trigger_json(t: &Trigger) -> Value { Trigger::Webhook { token } => { json::obj([("type", json::str("webhook")), ("token", json::str(token))]) } - Trigger::Shortcut { hotkey, midi } => json::obj([ + Trigger::Shortcut { + hotkey, + midi, + token, + only_live, + } => json::obj([ ("type", json::str("shortcut")), ("hotkey", json::str(hotkey)), ("midi", json::str(midi)), + ("token", json::str(token)), + ("only_live", Value::Bool(*only_live)), + ]), + Trigger::ChatActivity(a) => json::obj([ + ("type", json::str("chat_activity")), + ("window_ms", Value::Num(a.window_ms as f64)), + ("match", json::str(if a.match_all { "all" } else { "any" })), + ( + "rules", + Value::Arr( + a.rules + .iter() + .map(|r| { + json::obj([ + ("kind", json::str(r.kind.id())), + ("value", Value::Num(r.value)), + ("words", json::str(&r.words)), + ]) + }) + .collect(), + ), + ), + ("roles", roles_json(a.roles)), + ("only_live", Value::Bool(a.only_live)), + ]), + Trigger::Scene { + scene, + from, + except, + settle_ms, + only_live, + } => json::obj([ + ("type", json::str("scene")), + ("scene", json::str(scene)), + ("from", json::str(from)), + ("except", json::str(except)), + ("settle_ms", Value::Num(*settle_ms as f64)), + ("only_live", Value::Bool(*only_live)), ]), } } +fn activity_from_json(v: &Value) -> Result { + let rules = v + .get("rules") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .map(|r| { + let id = r.str_or("kind", ""); + Ok(ActivityRule { + kind: RuleKind::from_id(id).ok_or_else(|| format!("unknown chat rule \"{id}\""))?, + value: r.get("value").and_then(Value::as_f64).unwrap_or(0.0), + words: r.str_or("words", "").to_string(), + }) + }) + .collect::, String>>()?; + Ok(ChatActivity { + window_ms: v.u64_or("window_ms", 10_000), + match_all: v.str_or("match", "all") != "any", + rules, + roles: roles_from_json(v.get("roles")), + only_live: v.bool_or("only_live", true), + }) +} + fn trigger_from_json(v: &Value) -> Result { match v.str_or("type", "") { "event" => { @@ -718,6 +1007,16 @@ fn trigger_from_json(v: &Value) -> Result { "shortcut" => Ok(Trigger::Shortcut { hotkey: v.str_or("hotkey", "").trim().to_string(), midi: v.str_or("midi", "").trim().to_string(), + token: v.str_or("token", "").trim().to_string(), + only_live: v.bool_or("only_live", false), + }), + "chat_activity" => activity_from_json(v).map(Trigger::ChatActivity), + "scene" => Ok(Trigger::Scene { + scene: v.str_or("scene", "").trim().to_string(), + from: v.str_or("from", "").trim().to_string(), + except: v.str_or("except", "").trim().to_string(), + settle_ms: v.u64_or("settle_ms", 3_000), + only_live: v.bool_or("only_live", false), }), other => Err(format!("unknown trigger \"{other}\"")), } @@ -759,6 +1058,10 @@ fn steps_json(steps: &[Step]) -> Value { ("type".to_string(), json::str(s.kind.id())), ("params".to_string(), string_map_json(&s.params)), ]; + // Only written when off: most steps are on. + if !s.enabled { + fields.push(("enabled".to_string(), Value::Bool(false))); + } if s.kind == StepKind::If { fields.push(("then".to_string(), steps_json(&s.then))); fields.push(("else".to_string(), steps_json(&s.otherwise))); @@ -781,6 +1084,7 @@ fn steps_from_json(v: Option<&Value>, depth: usize) -> Result, String> let kind = StepKind::from_id(id).ok_or_else(|| format!("unknown step \"{id}\""))?; Ok(Step { kind, + enabled: s.bool_or("enabled", true), params: string_map_from_json(s.get("params")), then: steps_from_json(s.get("then"), depth + 1)?, otherwise: steps_from_json(s.get("else"), depth + 1)?, @@ -879,6 +1183,8 @@ mod tests { trigger: Trigger::Shortcut { hotkey: "Ctrl+Alt+K".into(), midi: "note:1:36@Launchpad".into(), + token: String::new(), + only_live: false, }, steps: vec![Step::new(StepKind::Overlay, &[("text", "Clipped!")])], }, @@ -913,10 +1219,34 @@ mod tests { roles: Roles::EVERYONE, user_cooldown_ms: 0, }; + bad.handlers[1].enabled = true; let errors = bad.validate(); assert!(errors.iter().any(|e| e.contains("name"))); assert!(errors.iter().any(|e| e == "pick a Discord channel")); assert!(errors.iter().any(|e| e.contains("\"clip\""))); + // Switched off, a half-set trigger doesn't stop it running. + bad.handlers[1].enabled = false; + assert!(!bad.validate().iter().any(|e| e.contains("\"clip\""))); + } + + #[test] + fn a_card_needs_any_part_and_a_plain_message_its_text() { + let mut i = sample(); + let card = Step::new( + StepKind::Discord, + &[("connection", "c"), ("style", "card"), ("title", "Live")], + ); + i.handlers[0].steps = vec![card]; + assert!(i.validate().is_empty(), "{:?}", i.validate()); + i.handlers[0].steps[0].params.remove("title"); + assert!(i + .validate() + .iter() + .any(|e| e.contains("title or some text"))); + let mut off = Step::new(StepKind::Discord, &[("connection", "c")]); + off.enabled = false; + i.handlers[0].steps = vec![off]; + assert!(i.validate().is_empty(), "a switched-off step can wait"); } #[test] @@ -1043,21 +1373,82 @@ mod tests { fn shortcuts_need_a_real_key_or_pad() { let mut i = sample(); let at = i.handlers.len() - 1; - i.handlers[at].trigger = Trigger::Shortcut { - hotkey: String::new(), - midi: String::new(), - }; - assert!(i.validate().iter().any(|e| e.contains("press a key"))); - i.handlers[at].trigger = Trigger::Shortcut { - hotkey: "K".into(), - midi: String::new(), + let button = |hotkey: &str, midi: &str, token: &str| Trigger::Shortcut { + hotkey: hotkey.into(), + midi: midi.into(), + token: token.into(), + only_live: false, }; + i.handlers[at].trigger = button("", "", ""); + assert!(i.validate().iter().any(|e| e.contains("set a hotkey"))); + i.handlers[at].trigger = button("K", "", ""); assert!(i.validate().iter().any(|e| e.contains("isn't a hotkey"))); - i.handlers[at].trigger = Trigger::Shortcut { - hotkey: String::new(), - midi: "note:1:36".into(), + i.handlers[at].trigger = button("", "note:1:36", ""); + assert!(i.validate().is_empty(), "{:?}", i.validate()); + i.handlers[at].trigger = button("", "", "0123456789abcdef0123"); + assert!(i.validate().is_empty(), "a web address alone is a button"); + i.handlers[at].trigger = button("", "", "short"); + assert!(i.validate().iter().any(|e| e.contains("web address"))); + } + + #[test] + fn chat_activity_and_scene_triggers_round_trip_and_validate() { + let mut i = sample(); + i.handlers[0].trigger = Trigger::ChatActivity(ChatActivity { + window_ms: 10_000, + match_all: false, + rules: vec![ + ActivityRule { + kind: RuleKind::Busier, + value: 3.0, + words: String::new(), + }, + ActivityRule { + kind: RuleKind::Words, + value: 40.0, + words: "clip, pog".into(), + }, + ], + roles: Roles::EVERYONE, + only_live: true, + }); + i.handlers[1].trigger = Trigger::Scene { + scene: "Game*".into(), + from: String::new(), + except: "Game over".into(), + settle_ms: 3_000, + only_live: true, }; assert!(i.validate().is_empty(), "{:?}", i.validate()); + let back = Integration::from_json(&json::parse(&i.to_json().to_json()).unwrap()).unwrap(); + assert_eq!(back, i); + } + + #[test] + fn chat_activity_rules_are_checked() { + let mut i = sample(); + let mut activity = ChatActivity { + window_ms: 1_000, + match_all: true, + rules: vec![ActivityRule { + kind: RuleKind::Words, + value: 150.0, + words: " ".into(), + }], + roles: Roles::EVERYONE, + only_live: true, + }; + i.handlers[0].trigger = Trigger::ChatActivity(activity.clone()); + let errors = i.validate(); + assert!(errors.iter().any(|e| e.contains("5 seconds"))); + assert!(errors.iter().any(|e| e.contains("words to look for"))); + assert!(errors.iter().any(|e| e.contains("percentage"))); + activity.rules.clear(); + i.handlers[0].trigger = Trigger::ChatActivity(activity); + assert!(i + .validate() + .iter() + .any(|e| e.contains("at least one chat rule"))); } #[test] diff --git a/src/integrations/obsws.rs b/src/integrations/obsws.rs new file mode 100644 index 0000000..a577df4 --- /dev/null +++ b/src/integrations/obsws.rs @@ -0,0 +1,859 @@ +//! 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. +//! +//! 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. +//! +//! 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. + +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::tcp::OwnedWriteHalf; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, watch}; + +/// How long to wait before trying again after OBS refused or went away. +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). +const SCENE_EVENTS: u32 = 1 << 2; +const INPUT_EVENTS: u32 = 1 << 3; +const DEFAULT_PORT: u16 = 4455; + +/// What the dashboard shows about the connection. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Status { + /// `off`, `connecting`, `connected` or `error`. + pub state: &'static str, + /// Why it isn't connected, for the user. + pub problem: String, + pub scenes: Vec, + /// Every source (input) in OBS, for OBS steps to pick from. + pub sources: Vec, + pub current: String, +} + +impl Status { + pub fn to_json(&self) -> Value { + json::obj([ + ( + "state", + json::str(if self.state.is_empty() { + "off" + } else { + self.state + }), + ), + ("problem", json::str(&self.problem)), + ( + "scenes", + Value::Arr(self.scenes.iter().map(json::str).collect()), + ), + ( + "sources", + Value::Arr(self.sources.iter().map(json::str).collect()), + ), + ("current", json::str(&self.current)), + ]) + } +} + +/// What OBS tells the engine. +#[derive(Debug, PartialEq)] +pub enum SceneNews { + /// The scene on air when the connection opened: not a switch. + Current(String), + /// The program scene switched. + Switched(String), +} + +/// Something an OBS step asks OBS to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + /// Put this scene on air. + Scene(String), + /// Show (`Some(true)`), hide (`Some(false)`) or flip (`None`) a source + /// in a scene; a blank scene is the one on air. + Source { + scene: String, + source: String, + visible: Option, + }, + /// Set what a text source says. + Text { source: String, text: 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>, + /// After this the step has given up: doing it then would switch a + /// scene nobody asked for any more. + pub deadline: std::time::Instant, +} + +/// Not `Debug`: it holds the WebSocket password. +struct Settings { + port: u16, + password: String, +} + +/// One whole message from OBS, as the reader task hands it over. +enum Incoming { + Json(Value), + /// A ping, to answer with this payload. + Ping(Vec), +} + +/// Keep a connection open while `want` is true, telling `news` about +/// scenes, keeping `status` current and doing what `commands` ask. Runs +/// until the engine stops. +pub async fn run( + mut want: watch::Receiver, + status: Arc>, + news: mpsc::Sender, + mut commands: mpsc::Receiver, +) { + loop { + while !*want.borrow_and_update() { + set_state(&status, "off", ""); + tokio::select! { + changed = want.changed() => if changed.is_err() { + return; + }, + Some(command) = commands.recv() => refuse(command, "OBS isn't connected right now"), + } + } + set_state(&status, "connecting", ""); + let problem = match read_settings() { + Err(problem) => problem, + 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; + match ended { + Ok(()) => continue, + Err(problem) => problem, + } + } + }; + set_state(&status, "error", &problem); + // Wait, but wake at once if it is no longer wanted. Steps asking + // meanwhile hear why OBS isn't there. + let retry = tokio::time::sleep(RETRY); + tokio::pin!(retry); + loop { + tokio::select! { + _ = &mut retry => break, + changed = want.changed() => { + if changed.is_err() { + return; + } + break; + } + Some(command) = commands.recv() => refuse(command, &problem), + } + } + } +} + +fn refuse(command: Command, why: &str) { + let _ = command.reply.send(Err(why.to_string())); +} + +fn set_state(status: &Mutex, state: &'static str, problem: &str) { + let mut s = status.lock(); + s.state = state; + s.problem = problem.to_string(); + if state != "connected" { + s.current.clear(); + } +} + +/// OBS's WebSocket server settings, from the file OBS keeps them in. +fn read_settings() -> Result { + let 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.", + )?; + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("couldn't read OBS's WebSocket settings: {e}"))?; + parse_settings(&text) +} + +fn parse_settings(text: &str) -> Result { + let v = json::parse(text.trim_start_matches('\u{feff}')) + .map_err(|_| "OBS's WebSocket settings file is damaged".to_string())?; + if !v.bool_or("server_enabled", false) { + return Err( + "Switch on OBS's WebSocket server: Tools > WebSocket Server Settings > Enable WebSocket server." + .to_string(), + ); + } + let port = v + .get("server_port") + .and_then(Value::as_f64) + .filter(|p| (1.0..=65535.0).contains(p)) + .map_or(DEFAULT_PORT, |p| p as u16); + let password = if v.bool_or("auth_required", false) { + v.str_or("server_password", "").to_string() + } else { + String::new() + }; + Ok(Settings { port, password }) +} + +/// One connection, from handshake until OBS goes away or it is no longer +/// wanted (`Ok`). +async fn session( + settings: &Settings, + want: &mut watch::Receiver, + status: &Arc>, + news: &mpsc::Sender, + commands: &mut mpsc::Receiver, +) -> Result<(), String> { + let addr = format!("127.0.0.1:{}", settings.port); + let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr)) + .await + .map_err(|_| "OBS didn't answer. Is OBS open?".to_string())? + .map_err(|_| "OBS isn't open, or its WebSocket server is off.".to_string())?; + // Something else answering on OBS's port must not hold the connection + // half open for ever. + tokio::time::timeout(CONNECT_TIMEOUT * 2, greet(&mut stream, settings)) + .await + .map_err(|_| { + "Something answered on OBS's port, but it isn't OBS's WebSocket server.".to_string() + })??; + let (mut reader, mut writer) = stream.into_split(); + let (inbox_tx, mut inbox) = mpsc::channel(64); + let reader_task = tokio::spawn(async move { + loop { + let message = read_message(&mut reader).await; + let lost = message.is_err(); + if inbox_tx.send(message).await.is_err() || lost { + return; + } + } + }); + let mut link = Link { + writer: &mut writer, + inbox: &mut inbox, + status, + news, + }; + let result = link.serve(want, commands).await; + reader_task.abort(); + result +} + +/// The WebSocket handshake and obs-websocket's Hello and Identify. +async fn greet(stream: &mut TcpStream, settings: &Settings) -> Result<(), String> { + handshake(stream, settings.port).await?; + let hello = read_json(stream).await?; + let identify = identify_message(&hello, &settings.password)?; + write_frame(stream, 1, identify.as_bytes()).await?; + let identified = read_json(stream).await?; + if identified.u64_or("op", 99) != 2 { + return Err("OBS refused to connect.".to_string()); + } + Ok(()) +} + +/// A live connection: where requests go and messages come from. +struct Link<'a> { + writer: &'a mut OwnedWriteHalf, + inbox: &'a mut mpsc::Receiver>, + status: &'a Arc>, + news: &'a mpsc::Sender, +} + +impl Link<'_> { + async fn serve( + &mut self, + want: &mut watch::Receiver, + commands: &mut mpsc::Receiver, + ) -> Result<(), String> { + self.request_lists().await?; + set_state(self.status, "connected", ""); + loop { + tokio::select! { + changed = want.changed() => { + if changed.is_err() || !*want.borrow() { + let _ = write_frame(self.writer, 8, &[]).await; + return Ok(()); + } + } + Some(command) = commands.recv() => { + if std::time::Instant::now() >= command.deadline { + continue; + } + let outcome = self.perform(&command.action).await; + let _ = command.reply.send(outcome); + } + message = self.inbox.recv() => { + let message = message.ok_or("lost OBS")??; + self.take(message).await?; + } + } + } + } + + /// Deal with a message that isn't the answer anyone is waiting for. + 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(()) + } + } + } + + 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 + } + + /// Ask OBS for one thing and wait for its answer, dealing with whatever + /// else arrives meanwhile. + async fn call(&mut self, request: &str, data: Value) -> Result { + static NEXT: AtomicU64 = AtomicU64::new(1); + let id = format!("step{}", NEXT.fetch_add(1, Ordering::Relaxed)); + send_request(self.writer, request, &id, data).await?; + loop { + let message = tokio::time::timeout(CALL_TIMEOUT, self.inbox.recv()) + .await + .map_err(|_| "OBS didn't answer in time".to_string())? + .ok_or("lost OBS")??; + let Incoming::Json(v) = &message else { + self.take(message).await?; + continue; + }; + if v.u64_or("op", 99) != 7 || v.path("d.requestId").and_then(Value::as_str) != Some(&id) + { + self.take(message).await?; + continue; + } + return answer(v); + } + } + + async fn perform(&mut self, action: &Action) -> Result<(), String> { + match action { + Action::Scene(scene) => { + let data = json::obj([("sceneName", json::str(scene))]); + self.call("SetCurrentProgramScene", data).await.map(drop) + } + Action::Text { source, text } => { + let data = json::obj([ + ("inputName", json::str(source)), + ("inputSettings", json::obj([("text", json::str(text))])), + ]); + self.call("SetInputSettings", data).await.map(drop) + } + Action::Source { + scene, + source, + visible, + } => self.show_source(scene, source, *visible).await, + } + } + + async fn show_source( + &mut self, + scene: &str, + source: &str, + visible: Option, + ) -> Result<(), String> { + let scene = match scene.trim() { + "" => self.status.lock().current.clone(), + name => name.to_string(), + }; + let found = self + .call( + "GetSceneItemId", + json::obj([ + ("sceneName", json::str(&scene)), + ("sourceName", json::str(source)), + ]), + ) + .await + .map_err(|_| format!("\"{source}\" isn't in the scene \"{scene}\""))?; + let item = found + .get("sceneItemId") + .and_then(Value::as_f64) + .ok_or("OBS didn't say where that source is")?; + let where_ = || { + vec![ + ("sceneName".to_string(), json::str(&scene)), + ("sceneItemId".to_string(), Value::Num(item)), + ] + }; + let show = match visible { + Some(show) => show, + None => { + let now = self + .call("GetSceneItemEnabled", Value::Obj(where_())) + .await?; + !now.bool_or("sceneItemEnabled", true) + } + }; + let mut set = where_(); + set.push(("sceneItemEnabled".to_string(), Value::Bool(show))); + self.call("SetSceneItemEnabled", Value::Obj(set)) + .await + .map(drop) + } +} + +/// A request's answer: its data, or why OBS said no. +fn answer(v: &Value) -> Result { + let d = v.get("d").cloned().unwrap_or(Value::Null); + if d.path("requestStatus.result").and_then(Value::as_bool) == Some(true) { + return Ok(d.get("responseData").cloned().unwrap_or(Value::Null)); + } + let comment = d + .path("requestStatus.comment") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + Err(if comment.is_empty() { + "OBS said no".to_string() + } else { + format!("OBS said no: {comment}") + }) +} + +async fn send_request( + writer: &mut OwnedWriteHalf, + request: &str, + id: &str, + data: Value, +) -> Result<(), String> { + let mut d = vec![ + ("requestType".to_string(), json::str(request)), + ("requestId".to_string(), json::str(id)), + ]; + if !matches!(data, Value::Null) { + d.push(("requestData".to_string(), data)); + } + let message = json::obj([("op", Value::Num(6.0)), ("d", Value::Obj(d))]).to_json(); + 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). +async fn handle_message( + message: &Value, + status: &Arc>, + news: &mpsc::Sender, +) -> bool { + 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, + }, + // Request response: one of the lists. + 7 => { + let Some(data) = d.get("responseData") else { + return false; + }; + match d.str_or("requestId", "") { + "scenes" => { + let current = data.str_or("currentProgramSceneName", "").to_string(); + // OBS lists scenes bottom to top; the dashboard shows them + // as OBS's scene dock does. + let scenes = names(data, "scenes", "sceneName", true); + { + let mut s = status.lock(); + s.scenes = scenes; + s.current = current.clone(); + } + let _ = news.send(SceneNews::Current(current)).await; + } + "inputs" => status.lock().sources = names(data, "inputs", "inputName", false), + _ => {} + } + false + } + _ => false, + } +} + +/// The `field` of each item in the `list` of an answer. +fn names(data: &Value, list: &str, field: &str, reversed: bool) -> Vec { + let mut out: Vec = data + .get(list) + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .map(|s| s.str_or(field, "").to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if reversed { + out.reverse(); + } + out +} + +/// The Identify message answering OBS's Hello, with the password proof +/// when OBS asks for one. +fn identify_message(hello: &Value, password: &str) -> Result { + if hello.u64_or("op", 99) != 0 { + return Err("that isn't OBS's WebSocket server".to_string()); + } + let mut d = vec![ + ("rpcVersion".to_string(), Value::Num(1.0)), + ( + "eventSubscriptions".to_string(), + Value::Num(f64::from(SCENE_EVENTS | INPUT_EVENTS)), + ), + ]; + if let Some(auth) = hello.path("d.authentication") { + if password.is_empty() { + return Err("OBS wants a WebSocket password but none is saved. Set one in OBS: Tools > WebSocket Server Settings.".to_string()); + } + let proof = auth_proof( + password, + auth.str_or("salt", ""), + auth.str_or("challenge", ""), + ); + d.push(("authentication".to_string(), json::str(proof))); + } + Ok(json::obj([("op", Value::Num(1.0)), ("d", Value::Obj(d))]).to_json()) +} + +/// obs-websocket's password proof: +/// base64(sha256(base64(sha256(password + salt)) + challenge)). +fn auth_proof(password: &str, salt: &str, challenge: &str) -> String { + let secret = base64(&crate::sha256::digest( + format!("{password}{salt}").as_bytes(), + )); + base64(&crate::sha256::digest( + format!("{secret}{challenge}").as_bytes(), + )) +} + +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(()) +} + +/// The next whole text message during the handshake, as JSON, answering +/// pings on the way. +async fn read_json(stream: &mut TcpStream) -> Result { + loop { + match read_message(stream).await? { + Incoming::Json(v) => return Ok(v), + Incoming::Ping(payload) => write_frame(stream, 10, &payload).await?, + } + } +} + +/// 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)), + _ => {} + } + } +} + +/// What OBS's close code means, for the user. +fn close_reason(payload: &[u8]) -> String { + let code = (payload.len() >= 2).then(|| u16::from_be_bytes([payload[0], payload[1]])); + match code { + Some(4009) => "OBS refused the password. Restart OBS if you just changed it.".to_string(), + Some(4010) => { + "This OBS is too old for scene triggers: update to OBS 28 or later.".to_string() + } + _ => "OBS closed the connection".to_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 +} + +#[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() { + assert_eq!( + auth_proof( + "supersecretpassword", + "lM1GncleQOaCu9lT1yeUZhFYnqhsLLP1G5lAGo3ixaI=", + "+IxH4CnCiqpX1rM9scsNynZzbOe4KhDeYcTNS3PDaeY=" + ), + "1Ct943GAT+6YQUUX47Ia/ncufilbe6+oD6lY+5kaCu4=" + ); + } + + #[test] + fn settings_need_the_server_switched_on() { + let on = r#"{"server_enabled":true,"server_port":4456,"auth_required":true,"server_password":"pw"}"#; + let s = parse_settings(on).unwrap(); + assert_eq!((s.port, s.password.as_str()), (4456, "pw")); + let open = r#"{"server_enabled":true,"auth_required":false,"server_password":"pw"}"#; + let s = parse_settings(open).unwrap(); + assert_eq!((s.port, s.password.as_str()), (4455, "")); + let off = parse_settings(r#"{"server_enabled":false}"#).err(); + assert!(off.is_some_and(|e| e.contains("Tools"))); + assert!(parse_settings("nope").is_err()); + } + + #[test] + fn identify_answers_hello_with_or_without_a_password() { + let open = json::parse(r#"{"op":0,"d":{"rpcVersion":1}}"#).unwrap(); + let msg = json::parse(&identify_message(&open, "").unwrap()).unwrap(); + assert_eq!(msg.u64_or("op", 0), 1); + assert!(msg.path("d.authentication").is_none()); + assert_eq!( + msg.path("d.eventSubscriptions").and_then(Value::as_f64), + Some(12.0), + "scenes and sources" + ); + let locked = json::parse( + r#"{"op":0,"d":{"rpcVersion":1,"authentication":{"salt":"s","challenge":"c"}}}"#, + ) + .unwrap(); + assert!(identify_message(&locked, "").is_err_and(|e| e.contains("password"))); + let msg = json::parse(&identify_message(&locked, "pw").unwrap()).unwrap(); + assert!(msg.path("d.authentication").is_some()); + } + + #[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( + r#"{"op":7,"d":{"requestId":"step1","requestStatus":{"result":true,"code":100},"responseData":{"sceneItemId":7}}}"#, + ) + .unwrap(); + assert_eq!( + answer(&yes) + .unwrap() + .get("sceneItemId") + .and_then(Value::as_f64), + Some(7.0) + ); + let no = json::parse( + r#"{"op":7,"d":{"requestId":"step2","requestStatus":{"result":false,"code":600,"comment":"No source was found"}}}"#, + ) + .unwrap(); + assert_eq!(answer(&no).unwrap_err(), "OBS said no: No source was found"); + } + + #[tokio::test] + async fn lists_land_in_the_status_by_request() { + let status = Arc::new(Mutex::new(Status::default())); + let (news, mut heard) = mpsc::channel(4); + let scenes = json::parse( + r#"{"op":7,"d":{"requestId":"scenes","requestStatus":{"result":true},"responseData":{"currentProgramSceneName":"Game","scenes":[{"sceneName":"Game"},{"sceneName":"Lobby"}]}}}"#, + ) + .unwrap(); + let inputs = json::parse( + 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); + let s = status.lock().clone(); + assert_eq!( + s.scenes, + ["Lobby", "Game"], + "as OBS's scene dock shows them" + ); + assert_eq!(s.sources, ["Cam", "Title"]); + assert_eq!(heard.recv().await, Some(SceneNews::Current("Game".into()))); + } + + #[test] + fn close_codes_explain_themselves() { + assert!(close_reason(&4009u16.to_be_bytes()).contains("password")); + assert!(close_reason(&[]).contains("closed")); + } +} diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index a368058..25f2806 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -17,8 +17,9 @@ pub struct Preset { /// `alerts`, `chat` or `auto`: the catalog's categories. pub category: &'static str, pub description: &'static str, - /// Connection it posts through: `discord`, `twitch`, `phone`, `web` or `file`. - pub needs: &'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. + pub needs: &'static [&'static str], pub recommended: bool, /// What the catalog card previews: (kind, sample text, sample chat line). pub preview: (&'static str, &'static str, &'static str), @@ -27,10 +28,10 @@ pub struct Preset { pub const PRESETS: &[Preset] = &[ Preset { id: "crash_alert", - name: "Crash alert", + name: "OBS crash alert", category: "alerts", - description: "One Discord message per drop that updates itself: down, then back.", - needs: "discord", + description: "One Discord message per OBS crash that updates itself: down, then back on air.", + needs: &["discord"], recommended: true, preview: ( "discord", @@ -38,12 +39,30 @@ pub const PRESETS: &[Preset] = &[ "", ), }, + Preset { + id: "smart_alerts", + name: "Smart platform alerts", + category: "alerts", + description: "Quiet on blips. One live-updating card when YouTube, Kick or any platform is really down or keeps dropping, and a health report when you end.", + needs: &["discord"], + recommended: true, + preview: ("discord", "🔴 YouTube is down", ""), + }, + Preset { + id: "stream_timeline", + name: "Stream timeline", + category: "alerts", + description: "One Discord card per stream that writes itself: crashes, delay changes and platform drops, each with its time in the VOD. Add Highlight button or Chapters and they land here too.", + needs: &["discord"], + recommended: true, + preview: ("discord", "📼 Stream timeline", ""), + }, Preset { id: "destination_down", - name: "Destination down", + name: "Platform dropped", category: "alerts", - description: "YouTube, Kick or any destination drops? You know why, right away.", - needs: "discord", + description: "Every time YouTube, Kick or any platform drops, you know why, right away. Even short blips.", + needs: &["discord"], recommended: false, preview: ("discord", "YouTube dropped: connection timed out.", ""), }, @@ -52,7 +71,7 @@ pub const PRESETS: &[Preset] = &[ name: "Going live ping", category: "alerts", description: "Tells your community the moment OBS starts streaming.", - needs: "discord", + needs: &["discord"], recommended: false, preview: ("discord", "We're live! Come hang out.", ""), }, @@ -61,7 +80,7 @@ pub const PRESETS: &[Preset] = &[ name: "Delay status", category: "alerts", description: "One Discord message that follows your delay: armed, ready, on, off.", - needs: "discord", + needs: &["discord"], recommended: false, preview: ("discord", "✅ Delay ready: 30s. It can go on air.", ""), }, @@ -70,7 +89,7 @@ pub const PRESETS: &[Preset] = &[ name: "Phone push on crash", category: "alerts", description: "A push on your lock screen, so you know to run back to the PC.", - needs: "phone", + needs: &["phone"], recommended: false, preview: ( "phone", @@ -83,7 +102,7 @@ pub const PRESETS: &[Preset] = &[ name: "Tell chat", category: "chat", description: "After 20 s, so short blips never reach chat. Says \"we're back\" too.", - needs: "twitch", + needs: &["twitch"], recommended: true, preview: ( "chat", @@ -96,7 +115,7 @@ pub const PRESETS: &[Preset] = &[ name: "!delay", category: "chat", description: "Anyone types it, the bot answers with your delay. Always up to date.", - needs: "twitch", + needs: &["twitch"], recommended: true, preview: ("chat", "Delay: 30s", "!delay"), }, @@ -105,7 +124,7 @@ pub const PRESETS: &[Preset] = &[ name: "Delay on / off notice", category: "chat", description: "Tells chat when the delay switches. Never says how long.", - needs: "twitch", + needs: &["twitch"], recommended: false, preview: ("chat", "Stream delay is now on.", ""), }, @@ -114,7 +133,7 @@ pub const PRESETS: &[Preset] = &[ name: "Mod controls", category: "chat", description: "!cut and !setdelay 30 for your mods. Controls the stream.", - needs: "twitch", + needs: &["twitch"], recommended: false, preview: ("chat", "Cut by mod_anna.", "!cut"), }, @@ -123,25 +142,70 @@ pub const PRESETS: &[Preset] = &[ name: "!discord", category: "chat", description: "A command with your own reply. Rename it to anything.", - needs: "twitch", + needs: &["twitch"], recommended: false, preview: ("chat", "Join the Discord: [YOUR INVITE LINK]", "!discord"), }, + Preset { + id: "chapters", + name: "Chapters", + category: "chat", + description: "Mods type \"!chapter Boss fight\" in chat to mark the VOD. When you end, you get a YouTube chapter list ready to paste.", + needs: &["twitch", "discord"], + recommended: false, + preview: ("chat", "📌 Chapter \"Boss fight\" at 1:04:10", "!chapter Boss fight"), + }, Preset { id: "api_command", name: "Command from a website", category: "chat", description: "Replies with anything a website returns: a rank, a stat, a quote.", - needs: "twitch", + needs: &["twitch"], recommended: false, preview: ("chat", "Radiant 450RR", "!rank"), }, + Preset { + id: "highlight", + name: "Highlight button", + category: "auto", + description: "A hotkey, MIDI pad or Stream Deck button clips the moment once it aired, marks the VOD and posts the clip to Discord.", + needs: &["twitch", "discord"], + recommended: true, + preview: ("discord", "⭐ New highlight", ""), + }, + Preset { + id: "hype_clip", + name: "Hype clip", + category: "auto", + description: "Chat goes wild, it clips. Rules you can stack, measured against your own chat, so it fits any channel size.", + needs: &["twitch", "discord"], + recommended: true, + preview: ("discord", "🔥 Chat went wild", ""), + }, + 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"], + recommended: false, + preview: ("json", "", ""), + }, + 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.", + needs: &["obs"], + recommended: false, + preview: ("action", "", ""), + }, Preset { id: "clip_button", name: "Clip button", category: "auto", description: "One key or MIDI pad clips the moment and posts the link in chat.", - needs: "twitch", + needs: &["twitch"], recommended: false, preview: ("chat", "🎬 Clipped: https://clips.twitch.tv/BraveSnipe", ""), }, @@ -150,7 +214,7 @@ pub const PRESETS: &[Preset] = &[ name: "VOD markers", category: "auto", description: "A marker at every crash and cut, so your editor finds them in seconds.", - needs: "twitch", + needs: &["twitch"], recommended: false, preview: ("marker", "Crash", ""), }, @@ -159,7 +223,7 @@ pub const PRESETS: &[Preset] = &[ name: "Webhook", category: "auto", description: "Every event as JSON to n8n, Home Assistant, Zapier or your own tool.", - needs: "web", + needs: &["web"], recommended: false, preview: ("json", "", ""), }, @@ -168,7 +232,7 @@ pub const PRESETS: &[Preset] = &[ name: "Crash counter", category: "auto", description: "Counts crashes into a text file an OBS text source can show.", - needs: "file", + needs: &["file"], recommended: false, preview: ("file", "Crashes today: 2", ""), }, @@ -182,6 +246,18 @@ pub struct Pack { } pub const PACKS: &[Pack] = &[ + Pack { + id: "stream_pro", + name: "Stream pro", + description: "Your whole stream in Discord: a live timeline, highlights, chapters and alerts that only speak up when it matters.", + presets: &["stream_timeline", "smart_alerts", "highlight", "chapters"], + }, + Pack { + id: "highlights", + name: "Highlights", + description: "Never miss a clip: a button for you, and chat's reaction for when you're too busy to press it.", + presets: &["highlight", "hype_clip", "chapters"], + }, Pack { id: "crash_safety", name: "Crash safety", @@ -197,8 +273,14 @@ pub const PACKS: &[Pack] = &[ Pack { id: "watchtower", name: "Watchtower", - description: "For when you step away: every problem reaches your phone and Discord.", - presets: &["destination_down", "phone_crash", "crash_alert"], + description: "For when you step away: OBS crashes reach your phone, platform problems reach Discord.", + presets: &["smart_alerts", "phone_crash", "crash_alert"], + }, + Pack { + id: "studio", + name: "Studio", + description: "Your room and OBS join in: an on-air light by the door, and the delay following your scenes.", + presets: &["on_air_light", "scene_delay"], }, ]; @@ -244,19 +326,71 @@ fn discord(channel: &str, text: &str) -> Step { ) } -/// Edits the message this integration last posted in the channel, so a -/// drop stays one message that changes ("down", then "back"). -fn discord_update(channel: &str, text: &str) -> Step { +// Card colors: the dashboard's own palette, which reads well on Discord's +// dark theme too. +const RED: &str = "#f2665a"; +const GREEN: &str = "#3fcf8e"; +const AMBER: &str = "#f0a53a"; +const BLUE: &str = "#5ac8fa"; +const GOLD: &str = "#fcd34d"; +const GREY: &str = "#8b949e"; +const PURPLE: &str = "#a78bfa"; + +/// A Discord card: a colored embed with a title, a body and a timestamp. +fn card(channel: &str, color: &str, title: &str, text: &str) -> Step { Step::new( StepKind::Discord, - &[("connection", channel), ("text", text), ("edit", "last")], + &[ + ("connection", channel), + ("style", "card"), + ("color", color), + ("title", title), + ("text", text), + ("footer", "InstantClone"), + ("timestamp", "yes"), + ], ) } +/// `step` with more parameters set. +fn with(mut step: Step, params: &[(&str, &str)]) -> Step { + for (k, v) in params { + step.params.insert(k.to_string(), v.to_string()); + } + step +} + fn chat(text: &str) -> Step { Step::new(StepKind::Chat, &[("text", text)]) } +/// A line for this stream's timeline. +fn line(kind: &str, at: &str, text: &str) -> Step { + Step::new( + StepKind::Timeline, + &[("kind", kind), ("at", at), ("text", text)], + ) +} + +/// A button: a hotkey, a MIDI pad or a Stream Deck address. It comes with +/// an address so a Stream Deck works straight away; keys are picked after. +fn button(steps: Vec) -> Handler { + Handler { + enabled: true, + trigger: Trigger::Shortcut { + hotkey: String::new(), + midi: String::new(), + token: crate::crypto::random_token()[..32].to_string(), + only_live: false, + }, + steps, + } +} + +fn wait_aired(extra_ms: &str) -> Step { + Step::new(StepKind::WaitDelay, &[("extra_ms", extra_ms)]) +} + fn reply(text: &str) -> Step { Step::new(StepKind::Chat, &[("text", text), ("reply", "yes")]) } @@ -278,39 +412,37 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option { - let mut ended = on( - EventKind::HoldEnded, - vec![discord_update(d, "⏹️ Crash protection ended by the streamer. The stream has ended.")], - ); - ended.enabled = false; - vec![ - // `` is Discord's own countdown: it ticks in the - // message without anyone editing it. - on(EventKind::HoldOpened, vec![discord(d, "🔴 OBS dropped ({reason}). Holding the stream: it ends unless OBS is back.")]), - on(EventKind::ObsBack, vec![discord_update(d, "🟢 OBS dropped, and was back after {down_for}. Live again.")]), - on(EventKind::HoldExpired, vec![discord_update(d, "⚫ OBS didn't come back within {hold}. The stream has ended.")]), - ended, - ] + "crash_alert" => crash_alert(d), + "delay_status" => delay_status(d), + "smart_alerts" => smart_alerts(d), + "stream_timeline" => stream_timeline(d), + "chapters" => chapters(d), + "highlight" => { + // Twitch refuses clips made seconds apart anyway. + cooldown_ms = 10_000; + highlight(d) } - // One message per arming, edited as the delay moves along. - "delay_status" => vec![ - on(EventKind::DelayArmed, vec![discord(d, "⏳ Delay armed: {delay}. Filling the buffer…")]), - on(EventKind::DelayReady, vec![discord_update(d, "✅ Delay ready: {delay}. It can go on air.")]), - on(EventKind::DelayOn, vec![discord_update(d, "🟢 Delay on air: {delay}.")]), - on(EventKind::DelayChanged, vec![discord_update(d, "🔁 Delay changed to {delay}.")]), - on(EventKind::DelayOff, vec![discord_update(d, "⚪ Delay off: viewers see you live.")]), - on(EventKind::DelayDisarmed, vec![discord_update(d, "⚪ Delay cancelled before it went on air.")]), - ], + "hype_clip" => { + cooldown_ms = 2 * 60_000; + hype_clip(d) + } + "on_air_light" => on_air_light(), + "scene_delay" => scene_delay(), "destination_down" => vec![on( EventKind::DestinationDropped, - vec![discord(d, "{destination} dropped: {reason}")], + vec![with( + card(d, RED, "🔴 {destination} dropped", "{reason}"), + &[("fields", "Platform | {platform}")], + )], )], "going_live" => { cooldown_ms = 10 * 60_000; vec![on( - EventKind::ObsConnected, - vec![discord(d, "We're live! Come hang out.")], + EventKind::StreamStarted, + vec![with( + card(d, PURPLE, "🔴 Live now", "We're live! Come hang out."), + &[("url", "https://twitch.tv/{channel}")], + )], )] } "phone_crash" => vec![on( @@ -319,7 +451,10 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option Option Option { cooldown_ms = 5_000; - vec![command("!delay", Roles::EVERYONE, 30_000, vec![reply("Delay: {delay|off}")])] + vec![command( + "!delay", + Roles::EVERYONE, + 30_000, + vec![reply("Delay: {delay|off}")], + )] } "delay_notice" => { // A burst of cuts must not become a burst of chat lines. @@ -383,19 +526,35 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option { cooldown_ms = 10_000; - vec![command("!discord", Roles::EVERYONE, 30_000, vec![reply("Join the Discord: [YOUR INVITE LINK]")])] + vec![command( + "!discord", + Roles::EVERYONE, + 30_000, + vec![reply("Join the Discord: [YOUR INVITE LINK]")], + )] } "api_command" => { cooldown_ms = 3_000; @@ -415,8 +574,13 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option Option Option vec![ - on(EventKind::HoldOpened, vec![Step::new(StepKind::Marker, &[("description", "Crash ({reason})")])]), - on(EventKind::DelayOff, vec![Step::new(StepKind::Marker, &[("description", "Delay cut")])]), + on( + EventKind::HoldOpened, + vec![Step::new( + StepKind::Marker, + &[("description", "Crash ({reason})")], + )], + ), + on( + EventKind::DelayOff, + vec![Step::new(StepKind::Marker, &[("description", "Delay cut")])], + ), ], "webhook" => EventKind::ALL .iter() .map(|kind| { - let body = format!( - r#"{{"event":"{}","delay_ms":{{delay_ms}},"phase":"{{phase}}","destinations_live":{{destinations_live}}}}"#, - kind.id() - ); + let body = webhook_body(*kind); on( *kind, - vec![Step::new(StepKind::Http, &[("method", "POST"), ("url", ""), ("body", &body)])], + vec![Step::new( + StepKind::Http, + &[("method", "POST"), ("url", ""), ("body", &body)], + )], ) }) .collect(), @@ -455,7 +632,10 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option return None, @@ -477,6 +657,563 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option String { + format!( + r#"{{"event":"{}","delay_ms":{{delay_ms}},"phase":"{{phase}}","destinations_live":{{destinations_live}}}}"#, + kind.id() + ) +} + +/// One card per drop that changes as the drop does: down, then back. +fn crash_alert(d: &str) -> Vec { + let finish = |color, title, text| with(card(d, color, title, text), &[("edit", "last")]); + let mut ended = on( + EventKind::HoldEnded, + vec![finish( + GREY, + "⏹️ Stream ended", + "You ended crash protection after {down_for}.", + )], + ); + ended.enabled = false; + vec![ + // `` is Discord's own countdown: it ticks in the message + // without anyone editing it. + on( + EventKind::HoldOpened, + vec![with( + card( + d, + RED, + "🔴 OBS dropped", + "Viewers see the reconnect screen. The stream ends unless OBS is back.", + ), + &[("fields", "Why | {reason}\nHolding for | {hold}")], + )], + ), + on( + EventKind::ObsBack, + vec![finish(GREEN, "🟢 Back on air", "OBS was gone for {down_for}. Viewers never left.")], + ), + on( + EventKind::HoldExpired, + vec![finish(GREY, "⚫ Stream ended", "OBS didn't come back within {hold}.")], + ), + ended, + ] +} + +/// One card per arming, edited as the delay moves along. +fn delay_status(d: &str) -> Vec { + let state = |kind, color, title, text, edit| { + let step = card(d, color, title, text); + on( + kind, + vec![if edit { + with(step, &[("edit", "last")]) + } else { + step + }], + ) + }; + vec![ + state( + EventKind::DelayArmed, + AMBER, + "⏳ Delay armed: {delay}", + "Filling the buffer…", + false, + ), + state( + EventKind::DelayReady, + BLUE, + "✅ Delay ready: {delay}", + "It can go on air.", + true, + ), + state( + EventKind::DelayOn, + GREEN, + "🟢 Delay on air: {delay}", + "Viewers are {delay} behind.", + true, + ), + state( + EventKind::DelayChanged, + BLUE, + "🔁 Delay now {delay}", + "It was {previous}.", + true, + ), + state( + EventKind::DelayOff, + GREY, + "⚪ Delay off", + "Viewers see you live.", + true, + ), + state( + EventKind::DelayDisarmed, + GREY, + "⚪ Delay cancelled", + "It never went on air.", + true, + ), + ] +} + +/// Destinations, judged on patterns instead of every blip: one card per +/// outage that turns green when it ends, one per run of drops, and a +/// health report at the end. +fn smart_alerts(d: &str) -> Vec { + let about = |key: &str, step: Step| with(step, &[("key", key)]); + let down = about( + "{destination}", + with( + card( + d, + RED, + "🔴 {destination} is down", + "It dropped {down_for} ago and hasn't come back. InstantClone keeps retrying.", + ), + &[("fields", "Why | {reason}\nDrops this stream | {drops}")], + ), + ); + let back = about( + "{destination}", + with( + card( + d, + GREEN, + "🟢 {destination} is back", + "It was down for {down_for}.", + ), + &[("edit", "close")], + ), + ); + let flapping = about( + "flap {destination}", + with( + card( + d, + AMBER, + "🟠 {destination} keeps dropping", + "{drops} drops in the last {within}. Viewers there may see buffering.", + ), + &[("edit", "last"), ("fields", "Last reason | {reason}")], + ), + ); + let steady = about( + "flap {destination}", + with( + card( + d, + GREEN, + "✅ {destination} is steady again", + "{drops} drops this stream, down {down_total} in total.", + ), + &[("edit", "close")], + ), + ); + let report = with( + card(d, BLUE, "📊 Stream health", "{report}"), + &[( + "fields", + "Duration | {duration}\nCrashes | {crashes}\nDrops | {drops}", + )], + ); + let mut phone = on( + EventKind::DestinationStillDown, + vec![Step::new( + StepKind::Phone, + &[ + ("title", "{destination} is still down"), + ("text", "Down for {down_for}: {reason}"), + ("priority", "high"), + ], + )], + ); + set_filter(&mut phone, "after_s", "180"); + phone.enabled = false; + vec![ + on(EventKind::DestinationStillDown, vec![down]), + on(EventKind::DestinationRecovered, vec![back]), + on(EventKind::DestinationUnstable, vec![flapping]), + on(EventKind::DestinationSteady, vec![steady]), + on(EventKind::StreamEnded, vec![report]), + phone, + ] +} + +fn set_filter(handler: &mut Handler, name: &str, value: &str) { + if let Trigger::Event { filters, .. } = &mut handler.trigger { + filters.insert(name.to_string(), value.to_string()); + } +} + +/// A card that writes itself as the stream goes: every line any +/// integration adds lands in it, with its place in the VOD. +fn stream_timeline(d: &str) -> Vec { + let timeline_card = |title: &str| { + with( + card(d, BLUE, title, "{timeline|Waiting for the first moment…}"), + &[ + ("key", "timeline"), + ("footer", "Updates as it happens · InstantClone"), + ], + ) + }; + let note = |kind, at: &str, text: &str| on(kind, vec![line("note", at, text)]); + // A platform gets a "down" line once it stayed down a minute, and a + // "back" line only then: a flapping one would fill the timeline (and + // edit the card) every few seconds. The two numbers go together. + let mut down = note( + EventKind::DestinationStillDown, + "aired", + "📡 {destination} down ({reason})", + ); + set_filter(&mut down, "after_s", "60"); + let back = on( + EventKind::DestinationRecovered, + vec![check( + "{down_s}", + "greater", + "59", + vec![line( + "note", + "aired", + "📡 {destination} back after {down_for}", + )], + vec![], + )], + ); + vec![ + // The card goes out before the first line, so that line's update + // finds it and edits it. + on( + EventKind::StreamStarted, + vec![ + timeline_card("📼 Stream timeline · {date}"), + line("note", "aired", "🟢 Stream started"), + ], + ), + on( + EventKind::TimelineUpdated, + vec![with( + timeline_card("📼 Stream timeline · {date}"), + &[("edit", "last")], + )], + ), + note(EventKind::HoldOpened, "live", "🔴 OBS off air ({reason})"), + note(EventKind::ObsBack, "live", "🟢 OBS back after {down_for}"), + note(EventKind::DelayOn, "aired", "⏱️ Delay on ({delay})"), + note( + EventKind::DelayChanged, + "aired", + "⏱️ Delay {previous} → {delay}", + ), + note(EventKind::DelayOff, "aired", "⏱️ Delay off"), + down, + back, + on( + EventKind::StreamEnded, + vec![with( + timeline_card("📼 Stream timeline · {date}"), + &[ + ("edit", "last"), + ("color", GREY), + ("footer", "Stream ended · InstantClone"), + ( + "fields", + "Duration | {duration}\nHighlights | {highlights}\nCrashes | {crashes}", + ), + ], + )], + ), + ] +} + +/// VOD markers plus a YouTube chapter list, from chat or a button. +fn chapters(d: &str) -> Vec { + let mark = |name: &str| { + vec![ + line("chapter", "aired", name), + Step::new(StepKind::Marker, &[("description", name)]), + ] + }; + let mut from_chat = mark("{args}"); + from_chat.push(reply("📌 Chapter \"{args}\" at {uptime}")); + // Mods watch the stream, so what they name has already aired: no wait. + let typed = command( + "!chapter", + Roles::MODS, + 0, + vec![check( + "{args}", + "not_empty", + "", + from_chat, + vec![reply("Name it: !chapter Boss fight")], + )], + ); + // A button is pressed live: the chapter starts once that moment airs. + let mut pressed = vec![wait_aired("0")]; + pressed.extend(mark("New chapter")); + let mut pressed = button(pressed); + pressed.enabled = false; + // A crash gets its own chapter, and the stream goes back to the one + // before once OBS is back. Both off at first: not everyone wants it. + let mut crash = on( + EventKind::HoldOpened, + vec![line("chapter", "live", "Technical difficulties")], + ); + crash.enabled = false; + let mut back = on( + EventKind::ObsBack, + vec![line("chapter", "live", "{previous_chapter}")], + ); + back.enabled = false; + let list = with( + card( + d, + PURPLE, + "📑 YouTube chapters", + "```\n{chapters}\n```\nPaste these into the video's description and YouTube makes them chapters.", + ), + &[("fields", "Stream | {duration}")], + ); + // YouTube needs three chapters or more; fewer is no list, and no card. + let posted = check("{chapters}", "not_empty", "", vec![list], vec![]); + vec![ + typed, + pressed, + crash, + back, + on(EventKind::StreamEnded, vec![posted]), + ] +} + +/// Clip the moment once viewers have seen it, mark the VOD, put it on the +/// timeline and in Discord, where the link unfurls into a player. +fn highlight(d: &str) -> Vec { + let post = |by: &str| { + let mut steps = vec![ + Step::new(StepKind::Clip, &[]), + check( + "{clip.ok}", + "is", + "yes", + vec![ + Step::new(StepKind::Marker, &[("description", "Highlight")]), + line("highlight", "aired", "⭐ Highlight · [clip]({clip.url})"), + with( + card(d, GOLD, "⭐ New highlight", "[Watch the clip]({clip.url})"), + &[ + ("above", "{clip.url}"), + ("fields", &format!("Stream time | {{uptime}}\nBy | {by}")), + ], + ), + Step::new( + StepKind::Overlay, + &[ + ("title", "Highlight"), + ("text", "That moment is saved"), + ("seconds", "4"), + ], + ), + ], + // No clip (Twitch said no, or isn't connected): the moment + // is still marked, and the streamer sees why. + vec![ + Step::new(StepKind::Marker, &[("description", "Highlight")]), + line("highlight", "aired", "⭐ Highlight (no clip: {clip.error})"), + Step::new( + StepKind::Overlay, + &[ + ("title", "Highlight"), + ("text", "Marked, but Twitch couldn't clip it"), + ("seconds", "4"), + ], + ), + ], + ), + ]; + steps.insert(0, wait_aired("3000")); + steps + }; + // Mods hear back in chat either way. + let mut from_mods = post("{user}")[1..].to_vec(); + if let Some(found) = from_mods.iter_mut().find(|s| s.kind == StepKind::If) { + found.then.push(reply("⭐ Clipped: {clip.url}")); + found + .otherwise + .push(reply("Couldn't clip that: {clip.error}")); + } + let mut from_mods = command("!highlight", Roles::MODS, 0, from_mods); + from_mods.enabled = false; + vec![button(post("{channel}")), from_mods] +} + +/// Chat decides: when it gets busy in the ways the rules say, clip it. +fn hype_clip(d: &str) -> Vec { + use super::model::{ActivityRule, ChatActivity, RuleKind}; + let rule = |kind, value, words: &str| ActivityRule { + kind, + value, + words: words.to_string(), + }; + let trigger = Trigger::ChatActivity(ChatActivity { + window_ms: 15_000, + match_all: true, + rules: vec![ + rule(RuleKind::Busier, 3.0, ""), + rule(RuleKind::Chatters, 5.0, ""), + rule( + RuleKind::Words, + 25.0, + "clip, pog, lul, kekw, omg, wtf, lol, w", + ), + ], + roles: Roles::EVERYONE, + only_live: true, + }); + let steps = + vec![ + Step::new(StepKind::Clip, &[]), + check( + "{clip.ok}", + "is", + "yes", + vec![ + line("highlight", "aired", "🔥 Chat went wild · [clip]({clip.url})"), + with( + card(d, AMBER, "🔥 Chat went wild", "[Watch the clip]({clip.url})"), + &[ + ("above", "{clip.url}"), + ( + "fields", + "Chat | {busier}× busier\nPeople | {chatters}\nTop word | {top_word|-}", + ), + ], + ), + ], + vec![line( + "highlight", + "aired", + "🔥 Chat went wild (no clip: {clip.error})", + )], + ), + ]; + vec![Handler { + enabled: true, + trigger, + steps, + }] +} + +/// 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. +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() +} + +/// Two scene rules to fill in: a delay for the game scene, live again for +/// the rest. Scenes are picked from OBS's own list in the editor. +fn scene_delay() -> Vec { + // Only while streaming: flicking through scenes before going live does + // nothing, and the stream starting on the game scene counts. + let scene = |pattern: &str, steps| Handler { + enabled: true, + trigger: Trigger::Scene { + scene: pattern.to_string(), + from: String::new(), + except: String::new(), + settle_ms: 3_000, + only_live: true, + }, + steps, + }; + vec![ + scene( + "", + vec![Step::new( + StepKind::DelayAction, + &[("action", "arm"), ("seconds", "30")], + )], + ), + // Any other scene: the editor keeps the game scene out of it. + scene( + "*", + vec![Step::new(StepKind::DelayAction, &[("action", "cut_after")])], + ), + ] +} + +/// A device the on-air light can drive: where it listens and what each +/// state sends it. `{ip}` style parts are for the user to replace. +pub struct LightStart { + pub id: &'static str, + pub label: &'static str, + pub method: &'static str, + pub url: &'static str, + /// live, delay, crash, off + pub bodies: [&'static str; 4], +} + +pub const LIGHT_STARTS: &[LightStart] = &[ + LightStart { + id: "home_assistant", + label: "Home Assistant", + method: "POST", + url: "http://homeassistant.local:8123/api/webhook/instantclone-onair", + bodies: [ + r#"{"state":"live"}"#, + r#"{"state":"delay"}"#, + r#"{"state":"crash"}"#, + r#"{"state":"off"}"#, + ], + }, + LightStart { + id: "wled", + label: "WLED", + method: "POST", + url: "http://wled.local/json/state", + bodies: [ + r#"{"on":true,"bri":200,"seg":[{"fx":0,"col":[[255,40,40]]}]}"#, + r#"{"on":true,"bri":200,"seg":[{"fx":0,"col":[[255,150,0]]}]}"#, + r#"{"on":true,"bri":255,"seg":[{"fx":1,"col":[[255,0,0]]}]}"#, + r#"{"on":false}"#, + ], + }, + LightStart { + id: "hue", + label: "Philips Hue", + method: "PUT", + url: "http://BRIDGE-IP/api/USERNAME/lights/1/state", + bodies: [ + r#"{"on":true,"bri":254,"hue":0,"sat":254,"alert":"none"}"#, + r#"{"on":true,"bri":254,"hue":7000,"sat":254,"alert":"none"}"#, + r#"{"on":true,"bri":254,"hue":0,"sat":254,"alert":"lselect"}"#, + r#"{"on":false}"#, + ], + }, +]; + /// A ready-made "command from a website": one click in the editor fills /// the address, the reply and the command. DecAPI answers in plain text /// and needs no key, which is what makes these work out of the box. @@ -622,16 +1359,40 @@ pub fn legacy_alerts(id: String, channel: &str) -> Integration { } } +/// Everything a pack's integrations need, each once, in catalog order. +fn pack_needs(pack: &Pack) -> Vec<&'static str> { + let mut needs = Vec::new(); + for need in pack + .presets + .iter() + .filter_map(|id| find(id)) + .flat_map(|p| p.needs.iter()) + { + if !needs.contains(need) { + needs.push(*need); + } + } + needs +} + pub fn catalog_json() -> Value { let presets = PRESETS .iter() .map(|p| { + // The catalog card previews what it really sends: its first + // switched-on trigger, built for real. + let sample = build(p.id, "preview".into(), "") + .and_then(|i| i.handlers.into_iter().find(|h| h.enabled)) + .map_or(Value::Null, |h| h.to_json()); json::obj([ ("id", json::str(p.id)), ("name", json::str(p.name)), ("category", json::str(p.category)), ("description", json::str(p.description)), - ("needs", json::str(p.needs)), + ( + "needs", + Value::Arr(p.needs.iter().map(|n| json::str(*n)).collect()), + ), ("recommended", Value::Bool(p.recommended)), ( "preview", @@ -641,6 +1402,27 @@ pub fn catalog_json() -> Value { ("ask", json::str(p.preview.2)), ]), ), + ("sample", sample), + ]) + }) + .collect(); + let lights = LIGHT_STARTS + .iter() + .map(|l| { + json::obj([ + ("id", json::str(l.id)), + ("label", json::str(l.label)), + ("method", json::str(l.method)), + ("url", json::str(l.url)), + ( + "bodies", + json::obj([ + ("live", json::str(l.bodies[0])), + ("delay", json::str(l.bodies[1])), + ("crash", json::str(l.bodies[2])), + ("off", json::str(l.bodies[3])), + ]), + ), ]) }) .collect(); @@ -651,6 +1433,10 @@ pub fn catalog_json() -> Value { ("id", json::str(p.id)), ("name", json::str(p.name)), ("description", json::str(p.description)), + ( + "needs", + Value::Arr(pack_needs(p).into_iter().map(json::str).collect()), + ), ( "presets", Value::Arr(p.presets.iter().map(|s| json::str(*s)).collect()), @@ -672,6 +1458,7 @@ pub fn catalog_json() -> Value { ("presets", Value::Arr(presets)), ("packs", Value::Arr(packs)), ("api_starts", Value::Arr(starts)), + ("light_starts", Value::Arr(lights)), ]) } @@ -687,8 +1474,10 @@ mod tests { // Fill what a user fills in before switching these on. for h in &mut i.handlers { fill_blank_paths(&mut h.steps); - if let Trigger::Shortcut { hotkey, .. } = &mut h.trigger { - *hotkey = "Ctrl+Alt+C".into(); + match &mut h.trigger { + Trigger::Shortcut { hotkey, .. } => *hotkey = "Ctrl+Alt+C".into(), + Trigger::Scene { scene, .. } => *scene = "Game".into(), + _ => {} } } assert!(i.validate().is_empty(), "{}: {:?}", p.id, i.validate()); @@ -708,6 +1497,21 @@ mod tests { } } + #[test] + fn presets_only_use_variables_that_exist() { + for p in PRESETS { + let i = build(p.id, "v".into(), "chan1").unwrap(); + let unknown = super::super::api::unknown_vars(&i); + assert!(unknown.is_empty(), "{}: {unknown:?}", p.id); + } + } + + #[test] + fn packs_say_everything_they_need() { + let pro = PACKS.iter().find(|p| p.id == "stream_pro").unwrap(); + assert_eq!(pack_needs(pro), ["discord", "twitch"]); + } + #[test] fn unfinished_presets_start_switched_off() { let webhook = build("webhook", "w".into(), "").unwrap(); @@ -768,6 +1572,67 @@ mod tests { assert_eq!(edits, ["", "last", "last", "last"]); } + #[test] + fn buttons_come_with_a_stream_deck_address() { + let i = build("highlight", "h".into(), "chan").unwrap(); + match &i.handlers[0].trigger { + Trigger::Shortcut { token, .. } => assert_eq!(token.len(), 32), + other => panic!("{other:?}"), + } + assert!(i.enabled, "an address alone is enough to start"); + } + + #[test] + fn smart_alerts_only_finish_messages_they_started() { + let i = build("smart_alerts", "s".into(), "chan").unwrap(); + let edit_of = |kind: EventKind| { + i.handlers + .iter() + .find(|h| matches!(&h.trigger, Trigger::Event { kind: k, .. } if *k == kind)) + .map(|h| h.steps[0].param("edit").to_string()) + }; + assert_eq!( + edit_of(EventKind::DestinationStillDown).as_deref(), + Some("") + ); + assert_eq!( + edit_of(EventKind::DestinationRecovered).as_deref(), + Some("close") + ); + assert_eq!( + edit_of(EventKind::DestinationSteady).as_deref(), + Some("close") + ); + } + + #[test] + fn the_scene_rules_wait_to_be_picked() { + let i = build("scene_delay", "s".into(), "").unwrap(); + assert!(!i.enabled, "no scene picked yet"); + assert!(i.validate().iter().any(|e| e.contains("scene"))); + } + + #[test] + fn light_presets_have_valid_json_bodies() { + for light in LIGHT_STARTS { + for body in light.bodies { + assert!(crate::config::is_valid_json(body), "{}: {body}", light.id); + } + } + } + + #[test] + fn catalog_cards_preview_a_real_handler() { + let catalog = catalog_json(); + for p in catalog.get("presets").and_then(Value::as_array).unwrap() { + assert!( + p.get("sample").is_some_and(|s| !matches!(s, Value::Null)), + "{} has no sample", + p.str_or("id", "") + ); + } + } + #[test] fn legacy_alerts_validate() { assert!(legacy_alerts("legacy".into(), "chan").validate().is_empty()); diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs index 304a71f..ed0d46b 100644 --- a/src/integrations/recipe.rs +++ b/src/integrations/recipe.rs @@ -3,7 +3,8 @@ //! A recipe is data, never code, and carries nothing private: //! - Discord connections are replaced by a placeholder; the importer picks //! one of their own connections for it. -//! - Web call links get new secret tokens on import. +//! - Web call links get new secret tokens on import, and buttons a new +//! Stream Deck address. //! - Hotkeys and MIDI pads are left for the importer to pick: a pad names //! the sharer's controller, and their keys may be taken on another PC. //! - Everything imported arrives switched off. @@ -60,24 +61,47 @@ pub fn export(name: &str, integrations: &[Integration]) -> String { } /// Blank whatever a recipe must never carry: see `strip_private`, plus web -/// call tokens and the hotkey or pad an integration starts from. +/// call tokens and the hotkey, pad or Stream Deck address a button starts +/// from. fn make_private(i: &mut Integration) { for h in &mut i.handlers { strip_private(&mut h.steps); match &mut h.trigger { Trigger::Webhook { token } => token.clear(), - Trigger::Shortcut { hotkey, midi } => { + Trigger::Shortcut { + hotkey, + midi, + token, + .. + } => { hotkey.clear(); midi.clear(); + token.clear(); } _ => {} } } } +/// Whether a web request body is one the catalog itself writes (an on-air +/// light state, the webhook's event JSON): those only say what happened, +/// so a recipe keeps them, or an imported light would send nothing a +/// device reads. Any other body can hold a key and goes. Judged by the +/// body itself, never by what the recipe says it is. +fn is_catalog_body(body: &str) -> bool { + super::presets::LIGHT_STARTS + .iter() + .any(|light| light.bodies.contains(&body)) + || super::event::EventKind::ALL + .iter() + .any(|kind| body == super::presets::webhook_body(*kind)) +} + /// The Discord connection, web requests (address, headers and body hold -/// API keys and webhook secrets), and programs and files (their paths and -/// arguments name the user's folders and carry tokens). +/// API keys and webhook secrets), programs and files (their paths and +/// arguments name the user's folders and carry tokens), and OBS steps' +/// scene and source: the importer picks their own, so a recipe can never +/// switch a scene or hide a source the user didn't choose. fn strip_private(steps: &mut [super::model::Step]) { for s in steps { match s.kind { @@ -88,7 +112,13 @@ fn strip_private(steps: &mut [super::model::Step]) { StepKind::Http => { s.params.insert("url".into(), String::new()); s.params.remove("headers"); - s.params.remove("body"); + if !is_catalog_body(s.param("body")) { + s.params.remove("body"); + } + } + StepKind::Obs => { + s.params.insert("scene".into(), String::new()); + s.params.insert("source".into(), String::new()); } StepKind::Program => { s.params.insert("path".into(), String::new()); @@ -137,9 +167,14 @@ pub fn parse(text: &str, mut new_id: impl FnMut() -> String) -> Result { + *token = new_id() + &new_id(); + } + _ => {} } } integrations.push(i); @@ -293,6 +328,8 @@ mod tests { i.handlers[0].trigger = Trigger::Shortcut { hotkey: "Ctrl+Alt+K".into(), midi: "note:1:36@Oriol's Launchpad".into(), + token: String::new(), + only_live: false, }; let text = export("x", &[i]); let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); @@ -303,6 +340,50 @@ mod tests { ); } + #[test] + fn a_shared_light_keeps_what_each_state_sends_and_buttons_get_an_address() { + let light = presets::build("on_air_light", "l".into(), "").unwrap(); + let highlight = presets::build("highlight", "h".into(), "").unwrap(); + let text = export("studio", &[light, highlight]); + let mut n = 0; + let recipe = parse(&text, || { + n += 1; + format!("fresh{n:011}") + }) + .unwrap(); + let body = recipe.integrations[0].handlers[0].steps[0].param("body"); + assert!(body.contains("state"), "{body}"); + assert_eq!(recipe.integrations[0].handlers[0].steps[0].param("url"), ""); + match &recipe.integrations[1].handlers[0].trigger { + Trigger::Shortcut { token, hotkey, .. } => { + assert!(token.starts_with("fresh") && hotkey.is_empty(), "{token}") + } + other => panic!("{other:?}"), + } + } + + #[test] + fn a_recipe_never_picks_an_obs_scene_or_a_made_up_body() { + use super::super::model::Step; + let mut i = presets::build("delay_command", "x".into(), "").unwrap(); + i.preset = "on_air_light".into(); + i.handlers[0].steps = vec![ + Step::new(StepKind::Obs, &[("action", "hide"), ("source", "Camera")]), + Step::new( + StepKind::Http, + &[("url", "https://x.example/"), ("body", "key=secret")], + ), + ]; + let recipe = parse(&export("x", &[i]), || "fresh1234567890ab".into()).unwrap(); + let steps = &recipe.integrations[0].handlers[0].steps; + assert_eq!(steps[0].param("source"), ""); + assert_eq!( + steps[1].param("body"), + "", + "claiming to be a light keeps nothing" + ); + } + #[test] fn a_hand_written_recipe_is_cleaned_on_import() { use super::super::model::Step; @@ -310,6 +391,8 @@ mod tests { i.handlers[0].trigger = Trigger::Shortcut { hotkey: "Ctrl+C".into(), midi: String::new(), + token: String::new(), + only_live: false, }; i.handlers[0].steps = vec![Step::new( StepKind::Http, diff --git a/src/integrations/runner.rs b/src/integrations/runner.rs index a638cdb..135e223 100644 --- a/src/integrations/runner.rs +++ b/src/integrations/runner.rs @@ -9,22 +9,36 @@ //! //! Test runs send messages for real, marked `[TEST]`, so the streamer sees //! exactly what arrives. They skip waits and never touch the stream, the -//! VOD, the on-stream alerts, programs or files: those are reported as what -//! would have happened. +//! VOD, the on-stream alerts, OBS, programs or files: those are reported as +//! what would have happened. A dry run sends nothing at all, messages +//! included, and says what each would have been. use super::clock; -use super::host::{fmt_delay, fmt_duration, Host, HttpRequest, LiveState}; +use super::event::{Event, EventKind}; +use super::host::{ + fmt_clock, fmt_delay, fmt_duration, DiscordCard, DiscordMessage, Host, HttpRequest, LiveState, +}; use super::model::{DiscordChannel, PhoneConnection, Step, StepKind}; +use super::obsws; use super::template::{self, Vars}; +use super::timeline::{self as tl, Timeline}; use crate::json; use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; /// Longest single wait a step may ask for. const MAX_WAIT: Duration = Duration::from_secs(3600); +/// Longest wait a value from a template (chat, a web call) can ask for: a +/// viewer typing a huge number must not hold the integration for an hour. +const MAX_FILLED_WAIT: Duration = Duration::from_secs(60); +/// Discord messages remembered for editing. Keys can come from chat, so +/// they are capped; past this, new ones simply post a new message. +const MAX_REMEMBERED_MESSAGES: usize = 500; /// How often `wait_delay` re-reads the delay while following it. const DELAY_POLL: Duration = Duration::from_millis(200); /// Chat messages are cut to Twitch's limit. @@ -34,6 +48,10 @@ const MAX_COUNTERS: usize = 5000; const MAX_COUNTER_NAME: usize = 100; /// How long a "Show on stream" card stays up when the step names no time. const OVERLAY_SECONDS: u64 = 6; +/// A card's color when none (or a broken one) is set: the InstantClone blue. +pub const DEFAULT_CARD_COLOR: u32 = 0x5a_c8_fa; +/// What `{timeline}` fits in: a Discord card's description. +const TIMELINE_CHARS: usize = 4000; /// Connections and persistent values a run can use. pub struct RunEnv { @@ -46,6 +64,13 @@ pub struct RunEnv { /// `integration:channel`), so a later step can edit it. In memory only: /// after a restart the next update simply posts a new message. pub discord_messages: Arc>>, + /// Whose turn it is to post or edit each of those messages. + pub discord_turns: Arc, + /// This stream's timeline, shared by every integration. + pub timeline: Arc>, + /// Report something a step did (a timeline line) as an event of its + /// own, for other integrations to react to. Never blocks. + pub emit: Box, } /// Per-run state: the trigger's variables plus what steps produce. @@ -56,6 +81,77 @@ pub struct RunContext { /// Chat message to reply to, when a chat trigger started the run. pub reply_to: Option, pub test: bool, + /// A test that sends nothing at all (see the module comment). + pub dry: bool, + /// The run's place in the engine's run limits; None for tests. + pub gate: Option, +} + +/// A run's place in the engine's limits on runs going at once. A run that +/// is waiting (a Wait step, or for the delay to air) gives its place back +/// and takes one again to go on, so presses during a long delay queue up +/// instead of being turned away. +pub struct Gate { + places: Vec>, + held: Vec, +} + +impl Gate { + /// `held` are the places taken from `places`, in the same order. + pub fn new(places: Vec>, held: Vec) -> Gate { + Gate { places, held } + } + + fn give_back(&mut self) { + self.held.clear(); + } + + async fn take_again(&mut self) { + if !self.held.is_empty() { + return; + } + for place in &self.places { + // Closed only when the engine stops; the run then ends anyway. + if let Ok(permit) = place.clone().acquire_owned().await { + self.held.push(permit); + } + } + } +} + +/// Posting and editing one Discord message, one run at a time and in +/// order. An edit with a newer edit of the same message queued behind it +/// is dropped: the newer one says it all, and Discord's rate limit isn't +/// spent on a state that is already gone. +#[derive(Default)] +pub struct DiscordTurns { + messages: crate::sync::Mutex>>, +} + +#[derive(Default)] +struct Turn { + queue: tokio::sync::Mutex<()>, + /// Edits asked for so far; each edit's number is its place in line. + edits: AtomicU64, +} + +impl DiscordTurns { + fn turn(&self, key: &str) -> Arc { + self.messages + .lock() + .entry(key.to_string()) + .or_default() + .clone() + } + + /// Forget a message's line once nobody is in it. + fn done(&self, key: &str, turn: Arc) { + let mut messages = self.messages.lock(); + // This copy and the map's: nobody else is waiting. + if Arc::strong_count(&turn) <= 2 { + messages.remove(key); + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -135,7 +231,8 @@ type BoxFuture<'a, T> = Pin + Send + 'a>>; impl Runner { fn steps<'a>(&'a mut self, steps: &'a [Step]) -> BoxFuture<'a, Flow> { Box::pin(async move { - for step in steps { + // Switched-off steps (and what's inside them) don't run. + for step in steps.iter().filter(|s| s.enabled) { if let Flow::Stop = self.step(step).await { return Flow::Stop; } @@ -167,23 +264,26 @@ impl Runner { fn fill_text(&self, text: &str) -> String { let live = self.host.live(); - let lookup = Lookup { - vars: &self.ctx.vars, - counters: &self.env.counters, - live: &live, - }; - template::render(text, &lookup) + template::render(text, &self.lookup(&live)) } /// `fill`, escaping inserted values with `escape`. fn fill_escaped(&self, step: &Step, name: &str, escape: &dyn Fn(&str) -> String) -> String { + self.fill_text_escaped(step.param(name), escape) + } + + fn fill_text_escaped(&self, text: &str, escape: &dyn Fn(&str) -> String) -> String { let live = self.host.live(); - let lookup = Lookup { + template::render_escaped(text, &self.lookup(&live), escape) + } + + fn lookup<'a>(&'a self, live: &'a LiveState) -> Lookup<'a> { + Lookup { vars: &self.ctx.vars, counters: &self.env.counters, - live: &live, - }; - template::render_escaped(step.param(name), &lookup, escape) + timeline: &self.env.timeline, + live, + } } fn set(&mut self, name: impl Into, value: impl Into) { @@ -228,10 +328,39 @@ impl Runner { StepKind::Counter => self.counter(step), StepKind::Overlay => self.overlay(step), StepKind::EditText => self.edit_text(step), + StepKind::Timeline => self.timeline(step), + StepKind::Obs => self.obs(step).await, } Flow::Continue } + /// Sleep without holding the run's place in the run limits. + async fn sleep(&mut self, how_long: Duration) { + if let Some(gate) = &mut self.ctx.gate { + gate.give_back(); + } + tokio::time::sleep(how_long).await; + if let Some(gate) = &mut self.ctx.gate { + gate.take_again().await; + } + } + + /// In a dry run, record what a step would have sent instead of sending + /// it. True when it was a dry run. + fn dry(&mut self, label: impl Into, would: impl Into) -> bool { + if !self.ctx.dry { + return false; + } + let would = would.into(); + let detail = if would.trim().is_empty() { + "dry run: nothing was sent".to_string() + } else { + format!("dry run, would send: {would}") + }; + self.record(label, StepStatus::Skipped, detail); + true + } + async fn check(&mut self, step: &Step) -> Flow { let left = self.fill(step, "left"); let right = self.fill(step, "right"); @@ -252,7 +381,7 @@ impl Runner { async fn wait(&mut self, step: &Step) { let ms = self.fill(step, "ms").trim().parse::().unwrap_or(0); - let dur = Duration::from_millis(ms).min(MAX_WAIT); + let dur = Duration::from_millis(ms).min(wait_cap(step, "ms")); if self.ctx.test { self.record( "Wait", @@ -264,7 +393,7 @@ impl Runner { ); return; } - tokio::time::sleep(dur).await; + self.sleep(dur).await; self.record("Wait", StepStatus::Ok, fmt_duration(dur.as_millis() as u64)); } @@ -297,14 +426,23 @@ impl Runner { start_delay }; // `extra` can come from chat, so it can be anything. - let due = Duration::from_millis(delay.saturating_add(extra)).min(MAX_WAIT); + let due = Duration::from_millis(delay) + .min(MAX_WAIT) + .saturating_add(Duration::from_millis(extra).min(wait_cap(step, "extra_ms"))); // One reading: a second one could pass `due` and underflow. let elapsed = since.elapsed(); if elapsed >= due { break; } + // The place is given back once, for the whole wait. + if let Some(gate) = &mut self.ctx.gate { + gate.give_back(); + } tokio::time::sleep((due - elapsed).min(DELAY_POLL)).await; } + if let Some(gate) = &mut self.ctx.gate { + gate.take_again().await; + } self.record( "Wait for the delay", StepStatus::Ok, @@ -333,37 +471,97 @@ impl Runner { ); return; }; - let text = self.marked(self.fill(step, "text")); + let label = format!("Discord · {}", channel.name); + if self.ctx.dry { + let shown = discord_shown(&self.discord_message(step)); + self.dry(label, shown); + return; + } // Test messages are tracked apart, so a test never edits a real one. + // "About" keeps one message per subject (per destination, say) + // instead of one per channel. + let about = self.fill(step, "key"); let key = format!( - "{}{}:{}", + "{}{}:{}{}{}", if self.ctx.test { "test:" } else { "" }, self.ctx.integration_id, - channel.id + channel.id, + if about.trim().is_empty() { "" } else { ":" }, + about.trim().chars().take(100).collect::() ); - let previous = (step.param("edit") == "last") + // `last`: edit the last one, or post when there is none. `close`: + // edit the last one into its final state and let it go, so the next + // alert starts a new message; with none to finish, say nothing (a + // "back" with no "down" before it is noise). + let mode = step.param("edit"); + let turns = self.env.discord_turns.clone(); + let turn = turns.turn(&key); + let number = if mode == "last" { + turn.edits.fetch_add(1, Ordering::SeqCst) + 1 + } else { + 0 + }; + // Waiting for the turn isn't doing anything: the place goes back. + if let Some(gate) = &mut self.ctx.gate { + gate.give_back(); + } + let queued = turn.queue.lock().await; + if let Some(gate) = &mut self.ctx.gate { + gate.take_again().await; + } + if mode == "last" && turn.edits.load(Ordering::SeqCst) != number { + drop(queued); + turns.done(&key, turn); + self.record( + label, + StepStatus::Skipped, + "a newer update replaced this one", + ); + return; + } + // Filled in on its turn, so it says what is true now: a timeline + // card edited twice in a row shows every line either way. + let mut message = self.discord_message(step); + if !super::effects::discord_has_body(&message) { + drop(queued); + turns.done(&key, turn); + self.record(label, StepStatus::Skipped, "the message came out empty"); + return; + } + let previous = matches!(mode, "last" | "close") .then(|| self.env.discord_messages.lock().get(&key).cloned()) .flatten(); + if mode == "close" && previous.is_none() { + drop(queued); + turns.done(&key, turn); + self.record(label, StepStatus::Skipped, "no earlier message to finish"); + return; + } // A test must never ping a whole server. An edit doesn't ping; the // ping still goes out if the message was deleted and is posted anew. - let ping = if self.ctx.test { + message.ping = if self.ctx.test { String::new() } else { step.param("ping").to_string() }; - let label = format!("Discord · {}", channel.name); - let shown = text.clone(); + let shown = discord_shown(&message); let sent = self - .blocking(move |h| h.discord(&channel.url, &text, &ping, previous.as_deref())) + .blocking(move |h| h.discord(&channel.url, &message, previous.as_deref())) .await; + if let Ok(posted) = &sent { + let mut messages = self.env.discord_messages.lock(); + if mode == "close" { + messages.remove(&key); + } else if !posted.message_id.is_empty() + && (messages.len() < MAX_REMEMBERED_MESSAGES || messages.contains_key(&key)) + { + messages.insert(key.clone(), posted.message_id.clone()); + } + } + drop(queued); + turns.done(&key, turn); match sent { Ok(posted) => { - if !posted.message_id.is_empty() { - self.env - .discord_messages - .lock() - .insert(key, posted.message_id); - } let label = if posted.edited { format!("{label} (updated)") } else { @@ -375,12 +573,100 @@ impl Runner { } } + /// The message a Discord step sends, filled in. As a card, `text` is the + /// card's body and `above` the plain line over it (a clip link there + /// unfurls into a player); otherwise `text` is the whole message. + fn discord_message(&self, step: &Step) -> DiscordMessage { + if step.param("style") != "card" { + return DiscordMessage { + content: self.marked(self.fill(step, "text")), + ..DiscordMessage::default() + }; + } + let mut card = DiscordCard { + title: self.fill(step, "title"), + url: self.fill(step, "url"), + description: self.fill(step, "text"), + color: parse_color(&self.fill(step, "color")).unwrap_or(DEFAULT_CARD_COLOR), + fields: parse_fields(step.param("fields")) + .into_iter() + .map(|(name, value, inline)| { + (self.fill_text(&name), self.fill_text(&value), inline) + }) + .collect(), + footer: self.fill(step, "footer"), + timestamp: step.param("timestamp") == "yes", + image: self.fill(step, "image"), + thumbnail: self.fill(step, "thumbnail"), + }; + if self.ctx.test { + if card.title.trim().is_empty() { + card.description = self.marked(card.description); + } else { + card.title = self.marked(card.title); + } + } + DiscordMessage { + content: self.fill(step, "above"), + card: Some(card), + ping: String::new(), + } + } + + /// Add a line to this stream's timeline, at the moment the stream (the + /// VOD) shows it: now on air (`aired`), or once the delay has passed + /// (`live`, for things happening in front of the camera right now). + /// Between streams there is no timeline: a line that only got here + /// after the stream ended (it waited for the delay) is left out. + fn timeline(&mut self, step: &Step) { + let text = self.fill(step, "text"); + let kind = tl::Kind::from_id(step.param("kind")); + let label = match kind { + tl::Kind::Chapter => "Timeline chapter", + tl::Kind::Highlight => "Timeline highlight", + tl::Kind::Note => "Timeline", + }; + if text.trim().is_empty() { + self.record(label, StepStatus::Skipped, "the line came out empty"); + return; + } + let live = self.host.live(); + if !live.streaming && !self.ctx.test { + self.record(label, StepStatus::Skipped, "no stream is on"); + return; + } + let delay = if step.param("at") == "aired" { + 0 + } else { + u64::from(live.delay_ms) + }; + let at_ms = live.uptime_ms + delay; + if self.ctx.test { + self.record( + label, + StepStatus::Skipped, + format!("would add `{}` {text}", fmt_clock(at_ms)), + ); + return; + } + let line = self.env.timeline.lock().add(at_ms, kind, &text); + (self.env.emit)( + Event::new(EventKind::TimelineUpdated) + .with("line", line.clone()) + .with("kind", kind.id()), + ); + self.record(label, StepStatus::Ok, line); + } + async fn chat(&mut self, step: &Step) { let text = safe_chat_text(&self.marked(self.fill(step, "text"))); if text.is_empty() { self.record("Chat", StepStatus::Skipped, "the message came out empty"); return; } + if self.dry("Twitch chat", text.clone()) { + return; + } let as_bot = step.param("as") == "bot"; let reply_to = (step.param("reply") == "yes") .then(|| self.ctx.reply_to.clone()) @@ -403,6 +689,9 @@ impl Runner { } let title = self.marked(self.fill(step, "title")); let text = self.fill(step, "text"); + if self.dry("Phone", text.clone()) { + return; + } let priority = step.param("priority").to_string(); let shown = text.clone(); let result = self @@ -458,6 +747,9 @@ impl Runner { }, }; let label = format!("{} {}", request.method, short_url(&request.url)); + if self.dry(label.clone(), request.body.clone()) { + return; + } match self.blocking(move |h| h.http(request)).await { Ok(resp) => { let ok = (200..300).contains(&resp.status); @@ -479,13 +771,29 @@ impl Runner { } async fn delay_action(&mut self, step: &Step) { - let action = step.param("action").to_string(); - let seconds = self - .fill(step, "seconds") - .trim() - .parse::() - .unwrap_or(0.0); - let ms = (seconds.clamp(0.0, 600.0) * 1000.0) as u32; + let mut action = step.param("action").to_string(); + let filled = self.fill(step, "seconds"); + let filled = filled.trim(); + // Whole seconds only: `!setdelay 2.5` or `!setdelay -5` fails the + // step instead of quietly becoming some other delay. Blank means + // the default delay, which the host reads from 0 ms. + let seconds = match filled { + "" => None, + text if action == "arm" && !is_whole_number(text) => { + self.record( + "Set the delay", + StepStatus::Failed, + format!("\"{text}\" isn't whole seconds, like 30"), + ); + return; + } + text => text.parse::().ok(), + }; + // A delay of 0 is no delay: what Disarm does (`!setdelay 0`). + if action == "arm" && seconds == Some(0) { + action = "disarm".to_string(); + } + let ms = (seconds.unwrap_or(0).min(600) * 1000) as u32; let label = delay_action_label(&action, ms); if self.ctx.test { self.record(label, StepStatus::Skipped, "tests never touch the stream"); @@ -535,6 +843,7 @@ impl Runner { } Err(e) => { self.set("clip.ok", "no"); + self.set("clip.error", redact_urls(&e)); self.record("Clip", StepStatus::Failed, e); } } @@ -544,11 +853,11 @@ impl Runner { // Never templated: see `model::validate_steps`. let path = step.param("path").trim().to_string(); // Split first, then fill each piece: a value from chat stays one - // argument whatever spaces or quotes it holds, so a viewer can't add - // arguments of their own. + // argument whatever spaces it holds, so a viewer can't add arguments + // of their own, and `program_arg` strips what a shell would run. let args: Vec = split_args(step.param("args")) .iter() - .map(|arg| self.fill_text(arg)) + .map(|arg| self.fill_text_escaped(arg, &template::program_arg)) .collect(); let label = format!("Run {}", file_name(&path)); if self.ctx.test { @@ -606,6 +915,46 @@ impl Runner { self.record("On stream", StepStatus::Ok, text); } + /// Ask OBS to switch a scene, show or hide a source, or set a text. + async fn obs(&mut self, step: &Step) { + // Never templated: see `model::validate_steps`. + let scene = step.param("scene").trim().to_string(); + let source = step.param("source").trim().to_string(); + let action = match step.param("action") { + "scene" => obsws::Action::Scene(scene), + "show" | "hide" | "toggle" => obsws::Action::Source { + scene, + source, + visible: match step.param("action") { + "show" => Some(true), + "hide" => Some(false), + _ => None, + }, + }, + "text" => obsws::Action::Text { + source, + text: self.fill(step, "text"), + }, + other => { + self.record( + "OBS", + StepStatus::Failed, + format!("unknown OBS action \"{other}\""), + ); + return; + } + }; + let label = obs_label(&action); + if self.ctx.test { + self.record(label, StepStatus::Skipped, "tests never touch OBS"); + return; + } + match self.blocking(move |h| h.obs(action)).await { + Ok(()) => self.record(label, StepStatus::Ok, ""), + Err(e) => self.record(label, StepStatus::Failed, e), + } + } + fn edit_text(&mut self, step: &Step) { let input = self.fill(step, "input"); let (a, b) = (self.fill(step, "a"), self.fill(step, "b")); @@ -664,6 +1013,7 @@ impl Runner { struct Lookup<'a> { vars: &'a BTreeMap, counters: &'a crate::sync::Mutex>, + timeline: &'a crate::sync::Mutex, live: &'a LiveState, } @@ -672,6 +1022,12 @@ impl Vars for Lookup<'_> { if let Some(v) = self.vars.get(name) { return Some(v.clone()); } + match name { + "timeline" => return Some(self.timeline.lock().render(TIMELINE_CHARS)), + "chapters" => return Some(self.timeline.lock().chapters()), + "previous_chapter" => return Some(self.timeline.lock().previous_chapter()), + _ => {} + } if let Some(counter) = name.strip_prefix("counter.") { return Some( self.counters @@ -690,10 +1046,53 @@ impl Vars for Lookup<'_> { if let Some(path) = name.strip_prefix("body.") { return json_field(self.vars.get("body")?, path); } + // `{query.team}` reads one part of a web call's `?team=red`. + if let Some(field) = name.strip_prefix("query.") { + return Some(query_field(self.vars.get("query")?, field)); + } live_var(name, self.live) } } +/// One field of a query string (`a=1&team=red`), decoded; empty when it +/// isn't there. +fn query_field(query: &str, field: &str) -> String { + query + .split('&') + .filter_map(|pair| pair.split_once('=').or(Some((pair, "")))) + .find(|(name, _)| percent_decode(name) == field) + .map(|(_, value)| percent_decode(value)) + .unwrap_or_default() +} + +/// `%20` and `+` back to spaces, and every other `%xx` to its byte. +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = bytes + .get(i + 1..i + 3) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match (bytes[i], hex) { + (b'%', Some(byte)) => { + out.push(byte); + i += 3; + } + (b'+', _) => { + out.push(b' '); + i += 1; + } + (byte, _) => { + out.push(byte); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + fn json_field(body: &str, path: &str) -> Option { let doc = json::parse(body).ok()?; doc.path(path).map(|v| v.to_display()) @@ -728,6 +1127,12 @@ pub const GLOBAL_VARS: &[(&str, &str)] = &[ ("channel", "yourchannel"), ("time", "21:04"), ("date", "2026-09-30"), + ("uptime", "1:02:14"), + ("vod_time", "1:02:44"), + ("onair", "live"), + ("timeline", "`0:12` 🟢 Live\n`1:02:14` ⭐ Highlight"), + ("chapters", "0:00 Start\n12:31 Ranked\n1:04:10 Boss fight"), + ("previous_chapter", "Ranked"), ("uses", "42"), ]; @@ -753,10 +1158,61 @@ fn live_var(name: &str, live: &LiveState) -> Option { "channel" => live.channel.clone(), "time" => clock::now().hm(), "date" => clock::now().date(), + "uptime" if live.uptime_ms > 0 => fmt_clock(live.uptime_ms), + // Where a moment happening now lands in the VOD: it airs, and is + // recorded, once the delay has passed. + "vod_time" if live.uptime_ms > 0 => fmt_clock(live.uptime_ms + u64::from(live.delay_ms)), + "uptime" | "vod_time" => String::new(), + "onair" => live.onair.clone(), _ => return None, }) } +/// `#5ac8fa`, `5ac8fa` or `#5cf` as a color, None when it isn't one. +pub fn parse_color(text: &str) -> Option { + let hex = text.trim().trim_start_matches('#'); + let full = match hex.len() { + 3 => hex.chars().flat_map(|c| [c, c]).collect::(), + 6 => hex.to_string(), + _ => return None, + }; + u32::from_str_radix(&full, 16).ok() +} + +/// A card's fields, one per line: `Name | value` sits side by side with +/// its neighbours, `Name || value` takes the whole width. Split before +/// filling, so a `|` in a value from chat can't move it into the name, and +/// at the first bar outside braces, so `{reason|unknown}` in a name stays +/// whole. +pub fn parse_fields(text: &str) -> Vec<(String, String, bool)> { + text.lines() + .filter_map(|line| { + let at = field_bar(line)?; + let inline = !line[at..].starts_with("||"); + let name = &line[..at]; + let value = &line[at + if inline { 1 } else { 2 }..]; + let (name, value) = (name.trim(), value.trim()); + (!name.is_empty() && !value.is_empty()) + .then(|| (name.to_string(), value.to_string(), inline)) + }) + .collect() +} + +/// Where the bar between a field's name and value is: the first `|` +/// outside `{...}`. +fn field_bar(line: &str) -> Option { + let mut depth = 0usize; + for (at, c) in line.char_indices() { + match c { + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + '|' if depth == 0 => return Some(at), + _ => {} + } + } + None +} + /// Evaluate an `if` step. Comparisons ignore case; `greater` / `less` /// compare as numbers when both sides are numbers. pub fn compare(left: &str, op: &str, right: &str) -> bool { @@ -773,10 +1229,16 @@ pub fn compare(left: &str, op: &str, right: &str) -> bool { "not_empty" => !(l.is_empty() || l == "0" || l == "no"), "greater" => numbers().is_some_and(|(a, b)| a > b), "less" => numbers().is_some_and(|(a, b)| a < b), + "whole_number" => is_whole_number(&l), _ => false, } } +/// `0`, `30`, `600`: digits only, so no sign, decimals or exponent. +fn is_whole_number(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) +} + /// Keep a chat message one line, within Twitch's limit, and unable to run /// a chat command even when it starts with text a viewer typed. pub fn safe_chat_text(text: &str) -> String { @@ -886,9 +1348,47 @@ fn short_url(url: &str) -> String { host.rsplit('@').next().unwrap_or(host).to_string() } +/// The longest a wait step's `name` may be: its own number as written, or +/// `MAX_FILLED_WAIT` when it is filled in from a variable. +fn wait_cap(step: &Step, name: &str) -> Duration { + if step.param(name).contains('{') { + MAX_FILLED_WAIT + } else { + MAX_WAIT + } +} + +/// What the activity log calls a Discord message: its title, its body, or +/// its plain text. +fn discord_shown(message: &DiscordMessage) -> String { + match &message.card { + Some(card) if !card.title.is_empty() => card.title.clone(), + Some(card) if !card.description.is_empty() => card.description.clone(), + _ => message.content.clone(), + } +} + +fn obs_label(action: &obsws::Action) -> String { + match action { + obsws::Action::Scene(scene) => format!("OBS scene {scene}"), + obsws::Action::Source { + source, + visible: Some(true), + .. + } => format!("Show {source}"), + obsws::Action::Source { + source, + visible: Some(false), + .. + } => format!("Hide {source}"), + obsws::Action::Source { source, .. } => format!("Show or hide {source}"), + obsws::Action::Text { source, .. } => format!("Text of {source}"), + } +} + fn delay_action_label(action: &str, ms: u32) -> String { match action { - "arm" if ms == 0 => "Set the delay to 0s".to_string(), + "arm" if ms == 0 => "Set the delay to your default".to_string(), "arm" => format!("Set the delay to {}", fmt_delay(ms)), "activate" => "Turn the delay on".to_string(), "cut" => "Cut the delay".to_string(), @@ -911,11 +1411,14 @@ mod tests { calls: Mutex>, delay_ms: u32, fail_discord: bool, + /// No stream on. + offline: bool, } impl Host for FakeHost { fn live(&self) -> LiveState { LiveState { + streaming: !self.offline, delay_ms: self.delay_ms, hold_active: true, hold_left_ms: 102_000, @@ -926,12 +1429,20 @@ mod tests { fn discord( &self, url: &str, - content: &str, - ping: &str, + message: &DiscordMessage, edit: Option<&str>, ) -> Result { let mut calls = self.calls.lock(); - calls.push(format!("discord {url} [{ping}] edit={edit:?} {content}")); + let card = message.card.as_ref().map_or_else(String::new, |c| { + format!( + "card[{}|{}|{:06x}|{:?}]", + c.title, c.description, c.color, c.fields + ) + }); + calls.push(format!( + "discord {url} [{}] edit={edit:?} {}{card}", + message.ping, message.content + )); if self.fail_discord { return Err("Discord answered 500".into()); } @@ -998,6 +1509,10 @@ mod tests { .lock() .push(format!("overlay {title}|{text}|{seconds}")); } + fn obs(&self, action: obsws::Action) -> Result<(), String> { + self.calls.lock().push(format!("obs {action:?}")); + Ok(()) + } } fn env() -> Arc { @@ -1014,9 +1529,270 @@ mod tests { counters: Arc::new(Mutex::new(BTreeMap::new())), counters_changed: Box::new(|| {}), discord_messages: Arc::new(Mutex::new(HashMap::new())), + discord_turns: Arc::new(DiscordTurns::default()), + timeline: Arc::new(Mutex::new(Timeline::default())), + emit: Box::new(|_| {}), }) } + #[tokio::test] + async fn a_card_fills_every_part_and_keeps_fields_in_place() { + let host = Arc::new(FakeHost::default()); + let step = Step::new( + StepKind::Discord, + &[ + ("connection", "mods"), + ("style", "card"), + ("title", "🔴 {destination} is down"), + ("text", "Since {down_for}"), + ("color", "#f2665a"), + ("fields", "Reason | {reason}\nNotes || a|b"), + ("above", ""), + ], + ); + let c = ctx( + &[ + ("destination", "YouTube"), + ("down_for", "1m"), + ("reason", "x | y"), + ], + false, + ); + go(&host, &[step], c).await; + let calls = host.calls.lock(); + assert!( + calls[0].contains("card[🔴 YouTube is down|Since 1m|f2665a|"), + "{}", + calls[0] + ); + assert!( + calls[0].contains(r#"("Reason", "x | y", true)"#), + "{}", + calls[0] + ); + assert!( + calls[0].contains(r#"("Notes", "a|b", false)"#), + "{}", + calls[0] + ); + } + + #[tokio::test] + async fn an_empty_message_is_skipped_not_sent() { + let host = Arc::new(FakeHost::default()); + let step = Step::new( + StepKind::Discord, + &[("connection", "mods"), ("text", "{nothing}")], + ); + let (_, log) = go(&host, &[step], ctx(&[], false)).await; + assert_eq!(log[0].status, StepStatus::Skipped); + assert!(host.calls.lock().is_empty()); + } + + #[tokio::test] + async fn messages_about_different_subjects_edit_their_own_message() { + let host = Arc::new(FakeHost::default()); + let env = env(); + let post = |dest: &str, edit: &str| { + let step = Step::new( + StepKind::Discord, + &[ + ("connection", "mods"), + ("text", "{destination}"), + ("edit", edit), + ("key", "{destination}"), + ], + ); + (step, ctx(&[("destination", dest)], false)) + }; + for (dest, edit) in [("YouTube", ""), ("Kick", ""), ("YouTube", "last")] { + let (step, c) = post(dest, edit); + run(&[step], c, host.clone(), env.clone()).await; + } + let calls = host.calls.lock(); + assert!(calls[2].contains("edit=Some(\"m1\")"), "{}", calls[2]); + } + + #[tokio::test] + async fn finishing_a_message_edits_it_once_and_never_posts() { + let host = Arc::new(FakeHost::default()); + let env = env(); + let send = |edit: &str| { + Step::new( + StepKind::Discord, + &[ + ("connection", "mods"), + ("text", "x"), + ("edit", edit), + ("key", "yt"), + ], + ) + }; + // Nothing to finish: no "back" without a "down". + let (_, log) = run(&[send("close")], ctx(&[], false), host.clone(), env.clone()).await; + assert_eq!(log[0].status, StepStatus::Skipped); + for edit in ["", "close", "close"] { + run(&[send(edit)], ctx(&[], false), host.clone(), env.clone()).await; + } + let calls = host.calls.lock(); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert!(calls[1].contains("edit=Some(\"m1\")")); + } + + #[tokio::test] + async fn timeline_lines_land_at_their_stream_position() { + let host = Arc::new(FakeHost { + delay_ms: 20_000, + ..FakeHost::default() + }); + let env = env(); + let line = |at: &str| { + Step::new( + StepKind::Timeline, + &[("text", "Boss {arg1}"), ("kind", "chapter"), ("at", at)], + ) + }; + let steps = [line("live"), line("aired")]; + run(&steps, ctx(&[("arg1", "fight")], false), host, env.clone()).await; + let shown = env.timeline.lock().render(1000); + assert_eq!(shown, "`0:00` Boss fight\n`0:20` Boss fight"); + } + + #[tokio::test] + async fn no_timeline_line_lands_after_the_stream_ended() { + let host = Arc::new(FakeHost { + offline: true, + ..FakeHost::default() + }); + let env = env(); + let step = Step::new(StepKind::Timeline, &[("text", "late highlight")]); + let (_, log) = run(&[step], ctx(&[], false), host, env.clone()).await; + assert_eq!(log[0].status, StepStatus::Skipped); + assert_eq!(env.timeline.lock().render(1000), ""); + } + + #[tokio::test] + async fn a_dry_run_sends_nothing_and_says_what_it_would_have() { + let host = Arc::new(FakeHost::default()); + let steps = [ + Step::new( + StepKind::Discord, + &[("connection", "mods"), ("text", "Hi {user}")], + ), + Step::new(StepKind::Chat, &[("text", "hello chat")]), + Step::new(StepKind::Http, &[("url", "https://example.com/x")]), + Step::new(StepKind::Obs, &[("action", "scene"), ("scene", "Game")]), + ]; + let mut c = ctx(&[("user", "Ana")], true); + c.dry = true; + let (_, log) = go(&host, &steps, c).await; + assert!(host.calls.lock().is_empty(), "{:?}", host.calls.lock()); + assert!(log.iter().all(|l| l.status == StepStatus::Skipped)); + assert!(log[0].detail.contains("[TEST] Hi Ana"), "{}", log[0].detail); + } + + #[tokio::test] + async fn switched_off_steps_are_skipped_with_what_is_inside() { + let host = Arc::new(FakeHost::default()); + let mut check = Step::new(StepKind::If, &[("left", "a"), ("op", "is"), ("right", "a")]); + check + .then + .push(Step::new(StepKind::Chat, &[("text", "inside")])); + check.enabled = false; + let steps = [check, Step::new(StepKind::Chat, &[("text", "after")])]; + go(&host, &steps, ctx(&[], false)).await; + assert_eq!(*host.calls.lock(), ["chat bot=false reply=None after"]); + } + + #[tokio::test] + async fn an_edit_with_a_newer_one_waiting_gives_way() { + let host = Arc::new(FakeHost::default()); + let env = env(); + let edit = Step::new( + StepKind::Discord, + &[("connection", "mods"), ("text", "{n}"), ("edit", "last")], + ); + // The first posts; then three edits queue behind a held turn. + run( + std::slice::from_ref(&edit), + ctx(&[("n", "1")], false), + host.clone(), + env.clone(), + ) + .await; + let turn = env.discord_turns.turn("crash:mods"); + let held = turn.queue.lock().await; + let mut runs = Vec::new(); + for n in ["2", "3", "4"] { + let (step, env, host) = (edit.clone(), env.clone(), host.clone()); + runs.push(tokio::spawn(async move { + run(&[step], ctx(&[("n", n)], false), host, env).await.1 + })); + tokio::task::yield_now().await; + } + drop(held); + drop(turn); + let mut skipped = 0; + for r in runs { + skipped += r + .await + .unwrap() + .iter() + .filter(|l| l.status == StepStatus::Skipped) + .count(); + } + assert_eq!(skipped, 2, "only the newest edit goes out"); + let calls = host.calls.lock(); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert!(calls[1].ends_with(" 4"), "{}", calls[1]); + } + + #[tokio::test] + async fn obs_steps_say_what_they_do_and_tests_leave_obs_alone() { + let host = Arc::new(FakeHost::default()); + let hide = Step::new(StepKind::Obs, &[("action", "hide"), ("source", "Cam")]); + go(&host, std::slice::from_ref(&hide), ctx(&[], false)).await; + assert!(host.calls.lock()[0].contains("visible: Some(false)")); + let (_, log) = go(&host, &[hide], ctx(&[], true)).await; + assert_eq!(log[0].status, StepStatus::Skipped); + assert_eq!(host.calls.lock().len(), 1); + } + + #[tokio::test] + async fn a_wait_chat_fills_in_is_kept_short() { + let step = Step::new(StepKind::Wait, &[("ms", "{arg1}")]); + assert_eq!(wait_cap(&step, "ms"), MAX_FILLED_WAIT); + let fixed = Step::new(StepKind::Wait, &[("ms", "120000")]); + assert_eq!(wait_cap(&fixed, "ms"), MAX_WAIT); + } + + #[test] + fn query_fields_are_read_and_decoded() { + assert_eq!(query_field("team=red%20one&x=1", "team"), "red one"); + assert_eq!(query_field("a=1+2", "a"), "1 2"); + assert_eq!(query_field("flag&b=2", "flag"), ""); + assert_eq!(query_field("a=1", "missing"), ""); + assert_eq!(percent_decode("100%"), "100%", "a stray % stays"); + } + + #[test] + fn colors_and_fields_parse() { + assert_eq!(parse_color("#5ac8fa"), Some(0x5ac8fa)); + assert_eq!(parse_color("5cf"), Some(0x55ccff)); + assert_eq!(parse_color("blue"), None); + assert_eq!( + parse_fields("A | 1\n\nB || 2\nno separator\n | empty name"), + vec![ + ("A".into(), "1".into(), true), + ("B".into(), "2".into(), false) + ] + ); + assert_eq!( + parse_fields("{why|Reason} | {reason|unknown}"), + vec![("{why|Reason}".into(), "{reason|unknown}".into(), true)] + ); + } + fn ctx(vars: &[(&str, &str)], test: bool) -> RunContext { RunContext { integration_id: "crash".into(), @@ -1026,6 +1802,8 @@ mod tests { .collect(), reply_to: Some("msg-1".into()), test, + dry: false, + gate: None, } } @@ -1103,11 +1881,12 @@ mod tests { StepKind::Program, &[("path", "say.exe"), ("args", "--from \"{user}\" {message}")], )]; - let said = [("user", "ana b"), ("message", "hi\" --admin \"x")]; + let said = [("user", "ana b"), ("message", "hi\" --admin \"x & calc")]; go(&host, &steps, ctx(&said, false)).await; + // The quotes and `&` are gone: a shell can't read a command into it. assert_eq!( host.calls.lock()[0], - r#"program say.exe ["--from", "ana b", "hi\" --admin \"x"]"# + r#"program say.exe ["--from", "ana b", "hi --admin x calc"]"# ); } @@ -1277,6 +2056,50 @@ mod tests { ); } + #[tokio::test] + async fn setting_the_delay_to_zero_turns_it_off() { + let host = Arc::new(FakeHost::default()); + let set = |seconds: &str| { + Step::new( + StepKind::DelayAction, + &[("action", "arm"), ("seconds", seconds)], + ) + }; + let steps = [set("{arg1}"), set(""), set("45"), set("9999")]; + let (status, log) = go(&host, &steps, ctx(&[("arg1", "0")], false)).await; + assert_eq!(status, RunStatus::Ok); + let calls = host.calls.lock(); + assert_eq!( + calls.as_slice(), + [ + "delay disarm 0", + "delay arm 0", + "delay arm 45000", + "delay arm 600000" + ], + "0 turns it off, blank means the default, the top is 600 s" + ); + assert_eq!(log[0].label, "Disarm the delay"); + assert_eq!(log[1].label, "Set the delay to your default"); + } + + #[tokio::test] + async fn the_delay_takes_whole_seconds_only() { + for typed in ["2.5", "-5", "1e3", "0.0004", "thirty"] { + let host = Arc::new(FakeHost::default()); + let steps = [Step::new( + StepKind::DelayAction, + &[("action", "arm"), ("seconds", "{arg1}")], + )]; + let (_, log) = go(&host, &steps, ctx(&[("arg1", typed)], false)).await; + assert!(host.calls.lock().is_empty(), "{typed} changed the delay"); + assert_eq!(log[0].status, StepStatus::Failed, "{typed}"); + } + assert!(compare("30", "whole_number", "")); + assert!(!compare("2.5", "whole_number", "")); + assert!(!compare("", "whole_number", "")); + } + #[tokio::test] async fn test_runs_mark_messages_and_leave_the_stream_alone() { let host = Arc::new(FakeHost::default()); @@ -1368,8 +2191,7 @@ mod tests { fn discord( &self, url: &str, - _: &str, - _: &str, + _: &DiscordMessage, _: Option<&str>, ) -> Result { Err(format!("couldn't reach {url}")) @@ -1399,6 +2221,9 @@ mod tests { Ok(()) } fn overlay(&self, _: &str, _: &str, _: u64) {} + fn obs(&self, _: obsws::Action) -> Result<(), String> { + Ok(()) + } } let steps = [Step::new( StepKind::Discord, diff --git a/src/integrations/session.rs b/src/integrations/session.rs new file mode 100644 index 0000000..dd8354b --- /dev/null +++ b/src/integrations/session.rs @@ -0,0 +1,627 @@ +//! One stream, from the moment OBS starts sending to the moment it ends +//! for good, read from the controller's events: when it started, how often +//! OBS crashed, and how each destination held up. +//! +//! A crash OBS comes back from is the same stream: crash protection kept +//! the destinations up, so viewers saw one stream. It ends when the +//! reconnect screen runs out or is ended, when OBS stops on purpose, or +//! when OBS drops without crash protection and isn't back within +//! `END_GRACE`: OBS reconnects on its own after a network blip, and that +//! blip must not become a second stream with its own reports and pings. +//! +//! Destinations are tracked by name, the way events name them. What this +//! keeps feeds the smart destination events (`destination_recovered`, +//! `_still_down`, `_unstable`, `_steady`) and the end-of-stream report. + +use super::event::{Event, EventKind}; +use super::host::{fmt_duration, fmt_span}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +/// How long OBS has to come back after dropping unprotected before the +/// stream counts as over. OBS's own reconnect tries for longer, but a +/// stream that is gone a minute has ended for its viewers. +pub const END_GRACE: Duration = Duration::from_secs(60); +/// Drops remembered per destination per stream. +const MAX_DROPS: usize = 1000; + +#[derive(Default)] +pub struct Session { + /// When the stream started: (monotonic, Unix ms). None between streams. + started: Option<(Instant, u64)>, + /// When OBS dropped without crash protection: the stream ends + /// `END_GRACE` later unless OBS comes back first. + dropped_at: Option, + crashes: u32, + /// OBS dropping without crash protection and coming back: how often, + /// and for how long in all. + obs_drops: u32, + obs_down: Duration, + destinations: BTreeMap, +} + +/// How one destination is doing in this stream. +#[derive(Clone, Debug, Default)] +pub struct Health { + pub platform: String, + pub reason: String, + pub down_since: Option, + pub live_since: Option, + /// The latest drops this stream, oldest first (at most `MAX_DROPS`). + pub drops: Vec, + /// Every drop this stream, however many: what reports and alerts count. + pub drop_count: usize, + pub down_total: Duration, +} + +impl Health { + pub fn drops_within(&self, window: Duration, now: Instant) -> usize { + self.drops + .iter() + .filter(|t| now.duration_since(**t) <= window) + .count() + } +} + +impl Session { + pub fn is_on(&self) -> bool { + self.started.is_some() + } + + /// Unix ms the stream started at, 0 between streams. + pub fn started_ms(&self) -> u64 { + self.started.map_or(0, |(_, ms)| ms) + } + + pub fn uptime(&self, now: Instant) -> Duration { + self.started + .map_or(Duration::ZERO, |(at, _)| now.duration_since(at)) + } + + pub fn destinations(&self) -> &BTreeMap { + &self.destinations + } + + /// Whether the stream is waiting to see if OBS comes back. + pub fn is_ending(&self) -> bool { + self.dropped_at.is_some() + } + + /// The stream ending once OBS stayed away `END_GRACE`: its report, as + /// of when OBS dropped. Call every tick while `is_ending`. + pub fn expire(&mut self, now: Instant, timeline: (usize, String)) -> Vec { + match self.dropped_at { + Some(at) if now.duration_since(at) >= END_GRACE => self.end(at, timeline), + _ => Vec::new(), + } + } + + /// Follow one controller event. Returns the events it makes: a stream + /// starting or ending, a destination coming back. `highlights` and + /// `chapters` are the timeline's, for the end-of-stream report. + pub fn observe( + &mut self, + event: &Event, + now: Instant, + unix_ms: u64, + timeline: (usize, String), + ) -> Vec { + match event.kind { + EventKind::ObsConnected if self.started.is_none() => { + self.started = Some((now, unix_ms)); + vec![Event::new(EventKind::StreamStarted)] + } + // Back within the grace: the same stream carries on, and the + // report still says OBS was gone. + EventKind::ObsConnected => { + if let Some(at) = self.dropped_at.take() { + self.obs_drops += 1; + self.obs_down += now.duration_since(at); + } + Vec::new() + } + // Stopping on purpose with "hold on every disconnect" opens a + // hold too, but that isn't a crash. + EventKind::HoldOpened if self.started.is_some() => { + if event.var("reason") != Some("stopped") { + self.crashes += 1; + } + Vec::new() + } + EventKind::ObsDisconnected if event.var("protected") == Some("no") => { + if event.var("stopped") == Some("yes") { + self.end(now, timeline) + } else { + self.dropped_at.get_or_insert(now); + Vec::new() + } + } + EventKind::HoldExpired | EventKind::HoldEnded => self.end(now, timeline), + // Between streams nothing is watched: a late event from the last + // one must not open the next one's report. + EventKind::DestinationDropped if self.started.is_some() => { + self.dropped(event, now); + Vec::new() + } + EventKind::DestinationLive if self.started.is_some() => self.came_back(event, now), + _ => Vec::new(), + } + } + + fn dropped(&mut self, event: &Event, now: Instant) { + let health = self.health(event); + health.reason = event.var("reason").unwrap_or("").to_string(); + // A drop while already down (a retry failing) is the same outage. + if health.down_since.is_none() { + health.down_since = Some(now); + // A destination flapping all stream long keeps its latest drops. + if health.drops.len() >= MAX_DROPS { + health.drops.remove(0); + } + health.drops.push(now); + health.drop_count += 1; + } + health.live_since = None; + } + + fn came_back(&mut self, event: &Event, now: Instant) -> Vec { + let health = self.health(event); + health.live_since = Some(now); + let Some(since) = health.down_since.take() else { + return Vec::new(); + }; + let down = now.duration_since(since); + health.down_total += down; + let platform = health.platform.clone(); + vec![Event::new(EventKind::DestinationRecovered) + .with("destination", event.var("destination").unwrap_or("")) + .with("platform", platform) + .with("down_for", fmt_duration(down.as_millis() as u64)) + .with("down_s", down.as_secs().to_string())] + } + + fn health(&mut self, event: &Event) -> &mut Health { + let name = event.var("destination").unwrap_or("").to_string(); + let health = self.destinations.entry(name).or_default(); + if let Some(platform) = event.var("platform") { + health.platform = platform.to_string(); + } + health + } + + /// The stream ended at `now`: the report, and a clean slate for the + /// next one. + fn end(&mut self, now: Instant, (highlights, chapters): (usize, String)) -> Vec { + self.dropped_at = None; + let Some((started, _)) = self.started.take() else { + return Vec::new(); + }; + let drops: usize = self.destinations.values().map(|h| h.drop_count).sum(); + let mut lines: Vec = Vec::new(); + if self.obs_drops > 0 { + lines.push(format!( + "OBS: dropped {} without crash protection, gone {}", + if self.obs_drops == 1 { + "once".to_string() + } else { + format!("{} times", self.obs_drops) + }, + fmt_duration(self.obs_down.as_millis() as u64) + )); + } + lines.extend( + self.destinations + .iter() + .map(|(name, h)| report_line(name, h, now)), + ); + let report = lines.join("\n"); + let event = Event::new(EventKind::StreamEnded) + .with( + "duration", + fmt_span(now.duration_since(started).as_millis() as u64), + ) + .with("crashes", (self.crashes + self.obs_drops).to_string()) + .with("drops", drops.to_string()) + .with("highlights", highlights.to_string()) + .with("report", report) + .with("chapters", chapters); + self.crashes = 0; + self.obs_drops = 0; + self.obs_down = Duration::ZERO; + self.destinations.clear(); + vec![event] + } +} + +/// `YouTube: 2 drops, down 1m 12s` or `Kick: steady`. +fn report_line(name: &str, h: &Health, now: Instant) -> String { + let down = h.down_total + + h.down_since + .map_or(Duration::ZERO, |s| now.duration_since(s)); + match h.drop_count { + 0 => format!("{name}: steady"), + 1 => format!( + "{name}: 1 drop, down {}", + fmt_duration(down.as_millis() as u64) + ), + n => format!( + "{name}: {n} drops, down {}", + fmt_duration(down.as_millis() as u64) + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dest(kind: EventKind, name: &str) -> Event { + Event::new(kind) + .with("destination", name) + .with("platform", "youtube") + .with("reason", "timed out") + } + + fn none() -> (usize, String) { + (0, String::new()) + } + + #[test] + fn a_stream_starts_once_and_survives_a_crash() { + let mut s = Session::default(); + let t0 = Instant::now(); + let kinds = |events: Vec| events.iter().map(|e| e.kind).collect::>(); + assert_eq!( + kinds(s.observe(&Event::new(EventKind::ObsConnected), t0, 1000, none())), + [EventKind::StreamStarted] + ); + s.observe(&Event::new(EventKind::HoldOpened), t0, 0, none()); + let protected = Event::new(EventKind::ObsDisconnected).with("protected", "yes"); + assert!(s.observe(&protected, t0, 0, none()).is_empty()); + assert!( + s.observe(&Event::new(EventKind::ObsConnected), t0, 0, none()) + .is_empty(), + "OBS back from a crash is the same stream" + ); + assert_eq!(s.started_ms(), 1000); + let stopped = Event::new(EventKind::ObsDisconnected) + .with("stopped", "yes") + .with("protected", "no"); + let end = s.observe( + &stopped, + t0 + Duration::from_secs(3600), + 0, + (2, "0:00 Start".into()), + ); + assert_eq!(end[0].kind, EventKind::StreamEnded); + assert_eq!(end[0].var("duration"), Some("1h 00m")); + assert_eq!(end[0].var("crashes"), Some("1")); + assert_eq!(end[0].var("highlights"), Some("2")); + assert!(!s.is_on()); + } + + #[test] + fn a_blip_without_crash_protection_is_still_one_stream() { + let mut s = Session::default(); + let t0 = Instant::now(); + let at = |secs| t0 + Duration::from_secs(secs); + s.observe(&Event::new(EventKind::ObsConnected), t0, 0, none()); + let dropped = Event::new(EventKind::ObsDisconnected) + .with("stopped", "no") + .with("protected", "no"); + assert!(s.observe(&dropped, at(100), 0, none()).is_empty()); + assert!(s.is_ending()); + assert!( + s.expire(at(130), none()).is_empty(), + "still within the grace" + ); + let back = s.observe(&Event::new(EventKind::ObsConnected), at(140), 0, none()); + assert!(back.is_empty(), "OBS came back: no second stream"); + assert!(!s.is_ending() && s.is_on()); + // Gone for good this time: it ends, as of when OBS dropped. + s.observe(&dropped, at(200), 0, none()); + let end = s.expire(at(200) + END_GRACE, none()); + assert_eq!(end[0].kind, EventKind::StreamEnded); + assert_eq!(end[0].var("duration"), Some("3m")); + assert!(!s.is_on() && !s.is_ending()); + } + + #[test] + fn an_unprotected_drop_obs_came_back_from_is_in_the_report() { + let mut s = Session::default(); + let t0 = Instant::now(); + s.observe(&Event::new(EventKind::ObsConnected), t0, 0, none()); + let dropped = Event::new(EventKind::ObsDisconnected) + .with("stopped", "no") + .with("protected", "no"); + s.observe(&dropped, t0 + Duration::from_secs(60), 0, none()); + s.observe( + &Event::new(EventKind::ObsConnected), + t0 + Duration::from_secs(80), + 0, + none(), + ); + let end = s.observe(&Event::new(EventKind::HoldEnded), t0, 0, none()); + assert_eq!(end[0].var("crashes"), Some("1")); + assert!(end[0].var("report").is_some_and( + |r| r.starts_with("OBS: dropped once without crash protection, gone 20 s") + )); + } + + #[test] + fn stopping_on_purpose_into_a_hold_is_not_a_crash() { + let mut s = Session::default(); + let t0 = Instant::now(); + s.observe(&Event::new(EventKind::ObsConnected), t0, 0, none()); + let stopped = Event::new(EventKind::HoldOpened).with("reason", "stopped"); + s.observe(&stopped, t0, 0, none()); + let end = s.observe(&Event::new(EventKind::HoldEnded), t0, 0, none()); + assert_eq!(end[0].var("crashes"), Some("0")); + } + + #[test] + fn a_destination_coming_back_says_how_long_it_was_gone() { + let mut s = Session::default(); + let t0 = Instant::now(); + s.observe(&Event::new(EventKind::ObsConnected), t0, 0, none()); + s.observe(&dest(EventKind::DestinationLive, "YouTube"), t0, 0, none()); + s.observe( + &dest(EventKind::DestinationDropped, "YouTube"), + t0, + 0, + none(), + ); + // A failed retry while down is the same outage, not a second drop. + s.observe( + &dest(EventKind::DestinationDropped, "YouTube"), + t0, + 0, + none(), + ); + let back = s.observe( + &dest(EventKind::DestinationLive, "YouTube"), + t0 + Duration::from_secs(72), + 0, + none(), + ); + assert_eq!(back[0].kind, EventKind::DestinationRecovered); + assert_eq!(back[0].var("down_for"), Some("1m 12s")); + assert_eq!(back[0].var("down_s"), Some("72")); + assert_eq!(s.destinations()["YouTube"].drop_count, 1); + } + + #[test] + fn the_report_names_every_destination() { + let mut s = Session::default(); + let t0 = Instant::now(); + s.observe(&Event::new(EventKind::ObsConnected), t0, 0, none()); + for (at, kind) in [ + (0, EventKind::DestinationDropped), + (30, EventKind::DestinationLive), + (60, EventKind::DestinationDropped), + (90, EventKind::DestinationLive), + ] { + s.observe( + &dest(kind, "YouTube"), + t0 + Duration::from_secs(at), + 0, + none(), + ); + } + s.observe(&dest(EventKind::DestinationLive, "Kick"), t0, 0, none()); + let end = s.observe(&Event::new(EventKind::HoldExpired), t0, 0, none()); + assert_eq!( + end[0].var("report"), + Some("Kick: steady\nYouTube: 2 drops, down 1m 00s") + ); + assert_eq!(end[0].var("drops"), Some("2")); + assert!(s.destinations().is_empty(), "a clean slate for the next"); + } + + #[test] + fn drops_are_counted_within_a_window() { + let t0 = Instant::now(); + let h = Health { + drops: vec![ + t0, + t0 + Duration::from_secs(300), + t0 + Duration::from_secs(590), + ], + ..Health::default() + }; + let now = t0 + Duration::from_secs(600); + assert_eq!(h.drops_within(Duration::from_secs(600), now), 3); + assert_eq!(h.drops_within(Duration::from_secs(60), now), 1); + } + + /// A stream day for one kind of streamer: events in order, each `secs` + /// after the start. Returns every event the session made, with when. + struct Day { + session: Session, + t0: Instant, + made: Vec<(u64, Event)>, + } + + impl Day { + fn new() -> Day { + Day { + session: Session::default(), + t0: Instant::now(), + made: Vec::new(), + } + } + + fn at(&mut self, secs: u64, event: Event) { + let now = self.t0 + Duration::from_secs(secs); + let mut out = self.session.expire(now, none()); + out.extend(self.session.observe(&event, now, 0, none())); + self.made.extend(out.into_iter().map(|e| (secs, e))); + } + + fn tick(&mut self, secs: u64) { + let now = self.t0 + Duration::from_secs(secs); + let out = self.session.expire(now, none()); + self.made.extend(out.into_iter().map(|e| (secs, e))); + } + + fn kinds(&self) -> Vec { + self.made.iter().map(|(_, e)| e.kind).collect() + } + + fn ends(&self) -> Vec<&Event> { + self.made + .iter() + .filter(|(_, e)| e.kind == EventKind::StreamEnded) + .map(|(_, e)| e) + .collect() + } + } + + fn obs_gone(stopped: bool, protected: bool) -> Event { + Event::new(EventKind::ObsDisconnected) + .with("stopped", if stopped { "yes" } else { "no" }) + .with("protected", if protected { "yes" } else { "no" }) + } + + fn hold(reason: &str) -> Event { + Event::new(EventKind::HoldOpened).with("reason", reason) + } + + /// Crash protection on, two platforms: OBS crashes once and comes + /// back, YouTube drops and recovers, then the hold after a stop runs + /// out. One stream, one crash, the YouTube outage in the report. + #[test] + fn day_of_a_careful_streamer() { + let mut day = Day::new(); + day.at(0, Event::new(EventKind::ObsConnected)); + day.at(600, dest(EventKind::DestinationDropped, "YouTube")); + day.at(645, dest(EventKind::DestinationLive, "YouTube")); + day.at(1800, obs_gone(false, true)); + day.at(1800, hold("crash")); + day.at(1830, Event::new(EventKind::ObsConnected)); + day.at(1830, Event::new(EventKind::ObsBack)); + day.at(3600, obs_gone(true, false)); + + use EventKind::*; + assert_eq!( + day.kinds(), + [StreamStarted, DestinationRecovered, StreamEnded], + "one stream, its YouTube outage said once" + ); + let end = day.ends()[0]; + assert_eq!(end.var("crashes"), Some("1")); + assert_eq!(end.var("drops"), Some("1")); + assert_eq!(end.var("duration"), Some("1h 00m")); + assert_eq!(end.var("report"), Some("YouTube: 1 drop, down 45 s")); + } + + /// No crash protection: OBS's connection blips twice and comes back + /// within the grace (still one stream, both in the report), then it + /// crashes for good and the stream ends as of that crash. + #[test] + fn day_of_a_streamer_without_crash_protection() { + let mut day = Day::new(); + day.at(0, Event::new(EventKind::ObsConnected)); + day.at(300, obs_gone(false, false)); + day.at(310, Event::new(EventKind::ObsConnected)); + day.at(900, obs_gone(false, false)); + day.at(950, Event::new(EventKind::ObsConnected)); + day.at(1200, obs_gone(false, false)); + day.tick(1259); + assert!(day.ends().is_empty(), "still within the grace"); + day.tick(1260); + + let ends = day.ends(); + assert_eq!(ends.len(), 1, "one stream"); + assert_eq!(ends[0].var("duration"), Some("20m"), "ends at the crash"); + assert_eq!(ends[0].var("crashes"), Some("2")); + assert!(ends[0].var("report").is_some_and( + |r| r.starts_with("OBS: dropped 2 times without crash protection, gone 1m") + )); + day.at(1500, Event::new(EventKind::ObsConnected)); + assert_eq!( + day.kinds().last(), + Some(&EventKind::StreamStarted), + "OBS back after the grace is a new stream" + ); + } + + /// Stop and go: stops in OBS, fixes a setting, starts again 20 s + /// later. Two streams (an app restart is treated the same way). + #[test] + fn day_of_a_stop_and_go_streamer() { + let mut day = Day::new(); + day.at(0, Event::new(EventKind::ObsConnected)); + day.at(120, obs_gone(true, false)); + day.at(140, Event::new(EventKind::ObsConnected)); + day.at(3000, obs_gone(true, false)); + + use EventKind::*; + assert_eq!( + day.kinds(), + [StreamStarted, StreamEnded, StreamStarted, StreamEnded] + ); + assert!(day.ends().iter().all(|e| e.var("crashes") == Some("0"))); + } + + /// "Hold on every disconnect": a stop on purpose keeps the stream up, + /// OBS comes back (same stream, no crash), then the second stop's hold + /// is ended from the dashboard. + #[test] + fn day_of_a_streamer_who_holds_every_disconnect() { + let mut day = Day::new(); + day.at(0, Event::new(EventKind::ObsConnected)); + day.at(600, obs_gone(true, true)); + day.at(600, hold("stopped")); + day.at(640, Event::new(EventKind::ObsConnected)); + day.at(1200, obs_gone(true, true)); + day.at(1200, hold("stopped")); + day.at(1260, Event::new(EventKind::HoldEnded)); + + let ends = day.ends(); + assert_eq!(ends.len(), 1, "one stream"); + assert_eq!(ends[0].var("crashes"), Some("0"), "stops aren't crashes"); + assert_eq!(ends[0].var("duration"), Some("21m")); + } + + /// A platform that drops all stream long (a bad Kick edge): every drop + /// counts, the memory of them stays bounded. + #[test] + fn day_of_a_streamer_with_a_flaky_platform() { + let mut day = Day::new(); + day.at(0, Event::new(EventKind::ObsConnected)); + for n in 0..1_500u64 { + day.at(1 + n * 4, dest(EventKind::DestinationDropped, "Kick")); + day.at(3 + n * 4, dest(EventKind::DestinationLive, "Kick")); + } + assert_eq!(day.session.destinations()["Kick"].drops.len(), MAX_DROPS); + day.at(7_000, obs_gone(true, false)); + + let end = day.ends()[0]; + assert_eq!(end.var("drops"), Some("1500")); + assert_eq!(end.var("report"), Some("Kick: 1500 drops, down 50m 00s")); + } + + /// Late news between streams (a platform drop after the stop, a hold + /// ending twice) opens nothing and ends nothing. + #[test] + fn news_between_streams_is_ignored() { + let mut day = Day::new(); + day.at(0, Event::new(EventKind::ObsConnected)); + day.at(60, obs_gone(true, false)); + day.at(70, dest(EventKind::DestinationDropped, "YouTube")); + day.at(80, dest(EventKind::DestinationLive, "YouTube")); + day.at(90, Event::new(EventKind::HoldEnded)); + day.at(95, Event::new(EventKind::HoldExpired)); + + use EventKind::*; + assert_eq!(day.kinds(), [StreamStarted, StreamEnded]); + day.at(100, Event::new(EventKind::ObsConnected)); + day.at(200, obs_gone(true, false)); + assert_eq!( + day.ends()[1].var("report"), + Some(""), + "the next report starts clean" + ); + } +} diff --git a/src/integrations/store.rs b/src/integrations/store.rs index 83b0679..9fe3ef4 100644 --- a/src/integrations/store.rs +++ b/src/integrations/store.rs @@ -58,6 +58,8 @@ pub struct Store { path: PathBuf, pub accounts: Mutex, pub counters: Arc>>, + /// A counter changed since the file was last written. + pub counters_dirty: std::sync::atomic::AtomicBool, /// Runs per integration id, for `{uses}`. pub uses: Mutex>, /// Serialises writes so two savers never interleave. @@ -99,6 +101,7 @@ impl Store { bot: account("twitch.bot"), }), counters: Arc::new(Mutex::new(counters)), + counters_dirty: Default::default(), uses: Mutex::new(uses), write_lock: Mutex::new(()), } diff --git a/src/integrations/template.rs b/src/integrations/template.rs index b7cf0ea..8c1fe45 100644 --- a/src/integrations/template.rs +++ b/src/integrations/template.rs @@ -95,6 +95,36 @@ pub fn one_line(value: &str) -> String { .collect() } +/// A value inside a program's argument. Each value already stays one +/// argument, but a shell or script host the step runs (cmd, PowerShell, +/// bash, `python -c`) reads its own meaning into that argument: `&`, `|`, +/// `;`, `$`, `%`, quotes and brackets could make a value from chat run a +/// command of its own. So only letters, digits, spaces and `_ . , : # + = +/// / -` survive; `..` can't walk up a path, and a value can't start with +/// `-` or `/` (an option) unless it is a negative number. +pub fn program_arg(value: &str) -> String { + let mut out: String = value + .chars() + .filter_map(|c| match c { + c if c.is_alphanumeric() => Some(c), + '_' | '.' | ',' | ':' | '#' | '+' | '=' | '/' | '-' => Some(c), + c if c.is_whitespace() => Some(' '), + _ => None, + }) + .collect(); + while out.contains("..") { + out = out.replace("..", "."); + } + loop { + let negative_number = + out.starts_with('-') && out[1..].starts_with(|c: char| c.is_ascii_digit()); + if negative_number || !out.starts_with(['-', '/']) { + return out; + } + out.remove(0); + } +} + /// Every variable name a template uses, in order, without repeats. pub fn names(template: &str) -> Vec { let mut found: Vec = Vec::new(); @@ -247,6 +277,20 @@ fn random_choice(input: &str) -> String { mod tests { use super::*; + #[test] + fn a_program_argument_from_chat_cannot_run_a_command() { + assert_eq!(program_arg("a&calc"), "acalc"); + assert_eq!(program_arg("x\" & calc & \""), "x calc "); + assert_eq!(program_arg("$(Start-Process calc)"), "Start-Process calc"); + assert_eq!(program_arg("%USERPROFILE%|more"), "USERPROFILEmore"); + assert_eq!(program_arg("--config=evil"), "config=evil"); + assert_eq!(program_arg("/s /t 0"), "s /t 0"); + assert_eq!(program_arg("../../secret"), "././secret"); + assert_eq!(program_arg("-5"), "-5", "a negative number stays"); + assert_eq!(program_arg("Café 2 + 2 = 4"), "Café 2 + 2 = 4"); + assert_eq!(program_arg("line\nbreak"), "line break"); + } + #[test] fn edit_text_shapes_answers() { assert_eq!( diff --git a/src/integrations/timeline.rs b/src/integrations/timeline.rs new file mode 100644 index 0000000..344718a --- /dev/null +++ b/src/integrations/timeline.rs @@ -0,0 +1,246 @@ +//! This stream's timeline: what happened and when, as a position in the +//! stream (the VOD), not a clock time. Integrations add lines with the +//! Timeline step; `{timeline}` and `{chapters}` read it back. +//! +//! In memory only, and cleared when a stream starts: it describes one +//! stream, and a restart mid-stream simply starts a shorter one. + +use super::host::fmt_clock; + +/// Lines kept per stream. A 12-hour stream with a line a minute fits. +const MAX_ENTRIES: usize = 800; +/// YouTube ignores chapters shorter than this. +const MIN_CHAPTER_MS: u64 = 10_000; +/// YouTube only turns a description's timestamps into chapters when there +/// are at least this many. +const MIN_CHAPTERS: usize = 3; +/// What `{chapters}` calls the stream's opening, which YouTube requires. +const FIRST_CHAPTER: &str = "Start"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Note, + Chapter, + Highlight, +} + +impl Kind { + pub fn from_id(id: &str) -> Kind { + match id { + "chapter" => Kind::Chapter, + "highlight" => Kind::Highlight, + _ => Kind::Note, + } + } + + pub fn id(self) -> &'static str { + match self { + Kind::Note => "note", + Kind::Chapter => "chapter", + Kind::Highlight => "highlight", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct Entry { + at_ms: u64, + kind: Kind, + text: String, +} + +#[derive(Default)] +pub struct Timeline { + entries: Vec, +} + +impl Timeline { + pub fn clear(&mut self) { + self.entries.clear(); + } + + /// Add a line at `at_ms` into the stream. Returns it as `{timeline}` + /// writes it. Past the cap the oldest plain line goes, so a flood of + /// notes never pushes out the chapters and highlights. + pub fn add(&mut self, at_ms: u64, kind: Kind, text: &str) -> String { + if self.entries.len() >= MAX_ENTRIES { + let oldest = self + .entries + .iter() + .position(|e| e.kind == Kind::Note) + .unwrap_or(0); + self.entries.remove(oldest); + } + let entry = Entry { + at_ms, + kind, + text: one_line(text), + }; + let line = format_line(&entry); + // Lines can arrive out of order (one waited for the delay): keep + // them in stream order. + let at = self.entries.partition_point(|e| e.at_ms <= at_ms); + self.entries.insert(at, entry); + line + } + + pub fn count(&self, kind: Kind) -> usize { + self.entries.iter().filter(|e| e.kind == kind).count() + } + + /// Every line, oldest first, within `max_chars`: when it doesn't fit, + /// the oldest lines give way to a note saying how many. + pub fn render(&self, max_chars: usize) -> String { + let lines: Vec = self.entries.iter().map(format_line).collect(); + let mut kept = 0; + let mut used = 0; + for line in lines.iter().rev() { + let cost = line.chars().count() + 1; + if used + cost > max_chars.saturating_sub(24) { + break; + } + used += cost; + kept += 1; + } + let skipped = lines.len() - kept; + let mut out = Vec::with_capacity(kept + 1); + if skipped > 0 { + out.push(format!("… {skipped} earlier")); + } + out.extend(lines.into_iter().skip(skipped)); + out.join("\n") + } + + /// The chapters, written the way YouTube reads them from a video's + /// description: the first at 0:00, each at least 10 seconds long, at + /// least three of them. Fewer is no chapter list at all: YouTube would + /// ignore it. + pub fn chapters(&self) -> String { + let mut kept: Vec<(u64, &str)> = Vec::new(); + for e in self.entries.iter().filter(|e| e.kind == Kind::Chapter) { + let at = if kept.is_empty() && e.at_ms < MIN_CHAPTER_MS { + 0 + } else { + e.at_ms + }; + match kept.last() { + Some((last, _)) if at < last + MIN_CHAPTER_MS => continue, + _ => kept.push((at, e.text.as_str())), + } + } + if kept.first().is_some_and(|(at, _)| *at != 0) { + kept.insert(0, (0, FIRST_CHAPTER)); + } + if kept.len() < MIN_CHAPTERS { + return String::new(); + } + kept.iter() + .map(|(at, text)| format!("{} {text}", fmt_clock(*at))) + .collect::>() + .join("\n") + } + + /// The chapter before the latest one, or the stream's opening: what a + /// stream goes back to after a "Technical difficulties" chapter. + pub fn previous_chapter(&self) -> String { + let mut chapters = self + .entries + .iter() + .rev() + .filter(|e| e.kind == Kind::Chapter); + chapters.next(); + chapters + .next() + .map_or_else(|| FIRST_CHAPTER.to_string(), |e| e.text.clone()) + } +} + +fn format_line(e: &Entry) -> String { + format!("`{}` {}", fmt_clock(e.at_ms), e.text) +} + +/// A timeline line is one line: chat and web answers can hold newlines. +fn one_line(text: &str) -> String { + let flat: String = text + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + flat.trim().chars().take(300).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_are_kept_in_stream_order() { + let mut t = Timeline::default(); + assert_eq!( + t.add(65_000, Kind::Note, "🔴 OBS crashed"), + "`1:05` 🔴 OBS crashed" + ); + t.add(5_000, Kind::Highlight, "⭐ Clip"); + assert_eq!(t.render(4000), "`0:05` ⭐ Clip\n`1:05` 🔴 OBS crashed"); + assert_eq!(t.count(Kind::Highlight), 1); + } + + #[test] + fn a_long_timeline_drops_its_oldest_lines_first() { + let mut t = Timeline::default(); + for n in 0..100 { + t.add(n * 1000, Kind::Note, &format!("line {n}")); + } + let shown = t.render(200); + assert!(shown.chars().count() <= 200, "{shown}"); + assert!(shown.starts_with("… "), "{shown}"); + assert!(shown.ends_with("line 99")); + } + + #[test] + fn chapters_follow_youtube_rules() { + let mut t = Timeline::default(); + assert_eq!(t.chapters(), ""); + t.add(751_000, Kind::Chapter, "Ranked"); + t.add(755_000, Kind::Chapter, "Too soon"); + t.add(3_850_000, Kind::Chapter, "Boss fight"); + t.add(900_000, Kind::Note, "not a chapter"); + assert_eq!(t.chapters(), "0:00 Start\n12:31 Ranked\n1:04:10 Boss fight"); + } + + #[test] + fn a_chapter_in_the_first_seconds_becomes_the_opening() { + let mut t = Timeline::default(); + t.add(4_000, Kind::Chapter, "Just chatting"); + t.add(60_000, Kind::Chapter, "Game"); + assert_eq!(t.chapters(), "", "two chapters: YouTube ignores them"); + t.add(120_000, Kind::Chapter, "Ranked"); + assert_eq!(t.chapters(), "0:00 Just chatting\n1:00 Game\n2:00 Ranked"); + } + + #[test] + fn a_full_timeline_lets_notes_go_before_chapters() { + let mut t = Timeline::default(); + t.add(0, Kind::Chapter, "Opening"); + for n in 1..MAX_ENTRIES as u64 + 50 { + t.add(n * 1000, Kind::Note, "back"); + } + assert_eq!(t.count(Kind::Chapter), 1); + assert_eq!(t.entries.len(), MAX_ENTRIES); + } + + #[test] + fn after_a_crash_chapter_the_stream_goes_back_to_the_one_before() { + let mut t = Timeline::default(); + assert_eq!(t.previous_chapter(), "Start"); + t.add(60_000, Kind::Chapter, "Ranked"); + t.add(90_000, Kind::Chapter, "Technical difficulties"); + assert_eq!(t.previous_chapter(), "Ranked"); + } + + #[test] + fn lines_stay_on_one_line() { + let mut t = Timeline::default(); + t.add(0, Kind::Note, "a\nb\r\nc"); + assert_eq!(t.render(100), "`0:00` a b c"); + } +} diff --git a/src/integrations/twitch/irc.rs b/src/integrations/twitch/irc.rs index 5f03555..ac44172 100644 --- a/src/integrations/twitch/irc.rs +++ b/src/integrations/twitch/irc.rs @@ -38,6 +38,9 @@ pub struct ChatMessage { pub is_mod: bool, pub is_vip: bool, pub is_sub: bool, + /// Sent in a partner channel during a Shared Chat session: Twitch + /// shows it here too, tagged with the room it came from. + pub from_shared_chat: bool, } /// A line to post, optionally as a reply. `delivery` hears what became of @@ -368,6 +371,10 @@ fn chat_message(line: &Line) -> Option { is_mod: line.tag("mod") == "1" || has_badge("moderator"), is_vip: has_badge("vip") || line.tag("vip") == "1", is_sub: line.tag("subscriber") == "1" || has_badge("subscriber") || has_badge("founder"), + from_shared_chat: { + let source = line.tag("source-room-id"); + !source.is_empty() && source != line.tag("room-id") + }, }) } @@ -389,6 +396,17 @@ mod tests { assert_eq!(msg.id, "b34ccfc7-4977-403a-8a94-33c6bac34fb8"); } + #[test] + fn notices_messages_from_a_shared_chat_partner() { + let partner = "@badges=;display-name=Bo;room-id=111;source-room-id=222 :bo!bo@bo.tmi.twitch.tv PRIVMSG #texaz :!delay"; + assert!(chat_message(&parse_line(partner)).unwrap().from_shared_chat); + let ours = "@badges=;display-name=Al;room-id=111;source-room-id=111 :al!al@al.tmi.twitch.tv PRIVMSG #texaz :!delay"; + assert!(!chat_message(&parse_line(ours)).unwrap().from_shared_chat); + let plain = + "@badges=;display-name=Al;room-id=111 :al!al@al.tmi.twitch.tv PRIVMSG #texaz :!delay"; + assert!(!chat_message(&parse_line(plain)).unwrap().from_shared_chat); + } + #[test] fn reads_me_actions_and_broadcaster_badges() { let raw = "@badges=broadcaster/1;display-name=Texaz :texaz!texaz@texaz.tmi.twitch.tv PRIVMSG #texaz :\u{1}ACTION waves\u{1}"; diff --git a/src/integrations/twitch/mod.rs b/src/integrations/twitch/mod.rs index ad0e6d0..bdccafb 100644 --- a/src/integrations/twitch/mod.rs +++ b/src/integrations/twitch/mod.rs @@ -203,6 +203,13 @@ impl Twitch { } } + /// The logged-in main account's Twitch user id, empty when logged out. + pub fn main_user_id(&self) -> String { + self.account(Which::Main) + .map(|a| a.user_id) + .unwrap_or_default() + } + fn account(&self, which: Which) -> Option { let accounts = self.store.accounts.lock(); match which { diff --git a/src/main.rs b/src/main.rs index 8ee71f6..d95a077 100644 --- a/src/main.rs +++ b/src/main.rs @@ -697,6 +697,9 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc>), > = std::collections::HashMap::new(); let mut vertical_wait = VerticalWaitLog::default(); + // Destination id -> when its running pump first found no 9:16 canvas. + let mut vertical_gone_since: std::collections::HashMap = + std::collections::HashMap::new(); // Mirror the ingest key into the controller on every settings change // (the ingest task enforces the key but has no settings handle). @@ -769,6 +772,7 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc, ctrl: Arc= VERTICAL_WAIT_GRACE && !ctrl.hold_active(); + if settled { + if let Some((_signature, handle)) = running.remove(&dest.id) { + stop_pump(&ctrl, &dest.id, handle).await; + ctrl.log(format!( + "[{}] vertical: the 9:16 canvas stopped - holding off connecting", + dest.name + )); + } } // Say so once per OBS session, and only once video is // flowing: before the first sequence header there is no way @@ -818,6 +832,7 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc, ctrl: Arc { - (running_signature != &signature && !ctrl.hold_active()) || handle.is_finished() + (running_signature != &signature && settled) || handle.is_finished() } None => true, }; diff --git a/src/midi.rs b/src/midi.rs index 5f90d37..09a10d8 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -901,6 +901,8 @@ mod tests { trigger: Trigger::Shortcut { hotkey: String::new(), midi: "note:1:40".into(), + token: String::new(), + only_live: false, }, steps: vec![Step::new(StepKind::Clip, &[])], }]; diff --git a/src/obs_register.rs b/src/obs_register.rs index 08964c6..1bc913a 100644 --- a/src/obs_register.rs +++ b/src/obs_register.rs @@ -30,7 +30,7 @@ use std::path::{Path, PathBuf}; /// Candidate OBS config directories for this platform, in priority order. /// Both `services.json` and the log folder are resolved relative to these, /// so one list drives every OBS-path lookup. -fn obs_config_dirs() -> Vec { +pub(crate) fn obs_config_dirs() -> Vec { #[cfg(windows)] { ["APPDATA", "LOCALAPPDATA"] @@ -887,16 +887,56 @@ pub fn active_profile_service_json_path() -> Option { /// what OBS streams with when Enhanced Broadcasting is off. None when OBS, /// the profile, or a usable bitrate can't be read. pub fn active_stream_settings() -> Option { - let dir = active_profile_dir()?; - let profile = obs_config_dirs() - .into_iter() - .map(|obs_dir| obs_dir.join(PROFILES_DIR_REL).join(&dir)) - .find(|p| p.join("basic.ini").exists())?; + let profile = active_profile_path()?; let basic = fs::read_to_string(profile.join("basic.ini")).ok()?; let encoder = fs::read_to_string(profile.join("streamEncoder.json")).ok(); stream_settings_from(&basic, encoder.as_deref()) } +/// The active profile's folder, the first config dir that holds its basic.ini. +fn active_profile_path() -> Option { + let dir = active_profile_dir()?; + obs_config_dirs() + .into_iter() + .map(|obs_dir| obs_dir.join(PROFILES_DIR_REL).join(&dir)) + .find(|p| p.join("basic.ini").exists()) +} + +/// True when the active profile's SAVED settings ask for Enhanced +/// Broadcasting on a service that can deliver it. Saved is not applied: +/// OBS rebuilds its stream output only while nothing is running, so a +/// Stream settings change made while the Replay Buffer or a recording runs +/// is written to disk but the next stream still goes out on the old setup. +/// The dashboard compares this against what actually arrives. +pub fn active_profile_wants_eb() -> bool { + let Some(profile) = active_profile_path() else { + return false; + }; + let (Ok(basic), Ok(service)) = ( + fs::read_to_string(profile.join("basic.ini")), + fs::read_to_string(profile.join("service.json")), + ) else { + return false; + }; + profile_wants_eb(&basic, &service) +} + +/// See `active_profile_wants_eb`. Only a Common service (the registered +/// InstantClone entry) carries the config URL to OBS: Custom RTMP drops the +/// key on load (see Phase C below), so a leftover URL there never enables EB +/// and must not count. +fn profile_wants_eb(basic_ini: &str, service_json: &str) -> bool { + if ini_get(basic_ini, "Stream1", "EnableMultitrackVideo") != Some("true") { + return false; + } + let compact: String = service_json + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + compact.contains(r#""type":"rtmp_common""#) + && compact.contains(r#""multitrack_video_configuration_url":"http"#) +} + /// Parse a profile's `basic.ini` and `streamEncoder.json`. Advanced output /// names the encoder in `[AdvOut] Encoder` and keeps its settings in /// `streamEncoder.json`; Simple output keeps only `[SimpleOutput] VBitrate` @@ -2109,6 +2149,40 @@ mod tests { assert_eq!(own.encoder.unwrap().settings_json, AMD_STREAM_ENCODER); } + const EB_ON_INI: &str = + "\u{feff}[General]\nName=Main\n\n[Stream1]\nEnableMultitrackVideo=true\n"; + const INSTANTCLONE_SERVICE: &str = r#"{"type":"rtmp_common","settings":{"service":"InstantClone","server":"rtmp://localhost:1935/live","multitrack_video_configuration_url":"http://127.0.0.1:7799/obs/multitrack-config"}}"#; + + #[test] + fn eb_ticked_on_the_instantclone_service_is_wanted() { + assert!(profile_wants_eb(EB_ON_INI, INSTANTCLONE_SERVICE)); + let spaced = INSTANTCLONE_SERVICE.replace("\":", "\": "); + assert!( + profile_wants_eb(EB_ON_INI, &spaced), + "JSON spacing is not meaningful" + ); + } + + #[test] + fn eb_unticked_or_unset_is_not_wanted() { + let off = EB_ON_INI.replace("=true", "=false"); + assert!(!profile_wants_eb(&off, INSTANTCLONE_SERVICE)); + assert!(!profile_wants_eb("[Stream1]\n", INSTANTCLONE_SERVICE)); + } + + /// Custom RTMP drops the config URL on load, so a leftover one there + /// can't turn EB on, and warning that EB "didn't apply" would be wrong. + #[test] + fn a_service_that_cannot_deliver_eb_is_not_wanted() { + let custom = INSTANTCLONE_SERVICE.replace("rtmp_common", "rtmp_custom"); + assert!(!profile_wants_eb(EB_ON_INI, &custom)); + let no_url = r#"{"type":"rtmp_common","settings":{"service":"InstantClone"}}"#; + assert!(!profile_wants_eb(EB_ON_INI, no_url)); + let empty_url = + INSTANTCLONE_SERVICE.replace("http://127.0.0.1:7799/obs/multitrack-config", ""); + assert!(!profile_wants_eb(EB_ON_INI, &empty_url)); + } + #[test] fn ini_get_reads_value_from_correct_section() { let ini = diff --git a/src/web.rs b/src/web.rs index 002ae1f..68cd19c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -403,8 +403,16 @@ async fn serve( return Ok(()); } + // Programs and file writes run on this PC: with no password, only the + // PC itself may set them up (see `integrations::api::route`). + let local_steps_allowed = + is_loopback(&peer_ip) || !settings.borrow().dashboard_password_hash.is_empty(); + let caller = RouteCaller { + is_admin, + local_steps_allowed, + }; let (status, ctype, payload) = route( - method, path, body, &ctrl, &settings, &cfg_path, &sysstat, is_admin, + method, path, body, &ctrl, &settings, &cfg_path, &sysstat, caller, ) .await; @@ -782,8 +790,17 @@ async fn auth_gate( }) } +/// Who is asking, as far as `route` needs to know. +#[derive(Clone, Copy)] +struct RouteCaller { + is_admin: bool, + /// The request may set up integration steps that run programs or + /// write files: it comes from this PC, or the dashboard has a password. + local_steps_allowed: bool, +} + // The central request dispatcher genuinely needs all of these: the request -// parts, the shared runtime handles, and the caller's admin flag. Bundling them +// parts, the shared runtime handles, and who the caller is. Bundling them // into a struct would only move the argument list, not remove it. #[allow(clippy::too_many_arguments)] async fn route( @@ -794,8 +811,9 @@ async fn route( settings: &Arc>, cfg_path: &Path, sysstat: &Arc, - is_admin: bool, + caller: RouteCaller, ) -> (&'static str, &'static str, String) { + let is_admin = caller.is_admin; // Strip ?query - we only read it for /overlay. let (bare_path, query) = match path.split_once('?') { Some((p, q)) => (p, q), @@ -848,8 +866,16 @@ async fn route( || bare_path.starts_with("/connections/") || bare_path.starts_with("/twitch/") { - if let Some(reply) = - crate::integrations::api::route(method, bare_path, body, ctrl, settings, cfg_path).await + if let Some(reply) = crate::integrations::api::route( + method, + bare_path, + body, + ctrl, + settings, + cfg_path, + caller.local_steps_allowed, + ) + .await { return reply; } @@ -1196,7 +1222,14 @@ async fn route( ("POST", "/cut-after") => post_cut_after(ctrl, settings, sysstat).await, ("POST", "/cut-after/cancel") => post_cut_after_cancel(ctrl, settings, sysstat).await, ("POST", "/crash-protection/end") => { - ctrl.end_hold_now(); + if !ctrl.end_hold_now() { + return ( + "409 Conflict", + "application/json", + r#"{"ok":false,"error":"Nothing to end: the stream already ended"}"# + .to_string(), + ); + } ( "200 OK", "application/json", @@ -1403,6 +1436,7 @@ fn hold_json(ctrl: &Controller) -> String { match status.reason { crate::crash_hold::HoldReason::Crash => "crash", crate::crash_hold::HoldReason::Freeze => "freeze", + crate::crash_hold::HoldReason::Stopped => "stopped", }, status.remaining.as_millis(), status.total.as_millis() @@ -1463,7 +1497,14 @@ fn state_json( // measured params are stale or zero, and a warning about a session // that already ended is pure noise. let compat_warning = if ctrl.ingest_alive() { - crate::compat::compat_warning(&ctrl.stream_params(), &s.destinations).unwrap_or_default() + [ + crate::compat::compat_warning(&ctrl.stream_params(), &s.destinations), + eb_not_applied_warning(ctrl), + ] + .into_iter() + .flatten() + .collect::>() + .join(" · ") } else { String::new() }; @@ -1473,11 +1514,12 @@ fn state_json( let vertical_present = ctrl.ingest_alive() && ctrl.vertical_track_on_wire().is_some(); format!( - r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"buffer_capacity_ms_est":{bc},"ingest_alive":{ia},"egress_alive":{ea},"destinations_alive":{dla},"destinations_total":{dlt},"buffer_building":{bb},"configured":{cfg},"obs_url":"{ou}","webhook_set":{ws},"video_codec":"{vc}","audio_codec":"{ac}","multitrack_video":{mtv},"multitrack_audio":{mta},"vertical_present":{vp},"cpu_pct":{cp:.2},"rss_bytes":{rb},"uptime_secs":{up},"publisher_token":{pt},"consumer_lag":{cl},"backpressure":{bp},"safe_cut_pending":{scp},"safe_cut_remaining_ms":{scr},"hold":{hold},"compat_warning":{cw},"hotkey_conflicts":[{hkc}],"last_action":{la},"stats":{{"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"ingest_disconnects":{id},"egress_reconnects":{er},"bitrate_kbps":{br}}},"destinations":[{dl}]}}"#, + r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"buffer_capacity_ms_est":{bc},"ingest_alive":{ia},"egress_alive":{ea},"destinations_alive":{dla},"destinations_total":{dlt},"buffer_building":{bb},"configured":{cfg},"obs_url":"{ou}","webhook_set":{ws},"video_codec":"{vc}","audio_codec":"{ac}","multitrack_video":{mtv},"multitrack_audio":{mta},"vertical_present":{vp},"cpu_pct":{cp:.2},"rss_bytes":{rb},"uptime_secs":{up},"publisher_token":{pt},"consumer_lag":{cl},"backpressure":{bp},"safe_cut_pending":{scp},"safe_cut_remaining_ms":{scr},"hold":{hold},"tail_ms":{tail},"compat_warning":{cw},"hotkey_conflicts":[{hkc}],"last_action":{la},"stats":{{"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"ingest_disconnects":{id},"egress_reconnects":{er},"bitrate_kbps":{br},"video_kbps":{vbr}}},"destinations":[{dl}]}}"#, ph = ctrl.phase(), scp = ctrl.safe_cut_pending(), scr = ctrl.safe_cut_remaining_ms(), hold = hold_json(ctrl), + tail = ctrl.tail_left().map_or(0, |left| left.as_millis()), cw = json_escape_quoted(&compat_warning), la = ctrl .last_action() @@ -1521,6 +1563,7 @@ fn state_json( id = ctrl.ingest_disconnects(), er = ctrl.egress_reconnects(), br = ctrl.bitrate_kbps(), + vbr = ctrl.video_bitrate_kbps(), vc = ctrl.video_codec().label(), ac = ctrl.audio_codec().label(), mtv = ctrl.multitrack_video(), @@ -1536,6 +1579,44 @@ fn state_json( ) } +/// Shown when OBS's saved profile asks for Enhanced Broadcasting but the +/// stream arriving is single-track. OBS rebuilds its stream output only +/// while nothing is running, so a Stream settings change applied while the +/// Replay Buffer or a recording runs never reaches the next stream. +const EB_NOT_APPLIED: &str = "OBS has Enhanced Broadcasting on but is not using it. \ + Stop the stream, Replay Buffer and recording (or restart OBS), then go live again."; + +/// How long one read of OBS's saved profile is reused. `/state` is polled +/// several times a second; the setting changes at human speed. +const OBS_PROFILE_TTL: Duration = Duration::from_secs(10); + +fn eb_not_applied_warning(ctrl: &Controller) -> Option { + // A measured keyframe interval means a few seconds of video arrived, + // and an EB stream marks its very first video tag as multi-track, so + // a stream that is just starting never trips this. A remote publisher + // runs some other machine's OBS, whose profile isn't the one here. + if ctrl.multitrack_video() || ctrl.keyframe_interval_ms() == 0 || !ctrl.publisher_local() { + return None; + } + obs_profile_wants_eb().then(|| EB_NOT_APPLIED.to_string()) +} + +/// `obs_register::active_profile_wants_eb`, re-read at most once per +/// `OBS_PROFILE_TTL`. +fn obs_profile_wants_eb() -> bool { + static CACHE: crate::sync::Mutex> = + crate::sync::Mutex::new(None); + let mut cache = CACHE.lock(); + if let Some((read_at, wants)) = *cache { + if read_at.elapsed() < OBS_PROFILE_TTL { + return wants; + } + } + let wants = crate::obs_register::active_profile_wants_eb(); + *cache = Some((std::time::Instant::now(), wants)); + wants +} + /// The state an overlay is allowed to see: the numbers it paints, and /// nothing else. /// @@ -4337,7 +4418,6 @@ fn apply_field_str(s: &mut Settings, key: &str, value: &str) { } "buffer_path" => s.buffer_path = std::path::PathBuf::from(value), "overlays_dir" => s.overlays_dir = std::path::PathBuf::from(value), - "discord_webhook_url" => s.discord_webhook_url = value.into(), "tracing_enabled" => { // Form encoding: checkbox sends "true"/"false" or "on"/"" - // treat anything non-empty-non-false as truthy. @@ -5917,6 +5997,8 @@ mod tests { clip.handlers[0].trigger = Trigger::Shortcut { hotkey: "Ctrl+Alt+K".into(), midi: String::new(), + token: String::new(), + only_live: false, }; s.integrations.push(clip); let form = config::parse_form("hotkey.cut=ctrl%2Balt%2Bk"); @@ -5929,6 +6011,8 @@ mod tests { s.integrations[0].handlers[0].trigger = Trigger::Shortcut { hotkey: String::new(), midi: "note:1:36".into(), + token: String::new(), + only_live: false, }; apply_field_str(&mut s, "midi.arm", "note:1:36@Deck A"); let form = config::parse_form("midi.arm=note%3A1%3A36%40Deck%20A"); diff --git a/web/dock.html b/web/dock.html index 576afc4..2561a8b 100644 --- a/web/dock.html +++ b/web/dock.html @@ -472,7 +472,7 @@
diff --git a/web/dock.js b/web/dock.js index ed1eb8e..61294b9 100644 --- a/web/dock.js +++ b/web/dock.js @@ -298,7 +298,8 @@ async function endHold() { } disarmEndHold(); const r = await fetchJ('/crash-protection/end', { method: 'POST' }); - toast(r.ok ? 'Stream ended on every destination' : 'Could not end the stream', r.ok ? 'ok' : 'err'); + 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'); tick(); } function disarmEndHold() { @@ -482,9 +483,16 @@ function paintEgress() { // Crash protection: the reconnect screen is holding the destinations. function paintHold(hold) { - setHidden($('w-hold'), !hold); + const tail = !hold && s.tail_ms > 0; + setHidden($('w-hold'), !hold && !tail); + if (tail) { + setText($('hold-why'), 'Delay airing'); + setText($('hold-clock'), fmtClock(s.tail_ms)); + setText($('hold-sub'), 'OBS stopped. Ends when viewers reach the end'); + return; + } if (!hold) { disarmEndHold(); return; } - setText($('hold-why'), hold.reason === 'freeze' ? 'OBS froze' : 'OBS dropped'); + setText($('hold-why'), hold.reason === 'freeze' ? 'OBS froze' : hold.reason === 'stopped' ? 'OBS stopped' : 'OBS dropped'); setText($('hold-clock'), fmtClock(hold.remaining_ms)); const held = (s.destinations || []).filter(d => d.on_hold).length; setText($('hold-sub'), held @@ -494,7 +502,8 @@ function paintHold(hold) { // Hint text: full sentences, or errors-only (encoder offline) if opted. function paintTip(ds, hold) { - const tip = hold ? 'Start streaming in OBS to resume.' + const tip = hold ? (hold.reason === 'crash' ? 'Reopen OBS (Run 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.` @@ -513,6 +522,7 @@ function applyState(j) { if (pending && pending === ds) { pending = null; clearTimeout(pendingTimer); } // 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); @@ -737,14 +747,13 @@ function renderStats() { // 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, webhook_set: false }; +let srvCfg = { auto_arm_on_connect: false, auto_activate_when_ready: false, tracing_enabled: true }; async function fetchServerCfg() { const r = await fetchJ('/config'); 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; - srvCfg.webhook_set = !!r.j.webhook_set; if (typeof r.j.buffer_mb === 'number') { bufferMB = r.j.buffer_mb; renderProfiles(); } renderBehavior(); renderSettings(); } @@ -779,41 +788,24 @@ function renderSettings() { const box = $('w-settings'); if (box.hidden) return; box.innerHTML = ''; box.appendChild(toggleRow('Wire trace log', 'tracing_enabled')); - const wh = document.createElement('div'); wh.className = 'brow'; - const lb = document.createElement('span'); lb.className = 'blbl'; lb.textContent = srvCfg.webhook_set ? 'Discord webhook set' : 'No Discord webhook'; + // 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'; + const lb = document.createElement('span'); lb.className = 'blbl'; lb.textContent = 'Discord, chat and phone alerts'; const btns = document.createElement('span'); btns.className = 'srow-btns'; - if (srvCfg.webhook_set) { - const test = document.createElement('button'); test.className = 'minibtn'; test.textContent = 'Test'; test.onclick = testWebhook; - const clr = document.createElement('button'); clr.className = 'minibtn'; clr.textContent = 'Clear'; clr.onclick = clearWebhook; - btns.append(test, clr); - } else { - // Setting a webhook means pasting a secret URL - awkward on a dock, so - // "Set" bounces to the dashboard (behind a confirm). - const set = document.createElement('button'); set.className = 'minibtn'; set.textContent = 'Set'; set.onclick = () => confirmOpenDashboard(btns); - btns.append(set); - } - wh.append(lb, btns); box.appendChild(wh); + const open = document.createElement('button'); open.className = 'minibtn'; open.textContent = 'Set up'; open.onclick = () => confirmOpenDashboard(btns); + btns.append(open); + ig.append(lb, btns); box.appendChild(ig); } function confirmOpenDashboard(btns) { btns.innerHTML = ''; const q = document.createElement('span'); q.className = 'cq'; q.textContent = 'Open dashboard?'; - q.title = 'This opens the InstantClone dashboard in a new tab so you can paste the webhook URL.'; + q.title = 'Opens the Integrations tab of the InstantClone dashboard in a new tab.'; const yes = document.createElement('button'); yes.className = 'minibtn'; yes.textContent = 'Yes'; - yes.onclick = () => { window.open(location.origin + '/', '_blank'); toast('Opening dashboard…', 'ok'); renderSettings(); }; + 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); } -async function testWebhook() { - const r = await fetchJ('/test-webhook', { method: 'POST' }); - // The endpoint answers 200 even when Discord rejects, so trust the JSON ok. - const ok = r.ok && (!r.j || r.j.ok !== false); - toast(ok ? 'Test event sent' : (r.j?.error || 'Test failed'), ok ? 'ok' : 'err'); -} -async function clearWebhook() { - const r = await fetchJ('/config', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'webhook_clear=1' }); - if (r.ok) { srvCfg.webhook_set = false; renderSettings(); toast('Webhook cleared', 'ok'); } - else toast('Failed', 'err'); -} // Overlays: list the OBS browser-source overlays and copy their URLs, so a // dock can double as an overlay picker. diff --git a/web/index.html b/web/index.html index 18ce229..b7dda18 100644 --- a/web/index.html +++ b/web/index.html @@ -2231,7 +2231,7 @@ /* Grid. */ .ig-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px} .ig-card{padding:8px;gap:8px;cursor:default} -.ig-card .dcard-screen{display:flex;align-items:center;height:96px;padding:10px;border-radius:6px;cursor:pointer; +.ig-card .dcard-screen{display:flex;align-items:flex-start;height:96px;overflow:hidden;padding:10px;border-radius:6px;cursor:pointer; transition:border-color .15s var(--ease-out)} .ig-card .dcard-screen:hover{border-color:color-mix(in oklch,var(--dc,var(--accent)) 45%,var(--line))} .ig-card .dcard-screen:focus-visible{outline:2px solid var(--accent);outline-offset:2px} @@ -2352,7 +2352,7 @@ display:grid;place-items:center;color:#051018} .ig-pv-discord .ig-av svg{width:11px;height:11px} .ig-pv-discord b{color:#f2f3f5;font-weight:600} -.ig-pv-discord .ig-app{padding:0 3px;margin-left:4px;border-radius:3px;background:#5865f2;color:#fff;font-size:8.5px;font-weight:700;vertical-align:1px} +.ig-pv-discord .ig-app{padding:0 3px;margin-left:4px;border-radius:3px;background:#5865f2;color:#fff;font-size:10px;font-weight:500;vertical-align:1px} .ig-pv-discord .ig-txt{color:#dbdee1;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word} .ig-pv-chat{display:flex;flex-direction:column;gap:3px;padding:8px 9px;border-radius:8px;background:#18181b;color:#efeff1} .ig-pv-chat div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -2401,7 +2401,7 @@ .ig-live-col{flex:1;min-width:0;font-size:14.5px} .ig-live-meta{display:flex;align-items:center;gap:6px;margin-bottom:6px} .ig-live-meta b{color:#f2f3f5} -.ig-live-meta .ig-app{padding:0 4px;border-radius:3px;background:#5865f2;color:#fff;font-size:10px;font-weight:700} +.ig-live-meta .ig-app{padding:0 4px;border-radius:3px;background:#5865f2;color:#fff;font-size:10px;font-weight:500} .ig-ping{padding:0 4px;border-radius:3px;background:rgba(88,101,242,.3);color:#c9cdfb;font-size:12px;animation:igPop .26s cubic-bezier(.2,0,0,1)} .ig-live-chat{background:#18181b;padding:14px 16px;color:#efeff1;font-size:14px;display:flex;flex-direction:column;gap:8px} .ig-live-chat .u1{color:#ff7eb6}.ig-live-chat .u2{color:#5ac8fa} @@ -2409,11 +2409,13 @@ .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} -.ig-token{display:inline-flex;align-items:baseline;gap:4px;padding:1px 8px;border-radius:999px;cursor:pointer; - border:1px solid color-mix(in oklch,var(--accent) 40%,transparent);background:transparent; - color:color-mix(in oklch,var(--accent) 70%,#fff);font:500 11px/1.6 'JetBrains Mono',monospace; - transition:background-color .15s var(--ease-out),transform .15s var(--ease-out)} -.ig-token:hover{background:color-mix(in oklch,var(--accent) 14%,transparent)} +.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)} @@ -2465,7 +2467,7 @@ transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out),transform .15s var(--ease-out)} .ig-chip:hover{border-color:var(--line-2)} .ig-chip:active{transform:scale(.96)} -.ig-chip span{color:var(--fg-3)} +.ig-chip span{color:var(--fg-2)} .ig-chip b{font-weight:600;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .ig-chip svg{width:11px;height:11px;color:var(--fg-3);transition:transform .25s var(--ease-out),color .15s var(--ease-out)} .ig-chip.warn{border-color:color-mix(in oklch,var(--warn) 55%,transparent);background:color-mix(in oklch,var(--warn) 9%,var(--surface-3))} @@ -2524,13 +2526,19 @@ .ig-cat-grid>*{animation:igTileIn .32s var(--ease-out) both;animation-delay:calc(min(var(--i,0),10) * 26ms)} @keyframes igTileIn{from{opacity:0;transform:translateY(8px) scale(.98);filter:blur(3px)}to{opacity:1;transform:none;filter:blur(0)}} .ig-cat-tile{padding:8px;gap:10px} -.ig-cat-tile .dcard-screen{min-height:84px;padding:10px;border-radius:6px;display:flex;align-items:center} +.ig-cat-tile .dcard-screen{height:128px;padding:10px;border-radius:6px;display:flex;align-items:flex-start;overflow:hidden; + -webkit-mask-image:linear-gradient(#000 78%,transparent);mask-image:linear-gradient(#000 78%,transparent)} .ig-cat-id{display:flex;gap:10px;align-items:flex-start;padding:0 4px} .ig-cat-id .dcard-icon{width:28px;height:28px;border-radius:8px} .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:4px;font-size:11px;color:var(--warn)} +.ig-cat-needs{margin-top:6px;display:flex;flex-wrap:wrap;gap:4px} +.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)} +.ig-need.miss{color:var(--warn);border-color:color-mix(in oklch,var(--warn) 40%,transparent)} +.ig-need svg{width:10px;height:10px;flex:none} .ig-cat-tile>.ic-btn{margin:auto 4px 4px} /* A confirm label ("Click again: …") is longer than the button it replaces: it wraps inside its row instead of pushing out of it. */ @@ -2551,8 +2559,10 @@ font-size:12.5px;line-height:1.5;color:var(--fg-2);text-wrap:pretty} .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; - letter-spacing:.02em;vertical-align:2px;color:var(--warn);cursor:help; + 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 not as a wall of solid buttons. It fills on hover. */ @@ -2583,7 +2593,7 @@ .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)} +.ig-canvas{flex:1;min-width:0;padding:16px 20px;overflow-y:auto;background:var(--bg);container-type:inline-size} .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; @@ -2600,7 +2610,9 @@ 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)} -.ig-step:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.ig-step:focus-visible,.ig-step:has(.ig-step-main:focus-visible){outline:2px solid var(--accent);outline-offset:2px} +.ig-step-main{flex:1;min-width:0;display:flex;align-items:center;gap:12px;padding:0;border:0;background:none;color:inherit; + font:inherit;text-align:left;cursor:pointer;outline:none} .ig-step.on{border-color:color-mix(in oklch,var(--accent) 60%,transparent); 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} @@ -2612,6 +2624,15 @@ .ig-step:hover .ig-step-tools,.ig-step.on .ig-step-tools,.ig-step:focus-within .ig-step-tools{opacity:1} .ig-step-tools svg{width:13px;height:13px} .ig-step-tools .ic-btn-tiny:disabled{opacity:.3;cursor:default} +/* A narrow canvas (inspector open on a small window, a phone): the tools + would squeeze the step's text to nothing, so only the picked step shows + 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} @@ -2777,8 +2798,15 @@ .ig-ok{width:20px;height:20px;flex:none;border-radius:50%;display:grid;place-items:center; background:color-mix(in oklch,var(--live) 18%,transparent);color:var(--live)} .ig-ok svg{width:12px;height:12px;animation:igPop .3s cubic-bezier(.2,0,0,1)} -.ig-recipe-item{padding-left:28px;font-size:13px;color:var(--fg-2)} +.ig-recipe-item{padding-left:28px;font-size:13px;color:var(--fg-2);display:flex;flex-direction:column;gap:4px} .ig-recipe-item b{color:var(--fg)} +.ig-rwhen{font-size:12px} +.ig-rwho{color:var(--fg-3)} +.ig-rflags{display:flex;flex-wrap:wrap;gap:6px} +.ig-rflag{font-size:11.5px;padding:2px 8px;border-radius:999px;background:var(--surface-3);color:var(--fg-2)} +.ig-rflag.caution{background:color-mix(in oklch,var(--warn) 16%,transparent);color:var(--warn)} +.ig-rflag.danger{background:color-mix(in oklch,var(--err) 16%,transparent);color:var(--err);font-weight:600} +.ig-palette button.ig-pal-locked{opacity:.45;cursor:not-allowed} .ig-recipe-safe{display:flex;align-items:flex-start;gap:8px} .ig-recipe-safe svg{width:14px;height:14px;flex:none;margin-top:2px;color:var(--accent)} .ig-share-list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow:auto} @@ -2905,6 +2933,145 @@ .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} +/* 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} +.ig-embed-title{color:#f2f3f5;font-weight:600;font-size:12px;line-height:1.3;overflow-wrap:anywhere} +.ig-embed-title.link{color:#00a8fc} +.ig-embed-desc{font-size:11.5px;line-height:1.4;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} +.ig-embed code{padding:0 .25em;border-radius:3px;background:#1e1f22;font:85% 'JetBrains Mono',monospace} +.ig-embed pre{margin:4px 0;padding:6px 8px;border-radius:4px;background:#1e1f22;border:1px solid #1a1b1e; + font:11px/1.45 'JetBrains Mono',monospace;color:#dbdee1;white-space:pre-wrap} +.ig-link{color:#00a8fc} +.ig-txt .ig-link,.ig-embed-desc .ig-link,.ig-embed-fields .ig-link{display:inline-block;max-width:100%;white-space:nowrap;overflow:hidden; + text-overflow:ellipsis;vertical-align:bottom} +.ig-embed-fields{display:none} +.ig-embed-foot{display:none} +.ig-embed.big{gap:8px;padding:8px 16px 16px 12px} +.ig-embed.big .ig-embed-title{font-size:16px;line-height:22px} +.ig-embed.big .ig-embed-desc{font-size:14px;line-height:1.375;-webkit-line-clamp:unset;display:block} +.ig-embed.big .ig-embed-fields{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:8px 16px} +.ig-embed-fields>div{grid-column:1/-1;display:flex;flex-direction:column;gap:2px;min-width:0;font-size:14px} +.ig-embed-fields>div.in{grid-column:span var(--span,4)} +.ig-embed-fields b{color:#f2f3f5;font-size:14px;font-weight:600} +.ig-embed-fields span{overflow-wrap:anywhere} +.ig-embed.big .ig-embed-foot{display:flex;align-items:center;gap:8px;font-size:12px;color:#b5bac1} +.ig-embed-foot i{width:20px;height:20px;flex:none;border-radius:50%;display:grid;place-items:center;background:var(--accent);color:#051018} +.ig-embed-foot i svg{width:11px;height:11px} +/* The card you write on: same card, every part a field. */ +.ig-above{width:100%;box-sizing:border-box;margin:0 0 6px;padding:5px 8px;border-radius:6px;border:1px dashed transparent; + background:transparent;color:#dbdee1;font:inherit;font-size:14px;outline:none;transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out)} +.ig-above::placeholder,.ig-embed.edit input::placeholder,.ig-embed.edit .ig-msg::placeholder{color:#949ba4} +.ig-above:hover,.ig-above:focus{border-color:color-mix(in oklch,var(--accent) 55%,transparent);background:color-mix(in oklch,var(--accent) 5%,transparent)} +.ig-above:focus-visible,.ig-embed.edit input:focus-visible{border-style:solid;border-color:var(--ec,var(--accent)); + box-shadow:0 0 0 2px color-mix(in oklch,var(--ec,var(--accent)) 35%,transparent)} +.ig-embed.edit{max-width:520px} +.ig-embed.edit input{min-width:0;box-sizing:border-box;padding:4px 6px;margin:0;border-radius:5px;border:1px dashed transparent; + background:transparent;color:inherit;font:inherit;outline:none;transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out)} +.ig-embed.edit input:hover,.ig-embed.edit input:focus{border-color:color-mix(in oklch,var(--ec) 60%,transparent);background:rgba(255,255,255,.03)} +.ig-embed-title-in{width:100%;color:#f2f3f5 !important;font-size:16px !important;font-weight:600 !important} +.ig-embed.edit .ig-msg{border-color:rgba(255,255,255,.12);background:transparent;font-size:14px} +.ig-embed.edit .ig-msg:hover{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.02)} +.ig-embed.edit .ig-msg:focus{border-color:var(--ec);box-shadow:0 0 0 2px color-mix(in oklch,var(--ec) 30%,transparent)} +.ig-fields-edit{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:8px} +.ig-field-edit{grid-column:span var(--span,4)} +.ig-field-add{grid-column:1/-1} +.ig-field-edit{position:relative;display:flex;flex-direction:column;gap:2px;padding:4px;border-radius:6px;background:rgba(0,0,0,.14)} +.ig-field-edit.full{grid-column:1/-1} +.ig-field-edit>:first-child,.ig-field-edit>:first-child input{color:#f2f3f5;font-weight:600;font-size:14px} +.ig-field-edit>:first-child{padding-right:60px} +.ig-field-edit>.ig-tpl:nth-child(2) :is(input,.ig-tpl-out){font-size:13.5px} +.ig-field-edit .ic-btn-tiny,.ig-field-w{position:absolute;top:3px;width:24px;height:24px;opacity:0;transition:opacity .15s var(--ease-out)} +.ig-field-edit .ic-btn-tiny{right:3px} +.ig-field-w{right:29px;padding:0;border:0;border-radius:5px;background:#1e1f22;color:#b5bac1; + font-family:inherit;font-size:11px;font-weight:600;line-height:20px;cursor:pointer} +.ig-field-edit:hover .ic-btn-tiny,.ig-field-edit:hover .ig-field-w,.ig-field-edit:focus-within .ic-btn-tiny,.ig-field-edit:focus-within .ig-field-w{opacity:1} +.ig-field-add{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px;border-radius:6px;border:1px dashed rgba(255,255,255,.14); + background:transparent;color:#949ba4;font:inherit;font-size:12.5px;cursor:pointer;transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} +.ig-field-add:hover{color:#dbdee1;border-color:color-mix(in oklch,var(--ec) 60%,transparent)} +.ig-field-add svg{width:13px;height:13px} +.ig-embed-foot.edit{display:flex;align-items:center;gap:8px;font-size:12px;color:#b5bac1} +.ig-embed-foot.edit input{flex:0 1 auto;field-sizing:content;min-width:60px;max-width:100%;font-size:12px} +.ig-embed-foot.edit .ig-foot-dot{color:#949ba4} +.ig-ts{display:inline-flex;align-items:center;gap:5px;flex:none;padding:3px 8px;border-radius:999px;border:1px dashed rgba(255,255,255,.16); + background:transparent;color:#949ba4;font:inherit;font-size:11.5px;cursor:pointer;transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} +.ig-ts svg{width:12px;height:12px} +.ig-ts.on{border-style:solid;border-color:transparent;color:#b5bac1} +.ig-ts:hover{color:#dbdee1} +.ig-swatches{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:10px} +.ig-swatch-set{display:contents} +.ig-swatch{position:relative;width:22px;height:22px;padding:0;border-radius:50%;border:0;background:var(--sw);cursor:pointer; + box-shadow:0 0 0 2px #313338;transition:transform .15s var(--ease-out),box-shadow .15s var(--ease-out)} +.ig-swatch:hover{transform:scale(1.1)} +.ig-swatch:active{transform:scale(.94)} +.ig-swatch.on{box-shadow:0 0 0 2px #313338,0 0 0 4px #f2f3f5} +.ig-swatch.custom{background:conic-gradient(#f2665a,#fcd34d,#3fcf8e,#5ac8fa,#a78bfa,#ff7eb6,#f2665a);overflow:hidden} +.ig-swatch.custom input{position:absolute;inset:0;opacity:0;cursor:pointer} +.ig-swatch:focus-visible,.ig-swatch.custom:focus-within{outline:2px solid #f2f3f5;outline-offset:3px} +.ig-inspector .ig-swatches{margin-top:0} +.ig-inspector .ig-swatch{box-shadow:0 0 0 2px var(--bg-2)} +.ig-inspector .ig-swatch.on{box-shadow:0 0 0 2px var(--bg-2),0 0 0 4px var(--fg)} +.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)} +/* 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} +.ig-pv-tl-kind{align-self:flex-start;padding:1px 8px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase; + background:color-mix(in oklch,var(--accent) 16%,var(--surface-3));color:var(--accent)} +.ig-pv-tl-kind.chapter{background:color-mix(in oklch,#a78bfa 18%,var(--surface-3));color:#a78bfa} +.ig-pv-tl-kind.highlight{background:color-mix(in oklch,#fcd34d 18%,var(--surface-3));color:#fcd34d} +/* Chat activity rules, each with a bar of where chat is right now. */ +.ig-rules{display:flex;flex-direction:column;gap:12px;container-type:inline-size} +.ig-rules-head{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:13px;color:var(--fg-2)} +.ig-rule-win{width:auto} +.ig-rule-list{display:flex;flex-direction:column;gap:8px} +.ig-rule{display:grid;grid-template-columns:minmax(150px,1fr) auto 72px auto auto;align-items:center;gap:8px;padding:10px 12px; + border-radius:10px;background:var(--bg-1);border:1px solid var(--line)} +.ig-rule-at,.ig-rule-unit{font-size:12.5px;color:var(--fg-3);white-space:nowrap} +.ig-rule-num{text-align:right} +.ig-rule-words{grid-column:1/-1} +.ig-rule-bar{grid-column:1/-1;position:relative;height:16px;border-radius:999px;background:var(--surface-3);overflow:hidden} +.ig-rule-bar i{position:absolute;left:0;top:0;bottom:0;border-radius:999px;min-width:0;background:color-mix(in oklch,var(--accent) 55%,transparent); + transition:width .5s var(--ease-out),background-color .3s var(--ease-out)} +.ig-rule-bar i.on{background:color-mix(in oklch,var(--live) 70%,transparent)} +/* Narrow homes (the builder's inspector, a phone): the rule's controls + share one line without "at least". */ +@container (max-width:420px){ + .ig-rule{grid-template-columns:minmax(0,1fr) 64px auto auto} + .ig-rule .ig-rule-at{display:none} +} +.ig-rule-bar span{position:relative;display:block;padding:0 8px;font:600 10.5px/16px 'JetBrains Mono',monospace;color:var(--fg);text-align:right} +.ig-meter{display:flex;align-items:center;flex-wrap:wrap;gap:6px 10px;padding:9px 12px;border-radius:10px;background:var(--bg-1); + border:1px solid var(--line);font-size:12.5px} +.ig-meter-dot{width:8px;height:8px;flex:none;border-radius:50%;background:var(--fg-4)} +.ig-meter-dot.fire{background:var(--live);box-shadow:0 0 0 0 color-mix(in oklch,var(--live) 60%,transparent);animation:igMeterPulse 1.6s infinite} +.ig-meter-dot.learn{background:var(--warn)} +.ig-meter-dot.ok{background:var(--live)} +.ig-meter-dot.wait{background:var(--warn);animation:igSoftPulse 1.4s ease-in-out infinite} +@keyframes igSoftPulse{50%{opacity:.35}} +.ig-meter-dot.off{background:var(--danger)} +@keyframes igMeterPulse{70%{box-shadow:0 0 0 7px color-mix(in oklch,var(--live) 0%,transparent)}100%{box-shadow:0 0 0 0 transparent}} +/* The OBS scene picker. */ +.ig-scene{display:flex;flex-direction:column;gap:10px} +.ig-scene-list{display:flex;flex-wrap:wrap;gap:6px} +.ig-start.live{position:relative;padding-left:22px} +.ig-start.live::before{content:'';position:absolute;left:9px;top:50%;width:7px;height:7px;margin-top:-3.5px;border-radius:50%;background:var(--danger)} +.ig-btn-row{display:flex;flex-wrap:wrap;gap:6px} +.ig-params{grid-template-columns:repeat(auto-fit,minmax(170px,1fr))} +.ig-narrow{max-width:220px} +@media (prefers-reduced-motion: reduce){ + .ig-meter-dot.fire,.ig-meter-dot.wait{animation:none} + .ig-rule-bar i,.ig-swatch{transition:none} +} +@media (max-width:560px){ + .ig-fields-edit,.ig-embed.big .ig-embed-fields{grid-template-columns:minmax(0,1fr)} + .ig-field-edit,.ig-embed-fields>div.in{grid-column:1/-1} + /* A phone gives the card the avatar's room. */ + .ig-live-discord .ig-live-av{display:none} + .ig-live-discord .ig-live-msg{padding:12px} + .ig-embed.big{padding:8px 10px 12px 10px} +} @media (prefers-reduced-motion: reduce){ .ig-skel::after{animation:none} .ig-spin,.ic-btn.is-busy::after,.ic-btn-tiny.is-busy::after{animation-duration:1.6s} @@ -2942,6 +3109,72 @@ .ig-palette-hint,.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 + wrote (variables and all) while you edit it. */ +.ig-tpl{position:relative;min-width:0} +.ig-tpl-out{display:none;cursor:text;white-space:pre-wrap;overflow-wrap:anywhere;border-radius:5px;border:1px dashed transparent} +.ig-tpl-out:hover{border-color:rgba(255,255,255,.16)} +.ig-tpl.has-vars:not(:focus-within)>.ig-tpl-out{display:block} +.ig-tpl.has-vars:not(:focus-within)>.ig-tpl-in{position:absolute;inset:0;opacity:0;pointer-events:none} +.ig-tpl.has-vars:focus-within~.ig-sample{display:block} +.ig-tpl~.ig-sample:not(.ig-instead){display:none} +.ig-tpl-out.msg{min-height:48px;padding:8px 10px;line-height:1.5;border-radius:8px; + border:1px dashed color-mix(in oklch,var(--accent) 55%,transparent);background:color-mix(in oklch,var(--accent) 5%,transparent)} +.ig-embed.edit .ig-tpl-out.msg{border-color:rgba(255,255,255,.12);background:transparent;font-size:14px} +.ig-live-phone .ig-tpl-out.line{padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:rgba(255,255,255,.08);font-size:13px} +.ig-tpl-out.line{padding:4px 6px;line-height:20px} +.ig-embed.edit input{line-height:20px} +.ig-embed-title-in{line-height:22px !important} +.ig-tpl-out.title{padding:4px 6px;color:#f2f3f5;font-size:16px;font-weight:600;line-height:22px} +.ig-tpl-out.above{padding:5px 8px;margin:0 0 6px;font-size:14px} +.ig-tpl-out .ig-tpl-none{color:#949ba4;font-style:italic} +.ig-tpl-out .ig-tpl-hint{float:right;margin-left:8px;padding:0 5px;border-radius:4px;background:rgba(255,255,255,.06); + font:500 10px/1.8 'JetBrains Mono',monospace;color:#949ba4;text-transform:uppercase;letter-spacing:.04em} +/* The channel header when no channel is picked. */ +.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 .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} +/* 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} +/* 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} +.ig-bfield .ic-btn-tiny svg{width:13px;height:13px} +.ig-insp-pv{padding:10px;border-radius:8px;max-height:220px;overflow:auto} +.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} +/* 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} +} +@media (pointer:coarse){ + .ig-field-edit .ic-btn-tiny,.ig-field-w{width:32px;height:32px;line-height:32px} + .ig-field-w{right:37px} + .ig-field-edit>:first-child{padding-right:76px} + .ig-token,.ig-ts{padding:5px 10px} + .ig-swatch{width:28px;height:28px} +} @@ -3195,7 +3428,7 @@

Welcome to InstantClone

`; } const k = KINDS[step.type]; const value = step.params[k.text] || ''; + const vars = sampleMap(h); // The trigger's own values first; the always-there ones behind "more". const all = varsFor(h); - const common = new Set(['delay', 'delay_state', 'hold_left', 'time']); - const globals = new Set(S.data.vars.global.map(v => v.name)); + const common = new Set(['delay', 'delay_state', 'hold_left', 'time', 'uptime']); // Fields of a tried answer are picked from its tree, so they wait here. - const shown = this.moreVars ? all - : all.filter(v => (!globals.has(v.name) || common.has(v.name)) && !v.name.includes('.json.')); - const tokens = shown.map(v => ``).join('') - + (shown.length < all.length ? `` : ''); - const sample = sampleLine(value, h, step.type === 'discord'); + const shown = this.moreVars ? all : all.filter(v => (v.group !== 'live' || common.has(v.name)) && !v.name.includes('.json.')); + const tokens = tokensHtml(shown, 'token', + shown.length < all.length ? `` : ''); + const discord = step.type === 'discord'; + const sample = sampleLine(value, h, discord); // A tried answer can send the run down another branch ("offline"). - const vars = sampleMap(h), reached = reachedStep(h.steps, vars, step.type); + const reached = reachedStep(h.steps, vars, step.type); const instead = reached && reached !== step ? `
Right now it would say: ${esc(renderSample(reached.params[k.text], vars))}
` : ''; - const editor = ` -
${sample}
${instead}`; + const card = discord && step.params.style === 'card'; + const area = ``; + const editor = tplHtml(area, value, vars, 'msg', discord) + + `
${sample}
${instead}`; let box; - if (step.type === 'discord'){ + if (discord){ + const set = S.data.connections.discord.some(c => c.id === step.params.connection); const ping = step.params.ping === 'here' ? '@here' : step.params.ping === 'everyone' ? '@everyone' : ''; - const updates = step.params.edit === 'last'; + const edit = step.params.edit || ''; + const note = {last:'updates its last message', close:'updates its last message, then starts fresh'}[edit]; + const body = card ? this.cardEditorHtml(step, h, editor) : editor; + const channel = set ? esc(channelName(step.params.connection).replace(/^#/, '')) + : ''; box = `
-
#${esc(channelName(step.params.connection).replace(/^#/, ''))}Live preview
+
#${channel}Live preview
${svg('shield')} -
InstantCloneAPP${ping && !updates ? `${esc(ping)}` : ''} - ${updates ? `${svg('pencil')}edits the last message` : ''}
- ${editor}
`; +
InstantCloneAPP${esc(discordNow())} + ${ping && edit !== 'close' ? `${esc(ping)}` : ''} + ${note ? `${svg('pencil')}${note}` : ''}
+ ${body}
`; } else if (step.type === 'chat'){ const t = h.trigger; - const ask = t.type === 'chat_command' ? (t.command || '!command') : t.type === 'chat_message' ? (t.pattern || 'hey') : ''; + const ask = t.type === 'chat_command' ? `${t.command || '!command'}${SAMPLE_ARGS.has(h) ? ' ' + SAMPLE_ARGS.get(h) : ''}` : t.type === 'chat_message' ? (t.pattern || 'hey') : ''; box = `
STREAM CHAT · LIVE PREVIEW
${ask ? `
viewer: ${esc(ask)}
` : ''} @@ -2052,14 +2498,62 @@ const Editor = { } else if (step.type === 'phone'){ box = `
INSTANTCLONE · now
- + ${tplHtml(``, step.params.title, vars, 'line')} ${editor}
`; + } else if (step.type === 'http' && this.d.preset === 'on_air_light'){ + const state = (h.trigger.filters || {}).state || 'live'; + box = `
+
When it turns ${esc({live:'live', delay:'delayed', crash:'crashed', off:'off'}[state] || state)}
+
InstantClone tells your light, at ${esc(maskUrl(step.params.url) || 'the address you set in Light')}.
+
What it sends (advanced)${editor}
`; + return box; } else { box = `
-
${esc(k.label)}${step.type === 'http' ? ' · body' : ''}
${editor}
`; +
${esc(k.label)}${step.type === 'http' ? ' · what it sends' : ''}
${editor}
`; } return `${box}
Insert${tokens}
`; }, + // A Discord card you write on: the text above it, then the card itself + // (title, body, fields, footer) in its color, then the color swatches. + // Every part shows what it will say until it's being edited. `editor` + // is the body's textarea with its "reads like" line. + cardEditorHtml(step, h, editor){ + const p = step.params, vars = sampleMap(h); + const color = cardColor(p.color, vars); + const fields = parseFields(p.fields, true); + const ts = p.timestamp === 'yes'; + const input = (bind, value, attrs, cls) => ``; + const known = CARD_COLORS.some(([c]) => c === color); + const swatches = CARD_COLORS.map(([c, label]) => { + const on = c === color; + return ``; + }).join(''); + return `${tplHtml(``, p.above, vars, 'above', true)} +
+ ${tplHtml(input('title', p.title, 'placeholder="Title" aria-label="Card title"', 'ig-embed-title-in'), p.title, vars, 'title', true)} + ${editor} +
${fields.map((f, n) => `
+ ${tplHtml(input('f-name', f.name, `data-n="${n}" placeholder="Name" aria-label="Field name"`), f.name, vars, 'line', false)} + ${tplHtml(input('f-value', f.value, `data-n="${n}" placeholder="Value, like {reason}" aria-label="Field value"`), f.value, vars, 'line', true)} + +
`).join('')} +
+
${svg('shield')} + ${tplHtml(input('footer', p.footer, 'placeholder="Footer" aria-label="Footer"'), p.footer, vars, 'line', false)} + ${ts && p.footer ? '' : ''} +
+
+
${swatches} +
`; + }, + // Edit the card's fields: `change` gets the list, the text is rebuilt. + fields(step, change){ + const list = parseFields(step.params.fields, true); + change(list); + step.params.fields = fieldsText(list); + }, // The chips shown for this moment, with their current value. chips(step, h){ const d = this.d, out = []; @@ -2070,37 +2564,50 @@ const Editor = { const set = S.data.connections.discord.some(c => c.id === id); out.push({id:'where', k:'Post in', v:set ? channelName(id) : 'pick a channel', warn:!set}); if (step && step.type === 'discord'){ - const ping = step.params.ping || ''; - out.push({id:'msg', k:'Message', v:step.params.edit === 'last' ? 'edits the last one' : 'a new one'}); - if (step.params.edit !== 'last') out.push({id:'ping', k:'Ping', v:ping === 'here' ? '@here' : ping === 'everyone' ? '@everyone' : 'nobody'}); + const ping = step.params.ping || '', edit = step.params.edit || ''; + out.push({id:'look', k:'Look', v:step.params.style === 'card' ? 'card' : 'plain message'}); + out.push({id:'msg', k:'Each time', v:{last:'updates the same message', close:'updates it, then starts fresh'}[edit] || 'posts a new message'}); + // Finishing a message never pings; the others can post a new one. + if (edit !== 'close') out.push({id:'ping', k:'Ping', v:ping === 'here' ? '@here' : ping === 'everyone' ? '@everyone' : 'nobody'}); } } - if (t.type === 'shortcut') out.push({id:'key', k:'Starts with', v:shortcutLabel(t) || 'set a key or pad', warn:!t.hotkey && !t.midi}); + if (t.type === 'shortcut') out.push({id:'key', k:'Starts with', v:shortcutLabel(t) || 'set a key, pad or Stream Deck', warn:!t.hotkey && !t.midi && !t.token}); + const action = firstStep(h, 'delay_action'); + if (action) out.push({id:'action', k:'Does', v:lowerFirst(delayActionLabel(action.params, {}))}); + if (t.type === 'chat_activity') out.push({id:'detect', k:'Fires when', v:activitySummary(t), warn:!(t.rules || []).length}); + if (t.type === 'scene') out.push({id:'scene', k:'Scene', v:t.scene ? sceneLabel(t) : 'pick a scene', warn:!t.scene}); + const tune = t.type === 'event' && tuneSummary(t); + if (tune) out.push({id:'tune', k:tune.k, v:tune.v, warn:tune.bad}); if (t.type === 'event' || t.type === 'timer' || t.type === 'webhook') out.push({id:'send', k:'Send', v:this.sendValue(h).label}); - if (t.type === 'event' && this.filterVars(t).length){ - const set = Object.entries(t.filters || {}).filter(([, v]) => v); - out.push({id:'only', k:'Only if', v:set.length ? set.map(([k, v]) => `${k} is ${v}`).join(', ') : 'always'}); + if (t.type === 'event' && this.filterVars(t).length && d.preset !== 'on_air_light'){ + const set = Object.entries(t.filters || {}).filter(([k, v]) => v && !isParam(t, k)); + out.push({id:'only', k:'Only if', v:set.length ? set.map(([k, v]) => `${filterName(k)}: ${v}`).join(', ') : 'no conditions'}); } if (t.type === 'chat_command'){ out.push({id:'cmd', k:'Command', v:[t.command].concat(t.aliases || []).join(' ')}); - out.push({id:'who', k:'Who', v:rolesLabel(t.roles)}); + out.push({id:'who', k:'Who can use it', v:rolesLabel(t.roles)}); } if (step && step.type === 'chat') out.push({id:'reply', k:'Reply as', v:step.params.as === 'bot' ? 'bot account' : 'your account'}); if (step && step.type === 'phone') out.push({id:'priority', k:'Priority', v:step.params.priority || 'normal'}); const http = firstStep(h, 'http'); - if (http) out.push({id:'url', k:t.type.startsWith('chat') ? 'Answer from' : 'Send to', v:maskUrl(http.params.url) || 'set the address', warn:!http.params.url}); + if (http && d.preset === 'on_air_light') out.push({id:'url', k:'Light', v:lightOf(http, t) || maskUrl(http.params.url) || 'pick your light', warn:!http.params.url}); + else if (http) out.push({id:'url', k:t.type.startsWith('chat') ? 'Answer from' : 'Send to', v:maskUrl(http.params.url) || 'set the address', warn:!http.params.url}); if (step && step.type === 'file') out.push({id:'file', k:'File', v:step.params.path || 'pick a file', warn:!step.params.path}); - out.push({id:'cooldown', k:'Wait between', v:fmtMs(d.cooldown_ms) || 'no limit'}); + out.push({id:'cooldown', k:'Repeat at most', v:d.cooldown_ms ? 'once every ' + fmtDur(d.cooldown_ms) : 'anytime'}); out.push({id:'quiet', k:'Quiet hours', v:d.quiet ? `${d.quiet.from} to ${d.quiet.to}` : 'off'}); return out; }, + // Values worth narrowing on ("only YouTube", "only crashes"); counts, + // durations and reports change every time. filterVars(t){ const e = eventOf(t.event); - return e ? e.vars.filter(v => v.name !== 'hold' && v.name !== 'down_for' && v.name !== 'previous') : []; + const skip = new Set(['hold', 'hold_ends_at', 'down_for', 'down_s', 'down_total', 'previous', 'drops', 'within', + 'duration', 'crashes', 'highlights', 'report', 'chapters', 'line']); + return e ? e.vars.filter(v => !skip.has(v.name)) : []; }, sendValue(h){ const first = (h.steps || [])[0]; - if (first && first.type === 'wait_delay') return {id:'aired', label:'once the delay aired'}; + if (first && first.type === 'wait_delay') return {id:'aired', label:'once viewers see it'}; if (first && first.type === 'wait'){ const ms = parseInt(first.params.ms, 10) || 0; return {id:'w' + ms, label:'after ' + fmtMs(ms)}; @@ -2108,7 +2615,7 @@ const Editor = { return {id:'now', label:'right away'}; }, popHtml(step, h){ - if (this.chip === '__test') return this.runHtml(); + if (this.chip === '__test') return testPanelHtml(this); const d = this.d, t = h.trigger; const seg = (items, cur, act) => `
${items.map(([id, label]) => ``).join('')}${SEG_IND}
`; @@ -2116,35 +2623,76 @@ const Editor = { case 'where': { const cur = (allSteps(d).find(s => s.type === 'discord') || {params:{}}).params.connection; return `
Post in
- ${S.data.connections.discord.map(c => ``).join('')}
`; } - case 'msg': - return `
Message
${seg([['', 'Send a new one'], ['last', 'Edit the last one']], step.params.edit || '', 'set-edit')} -
"Edit the last one" changes the message this integration last posted in that channel, so a drop stays one message that goes from down to back. If someone deleted it, a new one goes out.
`; + case 'look': + return `
Look
${seg([['', 'Plain message'], ['card', 'Card']], step.params.style || '', 'set-style')} +
A card has a colored edge, a title, fields side by side and a timestamp. Write on it right in the preview.
`; + case 'msg': { + const edit = step.params.edit || ''; + const key = step.params.key || ''; + const perPlatform = /\{destination\}/.test(key); + const canSplit = varsFor(h).some(v => v.name === 'destination'); + return `
Each time it runs
${seg([['', 'Post a new message'], ['last', 'Update the same message'], ['close', 'Update it, then start fresh']], edit, 'set-edit')} +
${{ + last:'Edits the message it posted there before, so one alert goes from "down" to "back" instead of piling up. If someone deleted it, a new one goes out.', + close:'Edits the message it posted before into its final state, then lets it go: the next alert starts a new message. With nothing to finish (a short blip never got a message), it stays quiet.', + }[edit] || 'Every run posts its own message.'}
+ ${edit && canSplit ? `` : ''} + ${edit ? `
Message group (advanced) +
+
Moments with the same group update the same message: in the ready-made alerts, "is down" and "is back" share one. Change it on both, or they stop finding each other.
` : ''}`; + } + case 'action': { + const a = firstStep(h, 'delay_action'); + const secs = a.params.seconds || ''; + const field = /\{/.test(secs) + ? `` + : durInput('seconds-dur', (parseFloat(secs) || 30) * 1000, 's'); + return `
Does
${seg(['arm', 'cut_after', 'cut', 'activate', 'disarm'].map(x => [x, DELAY_ACTIONS[x]]), a.params.action, 'set-action')} + ${a.params.action === 'arm' ? `
${field}
` : ''} +
${{ + arm:'Starts this delay, or changes it while one is on air.', + cut_after:'Goes back to live once viewers have caught up with you, so they miss nothing. It takes as long as the delay.', + cut:'Goes back to live right away. Viewers skip what was still in the delay.', + activate:'Puts on air the delay that finished getting ready.', + disarm:'No delay at all, and none getting ready.', + }[a.params.action] || ''} Tests never touch the stream.
`; + } + case 'detect': return rulesHtml(t, ''); + case 'scene': return sceneHtml(t, '', this.d); + case 'tune': { + const tune = tuneSummary(t); + return `
${esc(tune.k)}
${paramsHtml(t, '')}
Each moment keeps its own numbers: a phone push can wait longer than the Discord card.
`; + } case 'key': return shortcutHtml(t); case 'quiet': return quietHtml(d); case 'ping': return `
Ping
${seg([['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']], step.params.ping || '', 'set-ping')} -
Text from chat or a web service can never ping anyone: only this option can.
`; +
${step.params.edit === 'last' ? 'Only when it posts a new message: an update never pings. ' : ''}Text from chat or a web service can never ping anyone: only this option can.
`; case 'send': - return `
When to send
${seg([['now', 'Right away'], ['w10000', '10 s'], ['w20000', '20 s'], ['w30000', '30 s'], ['w60000', '1 min'], ['aired', 'Once the delay aired']], + return `
When to send
${seg([['now', 'Right away'], ['w10000', '10 s'], ['w20000', '20 s'], ['w30000', '30 s'], ['w60000', '1 min'], ['aired', 'Once viewers see it']], this.sendValue(h).id, 'set-send')} -
${t.type === 'event' && t.event === 'hold_opened' ? 'Waiting a bit means a 3-second blip never reaches anyone.' : '"Once the delay aired" waits until viewers have seen the moment.'}
`; +
${t.type === 'event' && t.event === 'hold_opened' ? 'Waiting a bit means a 3-second blip never reaches anyone.' : '"Once viewers see it" waits out your delay, so the moment has reached them.'}
`; case 'only': return `
Only if
${this.filterVars(t).map(v => { const cur = (t.filters || {})[v.name] || ''; - const choices = FILTER_CHOICES[v.name]; + const listed = filterChoices(t.event, v.name); + const choices = listed && (!cur || listed.includes(cur)) ? listed : null; const input = choices ? `` - : ``; - return `
${input}
`; - }).join('')}
`; + : ``; + return `
${input}
`; + }).join('')}
+
Several at once: YouTube, Kick. A * matches anything: *timed out*. Capitals don't matter.
`; case 'cmd': return `
-
${seg([['0', 'no limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min']], String(t.user_cooldown_ms || 0), 'set-ucd')}
`; +
${seg([['0', 'no limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min']], String(t.user_cooldown_ms || 0), 'set-ucd')}
`; case 'who': return `
Who can use it
${rolesHtml(t, 'role')}
You can always use your own commands.
`; case 'reply': @@ -2155,8 +2703,9 @@ const Editor = { return `
Priority
${seg([['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']], step.params.priority || '', 'set-priority')}`; case 'url': { const http = firstStep(h, 'http'), chatty = t.type.startsWith('chat'); - return `${chatty && d.preset === 'api_command' ? this.startsHtml(http) : ''}
-
+ if (d.preset === 'on_air_light') return this.lightHtml(http, t); + return `${chatty && d.preset === 'api_command' ? this.startsHtml(http) : ''}
${secretField('e-url', ``, + chatty ? 'Address, or a bot command to convert' : 'Address')}
${chatty ? '
Paste a command from Nightbot, StreamElements, Fossabot or Streamlabs: the address, the reply and the command name fill in for you.
' : ''} ${this.convertedHtml()} @@ -2167,15 +2716,32 @@ const Editor = {
Point an OBS text source at this file to show it on stream.
`; case 'cooldown': - return `
Wait between two runs
${seg([['0', 'No limit'], ['10000', '10 s'], ['30000', '30 s'], ['60000', '1 min'], ['300000', '5 min'], ['600000', '10 min']], String(d.cooldown_ms || 0), 'set-cooldown')}`; + return `
Repeat at most
${seg([['0', 'Anytime'], ['10000', 'Every 10 s'], ['30000', '30 s'], ['60000', '1 min'], ['300000', '5 min'], ['600000', '10 min']], String(d.cooldown_ms || 0), 'set-cooldown')} +
${durInput('cooldown-dur', d.cooldown_ms, 's')}
+
For every moment here, each counting on its own. A button pressed too soon shows as skipped in Recent activity.
`; } return ''; }, - runHtml(){ - if (!this.run) return '
Running the test…
'; - if (this.run.error) return warnHtml(esc(this.run.error)); - return `
Test run: ${esc(this.run.status)}
${runLogHtml(this.run.steps)} -
Messages and web requests went out for real, marked [TEST] and never pinging anyone. Waits were skipped; the stream, VOD, programs and files were left alone.
`; + // The on-air light's device and address. Picking a device fills in + // what each state sends it; the address is the user's own. + lightHtml(http, t){ + const starts = S.data.catalog.light_starts || []; + const device = lightStart(http, t); + const help = { + home_assistant:'In Home Assistant: Settings › Automations › Create, add a Webhook trigger with the id instantclone-onair. InstantClone sends live, delay, crash or off; use {{ trigger.json.state }} there to choose the color.', + wled:'Your WLED light\'s address. The WLED app shows it (Config › WiFi Setup).', + hue:'Your Hue bridge\'s address, a username from the bridge, and the light\'s number. The Hue app shows the bridge\'s IP.', + }; + const example = {home_assistant:'http://homeassistant.local:8123/api/webhook/instantclone-onair', wled:'http://192.168.1.50/json/state', + hue:'http://192.168.1.20/api/USERNAME/lights/1/state'}; + return `
Your light
${starts.map(x => + ``).join('')}
+ ${secretField('e-light-url', ``, 'Its address')} +
${device ? help[device.id] || '' : 'Pick your light: InstantClone fills in what each state sends it.'} Then Send a test lights it up for the moment picked above.
+
Advanced: method
+
What each state sends is in the preview: pick a moment (live, delayed…) and open "What it sends".
`; }, input(el){ const h = this.handler(), step = primaryStep(h), t = h.trigger; @@ -2195,7 +2761,7 @@ const Editor = { return; } else if (b === 'url' || b === 'method'){ - // A catalog webhook sends every event to one address: set them all. + // A catalog webhook or light sends to one address: set them all. allSteps(this.d).filter(s => s.type === 'http').forEach(s => { s.params[b] = el.value; }); if (b === 'url') this.converted = null; } @@ -2204,21 +2770,98 @@ const Editor = { tryEntry(firstStep(h, 'http')).inputs[el.dataset.name] = el.value; return; } + else if (b === 'test-value'){ (this.testValues = this.testValues || {})[el.dataset.name] = el.value; return; } else if ((b === 'path' || b === 'mode') && step) step.params[b] = el.value; - else if (!quietInput(this.d, el)) return; + else if ((b === 'above' || b === 'footer' || b === 'key' || b === 'color') && step) step.params[b] = el.value; + else if (b === 'key-split' && step) splitKey(step, el.checked); + else if ((b === 'f-name' || b === 'f-value') && step){ + this.fields(step, list => { + const f = list[+el.dataset.n]; + if (f) f[b === 'f-name' ? 'name' : 'value'] = el.value; + }); + } + else if (b === 'param'){ if (!paramInput(t, el)) return; } + else if (b === 'seconds' && firstStep(h, 'delay_action')) firstStep(h, 'delay_action').params.seconds = el.value.trim(); + else if (b === 'seconds-dur' && firstStep(h, 'delay_action')){ + const ms = durValue(el, 1000, 600000); + if (ms == null) return; + firstStep(h, 'delay_action').params.seconds = String(ms / 1000); + } + else if (b === 'cooldown-dur'){ + const ms = durValue(el, 0, 86400000); + if (ms == null) return; + this.d.cooldown_ms = ms; + } + else if (!rulesInput(t, el, '') && !sceneInput(t, el, '') && !onlyLiveInput(t, el, '') && !quietInput(this.d, el)) return; this.changed(); }, changed(){ this.dirty = true; + this.edits = (this.edits || 0) + 1; + syncSceneRules(this.d); this.render(); }, + // Point every state of the on-air light at one device, with what that + // device needs to hear for each state. An address the user typed stays. + useLight(id){ + const starts = S.data.catalog.light_starts || []; + const start = starts.find(x => x.id === id); + if (!start) return; + this.d.handlers.forEach(h => { + const state = (h.trigger.filters || {}).state, http = firstStep(h, 'http'); + if (!http) return; + http.params.method = start.method; + const url = (http.params.url || '').trim(); + if (!url || starts.some(x => x.url === url)) http.params.url = start.url; + if (start.bodies[state]) http.params.body = start.bodies[state]; + }); + this.changed(); + }, act(a, el){ const h = this.handler(), step = primaryStep(h), v = el.dataset.v; + if (rulesAct(h.trigger, a, el) || sceneAct(h.trigger, a, el)){ this.changed(); return true; } switch (a){ - case 'moment': this.sel = +el.dataset.n; if (this.chip === '__test') this.chip = null; this.render(); return true; + case 'set-style': if (step) step.params.style = v; this.changed(); return true; + case 'set-color': if (step) step.params.color = v; this.changed(); return true; + case 'set-ts': if (step) step.params.timestamp = step.params.timestamp === 'yes' ? '' : 'yes'; this.changed(); return true; + case 'f-add': { + if (!step) return true; + this.fields(step, list => list.push({name:'', value:'', inline:true})); + this.changed(); + const names = Modal.form.querySelectorAll('[data-bind="f-name"]'); + if (names.length) names[names.length - 1].focus(); + return true; + } + case 'f-del': if (step) this.fields(step, list => list.splice(+el.dataset.n, 1)); this.changed(); return true; + case 'f-inline': + if (step) this.fields(step, list => { const f = list[+el.dataset.n]; if (f) f.inline = !f.inline; }); + this.changed(); + return true; + case 'light-start': this.useLight(v); return true; + case 'set-action': { + const a = firstStep(h, 'delay_action'); + if (a){ + a.params.action = v; + if (v === 'arm' && !a.params.seconds) a.params.seconds = '30'; + } + this.changed(); + return true; + } + case 'moment': + this.sel = +el.dataset.n; + this.testValues = {}; + this.run = null; + if (this.chip === '__test') this.chip = null; + this.render(); + return true; case 'moment-toggle': { const x = this.d.handlers[+el.dataset.n]; x.enabled = !x.enabled; this.changed(); return true; } case 'chip': this.chip = this.chip === el.dataset.chip ? null : el.dataset.chip; this.render(); return true; - case 'token': insertAtCursor(q('.ig-msg'), '{' + el.dataset.token + '}'); return true; + case 'token': { + // Into the card field last written in, or the message itself. + const field = this.lastField && this.lastField.isConnected ? this.lastField : q('.ig-msg'); + insertAtCursor(field, '{' + el.dataset.token + '}'); + return true; + } case 'more-vars': this.moreVars = true; this.render(); return true; case 'set-where': allSteps(this.d).filter(s => s.type === 'discord').forEach(s => { s.params.connection = v; }); this.changed(); return true; case 'set-ping': if (step) step.params.ping = v; this.changed(); return true; @@ -2233,9 +2876,15 @@ const Editor = { Builder.open(this.d, {dirty:this.dirty, back:{title:'Back to the simple editor', keepsEdits:true, go:() => Editor.fromBuilder(Builder.d, Builder.dirty)}}); return true; - case 'ed-test': busy(el, () => this.test()); return true; + case 'ed-test': this.chip = '__test'; this.render(); focusAct('test-run'); return true; + case 'test-run': busy(el, () => this.test()); return true; + case 'test-mode': this.testDry = v === 'dry'; this.render(); return true; case 'delete': if (armConfirm(el, 'Delete for good?')) busy(el, () => remove(this.d.id)); return true; - case 'save': busy(el, () => saveIntegration(this.d, () => { this.dirty = false; })); return true; + case 'save': { + const at = this.edits; + busy(el, () => saveIntegration(this.d, () => { if (this.edits === at) this.dirty = false; })); + return true; + } case 'b-try': busy(el, async () => { await tryRequest(firstStep(h, 'http'), h); @@ -2252,19 +2901,184 @@ const Editor = { }, async test(){ this.chip = '__test'; - this.run = null; + this.run = {running:true}; this.render(); - const r = await api('/integrations/test', {integration:this.d, handler:this.sel}); + const r = await api('/integrations/test', {integration:this.d, handler:this.sel, values:testValues(this), dry:!!this.testDry}); this.run = r.ok ? r : {error:r.error || 'The test failed'}; if (Modal.kind === 'editor') this.render(); }, }; +// A test's panel, the same in both editors: how it runs (for real or +// dry), the values it runs with, and what each step did. +function testPanelHtml(o){ + const h = o.handler(); + const own = varsFor(h).filter(v => v.group === 'trigger'); + const values = o.testValues || {}; + const mode = `
${[['', 'Send it for real'], ['dry', 'Dry run']].map(([id, label]) => { + const on = (id === 'dry') === !!o.testDry; + return ``; + }).join('')}${SEG_IND}
`; + const inputs = own.length ? `
Test with other values +
${own.map(v => `
+
`).join('')}
` : ''; + const run = o.run; + const sent = run && (run.steps || []).some(st => st.status !== 'skipped'); + const verdict = run && (run.status === 'ok' ? (sent ? 'everything worked' : 'nothing was sent, see why below') + : {failed:'a step failed', stopped:'stopped early'}[run.status] || run.status); + const result = !run ? '' : run.running ? '
Running the test…
' + : run.error ? warnHtml(esc(run.error)) + : `
Result: ${esc(verdict)}
${runLogHtml(run.steps)}`; + const note = o.testDry ? 'A dry run sends nothing at all: each step says what it would have sent.' + : 'Messages go out for real, marked [TEST], and never ping anyone. Web requests go out too: a light changes and stays that way. Waits are skipped; the stream, the VOD, OBS, programs and files are left alone.'; + return `
${mode} +
+ ${inputs}
${result}
${note}
`; +} +// What a test runs with: every value the panel shows, edited or not. +function testValues(o){ + const values = {}; + varsFor(o.handler()).filter(v => v.group === 'trigger').forEach(v => { values[v.name] = v.sample; }); + return Object.assign(values, o.testValues || {}); +} + +// What a moment still misses before it can run, or ''. +function handlerMissing(h){ + const t = h.trigger; + if (t.type === 'scene' && !t.scene) return 'pick the scene'; + if (t.type === 'shortcut' && !t.hotkey && !t.midi && !t.token) return 'set a key, pad or Stream Deck'; + let missing = ''; + const look = steps => (steps || []).forEach(s => { + if (missing || s.enabled === false) return; + missing = stepMissing(s); + look(s.then); + look(s.else); + }); + look(h.steps); + return missing; +} +// What a step still misses, or ''. +function stepMissing(s){ + const p = s.params || {}; + if (s.type === 'discord' && !S.data.connections.discord.some(c => c.id === p.connection)) return 'pick a Discord channel'; + if (s.type === 'http' && !(p.url || '').trim()) return 'set the address'; + if (s.type === 'http' && /BRIDGE-IP|USERNAME/.test(p.url || '')) return 'replace BRIDGE-IP and USERNAME with your bridge\'s'; + if (s.type === 'phone' && !S.data.connections.phone.topic) return 'connect your phone'; + if (s.type === 'discord' && p.style === 'card' && !['title', 'text', 'fields', 'image', 'above'].some(k => (p[k] || '').trim())) return 'give the card a title or some text'; + if (s.type === 'if' && !(p.left || '').trim()) return 'say what it checks'; + if ((s.type === 'set_var' || s.type === 'counter') && !(p.name || '').trim()) return 'name the value'; + if (s.type === 'wait' && !/\{|^\d+$/.test((p.ms || '').trim())) return 'set how long to wait'; + if ([p.scene, p.source, s.type === 'program' || s.type === 'file' ? p.path : ''].some(v => /\{/.test(v || ''))) return 'names here can\'t use values'; + if ((s.type === 'program' || s.type === 'file') && !(p.path || '').trim()) return s.type === 'file' ? 'pick the file' : 'pick the program'; + if (s.type === 'obs'){ + const need = (OBS_ACTIONS[p.action] || [])[1]; + if (!need) return 'pick what to do in OBS'; + if (!(p[need] || '').trim()) return need === 'scene' ? 'pick the scene' : 'pick the source'; + } + if ((s.type === 'chat' || s.type === 'phone' || s.type === 'overlay' || s.type === 'timeline') && !(p.text || '').trim()) return 'write what it says'; + if (s.type === 'discord' && p.style !== 'card' && !(p.text || '').trim()) return 'write the message'; + return ''; +} + +// "A separate message for each platform": the message group follows the +// platform, or (unticked) goes. What it was is kept, so ticking it again +// brings back "flap {destination}" rather than a plain "{destination}" +// that would share the "is down" card's message. +const KEYS_WERE = new Map(); +function splitKey(step, on){ + const id = uidOf(step); + if (on) step.params.key = KEYS_WERE.get(id) || '{destination}'; + else { KEYS_WERE.set(id, step.params.key || ''); step.params.key = ''; } +} + +// Scene delay: the "any other scene" rule never counts the game scene, +// whatever the game scene is called. +function syncSceneRules(d){ + if (d.preset !== 'scene_delay') return; + const arm = d.handlers.find(h => h.trigger.type === 'scene' && (firstStep(h, 'delay_action') || {params:{}}).params.action === 'arm'); + if (!arm) return; + d.handlers.forEach(h => { + if (h !== arm && h.trigger.type === 'scene' && h.trigger.scene === '*') h.trigger.except = arm.trigger.scene || ''; + }); +} + +// The light device a step is set up for, by what it sends. +function lightStart(http, t){ + if (!(http.params.url || '').trim()) return null; + const state = ((t && t.filters) || {}).state || 'live'; + return (S.data.catalog.light_starts || []).find(x => x.bodies[state] === http.params.body && x.method === (http.params.method || 'POST')); +} +function lightOf(http, t){ + const device = lightStart(http, t); + return device ? device.label + (http.params.url ? '' : ' (set its address)') : ''; +} + +// A filter's name in words. +function filterName(name){ + return {destination:'platform name', platform:'platform', reason:'why', state:'on-air state', kind:'line kind', + stopped:'stopped on purpose', protected:'crash protection took over'}[name] || name.replace(/_/g, ' '); +} +// Whether a filter is really one of the trigger's settings (a threshold). +function isParam(t, name){ + const e = eventOf(t.event); + return !!(e && (e.params || []).some(p => p.name === name)); +} +// An event trigger's thresholds in words, for its chip: "Fires after: 60 s +// down". `bad` when one isn't a usable number. +function tuneSummary(t){ + const e = eventOf(t.event); + const params = (e && e.params) || []; + if (!params.length) return null; + const f = t.filters || {}; + const val = name => { const p = params.find(x => x.name === name); return f[name] || (p ? p.default : ''); }; + const bad = params.some(p => f[p.name] !== undefined && f[p.name] !== '' && !(+f[p.name] > 0)); + switch (t.event){ + case 'destination_still_down': return {k:'Fires after', v:`${val('after_s')} s down`, bad}; + case 'destination_unstable': return {k:'Fires at', v:`${val('drops')} drops in ${val('within_min')} min`, bad}; + case 'destination_steady': return {k:'Fires after', v:`${val('for_min')} min steady`, bad}; + } + return {k:'Settings', v:params.map(p => paramShort(p, val(p.name))).join(', '), bad}; +} + +// Each moment's name. A scene rule of a delay reads by its role ("Game: +// delay on", "Any other scene: back to live"); two that read the same +// otherwise are told apart by what they do. +function momentLabels(hs){ + const labels = hs.map(h => triggerLabel(h.trigger)); + return labels.map((label, n) => { + const h = hs[n]; + const step = primaryStep(h) || (h.steps || [])[0]; + if (h.trigger.type === 'scene' && step && step.type === 'delay_action'){ + const scene = !h.trigger.scene && step.params.action === 'arm' ? 'Game scene (pick it)' : sceneLabel(h.trigger); + return `${scene}: ${delayRole(step.params)}`; + } + if (labels.filter(x => x === label).length < 2) return label; + const does = step && step.type === 'delay_action' ? delayActionLabel(step.params, {}) + : step && step.type === 'timeline' ? 'adds a line' : step ? (KINDS[step.type] || {}).label : ''; + return does ? `${label} · ${does}` : `${label} · ${labels.slice(0, n + 1).filter(x => x === label).length}`; + }); +} +// A scene trigger's scene in words. +function sceneLabel(t){ + if (!t.scene) return 'A scene (pick it)'; + if (t.scene === '*') return t.except ? 'Any other scene' : 'Any scene'; + return t.scene; +} +function delayRole(p){ + return {arm:'delay on', activate:'delay on', cut_after:'back to live', cut:'back to live', disarm:'no delay', toggle:'delay on or off'}[p.action] || 'the delay'; +} +// "Start a 30 s delay", "Back to live, no skipping". +function delayActionLabel(p, vars){ + const seconds = renderSample(p.seconds || '', vars).trim(); + if (p.action === 'arm' && seconds && Number(seconds) <= 0) return 'Turn the delay off'; + return p.action === 'arm' && seconds ? `Turn on a ${seconds} s delay` : (DELAY_ACTIONS[p.action] || 'Delay action'); +} + // How a message with variables will read, or nothing when it has none. function sampleLine(text, h, discord){ - if (!/\{[A-Za-z0-9_.]/.test(text || '')) return ''; - const read = esc(renderSample(text, sampleMap(h))); - return 'Reads like: ' + (discord ? discordTimes(read) : read) + ''; + if (!needsRender(text, discord)) return ''; + const read = renderSample(text, sampleMap(h)); + return 'Reads like: ' + (discord ? discordMarkdown(read) : esc(read)) + ''; } // Quiet hours, shared by both editors. @@ -2275,7 +3089,7 @@ function quietHtml(d){ ``).join('')}${SEG_IND}
${q ? `
` : ''} -
${svg('moon', ' class="ig-inline-ic"')}Nothing runs between these times, on this PC's clock. For alerts that would wake you up.
`; +
${svg('moon', ' class="ig-inline-ic"')}For every moment here: nothing runs between these times, on this PC's clock. For alerts that would wake you up.
`; } function quietInput(d, el){ const b = el.dataset.bind; @@ -2377,9 +3191,23 @@ function refusedKey(i){ const h = (i.handlers || []).find(h => h.enabled && h.trigger.type === 'shortcut' && refused.includes(h.trigger.hotkey)); return h ? h.trigger.hotkey : ''; } +// The button's own address: a Stream Deck, Bitfocus Companion or any +// program presses it by calling it. +function buttonAddressHtml(t){ + if (!t.token){ + return `
`; + } + const url = `${location.origin}/hooks/${t.token}`; + return `
${secretField('btn-' + t.token.slice(0, 6), `
+
`, 'Stream Deck & apps (keep it secret)')} +
Stream Deck: a Website action with this address and GET request in background on. Companion: an HTTP request. Any program: curl ${esc(url.replace(/\/hooks\/.*/, '/hooks/…'))}
+
+
`; +} function shortcutHtml(t){ if (!S.data.shortcuts.available){ - return warnHtml('Hotkeys and MIDI pads work on Windows only. On this system, start it with a web call instead.'); + return warnHtml('Hotkeys and MIDI pads work on Windows only. Here, press it through its address instead.') + buttonAddressHtml(t) + + onlyLiveHtml(t, '', 'Only while streaming', 'Presses while OBS isn\'t streaming do nothing.'); } const refused = t.hotkey && (S.data.shortcuts.refused || []).includes(t.hotkey) ? warnHtml(`Windows won't let InstantClone use ${esc(t.hotkey)}: another app holds it (often Discord, a game overlay or a launcher). Pick another key.`) : ''; @@ -2392,16 +3220,257 @@ function shortcutHtml(t){ ${t.hotkey ? `` : ''}
+ ? 'Press a pad or button…' : t.midi ? `${esc(padLabel(t.midi))}` : 'Click, then press a pad'} ${t.midi ? `` : ''}
- ${refused}
Works while you're in a game. Use either one, or both. Keys and pads that run the delay (Controls tab) stay theirs.
`; + ${refused}
Works while you're in a game. Use any of them, or all. Keys and pads that run the delay (Controls tab) stay theirs.
+ ${buttonAddressHtml(t)} + ${onlyLiveHtml(t, '', 'Only while streaming', 'Presses while OBS isn\'t streaming do nothing.')}`; } function focusAct(act){ const el = q(`[data-act="${act}"]`); if (el) el.focus({preventScroll:true}); } +// ---------------------------------------------------------------- trigger panels + +// Panels both editors share: a chat activity trigger's rules with their +// live meter, an OBS scene picker, and an event trigger's thresholds. +// `bind` prefixes the field bindings so each editor routes them. + +// The live meter: chat right now against the rules on screen, every +// second, while a rules panel is open. Only the meter redraws. +const Meter = { + timer:null, reading:null, busy:false, + watch(){ + if (this.timer) return; + this.timer = setInterval(() => this.tick(), 1000); + this.tick(); + }, + async tick(){ + const owner = OWNERS[Modal.kind]; + const t = owner && owner.handler && owner.handler().trigger; + if (!q('.ig-rules') || !t || t.type !== 'chat_activity'){ this.stop(); return; } + if (this.busy) return; + this.busy = true; + const r = await api('/integrations/meter', {trigger:t}); + this.busy = false; + if (r && r.ok !== false) this.reading = r; + if (!Modal.form) return; + const box = q('.ig-rules .ig-meter'); + if (box) morph(box, meterHtml(t, this.reading)); + Modal.form.querySelectorAll('.ig-rule-bar').forEach(bar => { + const n = +bar.dataset.n, rule = (t.rules || [])[n]; + if (!rule) return; + morph(bar, ruleBarHtml(rule, this.reading, n)); + const now = ruleNow(rule, this.reading, n), want = +rule.value || 1; + bar.setAttribute('aria-valuenow', String(Math.min(now || 0, want))); + bar.setAttribute('aria-valuetext', now == null ? 'no chat yet' : `${now} of ${want}`); + }); + }, + stop(){ clearInterval(this.timer); this.timer = null; }, +}; +// How far chat is toward one rule's threshold, as a bar. A words rule +// reads its own words' share. +function ruleNow(rule, r, n){ + if (!r) return null; + if (rule.kind === 'words') return (r.shares || [])[n] ?? r.word_share; + return {busier:r.busier, messages:r.messages, chatters:r.chatters}[rule.kind]; +} +function meterAria(rule, now){ + const want = +rule.value || 1; + return `aria-valuemin="0" aria-valuemax="${want}" aria-valuenow="${Math.min(now || 0, want)}" aria-valuetext="${now == null ? 'no chat yet' : esc(`${now} of ${want}`)}"`; +} +function ruleBarHtml(rule, r, n){ + const live = S.data.twitch.main.chat === 'connected'; + const now = live ? ruleNow(rule, r, n) : null; + const want = +rule.value || 1; + const unit = rule.kind === 'words' ? '%' : rule.kind === 'busier' ? '×' : ''; + if (now == null) return 'no chat yet'; + const pct = now > 0 ? Math.max(2, Math.min(100, now / want * 100)) : 0; + return `${esc(String(now))}${unit} now`; +} +function meterHtml(t, r){ + if (S.data.twitch.main.chat !== 'connected') return 'Connect Twitch to see chat here.'; + if (!r) return 'Listening to chat…'; + const held = {hold:'but not during the reconnect screen', offline:'but only while you stream', warmup:'but not in the first 30 s of a stream'}[r.held]; + const state = r.fires ? ['fire', held ? `Would fire, ${held}` : 'Would fire right now'] + : !r.ready && (t.rules || []).some(x => x.kind === 'busier') ? ['learn', 'Learning how fast your chat normally goes…'] + : ['', 'Quiet: nothing would fire']; + return `${state[1]} + ${plural(r.messages, 'message')} · normal ${r.ready ? r.normal : '?'} · ${plural(r.chatters, 'person').replace('persons', 'people')}${r.top_word ? ` · top word "${esc(r.top_word)}"` : ''}`; +} +function rulesHtml(t, bind){ + const seg = (items, cur, act) => `
${items.map(([id, label]) => + ``).join('')}${SEG_IND}
`; + const win = Math.round((t.window_ms || 10000) / 1000); + const wins = [5, 10, 15, 20, 30, 45, 60, 90, 120]; + if (!wins.includes(win)) wins.push(win); + const rows = (t.rules || []).map((r, n) => { + const info = RULES[r.kind] || RULES.messages; + const now = ruleNow(r, Meter.reading, n); + return `
+ + at least + + ${esc(info[1])} + + ${r.kind === 'words' ? `` : ''} +
${ruleBarHtml(r, Meter.reading, n)}
+
`; + }).join(''); + return `
+
Fires when${seg([['all', 'all'], ['any', 'any']], t.match === 'any' ? 'any' : 'all', 'r-match')}of these are true, over the last +
+
${rows || '
No rules yet: add one.
'}
+ +
${meterHtml(t, Meter.reading)}
+
${rolesHtml(t, bind + 'role')}
+ +
Fires once per burst: then it waits for chat to calm down before it can fire again. "Busier than normal" compares with your own chat over the last 10 minutes, so the same rule fits a small and a huge channel.
+
`; +} +// Edit a chat activity trigger from a rules panel field. True if handled. +function rulesInput(t, el, bind){ + const b = el.dataset.bind, n = +el.dataset.n, rule = (t.rules || [])[n]; + if (b === bind + 'r-kind' && rule){ + rule.kind = el.value; + rule.value = {busier:3, messages:20, chatters:5, words:30}[el.value]; + } + else if (b === bind + 'r-value' && rule) rule.value = Math.max(0, parseFloat(el.value) || 0); + else if (b === bind + 'r-words' && rule) rule.words = el.value; + else if (b === bind + 'r-window') t.window_ms = (+el.value || 10) * 1000; + else if (b === bind + 'r-live') t.only_live = el.checked; + else if (b === bind + 'role'){ t.roles = t.roles || {}; t.roles[el.dataset.name] = el.checked; } + else return false; + return true; +} +// A rules panel button. True if handled. +function rulesAct(t, a, el){ + if (!t || t.type !== 'chat_activity') return false; + if (a === 'r-add'){ + const used = new Set((t.rules || []).map(r => r.kind)); + const kind = ['busier', 'chatters', 'words', 'messages'].find(k => !used.has(k)) || 'messages'; + (t.rules = t.rules || []).push({kind, value:{busier:3, messages:20, chatters:5, words:30}[kind], words:kind === 'words' ? 'clip, pog, lul' : ''}); + } + else if (a === 'r-del') t.rules.splice(+el.dataset.n, 1); + else if (a === 'r-match') t.match = el.dataset.v; + else return false; + return true; +} + +// The OBS connection as the scene picker and OBS steps show it, asked for +// every two seconds while one is open (that also keeps OBS connected +// meanwhile). +const ObsLive = { + timer:null, status:null, + watch(){ + if (this.timer) return; + this.timer = setInterval(() => this.tick(), 2000); + this.tick(); + }, + async tick(){ + if (!q('[data-obs-live]')){ this.stop(); return; } + const r = await api('/integrations/obs'); + if (!r || r.ok === false) return; + const changed = JSON.stringify(r) !== JSON.stringify(this.status); + this.status = r; + const owner = OWNERS[Modal.kind]; + if (changed && owner && owner.render) owner.render(); + }, + stop(){ clearInterval(this.timer); this.timer = null; }, +}; +// One line on how OBS is connected, with a Retry when it isn't. +function obsStatusHtml(){ + const o = ObsLive.status; + if (!o || o.state === 'connecting' || o.state === 'off') return '
Connecting to OBS…
'; + if (o.state === 'connected') return `
OBS connected${o.current ? 'on air: ' + esc(o.current) : ''}
`; + return `
${esc(o.problem || 'Can\'t reach OBS.')} + ${/WebSocket/.test(o.problem || '') ? '' : ' In OBS: Tools › WebSocket Server Settings › Enable WebSocket server, then OK.'} +
`; +} +// Scene buttons from OBS's list. `act` sets the value, `cur` is picked. +function scenePickHtml(cur, act, withAny){ + const o = ObsLive.status, scenes = (o && o.scenes) || []; + const pick = (value, label, live) => ``; + const any = withAny ? pick('*', 'Any other scene') : ''; + if (!scenes.length && !any) return '
Your scenes show here once OBS is connected.
'; + return `
${any}${scenes.map(x => pick(x, x, o && x === o.current)).join('')}
`; +} +function sceneHtml(t, bind, d){ + const settle = Math.round((t.settle_ms == null ? 3000 : t.settle_ms) / 1000); + const settles = [0, 1, 3, 5, 10, 20, 30, 60]; + if (!settles.includes(settle)) settles.push(settle); + // In a scene delay, the "back to live" rule can be any scene but the + // game one. + const isOther = d && d.preset === 'scene_delay' && (firstStep(d.handlers.find(h => h.trigger === t) || {}, 'delay_action') || {params:{}}).params.action !== 'arm'; + return `
+ ${obsStatusHtml()} +
Scene
${scenePickHtml(t.scene, 'set-scene', isOther)} +
+ ${isOther && t.scene === '*' ? `
Never your game scene${t.except ? ` (${esc(t.except)})` : ''}. Change it in the first moment.
` + : `
`} +
+
${settles.sort((x, y) => x - y).map(x => + ``).join('')}${SEG_IND}
+ ${onlyLiveHtml(t, bind, 'Only while streaming', 'Switching scenes before you go live does nothing. A stream that starts on a matching scene counts.')} +
Flicking through scenes does nothing: it only acts once the scene stayed. Never during the reconnect screen. Several scenes: Game, Game 2.${isOther ? ' "Any other scene" never counts the game scene.' : ''}
+
`; +} +// The "only while streaming" switch of a scene or button trigger. +function onlyLiveHtml(t, bind, title, hint){ + return ``; +} +function onlyLiveInput(t, el, bind){ + if (el.dataset.bind !== bind + 'only-live') return false; + t.only_live = el.checked; + return true; +} +function sceneInput(t, el, bind){ + const b = el.dataset.bind; + if (b === bind + 'scene') t.scene = el.value; + else if (b === bind + 'scene-from') t.from = el.value; + else if (b === bind + 'scene-except') t.except = el.value; + else return false; + return true; +} +function sceneAct(t, a, el){ + if (!t || t.type !== 'scene') return false; + if (a === 'set-scene') t.scene = el.dataset.v; + else if (a === 'set-settle') t.settle_ms = (+el.dataset.v) * 1000; + else return false; + return true; +} +// An event trigger's thresholds ("still down after 60 s"), kept among its +// filters. Empty when the event has none. A number that isn't above 0 is +// marked and not kept: the app would quietly use the default instead. +function paramsHtml(t, bind){ + const e = eventOf(t.event); + const params = (e && e.params) || []; + if (!params.length) return ''; + return `
${params.map(p => { + const unit = /second/i.test(p.label) ? 's' : /minute/i.test(p.label) ? 'min' : ''; + const label = p.label.replace(/\s*\(.*\)/, ''); + return `
+
`; + }).join('')}
`; +} +// Keep a threshold typed in a params field. False when it isn't a number +// above 0 (the field says so). +function paramInput(t, el){ + const v = el.value.trim(); + const ok = +v > 0 && Number.isFinite(+v); + el.setAttribute('aria-invalid', ok ? 'false' : 'true'); + el.title = ok ? '' : 'Use a number above 0'; + if (!ok) return false; + t.filters = t.filters || {}; + t.filters[el.dataset.name] = v; + return true; +} + // A draft saved with no trigger still opens: it gets a starting one. function withHandler(d){ if (!d.handlers || !d.handlers.length) d.handlers = [{enabled:true, trigger:newTrigger('event'), steps:[]}]; @@ -2438,7 +3507,10 @@ function rolesHtml(t, bind){ return `
${['everyone', 'subs', 'vips', 'mods'].map(r => ``).join('')}
`; } +// A field showing its sample text instead is hidden: measuring it then +// would give the shown text's height. It measures when it's shown again. function autosize(t){ + if (t.closest('.ig-tpl.has-vars') && t !== document.activeElement){ t.style.height = ''; return; } t.style.height = 'auto'; t.style.height = (t.scrollHeight + 2) + 'px'; } @@ -2468,25 +3540,27 @@ function insertAtCursor(field, text){ const PALETTE = [ ['Send', [['discord', 'Discord'], ['chat', 'Twitch chat'], ['phone', 'Phone'], ['http', 'Web request']]], ['Flow', [['if', 'If / otherwise'], ['wait', 'Wait'], ['wait_delay', 'Wait for the delay'], ['stop', 'Stop']]], - ['Stream', [['overlay', 'Show on stream'], ['delay_action', 'Delay action'], ['marker', 'VOD marker'], ['clip', 'Clip']]], + ['Stream', [['overlay', 'Show on stream'], ['delay_action', 'Delay action'], ['obs', 'OBS'], ['marker', 'VOD marker'], ['clip', 'Clip'], ['timeline', 'Timeline']]], ['Anything', [['edit_text', 'Edit text'], ['set_var', 'Remember a value'], ['counter', 'Counter'], ['program', 'Run a program'], ['file', 'Write a file']]], ]; // What can start an integration, shown as tiles so every kind is in sight. const TRIGGERS = [ - {id:'event', icon:'bolt', label:'Something happens', hint:'OBS crashes, a destination drops, the delay changes…', + {id:'event', icon:'bolt', label:'Something happens', hint:'OBS crashes, a platform drops, the delay changes…', eg:'OBS crashes: tell the mods on Discord'}, {id:'chat_command', icon:'hash', label:'A chat command', hint:'!rank, !delay, anything', chat:true, eg:'!rank answers with your rank'}, {id:'chat_message', icon:'bubble', label:'A chat message', hint:'When chat says something', chat:true, eg:'Someone says "gg": thank them'}, + {id:'chat_activity', icon:'flame', label:'Chat gets busy', hint:'Rules you stack: speed, people, words', chat:true, + eg:'Chat goes 3× faster: clip it'}, {id:'timer', icon:'clock', label:'On a timer', hint:'Every few minutes', eg:'Every 15 min: remind chat to follow'}, - {id:'webhook', icon:'link', label:'A web call', hint:'Stream Deck, scripts, other apps', eg:'A Stream Deck button cuts the delay'}, - {id:'shortcut', icon:'keyboard', label:'A hotkey or MIDI pad', hint:'A key combo or a pad, even in game', windows:true, + {id:'shortcut', icon:'keyboard', label:'A button', hint:'Hotkey, MIDI pad or Stream Deck, even in game', eg:'Ctrl+Alt+C clips and posts the link'}, + {id:'scene', icon:'layers', label:'An OBS scene', hint:'When OBS switches scene', eg:'The game scene turns the delay on'}, + {id:'webhook', icon:'link', label:'A web call', hint:'Scripts and apps that send data', eg:'n8n sends a message to post'}, ]; // Why a trigger tile can't be picked right now, or ''. function triggerBlocked(k){ - if (k.windows && !S.data.shortcuts.available) return 'Windows only'; return k.chat && !S.data.twitch.main.login ? 'Needs Twitch connected' : ''; } // How each event looks when picking one. @@ -2498,15 +3572,23 @@ const EVENT_INFO = { obs_back:{icon:'check', desc:'OBS reconnects and the stream carries on.'}, hold_expired:{icon:'clock', desc:'OBS didn\'t come back in time.'}, hold_ended:{icon:'cut', desc:'You end the reconnect screen yourself.'}, - destination_live:{icon:'megaphone', desc:'A destination starts showing your stream.'}, - destination_dropped:{icon:'warn', desc:'A destination loses your stream.'}, - all_destinations_down:{icon:'signal', desc:'No destination is live any more.'}, + destination_live:{icon:'megaphone', desc:'YouTube, Kick or another platform starts showing your stream.'}, + destination_dropped:{icon:'warn', desc:'A platform loses your stream, even for a moment.'}, + all_destinations_down:{icon:'signal', desc:'No platform is live any more.'}, delay_on:{icon:'clock', desc:'The delay starts.'}, delay_off:{icon:'clock', desc:'The delay stops: viewers see you live.'}, delay_changed:{icon:'clock', desc:'The delay gets longer or shorter.'}, delay_armed:{icon:'clock', desc:'You arm one: the buffer starts filling.'}, delay_ready:{icon:'check', desc:'The buffer holds it: it can go on air.'}, delay_disarmed:{icon:'x', desc:'You cancel it before it goes on air.'}, + stream_started:{icon:'play', desc:'A new stream begins. A crash OBS comes back from isn\'t one.'}, + stream_ended:{icon:'film', desc:'The stream is over, with a report: length, crashes, drops.'}, + onair_changed:{icon:'bulb', desc:'Live, delayed, crashed or off: for lights and signs.'}, + timeline_updated:{icon:'list', desc:'Any integration adds a line to the stream timeline.'}, + destination_recovered:{icon:'check', desc:'A platform that dropped is live again.'}, + destination_still_down:{icon:'warn', desc:'Still down after a while: not a blip.'}, + destination_unstable:{icon:'pulse', desc:'It drops again and again.'}, + destination_steady:{icon:'check', desc:'After dropping, it has held for a while.'}, }; // Every event as a tile, grouped; `big` for the canvas chooser. function eventTilesHtml(selected, big){ @@ -2525,7 +3607,8 @@ function eventTilesHtml(selected, big){ const pathKey = p => p.join('.'); const Builder = { - d:null, h:0, sel:'trigger', target:[], run:null, dirty:false, lastField:null, back:null, + d:null, h:0, sel:'trigger', target:[], after:null, run:null, dirty:false, lastField:null, back:null, + testValues:{}, testDry:false, testOpen:false, // opts: {dirty} carries the editor's unsaved state over, {switchOn} as in // Editor.open, {back} as in Modal.back (default: the catalog, if open). open(i, opts){ @@ -2546,10 +3629,16 @@ const Builder = { this.h = 0; this.sel = 'trigger'; this.target = []; - this.run = null; + this.after = null; this.lastField = null; + this.resetTest(); this.show(); }, + resetTest(){ + this.run = null; + this.testValues = {}; + this.testOpen = false; + }, resume(){ if (pickNewChannel(this.d)) this.dirty = true; this.show(); @@ -2563,18 +3652,22 @@ const Builder = { handler(){ return this.d.handlers[this.h]; }, render(){ const d = this.d, h = this.handler(); - const n = allSteps(d).length; - Modal.body(`${modalHead('steps', 'var(--accent)', titleInput(d.name), - `Your own · runs on this PC · ${plural(n, 'step')}`, - `${enableSwitch('enabled', d.enabled)}`)} + let count = 0; + walk(h.steps, () => { count++; }); + const hint = `${d.preset ? 'From the catalog' : 'Your own'} · ${plural(count, 'step')}` + + (d.handlers.length > 1 ? ` in this trigger, ${d.handlers.length} triggers` : ''); + const selected = Array.isArray(this.sel) ? this.stepAt(this.sel) : null; + Modal.body(`${modalHead('steps', 'var(--accent)', titleInput(d.name), hint, + `${enableSwitch('enabled', d.enabled)}`)}
-
${this.triggerTabs().map((label, i) => ``).join('')}
${this.choosing ? this.chooserHtml(h) : `
@@ -2583,13 +3676,16 @@ const Builder = { ${this.stepsHtml(h.steps, [], 'root')}
${this.selectedHttp() ? `
${tryHtml(this.selectedHttp(), h, 'Reply in chat with it')}
` : ''} - ${this.run ? `
Test run: ${esc(this.run.status || 'error')}
- ${this.run.error ? warnHtml(esc(this.run.error)) : this.run.status === 'running' - ? '
Running the test…
' : runLogHtml(this.run.steps)}
` : ''}`} + ${this.testOpen ? `
${testPanelHtml(this)}
` : ''}`}
${footHtml(d, this.dirty)}`); + if (this.sel === 'trigger' && !this.choosing){ + if (h.trigger.type === 'chat_activity') Meter.watch(); + if (h.trigger.type === 'scene') ObsLive.watch(); + } + if (selected && selected.type === 'obs') ObsLive.watch(); }, // Each trigger's tab label. Two alike get a number, so no two tabs read // the same and the one being edited is never in doubt. @@ -2602,18 +3698,17 @@ const Builder = { // the canvas, so the first decision is visible and can't be skipped. chooserHtml(h){ const picked = !this.unpicked.has(h); + const back = ``; if (this.choosing === 'event'){ return `
-
- -

What happens?

Pick the moment it reacts to.

- ${eventTilesHtml(picked ? h.trigger.event : '', true)}
`; +
${back}

What happens?

Pick the moment it reacts to.

+ ${eventTilesHtml(picked && h.trigger.type === 'event' ? h.trigger.event : '', true)}
`; } return `
-

How does it start?

Pick what sets it off. You can change it any time.

+
${picked ? back : ''}

How does it start?

Pick what sets it off. You can change it any time.

${TRIGGERS.map((k, n) => { const blocked = triggerBlocked(k); - return ``; }).join('')}
`; @@ -2625,18 +3720,26 @@ const Builder = { }, stepsHtml(steps, base, owner){ const here = pathKey(base); - const lit = pathKey(this.target) === here; + const lit = !this.after && pathKey(this.target) === here; const selKey = Array.isArray(this.sel) ? pathKey(this.sel) : null; + const afterKey = this.after ? pathKey(this.after) : null; const tool = (act, path, icon, label, extra) => - ``; + ``; const out = (steps || []).map((s, n) => { const path = base.concat(n), pk = pathKey(path), id = uidOf(s); const k = KINDS[s.type] || {label:s.type, tag:'?', c:'#888'}; - let html = `
- ${k.tag}${stepSentence(s)} + const off = s.enabled === false; + // Inside a switched-off check, nothing runs: nothing is missing. + const missing = off || this.insideOff(path) ? '' : stepMissing(s); + let html = `
+ + ${missing ? `${esc(missing)}` : ''} - ${tool('b-move', pk, 'up', 'Move up', ` data-dir="-1"${n === 0 ? ' disabled' : ''}`)} - ${tool('b-move', pk, 'down', 'Move down', ` data-dir="1"${n === steps.length - 1 ? ' disabled' : ''}`)} + ${tool('b-move', pk, 'up', 'Move up', ` data-dir="-1"${this.moveTarget(path, -1) ? '' : ' disabled'}`)} + ${tool('b-move', pk, 'down', 'Move down', ` data-dir="1"${this.moveTarget(path, 1) ? '' : ' disabled'}`)} + ${tool('b-toggle-step', pk, off ? 'eyeOff' : 'eye', off ? 'Switch this step on' : 'Switch this step off')} ${tool('b-dup', pk, 'copy', 'Duplicate')} ${tool('b-del', pk, 'x', 'Remove')}
`; @@ -2646,6 +3749,9 @@ const Builder = {
Otherwise
${this.stepsHtml(s.else, path.concat('else'), id + 'e')}
`; } + if (afterKey === pk){ + html += ``; + } return html; }).join(''); const empty = !base.length && !(steps || []).length @@ -2670,21 +3776,83 @@ const Builder = { const {list, index} = this.locate(path); return list[index]; }, + // Whether `count` more steps fit (64 in all) at a list `base` (checks + // nest 4 deep at most). Says why when they don't. + roomFor(base, count){ + let total = 0; + this.d.handlers.forEach(h => walk(h.steps, () => { total++; })); + if (total + count > 64){ toast('That\'s the most steps one integration can have (64)', 'info'); return false; } + if (base.filter(x => x === 'then' || x === 'else').length >= 4){ toast('Checks go 4 deep at most: add it a level up', 'info'); return false; } + return true; + }, + // Whether a step sits inside a switched-off check. + insideOff(path){ + for (let n = 1; n < path.length; n += 2){ + if (this.stepAt(path.slice(0, n)).enabled === false) return true; + } + return false; + }, + // Where one step up or down takes the step at `path`, in reading order: + // past its neighbour, into a check next to it (its Then from above, its + // Otherwise from below), between a check's Then and Otherwise, or out of + // a check. {list, at, path} as it will be once the step is taken out; + // null when it can't move that way (or it would nest too deep). + moveTarget(path, dir){ + const {list, index} = this.locate(path); + const base = path.slice(0, -1); + const to = index + dir; + let target = null; + if (to >= 0 && to < list.length){ + const next = list[to]; + if (next.type === 'if' && next.enabled !== false){ + target = dir > 0 ? {list:next.then, at:0, path:base.concat(index, 'then', 0)} + : {list:next.else, at:next.else.length, path:base.concat(to, 'else', next.else.length)}; + } else target = {list, at:to, path:base.concat(to)}; + } else if (path.length > 1){ + const branch = path[path.length - 2], parentPath = path.slice(0, -2); + const parent = this.stepAt(parentPath), {list:outer, index:at} = this.locate(parentPath); + if (branch === 'then' && dir > 0) target = {list:parent.else, at:0, path:parentPath.concat('else', 0)}; + else if (branch === 'else' && dir < 0) target = {list:parent.then, at:parent.then.length, path:parentPath.concat('then', parent.then.length)}; + else if (dir < 0) target = {list:outer, at, path:parentPath.slice(0, -1).concat(at)}; + else target = {list:outer, at:at + 1, path:parentPath.slice(0, -1).concat(at + 1)}; + } + if (!target) return null; + // Checks nest at most 4 deep, counting the step's own checks. + const height = s => 1 + Math.max(0, ...(s.then || []).concat(s.else || []).map(height)); + const depth = target.path.filter(x => x === 'then' || x === 'else').length + height(list[index]); + return depth <= 4 ? target : null; + }, inspectorHtml(){ const h = this.handler(); - const tokens = varsFor(h).map(v => ``).join(''); - const varsBox = `
Variables here
-
${tokens}
Click one to insert it in the field you last used. {name|text} uses the text when the value is empty or 0.
`; + const selected = Array.isArray(this.sel) ? this.stepAt(this.sel) : null; + // Only what this step can use: the trigger's values, what steps + // before it made, and the live ones. + const upto = selected || (h.steps || [])[0]; + const varsBox = `
Values it can use
+
${tokensHtml(varsFor(h, upto), 'b-token')}
Click a text field, then a value to put it there. {name|text} uses the text when the value is empty or 0.
`; if (this.choosing){ return `
WHEN${this.choosing === 'event' ? 'What happens?' : 'How does it start?'}

Pick it in the middle. Then add steps from the list on the left: each one runs in order.

`; } if (this.sel === 'trigger') return this.triggerInspector(h) + varsBox; - const s = this.stepAt(this.sel); - if (!s) return varsBox; - const k = KINDS[s.type]; + if (!selected) return varsBox; + const k = KINDS[selected.type]; + const off = selected.enabled === false; return `
${k.tag}${esc(k.label)}
-
${esc(k.hint)}
${this.stepFields(s)}${varsBox}`; +
${esc(k.hint)}
+ ${off ? warnHtml('This step is switched off: runs skip it. Switch it on') : ''} + ${this.stepFields(selected)}${varsBox}`; + }, + // A card's fields as rows: name, value, side by side or full width. + fieldsHtml(s){ + const fields = parseFields(s.params.fields, true); + return `
${fields.map((f, n) => `
+ + + +
`).join('')} +
`; }, stepFields(s){ const field = (name, label, opts) => { @@ -2692,46 +3860,87 @@ const Builder = { const v = s.params[name] || ''; if (opts.select) return `
`; if (opts.area) return `
`; - return `
`; + const input = ``; + // Addresses can carry keys (?key=, a Hue username): hidden until Show. + if (opts.secret) return secretField(`p-${name}-${uidOf(s)}`, input, label); + return `
${input}
`; + }; + // A length of time, or a value like {arg1} kept as written. + const duration = (name, label, unit, fallback) => { + const v = s.params[name] || ''; + if (/\{/.test(v)) return field(name, label + ' (ms)', {mono:true}); + return `
${durInput('p-dur', v === '' ? fallback : +v, unit, ` data-name="${name}"`)}
`; }; const note = text => `
${text}
`; const channels = S.data.connections.discord.map(c => [c.id, '#' + c.name]); switch (s.type){ - case 'discord': return (channels.length ? field('connection', 'Channel', {select:[['', 'Pick a channel']].concat(channels)}) : note('No Discord channel yet. Add one.')) - + field('text', 'Message', {area:true}) - + field('edit', 'Message', {select:[['', 'Send a new one'], ['last', 'Edit the last one it sent there']]}) - + (s.params.edit === 'last' ? note('A drop stays one message: "down", then "back". If the last one was deleted, a new one goes out.') - : field('ping', 'Ping', {select:[['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']]})) - + note('<t:{hold_ends_at}:R> shows a live countdown in Discord.'); + case 'discord': { + const card = s.params.style === 'card'; + const edit = s.params.edit || ''; + const color = cardColor(s.params.color); + const look = card ? field('title', 'Title', {ph:'🔴 {destination} is down'}) + + `
${CARD_COLORS.map(([c, label]) => + ``).join('')} +
` + + field('text', 'Card text', {area:true, ph:'Since {down_for}. **Bold**, `code` and [links](https://…) work.'}) + + `
${this.fieldsHtml(s)}
` + + field('footer', 'Footer', {ph:'InstantClone'}) + + field('timestamp', 'Time in the footer', {select:[['yes', 'Yes'], ['', 'No']]}) + + field('above', 'Line above the card', {ph:'{clip.url} shows a video player'}) + + `
Links and images${field('url', 'Title links to', {mono:true, ph:'https://twitch.tv/{channel}'}) + + field('image', 'Big image', {mono:true, ph:'https://…/image.png'}) + field('thumbnail', 'Small image (top right)', {mono:true, ph:'https://…'})}
` + : field('text', 'Message', {area:true}); + return (channels.length ? field('connection', 'Channel', {select:[['', 'Pick a channel']].concat(channels)}) : note('No Discord channel yet. Add one.')) + + `
${previewHtml(s, this.handler(), true)}
` + + field('style', 'Look', {select:[['card', 'Card'], ['', 'Plain message']]}) + + look + + field('edit', 'Each time it runs', {select:[['', 'Post a new message'], ['last', 'Update the same message'], ['close', 'Update it, then start fresh']]}) + + (edit ? note(edit === 'close' ? 'With nothing to finish (a short blip never got a message), it stays quiet.' + : 'If someone deleted that message, a new one goes out.') + + field('key', 'Message group (optional)', {mono:true, ph:'{destination}'}) + + note('Steps with the same group update the same message. {destination} gives each platform its own.') : '') + + (edit !== 'close' ? field('ping', 'Ping', {select:[['', 'Nobody'], ['here', '@here'], ['everyone', '@everyone']]}) + + (edit === 'last' && s.params.ping ? note('Only when it posts a new message: an update never pings.') : '') : '') + + note('<t:{hold_ends_at}:R> shows a live countdown in Discord.'); + } + case 'timeline': return field('text', 'Line', {area:true, ph:'⭐ Highlight · [clip]({clip.url})'}) + + field('kind', 'It is', {select:Object.entries(TIMELINE_KINDS)}) + + field('at', 'It marks', {select:[['live', 'What you do now (lands after the delay)'], ['aired', 'What viewers see now']]}) + + note('The timeline is this stream\'s, shared by every integration: {timeline} shows it, {chapters} lists the chapters for YouTube. A new stream starts a new one.') + + note('A button or a scene is pressed live: "what you do now". A mod\'s chat command is about what they saw: "what viewers see now".'); case 'chat': return field('text', 'Message', {area:true}) + field('as', 'Send as', {select:[['', 'Your account'], ['bot', 'Bot account']]}) + field('reply', 'Reply to the viewer', {select:[['', 'No'], ['yes', 'Yes, in their thread']]}); case 'phone': return field('title', 'Title') + field('text', 'Message', {area:true}) + field('priority', 'Priority', {select:[['', 'Normal'], ['high', 'High'], ['urgent', 'Urgent']]}); case 'http': return field('method', 'Method', {select:['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map(m => [m, m])}) - + field('url', 'Address', {mono:true, ph:'https://api.example/rank/{arg1}'}) + + field('url', 'Address', {mono:true, secret:true, ph:'https://api.example/rank/{arg1}'}) + note('Values from chat are encoded for you, so a viewer can only fill in their part. Keep API keys in Headers: they stay hidden here and never go into recipes.') + secretField('headers-' + uidOf(s), ``, 'Headers (one per line, Name: value)') - + field('body', 'Body', {area:true, ph:'{"event":"{delay}"}'}) + + field('body', 'What it sends', {area:true, ph:'{"event":"{delay}"}'}) + field('save_as', 'Save the answer as', {mono:true, ph:'response'}) + note(`Later steps can use {${esc(s.params.save_as || 'response')}.status}, .ok, .body and .json.field.`) + note('Try it under the steps: send the request for real and pick what to use from the answer.'); - case 'wait': return field('ms', 'Wait (milliseconds)', {mono:true, ph:'20000'}) + note(`${esc(fmtMs(s.params.ms) || '0 s')}. Up to an hour.`); + case 'wait': return duration('ms', 'Wait', 's', 10000) + note('Like 20s or 2m. Up to an hour.'); case 'wait_delay': return '

Waits until the moment this started has reached your viewers. Only InstantClone can do this: it knows your delay.

' - + field('extra_ms', 'Then wait a bit more (ms)', {mono:true, ph:'0'}) + field('follow', 'If the delay changes meanwhile', {select:[['', 'Follow it'], ['no', 'Keep the delay from the start']]}); + + duration('extra_ms', 'Then wait a bit more', 's', 0) + field('follow', 'If the delay changes meanwhile', {select:[['', 'Follow it'], ['no', 'Keep the delay from the start']]}); case 'if': return field('left', 'Check', {mono:true, ph:'{user_role}'}) + field('op', 'Is', {select:Object.entries(OPS)}) + field('right', 'Value', {mono:true, ph:'mod'}) + note('Text compares ignoring case; "is more than" compares numbers.'); case 'stop': return note('Ends this run here.'); - case 'delay_action': return field('action', 'Action', {select:Object.entries(DELAY_ACTIONS)}) - + (s.params.action === 'arm' ? field('seconds', 'Delay (seconds)', {mono:true, ph:'{arg1}'}) - + note('Arms this delay, or changes it live when a delay is already on air. Never disarms.') : '') + case 'delay_action': return field('action', 'Does', {select:Object.entries(DELAY_ACTIONS)}) + + (s.params.action === 'arm' ? field('seconds', 'Delay in whole seconds (0 turns it off), or a value like {arg1}', {mono:true, ph:'30'}) + + note('Starts this delay, or changes it while one is on air. Never cancels it.') : '') + warnHtml('This changes your stream. Tests never run it.'); case 'marker': return field('description', 'Label', {ph:'Crash'}) + note('Only works while you are live on Twitch.'); - case 'clip': return note('Clips the last moments of your stream. Later steps can use {clip.url} and {clip.ok}.'); - case 'program': return field('path', 'Program (a fixed path, no variables)', {mono:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) + case 'clip': return note('Clips the last moments of your stream. Later steps can use {clip.url}, {clip.ok} and, when it failed, {clip.error}.'); + case 'program': return (localLocked() ? warnHtml(esc(LOCAL_LOCK_TEXT)) : '') + + field('path', 'Program (a fixed path, no variables)', {mono:true, fixed:true, ph:'C:\\Tools\\thing.exe'}) + field('args', 'Arguments', {mono:true, ph:'--scene "Replay"'}) + + note('Values like {arg1} keep only letters, numbers, spaces and _ . , : # + = / -, so chat can\'t sneak in a command of its own.') + warnHtml('Runs on your PC. Recipes you import can never add this without asking you first.'); - case 'file': return field('path', 'File (a fixed path, no variables)', {mono:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); - case 'set_var': return field('name', 'Name', {mono:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + note('Later steps use it as {name}. Lasts for this run.'); + case 'file': return (localLocked() ? warnHtml(esc(LOCAL_LOCK_TEXT)) : '') + field('path', 'File (a fixed path, no variables)', {mono:true, fixed:true, ph:'C:\\Stream\\status.txt'}) + field('text', 'Text', {area:true}) + field('mode', 'Each time', {select:[['', 'Replace the text'], ['append', 'Add a line']]}); + case 'set_var': return field('name', 'Name', {mono:true, fixed:true, ph:'winner'}) + field('value', 'Value', {ph:'{user}'}) + note('Later steps use it as {name}. Lasts for this run.'); case 'overlay': return field('title', 'Small title', {ph:'Clip'}) + field('text', 'Text', {area:true, ph:'{user} just clipped that'}) - + field('seconds', 'On screen for', {select:[['3', '3 s'], ['6', '6 s'], ['10', '10 s'], ['15', '15 s'], ['30', '30 s']]}) + + field('seconds', 'On screen for', {select:[['3', '3 s'], ['6', '6 s'], ['10', '10 s'], ['15', '15 s'], ['30', '30 s']] + .concat(['', '3', '6', '10', '15', '30'].includes(s.params.seconds || '') ? [] : [[s.params.seconds, s.params.seconds + ' s']])}) + alertsSetupHtml() + note('Tests never put anything on stream: use "Show a test alert" above.'); case 'edit_text': { const op = EDIT_OPS[s.params.op] || EDIT_OPS.first_line; @@ -2739,53 +3948,82 @@ const Builder = { return field('input', 'Text', {mono:true, ph:'{api.body}'}) + field('op', 'Do', {select:Object.entries(EDIT_OPS).map(([id, o]) => [id, o[0]])}) + (op[1] ? field('a', op[1], {mono:true}) : '') + (op[2] ? field('b', op[2], {mono:true}) : '') - + field('save_as', 'Save it as', {mono:true, ph:'text'}) + + field('save_as', 'Save it as', {mono:true, fixed:true, ph:'text'}) + note(`Gives ${esc(got) || '(nothing)'} with the sample values.${s.params.op === 'random' ? ' Each run picks one of the lines, or one of the parts between |.' : ''}`) + note(`Later steps use it as {${esc((s.params.save_as || '').trim() || 'text')}}.`); } case 'counter': return field('name', 'Name', {mono:true, ph:'crashes'}) + field('op', 'Do', {select:[['', 'Add'], ['subtract', 'Subtract'], ['set', 'Set to'], ['reset', 'Reset to 0']]}) + field('by', 'By', {mono:true, ph:'1'}) + note(`Kept between runs. Use it anywhere as {counter.${esc(s.params.name || 'name')}}.`); + case 'obs': return this.obsFields(s, field, note); } return ''; }, + // An OBS step: what it does, and the scene or source, picked from OBS's + // own lists while it's connected. + obsFields(s, field, note){ + const action = s.params.action || 'scene'; + const o = ObsLive.status; + const pick = (items, name) => items.length ? `
${items.map(x => + ``).join('')}
` : ''; + const named = (name, label, ph, items) => `
${pick(items, name)} +
`; + const scenes = (o && o.scenes) || [], sources = (o && o.sources) || []; + let body = field('action', 'Does', {select:Object.entries(OBS_ACTIONS).map(([id, [label]]) => [id, label])}); + if (action === 'scene') body += named('scene', 'Scene', 'Scene name', scenes); + else { + body += named('source', action === 'text' ? 'Text source' : 'Source', 'Source name', sources); + body += action === 'text' ? field('text', 'Text', {area:true, ph:'Crashes today: {counter.crashes}'}) + : named('scene', 'In the scene (blank: the one on air)', 'the scene on air', scenes); + } + return `
${obsStatusHtml()}
${body}` + + note('Scene and source names are fixed here, so chat can never pick them. Tests never touch OBS.'); + }, triggerInspector(h){ const t = h.trigger; - const typeSel = `
${TRIGGERS.map(k => { - const blocked = triggerBlocked(k); - return ``; - }).join('')}
`; + const kind = TRIGGERS.find(k => k.id === t.type) || TRIGGERS[0]; + const typeSel = `
`; let body = ''; if (t.type === 'event'){ const current = eventOf(t.event), info = EVENT_INFO[t.event] || {icon:'bolt', desc:''}; body = `
`; - const e = eventOf(t.event); - (e ? e.vars : []).filter(v => FILTER_CHOICES[v.name] || v.name === 'destination').forEach(v => { - const cur = (t.filters || {})[v.name] || ''; - const ch = FILTER_CHOICES[v.name]; - body += `
${ch - ? `` - : ``}
`; - }); + body += paramsHtml(t, 't-'); + const filters = Editor.filterVars(t); + if (filters.length){ + body += `
Only if
${filters.map(v => { + const cur = (t.filters || {})[v.name] || ''; + const listed = filterChoices(t.event, v.name); + const ch = listed && (!cur || listed.includes(cur)) ? listed : null; + return `
${ch + ? `` + : ``}
`; + }).join('')}
Several at once: YouTube, Kick. A * matches anything: *timed out*.
`; + } + } else if (t.type === 'chat_activity'){ + body = rulesHtml(t, 't-'); + } else if (t.type === 'scene'){ + body = sceneHtml(t, 't-', this.d); } else if (t.type === 'chat_command'){ body = `
${rolesHtml(t, 't-role')}
-
`; +
${durInput('t-ucd-dur', t.user_cooldown_ms || 0, 's')}
`; } else if (t.type === 'chat_message'){ body = `
${rolesHtml(t, 't-role')}
`; } else if (t.type === 'timer'){ - body = `
+ body = `
${durInput('t-every-dur', t.every_ms || 600000, 'm')}
+
Like 15m or 1h. Once a minute at most.
`; } else if (t.type === 'webhook'){ const url = `${location.origin}/hooks/${t.token || ''}`; body = secretField('hook', `
`, 'Call this address (keep it secret)') + ` -
GET or POST from a Stream Deck, a script or any app on this network. The body is {body}; JSON fields are {body.field}.
+
GET or POST from a Stream Deck, a script or any app on this network. The body is {body}; JSON fields are {body.field}; ?team=red is {query.team}.
`; } else if (t.type === 'shortcut'){ body = shortcutHtml(t); @@ -2797,27 +4035,36 @@ const Builder = {
` : ''; return `
WHEN${esc(triggerLabel(t))}
${manage}${typeSel}${body} -
For the whole integration
-
Every trigger shares these.
-
+
Every trigger here
+
${durInput('b-cooldown-dur', this.d.cooldown_ms || 0, 's')}
+
0s: no limit. Each trigger counts its own time. A button pressed too soon shows as skipped in Recent activity.
${quietHtml(this.d)}
`; }, input(el){ const b = el.dataset.bind, h = this.handler(), t = h.trigger; + const durationOf = (min, max) => durValue(el, min, max); if (b === 'name') this.d.name = el.value; else if (b === 'enabled') this.d.enabled = el.checked; - else if (b === 'b-cooldown') this.d.cooldown_ms = Math.max(0, (parseFloat(el.value) || 0) * 1000); + else if (b === 'b-cooldown-dur'){ const ms = durationOf(0, 86400000); if (ms == null) return; this.d.cooldown_ms = ms; } else if (b === 'h-enabled') h.enabled = el.checked; else if (b === 't-filter'){ t.filters = t.filters || {}; t.filters[el.dataset.name] = el.value.trim(); } else if (b === 't-command') t.command = el.value.trim().toLowerCase(); else if (b === 't-aliases') t.aliases = el.value.split(/[\s,]+/).map(x => x.trim().toLowerCase()).filter(Boolean); else if (b === 't-role'){ t.roles = t.roles || {}; t.roles[el.dataset.name] = el.checked; } - else if (b === 't-ucd') t.user_cooldown_ms = Math.max(0, (parseFloat(el.value) || 0) * 1000); + else if (b === 't-ucd-dur'){ const ms = durationOf(0, 86400000); if (ms == null) return; t.user_cooldown_ms = ms; } else if (b === 't-pattern') t.pattern = el.value; else if (b === 't-mode') t.mode = el.value; - else if (b === 't-every') t.every_ms = Math.max(1, parseFloat(el.value) || 1) * 60000; + else if (b === 't-every-dur'){ const ms = durationOf(60000, 7 * 86400000); if (ms == null) return; t.every_ms = ms; } else if (b === 't-live') t.only_live = el.checked; + else if (b === 't-param'){ if (!paramInput(t, el)) return; } + else if (rulesInput(t, el, 't-') || sceneInput(t, el, 't-') || onlyLiveInput(t, el, 't-') || onlyLiveInput(t, el, '')){ /* changed below */ } else if (b === 'p') this.stepAt(this.sel).params[el.dataset.name] = el.value; + else if (b === 'p-dur'){ const ms = durationOf(0, 3600000); if (ms == null) return; this.stepAt(this.sel).params[el.dataset.name] = String(ms); } + else if (b === 'bf-name' || b === 'bf-value'){ + const s = this.stepAt(this.sel); + Editor.fields(s, list => { const f = list[+el.dataset.n]; if (f) f[b === 'bf-name' ? 'name' : 'value'] = el.value; }); + } + else if (b === 'test-value'){ this.testValues[el.dataset.name] = el.value; return; } else if (b === 'try'){ // Values to try the request with; not part of the integration. tryEntry(this.stepAt(this.sel)).inputs[el.dataset.name] = el.value; @@ -2828,80 +4075,149 @@ const Builder = { }, changed(){ this.dirty = true; + this.edits = (this.edits || 0) + 1; + syncSceneRules(this.d); this.render(); }, act(a, el){ const d = this.d; + if (rulesAct(this.handler().trigger, a, el) || sceneAct(this.handler().trigger, a, el)){ this.changed(); return true; } + const selected = () => Array.isArray(this.sel) ? this.stepAt(this.sel) : null; switch (a){ - case 'b-sel': this.sel = el.dataset.path === 'trigger' ? 'trigger' : this.resolve(el.dataset.path); this.render(); return true; - case 'b-target': this.target = this.resolve(el.dataset.path); this.render(); return true; + case 'b-color': { const s = selected(); if (s) s.params.color = el.dataset.v; this.changed(); return true; } + case 'b-obs-pick': { const s = selected(); if (s) s.params[el.dataset.name] = el.dataset.v; this.changed(); return true; } + case 'bf-add': { const s = selected(); if (s) Editor.fields(s, list => list.push({name:'', value:'', inline:true})); this.changed(); return true; } + case 'bf-del': { const s = selected(); if (s) Editor.fields(s, list => list.splice(+el.dataset.n, 1)); this.changed(); return true; } + case 'bf-inline': { + const s = selected(); + if (s) Editor.fields(s, list => { const f = list[+el.dataset.n]; if (f) f.inline = !f.inline; }); + this.changed(); + return true; + } + case 'b-sel': { + this.sel = el.dataset.path === 'trigger' ? 'trigger' : this.resolve(el.dataset.path); + // New blocks land right after the step picked. + this.after = Array.isArray(this.sel) ? this.sel : null; + this.render(); + return true; + } + case 'b-target': this.target = this.resolve(el.dataset.path); this.after = null; this.render(); return true; case 'b-add': { - let list = null; - try { - const owner = this.target.length ? this.stepAt(this.target.slice(0, -1)) : null; - if (!this.target.length || (owner && owner.type === 'if')) list = this.listAt(this.target); - } catch(_){ list = null; } - if (!list){ this.target = []; list = this.handler().steps; } - list.push(newStep(el.dataset.kind)); - this.sel = this.target.concat(list.length - 1); - if (el.dataset.kind === 'if') this.target = this.sel.concat('then'); + if (LOCAL_KINDS.includes(el.dataset.kind) && localLocked()){ toast(LOCAL_LOCK_TEXT, 'info', 7000); return true; } + const kind = el.dataset.kind, step = newStep(kind, this.handler().trigger); + let list = null, at = 0, base = []; + if (this.after){ + try { const found = this.locate(this.after); list = found.list; at = found.index + 1; base = this.after.slice(0, -1); } catch(_){ list = null; } + } + if (!list){ + try { + const owner = this.target.length ? this.stepAt(this.target.slice(0, -1)) : null; + if (!this.target.length || (owner && owner.type === 'if')) list = this.listAt(this.target); + } catch(_){ list = null; } + if (!list){ this.target = []; list = this.handler().steps; } + at = list.length; + base = this.target; + } + if (!this.roomFor(base, 1)) return true; + list.splice(at, 0, step); + this.sel = base.concat(at); + // A check's blocks go inside it; anything else, right after it. + if (kind === 'if'){ this.target = this.sel.concat('then'); this.after = null; } + else this.after = this.sel; this.changed(); const added = q('.ig-step.on'); if (added) added.scrollIntoView({block:'nearest', behavior:reduced() ? 'auto' : 'smooth'}); return true; } case 'b-move': { - const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); - const to = index + (+el.dataset.dir); - if (to < 0 || to >= list.length) return true; - [list[index], list[to]] = [list[to], list[index]]; - this.sel = path.slice(0, -1).concat(to); + const path = this.resolve(el.dataset.path); + const target = this.moveTarget(path, +el.dataset.dir); + if (!target) return true; + const {list, index} = this.locate(path); + const [step] = list.splice(index, 1); + target.list.splice(target.at, 0, step); + this.sel = target.path; + this.after = null; this.target = []; this.changed(); + // Keep the keyboard on the step that moved. + const moved = q(`.ig-step[data-path="${pathKey(target.path)}"] [data-act="b-move"][data-dir="${el.dataset.dir}"]`); + if (moved && !moved.disabled) moved.focus({preventScroll:true}); + return true; + } + case 'b-toggle-step': { + const s = this.stepAt(this.resolve(el.dataset.path)); + if (s.enabled === false) delete s.enabled; else s.enabled = false; + this.changed(); return true; } case 'b-dup': { const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); + let size = 0; + walk([list[index]], () => { size++; }); + if (!this.roomFor([], size)) return true; list.splice(index + 1, 0, clone(list[index])); this.sel = path.slice(0, -1).concat(index + 1); + this.after = this.sel; this.target = []; this.changed(); return true; } case 'b-del': { const path = this.resolve(el.dataset.path), {list, index} = this.locate(path); - list.splice(index, 1); + const [gone] = list.splice(index, 1); + const inside = gone.type === 'if' && (gone.then.length || gone.else.length); this.sel = 'trigger'; + this.after = null; this.target = []; this.changed(); + const handler = this.handler(); + undoToast(`Removed ${(KINDS[gone.type] || {label:'a step'}).label}${inside ? ' and the steps inside it' : ''}`, () => { + if (Modal.kind !== 'builder' || this.d !== d) return; + // Its list must still be part of the integration. + let attached = d.handlers.includes(handler) && list === handler.steps; + walk(handler.steps, x => { attached = attached || x.then === list || x.else === list; }); + if (!d.handlers.includes(handler) || !attached){ toast('Couldn\'t put it back: what held it was removed too', 'info'); return; } + list.splice(Math.min(index, list.length), 0, gone); + this.h = d.handlers.indexOf(handler); + this.sel = 'trigger'; + this.after = null; + this.changed(); + }); return true; } case 'b-handler': - this.h = +el.dataset.n; this.sel = 'trigger'; this.target = []; this.run = null; + this.leaveChooser(); + this.h = +el.dataset.n; this.sel = 'trigger'; this.target = []; this.after = null; this.resetTest(); this.choosing = this.unpicked.has(this.handler()) ? 'kind' : null; this.render(); return true; case 'b-add-handler': { + this.leaveChooser(); const added = {enabled:true, trigger:newTrigger('event'), steps:[]}; d.handlers.push(added); this.unpicked.add(added); this.choosing = 'kind'; - this.h = d.handlers.length - 1; this.sel = 'trigger'; this.target = []; this.run = null; + this.h = d.handlers.length - 1; this.sel = 'trigger'; this.target = []; this.after = null; this.resetTest(); this.changed(); return true; } case 'b-del-handler': if (d.handlers.length < 2 || !armConfirm(el, 'Click again: removes it and its steps')) return true; d.handlers.splice(this.h, 1); - this.h = 0; this.sel = 'trigger'; this.target = []; this.run = null; + this.h = 0; this.sel = 'trigger'; this.target = []; this.after = null; this.resetTest(); this.changed(); return true; + case 'b-change-kind': + this.cancelTrigger = clone(this.handler().trigger); + this.choosing = 'kind'; + this.render(); + return true; case 'b-trigger-type': { const h = this.handler(), v = el.dataset.v; if (v === 'event'){ - // Which event is the next question, asked in the canvas. - this.cancelTrigger = this.unpicked.has(h) ? null : clone(h.trigger); - if (h.trigger.type !== 'event') h.trigger = newTrigger('event'); + // Which event is the next question, asked in the canvas. The + // trigger only changes once one is picked. this.choosing = 'event'; this.render(); return true; @@ -2909,10 +4225,11 @@ const Builder = { if (h.trigger.type !== v || this.unpicked.has(h)) h.trigger = newTrigger(v); this.unpicked.delete(h); this.choosing = null; + this.cancelTrigger = null; this.sel = 'trigger'; this.changed(); if (v === 'shortcut') focusAct('key-record'); - const first = {chat_command:'t-command', chat_message:'t-pattern', timer:'t-every'}[v]; + const first = {chat_command:'t-command', chat_message:'t-pattern'}[v]; if (first){ // The starting value is a placeholder: typing replaces it. focusField(first); @@ -2948,8 +4265,13 @@ const Builder = { return true; } case 'b-new-token': this.handler().trigger.token = randomToken(); this.changed(); return true; - case 'b-token': insertAtCursor(this.lastField, '{' + el.dataset.token + '}'); return true; - case 'b-test': if (this.askToPick()) busy(el, () => this.test()); return true; + case 'b-token': + if (!this.lastField || !this.lastField.isConnected){ toast('Click a text field first, then the value to put in it', 'info'); return true; } + insertAtCursor(this.lastField, '{' + el.dataset.token + '}'); + return true; + case 'b-test': this.leaveChooser(); this.testOpen = true; this.render(); this.reveal('.ig-run-box'); focusAct('test-run'); return true; + case 'test-run': if (this.askToPick()) busy(el, () => this.test()); return true; + case 'test-mode': this.testDry = el.dataset.v === 'dry'; this.render(); return true; case 'b-try': busy(el, async () => { await tryRequest(this.selectedHttp(), this.handler()); @@ -2965,7 +4287,14 @@ const Builder = { return true; case 'b-use-reply': this.replyWithPick(); return true; case 'delete': if (armConfirm(el, 'Delete for good?')) busy(el, () => remove(d.id)); return true; - case 'save': if (this.askToPick()) busy(el, () => saveIntegration(d, () => { this.dirty = false; })); return true; + // The builder stays open after saving: building is trying things. + case 'save': { + this.leaveChooser(); + if (!this.askToPick()) return true; + const at = this.edits; + busy(el, () => saveIntegration(d, () => { if (this.edits === at) this.dirty = false; }, {stay:true})); + return true; + } } return false; }, @@ -2980,15 +4309,24 @@ const Builder = { if (next && next.type === 'chat'){ next.params.text = ((next.params.text || '').trimEnd() + ' ' + token).trim(); } else { - const chat = newStep('chat'); + const chat = newStep('chat', this.handler().trigger); const trig = this.handler().trigger; chat.params.text = trig.type.startsWith('chat') ? `{user}: ${token}` : token; list.splice(index + 1, 0, chat); } this.sel = this.sel.slice(0, -1).concat(index + 1); + this.after = this.sel; this.target = []; this.changed(); }, + // Leaving the chooser any other way than picking keeps the trigger as + // it was. + leaveChooser(){ + if (!this.choosing || this.unpicked.has(this.handler())) return; + if (this.cancelTrigger) this.handler().trigger = this.cancelTrigger; + this.cancelTrigger = null; + this.choosing = null; + }, // A trigger still waiting for "How does it start?" can't be saved or run. // Shows it and says so; true when every trigger is picked. askToPick(){ @@ -3012,9 +4350,11 @@ const Builder = { if (el) el.scrollIntoView({block:'nearest', behavior:reduced() ? 'auto' : 'smooth'}); }, async test(){ - this.run = {status:'running', steps:[]}; + this.testOpen = true; + this.run = {running:true}; this.render(); - const r = await api('/integrations/test', {integration:this.d, handler:this.h}); + this.reveal('.ig-run-box'); + const r = await api('/integrations/test', {integration:this.d, handler:this.h, values:testValues(this), dry:this.testDry}); this.run = r.ok ? r : {error:r.error || 'The test failed'}; if (Modal.kind === 'builder') this.render(); }, @@ -3026,30 +4366,48 @@ function newTrigger(type){ case 'chat_message': return {type, pattern:'', mode:'contains', roles:{everyone:true, subs:true, vips:true, mods:true}}; case 'timer': return {type, every_ms:600000, only_live:true}; case 'webhook': return {type, token:randomToken()}; - case 'shortcut': return {type, hotkey:'', midi:''}; + case 'shortcut': return {type, hotkey:'', midi:'', token:'', only_live:false}; + case 'chat_activity': return {type, window_ms:15000, match:'all', only_live:true, + roles:{everyone:true, subs:true, vips:true, mods:true}, + rules:[{kind:'busier', value:3, words:''}, {kind:'chatters', value:5, words:''}]}; + case 'scene': return {type, scene:'', from:'', except:'', settle_ms:3000, only_live:false}; } return {type:'event', event:'hold_opened', filters:{}}; } -function newStep(kind){ +// A new step of `kind`, set up for `trigger`: a timeline line from a +// button or a scene marks what you do now, one from chat what viewers saw. +function newStep(kind, trigger){ + const live = trigger && (trigger.type === 'shortcut' || trigger.type === 'scene' + || (trigger.type === 'event' && ['hold_opened', 'obs_back'].includes(trigger.event))); const p = { - discord:{connection:(S.data.connections.discord[0] || {}).id || '', text:''}, + discord:{connection:(S.data.connections.discord[0] || {}).id || '', style:'card', color:'#5ac8fa', title:'', text:'', + footer:'InstantClone', timestamp:'yes'}, + timeline:{text:'', kind:'note', at:live ? 'live' : 'aired'}, chat:{text:''}, phone:{title:'InstantClone', text:''}, http:{method:'POST', url:'', body:''}, wait:{ms:'10000'}, wait_delay:{}, if:{left:'', op:'is', right:''}, stop:{}, delay_action:{action:'cut'}, marker:{description:'Highlight'}, clip:{}, program:{path:'', args:''}, file:{path:'', text:'', mode:''}, set_var:{name:'', value:''}, counter:{name:'count', op:'', by:'1'}, overlay:{title:'', text:'', seconds:'6'}, edit_text:{input:'', op:'first_line', a:'', b:'', save_as:'text'}, + obs:{action:'scene', scene:'', source:'', text:''}, }[kind] || {}; return {type:kind, params:clone(p), then:[], else:[]}; } function triggerSentence(t){ switch (t.type){ - case 'event': { const f = Object.entries(t.filters || {}).filter(([, v]) => v).map(([k, v]) => `${k} is ${v}`); - return triggerLabel(t) + (f.length ? ' · only if ' + f.join(', ') : ''); } + case 'event': { + const f = Object.entries(t.filters || {}).filter(([k, v]) => v && !isParam(t, k)).map(([k, v]) => `${filterName(k)}: ${v}`); + const tune = tuneSummary(t); + return triggerLabel(t) + (tune ? ` · ${tune.k.toLowerCase()} ${tune.v}` : '') + (f.length ? ' · only if ' + f.join(', ') : ''); + } case 'chat_command': return `Someone types ${t.command || '!command'} · ${rolesLabel(t.roles)}`; case 'chat_message': return `A chat message ${t.mode === 'exact' ? 'is' : t.mode === 'starts_with' ? 'starts with' : 'matches'} "${t.pattern || ''}"`; - case 'timer': return `Every ${Math.round((t.every_ms || 0) / 60000)} min${t.only_live !== false ? ' while streaming' : ''}`; + case 'timer': return `Every ${fmtDur(t.every_ms || 0)}${t.only_live !== false ? ' while streaming' : ''}`; case 'webhook': return 'Something calls its secret address'; - case 'shortcut': return shortcutLabel(t) ? `You press ${shortcutLabel(t)}` : 'You press a hotkey or pad (not set yet)'; + case 'shortcut': return (shortcutLabel(t) ? `You press ${shortcutLabel(t)}` : 'You press a button (not set yet)') + (t.only_live ? ' while streaming' : ''); + case 'chat_activity': return `Chat gets busy: ${activitySummary(t)} in ${Math.round((t.window_ms || 10000) / 1000)} s`; + case 'scene': return t.scene + ? `OBS switches to ${sceneLabel(t)}${t.except && t.scene !== '*' ? ' (not ' + t.except + ')' : ''}${t.from ? ' from ' + t.from : ''}${t.settle_ms ? ' and stays ' + Math.round(t.settle_ms / 1000) + ' s' : ''}${t.only_live ? ' while streaming' : ''}` + : 'OBS switches to a scene (not picked yet)'; } return t.type; } @@ -3057,15 +4415,17 @@ function stepSentence(s){ const p = s.params || {}; const q = v => `“${highlightVars(v || '')}”`; switch (s.type){ - case 'discord': return `Discord ${esc(channelName(p.connection))} ${q(p.text)}${p.edit === 'last' ? ' · edits the last one' : p.ping ? ' · @' + esc(p.ping) : ''}`; + case 'discord': return `Discord ${p.style === 'card' ? 'card ' : ''}${esc(channelName(p.connection))} ${q(p.style === 'card' ? p.title || p.text : p.text)}${p.edit === 'last' ? ' · updates the same message' : p.edit === 'close' ? ' · updates it, then starts fresh' : p.ping ? ' · @' + esc(p.ping) : ''}`; + case 'timeline': return `Timeline ${esc((TIMELINE_KINDS[p.kind] || TIMELINE_KINDS.note).toLowerCase())} ${q(p.text)}`; case 'chat': return `Chat ${q(p.text)}${p.as === 'bot' ? ' · as bot' : ''}${p.reply === 'yes' ? ' · as a reply' : ''}`; case 'phone': return `Phone ${q(p.text)}`; case 'http': return `${esc(p.method || 'POST')} ${esc(maskUrl(p.url) || '(address)')}${p.save_as ? ' → ' + esc(p.save_as) : ''}`; - case 'wait': return `Wait ${esc(fmtMs(p.ms) || '0 s')}`; - case 'wait_delay': return 'Wait until it has aired for viewers {delay}'; + case 'wait': return `Wait ${/\{/.test(p.ms || '') ? highlightVars(p.ms) + ' ms' : esc(fmtDur(p.ms))}`; + case 'wait_delay': return 'Wait until it has aired for viewers {delay}' + (+p.extra_ms ? ' + ' + esc(fmtDur(p.extra_ms)) : ''); case 'if': return `If ${highlightVars(p.left || '…')} ${esc(OPS[p.op] || p.op || '')} ${highlightVars(p.right || '')}`; case 'stop': return 'Stop here'; - case 'delay_action': return esc(DELAY_ACTIONS[p.action] || 'Delay action') + (p.action === 'arm' && p.seconds ? ' to ' + highlightVars(p.seconds) + ' s' : ''); + case 'delay_action': return p.action === 'arm' && p.seconds && Number(p.seconds) <= 0 ? 'Turn the delay off' + : p.action === 'arm' && p.seconds ? `Turn on a ${highlightVars(p.seconds)} s delay` : esc(DELAY_ACTIONS[p.action] || 'Delay action'); case 'marker': return `VOD marker ${q(p.description)}`; case 'clip': return 'Create a clip'; case 'program': return `Run ${esc((p.path || '(program)').split(/[\\/]/).pop())} ${highlightVars(p.args || '')}`; @@ -3074,6 +4434,12 @@ function stepSentence(s){ case 'overlay': return `On stream ${q(p.text)}`; case 'edit_text': return `${esc((EDIT_OPS[p.op] || EDIT_OPS.first_line)[0])}: ${highlightVars(p.input || '…')} → {${esc((p.save_as || '').trim() || 'text')}}`; case 'counter': return `Counter ${esc(p.name || '…')} ${p.op === 'reset' ? 'reset' : p.op === 'set' ? '= ' + esc(p.by || '0') : (p.op === 'subtract' ? '-' : '+') + esc(p.by || '1')}`; + case 'obs': { + const what = (OBS_ACTIONS[p.action] || ['OBS'])[0]; + if (p.action === 'scene') return `OBS: switch to ${q(p.scene)}`; + if (p.action === 'text') return `OBS: ${esc(p.source || '(source)')} says ${q(p.text)}`; + return `OBS: ${esc(what.replace(/ a source.*$/, '').toLowerCase())} ${q(p.source)}${p.scene ? ' in ' + esc(p.scene) : ''}`; + } } return esc(s.type); } @@ -3127,9 +4493,17 @@ const Conn = { return warnHtml('This copy of InstantClone has no Twitch app, so it can\'t log in to Twitch yet. ' + 'You can add your own in a couple of minutes: System › Twitch login app.'); } - return (t.notice ? warnHtml(esc(t.notice)) : '') + const other = S.data.twitch_other_channel && t.main.login + ? warnHtml(`You're logged in as ${esc(t.main.login)}, but your Twitch destination streams to another channel. ` + + `VOD markers and clips go to ${esc(t.main.login)}'s channel, and fail while it's offline. ` + + 'To use them on the channel you stream to, disconnect and log in with that account.') + : ''; + return (t.notice ? warnHtml(esc(t.notice)) : '') + other + this.accountHtml('main', t.main, 'Your Twitch account', 'Chat, VOD markers and clips. Log in once: InstantClone keeps it fresh.') + this.accountHtml('bot', t.bot, 'Bot account (optional)', 'A second account that posts in chat instead of you.') + + `` + '
No password ever goes through InstantClone: you approve it on twitch.tv, and can revoke it there any time.' + (t.app === 'custom' ? ' Logging in through your own Twitch app (change).' : '') + '
'; }, @@ -3200,7 +4574,8 @@ const Conn = { case 'd-del': if (!armConfirm(el, 'Remove?')) return true; await busy(el, async () => { - await api('/connections/discord/delete', {id:el.dataset.id}); + const r = await api('/connections/discord/delete', {id:el.dataset.id}); + if (!r.ok){ toast(r.error || 'Could not remove it', 'err', 6000); return; } await refreshData(); this.render(); toast('Channel removed. Integrations posting there are off until you pick another.', 'info', 5000); @@ -3218,6 +4593,13 @@ const Conn = { case 't-cancel': await busy(el, async () => { await api('/twitch/cancel', {}); await refreshData(); this.render(); }); return true; + case 't-shared': { + const r = await api('/twitch/shared-chat', {on:el.checked}); + if (!r.ok){ el.checked = !el.checked; toast(r.error || 'Could not save it', 'err'); return true; } + await refreshData(); + toast(el.checked ? 'Partner channels\' viewers can use your commands' : 'Only your own chat counts', 'ok'); + return true; + } case 't-logout': if (!armConfirm(el, 'Chat stops. Sure?')) return true; await busy(el, async () => { @@ -3293,6 +4675,26 @@ function focusField(bind){ // ---------------------------------------------------------------- recipes +// One integration of a recipe being imported: its triggers and who can set +// them off, then everything it reaches, the riskiest first. +function recipeItemHtml(it){ + // "OBS crashes, the hold runs out": names after the first read on. + const who = (it.triggers || []).map((t, n) => esc(n && /^[A-Z][a-z]/.test(t.what) ? t.what[0].toLowerCase() + t.what.slice(1) : t.what) + + (t.who ? ` (${esc(t.who)})` : '')).join(', '); + const effects = (it.effects || []).map(e => + `${e.level === 'info' ? '' : '⚠ '}${esc(e.text)}`).join(''); + return `
${esc(it.name)} +
When ${who || 'nothing'}
+ ${effects ? `
${effects}
` : ''}
`; +} +// Said once above the Add button when anything in the recipe is risky. +function recipeWarningHtml(p){ + const danger = p.items.some(it => (it.effects || []).some(e => e.level === 'danger')); + if (p.local_effects && localLocked()) return warnHtml(esc(LOCAL_LOCK_TEXT)); + if (!danger) return ''; + return warnHtml('Read the red lines before adding it. Recipes arrive switched off: nothing runs until you open one and switch it on.'); +} + const Recipes = { preview:null, text:'', back:null, resume(){ @@ -3317,17 +4719,18 @@ const Recipes = { ${p ? `
${svg('check')}${esc(p.name)}${plural(p.items.length, 'integration')}
- ${p.items.map(it => `
${esc(it.name)} ${esc(it.summary)}
`).join('')} + ${p.items.map(it => recipeItemHtml(it)).join('')}
+ ${recipeWarningHtml(p)} ${p.uses_discord ? (channels.length ? `
` : warnHtml('It posts to Discord: add a Discord channel first.')) : ''} - ${p.local_effects ? `` : ''} + ${p.local_effects && !localLocked() ? `` : ''}
${svg('shield')}Recipes are settings, not code. They can't see your keys, tokens or webhook links, and they arrive switched off.
` : ''}
- ${p ? `` : ''} + ${p ? `` : ''}
`); }, async act(a, el){ @@ -3419,6 +4822,13 @@ async function onClick(e){ if (save) save.click(); break; } + case 'tpl-edit': { + // A field showing what it will say: back to what was written. + const field = el.parentElement && el.parentElement.querySelector('.ig-tpl-in'); + if (field){ field.focus(); const end = field.value.length; field.setSelectionRange(end, end); } + break; + } + case 'obs-retry': busy(el, () => ObsLive.tick()); break; case 'catalog': Catalog.open(); break; case 'build': Builder.open(null); break; case 'import': Recipes.openImport(); break; @@ -3444,6 +4854,8 @@ async function onClick(e){ case 'key-clear': Keys.trigger().hotkey = ''; OWNERS[Modal.kind].changed(); break; case 'pad-learn': if (!Keys.padTimer) Keys.learnPad(el); break; case 'pad-clear': Keys.trigger().midi = ''; OWNERS[Modal.kind].changed(); break; + case 'btn-token-new': Keys.trigger().token = randomToken(); OWNERS[Modal.kind].changed(); break; + case 'btn-token-clear': Keys.trigger().token = ''; OWNERS[Modal.kind].changed(); break; case 'set-quiet': { const o = OWNERS[Modal.kind]; o.d.quiet = el.dataset.v === 'on' ? (o.d.quiet || {from:'23:00', to:'08:00'}) : null; @@ -3453,7 +4865,16 @@ async function onClick(e){ case 'pack': busy(el, async () => { if (Modal.kind === 'catalog') Modal.close(true); - await addFrom({pack:el.dataset.pack}, false); + const added = await addFrom({pack:el.dataset.pack}, false); + // Nothing to post to yet: connect Discord now, and the pack's + // drafts get it as soon as it's saved. + const waiting = added.filter(i => !i.enabled && allSteps(i).some(st => st.type === 'discord')).map(i => i.id); + if (waiting.length && !S.data.connections.discord.length){ + Conn.open('discord'); + Conn.editing = {id:'', name:''}; + Conn.render(); + Modal.after = () => finishDrafts(waiting); + } }); break; } @@ -3483,17 +4904,50 @@ function onFieldLeave(e){ } } -// Enter or Space on a clickable card acts like a click. +// Enter or Space on a clickable card acts like a click; arrow keys move +// through a group of radio buttons (the card colors) and pick. function onActivateKey(e){ if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"][data-act]')){ e.preventDefault(); e.target.click(); + return; + } + if (Modal.kind === 'builder' && e.target.matches('.ig-step-main')){ + const row = e.target.closest('.ig-step'); + const tools = row.querySelector('.ig-step-tools'); + const pick = e.altKey && e.key === 'ArrowUp' ? tools.querySelector('[data-dir="-1"]') + : e.altKey && e.key === 'ArrowDown' ? tools.querySelector('[data-dir="1"]') + : (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd' ? tools.querySelector('[data-act="b-dup"]') + : e.key === 'Delete' ? tools.querySelector('[data-act="b-del"]') : null; + if (!pick) return; + e.preventDefault(); + if (pick.disabled) return; + Builder.act(pick.dataset.act, pick); + // The keyboard stays on the step it moved or copied. + if (Array.isArray(Builder.sel)){ + const at = q(`.ig-step-main[data-path="${pathKey(Builder.sel)}"]`); + if (at) at.focus({preventScroll:true}); + } + return; + } + const dir = {ArrowRight:1, ArrowDown:1, ArrowLeft:-1, ArrowUp:-1}[e.key]; + if (dir && e.target.matches('[role="radio"][data-act]')){ + const group = [...e.target.closest('[role="radiogroup"]').querySelectorAll('[role="radio"][data-act]')]; + const next = group[(group.indexOf(e.target) + dir + group.length) % group.length]; + e.preventDefault(); + next.click(); + const again = q(`[role="radio"][data-act="${next.dataset.act}"][data-v="${CSS.escape(next.dataset.v)}"]`); + if (again) again.focus(); } } -// Remember the last text field the builder used, for variable inserts. +// Remember the last text field each editor used, for variable inserts. document.addEventListener('focusin', e => { - if (Modal.kind === 'builder' && e.target.matches('.ig-inspector input:not([readonly]), .ig-inspector textarea')) Builder.lastField = e.target; + const text = ':not([readonly]):not([inputmode]):not([data-fixed]):not(.ig-dur):not([type="color"]):not([type="checkbox"])'; + // A message field measures itself again once it shows (see autosize). + if (e.target.matches && e.target.matches('.ig-msg')) autosize(e.target); + if (Modal.kind === 'builder' && e.target.matches(`.ig-inspector input${text}, .ig-inspector textarea`)) Builder.lastField = e.target; + if (Modal.kind === 'editor' && e.target.matches(`.ig-live input${text}, .ig-live textarea`)) Editor.lastField = e.target; }); // A hidden page has nobody to show activity to; catch up when it's back. document.addEventListener('visibilitychange', () => { From 3e779210f82c0e55cfcd2646284bc35b8aae7a47 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 4 Oct 2026 16:36:17 +0200 Subject: [PATCH 51/55] feat(integrations): import preview shows every step and the raw settings Each integration in a recipe being imported gets two folds: "See every step" lists its triggers and steps in the builder's words (checks with their branches nested, risky steps tinted), and "Show the raw settings" shows the exact JSON that would be added. Steps whose program, file, address or OBS scene a recipe can't carry say the importer picks it. The disarm action now reads "Turn the delay off", which is what it does on air too. --- CHANGELOG.md | 6 +++++- src/integrations/api.rs | 3 +++ web/index.html | 17 +++++++++++++++++ web/integrations.js | 39 +++++++++++++++++++++++++++++++++++---- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb8649..9f6d156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -214,7 +214,11 @@ Please try them and tell us what breaks or confuses you: the tab's - **Recipes say what they do before you add them:** who can start each integration (anyone in chat, only mods…) and everything it reaches. A program or file on your PC, or a delay anyone in chat can change, shows - in red; the delay and OBS in amber. + in red; the delay and OBS in amber. **See every step** lists each step + in plain words, checks and their branches included, and **Show the raw + settings** shows exactly what gets added. A recipe can never choose a + program, its arguments, a file, a web address or an OBS scene: you pick + those yourself after adding it, and the steps say so. - **Shared Chat is your choice.** While you share chat with other streamers, their viewers can't use your commands or count toward "chat gets busy" unless you switch on "listen to partner channels" in diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 2b9b548..3aba15c 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -866,6 +866,9 @@ fn import( ("summary", json::str(summary(i))), ("triggers", Value::Arr(trigger_review(i))), ("effects", Value::Arr(effect_review(i))), + // Every step, for "See every step". Parsing already + // dropped what a recipe never carries (addresses, paths). + ("integration", i.to_json()), ]) }) .collect(); diff --git a/web/index.html b/web/index.html index b7dda18..92eb977 100644 --- a/web/index.html +++ b/web/index.html @@ -2807,6 +2807,23 @@ .ig-rflag.caution{background:color-mix(in oklch,var(--warn) 16%,transparent);color:var(--warn)} .ig-rflag.danger{background:color-mix(in oklch,var(--err) 16%,transparent);color:var(--err);font-weight:600} .ig-palette button.ig-pal-locked{opacity:.45;cursor:not-allowed} +.ig-rdetails{margin-top:2px} +.ig-rdetails>summary{cursor:pointer;width:max-content;font-size:12px;color:var(--accent)} +.ig-rdetails>summary:hover{text-decoration:underline} +.ig-rhandler{margin-top:8px;padding:8px 10px;border-radius:8px;background:var(--surface-2);display:flex;flex-direction:column;gap:6px} +.ig-rtrig{font-size:12px;font-weight:600;color:var(--fg)} +.ig-rsteps{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:4px} +.ig-rsteps .ig-rsteps{margin-top:4px;border-left:1px dashed var(--line);padding-left:16px} +.ig-rstep{font-size:12.5px;color:var(--fg-2);overflow-wrap:anywhere} +.ig-rline{display:flex;align-items:baseline;gap:8px} +.ig-rtag{flex:none;font-size:9.5px;font-weight:700;letter-spacing:.08em;color:var(--c)} +.ig-rstep.danger>.ig-rline>.ig-step-text{color:var(--err)} +.ig-rstep.caution>.ig-rline>.ig-step-text{color:var(--warn)} +.ig-relse{font-size:11px;color:var(--fg-3);margin-top:2px} +.ig-rstep .ig-step-text{white-space:normal;overflow:visible;text-overflow:clip;min-width:0} +.ig-rstep .ig-rnote{color:var(--fg-3);font-size:11.5px} +.ig-rraw{margin:8px 0 0;padding:10px;max-height:280px;overflow:auto;border-radius:8px;background:var(--surface-2); + font:11.5px/1.5 'JetBrains Mono',monospace;color:var(--fg-2);white-space:pre-wrap;overflow-wrap:anywhere} .ig-recipe-safe{display:flex;align-items:flex-start;gap:8px} .ig-recipe-safe svg{width:14px;height:14px;flex:none;margin-top:2px;color:var(--accent)} .ig-share-list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow:auto} diff --git a/web/integrations.js b/web/integrations.js index 5502bea..701d8cf 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -126,7 +126,7 @@ const RULES = { const TIMELINE_KINDS = {note:'A line', chapter:'A chapter', highlight:'A highlight'}; const DELAY_ACTIONS = { arm:'Turn the delay on', cut_after:'Back to live, viewers miss nothing', cut:'Back to live now, viewers skip ahead', - activate:'Turn on the delay that\'s ready', toggle:'Turn the delay on or off', disarm:'Cancel a delay that\'s still getting ready', + activate:'Turn on the delay that\'s ready', toggle:'Turn the delay on or off', disarm:'Turn the delay off', end_hold:'End the reconnect screen', }; // What an OBS step can do, and what each needs. @@ -4428,8 +4428,8 @@ function stepSentence(s){ : p.action === 'arm' && p.seconds ? `Turn on a ${highlightVars(p.seconds)} s delay` : esc(DELAY_ACTIONS[p.action] || 'Delay action'); case 'marker': return `VOD marker ${q(p.description)}`; case 'clip': return 'Create a clip'; - case 'program': return `Run ${esc((p.path || '(program)').split(/[\\/]/).pop())} ${highlightVars(p.args || '')}`; - case 'file': return `${p.mode === 'append' ? 'Add to' : 'Write'} ${esc((p.path || '(file)').split(/[\\/]/).pop())} ${q(p.text)}`; + case 'program': return `Run ${p.path ? esc(p.path.split(/[\\/]/).pop()) : 'a program you pick'} ${highlightVars(p.args || '')}`; + case 'file': return `${p.mode === 'append' ? 'Add to' : 'Write'} ${p.path ? esc(p.path.split(/[\\/]/).pop()) : 'a file you pick'} ${q(p.text)}`; case 'set_var': return `Remember ${esc(p.name || '…')} = ${highlightVars(p.value || '')}`; case 'overlay': return `On stream ${q(p.text)}`; case 'edit_text': return `${esc((EDIT_OPS[p.op] || EDIT_OPS.first_line)[0])}: ${highlightVars(p.input || '…')} → {${esc((p.save_as || '').trim() || 'text')}}`; @@ -4683,9 +4683,40 @@ function recipeItemHtml(it){ + (t.who ? ` (${esc(t.who)})` : '')).join(', '); const effects = (it.effects || []).map(e => `${e.level === 'info' ? '' : '⚠ '}${esc(e.text)}`).join(''); + const handlers = (it.integration && it.integration.handlers) || []; return `
${esc(it.name)}
When ${who || 'nothing'}
- ${effects ? `
${effects}
` : ''}
`; + ${effects ? `
${effects}
` : ''} + ${handlers.length ? `
See every step${handlers.map(h => + `
${esc(triggerSentence(h.trigger))}${h.enabled === false ? ' (switched off)' : ''}
+
    ${recipeStepsHtml(h.steps)}
`).join('')}
` : ''} + ${it.integration ? `
Show the raw settings +
${esc(JSON.stringify(it.integration, null, 2))}
` : ''}
`; +} +// Recipes never carry where a step reaches: shared and imported, they lose +// the program and its arguments, the file, the web address and the OBS +// scene or source (recipe::strip_private). Say so where that step is, so +// nobody wonders which program it would run. +const RECIPE_FILLS = {program:[['path'], 'program and its arguments'], file:[['path'], 'file'], + http:[['url'], 'address'], obs:[['scene', 'source'], 'scene or source']}; +function recipeFillNote(s){ + const fill = RECIPE_FILLS[s.type], p = s.params || {}; + if (!fill || fill[0].some(name => p[name])) return ''; + return ` · a recipe can't choose the ${fill[1]}: you pick it after adding`; +} +// The steps of a recipe being imported, in the builder's words, checks +// with their branches nested. What runs on the PC is tinted red; what +// changes the delay, OBS or calls a web address, amber. +function recipeStepsHtml(steps){ + if (!steps || !steps.length) return '
  • Nothing
  • '; + return steps.map(s => { + const k = KINDS[s.type] || {}; + const risk = LOCAL_KINDS.includes(s.type) ? ' danger' : ['delay_action', 'obs', 'http'].includes(s.type) ? ' caution' : ''; + const branches = s.type !== 'if' ? '' : `
      ${recipeStepsHtml(s.then)}
    ` + + ((s.else || []).length ? `
    Otherwise
      ${recipeStepsHtml(s.else)}
    ` : ''); + return `
  • ${esc(k.tag || s.type)} + ${stepSentence(s)}${recipeFillNote(s)}${s.enabled === false ? ' (switched off)' : ''}
    ${branches}
  • `; + }).join(''); } // Said once above the Add button when anything in the recipe is risky. function recipeWarningHtml(p){ From d2472b2cfc6cee4061855912632cea254ebcd12e Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 4 Oct 2026 16:46:14 +0200 Subject: [PATCH 52/55] polish(integrations): import preview as cards with smooth folds Each integration of a recipe is a card whose edge shows the worst it can do, easing in after the one above. "See every step" and "Raw settings" are rows that open smoothly (interpolate-size, plain open elsewhere), remember their state across redraws, and count the steps. Steps get numbered badges tinted by risk, triggers a bolt, the raw settings colored JSON with a Copy button. Reduced motion turns the animations off. --- web/index.html | 75 ++++++++++++++++++++++++++++++++++++--------- web/integrations.js | 67 +++++++++++++++++++++++++++++++--------- 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/web/index.html b/web/index.html index 92eb977..306eabd 100644 --- a/web/index.html +++ b/web/index.html @@ -2793,37 +2793,82 @@ /* Recipes. */ .ig-textarea-code{width:100%;min-height:90px;resize:vertical;font-family:'JetBrains Mono',monospace;font-size:12px} .ig-recipe{display:flex;flex-direction:column;gap:12px;animation:igFadeUp .3s var(--ease-out)} -.ig-recipe-list{display:flex;flex-direction:column;gap:8px} +.ig-recipe-list{display:flex;flex-direction:column;gap:10px} .ig-recipe-head{display:flex;align-items:center;gap:8px} .ig-ok{width:20px;height:20px;flex:none;border-radius:50%;display:grid;place-items:center; background:color-mix(in oklch,var(--live) 18%,transparent);color:var(--live)} .ig-ok svg{width:12px;height:12px;animation:igPop .3s cubic-bezier(.2,0,0,1)} -.ig-recipe-item{padding-left:28px;font-size:13px;color:var(--fg-2);display:flex;flex-direction:column;gap:4px} -.ig-recipe-item b{color:var(--fg)} +/* One card per integration in a recipe, its edge colored by the worst it + can do. The rows under it open smoothly where the browser can animate + to auto height (interpolate-size), and simply open elsewhere. */ +.ig-recipe-list{interpolate-size:allow-keywords} +.ig-ritem{position:relative;display:flex;flex-direction:column;gap:6px;padding:12px 14px 12px 16px;border-radius:12px; + background:var(--surface-2);border:1px solid var(--line);font-size:13px;color:var(--fg-2);overflow:hidden; + animation:igFadeUp .32s var(--ease-out) both;animation-delay:calc(var(--i,0) * 60ms)} +.ig-ritem::before{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--line)} +.ig-ritem.caution::before{background:var(--warn)} +.ig-ritem.danger::before{background:var(--err)} +.ig-rname{color:var(--fg);font-size:13.5px} .ig-rwhen{font-size:12px} .ig-rwho{color:var(--fg-3)} .ig-rflags{display:flex;flex-wrap:wrap;gap:6px} -.ig-rflag{font-size:11.5px;padding:2px 8px;border-radius:999px;background:var(--surface-3);color:var(--fg-2)} +.ig-rflag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;padding:3px 9px;border-radius:999px; + background:var(--surface-3);color:var(--fg-2);animation:igFadeUp .3s var(--ease-out) both;animation-delay:calc(120ms + var(--i,0) * 50ms)} +.ig-rflag svg{width:12px;height:12px;flex:none} .ig-rflag.caution{background:color-mix(in oklch,var(--warn) 16%,transparent);color:var(--warn)} .ig-rflag.danger{background:color-mix(in oklch,var(--err) 16%,transparent);color:var(--err);font-weight:600} .ig-palette button.ig-pal-locked{opacity:.45;cursor:not-allowed} -.ig-rdetails{margin-top:2px} -.ig-rdetails>summary{cursor:pointer;width:max-content;font-size:12px;color:var(--accent)} -.ig-rdetails>summary:hover{text-decoration:underline} -.ig-rhandler{margin-top:8px;padding:8px 10px;border-radius:8px;background:var(--surface-2);display:flex;flex-direction:column;gap:6px} -.ig-rtrig{font-size:12px;font-weight:600;color:var(--fg)} -.ig-rsteps{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:4px} -.ig-rsteps .ig-rsteps{margin-top:4px;border-left:1px dashed var(--line);padding-left:16px} -.ig-rstep{font-size:12.5px;color:var(--fg-2);overflow-wrap:anywhere} +.ig-rfolds{display:flex;flex-direction:column;margin-top:4px;border-top:1px solid var(--line)} +.ig-rfold{border-bottom:1px solid var(--line)} +.ig-rfold:last-child{border-bottom:0} +.ig-rfold>summary{display:flex;align-items:center;gap:8px;padding:9px 4px;cursor:pointer;list-style:none;font-size:12.5px; + color:var(--fg-2);border-radius:8px;transition:color .15s,background .15s} +.ig-rfold>summary::-webkit-details-marker{display:none} +.ig-rfold>summary:hover{color:var(--fg);background:color-mix(in oklch,var(--fg) 4%,transparent)} +.ig-rfold>summary:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.ig-rfold[open]>summary{color:var(--fg)} +.ig-rfold-ic{width:14px;height:14px;flex:none;color:var(--fg-3)} +.ig-rfold-label{flex:1;display:flex;align-items:center;gap:6px} +.ig-rcount{font-size:10.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:var(--surface-3);color:var(--fg-3)} +.ig-rchev{width:14px;height:14px;flex:none;color:var(--fg-4);transform:rotate(-90deg);transition:transform .25s var(--ease-out)} +.ig-rfold[open] .ig-rchev{transform:none} +.ig-rfold::details-content{block-size:0;opacity:0;overflow:clip; + transition:block-size .3s var(--ease-out),opacity .25s var(--ease-out),content-visibility .3s allow-discrete} +.ig-rfold[open]::details-content{block-size:auto;opacity:1} +.ig-rfold-body{display:flex;flex-direction:column;gap:8px;padding:2px 0 12px} +.ig-rhandler{padding:10px 12px;border-radius:10px;background:var(--bg-2);border:1px solid var(--line);display:flex;flex-direction:column;gap:8px} +.ig-rtrig{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:var(--fg)} +.ig-rtrig svg{width:13px;height:13px;flex:none;color:var(--accent)} +.ig-rsteps{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;counter-reset:rstep} +.ig-rsteps .ig-rsteps{margin:6px 0 0 9px;padding-left:16px;border-left:1px dashed var(--line)} +.ig-rstep{counter-increment:rstep;font-size:12.5px;color:var(--fg-2);overflow-wrap:anywhere} +.ig-rfold[open] .ig-rstep{animation:igFadeUp .28s var(--ease-out) both;animation-delay:calc(var(--i,0) * 35ms)} .ig-rline{display:flex;align-items:baseline;gap:8px} +.ig-rline::before{content:counter(rstep);flex:none;align-self:flex-start;margin-top:-1px;width:18px;height:18px;border-radius:50%;display:grid;place-items:center; + font-size:10px;font-weight:700;color:var(--fg-3);background:var(--surface-3)} +.ig-rstep.ig-rnone{counter-increment:none;color:var(--fg-3);font-style:italic} .ig-rtag{flex:none;font-size:9.5px;font-weight:700;letter-spacing:.08em;color:var(--c)} +.ig-rstep.danger>.ig-rline::before{background:color-mix(in oklch,var(--err) 20%,transparent);color:var(--err)} +.ig-rstep.caution>.ig-rline::before{background:color-mix(in oklch,var(--warn) 20%,transparent);color:var(--warn)} .ig-rstep.danger>.ig-rline>.ig-step-text{color:var(--err)} .ig-rstep.caution>.ig-rline>.ig-step-text{color:var(--warn)} -.ig-relse{font-size:11px;color:var(--fg-3);margin-top:2px} +.ig-relse{display:inline-block;margin:6px 0 0 9px;padding:1px 8px;border-radius:999px;font-size:10.5px;font-weight:600; + color:var(--fg-3);background:var(--surface-3)} .ig-rstep .ig-step-text{white-space:normal;overflow:visible;text-overflow:clip;min-width:0} .ig-rstep .ig-rnote{color:var(--fg-3);font-size:11.5px} -.ig-rraw{margin:8px 0 0;padding:10px;max-height:280px;overflow:auto;border-radius:8px;background:var(--surface-2); - font:11.5px/1.5 'JetBrains Mono',monospace;color:var(--fg-2);white-space:pre-wrap;overflow-wrap:anywhere} +.ig-rraw-wrap{position:relative} +.ig-rcopy{position:absolute;top:8px;right:8px;z-index:1;backdrop-filter:blur(6px)} +.ig-rraw{margin:0;padding:12px 14px;max-height:300px;overflow:auto;border-radius:10px;background:var(--bg-2);border:1px solid var(--line); + font:11.5px/1.6 'JetBrains Mono',monospace;color:var(--fg-3);white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2;scrollbar-width:thin} +.ig-rraw .jk{color:color-mix(in oklch,var(--accent) 75%,var(--fg))} +.ig-rraw .js{color:var(--live)} +.ig-rraw .jd{color:#fcd34d} +.ig-rraw .jb{color:#a78bfa} +.ig-rraw .jn{color:var(--fg-4)} +@media (prefers-reduced-motion: reduce){ + .ig-ritem,.ig-rflag,.ig-rfold[open] .ig-rstep{animation:none} + .ig-rfold::details-content,.ig-rchev{transition:none} +} .ig-recipe-safe{display:flex;align-items:flex-start;gap:8px} .ig-recipe-safe svg{width:14px;height:14px;flex:none;margin-top:2px;color:var(--accent)} .ig-share-list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow:auto} diff --git a/web/integrations.js b/web/integrations.js index 701d8cf..3aabc92 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -45,6 +45,7 @@ const PATHS = { plus:'M12 5v14M5 12h14', check:'M20 6L9 17l-5-5', chev:'M6 9l6 6 6-6', + code:'M8 7l-5 5 5 5M16 7l5 5-5 5', up:'M18 15l-6-6-6 6', down:'M6 9l6 6 6-6', x:'M18 6L6 18M6 6l12 12', @@ -1911,6 +1912,10 @@ const Modal = { wrap.addEventListener('input', onInput); wrap.addEventListener('keydown', onActivateKey); wrap.addEventListener('focusout', onFieldLeave); + wrap.addEventListener('toggle', e => { + const key = e.target.dataset && e.target.dataset.fold; + if (key) e.target.open ? Recipes.folds.add(key) : Recipes.folds.delete(key); + }, true); document.addEventListener('keydown', onModalKey); this.el = wrap; this.form = wrap.querySelector('.dest-form'); @@ -4677,21 +4682,49 @@ function focusField(bind){ // One integration of a recipe being imported: its triggers and who can set // them off, then everything it reaches, the riskiest first. -function recipeItemHtml(it){ +function recipeItemHtml(it, n){ // "OBS crashes, the hold runs out": names after the first read on. - const who = (it.triggers || []).map((t, n) => esc(n && /^[A-Z][a-z]/.test(t.what) ? t.what[0].toLowerCase() + t.what.slice(1) : t.what) + const who = (it.triggers || []).map((t, i) => esc(i && /^[A-Z][a-z]/.test(t.what) ? t.what[0].toLowerCase() + t.what.slice(1) : t.what) + (t.who ? ` (${esc(t.who)})` : '')).join(', '); - const effects = (it.effects || []).map(e => - `${e.level === 'info' ? '' : '⚠ '}${esc(e.text)}`).join(''); + const effects = it.effects || []; + const level = effects.some(e => e.level === 'danger') ? 'danger' : effects.some(e => e.level === 'caution') ? 'caution' : 'safe'; + const flags = effects.map((e, i) => + `${e.level === 'info' ? '' : svg('warn')}${esc(e.text)}`).join(''); const handlers = (it.integration && it.integration.handlers) || []; - return `
    ${esc(it.name)} + let count = 0; + handlers.forEach(h => walk(h.steps, () => { count++; })); + const steps = handlers.map(h => + `
    ${svg('bolt')}${esc(triggerSentence(h.trigger))}${h.enabled === false ? ' (switched off)' : ''}
    +
      ${recipeStepsHtml(h.steps)}
    `).join(''); + const json = it.integration ? JSON.stringify(it.integration, null, 2) : ''; + const raw = `
    +
    ${jsonColored(json)}
    `; + return `
    + ${esc(it.name)}
    When ${who || 'nothing'}
    - ${effects ? `
    ${effects}
    ` : ''} - ${handlers.length ? `
    See every step${handlers.map(h => - `
    ${esc(triggerSentence(h.trigger))}${h.enabled === false ? ' (switched off)' : ''}
    -
      ${recipeStepsHtml(h.steps)}
    `).join('')}
    ` : ''} - ${it.integration ? `
    Show the raw settings -
    ${esc(JSON.stringify(it.integration, null, 2))}
    ` : ''}
    `; + ${flags ? `
    ${flags}
    ` : ''} + ${handlers.length ? `
    ${recipeFold(`steps-${n}`, 'steps', `See every step ${count}`, steps)} + ${json ? recipeFold(`raw-${n}`, 'code', 'Raw settings', raw) : ''}
    ` : ''}
    `; +} +// A row that opens smoothly onto `body`. Which rows are open survives a +// redraw of the import window (see Recipes.folds). +function recipeFold(key, icon, label, body){ + return `
    + ${svg(icon, ' class="ig-rfold-ic"')}${label}${svg('chev', ' class="ig-rchev"')} +
    ${body}
    `; +} +// JSON text with keys, strings, numbers and literals colored, every piece +// escaped on its own. +function jsonColored(text){ + const token = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g; + let out = '', last = 0, m; + while ((m = token.exec(text))){ + out += esc(text.slice(last, m.index)); + const cls = m[1] ? (m[2] ? 'jk' : 'js') : m[3] ? (m[3] === 'null' ? 'jn' : 'jb') : 'jd'; + out += `${esc(m[1] || m[0])}${m[2] ? esc(m[2]) : ''}`; + last = token.lastIndex; + } + return out + esc(text.slice(last)); } // Recipes never carry where a step reaches: shared and imported, they lose // the program and its arguments, the file, the web address and the OBS @@ -4708,13 +4741,13 @@ function recipeFillNote(s){ // with their branches nested. What runs on the PC is tinted red; what // changes the delay, OBS or calls a web address, amber. function recipeStepsHtml(steps){ - if (!steps || !steps.length) return '
  • Nothing
  • '; - return steps.map(s => { + if (!steps || !steps.length) return '
  • Nothing
  • '; + return steps.map((s, i) => { const k = KINDS[s.type] || {}; const risk = LOCAL_KINDS.includes(s.type) ? ' danger' : ['delay_action', 'obs', 'http'].includes(s.type) ? ' caution' : ''; const branches = s.type !== 'if' ? '' : `
      ${recipeStepsHtml(s.then)}
    ` + ((s.else || []).length ? `
    Otherwise
      ${recipeStepsHtml(s.else)}
    ` : ''); - return `
  • ${esc(k.tag || s.type)} + return `
  • ${esc(k.tag || s.type)} ${stepSentence(s)}${recipeFillNote(s)}${s.enabled === false ? ' (switched off)' : ''}
    ${branches}
  • `; }).join(''); } @@ -4728,6 +4761,8 @@ function recipeWarningHtml(p){ const Recipes = { preview:null, text:'', back:null, + // Which "See every step" and "Raw settings" rows are open. + folds:new Set(), resume(){ Modal.open('import', 700); Modal.back = this.back; @@ -4735,6 +4770,7 @@ const Recipes = { }, openImport(){ this.preview = null; + this.folds.clear(); this.text = ''; this.back = Modal.kind === 'catalog' ? {title:'Back to the catalog', go:() => Catalog.reopen()} : null; this.resume(); @@ -4750,7 +4786,7 @@ const Recipes = { ${p ? `
    ${svg('check')}${esc(p.name)}${plural(p.items.length, 'integration')}
    - ${p.items.map(it => recipeItemHtml(it)).join('')} + ${p.items.map((it, n) => recipeItemHtml(it, n)).join('')}
    ${recipeWarningHtml(p)} ${p.uses_discord ? (channels.length ? `
    ` @@ -4771,6 +4807,7 @@ const Recipes = { const r = await api('/integrations/import', {recipe:this.text}); if (!r.ok){ toast(r.error, 'err', 6000); return; } this.preview = r; + this.folds.clear(); this.render(); }); return true; From 5f0dec25bc51294f01bb6abe3894969c3b58009a Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 4 Oct 2026 17:07:29 +0200 Subject: [PATCH 53/55] fix: release review round 2 (freeze floor, program lock, overlay sandbox, UI edges) Stream path: - The never-re-air floor after a freeze only applies when the hold was already over (ended or run out) before OBS recovered, or protection was switched off mid-freeze. A short freeze OBS recovered from in time no longer makes a new destination wait out the whole delay (new sim). - On a full ring after a freeze, a destination waits for the ring to roll past the freeze instead of joining far short of the delay and looping. Security: - Program and File steps need a dashboard password while the dashboard is open to the network or reached through a reverse proxy (X-Forwarded-For, Forwarded, Via, X-Real-IP); a page loaded from this PC is no longer enough. Catalog entries with a File step follow the same rule. A proxied request can't set the first password either. - Overlay pages are served with CSP sandbox allow-scripts: they still paint from the open feeds but can't act as the dashboard. - Chat values in program args are ASCII only (no best-fit look-alikes), lose : and #, and no word of them can start with - or /. - !setdelay with a number too big to read caps at 600 s. - Logins in web addresses no longer show in step summaries. UI: - A secret field's label focuses the field instead of revealing it. - Save settles back to disabled after a builder save. - Locked integrations: the card switch, Ctrl+S and the unsaved bar say why instead of doing nothing; the footer explains on tap. - Delay action labels match the builder (Turn the delay off, Back to live now); dock and banner say the same thing about Normal Mode; capped stagger delays; Copy no longer covers the raw settings; #integrations is cleared after opening the tab; Needs OBS setup fits the card. --- CHANGELOG.md | 39 ++++++++++++--------- src/controller.rs | 58 ++++++++++++++++++++++++------ src/controller/hold_sim.rs | 44 +++++++++++++++++++++++ src/integrations/api.rs | 34 ++++++++++++------ src/integrations/runner.rs | 16 +++++---- src/integrations/template.rs | 52 ++++++++++++++++++--------- src/web.rs | 68 ++++++++++++++++++++++++++++++++---- web/dock.js | 4 +-- web/index.html | 13 ++++--- web/integrations.js | 34 ++++++++++++------ 10 files changed, 276 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6d156..7f5d88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,10 +215,12 @@ Please try them and tell us what breaks or confuses you: the tab's integration (anyone in chat, only mods…) and everything it reaches. A program or file on your PC, or a delay anyone in chat can change, shows in red; the delay and OBS in amber. **See every step** lists each step - in plain words, checks and their branches included, and **Show the raw - settings** shows exactly what gets added. A recipe can never choose a - program, its arguments, a file, a web address or an OBS scene: you pick - those yourself after adding it, and the steps say so. + in plain words, checks and their branches included, and **Raw settings** + shows exactly what gets added. A recipe can never choose a program, its + arguments, a file, a web address or an OBS scene: you pick those + yourself after adding it, and the steps say so. The delay action that + turns the delay off now says so ("Turn the delay off"); it used to read + as if it only cancelled one still getting ready. - **Shared Chat is your choice.** While you share chat with other streamers, their viewers can't use your commands or count toward "chat gets busy" unless you switch on "listen to partner channels" in @@ -442,24 +444,29 @@ stream settings: h264_texture_amf, 6000 kbps`. domain that re-resolves to your own PC. It could then save an integration that runs a program. The dashboard now answers only when it's opened by its address (`127.0.0.1`, `localhost` or a LAN IP). - Opening it by a name, such as behind a reverse proxy, needs a password. - Overlays, the alerts page and web call links keep working from + Opening it by a name needs a password, and a reverse proxy should + always have one. Overlays, the alerts page and web call links keep working from anywhere. - **Your settings can't be read by other websites.** Every page used to let any website read it. Now only the overlay, alerts and delay-status pages can be read from another site. The config (your ingest key and webhooks), destinations, integrations and logs can't. -- **Programs and files are set up on the streaming PC.** With the - dashboard open to your network and no password, anyone on the network - could add a step that runs a program and start it from chat. Run a - program and Write a file can now only be added, changed or switched on - from the streaming PC itself, or from anywhere once the dashboard has a - password. Elsewhere they show as locked and say why. +- **Programs and files need a password once the dashboard is on your + network.** With the dashboard open to your network and no password, + anyone on the network could add a step that runs a program and start it + from chat, directly or through an overlay page the streaming PC loads. + While the dashboard is open to the network (or behind a reverse proxy), + Run a program and Write a file can only be added, changed or switched + on once it has a password; they show as locked and say why. With the + dashboard only on this PC (the default), nothing changes. +- **Overlay pages run sandboxed.** An overlay is a page from the overlays + folder; it now runs with an origin of its own, so it still draws but + can't act as the dashboard. - **Chat can't run commands through a program.** A value typed in chat - stays one argument, and keeps only letters, numbers, spaces and simple - punctuation: a step like `cmd /c echo {arg1}` can't be talked into - running something else, and a value can't pass itself off as an option - (`--config`, `/s`). + stays one argument, and keeps only plain letters, numbers, spaces and + `_ . , + = / -`: a step like `cmd /c echo {arg1}` can't be talked into + running something else, look-alike letters can't turn into quotes, and + no word of it can pass itself off as an option (`--config`, `/s`). - **Web request addresses are hidden** until you click Show, like webhook links: they can hold keys (`?key=`, a Hue bridge username). - **A stream key under six characters is no longer logged in the clear** diff --git a/src/controller.rs b/src/controller.rs index bd1c8ff..39d54b6 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -591,6 +591,11 @@ pub struct Controller { /// (`NO_SEQ` when the ring was empty): the video before it is from /// before the hold, and a rejoin never starts there. resumed_after_seq: AtomicU64, + /// The newest ring seq when a frozen OBS recovered after its hold had + /// already ended (`NO_SEQ`: none this session). The destinations ended + /// with the hold, so the video before it is over for viewers: a pump + /// that starts later never seeds there (see `fresh_from_seq`). + replay_floor_seq: AtomicU64, /// How the last hold closed: 0 none yet, 1 resumed, 2 ended. last_hold_end: AtomicU8, /// Crash protection settings, mirrored from Settings by the supervisor. @@ -814,6 +819,7 @@ impl Controller { capacity_capped_logged: AtomicBool::new(false), last_rejection_log_ms: AtomicU64::new(0), resumed_after_seq: AtomicU64::new(NO_SEQ), + replay_floor_seq: AtomicU64::new(NO_SEQ), last_hold_end: AtomicU8::new(0), crash_protection: crate::sync::Mutex::new(Default::default()), crash_protection_on: AtomicBool::new(false), @@ -1756,6 +1762,8 @@ impl Controller { self.unpublish_received.store(false, Ordering::Relaxed); self.last_video_tag_ms.store(0, Ordering::Relaxed); self.ingest_frozen.store(false, Ordering::Relaxed); + // The ring is cleared above: nothing in it is from before a freeze. + self.replay_floor_seq.store(NO_SEQ, Ordering::Relaxed); // A hold past its deadline already ended for the pumps. Close it as // ended while ingest still reads as dead, so the sessions it kept // are forgotten rather than carried into this stream. @@ -1812,10 +1820,18 @@ impl Controller { } if self.ingest_frozen.load(Ordering::Relaxed) { self.ingest_frozen.store(false, Ordering::Relaxed); + // Back in time, the pumps rejoin past the freeze on + // their own and a short freeze's tail may still be + // airing. Only a hold that is already over (ended or + // run out) left everything before the freeze behind. + let hold_was_over = !self.hold_active(); // A hold past its deadline already ended for the pumps: // report it as ended, not as OBS being back in time. self.expire_hold(); self.resume_from_hold(); + if hold_was_over { + self.mark_replay_floor(); + } } if is_idr { self.sample_keyframe_interval(ts_ms); @@ -2163,13 +2179,23 @@ impl Controller { Some(self.resumed_after_seq.load(Ordering::Relaxed)).filter(|seq| *seq != NO_SEQ) } - /// The first ring seq a destination may start from. After a frozen OBS - /// recovers, everything it sent before the freeze has already aired - /// (as the delay tail of the hold), so a pump that starts later, or a - /// cut back to restore the delay, must never land there and replay it. - /// 0 when OBS never froze in this session. + /// The first ring seq a destination may start from. When a frozen OBS + /// recovers after its hold ended, everything it sent before the freeze + /// is over for viewers (aired as the delay tail, or cut by End now), so + /// a pump that starts later, or a cut back to restore the delay, must + /// never land there and replay it. 0 otherwise. fn fresh_from_seq(&self) -> u64 { - self.resumed_after_seq().map_or(0, |seq| seq + 1) + match self.replay_floor_seq.load(Ordering::Relaxed) { + NO_SEQ => 0, + seq => seq + 1, + } + } + + /// Everything in the ring now is over for viewers (see `fresh_from_seq`). + fn mark_replay_floor(&self) { + if let Some(latest) = self.ring.latest_seq() { + self.replay_floor_seq.store(latest, Ordering::Relaxed); + } } /// The latest keyframe of a track the hold re-sends instead of the @@ -2192,7 +2218,11 @@ impl Controller { // Off means no bookkeeping, and nothing stale left behind: an // old video stamp would read as a freeze and hold back egress. self.last_video_tag_ms.store(0, Ordering::Relaxed); - self.ingest_frozen.store(false, Ordering::Relaxed); + // Switched off mid-freeze, OBS no longer counts as frozen and a + // destination reconnects at once: never onto what already aired. + if self.ingest_frozen.swap(false, Ordering::Relaxed) { + self.mark_replay_floor(); + } self.held_keyframes.lock().clear(); self.slate_cache.clear(); } @@ -3682,10 +3712,16 @@ fn delayed_idr(ctrl: &Controller, target_ms: u64) -> Option { return None; } let oldest = ctrl.ring.oldest_ts()?; - ctrl.ring - .newest_idr_at_or_before(oldest + FULL_RING_MARGIN_MS) - .filter(|idr| idr.seq >= fresh) - .or_else(|| ctrl.ring.oldest_idr_at_or_after(fresh)) + let margin = ctrl + .ring + .newest_idr_at_or_before(oldest + FULL_RING_MARGIN_MS); + // After a freeze the keyframe there can be from before it: wait for + // the ring to roll past the freeze rather than join on OBS's newest + // video, far short of the delay. + if fresh > 0 { + return margin.filter(|idr| idr.seq >= fresh); + } + margin.or_else(|| ctrl.ring.oldest_idr_at_or_after(0)) }) } diff --git a/src/controller/hold_sim.rs b/src/controller/hold_sim.rs index f29f674..3b08174 100644 --- a/src/controller/hold_sim.rs +++ b/src/controller/hold_sim.rs @@ -563,6 +563,50 @@ async fn a_destination_after_an_ended_freeze_never_replays_what_already_aired() assert_strictly_increasing(&frames, "after an ended freeze"); } +/// OBS freezes for a moment and is back while the hold is still up. What it +/// sent before the freeze is still the delay viewers are watching, so a +/// destination that starts right after joins at the delay at once. The +/// replay guard above used to apply here too, and it waited out the whole +/// delay again before going live. +#[tokio::test] +async fn a_destination_after_a_short_freeze_joins_at_once() { + const DELAY_MS: u32 = 3_000; + let mut sim = Sim::new(true).await; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(3_500).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(1_000).await; + sim.obs_stalls(300).await; + sim.freeze_detected(); + assert!(sim.ctrl.hold_active(), "the freeze is held"); + sim.obs_stalls(300).await; + sim.obs_sends(500).await; + assert!(!sim.ctrl.hold_active(), "OBS is back in time"); + let joined = Instant::now(); + sim.destination_connects("second").await; + sim.obs_sends(1_500).await; + + let frames = sim.frames(1); + let first = frames.first().expect("the new destination went live"); + assert!( + first.at.duration_since(joined) < Duration::from_millis(1_000), + "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; + assert!( + delayed_by + 50 >= DELAY_MS, + "joined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" + ); +} + /// A pump that ends (OBS gone, protection off) stops holding its place in /// the buffer. A stale place pinned the buffer's trim, so it grew to the /// whole disk allowance while the destination waited to reconnect. diff --git a/src/integrations/api.rs b/src/integrations/api.rs index 3aba15c..e87eab2 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -47,8 +47,8 @@ fn done() -> Reply { } /// Why a Program or File step can't be set up from this request. -const LOCAL_STEPS_LOCKED: &str = "running programs and writing files can only be set up on the \ - streaming PC itself, or from here once the dashboard has a password (System > Security)"; +const LOCAL_STEPS_LOCKED: &str = "running programs and writing files needs a dashboard password \ + while the dashboard is open to your network (System > Security)"; pub fn new_id() -> String { format!("i{}", &crate::crypto::random_token()[..12]) @@ -57,10 +57,11 @@ pub fn new_id() -> String { /// Handle `/integrations*`, `/connections/*`, `/twitch/*`. `None` when the /// path is not ours. /// -/// `local_steps_allowed` is false for a request from another device while -/// the dashboard has no password. Anyone on the network could make such a -/// request, so it may not add, change or switch on a step that runs a -/// program or writes a file: from chat, that would run anything on the PC. +/// `local_steps_allowed` is false while the dashboard is open to the +/// network with no password (see `web::may_set_up_local_steps`). Anyone on +/// the network could then make the request, so it may not add, change or +/// switch on a step that runs a program or writes a file: from chat, that +/// would run anything on the PC. pub async fn route( method: &str, path: &str, @@ -80,7 +81,7 @@ pub async fn route( ("twitch", handle.twitch.status_json()), ])), ("POST", "/integrations/save") => save(&req(), settings, cfg_path, local_steps_allowed), - ("POST", "/integrations/add") => add(&req(), settings, cfg_path), + ("POST", "/integrations/add") => add(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/toggle") => toggle(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/delete") => { let id = req().str_or("id", "").to_string(); @@ -588,7 +589,12 @@ fn check_connections(integration: &Integration, s: &Settings) -> Result<(), Stri } } -fn add(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { +fn add( + req: &Value, + settings: &Arc>, + cfg_path: &Path, + local_steps_allowed: bool, +) -> Reply { let presets_to_add: Vec = match (req.get("preset"), req.get("pack")) { (Some(p), _) => vec![p.as_str().unwrap_or("").to_string()], (_, Some(pack)) => match presets::PACKS.iter().find(|p| Some(p.id) == pack.as_str()) { @@ -614,6 +620,13 @@ fn add(req: &Value, settings: &Arc>, cfg_path: &Path) -> } let built = presets::build(preset, new_id(), &channel).ok_or("unknown catalog entry")?; + // 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 { + continue; + } + return Err(LOCAL_STEPS_LOCKED.to_string()); + } ids.push(built.id.clone()); s.integrations.push(built); } @@ -1288,7 +1301,7 @@ mod tests { } #[test] - fn programs_and_files_are_set_up_only_from_this_pc_without_a_password() { + 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(); @@ -1300,7 +1313,8 @@ mod tests { StepKind::Program, &[("path", r"C:\Tools\thing.exe"), ("args", "")], )); - let locked = |reply: Reply| reply.0 != "200 OK" && reply.2.contains("streaming PC"); + let locked = + |reply: Reply| reply.0 != "200 OK" && reply.2.contains("needs a dashboard password"); assert!(locked(save(&i.to_json(), &settings, &cfg, false))); assert_eq!(save(&i.to_json(), &settings, &cfg, true).0, "200 OK"); diff --git a/src/integrations/runner.rs b/src/integrations/runner.rs index 135e223..19637e2 100644 --- a/src/integrations/runner.rs +++ b/src/integrations/runner.rs @@ -787,7 +787,9 @@ impl Runner { ); return; } - text => text.parse::().ok(), + // Too big for a number is past the 600 s top too. + text if is_whole_number(text) => Some(text.parse::().unwrap_or(u64::MAX)), + _ => None, }; // A delay of 0 is no delay: what Disarm does (`!setdelay 0`). if action == "arm" && seconds == Some(0) { @@ -1391,10 +1393,10 @@ fn delay_action_label(action: &str, ms: u32) -> String { "arm" if ms == 0 => "Set the delay to your default".to_string(), "arm" => format!("Set the delay to {}", fmt_delay(ms)), "activate" => "Turn the delay on".to_string(), - "cut" => "Cut the delay".to_string(), - "cut_after" => "Cut after this airs".to_string(), - "toggle" => "Toggle the delay".to_string(), - "disarm" => "Disarm the delay".to_string(), + "cut" => "Back to live now".to_string(), + "cut_after" => "Back to live after this airs".to_string(), + "toggle" => "Turn the delay on or off".to_string(), + "disarm" => "Turn the delay off".to_string(), "end_hold" => "End the reconnect screen".to_string(), other => format!("Delay action {other}"), } @@ -1886,7 +1888,7 @@ mod tests { // The quotes and `&` are gone: a shell can't read a command into it. assert_eq!( host.calls.lock()[0], - r#"program say.exe ["--from", "ana b", "hi --admin x calc"]"# + r#"program say.exe ["--from", "ana b", "hi admin x calc"]"# ); } @@ -2079,7 +2081,7 @@ mod tests { ], "0 turns it off, blank means the default, the top is 600 s" ); - assert_eq!(log[0].label, "Disarm the delay"); + assert_eq!(log[0].label, "Turn the delay off"); assert_eq!(log[1].label, "Set the delay to your default"); } diff --git a/src/integrations/template.rs b/src/integrations/template.rs index 8c1fe45..587620f 100644 --- a/src/integrations/template.rs +++ b/src/integrations/template.rs @@ -98,16 +98,18 @@ pub fn one_line(value: &str) -> String { /// A value inside a program's argument. Each value already stays one /// argument, but a shell or script host the step runs (cmd, PowerShell, /// bash, `python -c`) reads its own meaning into that argument: `&`, `|`, -/// `;`, `$`, `%`, quotes and brackets could make a value from chat run a -/// command of its own. So only letters, digits, spaces and `_ . , : # + = -/// / -` survive; `..` can't walk up a path, and a value can't start with -/// `-` or `/` (an option) unless it is a negative number. +/// `;`, `$`, `%`, `#`, quotes and brackets could make a value from chat run +/// a command of its own. So only ASCII letters, digits, spaces and +/// `_ . , + = / -` survive: a program reading its arguments in the ANSI +/// code page maps some other letters to quotes or `|` ("best fit"). `..` +/// can't walk up a path, `:` can't name a drive, and no word can start +/// with `-` or `/` (an option) unless it is a negative number. pub fn program_arg(value: &str) -> String { let mut out: String = value .chars() .filter_map(|c| match c { - c if c.is_alphanumeric() => Some(c), - '_' | '.' | ',' | ':' | '#' | '+' | '=' | '/' | '-' => Some(c), + c if c.is_ascii_alphanumeric() => Some(c), + '_' | '.' | ',' | '+' | '=' | '/' | '-' => Some(c), c if c.is_whitespace() => Some(' '), _ => None, }) @@ -115,14 +117,18 @@ pub fn program_arg(value: &str) -> String { while out.contains("..") { out = out.replace("..", "."); } - loop { - let negative_number = - out.starts_with('-') && out[1..].starts_with(|c: char| c.is_ascii_digit()); - if negative_number || !out.starts_with(['-', '/']) { - return out; - } - out.remove(0); - } + out.split(' ') + .map(|word| { + let mut word = word; + while word.starts_with(['-', '/']) + && !(word.starts_with('-') && word[1..].starts_with(|c: char| c.is_ascii_digit())) + { + word = &word[1..]; + } + word + }) + .collect::>() + .join(" ") } /// Every variable name a template uses, in order, without repeats. @@ -284,10 +290,22 @@ mod tests { assert_eq!(program_arg("$(Start-Process calc)"), "Start-Process calc"); assert_eq!(program_arg("%USERPROFILE%|more"), "USERPROFILEmore"); assert_eq!(program_arg("--config=evil"), "config=evil"); - assert_eq!(program_arg("/s /t 0"), "s /t 0"); + assert_eq!(program_arg("/s /t 0"), "s t 0"); + assert_eq!(program_arg("x --upload-pack=calc"), "x upload-pack=calc"); assert_eq!(program_arg("../../secret"), "././secret"); - assert_eq!(program_arg("-5"), "-5", "a negative number stays"); - assert_eq!(program_arg("Café 2 + 2 = 4"), "Café 2 + 2 = 4"); + assert_eq!(program_arg("C:/Windows"), "C/Windows"); + assert_eq!( + program_arg("-5 and -7"), + "-5 and -7", + "negative numbers stay" + ); + assert_eq!(program_arg("2 + 2 = 4, ok"), "2 + 2 = 4, ok"); + assert_eq!( + program_arg("Caf\u{e9} \u{2ba}x\u{1c0}y"), + "Caf xy", + "only ASCII" + ); + assert_eq!(program_arg("a #rest"), "a rest"); assert_eq!(program_arg("line\nbreak"), "line break"); } diff --git a/src/web.rs b/src/web.rs index 68cd19c..2465714 100644 --- a/src/web.rs +++ b/src/web.rs @@ -403,10 +403,7 @@ async fn serve( return Ok(()); } - // Programs and file writes run on this PC: with no password, only the - // PC itself may set them up (see `integrations::api::route`). - let local_steps_allowed = - is_loopback(&peer_ip) || !settings.borrow().dashboard_password_hash.is_empty(); + let local_steps_allowed = may_set_up_local_steps(&settings.borrow(), &peer_ip, head_str); let caller = RouteCaller { is_admin, local_steps_allowed, @@ -426,9 +423,18 @@ async fn serve( } else { "" }; + // An overlay is a page from the overlays folder, which anyone can drop + // files into. Sandboxed, it runs with an origin of its own: its scripts + // still paint (the feeds it reads allow any origin), but it can't act + // as the dashboard, whose API refuses a POST from a foreign origin. + let sandbox = if method == "GET" && bare_path.starts_with("/overlay/") { + "Content-Security-Policy: sandbox allow-scripts\r\n" + } else { + "" + }; let resp = format!( - "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n{}Cache-Control: no-store\r\nConnection: close\r\n\r\n", - status, ctype, payload.len(), acao + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n{}{}Cache-Control: no-store\r\nConnection: close\r\n\r\n", + status, ctype, payload.len(), acao, sandbox ); sock.write_all(resp.as_bytes()).await?; sock.write_all(payload.as_bytes()).await?; @@ -669,7 +675,7 @@ async fn auth_gate( // from seizing the dashboard before the owner sets a password; changing // an existing password already required an admin session at the gate. let first_time = settings.borrow().dashboard_password_hash.is_empty(); - if first_time && !is_loopback(peer_ip) { + if first_time && (!is_loopback(peer_ip) || is_proxied(head_str)) { write_simple( sock, "403 Forbidden", @@ -4812,6 +4818,28 @@ fn host_is_address(host: &str) -> bool { || name.parse::().is_ok() } +/// Whether a request may set up integration steps that run programs or +/// write files. A password always may. Without one, only while the +/// dashboard is closed to the network: then nothing but this PC can reach +/// it. Open to the network, anyone on it could plant a page that the +/// streamer's own browser or OBS loads from this PC (an overlay, say), and +/// a check on who connected would let that page through. +fn may_set_up_local_steps(settings: &Settings, peer_ip: &str, head: &str) -> bool { + !settings.dashboard_password_hash.is_empty() + || (!settings.web_bind_all && is_loopback(peer_ip) && !is_proxied(head)) +} + +/// Whether the request came through a reverse proxy, which connects from +/// this PC on behalf of someone who may be anywhere. +fn is_proxied(head: &str) -> bool { + head.lines().skip(1).any(|line| { + let name = line.split(':').next().unwrap_or("").trim(); + ["x-forwarded-for", "forwarded", "via", "x-real-ip"] + .iter() + .any(|h| name.eq_ignore_ascii_case(h)) + }) +} + fn is_loopback(ip: &str) -> bool { ip.parse::() .map(|a| a.is_loopback()) @@ -6419,6 +6447,32 @@ mod tests { // ── CSRF policy ────────────────────────────────────────────────── + #[test] + fn programs_need_a_password_once_the_dashboard_is_on_the_network() { + let head = "POST /integrations/save HTTP/1.1\r\nHost: 127.0.0.1:7799\r\n"; + let proxied = "POST /integrations/save HTTP/1.1\r\nHost: 127.0.0.1:7799\r\nX-Forwarded-For: 203.0.113.9\r\n"; + let mut s = Settings::defaults(); + assert!( + may_set_up_local_steps(&s, "127.0.0.1", head), + "closed to the network" + ); + assert!( + !may_set_up_local_steps(&s, "127.0.0.1", proxied), + "a proxy on this PC" + ); + s.web_bind_all = true; + assert!( + !may_set_up_local_steps(&s, "127.0.0.1", head), + "open to the network, even from this PC" + ); + assert!(!may_set_up_local_steps(&s, "192.168.1.50", head)); + s.dashboard_password_hash = "pbkdf2$1000$c2FsdA$aGFzaA".into(); + assert!( + may_set_up_local_steps(&s, "192.168.1.50", head), + "a password" + ); + } + #[test] fn csrf_allows_all_gets() { // GETs are always read-only; never gated. diff --git a/web/dock.js b/web/dock.js index 61294b9..8b0268f 100644 --- a/web/dock.js +++ b/web/dock.js @@ -488,7 +488,7 @@ function paintHold(hold) { if (tail) { setText($('hold-why'), 'Delay airing'); setText($('hold-clock'), fmtClock(s.tail_ms)); - setText($('hold-sub'), 'OBS stopped. Ends when viewers reach the end'); + setText($('hold-sub'), 'OBS stopped. The stream ends once your delay has aired.'); return; } if (!hold) { disarmEndHold(); return; } @@ -502,7 +502,7 @@ function paintHold(hold) { // Hint text: full sentences, or errors-only (encoder offline) if opted. function paintTip(ds, hold) { - const tip = hold ? (hold.reason === 'crash' ? 'Reopen OBS (Run in Normal Mode) and start streaming to resume.' : 'Start streaming in OBS to resume.') + 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.` diff --git a/web/index.html b/web/index.html index 306eabd..268d1fc 100644 --- a/web/index.html +++ b/web/index.html @@ -2804,7 +2804,7 @@ .ig-recipe-list{interpolate-size:allow-keywords} .ig-ritem{position:relative;display:flex;flex-direction:column;gap:6px;padding:12px 14px 12px 16px;border-radius:12px; background:var(--surface-2);border:1px solid var(--line);font-size:13px;color:var(--fg-2);overflow:hidden; - animation:igFadeUp .32s var(--ease-out) both;animation-delay:calc(var(--i,0) * 60ms)} + animation:igFadeUp .32s var(--ease-out) both;animation-delay:calc(min(var(--i,0), 8) * 60ms)} .ig-ritem::before{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--line)} .ig-ritem.caution::before{background:var(--warn)} .ig-ritem.danger::before{background:var(--err)} @@ -2842,7 +2842,7 @@ .ig-rsteps{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;counter-reset:rstep} .ig-rsteps .ig-rsteps{margin:6px 0 0 9px;padding-left:16px;border-left:1px dashed var(--line)} .ig-rstep{counter-increment:rstep;font-size:12.5px;color:var(--fg-2);overflow-wrap:anywhere} -.ig-rfold[open] .ig-rstep{animation:igFadeUp .28s var(--ease-out) both;animation-delay:calc(var(--i,0) * 35ms)} +.ig-rfold[open] .ig-rstep{animation:igFadeUp .28s var(--ease-out) both;animation-delay:calc(min(var(--i,0), 12) * 35ms)} .ig-rline{display:flex;align-items:baseline;gap:8px} .ig-rline::before{content:counter(rstep);flex:none;align-self:flex-start;margin-top:-1px;width:18px;height:18px;border-radius:50%;display:grid;place-items:center; font-size:10px;font-weight:700;color:var(--fg-3);background:var(--surface-3)} @@ -2857,7 +2857,8 @@ .ig-rstep .ig-step-text{white-space:normal;overflow:visible;text-overflow:clip;min-width:0} .ig-rstep .ig-rnote{color:var(--fg-3);font-size:11.5px} .ig-rraw-wrap{position:relative} -.ig-rcopy{position:absolute;top:8px;right:8px;z-index:1;backdrop-filter:blur(6px)} +.ig-rraw-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px;font-size:11.5px;color:var(--fg-3)} +.ig-foot-why{border:0;background:none;padding:0;font:inherit;cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px} .ig-rraw{margin:0;padding:12px 14px;max-height:300px;overflow:auto;border-radius:10px;background:var(--bg-2);border:1px solid var(--line); font:11.5px/1.6 'JetBrains Mono',monospace;color:var(--fg-3);white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2;scrollbar-width:thin} .ig-rraw .jk{color:color-mix(in oklch,var(--accent) 75%,var(--fg))} @@ -6005,7 +6006,7 @@

    New destination

    if (s.multitrack_video) return {ready:false, label:'No 9:16 canvas', hint:'In OBS, pick an Additional canvas', title:'OBS is streaming with Enhanced Broadcasting but sends no 9:16 canvas. In OBS: Settings \u2192 Stream \u2192 Enhanced Broadcasting \u2192 Additional canvas, pick your vertical canvas (for example Aitum Vertical).'}; - return {ready:false, label:'Needs Enhanced Broadcasting', hint:'Turn on Enhanced Broadcasting in OBS', + return {ready:false, label:'Needs OBS setup', hint:'Turn on Enhanced Broadcasting in OBS', title:'OBS is streaming without Enhanced Broadcasting, so there is no 9:16 canvas. In OBS: Settings \u2192 Stream, turn on Enhanced Broadcasting and pick your vertical canvas under Additional canvas.'}; } // Set the card's format icon + hover tooltip from the destination's @@ -7527,7 +7528,7 @@

    New destination

    const ingest = $('s-ibind').checked; const hasKey = !!($('s-ingest-key') && $('s-ingest-key').value.trim()); const parts = []; - if (web && !_authEnabled) parts.push('The web UI has no password, so anyone on your network can change the delay, cut and edit destinations. Integrations that run programs or write files stay locked to this PC until you set one in Security below.'); + if (web && !_authEnabled) parts.push('The web UI has no password, so anyone on your network can change the delay, cut and edit destinations. Integrations can’t be set up to run programs or write files until you set one in Security below.'); if (ingest && !hasKey) parts.push('The ingest port accepts any stream key, so anyone on your network can publish into your buffer. Set a required key below to lock it.'); const on = parts.length > 0; $('lan-warn').hidden = !on; @@ -10148,6 +10149,8 @@

    New destination

    window.addEventListener('load', () => { const name = location.hash.slice(1); if (name && document.querySelector(`.tab[data-tab="${CSS.escape(name)}"]`)) showTab(name); + // Once only: a refresh later stays on whatever tab is open then. + if (name) history.replaceState(null, '', location.pathname + location.search); }); function showTab(name){ // Guard unsaved Studio edits: switching away from the Overlay tab hides the diff --git a/web/integrations.js b/web/integrations.js index 3aabc92..b40055c 100644 --- a/web/integrations.js +++ b/web/integrations.js @@ -676,9 +676,10 @@ const oneLine = v => v.replace(/[\u0000-\u001f\u007f]/g, ' '); // An address as safe to show on stream: scheme and host only, since the // path and query often carry an API key or a webhook secret. function maskUrl(url){ - const m = /^(https?:\/\/[^/?#\s]+)(\S*)/i.exec(String(url || '').trim()); + // A login in the address (user:pass@) never shows, like the rest. + const m = /^(https?:\/\/)(?:[^@/?#\s]*@)?([^/?#\s]+)(\S*)/i.exec(String(url || '').trim()); if (!m) return String(url || '').trim() ? 'an address' : ''; - return m[1] + (m[2] && m[2] !== '/' ? '/…' : ''); + return m[1] + m[2] + (m[3] && m[3] !== '/' ? '/…' : ''); } // Whether a field holds something that reads differently once sent: a // variable, or (in Discord) Markdown, a link or a countdown. @@ -877,7 +878,8 @@ function patch(a, b, ctx){ if (tag === 'TEXTAREA' && a.classList.contains('ig-msg')) autosize(a); // A busy button redrawn as disabled (Preview turning into a locked Add) // stays disabled once the work is done: see `busy`. - if (busyEls.has(a)){ a._wantDisabled = b.hasAttribute('disabled'); a.classList.add('is-busy'); a.disabled = true; a.setAttribute('aria-busy', 'true'); } + if (tag === 'BUTTON') a._wantDisabled = b.hasAttribute('disabled'); + if (busyEls.has(a)){ a.classList.add('is-busy'); a.disabled = true; a.setAttribute('aria-busy', 'true'); } if (decoders.has(a)) a.classList.add('ig-decoding'); if (tag === 'INPUT'){ if (a.type === 'checkbox' || a.type === 'radio'){ @@ -1168,7 +1170,12 @@ const UNDO_MS = 8000; // like a password until the user asks to see it. function secretField(id, control, label){ const shown = S.revealed.has(id); - return `