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/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb04c69..38a587e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,9 +111,10 @@ jobs: - uses: Swatinem/rust-cache@v2 # native-tls links system OpenSSL on Linux (schannel on Windows), so the - # headers must be present for the build to link. + # headers must be present for the build to link. ffmpeg lets the + # reconnect screen's lossless round-trip test run instead of skipping. - name: install build deps - run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev + run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev ffmpeg - name: clippy run: cargo clippy --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39d6ce3..fca2c82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,12 @@ on: permissions: contents: write # needed to create releases +# The Twitch app id the Integrations tab logs in with (Device Code flow, a +# public client: the id is not a secret). Baked in at compile time through +# option_env!; set it as a repository variable named TWITCH_CLIENT_ID. +env: + INSTANTCLONE_TWITCH_CLIENT_ID: ${{ vars.TWITCH_CLIENT_ID }} + jobs: # Gate 1: tests must pass on the exact tagged commit before we ship. # This guards against the "tag a commit that was never on main" case @@ -48,8 +54,10 @@ jobs: - uses: Swatinem/rust-cache@v2 with: save-if: false + # ffmpeg: the reconnect screen's decode tests fail on Linux CI without it + # (same deps as ci.yml's test-linux). - name: install build deps - run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev + run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev ffmpeg - name: clippy run: cargo clippy --all-targets -- -D warnings - name: test diff --git a/CHANGELOG.md b/CHANGELOG.md index e4342a2..89272f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,792 @@ 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. 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. +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 + current delay, "tell chat" when OBS drops (only after 20 s, so short + blips never reach chat), VOD markers at every crash and cut, a phone + push, a destination-down alert, mod controls (`!cut`, `!setdelay 30`), + a webhook for n8n or Home Assistant, a crash counter an OBS text source + can show, and more. Starter packs add a few at once. +- **An editor that is the preview.** Pick a moment (OBS crashes, OBS is + back, the hold runs out), write on the message itself, and click a + variable to insert it. Every option (where it posts, when it sends, + who can use a command, cooldowns) is a chip that opens its own panel. +- **Build your own, with no limits.** A step editor: any trigger (an + InstantClone event, a chat command or message, a timer, or a secret web + address a Stream Deck or script can call), checks with then/otherwise, + waits, web requests whose answer later steps can use, delay actions, + clips, programs, files and counters. Plus the one only InstantClone can + do: **wait until the moment has aired for your viewers**, so a `!clip` + 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 + in, their variables translated (`$(user)`, `${1}`, `$(touser)`). Every + web request has **Try it**: send it for real, see the answer as a tree + and click the value you want in the reply. What viewers type is encoded into + its own spot of the request, so `!rank` can't be bent into calling + something else. Or start from a ready-made one: `!uptime`, + `!accountage`, `!followers` and `!game` work in one click, and the + preview shows the real answer right away. +- **One Discord message per drop.** The crash alert posts "OBS dropped" + with a live countdown to the end of the hold, then edits that same + 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 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. +- **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, + 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 + month you never log in again. An optional bot account can post instead + 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 + needs your explicit yes. +- **Nothing an integration does can touch the stream.** They run on their + 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. +- **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. **See every step** lists each step + 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 + 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 + time since boot, and that is negative right after a restart. Launching + at startup hit it every time. +- **The Twitch login can't get stuck.** A Twitch request that never + answered could stop token refreshes for the rest of the session. Twitch + requests now time out. Logging out during a refresh no longer logs you + back in. A clip or marker refused for a revoked login checks the login + right away. Chat reconnects with the current login instead of the one + it started with. +- **Hotkeys and MIDI pads, sorted out:** + - A delay hotkey or pad can't silently take one an integration uses. + - An integration can't be switched on with a key the delay already + 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 + quiet hours, or while the integration was busy, no longer makes the + viewer wait. "Busy" runs no longer hide a failing integration, and a + flood of them is one line in the log. +- **A Discord ping is kept when the message it edits was deleted** and + has to be posted again. +- **`{uses}` survives a restart or quit**, and the integrations data file + is written safely: a file damaged by a power cut is kept aside as + `.bad` instead of being overwritten. +- **Phones:** the editor's Save button and the builder's close button no + longer fall off the screen. The builder scrolls as one page, and the + 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. + - 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. +- 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". + +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. + +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 + +Sending a vertical feed to TikTok, YouTube Shorts or Kick mobile used to +require a Twitch destination, because the only 9:16 canvas InstantClone knew +about was Twitch's Dual Format one. Streamers who only go to TikTok (through +Restream, for example) could not use it at all, and the Vertical option was +greyed out for them (thanks **fashion** on Discord for the report and the +logs that tracked it down). + +- **OBS's Additional canvas feeds vertical destinations.** With no Twitch + destination, InstantClone now builds OBS's Enhanced Broadcasting config + itself from what OBS reports, and asks for your Additional canvas (for + example Aitum Vertical) when you picked one. Set it once in OBS: + Settings -> Stream -> Enhanced Broadcasting, then Additional canvas. +- **Vertical is always selectable.** The lock that disabled it without a + Twitch destination is gone. +- **A waiting destination says why.** Its card shows **EB off** or + **No 9:16 canvas** with the fix written out underneath, the Destinations + header counts how many are waiting, and the log says it once per stream + instead of the destination sitting silent. With a Twitch destination + whose channel has no Dual Format, the log names the vertical + destinations that will get nothing. +- **Twitch 2K channels feed vertical destinations too.** Twitch streams + Partners and Affiliates at 1440p in HEVC, vertical track included, even + with H.264 set in OBS. InstantClone could only find a vertical track in + H.264, so a vertical destination showed "No 9:16 canvas" and got nothing. + It now reads the vertical track from Twitch's config, in any codec, and + forwards it as Twitch sent it. Restream takes HEVC and converts it to + H.264 for TikTok and the rest. +- **A horizontal destination besides Twitch gets an H.264 main track.** + While one is on, InstantClone asks Twitch for H.264, since Kick and most + ingests can't play HEVC. Twitch then streams at 1080p instead of 2K. + +### Enhanced Broadcasting without Twitch now uses your OBS settings + +With Enhanced Broadcasting on, OBS asks InstantClone which video tracks to +encode. Where that answer comes from depends on your destinations: + +- **A Twitch destination is on when you start streaming:** nothing changes. + InstantClone asks Twitch, and Twitch's own Enhanced Broadcasting config is + used, exactly as when OBS streams straight to Twitch. Twitch picks the + codec, resolution and bitrate, and that's the best quality you can get + there. +- **No Twitch destination is on:** there is no Twitch to ask, so + InstantClone answers itself. This is the fallback, and it is what changed + in this version. + +Twitch is only asked at the moment you press Start Streaming. Switching a +Twitch destination on mid-stream keeps the fallback until you stop and start +streaming in OBS again. + +**What the fallback does now.** It used to make up its own settings: first +three x264 encodes at once (enough to overload a gaming PC's CPU), then a +fixed 6000 kbps at your full canvas size plus a vertical track nobody asked +for. That could look worse than streaming with Enhanced Broadcasting off. +Now it copies you instead: + +- **Your stream settings, as with Enhanced Broadcasting off.** The main + track uses what you set in OBS under Settings > Output. In Advanced mode + that is your encoder with all its settings, your bitrate and your Rescale + Output. In Simple mode it is your bitrate. To change how the fallback + looks, change it in OBS as you always would. +- **A vertical track only when you stream one.** OBS's Additional canvas is + encoded only while a destination is set to Vertical, so it costs no GPU + otherwise. It gets the same quality per pixel as your main track. It uses + your encoder too when every vertical destination plays its codec (HEVC to + YouTube or Restream, for example), and H.264 otherwise, which plays + everywhere. +- **If your OBS settings can't be read**, the old defaults are used: 6000 + kbps for the main track (or the Maximum Streaming Bandwidth you set for + Enhanced Broadcasting), on your GPU's H.264 encoder (NVENC, AMF or QSV) + when OBS lists it as available, and x264 otherwise. + +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 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 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 reached through a + reverse proxy or a port forwarder, 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. A web request step aimed at the dashboard itself + counts as coming from outside, so it can't set the first password + either. 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. An overlay file opened in the Studio is + cleaned first, so one made to break out of its styles can't run code + in the dashboard either. +- **Chat can't run commands through a program.** A value typed in chat + 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** + 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 + and Twitch restarted without it. +- **An OBS that reconnects but never sends a picture** no longer keeps the + reconnect screen up forever: after 10 seconds it counts as a freeze and + gets the normal time limit. +- **No backwards timestamps when the reconnect screen starts.** Its first + frames are stamped after the newest audio or video already sent, not the + last one, which can be a few milliseconds older. +- **A second Twitch account on a 2K channel** gets an H.264 main track like + Kick does, since it streams outside the Enhanced Broadcasting session. +- **The vertical track survives a stray config request** made with the wrong + InstantClone key, and the header's Vertical pill now also recognises an + HEVC vertical canvas. +- **Arcade style draws accents and curly quotes.** "¡Volvé!" and "We’ll" show + as VOLVE and WE'LL instead of dropping the letters. +- **Dashboard:** unsaved System edits survive saving a destination or the + password; saving a destination no longer replays the tab's animation; + arrow-key style changes show the save bar; Space on a focused button or + inside a dialog no longer arms or stops the delay; a log source filter + that empties resets to all sources; End now reports failures in the + dashboard and the dock; the panel refits when the crash-protection banner + appears. +- **Twitch no longer loops on "connection aborted" after OBS reconnects + during crash protection.** When OBS dropped with no Enhanced Broadcasting + session to keep and then started a fresh one, the reconnect threw that new + session away as stale. Twitch then got the multi-track stream on its plain + ingest and refused it on every retry. The same happened when OBS took + back the kept session and the hold ran out, or was ended, before OBS + started publishing on it. +- **A frozen OBS that recovers just after crash protection's deadline is + reported as ended, not as back in time.** Its first frame could beat the + deadline check and post "OBS is back, live again" for destinations that + had already ended. +- **A delay always goes on air, and never shorter than you set.** A 1 s + delay, or a 2 s delay with a 4 s keyframe interval, counted as "close + enough" to live, so viewers kept watching live while the dashboard said + delayed. A delay now lands on the newest keyframe at least that old, so it + can run up to one keyframe interval longer than set, never shorter. +- **Raising the delay on air jumps back once.** It could jump back twice + and show viewers the same stretch again. +- **An OBS restart mid-delay never puts viewers on live.** With crash + protection off, destinations came back live and then jumped back once the + buffer refilled. They now wait for the buffer to reach the delay. +- **A destination added mid-delay starts delayed** with long keyframe + intervals too, instead of airing live. +- **A delay longer than the buffer holds no longer gets stuck.** Only the + dashboard checked a delay against the buffer size; a hotkey, a MIDI pad, + auto-arm or a bitrate that rose after arming could ask for more than it + holds, and then the delay never filled: destinations waited forever and + ones already on air stalled. A full buffer now counts as ready, the delay + runs at what it holds, and the log says so. +- **A delay no longer replays the same moment over and over** when the + encoder's keyframes turn out further apart than first measured (OBS's + "auto" keyframe interval, right after a restart). +- **Crash protection plays out what's airing.** When its time runs out while + the delay is still airing, the rest airs in full before the stream ends, + as promised. **End now** ends at once. A frozen OBS that outlasts the + hold ends the stream too; it used to replay the delay's last seconds in a + loop. +- **Coming back from a freeze keeps the full delay.** The delay rebuilds from + OBS's first keyframe, not its first frame, so it no longer rejoins short + of the delay. +- **A destination switched on during crash protection joins the reconnect + screen** instead of waiting for OBS. +- **"Track 2" destinations get only the clean track.** With OBS sending both + tracks, a YouTube or Kick destination set to the VOD / clean track also + got the live one mixed in, copyrighted music included (since 0.1.13). +- **The VOD unlocker script works without Enhanced Broadcasting.** With EB + off, OBS's own stream setup freed the script's VOD audio encoder right + before going live, so OBS sent one audio track and the Twitch VOD had no + separate audio. Re-download the script from **System -> Twitch & OBS -> + Twitch VOD audio** and replace the old one in OBS. It now also logs a + warning when the VOD track goes missing, without needing verbose logging + (thanks **adTexaz** on Discord for the report and the logs that tracked + it down). +- **Editing a live destination restarts it cleanly.** A new URL, stream + format or audio track takes effect right away with a proper goodbye to + the platform. Switching to Vertical mid-stream used to corrupt the picture + until the next cut, and audio track changes waited for the next restart. +- **A platform that accepts and then drops the stream** is retried with a + growing backoff instead of every second, and a platform that never answers + a connect is retried after 20 s instead of showing "connecting" forever. + The reconnect counter now counts only real drops. +- **Restarting a hung OBS works.** A publisher that stopped sending video + hands over to a new one instead of locking it out until its connection + dies. +- **A stopped destination no longer holds the buffer back**, which let the + buffer grow to its full size. +- **Hardened ingest.** Oversized commands, half-sent messages and idle + connections that never publish are cut off before they can use lots of + memory, and refused publishes are logged at most every 10 s. The local + test sink now listens on this computer only. +- **Keyframes are read more carefully:** an encoder's end-of-stream marker is + no longer taken for a keyframe, and streams with short NAL lengths get cut + points. Custom RTMP servers that reject part of the publish setup + InstantClone sends (some answer it with an error) no longer fail the + connection. +- **No backward timestamp when OBS reconnects within half a second**, and a + new stream header mid-stream now goes out before the frame that needs it. +- **Your settings survive a settings file that can't be read.** A file + saved from Notepad as ANSI with an accent in it, or one briefly locked by + antivirus or OneDrive, used to start InstantClone on defaults and save + them over every destination and stream key. Now a stray byte only costs + that character, a locked file is retried, and a file that still can't be + read is copied to `.unreadable` and never overwritten at startup. +- **A deleted destination stays deleted.** Removing your last destination + brought back an enabled "Main" one on the next launch. +- **A malformed video frame can no longer close InstantClone.** A frame + ending in an empty NAL unit crashed the app mid-stream. The RTMP ingest + also no longer reserves memory for data a client only announces. +- **Enhanced Broadcasting destinations start on a clean picture.** A + destination joining a multitrack stream could start on another track's + keyframe and show blocky video until the next one. +- **A broken control request no longer disarms the delay.** A `POST /arm` + with an unreadable body, or one cut off before its end, used to run as if + it had no arguments. It now gets an error and changes nothing. +- **The OBS dock and Stream Deck token no longer see custom RTMP URLs**, + which often hold a stream key. The dashboard still shows them for editing. +- **OBS's service list stays valid.** Registering InstantClone into an OBS + with no services wrote invalid JSON, a failed refresh could list + InstantClone twice, and every refresh added a blank line. +- **The OBS VOD track setting sticks** when OBS's settings file has the same + section twice. +- **Update checks see every newer version**, including `beta.10` after + `beta.9` and versions tagged with build info. +- **Custom RTMP destinations accept IPv6 addresses** like + `rtmp://[::1]:1935/app/key`, and a URL with no stream key is reported + instead of publishing with an empty one. +- **Servers that send AMF0 dates or long strings** in their replies no + longer drop the connection. +- **No false "1920p" warning for Kick** when the main canvas is portrait. +- **Ending a stream now reaches the platform as a clean stop.** InstantClone + sent the platform its goodbye and closed the connection straight away, + and the closing could throw that goodbye away before the platform read + it, so the platform saw a dropped stream instead. It now closes only + after the platform has read it (or after 1 second). +- **The old "VOD + EB" desktop shortcut no longer edits OBS 32.2+.** On + those versions it can't turn on Enhanced Broadcasting for Custom RTMP, so + it now only opens OBS and the log points to the InstantClone service and + the VOD unlocker script. +- **Editing a Twitch destination could reset its pinned ingest to Auto.** + The first time the editor opened after loading the dashboard, the region + list arrived after the saved value was applied, so saving wrote Auto. +- **The Twitch mobile-risk pill never appeared.** It looked for a platform + field the live state doesn't carry. +- **The VOD + EB setup button reset a destination's audio track** to Auto. +- **The header's Vertical pill stayed on after OBS stopped.** +- **Destination save errors are shown as plain text,** and a save that + gets no answer from the app says so instead of doing nothing. +- **Hovering a blue button turned it grey** with near-invisible text, on + every primary button in the dashboard (Save, Arm, Add destination...). + Amber warning buttons lost their colour on hover the same way. +- **Buttons hovered green while a delay was arming.** The hover colour + blended the accent with the stream state's colour, and cyan plus amber + came out green. Each state now hovers in one clean colour: amber while + arming, green while active, the accent otherwise. +- **The bitrate stuck at its last value after OBS stopped** (the header and + graph kept showing, say, 17.40 Mbps with OBS offline). It now drops to 0 + a couple of seconds after data stops, and a destination's rate does the + same. +- **"… Applying" lingered for 2.5 s after Cut or Disarm** even when the app + answered at once. It now clears as soon as the new state arrives, and + reads "Applying" with the dots used elsewhere. The OBS dock no longer + flashes Arm for a moment after Cut delay (the delay stays armed, so it + goes straight to Activate). +- **Idle destination cards squeezed their message** into a narrow column + beside the counters. The counters now drop to their own line. + +### Polish + +- **System is organized by topic.** General, Crash protection, Controls + (hotkeys and MIDI), Twitch & OBS, Access (ingest key, password, ports, + LAN), Alerts & logs, and About. Behavior used to hold six unrelated + things while three tabs held one each. +- **Find a setting.** Type in System's search (or press **/**) and it + opens the right tab and highlights the section. It knows the words + people use, like "keybind", "brb" or "port". +- **A save bar that follows you.** It stays on screen while there are + unsaved changes, with **Discard** to undo them, and the page asks before + you close it with changes unsaved. +- Each tab's scroll area now ends inside the window instead of under it. +- **Destination cards say what they're doing.** "Goes live when OBS does", + "Connecting…" or "Off. Switch it on to stream here." instead of a dash, + frames, cuts and reconnects in words once there is something to count, + and one pulse in the platform's colour when a destination goes live. +- **Platform logos.** Twitch, YouTube and Kick show their own marks in their + colours on destination cards, the platform picker and the setup wizard; + Restream, Custom and the test sink get a clear symbol each. +- **A roomier destination editor.** Two columns on wide screens (name beside + platform, server beside stream key, format beside audio), one on narrow + ones, and a full-screen sheet on phones. The header shows the platform's + logo and an Enabled switch, and Save stays in view while the rest scrolls. +- **Livelier fields and buttons.** Inputs glow softly when focused and their + label lights up with them; buttons lift a touch on hover. Both pick up the + stream state's colour: cyan when idle, shifting toward amber or green. + Dropdowns open as a styled list that fades in, with a turning arrow and a + check on the current choice (older browsers keep the native list). +- **The aurora, reworked.** Five soft pools of light circle slowly behind the + whole delay panel, each at its own size, speed and direction, so the + colours keep folding into each other. They take the stream state's colour + (grey offline, amber buffering, cyan armed, green delayed), and while + buffering the light grows as the buffer fills. The page top glows faintly + with it, the panel's top edge catches the light, and each state change + blooms from the number. Buttons and cards pick up a subtle lit edge too. + It holds still with the system's reduced-motion setting. +- **Clearer at a glance.** Larger section titles, labels and stats numbers, + bigger tab icons with the open tab in the accent colour, and screens that + explain themselves when there's nothing yet (Stats before OBS streams, an + empty Profiles list). +- **A log you can read.** Every event is a row with its time and source; + errors are red, warnings amber, good news green. Filter to Problems or + Errors (with counts), one source, or any text, and jump back to the latest + line after scrolling up. +- **A friendly start.** With no destinations yet, the Destinations tab asks + where you stream and opens the form with that platform picked. +- **Motion that guides the eye.** Cards and sections ease in one after + another when a tab opens, and buttons give a small press. All of it + switches off with the system's reduced-motion setting. +- **A delay panel that answers back.** + - The main button eases between Arm, Activate, Adjust and Cut instead of + snapping, and when only the number changes, only the number flips. + - The delay field rolls its digits up or down as you step through values. + - − at 0, + at 600 or a typed 700 light up the range, and a typed value + past 600 settles on 600. + - A profile too big for the buffer says why when clicked, instead of + hiding it in a tooltip. + - "Waiting for OBS" visibly listens, and the button blooms once when OBS + connects. + - Clicking a profile sends a spark into the button it arms, and the armed + profile fills in. + + All of it plays only on a real change, never on the dashboard's 4× a + second refresh, which now writes nothing when nothing changed. Reduced + motion keeps the colour cues and drops the movement. +- **The status pill glides.** Its words change in step with its colour, and + it eases to its new width instead of jumping. The delay readout also stops + rewriting itself on every refresh when nothing changed. +- **The OBS dock, reworked for mid-stream use.** + - While the buffer fills, the main button counts down ("Ready in 0:23") + and fills toward Activate, instead of a spinner that restarted four + times a second. + - During a crash hold the dock says **Holding** (not Active and Offline + at once), and the hold card shows the countdown, how many destinations + are on the reconnect screen, and **End now**. It takes the place of the + delay controls, which step aside until OBS is back. A dock without + them, like a destinations-only dock, shows the card on top. + - Destinations show when they are on but not streaming yet ("Connecting…" + in amber) and when they are on the reconnect screen, instead of the + same grey dot as off. They follow the live state instead of a 4 s + refresh, so a "Turn off?" no longer vanishes before you can confirm. + - The status chip glides between states, the number pops only on a change + you made (not while the buffer counts up), and the idle "0 s" sits + together as "0s" without moving the +/− buttons. + - Like the dashboard, the dock now writes nothing when a refresh changes + nothing. +- **Add destination** is a tinted button with a real plus icon. +- The log wraps long lines instead of cutting them off, and stops jumping + to the bottom while you are reading older lines. +- Destination cards in a row line up, the frame, cut and reconnect counters + have a tooltip, and the "+ Add" tile keeps keyboard focus. +- The destination editor shows the vertical setup as two steps, only when + Vertical is picked, and folds the audio track details away. +- The OBS tab recommends a GPU encoder and notes that Custom RTMP can't use + Enhanced Broadcasting on OBS 32.2 and newer. ## [0.1.14] - Hotkeys and MIDI for the delay, a dashboard password, and Linux builds @@ -1703,7 +2488,8 @@ See `0.1.0` below for the full feature list. port pre-flight, and process / RSS sampler have Windows-specific paths. - No automated release pipeline yet. Build from source per the README. -[Unreleased]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.14...HEAD +[Unreleased]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.15...HEAD +[0.1.15]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.14...v0.1.15 [0.1.14]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.13...v0.1.14 [0.1.13]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.12...v0.1.13 [0.1.12]: https://github.com/Soulhackzlol/InstantClone/compare/v0.1.11...v0.1.12 diff --git a/Cargo.lock b/Cargo.lock index add05c3..03ed2d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "instantclone" -version = "0.1.14" +version = "0.1.15" dependencies = [ "bytes", "flate2", diff --git a/Cargo.toml b/Cargo.toml index c838b3e..e280172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "instantclone" -version = "0.1.14" +version = "0.1.15" edition = "2021" authors = ["s1moscs"] license = "GPL-3.0-only" @@ -61,6 +61,7 @@ windows-sys = { version = "0.61", features = [ "Win32_System_LibraryLoader", "Win32_Security_Cryptography", "Win32_System_Threading", + "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_DataExchange", "Win32_System_Memory", diff --git a/README.es.md b/README.es.md index b6ea29d..b9b0fb1 100644 --- a/README.es.md +++ b/README.es.md @@ -149,7 +149,7 @@ Haz simulcast de una señal de OBS a Twitch, YouTube, Kick y RTMP personalizado **📱 Vertical (9:16) gratis** -Activa **Formato Dual** de Twitch (Enhanced Broadcasting) y pon el formato de cualquier destino no-Twitch en **Vertical**. InstantClone reutiliza el lienzo 9:16 que OBS ya crea para Twitch y lo envía a YouTube Shorts, Kick móvil o TikTok, sin codificación extra. +En OBS, activa **Enhanced Broadcasting** y elige tu lienzo vertical (por ejemplo Aitum Vertical) en **Lienzo adicional**, y pon el formato de cualquier destino no-Twitch en **Vertical**. InstantClone envía ese lienzo 9:16 a TikTok, YouTube Shorts o Kick móvil, con o sin un destino de Twitch. Activa el destino Vertical antes de empezar a emitir: sin Twitch, la pista vertical solo se codifica mientras haya uno activo. ¿También en Twitch? El **Formato Dual** de Twitch aporta el mismo lienzo. @@ -191,13 +191,29 @@ Rearma o ajusta el delay arriba/abajo sin desarmar primero, expuesto como un con **⌨ Atajos de teclado globales** -Asigna delay on/off, armar, activar, cortar y **cortar cuando esto salga** a una combinación que funciona con un juego en pantalla completa por delante. Toda combinación necesita un modificador para que nada se dispare a media partida, una que otra app ya ocupa se marca en su fila en vez de fallar en silencio, y una acción rechazada te llega como globo en la bandeja. +Asigna delay on/off, armar, activar, cortar, **cortar cuando esto salga** y terminar la protección contra crasheos a una combinación que funciona con un juego en pantalla completa por delante. Toda combinación necesita un modificador para que nada se dispare a media partida, una que otra app ya ocupa se marca en su fila en vez de fallar en silencio, y una acción rechazada te llega como globo en la bandeja. **🎹 Pads y controladoras MIDI** -Mapea las mismas cinco acciones a un pad o un knob, aprendidos pulsando el control en vez de escribir un número de nota. Cada mapeo recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas aunque manden la misma nota, y puedes acotar a qué dispositivo escucha InstantClone. +Mapea las mismas acciones a un pad o un knob, aprendidos pulsando el control en vez de escribir un número de nota. Cada mapeo recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas aunque manden la misma nota, y puedes acotar a qué dispositivo escucha InstantClone. + + + + + + +**🛡 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. @@ -216,7 +232,7 @@ Mapea las mismas cinco acciones a un pad o un knob, aprendidos pulsando el contr -**Dos fases por diseño.** **Armas** un buffer (un tamaño objetivo en segundos). InstantClone lo prellena desde la señal de OBS sin tocar lo que sale. Cuando se llena pulsas **Activar**, y el cambio a diferido es instantáneo en pantalla: el reproductor solo salta del borde en directo a un punto N segundos atrás. +**Dos fases por diseño.** **Armas** un buffer (un tamaño objetivo en segundos). InstantClone lo prellena desde la señal de OBS sin tocar lo que sale. Cuando se llena pulsas **Activar**, y el cambio a diferido es instantáneo en pantalla: el reproductor solo salta del borde en directo al fotograma clave de al menos N segundos atrás, así que el público nunca queda más cerca del directo de lo que pusiste. @@ -249,6 +265,7 @@ Mapea las mismas cinco acciones a un pad o un knob, aprendidos pulsando el contr | POST | `/cut-after` | | Marca el borde en directo; autocorta cuando sale en todos. | | POST | `/cut-after/cancel` | | Descarta un corte programado pendiente. | | POST | `/delay` | `ms=NNN` | De un tiro: arma y autoactiva en cuanto esté listo. | +| POST | `/crash-protection/end` | | Termina ya la pantalla de reconexión: todos los destinos terminan. | | GET | `/state` | | Instantánea JSON puntual. | | GET | `/events` | | Flujo de estado JSON por server-sent events. Solo push. | @@ -306,7 +323,7 @@ Armado de un botón. Añade `/activate` y `/stop` a otros dos botones para contr - **Handshake RTMP con paridad total con OBS.** `connect` lleva la misma bolsa de capacidades de códec que envía librtmp (`audioCodecs=3191`, `videoCodecs=252`, `videoFunction=1`), el `fourCcList` de Enhanced-RTMP (AVC / HEVC / AV1 / VP9 / Opus / AC-3 / FLAC), `Set Chunk Size` antes de connect, `FCUnpublish → deleteStream` al cerrar, y RTMP Acknowledgement (BYTES_READ_REPORT) al umbral ventana/10 declarado por el par, en entrada y salida. - **Passthrough de Enhanced Broadcasting a Twitch.** Cuando OBS pasa a multipista "Auto" hacemos de proxy de `GetClientConfiguration` de Twitch, ruteamos la salida al endpoint IVS asignado a la sesión y reenviamos cada SPS/PPS por pista fielmente para que se encienda la escalera de transcodificado sin importar el nivel de cuenta. Los destinos no-Twitch reciben la pista primaria horizontal por defecto; las etiquetas de escalera con `TrackId != 0` se descartan para evitar la tormenta de varios frames por PTS que hace caer el decodificador de YouTube. Los cortes de EB caen en el IDR de la pista primaria (no en el keyframe del peldaño que gane el `partition_point`) para que el decodificador del destino siempre tenga su ancla. -- **Selección del lienzo vertical (9:16).** El lienzo vertical se identifica decodificando el SPS de cada pista por orientación (retrato, mayor área) en vez de confiar en el JSON privado de sesión de Twitch, y se autocorrige según se activa/desactiva Formato Dual. +- **Selección del lienzo vertical (9:16).** El lienzo vertical se identifica decodificando el SPS de cada pista por orientación (retrato, mayor área) en vez de confiar en el JSON privado de sesión de Twitch, y se autocorrige según aparece o desaparece el lienzo vertical. - **Audio de VOD de Twitch, desbloqueado en el servicio InstantClone.** OBS ata su pista de VOD al servicio llamado literalmente "Twitch" (`ServiceSupportsVodTrack == {"Twitch"}`), así que está bloqueada en el servicio InstantClone. Un pequeño script de OBS incluido (`optional-vod-unlocker.lua`, descargado desde el panel) engancha el mismo segundo codificador de audio que usaría la propia pista de VOD de OBS, sin la restricción. Su lector de formato coincide byte a byte con el `flv_packet_audio_ex` de OBS (`AudioPacketType` en el byte 0, `TrackId` en el byte 6). OBS 32.2+ necesita el script; OBS anterior puede usar la casilla de VOD Track integrada (escribimos `EnableCustomServerVodTrack` en el `user.ini` de OBS 32, con `global.ini` como respaldo). - **Ruteo de audio por destino.** Las pistas no seleccionadas se descartan y la elegida se aplana a una etiqueta de una sola pista estándar (AAC reescrito al `0xAF` legado), espejando el `flatten_multitrack_video` del lado de vídeo. Si la pista elegida no se está enviando, cae a la pista en directo en vez de quedarse en silencio. @@ -336,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, 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), y la descarga de autoactualización + verificación de checksum + intercambio del exe. **392 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.** @@ -346,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`) + 392 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** @@ -415,7 +432,7 @@ En la pestaña **Destinos** de InstantClone, nunca en OBS. OBS solo apunta a Ins
-Sí, ambas cosas. Cinco acciones - delay on/off, armar, activar, cortar a directo y **cortar cuando esto salga** - se asignan a un atajo de teclado global, a un pad o knob MIDI, o a los dos a la vez, en **Ajustes**. Los atajos funcionan con un juego en pantalla completa por delante, así no haces alt-tab a media partida, y toda combinación necesita un modificador (Ctrl, Alt, Shift o Win) para que una tecla suelta no dispare una acción de delay. Los mapeos MIDI se aprenden pulsando el control en vez de escribir un número de nota, y cada uno recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas. Por ahora solo en Windows. +Sí, ambas cosas. Seis acciones - delay on/off, armar, activar, cortar a directo, **cortar cuando esto salga** y terminar la protección contra crasheos - se asignan a un atajo de teclado global, a un pad o knob MIDI, o a los dos a la vez, en **Ajustes**. Los atajos funcionan con un juego en pantalla completa por delante, así no haces alt-tab a media partida, y toda combinación necesita un modificador (Ctrl, Alt, Shift o Win) para que una tecla suelta no dispare una acción de delay. Los mapeos MIDI se aprenden pulsando el control en vez de escribir un número de nota, y cada uno recuerda de qué dispositivo vino, así dos controladoras pueden mover acciones distintas. Por ahora solo en Windows. diff --git a/README.md b/README.md index 55d45e9..1a0cd90 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Simulcast one OBS feed to Twitch, YouTube, Kick, and custom RTMP at once, a free **📱 Vertical (9:16) for free** -Turn on Twitch **Dual Format** (Enhanced Broadcasting) and set any non-Twitch destination's format to **Vertical**. InstantClone reuses the 9:16 canvas OBS already makes for Twitch and sends it to YouTube Shorts, Kick mobile, or TikTok, with no extra encoding. +In OBS, turn on **Enhanced Broadcasting** and pick your vertical canvas (for example Aitum Vertical) under **Additional canvas**, then set any non-Twitch destination's format to **Vertical**. InstantClone sends that 9:16 canvas to TikTok, YouTube Shorts, or Kick mobile, with or without a Twitch destination. Switch the Vertical destination on before you start streaming: without Twitch, the vertical track is only encoded while one is on. Streaming to Twitch too? Twitch **Dual Format** provides the same canvas. @@ -191,13 +191,29 @@ Re-arm or nudge the delay up/down without disarming first, exposed as a single t **⌨ Global hotkeys** -Bind delay on/off, arm, activate, cut, and **cut after this airs** to a key combo that fires while a fullscreen game holds focus. Every binding needs a modifier so nothing trips mid-match, a combo another app already owns is flagged on the row instead of failing silently, and a refused action reaches you as a tray balloon. +Bind delay on/off, arm, activate, cut, **cut after this airs**, and ending crash protection to a key combo that fires while a fullscreen game holds focus. Every binding needs a modifier so nothing trips mid-match, a combo another app already owns is flagged on the row instead of failing silently, and a refused action reaches you as a tray balloon. **🎹 MIDI pads and decks** -Map the same five actions to a pad or knob, learned by pressing the control rather than typing a note number. Each mapping remembers which device it came from, so two controllers can drive different actions even when they send the same note, and you can narrow which device InstantClone listens to. +Map the same actions to a pad or knob, learned by pressing the control rather than typing a note number. Each mapping remembers which device it came from, so two controllers can drive different actions even when they send the same note, and you can narrow which device InstantClone listens to. + + + + + + +**🛡 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. @@ -216,7 +232,7 @@ Map the same five actions to a pad or knob, learned by pressing the control rath -**Two-phase by design.** You **arm** a buffer (a target size in seconds). InstantClone pre-fills it from the live OBS feed without touching what's going out. Once it's full you hit **Activate**, and the switch to delayed is instant on screen: the player just jumps from the live edge to a point N seconds back. +**Two-phase by design.** You **arm** a buffer (a target size in seconds). InstantClone pre-fills it from the live OBS feed without touching what's going out. Once it's full you hit **Activate**, and the switch to delayed is instant on screen: the player just jumps from the live edge to the keyframe at least N seconds back, so viewers are never closer to live than you set. @@ -249,6 +265,7 @@ Map the same five actions to a pad or knob, learned by pressing the control rath | POST | `/cut-after` | | Mark the live edge; auto-cut once it airs everywhere. | | POST | `/cut-after/cancel` | | Drop a pending scheduled cut. | | POST | `/delay` | `ms=NNN` | One-shot: arm, auto-activate as soon as ready. | +| POST | `/crash-protection/end` | | End the reconnect screen now: every destination ends. | | GET | `/state` | | One-shot JSON snapshot. | | GET | `/events` | | Server-sent stream of state JSON. Push-only. | @@ -306,7 +323,7 @@ One-button arming. Add `/activate` and `/stop` to two more buttons for full dela - **Full OBS-parity RTMP handshake.** `connect` carries the same codec-capability bag librtmp ships (`audioCodecs=3191`, `videoCodecs=252`, `videoFunction=1`), the Enhanced-RTMP `fourCcList` (AVC / HEVC / AV1 / VP9 / Opus / AC-3 / FLAC), `Set Chunk Size` before connect, `FCUnpublish → deleteStream` on shutdown, and RTMP Acknowledgement (BYTES_READ_REPORT) at the peer-declared window/10 threshold on both ingest and egress. - **Enhanced Broadcasting passthrough to Twitch.** When OBS hits multi-track "Auto" we proxy Twitch's `GetClientConfiguration`, route egress to the session-allocated IVS endpoint, and forward every per-track SPS/PPS bit-faithfully so the transcoded ladder lights up regardless of account tier. Non-Twitch destinations get the horizontal primary track by default; ladder tags with `TrackId != 0` are dropped to avoid the multi-frame-per-PTS storm that crashes YouTube's decoder. EB cuts land on the primary track's IDR (not whichever ladder rung's keyframe wins the `partition_point`) so the destination decoder always has its anchor. -- **Vertical (9:16) canvas selection.** The vertical canvas is identified by decoding each track's SPS for orientation (portrait, largest area) rather than trusting Twitch's private session JSON, and it self-heals as Dual Format toggles on/off. +- **Vertical (9:16) canvas selection.** The vertical canvas is identified by decoding each track's SPS for orientation (portrait, largest area) rather than trusting Twitch's private session JSON, and it self-heals as the vertical canvas comes and goes. - **Twitch VOD audio, unlocked on the InstantClone service.** OBS hardcodes its VOD Track to the service literally named "Twitch" (`ServiceSupportsVodTrack == {"Twitch"}`), so it's locked on the InstantClone service. A tiny bundled OBS script (`optional-vod-unlocker.lua`, downloaded from the dashboard) attaches the same second audio encoder OBS's own VOD Track would, without the gate. Its wire-format reader matches OBS's `flv_packet_audio_ex` byte-for-byte (`AudioPacketType` in byte 0, `TrackId` at byte 6). OBS 32.2+ needs the script; older OBS can still use the built-in VOD Track checkbox (we write `EnableCustomServerVodTrack` to OBS 32's `user.ini`, falling back to `global.ini`). - **Per-destination audio routing.** Non-selected tracks are dropped and the chosen one is flattened to a standard single-track tag (AAC rewritten to legacy `0xAF`), mirroring the video-side `flatten_multitrack_video`. If the chosen track isn't being sent, it falls back to the live track rather than going silent. @@ -336,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 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), and the self-update download + checksum-verify + exe swap. **392 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.** @@ -346,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`) + 392 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** @@ -415,7 +432,7 @@ In InstantClone's **Destinations** tab, never in OBS. OBS only ever points at In
-Yes, both. Five actions - delay on/off, arm, activate, cut to live, and **cut after this airs** - bind to a global keyboard shortcut, a MIDI pad or knob, or both at once, in **Settings**. Hotkeys fire while a fullscreen game holds focus, so you never alt-tab mid-match, and every binding needs a modifier (Ctrl, Alt, Shift or Win) so a stray keypress can't trip a delay action. MIDI mappings are learned by pressing the control rather than typing a note number, and each one remembers which device it came from, so two controllers can drive different actions. Windows only for now. +Yes, both. Six actions - delay on/off, arm, activate, cut to live, **cut after this airs**, and ending crash protection - bind to a global keyboard shortcut, a MIDI pad or knob, or both at once, in **Settings**. Hotkeys fire while a fullscreen game holds focus, so you never alt-tab mid-match, and every binding needs a modifier (Ctrl, Alt, Shift or Win) so a stray keypress can't trip a delay action. MIDI mappings are learned by pressing the control rather than typing a note number, and each one remembers which device it came from, so two controllers can drive different actions. Windows only for now. diff --git a/assets/fonts/Inter-Regular-slate.ttf b/assets/fonts/Inter-Regular-slate.ttf new file mode 100644 index 0000000..bea36c7 Binary files /dev/null and b/assets/fonts/Inter-Regular-slate.ttf differ diff --git a/assets/fonts/Inter-SemiBold-slate.ttf b/assets/fonts/Inter-SemiBold-slate.ttf new file mode 100644 index 0000000..d24ee49 Binary files /dev/null and b/assets/fonts/Inter-SemiBold-slate.ttf differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/build.rs b/build.rs index 570f4bc..6010b44 100644 --- a/build.rs +++ b/build.rs @@ -29,6 +29,8 @@ fn main() { // overlay runtime), so skip the line minifier and let gzip do it. ("dock.js", false), ("overlay-runtime.js", false), + // Integrations UI: template-literal heavy like the dock. + ("integrations.js", false), ] { let src_path = web_dir.join(name); println!("cargo:rerun-if-changed={}", src_path.display()); diff --git a/obs/instantclone-vod-track.lua b/obs/instantclone-vod-track.lua index 517f3cc..0347dc8 100644 --- a/obs/instantclone-vod-track.lua +++ b/obs/instantclone-vod-track.lua @@ -25,18 +25,38 @@ -- next start, on unload) is exactly what raced OBS and crashed -- (c0000005 in receive_audio / obs_encoder_release). So: no encoder state -- kept, no deferred releases, nothing to clean up. +-- +-- ONE EXCEPTION (OBS bug, every version 28-32): on OBS's own plain stream +-- outputs, StartStreaming -> SetupVodTrack -> clear_archive_encoder calls +-- obs_encoder_release() on the index-1 encoder it only BORROWED from +-- obs_output_get_audio_encoder. That stray release destroys our encoder +-- (and obs_encoder_destroy detaches it) before the stream starts, so OBS +-- sends one audio track. On those outputs we hand our create-reference to +-- OBS instead of releasing it: the stray release consumes it and the +-- output is again the sole owner. Enhanced Broadcasting streams through a +-- separate multitrack output that clear_archive_encoder never touches, so +-- there we release normally. (With OBS's native Twitch VOD enabled, OBS +-- replaces our encoder instead; the one idle encoder left over is inert.) local obs = obslua local ENCODER_ID = "ffmpeg_aac" -- always-present AAC encoder local ENCODER_NAME = "InstantClone VOD audio" -- how we recognise our own track +-- OBS's plain stream outputs (Advanced / Simple output mode), the ones +-- clear_archive_encoder stray-releases on. See the OWNERSHIP exception. +local PLAIN_STREAM_OUTPUTS = { adv_stream = true, simple_stream = true } + -- ── settings (mirrored from the script properties UI) ──────────────── local enabled = true local vod_track = 2 -- OBS mixer track (2..6) that carries the VOD audio local vod_bitrate = 160 -- kbps local verbose = false -- extra diagnostic logging (off for clean logs) +-- Whether this stream start attached our track, so the post-start check +-- can warn when it went missing. A flag only - never an encoder handle. +local attached = false + local function log(msg) obs.script_log(obs.LOG_INFO, "[InstantClone VOD] " .. msg) end @@ -60,6 +80,7 @@ end -- Called on STREAMING_STARTING - the pre-start window OBS uses for its own -- native VOD setup, so the encoder is picked up when the output starts. local function on_streaming_starting() + attached = false local output = obs.obs_frontend_get_streaming_output() if output == nil then log("no streaming output at start - cannot attach VOD track") @@ -85,10 +106,11 @@ local function on_streaming_starting() return end - -- Create a fresh encoder, attach it, and immediately release our - -- create-reference. The output now owns it (and releases the previous - -- index-1 encoder as part of the set). We keep no reference and never - -- release it again - see the OWNERSHIP note. + -- Create a fresh encoder, attach it, and give up our create-reference: + -- released here, or on a plain output left for OBS's stray release. + -- The output now owns it (and releases the previous index-1 encoder as + -- part of the set). We keep no reference and never release it again - + -- see the OWNERSHIP note. local settings = obs.obs_data_create() obs.obs_data_set_int(settings, "bitrate", vod_bitrate) local enc = obs.obs_audio_encoder_create(ENCODER_ID, ENCODER_NAME, settings, vod_track - 1, nil) @@ -100,14 +122,19 @@ local function on_streaming_starting() end obs.obs_encoder_set_audio(enc, obs.obs_get_audio()) obs.obs_output_set_audio_encoder(output, enc, 1) - obs.obs_encoder_release(enc) -- balance our create-ref; the output owns it now + if not PLAIN_STREAM_OUTPUTS[obs.obs_output_get_name(output)] then + obs.obs_encoder_release(enc) -- balance our create-ref; the output owns it now + end + attached = true log(string.format("VOD track attached: OBS track %d -> stream audio 2 @ %d kbps", vod_track, vod_bitrate)) obs.obs_output_release(output) end --- Diagnostic only (verbose): confirm our track is at index 1 after start. +-- Diagnostic: confirm our track is still at index 1 after start. Always +-- warns when an attached track went missing (the stream then carries one +-- audio track and Twitch has no VOD audio); the full line is verbose-only. local function verify_after_start() - if not verbose then + if not (attached or verbose) then return end local output = obs.obs_frontend_get_streaming_output() @@ -116,6 +143,11 @@ local function verify_after_start() end local present, ours = inspect_index_1(output) vlog(string.format("post-start check: idx1 present=%s ours=%s", tostring(present), tostring(ours))) + if attached and not present then + log("WARNING: VOD track missing after stream start - this stream has no VOD audio") + elseif attached and not ours then + log("OBS's native VOD track replaced ours - using OBS's VOD track") + end obs.obs_output_release(output) end diff --git a/scripts/subset_slate_fonts.py b/scripts/subset_slate_fonts.py new file mode 100644 index 0000000..976a3bb --- /dev/null +++ b/scripts/subset_slate_fonts.py @@ -0,0 +1,73 @@ +"""Build the Inter subsets the crash-protection reconnect screen draws with. + +Keeps Basic Latin, Latin-1 and common punctuation, drops hinting and +OpenType layout, and decomposes composite glyphs so the built-in +rasterizer (src/slate/font.rs) only ever sees simple quadratic outlines. + +Usage: + pip install fonttools + python scripts/subset_slate_fonts.py path/to/Inter-4.1/extras/ttf + +Inter is by Rasmus Andersson, SIL Open Font License 1.1 +(assets/fonts/OFL.txt). Download: https://github.com/rsms/inter/releases +""" + +import sys +from pathlib import Path + +from fontTools import subset +from fontTools.pens.recordingPen import DecomposingRecordingPen +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import TTFont + +WEIGHTS = ("Regular", "SemiBold") +UNICODES = ( + list(range(0x20, 0x7F)) + + list(range(0xA0, 0x100)) + + [0x2013, 0x2014, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2026] +) +OUT_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts" + + +def subset_font(source: Path) -> TTFont: + options = subset.Options() + options.hinting = False + options.layout_features = [] + options.name_IDs = [0, 1, 2, 3, 4, 5, 6, 13, 14] + options.notdef_outline = True + font = TTFont(source) + subsetter = subset.Subsetter(options) + subsetter.populate(unicodes=UNICODES) + subsetter.subset(font) + return font + + +def decompose_composites(font: TTFont) -> None: + glyph_set = font.getGlyphSet() + glyf = font["glyf"] + for name in font.getGlyphOrder(): + if not glyf[name].isComposite(): + continue + recording = DecomposingRecordingPen(glyph_set) + glyph_set[name].draw(recording) + pen = TTGlyphPen(None) + recording.replay(pen) + glyf[name] = pen.glyph() + glyf[name].recalcBounds(glyf) + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(__doc__) + source_dir = Path(sys.argv[1]) + OUT_DIR.mkdir(parents=True, exist_ok=True) + for weight in WEIGHTS: + font = subset_font(source_dir / f"Inter-{weight}.ttf") + decompose_composites(font) + target = OUT_DIR / f"Inter-{weight}-slate.ttf" + font.save(target) + print(f"{target.name}: {target.stat().st_size} bytes") + + +if __name__ == "__main__": + main() diff --git a/src/auth.rs b/src/auth.rs index c566f2c..cc6ed9b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -254,23 +254,105 @@ mod tests { assert!(a.begin_login_attempt(ip).is_ok()); } + /// The lockout `client` is currently serving, read back through `check`. + /// Measured just after the failure that armed it, so it sits a hair under + /// the armed value; `assert_lockout` allows for that. + fn remaining_lockout(r: &RateLimiter, client: &str) -> Duration { + r.check(client).expect_err("client should be locked out") + } + + fn assert_lockout(r: &RateLimiter, client: &str, want_secs: u64) { + let left = remaining_lockout(r, client); + let want = Duration::from_secs(want_secs); + assert!( + left <= want && left > want - Duration::from_secs(1), + "expected a ~{want_secs}s lockout, got {left:?}" + ); + } + + /// Each failure past the threshold doubles the lockout until it reaches + /// `max_lockout`, where it stays. Read back without sleeping: `check` + /// reports how long is left, which right after arming is the armed value. #[test] - fn check_and_count_counts_at_check_time() { - // The gate must count the attempt itself, so concurrent callers can't - // all pass before any failure lands (the TOCTOU the login await opened). + fn lockout_doubles_past_the_threshold_up_to_the_cap() { let r = RateLimiter::new( - 3, - Duration::from_secs(30), - Duration::from_secs(600), - Duration::from_secs(600), + 2, + Duration::from_secs(10), + Duration::from_secs(45), + Duration::from_secs(3600), ); let c = "peer"; - assert!(r.check_and_count(c).is_ok()); // 1 - assert!(r.check_and_count(c).is_ok()); // 2 - assert!(r.check_and_count(c).is_ok()); // 3 -> arms the lock - assert!(r.check_and_count(c).is_err()); // 4 -> locked, no verify happens - r.record_success(c); - assert!(r.check_and_count(c).is_ok()); // cleared + r.record_failure(c); + assert!(r.check(c).is_ok(), "one failure is under the threshold"); + for want in [10, 20, 40, 45, 45] { + r.record_failure(c); + assert_lockout(&r, c, want); + } + } + + /// A lockout never outlasts the forgiveness window. `check` forgets an + /// entry once it is older than the window, so a longer lock would be + /// wiped early and the two settings would disagree about when a client + /// may try again. + #[test] + fn lockout_is_clamped_to_the_forgiveness_window() { + let r = RateLimiter::new( + 1, + Duration::from_secs(10), + Duration::from_secs(3600), + Duration::from_secs(25), + ); + let c = "peer"; + for want in [10, 20, 25, 25] { + r.record_failure(c); + assert_lockout(&r, c, want); + } + } + + /// A client that keeps failing must not overflow the doubling. Without the + /// cap on the shift, the 33rd failure past the threshold is `1u32 << 32`: + /// a panic in a debug build, and in release a shift that wraps back to a + /// short lockout, handing the most persistent guesser the fastest retries. + #[test] + fn endless_failures_do_not_overflow_the_doubling() { + let day = Duration::from_secs(24 * 3600); + let r = RateLimiter::new(1, Duration::from_secs(1), day, day); + let c = "peer"; + for _ in 0..100 { + r.record_failure(c); + } + // The shift is capped at 6: base * 64. + assert_lockout(&r, c, 64); + } + + /// The tracked-client map is bounded, and when a flood fills it the + /// oldest entry is the one dropped. `MAX_TRACKED` is private to + /// `bump_failure`; the 8192 here mirrors it. + #[test] + fn a_full_table_evicts_the_oldest_client() { + const MAX_TRACKED: usize = 8192; + let r = RateLimiter::new( + 1, + Duration::from_secs(60), + Duration::from_secs(60), + Duration::from_secs(3600), + ); + r.record_failure("oldest"); + // Make every later entry strictly newer than "oldest" so it is the + // unique minimum. A busy-wait on the clock, well under a microsecond, + // rather than a sleep. + let armed = Instant::now(); + while Instant::now() <= armed {} + for i in 1..MAX_TRACKED { + r.record_failure(&format!("client-{i}")); + } + assert_eq!(r.attempts.lock().len(), MAX_TRACKED, "table is full"); + + r.record_failure("newcomer"); + assert_eq!(r.attempts.lock().len(), MAX_TRACKED, "table stays bounded"); + assert!(r.check("oldest").is_ok(), "the oldest entry was evicted"); + assert!(r.check("client-1").is_err(), "newer entries are kept"); + assert!(r.check("newcomer").is_err(), "the newcomer is tracked"); } #[test] diff --git a/src/autostart.rs b/src/autostart.rs index 8f70bd9..339f373 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -43,8 +43,15 @@ fn run_command() -> io::Result { /// means the same thing to the user either way. #[cfg(windows)] pub fn is_enabled() -> bool { + is_enabled_under(RUN_KEY) +} + +/// `is_enabled` against any HKCU subkey. Split out so the round-trip test +/// can use a private key instead of the developer's real Run key. +#[cfg(windows)] +fn is_enabled_under(subkey: &str) -> bool { use windows_sys::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_SZ}; - let subkey = wide(RUN_KEY); + let subkey = wide(subkey); let name = wide(VALUE_NAME); // Null output buffer + null size: asks only "does this value exist, // and is it a string?" without reading the data back. @@ -69,10 +76,17 @@ pub fn is_enabled() -> bool { /// of pointing at an executable that no longer exists. #[cfg(windows)] pub fn set(enabled: bool) -> io::Result<()> { + set_under(RUN_KEY, enabled) +} + +/// `set` against any HKCU subkey. Split out so the round-trip test can use +/// a private key instead of the developer's real Run key. +#[cfg(windows)] +fn set_under(subkey: &str, enabled: bool) -> io::Result<()> { use windows_sys::Win32::System::Registry::{ RegDeleteKeyValueW, RegSetKeyValueW, HKEY_CURRENT_USER, REG_SZ, }; - let subkey = wide(RUN_KEY); + let subkey = wide(subkey); let name = wide(VALUE_NAME); let rc = if enabled { @@ -297,29 +311,47 @@ mod tests { ); } - /// Round-trip against the real registry. Writes under HKCU, which - /// needs no elevation, and restores whatever state the machine was in - /// so running the suite never changes the developer's own startup. + /// Private HKCU key the round-trip test owns outright. A single level so + /// deleting it leaves nothing behind, and never the real Run key: a test + /// that touched that could rewrite or drop the developer's own startup + /// entry, and a panic midway would leave it that way. + const TEST_KEY: &str = r"Software\InstantCloneAutostartTest"; + + /// Deletes `TEST_KEY` on drop, so a failed assertion still cleans up. + struct TestKeyGuard; + + impl Drop for TestKeyGuard { + fn drop(&mut self) { + use windows_sys::Win32::System::Registry::{RegDeleteTreeW, HKEY_CURRENT_USER}; + let subkey = wide(TEST_KEY); + unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, subkey.as_ptr()) }; + } + } + + /// Round-trip through the same code path `set`/`is_enabled` use, but + /// against a private key, so running the suite never changes the + /// developer's own startup. #[test] fn enable_disable_round_trip() { - let original = is_enabled(); + let _cleanup = TestKeyGuard; + assert!( + !is_enabled_under(TEST_KEY), + "stale test key from an earlier run" + ); - set(true).expect("enable"); - assert!(is_enabled(), "value must exist after enabling"); + set_under(TEST_KEY, true).expect("enable"); + assert!( + is_enabled_under(TEST_KEY), + "value must exist after enabling" + ); - set(false).expect("disable"); - assert!(!is_enabled(), "value must be gone after disabling"); + set_under(TEST_KEY, false).expect("disable"); + assert!( + !is_enabled_under(TEST_KEY), + "value must be gone after disabling" + ); // Disabling an already-absent value is a no-op, not an error. - set(false).expect("disable twice"); - - if original { - set(true).expect("restore"); - } - assert_eq!( - is_enabled(), - original, - "test must leave startup state as found" - ); + set_under(TEST_KEY, false).expect("disable twice"); } } diff --git a/src/buffer.rs b/src/buffer.rs index 5b2a4c2..6b63b06 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -83,6 +83,11 @@ struct RingInner { /// `index`: every IDR push_back also push_backs here; every eviction /// also pops the IDR-front if it matches by seq. idr_index: VecDeque, + /// Payload bytes of every tag in `index`, kept in step with it. Near + /// `capacity` means the ring is full: new tags are overwriting the + /// oldest ones, so the buffer reaches back no further (see + /// `is_saturated`). + indexed_bytes: u64, } impl DiskRing { @@ -122,6 +127,7 @@ impl DiskRing { next_seq: 0, index: VecDeque::with_capacity(65_536), idr_index: VecDeque::with_capacity(2_048), + indexed_bytes: 0, }), video_seq_headers: Mutex::new(std::collections::BTreeMap::new()), audio_seq_headers: Mutex::new(std::collections::BTreeMap::new()), @@ -194,6 +200,7 @@ impl DiskRing { while let Some(front) = inner.index.front().copied() { if write_overlaps(offset, len, self.capacity, front.offset, front.len as u64) { inner.index.pop_front(); + inner.indexed_bytes -= u64::from(front.len); if front.is_idr { // Front of idr_index MUST be this same IDR - both // queues are time-ordered and we only ever push at @@ -237,6 +244,7 @@ impl DiskRing { is_idr, }; inner.index.push_back(meta); + inner.indexed_bytes += len; if is_idr && crate::h264::is_primary_video_idr(payload) { // Only the PRIMARY track's IDRs become cut candidates. Each // EB ladder rung has its own IDR cadence; OBS aligns them @@ -385,39 +393,46 @@ impl DiskRing { } } - /// Return the most recent IDR (used to seed the egress state cleanly - /// without walking the entire index entry-by-entry). - pub fn newest_idr(&self) -> Option { + /// The newest primary-track IDR stamped at or before `ts`, or None when + /// every IDR in the ring is newer. A delay lands here so viewers are + /// never closer to live than asked: the nearest IDR can be the newer one. + pub fn newest_idr_at_or_before(&self, ts: u64) -> Option { let inner = self.inner.lock(); - inner.index.iter().rev().find(|m| m.is_idr).copied() + let pos = inner.idr_index.partition_point(|m| m.ts_ms <= ts); + pos.checked_sub(1) + .and_then(|below| inner.idr_index.get(below).copied()) + } + + /// Return the most recent primary-track IDR (used to seed the egress + /// state cleanly without walking the entire index entry-by-entry). + /// + /// This and the two lookups below read `idr_index`, not `index`: a + /// tag's `is_idr` is also true for an Enhanced Broadcasting rung's + /// keyframe, which OBS sends right after the primary's for the same + /// frame. Seeding on that one starts a horizontal destination one tag + /// past its own keyframe, on P-frames with no reference. + pub fn newest_idr(&self) -> Option { + self.inner.lock().idr_index.back().copied() } - /// Most recent IDR whose seq is strictly greater than `min_seq`. - /// Used after a publisher reconnect to skip stale IDRs from the - /// previous session that still happen to live in the ring. + /// Most recent primary IDR whose seq is strictly greater than + /// `min_seq`. Used after a publisher reconnect to skip stale IDRs from + /// the previous session that still happen to live in the ring. pub fn newest_idr_after(&self, min_seq: u64) -> Option { let inner = self.inner.lock(); - inner - .index - .iter() - .rev() - .find(|m| m.is_idr && m.seq > min_seq) - .copied() + inner.idr_index.back().filter(|m| m.seq > min_seq).copied() } - /// OLDEST IDR whose seq is >= `min_seq`. Used by the egress pump - /// after eviction skip-ahead - landing on a random P-frame would + /// OLDEST primary IDR whose seq is >= `min_seq`. Used by the egress + /// pump after eviction skip-ahead - landing on a random P-frame would /// stream P-frames that reference absent reference frames and the /// player would show macroblocking until the next IDR. Returning /// the *earliest* IDR at or after the skip target loses the least /// content while keeping the decode chain valid. pub fn oldest_idr_at_or_after(&self, min_seq: u64) -> Option { let inner = self.inner.lock(); - inner - .index - .iter() - .find(|m| m.is_idr && m.seq >= min_seq) - .copied() + let pos = inner.idr_index.partition_point(|m| m.seq < min_seq); + inner.idr_index.get(pos).copied() } /// Seq of the most recently appended tag, or None if the ring is empty. @@ -446,6 +461,7 @@ impl DiskRing { } if front.ts_ms < cutoff { inner.index.pop_front(); + inner.indexed_bytes -= u64::from(front.len); // Keep the IDR-only index in sync - same defensive front // check as the byte-overlap eviction path in `append`. if front.is_idr && inner.idr_index.front().map(|m| m.seq) == Some(front.seq) { @@ -477,6 +493,15 @@ impl DiskRing { let mut inner = self.inner.lock(); inner.index.clear(); inner.idr_index.clear(); + inner.indexed_bytes = 0; + } + + /// Whether the ring is full: the tags it holds fill all but the last + /// 1/16 of it, so every new tag overwrites an old one and the buffer + /// reaches back no further at this bitrate. A delay longer than that + /// can never fill; the controller then delays by what the ring holds. + pub fn is_saturated(&self) -> bool { + self.inner.lock().indexed_bytes >= self.capacity - self.capacity / 16 } } @@ -683,41 +708,6 @@ mod tests { assert_eq!(*t.0.metadata.lock(), Some(b"onMetaData".to_vec())); } - #[test] - fn oversized_tag_is_rejected_silently() { - // Capacity 4096, tag of 3000 bytes (> cap/2) - must be dropped. - let t = tmp(4096); - let big = vec![0u8; 3000]; - let r = t.0.append(9, 0, &big, false, false).unwrap(); - assert!(r.is_none()); - assert_eq!(t.0.front_seq(), None, "ring must remain empty"); - } - - #[test] - fn wrapping_write_evicts_oldest_and_reads_correctly() { - // Capacity 256. Write 6 × 80 = 480 bytes total - wraps the cursor - // twice. Oldest tags get evicted; we read the latest one back. - let t = tmp(256); - let mut last_seq = 0; - for i in 0..6 { - let payload = vec![(i + 1) as u8; 80]; - last_seq = - t.0.append(9, i as u64, &payload, false, false) - .unwrap() - .unwrap(); - } - // Front is no longer seq=0 - let front = t.0.front_seq().unwrap(); - assert!(front > 0, "oldest tags must have been evicted"); - - let mut buf = Vec::new(); - assert!(t.0.try_read_seq(last_seq, &mut buf).unwrap().is_some()); - assert_eq!(buf, vec![6u8; 80]); - - // The evicted oldest seq must read as None (it's gone). - assert!(t.0.try_read_seq(0, &mut buf).unwrap().is_none()); - } - #[test] fn find_idr_near_picks_closest() { let t = tmp(8192); @@ -818,45 +808,6 @@ mod tests { assert!(t.0.try_read_seq(u64::MAX, &mut buf).unwrap().is_none()); } - #[test] - fn tag_exactly_at_half_capacity_is_accepted() { - // The rejection threshold is `> cap/2`. A tag exactly at cap/2 - // bytes must be accepted (boundary check, not off-by-one). - let t = tmp(2048); - let payload = vec![0u8; 1024]; // exactly cap/2 - let r = t.0.append(9, 100, &payload, false, false).unwrap(); - assert!( - r.is_some(), - "tag at cap/2 must be accepted, not the rejection branch" - ); - } - - #[test] - fn ring_survives_multiple_full_wraps() { - // Cap 512. Write 30 tags of 80 bytes = 2400 bytes total, ~4.7× - // the capacity. The ring must keep the most recent tag readable - // and the index must not corrupt itself across multiple wraps. - let t = tmp(512); - let mut last_seq = 0; - for i in 0..30u32 { - let payload = vec![i as u8; 80]; - last_seq = - t.0.append(9, (i * 100) as u64, &payload, false, false) - .unwrap() - .unwrap(); - } - let mut buf = Vec::new(); - let r = t.0.try_read_seq(last_seq, &mut buf).unwrap(); - assert!(r.is_some()); - assert_eq!( - buf.first(), - Some(&29u8), - "latest tag's bytes must be intact" - ); - // Front seq is well past 0 (many evictions happened) - assert!(t.0.front_seq().unwrap() > 20); - } - #[test] fn all_idr_queries_return_none_on_empty_ring() { // Every IDR-lookup variant must early-return None instead of @@ -880,6 +831,62 @@ mod tests { assert_eq!(t.0.find_idr_near(1000, 0).unwrap().ts_ms, 1000); } + /// A Twitch Enhanced Broadcasting keyframe for a NON-primary ladder + /// rung: OneTrack multitrack, FrameType 1, TrackId `track`. Ingest + /// stores it with `is_idr = true` (classify_video_tag reads the outer + /// FrameType), but it is not a keyframe of the horizontal primary. + fn rung_idr_payload(track: u8) -> Vec { + let mut v = vec![0x96, 0x01, b'a', b'v', b'c', b'1', track]; + v.resize(40, 0); + v + } + + #[test] + fn idr_seeds_are_primary_track_keyframes_only() { + // With EB, OBS sends the primary's IDR and then each rung's IDR for + // the same frame. Seeding a Track(0) destination on a rung's IDR + // starts it one tag PAST its own keyframe, so it opens on primary + // P-frames with no reference: exactly the glitch the primary-only + // `idr_index` exists to prevent, reintroduced by the seed lookups + // that walked `index` for any `is_idr`. + let t = tmp(8192); + let p_frame = [0x27u8; 40]; + let first = + t.0.append(9, 1000, &primary_idr_payload(40), true, false) + .unwrap() + .unwrap(); + let rung = + t.0.append(9, 1000, &rung_idr_payload(1), true, false) + .unwrap() + .unwrap(); + t.0.append(9, 1033, &p_frame, false, false).unwrap(); + let second = + t.0.append(9, 2000, &primary_idr_payload(40), true, false) + .unwrap() + .unwrap(); + t.0.append(9, 2000, &rung_idr_payload(2), true, false) + .unwrap(); + t.0.append(9, 2033, &p_frame, false, false).unwrap(); + + assert_eq!(t.0.newest_idr().map(|m| m.seq), Some(second)); + assert_eq!(t.0.newest_idr_after(first).map(|m| m.seq), Some(second)); + assert!( + t.0.newest_idr_after(second).is_none(), + "only rung IDRs follow" + ); + assert_eq!( + t.0.oldest_idr_at_or_after(rung).map(|m| m.seq), + Some(second) + ); + assert_eq!( + t.0.oldest_idr_at_or_after(first).map(|m| m.seq), + Some(first) + ); + // The rung's own flag is kept: the vertical-canvas pump reads + // `meta.is_idr` to find its own canvas's first keyframe. + assert!(t.0.find_by_seq(rung).unwrap().1.is_idr); + } + #[test] fn newest_idr_after_skips_stale_publisher_idrs() { // Models the publisher-reconnect case: there are old IDRs in the @@ -897,16 +904,58 @@ mod tests { #[cfg(test)] mod edge_tests { use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + static UNIQ: AtomicU32 = AtomicU32::new(0); + + /// A ring in its own temp file, deleted on drop - including when an + /// assertion fails mid-test. The name is process id plus a counter, so + /// tests running in parallel (or two `cargo test` runs) never share a + /// file the way a wall-clock name could. + struct Ring(DiskRing, std::path::PathBuf); - fn ring(capacity: u64) -> (DiskRing, std::path::PathBuf) { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let path = std::env::temp_dir().join(format!("ic-edge-ring-{nanos}-{capacity}.buf")); + impl Drop for Ring { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.1); + } + } + + impl std::ops::Deref for Ring { + type Target = DiskRing; + fn deref(&self) -> &DiskRing { + &self.0 + } + } + + fn ring(capacity: u64) -> Ring { + let n = UNIQ.fetch_add(1, Ordering::SeqCst); + let path = + std::env::temp_dir().join(format!("ic-edge-ring-{}-{}.buf", std::process::id(), n)); let _ = std::fs::remove_file(&path); - let r = DiskRing::create(&path, capacity).expect("ring create"); - (r, path) + Ring( + DiskRing::create(&path, capacity).expect("ring create"), + path, + ) + } + + /// Legacy AVC keyframe shape, so it passes the primary-IDR gate. + fn idr(len: usize) -> Vec { + let mut v = vec![0x17]; + v.resize(len, 0); + v + } + + /// Payload whose every byte depends on both the tag and the position, + /// so a read that mixes two tags or rotates the split cannot pass. + fn distinct(tag: u64, len: usize) -> Vec { + (0..len).map(|j| (tag * 7 + j as u64 * 13) as u8).collect() + } + + fn read(r: &DiskRing, seq: u64) -> Option> { + let mut buf = Vec::new(); + r.try_read_seq(seq, &mut buf) + .expect("no io error") + .map(|()| buf) } /// A tag too big for the ring to hold half of can never be stored @@ -914,7 +963,7 @@ mod edge_tests { /// the index. The ring has to stay usable afterwards. #[test] fn an_oversized_tag_is_refused_and_leaves_the_ring_usable() { - let (r, path) = ring(64 * 1024); + let r = ring(64 * 1024); let half = 32 * 1024; assert_eq!( @@ -931,31 +980,23 @@ mod edge_tests { .expect("no io error") .is_some()); assert_eq!(r.latest_ts(), Some(2_000)); - - let _ = std::fs::remove_file(&path); } /// An empty payload is a degenerate tag, not a reason to panic or to /// desynchronise the write cursor from the index. #[test] fn an_empty_payload_does_not_break_the_cursor() { - let (r, path) = ring(64 * 1024); + let r = ring(64 * 1024); r.append(9, 1_000, &[], false, false).expect("no io error"); r.append(9, 2_000, &[0x27; 100], false, false) .expect("no io error"); assert_eq!(r.latest_ts(), Some(2_000)); - let mut buf = Vec::new(); let seq = r.latest_seq().expect("a populated ring"); - assert!(r - .try_read_seq(seq, &mut buf) - .expect("no io error") - .is_some()); assert_eq!( - buf.len(), - 100, + read(&r, seq).map(|b| b.len()), + Some(100), "the tag after an empty one reads back whole" ); - let _ = std::fs::remove_file(&path); } /// Trim takes a cutoff derived from the newest timestamp. When every tag @@ -963,7 +1004,7 @@ mod edge_tests { /// clock jump from emptying the buffer. #[test] fn trim_keeps_everything_newer_than_its_cutoff() { - let (r, path) = ring(64 * 1024); + let r = ring(64 * 1024); for i in 1..=10u64 { r.append(9, i * 100, &[0x27; 64], false, false) .expect("no io error"); @@ -975,31 +1016,152 @@ mod edge_tests { // future timestamp used to trigger. r.trim_older_than(0, 1_000_000, u64::MAX); assert_eq!(r.oldest_ts(), None); - let _ = std::fs::remove_file(&path); + } + + /// The one tag whose bytes are split across the end of the file: the + /// write and the read each take two syscalls, and both halves have to + /// land in the right order at the right offsets. + #[test] + fn a_tag_straddling_the_end_of_the_file_reads_back_exactly() { + let r = ring(256); + for tag in 0..4u64 { + r.append(9, tag * 10, &distinct(tag, 80), false, false) + .expect("no io error"); + } + // Offsets 0, 80, 160, then 240..256 + 0..64, which overwrote tag 0. + assert_eq!(read(&r, 0), None, "tag 0's bytes were overwritten"); + for tag in 1..4u64 { + assert_eq!(read(&r, tag), Some(distinct(tag, 80)), "tag {tag}"); + } } /// Writes wrap the file. The index has to keep pointing at bytes that - /// still belong to the tag it names, across a full lap of the ring. + /// still belong to the tag it names, across several laps of the ring. + /// 300-byte tags do not divide the capacity, so the split point moves + /// on every lap, and every live tag is checked after every append. #[test] - fn tags_read_back_correctly_after_the_ring_laps() { - let (r, path) = ring(16 * 1024); - let mut last_seq = 0; - for i in 1..=200u64 { - let payload = vec![(i % 251) as u8; 300]; - if let Some(seq) = r.append(9, i * 10, &payload, false, false).expect("no io") { - last_seq = seq; + fn every_live_tag_reads_back_exactly_across_several_laps() { + let r = ring(16 * 1024); + for tag in 0..120u64 { + let seq = r + .append(9, tag * 10, &distinct(tag, 300), false, false) + .expect("no io error") + .expect("fits"); + assert_eq!(seq, tag); + let front = r.front_seq().expect("a populated ring"); + assert!( + (seq + 1 - front) * 300 <= 16 * 1024, + "live tags fit the file" + ); + for live in front..=seq { + assert_eq!(read(&r, live), Some(distinct(live, 300)), "tag {live}"); } } - let mut buf = Vec::new(); - assert!(r - .try_read_seq(last_seq, &mut buf) - .expect("no io error") - .is_some()); - assert_eq!(buf.len(), 300); + let front = r.front_seq().expect("a populated ring"); + assert!(front > 60, "two laps evicted the early tags"); + assert_eq!(read(&r, front - 1), None, "an evicted tag reads as gone"); + } + + /// Both ways a keyframe leaves the index - overwritten by a wrap, or + /// aged out by trim - must also take it out of the cut-point index, or + /// a cut would seek to bytes that now belong to another tag. + #[test] + fn evicted_keyframes_are_never_returned_as_cut_points() { + let r = ring(256); + r.append(9, 1_000, &idr(80), true, false) + .expect("no io error"); + for i in 1..4u64 { + r.append(9, 1_000 + i * 33, &[0x27; 80], false, false) + .expect("no io error"); + } + assert!(r.find_idr_near(1_000, 10_000).is_none(), "wrapped away"); + assert!(r.newest_idr().is_none()); + assert!(r.oldest_idr_at_or_after(0).is_none()); + + let r = ring(64 * 1024); + r.append(9, 100, &idr(40), true, false) + .expect("no io error"); + r.append(9, 200, &[0x27; 40], false, false) + .expect("no io error"); + r.append(9, 5_000, &idr(40), true, false) + .expect("no io error"); + r.append(9, 5_100, &[0x27; 40], false, false) + .expect("no io error"); + r.trim_older_than(1_000, 5_100, u64::MAX); + assert_eq!(r.oldest_ts(), Some(5_000)); + assert!(r.find_idr_near(100, 1_000).is_none(), "trimmed away"); + assert_eq!(r.find_idr_near(100, 10_000).map(|m| m.ts_ms), Some(5_000)); + assert_eq!(r.oldest_idr_at_or_after(0).map(|m| m.ts_ms), Some(5_000)); + } + + /// `clear` runs when a new publisher takes over. Seqs keep counting + /// from the old high-water mark so a destination's saved position can + /// never alias a new tag, and nothing from the old session - bytes or + /// cut points - may surface again. + #[test] + fn clear_forgets_the_old_session_but_keeps_counting_seqs() { + let r = ring(64 * 1024); + r.append(9, 90_000, &idr(40), true, false) + .expect("no io error"); + r.append(9, 90_033, &[0x27; 40], false, false) + .expect("no io error"); + r.clear(); + + assert_eq!(r.latest_seq(), None); + assert!(r.newest_idr().is_none()); + assert!(r.find_idr_near(90_000, 1_000).is_none()); + assert_eq!(read(&r, 0), None); + + let seq = r.append(9, 10, &[0x27; 40], false, false).unwrap().unwrap(); + assert_eq!(seq, 2, "continues above the old high-water mark"); + assert_eq!(read(&r, 1), None, "old seqs stay gone"); assert!( - buf.iter().all(|b| *b == buf[0]), - "the newest tag read back as a mix of two writes" + r.newest_idr_after(1).is_none(), + "no keyframe in this session yet" ); - let _ = std::fs::remove_file(&path); + let key = r.append(9, 20, &idr(40), true, false).unwrap().unwrap(); + assert_eq!(r.newest_idr_after(1).map(|m| m.seq), Some(key)); + } + + #[test] + fn find_idr_near_before_the_first_keyframe_and_on_a_tie() { + let r = ring(64 * 1024); + r.append(9, 1_000, &idr(40), true, false) + .expect("no io error"); + r.append(9, 2_000, &idr(40), true, false) + .expect("no io error"); + // Before every keyframe: only the one above can match. + assert_eq!(r.find_idr_near(800, 300).map(|m| m.ts_ms), Some(1_000)); + assert!(r.find_idr_near(800, 100).is_none()); + // Equidistant: the earlier one wins, so the delay is never less + // than asked for when the two are otherwise equal. + assert_eq!(r.find_idr_near(1_500, 500).map(|m| m.ts_ms), Some(1_000)); + assert!(r.find_idr_near(1_500, 499).is_none()); + } + + /// `is_saturated` follows what the ring holds: filling it past a lap + /// trips it (every new tag now overwrites the oldest), and trimming or + /// clearing releases it. + #[test] + fn saturation_follows_what_the_ring_holds() { + let r = ring(1_600); + let append = |from: u64, to: u64| { + for i in from..to { + r.append(8, i * 20, &[0u8; 100], false, false).unwrap(); + } + }; + append(0, 10); + assert!(!r.is_saturated(), "1000 of 1600 bytes held"); + append(10, 40); + assert!( + r.is_saturated(), + "a full lap: each tag overwrites the oldest" + ); + r.trim_older_than(100, 39 * 20, u64::MAX); + assert!(!r.is_saturated(), "trimmed to the last 100 ms"); + append(40, 80); + assert!(r.is_saturated(), "full again"); + r.clear(); + assert!(!r.is_saturated(), "cleared"); } } diff --git a/src/compat.rs b/src/compat.rs index f4a6f5c..fc1311f 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -95,10 +95,19 @@ fn keyframe_issue(params: &StreamParams, live: &[&Destination]) -> Option Option { - if params.height == 0 || params.height <= KICK_MAX_HEIGHT { + let short_side = if params.width == 0 { + params.height + } else { + params.width.min(params.height) + }; + if short_side == 0 || short_side <= KICK_MAX_HEIGHT { return None; } if !live.iter().any(|d| d.platform == "kick") { @@ -106,7 +115,7 @@ fn resolution_issue(params: &StreamParams, live: &[&Destination]) -> Option=` line. See `crate::integrations`. + pub integrations: Vec, + /// Discord webhooks the integrations can post to. + pub discord_channels: Vec, + /// Phone pushes (ntfy). + pub phone: PhoneConnection, + /// Whether the old single `discord_webhook_url` has been turned into a + /// Discord connection plus a "Stream alerts" integration. False only in + /// configs written before integrations existed. + 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, + /// 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)] +#[derive(Debug, Clone, PartialEq)] pub struct DelayProfile { pub name: String, pub delay_ms: u32, @@ -155,11 +179,12 @@ pub const HK_MOD_CONTROL: u32 = 0x0002; pub const HK_MOD_SHIFT: u32 = 0x0004; pub const HK_MOD_WIN: u32 = 0x0008; -/// The five delay actions a hotkey or MIDI control can drive, in the order -/// everything else keys off: `Hotkeys::entries`, `MidiBindings::entries`, and -/// the tray's `RegisterHotKey` ids. Adding an action means adding it here and -/// to the two structs below, and nothing else has to agree by hand. -pub const ACTIONS: [&str; 5] = ["toggle", "arm", "activate", "cut", "cut_after"]; +/// The actions a hotkey or MIDI control can drive, in the order everything +/// else keys off: `Hotkeys::entries`, `MidiBindings::entries`, and the +/// tray's `RegisterHotKey` ids. Adding an action means adding it here and to +/// the two structs below, and nothing else has to agree by hand. New actions +/// go at the end so existing tray ids stay put. +pub const ACTIONS: [&str; 6] = ["toggle", "arm", "activate", "cut", "cut_after", "end_hold"]; /// Global hotkey bindings, one canonical combo string per delay action /// (e.g. "Ctrl+Alt+D"; empty means unbound). Windows binds them via @@ -180,19 +205,22 @@ pub struct Hotkeys { pub cut: String, /// Schedule a cut for the moment the current live edge airs. pub cut_after: String, + /// End a crash-protection hold now (every destination ends). + pub end_hold: String, } impl Hotkeys { /// Stable (action-name, binding) pairs. One source of truth for the /// save writer, the JSON serializer, and the tray registrar, so a new /// action is added in exactly one place. - pub fn entries(&self) -> [(&'static str, &str); 5] { + pub fn entries(&self) -> [(&'static str, &str); 6] { [ ("toggle", &self.toggle), ("arm", &self.arm), ("activate", &self.activate), ("cut", &self.cut), ("cut_after", &self.cut_after), + ("end_hold", &self.end_hold), ] } @@ -203,6 +231,7 @@ impl Hotkeys { "activate" => &mut self.activate, "cut" => &mut self.cut, "cut_after" => &mut self.cut_after, + "end_hold" => &mut self.end_hold, _ => return None, }) } @@ -242,10 +271,10 @@ impl Hotkeys { } } -/// MIDI controller bindings, one signature string per delay action (empty +/// MIDI controller bindings, one signature string per action (empty /// means unbound). A signature is `note::` for a note-on pad /// or `cc::` for a control-change knob/button, channel -/// 1-16 and data 0-127. Same five actions as `Hotkeys`; the MIDI listener +/// 1-16 and data 0-127. Same actions as `Hotkeys`; the MIDI listener /// thread matches an incoming message against these and fires the shared /// controller action. Windows-only in effect (winmm), round-tripped /// everywhere. @@ -256,17 +285,19 @@ pub struct MidiBindings { pub activate: String, pub cut: String, pub cut_after: String, + pub end_hold: String, } impl MidiBindings { /// Stable (action-name, signature) pairs, mirroring `Hotkeys::entries`. - pub fn entries(&self) -> [(&'static str, &str); 5] { + pub fn entries(&self) -> [(&'static str, &str); 6] { [ ("toggle", &self.toggle), ("arm", &self.arm), ("activate", &self.activate), ("cut", &self.cut), ("cut_after", &self.cut_after), + ("end_hold", &self.end_hold), ] } @@ -277,6 +308,7 @@ impl MidiBindings { "activate" => &mut self.activate, "cut" => &mut self.cut, "cut_after" => &mut self.cut_after, + "end_hold" => &mut self.end_hold, _ => return None, }) } @@ -533,7 +565,7 @@ fn vk_to_key(vk: u32) -> Option { /// One streaming destination - Twitch, YouTube, a private server, etc. /// Each destination runs its own RTMP egress and is paced/cut/timestamp- /// rewritten independently from the same shared DiskRing. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Destination { pub id: String, // stable across renames; UI key pub name: String, // user label ("Twitch", "Backup", etc.) @@ -585,15 +617,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 @@ -700,9 +732,121 @@ impl Destination { pub fn wants_vertical(&self) -> bool { self.platform != "twitch" && self.stream_format == "vertical" } + + /// Whether this destination's ingest plays `codec` over Enhanced RTMP. + /// H.264 plays everywhere; HEVC and AV1 only where known to work + /// (YouTube takes both; Restream took an HEVC vertical live). Kick, + /// Trovo and custom servers get H.264, since a wrong guess is a stream + /// that never starts. + pub fn plays(&self, codec: crate::h264::VideoCodec) -> bool { + use crate::h264::VideoCodec; + match codec { + VideoCodec::Avc => true, + VideoCodec::Hevc => matches!(self.platform.as_str(), "youtube" | "restream"), + VideoCodec::Av1 => self.platform == "youtube", + VideoCodec::Vp9 | VideoCodec::Unknown => false, + } + } } impl Settings { + /// `integration.=`, `connection.discord.=` and + /// `connection.phone=` lines. Compact JSON has no newline, so each + /// stays one line of the key=value format. + fn write_integration_lines(&self, f: &mut impl Write) -> io::Result<()> { + if self.integrations_migrated { + writeln!(f, "integrations_migrated=true")?; + } + for c in &self.discord_channels { + let v = json::obj([("name", json::str(&c.name)), ("url", json::str(&c.url))]); + writeln!( + f, + "connection.discord.{}={}", + one_line(&c.id), + one_line(&v.to_json()) + )?; + } + if self.phone != PhoneConnection::default() { + let v = json::obj([ + ("server", json::str(&self.phone.server)), + ("topic", json::str(&self.phone.topic)), + ]); + writeln!(f, "connection.phone={}", one_line(&v.to_json()))?; + } + for i in &self.integrations { + writeln!( + f, + "integration.{}={}", + one_line(&i.id), + one_line(&i.to_json().to_json()) + )?; + } + Ok(()) + } + + /// Load one integration line. A line that no longer parses (a hand edit, + /// a newer version's step type) is skipped rather than failing the + /// whole config: the other integrations and settings still load. + fn load_integration(&mut self, value: &str) { + if self.integrations.len() >= MAX_INTEGRATIONS { + return; + } + let Ok(parsed) = json::parse(value) else { + return; + }; + let Ok(integration) = Integration::from_json(&parsed) else { + return; + }; + if crate::integrations::model::valid_id(&integration.id) + && !self.integrations.iter().any(|i| i.id == integration.id) + { + self.integrations.push(integration); + } + } + + fn load_discord_channel(&mut self, id: &str, value: &str) { + if !crate::integrations::model::valid_id(id) + || self.discord_channels.len() >= 32 + || self.discord_channels.iter().any(|c| c.id == id) + { + return; + } + if let Ok(v) = json::parse(value) { + self.discord_channels.push(DiscordChannel { + id: id.to_string(), + name: v.str_or("name", "Discord").to_string(), + url: v.str_or("url", "").to_string(), + }); + } + } + + /// One-time move of the old single Discord webhook into integrations: + /// a Discord connection plus a "Stream alerts" integration that posts + /// the same messages as before. Runs once per config. + fn migrate_discord_webhook(&mut self) { + if self.integrations_migrated { + return; + } + self.integrations_migrated = true; + let url = std::mem::take(&mut self.discord_webhook_url); + if url.trim().is_empty() { + return; + } + let channel_id = "discord".to_string(); + if !self.discord_channels.iter().any(|c| c.id == channel_id) { + self.discord_channels.push(DiscordChannel { + id: channel_id.clone(), + name: "Discord".to_string(), + url, + }); + } + self.integrations + .push(crate::integrations::presets::legacy_alerts( + "stream-alerts".to_string(), + &channel_id, + )); + } + pub fn defaults() -> Self { Self { ingest_port: 1935, @@ -755,7 +899,7 @@ impl Settings { tracing_enabled: false, // Behaviour toggles default off so the two-phase // arm/activate ceremony stays the canonical flow. Streamers - // who always want delay opt in once via System -> Behavior. + // who always want delay opt in once via System -> General. auto_arm_on_connect: false, auto_activate_when_ready: false, // 15 s is the same default the wizard suggests and matches @@ -778,6 +922,15 @@ impl Settings { // Likewise no MIDI bindings until the user maps a controller. midi: MidiBindings::default(), midi_device: String::new(), + crash_protection: CrashProtection::default(), + integrations: Vec::new(), + discord_channels: Vec::new(), + phone: PhoneConnection::default(), + // 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(), + twitch_shared_chat: false, } } @@ -787,7 +940,11 @@ impl Settings { // must see them stay deleted across restarts. s.profiles.clear(); s.destinations.clear(); - let text = fs::read_to_string(path)?; + // Lossy on purpose: a hand edit saved as ANSI is not valid UTF-8, + // and failing the whole read over one accented character would + // cost the user every other setting. Only the damaged line changes. + let bytes = fs::read(path)?; + let text = String::from_utf8_lossy(&bytes); for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { @@ -824,6 +981,7 @@ impl Settings { audio_track: "auto".into(), }); } + s.migrate_discord_webhook(); // Clamp / sanitize on load - hand-edited values can otherwise // hit divide-by-zero (buffer_mb=0 → `% capacity` in DiskRing) or // bind two services to the same port (one will silently fail). @@ -868,11 +1026,57 @@ impl Settings { } } - pub fn load_or_default(path: &Path) -> Self { - match Self::load(path) { - Ok(s) => s, - Err(_) => Self::defaults().with_smart_defaults(), - } + /// Load the config for process startup. Only a missing file means + /// "fresh install": that case gets smart defaults, saved at once so the + /// file appears for the user to find. + /// + /// Any other read error (antivirus or a sync client holding the file, + /// a permissions problem) is retried briefly. If it persists, the file + /// is copied aside to `.unreadable` and startup runs on defaults + /// with `may_save == false`, so startup itself never writes over it. + /// A later save from the dashboard does replace it, which is safe only + /// because the copy exists; when the copy cannot be made either, this + /// returns the error and the caller must not start. + pub fn load_for_startup(path: &Path) -> io::Result { + Self::load_for_startup_with(path, Self::load) + } + + /// `load_for_startup` with the loader injected, so tests can simulate a + /// locked file without one. + fn load_for_startup_with( + path: &Path, + load: impl Fn(&Path) -> io::Result, + ) -> io::Result { + let mut attempt = 1; + let error = loop { + match load(path) { + Ok(settings) => return Ok(StartupConfig::loaded(settings)), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + let settings = Self::defaults().with_smart_defaults(); + let _ = settings.save(path); + return Ok(StartupConfig::loaded(settings)); + } + Err(e) if attempt >= STARTUP_READ_ATTEMPTS => break e, + Err(_) => { + attempt += 1; + std::thread::sleep(STARTUP_READ_RETRY_DELAY); + } + } + }; + let backup = copy_aside(path, "unreadable")?; + Ok(StartupConfig { + settings: Self::defaults().with_smart_defaults(), + may_save: false, + notice: Some(format!( + "InstantClone couldn't read its settings file, so it started with \ + default settings.\n\nFile: {}\nError: {error}\n\nA copy of the file \ + was saved as:\n{}\n\nClose whatever is holding the file and restart \ + InstantClone to use your settings again. Until then, any settings \ + change replaces the file; the copy stays either way.", + path.display(), + backup.display() + )), + }) } /// On a brand-new install, pick smarter starting values than the @@ -931,6 +1135,11 @@ impl Settings { // Legacy single-destination fields: kept for backward compat with // older binaries that don't know about `destination.*`. We mirror // destinations[0] into them so a downgrade still works. + // + // With no destinations the key and URL are written empty, never + // from the loaded legacy fields: those still hold whatever the + // file had, and `load` would migrate them back into an enabled + // destination, undoing the user's delete of the last one. let mirror = self.destinations.first(); writeln!( f, @@ -944,20 +1153,12 @@ impl Settings { writeln!( f, "stream_key={}", - one_line( - mirror - .map(|d| d.stream_key.as_str()) - .unwrap_or(&self.stream_key) - ) + one_line(mirror.map(|d| d.stream_key.as_str()).unwrap_or("")) )?; writeln!( f, "custom_egress_url={}", - one_line( - mirror - .map(|d| d.custom_egress_url.as_str()) - .unwrap_or(&self.custom_egress_url) - ) + one_line(mirror.map(|d| d.custom_egress_url.as_str()).unwrap_or("")) )?; writeln!(f, "ingest_port={}", self.ingest_port)?; writeln!(f, "ingest_bind_all={}", self.ingest_bind_all)?; @@ -1037,6 +1238,12 @@ impl Settings { writeln!(f, "hotkey.{}={}", action, combo)?; } } + 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))?; @@ -1046,6 +1253,9 @@ impl Settings { writeln!(f, "midi.{}={}", action, sig)?; } } + // Crash protection: only fields that differ from the default. + self.crash_protection.write_lines(&mut f)?; + self.write_integration_lines(&mut f)?; for (i, p) in self.profiles.iter().enumerate() { writeln!(f, "profile.{}.name={}", i, one_line(&p.name))?; writeln!(f, "profile.{}.delay_ms={}", i, p.delay_ms)?; @@ -1187,11 +1397,30 @@ 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(), + "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.") => { self.midi.set(&k["midi.".len()..], value); } + k if k.starts_with(crash_protection::KEY_PREFIX) => { + self.crash_protection + .set(&k[crash_protection::KEY_PREFIX.len()..], value); + } + "integrations_migrated" => self.integrations_migrated = value == "true", + k if k.starts_with("integration.") => self.load_integration(value), + k if k.starts_with("connection.discord.") => { + self.load_discord_channel(&k["connection.discord.".len()..], value) + } + "connection.phone" => { + if let Ok(v) = json::parse(value) { + self.phone = PhoneConnection { + server: v.str_or("server", "").to_string(), + topic: v.str_or("topic", "").to_string(), + }; + } + } k if k.starts_with("profile.") => { let rest = &k[8..]; if let Some(dot) = rest.find('.') { @@ -1431,20 +1660,22 @@ impl Settings { // Hotkey bindings. Not secrets, so always emitted; the dashboard // only surfaces the editor on Windows (where they take effect). let hotkeys = format!( - r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca}}}"#, + r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca},"end_hold":{eh}}}"#, t = json_str(&self.hotkeys.toggle), a = json_str(&self.hotkeys.arm), ac = json_str(&self.hotkeys.activate), c = json_str(&self.hotkeys.cut), ca = json_str(&self.hotkeys.cut_after), + eh = json_str(&self.hotkeys.end_hold), ); let midi = format!( - r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca}}}"#, + r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca},"end_hold":{eh}}}"#, t = json_str(&self.midi.toggle), a = json_str(&self.midi.arm), ac = json_str(&self.midi.activate), c = json_str(&self.midi.cut), ca = json_str(&self.midi.cut_after), + eh = json_str(&self.midi.end_hold), ); // The two raw credentials are emitted only for a full session; a // dock-token caller gets them blanked (see the doc comment above). @@ -1459,7 +1690,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},"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), @@ -1488,6 +1719,9 @@ 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, ) } @@ -1522,6 +1756,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)); } @@ -1532,6 +1772,12 @@ impl Settings { errs.push("destination is missing a name".into()); continue; } + // A switched-off destination is never streamed, so a half-filled + // one (no server URL or key yet) must not block saving anything + // else. Switching it on runs the checks below. + if !d.enabled { + continue; + } if d.platform == "custom" || d.platform == "kick" { if d.custom_egress_url.is_empty() { let need = if d.platform == "kick" { @@ -1793,6 +2039,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 @@ -1808,7 +2062,7 @@ fn one_line(s: &str) -> std::borrow::Cow<'_, str> { } } -fn json_str(s: &str) -> String { +pub(crate) fn json_str(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { @@ -1826,6 +2080,216 @@ fn json_str(s: &str) -> String { out } +/// Attempts at reading an existing config before startup treats it as +/// unreadable. Antivirus scans and sync clients hold a file for well under +/// a second, so a short retry rides those out without delaying a real +/// failure noticeably. +const STARTUP_READ_ATTEMPTS: u32 = 4; +const STARTUP_READ_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100); + +/// What `Settings::load_for_startup` hands to `main`. +pub struct StartupConfig { + pub settings: Settings, + /// False when the settings are stand-in defaults for a file that exists + /// but could not be read: startup must not save them over it. + pub may_save: bool, + /// Message the user must see before the app runs, if any. + pub notice: Option, +} + +impl StartupConfig { + fn loaded(settings: Settings) -> Self { + Self { + settings, + may_save: true, + notice: None, + } + } +} + +/// Copy `path` to the first free `.`, `..2`, ... +/// so an earlier copy is never overwritten. Returns where it went. +fn copy_aside(path: &Path, suffix: &str) -> io::Result { + let name = path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "config path has no file name"))? + .to_string_lossy() + .into_owned(); + for n in 1..=99 { + let candidate = if n == 1 { + path.with_file_name(format!("{name}.{suffix}")) + } else { + path.with_file_name(format!("{name}.{suffix}.{n}")) + }; + if !candidate.exists() { + fs::copy(path, &candidate)?; + return Ok(candidate); + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("too many {name}.{suffix} copies already; move some away"), + )) +} + +/// Strict JSON validity check (RFC 8259, one value, nothing trailing). The +/// crate hand-writes every JSON document it emits, and a substring assert +/// cannot catch a stray comma or an unescaped newline, so tests parse what +/// they build with this. It also vets JSON read from OBS's files before it +/// goes back to OBS, which refuses to stream on a malformed config. No +/// dependency: a JSON crate would be pulled in for a yes or no. +pub(crate) fn is_valid_json(s: &str) -> bool { + let mut parser = json_check::Parser { + bytes: s.as_bytes(), + pos: 0, + }; + parser.skip_ws(); + let ok = parser.value(0); + parser.skip_ws(); + ok && parser.pos == parser.bytes.len() +} + +mod json_check { + /// Deep enough for anything we emit, shallow enough that a runaway + /// nesting bug fails the test instead of overflowing the stack. + const MAX_DEPTH: usize = 64; + + pub(super) struct Parser<'a> { + pub(super) bytes: &'a [u8], + pub(super) pos: usize, + } + + impl Parser<'_> { + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn eat(&mut self, b: u8) -> bool { + let hit = self.peek() == Some(b); + if hit { + self.pos += 1; + } + hit + } + + pub(super) fn skip_ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.pos += 1; + } + } + + pub(super) fn value(&mut self, depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + match self.peek() { + Some(b'{') => self.container(depth, b'}', true), + Some(b'[') => self.container(depth, b']', false), + Some(b'"') => self.string(), + Some(b't') => self.literal(b"true"), + Some(b'f') => self.literal(b"false"), + Some(b'n') => self.literal(b"null"), + _ => self.number(), + } + } + + /// Object (`keyed`) or array body, after the opening bracket. + fn container(&mut self, depth: usize, close: u8, keyed: bool) -> bool { + self.pos += 1; + self.skip_ws(); + if self.eat(close) { + return true; + } + loop { + self.skip_ws(); + if keyed { + if !self.string() { + return false; + } + self.skip_ws(); + if !self.eat(b':') { + return false; + } + self.skip_ws(); + } + if !self.value(depth + 1) { + return false; + } + self.skip_ws(); + if self.eat(close) { + return true; + } + if !self.eat(b',') { + return false; + } + } + } + + fn string(&mut self) -> bool { + if !self.eat(b'"') { + return false; + } + while let Some(b) = self.peek() { + self.pos += 1; + match b { + b'"' => return true, + b'\\' => { + let Some(esc) = self.peek() else { return false }; + self.pos += 1; + if esc == b'u' { + let hex = self.bytes.get(self.pos..self.pos + 4); + if !hex.is_some_and(|h| h.iter().all(u8::is_ascii_hexdigit)) { + return false; + } + self.pos += 4; + } else if !b"\"\\/bfnrt".contains(&esc) { + return false; + } + } + 0x00..=0x1f => return false, + _ => {} + } + } + false + } + + fn literal(&mut self, word: &[u8]) -> bool { + let hit = self.bytes[self.pos..].starts_with(word); + if hit { + self.pos += word.len(); + } + hit + } + + fn digits(&mut self) -> usize { + let start = self.pos; + while self.peek().is_some_and(|b| b.is_ascii_digit()) { + self.pos += 1; + } + self.pos - start + } + + fn number(&mut self) -> bool { + self.eat(b'-'); + if self.eat(b'0') { + // A leading zero stands alone: `01` is not JSON. + } else if self.digits() == 0 { + return false; + } + if self.eat(b'.') && self.digits() == 0 { + return false; + } + if self.eat(b'e') || self.eat(b'E') { + let _ = self.eat(b'+') || self.eat(b'-'); + if self.digits() == 0 { + return false; + } + } + true + } + } +} + /// Best-effort free space query. Returns None if the OS doesn't tell us /// (in which case the caller keeps the default). fn free_space_bytes(path: &Path) -> Option { @@ -1955,6 +2419,22 @@ mod tests { use super::*; use std::path::PathBuf; + /// HEVC or AV1 goes only where it is known to play; a wrong guess is a + /// vertical stream that never starts, so the rest get H.264. + #[test] + fn only_known_platforms_are_sent_hevc_or_av1() { + use crate::h264::VideoCodec::{Av1, Avc, Hevc, Unknown}; + let on = |platform: &str| test_destination("d", platform, "key"); + for platform in ["youtube", "restream", "kick", "trovo", "custom", "twitch"] { + assert!(on(platform).plays(Avc), "{platform} plays H.264"); + assert!(!on(platform).plays(Unknown), "{platform}"); + } + assert!(on("youtube").plays(Hevc) && on("restream").plays(Hevc)); + assert!(!on("kick").plays(Hevc) && !on("custom").plays(Hevc)); + assert!(on("youtube").plays(Av1)); + assert!(!on("restream").plays(Av1) && !on("kick").plays(Av1)); + } + /// Both legs, IPv4 first, in both bind modes. Order is contractual: /// `spawn_ingest_legs` treats only the first address as required, so a /// reordering here would silently make the IPv4 listener give up after @@ -2405,6 +2885,31 @@ mod tests { ); } + /// A switched-off, half-filled destination must not block saving other + /// settings (turning on crash protection failed on one); switching it + /// on still needs its fields. + #[test] + fn a_disabled_incomplete_destination_does_not_block_saving() { + let mut s = Settings::defaults(); + s.destinations.push(Destination { + id: "k".into(), + name: "test3".into(), + enabled: false, + platform: "kick".into(), + stream_key: String::new(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }); + assert!(s.validate().is_empty(), "{:?}", s.validate()); + s.destinations[0].enabled = true; + assert!(s.validate().iter().any(|e| e.contains("Kick Server URL"))); + } + #[test] fn destination_custom_rtmps_url_is_accepted() { // The Custom platform is the escape hatch for any TLS ingest @@ -2477,238 +2982,6 @@ mod tests { ); } - #[test] - fn settings_save_load_roundtrip_preserves_destinations_and_profiles() { - // Build a config that exercises every field the on-disk format - // touches: ports, paths, multiple destinations including a - // custom-URL one, multiple profiles, and a webhook URL. - let mut s = Settings::defaults(); - s.ingest_port = 1936; - s.web_port = 7898; - s.buffer_mb = 200; - s.discord_webhook_url = "https://discord.com/api/webhooks/123/abc".into(); - s.destinations.clear(); - s.destinations.push(Destination { - id: "tw1".into(), - name: "Twitch".into(), - enabled: true, - platform: "twitch".into(), - stream_key: "live_secret".into(), - custom_egress_url: String::new(), - twitch_ingest: "fra".into(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }); - s.destinations.push(Destination { - id: "yt1".into(), - name: "YT Backup".into(), - enabled: false, - platform: "youtube".into(), - stream_key: "yt_secret".into(), - custom_egress_url: String::new(), - twitch_ingest: String::new(), - youtube_ingest: "backup".into(), - vod_audio: false, - vod_audio_inject_eb: false, - // Non-default value so the round-trip proves stream_format - // both writes (only when != "horizontal") and reads back. - stream_format: "vertical".into(), - // Non-default: the clean track routed to YouTube (copyright). - // Proves audio_track writes only when != "auto" and reads back. - audio_track: "2".into(), - }); - s.profiles = vec![ - DelayProfile { - name: "Quick".into(), - delay_ms: 10_000, - }, - DelayProfile { - name: "Long".into(), - delay_ms: 120_000, - }, - ]; - - let path = std::env::temp_dir().join(format!("ic-test-cfg-{}.ini", std::process::id())); - let _ = std::fs::remove_file(&path); - s.save(&path).expect("save"); - let loaded = Settings::load(&path).expect("load"); - let _ = std::fs::remove_file(&path); - - assert_eq!(loaded.ingest_port, 1936); - assert_eq!(loaded.web_port, 7898); - assert_eq!(loaded.buffer_mb, 200); - assert_eq!( - loaded.discord_webhook_url, - "https://discord.com/api/webhooks/123/abc" - ); - assert_eq!(loaded.destinations.len(), 2); - assert_eq!(loaded.destinations[0].id, "tw1"); - assert_eq!(loaded.destinations[0].name, "Twitch"); - assert_eq!(loaded.destinations[0].stream_key, "live_secret"); - assert_eq!(loaded.destinations[0].twitch_ingest, "fra"); - assert!(loaded.destinations[0].enabled); - assert!(!loaded.destinations[1].enabled); - assert_eq!(loaded.destinations[1].youtube_ingest, "backup"); - assert_eq!(loaded.destinations[0].stream_format, "horizontal"); - assert_eq!(loaded.destinations[1].stream_format, "vertical"); - // audio_track: default stays "auto", the non-default "2" survives. - assert_eq!(loaded.destinations[0].audio_track, "auto"); - assert_eq!(loaded.destinations[1].audio_track, "2"); - assert_eq!(loaded.profiles.len(), 2); - assert_eq!(loaded.profiles[0].delay_ms, 10_000); - assert_eq!(loaded.profiles[1].name, "Long"); - } - - #[test] - fn dock_layouts_round_trip_through_save_and_load() { - // Dock layouts are opaque single-line JSON blobs stored as - // `dock.=`. Prove a value containing `=`, `:`, and quotes - // survives the line parser (it splits on the first `=` only, so the - // JSON body is preserved verbatim). - let mut s = Settings::defaults(); - s.docks.insert( - "default".into(), - r#"{"v":1,"preset":"minimal","w":{"number":{"on":true,"step":5}}}"#.into(), - ); - s.docks - .insert("gaming".into(), r#"{"preset":"delay","eq":"a=b"}"#.into()); - - let path = std::env::temp_dir().join(format!("ic-test-docks-{}.ini", std::process::id())); - let _ = std::fs::remove_file(&path); - s.save(&path).expect("save"); - let loaded = Settings::load(&path).expect("load"); - let _ = std::fs::remove_file(&path); - - assert_eq!(loaded.docks.len(), 2); - assert_eq!( - loaded.docks.get("default").map(String::as_str), - Some(r#"{"v":1,"preset":"minimal","w":{"number":{"on":true,"step":5}}}"#) - ); - assert_eq!( - loaded.docks.get("gaming").map(String::as_str), - Some(r#"{"preset":"delay","eq":"a=b"}"#), - "value after the first = (including embedded =) must survive" - ); - } - - #[test] - fn behavior_toggles_round_trip_through_save_and_load() { - // v0.1.4 added three Settings fields for the auto-arm / - // auto-activate behaviour. Save them, load them back, assert - // every field survives. The format::Display writer is - // conditional (only emits non-default values) so this also - // verifies the apply_field reader sees those conditional keys. - let path = std::env::temp_dir().join(format!( - "ic-test-behavior-roundtrip-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); - - let mut s = Settings::defaults(); - s.auto_arm_on_connect = true; - s.auto_activate_when_ready = true; - s.auto_arm_delay_ms = 30_000; - s.overlays_seeded = true; - // Need a destination so save() doesn't bail on the - // configured-but-empty path. - s.destinations.push(Destination { - id: "test".into(), - name: "Test".into(), - enabled: true, - platform: "twitch".into(), - stream_key: "test".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(), - }); - s.save(&path).expect("save"); - - let loaded = Settings::load(&path).expect("load"); - assert!( - loaded.auto_arm_on_connect, - "auto_arm_on_connect must survive round-trip" - ); - assert!( - loaded.auto_activate_when_ready, - "auto_activate_when_ready must survive round-trip" - ); - assert_eq!( - loaded.auto_arm_delay_ms, 30_000, - "auto_arm_delay_ms must survive round-trip" - ); - assert!( - loaded.overlays_seeded, - "overlays_seeded must survive round-trip" - ); - - let _ = std::fs::remove_file(&path); - } - - #[test] - fn default_true_toggles_round_trip_when_disabled() { - // update_check_enabled and open_dashboard_on_launch both default - // true and are written only when turned off. Flipping them off must - // survive a save/load, or a privacy opt-out would silently revert. - let path = std::env::temp_dir().join(format!( - "ic-test-optout-roundtrip-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); - - let mut s = Settings::defaults(); - assert!(s.update_check_enabled, "defaults on"); - assert!(s.open_dashboard_on_launch, "defaults on"); - s.update_check_enabled = false; - s.open_dashboard_on_launch = false; - s.destinations.push(Destination { - id: "test".into(), - name: "Test".into(), - enabled: true, - platform: "twitch".into(), - stream_key: "test".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(), - }); - s.save(&path).expect("save"); - - let loaded = Settings::load(&path).expect("load"); - assert!( - !loaded.update_check_enabled, - "update_check_enabled opt-out must survive round-trip" - ); - assert!( - !loaded.open_dashboard_on_launch, - "open_dashboard_on_launch opt-out must survive round-trip" - ); - - // A fresh config with neither key present must read as the on - // default, not accidentally inherit the false above. - let fresh = Settings::defaults(); - assert!(fresh.update_check_enabled && fresh.open_dashboard_on_launch); - - let _ = std::fs::remove_file(&path); - } - #[test] fn behavior_defaults_omitted_from_save_to_keep_config_lean() { // The save writer skips default values for the behaviour keys @@ -2833,16 +3106,119 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// The actual atomicity guarantee: when the write cannot complete, the + /// config on disk is the old one, byte for byte. A directory squatting + /// on the `.tmp` name makes the temp write fail the way a full disk or + /// an antivirus lock would. A save that wrote in place (truncate, then + /// write) would already have destroyed the original at that point. + #[test] + fn a_failed_save_leaves_the_existing_file_untouched() { + let path = scratch_config_path("atomic"); + let mut s = Settings::defaults(); + s.web_port = 9001; + s.save(&path).expect("first save"); + let before = std::fs::read(&path).unwrap(); + + let mut tmp_name = path.file_name().unwrap().to_os_string(); + tmp_name.push(".tmp"); + std::fs::create_dir(path.with_file_name(tmp_name)).unwrap(); + s.web_port = 9002; + let result = s.save(&path); + + let after = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert!(result.is_err(), "the blocked temp write must surface"); + assert_eq!(after, before, "the original config must be untouched"); + } + #[test] fn missing_config_file_falls_back_to_defaults() { - // load_or_default must not panic on a non-existent path - that's - // the cold-start case where there's nothing to read yet. - let path = std::env::temp_dir().join(format!("ic-no-such-file-{}.ini", std::process::id())); - let _ = std::fs::remove_file(&path); - let s = Settings::load_or_default(&path); + // The cold-start case: nothing to read yet. Defaults, written out + // at once so the user can find the file, and normal saving after. + let path = scratch_config_path("missing"); + let startup = Settings::load_for_startup(&path).expect("fresh install"); + let written = path.exists(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + let s = startup.settings; assert_eq!(s.ingest_port, 1935); assert_eq!(s.web_port, 7799); assert!(!s.configured); + assert!(startup.may_save && startup.notice.is_none()); + assert!(written, "a fresh install writes its config file"); + } + + /// Stand-in for a file another process holds open: every read fails + /// with something other than NotFound. + fn locked_read(_: &Path) -> io::Result { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } + + /// The data-loss path: a config that exists but cannot be read must + /// never be replaced by defaults at startup. Its bytes are copied + /// aside first, the original is left alone, and startup is told not + /// to save. Before this, a single failed read meant every destination + /// and stream key was overwritten with defaults on launch. + #[test] + fn an_unreadable_config_is_copied_aside_and_not_overwritten() { + let path = scratch_config_path("locked"); + let original = b"configured=true\ndestination.0.stream_key=live_abc\n".to_vec(); + std::fs::write(&path, &original).unwrap(); + let earlier_copy = path.with_file_name("instantclone.cfg.unreadable"); + std::fs::write(&earlier_copy, b"an older copy").unwrap(); + + let startup = Settings::load_for_startup_with(&path, locked_read).expect("copy made"); + + let on_disk = std::fs::read(&path).unwrap(); + let first_copy = std::fs::read(&earlier_copy).unwrap(); + let new_copy = std::fs::read(path.with_file_name("instantclone.cfg.unreadable.2")); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert_eq!(on_disk, original, "the original must be untouched"); + assert_eq!( + first_copy, b"an older copy", + "an earlier copy is not clobbered" + ); + assert_eq!(new_copy.expect("a second copy"), original); + assert!(!startup.may_save, "startup must not save defaults over it"); + assert!(!startup.settings.configured, "runs on defaults meanwhile"); + assert!(startup.notice.is_some(), "and the user is told why"); + } + + /// No copy, no start: if the file cannot even be copied, running on + /// defaults would let the first settings change destroy the only copy. + #[test] + fn an_unreadable_config_that_cannot_be_copied_stops_startup() { + // A directory at the config path reads and copies as an error on + // every OS, without needing a real lock. + let path = scratch_config_path("dir"); + std::fs::create_dir(&path).unwrap(); + let result = Settings::load_for_startup_with(&path, locked_read); + let still_there = path.is_dir(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert!(result.is_err()); + assert!(still_there); + } + + /// A lock that clears during the retry window loads normally, with no + /// copy and no notice: a brief antivirus scan is not worth a dialog. + #[test] + fn a_briefly_locked_config_loads_after_a_retry() { + let path = scratch_config_path("brief"); + std::fs::write(&path, "configured=true\nweb_port=9124\n").unwrap(); + let reads = std::cell::Cell::new(0); + let flaky = |p: &Path| { + reads.set(reads.get() + 1); + if reads.get() == 1 { + locked_read(p) + } else { + Settings::load(p) + } + }; + let startup = Settings::load_for_startup_with(&path, flaky).expect("load"); + let copied = path.with_file_name("instantclone.cfg.unreadable").exists(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert_eq!(startup.settings.web_port, 9124); + assert!(startup.may_save && startup.notice.is_none()); + assert!(!copied); } #[test] @@ -2878,28 +3254,6 @@ mod tests { assert_eq!(parse_hotkey("Ctrl+Home").expect("valid").1, 0x24); } - #[test] - fn parse_hotkey_rejects_bare_keys_and_multi_key_chords() { - // A bare key (no modifier) is the misclick risk we refuse, and a - // two-letter chord is not expressible via RegisterHotKey. - assert!(parse_hotkey("D").is_none(), "bare key must be rejected"); - assert!( - parse_hotkey("F8").is_none(), - "bare function key must be rejected" - ); - assert!( - parse_hotkey("Ctrl+D+L").is_none(), - "multi-key chord must be rejected" - ); - assert!( - parse_hotkey("Ctrl+Alt").is_none(), - "modifiers with no main key" - ); - assert!(parse_hotkey("Ctrl+Q1").is_none(), "unknown key token"); - assert!(parse_hotkey("Ctrl+F25").is_none(), "F-key out of range"); - assert!(parse_hotkey("").is_none()); - } - /// `ACTIONS` is what the tray derives its RegisterHotKey ids from and /// what both `unbind_elsewhere` walks, so it has to stay in step with /// the two structs. A reorder here without one there would file a @@ -3048,7 +3402,7 @@ mod tests { } // And nothing it injected survives a reload as a real setting. - let back = Settings::load_or_default(&path); + let back = Settings::load(&path).expect("load"); assert!(back.dashboard_password_hash.is_empty()); assert!(!back.web_bind_all); assert_eq!(back.web_port, s.web_port); @@ -3084,7 +3438,7 @@ mod tests { ) .expect("write"); - let s = Settings::load_or_default(&path); + let s = Settings::load(&path).expect("load"); assert_eq!(s.web_port, 8080, "a good line after bad ones still loads"); assert_eq!(s.hotkeys.toggle, "Ctrl+Alt+D", "and so does a good binding"); assert!( @@ -3189,30 +3543,42 @@ mod tests { /// Combos are typed by users and hand-edited in the config file. #[test] fn hotkey_parsing_rejects_the_awkward_shapes() { - // A modifier is mandatory: that requirement is the misclick guard. - assert!(canonicalize_hotkey("D").is_none(), "a bare key is refused"); - assert!(canonicalize_hotkey("Ctrl").is_none(), "modifiers alone too"); - assert!(canonicalize_hotkey("Ctrl+Alt").is_none()); - assert!(canonicalize_hotkey("").is_none()); - assert!(canonicalize_hotkey("+++").is_none()); - assert!(canonicalize_hotkey("Ctrl+NotAKey").is_none()); - // Two keys is not a combo we can register. - assert!(canonicalize_hotkey("Ctrl+A+B").is_none()); - // Order and case do not matter, and the output is canonical. - assert_eq!( - canonicalize_hotkey("alt+CTRL+d").as_deref(), - canonicalize_hotkey("Ctrl+Alt+D").as_deref() - ); - // A repeated modifier is the same combo, not a different one. - assert_eq!( - canonicalize_hotkey("Ctrl+Ctrl+Alt+D").as_deref(), - canonicalize_hotkey("Ctrl+Alt+D").as_deref() - ); - // Whitespace around the tokens survives a hand edit. - assert_eq!( - canonicalize_hotkey(" Ctrl + Alt + D ").as_deref(), - canonicalize_hotkey("Ctrl+Alt+D").as_deref() - ); + let refused = [ + // A modifier is mandatory: that requirement is the misclick guard. + ("D", "a bare key"), + ("F8", "a bare function key"), + ("Ctrl", "a modifier alone"), + ("Ctrl+Alt", "modifiers with no main key"), + ("", "nothing"), + ("+++", "only separators"), + ("Ctrl+NotAKey", "an unknown key name"), + ("Ctrl+Q1", "an unknown key token"), + ("Ctrl+F25", "an F-key out of range"), + // Two main keys is a chord RegisterHotKey cannot express. + ("Ctrl+A+B", "two main keys"), + ]; + for (combo, why) in refused { + assert!( + parse_hotkey(combo).is_none(), + "{why} must be refused: {combo:?}" + ); + assert!(canonicalize_hotkey(combo).is_none(), "{why}: {combo:?}"); + } + // Order, case, repeats and stray whitespace all land on the one + // canonical spelling. Compared against the literal, so a parser + // that refused both sides cannot pass as None == None. + for combo in [ + "Ctrl+Alt+D", + "alt+CTRL+d", + "Ctrl+Ctrl+Alt+D", + " Ctrl + Alt + D ", + ] { + assert_eq!( + canonicalize_hotkey(combo).as_deref(), + Some("Ctrl+Alt+D"), + "{combo:?}" + ); + } } #[test] @@ -3350,6 +3716,530 @@ Name@x" // An empty signature must never match an unbound action. assert_eq!(m.action_for(""), None); } + + /// Unique scratch path per call, so parallel tests never share a file + /// and none of them can reach the user's real settings. + fn scratch_config_path(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static NEXT: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "ic-cfg-{tag}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("instantclone.cfg") + } + + fn test_destination(id: &str, platform: &str, key: &str) -> Destination { + Destination { + id: id.into(), + name: id.into(), + enabled: true, + platform: platform.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(), + } + } + + /// A `Settings` with EVERY field away from its default, built as a + /// struct literal with no `..Default` so adding a field to `Settings`, + /// `Destination`, `DelayProfile`, `Hotkeys` or `MidiBindings` does not + /// compile until it is given a non-default value here. That is the + /// point: the round-trip test below then fails until `save` writes the + /// new field and `apply_field` reads it back. + /// + /// The legacy `platform`/`stream_key`/`custom_egress_url` fields mirror + /// `destinations[0]`, as `save` writes them. + fn every_field_non_default() -> Settings { + let first = Destination { + id: "d-main".into(), + name: "Main custom".into(), + enabled: false, + platform: "custom".into(), + stream_key: "key-one".into(), + custom_egress_url: "rtmps://ingest.example.com/app".into(), + twitch_ingest: "fra".into(), + youtube_ingest: "backup".into(), + vod_audio: true, + vod_audio_inject_eb: true, + stream_format: "vertical".into(), + audio_track: "2".into(), + }; + let second = Destination { + id: "d-yt".into(), + name: "YouTube".into(), + enabled: true, + platform: "youtube".into(), + stream_key: "key-two".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: "both".into(), + }; + Settings { + ingest_port: 1940, + ingest_bind_all: true, + ingest_key: "ingest-secret".into(), + platform: first.platform.clone(), + stream_key: first.stream_key.clone(), + custom_egress_url: first.custom_egress_url.clone(), + web_port: 7810, + web_bind_all: true, + dashboard_password_hash: "pbkdf2$1000$c2FsdA$aGFzaA".into(), + dock_token: "0123abcd".into(), + buffer_mb: 777, + buffer_path: PathBuf::from("D:/buffers/ic.buf"), + target_delay_ms: 45_000, + armed_delay_ms: 60_000, + configured: true, + profiles: vec![ + DelayProfile { + name: "Scrim".into(), + delay_ms: 20_000, + }, + DelayProfile { + name: "Finals".into(), + delay_ms: 180_000, + }, + ], + destinations: vec![first, second], + // Migrated away on load (see `migrate_discord_webhook`), so a + // round-trip only holds for the empty value. + discord_webhook_url: String::new(), + overlays_dir: PathBuf::from("D:/overlays"), + tracing_enabled: true, + auto_arm_on_connect: true, + auto_activate_when_ready: true, + auto_arm_delay_ms: 25_000, + update_check_enabled: false, + open_dashboard_on_launch: false, + overlays_seeded: true, + // A layout value holding its own `=`: the line parser must split + // on the first `=` only. + docks: BTreeMap::from([ + ("main".to_string(), r#"{"w":["delay","cut"]}"#.to_string()), + ( + "gaming".to_string(), + r#"{"preset":"delay","eq":"a=b"}"#.to_string(), + ), + ]), + hotkeys: Hotkeys { + toggle: "Ctrl+Alt+D".into(), + arm: "Ctrl+Alt+A".into(), + activate: "Ctrl+Shift+F9".into(), + cut: "Alt+Win+C".into(), + cut_after: "Ctrl+PageUp".into(), + end_hold: "Ctrl+Alt+E".into(), + }, + midi: MidiBindings { + toggle: "note:1:36".into(), + arm: "note:2:37".into(), + activate: "cc:1:20".into(), + cut: "cc:16:21".into(), + cut_after: "note:1:38".into(), + end_hold: "note:3:40".into(), + }, + midi_device: "Launchpad Mini".into(), + crash_protection: crate::crash_protection::CrashProtection { + enabled: true, + hold_secs: 120, + theme: crate::crash_protection::SlateTheme::Orbit, + accent: crate::slate::Rgb { + r: 250, + g: 60, + b: 90, + }, + background: Some(crate::slate::Rgb { + r: 10, + g: 20, + b: 30, + }), + headline: "Back in a sec".into(), + subline: "OBS is restarting".into(), + every_disconnect: true, + }, + integrations: vec![ + crate::integrations::presets::build("crash_alert", "alerts1".into(), "mods") + .unwrap(), + crate::integrations::presets::build("mod_controls", "mods2".into(), "").unwrap(), + ], + discord_channels: vec![DiscordChannel { + id: "mods".into(), + name: "Mods = team".into(), + url: "https://discord.com/api/webhooks/1/abc".into(), + }], + phone: PhoneConnection { + server: "https://ntfy.example".into(), + topic: "texaz-alerts".into(), + }, + integrations_migrated: true, + twitch_client_id: "abcdefghij0123456789klmnopqrst".into(), + twitch_shared_chat: true, + } + } + + /// 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}"); + } + } + + /// The old single webhook becomes a Discord connection plus a "Stream + /// alerts" integration, once, and a config saved afterwards keeps them. + #[test] + fn old_discord_webhook_is_migrated_once() { + let path = scratch_config_path("webhook-migration"); + std::fs::write( + &path, + "discord_webhook_url=https://discord.com/api/webhooks/9/tok +", + ) + .unwrap(); + let loaded = Settings::load(&path).unwrap(); + assert!(loaded.discord_webhook_url.is_empty()); + assert!(loaded.integrations_migrated); + assert_eq!(loaded.discord_channels.len(), 1); + assert_eq!( + loaded.discord_channels[0].url, + "https://discord.com/api/webhooks/9/tok" + ); + assert_eq!(loaded.integrations.len(), 1); + assert_eq!(loaded.integrations[0].preset, "stream_alerts"); + assert!(loaded.integrations[0].enabled); + + loaded.save(&path).unwrap(); + let again = Settings::load(&path).unwrap(); + assert_eq!(again.integrations.len(), 1, "migrated exactly once"); + assert_eq!(again.discord_channels, loaded.discord_channels); + let _ = std::fs::remove_file(&path); + } + + /// A damaged integration line is skipped; everything else still loads. + #[test] + fn a_broken_integration_line_does_not_cost_the_rest() { + let path = scratch_config_path("broken-integration"); + let good = crate::integrations::presets::build("delay_command", "good".into(), "") + .unwrap() + .to_json() + .to_json(); + std::fs::write( + &path, + format!( + "web_port=7811 +integration.bad={{not json +integration.good={good} +integrations_migrated=true +" + ), + ) + .unwrap(); + let loaded = Settings::load(&path).unwrap(); + assert_eq!(loaded.web_port, 7811); + assert_eq!(loaded.integrations.len(), 1); + assert_eq!(loaded.integrations[0].id, "good"); + let _ = std::fs::remove_file(&path); + } + + /// Every setting survives save then load unchanged. The one test that + /// notices a field `save` forgets to write or `apply_field` forgets to + /// read, which otherwise shows up as a setting that resets on restart. + #[test] + fn every_setting_round_trips_through_save_and_load() { + let path = scratch_config_path("full-roundtrip"); + let original = every_field_non_default(); + original.save(&path).unwrap(); + let loaded = Settings::load(&path); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert_eq!(loaded.unwrap(), original); + } + + /// Guards the guard: if `every_field_non_default` drifted back to a + /// default somewhere, the round trip would stop covering that field. + #[test] + fn the_round_trip_fixture_really_is_non_default() { + let s = every_field_non_default(); + let d = Settings::defaults(); + assert_ne!(s.ingest_port, d.ingest_port); + assert_ne!(s.web_port, d.web_port); + assert_ne!(s.buffer_mb, d.buffer_mb); + assert_ne!(s.buffer_path, d.buffer_path); + assert_ne!(s.overlays_dir, d.overlays_dir); + assert_ne!(s.auto_arm_delay_ms, d.auto_arm_delay_ms); + assert_ne!(s.profiles, d.profiles); + assert_ne!(s.hotkeys, d.hotkeys); + assert_ne!(s.midi, d.midi); + for (action, combo) in s.hotkeys.entries() { + assert_eq!( + canonicalize_hotkey(combo).as_deref(), + Some(combo), + "{action}" + ); + } + for (action, sig) in s.midi.entries() { + assert_eq!(canonicalize_midi(sig).as_deref(), Some(sig), "{action}"); + } + } + + /// A config exactly as 0.1.14 wrote it (same `save` order and keys). + /// Upgrading must keep every value, and anything added since must load + /// at its default. + #[test] + fn a_0_1_14_config_loads_intact() { + let path = scratch_config_path("v0114"); + std::fs::write( + &path, + "# InstantClone - written by the app. Hand edits OK.\n\ + configured=true\n\ + platform=twitch\n\ + stream_key=live_123\n\ + custom_egress_url=\n\ + ingest_port=1935\n\ + ingest_bind_all=false\n\ + web_port=7799\n\ + web_bind_all=false\n\ + buffer_mb=500\n\ + buffer_path=./instantclone.buf\n\ + target_delay_ms=0\n\ + armed_delay_ms=30000\n\ + discord_webhook_url=\n\ + overlays_dir=./overlays\n\ + tracing_enabled=false\n\ + overlays_seeded=true\n\ + hotkey.toggle=Ctrl+Alt+D\n\ + profile.0.name=Quick\n\ + profile.0.delay_ms=15000\n\ + destination.0.id=main\n\ + destination.0.name=Main\n\ + destination.0.enabled=true\n\ + destination.0.platform=twitch\n\ + destination.0.stream_key=live_123\n\ + destination.0.custom_egress_url=\n\ + destination.0.twitch_ingest=\n\ + destination.0.youtube_ingest=\n\ + destination.0.vod_audio=true\n", + ) + .unwrap(); + let loaded = Settings::load(&path); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + + let mut expected = Settings::defaults(); + expected.configured = true; + expected.stream_key = "live_123".into(); + expected.armed_delay_ms = 30_000; + expected.overlays_seeded = true; + expected.hotkeys.toggle = "Ctrl+Alt+D".into(); + expected.profiles.truncate(1); + let mut main = test_destination("main", "twitch", "live_123"); + main.name = "Main".into(); + main.vod_audio = true; + expected.destinations = vec![main]; + // Loading always completes the one-time webhook migration. + expected.integrations_migrated = true; + assert_eq!(loaded.unwrap(), expected); + } + + /// Rolling back to this build after a newer one wrote the file: keys we + /// do not know are skipped without disturbing the ones we do. + #[test] + fn unknown_keys_from_a_newer_build_do_not_disturb_known_ones() { + let path = scratch_config_path("newer"); + let original = every_field_non_default(); + original.save(&path).unwrap(); + let mut text = std::fs::read_to_string(&path).unwrap(); + text.insert_str(0, "future=1\ncrash_protection.future_mode=on\n"); + text.push_str("destination.0.newfield=x\ndestination.1.another=y\nfuture_tail=2\n"); + std::fs::write(&path, &text).unwrap(); + + let loaded = Settings::load(&path).unwrap(); + loaded.save(&path).unwrap(); + let resaved = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert_eq!(loaded, original); + // Pinned behavior: unknown lines are dropped on the next save, so a + // rollback followed by any settings change loses the newer build's + // extra settings. Change this deliberately if that ever changes. + for unknown in [ + "future=", + "future_mode", + "newfield", + "another=", + "future_tail", + ] { + assert!(!resaved.contains(unknown), "{unknown} survived:\n{resaved}"); + } + } + + /// A config hand-edited in Notepad and saved as ANSI turns an accented + /// character into a byte that is not UTF-8. That used to fail the whole + /// read, fall back to defaults, and have startup save the defaults over + /// every stream key. The damaged character may be lost; nothing else is. + #[test] + fn a_config_with_non_utf8_bytes_still_loads_its_other_fields() { + let path = scratch_config_path("ansi"); + let mut bytes = b"configured=true\nweb_port=9123\ndestination.0.id=d1\n\ + destination.0.enabled=true\ndestination.0.platform=twitch\n\ + destination.0.stream_key=live_abc\ndestination.0.name=Espa" + .to_vec(); + bytes.push(0xF1); // "ñ" in Windows-1252 + bytes.extend_from_slice(b"a\n"); + std::fs::write(&path, &bytes).unwrap(); + + let s = Settings::load(&path); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + let s = s.expect("a non-UTF-8 byte must not fail the whole load"); + assert!(s.configured); + assert_eq!(s.web_port, 9123); + assert_eq!(s.destinations.len(), 1); + assert_eq!(s.destinations[0].stream_key, "live_abc"); + assert!(s.destinations[0].name.starts_with("Espa")); + } + + /// Deleting the last destination must stick. The legacy `stream_key=` + /// line used to keep the old key after the delete, and the next load's + /// single-destination migration rebuilt an ENABLED "Main" from it: a + /// stream the user removed would go live again after a restart. + #[test] + fn deleting_the_last_destination_survives_a_restart() { + let path = scratch_config_path("last-dest"); + let mut s = Settings::defaults(); + s.destinations + .push(test_destination("d1", "youtube", "secret-key")); + s.save(&path).unwrap(); + + let mut loaded = Settings::load(&path).unwrap(); + assert_eq!(loaded.destinations.len(), 1); + loaded.destinations.clear(); + loaded.save(&path).unwrap(); + + let reloaded = Settings::load(&path).unwrap(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert!( + reloaded.destinations.is_empty(), + "deleted destination came back: {:?}", + reloaded.destinations + ); + } + + /// A config from before destinations existed (only the legacy + /// single-destination lines) still becomes one enabled destination. + #[test] + fn a_pre_destinations_config_still_migrates() { + let path = scratch_config_path("legacy"); + std::fs::write(&path, "configured=true\nplatform=youtube\nstream_key=abc\n").unwrap(); + let s = Settings::load(&path).unwrap(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert_eq!(s.destinations.len(), 1); + let d = &s.destinations[0]; + assert_eq!( + (d.platform.as_str(), d.stream_key.as_str()), + ("youtube", "abc") + ); + assert!(d.enabled); + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://a.rtmp.youtube.com/live2/abc") + ); + } + + /// The custom-URL-only variant: a key embedded in the URL and no + /// `stream_key=` line must migrate too, and keep the URL intact. + #[test] + fn a_pre_destinations_custom_url_config_still_migrates() { + let path = scratch_config_path("legacy-custom"); + let url = "rtmp://ingest.example.com/app/embedded-key"; + std::fs::write(&path, format!("platform=custom\ncustom_egress_url={url}\n")).unwrap(); + let s = Settings::load(&path).unwrap(); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + assert_eq!(s.destinations.len(), 1); + assert_eq!(s.destinations[0].platform, "custom"); + assert_eq!(s.destinations[0].custom_egress_url, url); + assert_eq!(s.destinations[0].egress_url().as_deref(), Some(url)); + } + + /// Other tests trust `is_valid_json` to catch broken hand-written JSON, + /// so it has to reject exactly the mistakes those writers can make. + #[test] + fn json_validator_accepts_json_and_rejects_the_usual_breakage() { + let valid = [ + r#"{}"#, + r#"[]"#, + r#" {"a":[1,-2.5e3,true,false,null,"x\né"],"b":{}} "#, + r#""plain string""#, + ]; + for doc in valid { + assert!(is_valid_json(doc), "should accept: {doc}"); + } + let invalid = [ + "", + r#"{"a":1,}"#, + r#"[1,,2]"#, + r#"[,1]"#, + "{\"a\":\"raw\nnewline\"}", + r#"{"a":1}{"b":2}"#, + r#"{"a" 1}"#, + r#"{a:1}"#, + r#"[01]"#, + r#"["\x"]"#, + r#"[1"#, + ]; + for doc in invalid { + assert!(!is_valid_json(doc), "should reject: {doc}"); + } + } + + /// The dashboard parses this on every poll; a stray comma or unescaped + /// character in any field blanks the whole page. + #[test] + fn to_json_is_valid_json_with_awkward_values() { + let mut s = Settings::defaults(); + s.midi_device = "Pad \"8\"\\\ttab".into(); + s.destinations.push(Destination { + id: "d1".into(), + name: "Main \"quoted\"".into(), + enabled: true, + platform: "custom".into(), + stream_key: "k\\ey".into(), + custom_egress_url: "rtmp://host/app".into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: String::new(), + audio_track: String::new(), + }); + for secrets in [false, true] { + let json = s.to_json(false, secrets); + assert!(is_valid_json(&json), "invalid JSON: {json}"); + } + } } // Tiny libc shim for Unix free-space - avoids adding the `libc` crate. diff --git a/src/controller.rs b/src/controller.rs index e25e296..ac93c83 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -11,6 +11,8 @@ use crate::buffer::{DiskRing, TagMeta}; use crate::compat::StreamParams; use crate::h264::{AudioCodec, VideoCodec}; +use crate::integrations::host::fmt_duration; +use crate::integrations::{Event, EventKind}; use crate::rtmp::client::{EgressClient, EgressSink, EgressUrl}; use std::collections::HashMap; use std::io; @@ -32,6 +34,62 @@ fn process_now_ms() -> u64 { Instant::now().saturating_duration_since(*start).as_millis() as u64 } +/// Rolling bitrate, lock-free: bytes are summed over windows of at least +/// `RATE_WINDOW_MS` and the rate is the last full window. A window that +/// hasn't rolled for `RATE_STALE_MS` means nothing has arrived since (the +/// stream stopped or froze), so the rate reads 0 rather than holding the +/// last second's value forever. +#[derive(Default)] +pub struct RateMeter { + window_bytes: AtomicU64, + /// 0 = no window yet. + window_start_ms: AtomicU64, + kbps: AtomicU32, +} + +const RATE_WINDOW_MS: u64 = 1_000; +const RATE_STALE_MS: u64 = 2_500; + +impl RateMeter { + pub fn note(&self, bytes: usize) { + self.note_at(bytes, process_now_ms()); + } + + fn note_at(&self, bytes: usize, now_ms: u64) { + let now_ms = now_ms.max(1); + let start = self.window_start_ms.load(Ordering::Relaxed); + // First bytes, or the first after a gap: they mark the start of a + // fresh window (so they count toward the time before it), instead + // of the silence being averaged into the rate. + if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS { + self.window_bytes.store(0, Ordering::Relaxed); + self.window_start_ms.store(now_ms, Ordering::Relaxed); + return; + } + let total = self.window_bytes.fetch_add(bytes as u64, Ordering::Relaxed) + bytes as u64; + let elapsed = now_ms.saturating_sub(start); + if elapsed >= RATE_WINDOW_MS { + // bytes * 8 / ms = kbps + self.kbps + .store(((total * 8) / elapsed) as u32, Ordering::Relaxed); + self.window_bytes.store(0, Ordering::Relaxed); + self.window_start_ms.store(now_ms, Ordering::Relaxed); + } + } + + pub fn kbps(&self) -> u32 { + self.kbps_at(process_now_ms()) + } + + fn kbps_at(&self, now_ms: u64) -> u32 { + let start = self.window_start_ms.load(Ordering::Relaxed); + if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS { + return 0; + } + self.kbps.load(Ordering::Relaxed) + } +} + /// Encode VideoCodec as a u8 for atomic storage. fn enc_vcodec(c: VideoCodec) -> u8 { match c { @@ -110,6 +168,9 @@ impl ActivateError { /// because a 24/7 relay logs for months: the oldest line is dropped rather /// than letting the buffer track uptime. const LOG_LINES_MAX: usize = 1_500; +/// How long OBS gets to send its first video frame after coming back from +/// a crash-protection hold before a missing picture counts as a freeze. +const FIRST_VIDEO_GRACE: Duration = Duration::from_secs(10); pub const NO_INGEST: &str = "OBS isn't sending anything yet - start streaming first"; @@ -152,7 +213,7 @@ pub struct DestinationState { pub bytes_sent: AtomicU64, pub cuts_performed: AtomicU32, pub reconnects: AtomicU32, - pub bitrate_kbps_out: AtomicU32, + pub rate_out: RateMeter, /// Set by the supervisor when this destination is being removed or /// the app is shutting down. The egress pump checks it once per loop /// and tears down the upstream session politely (deleteStream) before @@ -166,8 +227,6 @@ pub struct DestinationState { /// bytes don't match and the upstream decoder silently rejects every /// subsequent frame. last_seq_header_gen: AtomicU32, - rate_window_bytes: AtomicU64, - rate_window_start_ms: AtomicU64, /// True if this destination accepts Enhanced Broadcasting multi-track /// video on the wire. Set by the supervisor to `true` when the /// destination's platform is `twitch` and to `false` for everything @@ -204,9 +263,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 @@ -251,6 +310,19 @@ pub struct DestinationState { pub session_epoch: AtomicU64, } +/// The Enhanced Broadcasting config last handed to OBS, and the Twitch +/// session it opened on one destination. +#[derive(Debug, Clone)] +pub struct EbSession { + /// The rewritten GetClientConfiguration response OBS received. + pub config: String, + /// The session tokens OBS may publish with. + pub auths: Vec, + pub dest_id: String, + /// The IVS ingest URL the destination streams to for this session. + pub ivs_url: String, +} + /// Outcome of finishing a VOD-session fetch, returned by /// `complete_vod_fetch` so the supervisor can log it without re-deriving /// what happened. @@ -281,12 +353,10 @@ impl DestinationState { bytes_sent: AtomicU64::new(0), cuts_performed: AtomicU32::new(0), reconnects: AtomicU32::new(0), - bitrate_kbps_out: AtomicU32::new(0), + rate_out: RateMeter::default(), shutdown_requested: AtomicBool::new(false), last_seq_header_gen: AtomicU32::new(0), eb_override_url: crate::sync::Mutex::new(None), - rate_window_bytes: AtomicU64::new(0), - rate_window_start_ms: AtomicU64::new(0), // Default false: every newly-spawned destination flattens // multi-track until the supervisor decides otherwise. This // preserves beta.6 behaviour for any code path that creates @@ -308,9 +378,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) { @@ -423,27 +493,20 @@ impl DestinationState { outcome } - fn note_outbound_bytes(&self, n: usize) { - let now = process_now_ms(); - let total = self - .rate_window_bytes - .fetch_add(n as u64, Ordering::Relaxed) - + n as u64; - let start = self.rate_window_start_ms.load(Ordering::Relaxed); - if start == 0 { - self.rate_window_start_ms.store(now, Ordering::Relaxed); - return; - } - let elapsed = now.saturating_sub(start); - if elapsed >= 1_000 { - let kbps = ((total * 8) / elapsed.max(1)) as u32; - self.bitrate_kbps_out.store(kbps, Ordering::Relaxed); - self.rate_window_bytes.store(0, Ordering::Relaxed); - self.rate_window_start_ms.store(now, Ordering::Relaxed); - } + pub(crate) fn note_outbound_bytes(&self, n: usize) { + self.rate_out.note(n); } } +/// `Controller::resumed_after_seq` when the ring held nothing. +const NO_SEQ: u64 = u64::MAX; +/// `Controller::eb_vertical_track` when the config has no vertical track. +const NO_TRACK: u8 = 0xFF; + +/// `Controller::last_hold_end` values. +const HOLD_END_RESUMED: u8 = 1; +const HOLD_END_ENDED: u8 = 2; + /// What the main loop should do after a graceful shutdown: exit for good, or /// relaunch a fresh process in place. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -494,6 +557,68 @@ pub struct Controller { ingest_alive: AtomicBool, buffer_building: AtomicBool, publisher_token: AtomicU64, + /// Set when the live publisher sends FCUnpublish or deleteStream, read + /// (and cleared) when its connection closes: a disconnect after that + /// goodbye is a deliberate stop, not a crash. + unpublish_received: AtomicBool, + /// When the last video tag arrived (process ms), 0 before the first + /// one of a session or while crash protection is off. Used to spot a + /// frozen OBS. + last_video_tag_ms: AtomicU64, + /// OBS froze (a freeze hold opened) and has sent no video since. The + /// next video tag clears it and resumes; until then freeze detection + /// stays quiet, so a freeze hold that ran out or was ended doesn't + /// reopen itself. Kept apart from `hold` to stay lock-free per tag. + ingest_frozen: AtomicBool, + /// `hold` is Some. Lets the per-tag checks skip the lock. + hold_open: AtomicBool, + /// The newest ring seq when the last hold opened (`NO_SEQ`: none, or + /// 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, + /// 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. + capacity_capped_logged: AtomicBool, + /// Process ms of the last "rejected publisher" log line (see + /// `log_rejected_publish`). + last_rejection_log_ms: AtomicU64, + /// The newest ring seq when the last hold ended because OBS came back + /// (`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. + crash_protection: crate::sync::Mutex, + /// `crash_protection.enabled`, readable without a lock: the ingest path + /// does its crash-protection bookkeeping only while this is set. + crash_protection_on: AtomicBool, + /// The open crash-protection hold: OBS dropped and destinations are + /// being kept live on the reconnect screen. See `crate::crash_hold`. + hold: crate::sync::Mutex>, + /// Encoded reconnect-screen loops, shared by every destination. + pub slate_cache: Arc, + /// The latest keyframe of each track the reconnect screen can't + /// replace (HEVC, AV1), keyed like `video_seq_headers`: a hold re-sends + /// it instead. See `crash_hold::is_held_keyframe`. + held_keyframes: crate::sync::Mutex>>, + /// The Enhanced Broadcasting config last handed to OBS and the Twitch + /// session it opened, so OBS coming back during a hold continues it. + eb_session: crate::sync::Mutex>, + /// OBS asked for an Enhanced Broadcasting config (the kept one or a new + /// one) since the hold opened: it is coming back on that session. + /// Cleared when a hold opens. + eb_session_claimed: AtomicBool, // --- Per-destination state, keyed by Destination.id ----------------- // RwLock not Mutex: every `on_tag` (~150-300/s per active stream) @@ -506,12 +631,17 @@ pub struct Controller { // Ingest-side stats ingest_disconnects: AtomicU32, - bitrate_kbps: AtomicU32, // inbound (from OBS) - rate_window_bytes: AtomicU64, - rate_window_start_ms: AtomicU64, - - // Discord webhook URL. Empty = disabled. Updated live via update_webhook. - webhook_url: crate::sync::Mutex, + 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. + integrations: std::sync::OnceLock>, // Required RTMP stream key. Empty = accept any publisher (local default). // Mirrored from Settings via update_ingest_key so `begin_publish` can // enforce it without the ingest task needing a settings handle. @@ -525,10 +655,6 @@ pub struct Controller { // token out, so `begin_publish` trusts it alongside the ingest key. Bounded // and TTL'd (see remember_eb_key) so tokens never accumulate. eb_keys: crate::sync::Mutex>, - // Wall-clock ms (since UNIX epoch) of last webhook fire. Throttles - // rapid event sequences (e.g. reconnect flapping) so we never spawn - // more than one curl every ~2 s. - webhook_last_fire_ms: AtomicU64, // Coordination publish_lock: Mutex<()>, @@ -539,8 +665,16 @@ pub struct Controller { // whether Enhanced Broadcasting was caught + flattened. video_codec: AtomicU8, audio_codec: AtomicU8, + /// The track the last Enhanced Broadcasting config OBS got puts the + /// vertical canvas on, `NO_TRACK` when it has none. Finds the vertical + /// track when its SPS can't be read (Twitch's HEVC for 2K channels). + eb_vertical_track: AtomicU8, multitrack_video: AtomicBool, multitrack_audio: AtomicBool, + /// OBS is sending a 2nd audio track (TrackId 1, the VOD / clean track) + /// in this stream. A destination set to that track then gets only it + /// (see `h264::select_audio_bytes`). Reset with the publisher. + second_audio_track: AtomicBool, // --- Measured encoder parameters (per publisher session) --- // Feed `compat::compat_warning`, which compares them against the @@ -676,12 +810,27 @@ impl Controller { ingest_alive: AtomicBool::new(false), buffer_building: AtomicBool::new(false), publisher_token: AtomicU64::new(0), + unpublish_received: AtomicBool::new(false), + last_video_tag_ms: AtomicU64::new(0), + ingest_frozen: AtomicBool::new(false), + hold_open: AtomicBool::new(false), + hold_tail_end_seq: AtomicU64::new(NO_SEQ), + tail_plays_out: AtomicBool::new(false), + 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), + hold: crate::sync::Mutex::new(None), + slate_cache: Arc::default(), destinations: crate::sync::RwLock::new(HashMap::new()), ingest_disconnects: AtomicU32::new(0), - bitrate_kbps: AtomicU32::new(0), - rate_window_bytes: AtomicU64::new(0), - rate_window_start_ms: AtomicU64::new(0), - webhook_url: crate::sync::Mutex::new(String::new()), + 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 // the right key and clears its record on the first accept, so this @@ -693,10 +842,13 @@ impl Controller { std::time::Duration::from_secs(10 * 60), ), eb_keys: crate::sync::Mutex::new(Vec::new()), - webhook_last_fire_ms: AtomicU64::new(0), + held_keyframes: crate::sync::Mutex::new(std::collections::BTreeMap::new()), + eb_session: crate::sync::Mutex::new(None), + eb_session_claimed: AtomicBool::new(false), publish_lock: Mutex::new(()), video_codec: AtomicU8::new(0), audio_codec: AtomicU8::new(0), + eb_vertical_track: AtomicU8::new(NO_TRACK), video_dims: AtomicU64::new(0), idr_window_open: AtomicBool::new(false), first_idr_ts_ms: AtomicU64::new(0), @@ -705,6 +857,7 @@ impl Controller { keyframe_interval_cached: AtomicU32::new(0), multitrack_video: AtomicBool::new(false), multitrack_audio: AtomicBool::new(false), + second_audio_track: AtomicBool::new(false), seq_header_gen: AtomicU32::new(0), last_input_ts_u32: AtomicU32::new(0), input_ts_wrap_high: AtomicU32::new(0), @@ -767,6 +920,26 @@ impl Controller { pub fn video_codec(&self) -> VideoCodec { dec_vcodec(self.video_codec.load(Ordering::Relaxed)) } + /// Remember which track the config just handed to OBS puts the + /// vertical canvas on. + pub fn note_eb_config(&self, config: &str) { + let track = crate::local_eb_config::vertical_track(config).unwrap_or(NO_TRACK); + self.eb_vertical_track.store(track, Ordering::Relaxed); + } + /// The vertical track of the last config handed to OBS. + pub fn eb_vertical_track(&self) -> Option { + Some(self.eb_vertical_track.load(Ordering::Relaxed)).filter(|&t| t != NO_TRACK) + } + /// The portrait (9:16) track OBS is sending now. Only H.264 headers can + /// be measured; for any other codec, the vertical track the Enhanced + /// Broadcasting config named counts once OBS is actually sending it. + pub fn vertical_track_on_wire(&self) -> Option { + let headers = self.ring.video_seq_headers.lock(); + crate::h264::detect_vertical_primary_track(&headers).or_else(|| { + self.eb_vertical_track() + .filter(|track| headers.contains_key(track)) + }) + } pub fn audio_codec(&self) -> AudioCodec { dec_acodec(self.audio_codec.load(Ordering::Relaxed)) } @@ -899,16 +1072,28 @@ impl Controller { forwarding raw to Twitch destinations, flattening to the \ primary resolution for any other platform.", ); - self.fire_webhook( - "🎚️", - "Enhanced Broadcasting detected - multi-track forwarding active.", - ); + self.emit(Event::new(EventKind::EbDetected)); } } - pub fn note_multitrack_audio(&self) { + pub fn note_multitrack_audio(&self, payload: &[u8]) { if !self.multitrack_audio.swap(true, Ordering::Relaxed) { self.log("ingest: multi-track audio detected (VOD audio track) - forwarding as-is."); } + if !self.second_audio_track.load(Ordering::Relaxed) + && crate::h264::multitrack_audio_has_track(payload, 1) + { + self.second_audio_track.store(true, Ordering::Relaxed); + } + } + + /// Whether the audio track `egress` asks for is in this stream (the live + /// track always is). See `h264::select_audio_bytes`. + pub fn audio_target_on_wire(&self, egress: crate::h264::AudioEgress) -> bool { + match egress { + crate::h264::AudioEgress::Passthrough | crate::h264::AudioEgress::Track(0) => true, + crate::h264::AudioEgress::Track(1) => self.second_audio_track.load(Ordering::Relaxed), + crate::h264::AudioEgress::Track(_) => false, + } } /// Wipe codec/multitrack state when the publisher disconnects so a /// fresh OBS connect with a different codec starts from a clean slate. @@ -929,6 +1114,7 @@ impl Controller { self.keyframe_interval_cached.store(0, Ordering::Relaxed); self.multitrack_video.store(false, Ordering::Relaxed); self.multitrack_audio.store(false, Ordering::Relaxed); + self.second_audio_track.store(false, Ordering::Relaxed); self.last_multitrack_video_ms.store(0, Ordering::Relaxed); self.backpressure_since_ms.store(0, Ordering::Relaxed); self.last_input_ts_u32.store(0, Ordering::Relaxed); @@ -999,6 +1185,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); @@ -1044,7 +1238,7 @@ impl Controller { d.id.clone(), d.egress_alive.load(Ordering::Relaxed), d.consumer_seq.load(Ordering::Relaxed), - d.bitrate_kbps_out.load(Ordering::Relaxed), + d.rate_out.kbps(), d.tags_sent.load(Ordering::Relaxed), d.bytes_sent.load(Ordering::Relaxed), d.cuts_performed.load(Ordering::Relaxed), @@ -1090,6 +1284,7 @@ impl Controller { pub fn arm_delay(&self, ms: u32) { let ms = ms.min(600_000); let previous_target = self.target_delay_ms.load(Ordering::Relaxed); + self.capacity_capped_logged.store(false, Ordering::Relaxed); self.armed_delay_ms.store(ms, Ordering::Relaxed); if ms == 0 { // Disarm wipes target as well. @@ -1133,9 +1328,8 @@ impl Controller { if !self.ingest_alive() { return Err(ActivateError::NoIngest); } - let fill = self.buffer_fill_ms(); - if fill + 500 < armed { - let remaining_ms = armed.saturating_sub(fill); + if !self.buffer_holds(armed) { + let remaining_ms = armed.saturating_sub(self.buffer_fill_ms()); return Err(ActivateError::BufferShort { remaining_ms }); } self.target_delay_ms.store(armed, Ordering::Relaxed); @@ -1163,6 +1357,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) ------------------- @@ -1326,12 +1526,19 @@ impl Controller { if armed == 0 { return "idle"; } - if self.buffer_fill_ms() + 500 < armed { + if !self.buffer_holds(armed) { return "preparing"; } "ready" } + /// Whether the buffer holds a delay of `delay_ms`: it spans that much, + /// or it is full and spans all it ever will at this bitrate (the delay + /// then runs at what it holds, see `delayed_idr`). + fn buffer_holds(&self, delay_ms: u32) -> bool { + self.buffer_fill_ms() + 500 >= delay_ms || self.ring.is_saturated() + } + pub fn ingest_alive(&self) -> bool { self.ingest_alive.load(Ordering::Relaxed) } @@ -1389,32 +1596,22 @@ impl Controller { self.ingest_disconnects.load(Ordering::Relaxed) } pub fn bitrate_kbps(&self) -> u32 { - self.bitrate_kbps.load(Ordering::Relaxed) + 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 ---- - /// Called from the ingest path on every audio/video tag. Maintains a - /// 1-second rolling bitrate average (kbps) - cheap, lock-free. + /// Called from the ingest path on every audio/video tag. pub fn note_inbound_bytes(&self, n: usize) { - let now = process_now_ms(); - let total = self - .rate_window_bytes - .fetch_add(n as u64, Ordering::Relaxed) - + n as u64; - let start = self.rate_window_start_ms.load(Ordering::Relaxed); - if start == 0 { - self.rate_window_start_ms.store(now, Ordering::Relaxed); - return; - } - let elapsed = now.saturating_sub(start); - if elapsed >= 1_000 { - // Convert bytes/ms → kbps: bytes * 8 / ms - let kbps = ((total * 8) / elapsed.max(1)) as u32; - self.bitrate_kbps.store(kbps, Ordering::Relaxed); - self.rate_window_bytes.store(0, Ordering::Relaxed); - self.rate_window_start_ms.store(now, Ordering::Relaxed); - } + self.rate_in.note(n); } pub fn note_ingest_disconnect(&self) { @@ -1450,6 +1647,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 @@ -1462,14 +1664,11 @@ 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("ingest: rejected publisher (rate limited)"); + self.log_rejected_publish("ingest: rejected publisher (rate limited)"); return Err(io::Error::new( io::ErrorKind::PermissionDenied, "too many attempts", @@ -1489,7 +1688,7 @@ impl Controller { if remote { self.ingest_limiter.record_failure(peer_ip); } - self.log("ingest: rejected publisher (wrong stream key)"); + self.log_rejected_publish("ingest: rejected publisher (wrong stream key)"); return Err(io::Error::new( io::ErrorKind::PermissionDenied, "invalid stream key", @@ -1505,21 +1704,34 @@ impl Controller { // interleave its tags into the buffer with its own timestamp // origin and guarantee a viewer-visible glitch. if self.ingest_alive.load(Ordering::Relaxed) { - self.log("ingest: rejected second publisher (slot in use)"); - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "another publisher is already active", - )); + if !self.video_frozen_at(process_now_ms()) { + self.log_rejected_publish("ingest: rejected second publisher (slot in use)"); + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "another publisher is already active", + )); + } + // The publisher holding the slot has sent no video for + // FREEZE_AFTER: a hung OBS whose connection the OS keeps alive + // would otherwise lock a restarted OBS out indefinitely. Hand the + // slot over; the new token shuts the old connection out (see + // `end_publish`). A freeze hold, if one is up, resumes below as + // for any returning publisher. + self.log("ingest: the connected publisher stopped sending video - handing over to the new one"); + self.ingest_alive.store(false, Ordering::Relaxed); + self.note_ingest_disconnect(); + self.reset_codec_state(); } // Wipe every per-session cache that survives mark_ingest_dead. // The seq-header caches deliberately persist across an in-session - // egress restart (see `eb_seq_headers_survive_egress_restart`) so + // egress restart (see `every_track_config_leads_each_twitch_connection`) so // we clear them only when a new publisher takes the slot - // otherwise stale multi-track SPS/PPS leak into a non-EB // session and freeze the destination's decoder. self.ring.video_seq_headers.lock().clear(); self.ring.audio_seq_headers.lock().clear(); + self.held_keyframes.lock().clear(); // onMetaData leaks the same way: the prior publisher's // resolution / fps / encoder fields would be replayed at every // pump start until the new publisher's first onMetaData arrives. @@ -1545,11 +1757,39 @@ impl Controller { // fire hours later (or never) - clear it with the session. self.safe_cut_input_ts.store(0, Ordering::Relaxed); + // A goodbye from an earlier connection must not mark this session's + // end as a clean stop. + self.unpublish_received.store(false, Ordering::Relaxed); + 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. + self.expire_hold(); + let resuming = self.hold_active(); + if resuming { + self.forget_unclaimed_eb_session(); + } // 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 { + // Freeze detection only starts at a session's first video + // frame, so an OBS that reconnects and never sends any would + // keep the screen up with no deadline. Start its clock now, + // with room for a slow encoder start before it counts. + let grace = FIRST_VIDEO_GRACE.saturating_sub(crate::crash_hold::FREEZE_AFTER); + self.last_video_tag_ms.store( + process_now_ms() + grace.as_millis() as u64, + Ordering::Relaxed, + ); + } self.log("ingest: publisher connected"); - self.fire_webhook("✅", "OBS publisher connected - going live."); + self.emit(Event::new(EventKind::ObsConnected)); Ok(token) } @@ -1563,11 +1803,9 @@ impl Controller { // which are tiny and one-shot). if !is_seq { self.note_inbound_bytes(payload.len()); - } - // Bump the generation on every seq header so each egress pump - // knows to re-emit its cached config bytes on the next iteration. - if is_seq { - self.seq_header_gen.fetch_add(1, Ordering::Relaxed); + 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 @@ -1576,11 +1814,44 @@ impl Controller { if kind == 9 { if is_seq { self.note_video_dimensions(payload); - } else if is_idr { - self.sample_keyframe_interval(ts_ms); + } else { + if self.crash_protection_on.load(Ordering::Relaxed) { + self.note_video_for_crash_protection(payload); + } + 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. Everything before the freeze is behind viewers + // once the hold is over (ended or run out), or once the + // delay has moved past it: a freeze longer than the + // delay aired its whole tail, and the screen took over. + let tail_aired = self.ring.latest_ts().is_some_and(|last| { + ts_ms.saturating_sub(u64::from(self.target_delay_ms())) > last + }); + let hold_was_over = !self.hold_active() || tail_aired; + // 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); + } } } let _ = self.ring.append(kind, ts_ms, payload, is_idr, is_seq); + // Bump the generation on every seq header so each egress pump + // knows to re-emit its cached config bytes on the next iteration. + // After the append: a pump that sees the new generation must find + // the new header in the cache, or it would resend the old one and + // never learn of the change. + if is_seq { + self.seq_header_gen.fetch_add(1, Ordering::Relaxed); + } // Cap the buffer to the user's armed delay (plus a small slack for // IDR alignment). This keeps the on-screen "Buffer N/N s" exactly // what the user asked for, instead of growing to the full disk cap. @@ -1588,9 +1859,38 @@ impl Controller { // so we never evict a tag any pump is still about to read. if !is_seq { let target = self.effective_target_buffer_ms(); - let min_consumer = self.min_consumer_seq(); - self.ring.trim_older_than(target, ts_ms, min_consumer); + // Never trim the keyframe a delay of the whole buffer lands on + // (see `delayed_idr`): with a GOP longer than the slack, it is + // older than the cutoff, and without it the delay can't join. + let delay_keyframe = self + .ring + .newest_idr_at_or_before(ts_ms.saturating_sub(u64::from(self.target_buffer_ms()))) + .map_or(u64::MAX, |idr| idr.seq); + let keep_from = self.min_consumer_seq().min(delay_keyframe); + self.ring.trim_older_than(target, ts_ms, keep_from); + self.note_capacity_cap(); + } + } + + /// Say once per armed value when the buffer is full before it spans the + /// armed delay: the delay then runs at what the buffer holds. + fn note_capacity_cap(&self) { + let armed = self.armed_delay_ms(); + let fill = self.buffer_fill_ms(); + if armed == 0 + || self.capacity_capped_logged.load(Ordering::Relaxed) + || fill + 500 >= armed + || !self.ring.is_saturated() + { + return; } + self.capacity_capped_logged.store(true, Ordering::Relaxed); + self.log(format!( + "delay: the buffer is full at {} s at this bitrate, short of the {} s armed, so the delay runs at {} s. Raise the buffer size in System for the full delay.", + fill / 1000, + armed / 1000, + fill / 1000 + )); } /// Slowest consumer across all destinations. Used by trim to ensure @@ -1701,28 +2001,110 @@ impl Controller { self.target_buffer_ms() + BUFFER_SLACK_MS } + /// Whether a delayed destination can join right now: no delay is on, + /// or the buffer reaches back to it (see `delayed_idr`). + pub fn delay_reachable(&self) -> bool { + let target = u64::from(self.target_delay_ms()); + target == 0 || delayed_idr(self, target).is_some() + } + pub fn on_metadata(&self, payload: Vec) { *self.ring.metadata.lock() = Some(payload); } + /// A publishing connection closed. Only the current publisher's closing + /// counts: one that was handed over (`begin_publish`) has a stale token + /// and must not end the session that replaced it. + pub fn end_publish(&self, token: u64) { + if token == self.publisher_token() { + self.mark_ingest_dead(); + } + } + + /// Log a refused publish at most once every 10 s: a peer reconnecting + /// in a loop would otherwise push everything else out of the bounded + /// log. + fn log_rejected_publish(&self, line: &str) { + // Stamped one ms late, so 0 only ever means "never" (the process + // clock itself starts at 0). + let stamp = process_now_ms() + 1; + let last = self.last_rejection_log_ms.load(Ordering::Relaxed); + if last != 0 && stamp.saturating_sub(last) < 10_000 { + return; + } + self.last_rejection_log_ms.store(stamp, Ordering::Relaxed); + self.log(line.to_string()); + } + pub fn mark_ingest_dead(&self) { + // Only the live publisher's own disconnect gets here (its + // PublishGuard), once per session. + if !self.ingest_alive() { + return; + } + // 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.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) { self.note_ingest_disconnect(); self.reset_codec_state(); + if stopped { + self.log("ingest: publisher stopped the stream"); + } else { + self.log( + "ingest: publisher dropped without stopping (crash, kill or network loss)", + ); + } + // `protected`: a hold opened and has its own, more useful event. + self.emit( + Event::new(EventKind::ObsDisconnected) + .with("stopped", if stopped { "yes" } else { "no" }) + .with("protected", if self.hold_active() { "yes" } else { "no" }), + ); // Clear any Enhanced Broadcasting URL overrides on the // way out - the next stream may or may not be EB, and a // stale override would force a non-EB stream onto an IVS // endpoint that has no allocated session. The // /obs/multitrack-config proxy sets a fresh override on // every new EB session anyway. + // + // A destination a crash-protection hold keeps live keeps its + // session: OBS coming back continues it (see `held_eb_config`), + // and the hold ending without OBS forgets it (`close_hold`). for (_id, state) in self.all_destination_states() { - // Clear the override AND bump the session epoch atomically, - // so a VOD-session fetch still in flight discards its stale - // result instead of writing a dead-session IVS URL into the - // next stream (the late-completion race). - state.invalidate_session_override(); + if !self.hold_keeps(&state) { + // Clear the override AND bump the session epoch + // atomically, so a VOD-session fetch still in flight + // discards its stale result instead of writing a + // dead-session IVS URL into the next stream (the + // late-completion race). + state.invalidate_session_override(); + } // Note: we deliberately do NOT touch `vod_fetch_pending` // here. It's owned solely by the fetch lifecycle (claim sets // it, the task clears it on completion within ~15 s). If a @@ -1731,15 +2113,413 @@ impl Controller { // being spawned for the same destination - clearing it here // would reintroduce the multi-session bug on a fast restart. } - self.log("ingest: publisher disconnected"); - self.fire_webhook("⚠️", "OBS publisher disconnected."); } } - /// Update the Discord webhook URL - call when settings change. Empty - /// string disables webhook delivery entirely. - pub fn update_webhook(&self, url: String) { - *self.webhook_url.lock() = url; + /// Whether the open hold keeps `dest` live on the reconnect screen. + fn hold_keeps(&self, dest: &DestinationState) -> bool { + self.hold_active() + && dest.egress_alive.load(Ordering::Relaxed) + && crate::crash_hold::covers(self, dest) + } + + /// Remember the Enhanced Broadcasting config just handed to OBS and + /// the Twitch session it opened. + pub fn remember_eb_session(&self, session: EbSession) { + *self.eb_session.lock() = Some(session); + // OBS asked for this session, so it is Enhanced Broadcasting's own + // even when a hold is open (one that had no session to hand back): + // OBS publishing next must continue it, not drop it as stale. + self.eb_session_claimed.store(true, Ordering::Relaxed); + } + + /// The config to hand OBS again when it asks during a hold that is + /// keeping its Twitch session live: the same tracks and session token, + /// so its new publish continues the session instead of opening another + /// one (which would restart the destination once OBS is back). + pub fn held_eb_config(&self) -> Option { + if !self.hold_active() { + return None; + } + let session = self.eb_session.lock().clone()?; + let dest = self.destinations.read().get(&session.dest_id).cloned()?; + if dest.eb_override_url.lock().as_deref() != Some(session.ivs_url.as_str()) { + return None; + } + // The tokens were remembered when the session opened, possibly + // longer ago than their TTL. + for auth in &session.auths { + self.remember_eb_key(auth.clone()); + } + self.eb_session_claimed.store(true, Ordering::Relaxed); + Some(session.config) + } + + /// OBS is publishing again during a hold without having asked for an + /// Enhanced Broadcasting config (held or fresh) since the hold opened: + /// Enhanced Broadcasting is off now, and that multitrack session can't + /// take a single-track stream, so the destination starts a fresh one. + fn forget_unclaimed_eb_session(&self) { + if self.eb_session_claimed.load(Ordering::Relaxed) { + return; + } + let Some(session) = self.eb_session.lock().take() else { + return; + }; + if let Some(dest) = self.destinations.read().get(&session.dest_id) { + if dest.eb_override_url.lock().as_deref() == Some(session.ivs_url.as_str()) { + dest.invalidate_session_override(); + self.log(format!( + "[{}] OBS came back without Enhanced Broadcasting - starting a new \ + Twitch session", + session.dest_id + )); + } + } + } + + /// The newest ring seq when OBS came back from the last hold, or None + /// when the ring was empty (a new publisher starts it afresh). + pub fn resumed_after_seq(&self) -> Option { + Some(self.resumed_after_seq.load(Ordering::Relaxed)).filter(|seq| *seq != NO_SEQ) + } + + /// 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 { + 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 + /// reconnect screen. + pub fn held_keyframe(&self, track: u8) -> Option> { + self.held_keyframes.lock().get(&track).cloned() + } + + /// The live publisher sent FCUnpublish or deleteStream: it is ending + /// the stream on purpose, so its disconnect is a stop, not a crash. + pub fn note_unpublish(&self) { + self.unpublish_received.store(true, Ordering::Relaxed); + } + + /// Mirror the crash-protection settings (the supervisor calls this). + pub fn update_crash_protection(&self, settings: crate::crash_protection::CrashProtection) { + self.crash_protection_on + .store(settings.enabled, Ordering::Relaxed); + if !settings.enabled { + // 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); + // Switched off mid-freeze: no reconnect screen any more, so the + // freeze ends the stream like any OBS drop without protection, + // and when OBS is back the destinations start again past it. + if self.ingest_frozen.swap(false, Ordering::Relaxed) { + self.mark_replay_floor(); + self.end_hold_now(); + } + self.held_keyframes.lock().clear(); + self.slate_cache.clear(); + } + *self.crash_protection.lock() = settings; + } + + /// Per video frame while crash protection is on: when video last + /// arrived (for freeze detection), and the latest keyframe of any track + /// the reconnect screen can't replace. + fn note_video_for_crash_protection(&self, payload: &[u8]) { + // Never 0: that means "no video yet this session". + self.last_video_tag_ms + .store(process_now_ms().max(1), Ordering::Relaxed); + if crate::crash_hold::is_held_keyframe(payload) { + let track = crate::h264::seq_header_track_id(payload); + self.held_keyframes.lock().insert(track, Arc::from(payload)); + } + } + + pub fn crash_protection(&self) -> crate::crash_protection::CrashProtection { + self.crash_protection.lock().clone() + } + + /// Whether destinations are being held on the reconnect screen now. + pub fn hold_active(&self) -> bool { + self.hold_open.load(Ordering::Relaxed) + && self.hold_state() == crate::crash_hold::HoldState::Holding + } + + /// Where the hold stands. A hold past its deadline already counts as + /// ended, before `expire_hold` gets round to reporting it. + pub fn hold_state(&self) -> crate::crash_hold::HoldState { + use crate::crash_hold::HoldState; + match *self.hold.lock() { + Some(hold) if Instant::now() < hold.deadline => HoldState::Holding, + Some(_) => HoldState::Ended, + None if self.last_hold_end.load(Ordering::Relaxed) == HOLD_END_RESUMED => { + HoldState::Resumed + } + None => HoldState::Ended, + } + } + + /// The hold while it is on air, for the dashboard, dock and tray. + pub fn hold_status(&self) -> Option { + let hold = (*self.hold.lock())?; + Some(crate::crash_hold::HoldStatus { + reason: hold.reason, + remaining: hold.deadline.checked_duration_since(Instant::now())?, + total: hold.deadline - hold.started, + }) + } + + /// Open a hold when crash protection is on and a destination is live + /// to protect. + fn start_hold(&self, reason: crate::crash_hold::HoldReason) { + let settings = self.crash_protection(); + let any_live = self + .all_destination_states() + .iter() + .any(|(_, state)| state.egress_alive.load(Ordering::Relaxed)); + if !settings.enabled || !any_live { + return; + } + let hold_for = Duration::from_secs(u64::from(settings.hold_secs)); + let now = Instant::now(); + { + let mut hold = self.hold.lock(); + if hold.is_some() { + return; + } + *hold = Some(crate::crash_hold::Hold { + reason, + started: now, + deadline: now + hold_for, + }); + } + self.hold_open.store(true, Ordering::Relaxed); + self.hold_tail_end_seq + .store(self.ring.latest_seq().unwrap_or(NO_SEQ), Ordering::Relaxed); + self.tail_plays_out.store(false, Ordering::Relaxed); + self.eb_session_claimed.store(false, Ordering::Relaxed); + if reason == crate::crash_hold::HoldReason::Freeze { + self.ingest_frozen.store(true, Ordering::Relaxed); + } + let window = crate::crash_hold::minutes_seconds(hold_for); + 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}" + )); + self.emit( + Event::new(EventKind::HoldOpened) + .with( + "reason", + 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)) + .with( + "hold_ends_at", + (crate::integrations::twitch::unix_ms() / 1000 + hold_for.as_secs()) + .to_string(), + ), + ); + } + + /// Close the open hold, remembering how it ended for the pumps. + fn close_hold(&self, end: u8) -> Option { + // How it ended is stored before the lock is released, so + // `hold_state` never reads "no hold" with the previous hold's end. + let hold = { + let mut open = self.hold.lock(); + let hold = open.take()?; + self.last_hold_end.store(end, Ordering::Relaxed); + hold + }; + self.hold_open.store(false, Ordering::Relaxed); + // OBS never came back: forget the sessions the hold kept for it + // (see `mark_ingest_dead`); ending the hold ended them on Twitch. A + // frozen OBS is still connected, so its session is still the + // current one, and a config OBS asked for during the hold (the kept + // session or a new one) is the one it is about to publish on. + let obs_is_coming_back = self.eb_session_claimed.load(Ordering::Relaxed); + if end == HOLD_END_ENDED && !self.ingest_alive() && !obs_is_coming_back { + for (_id, state) in self.all_destination_states() { + state.invalidate_session_override(); + } + } + Some(hold) + } + + /// OBS is sending again (a new publisher, or video after a freeze). + /// Runs before the tag that brought it back reaches the ring. + fn resume_from_hold(&self) { + let after = self.ring.latest_seq().unwrap_or(NO_SEQ); + self.resumed_after_seq.store(after, Ordering::Relaxed); + self.tail_plays_out.store(false, Ordering::Relaxed); + self.hold_tail_end_seq.store(NO_SEQ, Ordering::Relaxed); + let Some(hold) = self.close_hold(HOLD_END_RESUMED) else { + return; + }; + let lasted = crate::crash_hold::minutes_seconds(hold.started.elapsed()); + self.log(format!( + "crash protection: OBS is back after {lasted} - resuming live" + )); + self.emit(Event::new(EventKind::ObsBack).with( + "down_for", + fmt_duration(hold.started.elapsed().as_millis() as u64), + )); + } + + /// Whether the publisher has sent no video for `FREEZE_AFTER` as of + /// `now_ms` (process ms). Never true before its first video frame. + fn video_frozen_at(&self, now_ms: u64) -> bool { + let last = self.last_video_tag_ms.load(Ordering::Relaxed); + last != 0 + && now_ms.saturating_sub(last) >= crate::crash_hold::FREEZE_AFTER.as_millis() as u64 + } + + /// A connected OBS that has sent no video for `FREEZE_AFTER` is frozen: + /// open a hold for it (the supervisor calls this every tick). + pub fn check_ingest_freeze(&self) { + self.check_ingest_freeze_at(process_now_ms()); + } + + fn check_ingest_freeze_at(&self, now_ms: u64) { + let latched = self.ingest_frozen.load(Ordering::Relaxed); + if !latched + && self.ingest_alive() + && self.video_frozen_at(now_ms) + && self.hold.lock().is_none() + { + self.start_hold(crate::crash_hold::HoldReason::Freeze); + } + } + + /// Whether OBS is connected and actually sending video, as opposed to + /// connected but frozen. Egress waits for this before connecting. + pub fn ingest_sending(&self) -> bool { + self.ingest_alive() && !self.video_frozen_at(process_now_ms()) + } + + /// OBS is gone: disconnected, or frozen with a freeze hold opened for + /// it and no video since. Nothing new reaches the buffer either way. + pub fn obs_gone(&self) -> bool { + !self.ingest_alive() || self.ingest_frozen.load(Ordering::Relaxed) + } + + /// Whether a destination still airing the delay tail of an OBS that is + /// gone finishes it before ending: yes once crash protection's time ran + /// out (the tail airs in full), not when the streamer ended the hold. + fn tail_plays_out(&self) -> bool { + self.tail_plays_out.load(Ordering::Relaxed) + || matches!(*self.hold.lock(), Some(open) if Instant::now() >= open.deadline) + } + + /// The last tag of the delay tail: the newest when the hold opened, or + /// the newest now when no hold marked it. + fn delay_tail_end_seq(&self) -> Option { + match self.hold_tail_end_seq.load(Ordering::Relaxed) { + NO_SEQ => self.ring.latest_seq(), + seq => Some(seq), + } + } + + /// Close a hold whose time ran out. The pumps end their sessions at the + /// deadline on their own; this reports it once (the supervisor calls it + /// every tick). + pub fn expire_hold(&self) { + let expired = matches!(*self.hold.lock(), Some(open) if Instant::now() >= open.deadline); + let Some(hold) = expired.then(|| self.close_hold(HOLD_END_ENDED)).flatten() else { + return; + }; + self.tail_plays_out + .store(self.obs_gone(), Ordering::Relaxed); + let window = crate::crash_hold::minutes_seconds(hold.deadline - hold.started); + self.log(format!( + "crash protection: OBS didn't come back within {window} - destinations ended" + )); + self.emit(Event::new(EventKind::HoldExpired).with( + "hold", + fmt_duration((hold.deadline - hold.started).as_millis() as u64), + )); + } + + /// 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 self.end_tail_now(); + }; + self.tail_plays_out.store(false, Ordering::Relaxed); + let lasted = crate::crash_hold::minutes_seconds(hold.started.elapsed()); + self.log(format!( + "crash protection: ended after {lasted} - destinations ended" + )); + self.emit(Event::new(EventKind::HoldEnded).with( + "down_for", + fmt_duration(hold.started.elapsed().as_millis() as u64), + )); + 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) { + let _ = self.integrations.set(handle); + } + + pub fn integrations(&self) -> Option<&Arc> { + self.integrations.get() + } + + /// Tell the integrations something happened. Never blocks and needs no + /// runtime, so it is safe from the tray, hotkey and MIDI threads. + pub fn emit(&self, event: Event) { + if let Some(handle) = self.integrations.get() { + handle.emit(event); + } } /// Mirror the required ingest stream key from Settings. Empty disables the @@ -1781,60 +2561,26 @@ impl Controller { && crate::crypto::constant_time_eq(k.as_bytes(), key.as_bytes()) }) } +} - /// Snapshot the current webhook URL. Used by the test endpoint so it - /// can route the request with verbose error reporting instead of - /// going through `fire_webhook` (which is fire-and-forget and - /// silently swallows everything from empty-URL to TLS failures). - pub fn webhook_url_snapshot(&self) -> String { - self.webhook_url.lock().clone() - } - - /// Fire-and-forget Discord post. Skips silently when no webhook is - /// configured, OR when the last fire was less than 2 s ago (rate - /// limit - prevents subprocess spam if a destination flaps). - /// - /// Uses `ureq` (tiny blocking HTTPS client, ~150 KB) wrapped in - /// `spawn_blocking` so the actual TCP+TLS work doesn't park the - /// current-thread runtime. Previously shelled out to `curl`, which - /// (a) silently failed when `curl.exe` wasn't on PATH and (b) made - /// "runtime deps" technically include the system curl binary. - pub fn fire_webhook(&self, emoji: &str, message: &str) { - let url = self.webhook_url.lock().clone(); - if url.is_empty() { - return; - } - - // Throttle: skip if we fired less than 2 s ago. - let now = process_now_ms(); - let last = self.webhook_last_fire_ms.load(Ordering::Relaxed); - if now.saturating_sub(last) < 2_000 { - return; - } - self.webhook_last_fire_ms.store(now, Ordering::Relaxed); - - let content = format!("{emoji} **InstantClone**: {message}"); - let body = format!(r#"{{"content":"{}"}}"#, json_escape_inline(&content)); - - tokio::spawn(async move { - // Spawn-blocking + 10 s outer timeout so a wedged Discord - // edge can't keep a thread tied up forever. Ignore the - // result: webhooks are fire-and-forget by design. - let _ = tokio::time::timeout( - Duration::from_secs(10), - tokio::task::spawn_blocking(move || { - let _ = crate::https::https_agent() - .post(&url) - .config() - .timeout_connect(Some(Duration::from_secs(5))) - .timeout_global(Some(Duration::from_secs(8))) - .build() - .header("Content-Type", "application/json") - .send(&body); - }), - ) - .await; - }); +/// The platform a destination's ingest host belongs to, for the `{platform}` +/// variable of destination events. +fn platform_of_host(host: &str) -> &'static str { + let host = host.to_ascii_lowercase(); + if host.contains("twitch") { + "twitch" + } else if host.contains("youtube") || host.contains("google") { + "youtube" + } else if host.contains("kick") || host.contains("live-video.net") { + "kick" + } else if host.contains("trovo") { + "trovo" + } else if host.contains("restream") { + "restream" + } else if host == "127.0.0.1" || host == "localhost" { + "sink" + } else { + "custom" } } @@ -1909,6 +2655,27 @@ 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(); + } + + /// 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), @@ -1920,6 +2687,15 @@ impl Controller { None } "cut_after" => self.action_cut_after(source), + "end_hold" => { + if self.end_hold_now() { + return None; + } + self.log(format!( + "[{source}] end crash protection ignored - nothing is on hold" + )); + Some("Crash protection isn't holding anything right now".to_string()) + } _ => None, } } @@ -2043,31 +2819,6 @@ impl Controller { } } -/// JSON-string escape that handles every C0 control char that would -/// otherwise produce an invalid Discord payload (the previous -/// `replace('\\', ..).replace('"', ..).replace('\n', ..)` chain missed -/// `\r`, `\t`, `\u{0008}` and friends - any destination name with a -/// stray control character could nuke the webhook body). -fn json_escape_inline(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 8); - for c in s.chars() { - match c { - '"' => out.push_str(r#"\""#), - '\\' => out.push_str(r"\\"), - '\n' => out.push_str(r"\n"), - '\r' => out.push_str(r"\r"), - '\t' => out.push_str(r"\t"), - '\u{0008}' => out.push_str(r"\b"), - '\u{000C}' => out.push_str(r"\f"), - c if (c as u32) < 0x20 => { - out.push_str(&format!("\\u{:04x}", c as u32)); - } - c => out.push(c), - } - } - out -} - // --------------------------------------------------------------------------- // Egress driver - the timing & cut-alignment core. // --------------------------------------------------------------------------- @@ -2095,6 +2846,7 @@ pub async fn run_egress( let mut backoff = Duration::from_secs(1); let max_backoff = Duration::from_secs(30); + let mut waiting_for_delay = false; loop { // Cooperative shutdown - check BEFORE attempting another @@ -2119,28 +2871,79 @@ pub async fn run_egress( // spam visible in /logs. Wait for ingest to come back instead, // polling cheaply every 500 ms so we react quickly to OBS // resuming a publish session. - if !ctrl.ingest_alive() { + // During a crash-protection hold a destination that lost its + // platform connection reconnects and goes straight back onto the + // reconnect screen - if the screen can cover it at all. + let holding = ctrl.hold_active() && crate::crash_hold::covers(&ctrl, &dest); + if !ctrl.ingest_sending() && !holding { tokio::time::sleep(Duration::from_millis(500)).await; continue; } - eprintln!( - "[egress {}] connecting to {}:{}/{}", - label, parsed.host, parsed.port, parsed.app - ); + // With a delay on, join only once the buffer reaches back to it (an + // OBS restart empties it; a new destination may find it short). + // Joining sooner would put viewers on live video, then jump back. + if !holding && !ctrl.delay_reachable() { + if !waiting_for_delay { + waiting_for_delay = true; + ctrl.log(format!( + "[{label}] waiting for the buffer to reach the delay before going live" + )); + } + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + waiting_for_delay = false; + eprintln!( + "[egress {}] connecting to {}:{}/{}", + label, parsed.host, parsed.port, parsed.app + ); ctrl.log(format!( "[{}] connecting to {}:{}", label, parsed.host, parsed.port )); - match EgressClient::connect(&parsed).await { + // Bounded: an edge that accepts TCP and then stalls in the TLS or + // RTMP handshake would otherwise hold this destination on + // "connecting" forever, never retrying. + let connected = tokio::time::timeout(CONNECT_TIMEOUT, EgressClient::connect(&parsed)) + .await + .unwrap_or_else(|_| { + Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("no answer within {} s", CONNECT_TIMEOUT.as_secs()), + )) + }); + match connected { Ok(client) => { - backoff = Duration::from_secs(1); + let connected_at = Instant::now(); let was_alive = dest.egress_alive.swap(true, Ordering::Relaxed); if !was_alive { - ctrl.fire_webhook("🟢", &format!("**{}** is now live.", label)); + ctrl.emit( + Event::new(EventKind::DestinationLive) + .with("destination", label.clone()) + .with("platform", platform_of_host(&parsed.host)), + ); } let sink = client.spawn_reader_drain(); let pump_result = pump_dest(&ctrl, &dest, sink).await; dest.egress_alive.store(false, Ordering::Relaxed); + // Not reading any more: its position must not hold back the + // buffer's trim while it reconnects or waits. + dest.consumer_seq.store(u64::MAX, Ordering::Relaxed); + // 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_…"). @@ -2150,15 +2953,23 @@ pub async fn run_egress( let safe = scrub_secret(&e.to_string(), &parsed.stream_key); eprintln!("[egress {}] pump error: {}", label, safe); ctrl.log(format!("[{}] disconnected ({})", label, safe)); - ctrl.fire_webhook("🔴", &format!("**{}** disconnected: {}", label, safe)); + ctrl.emit( + Event::new(EventKind::DestinationDropped) + .with("destination", label.clone()) + .with("platform", platform_of_host(&parsed.host)) + .with("reason", safe.clone()), + ); + if ctrl.destination_alive_summary().0 == 0 { + ctrl.emit(Event::new(EventKind::AllDestinationsDown)); + } + } + // Counted here (instead of at the loop tail) so a fresh + // connect or a connect failure never bumps it: "Egress + // reconnects: 1" must not be the resting state of a healthy + // fresh stream. + if dropped { + dest.reconnects.fetch_add(1, Ordering::Relaxed); } - // Count this as a reconnect: the next loop iteration WILL - // re-establish a connection. Incrementing here (instead of - // at the loop tail) avoids the old bug where every initial - // connect AND every connect-failure bumped the counter, - // making "Egress reconnects: 1" the resting state of a - // healthy fresh stream. - dest.reconnects.fetch_add(1, Ordering::Relaxed); } Err(e) => { let safe = scrub_secret(&e.to_string(), &parsed.stream_key); @@ -2246,6 +3057,28 @@ async fn pump_dest( state.last_publisher_token = ctrl.publisher_token(); let mut io_buf: Vec = Vec::with_capacity(64 * 1024); + // Connected to the platform in the middle of a crash-protection hold + // (the platform connection itself dropped and came back, or this + // destination was just switched on): go straight onto the reconnect + // screen, whether OBS crashed or froze, then rejoin on its new video. + let mut resume_after_ts: Option = None; + let mut rejoin_from_seq: Option = None; + if ctrl.hold_active() { + send_sequence_headers(ctrl, dest, &mut sink, 0).await?; + let outcome = crate::crash_hold::play(ctrl, dest, &mut sink, 0).await?; + let crate::crash_hold::HoldExit::Resumed { + rejoin_from_seq: from_seq, + } = outcome.exit + else { + let _ = sink.send_delete_stream().await; + return Ok(()); + }; + resume_after_ts = Some(outcome.last_ts); + rejoin_from_seq = Some(from_seq); + // The seed below is on the publisher OBS came back as. + state.last_publisher_token = ctrl.publisher_token(); + } + // Initial seed: if a delay is ALREADY active when this pump spawns // (multi-destination case - a second destination added mid-stream // while the first is on a 5 s delay), join at the right delayed @@ -2255,16 +3088,21 @@ async fn pump_dest( // No seed means the publisher went away (or this destination was // disabled) before a keyframe arrived. Leave rather than hold an open // session to the platform with nothing to send down it. - let Some(first_idr) = seed_idr(ctrl, dest).await else { + let seed = match rejoin_from_seq { + Some(from_seq) => rejoin_idr(ctrl, dest, from_seq).await, + None => seed_idr(ctrl, dest).await, + }; + let Some(first_idr) = seed else { ctrl.log(format!( "[{}] nothing to send - the publisher went away before a keyframe", dest.id )); + let _ = sink.send_delete_stream().await; return Ok(()); }; state.consumer_seq = first_idr.seq; state.input_ts_anchor = first_idr.ts_ms; - state.output_ts_base = 0; + state.output_ts_base = resume_after_ts.map_or(0, |ts| ts.wrapping_add(1)); state.wall_anchor = Instant::now(); state.wall_anchor_input_ts = first_idr.ts_ms; state.last_sent_input_ts = first_idr.ts_ms; @@ -2289,6 +3127,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 @@ -2311,13 +3151,52 @@ async fn pump_dest( // YouTube long after OBS dropped. The supervisor's gate on // ingest_alive prevents an immediate respawn here, so the // destination stays cleanly disconnected until OBS comes back. - if !ctrl.ingest_alive() { - ctrl.log(format!( - "[{}] ingest gone - closing destination session", - dest.id - )); - let _ = sink.send_delete_stream().await; - return Ok(()); + // + // Crash protection changes that: during a hold the pump first plays + // out whatever delay is still buffered, then loops the reconnect + // screen until OBS returns (the reconnect branch below takes over) + // or the hold ends. + // A frozen OBS (connected, no video) gets the same treatment. + // + // With no hold covering OBS being gone (crash protection off, its + // time ran out, or the streamer ended it) the session ends. When the + // time ran out mid-tail, the tail airs in full first: the pump keeps + // sending until it is out (a delay as long as the hold or longer). + if ctrl.obs_gone() || ctrl.hold_active() { + if !ctrl.hold_active() { + if !ctrl.tail_plays_out() || delay_tail_sent(ctrl, &state) { + ctrl.log(format!( + "[{}] OBS is gone - closing destination session", + dest.id + )); + 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?; + // Continue the timeline from the last slate frame, so the + // reconnect re-anchor stays strictly after it. + state.output_ts_base = outcome.last_ts; + state.input_ts_anchor = state.last_sent_input_ts; + let crate::crash_hold::HoldExit::Resumed { rejoin_from_seq } = outcome.exit else { + let _ = sink.send_delete_stream().await; + return Ok(()); + }; + // OBS is back (a new publisher, or the same one after a + // freeze): rejoin on its new video, a delay back when one is + // armed. The destination's decoder holds the screen's + // pictures, so it has to start at a keyframe. + ctrl.log(format!("[{}] OBS is back - rejoining", dest.id)); + let Some(idr) = rejoin_idr(ctrl, dest, rejoin_from_seq).await else { + let _ = sink.send_delete_stream().await; + return Ok(()); + }; + state.last_publisher_token = ctrl.publisher_token(); + rejoin_at(ctrl, dest, &mut sink, &mut state, idr).await?; + continue; + } } // Detect publisher reconnect (OBS stopped and re-started, new @@ -2327,6 +3206,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); @@ -2335,17 +3226,33 @@ async fn pump_dest( "[{}] publisher went away again before re-anchoring", dest.id )); + let _ = sink.send_delete_stream().await; return Ok(()); }; - reseed_after_publisher_change(&mut state, new_idr); state.last_publisher_token = current_token; - send_sequence_headers(ctrl, dest, &mut sink, state.output_ts_base).await?; - dest.consumer_seq - .store(state.consumer_seq, Ordering::Relaxed); - dest.last_seq_header_gen.store( - ctrl.seq_header_gen.load(Ordering::Relaxed), - Ordering::Relaxed, - ); + // OBS came back from a crash while the delay tail was still + // airing: the rest of the tail went with the old session (a new + // publisher starts the buffer afresh), and rejoining here would + // put viewers on live. Cover the gap with the screen until the + // delay has rebuilt from the new video, then rejoin delayed. + if resumed_mid_delay(ctrl, dest) { + let outcome = + crate::crash_hold::play(ctrl, dest, &mut sink, last_output_ts(&state)).await?; + state.output_ts_base = outcome.last_ts; + state.input_ts_anchor = state.last_sent_input_ts; + let crate::crash_hold::HoldExit::Resumed { rejoin_from_seq } = outcome.exit else { + let _ = sink.send_delete_stream().await; + return Ok(()); + }; + let Some(idr) = rejoin_idr(ctrl, dest, rejoin_from_seq).await else { + let _ = sink.send_delete_stream().await; + return Ok(()); + }; + state.last_publisher_token = ctrl.publisher_token(); + rejoin_at(ctrl, dest, &mut sink, &mut state, idr).await?; + continue; + } + rejoin_at(ctrl, dest, &mut sink, &mut state, new_idr).await?; continue; } @@ -2356,19 +3263,39 @@ async fn pump_dest( let cur_gen = ctrl.seq_header_gen.load(Ordering::Relaxed); if cur_gen != dest.last_seq_header_gen.load(Ordering::Relaxed) { ctrl.log(format!("[{}] sequence header changed - resending", dest.id)); - // Use last_sent_input_ts so the resent header lands AFTER - // anything we've already sent (same trick as apply_cut). - let delta_u32 = state - .last_sent_input_ts - .saturating_sub(state.input_ts_anchor) as u32; - let resend_ts = state.output_ts_base.wrapping_add(delta_u32); - send_sequence_headers(ctrl, dest, &mut sink, resend_ts).await?; + // On the last output timestamp, so the resent header lands + // AFTER anything we've already sent (same trick as apply_cut). + send_sequence_headers(ctrl, dest, &mut sink, last_output_ts(&state)).await?; dest.last_seq_header_gen.store(cur_gen, Ordering::Relaxed); } let next_real = next_or_wait(&ctrl.ring, state.consumer_seq, 500).await; + // A new publisher or sequence header can arrive while we wait, and + // the tag that woke us belongs to it: re-anchor or resend the header + // above first, or that tag airs on the old timeline or config. + let header_changed = ctrl.seq_header_gen.load(Ordering::Relaxed) + != dest.last_seq_header_gen.load(Ordering::Relaxed); + if header_changed || ctrl.publisher_token() != state.last_publisher_token { + continue; + } if let Some(meta) = next_real { + // Fell off the back of the ring (eviction passed this pump), so + // `next_or_wait` jumped ahead to the oldest keyframe. Take it as + // a cut: the timeline re-anchors instead of pausing for the + // skipped stretch, and a vertical destination waits for its + // own keyframe. + if meta.seq > state.consumer_seq { + apply_cut( + ctrl, + dest, + &mut sink, + &mut state, + PendingCut { target: meta }, + ) + .await?; + continue; + } let target_now = ctrl.target_delay_ms(); let due = target_now != state.last_seen_target || state.last_cut_check.elapsed() >= Duration::from_millis(500); @@ -2393,6 +3320,74 @@ async fn pump_dest( } } +/// Output timestamp of the last tag this pump sent. +fn last_output_ts(state: &EgressState) -> u32 { + let delta = state + .last_sent_input_ts + .saturating_sub(state.input_ts_anchor) as u32; + state.output_ts_base.wrapping_add(delta) +} + +/// OBS just came back from a crash-protection hold, with a delay armed +/// that the screen can hold viewers behind while it rebuilds. +fn resumed_mid_delay(ctrl: &Controller, dest: &DestinationState) -> bool { + ctrl.crash_protection_on.load(Ordering::Relaxed) + && ctrl.target_delay_ms() > 0 + && ctrl.hold_state() == crate::crash_hold::HoldState::Resumed + && crate::crash_hold::covers(ctrl, dest) +} + +/// Re-anchor on `idr` and lead with the sequence headers: how every +/// rejoin starts (a new publisher, or OBS back after a hold). +async fn rejoin_at( + ctrl: &Arc, + dest: &Arc, + sink: &mut EgressSink, + state: &mut EgressState, + idr: TagMeta, +) -> io::Result<()> { + reseed_after_publisher_change(state, idr); + send_sequence_headers(ctrl, dest, sink, state.output_ts_base).await?; + dest.consumer_seq + .store(state.consumer_seq, Ordering::Relaxed); + dest.last_seq_header_gen.store( + ctrl.seq_header_gen.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + Ok(()) +} + +/// The keyframe to rejoin at once OBS is back: the newest at least a delay +/// back from the live edge, like every delayed join (`delayed_idr`), and +/// never one from before `from_seq`, where OBS's new video starts. Without +/// a delay that is the newest keyframe. +async fn rejoin_idr( + ctrl: &Arc, + dest: &Arc, + from_seq: u64, +) -> Option { + let delay = ctrl.target_delay_ms() as u64; + let delayed = ctrl + .ring + .latest_ts() + .and_then(|latest| { + ctrl.ring + .newest_idr_at_or_before(latest.saturating_sub(delay)) + }) + .filter(|idr| idr.seq >= from_seq); + match delayed.or_else(|| ctrl.ring.oldest_idr_at_or_after(from_seq)) { + Some(idr) => Some(idr), + None => wait_for_idr(ctrl, dest, Some(from_seq.saturating_sub(1))).await, + } +} + +/// Whether every tag the ended publisher left in the buffer has been sent +/// (the delay tail), so the reconnect screen can take over. +fn delay_tail_sent(ctrl: &Controller, state: &EgressState) -> bool { + ctrl.delay_tail_end_seq() + .is_none_or(|end| state.consumer_seq > end) +} + /// Per-egress-session state. Lost on reconnect; re-anchored from scratch. struct EgressState { consumer_seq: u64, @@ -2400,7 +3395,8 @@ struct EgressState { output_ts_base: u32, // output ts assigned to the most recent cut target (RTMP wire is u32) wall_anchor: Instant, // wall clock at the most recent cut wall_anchor_input_ts: u64, // input ts that pairs with wall_anchor - last_sent_input_ts: u64, // input ts of the last tag we actually emitted - + last_sent_input_ts: u64, // highest input ts we actually emitted (audio and + // video interleave a few ms out of order) - // required so apply_cut can re-anchor the // output timeline *after* the last sent frame // (instead of after the last cut, which would @@ -2470,11 +3466,11 @@ async fn pace_and_send( return Ok(()); } let raw_delta_u64 = meta.ts_ms - state.input_ts_anchor; - // The wire send carries a u32. Truncation is fine here because the - // delta is bounded by buffer_target_ms + slack (~10 min absolute - // max), which fits in u32. wrapping_add against output_ts_base - // handles the (rare) out_ts wrap at the 49-day mark; downstream - // players accept the wrap because rtmp ts is defined as wrap-modulo. + // The wire send carries a u32. The delta is the time since the last + // cut or reseed, which can run for days, so the truncation relies on + // RTMP timestamps being modulo 2^32: wrapping_add against + // output_ts_base gives the same wire value as the untruncated sum, and + // players accept the wrap at the 49-day mark. let raw_delta = raw_delta_u64 as u32; let logical_offset_ms = meta.ts_ms.saturating_sub(state.wall_anchor_input_ts); @@ -2511,7 +3507,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. @@ -2527,7 +3523,9 @@ async fn pace_and_send( // (`audio_egress`), flattened, so a simulcast YouTube / Kick // gets exactly one audio track it can decode. Single-track // audio borrows through unchanged. - let Some(selected) = crate::h264::select_audio_bytes(io_buf, dest.audio_egress()) + let egress = dest.audio_egress(); + let Some(selected) = + crate::h264::select_audio_bytes(io_buf, egress, ctrl.audio_target_on_wire(egress)) else { state.consumer_seq = meta.seq + 1; dest.consumer_seq @@ -2536,7 +3534,7 @@ async fn pace_and_send( }; let bytes_out: &[u8] = &selected; let tags_so_far = dest.tags_sent.load(Ordering::Relaxed); - if tags_so_far < 20 || tags_so_far.is_multiple_of(200) { + if crate::trace::is_enabled() && (tags_so_far < 20 || tags_so_far.is_multiple_of(200)) { crate::trace::log( "TAG_AUDIO", &format!( @@ -2644,7 +3642,7 @@ async fn pace_and_send( .fetch_add(bytes_out.len() as u64, Ordering::Relaxed); dest.note_outbound_bytes(bytes_out.len()); state.consumer_seq = meta.seq + 1; - state.last_sent_input_ts = meta.ts_ms; + state.last_sent_input_ts = state.last_sent_input_ts.max(meta.ts_ms); dest.consumer_seq .store(state.consumer_seq, Ordering::Relaxed); return Ok(()); @@ -2656,7 +3654,7 @@ async fn pace_and_send( .fetch_add(io_buf.len() as u64, Ordering::Relaxed); dest.note_outbound_bytes(io_buf.len()); state.consumer_seq = meta.seq + 1; - state.last_sent_input_ts = meta.ts_ms; + state.last_sent_input_ts = state.last_sent_input_ts.max(meta.ts_ms); // Tell the ingest-side trimmer how far we've read. The trimmer takes // the MIN across all destinations, so a slow consumer protects all // others from over-aggressive eviction. @@ -2701,48 +3699,148 @@ fn idr_search_tolerance_ms(keyframe_interval_ms: u32) -> u32 { IDR_SEARCH_FLOOR_MS.max(keyframe_interval_ms / 2) } +/// The IDR a delay of `target_ms` lands on: the newest one at least that +/// old, so viewers are never closer to live than asked. None until the +/// 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)) + .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 + // keyframe: each new tag overwrites the oldest, and a pump parked there + // would be overwritten as it reads and keep falling off the back. + at_delay.or_else(|| { + if !ctrl.ring.is_saturated() { + return None; + } + let oldest = ctrl.ring.oldest_ts()?; + 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)) + }) +} + +/// How far in from its oldest tag a delay capped by a full ring starts +/// (see `delayed_idr`): room for the reads to stay ahead of the overwrites. +const FULL_RING_MARGIN_MS: u64 = 3_000; + +/// How long a connect to a platform (TCP, TLS and the RTMP handshake) may +/// take before it counts as failed and is retried. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +/// How long a platform session has to stay up before a reconnect starts +/// 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; + +/// Keyframe interval assumed until one is measured (OBS's default). +const DEFAULT_KEYFRAME_INTERVAL_MS: u64 = 2_000; + fn compute_delay_cut(ctrl: &Arc, current: &TagMeta) -> Option { let target_delay = ctrl.target_delay_ms.load(Ordering::Relaxed) as u64; let latest = ctrl.ring.latest_ts()?; - let oldest = ctrl.ring.oldest_ts()?; let current_delay = latest.saturating_sub(current.ts_ms); - - // Dead band scales with the measured GOP (see recut_dead_band_ms). At the - // default 2 s cadence this is the tuned 1500 ms; the trade-off is the - // delivered delay may sit up to one dead-band away from the requested - // value, which is exactly the price of not re-cutting to the same IDR. - let keyframe_interval = ctrl.keyframe_interval_ms(); - let dead_band = recut_dead_band_ms(keyframe_interval); - let diff = (current_delay as i64) - (target_delay as i64); - if diff.abs() < dead_band as i64 { - ctrl.buffer_building.store(false, Ordering::Relaxed); - return None; + if target_delay > 0 { + return compute_delayed_cut(ctrl, current, current_delay, target_delay); } + ctrl.buffer_building.store(false, Ordering::Relaxed); - // "Build buffer first" - if the user asked for a delay deeper than - // the buffer currently extends, we can't honor it yet. Mark the state - // and hold our position; the buffer keeps filling at real time, and - // once the requested delay becomes reachable, the next iteration cuts. - // Same dead band as the re-cut gate so the two agree on "close enough". - let have_seconds_back = latest.saturating_sub(oldest); - if target_delay > have_seconds_back.saturating_add(dead_band) { - ctrl.buffer_building.store(true, Ordering::Relaxed); + // Back to live: the keyframe nearest the live edge. Dead band scales + // with the measured GOP (see recut_dead_band_ms), so a pump already + // parked on the best keyframe doesn't re-cut to it every tick. + let keyframe_interval = ctrl.keyframe_interval_ms(); + if current_delay < recut_dead_band_ms(keyframe_interval) { return None; } - ctrl.buffer_building.store(false, Ordering::Relaxed); - // Binary-search on the IDR-only secondary index (~log n over just // the keyframes) rather than the old O(n) walk over every tag. - let desired_input_ts = latest.saturating_sub(target_delay); let target = ctrl .ring - .find_idr_near(desired_input_ts, idr_search_tolerance_ms(keyframe_interval))?; + .find_idr_near(latest, idr_search_tolerance_ms(keyframe_interval))?; if target.seq == current.seq { return None; } Some(PendingCut { target }) } +/// The cut a delay of `target_delay` needs, if any. A delay lands on the +/// newest keyframe at least that old (see `delayed_idr`), so it sits +/// between the target and one keyframe interval above it. Re-cutting only +/// outside that window means a landed delay never re-trips the check, and +/// viewers are never closer to live than asked, even on long GOPs. +fn compute_delayed_cut( + ctrl: &Controller, + current: &TagMeta, + current_delay: u64, + target_delay: u64, +) -> Option { + let keyframe_interval = ctrl.keyframe_interval_ms(); + let gop = match keyframe_interval { + 0 => DEFAULT_KEYFRAME_INTERVAL_MS, + measured => u64::from(measured), + }; + let too_little = current_delay + DELAY_JITTER_MS < target_delay; + let upper_band = recut_dead_band_ms(keyframe_interval).max(gop + DELAY_JITTER_MS); + let too_much = current_delay > target_delay + upper_band; + if !too_little && !too_much { + ctrl.buffer_building.store(false, Ordering::Relaxed); + return None; + } + // While OBS is gone (crashed, or frozen and held) the buffer can't + // grow, so the delay only shrinks as the delay tail plays out. Jumping + // back to restore it would replay the tail instead of finishing it. + if too_little && (ctrl.hold_active() || ctrl.obs_gone()) { + return None; + } + // "Build buffer first": until the buffer reaches back to the delay, + // hold position; it fills at real time and the next check cuts. + let Some(target) = delayed_idr(ctrl, target_delay) else { + ctrl.buffer_building.store(true, Ordering::Relaxed); + return None; + }; + ctrl.buffer_building.store(false, Ordering::Relaxed); + // A cut has to fix what it was called for. Too much delay moves + // forward; with a keyframe gap longer than measured, the keyframe at + // the delay can be the one this pump is already past, and cutting back + // to it would replay the same stretch on every check. Too little delay + // moves back, by more than the jitter: on a full ring the oldest + // keyframe is barely behind this pump, and re-cutting to it would + // replay a moment of video every check without adding any delay. + let latest = current_delay + current.ts_ms; + let worth_it = if too_much { + target.seq > current.seq + } else { + target.seq < current.seq + && latest.saturating_sub(target.ts_ms) > current_delay + DELAY_JITTER_MS + }; + worth_it.then_some(PendingCut { target }) +} + async fn apply_cut( ctrl: &Arc, dest: &Arc, @@ -2913,10 +4011,14 @@ async fn send_sequence_headers( } else { v_headers.iter().find(|(k, _)| *k == target) }; - if let Some((_, h)) = pick { - let selected = - crate::h264::select_video_bytes(h, crate::h264::VideoEgress::Track(target)) - .unwrap_or(std::borrow::Cow::Borrowed(h.as_slice())); + // A header that can't be flattened for this destination (only + // another track's has arrived so far) is not sent at all: a raw + // multi-track header means nothing to a single-track platform. The + // right one goes out when it arrives (new generation). + let selected = pick.and_then(|(_, h)| { + crate::h264::select_video_bytes(h, crate::h264::VideoEgress::Track(target)) + }); + if let Some(selected) = selected { let bytes_out: &[u8] = &selected; crate::trace::log( "VIDEO_SEQ_HDR_SENT", @@ -2976,7 +4078,9 @@ async fn send_sequence_headers( .or_else(|| a_headers.first()) .map(|(_, v)| v); if let Some(h) = header { - if let Some(bytes) = crate::h264::select_audio_bytes(h, egress) { + if let Some(bytes) = + crate::h264::select_audio_bytes(h, egress, ctrl.audio_target_on_wire(egress)) + { crate::trace::log( "AUDIO_SEQ_HDR_SENT", &format!( @@ -3024,7 +4128,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); @@ -3039,26 +4146,28 @@ async fn wait_for_idr( } } -/// Pick the seed IDR for a freshly spawned egress pump. If a delay is -/// already active and the ring has enough history, seed at the delayed -/// position so the new pump joins mid-stream cleanly; otherwise fall -/// back to the newest IDR (live edge). +/// Pick the seed IDR for a freshly spawned egress pump. With a delay on, +/// join at the delayed position, waiting for the buffer to reach back that +/// far (`run_egress` normally waits before connecting); a live start would +/// air live video, then jump back. With no delay, the newest IDR. async fn seed_idr(ctrl: &Arc, dest: &Arc) -> Option { - let target = ctrl.target_delay_ms() as u64; - if target > 0 { - if let (Some(latest), Some(oldest)) = (ctrl.ring.latest_ts(), ctrl.ring.oldest_ts()) { - // Only attempt the delayed seed if the buffer actually spans - // far enough back - otherwise find_idr_near may give us an - // IDR much closer to live than the user asked for. - if latest.saturating_sub(oldest) + 1_500 >= target { - let desired = latest.saturating_sub(target); - if let Some(idr) = ctrl.ring.find_idr_near(desired, 2_000) { - return Some(idr); - } - } + loop { + let target = u64::from(ctrl.target_delay_ms()); + if target == 0 { + return wait_for_idr(ctrl, dest, None).await; + } + let notified = ctrl.ring.on_append.notified(); + if let Some(idr) = delayed_idr(ctrl, target) { + return Some(idr); + } + if !ctrl.ingest_alive() || dest.shutdown_requested.load(Ordering::Relaxed) { + return None; + } + tokio::select! { + _ = notified => {} + _ = tokio::time::sleep(Duration::from_millis(250)) => {} } } - wait_for_idr(ctrl, dest, None).await } /// Re-anchor egress state after the publisher changed identity. Mirrors @@ -3115,9 +4224,40 @@ async fn next_or_wait(ring: &Arc, seq: u64, wait_ms: u64) -> Option 0, "mark is ahead of the consumer"); + // More stream arrives, all of it after the mark. + feed_seconds(&h.ctrl, 4_000, 2, 30); + let still_pending = |why: &str| { + h.ctrl.maybe_fire_safe_cut(); + assert!(h.ctrl.safe_cut_pending(), "{why}"); + assert_eq!(h.ctrl.target_delay_ms(), 2_000, "{why}"); + }; + let dest = |id: &str, alive: bool, seq: u64| { + let st = h.ctrl.destination_state(id); + st.egress_alive.store(alive, Ordering::Relaxed); + st.consumer_seq.store(seq, Ordering::Relaxed); + }; - // Consumer hasn't aired the mark yet → must NOT fire. - h.ctrl.maybe_fire_safe_cut(); - assert!(h.ctrl.safe_cut_pending()); - assert_eq!(h.ctrl.target_delay_ms(), 2_000); + dest("slow", false, 10); + dest("fast", false, mark_seq + 5); + dest("dead", false, 0); + still_pending("no live destination to measure against"); - // More stream arrives, and the consumer advances past the mark - // (the newest tag's ts is beyond the mark by construction). - feed_seconds(&h.ctrl, 4_000, 2, 30); - let latest_seq = h.ctrl.ring.latest_seq().expect("ring has tags"); - st.consumer_seq.store(latest_seq, Ordering::Relaxed); + dest("slow", true, 10); + dest("fast", true, mark_seq + 5); + still_pending("the slower live destination has not aired the mark"); + + dest("slow", true, mark_seq); + still_pending("the mark's own frame has not gone out yet"); + dest("slow", true, mark_seq + 1); h.ctrl.maybe_fire_safe_cut(); - assert!(!h.ctrl.safe_cut_pending(), "mark aired - must fire"); + assert!(!h.ctrl.safe_cut_pending(), "aired everywhere live - fires"); assert_eq!(h.ctrl.target_delay_ms(), 0, "fire runs the normal cut"); // The armed value survives, same as a manual Cut - the next // activate is instant. @@ -3488,32 +4639,17 @@ mod tests { ); } - #[test] - fn activate_consumes_auto_activate_pending() { - let h = harness(0); - h.ctrl.arm_delay(2_000); - feed_seconds(&h.ctrl, 0, 3, 30); - assert!(h.ctrl.auto_activate_pending()); - h.ctrl.activate_delay().expect("buffer is full"); - assert!( - !h.ctrl.auto_activate_pending(), - "successful activate must consume the pending slot" - ); - } - #[test] fn cut_consumes_auto_activate_pending_so_it_sticks() { // The bug-of-record: without this clear, the supervisor sees // phase revert to "ready" after cut and re-fires activate_delay. + // Cut while the buffer is still preparing: the slot is still set + // (nothing has activated yet), and the streamer's "stay live" has + // to win over the auto-activate the buffer filling would trigger. let h = harness(0); h.ctrl.arm_delay(2_000); - feed_seconds(&h.ctrl, 0, 3, 30); - h.ctrl.activate_delay().unwrap(); - // Slot was already cleared by activate. Re-arm to set it again, - // then exercise the cut path explicitly. (arm_delay live-update - // path - target > 0, so this DOESN'T set pending; we have to - // simulate the post-cut state.) - h.ctrl.stop_delay(); // cut: target -> 0, pending -> false + assert!(h.ctrl.auto_activate_pending(), "armed, not yet activated"); + h.ctrl.stop_delay(); assert!( !h.ctrl.auto_activate_pending(), "cut must keep pending false so the supervisor doesn't re-activate" @@ -3628,6 +4764,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 @@ -4058,6 +5215,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); @@ -4335,111 +5524,556 @@ mod tests { } } - // ── Ring + index correctness ───────────────────────────────────── - - #[test] - fn on_tag_appends_to_ring_with_monotonic_ts() { - // The wrap-expansion path is the load-bearing piece of timestamp - // logic. Even a small wrap should produce strictly increasing - // u64 timestamps in the ring. + /// Harness with crash protection on and one destination streaming. + fn protected_harness(every_disconnect: bool) -> Harness { let h = harness(0); - // Two tags well below any wrap point - strictly increasing. - h.ctrl.on_tag(9, 100, &[0u8; 10], true, false); - h.ctrl.on_tag(9, 200, &[0u8; 10], false, false); - h.ctrl.on_tag(9, 300, &[0u8; 10], false, false); - - let oldest = h.ctrl.ring.oldest_ts().unwrap(); - let latest = h.ctrl.ring.latest_ts().unwrap(); - assert_eq!(oldest, 100); - assert_eq!(latest, 300); - assert!(latest > oldest); + h.ctrl + .update_crash_protection(crate::crash_protection::CrashProtection { + enabled: true, + every_disconnect, + ..Default::default() + }); + h.ctrl + .destination_state("live") + .egress_alive + .store(true, Ordering::Relaxed); + h } - #[test] - fn idr_index_seek_finds_the_right_frame() { - let h = harness(0); - feed_seconds(&h.ctrl, 0, 5, 30); // IDR at 0, 1000, 2000, 3000, 4000 - // Target 2500, tolerance 600 → closest IDR is 2000 (distance 500) - let m = h - .ctrl - .ring - .find_idr_near(2500, 600) - .expect("should find IDR near 2500"); - assert_eq!(m.ts_ms, 2000); - assert!(m.is_idr); + /// OBS sends FCUnpublish + deleteStream from `RTMP_Close` on every + /// deliberate stop; a crash closes the socket without them. Only a + /// crash opens a hold, and a goodbye never leaks into the next session. + #[tokio::test] + async fn only_a_crash_opens_a_hold() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.note_unpublish(); + h.ctrl.mark_ingest_dead(); + assert!(!h.ctrl.hold_active(), "a deliberate stop ends the stream"); + + h.ctrl.note_unpublish(); // stray goodbye between sessions + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.mark_ingest_dead(); + assert!(h.ctrl.hold_active(), "a drop without goodbye is a crash"); + + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + assert_eq!(h.ctrl.hold_state(), crate::crash_hold::HoldState::Resumed); } - // ── Timestamp wrap (the load-bearing one) ────────────────────── + #[tokio::test] + async fn end_hold_hotkey_ends_a_hold_and_explains_when_there_is_none() { + let h = protected_harness(false); + assert!(h.ctrl.run_named_action("end_hold", 0, "hotkey").is_some()); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.mark_ingest_dead(); + assert!(h.ctrl.hold_active()); + assert_eq!(h.ctrl.run_named_action("end_hold", 0, "hotkey"), None); + assert_eq!(h.ctrl.hold_state(), crate::crash_hold::HoldState::Ended); + assert!( + h.ctrl.run_named_action("end_hold", 0, "hotkey").is_some(), + "nothing left to end" + ); + } - #[test] - fn wire_ts_wrap_promotes_to_monotonic_u64() { - // RTMP wire timestamps are u32 ms, so they wrap every ~49.7 days. - // The Controller's expand_ts() promotes them to u64 by detecting - // a wrap (current u32 << previous u32) and bumping a "high" - // counter. Without this, a wrap would make `latest_ts - oldest_ts` - // negative and the whole delay accounting would explode. - // - // We can't call expand_ts directly (private), so we exercise it - // through on_tag() and observe the result via ring.latest_ts(). + #[tokio::test] + async fn no_hold_when_protection_is_off_or_nothing_is_live() { let h = harness(0); - // Walk forward toward the wrap boundary, then over it. - let near_max = u32::MAX - 1000; - h.ctrl.on_tag(9, near_max, &[0u8; 20], true, false); - h.ctrl.on_tag(9, near_max + 500, &[0u8; 20], false, false); - // Cross the boundary: wire_ts drops near 0 (this LOOKS LIKE - // going backwards if you only had u32 math). - h.ctrl.on_tag(9, 200, &[0u8; 20], false, false); - - let latest = h.ctrl.ring.latest_ts().unwrap(); - let oldest = h.ctrl.ring.oldest_ts().unwrap(); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.mark_ingest_dead(); + assert!(!h.ctrl.hold_active(), "protection is off by default"); + + let h = protected_harness(false); + h.ctrl + .destination_state("live") + .egress_alive + .store(false, Ordering::Relaxed); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.mark_ingest_dead(); + assert!( + !h.ctrl.hold_active(), + "no live destination, nothing to protect" + ); + } + + #[tokio::test] + async fn a_frozen_publisher_opens_a_hold_that_its_next_frame_closes() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.on_tag(9, 0, &[0x27, 1, 0, 0, 0], false, false); + assert!(h.ctrl.ingest_sending()); + let last = h.ctrl.last_video_tag_ms.load(Ordering::Relaxed); + let freeze_ms = crate::crash_hold::FREEZE_AFTER.as_millis() as u64; + assert!(!h.ctrl.video_frozen_at(last + freeze_ms - 1)); + assert!( + h.ctrl.video_frozen_at(last + freeze_ms), + "connected but frozen" + ); + h.ctrl.check_ingest_freeze_at(last + 500); + assert!(!h.ctrl.hold_active(), "a short gap is not a freeze"); + h.ctrl.check_ingest_freeze_at(last + freeze_ms); + assert!(h.ctrl.hold_active()); + h.ctrl.on_tag(9, 40, &[0x27, 1, 0, 0, 0], false, false); + assert_eq!(h.ctrl.hold_state(), crate::crash_hold::HoldState::Resumed); + assert!(h.ctrl.ingest_sending()); + } + + /// OBS crashes with a 10 s delay on air: the buffer stops growing, so + /// the delay only shrinks while the tail plays out. Live, that gap + /// would re-cut back; during the hold it must not, or the tail loops. + #[tokio::test] + async fn a_crash_mid_delay_never_jumps_back_into_the_tail() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.arm_delay(10_000); + feed_seconds(&h.ctrl, 0, 12, 30); + h.ctrl.activate_delay().expect("the buffer holds the delay"); + // The pump is 4 s from the frozen live edge: 6 s short of the delay. + let current = h.ctrl.ring.find_idr_near(8_000, 500).unwrap(); assert!( - latest > oldest, - "post-wrap latest ({latest}) must be > oldest ({oldest}); \ - wrap promotion is broken" + compute_delay_cut(&h.ctrl, ¤t).is_some(), + "live, it re-cuts" ); - // The expand_ts machinery should have lifted the post-wrap value - // above u32::MAX (high bit promoted). + h.ctrl.mark_ingest_dead(); + assert!(h.ctrl.hold_active()); assert!( - latest > u32::MAX as u64, - "expected post-wrap ts above u32::MAX, got {latest}" + compute_delay_cut(&h.ctrl, ¤t).is_none(), + "holding, the tail plays out once" ); } - // ── Enhanced Broadcasting stability ────────────────────────────── + /// The tray, global hotkeys and MIDI run on plain threads with no Tokio + /// runtime. Ending a hold from them emits an integrations event, which + /// must not need one: a panic there aborts the whole process. + #[tokio::test] + async fn ending_a_hold_off_the_runtime_does_not_panic() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.mark_ingest_dead(); + assert!(h.ctrl.hold_active()); + let ctrl = h.ctrl.clone(); + let hotkey = std::thread::spawn(move || ctrl.run_named_action("end_hold", 0, "hotkey")); + assert_eq!(hotkey.join().expect("no panic off the runtime"), None); + assert!(!h.ctrl.hold_active()); + } - /// EB seq headers must survive across the `pace_and_send` resync - /// path: the supervisor restarts egress without disturbing the - /// publisher, the per-track BTreeMap stays populated, and the next - /// `send_sequence_headers` for a Twitch destination re-emits every - /// track from the cache. The bug we shipped + reverted was the - /// pre-BTreeMap single-Option cache stomping all tracks down to - /// just the last one received - verify via the ring's cache - /// directly so we'd catch a regression to that storage shape. - #[test] - fn eb_seq_headers_survive_egress_restart() { + /// Opens a freeze hold for `h` and returns the last video stamp. + fn freeze(h: &Harness) -> u64 { + h.ctrl.on_tag(9, 0, &[0x27, 1, 0, 0, 0], false, false); + let last = h.ctrl.last_video_tag_ms.load(Ordering::Relaxed); + let freeze_ms = crate::crash_hold::FREEZE_AFTER.as_millis() as u64; + h.ctrl.check_ingest_freeze_at(last + freeze_ms); + assert!(h.ctrl.hold_active()); + last + } + + #[tokio::test] + async fn stopping_in_obs_ends_a_freeze_hold() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + freeze(&h); + h.ctrl.note_unpublish(); + h.ctrl.mark_ingest_dead(); + assert!(!h.ctrl.hold_active(), "a deliberate stop ends it"); + assert_eq!(h.ctrl.hold_state(), crate::crash_hold::HoldState::Ended); + } + + #[tokio::test] + async fn a_freeze_hold_that_ended_does_not_reopen_until_video_returns() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + let last = freeze(&h); + assert!(h.ctrl.end_hold_now()); + h.ctrl.check_ingest_freeze_at(last + 60_000); + assert!(!h.ctrl.hold_active(), "still the same freeze"); + // Video comes back, then OBS freezes again: that one is protected. + let last = freeze(&h); + assert!(last > 0); + } + + /// With a delay armed, the screen stays up after OBS is back until its + /// new video spans the delay, and the pump rejoins from that video. + #[tokio::test] + async fn the_screen_waits_for_the_delay_to_rebuild_from_new_video() { let h = harness(0); - // Simulate OBS sending OneTrack-format seq headers for tracks - // 0..=3 (a typical Twitch EB four-rung ladder). - for track in 0u8..=3 { - let mut tag = vec![0x96, 0x00, 0x61, 0x76, 0x63, 0x31, track]; - tag.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); - h.ctrl - .ring - .append(9, 0, &tag, false, true) - .expect("seq-header append"); + h.ctrl.arm_delay(10_000); + feed_seconds(&h.ctrl, 0, 2, 10); + // OBS comes back (after a freeze: the old video stays in the ring). + h.ctrl.resume_from_hold(); + let mut rebuild = None; + let rebuilt = + |rebuild: &mut Option<_>| crate::crash_hold::delay_rebuilt(&h.ctrl, rebuild, 3_000); + assert_eq!(rebuilt(&mut rebuild), None, "no new video yet"); + let first_new = h.ctrl.ring.latest_seq().unwrap() + 1; + feed_seconds(&h.ctrl, 10_000, 2, 10); + assert_eq!(rebuilt(&mut rebuild), None, "1.9 s of new video"); + feed_seconds(&h.ctrl, 12_000, 2, 10); + assert_eq!(rebuilt(&mut rebuild), Some(first_new), "3.9 s: rejoin"); + } + + const KEPT_IVS: &str = "rtmps://ivs/app/kept"; + const KEPT_TOKEN: &str = "kept-token"; + const FRESH_IVS: &str = "rtmps://ivs/app/fresh"; + const FRESH_TOKEN: &str = "fresh-token"; + + /// How OBS left the stream. + #[derive(Debug, Clone, Copy, PartialEq)] + enum ObsLeaves { + Crashes, + /// Stopped on purpose, with "protect every disconnect" on. + Stops, + /// Still connected, but sending no video. + Freezes, + } + + /// When OBS asked for its Enhanced Broadcasting config again. + #[derive(Debug, Clone, Copy, PartialEq)] + enum ObsAsks { + Never, + DuringHold, + AfterHold, + } + + /// How the hold closed. + #[derive(Debug, Clone, Copy, PartialEq)] + enum HoldCloses { + ObsReturns, + /// `ticked`: the supervisor reported the deadline before OBS came back. + Deadline { + ticked: bool, + obs_returns: bool, + }, + EndNow { + obs_returns: bool, + }, + } + + #[derive(Debug)] + struct HoldCase { + had_eb_session: bool, + leaves: ObsLeaves, + asks: ObsAsks, + closes: HoldCloses, + } + + impl HoldCase { + fn obs_returns(&self) -> bool { + match self.closes { + HoldCloses::ObsReturns => true, + HoldCloses::Deadline { obs_returns, .. } | HoldCloses::EndNow { obs_returns } => { + obs_returns + } + } } - // mark_ingest_dead clears codec state and EB overrides, but - // the seq-header cache itself belongs to the ring and only - // gets wiped by a fresh publisher session - verify it stays. - let dest = h.ctrl.destination_state("d1"); - *dest.eb_override_url.lock() = Some("rtmps://stale".into()); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + + fn is_possible(&self) -> bool { + match self.asks { + ObsAsks::Never => true, + // A frozen OBS is still streaming: it asks for nothing. + ObsAsks::DuringHold => self.leaves != ObsLeaves::Freezes, + ObsAsks::AfterHold => { + self.leaves != ObsLeaves::Freezes + && self.obs_returns() + && self.closes != HoldCloses::ObsReturns + } + } + } + + /// The Twitch session the destination must end on: the one OBS + /// asked for and publishes on, or none for a single-track stream. + fn expected_session(&self) -> Option<&'static str> { + let kept = self.had_eb_session.then_some(KEPT_IVS); + if self.leaves == ObsLeaves::Freezes { + return kept; + } + match self.asks { + ObsAsks::Never => None, + ObsAsks::DuringHold => Some(kept.unwrap_or(FRESH_IVS)), + ObsAsks::AfterHold => Some(FRESH_IVS), + } + } + + fn expected_state(&self) -> crate::crash_hold::HoldState { + if self.closes == HoldCloses::ObsReturns { + crate::crash_hold::HoldState::Resumed + } else { + crate::crash_hold::HoldState::Ended + } + } + } + + fn all_hold_cases() -> Vec { + let mut closings = vec![HoldCloses::ObsReturns]; + for obs_returns in [true, false] { + closings.push(HoldCloses::EndNow { obs_returns }); + for ticked in [true, false] { + closings.push(HoldCloses::Deadline { + ticked, + obs_returns, + }); + } + } + let mut cases = Vec::new(); + for had_eb_session in [true, false] { + for leaves in [ObsLeaves::Crashes, ObsLeaves::Stops, ObsLeaves::Freezes] { + for asks in [ObsAsks::Never, ObsAsks::DuringHold, ObsAsks::AfterHold] { + for &closes in &closings { + let case = HoldCase { + had_eb_session, + leaves, + asks, + closes, + }; + if case.is_possible() { + cases.push(case); + } + } + } + } + } + cases + } + + /// The fresh-session path of /obs/multitrack-config (web.rs): accept the + /// token, point the Twitch destination at the session, keep it for a hold. + fn open_eb_session(h: &Harness, ivs: &str, token: &str) { + h.ctrl.remember_eb_key(token.into()); + *h.ctrl.destination_state("live").eb_override_url.lock() = Some(ivs.into()); + h.ctrl.remember_eb_session(EbSession { + config: format!("{{{token}}}"), + auths: vec![token.into()], + dest_id: "live".into(), + ivs_url: ivs.into(), + }); + } + + /// OBS asks /obs/multitrack-config for its config, which hands back the + /// session a hold kept or opens a new one. Returns OBS's stream key. + fn obs_asks_for_eb_config(h: &Harness) -> &'static str { + if h.ctrl.held_eb_config().is_some() { + return KEPT_TOKEN; + } + open_eb_session(h, FRESH_IVS, FRESH_TOKEN); + FRESH_TOKEN + } + + /// Plays `case` out and returns what went wrong, if anything. + async fn run_hold_case(case: &HoldCase) -> Vec { + let h = protected_harness(case.leaves == ObsLeaves::Stops); + h.ctrl.update_ingest_key("k".into()); + let mut stream_key = "k"; + if case.had_eb_session { + open_eb_session(&h, KEPT_IVS, KEPT_TOKEN); + stream_key = KEPT_TOKEN; + } + h.ctrl.begin_publish(stream_key, "127.0.0.1").await.unwrap(); + h.ctrl.on_tag(9, 0, &[0x17, 0, 0, 0, 0, 1], false, true); + match case.leaves { + ObsLeaves::Crashes => h.ctrl.mark_ingest_dead(), + ObsLeaves::Stops => { + h.ctrl.note_unpublish(); + h.ctrl.mark_ingest_dead(); + } + ObsLeaves::Freezes => { + freeze(&h); + } + } + assert!(h.ctrl.hold_active(), "{case:?}: no hold opened"); + + stream_key = "k"; + if case.asks == ObsAsks::DuringHold { + stream_key = obs_asks_for_eb_config(&h); + } + match case.closes { + HoldCloses::ObsReturns => {} + HoldCloses::Deadline { ticked, .. } => { + if let Some(hold) = h.ctrl.hold.lock().as_mut() { + hold.deadline = Instant::now() - Duration::from_millis(1); + } + if ticked { + h.ctrl.expire_hold(); + } + } + HoldCloses::EndNow { .. } => { + assert!(h.ctrl.end_hold_now(), "{case:?}: End now found no hold"); + } + } + if case.obs_returns() { + if case.asks == ObsAsks::AfterHold { + stream_key = obs_asks_for_eb_config(&h); + } + if case.leaves == ObsLeaves::Freezes { + h.ctrl.on_tag(9, 40, &[0x27, 1, 0, 0, 0], false, false); + } else if let Err(e) = h.ctrl.begin_publish(stream_key, "127.0.0.1").await { + return vec![format!("{case:?}: OBS's publish was refused: {e}")]; + } + } + // The supervisor's next tick. + h.ctrl.expire_hold(); + + let mut failures = Vec::new(); + let session = h + .ctrl + .destination_state("live") + .eb_override_url + .lock() + .clone(); + if session.as_deref() != case.expected_session() { + failures.push(format!( + "{case:?}: Twitch session {session:?}, expected {:?}", + case.expected_session() + )); + } + let state = h.ctrl.hold_state(); + if state != case.expected_state() { + failures.push(format!( + "{case:?}: hold {state:?}, expected {:?}", + case.expected_state() + )); + } + failures + } + + /// OBS asking for its config again during a hold gets the kept session + /// back, and that session's token is brokered again: it was accepted + /// when the session opened, possibly longer ago than its TTL, and OBS's + /// publish with it must still get past the ingest key. (The matrix below + /// pre-brokers every token, so it can't see this.) Outside a hold there + /// is nothing to hand back. + #[tokio::test] + async fn a_kept_session_is_handed_back_only_during_a_hold_and_rebrokers_its_token() { + let h = protected_harness(false); + h.ctrl.update_ingest_key("k".into()); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.on_tag(9, 0, &[0x17, 0, 0, 0, 0, 1], false, true); + let live = h.ctrl.destination_state("live"); + *live.eb_override_url.lock() = Some(KEPT_IVS.into()); + // Remembered without brokering its token: as if the token expired. + h.ctrl.remember_eb_session(EbSession { + config: "{config}".into(), + auths: vec![KEPT_TOKEN.into()], + dest_id: "live".into(), + ivs_url: KEPT_IVS.into(), + }); + assert_eq!(h.ctrl.held_eb_config(), None, "only during a hold"); + h.ctrl.mark_ingest_dead(); - let cache = h.ctrl.ring.video_seq_headers.lock(); - assert_eq!(cache.len(), 4, "all 4 tracks must persist across cut"); - for track in 0u8..=3 { - assert!(cache.contains_key(&track), "track {track} dropped"); + assert!(!h.ctrl.is_brokered_eb_key(KEPT_TOKEN)); + assert_eq!(h.ctrl.held_eb_config().as_deref(), Some("{config}")); + h.ctrl + .begin_publish(KEPT_TOKEN, "127.0.0.1") + .await + .expect("the token is let in again"); + assert_eq!(live.eb_override_url.lock().as_deref(), Some(KEPT_IVS)); + assert_eq!(h.ctrl.held_eb_config(), None, "the hold is over"); + } + + /// Every way a hold can play out with Enhanced Broadcasting in the mix. + /// Twitch refuses a stream on the wrong session (the multitrack ladder on + /// plain ingest, or one track on a multitrack session), so the + /// destination must end on exactly the session OBS publishes on. + #[tokio::test] + async fn every_hold_path_leaves_twitch_on_the_session_obs_publishes_on() { + let mut failures = Vec::new(); + for case in all_hold_cases() { + failures.extend(run_hold_case(&case).await); } + assert!( + failures.is_empty(), + "{} hold paths went wrong:\n{}", + failures.len(), + failures.join("\n") + ); + } + + /// OBS reconnects after a crash but never sends video: once the grace + /// runs out that counts as a freeze, so the screen gets a deadline. + #[tokio::test] + async fn a_returning_obs_that_sends_no_video_is_a_freeze() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.on_tag(9, 0, &[0x17, 0, 0, 0, 0, 1], false, true); + h.ctrl.mark_ingest_dead(); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + assert!(!h.ctrl.hold_active(), "resumed"); + let now = process_now_ms(); + let grace = FIRST_VIDEO_GRACE.as_millis() as u64; + h.ctrl.check_ingest_freeze_at(now + grace - 1_000); + assert!(!h.ctrl.hold_active(), "still within the grace"); + h.ctrl.check_ingest_freeze_at(now + grace + 1_000); + assert!(h.ctrl.hold_active(), "no video after the grace is a freeze"); + } + + #[tokio::test] + async fn protection_off_keeps_no_per_frame_state() { + let h = harness(0); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + let hevc_keyframe = [0x91, b'h', b'v', b'c', b'1', 0, 0, 0, 7]; + h.ctrl.on_tag(9, 0, &hevc_keyframe, true, false); + assert_eq!(h.ctrl.last_video_tag_ms.load(Ordering::Relaxed), 0); + assert!(h.ctrl.held_keyframe(0).is_none()); + + // Switched off mid-stream: nothing stale can later read as a freeze. + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.on_tag(9, 0, &hevc_keyframe, true, false); + assert!(h.ctrl.held_keyframe(0).is_some()); + h.ctrl + .update_crash_protection(crate::crash_protection::CrashProtection::default()); + assert_eq!(h.ctrl.last_video_tag_ms.load(Ordering::Relaxed), 0); + assert!(h.ctrl.held_keyframe(0).is_none()); + assert!(h.ctrl.ingest_sending()); + } + + #[tokio::test] + async fn an_hevc_stream_is_covered_by_holding_its_last_keyframe() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + let live = h.ctrl.destination_state("live"); + // Enhanced RTMP HEVC: SequenceStart, then a coded keyframe. + h.ctrl + .on_tag(9, 0, &[0x90, b'h', b'v', b'c', b'1', 1], false, true); + assert!( + !crate::crash_hold::covers(&h.ctrl, &live), + "nothing to hold yet" + ); + let keyframe = [0x91, b'h', b'v', b'c', b'1', 0, 0, 0, 7]; + h.ctrl.on_tag(9, 33, &keyframe, true, false); + assert!(crate::crash_hold::covers(&h.ctrl, &live)); + assert_eq!(h.ctrl.held_keyframe(0).as_deref(), Some(&keyframe[..])); + } + + #[tokio::test] + async fn an_enhanced_broadcasting_destination_is_covered_track_by_track() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + let live = h.ctrl.destination_state("live"); + live.pass_through_multitrack_video + .store(true, Ordering::Relaxed); + // Track 0 legacy H.264, track 1 an H.264 rung, track 2 an HEVC rung. + let one_track = |fourcc: &[u8; 4], track: u8, first: u8, packet: u8| { + let mut tag = vec![first, packet]; + tag.extend_from_slice(fourcc); + tag.extend_from_slice(&[track, 0, 0, 0, 7]); + tag + }; + h.ctrl.on_tag(9, 0, &[0x17, 0, 0, 0, 0, 1], false, true); + h.ctrl + .on_tag(9, 0, &one_track(b"avc1", 1, 0x96, 0x00), false, true); + assert!(crate::crash_hold::covers(&h.ctrl, &live)); + h.ctrl + .on_tag(9, 0, &one_track(b"hvc1", 2, 0x96, 0x00), false, true); + assert!( + !crate::crash_hold::covers(&h.ctrl, &live), + "every track needs a picture" + ); + h.ctrl + .on_tag(9, 33, &one_track(b"hvc1", 2, 0x96, 0x01), false, false); + assert!(crate::crash_hold::covers(&h.ctrl, &live)); + assert!(h.ctrl.held_keyframe(2).is_some()); + assert!( + h.ctrl.held_keyframe(1).is_none(), + "H.264 rungs get the screen" + ); } /// `mark_ingest_dead` must clear every destination's @@ -4472,6 +6106,44 @@ mod tests { /// Twitch Inspector. The latch is released by the fetch task on /// completion (modelled here by the explicit store), after which the /// next tick may claim again (e.g. to retry a failed fetch). + /// A hold keeps a destination's Twitch session only when it keeps that + /// destination on air: live, and coverable by the screen. One that is + /// down, or a vertical one whose canvas isn't known yet (the screen + /// can't cover it), ends with OBS, so its session goes with it, and a + /// session fetch still in flight for it is discarded. + #[tokio::test] + async fn a_hold_keeps_the_session_only_of_destinations_it_keeps_on_air() { + let h = protected_harness(false); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl.on_tag(9, 0, &[0x17, 0, 0, 0, 0, 1], false, true); + let live = h.ctrl.destination_state("live"); + let down = h.ctrl.destination_state("down"); + let vertical = h.ctrl.destination_state("vertical"); + vertical.egress_alive.store(true, Ordering::Relaxed); + vertical.egress_vertical.store(true, Ordering::Relaxed); + assert!(crate::crash_hold::covers(&h.ctrl, &live)); + assert!( + !crate::crash_hold::covers(&h.ctrl, &vertical), + "no canvas to cover yet" + ); + for dest in [&live, &down, &vertical] { + *dest.eb_override_url.lock() = Some(format!("rtmps://ivs/app/{}", dest.id)); + } + let epochs = [&down, &vertical].map(|dest| dest.session_epoch()); + + h.ctrl.mark_ingest_dead(); + assert!(h.ctrl.hold_active()); + assert_eq!( + live.eb_override_url.lock().as_deref(), + Some("rtmps://ivs/app/live"), + "kept on air, so its session is kept" + ); + for (dest, epoch) in [&down, &vertical].into_iter().zip(epochs) { + assert!(dest.eb_override_url.lock().is_none(), "{} ended", dest.id); + assert!(dest.session_epoch() > epoch, "{}: fetch discarded", dest.id); + } + } + #[test] fn try_claim_vod_fetch_admits_one_claimant() { let s = DestinationState::new("main".into()); @@ -4738,7 +6410,7 @@ mod tests { /// A fresh publisher session must clear out all cached audio and multi-track /// video sequence headers left over from a previous stream. While those /// headers are required to survive mid-stream egress supervisor restarts - /// (tested via `eb_seq_headers_survive_egress_restart`), allowing them to + /// (tested via `every_track_config_leads_each_twitch_connection`), allowing them to /// leak into a subsequent session causes severe pipeline pollution. If a /// publisher reconnects without Enhanced Broadcasting, a failure to clear /// this state causes the egress engine to inject stale multi-track headers @@ -5010,31 +6682,32 @@ mod tests { } /// A reconnect may bring a completely different OBS profile, so the - /// previous session's measurement must not leak into the new one. + /// previous session's measurements must not leak into the new one. #[test] fn reset_codec_state_clears_the_measurement() { let h = harness(0); feed_idrs(&h.ctrl, 0, 4_000, 4); - assert_eq!(h.ctrl.keyframe_interval_ms(), 4_000); + let header = crate::slate::test_sequence_header(64, 36); + h.ctrl.on_tag(9, 12_000, &header, false, true); + let before = h.ctrl.stream_params(); + assert_eq!(before.keyframe_interval_ms, 4_000); + assert_eq!((before.width, before.height), (64, 36)); h.ctrl.reset_codec_state(); - assert_eq!(h.ctrl.keyframe_interval_ms(), 0); - assert_eq!(h.ctrl.stream_params().width, 0); - assert_eq!(h.ctrl.stream_params().height, 0); + let after = h.ctrl.stream_params(); + assert_eq!(after.keyframe_interval_ms, 0); + assert_eq!((after.width, after.height), (0, 0)); } - /// The packed dims atomic must round-trip a real resolution and never - /// bleed width into height or vice-versa. + /// OBS's sequence header decodes to the resolution the compatibility + /// check sees, width and height each in their own place. #[test] - fn stream_params_reports_packed_dimensions() { + fn stream_params_reports_the_resolution_obs_sends() { let h = harness(0); - // 0x11 = AVC keyframe; sps_dimensions decodes 1920x1080 from a real - // SPS, but the unit here only needs the packing path exercised via a - // known resolution, so drive it through the public snapshot instead. - h.ctrl - .video_dims - .store(((1920u64) << 32) | 1080, Ordering::Relaxed); + assert_eq!(h.ctrl.stream_params().width, 0, "nothing sent yet"); + let header = crate::slate::test_sequence_header(64, 36); + h.ctrl.on_tag(9, 0, &header, false, true); let p = h.ctrl.stream_params(); - assert_eq!((p.width, p.height), (1920, 1080)); + assert_eq!((p.width, p.height), (64, 36)); } /// Dead band stays at the tuned 1500 ms for a 2 s GOP (and while @@ -5055,6 +6728,25 @@ mod tests { } } + /// Going delayed from live has to jump back even when the delay is no + /// longer than the re-cut dead band. Live sits a whole delay short of + /// it, which the dead band (hysteresis for a delay already in place) + /// used to read as close enough: 1 s against its 1.5 s floor, so the + /// dashboard said the delay was on while viewers stayed on live. + #[test] + fn a_one_second_delay_jumps_back_from_live() { + let h = harness(0); + h.ctrl.arm_delay(1_000); + feed_seconds(&h.ctrl, 0, 3, 30); + h.ctrl.activate_delay().expect("the buffer holds the delay"); + let latest = h.ctrl.ring.latest_seq().expect("a populated ring"); + let (_, live_edge) = h.ctrl.ring.find_by_seq(latest).unwrap(); + assert!( + compute_delay_cut(&h.ctrl, &live_edge).is_some(), + "live is a whole second short of the delay" + ); + } + #[test] fn idr_search_tolerance_never_below_floor_and_scales_up() { assert_eq!(idr_search_tolerance_ms(0), 2_000); @@ -5066,4 +6758,64 @@ mod tests { "6 s GOP needs 3 s reach" ); } + + /// OBS hung without its connection closing (the OS keeps ACKing), and + /// the streamer restarts it: the new publisher takes the slot once the + /// old one has sent no video for the freeze threshold, instead of being + /// refused until the old socket dies. The old connection closing later + /// must not end the new session. + #[tokio::test] + async fn a_new_publisher_takes_over_from_one_that_stopped_sending_video() { + let h = harness(0); + let first = h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl + .on_tag(9, 0, &[0x17, 1, 0, 0, 0, 0, 0, 0, 1, 0x65], true, false); + assert!( + h.ctrl.begin_publish("k", "127.0.0.1").await.is_err(), + "a publisher that is sending keeps the slot" + ); + + // The process clock starts at 0: let it pass the freeze threshold, + // so "the last video frame" can sit that far back. + let freeze_ms = crate::crash_hold::FREEZE_AFTER.as_millis() as u64; + while process_now_ms() <= freeze_ms + 10 { + tokio::time::sleep(Duration::from_millis(100)).await; + } + h.ctrl.last_video_tag_ms.store(1, Ordering::Relaxed); + let second = h + .ctrl + .begin_publish("k", "127.0.0.1") + .await + .expect("a publisher that stopped sending video hands over"); + assert_ne!(first, second); + + h.ctrl.end_publish(first); + assert!( + h.ctrl.ingest_alive(), + "the old connection closing is ignored" + ); + h.ctrl.end_publish(second); + assert!(!h.ctrl.ingest_alive(), "the current one closing ends it"); + } + + /// A peer retrying a refused publish in a loop logs one line per 10 s, + /// not one per attempt, so the bounded log keeps everything else. + #[tokio::test] + async fn refused_publishes_are_logged_at_most_once_per_ten_seconds() { + let h = harness(0); + h.ctrl.begin_publish("k", "127.0.0.1").await.unwrap(); + h.ctrl + .on_tag(9, 0, &[0x17, 1, 0, 0, 0, 0, 0, 0, 1, 0x65], true, false); + for _ in 0..50 { + let _ = h.ctrl.begin_publish("k", "127.0.0.1").await; + } + let refusals = h + .ctrl + .logs + .lock() + .iter() + .filter(|line| line.contains("slot in use")) + .count(); + assert_eq!(refusals, 1); + } } diff --git a/src/controller/delay_sim.rs b/src/controller/delay_sim.rs new file mode 100644 index 0000000..83a6e85 --- /dev/null +++ b/src/controller/delay_sim.rs @@ -0,0 +1,843 @@ +//! Delay simulations (see `sim` for the harness): the normal lifecycle of a +//! delay, played in real time through a real pump. OBS streams at 10 fps +//! with a keyframe every 500 ms; the streamer arms a delay, goes delayed, +//! cuts back to live, schedules "cut after this airs", and changes the +//! delay on air. The assertions are about what viewers would see: where the +//! picture jumps, whether it lands on a keyframe, how far behind OBS it +//! runs, that nothing airs more often than the delay itself explains, and +//! that timestamps never go back. + +use super::sim::{assert_strictly_increasing, wait, Frame, Sim, FRAME_MS, GOP_FRAMES}; +use super::*; +use std::collections::HashSet; + +/// The delay most scenarios arm. +const DELAY_MS: u32 = 2_000; +const GOP_MS: u32 = FRAME_MS * GOP_FRAMES; +/// Room for scheduling: the pump and OBS each sleep to a schedule, and the +/// test binary runs many of these at once. +const JITTER_MS: u32 = 150; +/// Each frame's audio is stamped this far behind its video, so a cut lands +/// on a keyframe whose own audio is older than it and has to be dropped. +const AUDIO_LAG_MS: u32 = 30; + +async fn delay_sim() -> Sim { + let mut sim = Sim::new(false).await; + sim.audio_lag_ms = AUDIO_LAG_MS; + sim +} + +/// How long after OBS made it `frame` reached the platform. +fn delay_ms(sim: &Sim, frame: &Frame) -> u32 { + frame + .at + .duration_since(sim.produced[&frame.number]) + .as_millis() as u32 +} + +/// Every frame in `frames` ran between `min_ms` and `max_ms` behind OBS. +fn assert_delays(sim: &Sim, frames: &[Frame], min_ms: u32, max_ms: u32, what: &str) { + let delays: Vec<(u32, u32)> = frames + .iter() + .map(|f| (f.number, delay_ms(sim, f))) + .collect(); + let off = delays + .iter() + .find(|(_, ms)| !(min_ms..=max_ms).contains(ms)); + assert_eq!( + off, None, + "{what}: a frame ran outside {min_ms}..={max_ms} ms behind OBS: {delays:?}" + ); +} + +/// Sleeps can wake a few ms early on a loaded test runner. +const TIMER_SLOP_MS: u32 = 50; + +/// A delay is honored: every frame in `frames` ran at least `delay_ms` +/// behind OBS (never closer to live than asked) and at most one keyframe +/// interval more, which is how far back the newest keyframe that old sits. +fn assert_delay_honored(sim: &Sim, frames: &[Frame], delay_ms: u32, gop_ms: u32, what: &str) { + let most = delay_ms + gop_ms + JITTER_MS; + assert_delays(sim, frames, delay_ms - TIMER_SLOP_MS, most, what); +} + +/// Where the picture jumps back to an earlier frame. +fn backward_jumps(frames: &[Frame]) -> Vec { + (1..frames.len()) + .filter(|&i| frames[i].number < frames[i - 1].number) + .collect() +} + +/// Where the picture skips ahead past frames it never showed. +fn forward_jumps(frames: &[Frame]) -> Vec { + (1..frames.len()) + .filter(|&i| frames[i].number > frames[i - 1].number + 1) + .collect() +} + +/// A jump back replays what the delay holds once; nothing airs a third time. +fn assert_airs_at_most_twice(frames: &[Frame]) { + let mut seen: HashMap = HashMap::new(); + for frame in frames { + *seen.entry(frame.number).or_default() += 1; + } + let mut thrice: Vec = seen + .into_iter() + .filter(|(_, n)| *n > 2) + .map(|(number, _)| number) + .collect(); + thrice.sort_unstable(); + assert!(thrice.is_empty(), "frames aired three times: {thrice:?}"); +} + +/// The pump re-sent the video and audio sequence headers between +/// `frames[i - 1]` and `frames[i]`, on `frames[i]`'s own timestamp, so the +/// decoder has its config before the frame it jumped to. +fn assert_headers_lead(sim: &mut Sim, conn: usize, frames: &[Frame], i: usize) { + let (from, to, ts) = (frames[i - 1].index, frames[i].index, frames[i].ts); + let between = &sim.received()[from + 1..to]; + let is_header = |kind: u8| { + between + .iter() + .any(|m| m.conn == conn && m.kind == kind && m.payload.get(1) == Some(&0) && m.ts == ts) + }; + assert!( + is_header(9), + "no video sequence header led frame {}", + frames[i].number + ); + assert!( + is_header(8), + "no audio sequence header led frame {}", + frames[i].number + ); +} + +/// Audio kept up with the video: every frame that aired had its audio air +/// too, except the one audio frame each join or cut drops because it is +/// stamped just before the keyframe it lands on. `joins` counts the pump's +/// seed plus its cuts. +fn assert_audio_kept_up(sim: &mut Sim, conn: usize, joins: usize) { + let video = sim.frames(conn); + let Some(last) = video.last().map(|f| f.number) else { + return; + }; + let mut owed: HashMap = HashMap::new(); + for frame in &video { + *owed.entry(frame.number).or_default() += 1; + } + for number in sim.audio_numbers(conn) { + *owed.entry(number).or_default() -= 1; + } + // The newest frame's audio may still be on its way. + owed.remove(&last); + let mut missing: Vec = owed + .iter() + .filter(|(_, n)| **n > 0) + .map(|(number, _)| *number) + .collect(); + missing.sort_unstable(); + assert!( + missing.len() <= joins, + "audio went missing for frames {missing:?} ({joins} joins and cuts)" + ); +} + +fn cuts(sim: &Sim, id: &str) -> u32 { + sim.ctrl + .destination_state(id) + .cuts_performed + .load(Ordering::Relaxed) +} + +/// Live (no delay): a destination joining mid-stream starts on OBS's newest +/// keyframe, then every frame goes out once, in order, as soon as it can. +#[tokio::test] +async fn live_frames_go_out_once_in_order_as_obs_makes_them() { + let mut sim = delay_sim().await; + sim.obs_connects().await; + sim.obs_sends(700).await; + let newest_keyframe = (sim.next_frame - 1) / GOP_FRAMES * GOP_FRAMES; + sim.destination_connects("platform").await; + sim.obs_sends(2_000).await; + + let frames = sim.frames(0); + assert!(frames.len() >= 18, "{} frames aired", frames.len()); + assert_eq!( + frames[0].number, newest_keyframe, + "joins on the newest keyframe" + ); + assert!(frames[0].keyframe); + assert_strictly_increasing(&frames, "live"); + assert_delays(&sim, &frames, 0, GOP_MS + JITTER_MS, "live"); + assert_eq!(cuts(&sim, "platform"), 0); + sim.assert_timestamps_monotonic(0); + assert_audio_kept_up(&mut sim, 0, 1); +} + +/// Going delayed: one jump back to the keyframe nearest the delay, led by +/// the sequence headers on its own timestamp, then the stream runs that far +/// behind OBS without ever jumping again. +#[tokio::test] +async fn activating_jumps_back_once_to_a_keyframe_the_delay_ago() { + let mut sim = delay_sim().await; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(2_100).await; + let activated = Instant::now(); + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(3_400).await; + + let frames = sim.frames(0); + let back = backward_jumps(&frames); + assert_eq!(back.len(), 1, "exactly one jump back"); + let i = back[0]; + let landed = frames[i]; + assert!(landed.keyframe, "lands on a keyframe"); + let late = landed.at.duration_since(activated).as_millis() as u32; + assert!( + late <= FRAME_MS + JITTER_MS, + "jumped {late} ms after activating" + ); + assert_headers_lead(&mut sim, 0, &frames, i); + let delayed = &frames[i..]; + assert_strictly_increasing(delayed, "delayed"); + let span = delayed.last().unwrap().at.duration_since(landed.at); + assert!( + span >= Duration::from_millis(2_500), + "only {span:?} delayed" + ); + assert_delay_honored(&sim, delayed, DELAY_MS, GOP_MS, "delayed"); + assert_eq!(cuts(&sim, "platform"), 1, "no re-cut once on the delay"); + assert_airs_at_most_twice(&frames); + sim.assert_timestamps_monotonic(0); + assert_audio_kept_up(&mut sim, 0, 2); +} + +/// Cutting back to live: one jump forward to the newest keyframe, with +/// nothing that already aired shown again, and the delay gone. +#[tokio::test] +async fn cutting_to_live_jumps_forward_to_the_newest_keyframe() { + let mut sim = delay_sim().await; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(2_100).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(2_000).await; + let cut_at = Instant::now(); + sim.ctrl.stop_delay(); + sim.obs_sends(1_500).await; + + let frames = sim.frames(0); + let ahead = forward_jumps(&frames); + assert_eq!(ahead.len(), 1, "exactly one jump forward"); + let i = ahead[0]; + let landed = frames[i]; + assert!(landed.at > cut_at, "the jump is the cut"); + let late = landed.at.duration_since(cut_at).as_millis() as u32; + assert!(late <= FRAME_MS + JITTER_MS, "cut {late} ms after asking"); + assert!(landed.keyframe, "lands on a keyframe"); + let aired_before = frames[..i].iter().map(|f| f.number).max().unwrap(); + assert!(landed.number > aired_before, "nothing is replayed"); + assert_headers_lead(&mut sim, 0, &frames, i); + assert_strictly_increasing(&frames[i..], "live again"); + assert_delays(&sim, &frames[i..], 0, GOP_MS + JITTER_MS, "live again"); + assert_eq!(cuts(&sim, "platform"), 2, "activate, then cut"); + sim.assert_timestamps_monotonic(0); + assert_audio_kept_up(&mut sim, 0, 3); +} + +/// "Cut after this airs": everything up to the moment the streamer marked +/// airs first, delayed, and only then does the stream jump to live. +#[tokio::test] +async fn cut_after_this_airs_waits_for_the_marked_frame_then_goes_live() { + let mut sim = delay_sim().await; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(2_100).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(600).await; + let marked = sim.next_frame - 1; + sim.ctrl.schedule_safe_cut().expect("a delay is on air"); + sim.obs_sends(3_000).await; + + let frames = sim.frames(0); + let back = backward_jumps(&frames); + assert_eq!(back.len(), 1, "the activation is the only jump back"); + let ahead = forward_jumps(&frames); + assert_eq!(ahead.len(), 1, "exactly one jump forward"); + let (from, to) = (back[0], ahead[0]); + let delayed = &frames[from..to]; + assert!( + delayed.windows(2).all(|p| p[1].number == p[0].number + 1), + "every delayed frame aired, in order" + ); + let aired = delayed + .iter() + .find(|f| f.number == marked) + .expect("the marked frame aired before the cut"); + let waited = frames[to].at.duration_since(aired.at).as_millis() as u32; + // The pump looks at the mark every 500 ms, on its next frame. + assert!( + waited <= 500 + FRAME_MS + JITTER_MS, + "cut {waited} ms after the mark aired" + ); + assert!(frames[to].keyframe, "lands on a keyframe"); + assert_delays(&sim, &frames[to..], 0, GOP_MS + JITTER_MS, "live again"); + assert!(!sim.ctrl.safe_cut_pending()); + assert_eq!(sim.ctrl.target_delay_ms(), 0); + assert_eq!(sim.ctrl.armed_delay_ms(), DELAY_MS, "armed for next time"); + assert_eq!(cuts(&sim, "platform"), 2); + sim.assert_timestamps_monotonic(0); + assert_audio_kept_up(&mut sim, 0, 3); +} + +/// Raising the delay on air past what the buffer holds: the pump holds its +/// place while the buffer grows, then jumps back once and plays on. It +/// used to jump as soon as the buffer spanned the new delay less the re-cut +/// dead band, landing short enough that the next check jumped back to the +/// same keyframe and viewers saw the same stretch twice in a row. Now it +/// waits for a keyframe at least the new delay old. +/// +/// Raised here to the most the buffer can reach at a moment its oldest +/// keyframe sits 400 ms in, which is where the old build-up cut short. +#[tokio::test] +async fn raising_the_delay_on_air_jumps_back_exactly_once() { + let mut sim = delay_sim().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(1_700).await; + let ring = &sim.ctrl.ring; + let oldest = ring.oldest_ts().unwrap(); + let oldest_keyframe = ring.oldest_idr_at_or_after(ring.front_seq().unwrap()); + let gap = oldest_keyframe.unwrap().ts_ms - oldest; + assert!(gap >= 300, "the oldest keyframe sits only {gap} ms in"); + let dead_band = recut_dead_band_ms(sim.ctrl.keyframe_interval_ms()) as u32; + let raised = (ring.latest_ts().unwrap() - oldest) as u32 + dead_band; + let raised_at = Instant::now(); + sim.ctrl.arm_delay(raised); + sim.obs_sends(3_000).await; + + assert_eq!(cuts(&sim, "platform"), 2, "activate, then one jump back"); + let after: Vec = sim + .frames(0) + .into_iter() + .filter(|f| f.at > raised_at) + .collect(); + let mut seen = HashSet::new(); + let again = after.iter().find(|f| !seen.insert(f.number)); + assert!( + again.is_none(), + "{again:?} aired twice after raising the delay" + ); + let reached = delay_ms(&sim, after.last().unwrap()); + assert!( + reached + TIMER_SLOP_MS >= raised, + "ran {reached} ms behind, asked for {raised}" + ); + sim.assert_timestamps_monotonic(0); +} + +/// Lowering the delay on air: one jump forward to the new delay, with +/// nothing that already aired shown again. +#[tokio::test] +async fn lowering_the_delay_on_air_jumps_forward_once() { + const LOWERED_MS: u32 = 1_000; + let mut sim = delay_sim().await; + sim.ctrl.arm_delay(3_000); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(3_100).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(900).await; + let lowered_at = Instant::now(); + sim.ctrl.arm_delay(LOWERED_MS); + sim.obs_sends(1_500).await; + + let frames = sim.frames(0); + let back = backward_jumps(&frames); + assert_eq!(back.len(), 1, "the activation is the only jump back"); + let ahead = forward_jumps(&frames); + assert_eq!(ahead.len(), 1, "exactly one jump forward"); + let i = ahead[0]; + assert!(frames[i].at > lowered_at, "the jump is the change"); + assert!(frames[i].keyframe, "lands on a keyframe"); + // What aired live before going delayed airs again by nature; nothing + // from the delayed stretch may. + let aired_delayed = frames[back[0]..i].iter().map(|f| f.number).max().unwrap(); + assert!(frames[i].number > aired_delayed, "nothing is replayed"); + assert_airs_at_most_twice(&frames); + assert_strictly_increasing(&frames[i..], "after lowering"); + assert_delay_honored(&sim, &frames[i..], LOWERED_MS, GOP_MS, "after lowering"); + assert_eq!(cuts(&sim, "platform"), 2, "activate, then lower"); + sim.assert_timestamps_monotonic(0); + assert_audio_kept_up(&mut sim, 0, 3); +} + +/// A keyframe every 6 s, in frames. +const GOP_6S: u32 = 60; + +/// A destination is switched on 2.6 s into a 2 s delay, with a keyframe +/// every 6 s: the only keyframe in the stream so far is OBS's first one. +async fn destination_added_mid_delay_on_a_long_gop(sim: &mut Sim) { + sim.gop_frames = GOP_6S; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("first").await; + sim.obs_sends(1_700).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(2_600).await; + sim.destination_connects("added").await; + sim.obs_sends(2_400).await; +} + +/// The added destination joins on a keyframe and plays on from there, +/// without the jump back a live start followed by a cut to the delay would +/// show, at least the delay behind and within a keyframe interval of it. +#[tokio::test] +async fn a_destination_added_mid_delay_joins_without_a_jump_on_a_long_gop() { + let mut sim = delay_sim().await; + destination_added_mid_delay_on_a_long_gop(&mut sim).await; + + let frames = sim.frames(1); + let first = frames.first().expect("the added destination is on air"); + assert!(first.keyframe, "joins on a keyframe"); + assert_delay_honored(&sim, &frames, DELAY_MS, GOP_6S * FRAME_MS, "added"); + assert_strictly_increasing(&frames, "the added destination"); + assert_eq!(cuts(&sim, "added"), 0, "no jump after joining"); + sim.assert_timestamps_monotonic(1); +} + +/// The keyframe nearest the delay when the destination is added is OBS's +/// first one, 4.3 s back, but the buffer keeps only the armed delay plus +/// 2 s, so it was trimmed as soon as the first destination played past it. +/// With no keyframe left to join on, the seed waits for OBS's next one and +/// starts there, at the live edge: the added destination's viewers watch +/// live while the other destination and the dashboard are on the delay. +#[tokio::test] +async fn a_destination_added_mid_delay_on_a_long_gop_never_airs_live() { + let mut sim = delay_sim().await; + destination_added_mid_delay_on_a_long_gop(&mut sim).await; + + let frames = sim.frames(1); + let first = frames.first().expect("the added destination is on air"); + let joined = delay_ms(&sim, first); + assert!( + joined + TIMER_SLOP_MS >= DELAY_MS, + "joined {joined} ms behind OBS with a {DELAY_MS} ms delay on" + ); +} + +/// Going delayed with a 4 s keyframe interval: a 2 s delay can only land +/// on a keyframe about 0 s or 4 s back, and it takes the one at least the +/// delay old, then stays put instead of chasing the exact delay. +/// +/// The re-cut dead band (2.5 s here) used to apply to going delayed too: +/// live sat 2 s from the 2 s delay, inside the band, so the pump never cut +/// and viewers kept watching live while the dashboard said delayed. +#[tokio::test] +async fn activating_with_a_long_gop_never_lands_short_of_the_delay() { + const GOP_4S: u32 = 40; + let mut sim = delay_sim().await; + sim.gop_frames = GOP_4S; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(4_500).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(1_500).await; + + let frames = sim.frames(0); + let back = backward_jumps(&frames); + assert_eq!(back.len(), 1, "exactly one jump back"); + let i = back[0]; + assert!(frames[i].keyframe, "lands on a keyframe"); + assert_delay_honored(&sim, &frames[i..], DELAY_MS, GOP_4S * FRAME_MS, "delayed"); + assert_strictly_increasing(&frames[i..], "delayed"); + assert_eq!(cuts(&sim, "platform"), 1, "no re-cut once on the delay"); + sim.assert_timestamps_monotonic(0); +} + +/// OBS's 32-bit millisecond clock rolls over every 49.7 days. Going +/// delayed across the rollover (live is past it, the delay before it) and +/// cutting back across it again must behave exactly as anywhere else. +#[tokio::test] +async fn going_delayed_and_back_across_the_timestamp_wrap() { + let mut sim = delay_sim().await; + sim.ctrl.arm_delay(DELAY_MS); + // The clock rolls over 1.5 s in. + sim.obs_connects_at(0u32.wrapping_sub(1_500)).await; + sim.destination_connects("platform").await; + sim.obs_sends(2_100).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(1_000).await; + let cut_at = Instant::now(); + sim.ctrl.stop_delay(); + sim.obs_sends(1_500).await; + + let frames = sim.frames(0); + let back = backward_jumps(&frames); + assert_eq!(back.len(), 1, "exactly one jump back"); + let ahead = forward_jumps(&frames); + assert_eq!(ahead.len(), 1, "exactly one jump forward"); + let (from, to) = (back[0], ahead[0]); + assert!(frames[from].keyframe && frames[to].keyframe); + assert!(frames[to].at > cut_at); + assert_delay_honored(&sim, &frames[from..to], DELAY_MS, GOP_MS, "delayed"); + let aired_before = frames[..to].iter().map(|f| f.number).max().unwrap(); + assert!(frames[to].number > aired_before, "nothing is replayed"); + assert_delays(&sim, &frames[to..], 0, GOP_MS + JITTER_MS, "live again"); + assert_eq!(cuts(&sim, "platform"), 2); + sim.assert_timestamps_monotonic(0); + assert_audio_kept_up(&mut sim, 0, 3); +} + +/// OBS drops and reconnects inside the half second the pump waits for its +/// next frame, with crash protection off: the pump never sees it gone, +/// only a new publisher. It carries on on the same connection, from a +/// keyframe of the new session, with its timestamps still going forward. +async fn obs_reconnects_inside_the_pumps_wait(sim: &mut Sim) -> Instant { + sim.obs_sends(1_000).await; + wait(100).await; + sim.obs_crashes(); + wait(100).await; + sim.obs_connects().await; + let back = Instant::now(); + sim.obs_sends(1_500).await; + back +} + +fn assert_rejoined_the_new_session(sim: &mut Sim, back: Instant) { + assert!( + !sim.commands(0).contains(&"deleteStream".into()), + "the destination never ended" + ); + assert!(sim.received().iter().all(|m| m.conn == 0), "one connection"); + let frames = sim.frames(0); + let rejoined = frames + .iter() + .find(|f| f.at > back) + .expect("the stream came back"); + assert!(rejoined.keyframe, "rejoins at a keyframe"); + let paused = rejoined.at.duration_since(back).as_millis() as u32; + assert!( + paused <= GOP_MS + FRAME_MS + JITTER_MS, + "back {paused} ms after OBS" + ); + assert_strictly_increasing(&frames, "across the reconnect"); + sim.assert_timestamps_monotonic(0); +} + +/// A destination that joined mid-stream, on a keyframe after OBS's first. +#[tokio::test] +async fn a_fast_reconnect_rejoins_the_new_session_on_a_keyframe() { + let mut sim = delay_sim().await; + sim.obs_connects().await; + // The newest frame is a keyframe: the pump joins on it and runs level + // with OBS, parked on the next frame when OBS drops. + sim.obs_sends(600).await; + sim.destination_connects("platform").await; + let back = obs_reconnects_inside_the_pumps_wait(&mut sim).await; + assert_rejoined_the_new_session(&mut sim, back); +} + +/// The same, for a destination that joined on OBS's very first keyframe +/// (the usual case: it connects as OBS starts). Its timeline is anchored at +/// input ts 0, so the new session's first keyframe, also at ts 0, passes +/// the "older than the anchor" guard. It used to go out before the pump +/// noticed the new publisher, on output timestamp 0, back at the start of +/// the stream. +#[tokio::test] +async fn a_fast_reconnect_keeps_timestamps_forward_when_the_pump_joined_at_the_start() { + let mut sim = delay_sim().await; + sim.obs_connects().await; + sim.destination_connects("platform").await; + let back = obs_reconnects_inside_the_pumps_wait(&mut sim).await; + assert_rejoined_the_new_session(&mut sim, back); +} + +/// OBS's encoder restarts mid-session with a new resolution: new config, +/// then a keyframe. Returns the header and the first frame made with it. +async fn obs_changes_resolution_mid_stream(sim: &mut Sim) -> (Vec, u32) { + // Audio level with the video, the order OBS's interleaver sends in. With + // audio stamped behind, the audio config resent on the video's newer + // timestamp would run ahead of the audio still to go out. + sim.audio_lag_ms = 0; + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(1_000).await; + let header = sim.obs_changes_resolution(128, 72); + let first_new = sim.next_frame; + sim.obs_sends(1_000).await; + (header, first_new) +} + +/// Where connection 0 received `header`, and the last timestamp of media +/// it had received by then. +fn header_arrival(sim: &mut Sim, header: &[u8]) -> (usize, u32, u32) { + let received = sim.received(); + let at = received + .iter() + .position(|m| m.conn == 0 && m.kind == 9 && m.payload == header) + .expect("the new sequence header was sent"); + let last_media_ts = received[..at] + .iter() + .filter(|m| m.conn == 0 && (m.kind == 8 || m.kind == 9)) + .map(|m| m.ts) + .max() + .unwrap(); + (at, received[at].ts, last_media_ts) +} + +/// A new sequence header mid-stream is resent once, on the last output +/// timestamp (never behind what already went out), with the audio config +/// alongside, and the stream carries on. +#[tokio::test] +async fn a_new_sequence_header_is_resent_mid_stream_on_the_last_timestamp() { + let mut sim = delay_sim().await; + let (header, first_new) = obs_changes_resolution_mid_stream(&mut sim).await; + + let (at, ts, last_media_ts) = header_arrival(&mut sim, &header); + assert_eq!(ts, last_media_ts, "on the last output timestamp"); + let sent = sim + .received() + .iter() + .filter(|m| m.conn == 0 && m.payload == header) + .count(); + assert_eq!(sent, 1, "resent once"); + let audio_config = sim.received()[at..] + .iter() + .find(|m| m.conn == 0 && m.kind == 8 && m.payload.get(1) == Some(&0)); + assert_eq!( + audio_config.map(|m| m.ts), + Some(ts), + "audio config alongside" + ); + let frames = sim.frames(0); + let second_new = frames + .iter() + .find(|f| f.number == first_new + 1) + .expect("the stream carried on"); + assert!(at < second_new.index, "before the stream moved on"); + sim.assert_timestamps_monotonic(0); +} + +/// The header has to reach the decoder before the frame encoded with it. +/// The pump compares the header generation at the top of its loop, then +/// parks waiting for the next frame; the frame that wakes it used to go +/// out before the loop came round again, decoded with the old config. +#[tokio::test] +async fn a_new_sequence_header_goes_out_before_the_frame_that_needs_it() { + let mut sim = delay_sim().await; + let (header, first_new) = obs_changes_resolution_mid_stream(&mut sim).await; + + let (at, _, _) = header_arrival(&mut sim, &header); + let frames = sim.frames(0); + let needs_it = frames + .iter() + .find(|f| f.number == first_new) + .expect("the new keyframe aired"); + assert!( + at < needs_it.index, + "frame {first_new} went out before its sequence header" + ); +} + +/// OBS stops and starts again while a delay is on air, crash protection +/// off. The destination ends with OBS, and the supervisor reconnects it +/// once OBS is back. Returns the first frame OBS made after the restart. +async fn obs_restarts_mid_delay(sim: &mut Sim) -> u32 { + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + sim.destination_runs("platform"); + sim.obs_sends(1_700).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_sends(500).await; + sim.obs_stops(); + wait(700).await; + sim.obs_connects().await; + let first_new = sim.next_frame; + sim.obs_sends(2_500).await; + first_new +} + +#[tokio::test] +async fn obs_restarting_mid_delay_brings_the_destination_back_on_a_keyframe() { + let mut sim = delay_sim().await; + let first_new = obs_restarts_mid_delay(&mut sim).await; + + assert_eq!(sim.ctrl.target_delay_ms(), DELAY_MS, "the delay stays on"); + assert!(sim.commands(0).contains(&"deleteStream".into())); + let frames = sim.frames(1); + let first = frames.first().expect("the destination came back"); + assert!(first.keyframe, "starts on a keyframe"); + assert!(first.number >= first_new, "on the new session's video"); + sim.assert_timestamps_monotonic(1); +} + +/// The delay stays on through the restart, so viewers must come back +/// delayed: never on live video, and never jumping back once the buffer +/// has rebuilt. The restart empties the buffer, so the destination waits +/// for it to reach back to the delay before it goes on air again. +#[tokio::test] +async fn obs_restarting_mid_delay_never_puts_viewers_on_live() { + let mut sim = delay_sim().await; + obs_restarts_mid_delay(&mut sim).await; + + let frames = sim.frames(1); + let first = frames.first().expect("the destination came back"); + let joined = delay_ms(&sim, first); + assert!( + joined + TIMER_SLOP_MS >= DELAY_MS, + "came back {joined} ms behind OBS with a {DELAY_MS} ms delay on" + ); + assert_strictly_increasing(&frames, "after the restart"); +} + +/// Twitch's Enhanced Broadcasting binds each track's decoder config to its +/// session, so every connection has to lead with every track's config, +/// including the one made when the supervisor restarts egress while OBS +/// keeps publishing. A cache that kept only the last track (the shape that +/// shipped once) leaves the others with none, and Twitch drops the stream. +#[tokio::test] +async fn every_track_config_leads_each_twitch_connection() { + let mut sim = delay_sim().await; + sim.obs_connects().await; + for track in 1u8..=3 { + let mut rung = vec![0x96, 0x00, b'a', b'v', b'c', b'1', track]; + rung.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + sim.ctrl.on_tag(9, 0, &rung, false, true); + } + let twitch = sim.ctrl.destination_state("twitch"); + twitch + .pass_through_multitrack_video + .store(true, Ordering::Relaxed); + sim.destination_connects("twitch").await; + sim.obs_sends(600).await; + twitch.shutdown_requested.store(true, Ordering::Relaxed); + sim.obs_sends(300).await; + assert!(sim.pumps[0].is_finished(), "the first session ended"); + twitch.shutdown_requested.store(false, Ordering::Relaxed); + sim.destination_connects("twitch").await; + sim.obs_sends(600).await; + + for conn in [0, 1] { + let first = sim.frames(conn).first().expect("frames aired").index; + let configs: HashSet<&[u8]> = sim.received()[..first] + .iter() + .filter(|m| m.conn == conn && m.kind == 9) + .map(|m| m.payload.as_slice()) + .collect(); + assert_eq!(configs.len(), 4, "connection {conn} led with every track"); + } +} + +/// A delay longer than the buffer can hold at this bitrate (a hotkey, +/// MIDI or auto-arm skip the dashboard's capacity check, and the bitrate +/// can rise after arming) must not wait forever. Once the buffer is full +/// it counts as ready, the destination joins as far back as the buffer +/// safely reaches, and the stream runs there without stalling or +/// replaying. The log says the delay is capped. +#[tokio::test] +async fn a_delay_longer_than_the_buffer_runs_at_what_it_holds() { + // About 7.5 s of stream (24 bytes a frame at 10 fps). + let mut sim = Sim::with_ring_bytes(false, 1_800).await; + sim.ctrl.arm_delay(30_000); + sim.obs_connects().await; + sim.obs_sends(9_000).await; + assert_eq!( + sim.ctrl.phase(), + "ready", + "a full buffer holds all it ever will" + ); + sim.ctrl + .activate_delay() + .expect("activates at what the buffer holds"); + sim.destination_runs("platform"); + sim.obs_sends(6_000).await; + + let frames = sim.frames(0); + assert!( + frames.len() >= 30, + "the destination joined and kept streaming: {} frames", + frames.len() + ); + assert_strictly_increasing(&frames, "a delay capped by the buffer"); + let behind = delay_ms(&sim, frames.last().unwrap()); + assert!( + (2_500..=7_500).contains(&behind), + "delayed by about what the buffer holds, not {behind} ms" + ); + let gaps: Vec = frames + .windows(2) + .map(|w| w[1].at.duration_since(w[0].at).as_millis() as u32) + .collect(); + assert!( + gaps.iter().all(|gap| *gap < 1_000), + "no stall while capped: {gaps:?}" + ); + assert!( + sim.ctrl + .logs + .lock() + .iter() + .any(|line| line.contains("the buffer is full")), + "the log says the delay is capped" + ); + sim.assert_timestamps_monotonic(0); +} + +/// The keyframe interval is measured from the first few gaps only. When +/// the encoder's real interval turns out much longer (OBS's "auto" after a +/// restart, or early scene-cut keyframes), a landed delay can sit more than +/// the expected band above its target. Cutting to fix that must move +/// forward: the keyframe at the delay is the one the pump already passed, +/// and cutting back to it replayed the same stretch on every check. +#[tokio::test] +async fn a_longer_keyframe_interval_than_measured_never_replays() { + let mut sim = delay_sim().await; + sim.ctrl.arm_delay(DELAY_MS); + sim.obs_connects().await; + // The measurement: keyframes every 500 ms (frames 0..29). + sim.obs_sends(3_000).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + // Then the real interval: a keyframe every 4 s, at frames 40 and 80. + sim.gop_frames = 40; + // Join 1 s after keyframe 80: it isn't the delay old yet, so the join + // lands on keyframe 40, about 5 s back - past the expected band. + sim.obs_sends(6_000).await; + sim.destination_connects("platform").await; + sim.obs_sends(10_000).await; + + let frames = sim.frames(0); + let back = backward_jumps(&frames); + assert!(back.is_empty(), "the picture jumped back at {back:?}"); + assert_airs_at_most_twice(&frames); + sim.assert_timestamps_monotonic(0); +} diff --git a/src/controller/hold_sim.rs b/src/controller/hold_sim.rs new file mode 100644 index 0000000..669f8fe --- /dev/null +++ b/src/controller/hold_sim.rs @@ -0,0 +1,666 @@ +//! Crash-protection simulations (see `sim` for the harness). A real +//! destination pump streams to an in-process platform while the test plays +//! OBS in real time: live, crash, freeze, stop, back again. The assertions +//! are about what viewers would see: the tail airs once, the screen covers +//! the gap, nothing from before the hold is replayed, and timestamps never +//! go back. + +use super::sim::{assert_strictly_increasing, wait, Frame, Sim}; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +/// OBS crashes with no delay: the screen covers the gap on the same +/// connection, and OBS coming back rejoins at its first keyframe with +/// nothing replayed. +#[tokio::test] +async fn crash_screen_then_obs_back_on_the_same_connection() { + let mut sim = Sim::new(true).await; + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(1_500).await; + sim.obs_crashes(); + let crashed = Instant::now(); + wait(1_500).await; + sim.obs_connects().await; + let back = Instant::now(); + sim.obs_sends(1_500).await; + + let frames = sim.frames(0); + assert!( + frames.iter().any(|f| f.at < crashed), + "live before the crash" + ); + assert!( + sim.screen_tags(0, crashed, back) >= 5, + "the screen covered the gap" + ); + let after: Vec = frames.iter().copied().filter(|f| f.at > back).collect(); + assert!( + after.first().is_some_and(|f| f.keyframe), + "rejoins at a keyframe" + ); + assert_strictly_increasing(&frames, "crash and back"); + assert!( + !sim.commands(0).contains(&"deleteStream".into()), + "never ended" + ); + assert!(sim.received().iter().all(|m| m.conn == 0), "one connection"); + sim.assert_timestamps_monotonic(0); +} + +/// OBS crashes with a 1.5 s delay on air: the tail airs once (no loop), +/// then the screen; when OBS is back the screen stays up until the delay +/// has rebuilt, so the stream comes back still delayed. +#[tokio::test] +async fn a_crash_mid_delay_airs_the_tail_once_and_the_delay_survives_the_return() { + 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_live = sim.next_frame - 1; + sim.obs_crashes(); + let crashed = Instant::now(); + wait(3_000).await; + + let tail: Vec = sim + .frames(0) + .into_iter() + .filter(|f| f.at > crashed) + .collect(); + assert_strictly_increasing(&tail, "the tail"); + assert_eq!( + tail.last().map(|f| f.number), + Some(last_live), + "the whole tail aired" + ); + let tail_ms = tail.last().unwrap().at.duration_since(crashed).as_millis(); + assert!( + tail_ms >= 1_000, + "the tail took the delay to air, not {tail_ms} ms" + ); + let screen_from = tail.last().unwrap().at; + assert!( + sim.screen_tags(0, screen_from, Instant::now()) >= 5, + "then the screen" + ); + + sim.obs_connects().await; + let back = Instant::now(); + sim.obs_sends(3_000).await; + let after: Vec = sim.frames(0).into_iter().filter(|f| f.at > back).collect(); + let rejoined = after.first().expect("the stream came back"); + assert!(rejoined.keyframe, "rejoins at a keyframe"); + let delayed_by = rejoined.at.duration_since(sim.produced[&rejoined.number]); + assert!( + delayed_by >= Duration::from_millis(1_000), + "still delayed after OBS came back, not live ({delayed_by:?})" + ); + assert_strictly_increasing(&after, "after the return"); + sim.assert_timestamps_monotonic(0); +} + +/// OBS comes back from a crash while the delay tail is still airing. The +/// rest of the tail went with the old session; the stream must not jump to +/// live: the screen covers the gap until the delay has rebuilt. +#[tokio::test] +async fn obs_back_mid_tail_never_puts_viewers_on_live() { + 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(2_000).await; + sim.obs_crashes(); + wait(1_000).await; + sim.obs_connects().await; + let back = Instant::now(); + sim.obs_sends(5_000).await; + + let first_new = sim + .frames(0) + .into_iter() + .find(|f| f.at > back && sim.produced[&f.number] > back) + .expect("the stream came back"); + assert!(first_new.keyframe, "rejoins at a keyframe"); + let delayed_by = first_new.at.duration_since(sim.produced[&first_new.number]); + assert!( + delayed_by >= Duration::from_millis(2_000), + "OBS's new video must air delayed, not live ({delayed_by:?})" + ); + sim.assert_timestamps_monotonic(0); +} + +/// End now from the tray, a hotkey or MIDI (threads with no runtime): the +/// destination ends with deleteStream, and nothing panics. +#[tokio::test] +async fn end_now_from_a_plain_thread_ends_the_destination_cleanly() { + let mut sim = Sim::new(true).await; + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(1_000).await; + sim.obs_crashes(); + wait(800).await; + let ctrl = sim.ctrl.clone(); + let hotkey = std::thread::spawn(move || ctrl.run_named_action("end_hold", 0, "hotkey")); + assert_eq!(hotkey.join().expect("no panic off the runtime"), None); + wait(800).await; + assert!(sim.commands(0).contains(&"deleteStream".into())); + assert!(sim.pumps[0].is_finished(), "the pump ended"); +} + +/// OBS freezes (connected, no video): the screen covers it, a destination +/// that (re)connects mid-freeze goes straight to the screen instead of +/// replaying old video, and both rejoin at OBS's first keyframe after it. +#[tokio::test] +async fn a_freeze_is_covered_and_a_reconnecting_destination_goes_straight_to_the_screen() { + let mut sim = Sim::new(true).await; + sim.obs_connects().await; + sim.destination_connects("first").await; + sim.obs_sends(1_000).await; + let last_before = sim.next_frame - 1; + sim.obs_stalls(300).await; + sim.freeze_detected(); + assert!(sim.ctrl.hold_active()); + sim.obs_stalls(800).await; + sim.destination_connects("second").await; + sim.obs_stalls(800).await; + let back = Instant::now(); + sim.obs_sends(1_500).await; + + let second = sim.frames(1); + assert!(second.iter().all(|f| f.at > back), "no old video replayed"); + assert!(sim.screen_tags(1, back - Duration::from_millis(700), back) >= 3); + for conn in [0, 1] { + let after: Vec = sim + .frames(conn) + .into_iter() + .filter(|f| f.at > back) + .collect(); + let first = after.first().expect("the stream came back"); + assert!(first.keyframe, "conn {conn} rejoins at a keyframe"); + assert!( + first.number > last_before, + "conn {conn} rejoins on new video" + ); + sim.assert_timestamps_monotonic(conn); + } + assert_strictly_increasing(&sim.frames(0), "the first destination"); +} + +/// Stopping in OBS while the screen covers a freeze ends the stream. +#[tokio::test] +async fn stopping_during_a_freeze_ends_the_destination() { + let mut sim = Sim::new(true).await; + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(1_000).await; + sim.obs_stalls(300).await; + sim.freeze_detected(); + sim.obs_stalls(500).await; + sim.obs_stops(); + wait(800).await; + assert!(sim.commands(0).contains(&"deleteStream".into())); +} + +/// Crash protection off: OBS dropping ends the destination at once, as +/// before crash protection existed. +#[tokio::test] +async fn protection_off_ends_the_destination_when_obs_drops() { + let mut sim = Sim::new(false).await; + sim.obs_connects().await; + sim.destination_connects("platform").await; + sim.obs_sends(1_000).await; + sim.obs_crashes(); + let crashed = Instant::now(); + wait(800).await; + assert!(sim.commands(0).contains(&"deleteStream".into())); + assert_eq!(sim.screen_tags(0, crashed, Instant::now()), 0, "no screen"); +} + +/// OBS comes back with a delay armed, so the screen stays up while the +/// delay rebuilds from its new video, then OBS stops on purpose before it +/// has. There is no hold left to end, so the screen has to notice OBS is +/// gone and end the stream at once, not wait out the rebuild grace. +#[tokio::test] +async fn obs_stopping_while_the_delay_rebuilds_ends_the_destination() { + const DELAY_MS: u32 = 1_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(1_000).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + sim.obs_crashes(); + wait(1_500).await; + sim.obs_connects().await; + let back = Instant::now(); + sim.obs_sends(300).await; + assert_eq!(sim.ctrl.hold_state(), crate::crash_hold::HoldState::Resumed); + sim.obs_stops(); + wait(600).await; + + let back_on_air = sim.frames(0).into_iter().filter(|f| f.at > back).count(); + assert_eq!(back_on_air, 0, "OBS's short new video never aired"); + assert!( + sim.commands(0).contains(&"deleteStream".into()), + "the stream ended when OBS stopped" + ); + assert!(sim.pumps[0].is_finished(), "the pump ended"); + sim.assert_timestamps_monotonic(0); +} + +/// Crash protection with a short hold, for the scenarios where it runs out. +fn one_second_hold(sim: &Sim) { + sim.ctrl + .update_crash_protection(crate::crash_protection::CrashProtection { + enabled: true, + hold_secs: 1, + ..Default::default() + }); +} + +/// OBS crashes with a delay longer than the hold time. The hold runs out +/// while the tail is still airing, and the tail airs in full before the +/// stream ends: viewers see everything OBS sent before the crash, then a +/// clean end. It used to end at the deadline, dropping the rest. +#[tokio::test] +async fn a_hold_that_runs_out_mid_tail_airs_the_tail_in_full_then_ends() { + const DELAY_MS: u32 = 3_000; + let mut sim = Sim::new(true).await; + one_second_hold(&sim); + 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(2_000).await; + let last_live = sim.next_frame - 1; + sim.obs_crashes(); + let crashed = Instant::now(); + wait(1_500).await; + // What the supervisor does every tick. + sim.ctrl.expire_hold(); + wait(3_000).await; + + let tail: Vec = sim + .frames(0) + .into_iter() + .filter(|f| f.at > crashed) + .collect(); + assert_eq!( + tail.last().map(|f| f.number), + Some(last_live), + "the whole tail aired" + ); + assert_strictly_increasing(&tail, "the tail"); + assert!( + sim.commands(0).contains(&"deleteStream".into()), + "then it ended" + ); + assert!(sim.pumps[0].is_finished(), "the pump ended"); + sim.assert_timestamps_monotonic(0); +} + +/// OBS freezes with a delay on, and stays frozen past the hold. The +/// destination ends; it used to fall back into the normal delay logic, +/// which saw too little delay and cut back into the tail again and again. +#[tokio::test] +async fn a_freeze_hold_that_runs_out_never_replays_the_tail() { + const DELAY_MS: u32 = 1_500; + let mut sim = Sim::new(true).await; + one_second_hold(&sim); + 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; + sim.obs_stalls(300).await; + let froze = Instant::now(); + sim.freeze_detected(); + assert!(sim.ctrl.hold_active(), "the freeze is held"); + sim.obs_stalls(1_500).await; + sim.ctrl.expire_hold(); + sim.obs_stalls(3_000).await; + + // From the last frame before the freeze on (activating the delay + // replayed its own stretch once, as it should). + let frames = sim.frames(0); + let from = frames.iter().rposition(|f| f.at < froze).unwrap_or(0); + assert_strictly_increasing(&frames[from..], "a frozen OBS past the hold"); + assert!( + sim.commands(0).contains(&"deleteStream".into()), + "the destination ended" + ); + sim.assert_timestamps_monotonic(0); +} + +/// End now while the delay tail is airing ends the stream at once: the +/// streamer asked for it, so the rest of the tail doesn't air first. +#[tokio::test] +async fn end_now_mid_tail_ends_the_stream_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(2_000).await; + let last_live = sim.next_frame - 1; + sim.obs_crashes(); + wait(500).await; + assert!(sim.ctrl.end_hold_now(), "a hold was on"); + wait(1_000).await; + + assert!(sim.commands(0).contains(&"deleteStream".into()), "it ended"); + assert!( + sim.frames(0).last().is_some_and(|f| f.number < last_live), + "without airing the rest of the tail" + ); +} + +/// 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 +/// keyframe at least that old, not the nearest one. +#[tokio::test] +async fn a_freeze_rejoin_lands_at_least_the_delay_back() { + const DELAY_MS: u32 = 1_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(2_000).await; + sim.ctrl + .activate_delay() + .expect("the buffer holds the delay"); + // Frames 20..41: the freeze starts two frames into a GOP. + sim.obs_sends(2_200).await; + sim.obs_stalls(300).await; + sim.freeze_detected(); + sim.obs_stalls(1_500).await; + let back = Instant::now(); + sim.obs_sends(4_000).await; + + let rejoined = sim + .frames(0) + .into_iter() + .find(|f| f.at > back && sim.produced[&f.number] > back) + .expect("the stream came back"); + assert!(rejoined.keyframe, "rejoins at a keyframe"); + let delayed_by = rejoined + .at + .duration_since(sim.produced[&rejoined.number]) + .as_millis() as u32; + assert!( + delayed_by + 50 >= DELAY_MS, + "rejoined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" + ); + 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"); +} + +/// 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" + ); +} + +/// OBS freezes for longer than the delay and is back while the hold is still +/// up: the whole tail aired and the screen took over. A destination that +/// reconnects then must not seed on the last keyframe before the freeze +/// (it would replay it, then sit silent across the freeze's gap). +#[tokio::test] +async fn a_destination_after_a_freeze_longer_than_the_delay_never_replays() { + 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(); + sim.obs_stalls(2_500).await; + assert!(sim.ctrl.hold_active(), "the hold is still up"); + sim.obs_sends(300).await; + sim.destination_connects("second").await; + sim.obs_sends(3_000).await; + + let frames = sim.frames(1); + assert!(!frames.is_empty(), "the new destination went live"); + assert!( + frames.iter().all(|f| f.number > last_before_freeze), + "nothing from before the freeze airs again" + ); + assert_strictly_increasing(&frames, "after a long 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. +#[tokio::test] +async fn a_pump_that_ends_releases_its_place_in_the_buffer() { + let mut sim = Sim::new(false).await; + sim.obs_connects().await; + sim.destination_runs("platform"); + sim.obs_sends(1_500).await; + let dest = sim.ctrl.destination_state("platform"); + assert_ne!( + dest.consumer_seq.load(Ordering::Relaxed), + u64::MAX, + "reading" + ); + sim.obs_crashes(); + wait(1_000).await; + assert_eq!( + dest.consumer_seq.load(Ordering::Relaxed), + u64::MAX, + "the ended pump released its place" + ); +} diff --git a/src/controller/sim.rs b/src/controller/sim.rs new file mode 100644 index 0000000..421e5ae --- /dev/null +++ b/src/controller/sim.rs @@ -0,0 +1,324 @@ +//! The harness the pump simulations share. A real destination pump streams +//! to an in-process platform (`sink::record`) that keeps everything it +//! receives, while the test plays OBS by feeding tags to the controller in +//! real time. Every video frame and its audio carry a number that never +//! repeats across OBS sessions, so what viewers saw can be read back: a +//! replay is a number seen twice, and the delay is when a frame arrived +//! minus when OBS made it. + +use super::*; +use crate::rtmp::client::{EgressClient, EgressUrl}; +use crate::sink::{record, Received}; +use std::path::PathBuf; +use tokio::sync::mpsc; + +/// OBS's frame interval (10 fps) and default keyframe cadence (every 500 ms). +pub(super) const FRAME_MS: u32 = 100; +pub(super) const GOP_FRAMES: u32 = 5; +/// The stream's resolution: small, so the screen encodes in no time. +const WIDTH: usize = 64; +const HEIGHT: usize = 36; + +pub(super) struct Sim { + pub(super) ctrl: Arc, + ring_path: PathBuf, + port: u16, + rx: mpsc::UnboundedReceiver, + got: Vec, + /// Frames get a number that never repeats, across OBS sessions, so a + /// replay shows up as a number seen twice. + pub(super) next_frame: u32, + pub(super) produced: HashMap, + /// This OBS session's wire clock. + wire_ts: u32, + /// When the wire clock read `clock_wire_ts`. Frames go out on that + /// schedule rather than one sleep after another, so the wire clock + /// never drifts from the wall clock the pump paces by. + clock_start: Instant, + clock_wire_ts: u32, + /// A keyframe every this many frames. + pub(super) gop_frames: u32, + /// How far each frame's audio is stamped behind its video. A real + /// muxer's audio trails the video it arrives after by a few ms. + pub(super) audio_lag_ms: u32, + pub(super) pumps: Vec>>, +} + +impl Sim { + pub(super) async fn new(protected: bool) -> Sim { + Sim::with_ring_bytes(protected, 16 * 1024 * 1024).await + } + + /// A simulation whose buffer holds only `ring_bytes`. A frame and its + /// audio take 24 bytes, so at 10 fps a buffer of 240 bytes a second + /// of stream fills up. + pub(super) async fn with_ring_bytes(protected: bool, ring_bytes: u64) -> Sim { + static UNIQ: AtomicU32 = AtomicU32::new(0); + let n = UNIQ.fetch_add(1, Ordering::Relaxed); + let ring_path = + std::env::temp_dir().join(format!("ic-pump-sim-{}-{n}.buf", std::process::id())); + let _ = std::fs::remove_file(&ring_path); + let ring = Arc::new(DiskRing::create(&ring_path, ring_bytes).unwrap()); + let ctrl = Arc::new(Controller::new(ring, 0)); + ctrl.update_crash_protection(crate::crash_protection::CrashProtection { + enabled: protected, + ..Default::default() + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = mpsc::unbounded_channel(); + tokio::spawn(record(listener, tx)); + Sim { + ctrl, + ring_path, + port, + rx, + got: Vec::new(), + next_frame: 0, + produced: HashMap::new(), + wire_ts: 0, + clock_start: Instant::now(), + clock_wire_ts: 0, + gop_frames: GOP_FRAMES, + audio_lag_ms: 0, + pumps: Vec::new(), + } + } + + /// OBS starts publishing: a new session, its clock from 0, the + /// sequence headers, and a keyframe first. + pub(super) async fn obs_connects(&mut self) { + self.obs_connects_at(0).await; + } + + /// OBS starts publishing with its wire clock at `wire_ts`. + pub(super) async fn obs_connects_at(&mut self, wire_ts: u32) { + self.ctrl.begin_publish("key", "127.0.0.1").await.unwrap(); + self.wire_ts = wire_ts; + self.clock_start = Instant::now(); + self.clock_wire_ts = wire_ts; + let video = crate::slate::test_sequence_header(WIDTH, HEIGHT); + self.ctrl.on_tag(9, wire_ts, &video, false, true); + // AAC-LC, 48 kHz, stereo. + self.ctrl + .on_tag(8, wire_ts, &[0xAF, 0, 0x11, 0x90], false, true); + self.next_frame = self.next_frame.div_ceil(self.gop_frames) * self.gop_frames; + } + + /// OBS sends `ms` of video and audio in real time. + pub(super) async fn obs_sends(&mut self, ms: u32) { + for _ in 0..ms / FRAME_MS { + let frame = self.next_frame; + let keyframe = frame.is_multiple_of(self.gop_frames); + self.ctrl.on_tag( + 9, + self.wire_ts, + &frame_tag(frame, keyframe), + keyframe, + false, + ); + let audio_ts = self.wire_ts.wrapping_sub(self.audio_lag_ms); + self.ctrl + .on_tag(8, audio_ts, &audio_tag(frame), false, false); + self.produced.insert(frame, Instant::now()); + self.next_frame += 1; + self.advance_clock(FRAME_MS).await; + } + } + + /// OBS is connected but sends nothing for `ms` (its clock runs on). + pub(super) async fn obs_stalls(&mut self, ms: u32) { + self.advance_clock(ms).await; + } + + async fn advance_clock(&mut self, ms: u32) { + self.wire_ts = self.wire_ts.wrapping_add(ms); + let elapsed = self.wire_ts.wrapping_sub(self.clock_wire_ts); + let due = self.clock_start + Duration::from_millis(u64::from(elapsed)); + tokio::time::sleep_until(tokio::time::Instant::from_std(due)).await; + } + + /// The encoder restarts mid-session with a new resolution: a new video + /// sequence header, and the next frame is a keyframe. + pub(super) fn obs_changes_resolution(&mut self, width: usize, height: usize) -> Vec { + let header = crate::slate::test_sequence_header(width, height); + self.ctrl.on_tag(9, self.wire_ts, &header, false, true); + self.next_frame = self.next_frame.div_ceil(self.gop_frames) * self.gop_frames; + header + } + + pub(super) fn obs_crashes(&self) { + self.ctrl.mark_ingest_dead(); + } + + pub(super) fn obs_stops(&self) { + self.ctrl.note_unpublish(); + self.ctrl.mark_ingest_dead(); + } + + /// What the supervisor does once OBS has sent no video for a while. + pub(super) fn freeze_detected(&self) { + let frozen_at = process_now_ms() + crate::crash_hold::FREEZE_AFTER.as_millis() as u64; + self.ctrl.check_ingest_freeze_at(frozen_at); + } + + fn platform_url(&self) -> String { + format!("rtmp://127.0.0.1:{}/live/key", self.port) + } + + /// A destination connects to the platform and its pump starts. + pub(super) async fn destination_connects(&mut self, id: &str) { + let dest = self.ctrl.destination_state(id); + dest.egress_alive.store(true, Ordering::Relaxed); + let url = EgressUrl::parse(&self.platform_url()).unwrap(); + let sink = EgressClient::connect(&url) + .await + .unwrap() + .spawn_reader_drain(); + let ctrl = self.ctrl.clone(); + self.pumps.push(tokio::spawn( + async move { pump_dest(&ctrl, &dest, sink).await }, + )); + } + + /// A destination the supervisor runs: it connects once OBS is sending, + /// and reconnects (with its backoff) after every session ends. + pub(super) fn destination_runs(&mut self, id: &str) { + let dest = self.ctrl.destination_state(id); + let ctrl = self.ctrl.clone(); + let url = self.platform_url(); + let label = id.to_string(); + self.pumps.push(tokio::spawn(async move { + run_egress(ctrl, label, url, dest).await + })); + } + + /// Everything the platform has received so far. + pub(super) fn received(&mut self) -> &[Received] { + while let Ok(message) = self.rx.try_recv() { + self.got.push(message); + } + &self.got + } + + /// OBS's frames as connection `conn` received them. + pub(super) fn frames(&mut self, conn: usize) -> Vec { + self.received() + .iter() + .enumerate() + .filter(|(_, m)| m.conn == conn && m.kind == 9) + .filter_map(|(index, m)| { + let number = frame_number(&m.payload)?; + Some(Frame { + number, + keyframe: m.payload[0] == 0x17, + ts: m.ts, + at: m.at, + index, + }) + }) + .collect() + } + + /// The numbers of the frames whose audio connection `conn` received. + pub(super) fn audio_numbers(&mut self, conn: usize) -> Vec { + self.received() + .iter() + .filter(|m| m.conn == conn && m.kind == 8) + .filter_map(|m| audio_number(&m.payload)) + .collect() + } + + /// Reconnect-screen video tags connection `conn` received in `window`. + pub(super) fn screen_tags(&mut self, conn: usize, from: Instant, to: Instant) -> usize { + self.received() + .iter() + .filter(|m| m.conn == conn && m.kind == 9 && (from..to).contains(&m.at)) + .filter(|m| m.payload.get(1) == Some(&1) && frame_number(&m.payload).is_none()) + .count() + } + + pub(super) fn commands(&mut self, conn: usize) -> Vec { + self.received() + .iter() + .filter(|m| m.conn == conn) + .filter_map(|m| m.command.clone()) + .collect() + } + + /// Video and audio timestamps each never go backwards. + pub(super) fn assert_timestamps_monotonic(&mut self, conn: usize) { + for kind in [8, 9] { + let stamps: Vec = self + .received() + .iter() + .filter(|m| m.conn == conn && m.kind == kind) + .map(|m| m.ts) + .collect(); + let back = stamps.windows(2).position(|pair| pair[1] < pair[0]); + assert_eq!( + back, None, + "type {kind} timestamps went backwards: {stamps:?}" + ); + } + } +} + +impl Drop for Sim { + fn drop(&mut self) { + for pump in &self.pumps { + pump.abort(); + } + let _ = std::fs::remove_file(&self.ring_path); + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct Frame { + pub(super) number: u32, + pub(super) keyframe: bool, + /// Output timestamp on the wire. + pub(super) ts: u32, + pub(super) at: Instant, + /// Where it sits in everything the platform received. + pub(super) index: usize, +} + +/// A legacy AVC video tag carrying frame `number` in its NAL unit. +fn frame_tag(number: u32, keyframe: bool) -> Vec { + let (flags, nal_type) = if keyframe { (0x17, 0x65) } else { (0x27, 0x41) }; + let mut tag = vec![flags, 1, 0, 0, 0, 0, 0, 0, 7, nal_type, b'I', b'C']; + tag.extend_from_slice(&number.to_be_bytes()); + tag +} + +pub(super) fn frame_number(payload: &[u8]) -> Option { + if payload.len() != 16 || payload[1] != 1 || &payload[10..12] != b"IC" { + return None; + } + Some(u32::from_be_bytes(payload[12..16].try_into().ok()?)) +} + +/// A legacy AAC tag carrying the number of the frame it belongs to. +fn audio_tag(number: u32) -> Vec { + let mut tag = vec![0xAF, 1, b'I', b'C']; + tag.extend_from_slice(&number.to_be_bytes()); + tag +} + +fn audio_number(payload: &[u8]) -> Option { + if payload.len() != 8 || payload[1] != 1 || &payload[2..4] != b"IC" { + return None; + } + Some(u32::from_be_bytes(payload[4..8].try_into().ok()?)) +} + +pub(super) async fn wait(ms: u32) { + tokio::time::sleep(Duration::from_millis(u64::from(ms))).await; +} + +pub(super) fn assert_strictly_increasing(frames: &[Frame], what: &str) { + let numbers: Vec = frames.iter().map(|f| f.number).collect(); + let replay = numbers.windows(2).position(|pair| pair[1] <= pair[0]); + assert_eq!(replay, None, "{what}: a frame was replayed: {numbers:?}"); +} 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 new file mode 100644 index 0000000..346b13b --- /dev/null +++ b/src/crash_hold.rs @@ -0,0 +1,935 @@ +//! Crash protection at runtime: the hold window after OBS drops, and the +//! reconnect screen played into each destination while it lasts. +//! +//! When OBS disconnects without saying goodbye (FCUnpublish / +//! deleteStream), or stops sending video for `FREEZE_AFTER` while still +//! connected, and crash protection is on, the controller opens a hold. +//! Each destination's pump first plays out any buffered delay, then keeps +//! every video track it receives alive until OBS publishes again (the +//! pump's reconnect path takes over from there), the hold time runs out, +//! or the streamer ends it early: +//! +//! - H.264 tracks loop the reconnect screen at the track's own resolution, +//! framed exactly like the stream's own tags (legacy, Enhanced RTMP, or +//! an Enhanced Broadcasting multitrack tag with its track id). +//! - Other tracks (HEVC, AV1) hold their last keyframe, re-sent once a +//! second, because the screen's encoder only speaks H.264. +//! - Every AAC track gets digital silence in its own format. +//! +//! Timestamps continue from the last real frame, so the platform sees one +//! unbroken stream. + +use crate::controller::{Controller, DestinationState}; +use crate::crash_protection::CrashProtection; +use crate::h264::{AudioEgress, VideoEgress}; +use crate::rtmp::client::EgressSink; +use crate::slate::{SlateLoop, StreamShape}; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::io; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Frame rate of the reconnect screen. It is a still-ish loop, so 30 is +/// plenty and halves the encode work of 60. +pub const SLATE_FPS: u32 = 30; + +/// No video for this long from a connected OBS counts as a freeze. +pub const FREEZE_AFTER: Duration = Duration::from_secs(3); + +/// A held keyframe is re-sent once a second (every this many screen +/// frames): a still picture needs no more, and keyframes are large. +const HELD_FRAME_EVERY: u64 = SLATE_FPS as u64; + +/// OBS is back but its video hasn't rebuilt the armed delay yet: stop +/// waiting this long past the delay itself (a buffer too small to hold +/// it, or a delay raised meanwhile) and rejoin where the video is. +const REBUILD_GRACE: Duration = Duration::from_secs(5); + +/// Used when a destination's resolution can't be read from its SPS. +const FALLBACK_SHAPE: (usize, usize) = (1280, 720); + +// FLV and Enhanced RTMP tag header fields. +const FLV_VIDEO_AVC: u8 = 7; +const FLV_AUDIO_AAC: u8 = 10; +const FLV_AUDIO_EX: u8 = 9; +const AVC_NALU: u8 = 1; +const AAC_RAW: u8 = 1; +const EX_VIDEO: u8 = 0x80; +const EX_SEQUENCE_START: u8 = 0; +const EX_CODED_FRAMES: u8 = 1; +const EX_VIDEO_MULTITRACK: u8 = 6; +const EX_AUDIO_MULTITRACK: u8 = 5; +const ONE_TRACK: u8 = 0; +const FRAME_KEY: u8 = 1; +const FRAME_INTER: u8 = 2; +const FOURCC_AVC1: [u8; 4] = *b"avc1"; +const FOURCC_MP4A: [u8; 4] = *b"mp4a"; + +/// Raw AAC-LC frames that decode to digital silence (verified with +/// ffmpeg: no decoder errors, every sample 0). +const SILENT_AAC_STEREO: &[u8] = &[0x21, 0x00, 0x49, 0x90, 0x02, 0x19, 0x00, 0x23, 0x80]; +const SILENT_AAC_MONO: &[u8] = &[0x01, 0x40, 0x20, 0x07]; +const AAC_OBJECT_LC: u8 = 2; +const AAC_FRAME_SAMPLES: f64 = 1024.0; +/// ISO 14496-3 sampling frequency index table. +const AAC_SAMPLE_RATES: [u32; 13] = [ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, +]; + +/// A hold on air, as the dashboard, dock and tray show it. +#[derive(Debug, Clone, Copy)] +pub struct HoldStatus { + pub reason: HoldReason, + pub remaining: Duration, + pub total: Duration, +} + +/// An open hold: why, when it started, and when it gives up. +#[derive(Debug, Clone, Copy)] +pub struct Hold { + pub reason: HoldReason, + pub started: Instant, + pub deadline: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoldReason { + /// OBS's connection dropped without a goodbye. + 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoldState { + /// Destinations are on the reconnect screen. + Holding, + /// The last hold closed because OBS is sending again. + Resumed, + /// The last hold closed without OBS: it ran out or was ended. + Ended, +} + +/// Why playback of the reconnect screen stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoldExit { + /// OBS is sending again and its video since then covers the armed + /// delay (at once without one): the pump rejoins at a keyframe at or + /// after `rejoin_from_seq`, never on video from before the hold. + Resumed { rejoin_from_seq: u64 }, + /// The hold ended (time ran out, the streamer ended it, or the + /// destination can't be covered): end the platform session cleanly. + Ended, +} + +pub struct HoldOutcome { + pub exit: HoldExit, + /// Last output timestamp sent, so the pump's timeline stays monotonic. + pub last_ts: u32, +} + +/// How a destination already receives a track, so the hold's tags are +/// framed exactly like the stream's own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Framing { + /// Legacy FLV: `0x17` / `0x27` AVC video, `0xAF`-style AAC audio. + Legacy, + /// Enhanced RTMP, one track: header byte, then the FourCC. + Enhanced, + /// Enhanced RTMP multitrack, OneTrack layout (Enhanced Broadcasting): + /// header byte, layout byte, FourCC, then this track id. + OneTrack(u8), +} + +/// What one destination gets during a hold: a picture for every video +/// track it receives, and silence for every AAC track. +struct HoldPlan { + video: Vec, + audio: Vec, +} + +enum VideoSource { + /// The reconnect screen, encoded at the track's own resolution. + Screen { + shape: StreamShape, + framing: Framing, + }, + /// The track's last keyframe as the destination receives it. + HeldFrame(Arc<[u8]>), +} + +/// A `VideoSource` ready to play, as the tags the destination gets. +enum VideoTrack { + /// The loop wrapped once in the track's framing, not once per frame. + Screen { + shape: StreamShape, + /// The two IDR variants, then the P frames, in loop order. + keyframes: [Vec; 2], + deltas: Vec>, + }, + HeldFrame(Arc<[u8]>), +} + +impl VideoTrack { + fn screen(slate: &SlateLoop, framing: Framing) -> Self { + VideoTrack::Screen { + shape: slate.shape, + keyframes: [0, 1].map(|replay| video_tag(framing, true, &slate.keyframes[replay])), + deltas: slate + .deltas + .iter() + .map(|sample| video_tag(framing, false, sample)) + .collect(), + } + } +} + +/// Silent AAC matching one of the stream's own AudioSpecificConfigs, so +/// the destination's decoder needs no reconfiguration. +struct SilentAudio { + framing: Framing, + /// First byte of the stream's own legacy audio tags (format, rate, + /// size, channels), reused so the silent tags look like the real ones. + /// Unused for Enhanced RTMP framings. + legacy_flags: u8, + frame: &'static [u8], + frame_ms: f64, +} + +/// Whether this destination can be kept live on the reconnect screen. +/// When it can't, it ends like it did before crash protection. +pub fn covers(ctrl: &Controller, dest: &DestinationState) -> bool { + plan_for(ctrl, dest).is_ok() +} + +/// Keep a reconnect loop encoded for each of `dest`'s video tracks. +pub fn prebuild_for(ctrl: &Controller, dest: &DestinationState, settings: &CrashProtection) { + let Some(egress) = dest.video_egress() else { + return; + }; + for (_, header) in video_headers_for(ctrl, egress) { + if avc_framing(&header).is_some() { + ctrl.slate_cache.prebuild(settings, screen_shape(&header)); + } + } +} + +/// Whether the hold plays this video tag's track by holding its last +/// keyframe: an Enhanced RTMP keyframe that isn't H.264 in a framing the +/// reconnect screen matches (HEVC, AV1, bundled multitrack layouts). The +/// controller keeps the latest one per track. +pub fn is_held_keyframe(tag: &[u8]) -> bool { + let Some(&first) = tag.first() else { + return false; + }; + if first & EX_VIDEO == 0 || !crate::h264::classify_video_tag(tag).is_idr { + return false; + } + let fourcc = match first & 0x0F { + EX_VIDEO_MULTITRACK if tag.get(1).map(|layout| layout >> 4) == Some(ONE_TRACK) => { + tag.get(2..6) + } + // A bundle of several tracks per tag: only the whole tag can be held. + EX_VIDEO_MULTITRACK => None, + _ => tag.get(1..5), + }; + fourcc != Some(&FOURCC_AVC1[..]) +} + +/// The picture and silence for each track of `dest`, or why it can't be +/// covered. +fn plan_for(ctrl: &Controller, dest: &DestinationState) -> Result { + let Some(egress) = dest.video_egress() else { + return Err("its vertical canvas isn't known".into()); + }; + let headers = video_headers_for(ctrl, egress); + if headers.is_empty() { + return Err("no video has arrived yet".into()); + } + let video = headers + .iter() + .map(|(track, header)| video_source(ctrl, egress, *track, header)) + .collect::, _>>()?; + Ok(HoldPlan { + video, + audio: silent_audio_for(ctrl, dest.audio_egress()), + }) +} + +/// The video sequence headers `egress` sends, keyed by ingest track id, +/// as the destination receives them (the same pick as the pump's +/// `send_sequence_headers`). +fn video_headers_for(ctrl: &Controller, egress: VideoEgress) -> Vec<(u8, Vec)> { + let headers = ctrl.ring.video_seq_headers.lock(); + let target = match egress { + VideoEgress::Passthrough => { + return headers.iter().map(|(t, h)| (*t, h.clone())).collect(); + } + VideoEgress::Track(target) => target, + }; + let pick = headers + .get_key_value(&target) + .or_else(|| (target == 0).then(|| headers.iter().next()).flatten()); + pick.map(|(track, header)| { + let sent = crate::h264::select_video_bytes(header, egress) + .map(Cow::into_owned) + .unwrap_or_else(|| header.clone()); + (*track, sent) + }) + .into_iter() + .collect() +} + +/// The reconnect screen for an H.264 track, or its last keyframe for +/// anything else. +fn video_source( + ctrl: &Controller, + egress: VideoEgress, + track: u8, + header: &[u8], +) -> Result { + if let Some(framing) = avc_framing(header) { + let shape = screen_shape(header); + return Ok(VideoSource::Screen { shape, framing }); + } + let codec = crate::h264::seq_header_codec(header).label(); + let Some(keyframe) = ctrl.held_keyframe(track) else { + return Err(format!( + "its {codec} video hasn't sent a keyframe to hold yet" + )); + }; + let sent: Arc<[u8]> = match crate::h264::select_video_bytes(&keyframe, egress) { + Some(Cow::Borrowed(_)) => keyframe.clone(), + Some(Cow::Owned(flattened)) => flattened.into(), + None => return Err(format!("its {codec} keyframe can't be sent here")), + }; + Ok(VideoSource::HeldFrame(sent)) +} + +/// The screen's shape for an H.264 track: its resolution, rounded down +/// to even sides, at the screen's frame rate. +fn screen_shape(header: &[u8]) -> StreamShape { + let (width, height) = crate::h264::sps_dimensions(header) + .map(|(w, h)| (w as usize & !1, h as usize & !1)) + .unwrap_or(FALLBACK_SHAPE); + StreamShape { + width, + height, + fps: SLATE_FPS, + } +} + +/// The framing of an H.264 sequence header the reconnect screen can +/// match, or `None` for other codecs and bundled multitrack layouts. +fn avc_framing(header: &[u8]) -> Option { + let &first = header.first()?; + if first & EX_VIDEO == 0 { + let is_avc_config = first & 0x0F == FLV_VIDEO_AVC && header.get(1) == Some(&0); + return is_avc_config.then_some(Framing::Legacy); + } + let (framing, fourcc, _) = enhanced_sequence_start(header, EX_VIDEO_MULTITRACK)?; + (fourcc == FOURCC_AVC1).then_some(framing) +} + +/// Parse an Enhanced RTMP sequence-start tag (video or audio): its +/// framing, FourCC and codec config. `multitrack` is the packet type that +/// marks a multitrack tag for this media kind. Only the single-track and +/// OneTrack layouts carry one track's config. +fn enhanced_sequence_start(tag: &[u8], multitrack: u8) -> Option<(Framing, [u8; 4], &[u8])> { + let packet_type = tag.first()? & 0x0F; + if packet_type == EX_SEQUENCE_START { + let fourcc = tag.get(1..5)?.try_into().ok()?; + return Some((Framing::Enhanced, fourcc, tag.get(5..)?)); + } + let layout = *tag.get(1)?; + if packet_type != multitrack || layout >> 4 != ONE_TRACK || layout & 0x0F != EX_SEQUENCE_START { + return None; + } + let fourcc = tag.get(2..6)?.try_into().ok()?; + Some((Framing::OneTrack(*tag.get(6)?), fourcc, tag.get(7..)?)) +} + +/// Silence for each AAC track this destination already receives. Other +/// audio (Opus, HE-AAC, surround) gets none; video still plays. +fn silent_audio_for(ctrl: &Controller, egress: AudioEgress) -> Vec { + let headers = ctrl.ring.audio_seq_headers.lock(); + let target = match egress { + AudioEgress::Passthrough => { + return headers + .values() + .filter_map(|h| silent_audio_from_header(h)) + .collect(); + } + AudioEgress::Track(target) => target, + }; + let Some(header) = headers + .get(&target) + .or_else(|| headers.get(&0)) + .or_else(|| headers.values().next()) + else { + return Vec::new(); + }; + crate::h264::select_audio_bytes(header, egress, ctrl.audio_target_on_wire(egress)) + .and_then(|tag| silent_audio_from_header(&tag)) + .into_iter() + .collect() +} + +/// Parse an AAC sequence-header tag (legacy `[flags, 0x00, ASC...]`, or +/// Enhanced RTMP `mp4a`) into matching silence. +fn silent_audio_from_header(header: &[u8]) -> Option { + let &first = header.first()?; + let (framing, legacy_flags, asc) = match first >> 4 { + FLV_AUDIO_AAC if header.get(1) == Some(&0) => (Framing::Legacy, first, header.get(2..)?), + FLV_AUDIO_EX => { + let (framing, fourcc, asc) = enhanced_sequence_start(header, EX_AUDIO_MULTITRACK)?; + if fourcc != FOURCC_MP4A { + return None; + } + (framing, 0, asc) + } + _ => return None, + }; + let asc = asc.get(..2)?; + let object_type = asc[0] >> 3; + let rate_index = ((asc[0] & 0x07) << 1) | (asc[1] >> 7); + let channels = (asc[1] >> 3) & 0x0F; + if object_type != AAC_OBJECT_LC { + return None; + } + let sample_rate = *AAC_SAMPLE_RATES.get(rate_index as usize)?; + let frame = match channels { + 1 => SILENT_AAC_MONO, + 2 => SILENT_AAC_STEREO, + _ => return None, + }; + Some(SilentAudio { + framing, + legacy_flags, + frame, + frame_ms: AAC_FRAME_SAMPLES * 1000.0 / sample_rate as f64, + }) +} + +/// Play the reconnect screen into `dest` until the hold ends or OBS comes +/// back. `after_ts` is the last output timestamp already sent. +pub async fn play( + ctrl: &Arc, + dest: &Arc, + sink: &mut EgressSink, + after_ts: u32, +) -> io::Result { + let ended = HoldOutcome { + exit: HoldExit::Ended, + last_ts: after_ts, + }; + let plan = match plan_for(ctrl, dest) { + Ok(plan) => plan, + Err(why) => { + ctrl.log(format!( + "[{}] crash protection can't cover this destination: {why}", + dest.id + )); + return Ok(ended); + } + }; + let settings = ctrl.crash_protection(); + let mut video = Vec::with_capacity(plan.video.len()); + for source in plan.video { + video.push(match source { + VideoSource::Screen { shape, framing } => { + let Some(slate) = ctrl.slate_cache.get(&settings, shape).await else { + ctrl.log(format!( + "[{}] crash protection couldn't build the reconnect screen", + dest.id + )); + return Ok(ended); + }; + VideoTrack::screen(&slate, framing) + } + VideoSource::HeldFrame(tag) => VideoTrack::HeldFrame(tag), + }); + } + ctrl.log(format!( + "[{}] crash protection: reconnect screen on air ({})", + dest.id, + describe(&video) + )); + // No sequence header: the loop's keyframes carry their own SPS/PPS + // in-band under ids the stream doesn't use (see slate::encoder), so the + // destination's decoder keeps the stream's config for the way back. + let base = after_ts.wrapping_add(1); + stream_loop(ctrl, dest, sink, &video, &plan.audio, base).await +} + +/// "1920x1080, 1280x720, last keyframe held" for the log. +fn describe(video: &[VideoTrack]) -> String { + video + .iter() + .map(|track| match track { + VideoTrack::Screen { shape, .. } => format!("{}x{}", shape.width, shape.height), + VideoTrack::HeldFrame(_) => "last keyframe held".to_string(), + }) + .collect::>() + .join(", ") +} + +/// Send screen frames and silent audio, interleaved by timestamp and paced +/// in real time, until OBS returns or the hold ends. +async fn stream_loop( + ctrl: &Arc, + dest: &Arc, + sink: &mut EgressSink, + video: &[VideoTrack], + audio: &[SilentAudio], + base: u32, +) -> io::Result { + let frame_ms = 1000.0 / SLATE_FPS as f64; + let start = tokio::time::Instant::now(); + let mut video_index = 0u64; + let mut audio_index = vec![0u64; audio.len()]; + let mut last_ts = base; + let mut rebuild: Option = None; + loop { + let exit = match ctrl.hold_state() { + _ if dest.shutdown_requested.load(Ordering::Relaxed) => Some(HoldExit::Ended), + HoldState::Holding => { + // OBS may drop again while the delay rebuilds: start over. + rebuild = None; + None + } + // Back, then gone again without a new hold (a deliberate stop). + HoldState::Resumed if !ctrl.ingest_alive() => Some(HoldExit::Ended), + // With a delay armed the screen stays up until OBS's new video + // covers it, so viewers never see live. + HoldState::Resumed => { + let delay = u64::from(ctrl.target_delay_ms()); + delay_rebuilt(ctrl, &mut rebuild, delay) + .map(|rejoin_from_seq| HoldExit::Resumed { rejoin_from_seq }) + } + HoldState::Ended => Some(HoldExit::Ended), + }; + if let Some(exit) = exit { + return Ok(HoldOutcome { exit, last_ts }); + } + sink.drain_pings().await?; + + let video_at = video_index as f64 * frame_ms; + // The audio track due soonest, when it is due before the next + // video frame. + let audio_due = audio + .iter() + .zip(&audio_index) + .map(|(track, sent)| *sent as f64 * track.frame_ms) + .enumerate() + .min_by(|a, b| a.1.total_cmp(&b.1)) + .filter(|(_, at)| *at < video_at); + let at = audio_due.map_or(video_at, |(_, at)| at); + tokio::time::sleep_until(start + Duration::from_micros((at * 1000.0) as u64)).await; + let ts = base.wrapping_add(at.round() as u32); + + if let Some((track, _)) = audio_due { + sink.send_audio(ts, &audio_tag(&audio[track])).await?; + audio_index[track] += 1; + } else { + for track in video { + let Some(tag) = video_frame(track, video_index) else { + continue; + }; + sink.send_video(ts, tag).await?; + dest.tags_sent.fetch_add(1, Ordering::Relaxed); + dest.bytes_sent + .fetch_add(tag.len() as u64, Ordering::Relaxed); + dest.note_outbound_bytes(tag.len()); + } + video_index += 1; + } + last_ts = ts; + } +} + +/// Where OBS's video since it came back starts, tracked while the screen +/// waits for it to rebuild the armed delay. +pub(crate) struct Rebuild { + /// The newest ring seq when OBS came back (None: the ring was empty, + /// as it is for a new publisher). Its new video starts after it. + after_seq: Option, + since: Instant, +} + +/// The ring seq OBS's new video starts at, once that video spans +/// `delay_ms` from its first keyframe (so at that keyframe for no delay; +/// or once the wait has run `REBUILD_GRACE` past the delay). Measured from +/// the keyframe, not the first tag: a frozen encoder often resumes on a +/// P-frame, and counting from it let the rejoin land short of the delay, or +/// wait on air with nothing to send until the keyframe came. +pub(crate) fn delay_rebuilt( + ctrl: &Controller, + rebuild: &mut Option, + delay_ms: u64, +) -> Option { + let rebuild = rebuild.get_or_insert_with(|| Rebuild { + after_seq: ctrl.resumed_after_seq(), + since: Instant::now(), + }); + let first_seq = match rebuild.after_seq { + Some(seq) => seq + 1, + None => ctrl.ring.front_seq()?, + }; + if rebuild.since.elapsed() >= Duration::from_millis(delay_ms) + REBUILD_GRACE { + return Some(first_seq); + } + let first_keyframe = ctrl.ring.oldest_idr_at_or_after(first_seq)?; + let spanned = ctrl.ring.latest_ts()?.saturating_sub(first_keyframe.ts_ms); + (spanned >= delay_ms).then_some(first_seq) +} + +/// The tag `track` sends for screen frame `index`, if any: every frame of +/// the loop, or the held keyframe once a second. +fn video_frame(track: &VideoTrack, index: u64) -> Option<&[u8]> { + match track { + VideoTrack::Screen { + keyframes, deltas, .. + } => { + let frames = 1 + deltas.len() as u64; + let position = (index % frames) as usize; + if position == 0 { + // Alternate the two IDR variants: back-to-back IDRs must not + // share an idr_pic_id. + let replay = (index / frames) as usize % 2; + Some(&keyframes[replay]) + } else { + Some(&deltas[position - 1]) + } + } + VideoTrack::HeldFrame(tag) => index.is_multiple_of(HELD_FRAME_EVERY).then_some(&tag[..]), + } +} + +/// An H.264 coded-frame tag in `framing`, zero composition time. +fn video_tag(framing: Framing, keyframe: bool, sample: &[u8]) -> Vec { + let frame_type = if keyframe { FRAME_KEY } else { FRAME_INTER }; + let mut tag = Vec::with_capacity(sample.len() + 12); + match framing { + Framing::Legacy => tag.extend_from_slice(&[frame_type << 4 | FLV_VIDEO_AVC, AVC_NALU]), + Framing::Enhanced => { + tag.push(EX_VIDEO | frame_type << 4 | EX_CODED_FRAMES); + tag.extend_from_slice(&FOURCC_AVC1); + } + Framing::OneTrack(track) => { + tag.push(EX_VIDEO | frame_type << 4 | EX_VIDEO_MULTITRACK); + tag.push(ONE_TRACK << 4 | EX_CODED_FRAMES); + tag.extend_from_slice(&FOURCC_AVC1); + tag.push(track); + } + } + tag.extend_from_slice(&[0, 0, 0]); + tag.extend_from_slice(sample); + tag +} + +/// One silent AAC frame in the track's own framing. +fn audio_tag(audio: &SilentAudio) -> Vec { + let mut tag = Vec::with_capacity(audio.frame.len() + 7); + match audio.framing { + Framing::Legacy => tag.extend_from_slice(&[audio.legacy_flags, AAC_RAW]), + Framing::Enhanced => { + tag.push(FLV_AUDIO_EX << 4 | EX_CODED_FRAMES); + tag.extend_from_slice(&FOURCC_MP4A); + } + Framing::OneTrack(track) => { + tag.push(FLV_AUDIO_EX << 4 | EX_AUDIO_MULTITRACK); + tag.push(ONE_TRACK << 4 | EX_CODED_FRAMES); + tag.extend_from_slice(&FOURCC_MP4A); + tag.push(track); + } + } + tag.extend_from_slice(audio.frame); + tag +} + +/// Encoded reconnect-screen loops, one per destination shape, rebuilt when +/// the screen's look changes. Encoding a 1080p loop takes a few hundred +/// ms of one core, so loops are built ahead of time while OBS is live and +/// a hold usually starts with one ready. +#[derive(Default)] +pub struct SlateCache { + /// Per (width, height): the loop and the settings it was built with. + ready: crate::sync::Mutex)>>, + building: crate::sync::Mutex>, +} + +/// A slate loop's (width, height). +type SlateKey = (usize, usize); + +impl SlateCache { + fn cached(&self, settings: &CrashProtection, shape: StreamShape) -> Option> { + self.ready + .lock() + .get(&(shape.width, shape.height)) + .filter(|(built_with, _)| built_with.same_screen(settings)) + .map(|(_, slate)| slate.clone()) + } + + /// The loop for `shape`, building it now if it isn't ready. When a + /// build of it is already running (a prebuild, or another destination + /// at the same resolution) this waits for that one rather than encode + /// the same loop twice at the moment OBS drops. + pub async fn get( + self: &Arc, + settings: &CrashProtection, + shape: StreamShape, + ) -> Option> { + const BUILD_POLL: Duration = Duration::from_millis(50); + let key = (shape.width, shape.height); + loop { + if let Some(slate) = self.cached(settings, shape) { + return Some(slate); + } + if self.building.lock().insert(key) { + break; + } + tokio::time::sleep(BUILD_POLL).await; + } + // The build runs as its own task, so it always finishes and clears + // `building`: a caller cancelled mid-build (its pump aborted by the + // supervisor) must not leave every later caller waiting forever. + let cache = self.clone(); + let settings = settings.clone(); + let task = tokio::spawn(async move { + let slate = build(settings.clone(), shape).await; + if let Some(slate) = &slate { + cache.ready.lock().insert(key, (settings, slate.clone())); + } + cache.building.lock().remove(&key); + slate + }); + task.await.ok().flatten() + } + + /// Start building the loop for `shape` in the background, unless it is + /// ready or already being built. + pub fn prebuild(self: &Arc, settings: &CrashProtection, shape: StreamShape) { + let key = (shape.width, shape.height); + if self.cached(settings, shape).is_some() || self.building.lock().contains(&key) { + return; + } + let cache = self.clone(); + let settings = settings.clone(); + tokio::spawn(async move { + cache.get(&settings, shape).await; + }); + } + + /// Drop every loop (crash protection was switched off). + pub fn clear(&self) { + self.ready.lock().clear(); + } +} + +/// Encode a loop off the async runtime. +async fn build(settings: CrashProtection, shape: StreamShape) -> Option> { + tokio::task::spawn_blocking(move || { + crate::slate::build_loop(&settings, shape) + .ok() + .map(Arc::new) + }) + .await + .ok() + .flatten() +} + +/// "1:05" for a duration, for logs and webhooks. +pub fn minutes_seconds(duration: Duration) -> String { + let secs = duration.as_secs(); + format!("{}:{:02}", secs / 60, secs % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::h264::select_video_bytes; + + fn aac_header(flags: u8, asc: [u8; 2]) -> Vec { + vec![flags, 0, asc[0], asc[1]] + } + + /// An Enhanced Broadcasting (OneTrack) sequence-start tag. + fn one_track_header(first: u8, fourcc: &[u8; 4], track: u8, config: &[u8]) -> Vec { + let mut tag = vec![first, ONE_TRACK << 4 | EX_SEQUENCE_START]; + tag.extend_from_slice(fourcc); + tag.push(track); + tag.extend_from_slice(config); + tag + } + + #[test] + fn silence_matches_the_streams_own_aac_config() { + // AAC-LC, 48 kHz (index 3), stereo: 00010 0011 0010 000. + let stereo = silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])).unwrap(); + assert_eq!(stereo.frame, SILENT_AAC_STEREO); + assert_eq!(stereo.framing, Framing::Legacy); + assert_eq!(audio_tag(&stereo)[..2], [0xAF, AAC_RAW]); + assert!((stereo.frame_ms - 21.333).abs() < 0.01); + // AAC-LC, 44.1 kHz (index 4), mono. + let mono = silent_audio_from_header(&aac_header(0xAE, [0x12, 0x08])).unwrap(); + assert_eq!(mono.frame, SILENT_AAC_MONO); + assert!((mono.frame_ms - 23.22).abs() < 0.01); + } + + #[test] + fn a_vod_audio_track_gets_silence_under_its_own_track_id() { + let header = one_track_header(0x95, b"mp4a", 1, &[0x11, 0x90]); + let silence = silent_audio_from_header(&header).unwrap(); + assert_eq!(silence.framing, Framing::OneTrack(1)); + let tag = audio_tag(&silence); + assert_eq!(tag[..7], [0x95, 0x01, b'm', b'p', b'4', b'a', 1]); + // It flattens back to the legacy silent frame any ingest accepts. + let flat = crate::h264::flatten_multitrack_audio(&tag, 1).unwrap(); + assert_eq!(flat[..2], [0xAF, AAC_RAW]); + assert_eq!(&flat[2..], SILENT_AAC_STEREO); + } + + #[test] + fn unsupported_audio_gets_no_silence() { + // HE-AAC (object type 5), 5.1 channels, a non-AAC tag, Opus. + assert!(silent_audio_from_header(&aac_header(0xAF, [0x29, 0x90])).is_none()); + assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0xB0])).is_none()); + assert!(silent_audio_from_header(&[0x2F, 0x00, 0x11, 0x90]).is_none()); + assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])[..2]).is_none()); + assert!(silent_audio_from_header(&one_track_header(0x95, b"Opus", 1, &[0; 8])).is_none()); + } + + #[test] + fn the_screen_matches_each_h264_framing_and_nothing_else() { + assert_eq!(avc_framing(&[0x17, 0, 0, 0, 0, 1]), Some(Framing::Legacy)); + let mut enhanced = vec![EX_VIDEO | FRAME_KEY << 4 | EX_SEQUENCE_START]; + enhanced.extend_from_slice(b"avc1"); + assert_eq!(avc_framing(&enhanced), Some(Framing::Enhanced)); + let rung = one_track_header(0x96, b"avc1", 2, &[1]); + assert_eq!(avc_framing(&rung), Some(Framing::OneTrack(2))); + assert_eq!(avc_framing(&one_track_header(0x96, b"hvc1", 2, &[1])), None); + // ManyTracks bundles several tracks' configs in one tag. + let mut bundle = one_track_header(0x96, b"avc1", 0, &[1]); + bundle[1] = 1 << 4; + assert_eq!(avc_framing(&bundle), None); + } + + #[test] + fn screen_frames_are_framed_like_the_track_they_replace() { + assert_eq!( + video_tag(Framing::Legacy, true, &[9, 9]), + vec![0x17, 1, 0, 0, 0, 9, 9] + ); + assert_eq!(video_tag(Framing::Legacy, false, &[9])[0], 0x27); + let rung = video_tag(Framing::OneTrack(3), true, &[9, 9]); + assert_eq!(rung[..7], [0x96, 0x01, b'a', b'v', b'c', b'1', 3]); + // A vertical destination fed track 3 sees the plain legacy frame. + let flat = select_video_bytes(&rung, VideoEgress::Track(3)).unwrap(); + assert_eq!(&flat[..], &[0x17, 1, 0, 0, 0, 9, 9]); + let enhanced = video_tag(Framing::Enhanced, false, &[9]); + assert_eq!(enhanced[..5], [0xA1, b'a', b'v', b'c', b'1']); + } + + #[test] + fn only_keyframes_the_screen_cant_replace_are_held() { + let key = EX_VIDEO | FRAME_KEY << 4 | EX_VIDEO_MULTITRACK; + let coded = ONE_TRACK << 4 | EX_CODED_FRAMES; + let hevc = [key, coded, b'h', b'v', b'c', b'1', 1, 0, 0, 0, 7]; + assert!(is_held_keyframe(&hevc)); + let mut avc = hevc; + avc[2..6].copy_from_slice(b"avc1"); + assert!(!is_held_keyframe(&avc)); + let mut inter = hevc; + inter[0] = EX_VIDEO | FRAME_INTER << 4 | EX_VIDEO_MULTITRACK; + assert!(!is_held_keyframe(&inter)); + let mut bundle = avc; + bundle[1] = 1 << 4 | EX_CODED_FRAMES; + assert!(is_held_keyframe(&bundle)); + let av1 = [ + EX_VIDEO | FRAME_KEY << 4 | EX_CODED_FRAMES, + b'a', + b'v', + b'0', + b'1', + 7, + ]; + assert!(is_held_keyframe(&av1)); + assert!(!is_held_keyframe(&[0x17, 1, 0, 0, 0, 0, 0, 0, 1, 0x65])); + } + + #[test] + fn a_held_keyframe_is_resent_once_a_second() { + let held = VideoTrack::HeldFrame(Arc::from(&[1u8, 2, 3][..])); + assert!(video_frame(&held, 0).is_some()); + assert!(video_frame(&held, 1).is_none()); + assert!(video_frame(&held, HELD_FRAME_EVERY - 1).is_none()); + assert_eq!(video_frame(&held, HELD_FRAME_EVERY).unwrap()[..], [1, 2, 3]); + } + + /// A caller cancelled while its loop is being built (its pump aborted) + /// must not leave the resolution marked as building for everyone else. + #[tokio::test] + async fn a_cancelled_build_does_not_wedge_the_cache() { + let cache = Arc::new(SlateCache::default()); + let settings = CrashProtection::default(); + let shape = StreamShape { + width: 320, + height: 180, + fps: SLATE_FPS, + }; + let first = { + let cache = cache.clone(); + let settings = settings.clone(); + tokio::spawn(async move { cache.get(&settings, shape).await }) + }; + tokio::task::yield_now().await; + first.abort(); + let later = tokio::time::timeout(Duration::from_secs(10), cache.get(&settings, shape)); + assert!(later.await.expect("not wedged").is_some()); + } + + /// OBS is back, but its new video comes in too slowly to span the + /// delay (a struggling encoder, or a delay raised meanwhile). The screen + /// can't wait forever: after the grace it rejoins where OBS's new video + /// starts, never on the video from before the hold. + #[test] + fn the_screen_stops_waiting_for_the_delay_after_the_grace() { + const DELAY_MS: u64 = 10_000; + let path = + std::env::temp_dir().join(format!("ic-rebuild-grace-{}.buf", std::process::id())); + let _ = std::fs::remove_file(&path); + let ring = Arc::new(crate::buffer::DiskRing::create(&path, 1 << 20).unwrap()); + let ctrl = Controller::new(ring, 0); + let keyframe = [0x17, 1, 0, 0, 0]; + ctrl.ring.append(9, 0, &keyframe, true, false).unwrap(); + let first_new = ctrl.ring.append(9, 5_000, &keyframe, true, false).unwrap(); + ctrl.ring.append(9, 6_000, &keyframe, true, false).unwrap(); + let mut rebuild = Some(Rebuild { + after_seq: Some(0), + since: Instant::now(), + }); + assert_eq!( + delay_rebuilt(&ctrl, &mut rebuild, DELAY_MS), + None, + "1 s of 10" + ); + + let waited = Duration::from_millis(DELAY_MS) + REBUILD_GRACE; + rebuild.as_mut().unwrap().since = Instant::now() - waited; + assert_eq!(delay_rebuilt(&ctrl, &mut rebuild, DELAY_MS), first_new); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn durations_print_as_minutes_and_seconds() { + assert_eq!(minutes_seconds(Duration::from_secs(65)), "1:05"); + assert_eq!(minutes_seconds(Duration::from_secs(120)), "2:00"); + } +} diff --git a/src/crash_protection.rs b/src/crash_protection.rs new file mode 100644 index 0000000..693d368 --- /dev/null +++ b/src/crash_protection.rs @@ -0,0 +1,364 @@ +//! Crash protection settings. +//! +//! When OBS dies mid-stream, crash protection keeps every destination live +//! with a looping reconnect screen until OBS publishes again or the hold +//! time runs out. This file owns the persisted knobs (the +//! `crash_protection.*` config keys); the screen itself is drawn and +//! encoded by `crate::slate`. + +use crate::slate::Rgb; +use std::io::{self, Write}; + +pub const HOLD_SECS_DEFAULT: u32 = 120; +pub const HOLD_SECS_MIN: u32 = 30; +pub const HOLD_SECS_MAX: u32 = 300; +pub const HEADLINE_MAX_CHARS: usize = 28; +pub const SUBLINE_MAX_CHARS: usize = 48; +const ACCENT_DEFAULT: Rgb = Rgb::new(0x5a, 0xc8, 0xfa); +const HEADLINE_DEFAULT: &str = "Reconnecting"; +const SUBLINE_DEFAULT: &str = "The stream will be back in a moment"; +/// Config and form keys share this prefix, like `hotkey.` and `midi.`. +pub const KEY_PREFIX: &str = "crash_protection."; + +/// Look of the reconnect screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlateTheme { + /// Minimal centred text with three pulsing dots. + Whisper, + /// 5x7 pixel font snapped to the 16 px macroblock grid. + Arcade, + /// Centred text under a dot sending out radar rings. + Beacon, + /// Centred text under a ring of eight dots, one lit and chasing round. + Orbit, + /// Broadcast lower third: text on the left with an accent bar and a + /// sliding progress stripe. + Studio, +} + +impl SlateTheme { + /// Every theme, in the order the dashboard offers them. + pub const ALL: [SlateTheme; 5] = [ + SlateTheme::Whisper, + SlateTheme::Beacon, + SlateTheme::Orbit, + SlateTheme::Studio, + SlateTheme::Arcade, + ]; + + pub fn id(self) -> &'static str { + match self { + SlateTheme::Whisper => "whisper", + SlateTheme::Arcade => "arcade", + SlateTheme::Beacon => "beacon", + SlateTheme::Orbit => "orbit", + SlateTheme::Studio => "studio", + } + } + + /// Name shown in the dashboard. + pub fn name(self) -> &'static str { + match self { + SlateTheme::Whisper => "Whisper", + SlateTheme::Arcade => "Arcade", + SlateTheme::Beacon => "Beacon", + SlateTheme::Orbit => "Orbit", + SlateTheme::Studio => "Studio", + } + } + + fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|theme| theme.id() == id) + } + + /// Background used when the streamer hasn't picked one. + pub fn default_background(self) -> Rgb { + match self { + SlateTheme::Whisper => Rgb::new(0x0e, 0x0f, 0x12), + SlateTheme::Arcade => Rgb::new(0x13, 0x0f, 0x22), + SlateTheme::Beacon => Rgb::new(0x0b, 0x12, 0x16), + SlateTheme::Orbit => Rgb::new(0x10, 0x10, 0x16), + SlateTheme::Studio => Rgb::new(0x0d, 0x10, 0x18), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrashProtection { + pub enabled: bool, + /// How long destinations stay up on the reconnect screen before the + /// stream ends cleanly. Clamped to HOLD_SECS_MIN..=HOLD_SECS_MAX. + pub hold_secs: u32, + pub theme: SlateTheme, + pub accent: Rgb, + /// None means the theme's own background. + pub background: Option, + pub headline: String, + pub subline: String, + /// Treat a clean disconnect (FCUnpublish/deleteStream) as a crash too. + /// OBS closes cleanly on encoder errors, so this is the only way to + /// protect against them; the streamer ends the hold with "End now". + pub every_disconnect: bool, +} + +impl Default for CrashProtection { + fn default() -> Self { + Self { + enabled: false, + hold_secs: HOLD_SECS_DEFAULT, + theme: SlateTheme::Whisper, + accent: ACCENT_DEFAULT, + background: None, + headline: HEADLINE_DEFAULT.into(), + subline: SUBLINE_DEFAULT.into(), + every_disconnect: false, + } + } +} + +impl CrashProtection { + /// Apply one `field=value` pair (the part after KEY_PREFIX). Malformed + /// values are dropped so a hand-edited config or form can't load a + /// colour the rasterizer can't parse or a hold time outside the range. + pub fn set(&mut self, field: &str, value: &str) { + match field { + "enabled" => self.enabled = is_truthy(value), + "hold_secs" => { + if let Ok(secs) = value.trim().parse::() { + self.hold_secs = secs.clamp(HOLD_SECS_MIN, HOLD_SECS_MAX); + } + } + "theme" => { + if let Some(theme) = SlateTheme::from_id(value) { + self.theme = theme; + } + } + "accent" => { + if let Some(color) = Rgb::from_hex(value) { + self.accent = color; + } + } + "background" if value.is_empty() => self.background = None, + "background" => { + if let Some(color) = Rgb::from_hex(value) { + self.background = Some(color); + } + } + "headline" => self.headline = sanitize_text(value, HEADLINE_MAX_CHARS), + "subline" => self.subline = sanitize_text(value, SUBLINE_MAX_CHARS), + "every_disconnect" => self.every_disconnect = is_truthy(value), + _ => {} + } + } + + /// Whether `other` draws the same reconnect screen. The hold time and + /// what triggers a hold don't change the picture, so editing them + /// never re-encodes a loop. + pub fn same_screen(&self, other: &CrashProtection) -> bool { + self.theme == other.theme + && self.accent == other.accent + && self.resolved_background() == other.resolved_background() + && self.headline == other.headline + && self.subline == other.subline + } + + /// Background the screen actually draws with. + pub fn resolved_background(&self) -> Rgb { + self.background + .unwrap_or_else(|| self.theme.default_background()) + } + + /// Write every field that differs from the default, one + /// `crash_protection.=` line each, so an untouched + /// install keeps its config file lean. + pub fn write_lines(&self, out: &mut impl Write) -> io::Result<()> { + let defaults = Self::default().entries(); + for ((field, value), (_, default_value)) in self.entries().into_iter().zip(defaults) { + if value != default_value { + writeln!(out, "{KEY_PREFIX}{field}={value}")?; + } + } + Ok(()) + } + + /// JSON object for `GET /config`. Nothing here is secret. `themes` + /// lists what the dashboard can offer, with each theme's default + /// background, so the page never keeps its own copy. + pub fn to_json(&self) -> String { + let themes: Vec = SlateTheme::ALL + .iter() + .map(|theme| { + format!( + r#"{{"id":{},"name":{},"background":{}}}"#, + crate::config::json_str(theme.id()), + crate::config::json_str(theme.name()), + crate::config::json_str(&theme.default_background().to_hex()), + ) + }) + .collect(); + format!( + r#"{{"enabled":{},"hold_secs":{},"theme":{},"accent":{},"background":{},"headline":{},"subline":{},"every_disconnect":{},"defaults":{{"headline":{},"subline":{}}},"themes":[{}]}}"#, + self.enabled, + self.hold_secs, + crate::config::json_str(self.theme.id()), + crate::config::json_str(&self.accent.to_hex()), + crate::config::json_str(&self.background.map(Rgb::to_hex).unwrap_or_default()), + crate::config::json_str(&self.headline), + crate::config::json_str(&self.subline), + self.every_disconnect, + crate::config::json_str(HEADLINE_DEFAULT), + crate::config::json_str(SUBLINE_DEFAULT), + themes.join(","), + ) + } + + fn entries(&self) -> [(&'static str, String); 8] { + [ + ("enabled", self.enabled.to_string()), + ("hold_secs", self.hold_secs.to_string()), + ("theme", self.theme.id().to_string()), + ("accent", self.accent.to_hex()), + ( + "background", + self.background.map(Rgb::to_hex).unwrap_or_default(), + ), + ("headline", self.headline.clone()), + ("subline", self.subline.clone()), + ("every_disconnect", self.every_disconnect.to_string()), + ] + } +} + +/// Checkbox values arrive as "true"/"false" or "on"/"" depending on the +/// caller, the same convention the other boolean settings use. +fn is_truthy(value: &str) -> bool { + !matches!(value, "" | "false" | "0" | "off") +} + +/// Screen text: no control characters (the config format is line-based), +/// surrounding whitespace trimmed, capped to what fits on the screen. +fn sanitize_text(value: &str, max_chars: usize) -> String { + value + .chars() + .filter(|c| !c.is_control()) + .collect::() + .trim() + .chars() + .take(max_chars) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn written(settings: &CrashProtection) -> String { + let mut out = Vec::new(); + settings.write_lines(&mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn defaults_write_nothing() { + assert_eq!(written(&CrashProtection::default()), ""); + } + + #[test] + fn changed_fields_round_trip_through_set() { + let mut settings = CrashProtection::default(); + settings.set("enabled", "true"); + settings.set("hold_secs", "90"); + settings.set("theme", "arcade"); + settings.set("accent", "#FF6B6B"); + settings.set("background", "#101010"); + settings.set("headline", "Be right back"); + settings.set("subline", "Grab a drink"); + settings.set("every_disconnect", "on"); + + let mut loaded = CrashProtection::default(); + for line in written(&settings).lines() { + let (key, value) = line.split_once('=').unwrap(); + loaded.set(key.strip_prefix(KEY_PREFIX).unwrap(), value); + } + assert_eq!(loaded, settings); + } + + #[test] + fn hold_secs_is_clamped_and_garbage_ignored() { + let mut settings = CrashProtection::default(); + settings.set("hold_secs", "5"); + assert_eq!(settings.hold_secs, HOLD_SECS_MIN); + settings.set("hold_secs", "9999"); + assert_eq!(settings.hold_secs, HOLD_SECS_MAX); + settings.set("hold_secs", "soon"); + assert_eq!(settings.hold_secs, HOLD_SECS_MAX); + } + + #[test] + fn bad_colours_and_themes_are_dropped() { + let mut settings = CrashProtection::default(); + settings.set("accent", "red"); + settings.set("accent", "#12345"); + settings.set("theme", "signal"); + assert_eq!(settings.accent, ACCENT_DEFAULT); + assert_eq!(settings.theme, SlateTheme::Whisper); + } + + #[test] + fn empty_background_means_theme_default() { + let mut settings = CrashProtection::default(); + settings.set("background", "#202020"); + settings.set("background", ""); + assert_eq!(settings.background, None); + assert_eq!( + settings.resolved_background(), + SlateTheme::Whisper.default_background() + ); + } + + #[test] + fn text_loses_control_characters_and_is_capped() { + let mut settings = CrashProtection::default(); + settings.set("headline", " Back\nsoon\t "); + assert_eq!(settings.headline, "Backsoon"); + settings.set("subline", &"x".repeat(100)); + assert_eq!(settings.subline.chars().count(), SUBLINE_MAX_CHARS); + } + + #[test] + fn json_carries_every_field() { + let json = CrashProtection::default().to_json(); + for field in [ + "enabled", + "hold_secs", + "theme", + "accent", + "background", + "headline", + "subline", + "every_disconnect", + "themes", + ] { + assert!( + json.contains(&format!("\"{field}\":")), + "missing {field} in {json}" + ); + } + assert!(json.contains(r##""accent":"#5ac8fa""##)); + } + + #[test] + fn every_theme_round_trips_by_id() { + for theme in SlateTheme::ALL { + let mut settings = CrashProtection::default(); + settings.set("theme", theme.id()); + assert_eq!(settings.theme, theme); + // The picked theme, not the `themes` list that names every id. + let json = settings.to_json(); + assert!( + json.contains(&format!(r#""theme":"{}""#, theme.id())), + "{json}" + ); + } + } +} diff --git a/src/crypto.rs b/src/crypto.rs index 8f4beee..ce1fbb0 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -206,6 +206,51 @@ mod tests { ); } + /// RFC 4231 test case 6: a 131-byte key, longer than the 64-byte block, so + /// it must be hashed down first. Cases 1 and 2 use short keys and never + /// reach that branch; a password longer than 64 bytes does. + #[test] + fn hmac_rfc4231_case6_hashes_an_oversized_key() { + let mac = hmac_sha256( + &[0xaa; 131], + b"Test Using Larger Than Block-Size Key - Hash Key First", + ); + assert_eq!( + sha256::hex_bytes(&mac), + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" + ); + } + + /// RFC 4231 test case 7: an oversized key AND a message longer than one + /// block, so the inner hash spans several blocks. + #[test] + fn hmac_rfc4231_case7_oversized_key_and_message() { + let mac = hmac_sha256( + &[0xaa; 131], + b"This is a test using a larger than block-size key and a larger than \ + block-size data. The key needs to be hashed before being used by the \ + HMAC algorithm.", + ); + assert_eq!( + sha256::hex_bytes(&mac), + "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2" + ); + } + + /// A key of exactly one block is used as-is, not hashed. This is the + /// boundary of the `>` in `hmac_sha256`: turning it into `>=` would hash a + /// 64-byte key and change every MAC made with one. Expected value is the + /// NIST HMAC-SHA256 "keylen=blocklen" example (key = bytes 0x00..0x3f). + #[test] + fn hmac_key_of_exactly_one_block_is_not_hashed() { + let key: Vec = (0u8..64).collect(); + let mac = hmac_sha256(&key, b"Sample message for keylen=blocklen"); + assert_eq!( + sha256::hex_bytes(&mac), + "8bb9a1db9806f20df7f77b82138c7914d174d59e13dc4d0169c9057b133e1d62" + ); + } + /// Published PBKDF2-HMAC-SHA256 vectors (password "password", salt "salt"). #[test] fn pbkdf2_known_vectors() { @@ -233,6 +278,22 @@ mod tests { assert_ne!(h, h2); } + /// A stored hash in the exact on-disk format, built from the published + /// PBKDF2 vector (password "password", salt "salt" = hex 73616c74, c = 1). + /// + /// `password_round_trip` only proves `hash_password` and `verify_password` + /// agree with each other, so a format change made to both sides passes it + /// while locking every existing user out of their dashboard. This one is + /// what a config file written by an older build actually contains. + const STORED_VECTOR: &str = "pbkdf2-sha256$1$73616c74$\ + 120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"; + + #[test] + fn an_existing_stored_hash_still_verifies() { + assert!(verify_password("password", STORED_VECTOR)); + assert!(!verify_password("Password", STORED_VECTOR)); + } + #[test] fn verify_rejects_garbage() { assert!(!verify_password("x", "")); @@ -241,6 +302,44 @@ mod tests { assert!(!verify_password("x", "pbkdf2-sha256$1$zz$bb")); // non-hex salt } + /// Every malformed variant of a hash that is otherwise correct for this + /// password. Starting from a valid value is the point: each case fails for + /// exactly the one reason it names, not because the password is wrong. + #[test] + fn verify_rejects_damaged_copies_of_a_valid_hash() { + let dk_len = STORED_VECTOR.len(); + for (stored, why) in [ + ( + STORED_VECTOR.replace("pbkdf2-sha256$", "pbkdf2-sha1$"), + "another algorithm's prefix", + ), + ( + STORED_VECTOR[..dk_len - 1].to_string(), + "an odd-length digest", + ), + ( + STORED_VECTOR[..dk_len - 2].to_string(), + "a truncated digest (31 bytes)", + ), + ( + format!("{STORED_VECTOR}00"), + "an overlong digest (33 bytes)", + ), + (format!("{STORED_VECTOR}$extra"), "five fields"), + ( + STORED_VECTOR.replacen("$1$", "$", 1), + "three fields (no iteration count)", + ), + ( + STORED_VECTOR.replacen("$1$", "$x$", 1), + "a non-numeric count", + ), + (STORED_VECTOR.replacen("$1$", "$-1$", 1), "a negative count"), + ] { + assert!(!verify_password("password", &stored), "{why}: {stored}"); + } + } + #[test] fn constant_time_eq_basics() { assert!(constant_time_eq(b"abc", b"abc")); diff --git a/src/h264.rs b/src/h264.rs index 95aecbb..67fb46d 100644 --- a/src/h264.rs +++ b/src/h264.rs @@ -57,6 +57,18 @@ const FOURCC_AV01: [u8; 4] = *b"av01"; const FOURCC_VP09: [u8; 4] = *b"vp09"; const FOURCC_AVC1: [u8; 4] = *b"avc1"; +/// Enhanced RTMP packet types that carry decoder config: SequenceStart (0) +/// and MPEG2TSSequenceStart (5, AV1's alternative config form). +fn is_config_packet(packet_type: u8) -> bool { + matches!(packet_type, 0 | 5) +} + +/// Enhanced RTMP packet types that carry coded pictures: CodedFrames (1) +/// and CodedFramesX (3, no composition time). +fn is_coded_frames_packet(packet_type: u8) -> bool { + matches!(packet_type, 1 | 3) +} + pub fn classify_video_tag(payload: &[u8]) -> VideoTagInfo { let unknown = VideoTagInfo { is_seq_header: false, @@ -101,14 +113,16 @@ pub fn classify_video_tag(payload: &[u8]) -> VideoTagInfo { // 5=MPEG2TSSequenceStart, 6=Multitrack. let frame_type = (b0 >> 4) & 0x07; let packet_type = b0 & 0x0F; - let is_seq_header = packet_type == 0; + let is_seq_header = is_config_packet(packet_type); let is_metadata = packet_type == 4; // Enhanced encoders always set FrameType=1 on keyframes. For AVC we // walked NALUs because some encoders flag P-frames as key incorrectly; // enhanced-rtmp tightens this and FrameType is the spec-blessed signal. - // SeqStart / Metadata packets carry no slice data, so refuse to flag - // them as IDR even if the encoder set FrameType=1. - let is_keyframe = frame_type == 1 && !is_seq_header && !is_metadata; + // Only coded frames (CodedFrames / CodedFramesX) carry a picture: + // config, SequenceEnd and Metadata packets never count as keyframes, + // even with FrameType=1 - a SequenceEnd taken for one became a cut + // point, and crash protection's held "last keyframe". + let is_keyframe = frame_type == 1 && is_coded_frames_packet(packet_type); let is_multitrack = packet_type == 6; // For multi-track, the FourCC sits behind the multitrack header; we @@ -126,11 +140,11 @@ pub fn classify_video_tag(payload: &[u8]) -> VideoTagInfo { // FrameType=1 (some do). if is_multitrack { let nested_pt = payload.get(1).map(|b| b & 0x0F).unwrap_or(0xFF); - let is_mt_seq_header = nested_pt == 0; + let is_mt_seq_header = is_config_packet(nested_pt); let is_mt_metadata = nested_pt == 4; return VideoTagInfo { is_seq_header: is_mt_seq_header, - is_idr: is_keyframe && !is_mt_seq_header && !is_mt_metadata, + is_idr: frame_type == 1 && is_coded_frames_packet(nested_pt), is_multitrack: true, is_metadata: is_mt_metadata, codec: VideoCodec::Unknown, @@ -163,19 +177,44 @@ pub fn classify_video_tag(payload: &[u8]) -> VideoTagInfo { } } -fn contains_idr_nalu(mut data: &[u8]) -> bool { - while data.len() >= 4 { - let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; - if 4 + len > data.len() { - return false; - } - let nal_unit_type = data[4] & 0x1F; - if nal_unit_type == 5 { - return true; +fn contains_idr_nalu(data: &[u8]) -> bool { + // NAL lengths are 4 bytes in practice (OBS, FFmpeg, x264), and an IDR + // found that way counts even if later bytes don't parse. The AVC config + // allows 1- or 2-byte lengths too (lengthSizeMinusOne); for those, a + // 4-byte walk finds nothing and fails, and a size is trusted only when + // its lengths tile the frame exactly, which a wrong size almost never does. + let (idr, tiled) = scan_nal_units(data, 4); + if idr || tiled { + return idr; + } + [2, 1] + .into_iter() + .any(|size| scan_nal_units(data, size) == (true, true)) +} + +/// Walk NAL units prefixed by `size`-byte big-endian lengths: whether an +/// IDR slice turned up, and whether the lengths tiled `data` exactly. +fn scan_nal_units(mut data: &[u8], size: usize) -> (bool, bool) { + let mut idr = false; + while !data.is_empty() { + let Some(prefix) = data.get(..size) else { + return (idr, false); + }; + let len = prefix + .iter() + .fold(0usize, |len, byte| (len << 8) | usize::from(*byte)); + let Some(nal) = data[size..].get(..len) else { + return (idr, false); + }; + // A zero-length NAL has no header byte to classify. Step over it: + // reading its header indexed past the slice when the empty NAL was + // the last thing in the tag, which aborts a release build. + if nal.first().is_some_and(|header| header & 0x1F == 5) { + idr = true; } - data = &data[4 + len..]; + data = &data[size + len..]; } - false + (idr, true) } /// Flatten an Enhanced RTMP multi-track video tag down to a standard @@ -774,6 +813,12 @@ impl<'a> BitReader<'a> { for _ in 0..size { if next_scale != 0 { let delta = self.read_se()?; + // delta_scale is -128..=127 (H.264 7.4.2.1.1.1). Anything + // else is a corrupt or hostile SPS, and a near-i32::MAX + // delta would overflow the sum below. + if !(-128..=127).contains(&delta) { + return None; + } next_scale = (last_scale + delta + 256) % 256; } last_scale = if next_scale == 0 { @@ -876,8 +921,9 @@ fn is_multitrack_audio(payload: &[u8]) -> bool { /// Flatten an Enhanced-RTMP multi-track AUDIO tag down to a standard /// single-track tag carrying only `target_track`. The audio twin of -/// [`flatten_multitrack_video`], shifted by one byte: the multitrack header -/// is byte 1, the FourCC is bytes 2..6, and (OneTrack) the TrackId is byte 6. +/// [`flatten_multitrack_video`], with the same offsets: the multitrack +/// header is byte 1, the FourCC is bytes 2..6, and (OneTrack) the TrackId is +/// byte 6. /// /// AAC (`mp4a`) is rewritten to a *legacy* AAC tag (`0xAF` ...), the form /// every RTMP ingest accepts - the same reason the vertical AVC path drops to @@ -889,15 +935,35 @@ fn is_multitrack_audio(payload: &[u8]) -> bool { /// layout or `target_track` isn't present, so the caller drops the tag for /// this destination. pub fn flatten_multitrack_audio(payload: &[u8], target_track: u8) -> Option> { + let (fourcc, track_payload) = locate_audio_track(payload, target_track)?; + let nested_pt = payload[1] & 0x0F; + let mut out = Vec::with_capacity(track_payload.len() + 5); + if fourcc == FOURCC_MP4A && nested_pt <= 1 { + // Legacy AAC: [0xAF][AACPacketType 0=seq/1=raw][data]. The nested + // audio packet type maps 1:1 (SequenceStart=0, CodedFrames=1). + out.push(0xAF); + out.push(nested_pt); + out.extend_from_slice(track_payload); + } else { + // Enhanced-RTMP single-track audio tag (non-AAC, or an AAC packet + // type legacy FLV can't express such as MultichannelConfig). + out.push(0x90 | (nested_pt & 0x0F)); + out.extend_from_slice(fourcc); + out.extend_from_slice(track_payload); + } + Some(out) +} + +/// The FourCC and payload of `target_track` in an Enhanced-RTMP multi-track +/// audio tag, across its three layouts (offsets mirror +/// `flatten_multitrack_video`). None when the tag isn't one, or doesn't +/// carry that track. +fn locate_audio_track(payload: &[u8], target_track: u8) -> Option<(&[u8], &[u8])> { if payload.len() < 7 || !is_multitrack_audio(payload) { return None; } let mt_type = (payload[1] >> 4) & 0x0F; - let nested_pt = payload[1] & 0x0F; - - // Locate the requested track's FourCC + payload across the three - // multi-track layouts (offsets mirror flatten_multitrack_video). - let (fourcc, track_payload): (&[u8], &[u8]) = match mt_type { + let located = match mt_type { 0 => { // OneTrack: [FourCC(4)][TrackId(1)][payload..] if payload[6] != target_track { @@ -944,22 +1010,12 @@ pub fn flatten_multitrack_audio(payload: &[u8], target_track: u8) -> Option return None, }; + Some(located) +} - let mut out = Vec::with_capacity(track_payload.len() + 5); - if fourcc == FOURCC_MP4A && nested_pt <= 1 { - // Legacy AAC: [0xAF][AACPacketType 0=seq/1=raw][data]. The nested - // audio packet type maps 1:1 (SequenceStart=0, CodedFrames=1). - out.push(0xAF); - out.push(nested_pt); - out.extend_from_slice(track_payload); - } else { - // Enhanced-RTMP single-track audio tag (non-AAC, or an AAC packet - // type legacy FLV can't express such as MultichannelConfig). - out.push(0x90 | (nested_pt & 0x0F)); - out.extend_from_slice(fourcc); - out.extend_from_slice(track_payload); - } - Some(out) +/// Whether a multi-track audio tag carries `track`. +pub fn multitrack_audio_has_track(payload: &[u8], track: u8) -> bool { + locate_audio_track(payload, track).is_some() } /// Decide what audio bytes to put on the wire given the destination's @@ -969,35 +1025,39 @@ pub fn flatten_multitrack_audio(payload: &[u8], target_track: u8) -> Option Option> { use std::borrow::Cow; let target = match egress { AudioEgress::Passthrough => return Some(Cow::Borrowed(payload)), AudioEgress::Track(t) => t, }; + let live_fallback = target == 0 || !target_on_wire; if !is_multitrack_audio(payload) { - // Single track = the only audio there is. Always forward it, whatever - // track was requested - a live track beats a silent destination when - // the 2nd track isn't being sent. - return Some(Cow::Borrowed(payload)); - } - // Flatten the requested track; fall back to the primary live track 0 when - // it isn't present, so picking "Track 2" on a single-track stream still - // sends audio instead of silence. + // A single-track tag is the live track. + return live_fallback.then_some(Cow::Borrowed(payload)); + } flatten_multitrack_audio(payload, target) - .or_else(|| flatten_multitrack_audio(payload, 0)) + .or_else(|| { + live_fallback + .then(|| flatten_multitrack_audio(payload, 0)) + .flatten() + }) .map(Cow::Owned) } @@ -1139,8 +1199,8 @@ fn fourcc_to_codec(fourcc: [u8; 4]) -> AudioCodec { /// Best-effort extraction of the Enhanced-RTMP audio TrackId for the /// OneTrack multi-track layout. Mirrors `seq_header_track_id` (the -/// video equivalent), shifted by one byte because audio's multitrack -/// header lives at byte 2 vs video's byte 1. +/// video equivalent): both put the multitrack header at byte 1 and the +/// TrackId at byte 6. /// /// Returns 0 for: /// * legacy AAC / MP3 (sound_format != 9) @@ -1287,6 +1347,55 @@ mod tests { assert_eq!(info.codec, VideoCodec::Av1); } + #[test] + fn enhanced_rtmp_fourccs_map_to_their_codec() { + // hev1 is HEVC with parameter sets in-band (some encoders send it + // instead of hvc1); avc1 is H.264 in Enhanced framing, which is what + // OBS uses for the EB ladder. Each drives the dashboard codec chip + // and the per-destination readout. + for (fourcc, codec) in [ + (b"hev1", VideoCodec::Hevc), + (b"vp09", VideoCodec::Vp9), + (b"avc1", VideoCodec::Avc), + (b"xxxx", VideoCodec::Unknown), + ] { + let mut frame = vec![0x80 | (2u8 << 4) | 1]; // inter, CodedFrames + frame.extend_from_slice(fourcc); + frame.extend_from_slice(&[0; 8]); + let info = classify_video_tag(&frame); + assert_eq!(info.codec, codec, "{}", String::from_utf8_lossy(fourcc)); + assert!(!info.is_idr, "an inter frame is never a cut point"); + + let mut seq = vec![0x90u8]; // keyframe, SequenceStart + seq.extend_from_slice(fourcc); + assert_eq!(seq_header_codec(&seq), codec); + } + } + + #[test] + fn seq_header_track_id_reads_only_the_onetrack_layout() { + // OneTrack: TrackId at byte 6. + assert_eq!(seq_header_track_id(&onetrack_avc_seq_header(3, 30, 40)), 3); + // ManyTracks / ManyTracksManyCodecs hold every track's config in + // one tag, so they share slot 0 even though byte 6 is a track id + // (ManyTracks) or a FourCC byte (ManyTracksManyCodecs). + let many_tracks = [0x96, 0x10, b'a', b'v', b'c', b'1', 2, 0, 0, 1, 0xAA]; + assert_eq!(seq_header_track_id(&many_tracks), 0); + let many_codecs = [0x96, 0x20, 2, b'a', b'v', b'c', b'1', 0, 0, 1, 0xAA]; + assert_eq!(seq_header_track_id(&many_codecs), 0); + // Legacy, Enhanced single-track and a OneTrack tag cut before its + // TrackId all fall back to slot 0 rather than reading past the end. + assert_eq!(seq_header_track_id(&legacy_avc_seq_header(40, 30)), 0); + assert_eq!( + seq_header_track_id(&[0x90, b'h', b'v', b'c', b'1', 7, 7]), + 0 + ); + assert_eq!( + seq_header_track_id(&[0x96, 0x00, b'a', b'v', b'c', b'1']), + 0 + ); + } + #[test] fn aac_seq_header_detected() { // sound_format 10 (AAC), packet_type 0 (AudioSpecificConfig) @@ -1368,22 +1477,6 @@ mod tests { assert!(!info.is_idr); } - #[test] - fn enhanced_aac_seq_header_via_packet_type_0() { - // Enhanced-RTMP single-track AAC seq header: byte 0 = 0x90 - // (SoundFormat=9, AudioPacketType=0=SequenceStart), followed - // by FourCC. This is the exact tag OBS emits for the live - // track when VOD audio is enabled (live = idx=0, single-track - // because not multitrack). - let mut payload = vec![0x90]; - payload.extend_from_slice(b"mp4a"); - let info = classify_audio_tag(&payload); - assert!(info.is_seq_header); - assert_eq!(info.codec, AudioCodec::Aac); - // The convenience shim agrees. - assert!(is_aac_seq_header(&payload)); - } - // ── select_video_bytes: per-destination flatten policy ─────────── // // The pump uses this helper to decide what bytes to put on the wire @@ -1436,7 +1529,9 @@ mod tests { fn select_video_bytes_passes_multitrack_through_for_twitch() { // Invariant 2: Twitch destinations get the raw multi-track // bytes verbatim - that's what unlocks the transcoded ladder. - // True for every TrackId, not just the primary. + // True for every TrackId, not just the primary. Twitch Dual Format + // rides on this too: the vertical canvas is just more TrackIds on + // the wire (`canvas_index` only exists in the config JSON). for track in 0u8..=4 { let tag = enhanced_rtmp_onetrack_video_bytes_track(track); let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) @@ -1457,11 +1552,69 @@ mod tests { let tag = enhanced_rtmp_onetrack_video_bytes_track(0); let youtube = select_video_bytes(&tag, VideoEgress::Track(0)).expect("track 0 must be forwarded"); - let direct_flat = flatten_multitrack_video(&tag).expect("OneTrack layout must flatten"); - assert_eq!(youtube.as_ref(), direct_flat.as_slice()); + assert_eq!(youtube.as_ref(), flat_hvc1_keyframe().as_slice()); assert!(matches!(youtube, std::borrow::Cow::Owned(_))); } + /// What `enhanced_rtmp_onetrack_video_bytes_track(_)` must flatten to, + /// written out byte by byte: a single-track Enhanced-RTMP keyframe + /// (0x80 | FrameType 1 << 4 | CodedFrames 1 = 0x91), the FourCC, then + /// the track body with the multitrack header and TrackId gone. + fn flat_hvc1_keyframe() -> Vec { + let mut out = vec![0x91, b'h', b'v', b'c', b'1']; + out.extend_from_slice(&[0xAA; 32]); + out + } + + #[test] + fn flatten_multitrack_video_emits_known_bytes_for_every_layout() { + // OneTrack HEVC is the Twitch Dual Format vertical path (2K channels + // get HEVC, which has no legacy framing, so it is flattened rather + // than rewritten to 0x17/0x27). Inter frame + CodedFramesX here, so + // the rebuilt header byte is 0x80 | 2 << 4 | 3 = 0xA3. + let onetrack = [0xA6, 0x03, b'h', b'v', b'c', b'1', 1, 0x10, 0x20, 0x30]; + assert_eq!( + flatten_multitrack_video(&onetrack).unwrap(), + [0xA3, b'h', b'v', b'c', b'1', 0x10, 0x20, 0x30] + ); + + // ManyTracks (mt_type 1): one FourCC, then [TrackId][Size(3)][body] + // per track. The first listed track is the primary and the rest go. + let many_tracks = [ + 0x96, 0x11, b'h', b'v', b'c', b'1', 0, 0, 0, 3, 0xA1, 0xA2, 0xA3, 1, 0, 0, 2, 0xB1, + 0xB2, + ]; + assert_eq!( + flatten_multitrack_video(&many_tracks).unwrap(), + [0x91, b'h', b'v', b'c', b'1', 0xA1, 0xA2, 0xA3] + ); + + // ManyTracksManyCodecs (mt_type 2): [TrackId][FourCC][Size(3)][body] + // per track, so the FourCC comes from the first track's own entry. + let many_codecs = [ + 0x96, 0x21, 0, b'a', b'v', b'0', b'1', 0, 0, 2, 0xC1, 0xC2, 1, b'h', b'v', b'c', b'1', + 0, 0, 1, 0xD1, + ]; + assert_eq!( + flatten_multitrack_video(&many_codecs).unwrap(), + [0x91, b'a', b'v', b'0', b'1', 0xC1, 0xC2] + ); + + // A first track that claims more bytes than the tag holds, and a + // layout type the spec reserves, both refuse rather than guess. + let mut overlong = many_tracks; + overlong[9] = 0xFF; + assert_eq!(flatten_multitrack_video(&overlong), None); + let mut reserved = onetrack; + reserved[1] = 0x31; + assert_eq!(flatten_multitrack_video(&reserved), None); + // Not multitrack at all. + assert_eq!( + flatten_multitrack_video(&avc_single_track_keyframe_bytes()), + None + ); + } + #[test] fn select_video_bytes_drops_nonzero_track_multitrack_for_non_twitch() { // The core fix: OBS sends the simulcast ladder as OneTrack @@ -1469,7 +1622,8 @@ mod tests { // Forwarding every one to YouTube delivered N frames per PTS // → decoder storm → reconnect cascade. These tags must be // dropped; the primary arrives separately as a legacy / - // Enhanced single-track tag. + // Enhanced single-track tag. With Dual Format, TrackId 1+ also + // carries the vertical canvas, which a horizontal dest never wants. for track in 1u8..=4 { let tag = enhanced_rtmp_onetrack_video_bytes_track(track); assert!( @@ -1479,48 +1633,6 @@ mod tests { } } - // ── Twitch Dual Format (dual canvas) forwarding ────────────────── - // - // Dual Format = a horizontal (canvas 0) and a vertical (canvas 1) - // layout sent together over the one Enhanced Broadcasting RTMP - // connection. `canvas_index` lives only in the GetClientConfiguration - // JSON; on the wire the canvases are just more TrackIds. So the - // existing per-track passthrough already carries the vertical canvas: - // these tests pin that down explicitly with dual-format framing. - - #[test] - fn select_video_bytes_forwards_dual_canvas_to_twitch() { - // Horizontal primary (TrackId 0) and a vertical-canvas rung - // (TrackId 1) both reach Twitch byte-for-byte. Nothing about a - // second canvas changes the Twitch passthrough contract. - let horizontal = enhanced_rtmp_onetrack_video_bytes_track(0); - let vertical = enhanced_rtmp_onetrack_video_bytes_track(1); - for tag in [&horizontal, &vertical] { - let twitch = select_video_bytes(tag, VideoEgress::Passthrough) - .expect("twitch must forward both canvases"); - assert_eq!(twitch.as_ref(), tag.as_slice()); - assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); - } - } - - #[test] - fn select_video_bytes_keeps_horizontal_drops_vertical_for_non_twitch() { - // A non-Twitch destination (YouTube/Kick) running alongside a - // Dual Format Twitch session keeps the horizontal primary and - // drops the vertical canvas - it has no concept of a second - // canvas, and TrackId != 0 is exactly the vertical rungs. - let horizontal = enhanced_rtmp_onetrack_video_bytes_track(0); - let vertical = enhanced_rtmp_onetrack_video_bytes_track(1); - assert!( - select_video_bytes(&horizontal, VideoEgress::Track(0)).is_some(), - "horizontal canvas (TrackId 0) must reach non-Twitch" - ); - assert!( - select_video_bytes(&vertical, VideoEgress::Track(0)).is_none(), - "vertical canvas (TrackId 1) must be dropped for non-Twitch" - ); - } - // ── Vertical-canvas selection: SPS orientation + Track(n) routing ── // // A vertical destination forwards ONLY the portrait canvas's track, @@ -1564,6 +1676,15 @@ mod tests { } self.put_bits(code, bits); } + /// Signed Exp-Golomb: 0, +1, -1, +2, ... map to codes 0, 1, 2, 3, ... + fn put_se(&mut self, v: i32) { + let code = if v > 0 { + 2 * v.unsigned_abs() - 1 + } else { + 2 * v.unsigned_abs() + }; + self.put_ue(code); + } fn finish(mut self) -> Vec { self.put_bit(1); // rbsp_stop_one_bit while self.nbits != 0 { @@ -1655,6 +1776,220 @@ mod tests { assert_eq!(sps_dimensions(&hevc), None); } + /// The SPS fields that change how many bits sit in front of the picture + /// size. Everything else is fixed by `build_rich_sps_rbsp`. + struct SpsFields { + profile_idc: u32, + chroma_format_idc: u32, + /// `(list index, delta_scale values)` for each scaling list sent. + /// Empty means `seq_scaling_matrix_present_flag = 0`. + scaling_lists: Vec<(usize, Vec)>, + pic_order_cnt_type: u32, + frame_mbs_only: bool, + width_in_mbs: u32, + height_in_map_units: u32, + /// (left, right, top, bottom) in crop units. + crop: Option<(u32, u32, u32, u32)>, + } + + impl SpsFields { + fn progressive(width_in_mbs: u32, height_in_map_units: u32) -> Self { + Self { + profile_idc: 100, + chroma_format_idc: 1, + scaling_lists: Vec::new(), + pic_order_cnt_type: 0, + frame_mbs_only: true, + width_in_mbs, + height_in_map_units, + crop: None, + } + } + } + + /// SPS RBSP per H.264 7.3.2.1.1, written field by field. The caller + /// supplies scaling-list deltas that end each list the way the spec + /// does (the reader stops reading once nextScale hits 0). + fn build_rich_sps_rbsp(f: &SpsFields) -> Vec { + let mut w = BitWriter::new(); + w.put_bits(f.profile_idc, 8); + w.put_bits(0, 8); // constraint flags + reserved + w.put_bits(40, 8); // level_idc + w.put_ue(0); // seq_parameter_set_id + if matches!(f.profile_idc, 100 | 110 | 122 | 244) { + w.put_ue(f.chroma_format_idc); + if f.chroma_format_idc == 3 { + w.put_bit(0); // separate_colour_plane_flag + } + w.put_ue(0); // bit_depth_luma_minus8 + w.put_ue(0); // bit_depth_chroma_minus8 + w.put_bit(0); // qpprime_y_zero_transform_bypass_flag + w.put_bit(u32::from(!f.scaling_lists.is_empty())); + if !f.scaling_lists.is_empty() { + let count = if f.chroma_format_idc == 3 { 12 } else { 8 }; + for i in 0..count { + match f.scaling_lists.iter().find(|(idx, _)| *idx == i) { + Some((_, deltas)) => { + w.put_bit(1); + deltas.iter().for_each(|d| w.put_se(*d)); + } + None => w.put_bit(0), + } + } + } + } + w.put_ue(0); // log2_max_frame_num_minus4 + w.put_ue(f.pic_order_cnt_type); + match f.pic_order_cnt_type { + 0 => w.put_ue(2), // log2_max_pic_order_cnt_lsb_minus4 + 1 => { + w.put_bit(0); // delta_pic_order_always_zero_flag + w.put_se(-2); // offset_for_non_ref_pic + w.put_se(5); // offset_for_top_to_bottom_field + w.put_ue(3); // num_ref_frames_in_pic_order_cnt_cycle + [1, -1, 7].iter().for_each(|o| w.put_se(*o)); + } + _ => {} + } + w.put_ue(4); // max_num_ref_frames + w.put_bit(0); // gaps_in_frame_num_value_allowed_flag + w.put_ue(f.width_in_mbs - 1); + w.put_ue(f.height_in_map_units - 1); + w.put_bit(u32::from(f.frame_mbs_only)); + if !f.frame_mbs_only { + w.put_bit(1); // mb_adaptive_frame_field_flag + } + w.put_bit(1); // direct_8x8_inference_flag + match f.crop { + Some((left, right, top, bottom)) => { + w.put_bit(1); + [left, right, top, bottom].iter().for_each(|c| w.put_ue(*c)); + } + None => w.put_bit(0), + } + w.put_bit(0); // vui_parameters_present_flag + w.finish() + } + + fn rich_sps_dimensions(f: &SpsFields) -> Option<(u32, u32)> { + sps_dimensions(&seq_header_around(&build_rich_sps_rbsp(f), false)) + } + + #[test] + fn sps_dimensions_decodes_real_x264_sequence_headers() { + // Real High-profile SPS NALs from x264, not produced by the test + // BitWriter, so a bug shared by the writer and the reader can't + // cancel out. 1280x720 has no cropping; 1920x1080 is coded as + // 1920x1088 with frame_crop_bottom_offset = 4 (x2 for 4:2:0). + let sps_720p = [ + 0x64, 0x00, 0x1F, 0xAC, 0xD9, 0x40, 0x50, 0x05, 0xBB, 0x01, 0x10, 0x00, 0x00, 0x03, + 0x00, 0x10, 0x00, 0x00, 0x03, 0x03, 0xC0, 0xF1, 0x83, 0x19, 0x60, + ]; + let sps_1080p = [ + 0x64, 0x00, 0x28, 0xAC, 0xD9, 0x40, 0x78, 0x02, 0x27, 0xE5, 0xC0, 0x44, 0x00, 0x00, + 0x03, 0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0xF0, 0x3C, 0x60, 0xC6, 0x58, + ]; + assert_eq!( + sps_dimensions(&seq_header_around(&sps_720p, false)), + Some((1280, 720)) + ); + assert_eq!( + sps_dimensions(&seq_header_around(&sps_1080p, true)), + Some((1920, 1080)) + ); + } + + #[test] + fn sps_dimensions_walks_high_profile_scaling_lists() { + // Each list must be skipped by exactly the bits it occupies or the + // size fields after it decode as garbage. The deltas cover a list + // that ends on its first read, one that takes two, both ends of the + // legal delta_scale range (-128 and 127), and an 8x8 list that never + // hits nextScale = 0 and so reads all 64 entries. + let mut f = SpsFields::progressive(120, 68); + f.scaling_lists = vec![ + (0, vec![-8]), + (1, vec![3, -11]), + (2, vec![127, 121]), + (3, vec![-128, 120]), + (6, vec![0; 64]), + ]; + f.crop = Some((0, 0, 0, 4)); + assert_eq!(rich_sps_dimensions(&f), Some((1920, 1080))); + } + + #[test] + fn sps_dimensions_rejects_out_of_range_scaling_deltas_without_panicking() { + // delta_scale is -128..=127 by spec. A hostile SPS can carry up to + // +/-(2^31 - 1), and `last_scale + delta + 256` overflowed i32: a + // panic in debug, a silently wrong parse in release. Nonsense like + // that means the SPS is not one we can size, so the parse gives up. + for bad_delta in [128, -129, i32::MAX, -i32::MAX] { + let mut f = SpsFields::progressive(120, 68); + f.scaling_lists = vec![(0, vec![bad_delta, 0])]; + assert_eq!(rich_sps_dimensions(&f), None, "delta {bad_delta}"); + } + } + + #[test] + fn sps_dimensions_applies_cropping_per_chroma_format() { + // Portrait 1080x1920 is coded 1088 wide; crop_right 4 x 2 (4:2:0). + let mut portrait = SpsFields::progressive(68, 120); + portrait.crop = Some((0, 4, 0, 0)); + assert_eq!(rich_sps_dimensions(&portrait), Some((1080, 1920))); + + // 4:2:2 crops vertically in single lines: bottom 8 x 1. + let mut yuv422 = SpsFields::progressive(120, 68); + yuv422.profile_idc = 122; + yuv422.chroma_format_idc = 2; + yuv422.crop = Some((0, 0, 0, 8)); + assert_eq!(rich_sps_dimensions(&yuv422), Some((1920, 1080))); + + // 4:4:4 has 12 scaling lists (the last six 8x8) and a crop unit of + // one pixel in both directions. + let mut yuv444 = SpsFields::progressive(120, 68); + yuv444.profile_idc = 244; + yuv444.chroma_format_idc = 3; + yuv444.scaling_lists = vec![(8, vec![-8]), (11, vec![0; 64])]; + yuv444.crop = Some((0, 16, 0, 8)); + assert_eq!(rich_sps_dimensions(&yuv444), Some((1904, 1080))); + } + + #[test] + fn sps_dimensions_handles_interlaced_and_every_poc_type() { + // frame_mbs_only = 0: height counts field pairs (2 x 34 x 16 = + // 1088), there is an extra mb_adaptive bit, and the vertical crop + // unit doubles: bottom 2 x 2 x 2 = 8 lines. Main profile, so no + // High-profile block, with poc type 2 (no extra fields). + let mut interlaced = SpsFields::progressive(120, 34); + interlaced.profile_idc = 77; + interlaced.frame_mbs_only = false; + interlaced.pic_order_cnt_type = 2; + interlaced.crop = Some((0, 0, 0, 2)); + assert_eq!(rich_sps_dimensions(&interlaced), Some((1920, 1080))); + + // poc type 1 carries signed offsets and a cycle before the size. + let mut poc1 = SpsFields::progressive(40, 30); + poc1.profile_idc = 66; + poc1.pic_order_cnt_type = 1; + assert_eq!(rich_sps_dimensions(&poc1), Some((640, 480))); + } + + #[test] + fn strip_emulation_prevention_drops_only_the_escape_byte() { + // 00 00 03 xx is how an encoder escapes 00 00 0x inside a NAL; the + // 03 goes, and the zero count restarts after it. + assert_eq!(strip_emulation_prevention(&[0, 0, 3, 1]), [0, 0, 1]); + assert_eq!(strip_emulation_prevention(&[0, 0, 3, 3]), [0, 0, 3]); + assert_eq!( + strip_emulation_prevention(&[0, 0, 3, 0, 0, 3, 0]), + [0, 0, 0, 0, 0] + ); + // One zero is not enough to start an escape. + assert_eq!(strip_emulation_prevention(&[0, 3, 1]), [0, 3, 1]); + assert_eq!(strip_emulation_prevention(&[0x42, 3]), [0x42, 3]); + } + #[test] fn sps_dimensions_huge_crop_returns_none_not_panic() { // A malformed SPS can carry near-u32::MAX frame-crop Exp-Golomb @@ -1692,28 +2027,305 @@ mod tests { } #[test] - fn sps_and_selection_never_panic_on_fuzz() { - // Deterministic pseudo-random byte blobs, wrapped as AVC seq - // headers, pushed through the parser + selector. These run on - // hostile wire input, so the invariant is simply: never panic. - let mut seed: u32 = 0x9e3779b9; - for _ in 0..3000 { - seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); - let n = (seed as usize % 48) + 1; - let mut blob = Vec::with_capacity(n); - let mut x = seed; - for _ in 0..n { - x = x.wrapping_mul(1103515245).wrapping_add(12345); - blob.push((x >> 16) as u8); + fn a_zero_length_nal_at_the_end_of_a_tag_is_not_an_idr_and_does_not_panic() { + // A length prefix of 0 as the last 4 bytes passes the "does the NAL + // fit" check (4 + 0 <= 4) and the walk then read the NAL header one + // byte past the slice. That runs on every published video tag, and + // with `panic = "abort"` one malformed frame killed the process. + let bare = [0x17, 0x01, 0, 0, 0, 0, 0, 0, 0]; + assert!(!classify_video_tag(&bare).is_idr); + + let mut after_a_slice = avc_tag(1, &[nal(1, 10)]); + after_a_slice.extend_from_slice(&[0, 0, 0, 0]); + assert!(!classify_video_tag(&after_a_slice).is_idr); + + // An empty NAL in the middle is skipped, so an IDR after it still counts. + let mut idr_after_empty = avc_tag(1, &[]); + idr_after_empty.extend_from_slice(&[0, 0, 0, 0]); + idr_after_empty.extend_from_slice(&nal(5, 8)); + assert!(classify_video_tag(&idr_after_empty).is_idr); + } + + /// Deterministic LCG for the fuzz tests: fixed seed, no dependency, and a + /// failure reproduces from the iteration number alone. + struct Lcg(u64); + + impl Lcg { + fn next_u32(&mut self) -> u32 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as u32 + } + + fn below(&mut self, n: u32) -> u32 { + self.next_u32() % n + } + + fn pick(&mut self, items: &[T]) -> T { + items[self.below(items.len() as u32) as usize] + } + + /// Zero- and 0xFF-heavy bytes. Uniform bytes almost never form a + /// zero length prefix or a long Exp-Golomb run, which are exactly the + /// shapes that break parsers. + fn byte(&mut self) -> u8 { + match self.below(8) { + 0..=2 => 0x00, + 3 => 0xFF, + _ => self.next_u32() as u8, } - // Legacy AVC framing so the bytes reach the SPS Exp-Golomb path. - let mut tag = vec![0x17u8, 0x00, 0, 0, 0]; - tag.extend_from_slice(&blob); + } + + fn bytes(&mut self, max_len: u32) -> Vec { + let n = self.below(max_len + 1); + (0..n).map(|_| self.byte()).collect() + } + } + + /// Length-prefixed NALs whose declared lengths are sometimes honest, + /// sometimes zero and sometimes past the end of the tag. + fn fuzz_nal_units(rng: &mut Lcg) -> Vec { + let mut out = Vec::new(); + for _ in 0..rng.below(4) { + let body = rng.bytes(12); + let declared = match rng.below(4) { + 0 => 0, + 1 => rng.next_u32(), + _ => body.len() as u32, + }; + out.extend_from_slice(&declared.to_be_bytes()); + out.extend_from_slice(&body); + } + out + } + + /// One tag shaped like the ones OBS sends (legacy AVC/AAC, Enhanced + /// single-track and multi-track video/audio) with a random body. + fn fuzz_tag(rng: &mut Lcg) -> Vec { + let b0 = match rng.below(8) { + 0 => 0x17, + 1 => 0x27, + 2 => 0x90 | rng.below(16) as u8, + 3 => 0x96, + 4 => 0xA6, + 5 => 0x95, + 6 => 0xAF, + _ => rng.next_u32() as u8, + }; + let mut tag = vec![b0]; + if rng.below(4) == 0 { + tag.extend(rng.bytes(24)); + return tag; + } + let fourccs = [ + *b"avc1", *b"hvc1", *b"hev1", *b"av01", *b"vp09", *b"mp4a", *b"zzzz", + ]; + let multitrack = b0 & 0x80 != 0 && (b0 & 0x0F == 6 || b0 == 0x95); + if b0 & 0x80 == 0 { + // Legacy: [AVCPacketType][CompositionTime(3)][NALs]. + tag.push(rng.pick(&[0u8, 1, 2])); + tag.extend_from_slice(&[0, 0, rng.byte()]); + tag.extend(fuzz_nal_units(rng)); + } else if !multitrack { + tag.extend_from_slice(&rng.pick(&fourccs)); + tag.extend(rng.bytes(16)); + } else { + let mt_type = rng.below(4) as u8; + tag.push((mt_type << 4) | rng.pick(&[0u8, 1, 3, 4])); + match mt_type { + 0 => { + // OneTrack: [FourCC][TrackId][body]. + tag.extend_from_slice(&rng.pick(&fourccs)); + tag.push(rng.below(3) as u8); + tag.extend(rng.bytes(16)); + } + 1 | 2 => { + // ManyTracks: [FourCC] then [TrackId][Size(3)][body]... + // ManyTracksManyCodecs: [TrackId][FourCC][Size(3)][body]... + if mt_type == 1 { + tag.extend_from_slice(&rng.pick(&fourccs)); + } + for _ in 0..=rng.below(3) { + tag.push(rng.below(3) as u8); + if mt_type == 2 { + tag.extend_from_slice(&rng.pick(&fourccs)); + } + let body = rng.bytes(8); + let honest = rng.below(4) != 0; + let size = if honest { + body.len() as u32 + } else { + rng.below(0x100) + }; + tag.extend_from_slice(&size.to_be_bytes()[1..]); + tag.extend(body); + } + } + _ => tag.extend(rng.bytes(16)), + } + } + if rng.below(2) == 0 { + let keep = rng.below(tag.len() as u32 + 1) as usize; + tag.truncate(keep); + } + tag + } + + /// Exp-Golomb values a hostile SPS would use: tiny, around the limits + /// the parser enforces, and as large as the 32-bit code allows. + fn fuzz_ue(rng: &mut Lcg) -> u32 { + rng.pick(&[ + 0, + 1, + 2, + 3, + 7, + 8, + 67, + 119, + 255, + 1 << 16, + 0x7FFF_FFFF, + 0xFFFF_FFFD, + ]) + } + + fn fuzz_se(rng: &mut Lcg) -> i32 { + rng.pick(&[ + 0, + 1, + -1, + -8, + 127, + -128, + 128, + -129, + 1 << 20, + i32::MAX, + -i32::MAX, + ]) + } + + /// An SPS RBSP built by the grammar, so the fuzz reaches the scaling + /// lists and the crop fields instead of dying on the first bad field. + fn fuzz_sps_rbsp(rng: &mut Lcg) -> Vec { + let mut w = BitWriter::new(); + w.put_bits(rng.pick(&[66, 77, 100, 110, 122, 244]), 8); + w.put_bits(0, 8); + w.put_bits(40, 8); + w.put_ue(fuzz_ue(rng) % 32); + w.put_ue(rng.pick(&[0, 1, 2, 3, 0x7FFF_FFFF])); + w.put_bit(rng.below(2)); + w.put_ue(rng.below(3)); + w.put_ue(rng.below(3)); + w.put_bit(0); + w.put_bit(1); // seq_scaling_matrix_present_flag + for _ in 0..rng.below(13) { + w.put_bit(rng.below(2)); + for _ in 0..rng.below(6) { + w.put_se(fuzz_se(rng)); + } + } + for _ in 0..rng.below(12) { + w.put_ue(fuzz_ue(rng)); + } + let mut rbsp = w.finish(); + rbsp.truncate(rng.below(rbsp.len() as u32 + 1) as usize); + rbsp + } + + /// An AVC sequence header with a correct spsLength around `rbsp`, in + /// either legacy or OneTrack framing, so the bytes reach the SPS parser. + fn seq_header_around(rbsp: &[u8], onetrack: bool) -> Vec { + let mut sps = vec![0x67u8]; + sps.extend_from_slice(rbsp); + let mut tag = if onetrack { + let mut t = vec![0x96u8, 0x00]; + t.extend_from_slice(b"avc1"); + t.push(1); + t + } else { + vec![0x17u8, 0x00, 0, 0, 0] + }; + tag.extend_from_slice(&[1, 100, 0, 40, 0xFF, 0xE1]); + tag.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + tag.extend_from_slice(&sps); + tag.push(0); + tag + } + + #[test] + fn every_tag_parser_survives_shape_aware_fuzz() { + // Every function here runs on raw publisher bytes, and a release + // build aborts on panic, so the floor is "never panic". On top of + // that, the invariants the egress pumps rely on must hold for any + // input: passthrough is always the exact bytes, and a single-track + // tag always reaches a Track(0) destination untouched. + let mut rng = Lcg(0x1C_2026); + for _ in 0..20_000 { + let tag = fuzz_tag(&mut rng); + let info = classify_video_tag(&tag); + let _ = classify_audio_tag(&tag); + let _ = is_primary_video_idr(&tag); + let _ = seq_header_track_id(&tag); + let _ = audio_seq_header_track_id(&tag); + let _ = flatten_multitrack_video(&tag); + let _ = seq_header_codec(&tag); let _ = sps_dimensions(&tag); - let _ = select_video_bytes(&tag, VideoEgress::Track((seed & 0xFF) as u8)); - let mut m = std::collections::BTreeMap::new(); - m.insert((seed & 0x7) as u8, tag); - let _ = detect_vertical_primary_track(&m); + let target = rng.below(4) as u8; + let _ = flatten_multitrack_audio(&tag, target); + for egress in [ + VideoEgress::Track(0), + VideoEgress::Track(1), + VideoEgress::Track(target), + ] { + let _ = select_video_bytes(&tag, egress); + } + for egress in [AudioEgress::Track(0), AudioEgress::Track(1)] { + for on_wire in [false, true] { + let _ = select_audio_bytes(&tag, egress, on_wire); + } + } + let passthrough = select_video_bytes(&tag, VideoEgress::Passthrough).unwrap(); + assert_eq!(passthrough.as_ref(), tag.as_slice()); + let passthrough = select_audio_bytes(&tag, AudioEgress::Passthrough, true).unwrap(); + assert_eq!(passthrough.as_ref(), tag.as_slice()); + if !info.is_multitrack { + let primary = select_video_bytes(&tag, VideoEgress::Track(0)).unwrap(); + assert_eq!(primary.as_ref(), tag.as_slice()); + } + } + } + + #[test] + fn sps_parsing_survives_random_and_grammar_built_fuzz() { + // Half the SPSs are raw bytes after a profile byte (High profile + // half the time, the one with the extra fields); half are built by + // the grammar with extreme field values, which is what reaches the + // scaling-list arithmetic. Both are wrapped with a correct spsLength + // so the parser, not the framing check, is what gets exercised. + let mut rng = Lcg(0x5B5_2026); + for i in 0..20_000 { + let rbsp = if i % 2 == 0 { + let mut raw = vec![if rng.below(2) == 0 { + 100 + } else { + rng.next_u32() as u8 + }]; + raw.extend(rng.bytes(40)); + raw + } else { + fuzz_sps_rbsp(&mut rng) + }; + let tag = seq_header_around(&rbsp, rng.below(2) == 0); + if let Some((w, h)) = sps_dimensions(&tag) { + assert!(w > 0 && h > 0, "a decoded size is never empty"); + } + let mut headers = std::collections::BTreeMap::new(); + headers.insert(rng.below(4) as u8, tag); + let _ = detect_vertical_primary_track(&headers); } } @@ -1761,8 +2373,7 @@ mod tests { let kept = select_video_bytes(&track1, VideoEgress::Track(1)) .expect("vertical target track must forward"); - let flat = flatten_multitrack_video(&track1).expect("OneTrack flattens"); - assert_eq!(kept.as_ref(), flat.as_slice()); + assert_eq!(kept.as_ref(), flat_hvc1_keyframe().as_slice()); assert!( select_video_bytes(&track2, VideoEgress::Track(1)).is_none(), @@ -1868,6 +2479,10 @@ mod tests { // EB ladder rungs 1..=4 each carry their own IDR cadence but // must NOT be picked as a cut anchor - if the cut lands here // the legacy decoder on the destination has no reference. + // Twitch Dual Format's vertical canvas is one of these TrackIds, + // so cuts are anchored to the horizontal canvas only; a vertical + // cut is clean because OBS aligns both canvases' keyframes. If + // cuts ever become canvas-aware, this is the guard that must flip. for track in 1u8..=4 { let tag = enhanced_rtmp_onetrack_video_bytes_track(track); assert!( @@ -1877,30 +2492,6 @@ mod tests { } } - #[test] - fn is_primary_video_idr_anchors_dual_format_cuts_to_horizontal_canvas() { - // Dual Format: the cut-point index is built only from the - // horizontal primary (TrackId 0); the vertical canvas's primary - // (a nonzero TrackId) is NOT a cut anchor. That means a Cut lands - // cleanly on the vertical canvas ONLY IF OBS aligned both - // canvases' keyframes to the same input timestamp - which it does - // when both encoders share keyint, but that is an OBS behaviour we - // can't prove from inside this crate (it needs one real capture). - // This test pins our half of the contract: cuts are anchored to - // the horizontal canvas. If a future change must make cuts - // canvas-aware, this is the regression guard that has to flip. - let horizontal_idr = enhanced_rtmp_onetrack_video_bytes_track(0); - let vertical_idr = enhanced_rtmp_onetrack_video_bytes_track(1); - assert!( - is_primary_video_idr(&horizontal_idr), - "horizontal canvas IDR must be a cut anchor" - ); - assert!( - !is_primary_video_idr(&vertical_idr), - "vertical canvas IDR must not (yet) be an independent cut anchor" - ); - } - #[test] fn is_primary_video_idr_rejects_enhanced_p_frame() { // Enhanced-RTMP single-track inter-frame: IsEx | FrameType=2 | @@ -2017,6 +2608,8 @@ mod tests { ); assert!(!info.is_multitrack); assert_eq!(info.codec, AudioCodec::Aac); + // sink.rs asks through the shim, which must agree. + assert!(is_aac_seq_header(&tag)); } #[test] @@ -2084,8 +2677,8 @@ mod tests { AudioEgress::Track(0), AudioEgress::Track(1), ] { - let out = - select_audio_bytes(&legacy_aac, egress).expect("single-track audio must forward"); + let out = select_audio_bytes(&legacy_aac, egress, false) + .expect("single-track audio must forward"); assert_eq!(out.as_ref(), legacy_aac.as_slice()); assert!(matches!(out, std::borrow::Cow::Borrowed(_))); } @@ -2097,18 +2690,37 @@ mod tests { // track (OneTrack TrackId 0) still gets audio: the requested track is // missing, so it falls back to the live track 0, flattened to legacy. let live_only = enhanced_audio_onetrack(0, 1); - let out = select_audio_bytes(&live_only, AudioEgress::Track(1)) + let out = select_audio_bytes(&live_only, AudioEgress::Track(1), false) .expect("must fall back to the live track, not go silent"); assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); } + /// OBS sends its live track as plain AAC and the VOD / clean track as a + /// OneTrack multi-track tag. Once the clean track is on the wire, a + /// "Track 2" destination must get only it: forwarding the live tags too + /// interleaved both tracks, copyrighted live mix included. + #[test] + fn a_track_two_destination_never_gets_the_live_track_once_the_second_is_sent() { + let live_legacy = vec![0xaf, 0x01, 0x12, 0x10, 0x56]; + let live_multitrack = enhanced_audio_onetrack(0, 1); + let clean = enhanced_audio_onetrack(1, 1); + let egress = AudioEgress::Track(1); + assert!(select_audio_bytes(&live_legacy, egress, true).is_none()); + assert!(select_audio_bytes(&live_multitrack, egress, true).is_none()); + let out = select_audio_bytes(&clean, egress, true).expect("the clean track goes out"); + assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); + // The live track's own destinations are unaffected. + assert!(select_audio_bytes(&live_legacy, AudioEgress::Track(0), true).is_some()); + assert!(select_audio_bytes(&clean, AudioEgress::Track(0), true).is_none()); + } + #[test] fn select_audio_bytes_passes_multitrack_through_for_twitch() { // Twitch destination (Passthrough): every track's bytes flow // through bit-faithfully so Twitch's VOD-audio slot keeps its feed. for track in 0u8..=3 { let tag = enhanced_audio_onetrack(track, 1); - let out = select_audio_bytes(&tag, AudioEgress::Passthrough) + let out = select_audio_bytes(&tag, AudioEgress::Passthrough, true) .unwrap_or_else(|| panic!("twitch must forward track {track}")); assert_eq!(out.as_ref(), tag.as_slice()); } @@ -2122,7 +2734,7 @@ mod tests { for track in 1u8..=3 { let tag = enhanced_audio_onetrack(track, 1); assert!( - select_audio_bytes(&tag, AudioEgress::Track(0)).is_none(), + select_audio_bytes(&tag, AudioEgress::Track(0), true).is_none(), "TrackId {track} must be dropped when the dest wants Track(0)", ); } @@ -2133,12 +2745,12 @@ mod tests { // The clean/second audio track routed to a single-track dest is // flattened to legacy AAC (0xAF ...), the universally-accepted form. let live = enhanced_audio_onetrack(0, 1); - let out = select_audio_bytes(&live, AudioEgress::Track(0)) + let out = select_audio_bytes(&live, AudioEgress::Track(0), true) .expect("track 0 must reach a Track(0) destination"); assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); let clean = enhanced_audio_onetrack(1, 1); - let out = select_audio_bytes(&clean, AudioEgress::Track(1)) + let out = select_audio_bytes(&clean, AudioEgress::Track(1), true) .expect("track 1 must reach a Track(1) destination"); assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); } @@ -2193,4 +2805,55 @@ mod tests { vec![0x91, b'O', b'p', b'u', b's', 0x33, 0x44], ); } + + /// Enhanced RTMP: only coded frames carry a picture. A SequenceEnd + /// (the encoder stopping) flagged as a keyframe was taken for one - a + /// cut point, and crash protection's held "last keyframe" - and AV1's + /// MPEG2TSSequenceStart config was never cached as a header. + #[test] + fn only_coded_frames_count_as_keyframes() { + let tag = |first: u8| vec![first, b'h', b'v', b'c', b'1', 0, 0, 0, 1]; + // IsEx | FrameType 1 (key) | PacketType. + assert!(classify_video_tag(&tag(0x91)).is_idr, "CodedFrames"); + assert!(classify_video_tag(&tag(0x93)).is_idr, "CodedFramesX"); + let end = classify_video_tag(&tag(0x92)); + assert!(!end.is_idr && !end.is_seq_header, "SequenceEnd"); + let ts_config = classify_video_tag(&tag(0x95)); + assert!( + !ts_config.is_idr && ts_config.is_seq_header, + "MPEG2TSSequenceStart" + ); + + // Multitrack: the nested packet type decides. + let multitrack = |nested: u8| vec![0x96, nested, b'a', b'v', b'c', b'1', 0, 0, 0, 1]; + assert!(classify_video_tag(&multitrack(0x01)).is_idr); + assert!( + !classify_video_tag(&multitrack(0x02)).is_idr, + "nested SequenceEnd" + ); + assert!(classify_video_tag(&multitrack(0x05)).is_seq_header); + } + + /// AVC allows 1- or 2-byte NAL lengths (lengthSizeMinusOne). A stream + /// using them had no cut points; OBS's 4-byte lengths read as before. + #[test] + fn keyframes_are_found_with_short_nal_lengths() { + let legacy = |nals: &[u8]| { + let mut tag = vec![0x17, 1, 0, 0, 0]; + tag.extend_from_slice(nals); + tag + }; + // 2-byte lengths: an SEI (type 6), then an IDR slice (type 5). + let two = legacy(&[0, 2, 0x06, 0xAA, 0, 3, 0x65, 0xBB, 0xCC]); + assert!(classify_video_tag(&two).is_idr); + // 1-byte lengths. + let one = legacy(&[2, 0x06, 0xAA, 2, 0x65, 0xBB]); + assert!(classify_video_tag(&one).is_idr); + // A P-slice with short lengths is not a keyframe. + let p_slice = legacy(&[0, 2, 0x41, 0xAA]); + assert!(!classify_video_tag(&p_slice).is_idr); + // 4-byte lengths, as OBS sends them, unchanged. + let four = legacy(&[0, 0, 0, 2, 0x65, 0xBB]); + assert!(classify_video_tag(&four).is_idr); + } } diff --git a/src/https.rs b/src/https.rs index 617d999..181d128 100644 --- a/src/https.rs +++ b/src/https.rs @@ -23,7 +23,8 @@ //! //! Use [`https_agent`] everywhere we need to call out to an HTTPS URL. -use ureq::tls::{TlsConfig, TlsProvider}; +use std::time::Duration; +use ureq::tls::{RootCerts, TlsConfig, TlsProvider}; use ureq::Agent; /// Build a ready-to-use ureq `Agent` with native-tls selected as the @@ -31,10 +32,29 @@ use ureq::Agent; /// access to non-2xx response bodies. Native-tls uses Windows schannel /// under the hood - no rustls + ring dependency chain. pub fn https_agent() -> Agent { + agent(None) +} + +/// `https_agent` whose every call gives up after `timeout`, connect to last +/// byte. For calls made from a loop that must keep going: a stalled server +/// would otherwise hold it forever. +pub fn https_agent_with_timeout(timeout: Duration) -> Agent { + agent(Some(timeout)) +} + +fn agent(timeout: Option) -> Agent { Agent::config_builder() + .timeout_global(timeout) .tls_config( TlsConfig::builder() .provider(TlsProvider::NativeTls) + // The OS certificate store (schannel on Windows). ureq's + // default hands native-tls a bundled root list instead, and + // schannel then refuses any server whose chain it builds to + // a root outside that list: Discord's does, so every + // webhook failed with "unable to find any user-specified + // roots in the final cert chain". + .root_certs(RootCerts::PlatformVerifier) .build(), ) // Non-2xx as Ok(resp): the Twitch proxy needs the 4xx body to @@ -44,3 +64,20 @@ pub fn https_agent() -> Agent { .build() .into() } + +#[cfg(test)] +mod tests { + /// Network smoke test for the TLS setup; run by hand with `--ignored`. + #[test] + #[ignore] + fn reaches_real_https_hosts() { + for url in [ + "https://discord.com/api/v10/gateway", + "https://id.twitch.tv/oauth2/validate", + "https://ntfy.sh/v1/health", + ] { + let r = super::https_agent().get(url).call(); + assert!(r.is_ok(), "{url}: {:?}", r.err()); + } + } +} 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/alerts.rs b/src/integrations/alerts.rs new file mode 100644 index 0000000..49801d0 --- /dev/null +++ b/src/integrations/alerts.rs @@ -0,0 +1,133 @@ +//! What "Show on stream" steps put on the alerts browser source. +//! +//! The board keeps the last few alerts with an increasing id. The page in +//! OBS (`/alerts`) reads them over its own event stream and plays each new +//! id once, in order, so two alerts close together queue instead of +//! overlapping. Only what a step chose to show is here: it is on stream +//! anyway, which is why the page needs no login. + +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +/// Alerts kept for a page that (re)connects. Older ones are never replayed. +const KEEP: usize = 8; +/// How long an alert stays on the board: a page that reconnects after this +/// no longer plays it. +const FRESH: Duration = Duration::from_secs(30); +pub const MIN_SECONDS: u64 = 2; +pub const MAX_SECONDS: u64 = 30; +const MAX_TITLE: usize = 80; +const MAX_TEXT: usize = 300; + +struct Alert { + id: u64, + title: String, + text: String, + ms: u64, + at: Instant, +} + +pub struct Board { + /// Changes with every start of the app, so a page that outlives a + /// restart knows the ids started over. + boot: String, + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + last_id: u64, + alerts: VecDeque, +} + +impl Board { + pub fn new() -> Board { + Board { + boot: crate::crypto::random_token()[..8].to_string(), + inner: Mutex::new(Inner::default()), + } + } + + /// Put an alert up for `seconds` (clamped to what reads well). + pub fn show(&self, title: &str, text: &str, seconds: u64) { + let mut inner = self.inner.lock(); + inner.last_id += 1; + let alert = Alert { + id: inner.last_id, + title: one_line(title, MAX_TITLE), + text: one_line(text, MAX_TEXT), + ms: seconds.clamp(MIN_SECONDS, MAX_SECONDS) * 1000, + at: Instant::now(), + }; + if inner.alerts.len() >= KEEP { + inner.alerts.pop_front(); + } + inner.alerts.push_back(alert); + } + + /// The fresh alerts, oldest first. Unchanged between alerts, so the + /// page's event stream only sends when something new is up. + pub fn to_json(&self) -> String { + let mut inner = self.inner.lock(); + inner.alerts.retain(|a| a.at.elapsed() < FRESH); + let alerts = Value::Arr( + inner + .alerts + .iter() + .map(|a| { + json::obj([ + ("id", Value::Num(a.id as f64)), + ("title", json::str(&a.title)), + ("text", json::str(&a.text)), + ("ms", Value::Num(a.ms as f64)), + ]) + }) + .collect(), + ); + json::obj([("boot", json::str(&self.boot)), ("alerts", alerts)]).to_json() + } +} + +fn one_line(text: &str, max: usize) -> String { + let flat: String = text + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(max) + .collect(); + flat.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alerts_queue_with_rising_ids_and_sane_lengths() { + let board = Board::new(); + board.show("Clip!", "by Ana\r\nnice", 1); + board.show("", &"x".repeat(900), 99); + let doc = json::parse(&board.to_json()).unwrap(); + assert_eq!(doc.str_or("boot", "").len(), 8); + let list = doc.get("alerts").and_then(Value::as_array).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].str_or("text", ""), "by Ana nice"); + assert_eq!(list[0].u64_or("ms", 0), MIN_SECONDS * 1000); + assert_eq!(list[1].u64_or("ms", 0), MAX_SECONDS * 1000); + assert_eq!(list[1].str_or("text", "").len(), MAX_TEXT); + assert!(list[1].u64_or("id", 0) > list[0].u64_or("id", 0)); + } + + #[test] + fn only_the_last_few_are_kept() { + let board = Board::new(); + for n in 0..20 { + board.show("", &n.to_string(), 5); + } + let doc = json::parse(&board.to_json()).unwrap(); + let list = doc.get("alerts").and_then(Value::as_array).unwrap(); + assert_eq!(list.len(), KEEP); + assert_eq!(list[0].str_or("text", ""), "12"); + } +} diff --git a/src/integrations/api.rs b/src/integrations/api.rs new file mode 100644 index 0000000..e87eab2 --- /dev/null +++ b/src/integrations/api.rs @@ -0,0 +1,1381 @@ +//! The dashboard's integrations API. Every route here is admin-only (the +//! web layer's access gate defaults to that) except `/hooks/`, which +//! is how outside apps trigger a "web call" integration and is guarded by +//! its secret token instead. +//! +//! Requests and answers are JSON. Settings writes follow the web layer's +//! pattern: take the write lock, clone, change, validate, save, publish. + +use super::event; +use super::model::{ + valid_discord_webhook, valid_id, DiscordChannel, Integration, PhoneConnection, Roles, Trigger, + MAX_INTEGRATIONS, +}; +use super::{engine, presets, recipe, runner, twitch::Which}; +use crate::config::Settings; +use crate::controller::Controller; +use crate::json::{self, Value}; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::watch; + +pub type Reply = (&'static str, &'static str, String); + +fn ok(v: Value) -> Reply { + ("200 OK", "application/json", v.to_json()) +} + +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))]).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))])) +} + +/// Why a Program or File step can't be set up from this request. +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]) +} + +/// Handle `/integrations*`, `/connections/*`, `/twitch/*`. `None` when the +/// path is not ours. +/// +/// `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, + body: &str, + 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(), 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, local_steps_allowed), + ("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(); + update(settings, cfg_path, |s| { + s.integrations.retain(|i| i.id != id); + Ok(()) + }) + } + ("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") => { + handle.alerts.show( + "InstantClone", + "This is where your alerts show up on stream.", + 6, + ); + done() + } + ("POST", "/integrations/midi/learn") => midi_learn(ctrl), + ("POST", "/integrations/midi/poll") => { + let captured = ctrl.midi().take_captured_for(&[MIDI_LEARN]); + ok(json::obj([ + ( + "learning", + Value::Bool(ctrl.midi().learning().as_deref() == Some(MIDI_LEARN)), + ), + ( + "captured", + captured.map_or(Value::Null, |(_, sig)| json::str(sig)), + ), + ])) + } + ("POST", "/integrations/midi/cancel") => { + if ctrl.midi().learning().as_deref() == Some(MIDI_LEARN) { + ctrl.midi().cancel_learn(); + } + done() + } + ("POST", "/integrations/export") => export(&req(), &settings.borrow()), + ("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") => { + // Copy out first: a settings borrow must not live across an await. + let channels = settings.borrow().discord_channels.clone(); + test_discord(&req(), channels).await + } + ("POST", "/connections/phone") => save_phone(&req(), settings, cfg_path), + ("POST", "/connections/phone/test") => { + let phone = settings.borrow().phone.clone(); + test_phone(phone).await + } + ("POST", "/twitch/login") => { + handle.twitch_login(Which::from_id(req().str_or("which", "main"))); + done() + } + ("POST", "/twitch/cancel") => { + 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() + } + _ => return None, + }; + 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; + +/// Arm a MIDI learn: the next pad pressed becomes the shortcut. The +/// dashboard polls `/integrations/midi/poll` for it. +fn midi_learn(ctrl: &Arc) -> Reply { + if !cfg!(windows) { + return fail("MIDI shortcuts work on Windows only"); + } + // 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); + done() +} + +/// `/hooks/`: fire the web call integrations holding this token. +pub fn hook(ctrl: &Arc, token: &str, body: &str, query: &str) -> Reply { + if let Some(handle) = ctrl.integrations() { + handle.hook( + token.to_string(), + body.chars().take(64 * 1024).collect(), + query.to_string(), + ); + } + ( + "202 Accepted", + "application/json", + r#"{"ok":true}"#.to_string(), + ) +} + +/// Clone the settings, change them, validate, save and publish. +fn update( + settings: &Arc>, + cfg_path: &Path, + change: impl FnOnce(&mut Settings) -> Result<(), String>, +) -> Reply { + let _guard = crate::web::settings_write_guard(); + let mut next = settings.borrow().clone(); + if let Err(e) = change(&mut next) { + return fail(e); + } + if let Err(e) = next.save(cfg_path) { + return fail(format!("couldn't save: {e}")); + } + settings.send_replace(next); + done() +} + +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( + runner::GLOBAL_VARS + .iter() + .map(|&(name, sample)| { + let sample = match name { + "channel" if !channel.is_empty() => channel.as_str(), + _ => sample, + }; + json::obj([("name", json::str(name)), ("sample", json::str(sample))]) + }) + .collect(), + ); + let discord = s + .discord_channels + .iter() + .map(|c| { + json::obj([ + ("id", json::str(&c.id)), + ("name", json::str(&c.name)), + ("hint", json::str(mask_webhook(&c.url))), + ]) + }) + .collect(); + let vars = json::obj([ + ("global", global), + ("chat", event::vars_json(runner::CHAT_VARS)), + ( + "web", + 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([ + ( + "integrations", + Value::Arr(s.integrations.iter().map(Integration::to_json).collect()), + ), + ( + "connections", + json::obj([ + ("discord", Value::Arr(discord)), + ( + "phone", + json::obj([ + ("server", json::str(&s.phone.server)), + ("topic", json::str(&s.phone.topic)), + ]), + ), + ]), + ), + // 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()), + ("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. + ( + "shortcuts", + json::obj([ + ("available", Value::Bool(cfg!(windows))), + ( + "refused", + Value::Arr(handle.refused_keys().into_iter().map(json::str).collect()), + ), + ( + "taken", + Value::Arr( + s.hotkeys + .entries() + .iter() + .chain(s.midi.entries().iter()) + .filter(|(_, v)| !v.is_empty()) + .map(|(_, v)| json::str(*v)) + .collect(), + ), + ), + ]), + ), + ("events", event::catalog_json()), + ("vars", vars), + ("activity", handle.activity_json()), + ("stats", handle.stats_json()), + ("dropped_events", Value::Num(handle.dropped_events() as f64)), + ]) +} + +/// `https://discord.com/api/webhooks/1234/abcd...` shown as `…/1234/••••`. +/// The id is read right after `/webhooks/`, never by counting from the +/// end: a link ending in `/slack` would show the token in its place. +fn mask_webhook(url: &str) -> String { + let id = url + .split_once("/webhooks/") + .and_then(|(_, rest)| rest.split('/').next()) + .filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())); + match id { + Some(id) => format!("…/webhooks/{id}/••••"), + None => "set".to_string(), + } +} + +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(); + } + let mut problems = integration.problems(); + if !problems.invalid.is_empty() { + return fail(problems.invalid.join("; ")); + } + if let Err(e) = check_connections(&integration, &settings.borrow()) { + problems.incomplete.push(e); + } + if let Some(e) = shortcut_taken(&integration, &settings.borrow()) { + return fail(e); + } + // Switched on, it must be ready to run; switched off, it saves as a + // draft and the dashboard lists what is still missing. + if integration.enabled && !problems.incomplete.is_empty() { + return fail(format!( + "{}. Finish it, or switch it off to save it as a draft.", + problems.incomplete.join("; ") + )); + } + let 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 => { + let at = restore_at.unwrap_or(usize::MAX).min(s.integrations.len()); + s.integrations.insert(at, integration); + } + } + Ok(()) + }); + if reply.0 != "200 OK" { + return reply; + } + ok(json::obj([ + ("ok", Value::Bool(true)), + ("id", json::str(&id)), + ( + "warnings", + Value::Arr(warnings.into_iter().map(json::str).collect()), + ), + ( + "missing", + Value::Arr(missing.into_iter().map(json::str).collect()), + ), + ])) +} + +/// Variables the integration's messages use that nothing provides: most +/// likely a typo, which would otherwise render as nothing. +pub fn unknown_vars(integration: &Integration) -> Vec { + use super::model::{visit_steps, StepKind}; + let mut unknown: Vec = Vec::new(); + for h in &integration.handlers { + let mut known: Vec = runner::GLOBAL_VARS + .iter() + .map(|(n, _)| n.to_string()) + .collect(); + let mut prefixes = vec!["counter.".to_string(), "clip.".to_string()]; + match &h.trigger { + Trigger::Event { kind, .. } => { + known.extend(kind.vars().iter().map(|(n, _)| n.to_string())) + } + Trigger::ChatCommand { .. } | Trigger::ChatMessage { .. } => { + for n in 1..=9 { + known.push(format!("arg{n}")); + } + known.extend(runner::CHAT_VARS.iter().map(|(n, _)| n.to_string())); + } + // 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::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() { + "" => "response.".to_string(), + name => format!("{name}."), + }), + StepKind::SetVar => known.push(s.param("name").trim().to_string()), + StepKind::EditText => known.push(match s.param("save_as").trim() { + "" => "text".to_string(), + name => name.to_string(), + }), + _ => {} + }); + visit_steps(&h.steps, &mut |s| { + for value in s.params.values() { + for name in super::template::names(value) { + let provided = known.contains(&name) + || prefixes.iter().any(|p| name.starts_with(p.as_str())); + if !provided && !unknown.contains(&name) { + unknown.push(name); + } + } + } + }); + } + unknown +} + +fn with_id(reply: Reply, id: &str) -> Reply { + if reply.0 != "200 OK" { + return reply; + } + ok(json::obj([ + ("ok", Value::Bool(true)), + ("id", json::str(id)), + ])) +} + +/// A hotkey or pad an integration wants that already runs a delay action: +/// 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 { + return None; + }; + 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('_', " ") + )); + } + 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('_', " ") + ) + }) + }) +} + +/// 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 { + 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 + && !id.is_empty() + && !s.discord_channels.iter().any(|c| c.id == id) + { + gone = true; + } + }); + } + if gone { + Err("a Discord channel it posts to was removed; pick another".to_string()) + } else { + Ok(()) + } +} + +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()) { + Some(pack) => pack.presets.iter().map(|p| p.to_string()).collect(), + None => return fail("unknown pack"), + }, + _ => return fail("pick something to add"), + }; + let mut ids = Vec::new(); + let reply = update(settings, cfg_path, |s| { + let channel = s + .discord_channels + .first() + .map(|c| c.id.clone()) + .unwrap_or_default(); + for preset in &presets_to_add { + // A pack skips what the user already has from the catalog. + if presets_to_add.len() > 1 && s.integrations.iter().any(|i| &i.preset == preset) { + continue; + } + if s.integrations.len() >= MAX_INTEGRATIONS { + return Err(format!("you already have {MAX_INTEGRATIONS} integrations")); + } + 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); + } + Ok(()) + }); + if reply.0 != "200 OK" { + return reply; + } + ok(json::obj([ + ("ok", Value::Bool(true)), + ("ids", Value::Arr(ids.into_iter().map(json::str).collect())), + ])) +} + +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| { + let at = s + .integrations + .iter() + .position(|i| i.id == id) + .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("; "))); + } + check_connections(integration, s)?; + // A delay hotkey bound since it was switched off wins otherwise, + // and the integration would never start from it. + if let Some(taken) = shortcut_taken(integration, s) { + return Err(taken); + } + } + s.integrations[at].enabled = enabled; + Ok(()) + }) +} + +fn duplicate(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { + let id = req.str_or("id", "").to_string(); + let copy_id = new_id(); + let returned = copy_id.clone(); + let reply = update(settings, cfg_path, move |s| { + if s.integrations.len() >= MAX_INTEGRATIONS { + return Err(format!("you already have {MAX_INTEGRATIONS} integrations")); + } + let original = s + .integrations + .iter() + .find(|i| i.id == id) + .cloned() + .ok_or("that integration no longer exists")?; + // The copy starts off, with no hotkey or pad and its own web call + // link: otherwise one press, command or link would run both. + let mut copy = original; + copy.id = copy_id; + copy.name = format!("{} (copy)", copy.name).chars().take(80).collect(); + copy.enabled = false; + for h in &mut copy.handlers { + match &mut h.trigger { + Trigger::Webhook { token } => *token = new_id() + &new_id(), + Trigger::Shortcut { + hotkey, + midi, + token, + .. + } => { + hotkey.clear(); + midi.clear(); + if !token.is_empty() { + *token = new_id() + &new_id(); + } + } + _ => {} + } + } + s.integrations.push(copy); + Ok(()) + }); + with_id(reply, &returned) +} + +async fn test(req: &Value, handle: &super::Handle) -> Reply { + let integration = match req.get("integration").map(Integration::from_json) { + Some(Ok(i)) => i, + Some(Err(e)) => return fail(e), + None => return fail("nothing to test"), + }; + let handler = req.u64_or("handler", 0) as usize; + if handler >= integration.handlers.len() { + return fail("that trigger doesn't exist"); + } + 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)), + ( + "steps", + Value::Arr( + record + .steps + .iter() + .map(|s| { + json::obj([ + ("label", json::str(&s.label)), + ("status", json::str(s.status.id())), + ("at_ms", Value::Num(s.at_ms as f64)), + ("detail", json::str(&s.detail)), + ]) + }) + .collect(), + ), + ), + ])), + None => fail("the test didn't finish within a minute"), + } +} + +/// The editor's paste box: a bot command (or an address) to the pieces of +/// a "command from a website". See `botcmd`. +fn convert(req: &Value) -> Reply { + match super::botcmd::convert(req.str_or("text", "")) { + Ok(c) => ok(json::obj([ + ("ok", Value::Bool(true)), + ("command", c.command.map_or(Value::Null, json::str)), + ("url", json::str(c.url)), + ("reply", c.reply.map_or(Value::Null, json::str)), + ("from", json::str(c.from)), + ( + "unknown", + Value::Arr(c.unknown.into_iter().map(json::str).collect()), + ), + ])), + Err(e) => fail(e), + } +} + +/// 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") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(Value::as_str) + .collect(); + let chosen: Vec = s + .integrations + .iter() + .filter(|i| ids.contains(&i.id.as_str())) + .cloned() + .collect(); + if chosen.is_empty() { + return fail("pick what to share"); + } + let name = match req.str_or("name", "").trim() { + "" => chosen[0].name.clone(), + n => n.to_string(), + }; + ok(json::obj([ + ("ok", Value::Bool(true)), + ("recipe", json::str(recipe::export(&name, &chosen))), + ])) +} + +/// Preview a recipe (`apply` false) or add it (`apply` true). +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), + }; + if !req.bool_or("apply", false) { + let items = parsed + .integrations + .iter() + .map(|i| { + json::obj([ + ("name", json::str(&i.name)), + ("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(); + return ok(json::obj([ + ("ok", Value::Bool(true)), + ("name", json::str(&parsed.name)), + ("items", Value::Arr(items)), + ("uses_discord", Value::Bool(parsed.uses_discord())), + ("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"); + } + if parsed.uses_discord() { + let channel = req.str_or("discord", "").to_string(); + if !settings + .borrow() + .discord_channels + .iter() + .any(|c| c.id == channel) + { + return fail("pick which Discord channel it should post to"); + } + recipe::map_discord(&mut parsed, &channel); + } + let count = parsed.integrations.len(); + let reply = update(settings, cfg_path, move |s| { + if s.integrations.len() + count > MAX_INTEGRATIONS { + return Err(format!( + "that would go over {MAX_INTEGRATIONS} integrations" + )); + } + s.integrations.extend(parsed.integrations); + Ok(()) + }); + if reply.0 != "200 OK" { + return reply; + } + ok(json::obj([ + ("ok", Value::Bool(true)), + ("added", Value::Num(count as f64)), + ])) +} + +/// 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 + .handlers + .iter() + .map(|h| match &h.trigger { + Trigger::Event { kind, .. } => kind.label().to_string(), + Trigger::ChatCommand { command, .. } => format!("{command} in chat"), + Trigger::ChatMessage { .. } => "a chat message".to_string(), + Trigger::Timer { every_ms, .. } => { + format!("every {} min", every_ms / 60_000) + } + Trigger::Webhook { .. } => "a web call".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 + .handlers + .iter() + .map(|h| super::model::count_steps(&h.steps)) + .sum(); + format!("When {} · {steps} steps", triggers.join(", ")) +} + +fn save_discord(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { + let name = req + .str_or("name", "") + .trim() + .chars() + .take(40) + .collect::(); + let url = req.str_or("url", "").trim().to_string(); + let id = match req.str_or("id", "") { + "" => new_id(), + id => id.to_string(), + }; + if name.is_empty() { + return fail("give the channel a name, like Mods"); + } + if !valid_id(&id) { + return fail("invalid id"); + } + let existing_url = settings + .borrow() + .discord_channels + .iter() + .find(|c| c.id == id) + .map(|c| c.url.clone()); + // Editing keeps the saved URL unless a new one is pasted. + let url = match (url.is_empty(), existing_url) { + (true, Some(saved)) => saved, + _ => url, + }; + if !valid_discord_webhook(&url) { + return fail("paste a Discord webhook link (Server settings > Integrations > Webhooks)"); + } + let returned = id.clone(); + let reply = update(settings, cfg_path, move |s| { + let channel = DiscordChannel { id, name, url }; + match s.discord_channels.iter().position(|c| c.id == channel.id) { + Some(at) => s.discord_channels[at] = channel, + None if s.discord_channels.len() >= 32 => { + return Err("that's a lot of Discord channels; remove one first".into()) + } + None => s.discord_channels.push(channel), + } + Ok(()) + }); + with_id(reply, &returned) +} + +fn delete_discord(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { + let id = req.str_or("id", "").to_string(); + update(settings, cfg_path, |s| { + s.discord_channels.retain(|c| c.id != id); + // Integrations that posted there stop until a new channel is picked. + for integration in &mut s.integrations { + let mut uses = false; + for h in &integration.handlers { + super::model::visit_steps(&h.steps, &mut |step| { + uses |= step.kind == super::model::StepKind::Discord + && step.param("connection") == id; + }); + } + if uses { + integration.enabled = false; + } + } + Ok(()) + }) +} + +async fn test_discord(req: &Value, channels: Vec) -> Reply { + let id = req.str_or("id", ""); + let Some(channel) = channels.into_iter().find(|c| c.id == id) else { + return fail("that channel no longer exists"); + }; + let sent = tokio::task::spawn_blocking(move || { + 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") + .send(body) + .map(|r| r.status().as_u16()) + .map_err(|e| e.to_string()) + }) + .await; + match sent { + Ok(Ok(status)) if (200..300).contains(&status) => done(), + Ok(Ok(401 | 403)) => fail("Discord refused this webhook link; copy it again from Discord"), + Ok(Ok(404)) => fail("Discord says this webhook no longer exists"), + Ok(Ok(status)) => fail(format!("Discord answered {status}")), + Ok(Err(e)) => fail(format!("couldn't reach Discord: {e}")), + Err(_) => fail("the test crashed"), + } +} + +fn save_phone(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { + let topic = req.str_or("topic", "").trim().to_string(); + let server = req + .str_or("server", "") + .trim() + .trim_end_matches('/') + .to_string(); + if !topic.is_empty() + && (topic.len() > 64 + || !topic + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')) + { + return fail("use letters, numbers, - and _ for the topic"); + } + if !server.is_empty() && !server.starts_with("https://") && !server.starts_with("http://") { + return fail("the server must start with https://"); + } + update(settings, cfg_path, |s| { + s.phone = PhoneConnection { server, topic }; + Ok(()) + }) +} + +async fn test_phone(phone: PhoneConnection) -> Reply { + if !phone.is_set() { + return fail("set a topic first"); + } + let body = json::obj([ + ("topic", json::str(phone.topic.trim())), + ("title", json::str("InstantClone")), + ( + "message", + json::str("🧪 Test push: your phone is connected."), + ), + ]) + .to_json(); + let sent = tokio::task::spawn_blocking(move || { + crate::https::https_agent_with_timeout(super::effects::HTTP_TIMEOUT) + .post(phone.server_or_default()) + .header("Content-Type", "application/json") + .send(body) + .map(|r| r.status().as_u16()) + .map_err(|e| e.to_string()) + }) + .await; + match sent { + Ok(Ok(status)) if (200..300).contains(&status) => done(), + Ok(Ok(status)) => fail(format!("the server answered {status}")), + Ok(Err(e)) => fail(format!("couldn't reach it: {e}")), + Err(_) => fail("the test crashed"), + } +} + +#[cfg(test)] +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!( + mask_webhook("https://discord.com/api/webhooks/123/abcDEF"), + "…/webhooks/123/••••" + ); + assert_eq!( + mask_webhook("https://discord.com/api/webhooks/123/abcDEF/slack"), + "…/webhooks/123/••••" + ); + } + + #[test] + fn typos_in_variables_are_reported() { + let mut i = presets::build("delay_command", "d".into(), "").unwrap(); + assert!(unknown_vars(&i).is_empty()); + i.handlers[0].steps[0] + .params + .insert("text".into(), "{delya} for {user} at {time}".into()); + assert_eq!(unknown_vars(&i), vec!["delya".to_string()]); + for p in presets::PRESETS { + let built = presets::build(p.id, "x".into(), "c").unwrap(); + assert!( + unknown_vars(&built).is_empty(), + "{}: {:?}", + p.id, + unknown_vars(&built) + ); + } + } + + #[test] + fn a_delay_hotkey_or_pad_cannot_start_an_integration_too() { + use super::super::model::{Handler, Step, StepKind, Trigger}; + let mut s = Settings::defaults(); + s.hotkeys.set("cut", "Ctrl+Alt+C"); + s.midi.set("toggle", "note:1:36"); + let mut i = presets::build("delay_command", "d".into(), "").unwrap(); + let mut shortcut = |hotkey: &str, midi: &str| { + i.handlers = vec![Handler { + enabled: true, + trigger: Trigger::Shortcut { + hotkey: hotkey.into(), + midi: midi.into(), + token: String::new(), + only_live: false, + }, + steps: vec![Step::new(StepKind::Clip, &[])], + }]; + shortcut_taken(&i, &s) + }; + assert!(shortcut("Ctrl+Alt+C", "").unwrap().contains("cut")); + assert!(shortcut("", "note:1:36").unwrap().contains("toggle")); + assert_eq!(shortcut("Ctrl+Alt+K", "note:1:37"), None); + } + + #[test] + fn programs_and_files_are_locked_when_the_request_may_not_set_them_up() { + use super::super::model::{Step, StepKind}; + let dir = std::env::temp_dir().join(format!("ic-local-steps-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let 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("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"); + 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 · 8 steps" + ); + } +} diff --git a/src/integrations/botcmd.rs b/src/integrations/botcmd.rs new file mode 100644 index 0000000..a444b0b --- /dev/null +++ b/src/integrations/botcmd.rs @@ -0,0 +1,344 @@ +//! Turn a chat bot command from Nightbot, StreamElements, Fossabot or +//! Streamlabs into the pieces of a "command from a website": the address it +//! fetches, the reply around the answer, and the command's name. Their +//! variables become ours: `$(user)` is `{user}`, `${1}` is `{arg1}`. +//! +//! Only commands that fetch an address convert. One that runs JavaScript +//! (`$(eval …)`) cannot, and says so; a variable with no match here is kept +//! as written and listed, so nothing is dropped silently. + +/// Where the converted reply reads the fetched answer (the preset saves +/// its web request as `api`). +pub const ANSWER: &str = "{api.body}"; + +#[derive(Debug)] +pub struct Converted { + /// `!name` when the paste was a whole "add command" line. + pub command: Option, + pub url: String, + /// The reply with the answer as `{api.body}`; `None` when only an + /// address was pasted. + pub reply: Option, + /// Which bot the syntax came from, for the dashboard's note. + pub from: &'static str, + /// Bot variables with no InstantClone match, kept as written. + pub unknown: Vec, +} + +/// How each bot writes "fetch this address": (opening, closing, bot). +const FETCHES: &[(&str, u8, &str)] = &[ + ("$(urlfetch ", b')', "Nightbot"), + ("$(customapi ", b')', "Fossabot"), + ("${customapi.", b'}', "StreamElements"), + ("{readapi.", b'}', "Streamlabs"), + ("$readapi(", b')', "Streamlabs Chatbot"), +]; + +/// Chat lines that add or edit a command, before the command's name. +const ADD_COMMANDS: &[&str] = &[ + "!addcom", + "!editcom", + "!commands", + "!command", + "!cmd", + "!addcmd", + "!editcmd", +]; + +pub fn convert(input: &str) -> Result { + let text = input.trim(); + if text.is_empty() { + return Err("paste a bot command or a web address".into()); + } + let lower = text.to_ascii_lowercase(); + if lower.contains("$(eval") || lower.contains("${eval") { + return Err( + "this command runs JavaScript ($(eval)), which InstantClone doesn't. Paste just the address it calls" + .into(), + ); + } + let (command, response) = split_command(text); + let mut unknown = Vec::new(); + let Some(fetch) = find_fetch(response) else { + let url = response.trim(); + if is_http(url) && !url.contains(char::is_whitespace) { + let url = translate(url, &mut unknown); + return Ok(Converted { + command, + url, + reply: None, + from: "a web address", + unknown, + }); + } + return Err( + "no web address in it: paste a $(urlfetch …), ${customapi.…} or https://… line".into(), + ); + }; + // Nightbot's `$(urlfetch json URL)` asks for raw JSON, which is what we get anyway. + let inner = fetch.inner.trim(); + let inner = inner.strip_prefix("json ").unwrap_or(inner).trim(); + let url = translate(inner, &mut unknown); + if !is_http(&url) { + return Err("the address it fetches doesn't start with http:// or https://".into()); + } + let before = translate(&response[..fetch.start], &mut unknown); + let after = translate(&response[fetch.end..], &mut unknown); + let reply = format!("{before}{ANSWER}{after}").trim().to_string(); + Ok(Converted { + command, + url, + reply: Some(reply), + from: fetch.from, + unknown, + }) +} + +fn is_http(s: &str) -> bool { + let s = s.get(..8).unwrap_or(s).to_ascii_lowercase(); + s.starts_with("http://") || s.starts_with("https://") +} + +/// `!commands add !rank -cd=5 ` to (`!rank`, ``); any +/// other text is all response. +fn split_command(text: &str) -> (Option, &str) { + let first = word(text); + if !ADD_COMMANDS.iter().any(|w| first.eq_ignore_ascii_case(w)) { + return (None, text); + } + let mut rest = after(text, first); + let verb = word(rest); + if ["add", "edit", "new"] + .iter() + .any(|v| verb.eq_ignore_ascii_case(v)) + { + rest = after(rest, verb); + } + rest = skip_flags(rest); + let name = word(rest).trim_start_matches('!').to_lowercase(); + if name.is_empty() + || !name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { + return (None, text); + } + let rest = skip_flags(after(rest, word(rest))); + (Some(format!("!{name}")), rest) +} + +fn word(s: &str) -> &str { + s.split_whitespace().next().unwrap_or("") +} + +/// `s` after its first word `w` (which `s` starts with, past whitespace). +fn after<'a>(s: &'a str, w: &str) -> &'a str { + &s.trim_start()[w.len()..] +} + +/// Nightbot's `-cd=5 -ul=moderator`. +fn skip_flags(mut s: &str) -> &str { + loop { + let w = word(s); + if !(w.starts_with('-') && w.contains('=')) { + return s; + } + s = after(s, w); + } +} + +struct Fetch<'a> { + start: usize, + end: usize, + inner: &'a str, + from: &'static str, +} + +/// The first fetch expression in `s`. +fn find_fetch(s: &str) -> Option> { + // ASCII lowercasing keeps byte offsets, so they index `s` too. + let lower = s.to_ascii_lowercase(); + let (start, open, close, from) = FETCHES + .iter() + .filter_map(|(open, close, from)| lower.find(open).map(|at| (at, *open, *close, *from))) + .min_by_key(|f| f.0)?; + let body = start + open.len(); + let len = closing(&s[body..], close)?; + Some(Fetch { + start, + end: body + len + 1, + inner: &s[body..body + len], + from, + }) +} + +/// Where the expression ends: the first `close` not inside a nested +/// `$(…)`, `${…}` or `{…}`. +fn closing(s: &str, close: u8) -> Option { + let bytes = s.as_bytes(); + let mut depth = 0usize; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'$' if matches!(bytes.get(i + 1), Some(b'(' | b'{')) => { + depth += 1; + i += 2; + continue; + } + b'{' => depth += 1, + b')' | b'}' if depth > 0 => depth -= 1, + b if b == close => return Some(i), + _ => {} + } + i += 1; + } + None +} + +/// Rewrite every bot variable in `s` as ours. +fn translate(s: &str, unknown: &mut Vec) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = next_variable(rest) { + out.push_str(&rest[..at]); + let tail = &rest[at..]; + let (open, close) = match tail.as_bytes()[0] { + b'$' => ( + 2, + if tail.as_bytes()[1] == b'(' { + b')' + } else { + b'}' + }, + ), + _ => (1, b'}'), + }; + let Some(len) = closing(&tail[open..], close) else { + out.push_str(tail); + return out; + }; + let whole = &tail[..open + len + 1]; + out.push_str(&ours(&tail[open..open + len], whole, open == 1, unknown)); + rest = &tail[whole.len()..]; + } + out.push_str(rest); + out +} + +/// Where the next `$(`, `${` or `{` starts. +fn next_variable(s: &str) -> Option { + let bytes = s.as_bytes(); + (0..bytes.len()).find(|&i| match bytes[i] { + b'$' => matches!(bytes.get(i + 1), Some(b'(' | b'{')), + b'{' => true, + _ => false, + }) +} + +/// One bot variable as ours. `bare`: written `{…}` (Streamlabs, or already +/// ours), which stays as written when unknown instead of being flagged. +fn ours(inner: &str, whole: &str, bare: bool, unknown: &mut Vec) -> String { + let inner = inner.trim(); + let (name, arg) = match inner.split_once(char::is_whitespace) { + Some((name, arg)) => (name, Some(arg.trim())), + None => (inner, None), + }; + let name = name.to_ascii_lowercase(); + // Encoding helpers: InstantClone encodes values in addresses itself. + if let ("urlencode" | "queryescape" | "pathescape" | "urlescape", Some(arg)) = + (name.as_str(), arg) + { + return translate(arg, unknown); + } + let mapped = match name.as_str() { + "user" | "user.name" | "user.display_name" | "sender" | "source" | "username" + | "displayname" => "user", + "touser" | "target" => "target", + "query" | "querystring" | "args" => "args", + "channel" | "channel.name" | "streamer" => "channel", + n if n.len() == 2 && n.ends_with(':') && n.as_bytes()[0].is_ascii_digit() => "args", + n if n.len() == 1 && (b'1'..=b'9').contains(&n.as_bytes()[0]) => { + return format!("{{arg{n}}}") + } + _ if bare => return whole.to_string(), + _ => { + unknown.push(whole.to_string()); + return whole.to_string(); + } + }; + format!("{{{mapped}}}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(text: &str) -> Converted { + convert(text).unwrap_or_else(|e| panic!("{text}: {e}")) + } + + #[test] + fn nightbot_add_line_gives_command_address_and_reply() { + let c = ok("!commands add !Rank -cd=5 $(user), your rank: $(urlfetch https://api.example/rank/$(querystring)?region=eu)"); + assert_eq!(c.command.as_deref(), Some("!rank")); + assert_eq!(c.url, "https://api.example/rank/{args}?region=eu"); + assert_eq!(c.reply.as_deref(), Some("{user}, your rank: {api.body}")); + assert_eq!(c.from, "Nightbot"); + assert!(c.unknown.is_empty()); + } + + #[test] + fn streamelements_nested_helpers_unwrap() { + let c = ok("!cmd add rank ${customapi.https://api.example/v1/${pathescape ${1}}/stats}"); + assert_eq!(c.command.as_deref(), Some("!rank")); + assert_eq!(c.url, "https://api.example/v1/{arg1}/stats"); + assert_eq!(c.reply.as_deref(), Some("{api.body}")); + assert_eq!(c.from, "StreamElements"); + } + + #[test] + fn fossabot_and_streamlabs_variables_map() { + let c = ok("$(customapi https://x.example/followage/$(channel)/$(user))"); + assert_eq!(c.url, "https://x.example/followage/{channel}/{user}"); + assert_eq!(c.command, None); + let c = ok("{readapi.https://x.example/q?u={user}&n={1}}"); + assert_eq!(c.url, "https://x.example/q?u={user}&n={arg1}"); + assert_eq!(c.from, "Streamlabs"); + } + + #[test] + fn touser_and_json_fetch() { + let c = ok("$(touser) is $(urlfetch json https://x.example/$(touser))"); + assert_eq!(c.url, "https://x.example/{target}"); + assert_eq!(c.reply.as_deref(), Some("{target} is {api.body}")); + } + + #[test] + fn a_plain_address_is_just_the_address() { + let c = ok(" https://x.example/a?b=1 "); + assert_eq!(c.url, "https://x.example/a?b=1"); + assert_eq!(c.reply, None); + } + + #[test] + fn unknown_variables_stay_and_are_listed() { + let c = ok("$(count) asks: $(urlfetch https://x.example)"); + assert_eq!(c.reply.as_deref(), Some("$(count) asks: {api.body}")); + assert_eq!(c.unknown, vec!["$(count)".to_string()]); + } + + #[test] + fn what_cannot_convert_says_why() { + assert!(convert("$(eval const r = 1; r)") + .unwrap_err() + .contains("JavaScript")); + assert!(convert("hello $(user)") + .unwrap_err() + .contains("no web address")); + assert!(convert("$(urlfetch ftp://x.example)") + .unwrap_err() + .contains("http")); + assert!(convert("$(urlfetch https://x.example").is_err()); + assert!(convert(" ").is_err()); + } +} diff --git a/src/integrations/clock.rs b/src/integrations/clock.rs new file mode 100644 index 0000000..456f104 --- /dev/null +++ b/src/integrations/clock.rs @@ -0,0 +1,101 @@ +//! Local wall-clock time for the `{time}` and `{date}` variables. +//! +//! std has no local time zone, so this asks the OS directly: GetLocalTime on +//! Windows, localtime_r elsewhere. + +pub struct LocalTime { + pub year: u32, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, +} + +impl LocalTime { + /// `21:04` + pub fn hm(&self) -> String { + format!("{:02}:{:02}", self.hour, self.minute) + } + + /// `2026-09-30` + pub fn date(&self) -> String { + format!("{:04}-{:02}-{:02}", self.year, self.month, self.day) + } +} + +#[cfg(windows)] +pub fn now() -> LocalTime { + use windows_sys::Win32::System::SystemInformation::GetLocalTime; + // SAFETY: GetLocalTime only writes the SYSTEMTIME we hand it. + let t = unsafe { + let mut t = std::mem::zeroed(); + GetLocalTime(&mut t); + t + }; + LocalTime { + year: t.wYear as u32, + month: t.wMonth as u32, + day: t.wDay as u32, + hour: t.wHour as u32, + minute: t.wMinute as u32, + } +} + +#[cfg(unix)] +pub fn now() -> LocalTime { + // SAFETY: time() and localtime_r() write only into the locals we pass. + let tm = unsafe { + let secs = libc::time(std::ptr::null_mut()); + let mut tm: libc::tm = std::mem::zeroed(); + libc::localtime_r(&secs, &mut tm); + tm + }; + LocalTime { + year: (tm.tm_year + 1900) as u32, + month: (tm.tm_mon + 1) as u32, + day: tm.tm_mday as u32, + hour: tm.tm_hour as u32, + minute: tm.tm_min as u32, + } +} + +/// `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(); + assert!(t.year >= 2024 && (1..=12).contains(&t.month) && (1..=31).contains(&t.day)); + assert_eq!(t.hm().len(), 5); + assert_eq!(t.date().len(), 10); + } +} diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs new file mode 100644 index 0000000..957bd44 --- /dev/null +++ b/src/integrations/effects.rs @@ -0,0 +1,812 @@ +//! 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, 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, 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. +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; + +/// Marks every web request InstantClone makes. A step can name this PC's +/// own dashboard as its address; arriving with this, the dashboard treats +/// it like a request through a proxy (`web::is_proxied`), so it can never +/// set the first password or a Run a program step. +const VIA: (&str, &str) = ("Via", "1.1 instantclone"); + +/// 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)) + .max_redirects(0) + .build(); + for (name, value) in &r.headers { + req = req.header(name.as_str(), value.as_str()); + } + req.header(VIA.0, VIA.1).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)) + .max_redirects(0) + .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.header(VIA.0, VIA.1).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(); + // Redirects aren't followed: one could point a request made from this + // PC at the dashboard itself, and post what it answers to chat. + if (300..400).contains(&status) { + let to = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .unwrap_or("another address"); + return Err(format!("the server moved this to {to}; use that address")); + } + let body = resp + .body_mut() + .with_config() + .limit(MAX_RESPONSE) + .read_to_string() + .unwrap_or_default(); + Ok(HttpResponse { status, body }) +} + +/// Post a Discord message, or edit message `edit` (see `Host::discord`). +/// An edit never pings; the ping is for when it has to be posted anew. +pub fn send_discord( + webhook_url: &str, + message: &DiscordMessage, + edit: Option<&str>, +) -> Result { + if let Some(id) = edit { + 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 status != 404 { + discord_status(status)?; + return Ok(DiscordPosted { + message_id: id.to_string(), + edited: true, + }); + } + } + // `wait=true` makes Discord answer with the message, and its id. + 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)) + .filter(|id| id.bytes().all(|b| b.is_ascii_digit())) + .unwrap_or_default(); + Ok(DiscordPosted { + message_id, + edited: false, + }) +} + +/// 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, + /// The streamer's usual delay, used when `arm` names none. + 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(), + hold_left_ms: hold.map_or(0, |h| h.remaining.as_millis() as u64), + destinations_live: alive as usize, + destinations_total: total as usize, + obs_live: self.ctrl.ingest_alive(), + bitrate_kbps: self.ctrl.bitrate_kbps() as u64, + channel: self.twitch.channel(), + } + } + + fn discord( + &self, + webhook_url: &str, + message: &DiscordMessage, + edit: Option<&str>, + ) -> Result { + send_discord(webhook_url, message, edit) + } + + fn http(&self, r: HttpRequest) -> Result { + send_http(r) + } + + fn phone( + &self, + server: &str, + topic: &str, + title: &str, + text: &str, + priority: &str, + ) -> Result<(), String> { + let priority = match priority { + "high" => 4.0, + "urgent" => 5.0, + _ => 3.0, + }; + let body = json::obj([ + ("topic", json::str(topic)), + ("title", json::str(title)), + ("message", json::str(text)), + ("priority", Value::Num(priority)), + ]) + .to_json(); + let resp = crate::https::https_agent() + .post(server) + .config() + .timeout_global(Some(HTTP_TIMEOUT)) + .build() + .header("Content-Type", "application/json") + .send(body) + .map_err(|e| format!("couldn't reach {server}: {e}"))?; + match resp.status().as_u16() { + 200..=299 => Ok(()), + 429 => Err("ntfy is rate limiting; slow down".to_string()), + s => Err(format!("ntfy answered {s}")), + } + } + + fn chat(&self, text: &str, as_bot: bool, reply_to: Option<&str>) -> Result<(), String> { + self.twitch.send(text, as_bot, reply_to) + } + + fn marker(&self, description: &str) -> Result<(), String> { + self.twitch.marker(description) + } + + fn clip(&self) -> Result { + let id = self.twitch.clip()?; + Ok(ClipInfo { + url: format!("https://clips.twitch.tv/{id}"), + id, + }) + } + + fn delay_action(&self, action: &str, ms: u32) -> Result<(), String> { + let default_ms = if ms > 0 { + ms + } else { + self.default_delay_ms.load(Ordering::Relaxed) + }; + match action { + // "Set the delay", not the hotkey's arm/disarm toggle. + "arm" => { + 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 + .run_named_action(action, default_ms, "integration") + { + None => Ok(()), + Some(problem) => Err(problem), + } + } + other => Err(format!("unknown delay action \"{other}\"")), + } + } + + fn program(&self, path: &str, args: &[String]) -> Result<(), String> { + if path.is_empty() { + return Err("pick a program to run".to_string()); + } + 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}"))?; + running.push(child); + Ok(()) + } + + fn file(&self, path: &str, text: &str, append: bool) -> Result<(), String> { + if path.is_empty() { + return Err("pick a file".to_string()); + } + if text.len() > MAX_FILE_TEXT { + return Err("that text is too big to write".to_string()); + } + let path = std::path::Path::new(path); + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("couldn't make the folder: {e}"))?; + } + let result = if append { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut f| writeln!(f, "{text}")) + } else { + std::fs::write(path, text) + }; + result.map_err(|e| format!("couldn't write it: {e}")) + } + + fn overlay(&self, title: &str, text: &str, seconds: u64) { + self.alerts.show(title, text, seconds); + } + + fn 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> { + match status { + 200..=299 => Ok(()), + 401 | 403 => { + Err("Discord refused this webhook link; copy it again from Discord".to_string()) + } + 404 => Err("Discord says this webhook no longer exists".to_string()), + 429 => Err("Discord is rate limiting this webhook; slow down".to_string()), + s => Err(format!("Discord answered {s}")), + } +} + +/// `url` with `param` added to its query (a webhook link can already carry +/// one, like `?thread_id=` for a forum thread). +fn with_query(url: &str, param: &str) -> String { + let joint = if url.contains('?') { '&' } else { '?' }; + format!("{url}{joint}{param}") +} + +/// Where one of a webhook's messages is edited: `/messages/`, +/// keeping the webhook's query so a thread message is found in its thread. +fn webhook_message_url(url: &str, id: &str) -> String { + let (base, query) = url + .split_once('?') + .map_or((url, None), |(b, q)| (b, Some(q))); + let base = base.trim_end_matches('/'); + match query { + Some(q) => format!("{base}/messages/{id}?{q}"), + None => format!("{base}/messages/{id}"), + } +} + +/// 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(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")]))]), + ), + "everyone" => ( + "@everyone ".to_string(), + json::obj([("parse", Value::Arr(vec![json::str("everyone")]))]), + ), + role if role.starts_with("role:") + && role[5..].bytes().all(|b| b.is_ascii_digit()) + && role.len() > 5 => + { + ( + format!("<@&{}> ", &role[5..]), + json::obj([("roles", Value::Arr(vec![json::str(&role[5..])]))]), + ) + } + _ => (String::new(), json::obj([("parse", Value::Arr(vec![]))])), + } +} + +#[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(&text("@everyone look", "")).unwrap(); + assert_eq!( + quiet + .path("allowed_mentions.parse") + .unwrap() + .as_array() + .unwrap() + .len(), + 0 + ); + let here = json::parse(&text("OBS dropped", "here")).unwrap(); + assert_eq!(here.str_or("content", ""), "@here OBS dropped"); + 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(&text("x", "role:1 OR 1")).unwrap(); + assert_eq!(bad.str_or("content", ""), "x"); + } + + /// A webhook that answers like Discord does: a new message gets an id, + /// an edit of a known id works, an edit of a deleted one is a 404. + fn fake_discord(answers: usize) -> (String, std::thread::JoinHandle>) { + use std::io::{BufRead, BufReader, Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!( + "http://{}/api/webhooks/1/tok", + listener.local_addr().unwrap() + ); + let seen = std::thread::spawn(move || { + let mut seen = Vec::new(); + for stream in listener.incoming().take(answers) { + let mut stream = stream.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: Vec<&str> = line.split_whitespace().collect(); + let (method, path) = (request[0].to_string(), request[1].to_string()); + let mut length = 0; + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + if header.trim().is_empty() { + break; + } + if let Some(v) = header.to_ascii_lowercase().strip_prefix("content-length:") { + length = v.trim().parse().unwrap(); + } + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + let (status, answer) = match (method.as_str(), path.as_str()) { + ("POST", _) => ("200 OK", r#"{"id":"111"}"#), + ("PATCH", p) if p.ends_with("/messages/111") => ("200 OK", r#"{"id":"111"}"#), + _ => ("404 Not Found", r#"{"message":"Unknown Message"}"#), + }; + let pinged = String::from_utf8_lossy(&body).contains("@here"); + seen.push(format!( + "{method} {path}{}", + if pinged { " @here" } else { "" } + )); + let reply = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}", + answer.len() + ); + stream.write_all(reply.as_bytes()).unwrap(); + } + seen + }); + (url, seen) + } + + #[test] + fn a_message_is_posted_edited_and_reposted_once_deleted() { + let (url, seen) = fake_discord(4); + 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(); + 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(); + assert_eq!((gone.message_id.as_str(), gone.edited), ("111", false)); + assert_eq!( + seen.join().unwrap(), + [ + "POST /api/webhooks/1/tok?wait=true", + "PATCH /api/webhooks/1/tok/messages/111", + "PATCH /api/webhooks/1/tok/messages/999", + "POST /api/webhooks/1/tok?wait=true @here", + ] + ); + } + + #[test] + fn edits_go_to_the_message_and_keep_the_thread() { + let hook = "https://discord.com/api/webhooks/1/tok"; + assert_eq!(with_query(hook, "wait=true"), format!("{hook}?wait=true")); + assert_eq!( + webhook_message_url(hook, "99"), + format!("{hook}/messages/99") + ); + let thread = "https://discord.com/api/webhooks/1/tok?thread_id=5"; + assert_eq!( + with_query(thread, "wait=true"), + format!("{thread}&wait=true") + ); + assert_eq!( + webhook_message_url(thread, "99"), + "https://discord.com/api/webhooks/1/tok/messages/99?thread_id=5" + ); + } + + #[test] + fn discord_messages_are_capped() { + 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 new file mode 100644 index 0000000..3b725a8 --- /dev/null +++ b/src/integrations/engine.rs @@ -0,0 +1,2199 @@ +//! The integrations engine: matches what happens to the integrations that +//! care, and runs them. +//! +//! It lives on its own OS thread with its own single-threaded runtime. The +//! stream pipeline never shares a thread with it, so a slow webhook, a +//! chat flood or a long `wait` cannot delay a single video frame. Events +//! come in through a bounded channel whose `try_send` works from any thread +//! (the controller, the tray, hotkey and MIDI callbacks); if the engine ever +//! falls behind, events are dropped and counted, never queued without end. +//! +//! Limits that keep a runaway integration harmless: +//! - 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, 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; +use crate::controller::Controller; +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +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 +/// 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); +/// Runs counted for `{uses}` are written at most this often: a busy chat +/// command would otherwise rewrite the data file on every use. +const USES_SAVE_EVERY: Duration = Duration::from_secs(5); +/// Runs each card's history strip shows. +const RECENT_KEEP: usize = 12; +/// 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); +/// 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), + Hook { + token: String, + body: String, + query: String, + }, + 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))] +pub enum Shortcut { + Hotkey, + Midi, +} + +/// One run, for the activity log. +#[derive(Clone, Debug)] +pub struct Record { + pub at_ms: u64, + pub integration_id: String, + pub name: String, + pub trigger: String, + /// `ok`, `stopped`, `failed` or `busy`. + pub status: &'static str, + pub test: bool, + pub steps: Vec, +} + +impl Record { + fn to_json(&self) -> Value { + json::obj([ + ("at_ms", Value::Num(self.at_ms as f64)), + ("integration_id", json::str(&self.integration_id)), + ("name", json::str(&self.name)), + ("trigger", json::str(&self.trigger)), + ("status", json::str(self.status)), + ("test", Value::Bool(self.test)), + ( + "steps", + Value::Arr( + self.steps + .iter() + .map(|s| { + json::obj([ + ("label", json::str(&s.label)), + ("status", json::str(s.status.id())), + ("at_ms", Value::Num(s.at_ms as f64)), + ("detail", json::str(&s.detail)), + ]) + }) + .collect(), + ), + ), + ]) + } +} + +#[derive(Clone, Default)] +struct Stat { + last_ms: u64, + last_status: &'static str, + /// Failures in a row; 0 after a good run. + failing: u32, + runs: u64, + /// The latest runs' outcomes, oldest first. + recent: VecDeque<&'static str>, +} + +/// The engine's public face: shared with the controller and the web layer. +pub struct Handle { + tx: mpsc::Sender, + activity: Mutex>, + stats: Mutex>, + pub twitch: Arc, + /// What "Show on stream" steps put on the alerts browser source. + pub alerts: Arc, + store: Arc, + /// Integration hotkeys Windows wouldn't register (another app holds + /// 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 { + /// Report something that happened. Never blocks; safe from any thread. + pub fn emit(&self, event: Event) { + if self.tx.try_send(Input::Event(event)).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + + /// A call to `/hooks/`. + pub fn hook(&self, token: String, body: String, query: String) { + if self + .tx + .try_send(Input::Hook { token, body, query }) + .is_err() + { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + + /// A global hotkey or MIDI pad was pressed; `signature` is the combo + /// (`Ctrl+Alt+K`) or the pad (`note:1:36@Device`). Never blocks. + #[cfg_attr(not(windows), allow(dead_code))] + pub fn shortcut(&self, from: Shortcut, signature: &str) { + let input = Input::Shortcut(from, signature.to_string()); + if self.tx.try_send(input).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + + #[cfg_attr(not(windows), allow(dead_code))] + pub fn set_refused_keys(&self, combos: Vec) { + *self.refused_keys.lock() = combos; + } + + pub fn refused_keys(&self) -> Vec { + self.refused_keys.lock().clone() + } + + /// Write what is kept in memory between saves (the `{uses}` counts). + /// Call before the app exits. + pub fn flush(&self) { + let _ = self.store.save(); + } + + /// 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()?; + tokio::time::timeout(Duration::from_secs(60), rx) + .await + .ok()? + .ok() + } + + pub fn twitch_login(&self, which: Which) { + let _ = self.tx.try_send(Input::Login(which)); + } + + pub fn twitch_cancel_login(&self) { + let _ = self.tx.try_send(Input::CancelLogin); + } + + pub fn twitch_logout(&self, which: Which) { + let _ = self.tx.try_send(Input::Logout(which)); + } + + fn push(&self, record: Record) { + // "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(); + // 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; + } + } + { + let mut stats = self.stats.lock(); + let stat = stats.entry(record.integration_id.clone()).or_default(); + if !record.test && !busy { + stat.last_ms = record.at_ms; + stat.last_status = record.status; + stat.runs += 1; + stat.failing = if record.status == "failed" { + stat.failing + 1 + } else { + 0 + }; + if stat.recent.len() >= RECENT_KEEP { + stat.recent.pop_front(); + } + stat.recent.push_back(record.status); + } + } + let mut activity = self.activity.lock(); + if activity.len() >= ACTIVITY_KEEP { + activity.pop_front(); + } + activity.push_back(record); + } + + /// Recent runs, newest first. + pub fn activity_json(&self) -> Value { + Value::Arr( + self.activity + .lock() + .iter() + .rev() + .map(Record::to_json) + .collect(), + ) + } + + /// Per-integration last run, keyed by integration id. + pub fn stats_json(&self) -> Value { + Value::Obj( + self.stats + .lock() + .iter() + .map(|(id, s)| { + ( + id.clone(), + json::obj([ + ("last_ms", Value::Num(s.last_ms as f64)), + ("last_status", json::str(s.last_status)), + ("failing", Value::Num(s.failing as f64)), + ("runs", Value::Num(s.runs as f64)), + ( + "recent", + Value::Arr(s.recent.iter().map(|r| json::str(*r)).collect()), + ), + ]), + ) + }) + .collect(), + ) + } + + /// 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) + } + + /// 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. +pub fn start( + ctrl: Arc, + settings: watch::Receiver, + data_dir: PathBuf, +) -> std::io::Result> { + let (tx, rx) = mpsc::channel(QUEUE); + let (chat_tx, chat_rx) = mpsc::channel(256); + let store = Arc::new(Store::open(&data_dir)); + let log_ctrl = ctrl.clone(); + let twitch = Arc::new(Twitch::new( + store.clone(), + chat_tx, + Box::new(move |line| log_ctrl.log(line)), + &settings.borrow().twitch_client_id, + )); + let alerts = Arc::new(Board::new()); + let obs = Arc::new(Mutex::new(obsws::Status::default())); + let handle = Arc::new(Handle { + tx, + activity: Mutex::new(VecDeque::new()), + stats: Mutex::new(HashMap::new()), + twitch: twitch.clone(), + alerts: alerts.clone(), + 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() + .name("instantclone-integrations".into()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + ctrl.log(format!("[integrations] could not start: {e}")); + return; + } + }; + 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, want_obs); + engine.run(rx, chat_rx, settings).await; + }); + })?; + Ok(handle) +} + +struct Engine { + handle: Arc, + host: Arc, + store: Arc, + 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, + /// 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: 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 { + fn new( + handle: Arc, + 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_turns), + (&timeline, &handle), + )); + Engine { + handle, + host, + store, + 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_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, + } + } + + async fn run( + &mut self, + mut rx: mpsc::Receiver, + mut chat_rx: mpsc::Receiver, + mut settings: watch::Receiver, + ) { + 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! { + input = rx.recv() => match input { + Some(input) => self.input(input), + None => return, + }, + Some(msg) = chat_rx.recv() => self.chat(msg), + changed = settings.changed() => { + if changed.is_err() { + return; + } + let snapshot = settings.borrow_and_update().clone(); + 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.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 { + last_prune = Instant::now(); + self.prune(); + } + } + } + } + } + + fn apply_settings(&mut self, s: &Settings) { + self.integrations = s.integrations.iter().cloned().map(Arc::new).collect(); + self.env = Arc::new(make_env( + s.discord_channels.clone(), + s.phone.clone(), + &self.store, + (&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); + } + + /// 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()); + // Here rather than on every settings change: a delete undone within + // the minute keeps its count and its Discord message. + let alive: Vec<&str> = self.integrations.iter().map(|i| i.id.as_str()).collect(); + { + let mut uses = self.store.uses.lock(); + let before = uses.len(); + uses.retain(|id, _| alive.contains(&id.as_str())); + self.uses_dirty |= uses.len() != before; + } + // Keys are `[test:]integration:channel`. + self.discord_messages.lock().retain(|key, _| { + let id = key + .trim_start_matches("test:") + .split(':') + .next() + .unwrap_or(""); + alive.contains(&id) + }); + } + + 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. + self.uses_dirty = self.store.save().is_err(); + } + } + + fn input(&mut self, input: Input) { + match input { + Input::Event(event) => self.event(event), + Input::Hook { token, body, query } => self.hook(&token, body, query), + Input::Test { + integration, + handler, + run, + 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), + } + } + + fn event(&mut self, event: Event) { + // 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) + } + _ => false, + }) + .map(|(i, idx, _)| (i, idx)) + .collect(); + for (integration, idx) in matches { + 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 = { + 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; + } + 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(); + let args: Vec<&str> = words.collect(); + let candidates: Vec<(Arc, usize, String)> = self + .enabled_handlers() + .filter_map(|(i, idx, h)| match &h.trigger { + Trigger::ChatCommand { + command, + aliases, + roles, + user_cooldown_ms, + } if (*command == first || aliases.contains(&first)) && allowed(*roles, &msg) => { + let key = format!("{}#{idx}#{}", i.id, msg.user_login); + let cooled = self + .viewer_last + .get(&key) + .is_some_and(|t| t.elapsed() < Duration::from_millis(*user_cooldown_ms)); + (!cooled).then(|| (i.clone(), idx, first.clone())) + } + Trigger::ChatMessage { + pattern, + mode, + roles, + } if allowed(*roles, &msg) && text_matches(text, pattern, *mode) => { + Some((i.clone(), idx, "chat".to_string())) + } + _ => None, + }) + .collect(); + for (integration, idx, label) in candidates { + let key = format!("{}#{idx}#{}", integration.id, msg.user_login); + let vars = chat_vars(&msg, &args); + let trigger = if label == "chat" { + "Chat message".to_string() + } else { + format!("{label} in chat") + }; + // A command that didn't run (quiet hours, busy) doesn't make the + // viewer wait before trying again. + if self.start_run(integration, idx, vars, Some(msg.id.clone()), trigger) { + self.viewer_last.insert(key, Instant::now()); + } + } + } + + /// 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 + } + (Trigger::Shortcut { midi, .. }, Shortcut::Midi) => pad_matches(midi, signature), + _ => false, + }) + .map(|(i, idx, _)| (i, idx)) + .collect(); + let (label, source) = match from { + Shortcut::Hotkey => (format!("Hotkey {signature}"), "hotkey"), + Shortcut::Midi => ("MIDI pad".to_string(), "midi"), + }; + for (integration, idx) in matches { + 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 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_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, + }) + .collect(); + 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, label.to_string()); + } + } + + fn run_timers(&mut self) { + let live = self.ctrl.ingest_alive(); + let timers: Vec<(Arc, usize, u64, bool)> = self + .enabled_handlers() + .filter_map(|(i, idx, h)| match h.trigger { + Trigger::Timer { + every_ms, + only_live, + } => Some((i, idx, every_ms, only_live)), + _ => None, + }) + .collect(); + // A timer switched off forgets its last run, so switching it back on + // waits a full period instead of firing at once. + let running: Vec = timers + .iter() + .map(|(i, idx, ..)| format!("{}#{idx}", i.id)) + .collect(); + self.timers.retain(|key, _| running.contains(key)); + for (integration, idx, every_ms, only_live) in timers { + let key = format!("{}#{idx}", integration.id); + // A new timer waits one full period before its first run. + let last = *self.timers.entry(key.clone()).or_insert_with(Instant::now); + if last.elapsed() < Duration::from_millis(every_ms) { + continue; + } + self.timers.insert(key, Instant::now()); + if live || !only_live { + self.start_run(integration, idx, BTreeMap::new(), None, "Timer".to_string()); + } + } + } + + /// 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(); + let Some(before) = self.delay_seen.replace(now) else { + return; + }; + let fmt = super::host::fmt_delay; + let kind = match (before, now) { + (0, n) if n > 0 => EventKind::DelayOn, + (b, 0) if b > 0 => EventKind::DelayOff, + (b, n) if b != n => EventKind::DelayChanged, + _ => return, + }; + let previous = if before == 0 { + "0s".to_string() + } else { + fmt(before) + }; + let mut event = Event::new(kind).with("previous", previous); + // The delay just set: the applied one only catches up a moment later + // (after the buffer jump), so `{delay}` would still read the old one. + if now > 0 { + event = event.with("delay", fmt(now)); + } + self.event(event); + } + + fn enabled_handlers( + &self, + ) -> impl Iterator, usize, &super::model::Handler)> + '_ { + self.integrations + .iter() + .filter(|i| i.enabled) + .flat_map(|i| { + i.handlers + .iter() + .enumerate() + .filter(|(_, h)| h.enabled) + .map(move |(idx, h)| (i.clone(), idx, h)) + }) + } + + /// Start a run unless quiet hours, the cooldown or the run limits say + /// no. True when it started: only then does it count toward cooldowns. + fn start_run( + &mut self, + integration: Arc, + idx: usize, + mut vars: BTreeMap, + reply_to: Option, + trigger: String, + ) -> bool { + if let Some(quiet) = integration.quiet { + let now = super::clock::now(); + if quiet.contains((now.hour * 60 + now.minute) as u16) { + return false; + } + } + let key = format!("{}#{idx}", integration.id); + if integration.cooldown_ms > 0 + && self + .last_run + .get(&key) + .is_some_and(|t| t.elapsed() < Duration::from_millis(integration.cooldown_ms)) + { + // A button pressed too soon says so: the press wasn't lost. + // Chat, much busier, doesn't fill the log with them. + if matches!( + integration.handlers[idx].trigger, + Trigger::Shortcut { .. } | Trigger::ChatActivity(_) + ) { + self.turned_away(&integration, trigger, "cooldown"); + } + return false; + } + let (started_limit, active_limit) = self + .limits + .entry(integration.id.clone()) + .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 = ( + 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(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()); + let uses = { + let mut uses = self.store.uses.lock(); + let count = uses.entry(integration.id.clone()).or_insert(0); + *count += 1; + *count + }; + self.uses_dirty = true; + vars.insert("uses".to_string(), uses.to_string()); + let ctx = RunContext { + integration_id: integration.id.clone(), + 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 _started = (started, started_all); + let at_ms = unix_ms(); + let (status, steps) = + runner::run(&integration.handlers[idx].steps, ctx, host, env).await; + let status = status_id(&status); + if status == "failed" { + if let Some(step) = steps + .iter() + .find(|s| s.status == runner::StepStatus::Failed) + { + ctrl.log(format!( + "[integrations] {}: {} failed: {}", + integration.name, step.label, step.detail + )); + } + } + handle.push(Record { + at_ms, + integration_id: integration.id.clone(), + name: integration.name.clone(), + trigger, + status, + test: false, + steps, + }); + }); + true + } + + /// 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 + .uses + .lock() + .get(&integration.id) + .copied() + .unwrap_or(0) + + 1; + vars.insert("uses".to_string(), next.to_string()); + let ctx = RunContext { + integration_id: integration.id.clone(), + vars, + reply_to: None, + test: true, + dry: run.dry, + gate: None, + }; + let host: Arc = self.host.clone(); + let env = self.env.clone(); + let handle = self.handle.clone(); + tokio::spawn(async move { + let at_ms = unix_ms(); + let (status, steps) = runner::run(&handler.steps, ctx, host, env).await; + let record = Record { + at_ms, + integration_id: integration.id.clone(), + name: integration.name.clone(), + trigger, + status: status_id(&status), + test: true, + steps, + }; + handle.push(record.clone()); + let _ = reply.send(record); + }); + } +} + +/// 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. +#[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 { + fn prune(&mut self, now: Instant) { + self.last + .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 Some((key, destination)) = Self::key(event) else { + return false; + }; + 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 { + match status { + RunStatus::Ok => "ok", + RunStatus::Stopped => "stopped", + RunStatus::Failed => "failed", + } +} + +/// 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, 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 || { + 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.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 + || (roles.mods && msg.is_mod) + || (roles.vips && msg.is_vip) + || (roles.subs && msg.is_sub) +} + +fn role_of(msg: &ChatMessage) -> &'static str { + if msg.is_broadcaster { + "broadcaster" + } else if msg.is_mod { + "mod" + } else if msg.is_vip { + "vip" + } else if msg.is_sub { + "sub" + } else { + "viewer" + } +} + +fn chat_vars(msg: &ChatMessage, args: &[&str]) -> BTreeMap { + let mut vars = BTreeMap::from([ + ("user".to_string(), msg.display_name.clone()), + ("user_login".to_string(), msg.user_login.clone()), + ("user_role".to_string(), role_of(msg).to_string()), + ("message".to_string(), msg.text.clone()), + ("args".to_string(), args.join(" ")), + // Who a command is about: `!so @ana` names ana, `!so` alone the viewer. + ( + "target".to_string(), + args.first() + .map(|a| a.trim_start_matches('@').to_string()) + .filter(|a| !a.is_empty()) + .unwrap_or_else(|| msg.display_name.clone()), + ), + ]); + for (i, arg) in args.iter().take(9).enumerate() { + vars.insert(format!("arg{}", i + 1), arg.to_string()); + } + vars +} + +/// Trigger variables with sample values, for test runs. +fn sample_vars(trigger: &Trigger) -> (BTreeMap, String) { + let owned = |pairs: &[(&str, &str)]| { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>() + }; + match trigger { + 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(); + } + (vars, format!("Test: {}", kind.label())) + } + Trigger::ChatCommand { command, .. } => ( + owned(&[ + ("user", "TestViewer"), + ("user_login", "testviewer"), + ("user_role", "mod"), + ("target", "TestViewer"), + ("message", &format!("{command} 30")), + ("args", "30"), + ("arg1", "30"), + ]), + format!("Test: {command}"), + ), + Trigger::ChatMessage { pattern, .. } => ( + owned(&[ + ("user", "TestViewer"), + ("user_login", "testviewer"), + ("user_role", "viewer"), + ("message", pattern.as_str()), + ]), + "Test: chat message".to_string(), + ), + Trigger::Timer { .. } => (BTreeMap::new(), "Test: timer".to_string()), + Trigger::Webhook { .. } => ( + owned(&[("body", r#"{"test":true}"#), ("query", "")]), + "Test: web call".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(); + let pattern = pattern.trim().to_lowercase(); + if pattern.is_empty() { + return false; + } + match mode { + MatchMode::Contains => text.contains(&pattern), + MatchMode::StartsWith => text.starts_with(&pattern), + MatchMode::Exact => text.trim() == pattern, + MatchMode::Wildcard => wildcard(text.trim(), &pattern), + } +} + +/// `*` = any run of characters, `?` = one character. Iterative, so a +/// hostile pattern cannot blow up the way naive backtracking would. +fn wildcard(text: &str, pattern: &str) -> bool { + let t: Vec = text.chars().collect(); + let p: Vec = pattern.chars().collect(); + let (mut ti, mut pi) = (0, 0); + let mut star: Option<(usize, usize)> = None; + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + ti += 1; + pi += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some((pi, ti)); + pi += 1; + } else if let Some((sp, st)) = star { + pi = sp + 1; + ti = st + 1; + star = Some((sp, st + 1)); + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(text: &str) -> ChatMessage { + ChatMessage { + text: text.into(), + user_login: "ana".into(), + display_name: "Ana".into(), + ..ChatMessage::default() + } + } + + #[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 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(); + 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*")); + assert!(wildcard("clip it", "clip ??")); + assert!(wildcard("abc", "*b*")); + assert!(!wildcard("abc", "a?")); + assert!(wildcard(&"a".repeat(200), &"*a".repeat(50))); + } + + #[test] + fn matching_ignores_case() { + assert!(text_matches("POG champ", "pog", MatchMode::StartsWith)); + assert!(text_matches(" GG ", "gg", MatchMode::Exact)); + assert!(!text_matches("gg", "", MatchMode::Contains)); + } + + #[test] + fn roles_gate_commands_but_never_the_broadcaster() { + let mut m = msg("!cut"); + assert!(!allowed(Roles::MODS, &m)); + m.is_mod = true; + assert!(allowed(Roles::MODS, &m)); + let mut owner = msg("!cut"); + owner.is_broadcaster = true; + assert!(allowed(Roles::MODS, &owner)); + } + + #[test] + fn chat_vars_split_arguments() { + let v = chat_vars(&msg("!setdelay 45 now"), &["45", "now"]); + assert_eq!(v["arg1"], "45"); + assert_eq!(v["target"], "45"); + assert_eq!(v["arg2"], "now"); + assert_eq!(v["args"], "45 now"); + assert_eq!(v["user_role"], "viewer"); + } + + #[test] + fn filters_compare_event_values() { + let crash = Event::new(EventKind::HoldOpened).with("reason", "crash"); + let want = |v: &str| BTreeMap::from([("reason".to_string(), v.to_string())]); + assert!(filters_match(&want("Crash"), &crash)); + 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 new file mode 100644 index 0000000..f7d58ce --- /dev/null +++ b/src/integrations/event.rs @@ -0,0 +1,405 @@ +//! What can happen in InstantClone that an integration can react to. +//! +//! Each kind has a stable id (saved in integrations, so never renamed), a +//! label and group for the dashboard, and the variables it carries with a +//! sample value each. The samples fill previews and test runs, so a test +//! message reads like the real one. + +use crate::json::{self, Value}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EventKind { + ObsConnected, + ObsDisconnected, + EbDetected, + HoldOpened, + ObsBack, + HoldExpired, + HoldEnded, + DestinationLive, + DestinationDropped, + AllDestinationsDown, + DelayOn, + DelayOff, + DelayChanged, + 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; 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, + EventKind::DelayReady, + EventKind::DelayOn, + EventKind::DelayChanged, + EventKind::DelayOff, + EventKind::DelayDisarmed, + ]; + + pub fn id(self) -> &'static str { + match self { + EventKind::ObsConnected => "obs_connected", + EventKind::ObsDisconnected => "obs_disconnected", + EventKind::EbDetected => "eb_detected", + EventKind::HoldOpened => "hold_opened", + EventKind::ObsBack => "obs_back", + EventKind::HoldExpired => "hold_expired", + EventKind::HoldEnded => "hold_ended", + EventKind::DestinationLive => "destination_live", + EventKind::DestinationDropped => "destination_dropped", + EventKind::AllDestinationsDown => "all_destinations_down", + EventKind::DelayOn => "delay_on", + EventKind::DelayOff => "delay_off", + EventKind::DelayChanged => "delay_changed", + 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", + } + } + + pub fn from_id(id: &str) -> Option { + EventKind::ALL.into_iter().find(|k| k.id() == id) + } + + pub fn label(self) -> &'static str { + match self { + 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", + EventKind::HoldExpired => "The hold runs out", + EventKind::HoldEnded => "You end the hold early", + 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 + | EventKind::StreamStarted + | EventKind::StreamEnded + | EventKind::OnAirChanged + | EventKind::TimelineUpdated => "Stream", + EventKind::HoldOpened + | EventKind::ObsBack + | EventKind::HoldExpired + | EventKind::HoldEnded => "Crash protection", + EventKind::DestinationLive + | EventKind::DestinationDropped + | EventKind::DestinationRecovered + | EventKind::DestinationStillDown + | EventKind::DestinationUnstable + | EventKind::DestinationSteady + | EventKind::AllDestinationsDown => "Platforms", + EventKind::DelayOn + | EventKind::DelayOff + | EventKind::DelayChanged + | EventKind::DelayArmed + | EventKind::DelayReady + | EventKind::DelayDisarmed => "Delay", + } + } + + /// Variables the event itself carries, on top of the always-available + /// ones in `vars::GLOBAL`. + pub fn vars(self) -> &'static [VarSpec] { + 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 => &[ + ("reason", "crash"), + ("hold", "2m 00s"), + ("hold_ends_at", "1790000000"), + ], + EventKind::ObsBack => &[("down_for", "38 s")], + EventKind::HoldExpired => &[("hold", "2m 00s")], + EventKind::HoldEnded => &[("down_for", "1m 12s")], + EventKind::DestinationLive => &[("destination", "YouTube"), ("platform", "youtube")], + EventKind::DestinationDropped => &[ + ("destination", "YouTube"), + ("platform", "youtube"), + ("reason", "connection timed out"), + ], + 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::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. +#[derive(Clone, Debug)] +pub struct Event { + pub kind: EventKind, + pub vars: Vec<(&'static str, String)>, +} + +impl Event { + pub fn new(kind: EventKind) -> Self { + Self { + kind, + vars: Vec::new(), + } + } + + pub fn with(mut self, name: &'static str, value: impl Into) -> Self { + self.vars.push((name, value.into())); + self + } + + /// The event filled with its sample values, for test runs. + pub fn sample(kind: EventKind) -> Self { + Self { + kind, + vars: kind + .vars() + .iter() + .map(|(n, v)| (*n, v.to_string())) + .collect(), + } + } + + pub fn var(&self, name: &str) -> Option<&str> { + self.vars + .iter() + .find(|(n, _)| *n == name) + .map(|(_, v)| v.as_str()) + } +} + +/// The event catalog for the dashboard: ids, labels, groups and variables. +pub fn catalog_json() -> Value { + Value::Arr( + EventKind::ALL + .iter() + .map(|k| { + json::obj([ + ("id", json::str(k.id())), + ("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(), + ) +} + +pub fn vars_json(vars: &[VarSpec]) -> Value { + Value::Arr( + vars.iter() + .map(|(name, sample)| { + json::obj([("name", json::str(*name)), ("sample", json::str(*sample))]) + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn ids_round_trip_and_are_unique() { + for kind in EventKind::ALL { + assert_eq!(EventKind::from_id(kind.id()), Some(kind)); + } + let mut ids: Vec<_> = EventKind::ALL.iter().map(|k| k.id()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), EventKind::ALL.len()); + } + + #[test] + fn samples_carry_every_declared_var() { + let e = Event::sample(EventKind::DestinationDropped); + assert_eq!(e.var("destination"), Some("YouTube")); + 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 new file mode 100644 index 0000000..4812b55 --- /dev/null +++ b/src/integrations/host.rs @@ -0,0 +1,196 @@ +//! Everything a running integration needs from the outside world, behind +//! one trait: the live stream state it reads and the effects it causes. +//! +//! The runner only talks to `Host`, so its logic (variables, checks, +//! waits, stop) is tested against a fake that records calls, and the real +//! implementation in `effects.rs` is the only place that does I/O. +//! +//! Effect methods block (HTTPS through ureq, file and process calls); the +//! runner calls them from `spawn_blocking` so a slow endpoint never stalls +//! other integrations. + +/// A snapshot of the stream, read when a step needs it. +#[derive(Clone, Debug, Default)] +pub struct LiveState { + pub delay_ms: u32, + pub phase: String, + pub hold_active: bool, + pub hold_left_ms: u64, + pub destinations_live: usize, + pub destinations_total: usize, + /// Whether OBS is streaming to InstantClone right now. + pub obs_live: bool, + 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 { + pub method: String, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: String, +} + +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +pub struct ClipInfo { + pub id: String, + 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. + pub message_id: String, + /// The earlier message was edited (rather than a new one posted). + pub edited: bool, +} + +pub trait Host: Send + Sync { + fn live(&self) -> LiveState; + /// Post a message, or edit message `edit` when given. An edit whose + /// message is gone (deleted in Discord) posts a new one instead. + fn discord( + &self, + webhook_url: &str, + message: &DiscordMessage, + edit: Option<&str>, + ) -> Result; + fn http(&self, request: HttpRequest) -> Result; + fn phone( + &self, + server: &str, + topic: &str, + title: &str, + text: &str, + priority: &str, + ) -> Result<(), String>; + /// Post in the connected channel's chat. `reply_to` is the id of the + /// message being answered, when there is one. + fn chat(&self, text: &str, as_bot: bool, reply_to: Option<&str>) -> Result<(), String>; + fn marker(&self, description: &str) -> Result<(), String>; + fn clip(&self) -> Result; + /// Run one of the named delay actions (`arm`, `cut`, ...). `ms` is the + /// delay for `arm`. + fn delay_action(&self, action: &str, ms: u32) -> Result<(), String>; + fn program(&self, path: &str, args: &[String]) -> Result<(), String>; + 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. +pub fn fmt_delay(ms: u32) -> String { + if ms == 0 { + return String::new(); + } + let total = (ms + 500) / 1000; + match (total / 60, total % 60) { + (0, s) => format!("{s}s"), + (m, 0) => format!("{m}m"), + (m, s) => format!("{m}m {s}s"), + } +} + +/// 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; + if total < 60 { + format!("{total} s") + } else { + format!("{}m {:02}s", total / 60, total % 60) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delays_read_like_people_say_them() { + assert_eq!(fmt_delay(0), ""); + assert_eq!(fmt_delay(30_000), "30s"); + assert_eq!(fmt_delay(90_000), "1m 30s"); + assert_eq!(fmt_delay(120_000), "2m"); + 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"); + assert_eq!(fmt_duration(120_000), "2m 00s"); + assert_eq!(fmt_duration(72_500), "1m 12s"); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs new file mode 100644 index 0000000..99eeb28 --- /dev/null +++ b/src/integrations/mod.rs @@ -0,0 +1,47 @@ +//! Integrations: react to what happens in InstantClone (a crash, a cut, a +//! chat command) by posting to Discord or Twitch chat, pushing to a phone, +//! calling a web service, running a program, or acting on the stream. +//! +//! Layout: +//! - `event`: what can happen, with the variables each event carries. +//! - `model`: integrations, their triggers and steps, and how they save. +//! - `presets`: the catalog of ready-made integrations and packs. +//! - `template`: `{var}` / `{var|fallback}` message templates. +//! - `botcmd`: Nightbot / StreamElements / Fossabot / Streamlabs commands +//! converted into a "command from a website". +//! - `runner`: runs one handler's steps against a `host::Host`. +//! - `alerts`: what "Show on stream" steps put on the alerts page in OBS. +//! - `host` / `effects`: the live state and I/O a run needs, and the real +//! implementation of both. +//! - `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; +pub mod clock; +pub mod effects; +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; +/// Only the tray (hotkeys) and the MIDI listener press shortcuts: Windows. +#[cfg(windows)] +pub use engine::Shortcut; +pub use event::{Event, EventKind}; diff --git a/src/integrations/model.rs b/src/integrations/model.rs new file mode 100644 index 0000000..dcc72b6 --- /dev/null +++ b/src/integrations/model.rs @@ -0,0 +1,1482 @@ +//! The shape of an integration, and how it is saved. +//! +//! An integration is one or more handlers; a handler is a trigger plus the +//! steps it runs. Catalog modules and hand-built ones are the same thing: +//! a module is an integration whose `preset` names the catalog entry it came +//! from, which is what lets the dashboard show it with the simple editor. +//! +//! Step settings are string parameters rather than typed fields. Every one +//! is a template the runner fills in at run time, and new step options can +//! be added without changing the saved format. + +use super::event::EventKind; +use crate::json::{self, Value}; +use std::collections::BTreeMap; + +pub const MAX_INTEGRATIONS: usize = 100; +/// 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; +const MAX_PARAM_LEN: usize = 4000; +const MAX_ID_LEN: usize = 40; + +#[derive(Clone, Debug, PartialEq)] +pub struct Integration { + pub id: String, + pub name: String, + pub enabled: bool, + /// Catalog entry this came from, or empty when built by hand. + pub preset: String, + /// Least time between two runs of the same handler. 0 = no limit. + pub cooldown_ms: u64, + /// Hours it stays silent, on this PC's clock. + pub quiet: Option, + pub handlers: Vec, +} + +/// A daily window, in minutes since midnight, when an integration doesn't +/// run. `from` after `to` spans midnight (23:00 to 08:00). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct QuietHours { + pub from: u16, + pub to: u16, +} + +impl QuietHours { + pub fn contains(self, minute_of_day: u16) -> bool { + if self.from <= self.to { + (self.from..self.to).contains(&minute_of_day) + } else { + minute_of_day >= self.from || minute_of_day < self.to + } + } + + fn to_json(self) -> Value { + json::obj([ + ("from", json::str(hhmm(self.from))), + ("to", json::str(hhmm(self.to))), + ]) + } + + /// `{"from":"23:00","to":"08:00"}`; both blank is no quiet hours. + fn from_json(v: Option<&Value>) -> Result, String> { + let Some(v) = v.filter(|v| !matches!(v, Value::Null)) else { + return Ok(None); + }; + let (from, to) = (v.str_or("from", "").trim(), v.str_or("to", "").trim()); + if from.is_empty() && to.is_empty() { + return Ok(None); + } + match (parse_hhmm(from), parse_hhmm(to)) { + (Some(from), Some(to)) if from != to => Ok(Some(QuietHours { from, to })), + (Some(_), Some(_)) => Err("quiet hours can't start and end at the same time".into()), + _ => Err("quiet hours need times like 23:00".into()), + } + } +} + +fn hhmm(minutes: u16) -> String { + format!("{:02}:{:02}", minutes / 60, minutes % 60) +} + +/// `23:00` or `8:05` to minutes since midnight. +fn parse_hhmm(text: &str) -> Option { + let (h, m) = text.split_once(':')?; + let (h, m) = (h.trim().parse::().ok()?, m.trim().parse::().ok()?); + (h < 24 && m < 60 && text.len() <= 5).then_some(h * 60 + m) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Handler { + pub enabled: bool, + pub trigger: Trigger, + pub steps: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Trigger { + /// An InstantClone event, optionally narrowed to events whose + /// variables match (`reason` = `crash`). + Event { + kind: EventKind, + filters: BTreeMap, + }, + /// `!command` in Twitch chat. + ChatCommand { + command: String, + aliases: Vec, + roles: Roles, + user_cooldown_ms: u64, + }, + /// Any chat message that matches. + ChatMessage { + pattern: String, + mode: MatchMode, + roles: Roles, + }, + /// Every `every_ms`, optionally only while OBS is streaming. + Timer { every_ms: u64, only_live: bool }, + /// A call to `/hooks/`, from a Stream Deck, a script or any app. + Webhook { token: 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 { + pub everyone: bool, + pub subs: bool, + pub vips: bool, + pub mods: bool, +} + +impl Roles { + pub const EVERYONE: Roles = Roles { + everyone: true, + subs: true, + vips: true, + mods: true, + }; + pub const MODS: Roles = Roles { + everyone: false, + subs: false, + vips: false, + mods: true, + }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MatchMode { + Contains, + StartsWith, + Exact, + /// `*` matches any run of characters, `?` one character. + Wildcard, +} + +impl MatchMode { + fn id(self) -> &'static str { + match self { + MatchMode::Contains => "contains", + MatchMode::StartsWith => "starts_with", + MatchMode::Exact => "exact", + MatchMode::Wildcard => "wildcard", + } + } + + fn from_id(id: &str) -> MatchMode { + match id { + "starts_with" => MatchMode::StartsWith, + "exact" => MatchMode::Exact, + "wildcard" => MatchMode::Wildcard, + _ => MatchMode::Contains, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StepKind { + Discord, + Chat, + Phone, + Http, + Wait, + WaitDelay, + If, + Stop, + DelayAction, + Marker, + Clip, + Program, + File, + SetVar, + Counter, + /// A card on the alerts browser source, on stream. + Overlay, + /// 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; 19] = [ + StepKind::Discord, + StepKind::Chat, + StepKind::Phone, + StepKind::Http, + StepKind::Wait, + StepKind::WaitDelay, + StepKind::If, + StepKind::Stop, + StepKind::DelayAction, + StepKind::Marker, + StepKind::Clip, + StepKind::Program, + StepKind::File, + StepKind::SetVar, + StepKind::Counter, + StepKind::Overlay, + StepKind::EditText, + StepKind::Timeline, + StepKind::Obs, + ]; + + pub fn id(self) -> &'static str { + match self { + StepKind::Discord => "discord", + StepKind::Chat => "chat", + StepKind::Phone => "phone", + StepKind::Http => "http", + StepKind::Wait => "wait", + StepKind::WaitDelay => "wait_delay", + StepKind::If => "if", + StepKind::Stop => "stop", + StepKind::DelayAction => "delay_action", + StepKind::Marker => "marker", + StepKind::Clip => "clip", + StepKind::Program => "program", + StepKind::File => "file", + StepKind::SetVar => "set_var", + StepKind::Counter => "counter", + StepKind::Overlay => "overlay", + StepKind::EditText => "edit_text", + StepKind::Timeline => "timeline", + StepKind::Obs => "obs", + } + } + + pub fn from_id(id: &str) -> Option { + StepKind::ALL.into_iter().find(|k| k.id() == id) + } + + /// 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 { + // 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")], + 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::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 => &[], + } + } + + /// Steps that can touch this PC outside InstantClone. An imported + /// recipe carrying one needs the user's say-so. + pub fn is_local_effect(self) -> bool { + matches!(self, StepKind::Program | StepKind::File) + } +} + +#[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, + pub otherwise: Vec, +} + +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())) + .collect(), + then: Vec::new(), + otherwise: Vec::new(), + } + } + + pub fn param(&self, name: &str) -> &str { + self.params.get(name).map(String::as_str).unwrap_or("") + } +} + +/// Count steps including nested ones. +pub fn count_steps(steps: &[Step]) -> usize { + steps + .iter() + .map(|s| 1 + count_steps(&s.then) + count_steps(&s.otherwise)) + .sum() +} + +/// Walk every step, nested ones included. +pub fn visit_steps<'a>(steps: &'a [Step], f: &mut dyn FnMut(&'a Step)) { + for step in steps { + f(step); + visit_steps(&step.then, f); + visit_steps(&step.otherwise, f); + } +} + +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()); + } + if self.name.trim().is_empty() { + errors.push("give it a name".to_string()); + } + if self.name.chars().count() > MAX_NAME_LEN { + errors.push(format!("the name is longer than {MAX_NAME_LEN} characters")); + } + if self.handlers.is_empty() { + incomplete.push("add at least one trigger".to_string()); + } + if self.handlers.len() > MAX_HANDLERS { + errors.push(format!("more than {MAX_HANDLERS} triggers")); + } + let total: usize = self.handlers.iter().map(|h| count_steps(&h.steps)).sum(); + if total > MAX_STEPS { + errors.push(format!("more than {MAX_STEPS} steps")); + } + for handler in &self.handlers { + // 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(); + Problems { + invalid: errors, + incomplete, + } + } + + /// Whether any step runs a program or writes a file. + pub fn has_local_effects(&self) -> bool { + let mut found = false; + for handler in &self.handlers { + visit_steps(&handler.steps, &mut |s| found |= s.kind.is_local_effect()); + } + found + } + + pub fn to_json(&self) -> Value { + json::obj([ + ("id", json::str(&self.id)), + ("name", json::str(&self.name)), + ("enabled", Value::Bool(self.enabled)), + ("preset", json::str(&self.preset)), + ("cooldown_ms", Value::Num(self.cooldown_ms as f64)), + ("quiet", self.quiet.map_or(Value::Null, QuietHours::to_json)), + ( + "handlers", + Value::Arr(self.handlers.iter().map(handler_json).collect()), + ), + ]) + } + + /// Read an integration from its JSON form. Unknown step or trigger + /// types are errors, so nothing is silently dropped on save. + pub fn from_json(v: &Value) -> Result { + let handlers = v + .get("handlers") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .map(handler_from_json) + .collect::, _>>()?; + Ok(Integration { + id: v.str_or("id", "").to_string(), + name: v.str_or("name", "").trim().to_string(), + enabled: v.bool_or("enabled", true), + preset: v.str_or("preset", "").to_string(), + cooldown_ms: v.u64_or("cooldown_ms", 0), + quiet: QuietHours::from_json(v.get("quiet"))?, + handlers, + }) + } +} + +/// The hotkeys and MIDI pads that switched-on integrations listen for, +/// each once. +pub fn shortcuts(integrations: &[Integration]) -> (Vec, Vec) { + let (mut keys, mut pads) = (Vec::new(), Vec::new()); + let live = integrations + .iter() + .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 !hotkey.is_empty() && !keys.contains(hotkey) { + keys.push(hotkey.clone()); + } + if !midi.is_empty() && !pads.contains(midi) { + pads.push(midi.clone()); + } + } + } + (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 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +fn validate_trigger(trigger: &Trigger, errors: &mut Vec) { + match trigger { + Trigger::ChatCommand { + command, aliases, .. + } => { + for c in std::iter::once(command).chain(aliases) { + if !valid_command(c) { + errors.push(format!( + "\"{c}\" is not a chat command: start it with ! and use no spaces" + )); + } + } + } + Trigger::ChatMessage { pattern, .. } if pattern.trim().is_empty() => { + errors.push("the chat message trigger needs something to match".to_string()); + } + Trigger::Timer { every_ms, .. } if *every_ms < 60_000 => { + errors.push("timers run at most once a minute".to_string()); + } + 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, + 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) + { + errors.push(format!( + "\"{hotkey}\" isn't a hotkey: hold Ctrl, Alt, Shift or Win and press one key" + )); + } + if !midi.is_empty() && crate::config::canonicalize_midi(midi).as_ref() != Some(midi) { + 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 + && c.starts_with('!') + && !c[1..].contains(|ch: char| ch.is_whitespace() || ch == '!') +} + +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 { + // 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() { + 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 + // 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()); + } + // 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", + step.kind.id() + )); + } + 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, 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, + } +} + +/// 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)), + ("subs", Value::Bool(r.subs)), + ("vips", Value::Bool(r.vips)), + ("mods", Value::Bool(r.mods)), + ]) +} + +fn roles_from_json(v: Option<&Value>) -> Roles { + match v { + Some(v) => Roles { + everyone: v.bool_or("everyone", false), + subs: v.bool_or("subs", false), + vips: v.bool_or("vips", false), + mods: v.bool_or("mods", false), + }, + None => Roles::EVERYONE, + } +} + +fn string_map_json(map: &BTreeMap) -> Value { + Value::Obj(map.iter().map(|(k, v)| (k.clone(), json::str(v))).collect()) +} + +fn string_map_from_json(v: Option<&Value>) -> BTreeMap { + match v { + Some(Value::Obj(fields)) => fields + .iter() + .map(|(k, v)| (k.clone(), v.to_display())) + .collect(), + _ => BTreeMap::new(), + } +} + +fn trigger_json(t: &Trigger) -> Value { + match t { + Trigger::Event { kind, filters } => json::obj([ + ("type", json::str("event")), + ("event", json::str(kind.id())), + ("filters", string_map_json(filters)), + ]), + Trigger::ChatCommand { + command, + aliases, + roles, + user_cooldown_ms, + } => json::obj([ + ("type", json::str("chat_command")), + ("command", json::str(command)), + ( + "aliases", + Value::Arr(aliases.iter().map(json::str).collect()), + ), + ("roles", roles_json(*roles)), + ("user_cooldown_ms", Value::Num(*user_cooldown_ms as f64)), + ]), + Trigger::ChatMessage { + pattern, + mode, + roles, + } => json::obj([ + ("type", json::str("chat_message")), + ("pattern", json::str(pattern)), + ("mode", json::str(mode.id())), + ("roles", roles_json(*roles)), + ]), + Trigger::Timer { + every_ms, + only_live, + } => json::obj([ + ("type", json::str("timer")), + ("every_ms", Value::Num(*every_ms as f64)), + ("only_live", Value::Bool(*only_live)), + ]), + Trigger::Webhook { token } => { + json::obj([("type", json::str("webhook")), ("token", json::str(token))]) + } + 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" => { + let id = v.str_or("event", ""); + let kind = EventKind::from_id(id).ok_or_else(|| format!("unknown event \"{id}\""))?; + Ok(Trigger::Event { + kind, + filters: string_map_from_json(v.get("filters")), + }) + } + "chat_command" => Ok(Trigger::ChatCommand { + command: v.str_or("command", "").trim().to_lowercase(), + aliases: v + .get("aliases") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(Value::as_str) + .map(|a| a.trim().to_lowercase()) + .filter(|a| !a.is_empty()) + .collect(), + roles: roles_from_json(v.get("roles")), + user_cooldown_ms: v.u64_or("user_cooldown_ms", 0), + }), + "chat_message" => Ok(Trigger::ChatMessage { + pattern: v.str_or("pattern", "").to_string(), + mode: MatchMode::from_id(v.str_or("mode", "")), + roles: roles_from_json(v.get("roles")), + }), + "timer" => Ok(Trigger::Timer { + every_ms: v.u64_or("every_ms", 0), + only_live: v.bool_or("only_live", true), + }), + "webhook" => Ok(Trigger::Webhook { + token: v.str_or("token", "").to_string(), + }), + "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}\"")), + } +} + +impl Handler { + pub fn to_json(&self) -> Value { + handler_json(self) + } +} + +fn handler_json(h: &Handler) -> Value { + json::obj([ + ("enabled", Value::Bool(h.enabled)), + ("trigger", trigger_json(&h.trigger)), + ("steps", steps_json(&h.steps)), + ]) +} + +fn handler_from_json(v: &Value) -> Result { + let trigger = v + .get("trigger") + .ok_or("a trigger is missing") + .map_err(str::to_string) + .and_then(trigger_from_json)?; + Ok(Handler { + enabled: v.bool_or("enabled", true), + trigger, + steps: steps_from_json(v.get("steps"), 0)?, + }) +} + +fn steps_json(steps: &[Step]) -> Value { + Value::Arr( + steps + .iter() + .map(|s| { + let mut fields = vec![ + ("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))); + } + Value::Obj(fields) + }) + .collect(), + ) +} + +fn steps_from_json(v: Option<&Value>, depth: usize) -> Result, String> { + if depth > MAX_DEPTH { + return Err(format!("checks nest more than {MAX_DEPTH} deep")); + } + v.and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .map(|s| { + let id = s.str_or("type", ""); + 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)?, + }) + }) + .collect() +} + +/// A saved Discord webhook the integrations can post to. +#[derive(Clone, Debug, PartialEq)] +pub struct DiscordChannel { + pub id: String, + pub name: String, + pub url: String, +} + +/// Phone pushes through ntfy (a topic on ntfy.sh or a self-hosted server). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PhoneConnection { + pub server: String, + pub topic: String, +} + +pub const DEFAULT_NTFY_SERVER: &str = "https://ntfy.sh"; + +impl PhoneConnection { + pub fn is_set(&self) -> bool { + !self.topic.trim().is_empty() + } + + pub fn server_or_default(&self) -> &str { + match self.server.trim() { + "" => DEFAULT_NTFY_SERVER, + s => s.trim_end_matches('/'), + } + } +} + +pub fn valid_discord_webhook(url: &str) -> bool { + let url = url.trim(); + [ + "https://discord.com/api/webhooks/", + "https://discordapp.com/api/webhooks/", + "https://ptb.discord.com/api/webhooks/", + "https://canary.discord.com/api/webhooks/", + ] + .iter() + .any(|p| url.starts_with(p) && url.len() > p.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Integration { + let mut check = Step::new( + StepKind::If, + &[("left", "{hold_active}"), ("op", "is"), ("right", "yes")], + ); + check.then.push(Step::new( + StepKind::Chat, + &[("text", "OBS dropped, back soon")], + )); + check.otherwise.push(Step::new(StepKind::Stop, &[])); + Integration { + id: "tell-chat".into(), + name: "Tell chat".into(), + enabled: true, + preset: "tell_chat".into(), + cooldown_ms: 60_000, + quiet: Some(QuietHours { + from: 23 * 60, + to: 8 * 60, + }), + handlers: vec![ + Handler { + enabled: true, + trigger: Trigger::Event { + kind: EventKind::HoldOpened, + filters: BTreeMap::from([("reason".to_string(), "crash".to_string())]), + }, + steps: vec![Step::new(StepKind::Wait, &[("ms", "20000")]), check], + }, + Handler { + enabled: false, + trigger: Trigger::ChatCommand { + command: "!clip".into(), + aliases: vec!["!c".into()], + roles: Roles::MODS, + user_cooldown_ms: 30_000, + }, + steps: vec![Step::new(StepKind::Clip, &[])], + }, + Handler { + enabled: true, + 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!")])], + }, + ], + } + } + + #[test] + fn json_round_trip_is_lossless() { + let original = sample(); + let text = original.to_json().to_json(); + assert!(crate::config::is_valid_json(&text)); + let back = Integration::from_json(&json::parse(&text).unwrap()).unwrap(); + assert_eq!(back, original); + } + + #[test] + fn valid_sample_passes_validation() { + assert!(sample().validate().is_empty(), "{:?}", sample().validate()); + } + + #[test] + fn validation_names_the_problem() { + let mut bad = sample(); + bad.name = " ".into(); + bad.handlers[0] + .steps + .push(Step::new(StepKind::Discord, &[("text", "hi")])); + bad.handlers[1].trigger = Trigger::ChatCommand { + command: "clip".into(), + aliases: vec![], + 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] + fn nesting_is_capped() { + let mut deepest = Step::new(StepKind::Stop, &[]); + for _ in 0..MAX_DEPTH + 1 { + let mut wrap = Step::new(StepKind::If, &[("left", "a"), ("op", "is")]); + wrap.then.push(deepest); + deepest = wrap; + } + let mut i = sample(); + i.handlers[0].steps = vec![deepest]; + assert!(i.validate().iter().any(|e| e.contains("nest"))); + } + + #[test] + fn unknown_types_are_errors_not_silently_dropped() { + let v = json::parse(r#"{"id":"x","name":"x","handlers":[{"trigger":{"type":"event","event":"nope"},"steps":[]}]}"#).unwrap(); + assert!(Integration::from_json(&v).is_err()); + let v = json::parse(r#"{"id":"x","name":"x","handlers":[{"trigger":{"type":"timer","every_ms":60000},"steps":[{"type":"teleport"}]}]}"#).unwrap(); + assert!(Integration::from_json(&v).is_err()); + } + + #[test] + fn local_effects_are_detected_when_nested() { + let mut i = sample(); + assert!(!i.has_local_effects()); + i.handlers[0].steps[1] + .otherwise + .push(Step::new(StepKind::Program, &[("path", "calc.exe")])); + 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 quiet_hours_can_span_midnight() { + let night = QuietHours { + from: 23 * 60, + to: 8 * 60, + }; + assert!(night.contains(23 * 60)); + assert!(night.contains(2 * 60)); + assert!(!night.contains(8 * 60)); + assert!(!night.contains(12 * 60)); + let lunch = QuietHours { + from: 12 * 60, + to: 13 * 60, + }; + assert!(lunch.contains(12 * 60 + 30)); + assert!(!lunch.contains(13 * 60)); + } + + #[test] + fn quiet_hours_read_clock_times_and_refuse_nonsense() { + let read = |text: &str| QuietHours::from_json(Some(&json::parse(text).unwrap())); + assert_eq!( + read(r#"{"from":"23:00","to":"8:05"}"#), + Ok(Some(QuietHours { + from: 23 * 60, + to: 8 * 60 + 5 + })) + ); + assert_eq!(read(r#"{"from":"","to":""}"#), Ok(None)); + assert_eq!(QuietHours::from_json(None), Ok(None)); + assert!(read(r#"{"from":"25:00","to":"08:00"}"#).is_err()); + assert!(read(r#"{"from":"10:00","to":"10:00"}"#).is_err()); + 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(); + let at = i.handlers.len() - 1; + 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 = 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] + fn only_live_shortcuts_are_listened_for() { + let mut i = sample(); + let (keys, pads) = shortcuts(std::slice::from_ref(&i)); + assert_eq!(keys, ["Ctrl+Alt+K"]); + assert_eq!(pads, ["note:1:36@Launchpad"]); + i.enabled = false; + assert_eq!(shortcuts(&[i]), (vec![], vec![])); + } + + #[test] + fn commands_need_a_bang_and_no_spaces() { + assert!(valid_command("!delay")); + assert!(!valid_command("delay")); + assert!(!valid_command("!")); + assert!(!valid_command("!two words")); + } + + #[test] + fn discord_webhook_urls_are_recognised() { + assert!(valid_discord_webhook( + "https://discord.com/api/webhooks/1/abc" + )); + assert!(!valid_discord_webhook( + "https://evil.example/api/webhooks/1" + )); + assert!(!valid_discord_webhook("https://discord.com/api/webhooks/")); + } +} 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 new file mode 100644 index 0000000..25f2806 --- /dev/null +++ b/src/integrations/presets.rs @@ -0,0 +1,1645 @@ +//! The catalog: ready-made integrations, and the packs that add several at +//! once. Each entry builds an ordinary `Integration`, so anything added +//! from here can be edited freely afterwards. +//! +//! Also home to the one-time migration of the old single Discord webhook +//! setting, which becomes a Discord connection plus a "Stream alerts" +//! integration sending exactly the messages it used to. + +use super::event::EventKind; +use super::model::{Handler, Integration, Roles, Step, StepKind, Trigger}; +use crate::json::{self, Value}; +use std::collections::BTreeMap; + +pub struct Preset { + pub id: &'static str, + pub name: &'static str, + /// `alerts`, `chat` or `auto`: the catalog's categories. + pub category: &'static str, + pub description: &'static str, + /// What it can't work without: `discord`, `twitch`, `phone`, `web` + /// (an address to send to), `file` or `obs`. The first is the main one. + 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), +} + +pub const PRESETS: &[Preset] = &[ + Preset { + id: "crash_alert", + name: "OBS crash alert", + category: "alerts", + description: "One Discord message per OBS crash that updates itself: down, then back on air.", + needs: &["discord"], + recommended: true, + preview: ( + "discord", + "OBS dropped (crash). Holding the stream for 2m 00s.", + "", + ), + }, + 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: "Platform dropped", + category: "alerts", + 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.", ""), + }, + Preset { + id: "going_live", + name: "Going live ping", + category: "alerts", + description: "Tells your community the moment OBS starts streaming.", + needs: &["discord"], + 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", + category: "alerts", + description: "A push on your lock screen, so you know to run back to the PC.", + needs: &["phone"], + recommended: false, + preview: ( + "phone", + "Crash protection is on for 2m 00s. Get back to the PC.", + "", + ), + }, + Preset { + id: "tell_chat", + name: "Tell chat", + category: "chat", + description: "After 20 s, so short blips never reach chat. Says \"we're back\" too.", + needs: &["twitch"], + recommended: true, + preview: ( + "chat", + "OBS dropped, we're coming back. Don't go anywhere!", + "frozen??", + ), + }, + Preset { + id: "delay_command", + name: "!delay", + category: "chat", + description: "Anyone types it, the bot answers with your delay. Always up to date.", + needs: &["twitch"], + recommended: true, + preview: ("chat", "Delay: 30s", "!delay"), + }, + Preset { + id: "delay_notice", + name: "Delay on / off notice", + category: "chat", + description: "Tells chat when the delay switches. Never says how long.", + needs: &["twitch"], + recommended: false, + preview: ("chat", "Stream delay is now on.", ""), + }, + Preset { + id: "mod_controls", + name: "Mod controls", + category: "chat", + description: "!cut and !setdelay 30 for your mods. Controls the stream.", + needs: &["twitch"], + recommended: false, + preview: ("chat", "Cut by mod_anna.", "!cut"), + }, + Preset { + id: "socials", + name: "!discord", + category: "chat", + description: "A command with your own reply. Rename it to anything.", + 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"], + 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"], + recommended: false, + preview: ("chat", "🎬 Clipped: https://clips.twitch.tv/BraveSnipe", ""), + }, + Preset { + id: "vod_markers", + name: "VOD markers", + category: "auto", + description: "A marker at every crash and cut, so your editor finds them in seconds.", + needs: &["twitch"], + recommended: false, + preview: ("marker", "Crash", ""), + }, + Preset { + id: "webhook", + name: "Webhook", + category: "auto", + description: "Every event as JSON to n8n, Home Assistant, Zapier or your own tool.", + needs: &["web"], + recommended: false, + preview: ("json", "", ""), + }, + Preset { + id: "crash_counter", + name: "Crash counter", + category: "auto", + description: "Counts crashes into a text file an OBS text source can show.", + needs: &["file"], + recommended: false, + preview: ("file", "Crashes today: 2", ""), + }, +]; + +pub struct Pack { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub presets: &'static [&'static str], +} + +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", + description: "OBS drops: Discord and chat hear about it, and your editor gets a marker.", + presets: &["crash_alert", "tell_chat", "vod_markers"], + }, + Pack { + id: "chat_helper", + name: "Chat helper", + description: "Viewers ask, the bot answers. No more editing commands by hand.", + presets: &["delay_command", "delay_notice", "socials"], + }, + Pack { + id: "watchtower", + name: "Watchtower", + 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"], + }, +]; + +pub fn find(id: &str) -> Option<&'static Preset> { + PRESETS.iter().find(|p| p.id == id) +} + +fn on(kind: EventKind, steps: Vec) -> Handler { + on_filtered(kind, &[], steps) +} + +fn on_filtered(kind: EventKind, filters: &[(&str, &str)], steps: Vec) -> Handler { + Handler { + enabled: true, + trigger: Trigger::Event { + kind, + filters: filters + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(), + }, + steps, + } +} + +fn command(command: &str, roles: Roles, user_cooldown_ms: u64, steps: Vec) -> Handler { + Handler { + enabled: true, + trigger: Trigger::ChatCommand { + command: command.to_string(), + aliases: Vec::new(), + roles, + user_cooldown_ms, + }, + steps, + } +} + +fn discord(channel: &str, text: &str) -> Step { + Step::new( + StepKind::Discord, + &[("connection", channel), ("text", text)], + ) +} + +// 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), + ("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")]) +} + +fn check(left: &str, op: &str, right: &str, then: Vec, otherwise: Vec) -> Step { + let mut step = Step::new( + StepKind::If, + &[("left", left), ("op", op), ("right", right)], + ); + step.then = then; + step.otherwise = otherwise; + step +} + +/// Build a catalog entry. `discord_channel` is the id of the Discord +/// connection to post to (empty when none exists yet). +pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option { + let p = find(preset_id)?; + let d = discord_channel; + let mut cooldown_ms = 0; + let handlers = match preset_id { + "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) + } + "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![with( + card(d, RED, "🔴 {destination} dropped", "{reason}"), + &[("fields", "Platform | {platform}")], + )], + )], + "going_live" => { + cooldown_ms = 10 * 60_000; + vec![on( + EventKind::StreamStarted, + vec![with( + card(d, PURPLE, "🔴 Live now", "We're live! Come hang out."), + &[("url", "https://twitch.tv/{channel}")], + )], + )] + } + "phone_crash" => vec![on( + EventKind::HoldOpened, + vec![Step::new( + StepKind::Phone, + &[ + ("title", "OBS crashed"), + ( + "text", + "Crash protection is on for {hold}. Get back to the PC.", + ), + ("priority", "high"), + ], + )], + )], + "tell_chat" => vec![ + on( + EventKind::HoldOpened, + vec![ + Step::new(StepKind::Wait, &[("ms", "20000")]), + check( + "{hold_active}", + "is", + "yes", + vec![ + chat("OBS dropped, we're coming back. Don't go anywhere!"), + Step::new( + StepKind::Counter, + &[("name", "chat_told"), ("op", "set"), ("by", "1")], + ), + ], + vec![], + ), + ], + ), + on( + EventKind::ObsBack, + vec![check( + "{counter.chat_told}", + "greater", + "0", + vec![ + chat("We're back!"), + Step::new(StepKind::Counter, &[("name", "chat_told"), ("op", "reset")]), + ], + vec![], + )], + ), + ], + "delay_command" => { + cooldown_ms = 5_000; + 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. + 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", + Roles::MODS, + 0, + vec![ + Step::new(StepKind::DelayAction, &[("action", "cut")]), + reply("Cut by {user}."), + ], + ), + command( + "!setdelay", + Roles::MODS, + 0, + vec![check( + "{arg1}", + "whole_number", + "", + vec![check( + "{arg1}", + "less", + "601", + vec![ + Step::new( + StepKind::DelayAction, + &[("action", "arm"), ("seconds", "{arg1}")], + ), + reply("Delay set to {arg1}s by {user}."), + ], + vec![reply("The delay goes up to 600s. Usage: !setdelay 30")], + )], + vec![reply( + "Usage: !setdelay 30, in whole seconds (0 turns it off)", + )], + )], + ), + ], + "socials" => { + cooldown_ms = 10_000; + vec![command( + "!discord", + Roles::EVERYONE, + 30_000, + vec![reply("Join the Discord: [YOUR INVITE LINK]")], + )] + } + "api_command" => { + cooldown_ms = 3_000; + vec![api_handler(&ApiStart { + id: "", + command: "!rank", + url: "", + reply: super::botcmd::ANSWER, + unless: None, + })] + } + "clip_button" => { + // Twitch refuses clips made seconds apart anyway. + cooldown_ms = 10_000; + vec![Handler { + enabled: true, + trigger: Trigger::Shortcut { + hotkey: String::new(), + midi: String::new(), + token: String::new(), + only_live: false, + }, + steps: vec![ + // Twitch clips what it has received; with a delay, the + // moment the key was pressed for arrives that much later. + wait_aired("3000"), + Step::new(StepKind::Clip, &[]), + check( + "{clip.ok}", + "is", + "yes", + vec![ + chat("🎬 Clipped: {clip.url}"), + Step::new( + StepKind::Overlay, + &[ + ("title", "Clip"), + ("text", "That moment is a clip now"), + ("seconds", "5"), + ], + ), + ], + vec![], + ), + ], + }] + } + "vod_markers" => vec![ + 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 = webhook_body(*kind); + on( + *kind, + vec![Step::new( + StepKind::Http, + &[("method", "POST"), ("url", ""), ("body", &body)], + )], + ) + }) + .collect(), + "crash_counter" => vec![on( + EventKind::HoldOpened, + vec![ + Step::new(StepKind::Counter, &[("name", "crashes")]), + Step::new( + StepKind::File, + &[("path", ""), ("text", "Crashes today: {counter.crashes}")], + ), + ], + )], + _ => return None, + }; + let mut integration = Integration { + id, + name: p.name.to_string(), + enabled: true, + preset: p.id.to_string(), + cooldown_ms, + quiet: None, + handlers, + }; + // Something still to fill in (a webhook address, a Discord channel): + // add it switched off; the editor opens right away to finish it. + if !integration.validate().is_empty() { + integration.enabled = false; + } + Some(integration) +} + +/// What the catalog's webhook sends for one event. +pub fn webhook_body(kind: EventKind) -> 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. +pub struct ApiStart { + pub id: &'static str, + pub command: &'static str, + pub url: &'static str, + pub reply: &'static str, + /// When the answer contains this, reply with that instead: the + /// website's way of saying "offline" or "no such user". + pub unless: Option<(&'static str, &'static str)>, +} + +pub const API_STARTS: &[ApiStart] = &[ + ApiStart { + id: "uptime", + command: "!uptime", + url: "https://decapi.me/twitch/uptime/{channel}", + reply: "{channel} has been live for {api.body}", + unless: Some(("offline", "{channel} isn't live right now.")), + }, + ApiStart { + id: "accountage", + command: "!accountage", + url: "https://decapi.me/twitch/accountage/{target}", + reply: "{target} made their account {api.body} ago", + unless: Some(("not found", "Couldn't find {target} on Twitch.")), + }, + ApiStart { + id: "followers", + command: "!followers", + url: "https://decapi.me/twitch/followcount/{channel}", + reply: "{channel} has {api.body} followers", + unless: None, + }, + ApiStart { + id: "game", + command: "!game", + url: "https://decapi.me/twitch/game/{channel}", + reply: "Playing {api.body} right now", + unless: None, + }, +]; + +/// The handler of a "command from a website": fetch, then reply with the +/// answer, a nicer line for a known "no", or a sorry when the site is down. +pub fn api_handler(start: &ApiStart) -> Handler { + let answer = match start.unless { + // The answer's reply comes first in walk order, so it is the one + // the simple editor edits, not the fallback. + Some((says, instead)) => vec![check( + "{api.body}", + "not_contains", + says, + vec![reply(start.reply)], + vec![reply(instead)], + )], + None => vec![reply(start.reply)], + }; + command( + start.command, + Roles::EVERYONE, + 10_000, + vec![ + Step::new( + StepKind::Http, + &[("method", "GET"), ("url", start.url), ("save_as", "api")], + ), + check( + "{api.ok}", + "is", + "yes", + answer, + vec![reply("Couldn't get that right now, try again in a bit.")], + ), + ], + ) +} + +/// The integration that replaces the old "Discord webhook" setting: the +/// same events, the same words. +pub fn legacy_alerts(id: String, channel: &str) -> Integration { + let c = channel; + Integration { + id, + name: "Stream alerts".to_string(), + enabled: true, + preset: "stream_alerts".to_string(), + cooldown_ms: 0, + quiet: None, + // Ordered by the life of a stream; the card previews the first. + handlers: vec![ + on( + EventKind::ObsConnected, + vec![discord(c, "✅ OBS publisher connected - going live.")], + ), + on_filtered( + EventKind::ObsDisconnected, + &[("protected", "no")], + vec![discord(c, "⚠️ OBS publisher disconnected.")], + ), + on( + EventKind::HoldOpened, + vec![discord( + c, + "🛡️ OBS dropped ({reason}). The reconnect screen is on air for up to {hold}.", + )], + ), + on( + EventKind::ObsBack, + vec![discord(c, "✅ OBS is back after {down_for}. Live again.")], + ), + on( + EventKind::HoldExpired, + vec![discord( + c, + "🔴 OBS didn't come back within {hold}. The stream has ended.", + )], + ), + on( + EventKind::HoldEnded, + vec![discord( + c, + "⏹️ Crash protection ended by the streamer. The stream has ended.", + )], + ), + on( + EventKind::DestinationLive, + vec![discord(c, "🟢 **{destination}** is now live.")], + ), + on( + EventKind::DestinationDropped, + vec![discord(c, "🔴 **{destination}** disconnected: {reason}")], + ), + on( + EventKind::EbDetected, + vec![discord( + c, + "🎚️ Enhanced Broadcasting detected - multi-track forwarding active.", + )], + ), + ], + } +} + +/// 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", + Value::Arr(p.needs.iter().map(|n| json::str(*n)).collect()), + ), + ("recommended", Value::Bool(p.recommended)), + ( + "preview", + json::obj([ + ("kind", json::str(p.preview.0)), + ("text", json::str(p.preview.1)), + ("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(); + let packs = PACKS + .iter() + .map(|p| { + json::obj([ + ("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()), + ), + ]) + }) + .collect(); + let starts = API_STARTS + .iter() + .map(|s| { + json::obj([ + ("id", json::str(s.id)), + ("command", json::str(s.command)), + ("handler", api_handler(s).to_json()), + ]) + }) + .collect(); + json::obj([ + ("presets", Value::Arr(presets)), + ("packs", Value::Arr(packs)), + ("api_starts", Value::Arr(starts)), + ("light_starts", Value::Arr(lights)), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_preset_builds_and_validates_once_connected() { + for p in PRESETS { + let mut i = build(p.id, format!("t_{}", p.id), "chan1") + .unwrap_or_else(|| panic!("{} did not build", p.id)); + // Fill what a user fills in before switching these on. + for h in &mut i.handlers { + fill_blank_paths(&mut h.steps); + 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()); + assert_eq!(i.preset, p.id); + } + } + + fn fill_blank_paths(steps: &mut [Step]) { + for s in steps { + for key in ["url", "path"] { + if s.params.get(key).is_some_and(|v| v.is_empty()) { + s.params.insert(key.into(), "https://example.com/x".into()); + } + } + fill_blank_paths(&mut s.then); + fill_blank_paths(&mut s.otherwise); + } + } + + #[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(); + assert!(!webhook.enabled); + let crash = build("crash_alert", "c".into(), "").unwrap(); + assert!(!crash.enabled, "no Discord channel yet"); + assert!(build("delay_command", "d".into(), "").unwrap().enabled); + } + + #[test] + fn packs_only_name_real_presets() { + for pack in PACKS { + for id in pack.presets { + assert!(find(id).is_some(), "{} names unknown {id}", pack.id); + } + } + } + + #[test] + fn webhook_bodies_are_json_once_filled() { + let i = build("webhook", "w".into(), "").unwrap(); + let body = i.handlers[0].steps[0].param("body").to_string(); + let filled = super::super::template::render(&body, &|name: &str| { + Some(match name { + "phase" => "active".to_string(), + _ => "3".to_string(), + }) + }); + assert!(crate::config::is_valid_json(&filled), "{filled}"); + } + + #[test] + fn api_starts_validate_and_their_reply_is_the_one_edited() { + for start in API_STARTS { + let mut i = build("api_command", "a".into(), "").unwrap(); + i.handlers = vec![api_handler(start)]; + assert!(i.validate().is_empty(), "{}: {:?}", start.id, i.validate()); + let unknown = super::super::api::unknown_vars(&i); + assert!(unknown.is_empty(), "{}: {unknown:?}", start.id); + let mut first = None; + super::super::model::visit_steps(&i.handlers[0].steps, &mut |s| { + if s.kind == StepKind::Chat && first.is_none() { + first = Some(s.param("text").to_string()); + } + }); + assert_eq!(first.as_deref(), Some(start.reply)); + } + } + + #[test] + fn crash_alert_is_one_message_that_updates() { + let i = build("crash_alert", "c".into(), "chan").unwrap(); + let edits: Vec<&str> = i + .handlers + .iter() + .map(|h| h.steps[0].param("edit")) + .collect(); + 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()); + } + + #[test] + fn catalog_is_valid_json() { + assert!(crate::config::is_valid_json(&catalog_json().to_json())); + } +} diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs new file mode 100644 index 0000000..ed0d46b --- /dev/null +++ b/src/integrations/recipe.rs @@ -0,0 +1,424 @@ +//! Recipes: integrations shared as a line of text (`ic-recipe:1:...`). +//! +//! 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, 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. +//! - Steps that run programs or write files are flagged, and the import +//! needs an explicit yes for them. +//! +//! Import cleans exactly what export does, so a recipe written by hand +//! can't bring in a web address, a program or a hotkey the user never set. + +use super::model::{visit_steps, Integration, StepKind, Trigger}; +use crate::json::{self, Value}; + +const PREFIX: &str = "ic-recipe:1:"; +/// Placeholder a shared Discord step points at until the importer maps it. +pub const DISCORD_PLACEHOLDER: &str = "@discord"; +const MAX_RECIPE_LEN: usize = 256 * 1024; +const MAX_RECIPE_INTEGRATIONS: usize = 20; + +pub struct Recipe { + pub name: String, + pub integrations: Vec, +} + +impl Recipe { + pub fn uses_discord(&self) -> bool { + self.integrations.iter().any(|i| { + let mut found = false; + for h in &i.handlers { + visit_steps(&h.steps, &mut |s| found |= s.kind == StepKind::Discord); + } + found + }) + } + + pub fn has_local_effects(&self) -> bool { + self.integrations.iter().any(Integration::has_local_effects) + } +} + +pub fn export(name: &str, integrations: &[Integration]) -> String { + let cleaned: Vec = integrations + .iter() + .map(|i| { + let mut i = i.clone(); + make_private(&mut i); + i.to_json() + }) + .collect(); + let doc = json::obj([ + ("name", json::str(name)), + ("integrations", Value::Arr(cleaned)), + ]); + format!("{PREFIX}{}", base64url_encode(doc.to_json().as_bytes())) +} + +/// Blank whatever a recipe must never carry: see `strip_private`, plus web +/// 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, + 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), 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 { + StepKind::Discord => { + s.params + .insert("connection".into(), DISCORD_PLACEHOLDER.into()); + } + StepKind::Http => { + s.params.insert("url".into(), String::new()); + s.params.remove("headers"); + 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()); + s.params.remove("args"); + } + StepKind::File => { + s.params.insert("path".into(), String::new()); + } + _ => {} + } + strip_private(&mut s.then); + strip_private(&mut s.otherwise); + } +} + +/// Parse a pasted recipe. `new_id` makes fresh ids so an import never +/// overwrites what the user already has. +pub fn parse(text: &str, mut new_id: impl FnMut() -> String) -> Result { + let text = text.trim(); + if text.len() > MAX_RECIPE_LEN { + return Err("that recipe is too big".to_string()); + } + let encoded = text + .strip_prefix(PREFIX) + .ok_or("that is not an InstantClone recipe (it should start with ic-recipe:1:)")?; + let bytes = base64url_decode(encoded).ok_or("the recipe is damaged; copy it again")?; + let doc = std::str::from_utf8(&bytes) + .ok() + .and_then(|t| json::parse(t).ok()) + .ok_or("the recipe is damaged; copy it again")?; + let list = doc + .get("integrations") + .and_then(Value::as_array) + .unwrap_or(&[]); + if list.is_empty() || list.len() > MAX_RECIPE_INTEGRATIONS { + return Err("the recipe has no integrations, or too many".to_string()); + } + let mut integrations = Vec::new(); + for v in list { + let mut i = Integration::from_json(v)?; + if let Some(problem) = i.problems().invalid.first() { + return Err(format!( + "the recipe has something unsafe or broken: {problem}" + )); + } + make_private(&mut i); + i.id = new_id(); + i.enabled = false; + // Fresh secrets: a web call keeps working, and a button can be + // pressed from a Stream Deck before any key or pad is picked. + for h in &mut i.handlers { + match &mut h.trigger { + Trigger::Webhook { token } | Trigger::Shortcut { token, .. } => { + *token = new_id() + &new_id(); + } + _ => {} + } + } + integrations.push(i); + } + Ok(Recipe { + name: doc + .str_or("name", "Shared recipe") + .chars() + .take(80) + .collect(), + integrations, + }) +} + +/// Point every placeholder Discord step at `channel`. +pub fn map_discord(recipe: &mut Recipe, channel: &str) { + for i in &mut recipe.integrations { + for h in &mut i.handlers { + map_steps(&mut h.steps, channel); + } + } +} + +fn map_steps(steps: &mut [super::model::Step], channel: &str) { + for s in steps { + if s.kind == StepKind::Discord && s.param("connection") == DISCORD_PLACEHOLDER { + s.params.insert("connection".into(), channel.to_string()); + } + map_steps(&mut s.then, channel); + map_steps(&mut s.otherwise, channel); + } +} + +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +fn base64url_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len())); + for i in 0..=chunk.len() { + out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char); + } + } + out +} + +fn base64url_decode(text: &str) -> Option> { + let mut out = Vec::with_capacity(text.len() * 3 / 4); + let mut buf = 0u32; + let mut bits = 0; + for c in text + .bytes() + .filter(|c| !c.is_ascii_whitespace() && *c != b'=') + { + let v = ALPHABET.iter().position(|a| *a == c)? as u32; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::super::presets; + use super::*; + + #[test] + fn base64_round_trips_every_length() { + for len in 0..40 { + let data: Vec = (0..len).map(|i| (i * 37 + 11) as u8).collect(); + assert_eq!(base64url_decode(&base64url_encode(&data)).unwrap(), data); + } + assert!(base64url_decode("abc*").is_none()); + } + + #[test] + fn export_hides_connections_and_import_starts_off() { + let alert = presets::build("crash_alert", "a1".into(), "my-private-channel").unwrap(); + let text = export("Crash kit", &[alert]); + assert!(text.starts_with(PREFIX)); + let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); + assert!(!decoded.contains("my-private-channel")); + + let mut n = 0; + let mut recipe = parse(&text, || { + n += 1; + format!("new{n}") + }) + .unwrap(); + assert_eq!(recipe.name, "Crash kit"); + assert!(recipe.uses_discord()); + let imported = &recipe.integrations[0]; + assert_eq!(imported.id, "new1"); + assert!(!imported.enabled); + + map_discord(&mut recipe, "mine"); + assert_eq!( + recipe.integrations[0].handlers[0].steps[0].param("connection"), + "mine" + ); + } + + #[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(); + i.handlers[0].trigger = Trigger::Webhook { + token: "secret-token-1234567890".into(), + }; + let text = export("x", &[i]); + let decoded = String::from_utf8(base64url_decode(&text[PREFIX.len()..]).unwrap()).unwrap(); + assert!(!decoded.contains("secret-token")); + let recipe = parse(&text, || "fresh1234567890ab".into()).unwrap(); + match &recipe.integrations[0].handlers[0].trigger { + Trigger::Webhook { token } => assert!(token.starts_with("fresh")), + other => panic!("{other:?}"), + } + } + + #[test] + fn shortcuts_are_left_for_the_importer() { + let mut i = presets::build("delay_command", "x".into(), "").unwrap(); + 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(); + assert!(!decoded.contains("Launchpad") && !decoded.contains("Ctrl+Alt+K")); + assert!( + parse(&text, || "fresh1234567890ab".into()).is_ok(), + "still imports, unfinished" + ); + } + + #[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; + let mut i = presets::build("delay_command", "x".into(), "").unwrap(); + 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, + &[ + ("url", "https://evil.example/?u={user}"), + ("body", "{message}"), + ], + )]; + // Encoded by hand, skipping the cleaning `export` does. + let doc = json::obj([("integrations", Value::Arr(vec![i.to_json()]))]); + let text = format!("{PREFIX}{}", base64url_encode(doc.to_json().as_bytes())); + let recipe = parse(&text, || "fresh1234567890ab".into()).unwrap(); + let h = &recipe.integrations[0].handlers[0]; + assert_eq!(h.steps[0].param("url"), ""); + assert_eq!(h.steps[0].param("body"), ""); + assert!(matches!(&h.trigger, Trigger::Shortcut { hotkey, .. } if hotkey.is_empty())); + } + + #[test] + fn junk_is_refused_politely() { + assert!(parse("hello", || "x".into()).is_err()); + assert!(parse("ic-recipe:1:!!!", || "x".into()).is_err()); + assert!( + parse(&format!("{PREFIX}{}", base64url_encode(b"{}")), || "x" + .into()) + .is_err() + ); + } +} diff --git a/src/integrations/runner.rs b/src/integrations/runner.rs new file mode 100644 index 0000000..19637e2 --- /dev/null +++ b/src/integrations/runner.rs @@ -0,0 +1,2253 @@ +//! Runs one handler's steps: fills in templates, follows checks, waits, +//! and records what each step did for the activity log. +//! +//! A failing step does not end the run. One webhook being down should not +//! keep the Discord message or the chat reply from going out, so the step +//! is logged as failed, its output variables say so (`{response.ok}` is +//! `no`), and the run carries on. Only a `stop` step, or its check failing +//! into nothing, ends a run early. +//! +//! 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, 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::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. +const MAX_CHAT_LEN: usize = 500; +/// Counters are all saved in one file, rewritten on every change. +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 { + pub discord: Vec, + pub phone: PhoneConnection, + pub counters: Arc>>, + /// Called after a counter changes, to save it. + pub counters_changed: Box, + /// The last Discord message each integration posted per channel (key + /// `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. +pub struct RunContext { + /// The integration this run belongs to. + pub integration_id: String, + pub vars: BTreeMap, + /// 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)] +pub enum StepStatus { + Ok, + Skipped, + Failed, +} + +impl StepStatus { + pub fn id(self) -> &'static str { + match self { + StepStatus::Ok => "ok", + StepStatus::Skipped => "skipped", + StepStatus::Failed => "failed", + } + } +} + +#[derive(Clone, Debug)] +pub struct StepLog { + pub label: String, + pub status: StepStatus, + /// Milliseconds since the run started. + pub at_ms: u64, + pub detail: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RunStatus { + Ok, + /// A `stop` step ended it on purpose. + Stopped, + /// At least one step failed; the rest still ran. + Failed, +} + +enum Flow { + Continue, + Stop, +} + +struct Runner { + host: Arc, + env: Arc, + ctx: RunContext, + started: Instant, + log: Vec, + failed: bool, +} + +pub async fn run( + steps: &[Step], + ctx: RunContext, + host: Arc, + env: Arc, +) -> (RunStatus, Vec) { + let mut runner = Runner { + host, + env, + ctx, + started: Instant::now(), + log: Vec::new(), + failed: false, + }; + let flow = runner.steps(steps).await; + let status = match (flow, runner.failed) { + (_, true) => RunStatus::Failed, + (Flow::Stop, false) => RunStatus::Stopped, + (Flow::Continue, false) => RunStatus::Ok, + }; + (status, runner.log) +} + +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 { + // 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; + } + } + Flow::Continue + }) + } + + 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, + }); + } + + /// Fill in a step parameter's template. + fn fill(&self, step: &Step, name: &str) -> String { + self.fill_text(step.param(name)) + } + + fn fill_text(&self, text: &str) -> String { + let live = self.host.live(); + 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(); + 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, + timeline: &self.env.timeline, + live, + } + } + + fn set(&mut self, name: impl Into, value: impl Into) { + self.ctx.vars.insert(name.into(), value.into()); + } + + /// Run a blocking effect off the async thread. + async fn blocking( + &self, + f: impl FnOnce(&dyn Host) -> Result + Send + 'static, + ) -> Result { + let host = self.host.clone(); + tokio::task::spawn_blocking(move || f(host.as_ref())) + .await + .unwrap_or_else(|_| Err("the step crashed".to_string())) + } + + async fn step(&mut self, step: &Step) -> Flow { + match step.kind { + StepKind::If => return self.check(step).await, + StepKind::Stop => { + self.record("Stop", StepStatus::Ok, ""); + return Flow::Stop; + } + StepKind::Wait => self.wait(step).await, + StepKind::WaitDelay => self.wait_delay(step).await, + StepKind::Discord => self.discord(step).await, + StepKind::Chat => self.chat(step).await, + StepKind::Phone => self.phone(step).await, + StepKind::Http => self.http(step).await, + StepKind::DelayAction => self.delay_action(step).await, + StepKind::Marker => self.marker(step).await, + StepKind::Clip => self.clip().await, + StepKind::Program => self.program(step).await, + StepKind::File => self.file(step).await, + StepKind::SetVar => { + let name = self.fill(step, "name"); + let value = self.fill(step, "value"); + self.record(format!("Remember {name}"), StepStatus::Ok, value.clone()); + self.set(name, value); + } + 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"); + let op = step.param("op"); + let passed = compare(&left, op, &right); + self.record( + "Check", + StepStatus::Ok, + format!( + "\"{left}\" {} \"{right}\": {}", + op.replace('_', " "), + if passed { "yes" } else { "no" } + ), + ); + let branch = if passed { &step.then } else { &step.otherwise }; + self.steps(branch).await + } + + 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(wait_cap(step, "ms")); + if self.ctx.test { + self.record( + "Wait", + StepStatus::Skipped, + format!( + "would wait {} (skipped in tests)", + fmt_duration(dur.as_millis() as u64) + ), + ); + return; + } + self.sleep(dur).await; + self.record("Wait", StepStatus::Ok, fmt_duration(dur.as_millis() as u64)); + } + + /// Wait until what was happening when the run started has reached + /// viewers, following the delay if it changes meanwhile. + async fn wait_delay(&mut self, step: &Step) { + let extra = self + .fill(step, "extra_ms") + .trim() + .parse::() + .unwrap_or(0); + let follow = step.param("follow") != "no"; + let start_delay = self.host.live().delay_ms as u64; + if self.ctx.test { + self.record( + "Wait for the delay", + StepStatus::Skipped, + format!( + "would wait {} (skipped in tests)", + fmt_duration(start_delay.saturating_add(extra)) + ), + ); + return; + } + let since = self.started; + loop { + let delay = if follow { + self.host.live().delay_ms as u64 + } else { + start_delay + }; + // `extra` can come from chat, so it can be anything. + 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, + format!( + "aired after {}", + fmt_duration(since.elapsed().as_millis() as u64) + ), + ); + } + + fn marked(&self, text: String) -> String { + if self.ctx.test { + format!("[TEST] {text}") + } else { + text + } + } + + async fn discord(&mut self, step: &Step) { + let id = step.param("connection").to_string(); + let Some(channel) = self.env.discord.iter().find(|c| c.id == id).cloned() else { + self.record( + "Discord", + StepStatus::Failed, + "that Discord channel was removed; pick another", + ); + return; + }; + 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, + if about.trim().is_empty() { "" } else { ":" }, + about.trim().chars().take(100).collect::() + ); + // `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. + message.ping = if self.ctx.test { + String::new() + } else { + step.param("ping").to_string() + }; + let shown = discord_shown(&message); + let sent = self + .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) => { + let label = if posted.edited { + format!("{label} (updated)") + } else { + label + }; + self.record(label, StepStatus::Ok, shown); + } + Err(e) => self.record(label, StepStatus::Failed, e), + } + } + + /// 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()) + .flatten(); + let shown = text.clone(); + match self + .blocking(move |h| h.chat(&text, as_bot, reply_to.as_deref())) + .await + { + Ok(()) => self.record("Twitch chat", StepStatus::Ok, shown), + Err(e) => self.record("Twitch chat", StepStatus::Failed, e), + } + } + + async fn phone(&mut self, step: &Step) { + let phone = self.env.phone.clone(); + if !phone.is_set() { + self.record("Phone", StepStatus::Failed, "connect a phone first"); + return; + } + 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 + .blocking(move |h| { + h.phone( + phone.server_or_default(), + phone.topic.trim(), + &title, + &text, + &priority, + ) + }) + .await; + match result { + Ok(()) => self.record("Phone", StepStatus::Ok, shown), + Err(e) => self.record("Phone", StepStatus::Failed, e), + } + } + + async fn http(&mut self, step: &Step) { + let save_as = match step.param("save_as").trim() { + "" => "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 form field, one header line. + let body = step.param("body").trim_start(); + // `{"a": …}` or `[…]` is JSON; `{user} said hi` is a variable. + let json_body = body.starts_with('[') + || body + .strip_prefix('{') + .is_some_and(|rest| rest.trim_start().starts_with(['"', '}'])); + let form_body = step + .param("headers") + .to_ascii_lowercase() + .contains("application/x-www-form-urlencoded"); + let request = HttpRequest { + method: match step.param("method").trim() { + "" => "POST".to_string(), + m => m.to_ascii_uppercase(), + }, + 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 if form_body { + self.fill_escaped(step, "body", &template::url_component) + } else { + self.fill(step, "body") + }, + }; + 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); + self.set(format!("{save_as}.status"), resp.status.to_string()); + self.set(format!("{save_as}.ok"), if ok { "yes" } else { "no" }); + self.set(format!("{save_as}.body"), resp.body); + let status = if ok { + StepStatus::Ok + } else { + StepStatus::Failed + }; + self.record(label, status, format!("answered {}", resp.status)); + } + Err(e) => { + self.set(format!("{save_as}.ok"), "no"); + self.record(label, StepStatus::Failed, e); + } + } + } + + async fn delay_action(&mut self, step: &Step) { + 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; + } + // 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) { + 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"); + return; + } + match self.blocking(move |h| h.delay_action(&action, ms)).await { + Ok(()) => self.record(label, StepStatus::Ok, ""), + Err(e) => self.record(label, StepStatus::Failed, e), + } + } + + async fn marker(&mut self, step: &Step) { + let description = self.fill(step, "description"); + if self.ctx.test { + self.record( + "VOD marker", + StepStatus::Skipped, + format!("would mark \"{description}\""), + ); + return; + } + let shown = description.clone(); + match self.blocking(move |h| h.marker(&description)).await { + Ok(()) => self.record("VOD marker", StepStatus::Ok, shown), + Err(e) => self.record("VOD marker", StepStatus::Failed, e), + } + } + + async fn clip(&mut self) { + if self.ctx.test { + self.set("clip.ok", "yes"); + self.set("clip.id", "TestClip"); + self.set("clip.url", "https://clips.twitch.tv/TestClip"); + self.record( + "Clip", + StepStatus::Skipped, + "would clip; the test uses a sample link", + ); + return; + } + match self.blocking(|h| h.clip()).await { + Ok(clip) => { + self.set("clip.ok", "yes"); + self.set("clip.id", clip.id); + self.set("clip.url", clip.url.clone()); + self.record("Clip", StepStatus::Ok, clip.url); + } + Err(e) => { + self.set("clip.ok", "no"); + self.set("clip.error", redact_urls(&e)); + self.record("Clip", StepStatus::Failed, e); + } + } + } + + async fn program(&mut self, step: &Step) { + // 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 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_escaped(arg, &template::program_arg)) + .collect(); + let label = format!("Run {}", file_name(&path)); + if self.ctx.test { + self.record(label, StepStatus::Skipped, "tests never run programs"); + return; + } + match self.blocking(move |h| h.program(&path, &args)).await { + Ok(()) => self.record(label, StepStatus::Ok, ""), + Err(e) => self.record(label, StepStatus::Failed, e), + } + } + + async fn file(&mut self, step: &Step) { + // Never templated: see `model::validate_steps`. + let path = step.param("path").trim().to_string(); + let text = self.fill(step, "text"); + let append = step.param("mode") == "append"; + let label = format!("Write {}", file_name(&path)); + if self.ctx.test { + self.record( + label, + StepStatus::Skipped, + format!("would write \"{text}\""), + ); + return; + } + let shown = text.clone(); + match self.blocking(move |h| h.file(&path, &text, append)).await { + Ok(()) => self.record(label, StepStatus::Ok, shown), + Err(e) => self.record(label, StepStatus::Failed, e), + } + } + + fn overlay(&mut self, step: &Step) { + let title = self.fill(step, "title"); + let text = self.fill(step, "text"); + if text.trim().is_empty() { + self.record("On stream", StepStatus::Skipped, "the text came out empty"); + return; + } + let seconds = self + .fill(step, "seconds") + .trim() + .parse::() + .unwrap_or(OVERLAY_SECONDS); + if self.ctx.test { + self.record( + "On stream", + StepStatus::Skipped, + format!("would show \"{text}\" (tests stay off stream)"), + ); + return; + } + self.host.overlay(&title, &text, seconds); + 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")); + let out = template::edit_text(step.param("op"), &input, &a, &b); + let name = match step.param("save_as").trim() { + "" => "text".to_string(), + name => name.to_string(), + }; + self.record(format!("Edit text → {name}"), StepStatus::Ok, out.clone()); + self.set(name, out); + } + + fn counter(&mut self, step: &Step) { + // Names can hold `{user}`, so viewers decide how many there are. + let name: String = self + .fill(step, "name") + .trim() + .chars() + .take(MAX_COUNTER_NAME) + .collect(); + let by = self.fill(step, "by").trim().parse::().unwrap_or(1); + let op = step.param("op"); + let value = { + let mut counters = self.env.counters.lock(); + if counters.len() >= MAX_COUNTERS && !counters.contains_key(&name) { + drop(counters); + self.record( + format!("Counter {name}"), + StepStatus::Failed, + format!("there are already {MAX_COUNTERS} counters; reset some first"), + ); + return; + } + let current = counters.get(&name).copied().unwrap_or(0); + let next = match op { + "subtract" => current.saturating_sub(by), + "set" => by, + "reset" => 0, + _ => current.saturating_add(by), + }; + if !self.ctx.test { + counters.insert(name.clone(), next); + } + next + }; + if !self.ctx.test { + (self.env.counters_changed)(); + } + self.set(format!("counter.{name}"), value.to_string()); + self.record(format!("Counter {name}"), StepStatus::Ok, value.to_string()); + } +} + +/// Where template values come from during a run, in priority order: the +/// run's own variables, saved counters, then the live stream state. +struct Lookup<'a> { + vars: &'a BTreeMap, + counters: &'a crate::sync::Mutex>, + timeline: &'a crate::sync::Mutex, + live: &'a LiveState, +} + +impl Vars for Lookup<'_> { + fn lookup(&self, name: &str) -> Option { + 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 + .lock() + .get(counter) + .copied() + .unwrap_or(0) + .to_string(), + ); + } + // `{response.json.user.name}` reads a field of a saved JSON body; + // `{body.x}` does the same for a web call's body. + if let Some((base, path)) = name.split_once(".json.") { + return json_field(self.vars.get(&format!("{base}.body"))?, path); + } + 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()) +} + +/// The variables a chat command or chat message brings, with the samples +/// previews use. `arg4` to `arg9` exist too; they are left out here so the +/// editor's list stays short. +pub const CHAT_VARS: &[(&str, &str)] = &[ + ("user", "Ana"), + ("user_login", "ana"), + ("user_role", "mod"), + ("target", "Ana"), + ("message", "!delay"), + ("args", "30"), + ("arg1", "30"), +]; + +/// The variables every run can use, whatever triggered it. `uses` is the +/// engine's to fill (it counts runs); the rest come from the live state. +pub const GLOBAL_VARS: &[(&str, &str)] = &[ + ("delay", "30s"), + ("delay_ms", "30000"), + ("delay_state", "on"), + ("phase", "active"), + ("hold_active", "no"), + ("hold_left", "1m 42s"), + ("destinations_live", "3"), + ("destinations_total", "3"), + ("obs_live", "yes"), + ("bitrate", "6200"), + ("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"), +]; + +fn live_var(name: &str, live: &LiveState) -> Option { + let yes_no = |b: bool| if b { "yes" } else { "no" }.to_string(); + Some(match name { + "delay" => fmt_delay(live.delay_ms), + "delay_ms" => live.delay_ms.to_string(), + "delay_state" => if live.delay_ms > 0 { "on" } else { "off" }.to_string(), + "phase" => live.phase.clone(), + "hold_active" => yes_no(live.hold_active), + "hold_left" => { + if live.hold_active { + fmt_duration(live.hold_left_ms) + } else { + String::new() + } + } + "destinations_live" => live.destinations_live.to_string(), + "destinations_total" => live.destinations_total.to_string(), + "obs_live" => yes_no(live.obs_live), + "bitrate" => live.bitrate_kbps.to_string(), + "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 { + let l = left.trim().to_lowercase(); + let r = right.trim().to_lowercase(); + let numbers = || Some((l.parse::().ok()?, r.parse::().ok()?)); + match op { + "is" => l == r, + "is_not" => l != r, + "contains" => l.contains(&r), + "not_contains" => !l.contains(&r), + "starts_with" => l.starts_with(&r), + "empty" => l.is_empty() || l == "0" || l == "no", + "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 { + let one_line: String = text + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let trimmed = one_line.trim(); + let command_like = trimmed.starts_with('/') || trimmed.starts_with('.'); + // The guard character counts toward the limit too. + let room = MAX_CHAT_LEN - usize::from(command_like); + let mut out: String = trimmed.chars().take(room).collect(); + if command_like { + out.insert(0, '\u{200B}'); + } + out +} + +/// `Name: value` lines to header pairs. +fn parse_headers(text: &str) -> Vec<(String, String)> { + text.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() +} + +/// 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); + // `user:password@` before the host is a secret too. + let host_start = whole[scheme_end..host_end] + .rfind('@') + .map_or(scheme_end, |i| scheme_end + i + 1); + out.push_str(&whole[..scheme_end]); + out.push_str(&whole[host_start..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(); + let mut current = String::new(); + let mut quoted = false; + let mut has_token = false; + for c in text.chars() { + match c { + '"' => { + quoted = !quoted; + has_token = true; + } + c if c.is_whitespace() && !quoted => { + if has_token { + args.push(std::mem::take(&mut current)); + has_token = false; + } + } + c => { + current.push(c); + has_token = true; + } + } + } + if has_token { + args.push(current); + } + args +} + +fn file_name(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} + +fn short_url(url: &str) -> String { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + let host = rest.split(['/', '?', '#']).next().unwrap_or(rest); + // Without any `user:password@` in front of it. + 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 your default".to_string(), + "arm" => format!("Set the delay to {}", fmt_delay(ms)), + "activate" => "Turn the delay on".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}"), + } +} + +#[cfg(test)] +mod tests { + use super::super::host::{ClipInfo, DiscordPosted, HttpResponse}; + use super::*; + use crate::sync::Mutex; + + #[derive(Default)] + struct FakeHost { + 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, + channel: "texaz".into(), + ..LiveState::default() + } + } + fn discord( + &self, + url: &str, + message: &DiscordMessage, + edit: Option<&str>, + ) -> Result { + let mut calls = self.calls.lock(); + 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()); + } + Ok(DiscordPosted { + message_id: edit.map_or_else(|| format!("m{}", calls.len()), str::to_string), + edited: edit.is_some(), + }) + } + fn http(&self, r: HttpRequest) -> Result { + self.calls + .lock() + .push(format!("http {} {} {}", r.method, r.url, r.body)); + Ok(HttpResponse { + status: 200, + body: r#"{"user":{"name":"ana"}}"#.into(), + }) + } + fn phone( + &self, + _: &str, + topic: &str, + title: &str, + text: &str, + _: &str, + ) -> Result<(), String> { + self.calls + .lock() + .push(format!("phone {topic} {title} {text}")); + Ok(()) + } + fn chat(&self, text: &str, as_bot: bool, reply_to: Option<&str>) -> Result<(), String> { + self.calls + .lock() + .push(format!("chat bot={as_bot} reply={reply_to:?} {text}")); + Ok(()) + } + fn marker(&self, d: &str) -> Result<(), String> { + self.calls.lock().push(format!("marker {d}")); + Ok(()) + } + fn clip(&self) -> Result { + self.calls.lock().push("clip".into()); + Ok(ClipInfo { + id: "Abc".into(), + url: "https://clips.twitch.tv/Abc".into(), + }) + } + fn delay_action(&self, action: &str, ms: u32) -> Result<(), String> { + self.calls.lock().push(format!("delay {action} {ms}")); + Ok(()) + } + fn program(&self, path: &str, args: &[String]) -> Result<(), String> { + self.calls.lock().push(format!("program {path} {args:?}")); + Ok(()) + } + fn file(&self, path: &str, text: &str, append: bool) -> Result<(), String> { + self.calls + .lock() + .push(format!("file {path} {append} {text}")); + Ok(()) + } + fn overlay(&self, title: &str, text: &str, seconds: u64) { + self.calls + .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 { + Arc::new(RunEnv { + discord: vec![DiscordChannel { + id: "mods".into(), + name: "Mods".into(), + url: "https://discord.com/api/webhooks/1/x".into(), + }], + phone: PhoneConnection { + server: String::new(), + topic: "my-topic".into(), + }, + 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(), + vars: vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + reply_to: Some("msg-1".into()), + test, + dry: false, + gate: None, + } + } + + async fn go(host: &Arc, steps: &[Step], c: RunContext) -> (RunStatus, Vec) { + run(steps, c, host.clone(), env()).await + } + + #[tokio::test] + async fn fills_templates_from_event_and_live_state() { + let host = Arc::new(FakeHost { + delay_ms: 30_000, + ..FakeHost::default() + }); + let steps = [Step::new( + StepKind::Discord, + &[ + ("connection", "mods"), + ( + "text", + "OBS dropped ({reason}), {hold_left} left, delay {delay|off}", + ), + ], + )]; + let (status, log) = go(&host, &steps, ctx(&[("reason", "crash")], false)).await; + assert_eq!(status, RunStatus::Ok); + assert_eq!(log[0].status, StepStatus::Ok); + let calls = host.calls.lock(); + assert!( + calls[0].ends_with("OBS dropped (crash), 1m 42s left, delay 30s"), + "{calls:?}" + ); + } + + #[tokio::test] + async fn an_update_edits_the_last_message() { + let host = Arc::new(FakeHost::default()); + let env = env(); + let down = [Step::new( + StepKind::Discord, + &[("connection", "mods"), ("text", "down"), ("ping", "here")], + )]; + let back = [Step::new( + StepKind::Discord, + &[ + ("connection", "mods"), + ("text", "back"), + ("edit", "last"), + ("ping", "here"), + ], + )]; + run(&back, ctx(&[], false), host.clone(), env.clone()).await; + run(&down, ctx(&[], false), host.clone(), env.clone()).await; + let (_, log) = run(&back, ctx(&[], false), host.clone(), env.clone()).await; + // A test's message is its own: it never edits the real one. + run(&back, ctx(&[], true), host.clone(), env.clone()).await; + let calls = host.calls.lock(); + assert!( + calls[0].contains("[here] edit=None back"), + "nothing to edit yet: {calls:?}" + ); + assert!(calls[1].contains("[here] edit=None down")); + // The ping rides along for when the message is gone and is reposted. + assert!( + calls[2].contains("[here] edit=Some(\"m2\") back"), + "{calls:?}" + ); + assert!(calls[3].contains("edit=None [TEST] back"), "{calls:?}"); + assert!(log[0].label.ends_with("(updated)")); + } + + #[tokio::test] + async fn chat_values_stay_one_program_argument() { + let host = Arc::new(FakeHost::default()); + let steps = [Step::new( + StepKind::Program, + &[("path", "say.exe"), ("args", "--from \"{user}\" {message}")], + )]; + 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 calc"]"# + ); + } + + #[tokio::test] + async fn overlay_shows_on_stream_except_in_tests() { + let host = Arc::new(FakeHost::default()); + let steps = [ + Step::new( + StepKind::Overlay, + &[("title", "Clip"), ("text", "by {user}"), ("seconds", "8")], + ), + Step::new(StepKind::Overlay, &[("text", "{nothing}")]), + ]; + let (_, log) = go(&host, &steps, ctx(&[("user", "Ana")], false)).await; + assert_eq!(log[1].status, StepStatus::Skipped); + let (_, log) = go(&host, &steps, ctx(&[("user", "Ana")], true)).await; + assert_eq!(log[0].status, StepStatus::Skipped); + assert_eq!(host.calls.lock().as_slice(), ["overlay Clip|by Ana|8"]); + } + + #[tokio::test] + async fn edited_text_feeds_later_steps() { + let host = Arc::new(FakeHost::default()); + let steps = [ + Step::new( + StepKind::EditText, + &[ + ("input", "{answer}"), + ("op", "between"), + ("a", "rank:"), + ("b", "("), + ("save_as", "rank"), + ], + ), + Step::new(StepKind::EditText, &[("input", "{rank}"), ("op", "upper")]), + Step::new(StepKind::Chat, &[("text", "{rank} / {text}")]), + ]; + go( + &host, + &steps, + ctx(&[("answer", "rank: Gold 2 (34 RR)")], false), + ) + .await; + assert!(host.calls.lock()[0].ends_with("Gold 2 / GOLD 2")); + } + + #[tokio::test] + async fn checks_branch_and_stop_ends_the_run() { + let host = Arc::new(FakeHost::default()); + let mut check = Step::new( + StepKind::If, + &[("left", "{user_role}"), ("op", "is"), ("right", "MOD")], + ); + check.then.push(Step::new( + StepKind::Chat, + &[("text", "ok {user}"), ("reply", "yes")], + )); + check.otherwise.push(Step::new(StepKind::Stop, &[])); + let steps = [ + check.clone(), + Step::new(StepKind::Marker, &[("description", "after")]), + ]; + + let (status, _) = go( + &host, + &steps, + ctx(&[("user_role", "mod"), ("user", "Ana")], false), + ) + .await; + assert_eq!(status, RunStatus::Ok); + let (status, _) = go( + &host, + &steps, + ctx(&[("user_role", "viewer"), ("user", "Bo")], false), + ) + .await; + assert_eq!(status, RunStatus::Stopped); + + let calls = host.calls.lock(); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert!(calls[0].contains("reply=Some(\"msg-1\") ok Ana")); + assert_eq!(calls[1], "marker after"); + } + + #[tokio::test] + async fn a_failing_step_does_not_stop_the_rest() { + let host = Arc::new(FakeHost { + fail_discord: true, + ..FakeHost::default() + }); + let steps = [ + Step::new(StepKind::Discord, &[("connection", "mods"), ("text", "a")]), + Step::new(StepKind::Chat, &[("text", "b")]), + ]; + let (status, log) = go(&host, &steps, ctx(&[], false)).await; + assert_eq!(status, RunStatus::Failed); + assert_eq!(log[0].status, StepStatus::Failed); + assert_eq!(log[1].status, StepStatus::Ok); + } + + #[tokio::test] + async fn step_outputs_feed_later_steps() { + let host = Arc::new(FakeHost::default()); + let steps = [ + Step::new( + StepKind::Http, + &[ + ("method", "get"), + ("url", "https://api.example/u"), + ("save_as", "who"), + ], + ), + Step::new(StepKind::Clip, &[]), + Step::new( + StepKind::Chat, + &[("text", "{who.json.user.name} {who.status} {clip.url}")], + ), + ]; + go(&host, &steps, ctx(&[], false)).await; + let calls = host.calls.lock(); + assert!(calls[0].starts_with("http GET https://api.example/u")); + assert!( + calls[2].ends_with("ana 200 https://clips.twitch.tv/Abc"), + "{calls:?}" + ); + } + + /// 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 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, "Turn the delay off"); + 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()); + let steps = [ + Step::new(StepKind::Wait, &[("ms", "600000")]), + Step::new(StepKind::DelayAction, &[("action", "cut")]), + Step::new(StepKind::Program, &[("path", "C:\\x.exe")]), + Step::new(StepKind::Chat, &[("text", "hi")]), + ]; + let (status, log) = go(&host, &steps, ctx(&[], true)).await; + assert_eq!(status, RunStatus::Ok); + assert_eq!(log[0].status, StepStatus::Skipped); + assert_eq!(log[1].status, StepStatus::Skipped); + let calls = host.calls.lock(); + assert_eq!(calls.as_slice(), ["chat bot=false reply=None [TEST] hi"]); + } + + #[tokio::test] + async fn counters_persist_between_runs() { + let host = Arc::new(FakeHost::default()); + let env = env(); + let steps = [ + Step::new(StepKind::Counter, &[("name", "crashes")]), + Step::new( + StepKind::File, + &[("path", "c.txt"), ("text", "Crashes: {counter.crashes}")], + ), + ]; + run(&steps, ctx(&[], false), host.clone(), env.clone()).await; + run(&steps, ctx(&[], false), host.clone(), env.clone()).await; + assert_eq!(env.counters.lock().get("crashes"), Some(&2)); + assert!(host.calls.lock()[1].ends_with("Crashes: 2")); + } + + #[tokio::test] + async fn waiting_for_the_delay_follows_it() { + let host = Arc::new(FakeHost { + delay_ms: 50, + ..FakeHost::default() + }); + let started = Instant::now(); + let (_, log) = go( + &host, + &[Step::new(StepKind::WaitDelay, &[])], + ctx(&[], false), + ) + .await; + assert!(started.elapsed() >= Duration::from_millis(50)); + assert_eq!(log[0].status, StepStatus::Ok); + } + + #[test] + fn compare_handles_text_and_numbers() { + assert!(compare("Crash", "is", "crash")); + assert!(compare("12", "greater", "9")); + assert!(!compare("abc", "greater", "9")); + assert!(compare("", "empty", "")); + assert!(compare("no", "empty", "")); + assert!(compare("hello world", "contains", "WORLD")); + 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"); + assert_eq!( + redact_urls("failed: https://ana:pw@api.host/x"), + "failed: https://api.host/…" + ); + assert_eq!(short_url("https://ana:pw@api.host/x?k=1"), "api.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, + _: &DiscordMessage, + _: Option<&str>, + ) -> Result { + 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(()) + } + fn overlay(&self, _: &str, _: &str, _: u64) {} + fn obs(&self, _: obsws::Action) -> 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"); + assert_eq!(safe_chat_text("a\r\nb"), "a b"); + assert_eq!(safe_chat_text(&"x".repeat(900)).chars().count(), 500); + assert_eq!(safe_chat_text(&"/".repeat(900)).chars().count(), 500); + } + + #[test] + fn args_keep_quoted_parts_together() { + assert_eq!( + split_args(r#"--scene "Clip replay" --for 10 """#), + vec!["--scene", "Clip replay", "--for", "10", ""] + ); + } +} 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 new file mode 100644 index 0000000..9fe3ef4 --- /dev/null +++ b/src/integrations/store.rs @@ -0,0 +1,198 @@ +//! What integrations keep between runs, in its own file next to the config: +//! the Twitch logins, the counters and how often each integration ran. +//! +//! Kept apart from the settings on purpose. Tokens change on their own +//! every few hours and counters on every crash; routing those through the +//! settings would rewrite the whole config and race the dashboard's own +//! saves. This file has one writer (the integrations engine), is written +//! atomically, and never leaves the machine. + +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub const FILE_NAME: &str = "instantclone.integrations-data.json"; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TwitchAccount { + pub login: String, + pub user_id: String, + pub access: String, + pub refresh: String, + /// When the access token runs out, in Unix milliseconds. + pub expires_at_ms: u64, +} + +impl TwitchAccount { + fn to_json(&self) -> Value { + json::obj([ + ("login", json::str(&self.login)), + ("user_id", json::str(&self.user_id)), + ("access", json::str(&self.access)), + ("refresh", json::str(&self.refresh)), + ("expires_at_ms", Value::Num(self.expires_at_ms as f64)), + ]) + } + + fn from_json(v: &Value) -> Option { + let account = TwitchAccount { + login: v.str_or("login", "").to_string(), + user_id: v.str_or("user_id", "").to_string(), + access: v.str_or("access", "").to_string(), + refresh: v.str_or("refresh", "").to_string(), + expires_at_ms: v.u64_or("expires_at_ms", 0), + }; + (!account.login.is_empty() && !account.refresh.is_empty()).then_some(account) + } +} + +#[derive(Default)] +pub struct Accounts { + pub main: Option, + pub bot: Option, +} + +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. + write_lock: Mutex<()>, +} + +impl Store { + /// Load from `dir`, starting empty if the file is missing or unreadable. + /// A file that doesn't parse is kept beside it (`.bad`) rather than + /// overwritten by the first save, so the logins in it can be recovered. + pub fn open(dir: &Path) -> Store { + let path = dir.join(FILE_NAME); + let doc = match std::fs::read_to_string(&path) { + Ok(text) => json::parse(&text).unwrap_or_else(|_| { + let _ = std::fs::rename(&path, path.with_extension("json.bad")); + Value::Null + }), + Err(_) => Value::Null, + }; + let account = |key: &str| doc.path(key).and_then(TwitchAccount::from_json); + let counters = match doc.get("counters") { + Some(Value::Obj(fields)) => fields + .iter() + .filter_map(|(k, v)| Some((k.clone(), v.as_f64()? as i64))) + .collect(), + _ => BTreeMap::new(), + }; + let uses = match doc.get("uses") { + Some(Value::Obj(fields)) => fields + .iter() + .filter_map(|(k, v)| Some((k.clone(), v.as_f64()? as u64))) + .collect(), + _ => BTreeMap::new(), + }; + Store { + path, + accounts: Mutex::new(Accounts { + main: account("twitch.main"), + bot: account("twitch.bot"), + }), + counters: Arc::new(Mutex::new(counters)), + counters_dirty: Default::default(), + uses: Mutex::new(uses), + write_lock: Mutex::new(()), + } + } + + pub fn save(&self) -> std::io::Result<()> { + let _guard = self.write_lock.lock(); + let doc = { + let accounts = self.accounts.lock(); + let twitch = json::obj([ + ( + "main", + accounts + .main + .as_ref() + .map_or(Value::Null, TwitchAccount::to_json), + ), + ( + "bot", + accounts + .bot + .as_ref() + .map_or(Value::Null, TwitchAccount::to_json), + ), + ]); + let counters = Value::Obj( + self.counters + .lock() + .iter() + .map(|(k, v)| (k.clone(), Value::Num(*v as f64))) + .collect(), + ); + let uses = Value::Obj( + self.uses + .lock() + .iter() + .map(|(k, v)| (k.clone(), Value::Num(*v as f64))) + .collect(), + ); + json::obj([("twitch", twitch), ("counters", counters), ("uses", uses)]) + }; + // On disk before the rename: after a power cut the file is the old + // one or the new one, never a truncated one. + let tmp = self.path.with_extension("json.tmp"); + let mut file = std::fs::File::create(&tmp)?; + std::io::Write::write_all(&mut file, doc.to_json().as_bytes())?; + file.sync_all()?; + drop(file); + std::fs::rename(&tmp, &self.path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saves_and_reloads_accounts_and_counters() { + let dir = std::env::temp_dir().join(format!("ic-store-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let store = Store::open(&dir); + store.accounts.lock().main = Some(TwitchAccount { + login: "texaz".into(), + user_id: "42".into(), + access: "a".into(), + refresh: "r".into(), + expires_at_ms: 123, + }); + store.counters.lock().insert("crashes".into(), 3); + store.uses.lock().insert("i1".into(), 41); + store.save().unwrap(); + + let back = Store::open(&dir); + assert_eq!(back.accounts.lock().main.as_ref().unwrap().login, "texaz"); + assert!(back.accounts.lock().bot.is_none()); + assert_eq!(back.counters.lock().get("crashes"), Some(&3)); + assert_eq!(back.uses.lock().get("i1"), Some(&41)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_broken_file_starts_empty_and_is_kept_aside() { + let dir = std::env::temp_dir().join(format!("ic-store-bad-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(FILE_NAME), "{not json").unwrap(); + let store = Store::open(&dir); + assert!(store.accounts.lock().main.is_none()); + assert!(store.counters.lock().is_empty()); + store.save().unwrap(); + let kept = dir.join(FILE_NAME).with_extension("json.bad"); + assert_eq!(std::fs::read_to_string(kept).unwrap(), "{not json"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/integrations/template.rs b/src/integrations/template.rs new file mode 100644 index 0000000..bcd1e38 --- /dev/null +++ b/src/integrations/template.rs @@ -0,0 +1,435 @@ +//! Message templates: `{name}` inserts a value, `{name|text}` inserts +//! `text` instead when the value is empty or 0, and `{{name}}` writes a +//! literal `{name}`. +//! +//! Anything in braces that is not a variable name stays as written, so a +//! JSON body like `{"event":"{event}"}` needs no escaping: `{"` can never +//! start a name. An unknown variable renders empty rather than leaking +//! `{typo}` into a chat; the editor flags unknown names before they are +//! saved. + +/// Where a template's values come from. +pub trait Vars { + fn lookup(&self, name: &str) -> Option; +} + +impl Option> Vars for F { + fn lookup(&self, name: &str) -> Option { + self(name) + } +} + +/// Longest variable name accepted; keeps a stray brace from scanning a +/// whole message. +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('{') { + out.push_str(&rest[..i]); + let tail = &rest[i..]; + if let Some(len) = escaped(tail) { + out.push_str(&tail[1..len - 1]); + rest = &tail[len..]; + continue; + } + match placeholder(tail) { + Some((name, fallback, len)) => { + let value = vars.lookup(name).unwrap_or_default(); + match fallback { + Some(text) if is_blank(&value) => out.push_str(text), + _ => out.push_str(&escape(&value)), + } + rest = &tail[len..]; + } + None => { + out.push_str(&tail[..1]); + rest = &tail[1..]; + } + } + } + out.push_str(rest); + 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 { + // `.` and `..` would still walk the path (`/users/../admin`). + if value == "." || value == ".." { + return value.replace('.', "%2E"); + } + 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() +} + +/// 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 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_ascii_alphanumeric() => Some(c), + '_' | '.' | ',' | '+' | '=' | '/' | '-' => Some(c), + c if c.is_whitespace() => Some(' '), + _ => None, + }) + .collect(); + while out.contains("..") { + out = out.replace("..", "."); + } + out.split(' ') + .map(|word| { + let mut word = word; + while word.starts_with(['-', '/']) && !is_negative_number(word) { + word = &word[1..]; + } + word + }) + .collect::>() + .join(" ") +} + +/// `-5`, `-0.5`: a minus and a number, nothing an option could be. +fn is_negative_number(word: &str) -> bool { + word.strip_prefix('-').is_some_and(|rest| { + rest.starts_with(|c: char| c.is_ascii_digit()) + && rest.chars().all(|c| c.is_ascii_digit() || c == '.') + }) +} + +/// Every variable name a template uses, in order, without repeats. +pub fn names(template: &str) -> Vec { + let mut found: Vec = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let tail = &rest[i..]; + if let Some(len) = escaped(tail) { + rest = &tail[len..]; + continue; + } + match placeholder(tail) { + Some((name, _, len)) => { + if !found.iter().any(|n| n == name) { + found.push(name.to_string()); + } + rest = &tail[len..]; + } + None => rest = &tail[1..], + } + } + found +} + +/// Length of a `{{name}}` escape at the start of `s`, braces included. +fn escaped(s: &str) -> Option { + let (_, _, len) = placeholder(s.strip_prefix('{')?)?; + s[1 + len..].starts_with('}').then_some(len + 2) +} + +/// A value the `|fallback` form replaces. +fn is_blank(value: &str) -> bool { + value.is_empty() || value == "0" +} + +/// Parse `{name}` or `{name|fallback}` at the start of `s`. Returns the +/// name, the fallback and the byte length consumed. +fn placeholder(s: &str) -> Option<(&str, Option<&str>, usize)> { + let body = s.strip_prefix('{')?; + let name_len = body + .bytes() + .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'.') + .count(); + if name_len == 0 || name_len > MAX_NAME { + return None; + } + let name = &body[..name_len]; + let after = &body[name_len..]; + if after.starts_with('}') { + return Some((name, None, 1 + name_len + 1)); + } + let fallback_body = after.strip_prefix('|')?; + let end = fallback_body.find('}')?; + let fallback = &fallback_body[..end]; + Some((name, Some(fallback), 1 + name_len + 1 + end + 1)) +} + +/// What an "Edit text" step does to its input, for answers that need a +/// little shaping before they go out: a web answer's first line, the part +/// between two markers, a rounded number. `a` and `b` are the operation's +/// two settings (what to find and what to put instead, a length, decimals). +/// Unknown operations leave the text as it is. +pub fn edit_text(op: &str, input: &str, a: &str, b: &str) -> String { + match op { + "first_line" => input + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .to_string(), + "between" => between(input, a, b).unwrap_or("").trim().to_string(), + "replace" if !a.is_empty() => input.replace(a, b), + "cut" => { + let max = a.trim().parse::().unwrap_or(100).max(1); + if input.chars().count() <= max { + input.to_string() + } else { + let kept: String = input.chars().take(max).collect(); + format!("{}…", kept.trim_end()) + } + } + "round" => match input.trim().parse::() { + Ok(n) if n.is_finite() => { + let decimals = a.trim().parse::().unwrap_or(0).min(6); + format!("{n:.decimals$}") + } + _ => input.to_string(), + }, + "digits" => group_digits(input.trim()).unwrap_or_else(|| input.to_string()), + "upper" => input.to_uppercase(), + "lower" => input.to_lowercase(), + "random" => random_choice(input), + _ => input.to_string(), + } +} + +/// The text after the first `start` and before the next `end`. A blank +/// `start` means from the beginning, a blank `end` to the end. +fn between<'a>(input: &'a str, start: &str, end: &str) -> Option<&'a str> { + let from = if start.is_empty() { + 0 + } else { + input.find(start)? + start.len() + }; + let rest = &input[from..]; + let to = if end.is_empty() { + rest.len() + } else { + rest.find(end)? + }; + Some(&rest[..to]) +} + +/// `12571578` as `12,571,578`; `None` for anything but a whole number. +fn group_digits(text: &str) -> Option { + let (sign, digits) = match text.strip_prefix('-') { + Some(d) => ("-", d), + None => ("", text), + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + Some(format!("{sign}{out}")) +} + +/// One of the input's lines, or of its `|`-separated parts when it is a +/// single line: an 8-ball answer, a random greeting. +fn random_choice(input: &str) -> String { + let separator = if input.contains('\n') { '\n' } else { '|' }; + let parts: Vec<&str> = input + .split(separator) + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(); + if parts.is_empty() { + return String::new(); + } + let mut bytes = [0u8; 4]; + crate::crypto::os_random(&mut bytes); + parts[u32::from_le_bytes(bytes) as usize % parts.len()].to_string() +} + +#[cfg(test)] +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("x --upload-pack=calc"), "x upload-pack=calc"); + assert_eq!(program_arg("../../secret"), "././secret"); + assert_eq!(program_arg("C:/Windows"), "C/Windows"); + assert_eq!( + program_arg("-5 and -0.5"), + "-5 and -0.5", + "negative numbers stay" + ); + assert_eq!( + program_arg("-5x"), + "5x", + "an option starting with a digit goes" + ); + 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"); + } + + #[test] + fn edit_text_shapes_answers() { + assert_eq!( + edit_text("first_line", "\n Radiant \n450 RR", "", ""), + "Radiant" + ); + assert_eq!( + edit_text("between", "rank: Gold 2 (34 RR)", "rank:", "("), + "Gold 2" + ); + assert_eq!(edit_text("between", "a=1&b=2", "b=", ""), "2"); + assert_eq!(edit_text("between", "no marker", "x=", ""), ""); + assert_eq!(edit_text("replace", "a-b-c", "-", " "), "a b c"); + assert_eq!(edit_text("replace", "abc", "", "x"), "abc"); + assert_eq!(edit_text("cut", "hello world", "5", ""), "hello…"); + assert_eq!(edit_text("cut", "hi", "5", ""), "hi"); + assert_eq!(edit_text("round", " 3.14159 ", "2", ""), "3.14"); + assert_eq!(edit_text("round", "2.5e1", "", ""), "25"); + assert_eq!(edit_text("round", "n/a", "2", ""), "n/a"); + assert_eq!(edit_text("digits", "12571578", "", ""), "12,571,578"); + assert_eq!(edit_text("digits", "-1000", "", ""), "-1,000"); + assert_eq!(edit_text("digits", "999", "", ""), "999"); + assert_eq!(edit_text("digits", "1.5", "", ""), "1.5"); + assert_eq!(edit_text("upper", "gg", "", ""), "GG"); + assert_eq!(edit_text("nope", "same", "", ""), "same"); + } + + #[test] + fn random_picks_one_of_the_parts() { + for _ in 0..20 { + let pick = edit_text("random", "Yes | No | Ask again", "", ""); + assert!( + ["Yes", "No", "Ask again"].contains(&pick.as_str()), + "{pick}" + ); + } + assert_eq!(edit_text("random", "only\n\n", "", ""), "only"); + assert_eq!(edit_text("random", " | ", "", ""), ""); + } + + fn vars(name: &str) -> Option { + match name { + "delay" => Some("30s".into()), + "off" => Some(String::new()), + "zero" => Some("0".into()), + "clip.url" => Some("https://clips.twitch.tv/x".into()), + _ => None, + } + } + + #[test] + fn inserts_values() { + assert_eq!(render("Delay: {delay}", &vars), "Delay: 30s"); + assert_eq!(render("{clip.url}!", &vars), "https://clips.twitch.tv/x!"); + } + + #[test] + fn fallback_covers_empty_and_zero() { + assert_eq!(render("Delay: {off|off}", &vars), "Delay: off"); + assert_eq!(render("{zero|none}", &vars), "none"); + assert_eq!(render("{delay|off}", &vars), "30s"); + assert_eq!(render("{missing|n/a}", &vars), "n/a"); + } + + #[test] + fn unknown_names_render_empty() { + assert_eq!(render("a{nope}b", &vars), "ab"); + } + + #[test] + fn json_bodies_pass_through() { + let body = r#"{"event":"{delay}","n":{"x":1}}"#; + assert_eq!(render(body, &vars), r#"{"event":"30s","n":{"x":1}}"#); + } + + #[test] + fn doubled_braces_write_a_literal_placeholder() { + assert_eq!(render("{{delay}} is {delay}", &vars), "{delay} is 30s"); + assert_eq!(render("a }} b {{ c", &vars), "a }} b {{ c"); + } + + #[test] + fn unterminated_braces_stay_as_written() { + assert_eq!(render("{delay", &vars), "{delay"); + assert_eq!(render("{delay|off", &vars), "{delay|off"); + 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"); + assert_eq!(url_component(".."), "%2E%2E"); + assert_eq!(url_component("v1.2"), "v1.2"); + } + + #[test] + fn lists_names_once_in_order() { + assert_eq!( + names("{b} {a|x} {b} {{c}} {\"j\":1}"), + vec!["b".to_string(), "a".to_string()] + ); + } +} 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/auth.rs b/src/integrations/twitch/auth.rs new file mode 100644 index 0000000..c241752 --- /dev/null +++ b/src/integrations/twitch/auth.rs @@ -0,0 +1,206 @@ +//! Twitch login without a server: the Device Code flow. +//! +//! InstantClone is a public client (open source, so it cannot keep a client +//! secret). Twitch allows public clients to log in with a device code the +//! user types on twitch.tv/activate and to refresh tokens without a secret. +//! A public client's refresh token expires after 30 days *unused*; the +//! manager refreshes well inside that, so a streamer who opens InstantClone +//! at least once a month never has to log in again. +//! +//! Every call here blocks (ureq); callers run them off the async thread. + +use crate::json::{self, Value}; + +/// Chat, VOD markers and clips for the streamer's account. +pub const MAIN_SCOPES: &str = "chat:read chat:edit channel:manage:broadcast clips:edit"; +/// A bot account only reads and posts chat. +pub const BOT_SCOPES: &str = "chat:read chat:edit"; + +const DEVICE_URL: &str = "https://id.twitch.tv/oauth2/device"; +const TOKEN_URL: &str = "https://id.twitch.tv/oauth2/token"; +const VALIDATE_URL: &str = "https://id.twitch.tv/oauth2/validate"; + +#[derive(Clone, Debug)] +pub struct DeviceCode { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Clone, Debug)] +pub struct Tokens { + pub access: String, + pub refresh: String, + pub expires_in: u64, +} + +pub enum Poll { + Pending, + SlowDown, + Done(Tokens), + /// The code ran out or the user refused; start over. + Ended(String), +} + +pub enum TokenError { + /// Twitch no longer accepts the token: the user must log in again. + Revoked, + /// Network or server trouble: try again later. + Temporary(String), +} + +pub struct Validated { + pub login: String, + pub user_id: String, +} + +pub fn start_device(client_id: &str, scopes: &str) -> Result { + let (status, body) = post_form(DEVICE_URL, &[("client_id", client_id), ("scopes", scopes)])?; + let v = json::parse(&body).map_err(|_| format!("Twitch answered {status}"))?; + if status != 200 { + return Err(message(&v, status)); + } + Ok(DeviceCode { + device_code: v.str_or("device_code", "").to_string(), + user_code: v.str_or("user_code", "").to_string(), + verification_uri: v + .str_or("verification_uri", "https://www.twitch.tv/activate") + .to_string(), + expires_in: v.u64_or("expires_in", 1800), + interval: v.u64_or("interval", 5).max(1), + }) +} + +pub fn poll_device(client_id: &str, scopes: &str, device_code: &str) -> Poll { + let form = [ + ("client_id", client_id), + ("scopes", scopes), + ("device_code", device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]; + let (status, body) = match post_form(TOKEN_URL, &form) { + Ok(r) => r, + Err(_) => return Poll::Pending, + }; + let Ok(v) = json::parse(&body) else { + return Poll::Pending; + }; + if status == 200 { + return Poll::Done(tokens(&v)); + } + match v.str_or("message", "") { + "authorization_pending" => Poll::Pending, + "slow_down" => Poll::SlowDown, + "access_denied" => Poll::Ended("You declined the login on Twitch.".to_string()), + "expired_token" | "invalid device code" => { + Poll::Ended("The code expired before it was entered. Try again.".to_string()) + } + _ => Poll::Ended(message(&v, status)), + } +} + +pub fn refresh(client_id: &str, refresh_token: &str) -> Result { + let form = [ + ("client_id", client_id), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ]; + let (status, body) = post_form(TOKEN_URL, &form).map_err(TokenError::Temporary)?; + let v = json::parse(&body) + .map_err(|_| TokenError::Temporary(format!("Twitch answered {status}")))?; + match status { + 200 => Ok(tokens(&v)), + 400 | 401 => Err(TokenError::Revoked), + _ => Err(TokenError::Temporary(message(&v, status))), + } +} + +pub fn validate(access: &str) -> Result { + let resp = crate::https::https_agent_with_timeout(super::HTTP_TIMEOUT) + .get(VALIDATE_URL) + .header("Authorization", format!("OAuth {access}")) + .call() + .map_err(|e| TokenError::Temporary(e.to_string()))?; + let status = resp.status().as_u16(); + let body = read_body(resp); + if status == 401 { + return Err(TokenError::Revoked); + } + let v = json::parse(&body) + .map_err(|_| TokenError::Temporary(format!("Twitch answered {status}")))?; + if status != 200 { + return Err(TokenError::Temporary(message(&v, status))); + } + Ok(Validated { + login: v.str_or("login", "").to_string(), + user_id: v.str_or("user_id", "").to_string(), + }) +} + +fn tokens(v: &Value) -> Tokens { + Tokens { + access: v.str_or("access_token", "").to_string(), + refresh: v.str_or("refresh_token", "").to_string(), + expires_in: v.u64_or("expires_in", 3600), + } +} + +fn message(v: &Value, status: u16) -> String { + match v.str_or("message", "") { + "" => format!("Twitch answered {status}"), + m => format!("Twitch: {m}"), + } +} + +fn post_form(url: &str, form: &[(&str, &str)]) -> Result<(u16, String), String> { + let resp = crate::https::https_agent_with_timeout(super::HTTP_TIMEOUT) + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .send(form_encode(form)) + .map_err(|e| e.to_string())?; + let status = resp.status().as_u16(); + Ok((status, read_body(resp))) +} + +pub fn read_body(mut resp: ureq::http::Response) -> String { + resp.body_mut() + .with_config() + .limit(1024 * 1024) + .read_to_string() + .unwrap_or_default() +} + +pub fn form_encode(pairs: &[(&str, &str)]) -> String { + pairs + .iter() + .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v))) + .collect::>() + .join("&") +} + +pub fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forms_are_percent_encoded() { + assert_eq!( + form_encode(&[("scopes", "chat:read chat:edit"), ("a", "x&y=z")]), + "scopes=chat%3Aread%20chat%3Aedit&a=x%26y%3Dz" + ); + } +} diff --git a/src/integrations/twitch/helix.rs b/src/integrations/twitch/helix.rs new file mode 100644 index 0000000..8ef6b16 --- /dev/null +++ b/src/integrations/twitch/helix.rs @@ -0,0 +1,73 @@ +//! The two Helix API calls integrations use: VOD markers and clips. +//! Blocking; run off the async thread. + +use super::auth::read_body; +use crate::json::{self, Value}; + +const API: &str = "https://api.twitch.tv/helix"; + +pub struct Auth<'a> { + pub client_id: &'a str, + pub token: &'a str, + pub user_id: &'a str, +} + +/// Mark this moment in the live VOD. Twitch only accepts markers while the +/// channel is live, and says so. +pub fn create_marker(auth: &Auth, description: &str) -> Result<(), String> { + let description: String = description.chars().take(140).collect(); + let body = json::obj([ + ("user_id", json::str(auth.user_id)), + ("description", json::str(description)), + ]) + .to_json(); + let (status, text) = post(auth, &format!("{API}/streams/markers"), &body)?; + match status { + 200 => Ok(()), + _ => Err(explain(status, &text, "add the marker")), + } +} + +/// Clip the last moments of the live stream. Returns the clip's id; the +/// clip page works right away, the video appears after Twitch processes it. +pub fn create_clip(auth: &Auth) -> Result { + let url = format!( + "{API}/clips?broadcaster_id={}", + super::auth::url_encode(auth.user_id) + ); + let (status, text) = post(auth, &url, "")?; + if status != 202 && status != 200 { + return Err(explain(status, &text, "clip")); + } + json::parse(&text) + .ok() + .as_ref() + .and_then(|v| v.path("data.0.id")) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "Twitch accepted the clip but sent no id".to_string()) +} + +fn post(auth: &Auth, url: &str, body: &str) -> Result<(u16, String), String> { + let resp = crate::https::https_agent_with_timeout(super::HTTP_TIMEOUT) + .post(url) + .header("Client-Id", auth.client_id) + .header("Authorization", format!("Bearer {}", auth.token)) + .header("Content-Type", "application/json") + .send(body) + .map_err(|e| e.to_string())?; + let status = resp.status().as_u16(); + Ok((status, read_body(resp))) +} + +fn explain(status: u16, body: &str, what: &str) -> String { + let message = json::parse(body) + .ok() + .and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_default(); + match (status, message.is_empty()) { + (401, _) => format!("Twitch refused to {what}: log in to Twitch again"), + (_, false) => format!("Twitch couldn't {what}: {message}"), + (_, true) => format!("Twitch couldn't {what} (HTTP {status})"), + } +} diff --git a/src/integrations/twitch/irc.rs b/src/integrations/twitch/irc.rs new file mode 100644 index 0000000..ac44172 --- /dev/null +++ b/src/integrations/twitch/irc.rs @@ -0,0 +1,475 @@ +//! Twitch chat over IRC (TLS, port 6697). +//! +//! IRC is line-based and runs over the TLS stack the crate already links, +//! so chat needs no websocket dependency. One connection per account: +//! the streamer's reads chat and posts, a bot account only posts. +//! +//! The connection lives until the stop signal fires. It reconnects on its +//! own with a growing backoff, answers PINGs, and paces sends under +//! Twitch's limit so a busy integration never gets the account muted. + +use crate::sync::Mutex; +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, watch}; + +const HOST: &str = "irc.chat.twitch.tv"; +const PORT: u16 = 6697; +/// Twitch's limit for a regular account: 20 messages per 30 seconds. +const SEND_WINDOW: Duration = Duration::from_secs(30); +const SEND_LIMIT: usize = 20; +const MAX_BACKOFF: Duration = Duration::from_secs(60); +/// A connection that stays up this long resets the backoff. +const STABLE: Duration = Duration::from_secs(60); +/// No line from Twitch for this long means the socket is dead. +const READ_TIMEOUT: Duration = Duration::from_secs(360); + +/// A chat message as integrations see it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ChatMessage { + pub id: String, + pub user_login: String, + pub display_name: String, + pub text: String, + pub is_broadcaster: bool, + 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 +/// 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)] +pub enum ChatState { + #[default] + Off, + Connecting, + Connected, + /// Twitch refused the token; the manager refreshes it and restarts us. + AuthFailed, + Error(String), +} + +/// What a connection needs to log in. +#[derive(Clone)] +pub struct Login { + pub login: String, + /// Shared with the manager, which swaps in each refreshed token: a + /// reconnect hours later must not log in with the one we started with. + pub token: Arc>, + /// Channel to join (the streamer's login). + pub channel: String, +} + +/// Run one account's chat connection until `stop` flips to true. +/// `incoming` is `None` for a send-only (bot) connection. +pub async fn run( + login: Login, + incoming: Option>, + mut outgoing: mpsc::Receiver, + state: Arc>, + mut stop: watch::Receiver, +) { + let mut backoff = Duration::from_secs(1); + let mut sent: VecDeque = VecDeque::new(); + loop { + if *stop.borrow() { + break; + } + *state.lock() = ChatState::Connecting; + let started = Instant::now(); + let result = session( + &login, + incoming.as_ref(), + &mut outgoing, + &state, + &mut stop, + &mut sent, + ) + .await; + if *stop.borrow() { + break; + } + match result { + Err(SessionEnd::AuthFailed) => { + *state.lock() = ChatState::AuthFailed; + return; + } + Err(SessionEnd::Io(e)) => *state.lock() = ChatState::Error(e), + Ok(()) => {} + } + if started.elapsed() >= STABLE { + backoff = Duration::from_secs(1); + } + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = stop.changed() => {} + } + backoff = (backoff * 2).min(MAX_BACKOFF); + } + // No state write here: whoever stopped us owns the state now. +} + +enum SessionEnd { + AuthFailed, + Io(String), +} + +async fn session( + login: &Login, + incoming: Option<&mpsc::Sender>, + outgoing: &mut mpsc::Receiver, + state: &Arc>, + stop: &mut watch::Receiver, + 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()))? + .map_err(io)?; + let connector = native_tls::TlsConnector::new().map_err(|e| SessionEnd::Io(e.to_string()))?; + let tls = tokio_native_tls::TlsConnector::from(connector) + .connect(HOST, tcp) + .await + .map_err(|e| SessionEnd::Io(format!("TLS: {e}")))?; + let (reader, mut writer) = tokio::io::split(tls); + let mut lines = BufReader::new(reader).lines(); + + let channel = login.channel.to_lowercase(); + let hello = format!( + "CAP REQ :twitch.tv/tags twitch.tv/commands\r\nPASS oauth:{}\r\nNICK {}\r\nJOIN #{}\r\n", + login.token.lock(), + login.login.to_lowercase(), + channel + ); + writer.write_all(hello.as_bytes()).await.map_err(io)?; + + loop { + tokio::select! { + line = tokio::time::timeout(READ_TIMEOUT, lines.next_line()) => { + let line = match line { + Ok(Ok(Some(line))) => line, + Ok(Ok(None)) => return Err(SessionEnd::Io("Twitch closed the chat connection".into())), + Ok(Err(e)) => return Err(io(e)), + Err(_) => return Err(SessionEnd::Io("chat went quiet; reconnecting".into())), + }; + let msg = parse_line(&line); + match msg.command { + "PING" => { + let pong = format!("PONG :{}\r\n", msg.trailing); + writer.write_all(pong.as_bytes()).await.map_err(io)?; + } + "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 + // moving and a stalled engine must not stall it. + let _ = tx.try_send(chat); + } + } + _ => {} + } + } + out = outgoing.recv() => { + let Some(out) = out else { return Ok(()) }; + 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(()), + } + } +} + +/// Wait until one more message fits in Twitch's rate window. +async fn pace(sent: &mut VecDeque) { + loop { + while sent.front().is_some_and(|t| t.elapsed() >= SEND_WINDOW) { + sent.pop_front(); + } + if sent.len() < SEND_LIMIT { + sent.push_back(Instant::now()); + return; + } + let oldest = *sent.front().expect("window is full"); + tokio::time::sleep(SEND_WINDOW.saturating_sub(oldest.elapsed())).await; + } +} + +fn privmsg_line(channel: &str, out: &Outgoing) -> String { + let text: String = out + .text + .chars() + .filter(|c| *c != '\r' && *c != '\n') + .collect(); + match &out.reply_to { + Some(id) if valid_msg_id(id) => { + format!("@reply-parent-msg-id={id} PRIVMSG #{channel} :{text}\r\n") + } + _ => format!("PRIVMSG #{channel} :{text}\r\n"), + } +} + +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") +} + +/// One IRC line, split into its parts. Borrowed from the line. +#[derive(Debug, Default, PartialEq)] +pub struct Line<'a> { + pub tags: Vec<(&'a str, String)>, + pub nick: &'a str, + pub command: &'a str, + pub target: &'a str, + pub trailing: &'a str, +} + +impl Line<'_> { + fn tag(&self, name: &str) -> &str { + self.tags + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.as_str()) + .unwrap_or("") + } +} + +pub fn parse_line(line: &str) -> Line<'_> { + let mut rest = line.trim_end_matches(['\r', '\n']); + let mut out = Line::default(); + if let Some(tagged) = rest.strip_prefix('@') { + let (tags, after) = tagged.split_once(' ').unwrap_or((tagged, "")); + out.tags = tags + .split(';') + .filter_map(|kv| { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + (!k.is_empty()).then(|| (k, unescape_tag(v))) + }) + .collect(); + rest = after; + } + if let Some(prefixed) = rest.strip_prefix(':') { + let (prefix, after) = prefixed.split_once(' ').unwrap_or((prefixed, "")); + out.nick = prefix.split('!').next().unwrap_or(""); + rest = after; + } + let (head, trailing) = match rest.split_once(" :") { + Some((head, trailing)) => (head, trailing), + None => (rest, ""), + }; + let mut parts = head.split(' ').filter(|p| !p.is_empty()); + out.command = parts.next().unwrap_or(""); + out.target = parts.next().unwrap_or(""); + out.trailing = trailing; + if out.command == "PING" && out.trailing.is_empty() { + out.trailing = out.target; + } + out +} + +/// IRCv3 tag value escapes. +fn unescape_tag(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + let mut chars = v.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some(':') => out.push(';'), + Some('s') => out.push(' '), + Some('r') => out.push('\r'), + Some('n') => out.push('\n'), + Some(other) => out.push(other), + None => {} + } + } + out +} + +fn chat_message(line: &Line) -> Option { + if line.trailing.is_empty() { + return None; + } + let badges = line.tag("badges"); + let has_badge = |name: &str| badges.split(',').any(|b| b.split('/').next() == Some(name)); + let display = line.tag("display-name"); + // `/me` arrives as a CTCP ACTION; integrations see the text. + let text = line + .trailing + .strip_prefix("\u{1}ACTION ") + .map(|t| t.trim_end_matches('\u{1}')) + .unwrap_or(line.trailing); + Some(ChatMessage { + id: line.tag("id").to_string(), + user_login: line.nick.to_string(), + display_name: if display.is_empty() { + line.nick.to_string() + } else { + display.to_string() + }, + text: text.to_string(), + is_broadcaster: has_badge("broadcaster"), + 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") + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_tagged_privmsg() { + let raw = "@badge-info=;badges=moderator/1,subscriber/12;display-name=Ana\\sB;id=b34ccfc7-4977-403a-8a94-33c6bac34fb8;mod=1;subscriber=1 :ana!ana@ana.tmi.twitch.tv PRIVMSG #texaz :!delay please\r\n"; + let line = parse_line(raw); + assert_eq!(line.command, "PRIVMSG"); + assert_eq!(line.target, "#texaz"); + let msg = chat_message(&line).unwrap(); + assert_eq!(msg.display_name, "Ana B"); + assert_eq!(msg.user_login, "ana"); + assert_eq!(msg.text, "!delay please"); + assert!(msg.is_mod && msg.is_sub && !msg.is_vip && !msg.is_broadcaster); + 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}"; + let msg = chat_message(&parse_line(raw)).unwrap(); + assert_eq!(msg.text, "waves"); + assert!(msg.is_broadcaster); + } + + #[test] + fn parses_ping_in_both_shapes() { + assert_eq!(parse_line("PING :tmi.twitch.tv").trailing, "tmi.twitch.tv"); + assert_eq!(parse_line("PING tmi.twitch.tv").trailing, "tmi.twitch.tv"); + } + + #[test] + fn spots_auth_failures() { + let line = parse_line(":tmi.twitch.tv NOTICE * :Login authentication failed"); + assert_eq!(line.command, "NOTICE"); + 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), + "@reply-parent-msg-id=abc-123 PRIVMSG #texaz :hiPRIVMSG #x :spam\r\n" + ); + 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"); + } + + #[tokio::test] + async fn pacing_admits_the_limit_then_holds() { + let mut sent = VecDeque::new(); + for _ in 0..SEND_LIMIT { + pace(&mut sent).await; + } + assert_eq!(sent.len(), SEND_LIMIT); + let next = tokio::time::timeout(Duration::from_millis(50), pace(&mut sent)).await; + assert!(next.is_err(), "the 21st message must wait"); + } +} diff --git a/src/integrations/twitch/mod.rs b/src/integrations/twitch/mod.rs new file mode 100644 index 0000000..bdccafb --- /dev/null +++ b/src/integrations/twitch/mod.rs @@ -0,0 +1,663 @@ +//! The Twitch connection: logins, token upkeep, chat, markers and clips. +//! +//! "Log in once and forget it" is this module's job: +//! - Logging in is the Device Code flow (see `auth`), no secret, no server. +//! - Access tokens are refreshed before they run out, and validated hourly +//! as Twitch asks. A refresh keeps the 30-day unused window from ever +//! closing while InstantClone is in use. +//! - Refresh tokens are single-use, so the new pair is saved before the old +//! one could be needed again. +//! - Chat reconnects on its own; a refused token triggers a refresh and a +//! reconnect instead of a dead chat. +//! - If Twitch really has logged us out, the dashboard says so with a +//! one-click reconnect. Nothing here ever touches the stream. + +pub mod auth; +pub mod helix; +pub mod irc; + +use super::store::{Store, TwitchAccount}; +use crate::json::{self, Value}; +use crate::sync::Mutex; +use irc::{ChatMessage, ChatState, Delivery, Outgoing}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, watch, Notify}; + +/// The app's Twitch client id, baked in at build time. A build without one +/// (a fork, a dev build) can still set it at run time. +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); +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 { + Main, + Bot, +} + +impl Which { + pub fn from_id(id: &str) -> Which { + if id == "bot" { + Which::Bot + } else { + Which::Main + } + } + + fn scopes(self) -> &'static str { + match self { + Which::Main => auth::MAIN_SCOPES, + Which::Bot => auth::BOT_SCOPES, + } + } +} + +struct LoginFlow { + which: Which, + user_code: String, + uri: String, + expires_at: Instant, + error: Option, + /// Bumped per attempt so a superseded poll loop stops quietly. + attempt: u64, +} + +#[derive(Default)] +struct Conn { + /// The running connection's state. Each start gets a fresh one, so a + /// stopped connection still winding down can't overwrite the new one's. + state: Mutex>>, + /// The token the running connection logs in with, kept fresh. + token: Mutex>>>, + out: Mutex>>, + stop: Mutex>>, +} + +impl Conn { + fn state(&self) -> ChatState { + self.state.lock().lock().clone() + } + + fn stop(&self) { + if let Some(stop) = self.stop.lock().take() { + let _ = stop.send(true); + } + self.out.lock().take(); + self.token.lock().take(); + *self.state.lock() = Arc::default(); + } +} + +pub struct Twitch { + /// 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, + main: Conn, + bot: Conn, + incoming: mpsc::Sender, + /// Shown on the dashboard when Twitch logged us out. + notice: Mutex>, + upkeep_now: Arc, + /// Set by `check_tokens_soon`: validate on the next upkeep round. + validate_soon: AtomicBool, + /// Writes a line to the dashboard log. + log: Box, +} + +impl Twitch { + pub fn new( + store: Arc, + incoming: mpsc::Sender, + log: Box, + custom_client_id: &str, + ) -> Twitch { + Twitch { + client_id: Mutex::new(effective_client_id(custom_client_id)), + store, + login: Mutex::new(None), + login_attempts: Default::default(), + main: Conn::default(), + bot: Conn::default(), + incoming, + notice: Mutex::new(None), + upkeep_now: Arc::new(Notify::new()), + validate_soon: AtomicBool::new(false), + log, + } + } + + pub fn available(&self) -> bool { + !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 { + match which { + Which::Main => &self.main, + Which::Bot => &self.bot, + } + } + + /// 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 { + Which::Main => accounts.main.clone(), + Which::Bot => accounts.bot.clone(), + } + } + + fn set_account(&self, which: Which, account: Option) { + { + let mut accounts = self.store.accounts.lock(); + match which { + Which::Main => accounts.main = account, + Which::Bot => accounts.bot = account, + } + } + if let Err(e) = self.store.save() { + // The refresh token rotates: one only in memory is a logout at + // the next start, so say so while it can still be fixed. + (self.log)(format!("[twitch] couldn't save the Twitch login: {e}")); + } + } + + /// The streamer's channel login, or empty. + pub fn channel(&self) -> String { + self.account(Which::Main) + .map(|a| a.login) + .unwrap_or_default() + } + + /// Start chat and the token upkeep. Call from the engine's runtime. + pub fn start(self: &Arc) { + self.restart_chat(Which::Main); + self.restart_chat(Which::Bot); + let me = self.clone(); + tokio::spawn(async move { me.upkeep().await }); + } + + fn restart_chat(&self, which: Which) { + let conn = self.conn(which); + conn.stop(); + let (Some(account), Some(main)) = (self.account(which), self.account(Which::Main)) else { + return; + }; + let (out_tx, out_rx) = mpsc::channel(64); + let (stop_tx, stop_rx) = watch::channel(false); + *conn.out.lock() = Some(out_tx); + *conn.stop.lock() = Some(stop_tx); + let token = Arc::new(Mutex::new(account.access)); + *conn.token.lock() = Some(token.clone()); + let login = irc::Login { + login: account.login, + token, + channel: main.login, + }; + let incoming = (which == Which::Main).then(|| self.incoming.clone()); + let state = Arc::new(Mutex::new(ChatState::Connecting)); + *conn.state.lock() = state.clone(); + let upkeep_now = self.upkeep_now.clone(); + tokio::spawn(async move { + 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(); + } + }); + } + + /// Begin a Device Code login. The dashboard shows the code from + /// `status_json` and the user enters it on twitch.tv/activate. + pub fn begin_login(self: &Arc, which: Which) { + let attempt = self + .login_attempts + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + *self.login.lock() = Some(LoginFlow { + which, + user_code: String::new(), + uri: String::new(), + expires_at: Instant::now() + Duration::from_secs(600), + 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() { + return; + } + let me = self.clone(); + tokio::spawn(async move { me.run_login(which, attempt).await }); + } + + pub fn cancel_login(&self) { + self.login_attempts + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + *self.login.lock() = None; + } + + fn login_current(&self, attempt: u64) -> bool { + self.login + .lock() + .as_ref() + .is_some_and(|f| f.attempt == attempt) + } + + fn login_failed(&self, attempt: u64, error: String) { + if let Some(flow) = self.login.lock().as_mut().filter(|f| f.attempt == attempt) { + flow.error = Some(error); + } + } + + async fn run_login(self: Arc, which: Which, attempt: u64) { + 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()) + }) + .await + .unwrap_or_else(|_| Err("login crashed".to_string())); + let code = match started { + Ok(code) => code, + Err(e) => return self.login_failed(attempt, e), + }; + if let Some(flow) = self.login.lock().as_mut().filter(|f| f.attempt == attempt) { + flow.user_code = code.user_code.clone(); + flow.uri = code.verification_uri.clone(); + flow.expires_at = Instant::now() + Duration::from_secs(code.expires_in); + } + let mut interval = Duration::from_secs(code.interval); + let deadline = Instant::now() + Duration::from_secs(code.expires_in); + let tokens = loop { + tokio::time::sleep(interval).await; + if !self.login_current(attempt) { + return; + } + if Instant::now() >= deadline { + return self.login_failed( + attempt, + "The code expired before it was entered. Try again.".into(), + ); + } + let (id, device) = (client_id.clone(), code.device_code.clone()); + let poll = tokio::task::spawn_blocking(move || { + auth::poll_device(&id, which.scopes(), &device) + }) + .await + .unwrap_or(auth::Poll::Pending); + match poll { + auth::Poll::Pending => {} + auth::Poll::SlowDown => interval += Duration::from_secs(5), + auth::Poll::Done(tokens) => break tokens, + auth::Poll::Ended(e) => return self.login_failed(attempt, e), + } + }; + let access = tokens.access.clone(); + let validated = tokio::task::spawn_blocking(move || auth::validate(&access)).await; + let who = match validated { + Ok(Ok(v)) => v, + _ => { + return self.login_failed( + attempt, + "Twitch logged you in but would not say who you are. Try again.".into(), + ) + } + }; + if !self.login_current(attempt) { + return; + } + self.set_account( + which, + Some(TwitchAccount { + login: who.login, + user_id: who.user_id, + access: tokens.access, + refresh: tokens.refresh, + expires_at_ms: unix_ms() + tokens.expires_in * 1000, + }), + ); + *self.login.lock() = None; + *self.notice.lock() = None; + self.restart_chat(which); + if which == Which::Main { + // The bot joins the streamer's channel, which may have changed. + self.restart_chat(Which::Bot); + } + } + + /// Forget an account and revoke its token at Twitch. + pub fn logout(&self, which: Which) { + self.conn(which).stop(); + if let Some(account) = self.account(which) { + let client_id = self.client_id(); + std::thread::spawn(move || { + let form = [ + ("client_id", client_id.as_str()), + ("token", account.access.as_str()), + ]; + let _ = crate::https::https_agent_with_timeout(HTTP_TIMEOUT) + .post("https://id.twitch.tv/oauth2/revoke") + .header("Content-Type", "application/x-www-form-urlencoded") + .send(auth::form_encode(&form)); + }); + } + self.set_account(which, None); + if which == Which::Main { + // No channel to join any more. + self.bot.stop(); + } + } + + async fn upkeep(self: Arc) { + // None until the first check. Not `now - VALIDATE_EVERY`: Windows + // counts Instant from boot, so that panics in the first hour after it. + let mut last_validate: Option = None; + loop { + let validate_now = self.validate_soon.swap(false, Ordering::Relaxed) + || last_validate.is_none_or(|at| at.elapsed() >= VALIDATE_EVERY); + if validate_now { + last_validate = Some(Instant::now()); + } + for which in [Which::Main, Which::Bot] { + self.keep_fresh(which, validate_now).await; + } + tokio::select! { + _ = tokio::time::sleep(UPKEEP_EVERY) => {} + _ = self.upkeep_now.notified() => {} + } + } + } + + async fn keep_fresh(&self, which: Which, validate: bool) { + let Some(account) = self.account(which) else { + return; + }; + let auth_failed = self.conn(which).state() == ChatState::AuthFailed; + let expiring = account.expires_at_ms <= unix_ms() + REFRESH_MARGIN.as_millis() as u64; + if validate && !expiring && !auth_failed { + let access = account.access.clone(); + match tokio::task::spawn_blocking(move || auth::validate(&access)).await { + Ok(Err(auth::TokenError::Revoked)) => {} + _ => return, + } + } else if !expiring && !auth_failed { + return; + } + 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 account changed while Twitch answered: the user logged out, + // logged in again, or switched apps (which logs out). Applying this + // answer would bring the old login back or wipe the new one. + let unchanged = self + .account(which) + .is_some_and(|now| now.refresh == account.refresh); + if self.client_id() != id || !unchanged { + return; + } + match result { + Ok(tokens) => { + if let Some(token) = &*self.conn(which).token.lock() { + *token.lock() = tokens.access.clone(); + } + self.set_account( + which, + Some(TwitchAccount { + access: tokens.access, + refresh: tokens.refresh, + expires_at_ms: unix_ms() + tokens.expires_in * 1000, + ..account + }), + ); + if auth_failed || self.conn(which).state() == ChatState::Off { + self.restart_chat(which); + } + } + Err(auth::TokenError::Revoked) => { + self.conn(which).stop(); + if which == Which::Main { + // The bot posts in the streamer's channel: no channel now. + self.bot.stop(); + } + self.set_account(which, None); + let who = if which == Which::Main { + "your Twitch account" + } else { + "the bot account" + }; + (self.log)(format!( + "[twitch] Twitch logged out {who}; connect it again in Integrations" + )); + *self.notice.lock() = Some(format!( + "Twitch logged out {who} (it was revoked, or InstantClone went unused for 30 days). Connect it again." + )); + } + // Offline or Twitch hiccup: the next upkeep round tries again. + Err(auth::TokenError::Temporary(e)) => { + (self.log)(format!( + "[twitch] couldn't refresh the login yet, will retry: {e}" + )); + } + } + } + + /// Ask the upkeep loop to check tokens now (after a refused call): + /// Twitch may have revoked one that isn't due for a refresh yet. + pub fn check_tokens_soon(&self) { + self.validate_soon.store(true, Ordering::Relaxed); + self.upkeep_now.notify_one(); + } + + /// 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) { + &self.bot + } else { + &self.main + }; + if !connected(conn) { + return Err(match self.account(Which::Main) { + None => "connect Twitch first".to_string(), + Some(_) => "Twitch chat is reconnecting; try again in a moment".to_string(), + }); + } + let out = conn + .out + .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())?; + // 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( + &self, + 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: &client_id, + token: &account.access, + user_id: &account.user_id, + }; + let result = call(&auth); + if matches!(&result, Err(e) if e.contains("log in to Twitch again")) { + self.check_tokens_soon(); + } + result + } + + pub fn marker(&self, description: &str) -> Result<(), String> { + self.helix_call(|a| helix::create_marker(a, description)) + } + + pub fn clip(&self) -> Result { + self.helix_call(helix::create_clip) + } + + pub fn status_json(&self) -> Value { + let conn_json = |which: Which| { + let account = self.account(which); + let state = match self.conn(which).state() { + _ if account.is_none() => "off".to_string(), + ChatState::Off => "off".to_string(), + ChatState::Connecting => "connecting".to_string(), + ChatState::Connected => "connected".to_string(), + ChatState::AuthFailed => "reconnecting".to_string(), + ChatState::Error(e) => format!("error: {e}"), + }; + json::obj([ + ( + "login", + json::str(account.map(|a| a.login).unwrap_or_default()), + ), + ("chat", json::str(state)), + ]) + }; + let flow = match &*self.login.lock() { + Some(f) => json::obj([ + ( + "which", + json::str(if f.which == Which::Bot { "bot" } else { "main" }), + ), + ("user_code", json::str(&f.user_code)), + ("uri", json::str(&f.uri)), + ( + "expires_in_s", + Value::Num( + f.expires_at + .saturating_duration_since(Instant::now()) + .as_secs() as f64, + ), + ), + ("error", f.error.as_deref().map_or(Value::Null, json::str)), + ]), + 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), + ( + "notice", + self.notice.lock().as_deref().map_or(Value::Null, json::str), + ), + ]) + } +} + +pub fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 0000000..c752bc8 --- /dev/null +++ b/src/json.rs @@ -0,0 +1,492 @@ +//! Minimal JSON value: parse, walk, serialize. +//! +//! The rest of the crate hand-writes the JSON it emits and never needed to +//! read structured JSON back. Integrations do: the dashboard posts whole +//! integrations (triggers, nested steps) and web requests hand back bodies +//! whose fields a step can use. A few hundred lines here beat a serde +//! dependency for that, and keep the crate's no-JSON-crate stance. +//! +//! Objects keep their key order, so a value read and written back comes out +//! the way it went in. + +use std::fmt::Write as _; + +/// Nesting limit for parsing: deep enough for any integration, shallow +/// enough that hostile input cannot overflow the stack. +const MAX_DEPTH: usize = 64; + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Num(f64), + Str(String), + Arr(Vec), + Obj(Vec<(String, Value)>), +} + +impl Value { + /// The value under `key` when this is an object. + pub fn get(&self, key: &str) -> Option<&Value> { + match self { + Value::Obj(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + /// Walk a dotted path: object keys and array indexes (`items.0.name`). + pub fn path(&self, path: &str) -> Option<&Value> { + path.split('.').try_fold(self, |node, part| match node { + Value::Obj(_) => node.get(part), + Value::Arr(items) => part.parse::().ok().and_then(|i| items.get(i)), + _ => None, + }) + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(s) => Some(s), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match self { + Value::Num(n) => Some(*n), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Value::Arr(items) => Some(items), + _ => None, + } + } + + /// String field with a default, for reading optional settings. + pub fn str_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str { + self.get(key).and_then(Value::as_str).unwrap_or(default) + } + + pub fn bool_or(&self, key: &str, default: bool) -> bool { + self.get(key).and_then(Value::as_bool).unwrap_or(default) + } + + /// Non-negative integer field, clamped to `u64`, with a default. + pub fn u64_or(&self, key: &str, default: u64) -> u64 { + match self.get(key).and_then(Value::as_f64) { + Some(n) if n.is_finite() && n >= 0.0 => n.min(u64::MAX as f64) as u64, + _ => default, + } + } + + /// Text a template can show: strings as-is, scalars printed, and + /// containers as compact JSON. + pub fn to_display(&self) -> String { + match self { + Value::Null => String::new(), + Value::Str(s) => s.clone(), + other => other.to_json(), + } + } + + /// Compact JSON text. + pub fn to_json(&self) -> String { + let mut out = String::new(); + self.write(&mut out); + out + } + + fn write(&self, out: &mut String) { + match self { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Num(n) => write_number(*n, out), + Value::Str(s) => write_string(s, out), + Value::Arr(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + item.write(out); + } + out.push(']'); + } + Value::Obj(fields) => { + out.push('{'); + for (i, (key, value)) in fields.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(key, out); + out.push(':'); + value.write(out); + } + out.push('}'); + } + } + } +} + +/// Builder shorthand for objects: `obj([("a", Value::Bool(true))])`. +pub fn obj(fields: [(&str, Value); N]) -> Value { + Value::Obj( + fields + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) +} + +pub fn str(s: impl Into) -> Value { + Value::Str(s.into()) +} + +fn write_number(n: f64, out: &mut String) { + if !n.is_finite() { + out.push_str("null"); + } else if n.fract() == 0.0 && n.abs() < 1e15 { + let _ = write!(out, "{}", n as i64); + } else { + let _ = write!(out, "{n}"); + } +} + +/// Append `s` as a quoted JSON string. +pub fn write_string(s: &str, out: &mut String) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + +/// Parse one JSON document (RFC 8259). Trailing data is an error. +pub fn parse(text: &str) -> Result { + let mut parser = Parser { + bytes: text.as_bytes(), + pos: 0, + }; + parser.skip_ws(); + let value = parser.value(0)?; + parser.skip_ws(); + if parser.pos != parser.bytes.len() { + return Err(format!("unexpected data at byte {}", parser.pos)); + } + Ok(value) +} + +struct Parser<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn skip_ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.pos += 1; + } + } + + fn fail(&self, what: &str) -> Result { + Err(format!("{what} at byte {}", self.pos)) + } + + fn eat(&mut self, byte: u8) -> Result<(), String> { + if self.peek() == Some(byte) { + self.pos += 1; + Ok(()) + } else { + self.fail(&format!("expected '{}'", byte as char)) + } + } + + fn literal(&mut self, word: &str, value: Value) -> Result { + if self.bytes[self.pos..].starts_with(word.as_bytes()) { + self.pos += word.len(); + Ok(value) + } else { + self.fail("invalid literal") + } + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return self.fail("nested too deep"); + } + match self.peek() { + Some(b'{') => self.object(depth), + Some(b'[') => self.array(depth), + Some(b'"') => self.string().map(Value::Str), + Some(b't') => self.literal("true", Value::Bool(true)), + Some(b'f') => self.literal("false", Value::Bool(false)), + Some(b'n') => self.literal("null", Value::Null), + Some(b'-' | b'0'..=b'9') => self.number(), + _ => self.fail("expected a value"), + } + } + + fn object(&mut self, depth: usize) -> Result { + self.eat(b'{')?; + let mut fields = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + return Ok(Value::Obj(fields)); + } + loop { + self.skip_ws(); + let key = self.string()?; + self.skip_ws(); + self.eat(b':')?; + self.skip_ws(); + let value = self.value(depth + 1)?; + fields.push((key, value)); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b'}') => { + self.pos += 1; + return Ok(Value::Obj(fields)); + } + _ => return self.fail("expected ',' or '}'"), + } + } + } + + fn array(&mut self, depth: usize) -> Result { + self.eat(b'[')?; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.pos += 1; + return Ok(Value::Arr(items)); + } + loop { + self.skip_ws(); + items.push(self.value(depth + 1)?); + self.skip_ws(); + match self.peek() { + Some(b',') => self.pos += 1, + Some(b']') => { + self.pos += 1; + return Ok(Value::Arr(items)); + } + _ => return self.fail("expected ',' or ']'"), + } + } + } + + fn number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { + self.pos += 1; + } + match self.peek() { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => self.digits(), + _ => return self.fail("invalid number"), + } + if self.peek() == Some(b'.') { + self.pos += 1; + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return self.fail("invalid number"); + } + self.digits(); + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.pos += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.pos += 1; + } + if !matches!(self.peek(), Some(b'0'..=b'9')) { + return self.fail("invalid number"); + } + self.digits(); + } + let text = std::str::from_utf8(&self.bytes[start..self.pos]).unwrap_or(""); + text.parse::() + .map(Value::Num) + .or_else(|_| self.fail("invalid number")) + } + + fn digits(&mut self) { + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + + fn string(&mut self) -> Result { + self.eat(b'"')?; + let mut out = String::new(); + loop { + // Copy the run of plain bytes up to the next quote or escape in + // one go; the input is a &str, so the run is valid UTF-8. + let run_start = self.pos; + while let Some(b) = self.peek() { + if b == b'"' || b == b'\\' || b < 0x20 { + break; + } + self.pos += 1; + } + out.push_str(std::str::from_utf8(&self.bytes[run_start..self.pos]).unwrap_or("")); + match self.peek() { + Some(b'"') => { + self.pos += 1; + return Ok(out); + } + Some(b'\\') => { + self.pos += 1; + self.escape(&mut out)?; + } + Some(_) => return self.fail("control character in string"), + None => return self.fail("unterminated string"), + } + } + } + + fn escape(&mut self, out: &mut String) -> Result<(), String> { + let Some(b) = self.peek() else { + return self.fail("unterminated escape"); + }; + self.pos += 1; + match b { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let first = self.hex4()?; + let code = if (0xD800..0xDC00).contains(&first) { + // A high surrogate must be followed by `\u` + low surrogate. + if !self.bytes[self.pos..].starts_with(b"\\u") { + return self.fail("lone surrogate"); + } + self.pos += 2; + let second = self.hex4()?; + if !(0xDC00..0xE000).contains(&second) { + return self.fail("invalid surrogate pair"); + } + 0x10000 + ((first - 0xD800) << 10) + (second - 0xDC00) + } else { + first + }; + match char::from_u32(code) { + Some(c) => out.push(c), + None => return self.fail("invalid unicode escape"), + } + } + _ => return self.fail("invalid escape"), + } + Ok(()) + } + + fn hex4(&mut self) -> Result { + let Some(digits) = self.bytes.get(self.pos..self.pos + 4) else { + return self.fail("short unicode escape"); + }; + let text = std::str::from_utf8(digits).unwrap_or(""); + let code = u32::from_str_radix(text, 16).or_else(|_| self.fail("bad unicode escape"))?; + self.pos += 4; + Ok(code) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_nested_documents() { + let v = + parse(r#" {"a": [1, 2.5, -3e2], "b": {"c": "x\ny"}, "d": null, "e": true} "#).unwrap(); + assert_eq!(v.path("a.1").and_then(Value::as_f64), Some(2.5)); + assert_eq!(v.path("a.2").and_then(Value::as_f64), Some(-300.0)); + assert_eq!(v.path("b.c").and_then(Value::as_str), Some("x\ny")); + assert_eq!(v.get("d"), Some(&Value::Null)); + assert!(v.bool_or("e", false)); + } + + #[test] + fn round_trips_and_keeps_key_order() { + let text = r#"{"z":1,"a":"é \"q\" \\ \u0001","list":[true,false,null],"o":{}}"#; + let v = parse(text).unwrap(); + assert_eq!(v.to_json(), text); + assert!(crate::config::is_valid_json(&v.to_json())); + } + + #[test] + fn decodes_surrogate_pairs() { + let v = parse(r#""😀""#).unwrap(); + assert_eq!(v.as_str(), Some("😀")); + assert!(parse(r#""\ud83d""#).is_err()); + } + + #[test] + fn rejects_malformed_input() { + for bad in [ + "", + "{", + "[1,]", + "{\"a\" 1}", + "01", + "1.", + "\"\u{1}\"", + "tru", + "[1] x", + "{\"a\":1,}", + ] { + assert!(parse(bad).is_err(), "accepted {bad:?}"); + } + } + + #[test] + fn refuses_runaway_nesting() { + let deep = "[".repeat(200) + &"]".repeat(200); + assert!(parse(&deep).is_err()); + } + + #[test] + fn integers_print_without_a_fraction() { + assert_eq!(Value::Num(30000.0).to_json(), "30000"); + assert_eq!(Value::Num(1.5).to_json(), "1.5"); + assert_eq!(Value::Num(f64::NAN).to_json(), "null"); + } + + #[test] + fn typed_getters_fall_back() { + let v = parse(r#"{"n": 5, "neg": -1, "s": "x"}"#).unwrap(); + assert_eq!(v.u64_or("n", 0), 5); + assert_eq!(v.u64_or("neg", 7), 7); + assert_eq!(v.u64_or("missing", 9), 9); + assert_eq!(v.str_or("s", "d"), "x"); + assert_eq!(v.str_or("n", "d"), "d"); + } +} diff --git a/src/local_eb_config.rs b/src/local_eb_config.rs new file mode 100644 index 0000000..3826050 --- /dev/null +++ b/src/local_eb_config.rs @@ -0,0 +1,842 @@ +//! 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)) +} + +/// The track a config puts the vertical canvas on: the largest portrait +/// entry of `encoder_configurations`. OBS numbers tracks in that list's +/// order (`obs_output_set_video_encoder2(output, encoder, i)`), so this +/// finds the vertical track in any codec, where reading its SPS only works +/// for H.264. Twitch sends 2K channels an HEVC vertical track. +pub fn vertical_track(config: &str) -> Option { + array_objects(config, "encoder_configurations") + .into_iter() + .enumerate() + .filter_map(|(track, entry)| { + let width = number_field(entry, "width")?; + let height = number_field(entry, "height")?; + (height > width).then_some((track, width.saturating_mul(height))) + }) + .max_by_key(|&(_, area)| area) + .and_then(|(track, _)| u8::try_from(track).ok()) +} + +/// 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"`. +pub(crate) 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: String, + pub settings_json: String, +} + +impl TrackEncoder { + /// The codec this libobs encoder id produces. Unknown for an id that + /// names none, which no destination is assumed to play. + pub fn codec(&self) -> crate::h264::VideoCodec { + use crate::h264::VideoCodec; + let id = self.encoder_type.to_ascii_lowercase(); + if id.contains("av1") { + VideoCodec::Av1 + } else if id.contains("hevc") || id.contains("265") { + VideoCodec::Hevc + } else if id.contains("264") + || id.contains("avc") + || matches!( + id.as_str(), + "jim_nvenc" | "ffmpeg_nvenc" | "obs_qsv11" | "obs_qsv11_v2" + ) + { + VideoCodec::Avc + } else { + VideoCodec::Unknown + } + } + + /// The same encoder and settings at `kbps`. + pub fn at_bitrate(&self, kbps: u32) -> TrackEncoder { + const KEY: &str = "\"bitrate\""; + let json = &self.settings_json; + let settings_json = match json.find(KEY) { + Some(at) => { + let after_key = at + KEY.len(); + let rest = &json[after_key..]; + let value = rest + .trim_start() + .strip_prefix(':') + .unwrap_or(rest) + .trim_start(); + let value_start = json.len() - value.len(); + let digits = value + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(value.len()); + format!( + "{}:{kbps}{}", + &json[..after_key], + &json[value_start + digits..] + ) + } + None => { + let fields = json + .trim() + .trim_start_matches('{') + .trim_end_matches('}') + .trim(); + match fields { + "" => format!("{{{KEY}:{kbps}}}"), + _ => format!("{{{KEY}:{kbps},{fields}}}"), + } + } + }; + TrackEncoder { + encoder_type: self.encoder_type.clone(), + settings_json, + } + } +} + +/// The streamer's own stream settings from OBS (Settings > Output), what +/// OBS streams with when Enhanced Broadcasting is off. The main track +/// copies them, so the local config looks like EB off, and a streamer who +/// wants it different changes it where they always do: in OBS. +pub struct StreamerSettings { + /// Encoder id and its settings JSON (Advanced output). None in Simple + /// output, which keeps no settings file: only the bitrate is theirs. + pub encoder: Option, + pub bitrate_kbps: u32, + /// "Rescale Output" (Advanced output), when it is on. + pub rescale: Option<(u32, u32)>, +} + +/// 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). The main track copies `streamer`'s settings +/// when OBS's were readable; otherwise the budget is split and +/// `encoder_for(kbps)` supplies the chosen encoder at a given bitrate. A +/// second canvas always gets `encoder_for`'s encoder, so the caller passes +/// one only when a destination streams it. +pub fn build( + canvases: &[ObsCanvas], + bandwidth_kbps: u32, + streamer: Option<&StreamerSettings>, + ingest_port: u16, + encoder_for: impl Fn(u32) -> TrackEncoder, +) -> Option<(String, LocalPlan)> { + let canvas = *canvases.first()?; + let additional = canvases.get(1).copied(); + let (main, main_kbps) = match streamer { + Some(own) => { + let (width, height) = own.rescale.unwrap_or((canvas.width, canvas.height)); + let sized = ObsCanvas { + width, + height, + ..canvas + }; + (sized, own.bitrate_kbps) + } + None if additional.is_some() => (canvas, share(bandwidth_kbps, MAIN_TRACK_SHARE_PCT)), + None => (canvas, bandwidth_kbps.max(MIN_TRACK_KBPS)), + }; + let main_track = match streamer.and_then(|own| own.encoder.as_ref()) { + Some(encoder) => track_json(&main, 0, encoder), + None => track_json(&main, 0, &encoder_for(main_kbps)), + }; + let mut tracks = vec![main_track]; + if let Some(canvas) = additional { + let kbps = match streamer { + Some(_) => same_quality_kbps(main_kbps, &main, &canvas), + None => 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, + ) +} + +/// The bitrate that gives `other` the same bits per pixel per frame as +/// `main` gets at `main_kbps`: the vertical track looks as good as the +/// streamer's own. Never above `main_kbps`, never under `MIN_TRACK_KBPS`. +fn same_quality_kbps(main_kbps: u32, main: &ObsCanvas, other: &ObsCanvas) -> u32 { + let pixel_rate = |c: &ObsCanvas, den: u32| { + u128::from(c.width) * u128::from(c.height) * u128::from(c.fps_num) * u128::from(den) + }; + let kbps = u128::from(main_kbps) * pixel_rate(other, main.fps_den) + / pixel_rate(main, other.fps_den).max(1); + let ceiling = main_kbps.max(MIN_TRACK_KBPS); + u32::try_from(kbps) + .unwrap_or(ceiling) + .clamp(MIN_TRACK_KBPS, ceiling) +} + +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".into(), + 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, None, 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, None, 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), None, 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) + ); + assert_eq!( + requested_budget_kbps(r#"{"preferences":{"maximum_aggregate_bitrate":999999}}"#), + Some(50000), + "clamped to the 50000 ceiling" + ); + assert_eq!( + requested_budget_kbps(r#"{"preferences":{"maximum_aggregate_bitrate":0}}"#), + None, + "0 is not a cap" + ); + } + + fn canvas(width: u32, height: u32) -> ObsCanvas { + ObsCanvas { + width, + height, + fps_num: 60, + fps_den: 1, + } + } + + /// OBS refuses to start the stream on malformed JSON, and the tracks + /// together must never ask for more than the streamer's budget. Checked + /// at both clamps (at 1250 the 60 / 40 split meets the 500 kbps track + /// minimum exactly) and on odd canvases: portrait main, tiny, 4K, and + /// a third canvas that must not get a track. + #[test] + fn every_local_config_is_valid_json_within_its_budget() { + let landscape = canvas(1920, 1080); + let portrait = canvas(1080, 1920); + let canvas_sets: [&[ObsCanvas]; 6] = [ + &[landscape], + &[landscape, portrait], + &[portrait], + &[canvas(16, 16), canvas(2, 2)], + &[canvas(3840, 2160), canvas(2160, 3840)], + &[landscape, portrait, canvas(1280, 720)], + ]; + for canvases in canvas_sets { + for budget in [MIN_BUDGET_KBPS, MIN_BUDGET_KBPS + 1, 6001, MAX_BUDGET_KBPS] { + let (config, plan) = build(canvases, budget, None, 1935, x264).unwrap(); + let context = format!("{} at {budget} kbps", plan.describe()); + assert!(crate::config::is_valid_json(&config), "{context}: {config}"); + let bitrates: Vec = array_objects(&config, "encoder_configurations") + .iter() + .map(|track| number_field(track, "bitrate").unwrap()) + .collect(); + assert_eq!( + bitrates.len(), + canvases.len().min(2), + "{context}: one track per canvas, two at most" + ); + assert!( + bitrates.iter().sum::() <= budget, + "{context}: {bitrates:?} is over budget" + ); + assert!( + bitrates.iter().all(|kbps| *kbps >= MIN_TRACK_KBPS), + "{context}: {bitrates:?} starves a track" + ); + // What the ingest side and the controller read back from it. + assert_eq!( + names_additional_canvas(&config), + plan.additional.is_some(), + "{context}" + ); + assert_eq!( + vertical_track(&config).is_some(), + plan.feeds_vertical(), + "{context}" + ); + } + } + } + + /// The split rounds down, so for every budget OBS can ask for, the two + /// tracks never add up to more than it. + #[test] + fn the_track_split_never_exceeds_any_budget() { + for budget in MIN_BUDGET_KBPS..=MAX_BUDGET_KBPS { + let main = share(budget, MAIN_TRACK_SHARE_PCT); + let additional = share(budget, 100 - MAIN_TRACK_SHARE_PCT); + assert!( + main + additional <= budget, + "{main} + {additional} > {budget}" + ); + } + } + + #[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, None, 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, None, 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, None, 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, None, 1935, x264).is_none()); + } + + #[test] + fn the_vertical_track_is_found_in_any_codec() { + // Twitch's reply to a 2K channel on an AMD GPU, as a streamer's + // trace logged it: both tracks HEVC, the vertical one second. + let twitch = r#"{"encoder_configurations": [ + {"type": "h265_texture_amf", "width": 2560, "height": 1440, "canvas_index": 0, + "framerate": {"numerator": 60, "denominator": 1}, "settings": {"bitrate": 9000}}, + {"type": "h265_texture_amf", "width": 1080, "height": 1920, "canvas_index": 1, + "framerate": {"numerator": 60, "denominator": 1}, "settings": {"bitrate": 5000}} + ]}"#; + assert_eq!(vertical_track(twitch), Some(1)); + } + + #[test] + fn a_config_without_a_portrait_track_has_no_vertical_track() { + let landscape_ladder = r#"{"encoder_configurations": [ + {"width": 1920, "height": 1080}, {"width": 1280, "height": 720}]}"#; + assert_eq!(vertical_track(landscape_ladder), None); + assert_eq!(vertical_track(r#"{"ingest_endpoints": []}"#), None); + } + + #[test] + fn the_largest_portrait_track_is_the_vertical_one() { + let two_portrait_rungs = r#"{"encoder_configurations": [ + {"width": 1920, "height": 1080}, + {"width": 540, "height": 960}, + {"width": 1080, "height": 1920}]}"#; + assert_eq!(vertical_track(two_portrait_rungs), Some(2)); + } + + const OWN_AMD: &str = + r#"{"bitrate":6000,"rate_control":"CBR","preset":"quality","profile":"high","bf":2}"#; + + fn own_amd(rescale: Option<(u32, u32)>) -> StreamerSettings { + StreamerSettings { + encoder: Some(TrackEncoder { + encoder_type: "h264_texture_amf".into(), + settings_json: OWN_AMD.into(), + }), + bitrate_kbps: 6000, + rescale, + } + } + + fn canvas_at(width: u32, height: u32, fps: u32) -> ObsCanvas { + ObsCanvas { + width, + height, + fps_num: fps, + fps_den: 1, + } + } + + /// With the streamer's settings readable, the main track is exactly + /// their EB-off stream: their encoder, their settings untouched, their + /// Rescale Output size, whatever the budget says. + #[test] + fn the_main_track_streams_with_the_streamers_own_settings() { + let main_only = [canvas_at(2560, 1440, 60)]; + let own = own_amd(Some((1920, 1080))); + let (config, plan) = build(&main_only, 10_000, Some(&own), 1935, x264).unwrap(); + assert!(crate::config::is_valid_json(&config), "{config}"); + let tracks = array_objects(&config, "encoder_configurations"); + assert_eq!(tracks.len(), 1); + assert!( + tracks[0].contains(r#""type":"h264_texture_amf""#), + "{config}" + ); + assert!( + tracks[0].contains(&format!(r#""settings":{OWN_AMD}"#)), + "{config}" + ); + assert_eq!(number_field(tracks[0], "width"), Some(1920)); + assert_eq!(number_field(tracks[0], "height"), Some(1080)); + assert_eq!(number_field(tracks[0], "numerator"), Some(60)); + assert_eq!((plan.main.width, plan.main.height), (1920, 1080)); + } + + /// Simple output leaves only the bitrate to copy: the main track takes + /// it on InstantClone's own encoder, not the default budget. + #[test] + fn simple_output_gives_the_main_track_its_bitrate() { + let own = StreamerSettings { + encoder: None, + bitrate_kbps: 4500, + rescale: None, + }; + let (config, _) = + build(&[canvas_at(1920, 1080, 60)], 10_000, Some(&own), 1935, x264).unwrap(); + let tracks = array_objects(&config, "encoder_configurations"); + assert_eq!(number_field(tracks[0], "bitrate"), Some(4500), "{config}"); + } + + /// A vertical track gets the main track's quality: the same bits per + /// pixel per frame, on InstantClone's H.264 encoder. + #[test] + fn the_vertical_track_matches_the_main_tracks_quality() { + let own = own_amd(None); + let canvases = [canvas_at(2560, 1440, 60), canvas_at(1080, 1920, 60)]; + let (config, plan) = build(&canvases, 10_000, Some(&own), 1935, x264).unwrap(); + assert!(crate::config::is_valid_json(&config), "{config}"); + assert!(plan.feeds_vertical()); + let tracks = array_objects(&config, "encoder_configurations"); + assert_eq!(tracks.len(), 2); + assert!(tracks[1].contains(r#""type":"obs_x264""#), "{config}"); + // 6000 kbps * (1080 * 1920) / (2560 * 1440) + assert_eq!(number_field(tracks[1], "bitrate"), Some(3375)); + } + + fn encoder(id: &str, settings: &str) -> TrackEncoder { + TrackEncoder { + encoder_type: id.into(), + settings_json: settings.into(), + } + } + + /// libobs encoder ids across OBS 30-32 and the three GPU vendors. + #[test] + fn the_codec_is_read_from_the_encoder_id() { + use crate::h264::VideoCodec::{Av1, Avc, Hevc, Unknown}; + for (id, codec) in [ + ("obs_x264", Avc), + ("jim_nvenc", Avc), + ("obs_nvenc_h264_tex", Avc), + ("h264_texture_amf", Avc), + ("obs_qsv11_v2", Avc), + ("com.apple.videotoolbox.videoencoder.ave.avc", Avc), + ("jim_hevc_nvenc", Hevc), + ("obs_nvenc_hevc_tex", Hevc), + ("h265_texture_amf", Hevc), + ("obs_qsv11_hevc", Hevc), + ("av1_texture_amf", Av1), + ("obs_nvenc_av1_tex", Av1), + ("ffmpeg_svt_av1", Av1), + ("some_future_encoder", Unknown), + ] { + assert_eq!(encoder(id, "{}").codec(), codec, "{id}"); + } + } + + /// Only the bitrate changes: every other setting the streamer chose + /// stays, including a `max_bitrate` that merely ends in "bitrate". + #[test] + fn at_bitrate_changes_only_the_bitrate() { + let own = encoder("h265_texture_amf", OWN_AMD); + let vertical = own.at_bitrate(3375); + assert_eq!(vertical.encoder_type, "h265_texture_amf"); + assert_eq!( + vertical.settings_json, + r#"{"bitrate":3375,"rate_control":"CBR","preset":"quality","profile":"high","bf":2}"# + ); + let spaced = encoder("x", r#"{"max_bitrate": 9000, "bitrate" : 6000 }"#); + assert_eq!( + spaced.at_bitrate(1000).settings_json, + r#"{"max_bitrate": 9000, "bitrate":1000 }"# + ); + assert_eq!( + encoder("x", "{}").at_bitrate(800).settings_json, + r#"{"bitrate":800}"# + ); + let no_bitrate = encoder("x", r#"{"preset":"p5"}"#).at_bitrate(800); + assert_eq!(no_bitrate.settings_json, r#"{"bitrate":800,"preset":"p5"}"#); + assert!(crate::config::is_valid_json(&no_bitrate.settings_json)); + } + + #[test] + fn same_quality_bitrate_follows_pixels_and_frame_rate_within_bounds() { + let main = canvas_at(2560, 1440, 60); + let half_rate = ObsCanvas { + fps_den: 2, + ..canvas_at(1080, 1920, 60) + }; + assert_eq!( + same_quality_kbps(6000, &main, &canvas_at(1080, 1920, 30)), + 1687 + ); + assert_eq!(same_quality_kbps(6000, &main, &half_rate), 1687); + assert_eq!( + same_quality_kbps(6000, &main, &canvas_at(90, 160, 30)), + MIN_TRACK_KBPS + ); + assert_eq!( + same_quality_kbps(6000, &main, &canvas_at(3840, 2160, 60)), + 6000, + "never above the main track" + ); + assert_eq!(same_quality_kbps(300, &main, &main), MIN_TRACK_KBPS); + } +} diff --git a/src/main.rs b/src/main.rs index 3d56cb1..d95a077 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,9 +30,14 @@ mod buffer; mod compat; mod config; mod controller; +mod crash_hold; +mod crash_protection; mod crypto; mod h264; mod https; +mod integrations; +mod json; +mod local_eb_config; mod midi; mod obs_register; mod portcheck; @@ -40,6 +45,7 @@ mod rtmp; mod self_update; mod sha256; mod sink; +mod slate; mod sync; mod sysstat; mod trace; @@ -52,7 +58,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<()> { @@ -126,16 +132,33 @@ fn main() -> std::io::Result<()> { // good capture. Opt-out via INSTANTCLONE_NO_TRACE=1. trace::init("./instantclone-trace.log"); - let mut settings = Settings::load_or_default(&cfg_path); + // A missing file is a fresh install (defaults, written out at once). A + // file that exists but can't be read is never replaced at startup: see + // `Settings::load_for_startup` for the copy-aside rule. + let startup = match Settings::load_for_startup(&cfg_path) { + Ok(startup) => startup, + Err(e) => { + let msg = format!( + "InstantClone couldn't read its settings file, and couldn't make a \ + safety copy of it either, so it did not start (starting on defaults \ + could overwrite your settings).\n\nFile: {}\nError: {e}\n\n\ + Close whatever is holding the file (antivirus, OneDrive, an editor) \ + and start InstantClone again.", + cfg_path.display() + ); + notify_fatal("InstantClone settings error", &msg); + return Err(e); + } + }; + if let Some(notice) = &startup.notice { + notify_fatal("InstantClone settings reset", notice); + } + let may_save_at_startup = startup.may_save; + let mut settings = startup.settings; // Honour the persisted tracing toggle from disk. init() defaults to // enabled; if the user disabled it last session, flip it off before // any code path starts writing. trace::set_enabled(settings.tracing_enabled); - // If the file didn't exist, persist the smart defaults so the file - // appears on disk immediately (useful for the user to find and edit). - if !cfg_path.exists() { - let _ = settings.save(&cfg_path); - } // Port pre-flight. Without this, a busy port leaves us in a silent // retry loop - the worst possible first-run UX (no console on Windows, @@ -176,8 +199,10 @@ fn main() -> std::io::Result<()> { } } // Persist any chosen replacement port so the user doesn't get - // re-prompted on every launch. - let _ = settings.save(&cfg_path); + // re-prompted on every launch. Not over a file we couldn't read. + if may_save_at_startup { + let _ = settings.save(&cfg_path); + } } // Create overlays/ directory and write the three built-in templates if @@ -245,6 +270,18 @@ fn main() -> std::io::Result<()> { let (tx, rx) = watch::channel(settings.clone()); let tx = Arc::new(tx); + // Integrations run on their own thread, so nothing they do can + // delay the stream. Their data file sits next to the config. + let data_dir = cfg_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + match integrations::engine::start(ctrl.clone(), rx.clone(), data_dir) { + Ok(handle) => ctrl.attach_integrations(handle), + Err(e) => ctrl.log(format!("[integrations] could not start: {e}")), + } + // An update can leave the OBS entry we wrote pointing at the old shape // of things, and on an in-app update the user never saw the dashboard // button change back to ask them about it. Repair it for them. @@ -305,7 +342,7 @@ fn main() -> std::io::Result<()> { // tray icon stays running in the background - closing the tab // doesn't kill the proxy. `--no-browser` skips this for autostart // / headless setups; the persisted `open_dashboard_on_launch` - // toggle (System -> Behavior) does the same from the UI, so a + // toggle (System -> General) does the same from the UI, so a // tray-resident user isn't forced into a tab every launch. if !suppress_browser && settings.open_dashboard_on_launch { let url = format!("http://127.0.0.1:{}/", settings.web_port); @@ -329,11 +366,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 @@ -371,16 +425,21 @@ fn main() -> std::io::Result<()> { // The MIDI half of this is (bindings, chosen device): both live // in the listener's mirror, and a device change with the same // bindings still has to reach it or the pick is inert. - let (mut bound_hotkeys, mut bound_midi) = { - let s = hk_rx.borrow(); - (s.hotkeys.clone(), (s.midi.clone(), s.midi_device.clone())) + // Integrations started from a hotkey register through the tray + // too, so their combos count as hotkey bindings here. + let bindings = |s: &config::Settings| { + ( + ( + s.hotkeys.clone(), + integrations::model::shortcuts(&s.integrations).0, + ), + (s.midi.clone(), s.midi_device.clone()), + ) }; + let (mut bound_hotkeys, mut bound_midi) = bindings(&hk_rx.borrow()); tokio::spawn(async move { while hk_rx.changed().await.is_ok() { - let (hotkeys, midi) = { - let s = hk_rx.borrow(); - (s.hotkeys.clone(), (s.midi.clone(), s.midi_device.clone())) - }; + let (hotkeys, midi) = bindings(&hk_rx.borrow()); if midi != bound_midi { hk_ctrl.midi().update_from_settings(&hk_rx.borrow()); bound_midi = midi; @@ -453,6 +512,11 @@ fn main() -> std::io::Result<()> { // Flush the egress trace so the last few thousand events make // it to disk before the BufWriter is dropped on process exit. trace::flush(); + // Integration run counts (`{uses}`) are saved a few seconds late; + // write the latest now or a restart replays numbers. + if let Some(integrations) = ctrl.integrations() { + integrations.flush(); + } // Flip shutdown on every active destination so each pump sends // `deleteStream` to its upstream before the runtime drops them. // Tiny window - if a pump is mid-await it'll just exit on next @@ -583,20 +647,62 @@ 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. async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc) { - // Currently-running egress pumps, indexed by Destination.id. + // Currently-running egress pumps, indexed by Destination.id, with the + // signature each started with (see `pump_signature`). let mut running: std::collections::HashMap< String, (String, tokio::task::JoinHandle>), > = 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 webhook URL + ingest key into the controller on every settings - // change (the ingest task enforces the key but has no settings handle). - let initial_webhook = { rx.borrow().discord_webhook_url.clone() }; - ctrl.update_webhook(initial_webhook); + // Mirror the ingest key into the controller on every settings change + // (the ingest task enforces the key but has no settings handle). ctrl.update_ingest_key(rx.borrow().ingest_key.clone()); // Managed "Local test sink" child process. Spawned while any @@ -623,6 +729,10 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc = { rx.borrow().active_destinations() }; + // Crash protection reads its settings from the controller, which + // decides whether to open a hold the instant OBS drops. + let crash_settings = rx.borrow().crash_protection.clone(); + ctrl.update_crash_protection(crash_settings.clone()); // Keep the managed test-sink child in sync with the desired set // BEFORE the pump diff below, so a freshly enabled sink @@ -640,21 +750,8 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc, 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 + // to know whether a vertical canvas is coming. Without this + // the destination sits silent for the whole stream. + let video_known = !ctrl.ring.video_seq_headers.lock().is_empty(); + if ingest_alive + && video_known + && vertical_wait.missing(&dest.id, ctrl.publisher_token(), Instant::now()) + { ctrl.log(format!( - "[{}] vertical: no Dual Format canvas yet - holding off connecting", + "[{}] vertical: OBS isn't sending a 9:16 canvas, so nothing goes out. \ + In OBS: Settings → Stream → Enhanced Broadcasting on, then pick your \ + vertical canvas under Additional canvas. Or set this destination's \ + Stream format to Horizontal.", dest.name )); } continue; } + vertical_gone_since.remove(&dest.id); // Enhanced Broadcasting override: when the // /obs/multitrack-config proxy gets back a real // session-allocated IVS URL from Twitch's API, it stashes @@ -786,14 +907,21 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc existing_url != url || handle.is_finished(), + Some((running_signature, handle)) => { + (running_signature != &signature && settled) || handle.is_finished() + } None => true, }; if needs_restart { - if let Some((_old_url, handle)) = running.remove(&dest.id) { - handle.abort(); - let _ = handle.await; + if let Some((_old_signature, handle)) = running.remove(&dest.id) { + stop_pump(&ctrl, &dest.id, handle).await; } // Don't open a fresh egress while OBS isn't sending - // we'd either burn TCP to Twitch / YouTube for an empty @@ -801,7 +929,10 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc, ctrl: Arc, ctrl: Arc { if ch.is_err() { return; } - // Mirror webhook URL + ingest key on every settings change. - let new_webhook = { rx.borrow().discord_webhook_url.clone() }; - ctrl.update_webhook(new_webhook); + // Mirror the ingest key on every settings change. ctrl.update_ingest_key(rx.borrow().ingest_key.clone()); } _ = periodic_wake => { @@ -982,6 +1121,41 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc String { + format!("{url}\n{stream_format}\n{audio_track}") +} + +/// Stop a destination's pump: ask it to close its session cleanly (the +/// platform gets its goodbye instead of a dropped connection), give it 1.5 s, +/// then abort whatever is left - a pump stuck in a connect loop would +/// otherwise run on detached. A pump cut off mid-send never clears its own +/// state, so it is cleared here: it no longer reads as live, and its read +/// position no longer holds back the buffer's trim. +async fn stop_pump( + ctrl: &controller::Controller, + id: &str, + handle: tokio::task::JoinHandle>, +) { + let state = ctrl.destination_state(id); + state + .shutdown_requested + .store(true, std::sync::atomic::Ordering::Relaxed); + let abort = handle.abort_handle(); + let _ = tokio::time::timeout(Duration::from_millis(1500), handle).await; + abort.abort(); + state + .egress_alive + .store(false, std::sync::atomic::Ordering::Relaxed); + state + .consumer_seq + .store(u64::MAX, std::sync::atomic::Ordering::Relaxed); +} + async fn manage_test_sink( child: &mut Option, last_spawn: &mut Option, @@ -1155,7 +1329,8 @@ fn resolve_port_conflict(label: &str, port: u16, host: &str) -> Option { } } -/// Surface a fatal, pre-runtime error on every platform. The Windows release +/// Surface a pre-runtime problem the user must see (fatal errors, and the +/// settings-reset notice) on every platform. The Windows release /// build has no console, and a Linux desktop launched from a `.desktop` entry /// has none either, so a bare stderr line would vanish. Always print, then add /// the platform's native surface on top. @@ -1374,6 +1549,32 @@ mod tests { use super::anchor_dir; use std::path::{Path, PathBuf}; + #[test] + fn vertical_wait_logs_once_per_session_after_the_grace() { + let mut log = super::VerticalWaitLog::default(); + let start = std::time::Instant::now(); + let later = start + super::VERTICAL_WAIT_GRACE; + assert!( + !log.missing("tiktok", 1, start), + "first look only starts the clock" + ); + assert!(!log.missing("tiktok", 1, start + std::time::Duration::from_millis(10))); + assert!( + log.missing("tiktok", 1, later), + "logs once the grace has passed" + ); + assert!( + !log.missing("tiktok", 1, later), + "and only once per session" + ); + assert!( + !log.missing("tiktok", 2, later), + "a new OBS session starts over" + ); + log.retain(|id| id != "tiktok"); + assert!(log.seen.is_empty(), "deleted destinations are forgotten"); + } + #[test] fn anchor_dir_picks_the_folder_holding_the_exe() { let exe = PathBuf::from("C:/Users/me/Desktop/InstantClone/instantclone.exe"); @@ -1462,4 +1663,22 @@ mod tests { None ); } + + /// A running pump restarts when anything it only reads at start + /// changes: the URL, the stream format, or the audio track. + #[test] + fn a_pump_restarts_for_its_url_format_or_audio_track() { + let base = super::pump_signature("rtmp://a/app/key", "horizontal", "auto"); + assert_eq!( + base, + super::pump_signature("rtmp://a/app/key", "horizontal", "auto") + ); + for changed in [ + super::pump_signature("rtmp://b/app/key", "horizontal", "auto"), + super::pump_signature("rtmp://a/app/key", "vertical", "auto"), + super::pump_signature("rtmp://a/app/key", "horizontal", "2"), + ] { + assert_ne!(base, changed); + } + } } diff --git a/src/midi.rs b/src/midi.rs index 63d88f4..09a10d8 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -12,6 +12,8 @@ //! `available` stays false and the dashboard hides the section - exactly //! like the keyboard hotkeys on Linux. +#[cfg(any(windows, test))] +use std::collections::HashSet; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -31,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 { @@ -135,11 +140,18 @@ impl MidiState { None => None, } } - /// Take a captured (action, signature) if one is waiting and still - /// inside its learn window. The web layer persists it to config, so it - /// is consumed exactly once. - pub fn take_captured(&self) -> Option<(String, String)> { + /// Take a captured (action, signature) if one is waiting, still inside + /// its learn window, and learned for one of `actions`. The delay + /// actions' poll and the integrations' poll each take only their own, + /// so neither can swallow the other's press. Consumed exactly once. + pub fn take_captured_for(&self, actions: &[&str]) -> Option<(String, String)> { let mut captured = self.captured.lock(); + if !captured + .as_ref() + .is_some_and(|(action, _, _)| actions.contains(&action.as_str())) + { + return None; + } match captured.take() { Some((action, signature, deadline)) if Instant::now() < deadline => { Some((action, signature)) @@ -148,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 @@ -173,6 +206,9 @@ impl MidiState { if let Some(problem) = ctrl.run_named_action(action, default_ms, "midi") { crate::tray::notify_problem(&problem); } + } else if let Some(integrations) = ctrl.integrations() { + // Not a delay action's pad: maybe an integration's shortcut. + integrations.shortcut(crate::integrations::Shortcut::Midi, signature); } } @@ -180,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(), @@ -190,16 +228,17 @@ impl MidiState { let bindings = { let b = self.bindings.lock(); format!( - r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca}}}"#, + r#"{{"toggle":{t},"arm":{a},"activate":{ac},"cut":{c},"cut_after":{ca},"end_hold":{eh}}}"#, t = json_string(&b.toggle), a = json_string(&b.arm), ac = json_string(&b.activate), c = json_string(&b.cut), ca = json_string(&b.cut_after), + eh = json_string(&b.end_hold), ) }; 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, @@ -207,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), ) } } @@ -245,6 +285,27 @@ fn signature_for(status: u8, data1: u8, data2: u8) -> Option { } } +/// Whether a control-change message is a press: its value just crossed up +/// past the middle. A knob or fader sends a stream of values as it turns; +/// without this, one turn would be dozens of presses. `high` remembers the +/// controls (by device, status and number) currently up. +#[cfg(any(windows, test))] +fn cc_press_edge( + high: &mut HashSet<(usize, u8, u8)>, + device: usize, + status: u8, + cc: u8, + value: u8, +) -> bool { + let key = (device, status, cc); + if value >= 64 { + high.insert(key) + } else { + high.remove(&key); + false + } +} + /// Spawn the MIDI listener. Windows opens every input device via winmm; /// other platforms have no backend yet, so this is a no-op and `available` /// stays false (the dashboard then hides the MIDI section). The settings @@ -270,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 @@ -281,15 +350,24 @@ pub fn spawn( /// Built everywhere so its rule is tested on every CI target; only Windows /// has a listener to act on it. #[cfg_attr(not(windows), allow(dead_code))] -fn should_hold_devices(learning: bool, midi: &crate::config::MidiBindings) -> bool { - learning || midi.entries().iter().any(|(_, sig)| !sig.is_empty()) +fn should_hold_devices(learning: bool, settings: &Settings) -> bool { + learning + || settings + .midi + .entries() + .iter() + .any(|(_, sig)| !sig.is_empty()) + || !crate::integrations::model::shortcuts(&settings.integrations) + .1 + .is_empty() } #[cfg(windows)] mod win { - use super::{signature_for, MidiState}; + use super::{cc_press_edge, signature_for, MidiState}; use crate::config::Settings; use crate::controller::Controller; + use std::collections::HashSet; use std::sync::Arc; use tokio::sync::watch; use windows_sys::Win32::Media::Audio::{ @@ -312,6 +390,8 @@ mod win { /// it came from. winmm hands the handle to every callback; without /// this map two controllers sending the same note are one control. by_handle: crate::sync::Mutex>, + /// Control-changes currently up; see `cc_press_edge`. + cc_high: crate::sync::Mutex>, } pub fn run(ctrl: Arc, state: Arc, settings: watch::Receiver) { @@ -320,12 +400,13 @@ mod win { state, settings, by_handle: crate::sync::Mutex::new(Vec::new()), + cc_high: crate::sync::Mutex::new(HashSet::new()), })); let instance = ctx as *const CallbackCtx as usize; 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 @@ -336,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() }; @@ -366,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); @@ -386,7 +464,27 @@ mod win { // Scoped: the borrow parks the settings watch, and opening devices // below is slow enough to matter. let settings = ctx.settings.borrow(); - super::should_hold_devices(learning, &settings.midi) + 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 @@ -410,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; @@ -499,22 +596,35 @@ mod win { let status = (param1 & 0xFF) as u8; let data1 = ((param1 >> 8) & 0x7F) as u8; let data2 = ((param1 >> 16) & 0x7F) as u8; + let ctx = &*(instance as *const CallbackCtx); + if status & 0xF0 == 0xB0 + && !cc_press_edge(&mut ctx.cc_high.lock(), hmi as usize, status, data1, data2) + { + return; + } let Some(sig) = signature_for(status, data1, data2) else { return; }; - let ctx = &*(instance as *const CallbackCtx); // 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; @@ -526,6 +636,7 @@ mod win { mod tests { use super::should_hold_devices; use super::signature_for; + use std::collections::HashSet; // `MidiState` itself is cross-platform - only the listener that feeds it // is Windows-only - so the device-choice test below runs everywhere. use super::MidiState; @@ -552,6 +663,43 @@ 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(); + let presses = (40..=127u8) + .filter(|v| super::cc_press_edge(&mut high, 1, 0xB0, 20, *v)) + .count(); + assert_eq!(presses, 1, "one turn up is one press"); + assert!(!super::cc_press_edge(&mut high, 1, 0xB0, 20, 0), "release"); + assert!( + super::cc_press_edge(&mut high, 1, 0xB0, 20, 127), + "a new press" + ); + assert!( + super::cc_press_edge(&mut high, 2, 0xB0, 20, 127), + "another deck's control" + ); + } + // Build a real Controller backed by a temp ring, so the dispatch below // exercises the same code the live app runs. #[cfg(windows)] @@ -604,11 +752,18 @@ mod tests { "a mapped MIDI note must arm the delay at the default" ); - // A note that is NOT mapped must do nothing. + // A note that is NOT mapped must do nothing. Every dispatched + // action stamps `last_action` with a fresh seq, even one that + // changes no state, so an unchanged seq proves nothing fired. + let seq_before = ctrl.last_action().map(|a| a.seq); + assert!(seq_before.is_some(), "the mapped note must have stamped"); let other = signature_for(0x90, 40, 100).expect("a press edge"); - ctrl.stop_delay(); state.on_signature(&ctrl, 15_000, &other); - // (arm stays as-is; the unmapped note fired no action.) + assert_eq!( + ctrl.last_action().map(|a| a.seq), + seq_before, + "an unmapped note must not fire any action" + ); let _ = std::fs::remove_file(&path); } @@ -670,12 +825,12 @@ mod tests { #[test] fn a_fresh_install_can_still_record_its_first_binding() { let state = MidiState::new(); - let midi = crate::config::MidiBindings::default(); + let fresh = crate::config::Settings::defaults(); // Fresh install: a controller is plugged in, nothing is bound yet. state.set_devices(vec!["Launchpad MK2".to_string()], Vec::new()); assert!( - !should_hold_devices(false, &midi), + !should_hold_devices(false, &fresh), "nothing bound: the controller stays free for other software" ); assert!(!state.available(), "so nothing is open"); @@ -687,7 +842,7 @@ mod tests { // Arming a learn is what asks for the device. state.start_learn("arm"); assert!( - should_hold_devices(state.learning().is_some(), &midi), + should_hold_devices(state.learning().is_some(), &fresh), "an armed learn opens the device so the press can be heard" ); @@ -711,30 +866,72 @@ mod tests { /// nothing until there is a reason to. #[test] fn no_bindings_means_we_leave_every_midi_device_alone() { - let mut midi = crate::config::MidiBindings::default(); + let mut s = crate::config::Settings::defaults(); assert!( - !should_hold_devices(false, &midi), + !should_hold_devices(false, &s), "nothing bound and not learning: hold nothing" ); assert!( - should_hold_devices(true, &midi), + should_hold_devices(true, &s), "a learn needs a device open to hear the press" ); - midi.set("arm", "note:1:36"); + s.midi.set("arm", "note:1:36"); assert!( - should_hold_devices(false, &midi), + should_hold_devices(false, &s), "one binding is reason enough" ); // Clearing the last binding hands the controller back. - midi.set("arm", ""); + s.midi.set("arm", ""); assert!( - !should_hold_devices(false, &midi), + !should_hold_devices(false, &s), "the last binding going away releases the device" ); } + /// An integration started from a pad is a binding too. + #[test] + fn an_integration_pad_holds_the_device() { + use crate::integrations::model::{Handler, Step, StepKind, Trigger}; + let mut s = crate::config::Settings::defaults(); + let mut i = crate::integrations::presets::build("delay_command", "p".into(), "").unwrap(); + i.handlers = vec![Handler { + enabled: true, + trigger: Trigger::Shortcut { + hotkey: String::new(), + midi: "note:1:40".into(), + token: String::new(), + only_live: false, + }, + steps: vec![Step::new(StepKind::Clip, &[])], + }]; + s.integrations.push(i); + assert!(should_hold_devices(false, &s)); + s.integrations[0].enabled = false; + assert!( + !should_hold_devices(false, &s), + "a switched-off one doesn't" + ); + } + + /// The Controls tab's poll and the Integrations tab's poll each take + /// only their own capture. + #[test] + fn each_learn_is_collected_by_its_own_poll() { + let state = MidiState::new(); + *state.captured.lock() = Some(( + "integration".into(), + "note:1:36@Pad".into(), + std::time::Instant::now() + super::LEARN_WINDOW, + )); + assert_eq!(state.take_captured_for(&crate::config::ACTIONS), None); + assert_eq!( + state.take_captured_for(&["integration"]), + Some(("integration".into(), "note:1:36@Pad".into())) + ); + } + /// The whole point of naming the device: two decks that both send pad 1 /// as note 36 on channel 1 stay two decks, and can drive different /// actions. This is the matching half - `canonicalize_midi` covers the @@ -785,7 +982,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}"); @@ -816,7 +1013,11 @@ mod tests { 15_000, "the press must run its bound action" ); - assert_eq!(state.take_captured(), None, "and must not be captured"); + assert_eq!( + state.take_captured_for(&crate::config::ACTIONS), + None, + "and must not be captured" + ); let _ = std::fs::remove_file(&path); } @@ -834,7 +1035,7 @@ mod tests { state.on_signature(&ctrl, 15_000, &sig); state.cancel_learn(); - assert_eq!(state.take_captured(), None); + assert_eq!(state.take_captured_for(&crate::config::ACTIONS), None); let _ = std::fs::remove_file(&path); } @@ -845,23 +1046,31 @@ mod tests { #[test] fn learn_captures_the_note_without_firing() { let (ctrl, path) = test_controller(); + // A live publisher and a control already bound to "arm", so the + // press WOULD visibly arm the delay if learn mode let it through. + // Without both, "nothing fired" would hold even with learn broken. + ctrl.mark_ingest_alive_for_test(); let state = MidiState::new(); + let mut s = crate::config::Settings::defaults(); + s.midi.set("arm", "cc:1:20"); + state.update_from_settings(&s); - state.start_learn("toggle"); + state.start_learn("arm"); let sig = signature_for(0xB0, 20, 127).expect("cc press"); state.on_signature(&ctrl, 15_000, &sig); assert_eq!( - state.take_captured(), - Some(("toggle".to_string(), "cc:1:20".to_string())), + state.take_captured_for(&crate::config::ACTIONS), + Some(("arm".to_string(), "cc:1:20".to_string())), "learn must capture the pressed control" ); assert_eq!(state.learning(), None, "learn clears after one capture"); assert_eq!( - ctrl.target_delay_ms(), + ctrl.armed_delay_ms(), 0, "capturing must not also fire the action" ); + assert!(ctrl.last_action().is_none(), "no action may be stamped"); let _ = std::fs::remove_file(&path); } diff --git a/src/obs_register.rs b/src/obs_register.rs index a38b7c5..1bc913a 100644 --- a/src/obs_register.rs +++ b/src/obs_register.rs @@ -24,13 +24,13 @@ //! * macOS: not handled yet (no candidates). use std::fs; -use std::io; +use std::io::{self, Read}; 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"] @@ -283,26 +283,38 @@ pub fn register(web_port: u16, ingest_port: u16) -> io::Result<()> { ) })?; let original = fs::read_to_string(&path)?; - looks_like_services_json(&original)?; + let patched = with_entry_registered(&original, web_port, ingest_port)?; + let bak = path.with_extension("json.instantclone.bak"); + write_or_friendly(&bak, &original)?; + write_or_friendly(&path, &patched)?; + Ok(()) +} + +/// `register`'s whole edit of services.json, without the disk. Pure so the +/// properties that matter (idempotent, everything else byte-for-byte +/// untouched, never a second entry) are unit-testable. +fn with_entry_registered(original: &str, web_port: u16, ingest_port: u16) -> io::Result { + looks_like_services_json(original)?; // If our entry is already there, strip it first so a port-changed - // re-register actually refreshes the URL. remove_entry's parse is - // forgiving - falling back to the raw file keeps register() a - // no-fail path when the entry isn't structurally cleanly bounded. - let base = if entry_exists(&original) { - remove_entry(&original).unwrap_or_else(|| original.clone()) + // re-register actually refreshes the URL. When it cannot be cut out + // cleanly, refuse: inserting anyway would leave OBS two entries. + let base = if entry_exists(original) { + remove_entry(original).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "couldn't cleanly replace the existing InstantClone entry in services.json - \ + restore it from .instantclone.bak or let OBS regenerate it", + ) + })? } else { - original.clone() + original.to_string() }; - let patched = insert_entry(&base, &entry_json(web_port, ingest_port)).ok_or_else(|| { + insert_entry(&base, &entry_json(web_port, ingest_port)).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "couldn't locate the `\"services\":[` array in services.json - file shape unexpected", ) - })?; - let bak = path.with_extension("json.instantclone.bak"); - write_or_friendly(&bak, &original)?; - write_or_friendly(&path, &patched)?; - Ok(()) + }) } /// Remove our entry from services.json. Returns Ok(()) even when not @@ -394,8 +406,13 @@ fn insert_entry(file: &str, entry: &str) -> Option { let absolute_bracket = key_pos + key.len() + bracket_offset; let (head, tail) = file.split_at(absolute_bracket + 1); // Insert our entry followed by a comma so the existing first - // service stays valid JSON. - Some(format!("{head}\n{entry},{tail}")) + // service stays valid JSON; an empty list gets no comma. + let separator = if tail.trim_start().starts_with(']') { + "" + } else { + "," + }; + Some(format!("{head}\n{entry}{separator}{tail}")) } /// Byte range of our service object in `file`, braces included. @@ -469,6 +486,12 @@ fn remove_entry(file: &str) -> Option { } if probe < bytes.len() && bytes[probe] == b',' { right = probe + 1; + // Take the whitespace before the entry too. The whitespace after + // the comma stays for the next element, so leaving this run as + // well would add a blank line on every re-register. + while left > 0 && bytes[left - 1].is_ascii_whitespace() { + left -= 1; + } } else { // No trailing comma - try eating a leading one instead so the // remaining array doesn't end with a stray `,]`. @@ -705,7 +728,10 @@ fn ini_set(file: &str, section: &str, key: &str, enable: bool) -> String { out.push('\n'); continue; } - if in_target_section && !wrote_key { + // Every occurrence, not just the first: a file with duplicate + // target sections would otherwise keep a stale value in the later + // block (disable would leave a second `=true` behind). + if in_target_section { if let Some((k, _)) = trimmed.split_once('=') { if k.trim() == key { out.push_str(key); @@ -857,6 +883,106 @@ pub fn active_profile_service_json_path() -> Option { .find(|p| p.exists()) } +/// The active profile's stream settings (Settings > Output > Streaming): +/// 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 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` +/// (OBS derives the rest), so there only the bitrate is taken. +fn stream_settings_from( + basic_ini: &str, + encoder_json: Option<&str>, +) -> Option { + use crate::local_eb_config::{number_field, StreamerSettings, TrackEncoder}; + let positive = |kbps: u32| (kbps > 0).then_some(kbps); + if ini_get(basic_ini, "Output", "Mode") != Some("Advanced") { + let kbps = ini_get(basic_ini, "SimpleOutput", "VBitrate")?.parse().ok(); + return Some(StreamerSettings { + encoder: None, + bitrate_kbps: kbps.and_then(positive)?, + rescale: None, + }); + } + let id = ini_get(basic_ini, "AdvOut", "Encoder").filter(|id| !id.is_empty())?; + // Handed back to OBS as the track's settings, and OBS refuses to start a + // stream on a malformed config: a broken hand edit keeps the defaults. + let json = encoder_json?.trim_start_matches('\u{feff}').trim(); + if !(json.starts_with('{') && crate::config::is_valid_json(json)) { + return None; + } + let rescale = match ini_get(basic_ini, "AdvOut", "Rescale") { + Some("true") => ini_get(basic_ini, "AdvOut", "RescaleRes").and_then(parse_size), + _ => None, + }; + Some(StreamerSettings { + bitrate_kbps: number_field(json, "bitrate").and_then(positive)?, + encoder: Some(TrackEncoder { + encoder_type: id.to_string(), + settings_json: json.to_string(), + }), + rescale, + }) +} + +/// "1920x1080" as (width, height), both non-zero. +fn parse_size(value: &str) -> Option<(u32, u32)> { + let (width, height) = value.split_once('x')?; + let size = (width.trim().parse().ok()?, height.trim().parse().ok()?); + (size.0 > 0 && size.1 > 0).then_some(size) +} + /// True when our `multitrack_video_configuration_url` is currently /// injected into the active profile's service.json. Used by the UI /// status indicator so a manual edit / profile switch is reflected. @@ -990,6 +1116,61 @@ fn first_version_triple(s: &str) -> 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 +1187,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,15 +1557,20 @@ mod tests { use super::*; use std::sync::atomic::{AtomicU32 as TestUniq, Ordering as TestOrd}; - // 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. #[test] - fn the_registered_server_url_is_resolvable_under_either_ip_family() { - assert_eq!(server_url(1935), "rtmp://localhost:1935/live"); - let entry = entry_json(7799, 1935); - assert!(entry.contains("rtmp://localhost:1935/live"), "{entry}"); - assert!(!entry.contains("rtmp://127.0.0.1"), "{entry}"); + 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()); } #[cfg(target_os = "linux")] @@ -1440,19 +1627,103 @@ mod tests { assert_eq!(first_version_triple("no digits here"), None); } + /// Whitespace-blind view of a JSON fixture, for "same document" checks + /// where only indentation may differ. + fn without_whitespace(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() + } + + fn count_entries(file: &str) -> usize { + file.matches(r#""name": "InstantClone""#).count() + } + + /// Register then unregister must give the user back the file they + /// had. A leftover comma or brace still "contains Twitch", so the + /// result is compared whole and parsed, not probed for substrings. #[test] - fn insert_then_detect_then_remove_roundtrips() { + fn register_then_remove_restores_the_original_document() { let original = fake_services_json(); - let entry = entry_json(7799, 1935); - let patched = insert_entry(&original, &entry).expect("insert must succeed"); - assert!(entry_exists(&patched)); + let patched = with_entry_registered(&original, 7799, 1935).expect("register"); + assert!(crate::config::is_valid_json(&patched), "{patched}"); + assert_eq!(count_entries(&patched), 1); + let stripped = remove_entry(&patched).expect("remove must succeed"); - assert!(!entry_exists(&stripped)); - // Whitespace differences are expected (we insert with a - // newline, remove just strips the entry + a comma) but the - // services array should still contain both original entries. - assert!(stripped.contains("Twitch")); - assert!(stripped.contains("YouTube")); + assert!(crate::config::is_valid_json(&stripped), "{stripped}"); + assert_eq!(without_whitespace(&stripped), without_whitespace(&original)); + } + + /// An OBS with no services listed: our entry becomes the only element, + /// so it takes no comma (`[{entry},]` is invalid JSON, and OBS would + /// drop the whole list), and removing it gives the empty list back. + #[test] + fn registering_into_an_empty_service_list_keeps_the_file_valid() { + let original = "{\n \"format_version\": 5,\n \"services\": []\n}\n"; + let patched = with_entry_registered(original, 7799, 1935).expect("register"); + assert!(crate::config::is_valid_json(&patched), "{patched}"); + assert_eq!(count_entries(&patched), 1); + + let stripped = remove_entry(&patched).expect("remove must succeed"); + assert!(crate::config::is_valid_json(&stripped), "{stripped}"); + assert_eq!(without_whitespace(&stripped), without_whitespace(original)); + } + + /// Registering touches nothing but our own entry: cut the entry back + /// out of the result by its exact span and every other byte of OBS's + /// file must still be there, in order. + #[test] + fn register_preserves_every_other_byte() { + let original = fake_services_json(); + let patched = with_entry_registered(&original, 7799, 1935).expect("register"); + let spliced = format!("\n{},", entry_json(7799, 1935)); + assert_eq!(patched.replacen(&spliced, "", 1), original); + } + + /// `register` runs from the dashboard button and again from the stale + /// entry repair at startup. Running it twice must be the same file as + /// running it once, not a second entry or a file that grows each time. + #[test] + fn register_twice_is_the_same_as_once() { + let once = with_entry_registered(&fake_services_json(), 7799, 1935).unwrap(); + let twice = with_entry_registered(&once, 7799, 1935).unwrap(); + assert_eq!(twice, once); + } + + /// OBS rewrites services.json on its own schedule and does not keep our + /// entry first. With our entry last, removal takes the leading-comma + /// path, which must still leave one entry and valid JSON behind. + #[test] + fn register_and_remove_handle_our_entry_last_in_the_array() { + let original = fake_services_json(); + let last = original.replacen( + "\n ]", + &format!(",\n {}\n ]", entry_json(7799, 1935)), + 1, + ); + assert!(crate::config::is_valid_json(&last), "fixture: {last}"); + + let refreshed = with_entry_registered(&last, 8800, 1935).expect("register"); + assert!(crate::config::is_valid_json(&refreshed), "{refreshed}"); + assert_eq!(count_entries(&refreshed), 1); + assert!(refreshed.contains(":8800/") && !refreshed.contains(":7799/")); + + let stripped = remove_entry(&last).expect("remove"); + assert!(crate::config::is_valid_json(&stripped), "{stripped}"); + assert_eq!(without_whitespace(&stripped), without_whitespace(&original)); + } + + /// When our old entry cannot be cut out cleanly (here: a file cut off + /// mid-entry by a crash during a write), inserting anyway would leave + /// two InstantClone entries in OBS's service list. Refuse instead. + #[test] + fn register_refuses_when_the_old_entry_cannot_be_removed() { + let truncated = + r#"{"format_version": 4, "services": [{"name": "InstantClone", "common": true"#; + assert!(entry_exists(truncated) && remove_entry(truncated).is_none()); + match with_entry_registered(truncated, 7799, 1935) { + Ok(patched) => panic!("must refuse, wrote {} entries", count_entries(&patched)), + Err(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidData), + } + assert!(with_entry_registered("definitely not services.json", 7799, 1935).is_err()); } /// The single server entry must be the `localhost` spelling. An IP @@ -1609,23 +1880,6 @@ mod tests { entry_exists(file) && registration_is_current(file, 7799, 1935) } - #[test] - fn insert_is_idempotent_for_caller() { - // Caller (`register`) checks `entry_exists` before splicing, - // so the second call to `register` is the cheap no-op path - // even though `insert_entry` would happily double-insert. - let original = fake_services_json(); - let entry = entry_json(7799, 1935); - let once = insert_entry(&original, &entry).unwrap(); - assert!(entry_exists(&once)); - // `register` would short-circuit here, but verify the helper - // doesn't crash on a second insert call and produces a file - // with exactly two `"name": "InstantClone"` markers (other - // "InstantClone" mentions in URLs and labels don't count). - let twice = insert_entry(&once, &entry).unwrap(); - assert_eq!(twice.matches(r#""name": "InstantClone""#).count(), 2); - } - #[test] fn remove_handles_single_entry_array() { // After our entry has been the only thing in the services @@ -1701,37 +1955,20 @@ mod tests { assert!(looks_like_services_json(&fake_services_json()).is_ok()); } - #[test] - fn insert_into_garbage_file_is_refused_at_validate_step() { - // Confirms the validate step in register() catches garbage - // before insert_entry would happily splice into nonsense. - // We can't drive register() in unit tests (it touches the - // filesystem) but the validator + insert chain is what - // protects us. - let bad = "definitely not services.json"; - assert!(looks_like_services_json(bad).is_err()); - } - + /// Re-registering after the dashboard or ingest port changed must + /// swap the URLs in place, through the same transform `register` runs. #[test] fn re_register_refreshes_changed_port() { - // The flow we exercise here: register at port A, then "register" - // at port B with the same entry already present. We can't call - // `register()` directly (it touches disk), but the helpers it - // uses - remove_entry + insert_entry - must compose into a file - // that contains the *new* port. - let original = fake_services_json(); - let v1 = insert_entry(&original, &entry_json(7799, 1935)).unwrap(); + let v1 = with_entry_registered(&fake_services_json(), 7799, 1935).unwrap(); assert!(v1.contains(":7799/")); - // Re-register with a new port: strip the old entry, splice fresh. - let cleaned = remove_entry(&v1).expect("strip must succeed"); - let v2 = insert_entry(&cleaned, &entry_json(8800, 1935)).unwrap(); - assert!(v2.contains(":8800/"), "must reflect new port"); - assert!(!v2.contains(":7799/"), "old port must be gone"); - assert_eq!( - v2.matches(r#""name": "InstantClone""#).count(), - 1, - "exactly one InstantClone entry, not two" + let v2 = with_entry_registered(&v1, 8800, 1936).unwrap(); + assert!( + v2.contains(":8800/") && v2.contains("localhost:1936/"), + "{v2}" ); + assert!(!v2.contains(":7799/") && !v2.contains(":1935/"), "{v2}"); + assert_eq!(count_entries(&v2), 1, "exactly one InstantClone entry"); + assert!(crate::config::is_valid_json(&v2), "{v2}"); } // ── INI read/write ─────────────────────────────────────────────── @@ -1781,6 +2018,25 @@ mod tests { assert_eq!(ini_get(&out, "Basic", "Profile"), Some("A")); } + /// Files written by older builds can carry two `[General]` blocks, + /// each with the flag (see the BOM test above). Rewriting only the + /// first left the second at `=true`, so turning VOD audio off did not + /// turn the OBS checkbox off for whichever block OBS reads. + #[test] + fn ini_set_rewrites_the_key_in_every_duplicate_section() { + let ini = "[General]\nEnableCustomServerVodTrack=true\n\n[Basic]\nProfile=A\n\n\ + [General]\nEnableCustomServerVodTrack=true\nName=X\n"; + let out = ini_set(ini, "General", "EnableCustomServerVodTrack", false); + + assert_eq!(out.matches("EnableCustomServerVodTrack=false").count(), 2); + assert!(!out.contains("EnableCustomServerVodTrack=true"), "{out}"); + // Every other line is untouched and nothing is appended. + assert_eq!(out.matches("[General]").count(), 2); + assert_eq!(ini_get(&out, "Basic", "Profile"), Some("A")); + assert!(out.contains("Name=X\n")); + assert_eq!(out.lines().count(), ini.lines().count()); + } + // ── Active profile directory resolution ────────────────────────── /// `[Basic] Profile` is a display name and `[Basic] ProfileDir` is the @@ -1816,6 +2072,117 @@ mod tests { assert_eq!(profile_dir_by_name(&[fresh_obs_dir("empty")], "A"), None); } + /// A real Advanced-output profile (AMD H.264, CBR 6000): the local + /// Enhanced Broadcasting config must stream with exactly these. + const ADVANCED_BASIC_INI: &str = "[General]\nName=Main\n\n[Output]\nMode=Advanced\n\n\ + [AdvOut]\nEncoder=h264_texture_amf\nRescale=false\nRescaleRes=1920x1080\n"; + const AMD_STREAM_ENCODER: &str = + r#"{"bitrate":6000,"rate_control":"CBR","preset":"quality","profile":"high","bf":2}"#; + + #[test] + fn advanced_output_settings_are_taken_whole() { + let own = stream_settings_from(ADVANCED_BASIC_INI, Some(AMD_STREAM_ENCODER)) + .expect("readable settings"); + let encoder = own.encoder.expect("Advanced output names its encoder"); + assert_eq!(encoder.encoder_type, "h264_texture_amf"); + assert_eq!(encoder.settings_json, AMD_STREAM_ENCODER); + assert_eq!(own.bitrate_kbps, 6000); + assert_eq!(own.rescale, None, "Rescale Output is off"); + } + + #[test] + fn rescale_output_sets_the_main_track_size_only_when_on() { + let on = ADVANCED_BASIC_INI.replace("Rescale=false", "Rescale=true"); + let own = stream_settings_from(&on, Some(AMD_STREAM_ENCODER)).unwrap(); + assert_eq!(own.rescale, Some((1920, 1080))); + let bad_size = on.replace("RescaleRes=1920x1080", "RescaleRes=0x1080"); + let own = stream_settings_from(&bad_size, Some(AMD_STREAM_ENCODER)).unwrap(); + assert_eq!(own.rescale, None, "a size OBS would refuse is ignored"); + } + + #[test] + fn simple_output_contributes_only_its_bitrate() { + let simple = "[Output]\nMode=Simple\n\n[SimpleOutput]\nVBitrate=4500\n"; + let own = stream_settings_from(simple, Some(AMD_STREAM_ENCODER)).unwrap(); + assert!( + own.encoder.is_none(), + "Simple output keeps no encoder settings" + ); + assert_eq!(own.bitrate_kbps, 4500); + let no_mode = "[SimpleOutput]\nVBitrate=3000\n"; + let own = stream_settings_from(no_mode, None).unwrap(); + assert_eq!(own.bitrate_kbps, 3000, "OBS defaults to Simple output"); + } + + /// Anything that can't be mirrored faithfully gives None, and the local + /// config keeps its own defaults instead of guessing. + #[test] + fn settings_that_cannot_be_mirrored_are_not_guessed() { + let unreadable = [ + (ADVANCED_BASIC_INI, None), + (ADVANCED_BASIC_INI, Some("not json")), + (ADVANCED_BASIC_INI, Some(r#"{"bitrate":6000,}"#)), + ( + ADVANCED_BASIC_INI, + Some(r#"{"bitrate":6000,"preset":"quality"}}"#), + ), + (ADVANCED_BASIC_INI, Some(r#"["bitrate",6000]"#)), + ( + ADVANCED_BASIC_INI, + Some(r#"{"rate_control":"CQP","cqp":20}"#), + ), + (ADVANCED_BASIC_INI, Some(r#"{"bitrate":0}"#)), + ]; + for (ini, json) in unreadable { + assert!(stream_settings_from(ini, json).is_none(), "{json:?}"); + } + let no_encoder = ADVANCED_BASIC_INI.replace("Encoder=h264_texture_amf", "Encoder="); + assert!(stream_settings_from(&no_encoder, Some(AMD_STREAM_ENCODER)).is_none()); + let simple_without_bitrate = "[Output]\nMode=Simple\n"; + assert!(stream_settings_from(simple_without_bitrate, None).is_none()); + } + + #[test] + fn a_bom_prefixed_stream_encoder_file_still_reads() { + let with_bom = format!("\u{feff}{AMD_STREAM_ENCODER}\r\n"); + let own = stream_settings_from(ADVANCED_BASIC_INI, Some(&with_bom)).unwrap(); + 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/portcheck.rs b/src/portcheck.rs index f83bd40..78f4a28 100644 --- a/src/portcheck.rs +++ b/src/portcheck.rs @@ -300,13 +300,21 @@ mod tests { #[test] fn find_free_port_skips_held_and_returns_next() { - // Hold a known port and ask find_free_port to start there. - // It should jump to a later one. - let (held, port) = bind_eph(); - let found = find_free_port("127.0.0.1", port, 50) - .expect("some port in the +50 window must be free"); + const WINDOW: u16 = 50; + // Hold a known port and ask find_free_port to start there. It + // should jump to a later one. The OS can hand out an ephemeral port + // within WINDOW of 65535, where the scan has no room to move on and + // `port + WINDOW` would overflow, so draw again until it has room. + let (held, port) = loop { + let (listener, port) = bind_eph(); + if port <= u16::MAX - WINDOW { + break (listener, port); + } + }; + let found = find_free_port("127.0.0.1", port, WINDOW) + .expect("some port in the window must be free"); assert_ne!(found, port, "must skip the held port"); - assert!(found > port && found <= port + 50); + assert!(found > port && found <= port + WINDOW); drop(held); } diff --git a/src/rtmp/amf0.rs b/src/rtmp/amf0.rs index b5cf839..979bc15 100644 --- a/src/rtmp/amf0.rs +++ b/src/rtmp/amf0.rs @@ -20,6 +20,10 @@ //! to read the list's contents, but the in-tree //! sink and any cross-talk between proxies has //! to be able to step over it. +//! 0x0B date - 8-byte f64 ms since epoch + 2-byte time zone (unused) +//! 0x0C long string - 4-byte length BE + UTF-8 bytes, for text over 64 KiB. +//! Servers may use either in a reply; one unknown +//! marker fails the whole command, so both decode. use bytes::{BufMut, BytesMut}; use std::collections::HashMap; @@ -35,6 +39,9 @@ pub enum Amf0 { Undefined, EcmaArray(HashMap), StrictArray(Vec), + /// Milliseconds since the Unix epoch. The wire also carries a 2-byte + /// time zone that the spec reserves (always 0), so it is not kept. + Date(f64), } impl Amf0 { @@ -63,11 +70,18 @@ impl Amf0 { /// Maximum nesting depth we'll honour when decoding AMF0. Real RTMP /// command/data payloads from OBS, ffmpeg, Twitch etc. never go more /// than 3-4 levels deep. A malicious peer sending `0x03 0x03 0x03 …` ad -/// infinitum would otherwise blow the stack and crash the ingest task -/// (or, worse with `ingest_bind_all=true`, the whole process from a -/// LAN attacker). +/// infinitum would otherwise overflow the stack, and a stack overflow +/// aborts the whole process, not just the ingest task - reachable from +/// the LAN with `ingest_bind_all=true`. const AMF0_MAX_DEPTH: u32 = 16; +/// Most items a strict array reserves room for up front. Real arrays are +/// a handful of items; a longer one still decodes, the Vec just grows as +/// items arrive. Reserving the declared count (even capped at the bytes +/// left) let one 16 MB command reserve ~56 bytes per input byte - about +/// 1 GB - before any stream key is checked. +const AMF0_MAX_PREALLOC: usize = 64; + pub fn decode_all(mut data: &[u8]) -> io::Result> { let mut out = Vec::new(); while !data.is_empty() { @@ -119,13 +133,12 @@ fn decode_one(data: &[u8], depth: u32) -> io::Result<(Amf0, &[u8])> { 0x0A => { need(rest, 4)?; let count = u32::from_be_bytes(rest[..4].try_into().unwrap()) as usize; - // Cap the declared count at the number of bytes remaining; a - // malicious peer could otherwise advertise a huge count and - // walk us into an OOM by pushing into a Vec we pre-grew. - // Each AMF0 value is at least 1 byte (the marker), so the - // remaining buffer length is a strict upper bound. + // Never trust the declared count for the reservation: a peer + // could advertise a huge one and walk us into an OOM. Each + // AMF0 value is at least 1 byte, so the bytes left bound the + // items, and `AMF0_MAX_PREALLOC` bounds the up-front memory. let mut data = &rest[4..]; - let cap = count.min(data.len()); + let cap = count.min(data.len()).min(AMF0_MAX_PREALLOC); let mut items = Vec::with_capacity(cap); for _ in 0..count { let (v, next) = decode_one(data, depth + 1)?; @@ -134,6 +147,22 @@ fn decode_one(data: &[u8], depth: u32) -> io::Result<(Amf0, &[u8])> { } Ok((Amf0::StrictArray(items), data)) } + 0x0B => { + // Date: f64 ms since the epoch, then a reserved 2-byte time zone. + need(rest, 10)?; + let ms = f64::from_be_bytes(rest[..8].try_into().unwrap()); + Ok((Amf0::Date(ms), &rest[10..])) + } + 0x0C => { + // Long string: like 0x02 but with a 4-byte length, for text + // over 64 KiB. Surfaces as a plain String - callers never care + // which length prefix it came with. + need(rest, 4)?; + let len = u32::from_be_bytes(rest[..4].try_into().unwrap()) as usize; + let body = &rest[4..]; + need(body, len)?; + Ok((Amf0::String(utf8(&body[..len])?), &body[len..])) + } m => Err(io::Error::new( ErrorKind::InvalidData, format!("amf0: unsupported marker {:#x}", m), @@ -145,10 +174,13 @@ fn decode_string(data: &[u8]) -> io::Result<(String, &[u8])> { need(data, 2)?; let len = u16::from_be_bytes([data[0], data[1]]) as usize; need(&data[2..], len)?; - let s = std::str::from_utf8(&data[2..2 + len]) - .map_err(|_| io::Error::new(ErrorKind::InvalidData, "amf0: utf8"))? - .to_owned(); - Ok((s, &data[2 + len..])) + Ok((utf8(&data[2..2 + len])?, &data[2 + len..])) +} + +fn utf8(bytes: &[u8]) -> io::Result { + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| io::Error::new(ErrorKind::InvalidData, "amf0: utf8")) } fn decode_object_body(mut data: &[u8], depth: u32) -> io::Result<(HashMap, &[u8])> { @@ -216,6 +248,11 @@ pub fn enc_value(out: &mut BytesMut, v: &Amf0) { let pairs: Vec<(&str, &Amf0)> = m.iter().map(|(k, v)| (k.as_str(), v)).collect(); enc_object(out, &pairs); } + Amf0::Date(ms) => { + out.put_u8(0x0B); + out.put_f64(*ms); + out.put_i16(0); // time zone: reserved, always 0 + } Amf0::StrictArray(items) => { out.put_u8(0x0A); out.put_u32(items.len() as u32); @@ -392,21 +429,234 @@ mod tests { } #[test] - fn deeply_nested_object_rejected() { - // Hand-craft a deeply nested object payload - each 0x03 starts a - // new object body. AMF0_MAX_DEPTH=16, so a 17-deep chain trips the - // guard. The empty-key + 0x09 end markers are appended in reverse - // to keep the payload syntactically closeable (the decoder bails - // on the depth check before getting that far). - // 17 opening Object markers, then 17 (empty-key + end) trailers. - let mut payload: Vec = vec![0x03; 17]; - for _ in 0..17 { - payload.extend_from_slice(&[0x00, 0x00, 0x09]); - } - let r = decode_all(&payload); + fn date_and_long_string_decode_instead_of_failing_the_command() { + // Servers are free to put a Date (0x0B) or a long string (0x0C) in + // a `_result` / `onStatus`, and one unknown marker fails decode_all + // for the whole command: an egress connect then dies on a reply it + // did not even need, and an ingest connection is dropped. + let mut buf = vec![0x0B]; + buf.extend_from_slice(&1_700_000_000_000.0f64.to_be_bytes()); + buf.extend_from_slice(&[0x00, 0x00]); // time zone, reserved + buf.push(0x0C); + buf.extend_from_slice(&5u32.to_be_bytes()); + buf.extend_from_slice(b"hello"); + buf.extend_from_slice(&[0x05]); // something after both + let decoded = decode_all(&buf).unwrap(); + assert_eq!(decoded.len(), 3); + assert!(matches!(decoded[0], Amf0::Date(ms) if ms == 1_700_000_000_000.0)); + assert_eq!(decoded[1].as_str(), Some("hello")); + assert!(matches!(decoded[2], Amf0::Null)); + + // A long string longer than 64 KiB, which is the point of 0x0C. + let big = "x".repeat(70_000); + let mut buf = vec![0x0C]; + buf.extend_from_slice(&(big.len() as u32).to_be_bytes()); + buf.extend_from_slice(big.as_bytes()); + assert_eq!(decode_all(&buf).unwrap()[0].as_str(), Some(big.as_str())); + + // And they re-encode to the same bytes a Date is sent as. + let mut out = BytesMut::new(); + enc_value(&mut out, &Amf0::Date(2.0)); + let mut expected = vec![0x0B]; + expected.extend_from_slice(&2.0f64.to_be_bytes()); + expected.extend_from_slice(&[0, 0]); + assert_eq!(&out[..], &expected[..]); + } + + #[test] + fn truncated_date_and_long_string_are_errors() { + // Short on the time zone, short on the length, and a length that + // claims more than is there: never read past the buffer. + assert!(decode_all(&[0x0B, 0, 0, 0, 0, 0, 0, 0, 0, 0]).is_err()); + assert!(decode_all(&[0x0C, 0, 0, 0]).is_err()); + assert!(decode_all(&[0x0C, 0xFF, 0xFF, 0xFF, 0xFF, b'a']).is_err()); assert!( - r.is_err(), - "deeply nested objects must be rejected to prevent stack-blow" + decode_all(&[0x0C, 0, 0, 0, 2, 0xC3, 0x28]).is_err(), + "bad UTF-8" ); } + + /// `levels` containers nested inside each other, each holding exactly + /// one child, built with the real wire syntax for each container type. + fn nested(levels: usize, marker: u8) -> Vec { + let (open, empty, close): (&[u8], &[u8], &[u8]) = match marker { + 0x03 => ( + &[0x03, 0x00, 0x01, b'k'], + &[0x03, 0x00, 0x00, 0x09], + &[0x00, 0x00, 0x09], + ), + 0x08 => ( + &[0x08, 0, 0, 0, 1, 0x00, 0x01, b'k'], + &[0x08, 0, 0, 0, 0, 0x00, 0x00, 0x09], + &[0x00, 0x00, 0x09], + ), + _ => (&[0x0A, 0, 0, 0, 1], &[0x0A, 0, 0, 0, 0], &[]), + }; + let mut out = open.repeat(levels - 1); + out.extend_from_slice(empty); + out.extend(close.repeat(levels - 1)); + out + } + + #[test] + fn nesting_past_the_depth_limit_is_rejected_for_every_container() { + // A peer can nest containers without bound and recurse the decoder + // off the stack. The guard allows AMF0_MAX_DEPTH + 1 levels (the + // top-level value is depth 0) and refuses the next one with + // InvalidData - not UnexpectedEof, which is what malformed bytes + // produce, so this fails if the guard is removed. + let max_levels = AMF0_MAX_DEPTH as usize + 1; + for marker in [0x03, 0x08, 0x0A] { + assert!( + decode_all(&nested(max_levels, marker)).is_ok(), + "marker {marker:#x}: {max_levels} levels are legal" + ); + let err = decode_all(&nested(max_levels + 1, marker)).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidData, "marker {marker:#x}"); + } + // Mixed containers count toward the same limit. + let mut mixed = nested(max_levels, 0x03); + mixed.splice(0..0, [0x0A, 0, 0, 0, 1]); + let err = decode_all(&mixed).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidData); + } + + #[test] + fn a_strict_array_claiming_four_billion_items_errors_quickly() { + // The count is capped by the bytes left before anything is + // reserved, so this is a short error, not a 64 GB allocation. + let mut buf = vec![0x0A, 0xFF, 0xFF, 0xFF, 0xFF]; + buf.extend_from_slice(&[0x05, 0x05, 0x05]); + assert!(decode_all(&buf).is_err()); + } + + #[test] + fn edge_values_decode_exactly() { + let mut nan = vec![0x00]; + nan.extend_from_slice(&f64::NAN.to_be_bytes()); + assert!(decode_all(&nan).unwrap()[0].as_f64().unwrap().is_nan()); + assert_eq!( + decode_all(&[0x02, 0x00, 0x00]).unwrap()[0].as_str(), + Some("") + ); + assert!(matches!(decode_all(&[0x06]).unwrap()[0], Amf0::Undefined)); + // An object whose end marker never comes is an error, whether the + // bytes stop after a value or after the empty key. + assert!(decode_all(&[0x03, 0x00, 0x01, b'a', 0x05]).is_err()); + assert!(decode_all(&[0x03, 0x00, 0x00]).is_err()); + // An empty key followed by anything but 0x09 is a value, not the end. + assert!(decode_all(&[0x03, 0x00, 0x00, 0x05, 0x00, 0x00, 0x09]).is_ok()); + } + + #[test] + fn enc_object_writes_known_bytes() { + // The shape every _result / onStatus we send is built from. + let mut out = BytesMut::new(); + enc_object( + &mut out, + &[("a", &Amf0::Number(1.0)), ("ok", &Amf0::Boolean(true))], + ); + let mut expected = vec![0x03, 0x00, 0x01, b'a', 0x00]; + expected.extend_from_slice(&1.0f64.to_be_bytes()); + expected.extend_from_slice(&[0x00, 0x02, b'o', b'k', 0x01, 0x01]); + expected.extend_from_slice(&[0x00, 0x00, 0x09]); + assert_eq!(&out[..], &expected[..]); + } + + /// Structural equality. Amf0 has no PartialEq (maps hold f64s), and + /// the tests only need it here. + fn same(a: &Amf0, b: &Amf0) -> bool { + match (a, b) { + (Amf0::Number(x), Amf0::Number(y)) | (Amf0::Date(x), Amf0::Date(y)) => { + x.to_bits() == y.to_bits() + } + (Amf0::Boolean(x), Amf0::Boolean(y)) => x == y, + (Amf0::String(x), Amf0::String(y)) => x == y, + (Amf0::Null, Amf0::Null) | (Amf0::Undefined, Amf0::Undefined) => true, + (Amf0::Object(x), Amf0::Object(y)) | (Amf0::EcmaArray(x), Amf0::EcmaArray(y)) => { + x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w))) + } + (Amf0::StrictArray(x), Amf0::StrictArray(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same(v, w)) + } + _ => false, + } + } + + /// A connect command as an Enhanced-RTMP publisher sends it, plus the + /// containers and markers servers use in replies. Returns the bytes + /// and the offset where each top-level value ends. + fn rich_connect_payload() -> (Vec, Vec) { + let mut buf = BytesMut::new(); + let mut ends = Vec::new(); + enc_string(&mut buf, "connect"); + ends.push(buf.len()); + enc_number(&mut buf, 1.0); + ends.push(buf.len()); + enc_object_begin(&mut buf); + enc_object_key(&mut buf, "app"); + enc_string(&mut buf, "live"); + enc_object_key(&mut buf, "fpad"); + enc_value(&mut buf, &Amf0::Boolean(false)); + enc_object_key(&mut buf, "fourCcList"); + enc_strict_array_str(&mut buf, &["avc1", "hvc1"]); + enc_object_key(&mut buf, "ecma"); + buf.extend_from_slice(&[0x08, 0, 0, 0, 2, 0x00, 0x01, b'u', 0x06]); + buf.extend_from_slice(&[0x00, 0x01, b'd', 0x0B]); + buf.extend_from_slice(&3.0f64.to_be_bytes()); + buf.extend_from_slice(&[0, 0, 0x00, 0x00, 0x09]); + enc_object_key(&mut buf, "long"); + buf.extend_from_slice(&[0x0C, 0, 0, 0, 3, b'a', b'b', b'c']); + enc_object_end(&mut buf); + ends.push(buf.len()); + enc_null(&mut buf); + ends.push(buf.len()); + (buf.to_vec(), ends) + } + + #[test] + fn every_truncation_of_a_connect_payload_is_an_error_or_a_clean_prefix() { + // Commands arrive as whole messages, but a peer controls their + // length. Cut anywhere inside a value, decoding must fail; cut + // exactly between top-level values, it must return exactly the + // values before the cut. Never a panic, never a half-built value. + let (payload, ends) = rich_connect_payload(); + let full = decode_all(&payload).expect("the full payload decodes"); + assert_eq!(full.len(), 4); + for cut in 0..payload.len() { + match decode_all(&payload[..cut]) { + Ok(values) => { + let boundary = cut == 0 || ends.contains(&cut); + assert!(boundary, "a cut at {cut} decoded mid-value"); + assert!(values.iter().zip(&full).all(|(v, w)| same(v, w))); + } + Err(e) => assert!(!ends.contains(&cut), "a cut at {cut} failed: {e}"), + } + } + } + + #[test] + fn random_marker_soup_never_panics() { + // Deterministic LCG, fixed seed. Markers are drawn from the full + // range the decoder knows (plus a few it doesn't) so each branch + // sees garbage lengths and counts. + let mut state: u64 = 0xA3F0_2026; + let mut next = move || { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (state >> 33) as u8 + }; + for _ in 0..20_000 { + let len = next() % 24; + let blob: Vec = (0..len) + .map(|_| match next() % 4 { + 0 => next() % 0x0E, + 1 => 0x00, + _ => next(), + }) + .collect(); + let _ = decode_all(&blob); + } + } } diff --git a/src/rtmp/chunk.rs b/src/rtmp/chunk.rs index d239692..7330ebc 100644 --- a/src/rtmp/chunk.rs +++ b/src/rtmp/chunk.rs @@ -23,6 +23,14 @@ use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt}; pub const DEFAULT_CHUNK_SIZE: usize = 128; +/// Most bytes of unfinished messages a connection may hold across all its +/// chunk streams. A publisher has one or two messages in flight (a large +/// keyframe on one stream, audio on another); each can be up to 16 MB, so +/// this leaves room for several. Without a total, a peer could open +/// thousands of chunk streams and park a nearly finished 16 MB message on +/// each, holding memory without ever publishing. +pub const MAX_PENDING_BYTES: usize = 64 * 1024 * 1024; + #[derive(Debug, Clone)] pub struct Message { pub timestamp: u32, @@ -86,6 +94,9 @@ pub struct ChunkReader { inner: R, chunk_size: usize, streams: HashMap, + /// Bytes of unfinished messages across all of `streams`, capped at + /// `MAX_PENDING_BYTES`. + pending: usize, /// Total wire bytes consumed from the peer since the connection /// opened - chunk headers + payload + extended timestamps + control /// messages. Used to emit RTMP Acknowledgement (BYTES_READ_REPORT, @@ -111,6 +122,7 @@ impl ChunkReader { inner, chunk_size: DEFAULT_CHUNK_SIZE, streams: HashMap::with_capacity(8), + pending: 0, bytes_in: 0, window_ack_size: 2_500_000, bytes_in_at_last_ack: 0, @@ -265,6 +277,7 @@ impl ChunkReader { // dispatch into the per-CSID state. The two phases exist so // the async byte-counting reads can hold &mut self exclusively. let header = self.read_chunk_header(fmt, csid).await?; + let chunk_size = self.chunk_size; let st = self.streams.entry(csid).or_default(); match header { ChunkHeader::Fmt0 { @@ -280,7 +293,8 @@ impl ChunkReader { st.type_id = type_id; st.stream_id = msid; st.last_had_ext_ts = ext_ts_present; - st.buf = BytesMut::with_capacity(length as usize); + self.pending = self.pending.saturating_sub(st.buf.len()); + st.buf = message_buf(length, chunk_size); st.receiving = true; } ChunkHeader::Fmt1 { @@ -294,7 +308,8 @@ impl ChunkReader { st.length = length; st.type_id = type_id; st.last_had_ext_ts = ext_ts_present; - st.buf = BytesMut::with_capacity(length as usize); + self.pending = self.pending.saturating_sub(st.buf.len()); + st.buf = message_buf(length, chunk_size); st.receiving = true; } ChunkHeader::Fmt2 { @@ -304,7 +319,8 @@ impl ChunkReader { st.timestamp = st.timestamp.wrapping_add(delta); st.timestamp_delta = delta; st.last_had_ext_ts = ext_ts_present; - st.buf = BytesMut::with_capacity(st.length as usize); + self.pending = self.pending.saturating_sub(st.buf.len()); + st.buf = message_buf(st.length, chunk_size); st.receiving = true; } ChunkHeader::Fmt3 => { @@ -313,7 +329,7 @@ impl ChunkReader { // so the absolute timestamp advances by the // last delta and the buffer restarts. st.timestamp = st.timestamp.wrapping_add(st.timestamp_delta); - st.buf = BytesMut::with_capacity(st.length as usize); + st.buf = message_buf(st.length, chunk_size); st.receiving = true; } } @@ -329,6 +345,13 @@ impl ChunkReader { // self.streams. We bump the byte counter manually afterwards. self.inner.read_exact(&mut st.buf[start..]).await?; self.bytes_in += to_read as u64; + self.pending += to_read; + if self.pending > MAX_PENDING_BYTES { + return Err(io::Error::new( + ErrorKind::InvalidData, + "too much unfinished message data on this connection", + )); + } if st.buf.len() as u32 == st.length { let msg = Message { @@ -338,6 +361,7 @@ impl ChunkReader { payload: st.buf.split().freeze(), }; st.receiving = false; + self.pending = self.pending.saturating_sub(msg.payload.len()); // Handle protocol-control messages in-band so callers // only ever see semantic messages. @@ -355,7 +379,25 @@ impl ChunkReader { } continue; } - 2 => continue, // Abort Message + 2 => { + // Abort Message: drop the partial message on the + // named csid, so the next fmt-3 there starts a new + // message instead of being appended to stale bytes. + if msg.payload.len() >= 4 { + let aborted = u32::from_be_bytes([ + msg.payload[0], + msg.payload[1], + msg.payload[2], + msg.payload[3], + ]); + if let Some(st) = self.streams.get_mut(&aborted) { + self.pending = self.pending.saturating_sub(st.buf.len()); + st.buf = BytesMut::new(); + st.receiving = false; + } + } + continue; + } 3 => continue, // Acknowledgement 5 => { // Window Acknowledgement Size from the peer - @@ -480,6 +522,12 @@ impl ChunkWriter { pub async fn flush(&mut self) -> io::Result<()> { self.inner.flush().await } + + /// Close the sending side (TCP FIN). The peer still gets everything + /// already written, and this side can keep reading until it closes. + pub async fn shutdown(&mut self) -> io::Result<()> { + self.inner.shutdown().await + } } fn write_basic_header(out: &mut Vec, fmt: u8, csid: u32) { @@ -497,6 +545,17 @@ fn write_basic_header(out: &mut Vec, fmt: u8, csid: u32) { } } +/// Empty buffer for a message the peer declares to be `length` bytes. +/// +/// Reserves at most one chunk. The length is only the peer's claim, read +/// before `publish` is gated, and every csid (up to 65599 of them) holds its +/// own partial message: reserving the full 24-bit length up front let each +/// ~140-byte header pin 16 MB. Capped here, the buffer grows only as chunks +/// actually arrive, so memory tracks bytes received. +fn message_buf(length: u32, chunk_size: usize) -> BytesMut { + BytesMut::with_capacity((length as usize).min(chunk_size)) +} + fn push_u24_be(out: &mut Vec, v: u32) { out.push(((v >> 16) & 0xFF) as u8); out.push(((v >> 8) & 0xFF) as u8); @@ -554,6 +613,14 @@ mod tests { b } + /// fmt-0 header declaring `declared` bytes (msid 1), followed by only + /// the first chunk's bytes: the rest must come as fmt-3 continuations. + fn fmt0_first_chunk(csid: u8, ts: u32, type_id: u8, declared: u32, first: &[u8]) -> Vec { + let mut b = fmt0(csid, ts, type_id, 1, first); + b[4..7].copy_from_slice(&u24(declared)); + b + } + fn fmt1(csid: u8, delta: u32, type_id: u8, payload: &[u8]) -> Vec { let mut b = vec![(1 << 6) | (csid & 0x3F)]; b.extend_from_slice(&u24(delta)); @@ -598,11 +665,34 @@ mod tests { } #[tokio::test] - async fn extended_timestamp_roundtrips() { - // >= 0xFF_FFFF triggers the 32-bit extended-timestamp field - the - // "single most-broken-in-practice corner of RTMP" per the module doc. - let msg = roundtrip(6, 0x0100_0000, 9, 1, b"x").await; - assert_eq!(msg.timestamp, 0x0100_0000); + async fn writer_emits_spec_bytes_for_an_extended_timestamp_at_the_boundary() { + // Round trips can't catch a mistake the writer and reader share, so + // pin the wire bytes. Exactly 0xFF_FFFF is the first value that + // needs the extended field (the 24-bit slot then holds the 0xFFFFFF + // marker), message stream id is the one little-endian field, and the + // fmt-3 continuation repeats the 4-byte extended timestamp. + let payload: Vec = (0..200u32).map(|i| i as u8).collect(); + let mut wire: Vec = Vec::new(); + ChunkWriter::new(&mut wire) + .write_message(6, 0x00FF_FFFF, 9, 1, &payload) + .await + .unwrap(); + + let mut expected = vec![0x06, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xC8, 0x09]; + expected.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // msid 1, LE + expected.extend_from_slice(&[0x00, 0xFF, 0xFF, 0xFF]); // ext ts, BE + expected.extend_from_slice(&payload[..128]); + expected.extend_from_slice(&[0xC6, 0x00, 0xFF, 0xFF, 0xFF]); // fmt 3 + ext ts + expected.extend_from_slice(&payload[128..]); + assert_eq!(wire, expected); + + // One below the boundary still fits in 24 bits: no extended field. + let mut wire: Vec = Vec::new(); + ChunkWriter::new(&mut wire) + .write_message(6, 0x00FF_FFFE, 9, 1, b"x") + .await + .unwrap(); + assert_eq!(wire, [0x06, 0xFF, 0xFF, 0xFE, 0, 0, 1, 9, 1, 0, 0, 0, b'x']); } #[tokio::test] @@ -729,10 +819,312 @@ mod tests { let mut r = reader(wire); let msg = r.read_message().await.unwrap(); assert_eq!(msg.type_id, 9); // control consumed in-band, media surfaced - assert!(r.take_pending_ack().is_some()); + // The ack reports every wire byte: (1 + 11 + 4) + (1 + 11 + 10). + assert_eq!(r.take_pending_ack(), Some(38)); assert!(r.take_pending_ack().is_none()); } + #[tokio::test] + async fn ack_count_includes_extended_timestamps_and_continuation_headers() { + // The peer counts what it put on the wire, so a reader that skips + // the 4-byte extended timestamps or the fmt-3 basic headers + // under-reports, and a strict peer stalls waiting for the window. + let mut wire = fmt0(2, 0, 5, 0, &200u32.to_be_bytes()); // 16 bytes + ChunkWriter::new(&mut wire) + .write_message(6, 0x0100_0000, 9, 1, &[0u8; 200]) + .await + .unwrap(); + let mut r = reader(wire); + r.read_message().await.unwrap(); + // 16 + (1 + 11 + 4 + 128) + (1 + 4 + 72) + assert_eq!(r.take_pending_ack(), Some(237)); + } + + #[tokio::test] + async fn reader_decodes_two_and_three_byte_basic_headers() { + // csid 64..=319 uses [fmt|0][csid - 64]; 320..=65599 uses + // [fmt|1][lo][hi] of csid - 64. Each must land in its own csid state + // or two streams would share one message buffer. + for csid in [64u32, 200, 319, 320, 1000, 65_599] { + let mut wire: Vec = Vec::new(); + ChunkWriter::new(&mut wire) + .write_message(csid, 7, 9, 1, &[0xAB; 300]) + .await + .unwrap(); + let mut r = reader(wire); + let msg = r.read_message().await.unwrap(); + assert_eq!(&msg.payload[..], &[0xAB; 300][..], "csid {csid}"); + assert!(r.streams.contains_key(&csid), "csid {csid} decoded"); + assert_eq!(r.streams.len(), 1, "csid {csid}: one state only"); + } + } + + #[tokio::test] + async fn interleaved_messages_on_different_csids_stay_intact() { + // Publishers interleave audio between the chunks of a large video + // frame. The audio completes first; the video resumes on its own + // csid with a fmt-3 and still reassembles byte for byte. + let video: Vec = (0..200u32).map(|i| (i * 3) as u8).collect(); + let mut wire = fmt0_first_chunk(6, 40, 9, 200, &video[..128]); + wire.extend(fmt0(4, 41, 8, 1, b"audio")); + wire.extend(fmt3(6, &video[128..])); + let mut r = reader(wire); + let audio = r.read_message().await.unwrap(); + assert_eq!((audio.type_id, audio.timestamp), (8, 41)); + assert_eq!(&audio.payload[..], b"audio"); + let frame = r.read_message().await.unwrap(); + assert_eq!((frame.type_id, frame.timestamp), (9, 40)); + assert_eq!(&frame.payload[..], &video[..]); + } + + #[tokio::test] + async fn set_chunk_size_mid_message_applies_to_the_next_chunk() { + // A Set Chunk Size that arrives between two chunks of another csid's + // message governs the rest of that message: here the remaining 172 + // bytes come as one chunk instead of 128 + 44. + let video: Vec = (0..300u32).map(|i| i as u8).collect(); + let mut wire = fmt0_first_chunk(6, 0, 9, 300, &video[..128]); + wire.extend(fmt0(2, 0, 1, 0, &4096u32.to_be_bytes())); + wire.extend(fmt3(6, &video[128..])); + wire.extend(fmt0(4, 0, 8, 1, b"after")); + let mut r = reader(wire); + assert_eq!(&r.read_message().await.unwrap().payload[..], &video[..]); + assert_eq!(&r.read_message().await.unwrap().payload[..], b"after"); + } + + #[tokio::test] + async fn extended_deltas_on_fmt1_and_fmt2_carry_into_their_continuations() { + // A delta of 0xFFFFFF or more also moves to the extended field, and + // then every fmt-3 continuation of that message repeats it. The + // writer never produces this (it only emits fmt 0), so hand-build it. + let body: Vec = (0..200u32).map(|i| (i * 5) as u8).collect(); + let big_delta = 0x0123_4567u32; + let mut wire = fmt0(6, 1000, 9, 1, b"seed"); + + let mut fmt1_hdr = vec![0x46]; // fmt 1, csid 6 + fmt1_hdr.extend_from_slice(&u24(0xFF_FFFF)); + fmt1_hdr.extend_from_slice(&u24(200)); + fmt1_hdr.push(9); + fmt1_hdr.extend_from_slice(&big_delta.to_be_bytes()); + wire.extend(fmt1_hdr); + wire.extend_from_slice(&body[..128]); + wire.extend_from_slice(&[0xC6]); + wire.extend_from_slice(&big_delta.to_be_bytes()); + wire.extend_from_slice(&body[128..]); + + let mut fmt2_hdr = vec![0x86]; // fmt 2, csid 6: reuses length 200 + fmt2_hdr.extend_from_slice(&u24(0xFF_FFFF)); + fmt2_hdr.extend_from_slice(&big_delta.to_be_bytes()); + wire.extend(fmt2_hdr); + wire.extend_from_slice(&body[..128]); + wire.extend_from_slice(&[0xC6]); + wire.extend_from_slice(&big_delta.to_be_bytes()); + wire.extend_from_slice(&body[128..]); + + let mut r = reader(wire); + r.read_message().await.unwrap(); + let first = r.read_message().await.unwrap(); + assert_eq!(first.timestamp, 1000 + big_delta); + assert_eq!(&first.payload[..], &body[..]); + let second = r.read_message().await.unwrap(); + assert_eq!(second.timestamp, 1000 + 2 * big_delta); + assert_eq!(&second.payload[..], &body[..]); + } + + #[tokio::test] + async fn timestamp_deltas_wrap_at_32_bits() { + // RTMP time is u32 milliseconds and wraps every ~49.7 days. The + // controller widens it; the reader just has to wrap, not panic. + let mut wire = vec![0x06]; + wire.extend_from_slice(&u24(0xFF_FFFF)); + wire.extend_from_slice(&u24(1)); + wire.push(9); + wire.extend_from_slice(&1u32.to_le_bytes()); + wire.extend_from_slice(&0xFFFF_FFF0u32.to_be_bytes()); + wire.push(b'a'); + wire.extend(fmt1(6, 0x20, 9, b"b")); + let mut r = reader(wire); + assert_eq!(r.read_message().await.unwrap().timestamp, 0xFFFF_FFF0); + assert_eq!(r.read_message().await.unwrap().timestamp, 0x10); + } + + #[tokio::test] + async fn out_of_range_set_chunk_size_is_clamped_not_trusted() { + // 0 would make no progress, so it clamps to 1: every later byte of + // payload then rides in its own chunk and still reassembles. + let mut wire = fmt0(2, 0, 1, 0, &0u32.to_be_bytes()); + wire.extend(fmt0_first_chunk(6, 0, 9, 3, b"x")); + wire.extend(fmt3(6, b"y")); + wire.extend(fmt3(6, b"z")); + let mut r = reader(wire); + assert_eq!(&r.read_message().await.unwrap().payload[..], b"xyz"); + assert_eq!(r.chunk_size, 1); + + // The spec reserves the top bit. The value is clamped to the 24-bit + // maximum rather than read as a ~2 GB chunk. + let mut r = reader(fmt0(2, 0, 1, 0, &0x8000_0400u32.to_be_bytes())); + assert!(r.read_message().await.is_err(), "only the control message"); + assert_eq!(r.chunk_size, 0xFF_FFFF); + } + + #[tokio::test] + async fn short_control_payloads_are_ignored_without_panicking() { + // Set Chunk Size, Abort, Ack and Window Ack Size all carry a u32. + // A peer that sends fewer bytes must not make us index past the + // payload; the message is dropped and the stream carries on. + let mut wire = Vec::new(); + for type_id in [1u8, 2, 3, 5, 6] { + wire.extend(fmt0(2, 0, type_id, 0, &[0x01, 0x02, 0x03])); + wire.extend(fmt0(2, 0, type_id, 0, &[])); + } + wire.extend(fmt0(4, 9, 8, 1, b"media")); + let mut r = reader(wire); + assert_eq!(&r.read_message().await.unwrap().payload[..], b"media"); + assert_eq!(r.chunk_size, DEFAULT_CHUNK_SIZE); + assert_eq!(r.window_ack_size, 2_500_000); + } + + /// Deterministic LCG for the fuzz test: fixed seed, no dependency. + struct Lcg(u64); + + impl Lcg { + fn next_u32(&mut self) -> u32 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as u32 + } + + fn below(&mut self, n: u32) -> u32 { + self.next_u32() % n + } + } + + /// Feed `wire` to a fresh reader until it errors. Returns the messages + /// it surfaced. A capture always ends, so an Err must always come: a + /// reader that loops without consuming bytes would hang the test. + async fn drain(wire: Vec) -> Vec { + let mut r = reader(wire); + let mut out = Vec::new(); + while let Ok(msg) = r.read_message().await { + out.push(msg); + } + out + } + + /// A publisher-shaped capture: control messages, fmt 0-3, interleaving, + /// an extended timestamp with continuations, and an Abort. + async fn realistic_capture() -> Vec { + let mut wire: Vec = Vec::new(); + { + let mut w = ChunkWriter::new(&mut wire); + w.write_message(2, 0, 5, 0, &2_500_000u32.to_be_bytes()) + .await + .unwrap(); + w.write_message(3, 0, 20, 0, b"\x02\x00\x07connect") + .await + .unwrap(); + w.write_message(6, 0x0100_0000, 9, 1, &[0x17; 300]) + .await + .unwrap(); + } + wire.extend(fmt1(6, 33, 9, &[0x27; 40])); + wire.extend(fmt2(6, 33, &[0x27; 40])); + wire.extend(fmt3(6, &[0x27; 40])); + wire.extend(fmt0(4, 5, 8, 1, &[0xAF; 9])); + wire.extend(fmt0(2, 0, 2, 0, &6u32.to_be_bytes())); + { + let mut w = ChunkWriter::new(&mut wire); + w.send_set_chunk_size(64).await.unwrap(); + w.write_message(4, 30, 8, 1, &[0xAF; 150]).await.unwrap(); + } + wire + } + + #[tokio::test] + async fn every_prefix_and_random_stream_terminates_without_panicking() { + // The reader runs on bytes from anyone who completes a handshake. + // Whatever arrives, it must return an error or a message, never + // panic and never spin: every prefix of a real capture (each cut + // lands mid-header or mid-payload somewhere), then random streams. + let capture = realistic_capture().await; + assert_eq!(drain(capture.clone()).await.len(), 7, "the full capture"); + for end in 0..capture.len() { + drain(capture[..end].to_vec()).await; + } + + let mut rng = Lcg(0xC4_2026); + for _ in 0..3_000 { + let mut wire = Vec::new(); + for _ in 0..rng.below(6) { + // Mostly small csids and lengths so chunks actually complete + // and state carries across chunks, with the odd huge value. + wire.push(rng.next_u32() as u8 & 0xC7); + let n = rng.below(16) as usize; + wire.extend((0..n).map(|_| rng.next_u32() as u8 & 0x0F)); + if rng.below(4) == 0 { + wire.extend_from_slice(&[0xFF, 0xFF, 0xFF]); + } + } + drain(wire).await; + } + } + + #[tokio::test] + async fn declared_lengths_do_not_reserve_memory_before_the_bytes_arrive() { + // The 24-bit length is the peer's claim, readable before publish is + // gated. Reserving it up front let every fmt-0 header on a fresh + // csid pin ~16 MB, and there are 65599 csids: memory exhaustion for + // the price of 140 wire bytes per header. What is held has to track + // what was actually received. + let mut wire = Vec::new(); + for csid in 3u8..11 { + wire.push(csid); + wire.extend_from_slice(&u24(0)); + wire.extend_from_slice(&u24(0xFF_FFFF)); + wire.push(9); + wire.extend_from_slice(&1u32.to_le_bytes()); + wire.extend(std::iter::repeat_n(0u8, DEFAULT_CHUNK_SIZE)); + } + let mut r = reader(wire); + assert!( + r.read_message().await.is_err(), + "the capture ends mid-message" + ); + assert_eq!(r.streams.len(), 8, "every csid started a message"); + for (csid, st) in &r.streams { + assert!( + st.buf.capacity() <= 4 * DEFAULT_CHUNK_SIZE, + "csid {csid} reserved {} bytes after receiving {}", + st.buf.capacity(), + st.buf.len() + ); + } + } + + #[tokio::test] + async fn abort_message_discards_the_partial_message_on_its_csid() { + // Abort (type 2) tells us to drop whatever is half-received on the + // named csid. Ignoring it meant the next fmt-3 on that csid was + // appended to the stale half, surfacing a message stitched from two + // frames and desyncing the rest of the stream. + let stale = [0x11u8; 200]; + let fresh: Vec = (0..200u32).map(|i| i as u8).collect(); + let mut wire = fmt0_first_chunk(6, 1000, 9, 200, &stale[..128]); + wire.extend(fmt0(2, 0, 2, 0, &6u32.to_be_bytes())); // Abort csid 6 + wire.extend(fmt3(6, &fresh[..128])); // a new message, header replayed + wire.extend(fmt3(6, &fresh[128..])); + wire.extend(fmt0(4, 0, 8, 1, b"next")); // the stream stays in sync + let mut r = reader(wire); + let msg = r.read_message().await.unwrap(); + assert_eq!(&msg.payload[..], &fresh[..]); + // fmt-3 opening a message advances by the last delta (fmt-0 seeds + // it with the absolute timestamp), same as any other replay. + assert_eq!((msg.timestamp, msg.type_id, msg.stream_id), (2000, 9, 1)); + assert_eq!(&r.read_message().await.unwrap().payload[..], b"next"); + } + #[test] fn basic_header_encodes_all_csid_ranges() { // 1-byte form (csid < 64), 2-byte form (< 320), 3-byte form (>= 320). @@ -757,4 +1149,28 @@ mod tests { assert_eq!(out, [0x12, 0x34, 0x56]); assert_eq!(u24_be(&out), 0x12_3456); } + + /// A peer that parks a nearly finished message on stream after stream + /// is cut off once the unfinished bytes pass `MAX_PENDING_BYTES`, + /// instead of holding that memory for as long as it likes. + #[tokio::test] + async fn unfinished_messages_are_capped_per_connection() { + const CHUNK: usize = 8 * 1024 * 1024; + let mut wire = fmt0(2, 0, 1, 0, &(CHUNK as u32).to_be_bytes()); + let first = vec![0u8; CHUNK]; + let streams = MAX_PENDING_BYTES / CHUNK + 1; + for csid in 0..streams { + // Declares 12 MB, sends only the first 8 MB chunk. + wire.extend(fmt0_first_chunk( + 3 + csid as u8, + 0, + 9, + 12 * 1024 * 1024, + &first, + )); + } + let mut reader = ChunkReader::new(std::io::Cursor::new(wire)); + let err = reader.read_message().await.unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidData, "{err}"); + } } diff --git a/src/rtmp/client.rs b/src/rtmp/client.rs index 4ddb1d1..eef433e 100644 --- a/src/rtmp/client.rs +++ b/src/rtmp/client.rs @@ -6,7 +6,8 @@ //! URL form: rtmp://host[:port]/app/stream_key (default port 1935), or //! rtmps://host[:port]/app/stream_key for TLS (default port 443). Kick's //! ingest is RTMPS-only, so the egress socket transparently upgrades to -//! TLS when the URL scheme is `rtmps://` - see `EgressStream`. +//! TLS when the URL scheme is `rtmps://` - see `EgressStream`. An IPv6 +//! host is written in brackets: rtmp://[::1]:1935/app/stream_key. use crate::rtmp::amf0::{self, Amf0}; use crate::rtmp::chunk::{ChunkReader, ChunkWriter, Message}; @@ -47,24 +48,25 @@ impl EgressUrl { let slash = rest .find('/') .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing app path"))?; - let host_part = &rest[..slash]; + let (host, port) = split_host_port(&rest[..slash], default_port)?; let path = &rest[slash + 1..]; - let (host, port) = match host_part.find(':') { - Some(c) => ( - host_part[..c].to_string(), - host_part[c + 1..] - .parse() - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "bad port"))?, - ), - None => (host_part.to_string(), default_port), - }; - let last_slash = path - .rfind('/') - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing stream key"))?; + let missing_key = || io::Error::new(io::ErrorKind::InvalidInput, "missing stream key"); + let last_slash = path.rfind('/').ok_or_else(missing_key)?; let app = path[..last_slash].to_string(); let stream_key = path[last_slash + 1..].to_string(); + // A trailing slash leaves nothing after it. Publishing with "" only + // earns a platform auth error that never says the key was missing. + if stream_key.is_empty() { + return Err(missing_key()); + } let scheme = if tls { "rtmps" } else { "rtmp" }; - let tc_url = format!("{}://{}:{}/{}", scheme, host, port, app); + // tcUrl is a URL, so an IPv6 host goes back inside its brackets. + let url_host = if host.contains(':') { + format!("[{host}]") + } else { + host.clone() + }; + let tc_url = format!("{}://{}:{}/{}", scheme, url_host, port, app); Ok(Self { host, port, @@ -76,6 +78,35 @@ impl EgressUrl { } } +/// Split `host[:port]` or `[ipv6]:port` into the host to dial and the port. +/// An IPv6 literal has colons of its own, so it must be bracketed and its +/// port can only follow the `]`; splitting on the first ':' tore it apart. +fn split_host_port(host_part: &str, default_port: u16) -> io::Result<(String, u16)> { + let invalid = |msg: &str| io::Error::new(io::ErrorKind::InvalidInput, msg.to_string()); + let (host, port) = match host_part.strip_prefix('[') { + Some(bracketed) => { + let (host, after) = bracketed + .split_once(']') + .ok_or_else(|| invalid("unclosed [ in host"))?; + if after.is_empty() { + (host, None) + } else { + let port = after.strip_prefix(':').ok_or_else(|| invalid("bad port"))?; + (host, Some(port)) + } + } + None => match host_part.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (host_part, None), + }, + }; + let port = match port { + Some(p) => p.parse().map_err(|_| invalid("bad port"))?, + None => default_port, + }; + Ok((host.to_string(), port)) +} + /// Egress socket that is either a plain TCP stream or a TLS stream over /// TCP. One concrete type keeps `EgressClient` / `EgressSink` free of a /// generic IO parameter (so the controller's egress supervisor never has @@ -263,7 +294,7 @@ impl EgressClient { FOURCC_LIST.join(",") ), ); - let connect_resp = await_command_status(&mut reader, "_result").await?; + let connect_resp = await_command_status(&mut reader, "_result", Some(1.0)).await?; crate::trace::log( "AMF_IN", &format!( @@ -294,7 +325,7 @@ impl EgressClient { // --- createStream --- send_cmd(&mut writer, "createStream", 4.0, &[]).await?; crate::trace::log("AMF_OUT", "cmd=createStream"); - let create_resp = await_command_status(&mut reader, "_result").await?; + let create_resp = await_command_status(&mut reader, "_result", Some(4.0)).await?; let stream_id = create_resp.get(3).and_then(|v| v.as_f64()).unwrap_or(1.0) as u32; crate::trace::log( "AMF_IN", @@ -311,7 +342,7 @@ impl EgressClient { writer.write_message(4, 0, 20, stream_id, &buf).await?; writer.flush().await?; crate::trace::log("AMF_OUT", "cmd=publish key_redacted=true type=live"); - let publish_resp = await_command_status(&mut reader, "onStatus").await?; + let publish_resp = await_command_status(&mut reader, "onStatus", None).await?; crate::trace::log( "AMF_IN", &format!( @@ -390,7 +421,7 @@ impl EgressClient { stream_id: self.stream_id, ping_rx, ack_rx, - _drain_abort: drain.abort_handle(), + drain, } } } @@ -408,14 +439,17 @@ pub struct EgressSink { /// see `spawn_reader_drain`. Drained by `drain_pings` on every /// pump tick. ack_rx: tokio::sync::mpsc::UnboundedReceiver, - // Aborts the paired drain task when the sink is dropped. Held only - // for its Drop side-effect; never read. - _drain_abort: tokio::task::AbortHandle, + /// The paired drain task: aborted when the sink is dropped, and + /// awaited by `send_delete_stream` to close the connection cleanly. + drain: tokio::task::JoinHandle<()>, } +/// How long a goodbye waits for the platform to read it and close. +const GOODBYE_WAIT: std::time::Duration = std::time::Duration::from_secs(1); + impl Drop for EgressSink { fn drop(&mut self) { - self._drain_abort.abort(); + self.drain.abort(); } } @@ -518,7 +552,20 @@ impl EgressSink { amf0::enc_null(&mut buf); amf0::enc_number(&mut buf, self.stream_id as f64); self.writer.write_message(3, 0, 20, 0, &buf).await?; - self.writer.flush().await + self.writer.flush().await?; + // Close cleanly rather than just dropping the socket. The platform + // answers FCUnpublish, and closing with that answer unread makes + // the OS reset the connection, which can discard the deleteStream + // the platform hasn't read yet: it then sees a dropped stream, not + // a finished one. So send FIN, keep draining its replies, and let + // it close first (bounded, in case it never does). + let _ = self.writer.shutdown().await; + // A finished task can't be awaited again (it would panic, and + // panics abort the process), so only wait while it's running. + if !self.drain.is_finished() { + let _ = tokio::time::timeout(GOODBYE_WAIT, &mut self.drain).await; + } + Ok(()) } } @@ -555,9 +602,15 @@ async fn send_cmd( writer.flush().await } +/// Wait for `expect`. With `txn`, only the `_result` / `_error` answering +/// that transaction counts: servers also answer releaseStream / FCPublish, +/// sometimes with `_error` (no such stream yet), and taking that reply for +/// createStream's - or failing on it - broke the connect on those servers. +/// onStatus carries no transaction (0), so it is matched by name alone. async fn await_command_status( reader: &mut ChunkReader, expect: &str, + txn: Option, ) -> io::Result> { // Hard cap so a wedged Twitch / YouTube edge that goes silent // mid-handshake can't hang the egress task forever. 15 s is well @@ -575,11 +628,13 @@ async fn await_command_status( } let vals = amf0::decode_all(&msg.payload)?; let cmd = vals.first().and_then(|v| v.as_str()); + let answers_us = + txn.is_none_or(|txn| vals.get(1).and_then(|v| v.as_f64()) == Some(txn)); // Both `_error` and `onStatus { level: "error" }` indicate failure. // Surface them as io::Errors so the supervisor can log a useful // reason instead of silently looping forever waiting for `_result`. - if cmd == Some("_error") { + if cmd == Some("_error") && answers_us { let desc = vals .iter() .find_map(|v| v.as_object()) @@ -602,7 +657,7 @@ async fn await_command_status( } } } - if cmd == Some(expect) { + if cmd == Some(expect) && answers_us { return Ok(vals); } // Anything else (Window Ack, onBWDone, onFCPublish, ping …) is @@ -668,4 +723,190 @@ mod tests { assert!(EgressUrl::parse("http://host/app/k").is_err()); assert!(EgressUrl::parse("host/app/k").is_err()); } + + #[test] + fn parse_accepts_bracketed_ipv6_hosts() { + // An IPv6 literal has to be bracketed in a URL; splitting host and + // port on the first ':' cut it apart and failed on "bad port". + let u = EgressUrl::parse("rtmp://[::1]:1936/app/k").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("::1", 1936)); + // The host is dialled bare, but tcUrl is a URL, so it keeps brackets. + assert_eq!(u.tc_url, "rtmp://[::1]:1936/app"); + + let u = EgressUrl::parse("rtmps://[2001:db8::7]/app/k").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("2001:db8::7", 443)); + assert_eq!(u.tc_url, "rtmps://[2001:db8::7]:443/app"); + + for bad in [ + "rtmp://[::1/app/k", // no closing bracket + "rtmp://[::1]x/app/k", // junk after it + "rtmp://[::1]:/app/k", // empty port + "rtmp://::1:1935/app/k", // unbracketed + ] { + assert!(EgressUrl::parse(bad).is_err(), "{bad}"); + } + } + + #[test] + fn parse_refuses_an_empty_stream_key() { + // A trailing slash left the key empty, and we published with "" - + // the platform then refuses with an auth error that doesn't say the + // key was missing. Saying so up front is the useful error. + let err = EgressUrl::parse("rtmp://live.twitch.tv/app/") + .err() + .unwrap(); + assert!(err.to_string().contains("stream key"), "{err}"); + assert!(EgressUrl::parse("rtmp://live.twitch.tv/app").is_err()); + } + + #[test] + fn parse_keeps_nested_app_paths_and_rejects_bad_ports() { + // Everything between the host and the last '/' is the app, which is + // how Wowza-style `app/instance` ingests are addressed. + let u = EgressUrl::parse("rtmp://host:1935/app/inst/key?x=1").unwrap(); + assert_eq!(u.app, "app/inst"); + assert_eq!( + u.stream_key, "key?x=1", + "query rides on the key, as OBS sends it" + ); + assert_eq!(u.tc_url, "rtmp://host:1935/app/inst"); + + for bad in [ + "rtmp://host:abc/app/k", + "rtmp://host:70000/app/k", + "rtmp://host:/app/k", + ] { + assert!(EgressUrl::parse(bad).is_err(), "{bad}"); + } + } + + /// Wire bytes of AMF0 command messages as a server would send them. + async fn server_replies(commands: &[(u8, BytesMut)]) -> Vec { + let mut wire: Vec = Vec::new(); + let mut w = ChunkWriter::new(&mut wire); + for (type_id, body) in commands { + w.write_message(3, 0, *type_id, 0, body).await.unwrap(); + } + wire + } + + fn command(name: &str, info: Option>) -> BytesMut { + let mut buf = BytesMut::new(); + amf0::enc_string(&mut buf, name); + amf0::enc_number(&mut buf, 1.0); + amf0::enc_null(&mut buf); + if let Some(pairs) = info { + let values: Vec = pairs + .iter() + .map(|(_, v)| Amf0::String(v.to_string())) + .collect(); + let fields: Vec<(&str, &Amf0)> = pairs + .iter() + .zip(&values) + .map(|((k, _), v)| (*k, v)) + .collect(); + amf0::enc_object(&mut buf, &fields); + } + buf + } + + async fn await_on(wire: Vec, expect: &str) -> io::Result> { + let mut reader = ChunkReader::new(std::io::Cursor::new(wire)); + await_command_status(&mut reader, expect, None).await + } + + #[tokio::test] + async fn await_command_status_skips_unrelated_traffic_until_the_answer() { + // Servers interleave onBWDone, onFCPublish and control messages + // with the reply we wait for; none of those is the answer. + let mut info_with_date = command("_result", None); + info_with_date.extend_from_slice(&[0x0B, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + let wire = server_replies(&[ + (18, command("onMetaData", None)), + (20, command("onBWDone", None)), + ( + 20, + command( + "onFCPublish", + Some(vec![("code", "NetStream.Publish.Start")]), + ), + ), + (20, info_with_date), + ]) + .await; + let vals = await_on(wire, "_result").await.expect("the _result"); + assert_eq!(vals[0].as_str(), Some("_result")); + assert!( + matches!(vals[3], Amf0::Date(_)), + "a Date in a reply is fine" + ); + } + + #[tokio::test] + async fn await_command_status_turns_server_errors_into_errors() { + let wire = server_replies(&[( + 20, + command( + "_error", + Some(vec![("level", "error"), ("description", "bad app")]), + ), + )]) + .await; + let err = await_on(wire, "_result").await.unwrap_err(); + assert!(err.to_string().contains("bad app"), "{err}"); + + let wire = server_replies(&[( + 20, + command( + "onStatus", + Some(vec![ + ("level", "error"), + ("code", "NetStream.Publish.BadName"), + ("description", "in use"), + ]), + ), + )]) + .await; + let err = await_on(wire, "onStatus").await.unwrap_err(); + assert!( + err.to_string() + .contains("NetStream.Publish.BadName: in use"), + "{err}" + ); + + // A status-level onStatus is the answer, not a failure. + let wire = + server_replies(&[(20, command("onStatus", Some(vec![("level", "status")])))]).await; + assert!(await_on(wire, "onStatus").await.is_ok()); + + // The connection closing before any answer is an error too. + assert!(await_on(Vec::new(), "_result").await.is_err()); + } + + /// Some servers answer releaseStream / FCPublish, even with `_error` + /// (no such stream yet). Only the reply to the awaited transaction + /// counts: createStream's `_result` carries the stream id, and an + /// `_error` for an earlier command doesn't fail the connect. + #[tokio::test] + async fn replies_are_matched_by_transaction() { + let reply = |name: &str, txn: f64, id: f64| { + let mut buf = BytesMut::new(); + amf0::enc_string(&mut buf, name); + amf0::enc_number(&mut buf, txn); + amf0::enc_null(&mut buf); + amf0::enc_number(&mut buf, id); + buf + }; + let wire = server_replies(&[ + (20, reply("_error", 2.0, 0.0)), + (20, reply("_result", 3.0, 0.0)), + (20, reply("_result", 4.0, 7.0)), + ]) + .await; + let mut reader = ChunkReader::new(std::io::Cursor::new(wire)); + let vals = await_command_status(&mut reader, "_result", Some(4.0)) + .await + .expect("createStream's own reply"); + assert_eq!(vals.get(3).and_then(|v| v.as_f64()), Some(7.0)); + } } diff --git a/src/rtmp/server.rs b/src/rtmp/server.rs index 7b62f89..6b211a8 100644 --- a/src/rtmp/server.rs +++ b/src/rtmp/server.rs @@ -17,9 +17,25 @@ use bytes::BytesMut; use std::collections::HashMap; use std::io; use std::sync::Arc; +use std::time::Duration; use tokio::io::split; use tokio::net::TcpListener; +/// How long a new connection has to finish the RTMP handshake. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long a connection that hasn't published may go without a complete +/// message. OBS publishes within a second of connecting; this only frees +/// sockets that connect and then sit. A publishing connection has no such +/// limit: a frozen OBS is crash protection's to handle. +const UNPUBLISHED_IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Largest AMF0 command accepted. OBS's connect / publish commands are a +/// few hundred bytes. A 16 MB command of 1-byte values would decode into +/// millions of values (~56 bytes each in memory) before any stream key is +/// checked. +const MAX_COMMAND_BYTES: usize = 64 * 1024; + /// Bind one ingest address. Kept separate from `serve` so the supervisor /// can tell a bind failure (permanent for the IPv6 leg on a machine with /// IPv6 disabled) from a serve failure (worth retrying), instead of @@ -87,11 +103,20 @@ pub async fn serve(listener: TcpListener, ctrl: Arc) -> io::Result<( struct PublishGuard { ctrl: Arc, active: bool, + /// The publisher token `begin_publish` gave this connection. + token: u64, +} +impl PublishGuard { + /// This connection published and is still the current publisher (a + /// newer one takes over from a hung OBS, see `Controller::begin_publish`). + fn is_current(&self) -> bool { + self.active && self.ctrl.publisher_token() == self.token + } } impl Drop for PublishGuard { fn drop(&mut self) { if self.active { - self.ctrl.mark_ingest_dead(); + self.ctrl.end_publish(self.token); } } } @@ -101,7 +126,9 @@ async fn handle( ctrl: Arc, peer: std::net::SocketAddr, ) -> io::Result<()> { - handshake::perform_server(&mut sock).await?; + tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake::perform_server(&mut sock)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "handshake timed out"))??; // Keys the ingest-key rate limiter; the port pre-flight already resolved a // stable bind, so the connecting peer's IP is the right client identity. @@ -117,12 +144,19 @@ async fn handle( let mut guard = PublishGuard { ctrl: ctrl.clone(), active: false, + token: 0, }; // One warning per connection for media sent without publishing. let mut warned_unpublished = false; loop { - let msg = reader.read_message().await?; + let msg = if guard.active { + reader.read_message().await? + } else { + tokio::time::timeout(UNPUBLISHED_IDLE_TIMEOUT, reader.read_message()) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "idle without publishing"))?? + }; // librtmp's window-ack rule: fire BYTES_READ_REPORT once we've // received more than `window_ack_size/10` bytes since our last // ack. Strict RTMP relays (nginx-rtmp under tight config, SRS, @@ -144,6 +178,11 @@ async fn handle( // real publisher's on a different origin, and its onMetaData replaces // the cached one that is replayed on every cut and reconnect. A // conforming client always publishes first, so this costs nothing. + // A newer publisher took over from this one (it had stopped sending + // video): nothing more it sends may reach the buffer. + if guard.active && !guard.is_current() { + return Err(io::Error::other("replaced by a newer publisher")); + } if ignore_before_publish(guard.active, msg.type_id) { // Once per connection, not once per message. Reaching this needs // only TCP plus the handshake, and a peer can manufacture a @@ -160,6 +199,12 @@ async fn handle( } match msg.type_id { 20 /* AMF0 command */ => { + if msg.payload.len() > MAX_COMMAND_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oversized AMF0 command", + )); + } handle_command(&mut writer, &ctrl, &msg, &mut guard, &peer_ip).await?; } 18 /* AMF0 data - onMetaData et al */ => { @@ -168,7 +213,7 @@ async fn handle( 8 /* audio */ => { let info = h264::classify_audio_tag(&msg.payload); ctrl.note_audio_codec(info.codec); - if info.is_multitrack { ctrl.note_multitrack_audio(); } + if info.is_multitrack { ctrl.note_multitrack_audio(&msg.payload); } // Audio multi-track (VOD audio) is forwarded bit-faithfully // - Twitch consumes the second track for VOD audio. ctrl.on_tag(8, msg.timestamp, &msg.payload, false, info.is_seq_header); @@ -268,10 +313,19 @@ async fn handle_command( info.insert("objectEncoding".to_string(), Amf0::Number(0.0)); send_command_result(writer, txn_id, Amf0::Object(props), Amf0::Object(info)).await?; } - "releaseStream" | "FCPublish" | "FCUnpublish" | "deleteStream" => { + "releaseStream" | "FCPublish" => { // No-op acks. Most clients don't care about the response body. send_simple_result(writer, txn_id).await?; } + "FCUnpublish" | "deleteStream" => { + // OBS sends both from RTMP_Close on every deliberate stop, and + // never on a crash. Remember it so the disconnect that follows + // counts as a stop (crash protection stays out of the way). + if guard.is_current() { + ctrl.note_unpublish(); + } + send_simple_result(writer, txn_id).await?; + } "createStream" => { // Stream id 1. We always use the same. let mut buf = BytesMut::new(); @@ -289,8 +343,9 @@ async fn handle_command( .unwrap_or("") .to_string(); match ctrl.begin_publish(&stream_key, peer_ip).await { - Ok(_token) => { + Ok(token) => { guard.active = true; + guard.token = token; // onStatus NetStream.Publish.Start let mut info = HashMap::new(); info.insert("level".to_string(), Amf0::String("status".into())); @@ -399,6 +454,11 @@ fn ignore_before_publish(published: bool, type_id: u8) -> bool { #[cfg(test)] mod tests { + use super::*; + use crate::buffer::DiskRing; + use crate::rtmp::client::{EgressClient, EgressUrl}; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::time::Duration; /// The stream key - and the ingest key with it - is checked inside /// `begin_publish` and nowhere else. A peer that connects and then sends @@ -426,17 +486,156 @@ mod tests { ); } } - use super::*; - /// A machine or container with IPv6 switched off cannot run these. - /// The product degrades to IPv4 there by design (`supervise_ingest_leg` - /// drops the optional leg), so skipping is the honest outcome rather - /// than a red suite on a Docker image without IPv6. - async fn bind_v6_or_skip(addr: &str) -> Option { - match bind(addr).await { + static UNIQ: AtomicU32 = AtomicU32::new(0); + + /// A real ingest listener on a loopback port, serving a Controller over + /// its own temp ring. Stops the listener and deletes the ring on drop. + struct Ingest { + ctrl: Option>, + port: u16, + path: std::path::PathBuf, + server: tokio::task::JoinHandle>, + } + + impl Ingest { + fn ctrl(&self) -> &Arc { + self.ctrl.as_ref().expect("alive until drop") + } + } + + impl Drop for Ingest { + fn drop(&mut self) { + self.server.abort(); + // Best-effort: a connection task still winding down may hold + // the ring open a moment longer, which Windows won't delete. + drop(self.ctrl.take()); + let _ = std::fs::remove_file(&self.path); + } + } + + async fn ingest() -> Ingest { + let n = UNIQ.fetch_add(1, Ordering::SeqCst); + let path = + std::env::temp_dir().join(format!("ic-test-ingest-{}-{}.buf", std::process::id(), n)); + let _ = std::fs::remove_file(&path); + let ring = Arc::new(DiskRing::create(&path, 1 << 20).expect("ring create")); + let ctrl = Arc::new(Controller::new(ring, 0)); + let listener = bind("127.0.0.1:0").await.expect("loopback bind"); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(serve(listener, ctrl.clone())); + Ingest { + ctrl: Some(ctrl), + port, + path, + server, + } + } + + /// Poll `done` until it holds, failing the test after 5 s. The ingest + /// runs on its own task, so its effects land shortly after the bytes. + async fn eventually(what: &str, done: impl Fn() -> bool) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !done() { + assert!(tokio::time::Instant::now() < deadline, "timed out: {what}"); + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + + fn read_tag(ctrl: &Controller, seq: u64) -> Vec { + let mut buf = Vec::new(); + ctrl.ring + .try_read_seq(seq, &mut buf) + .expect("no io error") + .expect("tag present"); + buf + } + + /// End to end through `serve` and `handle`, driven by the crate's own + /// egress client: handshake, connect, createStream, publish, then media. + /// Every unit test of the pieces can pass while this wiring is broken + /// (a reply the client rejects, a tag routed to the wrong kind), and + /// this is the one that would notice. + #[tokio::test] + async fn a_publisher_reaches_the_ring_through_the_real_ingest_path() { + let ingest = ingest().await; + let ctrl = ingest.ctrl().clone(); + let url = EgressUrl::parse(&format!("rtmp://127.0.0.1:{}/live/key", ingest.port)).unwrap(); + let client = tokio::time::timeout(Duration::from_secs(5), EgressClient::connect(&url)) + .await + .expect("handshake and publish finish") + .expect("the ingest accepts the publish"); + assert!(ctrl.ingest_alive(), "publish claimed the slot"); + + let mut sink = client.spawn_reader_drain(); + // Legacy AVC keyframe whose NAL walk finds an IDR, then AAC. + let video = [0x17, 0x01, 0, 0, 0, 0, 0, 0, 5, 0x65, 1, 2, 3, 4]; + let audio = [0xAF, 0x01, 0x21, 0x10]; + sink.send_video(40, &video).await.unwrap(); + sink.send_audio(41, &audio).await.unwrap(); + sink.flush().await.unwrap(); + + eventually("both tags indexed", || ctrl.ring.latest_seq() == Some(1)).await; + assert_eq!(read_tag(&ctrl, 0), video); + assert_eq!(read_tag(&ctrl, 1), audio); + let idr = ctrl.ring.newest_idr().expect("the keyframe is a cut point"); + assert_eq!((idr.seq, idr.ts_ms, idr.kind), (0, 40, 9)); + + // Closing the connection releases the publisher slot. + drop(sink); + eventually("slot released", || !ctrl.ingest_alive()).await; + } + + /// The pre-publish gate, through the real connection handler: a peer + /// that sends a frame without publishing gets nothing into the ring. + /// The `connect` sent afterwards is answered only once the frame before + /// it was handled, so its `_result` proves the frame was seen and + /// dropped rather than still in flight. + #[tokio::test] + async fn media_before_publish_never_reaches_the_ring() { + let ingest = ingest().await; + let ctrl = ingest.ctrl().clone(); + let mut sock = tokio::net::TcpStream::connect(("127.0.0.1", ingest.port)) + .await + .unwrap(); + handshake::perform_client(&mut sock).await.unwrap(); + let (rd, wr) = split(sock); + let mut reader = ChunkReader::new(rd); + let mut writer = ChunkWriter::new(wr); + + let frame = [0x17, 0x01, 0, 0, 0, 0, 0, 0, 5, 0x65, 1, 2, 3, 4]; + writer.write_message(6, 0, 9, 1, &frame).await.unwrap(); + let mut connect = BytesMut::new(); + amf0::enc_string(&mut connect, "connect"); + amf0::enc_number(&mut connect, 1.0); + amf0::enc_object(&mut connect, &[("app", &Amf0::String("live".into()))]); + writer.write_message(3, 0, 20, 0, &connect).await.unwrap(); + writer.flush().await.unwrap(); + + let reply = tokio::time::timeout(Duration::from_secs(5), reader.read_message()) + .await + .expect("the ingest answers connect") + .unwrap(); + let values = amf0::decode_all(&reply.payload).unwrap(); + assert_eq!(values[0].as_str(), Some("_result")); + assert_eq!( + ctrl.ring.latest_seq(), + None, + "the unpublished frame was dropped" + ); + assert!(!ctrl.ingest_alive()); + } + + /// Whether this machine can bind IPv6 at all. A machine or container + /// with IPv6 switched off cannot run the IPv6 tests; the product + /// degrades to IPv4 there by design (`supervise_ingest_leg` drops the + /// optional leg), so skipping is the honest outcome rather than a red + /// suite on a Docker image without IPv6. + async fn ipv6_loopback_or_skip() -> Option { + match bind("[::1]:0").await { Ok(l) => Some(l), Err(e) => { - eprintln!("skipping IPv6 test, {addr} is unbindable here: {e}"); + eprintln!("skipping IPv6 test, [::1] is unbindable here: {e}"); None } } @@ -446,14 +645,19 @@ mod tests { /// `IPV6_V6ONLY` this passes on Windows (the option defaults on) and /// fails on Linux with EADDRINUSE, because `net.ipv6.bindv6only` /// defaults off there and `[::]` swallows v4-mapped addresses. This is - /// the test that keeps the two-socket layout portable. + /// the test that keeps the two-socket layout portable. IPv6 support is + /// probed on a different address first, so that exact EADDRINUSE is a + /// failure here and not mistaken for "no IPv6" and skipped. #[tokio::test] async fn wildcard_v4_and_v6_listeners_share_one_port() { + if ipv6_loopback_or_skip().await.is_none() { + return; + } let v4 = bind("0.0.0.0:0").await.expect("IPv4 wildcard bind"); let port = v4.local_addr().unwrap().port(); - let Some(v6) = bind_v6_or_skip(&format!("[::]:{port}")).await else { - return; - }; + let v6 = bind(&format!("[::]:{port}")) + .await + .expect("[::] must share the port IPv4 holds (is IPV6_V6ONLY set?)"); assert!(v6.local_addr().unwrap().is_ipv6()); } @@ -464,7 +668,7 @@ mod tests { /// publisher sitting at the machine would be rate-limited as remote. #[tokio::test] async fn ipv6_loopback_listener_accepts_and_reports_a_loopback_peer() { - let Some(v6) = bind_v6_or_skip("[::1]:0").await else { + let Some(v6) = ipv6_loopback_or_skip().await else { return; }; let addr = v6.local_addr().unwrap(); diff --git a/src/rtmp/tcp.rs b/src/rtmp/tcp.rs index 20efb41..c52be1e 100644 --- a/src/rtmp/tcp.rs +++ b/src/rtmp/tcp.rs @@ -243,6 +243,81 @@ pub fn set_v6_only(_sock: &tokio::net::TcpSocket) -> io::Result<()> { Ok(()) } +#[cfg(all(test, target_os = "windows"))] +mod windows_tests { + use super::*; + use std::os::windows::io::AsRawSocket; + use windows_sys::Win32::Networking::WinSock::{ + getsockopt, IPPROTO_IPV6, IPV6_V6ONLY, SOL_SOCKET, SO_KEEPALIVE, SO_SNDBUF, + }; + + fn opt(sock: &impl AsRawSocket, level: i32, name: i32) -> i32 { + // Zeroed, not -1: Winsock writes a single byte for BOOL options + // like SO_KEEPALIVE, leaving the rest of the int as it found it. + let mut v: i32 = 0; + let mut len = std::mem::size_of::() as i32; + let rc = unsafe { + getsockopt( + sock.as_raw_socket() as usize, + level, + name, + &mut v as *mut i32 as *mut u8, + &mut len, + ) + }; + assert_eq!(rc, 0, "getsockopt failed"); + v + } + + async fn connected_pair() -> (TcpStream, TcpStream) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listen"); + let client = TcpStream::connect(listener.local_addr().expect("addr")) + .await + .expect("connect"); + let (server, _) = listener.accept().await.expect("accept"); + (client, server) + } + + /// The raw `WSAIoctl` declaration is hand-written FFI: a wrong struct + /// layout or control code fails silently at runtime, leaving the + /// two-hour OS default in place. Read the option back to prove it took. + #[tokio::test] + async fn keepalive_and_send_buffer_reach_the_socket_on_windows() { + let (client, _server) = connected_pair().await; + assert_eq!( + opt(&client, SOL_SOCKET, SO_KEEPALIVE), + 0, + "off until we set it" + ); + set_aggressive_keepalive(&client).expect("set keepalive"); + assert_eq!(opt(&client, SOL_SOCKET, SO_KEEPALIVE), 1); + + set_send_buffer(&client, 1024 * 1024).expect("set send buffer"); + assert_eq!(opt(&client, SOL_SOCKET, SO_SNDBUF), 1024 * 1024); + } + + /// `bind_v6_only` relies on this sticking before the bind; a dual-stack + /// socket would collide with the IPv4 leg and report local peers as + /// v4-mapped (not loopback). + #[test] + fn v6_only_sticks_on_an_unbound_socket() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + rt.block_on(async { + let Ok(sock) = tokio::net::TcpSocket::new_v6() else { + eprintln!("skipping: no IPv6 on this machine"); + return; + }; + set_v6_only(&sock).expect("set v6 only"); + assert_eq!(opt(&sock, IPPROTO_IPV6, IPV6_V6ONLY), 1); + }); + } +} + #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; diff --git a/src/self_update.rs b/src/self_update.rs index 29bb621..e2b1960 100644 --- a/src/self_update.rs +++ b/src/self_update.rs @@ -583,4 +583,30 @@ mod e2e { assert!(!staged.exists(), "staged .new should have been consumed"); let _ = std::fs::remove_dir_all(&dir); } + + /// The half-swap case: the running exe is already parked at `.old` + /// when the second rename fails (here: the staged file vanished, as + /// when antivirus quarantines it). The rollback must put the old exe + /// back, or the install is left with nothing to launch. + #[test] + fn swap_in_place_rolls_back_when_the_staged_file_is_missing() { + let dir = std::env::temp_dir().join(format!("ic-swap-rollback-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let exe = dir.join("instantclone.exe"); + let staged = sidecar(&exe, "new"); + std::fs::write(&exe, b"OLD-VERSION").unwrap(); + let _ = std::fs::remove_file(&staged); + + let result = swap_in_place(&exe, &staged); + + let exe_bytes = std::fs::read(&exe); + let old_left_behind = sidecar(&exe, "old").exists(); + let _ = std::fs::remove_dir_all(&dir); + assert!(result.is_err(), "a missing staged file must fail the swap"); + assert_eq!(exe_bytes.unwrap(), b"OLD-VERSION"); + assert!( + !old_left_behind, + "rollback must move .old back, not copy it" + ); + } } diff --git a/src/sha256.rs b/src/sha256.rs index f9f60f3..f807797 100644 --- a/src/sha256.rs +++ b/src/sha256.rs @@ -140,6 +140,40 @@ mod tests { ); } + /// The padding boundaries. 55 bytes is the longest message whose 0x80 byte + /// and 8-byte length still fit in one block; 56 is the first that spills + /// into a second; 63, 64 and 65 sit on either side of a full block. An + /// off-by-one in the `% 64 != 56` padding loop breaks exactly one of these. + /// Expected digests were computed with Python's hashlib and cross-checked + /// with coreutils `sha256sum`. + #[test] + fn padding_boundary_lengths() { + for (len, want) in [ + ( + 55, + "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318", + ), + ( + 56, + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a", + ), + ( + 63, + "7d3e74a05d7db15bce4ad9ec0658ea98e3f06eeecf16b4c6fff2da457ddc2f34", + ), + ( + 64, + "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb", + ), + ( + 65, + "635361c48bb9eab14198e76ea8ab7f1a41685d6ad62aa9146d301d4f17eb0ae0", + ), + ] { + assert_eq!(hex(&vec![b'a'; len]), want, "{len} x 'a'"); + } + } + #[test] fn one_million_a() { let data = vec![b'a'; 1_000_000]; diff --git a/src/sink.rs b/src/sink.rs index 4989cc6..95bc0d8 100644 --- a/src/sink.rs +++ b/src/sink.rs @@ -125,7 +125,9 @@ async fn run_sink( max_mb: Option, temp: bool, ) -> io::Result<()> { - let addr = format!("0.0.0.0:{}", port); + // Loopback only: InstantClone publishes to it on this machine, and the + // RTMP port takes a publish from anyone who can reach it. + let addr = format!("127.0.0.1:{}", port); let listener = TcpListener::bind(&addr).await?; let live = LiveStream::new(); @@ -624,7 +626,9 @@ fn flv_tag_bytes(tag_type: u8, ts: u32, payload: &[u8]) -> Vec { } async fn run_web(port: u16, live: Arc) -> io::Result<()> { - let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?; + // Loopback only, like the RTMP side: the preview is the live stream, + // served with CORS open, so it must not reach the LAN. + let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?; println!( "[sink-web] 🎬 open http://127.0.0.1:{}/ in your browser to watch live", port @@ -1382,6 +1386,67 @@ start(); "##; +/// One RTMP message a test platform received. +#[cfg(test)] +#[derive(Debug, Clone)] +pub(crate) struct Received { + /// Which connection it came on, counting from 0 in accept order. + pub conn: usize, + /// RTMP message type: 8 audio, 9 video, 20 command. + pub kind: u8, + pub ts: u32, + pub payload: Vec, + /// The command name, for type 20 (e.g. "deleteStream"). + pub command: Option, + pub at: Instant, +} + +/// A platform for in-process egress tests: accept publishers on +/// `listener`, answer them like the sink does, and forward every message +/// they send, in order, to `tx`. +#[cfg(test)] +pub(crate) async fn record( + listener: TcpListener, + tx: tokio::sync::mpsc::UnboundedSender, +) -> io::Result<()> { + for conn in 0.. { + let (mut sock, _) = listener.accept().await?; + let tx = tx.clone(); + tokio::spawn(async move { + handshake::perform_server(&mut sock).await?; + let (rd, wr) = split(sock); + let mut reader = ChunkReader::new(rd); + let mut writer = ChunkWriter::new(wr); + writer.send_set_chunk_size(4096).await?; + loop { + let msg = reader.read_message().await?; + let command = (msg.type_id == 20) + .then(|| amf0::decode_all(&msg.payload).ok()) + .flatten() + .and_then(|values| values.first().and_then(|v| v.as_str().map(String::from))); + let received = Received { + conn, + kind: msg.type_id, + ts: msg.timestamp, + payload: msg.payload.to_vec(), + command, + at: Instant::now(), + }; + if tx.send(received).is_err() { + return Ok::<(), io::Error>(()); + } + // Recorded first: a publisher that is leaving (FCUnpublish, + // then deleteStream, then close) may be gone before the + // reply to its goodbye can be written. + if msg.type_id == 20 { + let _ = handle_command(&mut writer, &msg).await; + } + } + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1392,6 +1457,33 @@ mod tests { /// would refuse to start for any path whose accent happened to straddle /// the cut. Same bug the config redactors and `scrub_secret` had; this /// was the last copy of it. + /// FLV splits the timestamp: the low 24 bits big-endian, then the top + /// 8 bits in a separate "extended" byte. Past 2^24 ms (~4.6 hours) a + /// writer that drops or misplaces that byte jumps the player's clock + /// backwards. The file recorder and the live web player each build + /// tags with their own copy of this code, so both are pinned. + #[test] + fn flv_tags_carry_the_extended_timestamp_byte() { + let expected = [ + 0x09, 0x00, 0x00, 0x02, // video, DataSize 2 + 0x34, 0x56, 0x78, 0x12, // ts low 24 bits, then bits 24..32 + 0x00, 0x00, 0x00, // StreamID + 0xAA, 0xBB, // payload + 0x00, 0x00, 0x00, 0x0D, // PreviousTagSize = 11 + 2 + ]; + assert_eq!(flv_tag_bytes(9, 0x1234_5678, &[0xAA, 0xBB]), expected); + + let path = std::env::temp_dir().join(format!("ic-test-sink-{}.flv", std::process::id())); + { + let mut w = FlvWriter::create(path.to_str().unwrap(), None).unwrap(); + w.write_tag(9, 0x1234_5678, &[0xAA, 0xBB]).unwrap(); + } + let file = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(&file[..13], b"FLV\x01\x05\x00\x00\x00\x09\x00\x00\x00\x00"); + assert_eq!(&file[13..], expected); + } + #[test] fn truncate_cuts_on_characters_not_bytes() { // The accent straddles byte 41, which is where the old code cut. diff --git a/src/slate/bitstream.rs b/src/slate/bitstream.rs new file mode 100644 index 0000000..5822116 --- /dev/null +++ b/src/slate/bitstream.rs @@ -0,0 +1,149 @@ +//! H.264 bit-level writing: fixed-width and Exp-Golomb fields, RBSP +//! trailing bits, and NAL unit framing with emulation prevention. + +/// MSB-first bit writer for one RBSP. +#[derive(Default)] +pub struct BitWriter { + bytes: Vec, + current: u8, + filled: u8, +} + +impl BitWriter { + pub fn new() -> Self { + Self::default() + } + + pub fn bit(&mut self, on: bool) { + self.current = (self.current << 1) | on as u8; + self.filled += 1; + if self.filled == 8 { + self.bytes.push(self.current); + self.current = 0; + self.filled = 0; + } + } + + /// Low `count` bits of `value`, most significant first. u(n). + pub fn bits(&mut self, value: u32, count: u8) { + for shift in (0..count).rev() { + self.bit((value >> shift) & 1 == 1); + } + } + + /// Unsigned Exp-Golomb, ue(v). + pub fn ue(&mut self, value: u32) { + let code = value as u64 + 1; + let length = 64 - code.leading_zeros() as u8; + for _ in 1..length { + self.bit(false); + } + for shift in (0..length).rev() { + self.bit((code >> shift) & 1 == 1); + } + } + + /// Signed Exp-Golomb, se(v): 1, -1, 2, -2, ... map to 1, 2, 3, 4, ... + pub fn se(&mut self, value: i32) { + let mapped = if value > 0 { + value as u32 * 2 - 1 + } else { + value.unsigned_abs() * 2 + }; + self.ue(mapped); + } + + /// Zero bits up to the next byte boundary (pcm_alignment_zero_bit). + pub fn align_with_zeros(&mut self) { + while self.filled != 0 { + self.bit(false); + } + } + + /// Whole bytes; the writer must be byte-aligned. + pub fn bytes(&mut self, data: &[u8]) { + debug_assert_eq!(self.filled, 0, "raw bytes need byte alignment"); + self.bytes.extend_from_slice(data); + } + + /// Close the RBSP with rbsp_stop_one_bit and alignment zeros. + pub fn finish(mut self) -> Vec { + self.bit(true); + self.align_with_zeros(); + self.bytes + } +} + +pub const NAL_SLICE: u8 = 1; +pub const NAL_IDR_SLICE: u8 = 5; +pub const NAL_SPS: u8 = 7; +pub const NAL_PPS: u8 = 8; + +/// NAL header plus the RBSP with emulation-prevention bytes inserted, so +/// no 00 00 0x (x <= 3) start-code pattern appears inside the payload. +pub fn nal_unit(nal_ref_idc: u8, nal_type: u8, rbsp: &[u8]) -> Vec { + let mut out = Vec::with_capacity(rbsp.len() + rbsp.len() / 64 + 1); + out.push((nal_ref_idc << 5) | nal_type); + let mut zeros = 0; + for &byte in rbsp { + if zeros >= 2 && byte <= 3 { + out.push(3); + zeros = 0; + } + out.push(byte); + zeros = if byte == 0 { zeros + 1 } else { 0 }; + } + out +} + +/// One AVCC sample: each NAL prefixed with its 4-byte big-endian length. +pub fn length_prefixed(nals: &[&[u8]]) -> Vec { + let total: usize = nals.iter().map(|nal| nal.len() + 4).sum(); + let mut out = Vec::with_capacity(total); + for nal in nals { + out.extend_from_slice(&(nal.len() as u32).to_be_bytes()); + out.extend_from_slice(nal); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn written(fill: impl FnOnce(&mut BitWriter)) -> Vec { + let mut writer = BitWriter::new(); + fill(&mut writer); + writer.finish() + } + + #[test] + fn exp_golomb_matches_the_spec_table() { + // ue: 0 -> 1, 1 -> 010, 2 -> 011, 3 -> 00100. Then the stop bit. + assert_eq!(written(|w| w.ue(0)), vec![0b1100_0000]); + assert_eq!(written(|w| w.ue(1)), vec![0b0101_0000]); + assert_eq!(written(|w| w.ue(3)), vec![0b0010_0100]); + // se: 1 -> ue(1), -1 -> ue(2). + assert_eq!(written(|w| w.se(1)), written(|w| w.ue(1))); + assert_eq!(written(|w| w.se(-1)), written(|w| w.ue(2))); + assert_eq!(written(|w| w.se(0)), written(|w| w.ue(0))); + } + + #[test] + fn bits_are_msb_first() { + assert_eq!(written(|w| w.bits(0b101, 3)), vec![0b1011_0000]); + assert_eq!( + written(|w| w.bits(0xDEAD_BEEF, 32)), + vec![0xDE, 0xAD, 0xBE, 0xEF, 0x80] + ); + } + + #[test] + fn emulation_prevention_breaks_start_code_patterns() { + assert_eq!( + nal_unit(3, NAL_SPS, &[0, 0, 1, 0, 0, 0, 0]), + vec![0x67, 0, 0, 3, 1, 0, 0, 3, 0, 0] + ); + assert_eq!(nal_unit(0, NAL_SLICE, &[0, 0, 4]), vec![0x01, 0, 0, 4]); + } +} diff --git a/src/slate/encoder.rs b/src/slate/encoder.rs new file mode 100644 index 0000000..b97cd0b --- /dev/null +++ b/src/slate/encoder.rs @@ -0,0 +1,679 @@ +//! Lossless H.264 encoder for flat-colour screens. +//! +//! Only needs three macroblock types, which is what keeps it small: +//! - I_16x16 with no residual: the block is an exact copy of a vertical, +//! horizontal or DC prediction from its neighbours (flat areas). +//! - I_PCM: the 384 raw samples, for anything with detail. +//! - P_Skip: unchanged since the previous frame. +//! +//! Every macroblock reconstructs to exactly its source samples, so later +//! predictions can read the source frame as if it were the decoder's +//! output. Deblocking is switched off in every slice so nothing touches +//! the reconstruction afterwards. +//! +//! Stream shape: Constrained Baseline, CAVLC, one slice per picture, a +//! single reference frame, POC type 2 (output order = decode order). + +use super::bitstream::{self, BitWriter}; +use super::raster::YuvFrame; + +const MB: usize = 16; +const CHROMA_MB: usize = 8; +const LOG2_MAX_FRAME_NUM: u8 = 8; +/// Parameter-set ids for the reconnect screen. Streams from OBS use id 0, +/// so the screen's own SPS/PPS sit beside the stream's in a decoder instead +/// of replacing them: switching to the screen and back needs only a +/// keyframe, never a new sequence header (which decoders don't reliably +/// apply mid-stream). +const SPS_ID: u32 = 1; +const PPS_ID: u32 = 1; +const PROFILE_BASELINE: u32 = 66; +/// constraint_set0 + constraint_set1: Constrained Baseline. +const CONSTRAINT_FLAGS: u32 = 0b1100_0000; +const MB_TYPE_I_PCM: u32 = 25; +/// Intra mb_type values are offset by 5 inside P slices. +const P_SLICE_INTRA_OFFSET: u32 = 5; +const SLICE_TYPE_P: u32 = 5; +const SLICE_TYPE_I: u32 = 7; +/// nC reported for an I_PCM neighbour (spec 9.2.1). +const PCM_TOTAL_COEFF: u32 = 16; + +/// Visible size and frame rate of the stream being encoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StreamShape { + pub width: usize, + pub height: usize, + pub fps: u32, +} + +/// SPS and PPS NAL units (header included, no length prefix). +pub struct ParameterSets { + pub sps: Vec, + pub pps: Vec, +} + +pub fn parameter_sets(shape: StreamShape) -> ParameterSets { + ParameterSets { + sps: bitstream::nal_unit(3, bitstream::NAL_SPS, &sps_rbsp(shape)), + pps: bitstream::nal_unit(3, bitstream::NAL_PPS, &pps_rbsp()), + } +} + +fn sps_rbsp(shape: StreamShape) -> Vec { + let width_mbs = shape.width.div_ceil(MB); + let height_mbs = shape.height.div_ceil(MB); + let mut w = BitWriter::new(); + w.bits(PROFILE_BASELINE, 8); + w.bits(CONSTRAINT_FLAGS, 8); + w.bits(level_idc(width_mbs * height_mbs, shape.fps), 8); + w.ue(SPS_ID); + w.ue(LOG2_MAX_FRAME_NUM as u32 - 4); + w.ue(2); // pic_order_cnt_type: POC follows frame_num + w.ue(1); // max_num_ref_frames + w.bit(false); // gaps_in_frame_num_value_allowed_flag + w.ue(width_mbs as u32 - 1); + w.ue(height_mbs as u32 - 1); + w.bit(true); // frame_mbs_only_flag + w.bit(true); // direct_8x8_inference_flag + let crop_right = (width_mbs * MB - shape.width) / 2; + let crop_bottom = (height_mbs * MB - shape.height) / 2; + let needs_crop = crop_right > 0 || crop_bottom > 0; + w.bit(needs_crop); + if needs_crop { + // Crop units are 2 luma samples in 4:2:0. + w.ue(0); + w.ue(crop_right as u32); + w.ue(0); + w.ue(crop_bottom as u32); + } + w.bit(true); // vui_parameters_present_flag + write_vui(&mut w, shape.fps); + w.finish() +} + +/// BT.709 limited range (matches `raster`), fixed frame rate, and no +/// frame reordering so players can show each frame as it arrives. +fn write_vui(w: &mut BitWriter, fps: u32) { + w.bit(false); // aspect_ratio_info_present_flag + w.bit(false); // overscan_info_present_flag + w.bit(true); // video_signal_type_present_flag + w.bits(5, 3); // video_format: unspecified + w.bit(false); // video_full_range_flag + w.bit(true); // colour_description_present_flag + w.bits(1, 8); // colour_primaries: BT.709 + w.bits(1, 8); // transfer_characteristics: BT.709 + w.bits(1, 8); // matrix_coefficients: BT.709 + w.bit(false); // chroma_loc_info_present_flag + w.bit(true); // timing_info_present_flag + w.bits(1, 32); // num_units_in_tick + w.bits(fps * 2, 32); // time_scale: two ticks per frame + w.bit(true); // fixed_frame_rate_flag + w.bit(false); // nal_hrd_parameters_present_flag + w.bit(false); // vcl_hrd_parameters_present_flag + w.bit(false); // pic_struct_present_flag + w.bit(true); // bitstream_restriction_flag + w.bit(true); // motion_vectors_over_pic_boundaries_flag + w.ue(0); // max_bytes_per_pic_denom: no limit + w.ue(0); // max_bits_per_mb_denom: no limit + w.ue(16); // log2_max_mv_length_horizontal + w.ue(16); // log2_max_mv_length_vertical + w.ue(0); // max_num_reorder_frames + w.ue(1); // max_dec_frame_buffering +} + +/// Smallest level (Table A-1) whose frame size and macroblock rate fit. +fn level_idc(frame_mbs: usize, fps: u32) -> u32 { + const LEVELS: [(u32, usize, usize); 10] = [ + (30, 1_620, 40_500), + (31, 3_600, 108_000), + (32, 5_120, 216_000), + (40, 8_192, 245_760), + (42, 8_704, 522_240), + (50, 22_080, 589_824), + (51, 36_864, 983_040), + (52, 36_864, 2_073_600), + (60, 139_264, 4_177_920), + (61, 139_264, 8_355_840), + ]; + let mb_rate = frame_mbs * fps as usize; + LEVELS + .iter() + .find(|(_, max_fs, max_mbps)| frame_mbs <= *max_fs && mb_rate <= *max_mbps) + .map_or(62, |(level, _, _)| *level) +} + +fn pps_rbsp() -> Vec { + let mut w = BitWriter::new(); + w.ue(PPS_ID); + w.ue(SPS_ID); + w.bit(false); // entropy_coding_mode_flag: CAVLC + w.bit(false); // bottom_field_pic_order_in_frame_present_flag + w.ue(0); // num_slice_groups_minus1 + w.ue(0); // num_ref_idx_l0_default_active_minus1 + w.ue(0); // num_ref_idx_l1_default_active_minus1 + w.bit(false); // weighted_pred_flag + w.bits(0, 2); // weighted_bipred_idc + w.se(0); // pic_init_qp_minus26 + w.se(0); // pic_init_qs_minus26 + w.se(0); // chroma_qp_index_offset + w.bit(true); // deblocking_filter_control_present_flag + w.bit(false); // constrained_intra_pred_flag + w.bit(false); // redundant_pic_cnt_present_flag + w.finish() +} + +/// IDR picture. Consecutive IDRs must differ in `idr_pic_id`, so a loop +/// that replays its keyframe alternates between two ids. +pub fn encode_idr(frame: &YuvFrame, idr_pic_id: u32) -> Vec { + let mut w = BitWriter::new(); + w.ue(0); // first_mb_in_slice + w.ue(SLICE_TYPE_I); + w.ue(PPS_ID); + w.bits(0, LOG2_MAX_FRAME_NUM); // frame_num + w.ue(idr_pic_id); + w.bit(false); // no_output_of_prior_pics_flag + w.bit(false); // long_term_reference_flag + write_slice_tail(&mut w); + let mut coder = MacroblockCoder::new(frame); + for index in 0..coder.mb_count() { + let coding = choose_intra(frame, coder.position(index)); + coder.write(&mut w, index, coding, 0); + } + bitstream::nal_unit(3, bitstream::NAL_IDR_SLICE, &w.finish()) +} + +/// P picture: blocks identical to `previous` are skipped, the rest are +/// intra coded. Every P picture is a reference so the next one can skip +/// against it; `frame_num` counts up from 1 after the IDR. +pub fn encode_p(frame: &YuvFrame, previous: &YuvFrame, frame_num: u32) -> Vec { + let mut w = BitWriter::new(); + w.ue(0); // first_mb_in_slice + w.ue(SLICE_TYPE_P); + w.ue(PPS_ID); + w.bits(frame_num % (1 << LOG2_MAX_FRAME_NUM), LOG2_MAX_FRAME_NUM); + w.bit(false); // num_ref_idx_active_override_flag + w.bit(false); // ref_pic_list_modification_flag_l0 + w.bit(false); // adaptive_ref_pic_marking_mode_flag + write_slice_tail(&mut w); + + let mut coder = MacroblockCoder::new(frame); + let mut skip_run = 0; + for index in 0..coder.mb_count() { + let position = coder.position(index); + if is_unchanged(frame, previous, position) { + coder.mark_skipped(index); + skip_run += 1; + continue; + } + w.ue(skip_run); + skip_run = 0; + coder.write( + &mut w, + index, + choose_intra(frame, position), + P_SLICE_INTRA_OFFSET, + ); + } + if skip_run > 0 { + w.ue(skip_run); + } + bitstream::nal_unit(2, bitstream::NAL_SLICE, &w.finish()) +} + +/// slice_qp_delta and the deblocking switch, shared by both slice types. +fn write_slice_tail(w: &mut BitWriter) { + w.se(0); // slice_qp_delta + w.ue(1); // disable_deblocking_filter_idc: off +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MbPosition { + x: usize, + y: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LumaMode { + Vertical = 0, + Horizontal = 1, + Dc = 2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChromaMode { + Dc = 0, + Horizontal = 1, + Vertical = 2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MbCoding { + Predicted { luma: LumaMode, chroma: ChromaMode }, + Pcm, +} + +/// Writes macroblocks and remembers which were I_PCM, because the +/// CAVLC coeff_token table for the next block depends on it. +struct MacroblockCoder<'a> { + frame: &'a YuvFrame, + width_mbs: usize, + is_pcm: Vec, +} + +impl<'a> MacroblockCoder<'a> { + fn new(frame: &'a YuvFrame) -> Self { + let width_mbs = frame.width / MB; + let height_mbs = frame.height / MB; + Self { + frame, + width_mbs, + is_pcm: vec![false; width_mbs * height_mbs], + } + } + + fn mb_count(&self) -> usize { + self.is_pcm.len() + } + + fn position(&self, index: usize) -> MbPosition { + MbPosition { + x: index % self.width_mbs, + y: index / self.width_mbs, + } + } + + fn mark_skipped(&mut self, index: usize) { + self.is_pcm[index] = false; + } + + fn write(&mut self, w: &mut BitWriter, index: usize, coding: MbCoding, type_offset: u32) { + match coding { + MbCoding::Pcm => { + w.ue(type_offset + MB_TYPE_I_PCM); + w.align_with_zeros(); + write_pcm_samples(w, self.frame, self.position(index)); + self.is_pcm[index] = true; + } + MbCoding::Predicted { luma, chroma } => { + // I_16x16__0_0: both coded block patterns zero. + w.ue(type_offset + 1 + luma as u32); + w.ue(chroma as u32); + w.se(0); // mb_qp_delta + self.write_empty_dc_block(w, index); + self.is_pcm[index] = false; + } + } + } + + /// Intra16x16DCLevel with no coefficients. Its coeff_token codeword + /// depends on nC, the coefficient counts of the left and top blocks. + fn write_empty_dc_block(&self, w: &mut BitWriter, index: usize) { + let position = self.position(index); + let left = (position.x > 0).then(|| self.neighbour_total_coeff(index - 1)); + let top = (position.y > 0).then(|| self.neighbour_total_coeff(index - self.width_mbs)); + let n_c = match (left, top) { + (Some(a), Some(b)) => (a + b + 1) >> 1, + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => 0, + }; + // TotalCoeff = 0, TrailingOnes = 0 in Table 9-5. + match n_c { + 0..=1 => w.bits(0b1, 1), + 2..=3 => w.bits(0b11, 2), + 4..=7 => w.bits(0b1111, 4), + _ => w.bits(0b000011, 6), + } + } + + /// Skipped and predicted blocks carry no coefficients; I_PCM counts + /// as 16. + fn neighbour_total_coeff(&self, index: usize) -> u32 { + if self.is_pcm[index] { + PCM_TOTAL_COEFF + } else { + 0 + } + } +} + +fn write_pcm_samples(w: &mut BitWriter, frame: &YuvFrame, position: MbPosition) { + for row in 0..MB { + let start = (position.y * MB + row) * frame.width + position.x * MB; + w.bytes(&frame.y[start..start + MB]); + } + let chroma_width = frame.width / 2; + for plane in [&frame.u, &frame.v] { + for row in 0..CHROMA_MB { + let start = (position.y * CHROMA_MB + row) * chroma_width + position.x * CHROMA_MB; + w.bytes(&plane[start..start + CHROMA_MB]); + } + } +} + +fn is_unchanged(frame: &YuvFrame, previous: &YuvFrame, position: MbPosition) -> bool { + let luma_same = (0..MB).all(|row| { + let start = (position.y * MB + row) * frame.width + position.x * MB; + frame.y[start..start + MB] == previous.y[start..start + MB] + }); + let chroma_width = frame.width / 2; + luma_same + && (0..CHROMA_MB).all(|row| { + let start = (position.y * CHROMA_MB + row) * chroma_width + position.x * CHROMA_MB; + frame.u[start..start + CHROMA_MB] == previous.u[start..start + CHROMA_MB] + && frame.v[start..start + CHROMA_MB] == previous.v[start..start + CHROMA_MB] + }) +} + +/// First luma + chroma prediction pair that reproduces the block +/// exactly, or I_PCM when none does. +fn choose_intra(frame: &YuvFrame, position: MbPosition) -> MbCoding { + let luma = [LumaMode::Vertical, LumaMode::Horizontal, LumaMode::Dc] + .into_iter() + .find(|mode| predicts_luma(frame, position, *mode)); + let chroma = [ChromaMode::Dc, ChromaMode::Horizontal, ChromaMode::Vertical] + .into_iter() + .find(|mode| predicts_chroma(frame, position, *mode)); + match (luma, chroma) { + (Some(luma), Some(chroma)) => MbCoding::Predicted { luma, chroma }, + _ => MbCoding::Pcm, + } +} + +/// One plane of a frame plus the block geometry, so the luma and both +/// chroma predictors share the same neighbour reads. +struct PlaneBlock<'a> { + samples: &'a [u8], + stride: usize, + x0: usize, + y0: usize, + size: usize, +} + +impl PlaneBlock<'_> { + fn at(&self, x: usize, y: usize) -> u8 { + self.samples[(self.y0 + y) * self.stride + self.x0 + x] + } + + fn has_top(&self) -> bool { + self.y0 > 0 + } + + fn has_left(&self) -> bool { + self.x0 > 0 + } + + fn top(&self, x: usize) -> u32 { + self.samples[(self.y0 - 1) * self.stride + self.x0 + x] as u32 + } + + fn left(&self, y: usize) -> u32 { + self.samples[(self.y0 + y) * self.stride + self.x0 - 1] as u32 + } + + /// Whether `predict(x, y)` equals every sample of the block. + fn matches(&self, predict: impl Fn(usize, usize) -> u32) -> bool { + (0..self.size).all(|y| (0..self.size).all(|x| self.at(x, y) as u32 == predict(x, y))) + } +} + +fn luma_block(frame: &YuvFrame, position: MbPosition) -> PlaneBlock<'_> { + PlaneBlock { + samples: &frame.y, + stride: frame.width, + x0: position.x * MB, + y0: position.y * MB, + size: MB, + } +} + +fn chroma_blocks(frame: &YuvFrame, position: MbPosition) -> [PlaneBlock<'_>; 2] { + let block = |samples| PlaneBlock { + samples, + stride: frame.width / 2, + x0: position.x * CHROMA_MB, + y0: position.y * CHROMA_MB, + size: CHROMA_MB, + }; + [block(&frame.u), block(&frame.v)] +} + +/// Intra_16x16 prediction (spec 8.3.3). +fn predicts_luma(frame: &YuvFrame, position: MbPosition, mode: LumaMode) -> bool { + let block = luma_block(frame, position); + match mode { + LumaMode::Vertical => block.has_top() && block.matches(|x, _| block.top(x)), + LumaMode::Horizontal => block.has_left() && block.matches(|_, y| block.left(y)), + LumaMode::Dc => { + let top: u32 = (0..MB) + .map(|x| if block.has_top() { block.top(x) } else { 0 }) + .sum(); + let left: u32 = (0..MB) + .map(|y| if block.has_left() { block.left(y) } else { 0 }) + .sum(); + let dc = match (block.has_top(), block.has_left()) { + (true, true) => (top + left + 16) >> 5, + (true, false) => (top + 8) >> 4, + (false, true) => (left + 8) >> 4, + (false, false) => 128, + }; + block.matches(|_, _| dc) + } + } +} + +/// Intra chroma prediction (spec 8.3.4) for both chroma planes. +fn predicts_chroma(frame: &YuvFrame, position: MbPosition, mode: ChromaMode) -> bool { + chroma_blocks(frame, position) + .iter() + .all(|block| match mode { + ChromaMode::Vertical => block.has_top() && block.matches(|x, _| block.top(x)), + ChromaMode::Horizontal => block.has_left() && block.matches(|_, y| block.left(y)), + ChromaMode::Dc => block.matches(|x, y| chroma_dc(block, x / 4 * 4, y / 4 * 4)), + }) +} + +/// DC value of the 4x4 chroma sub-block at (x_offset, y_offset), spec +/// 8.3.4.1-3. The corner and diagonal sub-blocks average both edges; +/// the top-right one prefers the top edge, the bottom-left one the left. +fn chroma_dc(block: &PlaneBlock, x_offset: usize, y_offset: usize) -> u32 { + let top_sum = || (0..4).map(|i| block.top(x_offset + i)).sum::(); + let left_sum = || (0..4).map(|i| block.left(y_offset + i)).sum::(); + let (has_top, has_left) = (block.has_top(), block.has_left()); + let is_corner_or_diagonal = (x_offset == 0) == (y_offset == 0); + if is_corner_or_diagonal && has_top && has_left { + return (top_sum() + left_sum() + 4) >> 3; + } + let prefers_top = x_offset > 0 && y_offset == 0; + match (prefers_top, has_top, has_left) { + (true, true, _) => (top_sum() + 2) >> 2, + (false, _, true) => (left_sum() + 2) >> 2, + (_, true, _) => (top_sum() + 2) >> 2, + (_, _, true) => (left_sum() + 2) >> 2, + _ => 128, + } +} + +#[cfg(test)] +mod tests { + use super::super::raster::{Canvas, Rgb}; + use super::*; + + const BACKGROUND: Rgb = Rgb::new(0x0e, 0x0f, 0x12); + const SHAPE_1080P: StreamShape = StreamShape { + width: 1920, + height: 1080, + fps: 30, + }; + + /// Reads fields back out of one NAL unit: past its header byte, with + /// the emulation-prevention bytes dropped. + struct FieldReader { + bits: Vec, + at: usize, + } + + impl FieldReader { + fn new(nal: &[u8]) -> Self { + let mut rbsp = Vec::new(); + let mut zeros = 0; + for &byte in &nal[1..] { + if zeros >= 2 && byte == 3 { + zeros = 0; + continue; + } + rbsp.push(byte); + zeros = if byte == 0 { zeros + 1 } else { 0 }; + } + let bits = rbsp + .iter() + .flat_map(|byte| (0..8).rev().map(move |shift| (byte >> shift) & 1 == 1)) + .collect(); + Self { bits, at: 0 } + } + + /// u(n) + fn bits(&mut self, count: usize) -> u32 { + let field = &self.bits[self.at..self.at + count]; + self.at += count; + field + .iter() + .fold(0, |value, bit| (value << 1) | u32::from(*bit)) + } + + /// ue(v) + fn ue(&mut self) -> u32 { + let zeros = self.bits[self.at..].iter().take_while(|bit| !**bit).count(); + self.at += zeros; + self.bits(zeros + 1) - 1 + } + } + + /// The SPS's id and how many bits its frame_num takes in a slice. + fn sps_id_and_frame_num_bits(sps: &[u8]) -> (u32, usize) { + let mut reader = FieldReader::new(sps); + reader.bits(24); // profile_idc, constraint flags, level_idc + let id = reader.ue(); + (id, reader.ue() as usize + 4) + } + + /// A slice header read up to frame_num: (reader, slice_type % 5, + /// pic_parameter_set_id, frame_num). + fn read_slice_header(slice: &[u8], frame_num_bits: usize) -> (FieldReader, u32, u32, u32) { + let mut header = FieldReader::new(slice); + assert_eq!(header.ue(), 0, "one slice per picture"); + let slice_type = header.ue() % 5; + let pps_id = header.ue(); + let frame_num = header.bits(frame_num_bits); + (header, slice_type, pps_id, frame_num) + } + + fn flat_frame(width: usize, height: usize) -> YuvFrame { + Canvas::new(width, height, BACKGROUND).to_yuv420() + } + + /// OBS's streams use parameter-set id 0. The screen's SPS and PPS must + /// sit under another id, and its slices must name that id: otherwise + /// switching to the screen overwrites the stream's own SPS in every + /// decoder, and switching back needs a sequence header they may ignore. + #[test] + fn the_screen_never_reuses_the_parameter_set_ids_of_the_stream() { + let sets = parameter_sets(SHAPE_1080P); + let (sps_id, frame_num_bits) = sps_id_and_frame_num_bits(&sets.sps); + let mut pps = FieldReader::new(&sets.pps); + let pps_id = pps.ue(); + assert_ne!(sps_id, 0, "SPS id 0 belongs to the stream"); + assert_ne!(pps_id, 0, "PPS id 0 belongs to the stream"); + assert_eq!(pps.ue(), sps_id, "the PPS points at the screen's SPS"); + + let frame = flat_frame(64, 64); + for slice in [encode_idr(&frame, 0), encode_p(&frame, &frame, 1)] { + let (_, _, slice_pps_id, _) = read_slice_header(&slice, frame_num_bits); + assert_eq!(slice_pps_id, pps_id, "slices point at the screen's PPS"); + } + } + + /// What replaying the loop relies on: each IDR carries the idr_pic_id + /// it was asked for (back-to-back IDRs must differ), and P pictures are + /// kept as references, since the next one skips against them, with + /// frame_num counting up and wrapping at the size the SPS declares. + /// A 240 fps loop has 480 pictures, so the wrap is a real path. + #[test] + fn slice_headers_number_the_loop_the_way_the_sps_says() { + let (_, frame_num_bits) = sps_id_and_frame_num_bits(¶meter_sets(SHAPE_1080P).sps); + let frame = flat_frame(64, 64); + for idr_pic_id in [0, 1] { + let idr = encode_idr(&frame, idr_pic_id); + assert_eq!(idr[0] & 0x1F, bitstream::NAL_IDR_SLICE); + assert_ne!(idr[0] >> 5, 0, "an IDR must be a reference"); + let (mut header, slice_type, _, frame_num) = read_slice_header(&idr, frame_num_bits); + assert_eq!((slice_type, frame_num), (2, 0), "I slice, frame_num 0"); + assert_eq!(header.ue(), idr_pic_id); + } + let max_frame_num = 1 << frame_num_bits; + for index in [1, max_frame_num - 1, max_frame_num, max_frame_num + 223] { + let p = encode_p(&frame, &frame, index); + assert_eq!(p[0] & 0x1F, bitstream::NAL_SLICE); + assert_ne!(p[0] >> 5, 0, "picture {index} must be a reference"); + let (_, slice_type, _, frame_num) = read_slice_header(&p, frame_num_bits); + assert_eq!(slice_type, 0, "picture {index} is a P slice"); + assert_eq!(frame_num, index % max_frame_num, "picture {index}"); + } + } + + /// level_idc must be the smallest level of Table A-1 whose frame size + /// and macroblock rate both fit: too low and strict decoders refuse + /// the screen. The table starts at 3.0, so anything smaller reports + /// 3.0, which every decoder of a real stream handles. + #[test] + fn level_is_the_smallest_in_table_a1_that_fits() { + for (width, height, fps, level) in [ + (320, 180, 30, 30), + (854, 480, 30, 31), + (1280, 720, 30, 31), + (1280, 720, 60, 32), + (1920, 1080, 30, 40), + (1080, 1920, 30, 40), + (1920, 1080, 60, 42), + (2560, 1440, 30, 50), + (2560, 1440, 60, 51), + (3840, 2160, 30, 51), + (3840, 2160, 60, 52), + (7680, 4320, 30, 60), + (7680, 4320, 60, 61), + (7680, 4320, 120, 62), + ] { + let sps = parameter_sets(StreamShape { width, height, fps }).sps; + assert_eq!(sps[3], level, "{width}x{height} at {fps} fps"); + } + } + + /// A picture identical to the previous one is a single skip run, a few + /// bytes at any resolution: that is what keeps the held screen cheap. + /// A block that did change must still be coded, and only that block, + /// or viewers keep a stale picture. + #[test] + fn a_p_picture_codes_only_the_blocks_that_changed() { + let still = flat_frame(1920, 1080); + let unchanged = encode_p(&still, &still, 1); + assert!( + unchanged.len() < 16, + "{} bytes for an unchanged 1080p picture", + unchanged.len() + ); + + // Noise in one macroblock: no prediction reproduces it, so it goes + // out as I_PCM, 384 raw samples. + let mut noisy = Canvas::new(1920, 1080, BACKGROUND); + for i in 0..256 { + let color = Rgb::new((i * 37) as u8, (i * 91) as u8, (i * 53) as u8); + noisy.fill_rect(800 + i % 16, 400 + i / 16, 1, 1, color); + } + let changed = encode_p(&noisy.to_yuv420(), &still, 1); + assert!( + (384..384 + 32).contains(&changed.len()), + "{} bytes for one changed block", + changed.len() + ); + } +} diff --git a/src/slate/font.rs b/src/slate/font.rs new file mode 100644 index 0000000..800fd74 --- /dev/null +++ b/src/slate/font.rs @@ -0,0 +1,572 @@ +//! Text for the reconnect screen: a minimal TrueType reader and an +//! anti-aliased coverage rasterizer, enough to draw one line of Inter. +//! +//! The embedded fonts are built by `scripts/subset_slate_fonts.py`, which +//! guarantees what this reader relies on: a format 4 cmap, quadratic +//! outlines and no composite glyphs. No kerning; the screen text is a +//! short headline where it isn't missed. +//! +//! The rasterizer is the signed-area accumulation technique used by +//! font-rs (Raph Levien): each edge adds signed coverage to the cells it +//! crosses, and a running sum along each row turns that into per-pixel +//! coverage. + +use super::raster::{Canvas, Rgb}; +use std::sync::OnceLock; + +static REGULAR_TTF: &[u8] = include_bytes!("../../assets/fonts/Inter-Regular-slate.ttf"); +static SEMIBOLD_TTF: &[u8] = include_bytes!("../../assets/fonts/Inter-SemiBold-slate.ttf"); +/// Vertical reach of a line relative to its size: accented capitals rise +/// to about 1.2 em above the baseline and descenders drop about 0.3 em. +const ABOVE_BASELINE_EM: f32 = 1.25; +const BELOW_BASELINE_EM: f32 = 0.4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Weight { + Regular, + SemiBold, +} + +/// Which point of the line `x` names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + Center, + Left, +} + +/// How one line of text is set. +#[derive(Debug, Clone, Copy)] +pub struct Line { + pub size_px: f32, + pub weight: Weight, + pub align: Align, +} + +/// Draw `text` with its baseline at `baseline_y`, centred on `x` or +/// starting at it, as `line.align` says. +pub fn draw(canvas: &mut Canvas, text: &str, x: f32, baseline_y: f32, line: Line, color: Rgb) { + let Some(font) = font_for(line.weight) else { + return; + }; + let scale = line.size_px / font.units_per_em; + let glyphs: Vec = text.chars().map(|c| font.glyph_id(c)).collect(); + let width: f32 = glyphs.iter().map(|gid| font.advance(*gid) * scale).sum(); + + let band_top = (baseline_y - line.size_px * ABOVE_BASELINE_EM) + .floor() + .max(0.0) as usize; + let band_bottom = ((baseline_y + line.size_px * BELOW_BASELINE_EM) + .ceil() + .max(0.0) as usize) + .min(canvas.height()); + if band_bottom <= band_top { + return; + } + let mut coverage = Coverage::new(canvas.width(), band_top, band_bottom - band_top); + let mut pen_x = match line.align { + Align::Center => x - width / 2.0, + Align::Left => x, + }; + for gid in glyphs { + for contour in font.contours(gid) { + let to_pixels = + |(x, y): (f32, f32)| (pen_x + x * scale, baseline_y - y * scale - band_top as f32); + let points: Vec<(f32, f32)> = flatten_contour(&contour) + .into_iter() + .map(to_pixels) + .collect(); + for pair in points.windows(2) { + coverage.add_line(pair[0], pair[1]); + } + } + pen_x += font.advance(gid) * scale; + } + coverage.blend_into(canvas, color); +} + +fn font_for(weight: Weight) -> Option<&'static Font> { + static REGULAR: OnceLock> = OnceLock::new(); + static SEMIBOLD: OnceLock> = OnceLock::new(); + match weight { + Weight::Regular => REGULAR.get_or_init(|| Font::parse(REGULAR_TTF)).as_ref(), + Weight::SemiBold => SEMIBOLD.get_or_init(|| Font::parse(SEMIBOLD_TTF)).as_ref(), + } +} + +/// One outline point in font units. +#[derive(Debug, Clone, Copy)] +struct Point { + x: f32, + y: f32, + on_curve: bool, +} + +struct Font { + data: &'static [u8], + units_per_em: f32, + long_loca: bool, + num_glyphs: u16, + num_h_metrics: u16, + loca: usize, + glyf: usize, + hmtx: usize, + cmap4: usize, +} + +impl Font { + fn parse(data: &'static [u8]) -> Option { + let table = |tag: &[u8; 4]| find_table(data, tag); + let head = table(b"head")?; + let cmap = table(b"cmap")?; + Some(Self { + data, + units_per_em: read_u16(data, head + 18)? as f32, + long_loca: read_u16(data, head + 50)? == 1, + num_glyphs: read_u16(data, table(b"maxp")? + 4)?, + num_h_metrics: read_u16(data, table(b"hhea")? + 34)?, + loca: table(b"loca")?, + glyf: table(b"glyf")?, + hmtx: table(b"hmtx")?, + cmap4: find_cmap4(data, cmap)?, + }) + } + + /// Glyph for `c`, or 0 (.notdef) when the subset doesn't have it. + fn glyph_id(&self, c: char) -> u16 { + let code = c as u32; + if code > 0xFFFF { + return 0; + } + self.cmap4_lookup(code as u16).unwrap_or(0) + } + + fn cmap4_lookup(&self, code: u16) -> Option { + let d = self.data; + let base = self.cmap4; + let seg_count = (read_u16(d, base + 6)? / 2) as usize; + let ends = base + 14; + let starts = ends + seg_count * 2 + 2; + let deltas = starts + seg_count * 2; + let offsets = deltas + seg_count * 2; + for seg in 0..seg_count { + if read_u16(d, ends + seg * 2)? < code { + continue; + } + let start = read_u16(d, starts + seg * 2)?; + if start > code { + return None; + } + let delta = read_u16(d, deltas + seg * 2)?; + let range_offset = read_u16(d, offsets + seg * 2)? as usize; + if range_offset == 0 { + return Some(code.wrapping_add(delta)); + } + let at = offsets + seg * 2 + range_offset + (code - start) as usize * 2; + let gid = read_u16(d, at)?; + return Some(if gid == 0 { 0 } else { gid.wrapping_add(delta) }); + } + None + } + + fn advance(&self, gid: u16) -> f32 { + let index = gid.min(self.num_h_metrics.saturating_sub(1)) as usize; + read_u16(self.data, self.hmtx + index * 4).unwrap_or(0) as f32 + } + + fn glyph_range(&self, gid: u16) -> Option<(usize, usize)> { + if gid >= self.num_glyphs { + return None; + } + let gid = gid as usize; + let (start, end) = if self.long_loca { + ( + read_u32(self.data, self.loca + gid * 4)?, + read_u32(self.data, self.loca + gid * 4 + 4)?, + ) + } else { + let short = |i: usize| read_u16(self.data, self.loca + i * 2).map(|v| v as u32 * 2); + (short(gid)?, short(gid + 1)?) + }; + Some((self.glyf + start as usize, self.glyf + end as usize)) + } + + /// Outline contours of a simple glyph. Empty for blank glyphs + /// (space) and for anything malformed. + fn contours(&self, gid: u16) -> Vec> { + self.glyph_range(gid) + .filter(|(start, end)| end > start) + .and_then(|(start, _)| parse_simple_glyph(self.data, start)) + .unwrap_or_default() + } +} + +fn parse_simple_glyph(d: &[u8], start: usize) -> Option>> { + let contour_count = read_u16(d, start)? as i16; + if contour_count <= 0 { + return None; + } + let mut ends = Vec::with_capacity(contour_count as usize); + for i in 0..contour_count as usize { + ends.push(read_u16(d, start + 10 + i * 2)? as usize); + } + let point_count = *ends.last()? + 1; + let instructions_len = read_u16(d, start + 10 + ends.len() * 2)? as usize; + let mut at = start + 12 + ends.len() * 2 + instructions_len; + + let flags = read_flags(d, &mut at, point_count)?; + let xs = read_coordinates(d, &mut at, &flags, 0x02, 0x10)?; + let ys = read_coordinates(d, &mut at, &flags, 0x04, 0x20)?; + + let mut contours = Vec::with_capacity(ends.len()); + let mut first = 0; + for end in ends { + let contour = (first..=end.min(point_count - 1)) + .map(|i| Point { + x: xs[i], + y: ys[i], + on_curve: flags[i] & 0x01 != 0, + }) + .collect(); + contours.push(contour); + first = end + 1; + } + Some(contours) +} + +fn read_flags(d: &[u8], at: &mut usize, count: usize) -> Option> { + let mut flags = Vec::with_capacity(count); + while flags.len() < count { + let flag = *d.get(*at)?; + *at += 1; + flags.push(flag); + if flag & 0x08 != 0 { + let repeats = *d.get(*at)?; + *at += 1; + flags.extend(std::iter::repeat_n(flag, repeats as usize)); + } + } + flags.truncate(count); + Some(flags) +} + +/// Decode one delta-encoded coordinate array. `short_bit` marks a 1-byte +/// delta whose sign is `same_bit`; without `short_bit`, `same_bit` means +/// "unchanged" and otherwise a signed 2-byte delta follows. +fn read_coordinates( + d: &[u8], + at: &mut usize, + flags: &[u8], + short_bit: u8, + same_bit: u8, +) -> Option> { + let mut value: i32 = 0; + let mut out = Vec::with_capacity(flags.len()); + for flag in flags { + if flag & short_bit != 0 { + let delta = *d.get(*at)? as i32; + *at += 1; + value += if flag & same_bit != 0 { delta } else { -delta }; + } else if flag & same_bit == 0 { + value += read_u16(d, *at)? as i16 as i32; + *at += 2; + } + out.push(value as f32); + } + Some(out) +} + +/// Turn a contour of on/off-curve points into a closed polyline in font +/// units. Two off-curve points in a row imply an on-curve midpoint. +fn flatten_contour(points: &[Point]) -> Vec<(f32, f32)> { + let Some(start_index) = points.iter().position(|p| p.on_curve) else { + return Vec::new(); + }; + let start = points[start_index]; + let mut out = vec![(start.x, start.y)]; + let mut control: Option = None; + let rotated = points[start_index + 1..] + .iter() + .chain(&points[..=start_index]); + for point in rotated { + let current = *out.last().unwrap_or(&(start.x, start.y)); + match (control, point.on_curve) { + (None, true) => out.push((point.x, point.y)), + (None, false) => control = Some(*point), + (Some(c), true) => { + push_quadratic(&mut out, current, (c.x, c.y), (point.x, point.y)); + control = None; + } + (Some(c), false) => { + let mid = ((c.x + point.x) / 2.0, (c.y + point.y) / 2.0); + push_quadratic(&mut out, current, (c.x, c.y), mid); + control = Some(*point); + } + } + } + out +} + +/// Append a quadratic Bezier as line segments. Segment count follows +/// how far the control point bends the curve (font-rs's heuristic, +/// in font units so it's generous at small sizes). +fn push_quadratic(out: &mut Vec<(f32, f32)>, p0: (f32, f32), p1: (f32, f32), p2: (f32, f32)) { + let bend_x = p0.0 - 2.0 * p1.0 + p2.0; + let bend_y = p0.1 - 2.0 * p1.1 + p2.1; + let bend = (bend_x * bend_x + bend_y * bend_y).sqrt(); + let steps = (bend.sqrt() / 2.0).ceil().clamp(1.0, 24.0) as usize; + for step in 1..=steps { + let t = step as f32 / steps as f32; + let u = 1.0 - t; + out.push(( + u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, + u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1, + )); + } +} + +/// Signed-area accumulation buffer for a band of canvas rows starting +/// at `top`. Edge coordinates are relative to the band. +struct Coverage { + width: usize, + top: usize, + height: usize, + cells: Vec, +} + +impl Coverage { + fn new(width: usize, top: usize, height: usize) -> Self { + // Two spare columns so edges at the right border never index out. + Self { + width, + top, + height, + cells: vec![0.0; (width + 2) * height], + } + } + + /// Add one edge. Straight port of font-rs `draw_line`, with x clamped + /// to the canvas: anything left of 0 or right of the width only shifts + /// where the row's coverage starts, never what visible pixels get. + fn add_line(&mut self, from: (f32, f32), to: (f32, f32)) { + if (from.1 - to.1).abs() <= f32::EPSILON { + return; + } + let (direction, top, bottom) = if from.1 < to.1 { + (1.0, from, to) + } else { + (-1.0, to, from) + }; + let dx_dy = (bottom.0 - top.0) / (bottom.1 - top.1); + let mut x = top.0; + if top.1 < 0.0 { + x -= top.1 * dx_dy; + } + let first_row = top.1.max(0.0) as usize; + let last_row = (bottom.1.ceil().max(0.0) as usize).min(self.height); + for row in first_row..last_row { + let dy = (row as f32 + 1.0).min(bottom.1) - (row as f32).max(top.1); + let x_next = x + dx_dy * dy; + self.add_row_piece(row, x, x_next, dy * direction); + x = x_next; + } + } + + /// Distribute one row's piece of an edge, from `x` to `x_next`, whose + /// signed height is `delta`. + fn add_row_piece(&mut self, row: usize, x: f32, x_next: f32, delta: f32) { + let max_x = self.width as f32; + let (x, x_next) = (x.clamp(0.0, max_x), x_next.clamp(0.0, max_x)); + let (x0, x1) = if x < x_next { (x, x_next) } else { (x_next, x) }; + let stride = self.width + 2; + let cells = &mut self.cells[row * stride..(row + 1) * stride]; + let x0_floor = x0.floor(); + let x0i = x0_floor as usize; + let x1_ceil = x1.ceil(); + let x1i = x1_ceil as usize; + if x1i <= x0i + 1 { + let middle = 0.5 * (x + x_next) - x0_floor; + cells[x0i] += delta - delta * middle; + cells[x0i + 1] += delta * middle; + return; + } + let inverse_span = (x1 - x0).recip(); + let x0_fraction = x0 - x0_floor; + let first_area = 0.5 * inverse_span * (1.0 - x0_fraction) * (1.0 - x0_fraction); + let x1_fraction = x1 - x1_ceil + 1.0; + let last_area = 0.5 * inverse_span * x1_fraction * x1_fraction; + cells[x0i] += delta * first_area; + if x1i == x0i + 2 { + cells[x0i + 1] += delta * (1.0 - first_area - last_area); + } else { + let second_area = inverse_span * (1.5 - x0_fraction); + cells[x0i + 1] += delta * (second_area - first_area); + for cell in &mut cells[x0i + 2..x1i - 1] { + *cell += delta * inverse_span; + } + let before_last = second_area + (x1i - x0i - 3) as f32 * inverse_span; + cells[x1i - 1] += delta * (1.0 - before_last - last_area); + } + cells[x1i] += delta * last_area; + } + + fn blend_into(&self, canvas: &mut Canvas, color: Rgb) { + let stride = self.width + 2; + for row in 0..self.height { + let mut running = 0.0; + for column in 0..self.width { + running += self.cells[row * stride + column]; + canvas.blend(column, self.top + row, color, running.abs().min(1.0)); + } + } + } +} + +fn find_table(data: &[u8], tag: &[u8; 4]) -> Option { + let count = read_u16(data, 4)? as usize; + (0..count).find_map(|i| { + let record = 12 + i * 16; + (data.get(record..record + 4)? == tag) + .then(|| read_u32(data, record + 8).map(|v| v as usize))? + }) +} + +/// Offset of the Unicode BMP format 4 subtable. +fn find_cmap4(data: &[u8], cmap: usize) -> Option { + let count = read_u16(data, cmap + 2)? as usize; + (0..count).find_map(|i| { + let record = cmap + 4 + i * 8; + let platform = read_u16(data, record)?; + let encoding = read_u16(data, record + 2)?; + let offset = cmap + read_u32(data, record + 4)? as usize; + let is_unicode = platform == 0 || (platform == 3 && encoding == 1); + (is_unicode && read_u16(data, offset)? == 4).then_some(offset) + }) +} + +fn read_u16(data: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(data.get(at..at + 2)?.try_into().ok()?)) +} + +fn read_u32(data: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(data.get(at..at + 4)?.try_into().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_fonts_parse_and_map_latin() { + for weight in [Weight::Regular, Weight::SemiBold] { + let font = font_for(weight).expect("embedded font must parse"); + assert_eq!(font.units_per_em, 2048.0); + assert_ne!(font.glyph_id('R'), 0); + assert_ne!(font.glyph_id('é'), 0, "Latin-1 is in the subset"); + assert_eq!( + font.glyph_id('€'), + 0, + "outside the subset falls back to .notdef" + ); + assert!(font.advance(font.glyph_id('M')) > font.advance(font.glyph_id('i'))); + } + } + + #[test] + fn letters_have_outlines_and_space_does_not() { + let font = font_for(Weight::Regular).unwrap(); + assert_eq!( + font.contours(font.glyph_id('o')).len(), + 2, + "o has an inner and outer contour" + ); + assert!(font.contours(font.glyph_id(' ')).is_empty()); + } + + #[test] + fn filled_square_covers_its_inside_exactly() { + let mut coverage = Coverage::new(10, 0, 10); + let square = [(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0), (2.0, 2.0)]; + for pair in square.windows(2) { + coverage.add_line(pair[0], pair[1]); + } + let mut canvas = Canvas::new(10, 10, Rgb::new(0, 0, 0)); + coverage.blend_into(&mut canvas, Rgb::new(255, 255, 255)); + let frame = canvas.to_yuv420(); + assert_eq!(frame.y[5 * 16 + 5], 235, "inside is fully covered"); + assert_eq!(frame.y[5 * 16 + 1], 16, "outside is untouched"); + assert_eq!(frame.y[5 * 16 + 8], 16, "right edge is exclusive"); + } + + #[test] + fn slanted_edge_gives_partial_coverage() { + let mut coverage = Coverage::new(10, 0, 4); + let triangle = [(0.0, 0.0), (8.0, 4.0), (0.0, 4.0), (0.0, 0.0)]; + for pair in triangle.windows(2) { + coverage.add_line(pair[0], pair[1]); + } + let mut canvas = Canvas::new(10, 4, Rgb::new(0, 0, 0)); + coverage.blend_into(&mut canvas, Rgb::new(255, 255, 255)); + let frame = canvas.to_yuv420(); + let row = 2; + let samples: Vec = (0..10).map(|x| frame.y[row * 16 + x]).collect(); + assert_eq!(samples[0], 235); + assert!( + samples[4] > 16 && samples[4] < 235, + "edge pixel is blended: {samples:?}" + ); + assert_eq!(samples[9], 16); + } + + #[test] + fn centred_text_draws_symmetrically() { + let mut canvas = Canvas::new(200, 60, Rgb::new(0, 0, 0)); + let line = Line { + size_px: 40.0, + weight: Weight::SemiBold, + align: Align::Center, + }; + draw( + &mut canvas, + "HOH", + 100.0, + 45.0, + line, + Rgb::new(255, 255, 255), + ); + let (first, last) = lit_span(&canvas, 30); + assert!( + (first as i32 + last as i32 - 199).abs() <= 2, + "not centred: {first}..{last}" + ); + } + + #[test] + fn left_aligned_text_starts_at_x() { + let mut canvas = Canvas::new(200, 60, Rgb::new(0, 0, 0)); + let line = Line { + size_px: 40.0, + weight: Weight::SemiBold, + align: Align::Left, + }; + draw( + &mut canvas, + "HOH", + 20.0, + 45.0, + line, + Rgb::new(255, 255, 255), + ); + let (first, _) = lit_span(&canvas, 30); + // The H's left side bearing keeps ink a few pixels right of the pen. + assert!((20..=26).contains(&first), "starts at {first}"); + } + + /// First and last bright pixel on `row`. + fn lit_span(canvas: &Canvas, row: usize) -> (usize, usize) { + let frame = canvas.to_yuv420(); + let lit: Vec = (0..canvas.width()) + .filter(|x| frame.y[row * frame.width + x] > 128) + .collect(); + (*lit.first().unwrap(), *lit.last().unwrap()) + } +} diff --git a/src/slate/mod.rs b/src/slate/mod.rs new file mode 100644 index 0000000..0a36930 --- /dev/null +++ b/src/slate/mod.rs @@ -0,0 +1,440 @@ +//! The crash-protection reconnect screen. +//! +//! Draws the streamer's chosen theme and encodes it as a 2 s H.264 loop: +//! one keyframe plus P frames, built once per stream shape and replayed +//! with fresh timestamps for as long as the hold lasts. Keeping the loop +//! exactly one keyframe interval long means every replay starts on an +//! IDR, so viewers who join mid-hold get a picture within 2 s. + +mod bitstream; +mod encoder; +mod font; +mod pixel_font; +mod png; +mod raster; +mod themes; + +pub use encoder::StreamShape; +pub use raster::Rgb; + +use crate::crash_protection::CrashProtection; +use raster::Canvas; +use std::fmt; +use themes::ScreenStyle; + +pub const LOOP_MS: usize = 2000; +const MIN_SIDE: usize = 16; +const MAX_SIDE: usize = 8192; +const MAX_FPS: u32 = 240; + +/// One encoded loop, ready to be wrapped in RTMP video tags. It needs no +/// sequence header of its own: every keyframe carries the loop's SPS and +/// PPS in-band, under ids the stream it interrupts doesn't use. +pub struct SlateLoop { + /// The loop's first picture as an AVCC sample (SPS, PPS, IDR), encoded + /// twice with idr_pic_id 0 and 1. Replays alternate them, because + /// back-to-back IDRs must not share an id. + pub keyframes: [Vec; 2], + /// The rest of the loop (P pictures) as AVCC samples, in order. + pub deltas: Vec>, + pub shape: StreamShape, +} + +/// The encoder only handles 4:2:0 frames with even sides. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnsupportedShape(pub StreamShape); + +impl fmt::Display for UnsupportedShape { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let shape = self.0; + write!( + f, + "can't build a reconnect screen at {}x{} {} fps (sides must be even, \ + {MIN_SIDE} to {MAX_SIDE} px, and 1 to {MAX_FPS} fps)", + shape.width, shape.height, shape.fps + ) + } +} + +impl std::error::Error for UnsupportedShape {} + +/// The screen at each of `phases` (moments of the loop, 0..1), stacked +/// top to bottom in one PNG. Drawn by the same code as the stream, so the +/// dashboard preview is exactly what viewers get; one image for the whole +/// animation keeps the dashboard to a single request per change. +pub fn preview_png( + settings: &CrashProtection, + width: usize, + height: usize, + phases: &[f32], +) -> Vec { + let style = screen_style(settings); + let frames = phases + .iter() + .map(|phase| { + let mut canvas = Canvas::new(width, height, style.background); + themes::draw(&mut canvas, &style, *phase); + canvas + }) + .collect(); + Canvas::stacked(frames) + .unwrap_or_else(|| Canvas::new(width, height, style.background)) + .to_png() +} + +fn screen_style(settings: &CrashProtection) -> ScreenStyle<'_> { + ScreenStyle { + theme: settings.theme, + accent: settings.accent, + background: settings.resolved_background(), + headline: &settings.headline, + subline: &settings.subline, + } +} + +/// Render and encode the screen described by `settings` at `shape`. +pub fn build_loop( + settings: &CrashProtection, + shape: StreamShape, +) -> Result { + let side_ok = |side: usize| (MIN_SIDE..=MAX_SIDE).contains(&side) && side.is_multiple_of(2); + if !side_ok(shape.width) || !side_ok(shape.height) || !(1..=MAX_FPS).contains(&shape.fps) { + return Err(UnsupportedShape(shape)); + } + let style = screen_style(settings); + let frame_count = frames_per_loop(shape.fps); + let render = |index: usize| { + let mut canvas = Canvas::new(shape.width, shape.height, style.background); + themes::draw(&mut canvas, &style, index as f32 / frame_count as f32); + canvas + }; + + let parameter_sets = encoder::parameter_sets(shape); + let mut previous_canvas = render(0); + let mut previous = previous_canvas.to_yuv420(); + let keyframes = [0, 1].map(|idr_pic_id| { + let idr = encoder::encode_idr(&previous, idr_pic_id); + bitstream::length_prefixed(&[¶meter_sets.sps, ¶meter_sets.pps, &idr]) + }); + let mut deltas = Vec::with_capacity(frame_count - 1); + for index in 1..frame_count { + let canvas = render(index); + // Only the animated rows change between frames. + let frame = canvas.to_yuv420_after(&previous_canvas, &previous); + let picture = encoder::encode_p(&frame, &previous, index as u32); + deltas.push(bitstream::length_prefixed(&[&picture])); + (previous_canvas, previous) = (canvas, frame); + } + + Ok(SlateLoop { + keyframes, + deltas, + shape, + }) +} + +/// A legacy FLV H.264 sequence-header tag describing a `width` x `height` +/// stream, for tests that need video the reconnect screen can cover. +#[cfg(test)] +pub fn test_sequence_header(width: usize, height: usize) -> Vec { + let sets = encoder::parameter_sets(StreamShape { + width, + height, + fps: 30, + }); + let (sps, pps) = (&sets.sps, &sets.pps); + // AVCDecoderConfigurationRecord: version 1, profile/compat/level from + // the SPS, 4-byte NAL lengths, one SPS, one PPS. + let mut tag = vec![0x17, 0, 0, 0, 0, 1, sps[1], sps[2], sps[3], 0xFF, 0xE1]; + tag.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + tag.extend_from_slice(sps); + tag.push(1); + tag.extend_from_slice(&(pps.len() as u16).to_be_bytes()); + tag.extend_from_slice(pps); + tag +} + +/// Whether ffmpeg runs here, for the tests that decode with it. Missing +/// on a dev machine skips them with a note; missing on CI (`CI` set) +/// fails, so a runner without ffmpeg can't turn them into silent passes. +#[cfg(test)] +fn ffmpeg_is_available(check: &str) -> bool { + let found = std::process::Command::new("ffmpeg") + .arg("-version") + .output() + .is_ok(); + if !found { + // The Linux CI job installs ffmpeg, so there a missing one is a + // broken job, not a machine without it; the Windows job skips. + assert!( + !(cfg!(target_os = "linux") && std::env::var_os("CI").is_some()), + "ffmpeg is required on Linux CI for {check}" + ); + eprintln!("NOTE: ffmpeg not found, so {check} did not run"); + } + found +} + +fn frames_per_loop(fps: u32) -> usize { + (fps as usize * LOOP_MS / 1000).max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crash_protection::SlateTheme; + use std::process::Command; + + fn settings(theme: SlateTheme) -> CrashProtection { + CrashProtection { + theme, + ..CrashProtection::default() + } + } + + #[test] + fn loop_is_one_keyframe_interval_long() { + let shape = StreamShape { + width: 320, + height: 180, + fps: 30, + }; + let slate = build_loop(&settings(SlateTheme::Arcade), shape).unwrap(); + assert_eq!(1 + slate.deltas.len(), 60); + assert_ne!(slate.keyframes[0], slate.keyframes[1], "idr_pic_id differs"); + } + + #[test] + fn keyframes_carry_their_own_parameter_sets_in_band() { + let shape = StreamShape { + width: 320, + height: 180, + fps: 10, + }; + let slate = build_loop(&settings(SlateTheme::Whisper), shape).unwrap(); + let types = |sample: &[u8]| { + nal_units(sample) + .iter() + .map(|nal| nal[0] & 0x1F) + .collect::>() + }; + for keyframe in &slate.keyframes { + assert_eq!(types(keyframe), vec![7, 8, 5], "SPS, PPS, IDR"); + } + for delta in &slate.deltas { + assert_eq!(types(delta), vec![1], "P frames are just the slice"); + } + } + + /// The SPS must describe the visible size, cropping included, or the + /// screen comes out padded or rescaled against the stream it covers. + /// Read back with the parser that sizes real streams. + #[test] + fn sequence_header_describes_the_visible_size() { + for (width, height) in [ + (1920, 1080), + (1080, 1920), + (1280, 720), + (854, 480), + (2560, 1440), + (200, 360), + (16, 16), + ] { + let header = test_sequence_header(width, height); + assert_eq!( + crate::h264::sps_dimensions(&header), + Some((width as u32, height as u32)), + "{width}x{height}" + ); + } + } + + #[test] + fn odd_or_absurd_shapes_are_rejected() { + let base = settings(SlateTheme::Whisper); + for (width, height, fps) in [(321, 180, 30), (320, 8, 30), (320, 180, 0), (9000, 180, 30)] { + let shape = StreamShape { width, height, fps }; + assert_eq!( + build_loop(&base, shape).err(), + Some(UnsupportedShape(shape)) + ); + } + } + + /// Every theme's loop at `width` x `height`, built at the rate a hold + /// really uses, stays under `max_kbps`. + fn assert_every_theme_fits(width: usize, height: usize, max_kbps: usize) { + let shape = StreamShape { + width, + height, + fps: crate::crash_hold::SLATE_FPS, + }; + for theme in SlateTheme::ALL { + let slate = build_loop(&settings(theme), shape).unwrap(); + let loop_bytes: usize = + slate.keyframes[0].len() + slate.deltas.iter().map(Vec::len).sum::(); + // Bits per millisecond are kbps. + let kbps = loop_bytes * 8 / LOOP_MS; + assert!( + kbps < max_kbps, + "{theme:?} loop at {width}x{height} runs at {kbps} kbps" + ); + } + } + + /// The screen goes to every destination for as long as a hold lasts, + /// on the streamer's upload: under 1 Mbps at 1080p. + #[test] + fn loop_stays_small_at_1080p() { + assert_every_theme_fits(1920, 1080, 1_000); + } + + /// Destinations set to Vertical get the screen at 1080x1920: the same + /// pixel count and, as themes scale by the short side, the same layout + /// scale as 1080p, so the same 1 Mbps. + #[test] + fn loop_stays_small_on_a_vertical_canvas() { + assert_every_theme_fits(1080, 1920, 1_000); + } + + /// 2K channels stream 1440p. Themes scale their layout by the short + /// side, 1440 / 1080, and the costly blocks are the edges of what they + /// draw, so the budget scales the same way: 1333 kbps. + #[test] + fn loop_stays_small_at_1440p() { + assert_every_theme_fits(2560, 1440, 1_333); + } + + /// Decode two replays of the loop with ffmpeg and require every pixel + /// to match what we rendered. Every theme at 320x180 (180 isn't whole + /// macroblocks), then one portrait 200x360 so a real decoder checks the + /// cropping of the width too; each ffmpeg run costs about half a second. + #[test] + fn ffmpeg_decodes_the_loop_losslessly() { + if !ffmpeg_is_available("the reconnect screen decode round trip") { + return; + } + let landscape = SlateTheme::ALL.map(|theme| (theme, 320, 180)); + let portrait = (SlateTheme::Studio, 200, 360); + for (theme, width, height) in landscape.into_iter().chain([portrait]) { + let shape = StreamShape { + width, + height, + fps: 10, + }; + let slate = build_loop(&settings(theme), shape).unwrap(); + let decoded = decode_with_ffmpeg(&annex_b(&slate), shape.fps, theme); + let expected = expected_frames(&settings(theme), shape); + assert_eq!( + decoded.len(), + expected.len(), + "{theme:?} at {width}x{height}: frame count" + ); + assert!( + decoded == expected, + "{theme:?} at {width}x{height}: decoded pixels differ from the render" + ); + } + } + + /// The NAL units of one AVCC sample (4-byte big-endian lengths). Fails + /// unless the lengths tile the sample exactly and every unit is well + /// formed: a stray byte, or a start code inside a unit, breaks every + /// decoder downstream. + fn nal_units(sample: &[u8]) -> Vec<&[u8]> { + let mut units = Vec::new(); + let mut rest = sample; + while !rest.is_empty() { + assert!(rest.len() >= 4, "truncated length prefix"); + let (prefix, tail) = rest.split_at(4); + let len = u32::from_be_bytes(prefix.try_into().unwrap()) as usize; + assert!( + (1..=tail.len()).contains(&len), + "NAL length {len} doesn't fit the sample" + ); + let (unit, tail) = tail.split_at(len); + assert_eq!(unit[0] & 0x80, 0, "forbidden_zero_bit is set"); + assert!( + !unit.windows(3).any(|w| w[0] == 0 && w[1] == 0 && w[2] <= 2), + "start code pattern inside a NAL unit" + ); + units.push(unit); + rest = tail; + } + units + } + + /// Two loop replays as an Annex B elementary stream. + fn annex_b(slate: &SlateLoop) -> Vec { + let mut out = Vec::new(); + for keyframe in &slate.keyframes { + for sample in std::iter::once(keyframe).chain(&slate.deltas) { + for nal in nal_units(sample) { + out.extend_from_slice(&[0, 0, 0, 1]); + out.extend_from_slice(nal); + } + } + } + out + } + + /// Raw H.264 carries no timestamps, so the input rate is given + /// explicitly and `-vsync 0` stops ffmpeg dropping identical frames. + fn decode_with_ffmpeg(stream: &[u8], fps: u32, theme: SlateTheme) -> Vec { + let dir = std::env::temp_dir(); + let tag = format!("instantclone-slate-{}-{}", std::process::id(), theme.id()); + let input = dir.join(format!("{tag}.h264")); + let output = dir.join(format!("{tag}.yuv")); + std::fs::write(&input, stream).unwrap(); + let result = Command::new("ffmpeg") + .args([ + "-v", + "error", + "-y", + "-r", + &fps.to_string(), + "-f", + "h264", + "-i", + ]) + .arg(&input) + .args(["-vsync", "0", "-f", "rawvideo", "-pix_fmt", "yuv420p"]) + .arg(&output) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&result.stderr).to_string(); + let decoded = std::fs::read(&output).unwrap_or_default(); + let _ = std::fs::remove_file(&input); + let _ = std::fs::remove_file(&output); + assert!( + result.status.success() && stderr.trim().is_empty(), + "ffmpeg: {stderr}" + ); + decoded + } + + /// The rendered frames, cropped to the visible size, twice over. + fn expected_frames(settings: &CrashProtection, shape: StreamShape) -> Vec { + let style = screen_style(settings); + let frame_count = frames_per_loop(shape.fps); + let mut one_loop = Vec::new(); + for index in 0..frame_count { + let mut canvas = Canvas::new(shape.width, shape.height, style.background); + themes::draw(&mut canvas, &style, index as f32 / frame_count as f32); + let frame = canvas.to_yuv420(); + let (chroma_stride, chroma_width, chroma_height) = + (frame.width / 2, shape.width / 2, shape.height / 2); + let planes = [ + (&frame.y, frame.width, shape.width, shape.height), + (&frame.u, chroma_stride, chroma_width, chroma_height), + (&frame.v, chroma_stride, chroma_width, chroma_height), + ]; + for (plane, stride, width, height) in planes { + for row in 0..height { + one_loop.extend_from_slice(&plane[row * stride..row * stride + width]); + } + } + } + [one_loop.clone(), one_loop].concat() + } +} diff --git a/src/slate/pixel_font.rs b/src/slate/pixel_font.rs new file mode 100644 index 0000000..1d39757 --- /dev/null +++ b/src/slate/pixel_font.rs @@ -0,0 +1,139 @@ +//! 5x7 pixel font for the Arcade theme. Each glyph is seven rows, top to +//! bottom, with the five pixels of a row in the low bits (bit 4 is the +//! leftmost column). Letters are uppercase only; `to_pixel_text` folds +//! lowercase, accents and curly punctuation, and turns anything else it +//! can't draw into a space. + +pub const GLYPH_COLUMNS: usize = 5; +pub const GLYPH_ROWS: usize = 7; +/// Glyph plus one blank column between letters. +pub const ADVANCE_COLUMNS: usize = 6; + +const GLYPHS: [(char, [u8; GLYPH_ROWS]); 47] = [ + ('A', [14, 17, 17, 31, 17, 17, 17]), + ('B', [30, 17, 17, 30, 17, 17, 30]), + ('C', [14, 17, 16, 16, 16, 17, 14]), + ('D', [30, 17, 17, 17, 17, 17, 30]), + ('E', [31, 16, 16, 30, 16, 16, 31]), + ('F', [31, 16, 16, 30, 16, 16, 16]), + ('G', [14, 17, 16, 23, 17, 17, 15]), + ('H', [17, 17, 17, 31, 17, 17, 17]), + ('I', [14, 4, 4, 4, 4, 4, 14]), + ('J', [7, 2, 2, 2, 2, 18, 12]), + ('K', [17, 18, 20, 24, 20, 18, 17]), + ('L', [16, 16, 16, 16, 16, 16, 31]), + ('M', [17, 27, 21, 21, 17, 17, 17]), + ('N', [17, 17, 25, 21, 19, 17, 17]), + ('O', [14, 17, 17, 17, 17, 17, 14]), + ('P', [30, 17, 17, 30, 16, 16, 16]), + ('Q', [14, 17, 17, 17, 21, 18, 13]), + ('R', [30, 17, 17, 30, 20, 18, 17]), + ('S', [15, 16, 16, 14, 1, 1, 30]), + ('T', [31, 4, 4, 4, 4, 4, 4]), + ('U', [17, 17, 17, 17, 17, 17, 14]), + ('V', [17, 17, 17, 17, 17, 10, 4]), + ('W', [17, 17, 17, 21, 21, 21, 10]), + ('X', [17, 17, 10, 4, 10, 17, 17]), + ('Y', [17, 17, 10, 4, 4, 4, 4]), + ('Z', [31, 1, 2, 4, 8, 16, 31]), + ('0', [14, 17, 19, 21, 25, 17, 14]), + ('1', [4, 12, 4, 4, 4, 4, 14]), + ('2', [14, 17, 1, 2, 4, 8, 31]), + ('3', [31, 2, 4, 2, 1, 17, 14]), + ('4', [2, 6, 10, 18, 31, 2, 2]), + ('5', [31, 16, 30, 1, 1, 17, 14]), + ('6', [6, 8, 16, 30, 17, 17, 14]), + ('7', [31, 1, 2, 4, 8, 8, 8]), + ('8', [14, 17, 17, 14, 17, 17, 14]), + ('9', [14, 17, 17, 15, 1, 2, 12]), + ('.', [0, 0, 0, 0, 0, 12, 12]), + (',', [0, 0, 0, 0, 12, 4, 8]), + ('!', [4, 4, 4, 4, 4, 0, 4]), + ('?', [14, 17, 1, 2, 4, 0, 4]), + ('-', [0, 0, 0, 31, 0, 0, 0]), + (':', [0, 12, 12, 0, 12, 12, 0]), + ('\'', [4, 4, 8, 0, 0, 0, 0]), + ('/', [1, 1, 2, 4, 8, 16, 16]), + ('(', [2, 4, 8, 8, 8, 4, 2]), + (')', [8, 4, 2, 2, 2, 4, 8]), + (' ', [0, 0, 0, 0, 0, 0, 0]), +]; + +/// Rows for `c`, or None when the font has no glyph for it. +pub fn glyph(c: char) -> Option<&'static [u8; GLYPH_ROWS]> { + GLYPHS + .iter() + .find(|(glyph_char, _)| *glyph_char == c) + .map(|(_, rows)| rows) +} + +/// Uppercase `text`, fold what the font can stand in for, and replace +/// the rest with spaces, so layout can count columns without looking +/// glyphs up twice. +pub fn to_pixel_text(text: &str) -> Vec { + text.chars() + .flat_map(char::to_uppercase) + .map(fold) + .map(|c| if glyph(c).is_some() { c } else { ' ' }) + .collect() +} + +/// The glyph drawn for a typographic or accented character: a phone +/// keyboard's curly apostrophe, dashes, Spanish and French accents. +fn fold(c: char) -> char { + match c { + '\u{2018}' | '\u{2019}' | '\u{201C}' | '\u{201D}' | '"' => '\'', + '\u{2013}' | '\u{2014}' => '-', + '\u{2026}' => '.', + '\u{A1}' => '!', + '\u{BF}' => '?', + '\u{C0}'..='\u{C5}' => 'A', + '\u{C7}' => 'C', + '\u{C8}'..='\u{CB}' => 'E', + '\u{CC}'..='\u{CF}' => 'I', + '\u{D1}' => 'N', + '\u{D2}'..='\u{D6}' => 'O', + '\u{D9}'..='\u{DC}' => 'U', + '\u{DD}' => 'Y', + other => other, + } +} + +/// Whether column `column` (0 = left) of `row` is lit. +pub fn is_lit(row: u8, column: usize) -> bool { + row & (1 << (GLYPH_COLUMNS - 1 - column)) != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercase_folds_and_unknown_becomes_space() { + assert_eq!(to_pixel_text("Hi #!"), vec!['H', 'I', ' ', ' ', '!']); + } + + #[test] + fn accents_and_curly_punctuation_fold_to_drawable_glyphs() { + let folded = |text: &str| to_pixel_text(text).into_iter().collect::(); + assert_eq!(folded("\u{A1}Volv\u{E9}!"), "!VOLVE!"); + assert_eq!(folded("We\u{2019}ll \u{2014} ok"), "WE'LL - OK"); + } + + #[test] + fn every_row_fits_in_five_columns() { + for (c, rows) in GLYPHS { + assert!( + rows.iter().all(|row| *row < 32), + "glyph {c:?} is wider than 5 columns" + ); + } + } + + #[test] + fn leftmost_column_is_bit_four() { + assert!(is_lit(16, 0)); + assert!(!is_lit(16, 4)); + assert!(is_lit(1, 4)); + } +} diff --git a/src/slate/png.rs b/src/slate/png.rs new file mode 100644 index 0000000..765ab84 --- /dev/null +++ b/src/slate/png.rs @@ -0,0 +1,265 @@ +//! Minimal PNG writer for the dashboard preview of the reconnect screen. +//! +//! The screens are flat colour with a little anti-aliased text, so rows +//! filtered against their neighbours ("Sub" or "Up", whichever leaves +//! smaller values) are almost all zeros. Deflating those needs no real +//! LZ77 search: repeats of the previous byte (distance 1 back-references) +//! in one fixed-Huffman block squeeze a 24-frame preview strip to a few +//! hundred KB, where the uncompressed bitmap was 9 MB. +//! +//! Format references: PNG (RFC 2083), zlib (RFC 1950), deflate (RFC 1951). + +const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; +const BYTES_PER_PIXEL: usize = 3; +const FILTER_SUB: u8 = 1; +const FILTER_UP: u8 = 2; +/// Deflate's longest match and shortest one worth sending. +const MAX_MATCH: usize = 258; +const MIN_MATCH: usize = 3; +/// zlib header for deflate with a 32 KB window and no preset dictionary; +/// the pair is a multiple of 31, as zlib requires. +const ZLIB_HEADER: [u8; 2] = [0x78, 0x01]; + +/// Encode `rgb` (rows of `width` pixels, 3 bytes each, top to bottom) as +/// an 8-bit RGB PNG. +pub fn encode(width: usize, height: usize, rgb: &[u8]) -> Vec { + assert_eq!( + rgb.len(), + width * height * BYTES_PER_PIXEL, + "pixel data size" + ); + let mut out = SIGNATURE.to_vec(); + let mut header = Vec::with_capacity(13); + header.extend_from_slice(&(width as u32).to_be_bytes()); + header.extend_from_slice(&(height as u32).to_be_bytes()); + // 8 bits per channel, colour type 2 (RGB), default compression and + // filter methods, no interlacing. + header.extend_from_slice(&[8, 2, 0, 0, 0]); + write_chunk(&mut out, b"IHDR", &header); + write_chunk(&mut out, b"IDAT", &zlib(&filtered_rows(width, rgb))); + write_chunk(&mut out, b"IEND", &[]); + out +} + +/// Each row prefixed by its filter type, filtered with whichever of Sub +/// (difference from the pixel to the left) and Up (from the pixel above) +/// leaves the smaller values: the usual PNG heuristic. +fn filtered_rows(width: usize, rgb: &[u8]) -> Vec { + let stride = width * BYTES_PER_PIXEL; + let mut out = Vec::with_capacity(rgb.len() + rgb.len() / stride.max(1)); + let mut sub = vec![0u8; stride]; + let mut up = vec![0u8; stride]; + for (index, row) in rgb.chunks(stride).enumerate() { + for x in 0..stride { + let left = if x >= BYTES_PER_PIXEL { + row[x - BYTES_PER_PIXEL] + } else { + 0 + }; + let above = if index > 0 { + rgb[(index - 1) * stride + x] + } else { + 0 + }; + sub[x] = row[x].wrapping_sub(left); + up[x] = row[x].wrapping_sub(above); + } + let cost = + |bytes: &[u8]| -> u64 { bytes.iter().map(|b| (*b as i8).unsigned_abs() as u64).sum() }; + let (filter, bytes) = if cost(&up) < cost(&sub) { + (FILTER_UP, &up) + } else { + (FILTER_SUB, &sub) + }; + out.push(filter); + out.extend_from_slice(bytes); + } + out +} + +fn write_chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + let start = out.len(); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let crc = crc32(&out[start..]); + out.extend_from_slice(&crc.to_be_bytes()); +} + +/// zlib stream holding one fixed-Huffman deflate block. +fn zlib(data: &[u8]) -> Vec { + let mut bits = BitWriter::default(); + bits.write(1, 1); // BFINAL: this is the last block + bits.write(1, 2); // BTYPE 01: fixed Huffman codes + let mut i = 0; + while i < data.len() { + let byte = data[i]; + bits.literal(byte); + i += 1; + // Repeats of this byte go out as distance-1 back-references. + let mut run = data[i..].iter().take_while(|b| **b == byte).count(); + while run >= MIN_MATCH { + let length = run.min(MAX_MATCH); + bits.length(length); + bits.huffman(0, 5); // distance code 0: distance 1, no extra bits + i += length; + run -= length; + } + } + bits.huffman(0, 7); // end-of-block (code 256) + let mut out = ZLIB_HEADER.to_vec(); + out.extend_from_slice(&bits.finish()); + out.extend_from_slice(&adler32(data).to_be_bytes()); + out +} + +/// Deflate's bit order: values fill each byte from the least significant +/// bit, while Huffman codes go most significant bit first. +#[derive(Default)] +struct BitWriter { + bytes: Vec, + buffer: u64, + count: u32, +} + +impl BitWriter { + fn write(&mut self, value: u32, width: u32) { + self.buffer |= u64::from(value) << self.count; + self.count += width; + while self.count >= 8 { + self.bytes.push(self.buffer as u8); + self.buffer >>= 8; + self.count -= 8; + } + } + + fn huffman(&mut self, code: u32, width: u32) { + self.write(code.reverse_bits() >> (32 - width), width); + } + + /// A literal byte in the fixed code (RFC 1951 3.2.6). + fn literal(&mut self, byte: u8) { + match byte { + 0..=143 => self.huffman(0x30 + u32::from(byte), 8), + _ => self.huffman(0x190 + u32::from(byte) - 144, 9), + } + } + + /// A match length (3..=258): its length code, then its extra bits. + fn length(&mut self, length: usize) { + const BASE: [usize; 29] = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, + 115, 131, 163, 195, 227, 258, + ]; + const EXTRA: [u32; 29] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, + ]; + let index = BASE.iter().rposition(|base| *base <= length).unwrap_or(0); + let code = 257 + index as u32; + // Codes 257..279 are 7 bits (0000000 + n), 280..287 are 8 bits. + if code <= 279 { + self.huffman(code - 256, 7); + } else { + self.huffman(0xc0 + code - 280, 8); + } + self.write((length - BASE[index]) as u32, EXTRA[index]); + } + + fn finish(mut self) -> Vec { + if self.count > 0 { + self.bytes.push(self.buffer as u8); + } + self.bytes + } +} + +fn adler32(data: &[u8]) -> u32 { + const MOD: u32 = 65_521; + let (mut a, mut b) = (1u32, 0u32); + // 5552 bytes is the most that can be summed before b can overflow. + for chunk in data.chunks(5552) { + for byte in chunk { + a += u32::from(*byte); + b += a; + } + a %= MOD; + b %= MOD; + } + (b << 16) | a +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for byte in data { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xedb8_8320 + } else { + crc >> 1 + }; + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checksums_match_their_reference_values() { + // Standard check values for the ASCII string "123456789". + assert_eq!(crc32(b"123456789"), 0xcbf4_3926); + assert_eq!(adler32(b"123456789"), 0x091e_01de); + } + + #[test] + fn a_flat_frame_compresses_to_almost_nothing() { + let (width, height) = (480, 270); + let rgb: Vec = [0x0e, 0x0f, 0x12].repeat(width * height); + let png = encode(width, height, &rgb); + assert_eq!(&png[..8], &SIGNATURE); + assert_eq!(&png[12..16], b"IHDR"); + assert_eq!(u32::from_be_bytes(png[16..20].try_into().unwrap()), 480); + assert_eq!(u32::from_be_bytes(png[20..24].try_into().unwrap()), 270); + // Each row costs about 13 bytes: its filter byte breaks the run of + // zeros, which then takes a literal and a few 258-byte matches. + assert!( + png.len() < 4_000, + "{} bytes for a flat 480x270 frame", + png.len() + ); + } + + /// Decode with ffmpeg and require every pixel back. Needs ffmpeg the + /// same way the reconnect screen's own decode test does. + #[test] + fn ffmpeg_decodes_the_png_losslessly() { + use std::process::{Command, Stdio}; + if !super::super::ffmpeg_is_available("the PNG decode check") { + return; + } + let (width, height) = (37, 11); + // Noise, runs and repeated rows, so every filter and match path runs. + let rgb: Vec = (0..width * height * 3) + .map(|i| match (i / 3) % width { + 0..=9 => 200, + 10..=19 => ((i * 7919) % 251) as u8, + _ => (i / (width * 3)) as u8 * 20, + }) + .collect(); + let path = std::env::temp_dir().join(format!("ic-png-test-{}.png", std::process::id())); + std::fs::write(&path, encode(width, height, &rgb)).unwrap(); + let decoded = Command::new("ffmpeg") + .args(["-v", "error", "-i"]) + .arg(&path) + .args(["-f", "rawvideo", "-pix_fmt", "rgb24", "-"]) + .stderr(Stdio::inherit()) + .output() + .unwrap(); + let _ = std::fs::remove_file(&path); + assert!(decoded.status.success(), "ffmpeg rejected the PNG"); + assert!(decoded.stdout == rgb, "decoded pixels differ"); + } +} diff --git a/src/slate/raster.rs b/src/slate/raster.rs new file mode 100644 index 0000000..55208af --- /dev/null +++ b/src/slate/raster.rs @@ -0,0 +1,356 @@ +//! Tiny RGB rasterizer for the reconnect screen, plus the BT.709 +//! limited-range conversion to the 4:2:0 planes the encoder consumes. +//! +//! Shapes are flat fills. Only edges get partial coverage, so the +//! interior of every shape stays one exact colour and encodes as a +//! predicted (near-free) macroblock. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + /// Parse `#rrggbb` (either case). Anything else is None. + pub fn from_hex(text: &str) -> Option { + let hex = text.trim().strip_prefix('#')?; + if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let value = u32::from_str_radix(hex, 16).ok()?; + Some(Self::new( + (value >> 16) as u8, + (value >> 8) as u8, + value as u8, + )) + } + + pub fn to_hex(self) -> String { + format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) + } + + /// Linear mix towards `other`: 0.0 is self, 1.0 is other. + pub fn mix(self, other: Rgb, amount: f32) -> Rgb { + let amount = amount.clamp(0.0, 1.0); + let channel = |from: u8, to: u8| -> u8 { + (from as f32 + (to as f32 - from as f32) * amount).round() as u8 + }; + Rgb::new( + channel(self.r, other.r), + channel(self.g, other.g), + channel(self.b, other.b), + ) + } +} + +/// RGB drawing surface at the visible (uncropped) resolution. +pub struct Canvas { + width: usize, + height: usize, + pixels: Vec, +} + +impl Canvas { + pub fn new(width: usize, height: usize, background: Rgb) -> Self { + Self { + width, + height, + pixels: vec![background; width * height], + } + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + /// Opaque rectangle, clipped to the canvas. + pub fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: Rgb) { + let x0 = (x.max(0) as usize).min(self.width); + let y0 = (y.max(0) as usize).min(self.height); + let x1 = (x.saturating_add(w)).clamp(0, self.width as i32) as usize; + let y1 = (y.saturating_add(h)).clamp(0, self.height as i32) as usize; + for row in y0..y1 { + let start = row * self.width; + self.pixels[start + x0..start + x1.max(x0)].fill(color); + } + } + + /// Circle with 4x4 supersampled edges. + pub fn fill_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Rgb) { + self.fill_ring(cx, cy, radius, 0.0, color); + } + + /// The band between `inner` and `outer` radius, 4x4 supersampled. + pub fn fill_ring(&mut self, cx: f32, cy: f32, outer: f32, inner: f32, color: Rgb) { + const SAMPLES: usize = 4; + let x0 = (cx - outer).floor().max(0.0) as usize; + let y0 = (cy - outer).floor().max(0.0) as usize; + let x1 = ((cx + outer).ceil().max(0.0) as usize).min(self.width); + let y1 = ((cy + outer).ceil().max(0.0) as usize).min(self.height); + for y in y0..y1 { + for x in x0..x1 { + let mut inside = 0; + for sy in 0..SAMPLES { + for sx in 0..SAMPLES { + let px = x as f32 + (sx as f32 + 0.5) / SAMPLES as f32 - cx; + let py = y as f32 + (sy as f32 + 0.5) / SAMPLES as f32 - cy; + let distance_squared = px * px + py * py; + if distance_squared <= outer * outer && distance_squared >= inner * inner { + inside += 1; + } + } + } + self.blend(x, y, color, inside as f32 / (SAMPLES * SAMPLES) as f32); + } + } + } + + /// Mix `color` into one pixel by `coverage` (0.0..=1.0). + pub fn blend(&mut self, x: usize, y: usize, color: Rgb, coverage: f32) { + if x >= self.width || y >= self.height || coverage <= 0.0 { + return; + } + let pixel = &mut self.pixels[y * self.width + x]; + *pixel = if coverage >= 1.0 { + color + } else { + pixel.mix(color, coverage) + }; + } + + /// Convert to 4:2:0 planes padded to whole macroblocks. Padding + /// repeats the edge pixels, which the SPS crops away again. + pub fn to_yuv420(&self) -> YuvFrame { + let coded_width = self.width.div_ceil(16) * 16; + let coded_height = self.height.div_ceil(16) * 16; + let mut frame = YuvFrame::blank(coded_width, coded_height); + for pair in 0..coded_height / 2 { + self.convert_row_pair(&mut frame, pair); + } + frame + } + + /// `to_yuv420`, reusing `before_frame` (the conversion of `before`) + /// for every pair of rows the two canvases share. A loop's frames + /// differ in a few rows, and converting the rest again was 90% of the + /// time it takes to build one. + pub fn to_yuv420_after(&self, before: &Canvas, before_frame: &YuvFrame) -> YuvFrame { + let mut frame = before_frame.clone(); + for pair in 0..frame.height / 2 { + let changed = [2 * pair, 2 * pair + 1] + .iter() + .any(|&y| self.row(y) != before.row(y)); + if changed { + self.convert_row_pair(&mut frame, pair); + } + } + frame + } + + /// Coded luma rows `2 * pair` and `2 * pair + 1`, and chroma row + /// `pair`. Neighbouring pixels are usually the same colour, so each + /// conversion is reused until the colour changes. + fn convert_row_pair(&self, frame: &mut YuvFrame, pair: usize) { + let coded_width = frame.width; + let rows = [self.row(2 * pair), self.row(2 * pair + 1)]; + let at = |row: &[Rgb], x: usize| row[x.min(self.width - 1)]; + for (offset, row) in rows.iter().enumerate() { + let start = (2 * pair + offset) * coded_width; + let mut last = (row[0], luma(row[0])); + for (x, sample) in frame.y[start..start + coded_width].iter_mut().enumerate() { + let pixel = at(row, x); + if pixel != last.0 { + last = (pixel, luma(pixel)); + } + *sample = last.1; + } + } + let chroma_width = coded_width / 2; + let mut last: Option<([Rgb; 4], (u8, u8))> = None; + for cx in 0..chroma_width { + let quad = [ + at(rows[0], 2 * cx), + at(rows[0], 2 * cx + 1), + at(rows[1], 2 * cx), + at(rows[1], 2 * cx + 1), + ]; + let (u, v) = match last { + Some((seen, samples)) if seen == quad => samples, + _ => { + let samples = chroma_samples(quad); + last = Some((quad, samples)); + samples + } + }; + frame.u[pair * chroma_width + cx] = u; + frame.v[pair * chroma_width + cx] = v; + } + } + + /// Row `y`, repeating the bottom edge below the canvas. + fn row(&self, y: usize) -> &[Rgb] { + let start = y.min(self.height - 1) * self.width; + &self.pixels[start..start + self.width] + } + + /// Same-size canvases one above the other, first on top: the preview + /// strip the dashboard animates by sliding it. `None` when empty or + /// the sizes differ. + pub fn stacked(frames: Vec) -> Option { + let first = frames.first()?; + let (width, height) = (first.width, first.height); + if frames + .iter() + .any(|f| f.width != width || f.height != height) + { + return None; + } + let count = frames.len(); + Some(Canvas { + width, + height: height * count, + pixels: frames.into_iter().flat_map(|f| f.pixels).collect(), + }) + } + + /// PNG of the canvas, for the dashboard preview. + pub fn to_png(&self) -> Vec { + let rgb: Vec = self.pixels.iter().flat_map(|p| [p.r, p.g, p.b]).collect(); + super::png::encode(self.width, self.height, &rgb) + } +} + +/// Planar 4:2:0 frame at the coded size (multiples of 16). +#[derive(Clone)] +pub struct YuvFrame { + pub width: usize, + pub height: usize, + pub y: Vec, + pub u: Vec, + pub v: Vec, +} + +impl YuvFrame { + fn blank(width: usize, height: usize) -> Self { + Self { + width, + height, + y: vec![16; width * height], + u: vec![128; width * height / 4], + v: vec![128; width * height / 4], + } + } +} + +// BT.709 coefficients, limited range: the colour space OBS streams in by +// default, and what the SPS VUI advertises. +const KR: f32 = 0.2126; +const KB: f32 = 0.0722; + +fn luma_linear(color: Rgb) -> f32 { + let (r, g, b) = (color.r as f32, color.g as f32, color.b as f32); + KR * r + (1.0 - KR - KB) * g + KB * b +} + +fn luma(color: Rgb) -> u8 { + to_sample(16.0 + luma_linear(color) * 219.0 / 255.0) +} + +/// U and V samples for a 2x2 block of pixels: their chroma, averaged. +fn chroma_samples(quad: [Rgb; 4]) -> (u8, u8) { + let (mut u_sum, mut v_sum) = (0.0, 0.0); + for pixel in quad { + let (u, v) = chroma(pixel); + u_sum += u; + v_sum += v; + } + ( + to_sample(128.0 + u_sum / 4.0), + to_sample(128.0 + v_sum / 4.0), + ) +} + +/// Chroma offsets from 128, before averaging. +fn chroma(color: Rgb) -> (f32, f32) { + let y = luma_linear(color); + let u = (color.b as f32 - y) / (2.0 * (1.0 - KB)) * 224.0 / 255.0; + let v = (color.r as f32 - y) / (2.0 * (1.0 - KR)) * 224.0 / 255.0; + (u, v) +} + +/// Round into 1..=254: 0 and 255 are never needed in limited range, and +/// keeping clear of 0 keeps I_PCM samples valid for strict decoders. +fn to_sample(value: f32) -> u8 { + value.round().clamp(1.0, 254.0) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_round_trips_and_rejects_junk() { + assert_eq!(Rgb::from_hex("#5AC8FA"), Some(Rgb::new(0x5a, 0xc8, 0xfa))); + assert_eq!(Rgb::new(1, 2, 3).to_hex(), "#010203"); + assert_eq!(Rgb::from_hex("5ac8fa"), None); + assert_eq!(Rgb::from_hex("#5ac8f"), None); + assert_eq!(Rgb::from_hex("#5ac8fg"), None); + } + + #[test] + fn fill_rect_clips_to_canvas() { + let mut canvas = Canvas::new(8, 8, Rgb::new(0, 0, 0)); + canvas.fill_rect(-4, 6, 100, 100, Rgb::new(255, 0, 0)); + assert_eq!(canvas.row(5)[0], Rgb::new(0, 0, 0)); + assert_eq!(canvas.row(7)[7], Rgb::new(255, 0, 0)); + } + + #[test] + fn yuv_pads_to_macroblocks_and_maps_black_and_white() { + let mut canvas = Canvas::new(20, 18, Rgb::new(0, 0, 0)); + canvas.fill_rect(0, 0, 10, 18, Rgb::new(255, 255, 255)); + let frame = canvas.to_yuv420(); + assert_eq!((frame.width, frame.height), (32, 32)); + assert_eq!(frame.y[0], 235, "white is limited-range 235"); + assert_eq!(frame.y[19], 16, "black is limited-range 16"); + assert_eq!(frame.y[31], 16, "padding repeats the right edge"); + assert_eq!(frame.u[0], 128); + } + + #[test] + fn converting_only_changed_rows_matches_a_full_conversion() { + let mut before = Canvas::new(20, 18, Rgb::new(10, 20, 30)); + before.fill_rect(2, 2, 6, 3, Rgb::new(200, 40, 90)); + let mut after = Canvas::new(20, 18, Rgb::new(10, 20, 30)); + after.fill_rect(2, 2, 6, 3, Rgb::new(200, 40, 90)); + // A middle row and the bottom edge, which the padding repeats. + after.fill_rect(5, 9, 3, 1, Rgb::new(90, 250, 12)); + after.fill_rect(0, 17, 20, 1, Rgb::new(255, 255, 255)); + let reused = after.to_yuv420_after(&before, &before.to_yuv420()); + let full = after.to_yuv420(); + assert!(reused.y == full.y && reused.u == full.u && reused.v == full.v); + } + + #[test] + fn stacked_frames_keep_their_order_and_refuse_mixed_sizes() { + let red = Canvas::new(4, 2, Rgb::new(255, 0, 0)); + let blue = Canvas::new(4, 2, Rgb::new(0, 0, 255)); + let strip = Canvas::stacked(vec![red, blue]).unwrap(); + assert_eq!((strip.width(), strip.height()), (4, 4)); + assert_eq!(strip.row(0)[0], Rgb::new(255, 0, 0)); + assert_eq!(strip.row(3)[0], Rgb::new(0, 0, 255)); + let small = Canvas::new(2, 2, Rgb::new(0, 0, 0)); + assert!(Canvas::stacked(vec![Canvas::new(4, 2, Rgb::new(0, 0, 0)), small]).is_none()); + assert!(Canvas::stacked(Vec::new()).is_none()); + } +} diff --git a/src/slate/themes.rs b/src/slate/themes.rs new file mode 100644 index 0000000..d67c805 --- /dev/null +++ b/src/slate/themes.rs @@ -0,0 +1,437 @@ +//! The reconnect screen themes. Each draws one frame of the 2 s loop at +//! `phase` (0.0..1.0) onto a canvas already cleared to the background. +//! +//! Layout numbers are designed at 1080 px on the short side and scaled, +//! so the same theme works for every rendition and for vertical canvases. +//! +//! Every animation moves in a few discrete steps and only over a small +//! area: most frames then repeat the previous one exactly and encode as +//! skipped blocks, which keeps the loop cheap for the streamer's upload. + +use super::font::{self, Align, Weight}; +use super::pixel_font::{self, ADVANCE_COLUMNS, GLYPH_COLUMNS, GLYPH_ROWS}; +use super::raster::{Canvas, Rgb}; +use crate::crash_protection::SlateTheme; +use std::f32::consts::{FRAC_PI_2, TAU}; + +/// Everything a theme needs from the streamer's settings. +pub struct ScreenStyle<'a> { + pub theme: SlateTheme, + pub accent: Rgb, + pub background: Rgb, + pub headline: &'a str, + pub subline: &'a str, +} + +pub fn draw(canvas: &mut Canvas, style: &ScreenStyle, phase: f32) { + match style.theme { + SlateTheme::Whisper => draw_whisper(canvas, style, phase), + SlateTheme::Arcade => draw_arcade(canvas, style, phase), + SlateTheme::Beacon => draw_beacon(canvas, style, phase), + SlateTheme::Orbit => draw_orbit(canvas, style, phase), + SlateTheme::Studio => draw_studio(canvas, style, phase), + } +} + +/// Short side over the 1080 px design size. +fn design_scale(canvas: &Canvas) -> f32 { + canvas.width().min(canvas.height()) as f32 / 1080.0 +} + +fn canvas_center(canvas: &Canvas) -> (f32, f32) { + ( + (canvas.width() as f32 / 2.0).round(), + (canvas.height() as f32 / 2.0).round(), + ) +} + +/// `phase` rounded down to one of `steps` positions. +fn stepped(phase: f32, steps: f32) -> f32 { + (phase * steps).floor() / steps +} + +const HEADLINE_COLOR: Rgb = Rgb::new(0xee, 0xf0, 0xf3); +const SUBLINE_COLOR: Rgb = Rgb::new(0x7d, 0x85, 0x91); +const HEADLINE_PX: f32 = 46.0; +const SUBLINE_PX: f32 = 25.0; +/// Headline baseline to subline baseline, at 1080 px. +const SUBLINE_DROP_PX: f32 = 48.0; + +/// Unlit dots and empty tracks: the background lifted a little toward the +/// text, so they read on any background the streamer picks. +fn idle_color(style: &ScreenStyle) -> Rgb { + style.background.mix(HEADLINE_COLOR, 0.16) +} + +/// Headline over subline, placed by `align` at `x`, with the headline's +/// baseline at `baseline_y`. The text-first themes all set it this way. +fn draw_text_block( + canvas: &mut Canvas, + style: &ScreenStyle, + (x, baseline_y): (f32, f32), + align: Align, +) { + let scale = design_scale(canvas); + let headline = font::Line { + size_px: HEADLINE_PX * scale, + weight: Weight::SemiBold, + align, + }; + font::draw( + canvas, + style.headline, + x, + baseline_y, + headline, + HEADLINE_COLOR, + ); + let subline = font::Line { + size_px: SUBLINE_PX * scale, + weight: Weight::Regular, + align, + }; + let subline_y = baseline_y + SUBLINE_DROP_PX * scale; + font::draw(canvas, style.subline, x, subline_y, subline, SUBLINE_COLOR); +} + +/// Glow levels per dot. +const WHISPER_GLOW_STEPS: f32 = 8.0; + +/// Minimal centred text over three dots whose glow chases left to right. +fn draw_whisper(canvas: &mut Canvas, style: &ScreenStyle, phase: f32) { + let scale = design_scale(canvas); + let (center_x, center_y) = canvas_center(canvas); + draw_text_block( + canvas, + style, + (center_x, center_y - 8.0 * scale), + Align::Center, + ); + + let spacing = 30.0 * scale; + let radius = (7.0 * scale).round().max(1.0); + let dot_y = (center_y + 100.0 * scale).round(); + for index in 0..3 { + let wave = phase - index as f32 / 6.0; + let glow = 0.5 + 0.5 * (TAU * wave).sin(); + let glow = (glow * WHISPER_GLOW_STEPS).round() / WHISPER_GLOW_STEPS; + let dot_x = (center_x + (index as f32 - 1.0) * spacing).round(); + let color = idle_color(style).mix(style.accent, glow); + canvas.fill_circle(dot_x, dot_y, radius, color); + } +} + +/// Ring sizes per loop. +const BEACON_STEPS: f32 = 6.0; + +/// Centred text under a dot that sends out two radar rings, half a loop +/// apart, each growing and fading into the background. +fn draw_beacon(canvas: &mut Canvas, style: &ScreenStyle, phase: f32) { + let scale = design_scale(canvas); + let (center_x, center_y) = canvas_center(canvas); + let beacon_y = (center_y - 60.0 * scale).round(); + let thickness = (4.0 * scale).max(1.0); + for offset in [0.0, 0.5] { + let progress = stepped((phase + offset).fract(), BEACON_STEPS); + let radius = (14.0 + 54.0 * progress) * scale; + let color = style.background.mix(style.accent, 0.9 * (1.0 - progress)); + canvas.fill_ring(center_x, beacon_y, radius, radius - thickness, color); + } + canvas.fill_circle(center_x, beacon_y, (10.0 * scale).max(1.0), style.accent); + draw_text_block( + canvas, + style, + (center_x, center_y + 90.0 * scale), + Align::Center, + ); +} + +const ORBIT_DOTS: usize = 8; +/// Glow of the lit dot and the two it just left. +const ORBIT_TRAIL: [f32; 3] = [1.0, 0.55, 0.25]; + +/// Centred text under a ring of dots, one lit and chasing clockwise with +/// a fading trail: the classic loading spinner. +fn draw_orbit(canvas: &mut Canvas, style: &ScreenStyle, phase: f32) { + let scale = design_scale(canvas); + let (center_x, center_y) = canvas_center(canvas); + let spinner_y = center_y - 50.0 * scale; + let orbit_radius = 34.0 * scale; + let dot_radius = (6.0 * scale).max(1.0); + let lit = (phase * ORBIT_DOTS as f32) as usize % ORBIT_DOTS; + for index in 0..ORBIT_DOTS { + let behind = (lit + ORBIT_DOTS - index) % ORBIT_DOTS; + let glow = ORBIT_TRAIL.get(behind).copied().unwrap_or(0.0); + // Dot 0 sits at 12 o'clock; the index grows clockwise. + let angle = TAU * index as f32 / ORBIT_DOTS as f32 - FRAC_PI_2; + let dot_x = (center_x + orbit_radius * angle.cos()).round(); + let dot_y = (spinner_y + orbit_radius * angle.sin()).round(); + let color = idle_color(style).mix(style.accent, glow); + canvas.fill_circle(dot_x, dot_y, dot_radius, color); + } + draw_text_block( + canvas, + style, + (center_x, center_y + 50.0 * scale), + Align::Center, + ); +} + +/// Stripe positions per loop. +const STUDIO_STEPS: f32 = 12.0; +/// On a vertical canvas the text sits this far up from the bottom (of the +/// height): TikTok and Shorts cover the lowest part with comments and +/// buttons. +const STUDIO_VERTICAL_LIFT: f32 = 0.3; + +/// A broadcast lower third: an accent bar beside left-aligned text, and a +/// short stripe sliding along a track under it. +fn draw_studio(canvas: &mut Canvas, style: &ScreenStyle, phase: f32) { + let scale = design_scale(canvas); + let px = |design: f32| (design * scale).round() as i32; + let margin = px(96.0); + let height = canvas.height() as f32; + let lift = if height > canvas.width() as f32 { + height * STUDIO_VERTICAL_LIFT + } else { + 200.0 * scale + }; + let baseline = (height - lift).round() as i32; + + let bar_width = px(8.0).max(1); + canvas.fill_rect( + margin, + baseline - px(40.0), + bar_width, + px(100.0), + style.accent, + ); + let text_x = margin + bar_width + px(28.0); + draw_text_block(canvas, style, (text_x as f32, baseline as f32), Align::Left); + + let track_y = baseline + px(SUBLINE_DROP_PX + 36.0); + let track_width = px(360.0); + let track_height = px(4.0).max(1); + canvas.fill_rect( + text_x, + track_y, + track_width, + track_height, + idle_color(style), + ); + // The stripe enters from the left edge of the track and leaves at the + // right, clipped to it, so it reads as continuous motion. + let stripe_width = track_width / 4; + let travel = (track_width + stripe_width) as f32; + let stripe_x = text_x - stripe_width + (travel * stepped(phase, STUDIO_STEPS)) as i32; + let left = stripe_x.max(text_x); + let right = (stripe_x + stripe_width).min(text_x + track_width); + canvas.fill_rect(left, track_y, right - left, track_height, style.accent); +} + +const ARCADE_SUBLINE: Rgb = Rgb::new(0xb9, 0xb2, 0xd6); +const ARCADE_SQUARES: usize = 8; +/// Headline cell size at 1080 px: exactly one macroblock, so every +/// block of the pixel art is a single flat colour. +const ARCADE_CELL_AT_1080: f32 = 16.0; +const ARCADE_CURSOR_COLUMNS: i32 = 4; + +/// Pixel art snapped to the cell grid. +fn draw_arcade(canvas: &mut Canvas, style: &ScreenStyle, phase: f32) { + let width = canvas.width() as f32; + let height = canvas.height() as f32; + let scale = design_scale(canvas); + let headline = pixel_font::to_pixel_text(style.headline); + let subline = pixel_font::to_pixel_text(style.subline); + + // Headline plus a cursor of 4 columns and a 1 column gap. + let head_columns = (headline.len() * ADVANCE_COLUMNS + GLYPH_COLUMNS) as f32; + let cell = (ARCADE_CELL_AT_1080 * scale) + .round() + .min((width * 0.88 / head_columns).floor()) + .max(2.0) as i32; + let sub_columns = (subline.len() * ADVANCE_COLUMNS).max(1) as f32; + let sub_cell = ((cell as f32 * 0.45).round()) + .min((width * 0.9 / sub_columns).floor()) + .max(1.0) as i32; + let square = cell * 2; + let rows = GLYPH_ROWS as i32; + let total_height = 11 * cell + rows * sub_cell + 5 * cell + square; + + let head_x = snap((width - head_columns * cell as f32) / 2.0, cell); + let head_y = snap(height / 2.0 - total_height as f32 / 2.0, cell); + draw_pixel_text(canvas, &headline, head_x, head_y, cell, style.accent); + if phase < 0.5 { + let cursor_x = head_x + (headline.len() * ADVANCE_COLUMNS) as i32 * cell; + canvas.fill_rect( + cursor_x, + head_y, + ARCADE_CURSOR_COLUMNS * cell, + rows * cell, + style.accent, + ); + } + + let sub_y = head_y + 11 * cell; + let sub_x = snap((width - sub_columns * sub_cell as f32) / 2.0, sub_cell); + draw_pixel_text(canvas, &subline, sub_x, sub_y, sub_cell, ARCADE_SUBLINE); + + let row_width = ARCADE_SQUARES as i32 * square + (ARCADE_SQUARES as i32 - 1) * cell; + let row_x = snap((width - row_width as f32) / 2.0, cell); + let row_y = snap((sub_y + rows * sub_cell + 5 * cell) as f32, cell); + let lit = ((phase * ARCADE_SQUARES as f32) as usize).min(ARCADE_SQUARES - 1); + let trail = (lit + ARCADE_SQUARES - 1) % ARCADE_SQUARES; + let idle = idle_color(style); + for index in 0..ARCADE_SQUARES { + let color = match index { + i if i == lit => style.accent, + i if i == trail => idle.mix(style.accent, 0.4), + _ => idle, + }; + canvas.fill_rect( + row_x + index as i32 * (square + cell), + row_y, + square, + square, + color, + ); + } +} + +fn draw_pixel_text(canvas: &mut Canvas, text: &[char], x: i32, y: i32, cell: i32, color: Rgb) { + for (index, c) in text.iter().enumerate() { + let Some(rows) = pixel_font::glyph(*c) else { + continue; + }; + let glyph_x = x + (index * ADVANCE_COLUMNS) as i32 * cell; + for (row, bits) in rows.iter().enumerate() { + for column in (0..GLYPH_COLUMNS).filter(|column| pixel_font::is_lit(*bits, *column)) { + canvas.fill_rect( + glyph_x + column as i32 * cell, + y + row as i32 * cell, + cell, + cell, + color, + ); + } + } + } +} + +/// Round `value` to the nearest multiple of `step`. +fn snap(value: f32, step: i32) -> i32 { + (value / step as f32).round() as i32 * step +} + +#[cfg(test)] +mod tests { + use super::super::raster::YuvFrame; + use super::*; + + fn style(theme: SlateTheme) -> ScreenStyle<'static> { + ScreenStyle { + theme, + accent: Rgb::new(0x5a, 0xc8, 0xfa), + background: theme.default_background(), + headline: "Reconnecting", + subline: "Back in a moment", + } + } + + fn render(style: &ScreenStyle, width: usize, height: usize, phase: f32) -> YuvFrame { + let mut canvas = Canvas::new(width, height, style.background); + draw(&mut canvas, style, phase); + canvas.to_yuv420() + } + + fn is_flat_block(frame: &YuvFrame, block_x: usize, block_y: usize) -> bool { + let first = frame.y[block_y * 16 * frame.width + block_x * 16]; + (0..16).all(|dy| { + (0..16) + .all(|dx| frame.y[(block_y * 16 + dy) * frame.width + block_x * 16 + dx] == first) + }) + } + + #[test] + fn arcade_headline_lands_on_the_macroblock_grid_at_1080p() { + let frame = render(&style(SlateTheme::Arcade), 1920, 1080, 0.75); + // Headline cells are 16 px and snapped to multiples of 16, so the + // block rows the headline occupies are all single-colour. + let blocks_wide = 1920 / 16; + let flat = (0..blocks_wide) + .filter(|bx| is_flat_block(&frame, *bx, 24)) + .count(); + assert_eq!(flat, blocks_wide, "every headline-row block should be flat"); + // An empty row is flat too: the headline must really be on it. + let background = frame.y[0]; + let row = &frame.y[24 * 16 * frame.width..][..frame.width]; + assert!( + row.iter().any(|luma| *luma != background), + "the headline moved off block row 24" + ); + } + + #[test] + fn every_theme_animates_across_the_loop() { + for theme in SlateTheme::ALL { + let style = style(theme); + let early = render(&style, 640, 360, 0.1); + // A quarter loop apart: Beacon repeats every half loop, by design. + let late = render(&style, 640, 360, 0.35); + assert!(early.y != late.y, "{theme:?} is a still image"); + } + } + + #[test] + fn every_theme_draws_its_own_picture() { + let frames: Vec> = SlateTheme::ALL + .iter() + .map(|theme| render(&style(*theme), 640, 360, 0.3).y) + .collect(); + for (index, frame) in frames.iter().enumerate() { + let twins = frames.iter().filter(|other| *other == frame).count(); + assert_eq!( + twins, + 1, + "{:?} looks like another theme", + SlateTheme::ALL[index] + ); + } + } + + /// Full-size vertical canvases are built in full by the budget tests in + /// `slate`; this covers the tiny ones, either way up. + #[test] + fn themes_fit_tiny_canvases() { + for theme in SlateTheme::ALL { + for (width, height) in [(284, 160), (160, 284), (32, 32)] { + render(&style(theme), width, height, 0.3); + } + } + } + + /// Across the whole loop the track row is painted on exactly the + /// track's columns, including while the stripe enters and leaves, and + /// the stripe (a second colour on the track) shows up at some point. + #[test] + fn studio_stripe_stays_on_its_track() { + let style = style(SlateTheme::Studio); + let track_left = 96 + 8 + 28; + let track_columns: Vec = (track_left..track_left + 360).collect(); + let track_row = (1080.0 - 200.0 + SUBLINE_DROP_PX + 36.0) as usize; + let mut stripe_seen = false; + for step in 0..STUDIO_STEPS as usize { + let phase = step as f32 / STUDIO_STEPS; + let frame = render(&style, 1920, 1080, phase); + let row = &frame.y[track_row * frame.width..][..1920]; + let background_luma = row[0]; + let painted: Vec = (0..1920).filter(|x| row[*x] != background_luma).collect(); + assert_eq!( + painted, track_columns, + "phase {phase}: the track row is painted off the track's columns" + ); + stripe_seen |= painted.iter().any(|x| row[*x] != row[painted[0]]); + } + assert!(stripe_seen, "the stripe never showed on the track"); + } +} diff --git a/src/tray.rs b/src/tray.rs index d76de4e..115eb0a 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -73,6 +73,17 @@ const RESUME_TIMER_ID: usize = 1; /// back, the newer one is the one worth showing. static PENDING_BALLOON: crate::sync::Mutex = crate::sync::Mutex::new(String::new()); +/// Integration shortcuts register from this id up, one per combo, in the +/// order of `INTEGRATION_KEYS`. Far above the delay actions' ids. +const INTEGRATION_HOTKEY_BASE: i32 = 100; +/// More combos than anyone binds; keeps a broken config from registering +/// hundreds of system-wide hotkeys. +const MAX_INTEGRATION_HOTKEYS: usize = 64; + +/// The combos integrations listen for, as registered: id +/// `INTEGRATION_HOTKEY_BASE + n` is `INTEGRATION_KEYS[n]`. +static INTEGRATION_KEYS: crate::sync::Mutex> = crate::sync::Mutex::new(Vec::new()); + /// RegisterHotKey id for an action: its 1-based position in /// `config::ACTIONS` (0 is not a valid application hotkey id). Deriving both /// directions from that one list is what keeps the id a binding is filed @@ -108,6 +119,7 @@ const ID_OPEN_DASH: usize = 0x101; const ID_OPEN_DOCK: usize = 0x102; const ID_CUT_DELAY: usize = 0x103; const ID_COPY_URL: usize = 0x104; +const ID_END_HOLD: usize = 0x106; // 0x105 was "Launch OBS (VOD + EB)", a launcher from before the InstantClone // service existed. It passed OBS `--config-url` to switch Enhanced // Broadcasting on for a custom RTMP server, and wrote OBS's VOD-track flag on @@ -375,6 +387,9 @@ unsafe extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) // no runtime needed. state.ctrl.stop_delay(); } + ID_END_HOLD => { + state.ctrl.end_hold_now(); + } ID_COPY_URL => { let url = obs_rtmp_url(&state.settings.borrow()); let _ = set_clipboard_text(hwnd, &url); @@ -466,6 +481,46 @@ unsafe fn register_hotkeys(hwnd: HWND) { // Always published, including the empty case, so the dashboard clears a // warning the moment the user picks a combo that is actually free. state.ctrl.set_hotkey_conflicts(conflicts); + register_integration_hotkeys(hwnd, state, &hotkeys); +} + +/// Register the combos integrations start from. A combo a delay action +/// already holds stays the delay action's (the dashboard refuses to save +/// that pair, so this only guards a hand-edited config). +unsafe fn register_integration_hotkeys(hwnd: HWND, state: &TrayState, delay: &config::Hotkeys) { + let (mut keys, _) = + crate::integrations::model::shortcuts(&state.settings.borrow().integrations); + keys.retain(|combo| !delay.entries().iter().any(|(_, bound)| bound == combo)); + // Past the id range nothing registers: say so rather than drop them. + let mut refused = keys.split_off(keys.len().min(MAX_INTEGRATION_HOTKEYS)); + if !refused.is_empty() { + state.ctrl.log(format!( + "[hotkey] only {MAX_INTEGRATION_HOTKEYS} integration hotkeys can be live; {} more won't start anything", + refused.len() + )); + } + for (n, combo) in keys.iter().enumerate() { + let Some((mods, vk)) = config::parse_hotkey(combo) else { + continue; + }; + if RegisterHotKey( + hwnd, + INTEGRATION_HOTKEY_BASE + n as i32, + mods | MOD_NOREPEAT, + vk, + ) == 0 + { + state.ctrl.log(format!( + "[hotkey] {combo} is already in use by another app - the integration using it won't start from it" + )); + refused.push(combo.clone()); + } + } + // Always published, the empty case too, so a fixed key clears its warning. + if let Some(integrations) = state.ctrl.integrations() { + integrations.set_refused_keys(refused); + } + *INTEGRATION_KEYS.lock() = keys; } /// Keep the resume timer in step with the suspension: armed for whatever is @@ -491,12 +546,25 @@ unsafe fn unregister_hotkeys(hwnd: HWND) { for i in 0..config::ACTIONS.len() { UnregisterHotKey(hwnd, i as i32 + 1); } + let registered = std::mem::take(&mut *INTEGRATION_KEYS.lock()); + for n in 0..registered.len() { + UnregisterHotKey(hwnd, INTEGRATION_HOTKEY_BASE + n as i32); + } } /// Run the delay action bound to a fired hotkey. The RegisterHotKey id maps /// back to an action name via `hotkey_action`; the shared controller method /// carries the semantics (identical to the MIDI path). fn dispatch_hotkey(state: &TrayState, id: i32) { + if id >= INTEGRATION_HOTKEY_BASE { + let combo = usize::try_from(id - INTEGRATION_HOTKEY_BASE) + .ok() + .and_then(|n| INTEGRATION_KEYS.lock().get(n).cloned()); + if let (Some(combo), Some(integrations)) = (combo, state.ctrl.integrations()) { + integrations.shortcut(crate::integrations::Shortcut::Hotkey, &combo); + } + return; + } let Some(action) = hotkey_action(id) else { return; }; @@ -589,6 +657,12 @@ unsafe fn show_menu(hwnd: HWND) { MF_STRING | MF_DISABLED | MF_GRAYED }; AppendMenuW(menu, cut_flags, ID_CUT_DELAY, cut_label.as_ptr()); + // Crash protection: ending the hold ends the stream everywhere, so the + // item only exists while a hold is on air. + let end_hold_label = wide("End crash protection (ends the stream)"); + if state.ctrl.hold_active() { + AppendMenuW(menu, MF_STRING, ID_END_HOLD, end_hold_label.as_ptr()); + } AppendMenuW(menu, MF_SEPARATOR, 0, ptr::null()); let open = wide("Open dashboard"); @@ -645,6 +719,12 @@ unsafe fn readd_tray_icon(hwnd: HWND) { /// One-line summary of the controller's current state. Read on every /// menu open so it's always fresh. fn status_label(ctrl: &Controller) -> String { + if let Some(status) = ctrl.hold_status() { + return format!( + "Status: RECONNECT SCREEN - {} left", + crate::crash_hold::minutes_seconds(status.remaining) + ); + } let phase = ctrl.phase(); match phase { "active" => { diff --git a/src/update_check.rs b/src/update_check.rs index 5c45a58..b67a4e4 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -27,21 +27,15 @@ pub struct UpdateInfo { impl UpdateInfo { pub fn to_json(&self) -> String { - let latest_field = match &self.latest { - Some(v) => format!("\"{}\"", v.replace('"', "'")), - None => "null".to_string(), - }; - let error_field = match &self.error { - Some(e) => format!("\"{}\"", e.replace('"', "'").replace('\\', "\\\\")), - None => "null".to_string(), - }; + use crate::config::json_str; + let optional = |v: &Option| v.as_deref().map_or("null".to_string(), json_str); format!( - r#"{{"current":"{}","latest":{},"update_available":{},"release_url":"{}","error":{}}}"#, - self.current.replace('"', "'"), - latest_field, + r#"{{"current":{},"latest":{},"update_available":{},"release_url":{},"error":{}}}"#, + json_str(&self.current), + optional(&self.latest), self.update_available, - RELEASES_PAGE, - error_field, + json_str(RELEASES_PAGE), + optional(&self.error), ) } } @@ -116,7 +110,7 @@ fn fetch_latest() -> UpdateInfo { }; } }; - let latest_stripped = latest_tag.trim_start_matches('v').to_string(); + let latest_stripped = strip_tag_prefix(&latest_tag).to_string(); let update_available = is_newer(&latest_stripped, ¤t); UpdateInfo { current, @@ -142,14 +136,14 @@ fn extract_tag_name(body: &str) -> Option { Some(value_slice[..quote_end].to_string()) } -/// SemVer-ish comparison: split on `.` and `-`, compare component by -/// component (numbers numerically, suffixes lexically). Handles -/// `0.1.3` vs `0.1.3-beta.7` correctly (prerelease loses to release of -/// the same base version). Returns true iff `latest` is strictly newer -/// than `current`. +/// SemVer comparison: base components numerically, then the prerelease +/// per SemVer 11.4 (see `compare_prerelease`). Handles `0.1.3` vs +/// `0.1.3-beta.7` correctly (prerelease loses to release of the same base +/// version). A `v`/`V` tag prefix and `+build` metadata are ignored. +/// Returns true iff `latest` is strictly newer than `current`. fn is_newer(latest: &str, current: &str) -> bool { - let (lat_base, lat_pre) = split_prerelease(latest); - let (cur_base, cur_pre) = split_prerelease(current); + let (lat_base, lat_pre) = split_prerelease(strip_build_metadata(strip_tag_prefix(latest))); + let (cur_base, cur_pre) = split_prerelease(strip_build_metadata(strip_tag_prefix(current))); let lat_parts = parse_dotted(lat_base); let cur_parts = parse_dotted(cur_base); match lat_parts.cmp(&cur_parts) { @@ -160,11 +154,48 @@ fn is_newer(latest: &str, current: &str) -> bool { (None, None) => false, (None, Some(_)) => true, (Some(_), None) => false, - (Some(a), Some(b)) => a > b, + (Some(a), Some(b)) => compare_prerelease(a, b).is_gt(), }, } } +/// `v0.1.15` / `V0.1.15` -> `0.1.15`. Release tags are written by hand, so +/// the prefix case is not guaranteed. +fn strip_tag_prefix(tag: &str) -> &str { + tag.trim().trim_start_matches(['v', 'V']) +} + +/// SemVer build metadata (`+...`) carries no precedence, so it is dropped. +fn strip_build_metadata(v: &str) -> &str { + v.split_once('+').map_or(v, |(version, _)| version) +} + +/// SemVer 11.4 prerelease precedence: dot-separated identifiers compared +/// left to right, numeric ones as numbers (so `beta.10` > `beta.9`), +/// numeric below alphanumeric, and a longer list wins a shared prefix. +fn compare_prerelease(a: &str, b: &str) -> std::cmp::Ordering { + use std::cmp::Ordering; + let mut left = a.split('.'); + let mut right = b.split('.'); + loop { + let (l, r) = match (left.next(), right.next()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(l), Some(r)) => (l, r), + }; + let order = match (l.parse::(), r.parse::()) { + (Ok(l), Ok(r)) => l.cmp(&r), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + (Err(_), Err(_)) => l.cmp(r), + }; + if order.is_ne() { + return order; + } + } +} + fn split_prerelease(v: &str) -> (&str, Option<&str>) { match v.split_once('-') { Some((base, pre)) => (base, Some(pre)), @@ -223,4 +254,51 @@ mod tests { assert!(is_newer("0.1.3-beta.8", "0.1.3-beta.7")); assert!(!is_newer("0.1.3-beta.6", "0.1.3-beta.7")); } + + /// Prerelease numbers compare as numbers (SemVer 11.4.1). As text, + /// "10" sorts before "9", so beta.10 would never be offered to beta.9. + #[test] + fn is_newer_prerelease_numbers_compare_numerically() { + assert!(is_newer("0.1.3-beta.10", "0.1.3-beta.9")); + assert!(!is_newer("0.1.3-beta.9", "0.1.3-beta.10")); + // A longer identifier list wins when the shared prefix is equal, + // and a numeric identifier sorts below an alphanumeric one. + assert!(is_newer("0.1.3-beta.1.1", "0.1.3-beta.1")); + assert!(is_newer("0.1.3-rc", "0.1.3-2")); + } + + /// Two-digit components must not sort as text either. + #[test] + fn is_newer_two_digit_patch_beats_one_digit() { + assert!(is_newer("0.1.10", "0.1.9")); + assert!(!is_newer("0.1.9", "0.1.10")); + } + + /// Tag prefixes and build metadata are not part of the version: an + /// uppercase `V` or a `+build` suffix used to parse a component as 0, + /// so the release looked older than what is installed. + #[test] + fn is_newer_ignores_tag_prefix_case_and_build_metadata() { + assert!(is_newer("V1.0.0", "0.1.14")); + assert!(is_newer("0.1.15+build.7", "0.1.14")); + assert!(!is_newer("0.1.14+build.7", "0.1.14")); + assert_eq!(strip_tag_prefix("V1.0.0"), "1.0.0"); + assert_eq!(strip_tag_prefix("v0.1.15"), "0.1.15"); + } + + /// The error text comes from ureq or the OS and can carry newlines or + /// control characters, which a raw JSON string cannot contain. The + /// dashboard would then fail to parse the whole response. + #[test] + fn to_json_escapes_control_characters_in_the_error() { + let info = UpdateInfo { + current: "0.1.14".into(), + latest: None, + update_available: false, + error: Some("network: line one\nline \"two\"\t\\ end".into()), + }; + let json = info.to_json(); + assert!(crate::config::is_valid_json(&json), "invalid JSON: {json}"); + assert!(json.contains(r#""error":"network: line one\nline \"two\"\t\\ end""#)); + } } diff --git a/src/web.rs b/src/web.rs index 075f432..c5dd3fc 100644 --- a/src/web.rs +++ b/src/web.rs @@ -44,7 +44,7 @@ static SETTINGS_WRITE_LOCK: crate::sync::Mutex<()> = crate::sync::Mutex::new(()) /// Take the settings write lock for a full read-modify-write-send cycle. Hold /// the guard from just before `settings.borrow().clone()` until after /// `settings.send(..)`. -fn settings_write_guard() -> std::sync::MutexGuard<'static, ()> { +pub(crate) fn settings_write_guard() -> std::sync::MutexGuard<'static, ()> { SETTINGS_WRITE_LOCK.lock() } @@ -144,17 +144,7 @@ async fn serve( let Some(content_length) = content_length else { let body = r#"{"ok":false,"error":"malformed Content-Length or unsupported Transfer-Encoding"}"#; - let r = format!( - "HTTP/1.1 400 Bad Request -Content-Type: application/json - Content-Length: {} -Connection: close - -{}", - body.len(), - body - ); - sock.write_all(r.as_bytes()).await?; + write_simple(&mut sock, "400 Bad Request", "application/json", body, "").await?; return Ok(()); }; let (origin, host) = parse_origin_host(head_str); @@ -215,13 +205,21 @@ Connection: close let mut tmp = [0u8; 4096]; let n = match tokio::time::timeout_at(body_deadline, sock.read(&mut tmp)).await { Ok(r) => r?, - Err(_) => break, // body took too long; use what we have + Err(_) => break, // body took too long; refused below }; if n == 0 { break; } b.extend_from_slice(&tmp[..n]); } + // The client stopped (EOF or the deadline) before the body it + // announced. Running the route on the fragment would read a cut-off + // `ms=30000` as `ms=3` and arm that, so refuse instead. + if b.len() < content_length { + let body = r#"{"ok":false,"error":"request body shorter than its Content-Length"}"#; + write_simple(&mut sock, "400 Bad Request", "application/json", body, "").await?; + return Ok(()); + } // Drop any pipelined bytes past this request (we always Connection: // close, so there is no next request on this socket anyway). b.truncate(content_length); @@ -229,7 +227,13 @@ Connection: close } else { Vec::new() }; - let body = std::str::from_utf8(&body_buf).unwrap_or(""); + // Same reasoning as the Content-Length refusal: reading a body that is not + // UTF-8 as "" would run `POST /arm` with no `ms`, which is a disarm. + let Ok(body) = std::str::from_utf8(&body_buf) else { + let body = r#"{"ok":false,"error":"request body is not valid UTF-8"}"#; + write_simple(&mut sock, "400 Bad Request", "application/json", body, "").await?; + return Ok(()); + }; // Optional dashboard auth. Off by default (a single is_empty() inside), // fail-closed once a password is set. The whole security-critical surface @@ -283,7 +287,11 @@ Connection: close // Overlay Studio runtime - static pre-gzipped JS, same fast-path as // the dashboard. Served to the dashboard tab only; baked overlays // inline what they need and never request this. - if method == "GET" && (bare_path == "/overlay-runtime.js" || bare_path == "/dock.js") { + if method == "GET" + && (bare_path == "/overlay-runtime.js" + || bare_path == "/dock.js" + || bare_path == "/integrations.js") + { if !accept_gzip { let body = b"this build serves gzip-encoded JS; \ retry with Accept-Encoding: gzip" as &[u8]; @@ -296,10 +304,10 @@ Connection: close sock.write_all(body).await?; return Ok(()); } - let blob: &'static [u8] = if bare_path == "/dock.js" { - DOCK_JS_GZ - } else { - OVERLAY_RUNTIME_JS_GZ + let blob: &'static [u8] = match bare_path { + "/dock.js" => DOCK_JS_GZ, + "/integrations.js" => INTEGRATIONS_JS_GZ, + _ => OVERLAY_RUNTIME_JS_GZ, }; let r = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/javascript; charset=utf-8\r\n\ @@ -347,6 +355,10 @@ Connection: close if method == "GET" && bare_path == "/overlay-events" { return handle_sse(sock, ctrl, settings, sysstat, Feed::Overlay).await; } + // The alerts browser source's feed: what "Show on stream" steps put up. + if method == "GET" && bare_path == "/alerts-events" { + return handle_sse(sock, ctrl, settings, sysstat, Feed::Alerts).await; + } // Lifecycle controls (admin-gated by auth_gate above): acknowledge and // flush the 200 to the client BEFORE tripping the shutdown signal. The main @@ -370,29 +382,108 @@ Connection: close return Ok(()); } + // Crash-protection preview: the reconnect screen from the same renderer + // the stream uses. Binary, so it can't go through `route`. Admin-gated + // by auth_gate (classify_access's default). A 24-frame strip takes tens + // of milliseconds to draw, so it renders on a blocking thread: on this + // runtime's one thread it would stall every destination's egress. + if method == "GET" && bare_path == "/crash-protection/preview" { + let query = path.split_once('?').map_or("", |(_, q)| q).to_string(); + let saved = settings.borrow().crash_protection.clone(); + let image = tokio::task::spawn_blocking(move || crash_protection_preview(&query, &saved)) + .await + .map_err(io::Error::other)?; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\n\ + Cache-Control: no-store\r\nConnection: close\r\n\r\n", + image.len() + ); + sock.write_all(head.as_bytes()).await?; + sock.write_all(&image).await?; + return Ok(()); + } + + let local_steps_allowed = may_set_up_local_steps(&settings.borrow(), &peer_ip, head_str); + 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; - // ACAO is intentionally restrictive now - only set on GET responses - // so overlays / docks loaded as foreign origins can still read state. - // POST endpoints get NO ACAO header, which (with credentials=false) - // blocks cross-origin script-readable responses too. - let acao = if method == "GET" { + // ACAO is intentionally restrictive: only GETs that carry no secret, so + // overlays and widgets loaded from other origins can still read state. + // Everything else (config, destinations, integrations, logs) gets none, + // or any website could read stream keys and webhooks from a dashboard + // without a password. POSTs get none either. + let acao = if method == "GET" && readable_cross_origin(bare_path) { "Access-Control-Allow-Origin: *\r\n" } 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?; Ok(()) } +/// Render the preview for the saved crash-protection settings with any +/// unsaved `crash_protection.*` edits from the query applied on top, so +/// the dashboard can preview before Save. `orientation=vertical` flips it +/// to 9:16; `phase` (0..1) picks the moment in the 2 s loop, or +/// `frames=N` stacks N evenly spaced moments into one strip the dashboard +/// animates; `size=thumb` draws the small version the theme picker shows. +fn crash_protection_preview( + query: &str, + saved: &crate::crash_protection::CrashProtection, +) -> Vec { + const DEFAULT_PHASE: f32 = 0.25; + const MAX_FRAMES: usize = 24; + let form = config::parse_form(query); + let mut settings = saved.clone(); + for (key, value) in &form { + if let Some(field) = key.strip_prefix(crate::crash_protection::KEY_PREFIX) { + settings.set(field, value); + } + } + let (long_side, short_side) = match form.get("size").map(String::as_str) { + Some("thumb") => (192, 108), + _ => (480, 270), + }; + let (width, height) = if form.get("orientation").map(String::as_str) == Some("vertical") { + (short_side, long_side) + } else { + (long_side, short_side) + }; + let frames = form + .get("frames") + .and_then(|value| value.parse::().ok()) + .map(|count| count.clamp(1, MAX_FRAMES)); + let phases: Vec = match frames { + Some(count) => (0..count).map(|i| i as f32 / count as f32).collect(), + None => vec![form + .get("phase") + .and_then(|value| value.parse::().ok()) + .filter(|value| (0.0..1.0).contains(value)) + .unwrap_or(DEFAULT_PHASE)], + }; + crate::slate::preview_png(&settings, width, height, &phases) +} + /// Result of the auth gate: either it already wrote a response (login page, /// 401, redirect, or an /auth/* mutation) and the caller returns, or the /// request is allowed through with an optional `/dock` Set-Cookie header. @@ -550,6 +641,30 @@ async fn auth_gate( } } + // DNS rebinding guard. With no password, the browser's same-origin rule + // is all that keeps a website away from this API, and a site whose name + // re-resolves to 127.0.0.1 passes it: its pages become "same origin" with + // the dashboard and could save an integration that runs a program. Such a + // request names the attacker's domain in Host, while the dashboard is + // reached by an IP or localhost. A password closes this on its own (the + // session cookie never belongs to the attacker's origin), so names stay + // allowed then, for reverse proxies and LAN host names. + if settings.borrow().dashboard_password_hash.is_empty() + && classify_access(method, bare_path) != Access::Public + && !host_is_address(&parse_origin_host(head_str).1) + { + write_simple( + sock, + "403 Forbidden", + "text/plain; charset=utf-8", + "Open InstantClone by its address (http://127.0.0.1:), \ + or set a dashboard password to reach it by name.", + "", + ) + .await?; + return Ok(AuthDecision::Handled); + } + // Auth management (cookie-bearing responses). Reached only after the gate // above (these classify as admin, so session-gated when auth is on); open // when auth is off so the very first password can be set locally. @@ -560,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 && !from_this_pc(peer_ip, head_str) { write_simple( sock, "403 Forbidden", @@ -681,8 +796,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( @@ -693,8 +817,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), @@ -738,10 +863,39 @@ async fn route( } } + // Integrations: `/hooks/` is how outside apps trigger a web call + // integration; the rest is the dashboard's integrations API. + if let Some(token) = bare_path.strip_prefix("/hooks/") { + return crate::integrations::api::hook(ctrl, token, body, query); + } + if bare_path.starts_with("/integrations") + || 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, + caller.local_steps_allowed, + ) + .await + { + return reply; + } + } + match (method, bare_path) { // GET / and GET /dock are handled in serve() as a fast-path // (static gz blob, no allocation, no String round-trip). ("GET", "/overlay") => ("200 OK", "text/html; charset=utf-8", overlay_html(query)), + ("GET", "/alerts") => ( + "200 OK", + "text/html; charset=utf-8", + ALERTS_HTML.to_string(), + ), ("GET", "/state") => ( "200 OK", "application/json", @@ -773,15 +927,20 @@ async fn route( // no Twitch destination is configured or the upstream call // fails. GET is supported as an escape hatch for poking the // static fallback from a browser address bar. - ("POST", "/obs/multitrack-config") => ( - "200 OK", - "application/json", - obs_multitrack_config_proxy(body, query, ctrl, settings).await, - ), + ("POST", "/obs/multitrack-config") => { + let authorized = eb_request_authorized(body, &settings.borrow().ingest_key); + let config = obs_multitrack_config_proxy(body, query, ctrl, settings).await; + // A refused request gets the static config and its publish is + // turned away: it must not replace the live session's tracks. + if authorized { + ctrl.note_eb_config(&config); + } + ("200 OK", "application/json", config) + } ("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(); @@ -1052,7 +1211,8 @@ async fn route( ("GET", "/destinations") => ( "200 OK", "application/json", - destinations_json(ctrl, settings), + // Custom URLs raw for a full session only; see destinations_json. + destinations_json(ctrl, settings, is_admin), ), ("POST", "/config") => post_config(body, ctrl, settings, cfg_path).await, @@ -1067,6 +1227,21 @@ async fn route( ("POST", "/go-live") => post_stop(ctrl, settings, cfg_path, sysstat).await, ("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") => { + 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", + state_json(ctrl, settings, sysstat), + ) + } // MIDI mapping (Windows). Learn mode is server-side because the MIDI // events arrive at the backend, not the browser; the dashboard polls. #[cfg(windows)] @@ -1075,7 +1250,6 @@ async fn route( ("POST", "/midi/learn/cancel") => post_midi_learn_cancel(ctrl).await, ("POST", "/midi/poll") => post_midi_poll(ctrl, settings, cfg_path).await, ("POST", "/test-egress") => test_egress(settings).await, - ("POST", "/test-webhook") => post_test_webhook(ctrl).await, ("POST", "/logs/clear") => { ctrl.clear_logs(); ("200 OK", "application/json", r#"{"ok":true}"#.into()) @@ -1120,6 +1294,8 @@ async fn route( enum Feed { Dashboard, Overlay, + /// The integrations' on-stream alerts: only the cards themselves. + Alerts, } async fn handle_sse( @@ -1151,6 +1327,10 @@ async fn handle_sse( let cur = match feed { Feed::Dashboard => state_json(&ctrl, &settings, &sysstat), Feed::Overlay => overlay_state_json(&ctrl, &settings), + Feed::Alerts => ctrl.integrations().map_or_else( + || r#"{"boot":"","alerts":[]}"#.to_string(), + |h| h.alerts.to_json(), + ), }; let now = std::time::Instant::now(); let changed = cur != last_payload; @@ -1198,7 +1378,7 @@ fn video_readouts(ctrl: &Controller) -> std::collections::BTreeMap String { + match ctrl.hold_status() { + Some(status) => format!( + r#"{{"reason":"{}","remaining_ms":{},"total_ms":{}}}"#, + 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() + ), + None => "null".into(), + } +} + fn state_json( ctrl: &Controller, settings: &Arc>, @@ -1277,6 +1477,7 @@ fn state_json( // only refetched on config edits; anything live that lives solely // there freezes on the cards until the user reloads. let readouts = video_readouts(ctrl); + let holding = ctrl.hold_active(); let dest_list = s.destinations.iter().map(|d| { let st = snap.iter().find(|t| t.0 == d.id); let (alive, kbps, tags, bytes, cuts, recon) = st @@ -1284,7 +1485,7 @@ fn state_json( .unwrap_or((false, 0u32, 0u64, 0u64, 0u32, 0u32)); let v = dest_video(ctrl, d, &readouts); format!( - r#"{{"id":{id},"name":{n},"enabled":{en},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"reconnects":{rc},"vertical_ready":{vr},"vertical_canvas_present":{vcp},"video_res":{vres},"video_codec":{vcod}}}"#, + r#"{{"id":{id},"name":{n},"enabled":{en},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"reconnects":{rc},"vertical_ready":{vr},"vertical_canvas_present":{vcp},"video_res":{vres},"video_codec":{vcod},"on_hold":{oh}}}"#, id = json_escape_quoted(&d.id), n = json_escape_quoted(&d.name), en = d.enabled, al = alive, br = kbps, ts = tags, bs = bytes, cu = cuts, rc = recon, @@ -1292,6 +1493,7 @@ fn state_json( vcp = v.vertical_canvas_present, vres = json_escape_quoted(&v.res), vcod = json_escape_quoted(&v.codec), + oh = holding && alive && crate::crash_hold::covers(ctrl, &ctrl.destination_state(&d.id)), ) }).collect::>().join(","); @@ -1301,21 +1503,29 @@ 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() }; - // A portrait canvas is present on the wire (Twitch Dual Format is live). - // Drives the header "Dual Format" pill. - let vertical_present = - crate::h264::detect_vertical_primary_track(&ctrl.ring.video_seq_headers.lock()).is_some(); + // A portrait canvas is on the wire right now (OBS's Additional canvas, + // or Twitch Dual Format). Drives the header "Vertical" pill. + 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},"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() @@ -1359,6 +1569,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(), @@ -1374,6 +1585,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. /// @@ -1527,7 +1776,23 @@ async fn obs_multitrack_config_proxy( "OBS_MULTITRACK", "wrong ingest key in request - refusing to broker an EB session", ); - return obs_multitrack_config_static(query, settings); + return obs_multitrack_config_static(query, body, settings); + } + + // OBS is back while crash protection keeps its Twitch session live on + // the reconnect screen: hand it the same config, so it publishes the + // same tracks with the same session token and the destination carries + // on without a restart. + if let Some(config) = ctrl.held_eb_config() { + ctrl.log( + "[OBS multitrack] crash protection: OBS is back - continuing the live \ + Twitch session", + ); + crate::trace::log( + "OBS_MULTITRACK", + "hold active - reusing the live EB session", + ); + return config; } // The streamer's real Twitch key lives in our destinations list. @@ -1546,14 +1811,7 @@ async fn obs_multitrack_config_proxy( .map(|d| d.stream_key.clone()) }; let Some(twitch_key) = twitch_key else { - ctrl.log( - "[OBS multitrack] no enabled Twitch destination with a stream key - \ - returning static config. Twitch will accept the multi-track stream \ - but won't go live to viewers without an API-allocated session. \ - Fix: Destinations → enable a Twitch destination with the real key.", - ); - crate::trace::log("OBS_MULTITRACK", "no twitch destination - static fallback"); - return obs_multitrack_config_static(query, settings); + return obs_multitrack_config_local(body, query, ctrl, settings); }; // Swap the `authentication` field in OBS's payload with the real @@ -1571,8 +1829,27 @@ async fn obs_multitrack_config_proxy( "OBS_MULTITRACK", "could not patch authentication field - static fallback", ); - return obs_multitrack_config_static(query, settings); + return obs_multitrack_config_static(query, body, settings); + } + }; + + // Twitch picks the main track's codec from the ones OBS offers: HEVC + // for a 2K (1440p) channel on a GPU that has it. A horizontal + // destination besides Twitch gets that same track, so while one is + // enabled only H.264 is offered. Twitch keeps the vertical track's codec + // whatever this list says, and refuses a session whose tracks don't + // match its config, so the vertical track is forwarded as it comes. + let needs_h264 = feeds_main_track_beyond_twitch(&settings.borrow().destinations, &twitch_key); + let modified_body = match offer_only_h264(&modified_body) { + Some(h264_only) if needs_h264 && h264_only != modified_body => { + ctrl.log( + "[OBS multitrack] asking Twitch for an H.264 main track - your \ + other destinations can't play HEVC or AV1", + ); + crate::trace::log("OBS_MULTITRACK", "supported_codecs narrowed to h264"); + h264_only } + _ => modified_body, }; let ingest_port = settings.borrow().ingest_port; @@ -1646,12 +1923,12 @@ async fn obs_multitrack_config_proxy( from this machine.", ); crate::trace::log("OBS_MULTITRACK", "Twitch API timed out - static fallback"); - return obs_multitrack_config_static(query, settings); + return obs_multitrack_config_static(query, body, settings); } - ProxyOutcome::HttpError(code, body) => { + 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 +1937,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 +1948,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,10 +1959,25 @@ 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); } }; + apply_twitch_multitrack_config(&twitch_json, &twitch_key, ingest_port, ctrl, settings) +} + +/// What the proxy does with a successful Twitch response: point OBS at our +/// ingest, remember the session tokens OBS will publish with, and aim the +/// matching Twitch destination at the session's IVS endpoint. Split from the +/// HTTP call in `obs_multitrack_config_proxy` so it can be tested without the +/// network; the behaviour is unchanged. +fn apply_twitch_multitrack_config( + twitch_json: &str, + twitch_key: &str, + ingest_port: u16, + ctrl: &Arc, + settings: &Arc>, +) -> String { // Twitch's response has one or more `ingest_endpoints` entries // with `url_template` values like // `rtmps://.contribute.live-video.net/app/{stream_key}`. @@ -1696,7 +1988,7 @@ async fn obs_multitrack_config_proxy( // set that token would not match it, so we remember the token below and // begin_publish accepts it (see Controller::remember_eb_key). let rewritten = rewrite_url_templates( - &twitch_json, + twitch_json, &format!("rtmp://localhost:{}/live/{{stream_key}}", ingest_port), ); // Extract the *original* IVS ingest URL from Twitch's response @@ -1715,7 +2007,7 @@ async fn obs_multitrack_config_proxy( // any error html_en_us payload. The stream key gets redacted out // of any url_template via simple substring replacement so the // trace stays shareable. - let sanitized = twitch_json.replace(&twitch_key, ""); + let sanitized = twitch_json.replace(twitch_key, ""); crate::trace::log( "OBS_MULTITRACK_RESPONSE", &format!("(stream key redacted) {sanitized}"), @@ -1736,10 +2028,10 @@ async fn obs_multitrack_config_proxy( // value. When absent (rare - non-IVS multitrack services), fall // back to the user's configured Twitch stream key so we at least // attempt a valid auth. - let (ivs_template, ivs_auth) = first_ingest_endpoint(&twitch_json) + let (ivs_template, ivs_auth) = first_ingest_endpoint(twitch_json) .map(|e| (Some(e.url_template), e.authentication)) .unwrap_or((None, None)); - let substitution = ivs_auth.as_deref().unwrap_or(&twitch_key); + let substitution = ivs_auth.as_deref().unwrap_or(twitch_key); // OBS will publish the EB stream to our ingest using an endpoint's session // token as the stream key (it fills the {stream_key} token in the url_template // we returned). Remember EVERY endpoint's token, not just the first: OBS's @@ -1748,7 +2040,7 @@ async fn obs_multitrack_config_proxy( // token for its RTMP and RTMPS endpoints, but we don't rely on that. Each is // remembered so begin_publish accepts the EB publish once an ingest key is set // - the token and the ingest key are different by design. - let endpoint_auths = all_ingest_endpoint_auths(&twitch_json); + let endpoint_auths = all_ingest_endpoint_auths(twitch_json); for auth in &endpoint_auths { ctrl.remember_eb_key(auth.clone()); } @@ -1756,7 +2048,7 @@ async fn obs_multitrack_config_proxy( // Key field, which our proxy swapped for the real Twitch key - remember that // so the publish is still accepted. if endpoint_auths.is_empty() { - ctrl.remember_eb_key(twitch_key.clone()); + ctrl.remember_eb_key(twitch_key.to_string()); } let ivs_url = ivs_template.map(|t| t.replace("{stream_key}", substitution)); if let Some(ivs) = ivs_url.as_ref() { @@ -1786,6 +2078,17 @@ async fn obs_multitrack_config_proxy( if let Some(id) = chosen_id { let state = ctrl.destination_state(&id); *state.eb_override_url.lock() = Some(ivs.clone()); + let auths = if endpoint_auths.is_empty() { + vec![twitch_key.to_string()] + } else { + endpoint_auths.clone() + }; + ctrl.remember_eb_session(crate::controller::EbSession { + config: rewritten.clone(), + auths, + dest_id: id, + ivs_url: ivs.clone(), + }); } // Clean up any stale override on OTHER Twitch destinations - // the proxy might have run before and left stale state from a @@ -1832,6 +2135,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 } @@ -1957,6 +2267,39 @@ fn replace_auth_field(json: &str, new_value: &str) -> Option { )) } +/// Whether an enabled horizontal destination other than the Twitch one +/// holding the EB session (`session_key`) gets the main EB track. Kick and +/// most ingests only decode H.264, and so does a second Twitch account, +/// which streams outside any EB session. The local test sink takes anything. +fn feeds_main_track_beyond_twitch(destinations: &[config::Destination], session_key: &str) -> bool { + destinations.iter().any(|d| { + let holds_session = d.platform == "twitch" && d.stream_key == session_key; + d.enabled && !holds_session && d.platform != "sink" && !d.wants_vertical() + }) +} + +/// OBS's config request with `client.supported_codecs` narrowed to +/// `["h264"]`, so Twitch builds every track as H.264. `None` when the list +/// is missing or has no H.264 to keep; the request then goes unchanged. +fn offer_only_h264(json: &str) -> Option { + const KEY: &str = r#""supported_codecs""#; + let after_key = &json[json.find(KEY)? + KEY.len()..]; + let list = after_key.trim_start().strip_prefix(':')?.trim_start(); + if !list.starts_with('[') { + return None; + } + let list_len = list.find(']')? + 1; + if !list[..list_len].contains(r#""h264""#) { + return None; + } + let list_start = json.len() - list.len(); + Some(format!( + r#"{}["h264"]{}"#, + &json[..list_start], + &json[list_start + list_len..] + )) +} + /// Self-triggered Twitch GetClientConfiguration for VOD-audio mode. /// Called from the supervisor when a destination has `vod_audio=true` /// but no eb_override_url yet. We construct a minimal POST body asking @@ -2023,18 +2366,21 @@ fn rewrite_url_templates(json: &str, new_value: &str) -> String { let key_pos = cursor + rel_pos; // Copy everything before the key verbatim. out.push_str(&json[cursor..key_pos]); - // Walk through key + `:` + whitespace + opening quote. + // Walk through key + `:` + whitespace + opening quote. Only a key is + // followed by a colon: the same text as a VALUE ("note":"url_template") + // is followed by `,` `}` or `]`, and searching on for the next colon + // would take the next field's value for ours and drop the key between. + // Likewise a non-string value (`null`) is not a URL to replace. In both + // cases copy the match verbatim and keep looking. let after_key = &json[key_pos + key.len()..]; - let Some(colon_off) = after_key.find(':') else { - // Malformed - bail and emit the remainder unchanged. - out.push_str(&json[key_pos..]); - return out; - }; - let after_colon = &after_key[colon_off + 1..]; - let Some(quote_off) = after_colon.find('"') else { - out.push_str(&json[key_pos..]); - return out; - }; + let colon_off = after_key.len() - after_key.trim_start().len(); + let after_colon = after_key[colon_off..].strip_prefix(':').unwrap_or(""); + let quote_off = after_colon.len() - after_colon.trim_start().len(); + if !after_key[colon_off..].starts_with(':') || !after_colon[quote_off..].starts_with('"') { + out.push_str(key); + cursor = key_pos + key.len(); + continue; + } let value_start_abs = key_pos + key.len() + colon_off + 1 + quote_off; let after_quote = &json[value_start_abs + 1..]; let Some(end_quote_off) = after_quote.find('"') else { @@ -2057,60 +2403,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 +2503,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 +2522,274 @@ 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.into(), + 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 offered = crate::local_eb_config::parse_canvases(body); + let canvases = canvases_to_encode(&offered, &settings.borrow().destinations); + if canvases.len() < offered.len() { + ctrl.log( + "[OBS multitrack] OBS's Additional canvas is not encoded: no destination is set \ + to Vertical", + ); + } + 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)); + // The main track is what OBS streams with Enhanced Broadcasting off, so + // turning EB on changes nothing about it (see `StreamerSettings`). + let streamer = crate::obs_register::active_stream_settings(); + match &streamer { + Some(own) => ctrl.log(format!( + "[OBS multitrack] main track uses your OBS stream settings: {}, {} kbps", + own.encoder + .as_ref() + .map_or("your bitrate with InstantClone's encoder", |e| { + e.encoder_type.as_str() + }), + own.bitrate_kbps + )), + None => ctrl.log( + "[OBS multitrack] couldn't read your OBS stream settings (Settings > Output) - \ + using InstantClone's defaults", + ), + } + let own_encoder = streamer.as_ref().and_then(|own| own.encoder.as_ref()); + let vertical_own = own_encoder_for_vertical(own_encoder, &settings.borrow().destinations); + if canvases.len() > 1 { + ctrl.log(match vertical_own { + Some(own) => format!( + "[OBS multitrack] vertical track uses your encoder ({})", + own.codec().label() + ), + None => "[OBS multitrack] vertical track uses H.264, which every vertical \ + destination plays" + .to_string(), + }); + } + let ingest_port = settings.borrow().ingest_port; + let built = crate::local_eb_config::build( + &canvases, + bandwidth, + streamer.as_ref(), + ingest_port, + |kbps| picked_encoder(vertical_own, &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); + }; + let budget = match streamer { + Some(_) => String::new(), + None => format!("{bandwidth} kbps total, "), + }; + ctrl.log(format!( + "[OBS multitrack] no Twitch destination - building the config here: {}, \ + {budget}InstantClone's encoder: {} ({})", + 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 +} + +/// The canvases OBS offered that are worth encoding. The Additional canvas +/// only feeds destinations set to Vertical, so with none enabled it would +/// cost a whole encode (and, on the defaults, a share of the bitrate) for a +/// track nobody receives. +fn canvases_to_encode( + offered: &[crate::local_eb_config::ObsCanvas], + destinations: &[config::Destination], +) -> Vec { + let streams_vertical = destinations.iter().any(|d| d.enabled && d.wants_vertical()); + let keep = if streams_vertical { offered.len() } else { 1 }; + offered.iter().take(keep).copied().collect() +} + +/// The encoder for a track InstantClone fills in: the streamer's own at +/// `kbps` when it suits every vertical destination (see +/// `own_encoder_for_vertical`), otherwise InstantClone's choice. +fn picked_encoder( + own_for_vertical: Option<&crate::local_eb_config::TrackEncoder>, + choice: &EncoderChoice, + kbps: u32, +) -> crate::local_eb_config::TrackEncoder { + match own_for_vertical { + Some(own) => own.at_bitrate(kbps), + None => encoder_for(choice, kbps), + } +} + +/// The streamer's own encoder when every enabled vertical destination plays +/// its codec (HEVC to YouTube, say), so the vertical track matches the main +/// one; None means InstantClone's H.264, which plays everywhere. +fn own_encoder_for_vertical<'a>( + own: Option<&'a crate::local_eb_config::TrackEncoder>, + destinations: &[config::Destination], +) -> Option<&'a crate::local_eb_config::TrackEncoder> { + own.filter(|own| { + let codec = own.codec(); + destinations + .iter() + .filter(|d| d.enabled && d.wants_vertical()) + .all(|d| d.plays(codec)) + }) +} + fn twitch_ingests_json() -> String { let mut out = String::from("["); for (i, (slug, label)) in config::twitch_ingests().iter().enumerate() { @@ -2215,33 +2802,40 @@ fn twitch_ingests_json() -> String { out } -/// Config keys this route will write. Every other field has its own route +/// Exact config keys `POST /config` will write, besides the `hotkey.` +/// and `midi.` families. Every other field has its own route /// (destinations, auth, profiles) or is not user-settable at all, and the /// default is refusal. /// /// This list and `apply_field_str` must cover the same keys: a key allowed /// here that the applier does not know is silently dropped, which is how the -/// MIDI clear button spent 0.1.14 pretending to work. +/// MIDI clear button spent 0.1.14 pretending to work. A named list rather than +/// a `matches!` so a test can walk every key through save and load. +const SETTABLE_KEYS: &[&str] = &[ + "ingest_port", + "ingest_bind_all", + "ingest_key", + "web_port", + "web_bind_all", + "buffer_mb", + "buffer_path", + "overlays_dir", + "tracing_enabled", + "auto_arm_on_connect", + "auto_activate_when_ready", + "auto_arm_delay_ms", + "update_check_enabled", + "open_dashboard_on_launch", + "midi_device", + "twitch_client_id", +]; + +/// Whether `POST /config` may write key `k`. See `SETTABLE_KEYS`. fn is_settable_key(k: &str) -> bool { - matches!( - k, - "ingest_port" - | "ingest_bind_all" - | "ingest_key" - | "web_port" - | "web_bind_all" - | "buffer_mb" - | "buffer_path" - | "overlays_dir" - | "tracing_enabled" - | "auto_arm_on_connect" - | "auto_activate_when_ready" - | "auto_arm_delay_ms" - | "update_check_enabled" - | "open_dashboard_on_launch" - ) || k.starts_with("hotkey.") + SETTABLE_KEYS.contains(&k) + || k.starts_with("hotkey.") || k.starts_with("midi.") - || k == "midi_device" + || k.starts_with(crate::crash_protection::KEY_PREFIX) } async fn post_config( @@ -2267,15 +2861,6 @@ async fn post_config( apply_field_str(&mut new_settings, k, v); } } - if let Some(v) = form.get("discord_webhook_url") { - if !v.is_empty() { - apply_field_str(&mut new_settings, "discord_webhook_url", v); - } - // explicit clear: caller must POST `webhook_clear=1` - } - if form.get("webhook_clear").map(|s| s.as_str()) == Some("1") { - new_settings.discord_webhook_url.clear(); - } // Autostart lives in the registry, not in Settings, so it is applied // here rather than through `apply_field_str`. A failure is logged and @@ -2344,6 +2929,13 @@ async fn post_config( format!(r#"{{"ok":false,"error":"{}"}}"#, json_escape(&msg)), ); } + if let Some(msg) = binding_taken_by_integration(&form, &new_settings) { + return ( + "409 Conflict", + "application/json", + format!(r#"{{"ok":false,"error":"{}"}}"#, json_escape(&msg)), + ); + } let old = settings.borrow().clone(); let needs_restart = @@ -2364,9 +2956,6 @@ async fn post_config( ), ); } - // Mirror webhook into the controller so events fire correctly even - // before the next supervisor settings-change tick. - ctrl.update_webhook(new_settings.discord_webhook_url.clone()); // Same for the ingest key: mirror it inline so locking down the ingest port // takes effect this instant. Waiting for the supervisor tick would leave a // brief window where begin_publish still enforces the previous (usually @@ -2390,6 +2979,37 @@ async fn post_config( ) } +/// A delay hotkey or pad being set that an enabled integration starts from. +/// The delay action would take it over without a word, so it's refused the +/// way the Integrations tab refuses the reverse. +fn binding_taken_by_integration( + form: &std::collections::HashMap, + s: &Settings, +) -> Option { + let owner = |wanted: &str, is_pad: bool| { + 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), + ("midi.", s.midi.entries(), true), + ]; + for (prefix, entries, is_pad) in bindings { + for (action, bound) in entries { + if bound.is_empty() || !form.contains_key(&format!("{prefix}{action}")) { + continue; + } + if let Some(name) = owner(bound, is_pad) { + let what = if is_pad { "That pad" } else { bound }; + return Some(format!( + "{what} already starts the integration \"{name}\"; pick another, or change it there" + )); + } + } + } + None +} + /// Reset the persisted config to defaults. Two scopes: /// /// - `scope=settings`: app-level knobs (ports, buffer, webhook, @@ -2423,6 +3043,13 @@ async fn post_config_reset( next.destinations = prev.destinations; next.profiles = prev.profiles; next.configured = prev.configured; + // Integrations and their connections are the user's own work too. + next.integrations = prev.integrations; + 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", @@ -2440,7 +3067,6 @@ async fn post_config_reset( ), ); } - ctrl.update_webhook(next.discord_webhook_url.clone()); crate::trace::set_enabled(next.tracing_enabled); if scope == "all" { // Nuke the controller's live delay state too. Settings on @@ -2454,6 +3080,11 @@ async fn post_config_reset( // send below swaps in defaults). The seeded flag is back to false in // `next`, so the dashboard re-bakes the presets on its next load. wipe_studio_overlays(&settings.borrow().overlays_dir); + // A factory reset forgets the Twitch logins as well. + if let Some(integrations) = ctrl.integrations() { + integrations.twitch_logout(crate::integrations::twitch::Which::Main); + integrations.twitch_logout(crate::integrations::twitch::Which::Bot); + } } ctrl.log(format!("config reset (scope={})", scope)); reconcile_obs_vod_files(&next, ctrl); @@ -2754,16 +3385,31 @@ async fn post_midi_poll( settings: &Arc>, cfg_path: &Path, ) -> (&'static str, &'static str, String) { - if let Some((action, signature)) = ctrl.midi().take_captured() { + 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 @@ -2780,7 +3426,7 @@ pub(crate) fn persist_delay_state( let armed = ctrl.armed_delay_ms(); let target = ctrl.target_delay_ms(); // Track "last manually armed delay" in auto_arm_delay_ms so the - // System -> Behavior auto-arm picks up wherever the streamer last + // System -> General auto-arm picks up wherever the streamer last // explicitly armed. Only updates on non-zero arm so a Disarm // (arm_delay(0)) doesn't wipe the preference. let new_auto_arm = if armed > 0 { Some(armed) } else { None }; @@ -3127,6 +3773,21 @@ async fn post_destination_toggle( ); }; dest.enabled = enabled; + // Switching on a half-filled destination would stream nothing and, once + // on, fail every later save. Say what's missing instead. + if enabled { + let errors = ns.validate(); + if !errors.is_empty() { + return ( + "400 Bad Request", + "application/json", + format!( + r#"{{"ok":false,"error":"{}"}}"#, + json_escape(&errors.join("; ")) + ), + ); + } + } // `configured` is a first-run setup latch, not a live "has an active // destination" flag. Toggling your last destination off must not bounce @@ -3294,7 +3955,16 @@ async fn dock_layout_save( /// Live per-destination snapshot: id, name, status, bitrate, frames, etc. /// Joins settings (the user's configured list) with the controller's /// runtime stats (only present for destinations that were spawned). -fn destinations_json(ctrl: &Controller, settings: &Arc>) -> String { +/// +/// `include_secrets` is the caller's admin flag. A custom URL can carry the +/// stream key in its path (rtmp://host/app/SECRET), so only a full dashboard +/// session (whose edit form needs it) gets it raw; a dock-token caller gets it +/// blank plus `custom_egress_url_set`, the same rule `GET /config` applies. +fn destinations_json( + ctrl: &Controller, + settings: &Arc>, + include_secrets: bool, +) -> String { let s = settings.borrow(); let snap = ctrl.destination_snapshot(); let stats_for = |id: &str| { @@ -3316,17 +3986,19 @@ fn destinations_json(ctrl: &Controller, settings: &Arc>) // dashboard turns this into a green "Vertical" badge vs an amber // "waiting for Dual Format" hint. let v = dest_video(ctrl, d, &readouts); + let custom_url_shown = if include_secrets { + d.custom_egress_url.as_str() + } else { + "" + }; out.push_str(&format!( - r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"twitch_ingest":{ti},"youtube_ingest":{yi},"vod_audio":{va},"vod_audio_inject_eb":{vie},"stream_format":{sf},"audio_track":{at},"vertical_ready":{vr},"vertical_canvas_present":{vcp},"video_res":{vres},"video_codec":{vcod},"stream_key_set":{ks},"url_redacted":{ur},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{ct},"reconnects":{rc}}}"#, + r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"custom_egress_url_set":{cus},"twitch_ingest":{ti},"youtube_ingest":{yi},"vod_audio":{va},"vod_audio_inject_eb":{vie},"stream_format":{sf},"audio_track":{at},"vertical_ready":{vr},"vertical_canvas_present":{vcp},"video_res":{vres},"video_codec":{vcod},"stream_key_set":{ks},"url_redacted":{ur},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{ct},"reconnects":{rc}}}"#, id = json_escape_quoted(&d.id), n = json_escape_quoted(&d.name), en = d.enabled, p = json_escape_quoted(&d.platform), - // NOTE: returned raw, NOT redacted, because the dashboard's edit - // form needs it to populate the input field. Anyone reading this - // endpoint already has localhost access and can read the plaintext - // config file directly, so this doesn't expand the risk surface. - cu = json_escape_quoted(&d.custom_egress_url), + cu = json_escape_quoted(custom_url_shown), + cus = !d.custom_egress_url.is_empty(), ti = json_escape_quoted(&d.twitch_ingest), yi = json_escape_quoted(&d.youtube_ingest), va = d.vod_audio, @@ -3725,78 +4397,6 @@ fn serve_overlay_file( } } -// ---------------------------------------------------------------------- -// Discord webhook test ping -// ---------------------------------------------------------------------- - -async fn post_test_webhook(ctrl: &Arc) -> (&'static str, &'static str, String) { - // Verbose test path. The fire-and-forget `fire_webhook` is the wrong - // tool here because it (a) silently returns when the URL is empty, - // (b) silently returns when its 2-second throttle is active (a fresh - // destination-connect notification suppresses the user's test for 2s), - // and (c) drops any HTTP/TLS error from Discord on the floor. This - // path bypasses the throttle, validates the URL, and surfaces the - // real result so the user knows whether the webhook is actually - // reachable from this machine. - let url = ctrl.webhook_url_snapshot(); - if url.is_empty() { - return ( - "200 OK", - "application/json", - r#"{"ok":false,"error":"webhook URL is empty - set it in the System tab and save first"}"#.into(), - ); - } - let body = - r#"{"content":"🧪 **InstantClone**: Test message - webhook is wired up and working."}"#; - // Map ureq::Error (a fat enum that would trip clippy::result_large_err - // if propagated) down to just the status code on success or a short - // string on failure inside the worker thread. - let send = tokio::time::timeout( - std::time::Duration::from_secs(10), - tokio::task::spawn_blocking(move || -> Result { - crate::https::https_agent() - .post(&url) - .config() - .timeout_connect(Some(std::time::Duration::from_secs(5))) - .timeout_global(Some(std::time::Duration::from_secs(8))) - .build() - .header("Content-Type", "application/json") - .send(body) - .map(|r| r.status().as_u16()) - .map_err(|e| e.to_string()) - }), - ) - .await; - let (ok, msg) = match send { - Ok(Ok(Ok(status))) if (200..300).contains(&status) => { - (true, format!("Discord accepted (HTTP {})", status)) - } - Ok(Ok(Ok(status))) => ( - false, - format!( - "Discord rejected with HTTP {} - check the webhook URL", - status - ), - ), - Ok(Ok(Err(e))) => (false, format!("connection error: {}", e)), - Ok(Err(e)) => (false, format!("internal: {}", e)), - Err(_) => ( - false, - "timed out after 10 s waiting for Discord".to_string(), - ), - }; - // Match the dashboard contract: `ok=true` carries a friendly status - // in `message`, `ok=false` carries the same string in `error` so the - // existing toast handler ("Webhook test failed: ${r.error}") just - // works without a client-side change. - let json = if ok { - format!(r#"{{"ok":true,"message":{}}}"#, json_escape_quoted(&msg)) - } else { - format!(r#"{{"ok":false,"error":{}}}"#, json_escape_quoted(&msg)) - }; - ("200 OK", "application/json", json) -} - fn apply_field_str(s: &mut Settings, key: &str, value: &str) { // Wraps Settings::apply_field but is callable from outside the module. // Implementing here avoids exposing it on Settings. @@ -3824,7 +4424,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. @@ -3858,6 +4457,12 @@ fn apply_field_str(s: &mut Settings, key: &str, value: &str) { k if k.starts_with("midi.") => { s.midi.set(&k["midi.".len()..], value); } + // crash_protection.=. `set` clamps and drops anything + // malformed, same contract as the config loader. + k if k.starts_with(crate::crash_protection::KEY_PREFIX) => { + s.crash_protection + .set(&k[crate::crash_protection::KEY_PREFIX.len()..], value); + } // Empty means "every device", which is also what an unknown name // amounts to - the listener simply finds nothing to open and the // dashboard says so. @@ -3869,6 +4474,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(), _ => {} } } @@ -4048,9 +4654,16 @@ fn classify_access(method: &str, path: &str) -> Access { if path == "/login" { return Access::Public; } + // Web call integrations: outside apps (a Stream Deck, a script) can't log + // in. The secret token in the path is the credential, and all a caller + // can do is run the integrations that hold that exact token. + if path.starts_with("/hooks/") && (method == "GET" || method == "POST") { + return Access::Public; + } // Overlay DISPLAY only (browser sources can't log in); saving overlays is - // a POST to /overlays/ which stays Admin. - if path == "/overlay" || path.starts_with("/overlay/") { + // a POST to /overlays/ which stays Admin. GET only: nothing writes under + // /overlay, and a future route that does must not inherit this exemption. + if method == "GET" && (path == "/overlay" || path.starts_with("/overlay/")) { return Access::Public; } // OBS fetches the multitrack (Enhanced Broadcasting) config when it starts @@ -4076,6 +4689,12 @@ fn classify_access(method: &str, path: &str) -> Access { if method == "GET" && (path == "/overlay-state" || path == "/overlay-events") { return Access::Public; } + // The alerts browser source, for the same reason, and safe for the same + // reason: its feed is only the cards integrations chose to put on + // stream. Nothing there identifies the streamer or controls anything. + if method == "GET" && (path == "/alerts" || path == "/alerts-events") { + return Access::Public; + } match (method, path) { ("GET", "/state") | ("GET", "/events") @@ -4101,7 +4720,9 @@ fn classify_access(method: &str, path: &str) -> Access { | ("POST", "/delay") | ("POST", "/go-live") | ("POST", "/cut-after") - | ("POST", "/cut-after/cancel") => Access::Control, + | ("POST", "/cut-after/cancel") + // Ending a crash-protection hold is operational, like a cut. + | ("POST", "/crash-protection/end") => Access::Control, ("POST", p) if p.starts_with("/docks/") => Access::Control, // GET a saved dock layout (the dock loads its own persisted layout). ("GET", p) if p.starts_with("/docks/") => Access::Control, @@ -4176,6 +4797,69 @@ fn wants_html(head: &str) -> bool { /// IPv4/IPv6 loopback address. Used to keep first-time password bootstrap on /// the local machine. An unparseable value is treated as non-loopback so the /// restriction fails closed. +/// GET routes another origin may read: the public ones (overlays, alerts) +/// and the delay status widgets poll. None of them carries a secret. +fn readable_cross_origin(path: &str) -> bool { + classify_access("GET", path) == Access::Public || path == "/state" +} + +/// True when a `Host` header names an IP address or localhost rather than a +/// domain. An empty Host (HTTP/1.0 tools) is a local tool, not a browser. +fn host_is_address(host: &str) -> bool { + let name = match host.strip_prefix('[') { + // [::1]:7799 + Some(v6) => v6.split(']').next().unwrap_or(""), + None => host.rsplit_once(':').map_or(host, |(name, _)| name), + }; + let name = name.to_ascii_lowercase(); + host.is_empty() + || name == "localhost" + || name.ends_with(".localhost") + || 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 && from_this_pc(peer_ip, head)) +} + +/// Whether a request comes from this PC itself: a loopback connection, +/// addressed to a loopback name, not relayed. A port forwarder on this PC +/// (netsh portproxy, ssh -R) connects from loopback too, but keeps the +/// address the remote side dialled in Host. +fn from_this_pc(peer_ip: &str, head: &str) -> bool { + let host = parse_origin_host(head).1; + let name = match host.strip_prefix('[') { + Some(v6) => v6.split(']').next().unwrap_or("").to_string(), + None => host + .rsplit_once(':') + .map_or(host.as_str(), |(n, _)| n) + .to_ascii_lowercase(), + }; + let loopback_name = name == "localhost" + || name + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + is_loopback(peer_ip) && loopback_name && !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()) @@ -4270,6 +4954,12 @@ static DOCK_JS_GZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/dock.js.gz" /// Main dashboard / first-run wizard. Source lives in `web/index.html`; /// build-time minified + gzipped (see `build.rs`). static INDEX_HTML_GZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/index.html.gz")); +/// The alerts browser source (`/alerts`); see `integrations::alerts`. +static ALERTS_HTML: &str = include_str!("../web/alerts.html"); + +/// The dashboard's Integrations tab; source in `web/integrations.js`, +/// build-time gzipped like the dock script. +static INTEGRATIONS_JS_GZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/integrations.js.gz")); /// Overlay Studio author-time runtime + baker. Loaded only by the /// dashboard (never by a live overlay in OBS). Source lives in @@ -4844,6 +5534,13 @@ mod tests { // inheriting the read exemption. assert_eq!(classify_access("POST", "/overlay-state"), Access::Admin); assert_eq!(classify_access("POST", "/overlay-events"), Access::Admin); + assert_eq!(classify_access("GET", "/alerts-events"), Access::Public); + assert_eq!(classify_access("POST", "/alerts-events"), Access::Admin); + // Putting a test card up is a dashboard action. + assert_eq!( + classify_access("POST", "/integrations/alerts/test"), + Access::Admin + ); // The dashboard's own feed stays behind a login. If this ever // relaxes to Public, the overlay split above has been undone and @@ -4860,6 +5557,25 @@ mod tests { /// time a user set a dashboard password. #[test] fn neither_overlay_renderer_asks_for_the_dashboard_feed() { + // Matching one exact spelling (`fetch('/state')`) let every other + // spelling through: double quotes, a template literal, a query string, + // an origin prefix. So the check is "a URL string that ends in this + // path", whatever surrounds it. Prose in comments ("not /events and + // /state.") is not a URL string and must not count. + for (js, want) in [ + (r#"fetch("/state")"#, 1), + ("new EventSource(`/events`)", 1), + ("fetch('/state?t=1')", 1), + ("fetch(location.origin + '/events#x')", 1), + ("// read /overlay-events, not /events and /state.", 0), + ] { + assert_eq!( + feed_references(js, "/events") + feed_references(js, "/state"), + want, + "{js}" + ); + } + let saved = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/web/overlay-runtime.js" @@ -4870,16 +5586,32 @@ mod tests { ("the built-in overlay", builtin.as_str()), ] { assert!( - js.contains("/overlay-events") && js.contains("/overlay-state"), + feed_references(js, "/overlay-events") > 0 + && feed_references(js, "/overlay-state") > 0, "{what} must read the overlay feed" ); - assert!( - !js.contains("EventSource('/events')") && !js.contains("fetch('/state')"), + assert_eq!( + feed_references(js, "/events") + feed_references(js, "/state"), + 0, "{what} must not read the dashboard feed - it needs a session" ); } } + /// How many times `js` uses `path` as a URL: the path followed by a + /// string's closing quote, a query or a fragment. `/overlay-state` does not + /// contain `/state`, so the two feeds never match each other. + fn feed_references(js: &str, path: &str) -> usize { + js.match_indices(path) + .filter(|(at, _)| { + matches!( + js[at + path.len()..].chars().next(), + Some('\'' | '"' | '`' | '?' | '#') + ) + }) + .count() + } + /// The dashboard's copy of the offline guard. The hotkey and MIDI paths /// refuse to build a delay with nothing publishing; `/arm` and `/delay` /// have to refuse it too, or the browser is a way around the rule. @@ -5015,61 +5747,339 @@ mod tests { ); } - #[test] - fn apply_field_str_persists_ingest_key() { - // Regression: ingest_key is in post_config's form whitelist, so - // apply_field_str must have a matching arm or the "Save key" button - // silently no-ops and the ingest is never actually locked. - let mut s = crate::config::Settings::defaults(); - assert!(s.ingest_key.is_empty()); - apply_field_str(&mut s, "ingest_key", "hunter2ingest"); - assert_eq!(s.ingest_key, "hunter2ingest"); - apply_field_str(&mut s, "ingest_key", ""); - assert!(s.ingest_key.is_empty()); + /// One settable key in the save round trip below: what the form posts to + /// set it, what the field then reads as, what the form posts to put it + /// back, and how to read the field. + struct SettableRow { + key: &'static str, + set: &'static str, + reads_as: &'static str, + reset: &'static str, + read: fn(&Settings) -> String, } - #[test] - fn apply_field_str_persists_tracing_enabled_value() { - // The dispatch in post_config gates which keys reach this - // function. The test below asserts the gate includes our - // key by simulating the form-loop assignment. - let mut s = crate::config::Settings::defaults(); - // beta.6 default is `false`. - assert!(!s.tracing_enabled); - apply_field_str(&mut s, "tracing_enabled", "true"); - assert!(s.tracing_enabled); - apply_field_str(&mut s, "tracing_enabled", "false"); - assert!(!s.tracing_enabled); + /// A form value, percent-encoded so `parse_form` hands the route exactly + /// this string (a raw `+` in a hotkey would otherwise arrive as a space). + fn form_encode(value: &str) -> String { + value + .bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() } - // ── Behavior toggles: dispatch path tests ───────────────────── - // - // Same regression class as the tracing_enabled test above. The - // post_config form-key whitelist plus apply_field_str's match arm - // both have to know each new behaviour key, or settings silently - // round-trip to the default. These tests pin both sides for the - // v0.1.4 auto-arm fields. + /// Every key `POST /config` accepts, driven through the real handler, the + /// config file, and `Settings::load`, then set back the same way. + /// + /// This is the regression class that kept recurring: a key missing from + /// the whitelist, from `apply_field_str`, or from save/load is dropped + /// without an error, and the dashboard shows "Saved" for a setting that + /// reverts on the next launch (tracing_enabled, ingest_key and the MIDI + /// clear button all shipped like that). Calling `apply_field_str` alone + /// skipped the whitelist, so removing a key from it still passed. + #[tokio::test] + async fn every_settable_key_survives_post_config_save_and_load() { + let rows: &[SettableRow] = &[ + SettableRow { + key: "ingest_port", + set: "19350", + reads_as: "19350", + reset: "1935", + read: |s| s.ingest_port.to_string(), + }, + SettableRow { + key: "ingest_bind_all", + set: "on", + reads_as: "true", + reset: "false", + read: |s| s.ingest_bind_all.to_string(), + }, + SettableRow { + key: "ingest_key", + set: "loop_key-1", + reads_as: "loop_key-1", + reset: "", + read: |s| s.ingest_key.clone(), + }, + SettableRow { + key: "web_port", + set: "17799", + reads_as: "17799", + reset: "7799", + read: |s| s.web_port.to_string(), + }, + SettableRow { + key: "web_bind_all", + set: "1", + reads_as: "true", + reset: "", + read: |s| s.web_bind_all.to_string(), + }, + SettableRow { + key: "buffer_mb", + set: "640", + reads_as: "640", + reset: "500", + read: |s| s.buffer_mb.to_string(), + }, + SettableRow { + key: "buffer_path", + set: "loop-ring.buf", + reads_as: "loop-ring.buf", + reset: "./instantclone.buf", + read: |s| s.buffer_path.display().to_string(), + }, + SettableRow { + key: "overlays_dir", + set: "loop-overlays", + reads_as: "loop-overlays", + reset: "./overlays", + read: |s| s.overlays_dir.display().to_string(), + }, + SettableRow { + key: "tracing_enabled", + set: "true", + reads_as: "true", + reset: "false", + read: |s| s.tracing_enabled.to_string(), + }, + // A checkbox posts "on" when ticked; it must read as true. + SettableRow { + key: "auto_arm_on_connect", + set: "on", + reads_as: "true", + reset: "off", + read: |s| s.auto_arm_on_connect.to_string(), + }, + SettableRow { + key: "auto_activate_when_ready", + set: "true", + reads_as: "true", + reset: "0", + read: |s| s.auto_activate_when_ready.to_string(), + }, + SettableRow { + key: "auto_arm_delay_ms", + set: "30000", + reads_as: "30000", + reset: "15000", + read: |s| s.auto_arm_delay_ms.to_string(), + }, + SettableRow { + key: "update_check_enabled", + set: "false", + reads_as: "false", + reset: "true", + read: |s| s.update_check_enabled.to_string(), + }, + SettableRow { + key: "open_dashboard_on_launch", + set: "off", + reads_as: "false", + reset: "on", + read: |s| s.open_dashboard_on_launch.to_string(), + }, + SettableRow { + key: "midi_device", + set: "LoopDevice", + reads_as: "LoopDevice", + 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 + // settable key cannot skip this test and a dropped one cannot hide. + for key in SETTABLE_KEYS { + assert!( + rows.iter().any(|r| r.key == *key), + "{key} is settable but has no row here - add one" + ); + } + for row in rows { + assert!(is_settable_key(row.key), "{} is not settable", row.key); + } + + let defaults = Settings::defaults(); + for row in rows { + // Precondition: the new value differs from the default, or the + // round trip would pass on a key that never landed. + assert_ne!( + (row.read)(&defaults), + row.reads_as, + "{}: pick a non-default", + row.key + ); + } + + // Every binding, too: `hotkey.` and `midi.`. + let letters = ["A", "B", "C", "D", "E"]; + let bindings: Vec<(String, String, String)> = config::ACTIONS + .iter() + .zip(letters) + .enumerate() + .flat_map(|(i, (action, letter))| { + [ + ( + action.to_string(), + format!("hotkey.{action}"), + format!("Ctrl+Alt+{letter}"), + ), + ( + action.to_string(), + format!("midi.{action}"), + format!("note:1:{}", 36 + i), + ), + ] + }) + .collect(); + + let live = Live::new(Settings::defaults()); + let post = |pairs: Vec<(String, String)>| { + pairs + .iter() + .map(|(k, v)| format!("{k}={}", form_encode(v))) + .collect::>() + .join("&") + }; + + let set_body = post( + rows.iter() + .map(|r| (r.key.to_string(), r.set.to_string())) + .chain(bindings.iter().map(|(_, k, v)| (k.clone(), v.clone()))) + .collect(), + ); + let (status, _, body) = + post_config(&set_body, &live.ctrl, &live.settings, &live.cfg_path).await; + assert_eq!(status, "200 OK", "{body}"); + let loaded = Settings::load(&live.cfg_path).expect("config was written"); + for row in rows { + assert_eq!((row.read)(&loaded), row.reads_as, "{} after load", row.key); + assert_eq!( + (row.read)(&live.settings.borrow()), + row.reads_as, + "{} live", + row.key + ); + } + for (action, key, value) in &bindings { + let bound = if key.starts_with("hotkey.") { + loaded.hotkeys.entries() + } else { + loaded.midi.entries() + }; + let got = bound + .iter() + .find(|(a, _)| *a == action.as_str()) + .map(|(_, v)| *v); + assert_eq!(got, Some(value.as_str()), "{key} after load"); + } + + let reset_body = post( + rows.iter() + .map(|r| (r.key.to_string(), r.reset.to_string())) + .chain(bindings.iter().map(|(_, k, _)| (k.clone(), String::new()))) + .collect(), + ); + let (status, _, body) = + post_config(&reset_body, &live.ctrl, &live.settings, &live.cfg_path).await; + assert_eq!(status, "200 OK", "{body}"); + let loaded = Settings::load(&live.cfg_path).expect("config was written"); + for row in rows { + assert_eq!( + (row.read)(&loaded), + (row.read)(&defaults), + "{} back to default", + row.key + ); + } + assert!(loaded.hotkeys.entries().iter().all(|(_, c)| c.is_empty())); + assert!(loaded.midi.entries().iter().all(|(_, m)| m.is_empty())); + } #[test] - fn apply_field_str_persists_auto_arm_on_connect() { - let mut s = crate::config::Settings::defaults(); - assert!(!s.auto_arm_on_connect); - apply_field_str(&mut s, "auto_arm_on_connect", "true"); - assert!(s.auto_arm_on_connect); - apply_field_str(&mut s, "auto_arm_on_connect", "false"); - assert!(!s.auto_arm_on_connect); - apply_field_str(&mut s, "auto_arm_on_connect", "on"); - assert!(s.auto_arm_on_connect, "checkbox 'on' must read as truthy"); + fn crash_protection_preview_applies_unsaved_edits() { + let saved = crate::crash_protection::CrashProtection::default(); + let landscape = crash_protection_preview("", &saved); + let arcade = crash_protection_preview("crash_protection.theme=arcade", &saved); + let vertical = crash_protection_preview("orientation=vertical", &saved); + // PNG: width and height sit big-endian at bytes 16 and 20. + let size = |png: &[u8]| { + let read = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap()); + (read(16), read(20)) + }; + assert_eq!(&landscape[1..4], b"PNG"); + assert_ne!(landscape, arcade, "query edits reach the render"); + assert_eq!(size(&vertical), (270, 480)); + let thumb = crash_protection_preview("size=thumb", &saved); + assert_eq!(size(&thumb), (192, 108)); + let strip = crash_protection_preview("frames=24", &saved); + assert_eq!(size(&strip), (480, 270 * 24), "24 frames stacked"); + let capped = crash_protection_preview("frames=500&size=thumb", &saved); + assert_eq!(size(&capped), (192, 108 * 24)); + for preview_only in ["orientation", "size", "phase", "frames"] { + assert!( + !is_settable_key(preview_only), + "preview-only params never persist" + ); + } + } + + #[test] + fn a_delay_hotkey_cannot_take_an_integrations_key() { + use crate::integrations::model::Trigger; + let mut s = Settings::defaults(); + let mut clip = crate::integrations::presets::build("clip_button", "c".into(), "").unwrap(); + clip.enabled = true; + 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"); + apply_field_str(&mut s, "hotkey.cut", "ctrl+alt+k"); + let refused = binding_taken_by_integration(&form, &s).unwrap(); + 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(), + 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"); + assert!(binding_taken_by_integration(&form, &s).is_some()); } #[test] - fn apply_field_str_persists_auto_activate_when_ready() { + fn apply_field_str_persists_crash_protection_fields() { let mut s = crate::config::Settings::defaults(); - assert!(!s.auto_activate_when_ready); - apply_field_str(&mut s, "auto_activate_when_ready", "true"); - assert!(s.auto_activate_when_ready); - apply_field_str(&mut s, "auto_activate_when_ready", "off"); - assert!(!s.auto_activate_when_ready); + assert!(is_settable_key("crash_protection.enabled")); + apply_field_str(&mut s, "crash_protection.enabled", "on"); + apply_field_str(&mut s, "crash_protection.theme", "arcade"); + apply_field_str(&mut s, "crash_protection.hold_secs", "45"); + assert!(s.crash_protection.enabled); + assert_eq!( + s.crash_protection.theme, + crate::crash_protection::SlateTheme::Arcade + ); + assert_eq!(s.crash_protection.hold_secs, 45); } #[test] @@ -5127,6 +6137,74 @@ mod tests { assert!(replace_auth_field(body, "x").is_none()); } + #[test] + fn offer_only_h264_narrows_the_codecs_obs_offers() { + // OBS 32 serialises the request with nlohmann (compact, keys sorted). + let body = r#"{"client":{"name":"obs-studio","supported_codecs":["av1","h265","h264"],"version":"32.2.2"},"preferences":{"canvases":[{"width":1080}]}}"#; + assert_eq!( + offer_only_h264(body).unwrap(), + r#"{"client":{"name":"obs-studio","supported_codecs":["h264"],"version":"32.2.2"},"preferences":{"canvases":[{"width":1080}]}}"# + ); + let spaced = r#"{"client": {"supported_codecs" : [ "h265", "h264" ] }}"#; + assert_eq!( + offer_only_h264(spaced).unwrap(), + r#"{"client": {"supported_codecs" : ["h264"] }}"# + ); + } + + #[test] + fn offer_only_h264_leaves_requests_it_cannot_narrow() { + // No list, no H.264 in it, or a list that isn't an array: Twitch + // must still get a request, so the caller keeps the original. + assert!(offer_only_h264(r#"{"client":{"name":"obs-studio"}}"#).is_none()); + assert!(offer_only_h264(r#"{"client":{"supported_codecs":["h265"]}}"#).is_none()); + assert!(offer_only_h264(r#"{"supported_codecs":"h264","x":["h264"]}"#).is_none()); + } + + #[test] + fn only_a_horizontal_destination_besides_twitch_narrows_the_codecs() { + let dest = |platform: &str, enabled: bool, format: &str| config::Destination { + id: platform.into(), + name: platform.into(), + enabled, + platform: platform.into(), + stream_key: "k".into(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: format.into(), + audio_track: "auto".into(), + }; + let twitch = dest("twitch", true, "horizontal"); + let test_sink = dest("sink", true, "horizontal"); + let kick = dest("kick", true, "horizontal"); + let kick_off = dest("kick", false, "horizontal"); + let tiktok = dest("restream", true, "vertical"); + let second_twitch = config::Destination { + stream_key: "other-account".into(), + ..twitch.clone() + }; + assert!(!feeds_main_track_beyond_twitch( + &[twitch.clone(), test_sink], + "k" + )); + assert!(!feeds_main_track_beyond_twitch( + &[twitch.clone(), kick_off], + "k" + )); + assert!(!feeds_main_track_beyond_twitch( + &[twitch.clone(), tiktok], + "k" + )); + assert!(feeds_main_track_beyond_twitch( + &[twitch.clone(), second_twitch], + "k" + )); + assert!(feeds_main_track_beyond_twitch(&[twitch, kick], "k")); + } + #[test] fn rewrite_url_templates_replaces_every_endpoint() { // Twitch returns multiple `ingest_endpoints` for regional @@ -5155,6 +6233,22 @@ mod tests { let rewritten = rewrite_url_templates(response, "rtmp://new"); assert!(rewritten.contains(r#""description":"see url_template""#)); assert!(rewritten.contains(r#""rtmp://new""#)); + + // The sharper decoy: a VALUE that is exactly "url_template", quotes + // and all. Only a key is followed by a colon; the matcher used to take + // the next field's value for this one's and drop the key in between. + let response = r#"{"note":"url_template","keep":"rtmp://decoy","ingest_endpoints":[{"url_template":"rtmp://old"}]}"#; + let rewritten = rewrite_url_templates(response, "rtmp://new"); + assert!( + rewritten.contains(r#""note":"url_template","keep":"rtmp://decoy""#), + "{rewritten}" + ); + assert_eq!(rewritten.matches("rtmp://new").count(), 1, "{rewritten}"); + assert!(!rewritten.contains("rtmp://old"), "{rewritten}"); + + // A non-string value is not a URL to replace either. + let response = r#"{"url_template":null,"keep":"rtmp://decoy"}"#; + assert_eq!(rewrite_url_templates(response, "rtmp://new"), response); } #[test] @@ -5354,13 +6448,6 @@ mod tests { assert_eq!(len, Some(42)); } - #[test] - fn parse_request_head_treats_missing_content_length_as_zero() { - let head = "GET / HTTP/1.1\r\nHost: x\r\n"; - let (_, _, len) = parse_request_head(head); - assert_eq!(len, Some(0)); - } - #[test] fn parse_request_head_is_case_insensitive_on_header_name() { let head = "POST /x HTTP/1.1\r\ncontent-length: 7\r\n"; @@ -5380,6 +6467,46 @@ 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.web_bind_all = false; + let forwarded = "POST /integrations/save HTTP/1.1\r\nHost: 192.168.1.20:8080\r\n"; + assert!( + !may_set_up_local_steps(&s, "127.0.0.1", forwarded), + "a port forwarder" + ); + let own = + "POST /integrations/save HTTP/1.1\r\nHost: 127.0.0.1:7799\r\nVia: 1.1 instantclone\r\n"; + assert!( + !may_set_up_local_steps(&s, "127.0.0.1", own), + "a web request step" + ); + let localhost = "POST /integrations/save HTTP/1.1\r\nHost: localhost:7799\r\n"; + assert!(may_set_up_local_steps(&s, "127.0.0.1", localhost)); + 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. @@ -5448,6 +6575,145 @@ mod tests { } } + /// The vertical track takes the streamer's own encoder only when every + /// enabled vertical destination plays its codec; one that doesn't (or an + /// unknown encoder) means H.264 for the whole vertical track. + #[test] + fn the_vertical_track_uses_the_streamers_codec_only_where_it_plays() { + let hevc = crate::local_eb_config::TrackEncoder { + encoder_type: "h265_texture_amf".into(), + settings_json: "{}".into(), + }; + let own = Some(&hevc); + let to = |dests: &[crate::config::Destination]| own_encoder_for_vertical(own, dests); + assert!( + to(&[vertical_dest("youtube", true)]).is_some(), + "YouTube plays HEVC" + ); + assert!(to(&[vertical_dest("youtube", true), vertical_dest("kick", true)]).is_none()); + assert!( + to(&[vertical_dest("youtube", true), vertical_dest("kick", false)]).is_some(), + "a disabled destination doesn't hold the codec back" + ); + assert!( + to(&[vertical_dest("custom", true)]).is_none(), + "unknown server" + ); + let h264 = crate::local_eb_config::TrackEncoder { + encoder_type: "h264_texture_amf".into(), + settings_json: "{}".into(), + }; + let kick_only = [vertical_dest("kick", true)]; + assert!( + own_encoder_for_vertical(Some(&h264), &kick_only).is_some(), + "the streamer's own H.264 plays everywhere" + ); + assert!(own_encoder_for_vertical(None, &kick_only).is_none()); + } + + fn vertical_dest(platform: &str, enabled: bool) -> crate::config::Destination { + crate::config::Destination { + platform: platform.into(), + stream_format: "vertical".into(), + enabled, + ..twitch_dest(true, "key") + } + } + + /// OBS's Additional canvas is encoded only while an enabled destination + /// streams vertical; otherwise it is a whole encode nobody receives. + #[test] + fn the_additional_canvas_is_encoded_only_for_a_vertical_destination() { + use crate::local_eb_config::ObsCanvas; + let at = |width, height| ObsCanvas { + width, + height, + fps_num: 60, + fps_den: 1, + }; + let offered = [at(2560, 1440), at(1080, 1920)]; + let horizontal_only = [twitch_dest(true, "key")]; + assert_eq!(canvases_to_encode(&offered, &horizontal_only).len(), 1); + assert_eq!( + canvases_to_encode(&offered, &[]).len(), + 1, + "no destinations" + ); + let off = [vertical_dest("youtube", false)]; + assert_eq!(canvases_to_encode(&offered, &off).len(), 1, "disabled"); + let twitch_set_vertical = [crate::config::Destination { + stream_format: "vertical".into(), + ..twitch_dest(true, "key") + }]; + assert_eq!( + canvases_to_encode(&offered, &twitch_set_vertical).len(), + 1, + "Twitch never takes the vertical feed from here" + ); + let on = [twitch_dest(true, "key"), vertical_dest("kick", true)]; + assert_eq!(canvases_to_encode(&offered, &on), offered.to_vec()); + assert!(canvases_to_encode(&[], &on).is_empty()); + } + + /// End to end past OBS's files: the streamer's own HEVC settings, and a + /// vertical destination. YouTube plays HEVC, so the vertical track is + /// the streamer's encoder at the main track's quality per pixel; Kick + /// doesn't, so it is InstantClone's H.264 at that same bitrate. The main + /// track is the streamer's untouched either way. + #[test] + fn a_vertical_destination_gets_the_streamers_codec_only_if_it_plays_it() { + use crate::local_eb_config::{build, parse_canvases, StreamerSettings, TrackEncoder}; + let own_settings = r#"{"bitrate":6000,"rate_control":"CBR","preset":"quality"}"#; + let streamer = StreamerSettings { + encoder: Some(TrackEncoder { + encoder_type: "h265_texture_amf".into(), + settings_json: own_settings.into(), + }), + bitrate_kbps: 6000, + rescale: None, + }; + let request = r#"{"preferences":{"canvases":[ + {"width":2560,"height":1440,"framerate":{"numerator":60,"denominator":1}}, + {"width":1080,"height":1920,"framerate":{"numerator":60,"denominator":1}}]}}"#; + let choice = EncoderChoice { + family: "amd", + id: "h264_texture_amf", + reason: String::new(), + }; + let config_for = |vertical: &str| { + let dests = [vertical_dest(vertical, true)]; + let canvases = canvases_to_encode(&parse_canvases(request), &dests); + let own = streamer.encoder.as_ref(); + let vertical_own = own_encoder_for_vertical(own, &dests); + let (config, _) = build(&canvases, 10_000, Some(&streamer), 1935, |kbps| { + picked_encoder(vertical_own, &choice, kbps) + }) + .expect("canvases were sent"); + assert!(crate::config::is_valid_json(&config), "{config}"); + config + }; + + let youtube = config_for("youtube"); + assert!(youtube.contains(r#""type":"h265_texture_amf","width":2560"#)); + assert!( + youtube.contains(own_settings), + "main track untouched: {youtube}" + ); + assert!( + youtube.contains(r#""type":"h265_texture_amf","width":1080"#), + "{youtube}" + ); + assert!(youtube.contains(r#"{"bitrate":3375,"rate_control":"CBR","preset":"quality"}"#)); + + let kick = config_for("kick"); + assert!(kick.contains(own_settings), "main track untouched: {kick}"); + assert!( + kick.contains(r#""type":"h264_texture_amf","width":1080"#), + "{kick}" + ); + assert!(kick.contains(r#""bitrate":3375"#), "{kick}"); + } + fn settings_with_dests(dests: Vec) -> Settings { let mut s = Settings::defaults(); s.destinations = dests; @@ -5473,22 +6739,48 @@ mod tests { ]))); } - #[test] - fn configured_latch_only_rises() { - // Mirror the handler's latch step - `if has_streamable_dest { =true }` - // starting from an already-configured install whose last destination - // is now disabled. The flag must stay true (no wizard reopen); only a - // full reset clears it. - let s = settings_with_dests(vec![twitch_dest(false, "livekey123")]); + /// The latch through the real settings handler: a completed setup whose + /// only destination is now disabled saves an unrelated setting, and must + /// stay configured (no wizard reopen); a fresh install saving a usable + /// destination through the wizard fields must become configured. + /// + /// `post_destination_toggle` and `post_destination_delete` carry the same + /// latch but are not driven here: both call `reconcile_obs_vod_files`, + /// which rewrites the developer's real OBS `user.ini` (it has no test + /// seam), and a test must never flip someone's VOD-track flag. + #[tokio::test] + async fn configured_latch_only_rises() { + let mut s = settings_with_dests(vec![twitch_dest(false, "livekey123")]); + s.configured = true; assert!(!has_streamable_dest(&s), "precondition: not streamable"); - let mut configured = true; // setup completed earlier - if has_streamable_dest(&s) { - configured = true; - } + let live = Live::new(s); + let (status, _, body) = post_config( + "auto_arm_delay_ms=20000", + &live.ctrl, + &live.settings, + &live.cfg_path, + ) + .await; + assert_eq!(status, "200 OK", "{body}"); assert!( - configured, + live.settings.borrow().configured, "a completed setup must never re-open the wizard" ); + assert!(Settings::load(&live.cfg_path).unwrap().configured); + + let fresh = Live::new(Settings::defaults()); + let (status, _, body) = post_config( + "platform=twitch&stream_key=livekey123", + &fresh.ctrl, + &fresh.settings, + &fresh.cfg_path, + ) + .await; + assert_eq!(status, "200 OK", "{body}"); + assert!( + fresh.settings.borrow().configured, + "a usable destination raises it" + ); } // ── Overlay Studio CRUD ────────────────────────────────────── @@ -5525,8 +6817,68 @@ 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}")); + // This route is public (OBS fetches it with no session), so the + // ingest key it would otherwise protect must never ride along. + assert!( + !cfg.contains("abc123def"), + "public config leaked the ingest 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] @@ -5695,4 +7047,1124 @@ mod tests { Some("spaced".to_string()) ); } + + // ── Live HTTP surface ──────────────────────────────────────────── + // + // These drive the real `serve` (head parse, CSRF guard, auth gate, + // router) over a loopback socket: the bugs they pin live in the glue + // between those pieces, which no helper-level test reaches. + + /// Removes a test's temp directory when dropped. Kept as the last field of + /// `Live` so it drops after the controller, whose ring file lives inside + /// and cannot be deleted on Windows while it is open. + struct TempDir(PathBuf); + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Everything `serve` and the handlers need, confined to a private temp + /// directory. Holds a settings receiver because `watch::Sender::send` is a + /// silent no-op without one: a handler would "save" nothing and every + /// assertion about the live settings would pass without testing anything. + struct Live { + ctrl: Arc, + settings: Arc>, + _rx: watch::Receiver, + cfg_path: PathBuf, + auth: Arc, + _dir: TempDir, + } + + impl Live { + fn new(mut s: Settings) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("ic-live-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + // Nothing a request does may land outside the temp directory. + s.overlays_dir = dir.join("overlays"); + s.buffer_path = dir.join("ring.buf"); + let ring = crate::buffer::DiskRing::create(&dir.join("ctrl.buf"), 4 * 1024 * 1024) + .expect("ring create"); + let (tx, rx) = watch::channel(s); + Live { + ctrl: Arc::new(Controller::new(Arc::new(ring), 0)), + settings: Arc::new(tx), + _rx: rx, + cfg_path: dir.join("instantclone.cfg"), + auth: Arc::new(crate::auth::AuthState::new()), + _dir: TempDir(dir), + } + } + + async fn send(&self, raw: &[u8]) -> Response { + self.send_from("127.0.0.1", raw).await + } + + /// One request through the real `serve`, as if from `peer_ip`. The + /// write half is closed after the request, so a body shorter than its + /// Content-Length reaches EOF instead of waiting out the body deadline. + async fn send_from(&self, peer_ip: &str, raw: &[u8]) -> Response { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let mut client = TcpStream::connect(addr).await.expect("connect"); + let (sock, _) = listener.accept().await.expect("accept"); + let server = tokio::spawn(serve( + sock, + self.ctrl.clone(), + self.settings.clone(), + self.cfg_path.clone(), + Arc::new(SysStat::new()), + self.auth.clone(), + peer_ip.to_string(), + )); + client.write_all(raw).await.expect("write request"); + client.shutdown().await.expect("half-close"); + let mut out = Vec::new(); + // Windows can end a loopback connection with a reset instead of + // a close once the whole response is out (seen under load). A + // client reading by Content-Length never notices, and `parse` + // below still fails on a response the reset cut short. + match client.read_to_end(&mut out).await { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::ConnectionReset && !out.is_empty() => {} + Err(e) => { + let got = String::from_utf8_lossy(&out); + panic!( + "read response failed after {} bytes: {e:?}\n{got}", + out.len() + ); + } + } + server.await.expect("serve task").expect("serve"); + Response::parse(&out) + } + } + + struct Response { + status: u16, + headers: Vec<(String, String)>, + body: String, + } + + impl Response { + /// Parse, asserting on the way that the bytes are a well-formed + /// HTTP/1.1 response: CRLF line ends, no folded header lines, a status + /// code, and a Content-Length that matches the body. Every live test + /// gets this check for free. + fn parse(raw: &[u8]) -> Self { + let text = std::str::from_utf8(raw).expect("response is UTF-8"); + let (head, body) = text + .split_once("\r\n\r\n") + .unwrap_or_else(|| panic!("no CRLF blank line ends the head: {text:?}")); + assert!( + !head.replace("\r\n", "").contains(['\r', '\n']), + "bare CR or LF in the head: {head:?}" + ); + let mut lines = head.split("\r\n"); + let status_line = lines.next().unwrap_or_default(); + let status = status_line + .strip_prefix("HTTP/1.1 ") + .and_then(|s| s.get(..3)) + .and_then(|code| code.parse().ok()) + .unwrap_or_else(|| panic!("bad status line: {status_line:?}")); + let mut headers = Vec::new(); + for line in lines { + assert!( + !line.starts_with([' ', '\t']), + "folded (obsolete) header line: {line:?}" + ); + let (name, value) = line + .split_once(':') + .unwrap_or_else(|| panic!("header without a colon: {line:?}")); + headers.push((name.to_ascii_lowercase(), value.trim().to_string())); + } + let response = Response { + status, + headers, + body: body.to_string(), + }; + let length: usize = response + .header("content-length") + .and_then(|v| v.parse().ok()) + .expect("a numeric Content-Length"); + assert_eq!( + length, + response.body.len(), + "Content-Length must match the body" + ); + response + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.as_str()) + } + + /// The `ic_session` token a Set-Cookie on this response hands out. + fn session_cookie(&self) -> String { + self.header("set-cookie") + .and_then(|c| c.split(';').next()) + .and_then(|c| c.strip_prefix("ic_session=")) + .expect("a session cookie") + .to_string() + } + } + + /// A request with a correct Content-Length. `headers` is zero or more + /// complete `Name: value\r\n` lines. + fn request(method: &str, path: &str, headers: &str, body: &[u8]) -> Vec { + let mut raw = format!( + "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{headers}Content-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + raw.extend_from_slice(body); + raw + } + + /// A request whose body length cannot be read gets a 400 that is itself a + /// valid HTTP response. It used to be built from a multi-line raw string: + /// bare LF line ends and an indented `Content-Length:` line, which reads as + /// an obsolete folded header, so a client got garbage instead of the error. + #[tokio::test] + async fn a_malformed_content_length_gets_a_well_formed_400() { + let live = Live::new(Settings::defaults()); + let r = live + .send(b"POST /arm HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: abc\r\n\r\n") + .await; + assert_eq!(r.status, 400); + assert!(r.body.contains("malformed Content-Length"), "{}", r.body); + } + + /// A publisher connected and a 5 s delay armed: the state a bad request + /// must leave alone. + fn armed_live() -> Live { + let live = Live::new(Settings::defaults()); + live.ctrl.mark_ingest_alive_for_test(); + live.ctrl.arm_delay(5_000); + live + } + + /// One byte that is not UTF-8 used to turn the whole body into "", and + /// `POST /arm` with no `ms` is a disarm: a corrupt request dropped the + /// streamer's delay. Same bug class as the Content-Length fix. + #[tokio::test] + async fn a_body_that_is_not_utf8_is_refused_not_read_as_empty() { + let live = armed_live(); + let r = live + .send(&request("POST", "/arm", "", b"ms=9000\xff")) + .await; + assert_eq!(r.status, 400, "{}", r.body); + assert_eq!( + live.ctrl.armed_delay_ms(), + 5_000, + "the armed delay must survive" + ); + } + + /// A client that stops before the body it announced (EOF or the body + /// deadline) must not have the fragment run: `ms=30000` cut after four + /// bytes is `ms=3`, a 3 ms delay armed in place of a 30 s one. + #[tokio::test] + async fn a_body_shorter_than_its_content_length_is_refused_not_run_truncated() { + let live = armed_live(); + let r = live + .send(b"POST /arm HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 8\r\n\r\nms=3") + .await; + assert_eq!(r.status, 400, "{}", r.body); + assert_eq!( + live.ctrl.armed_delay_ms(), + 5_000, + "the armed delay must survive" + ); + } + + /// Over 32 MB is refused from the head alone, before any body is read or + /// buffered. Exactly 32 MB passes the size check, which the short-body + /// refusal then shows without the test sending 32 MB. + #[tokio::test] + async fn an_oversized_body_is_refused_before_any_of_it_is_read() { + const LIMIT: usize = 32 * 1024 * 1024; + let live = Live::new(Settings::defaults()); + let head = |len: usize| { + format!("POST /config HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {len}\r\n\r\n") + }; + let over = live.send(head(LIMIT + 1).as_bytes()).await; + assert_eq!(over.status, 413, "{}", over.body); + let at_limit = live.send(head(LIMIT).as_bytes()).await; + assert_eq!( + at_limit.status, 400, + "the limit itself is not too large: {}", + at_limit.body + ); + } + + /// A head that fills the 16 KB buffer without ending is answered with a + /// 400, not a silently dropped connection. + #[tokio::test] + async fn a_request_head_larger_than_the_buffer_is_refused() { + let live = Live::new(Settings::defaults()); + let mut raw = b"GET /state HTTP/1.1\r\nX-Pad: ".to_vec(); + raw.resize(16 * 1024, b'a'); + let r = live.send(&raw).await; + assert_eq!(r.status, 400); + } + + // ── Route access: the regression net ──────────────────────────── + // + // With a dashboard password set, every route is Public, Control (session + // or the dock token) or Admin (session only), and anything unlisted in + // `classify_access` falls to Admin. The net below reads this file's own + // source, so a new route fails the build's tests until someone writes + // down which level it should have. + + /// Routes answered before the access gate runs. They skip auth entirely, + /// so this list is exact: the login page and the login/logout actions. + const BEFORE_GATE: &[(&str, &str)] = + &[("GET", "/login"), ("POST", "/login"), ("POST", "/logout")]; + + /// Every exact route and the level it must have once a password is set. + const ROUTE_ACCESS: &[(&str, &str, Access)] = &[ + // Fast paths in `serve`. + ("GET", "/", Access::Admin), + ("GET", "/dock", Access::Control), + ("GET", "/dock.js", Access::Control), + ("GET", "/overlay-runtime.js", Access::Admin), + ("GET", "/integrations.js", Access::Admin), + ("GET", "/obs/vod-script/download", Access::Admin), + ("GET", "/events", Access::Control), + ("GET", "/overlay-events", Access::Public), + ("GET", "/alerts-events", Access::Public), + // Renders arbitrary settings from the query: a settings page tool. + ("GET", "/crash-protection/preview", Access::Admin), + ("POST", "/app/restart", Access::Admin), + ("POST", "/app/quit", Access::Admin), + // Auth management in `auth_gate`, after the gate. + ("POST", "/auth/set-password", Access::Admin), + ("POST", "/auth/disable", Access::Admin), + ("POST", "/auth/regen-dock", Access::Admin), + // The router. + ("GET", "/overlay", Access::Public), + ("GET", "/state", Access::Control), + ("GET", "/overlay-state", Access::Public), + ("GET", "/alerts", Access::Public), + // Ending a hold is operational, like a cut: the dock has the button. + ("POST", "/crash-protection/end", Access::Control), + ("GET", "/config", Access::Control), + ("GET", "/docks", Access::Control), + ("GET", "/platforms", Access::Control), + ("POST", "/obs/multitrack-config", Access::Public), + ("GET", "/obs/multitrack-config", Access::Public), + ("GET", "/obs/register-status", Access::Admin), + ("POST", "/obs/register", Access::Admin), + ("POST", "/obs/unregister", Access::Admin), + ("POST", "/obs/launch-with-eb", Access::Admin), + ("POST", "/obs/setup-vod-eb", Access::Admin), + ("POST", "/shortcut/create-eb", Access::Admin), + ("GET", "/update-check", Access::Admin), + ("POST", "/update/apply", Access::Admin), + ("POST", "/reveal/buffer", Access::Admin), + ("POST", "/reveal/overlays", Access::Admin), + ("POST", "/reveal/trace", Access::Admin), + ("GET", "/obs/launch-status", Access::Admin), + ("GET", "/twitch_ingests", Access::Admin), + ("GET", "/profiles", Access::Control), + ("GET", "/logs", Access::Admin), + ("GET", "/overlays", Access::Control), + ("GET", "/destinations", Access::Control), + ("POST", "/config", Access::Admin), + ("POST", "/config/reset", Access::Admin), + ("POST", "/arm", Access::Control), + ("POST", "/activate", Access::Control), + ("POST", "/stop", Access::Control), + ("POST", "/disarm", Access::Control), + ("POST", "/delay", Access::Control), + ("POST", "/go-live", Access::Control), + ("POST", "/cut-after", Access::Control), + ("POST", "/cut-after/cancel", Access::Control), + ("POST", "/hotkeys/capture", Access::Admin), + ("POST", "/midi/learn", Access::Admin), + ("POST", "/midi/learn/cancel", Access::Admin), + ("POST", "/midi/poll", Access::Admin), + ("POST", "/test-egress", Access::Admin), + ("POST", "/logs/clear", Access::Admin), + ("POST", "/profiles", Access::Admin), + ("POST", "/profiles/delete", Access::Admin), + ("POST", "/destinations", Access::Admin), + ("POST", "/destinations/toggle", Access::Control), + ("POST", "/destinations/delete", Access::Admin), + ]; + + /// Prefix routes in the router, checked with a sample path beneath each. + const PREFIX_ACCESS: &[(&str, &str, Access)] = &[ + ("GET", "/overlay/", Access::Public), + ("POST", "/overlays/", Access::Admin), + ("GET", "/docks/", Access::Control), + ("POST", "/docks/", Access::Control), + // Web call integrations: the token in the path is the credential. + ("GET", "/hooks/", Access::Public), + ("POST", "/hooks/", Access::Public), + // The integrations API (see `integrations::api`): settings and + // secrets, so a session only. + ("GET", "/integrations", Access::Admin), + ("POST", "/integrations", Access::Admin), + ("POST", "/connections/", Access::Admin), + ("POST", "/twitch/", Access::Admin), + ]; + + /// The production half of this file, so the tables above are never + /// mistaken for routes. + fn production_source() -> &'static str { + let src = include_str!("web.rs"); + &src[..src.find("mod tests {").expect("the test module")] + } + + /// The body of `fn (`, up to the first line that is a lone `}`. + fn fn_body<'a>(src: &'a str, name: &str) -> &'a str { + let start = src + .find(&format!("fn {name}(")) + .unwrap_or_else(|| panic!("fn {name} not found")); + let rest = &src[start..]; + let end = rest + .match_indices("\n}") + .map(|(at, _)| at) + .find(|at| matches!(rest[at + 2..].chars().next(), None | Some('\r' | '\n'))) + .unwrap_or_else(|| panic!("end of fn {name} not found")); + &rest[..end] + } + + /// The text of the string literal starting at `s` (just past its quote). + fn literal(s: &str) -> &str { + &s[..s.find('"').unwrap_or(0)] + } + + /// `("METHOD", "/path")` match arms, for any upper-case method. + fn match_arm_routes(body: &str) -> Vec<(String, String)> { + let mut routes = Vec::new(); + for (at, _) in body.match_indices("(\"") { + let rest = &body[at + 2..]; + let method = literal(rest); + if method.is_empty() || !method.bytes().all(|b| b.is_ascii_uppercase()) { + continue; + } + let Some(after) = rest[method.len()..].strip_prefix("\", \"") else { + continue; + }; + let path = literal(after); + if path.starts_with('/') { + routes.push((method.to_string(), path.to_string())); + } + } + routes + } + + /// Paths compared with `bare_path` via `needle` (`bare_path == "`, or a + /// prefix test), each with the method its statement checks, if any. The + /// statement runs back to the previous `{`, `}` or `;`, so a condition + /// rustfmt wraps over several lines is still read whole. + fn bare_path_tests(body: &str, needle: &str) -> Vec<(Option, String)> { + let method_marker = "method == \""; + let mut found = Vec::new(); + for (at, _) in body.match_indices(needle) { + let path = literal(&body[at + needle.len()..]).to_string(); + let statement_start = body[..at].rfind(['{', '}', ';']).map_or(0, |i| i + 1); + let statement = &body[statement_start..at]; + let method = statement + .find(method_marker) + .map(|m| literal(&statement[m + method_marker.len()..]).to_string()); + found.push((method, path)); + } + found + } + + /// `method == "M" && bare_path == "/p"` fast paths. A comparison with no + /// method in its statement (`let restart = bare_path == ...`) is not a + /// route of its own. + fn guarded_routes(body: &str) -> Vec<(String, String)> { + bare_path_tests(body, "bare_path == \"") + .into_iter() + .filter_map(|(method, path)| Some((method?, path))) + .collect() + } + + fn route_set<'a>( + routes: impl IntoIterator, + ) -> std::collections::BTreeSet<(String, String)> { + routes + .into_iter() + .map(|(m, p)| (m.to_string(), p.to_string())) + .collect() + } + + #[test] + fn every_route_has_a_deliberate_access_level() { + let src = production_source(); + let gate = fn_body(src, "auth_gate"); + let (before_gate, after_gate) = + gate.split_at(gate.find("classify_access(").expect("the gate classifies")); + + let answered_before = route_set( + guarded_routes(before_gate) + .iter() + .map(|(m, p)| (m.as_str(), p.as_str())), + ); + assert_eq!( + answered_before, + route_set(BEFORE_GATE.iter().copied()), + "a route answered before the access gate needs no login at all" + ); + + let mut found = route_set( + match_arm_routes(fn_body(src, "route")) + .iter() + .map(|(m, p)| (m.as_str(), p.as_str())), + ); + for body in [fn_body(src, "serve"), after_gate] { + for (m, p) in guarded_routes(body) { + found.insert((m, p)); + } + } + let table = route_set(ROUTE_ACCESS.iter().map(|(m, p, _)| (*m, *p))); + for (m, p) in &found { + assert!( + table.contains(&(m.clone(), p.clone())), + "unclassified route {m} {p}: add it to ROUTE_ACCESS with the access \ + level it must have once a dashboard password is set" + ); + } + for (m, p) in &table { + assert!( + found.contains(&(m.clone(), p.clone())), + "ROUTE_ACCESS lists {m} {p}, which no longer exists: remove the row" + ); + } + + let route = fn_body(src, "route"); + let mut prefixes: Vec = ["bare_path.starts_with(\"", "bare_path.strip_prefix(\""] + .iter() + .flat_map(|needle| bare_path_tests(route, needle)) + .map(|(_, p)| p) + .collect(); + prefixes.sort(); + prefixes.dedup(); + let mut listed: Vec = PREFIX_ACCESS + .iter() + .map(|(_, p, _)| p.to_string()) + .collect(); + listed.sort(); + listed.dedup(); + assert_eq!( + prefixes, listed, + "a prefix route was added or removed: update PREFIX_ACCESS" + ); + + for (m, p, want) in ROUTE_ACCESS { + assert_eq!(&classify_access(m, p), want, "{m} {p}"); + } + for (m, prefix, want) in PREFIX_ACCESS { + assert_eq!( + &classify_access(m, &format!("{prefix}sample")), + want, + "{m} {prefix}*" + ); + } + for p in [ + "/overlays/seeded", + "/overlays/reset", + "/overlays/sample/delete", + ] { + assert_eq!(classify_access("POST", p), Access::Admin, "POST {p}"); + } + // Fail closed: whatever nobody listed needs a session. + for m in ["GET", "POST", "PUT", "DELETE", "HEAD"] { + assert_eq!(classify_access(m, "/not-a-route"), Access::Admin, "{m}"); + } + } + + /// The overlay display is public because an OBS browser source cannot log + /// in, and it only ever GETs. Public for any method would hand a future + /// `POST /overlay/...` to anyone who can reach the port, so the exemption + /// is GET only. The multitrack config is public for both methods on + /// purpose: OBS POSTs it, and GET is the browser escape hatch. + #[test] + fn public_exemptions_cover_only_the_methods_their_callers_use() { + assert_eq!(classify_access("GET", "/overlay"), Access::Public); + assert_eq!(classify_access("GET", "/overlay/x.html"), Access::Public); + assert_eq!(classify_access("POST", "/overlay"), Access::Admin); + assert_eq!(classify_access("POST", "/overlay/x.html"), Access::Admin); + assert_eq!( + classify_access("GET", "/obs/multitrack-config"), + Access::Public + ); + assert_eq!( + classify_access("POST", "/obs/multitrack-config"), + Access::Public + ); + } + + /// A valid stored hash for the password "password" (the published PBKDF2 + /// vector, one iteration), so a test can turn auth on without paying for + /// 210 000 iterations of an unoptimized hash. + const TEST_PASSWORD_HASH: &str = "pbkdf2-sha256$1$73616c74$\ + 120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"; + const TEST_DOCK_TOKEN: &str = "dock0token0under0test"; + + /// Auth on, with a known dock token. + fn locked_live() -> Live { + let mut s = Settings::defaults(); + s.dashboard_password_hash = TEST_PASSWORD_HASH.into(); + s.dock_token = TEST_DOCK_TOKEN.into(); + Live::new(s) + } + + fn session_header(token: &str) -> String { + format!("Cookie: ic_session={token}\r\n") + } + + /// The dock token opens Control routes and nothing more. Only routes that + /// are harmless even if the gate broke are sent for real: a regression + /// here must fail the test, not register with the developer's OBS or open + /// a file browser. The rest of the Admin list is pinned by the + /// classification table above. + #[tokio::test] + async fn the_dock_token_cannot_reach_admin_routes() { + let live = locked_live(); + let by_query = |path: &str| { + let sep = if path.contains('?') { '&' } else { '?' }; + format!("{path}{sep}token={TEST_DOCK_TOKEN}") + }; + let by_cookie = format!("Cookie: ic_dock={TEST_DOCK_TOKEN}\r\n"); + + // The token itself is good: it opens a Control route both ways. + let ok = live + .send(&request("GET", &by_query("/state"), "", b"")) + .await; + assert_eq!(ok.status, 200, "{}", ok.body); + let ok = live.send(&request("GET", "/state", &by_cookie, b"")).await; + assert_eq!(ok.status, 200, "{}", ok.body); + + for (method, path) in [ + ("GET", "/"), + ("GET", "/logs"), + ("POST", "/config"), + ("POST", "/config/reset"), + ("POST", "/auth/set-password"), + ("POST", "/auth/disable"), + ("POST", "/auth/regen-dock"), + ("POST", "/app/quit"), + ("POST", "/app/restart"), + ("POST", "/destinations"), + ("POST", "/destinations/delete"), + ("POST", "/logs/clear"), + ("POST", "/profiles"), + ("POST", "/overlays/probe"), + ] { + let r = live.send(&request(method, &by_query(path), "", b"")).await; + assert_eq!(r.status, 401, "{method} {path} via ?token="); + let r = live.send(&request(method, path, &by_cookie, b"")).await; + assert_eq!(r.status, 401, "{method} {path} via the ic_dock cookie"); + } + let s = live.settings.borrow(); + assert_eq!( + s.dashboard_password_hash, TEST_PASSWORD_HASH, + "auth untouched" + ); + assert_eq!(s.dock_token, TEST_DOCK_TOKEN, "dock token untouched"); + } + + /// An empty stored dock token means "no dock access", never "an empty + /// token matches": `?token=` must not open anything. + #[tokio::test] + async fn an_empty_dock_token_grants_nothing() { + let mut s = Settings::defaults(); + s.dashboard_password_hash = TEST_PASSWORD_HASH.into(); + s.dock_token.clear(); + let live = Live::new(s); + for (path, headers) in [ + ("/state?token=", ""), + ("/state?token=anything", ""), + ("/state", "Cookie: ic_dock=\r\n"), + ] { + let r = live.send(&request("GET", path, headers, b"")).await; + assert_eq!(r.status, 401, "{path} {headers:?}"); + } + } + + /// Changing the password ends every earlier session, so a stolen cookie + /// dies with the old password; whoever changed it gets a fresh one. + #[tokio::test] + async fn changing_the_password_ends_every_old_session() { + let live = locked_live(); + let old = live.auth.create_session(); + let r = live + .send(&request("GET", "/logs", &session_header(&old), b"")) + .await; + assert_eq!(r.status, 200, "precondition: the session is admin"); + + let r = live + .send(&request( + "POST", + "/auth/set-password", + &session_header(&old), + b"password=a-new-password", + )) + .await; + assert_eq!(r.status, 200, "{}", r.body); + let fresh = r.session_cookie(); + + let r = live + .send(&request("GET", "/logs", &session_header(&old), b"")) + .await; + assert_eq!(r.status, 401, "the old session must be dead"); + let r = live + .send(&request("GET", "/logs", &session_header(&fresh), b"")) + .await; + assert_eq!(r.status, 200, "the new session works"); + assert_ne!( + live.settings.borrow().dashboard_password_hash, + TEST_PASSWORD_HASH + ); + } + + /// Turning auth off clears the dock token too, in memory and on disk, so + /// switching auth back on later cannot revive an old token. + #[tokio::test] + async fn disabling_auth_clears_the_dock_token() { + let live = locked_live(); + let session = live.auth.create_session(); + let r = live + .send(&request( + "POST", + "/auth/disable", + &session_header(&session), + b"", + )) + .await; + assert_eq!(r.status, 200, "{}", r.body); + for s in [ + live.settings.borrow().clone(), + Settings::load(&live.cfg_path).expect("saved"), + ] { + assert!(s.dashboard_password_hash.is_empty()); + assert!(s.dock_token.is_empty()); + } + } + + /// Rotating the dock token retires the old one at once. + #[tokio::test] + async fn regenerating_the_dock_token_retires_the_old_one() { + let live = locked_live(); + let session = live.auth.create_session(); + let r = live + .send(&request( + "POST", + "/auth/regen-dock", + &session_header(&session), + b"", + )) + .await; + assert_eq!(r.status, 200, "{}", r.body); + let fresh = read_string_field(&r.body, "dock_token").expect("the new token"); + assert!(!fresh.is_empty() && fresh != TEST_DOCK_TOKEN); + + let old = live + .send(&request( + "GET", + &format!("/state?token={TEST_DOCK_TOKEN}"), + "", + b"", + )) + .await; + assert_eq!(old.status, 401, "the old token must stop working"); + let new = live + .send(&request("GET", &format!("/state?token={fresh}"), "", b"")) + .await; + assert_eq!(new.status, 200, "{}", new.body); + } + + /// The first password is the one admin action reachable without a + /// session, so it is local-only: a LAN peer on a bind-all box must not be + /// able to claim the dashboard before its owner does. An IPv4-mapped IPv6 + /// loopback is not recognised as local and is refused too (fail closed). + #[tokio::test] + async fn the_first_password_must_be_set_from_this_machine() { + let live = Live::new(Settings::defaults()); + for peer in ["192.168.1.50", "::ffff:127.0.0.1"] { + let r = live + .send_from( + peer, + &request("POST", "/auth/set-password", "", b"password=long-enough-pw"), + ) + .await; + assert_eq!(r.status, 403, "from {peer}: {}", r.body); + assert!(live.settings.borrow().dashboard_password_hash.is_empty()); + } + } + + #[test] + fn only_addresses_pass_the_rebinding_guard() { + for host in [ + "127.0.0.1:7799", + "localhost:7799", + "LOCALHOST", + "dock.localhost:7799", + "192.168.1.50:7799", + "[::1]:7799", + "", + ] { + assert!(host_is_address(host), "{host}"); + } + for host in [ + "evil.example:7799", + "evil.example", + "127.0.0.1.nip.io:7799", + "localhost.evil.com", + ] { + assert!(!host_is_address(host), "{host}"); + } + } + + #[test] + fn secrets_are_never_readable_cross_origin() { + for path in ["/overlay", "/overlay-state", "/alerts", "/state"] { + assert!(readable_cross_origin(path), "{path}"); + } + for path in [ + "/config", + "/destinations", + "/integrations", + "/integrations/activity", + "/logs", + "/docks", + ] { + assert!(!readable_cross_origin(path), "{path}"); + } + } + + #[test] + fn is_loopback_fails_closed() { + for ip in ["127.0.0.1", "127.8.9.10", "::1"] { + assert!(is_loopback(ip), "{ip}"); + } + for ip in [ + "::ffff:127.0.0.1", + "192.168.1.50", + "0.0.0.0", + "", + "localhost", + "garbage", + ] { + assert!(!is_loopback(ip), "{ip}"); + } + } + + /// A custom URL can carry the stream key in its path, so `/destinations` + /// shows it raw only to a full session (the edit form needs it). The dock + /// token reads this route too, and gets it blank plus a "set" flag. + #[tokio::test] + async fn the_dock_token_cannot_read_a_custom_egress_url() { + const SECRET: &str = "SECRETKEY1234567890"; + let mut s = Settings::defaults(); + s.dashboard_password_hash = TEST_PASSWORD_HASH.into(); + s.dock_token = TEST_DOCK_TOKEN.into(); + s.destinations.push(crate::config::Destination { + platform: "custom".into(), + stream_key: String::new(), + custom_egress_url: format!("rtmp://host/app/{SECRET}"), + ..twitch_dest(true, "") + }); + let live = Live::new(s); + + let dock = live + .send(&request( + "GET", + &format!("/destinations?token={TEST_DOCK_TOKEN}"), + "", + b"", + )) + .await; + assert_eq!(dock.status, 200, "{}", dock.body); + assert!( + !dock.body.contains(SECRET), + "the dock read the key: {}", + dock.body + ); + assert!( + dock.body.contains(r#""custom_egress_url":"""#), + "{}", + dock.body + ); + assert!( + dock.body.contains(r#""custom_egress_url_set":true"#), + "{}", + dock.body + ); + + let session = live.auth.create_session(); + let admin = live + .send(&request( + "GET", + "/destinations", + &session_header(&session), + b"", + )) + .await; + assert_eq!(admin.status, 200, "{}", admin.body); + assert!( + admin.body.contains(&format!( + r#""custom_egress_url":"rtmp://host/app/{SECRET}""# + )), + "the dashboard's edit form needs the raw URL: {}", + admin.body + ); + assert!(admin.body.contains(r#""custom_egress_url_set":true"#)); + } + + // ── OBS multitrack config ──────────────────────────────────────── + + /// A usable Twitch destination with id `id` and stream key `key`. + fn twitch_dest_with(id: &str, key: &str) -> crate::config::Destination { + crate::config::Destination { + id: id.into(), + name: format!("Twitch {id}"), + ..twitch_dest(true, key) + } + } + + /// During a crash-protection hold the kept config carries the live + /// Twitch session's token. A wrong ingest key must not get it, nor move + /// the destination off that session; the right key gets it back as is. + #[tokio::test] + async fn a_wrong_ingest_key_never_gets_the_config_a_hold_keeps() { + let mut s = settings_with_dests(vec![twitch_dest_with("a", "live_real_key_123")]); + s.ingest_key = "right-key".into(); + let live = Live::new(s); + let ctrl = &live.ctrl; + ctrl.update_crash_protection(crate::crash_protection::CrashProtection { + enabled: true, + ..Default::default() + }); + let dest = ctrl.destination_state("a"); + dest.egress_alive + .store(true, std::sync::atomic::Ordering::Relaxed); + let held_ivs = "rtmps://ivs/app/held-token"; + *dest.eb_override_url.lock() = Some(held_ivs.into()); + ctrl.remember_eb_session(crate::controller::EbSession { + config: "{held-config}".into(), + auths: vec!["held-token".into()], + dest_id: "a".into(), + ivs_url: held_ivs.into(), + }); + ctrl.begin_publish("right-key", "127.0.0.1").await.unwrap(); + ctrl.on_tag(9, 0, &[0x17, 0, 0, 0, 0, 1], false, true); + ctrl.mark_ingest_dead(); + assert!(ctrl.hold_active()); + + let wrong = r#"{"authentication":"wrong-key","client":{}}"#; + let cfg = obs_multitrack_config_proxy(wrong, "", ctrl, &live.settings).await; + assert!(!cfg.contains("{held-config}"), "{cfg}"); + assert_eq!(dest.eb_override_url.lock().as_deref(), Some(held_ivs)); + + let right = r#"{"authentication":"right-key","client":{}}"#; + let cfg = obs_multitrack_config_proxy(right, "", ctrl, &live.settings).await; + assert_eq!(cfg, "{held-config}"); + } + + /// The ingest key is the only thing standing between a stranger's OBS and + /// a brokered Twitch session. A wrong key gets the static config and + /// nothing else: no Twitch key, no session, no egress override. + #[tokio::test] + async fn a_wrong_ingest_key_brokers_nothing() { + let mut s = settings_with_dests(vec![twitch_dest_with("a", "live_real_key_123")]); + s.ingest_key = "right-key".into(); + let live = Live::new(s); + let body = r#"{"authentication":"wrong-key","client":{}}"#; + let query = "encoder=x264&tracks=1"; + let cfg = obs_multitrack_config_proxy(body, query, &live.ctrl, &live.settings).await; + assert_eq!( + cfg, + obs_multitrack_config_static(query, body, &live.settings) + ); + assert!(!cfg.contains("live_real_key_123") && !cfg.contains("right-key")); + assert!(live + .ctrl + .destination_state("a") + .eb_override_url + .lock() + .is_none()); + assert!(logs_json(&live.ctrl).contains("rejected config request")); + } + + /// With no usable Twitch destination there is nothing to broker: the + /// static config comes back and the log says why. Asserting the log line, + /// not just the output, matters: were a bad destination ever picked, the + /// Twitch call would fail and fall back to the same static config. + #[tokio::test] + async fn no_usable_twitch_destination_falls_back_to_the_static_config() { + let youtube = crate::config::Destination { + platform: "youtube".into(), + ..twitch_dest(true, "yt_key") + }; + for (dests, why) in [ + (vec![], "no destinations"), + (vec![twitch_dest(false, "livekey123")], "disabled"), + (vec![twitch_dest(true, "")], "no stream key"), + (vec![youtube], "not Twitch"), + ] { + let live = Live::new(settings_with_dests(dests)); + let body = r#"{"authentication":"anything"}"#; + let cfg = obs_multitrack_config_proxy(body, "", &live.ctrl, &live.settings).await; + assert_eq!( + cfg, + obs_multitrack_config_static("", body, &live.settings), + "{why}" + ); + assert!( + logs_json(&live.ctrl).contains("no Twitch destination, and OBS didn't describe"), + "{why}" + ); + } + } + + /// OBS's body without an `authentication` field cannot be patched with + /// the real key, so the proxy falls back rather than send Twitch a body + /// it did not mean to. + #[tokio::test] + async fn a_body_without_authentication_falls_back_to_the_static_config() { + let live = Live::new(settings_with_dests(vec![twitch_dest_with("a", "live_key")])); + let body = r#"{"client":{}}"#; + let cfg = obs_multitrack_config_proxy(body, "", &live.ctrl, &live.settings).await; + assert_eq!(cfg, obs_multitrack_config_static("", body, &live.settings)); + assert!(logs_json(&live.ctrl).contains("didn't expose an authentication")); + } + + /// A real-shaped Twitch answer: OBS is pointed at us on every endpoint, + /// the Twitch destination whose key was sent gets the session's IVS URL + /// with the session token, and a stale override on another Twitch + /// destination is cleared (two egresses on one session token collide). + #[tokio::test] + async fn a_twitch_session_is_applied_to_exactly_the_matching_destination() { + let live = Live::new(settings_with_dests(vec![ + twitch_dest_with("a", "key_a"), + twitch_dest_with("b", "key_b"), + ])); + *live.ctrl.destination_state("b").eb_override_url.lock() = Some("rtmps://stale".into()); + let twitch = r#"{"meta":{"config_id":"x"},"ingest_endpoints":[ + {"protocol":"RTMP","url_template":"rtmp://jfk.contribute.live-video.net/app/{stream_key}","authentication":"v1_session"}, + {"protocol":"RTMPS","url_template":"rtmps://jfk.contribute.live-video.net:443/app/{stream_key}","authentication":"v1_session"} + ],"encoder_configurations":[]}"#; + + let out = apply_twitch_multitrack_config(twitch, "key_a", 1935, &live.ctrl, &live.settings); + + assert_eq!(out.matches("contribute.live-video.net").count(), 0, "{out}"); + assert_eq!( + out.matches("rtmp://localhost:1935/live/{stream_key}") + .count(), + 2 + ); + assert_eq!( + *live.ctrl.destination_state("a").eb_override_url.lock(), + Some("rtmp://jfk.contribute.live-video.net/app/v1_session".to_string()) + ); + assert!(live + .ctrl + .destination_state("b") + .eb_override_url + .lock() + .is_none()); + assert!(logs_json(&live.ctrl).contains("2 enabled Twitch destinations")); + } + + /// Endpoints without a session token (non-IVS multitrack): the user's own + /// Twitch key fills the template instead. + #[tokio::test] + async fn endpoints_without_a_token_use_the_twitch_key() { + let live = Live::new(settings_with_dests(vec![twitch_dest_with("a", "key_a")])); + let twitch = r#"{"ingest_endpoints":[{"url_template":"rtmp://x.contribute.live-video.net/app/{stream_key}"}]}"#; + apply_twitch_multitrack_config(twitch, "key_a", 1935, &live.ctrl, &live.settings); + assert_eq!( + *live.ctrl.destination_state("a").eb_override_url.lock(), + Some("rtmp://x.contribute.live-video.net/app/key_a".to_string()) + ); + } + + /// A 200 with no ingest endpoints carries no session to switch to: no + /// override is set and the response passes through unchanged. + #[tokio::test] + async fn a_response_without_endpoints_sets_no_override() { + let live = Live::new(settings_with_dests(vec![twitch_dest_with("a", "key_a")])); + let twitch = r#"{"meta":{"config_id":"x"},"encoder_configurations":[]}"#; + let out = apply_twitch_multitrack_config(twitch, "key_a", 1935, &live.ctrl, &live.settings); + assert_eq!(out, twitch); + assert!(live + .ctrl + .destination_state("a") + .eb_override_url + .lock() + .is_none()); + assert!(logs_json(&live.ctrl).contains("couldn't parse the ingest URL")); + } + + // ── Overlay files ──────────────────────────────────────────────── + + /// `/overlay/` is public, so its name check is the first line of + /// defence against reading files outside the overlays folder. + #[test] + fn overlay_names_that_could_leave_the_folder_are_refused() { + let dir = unique_tmp_dir("names"); + let settings = settings_with_overlays_dir(&dir); + for name in [ + "", + "..", + "../x", + "..\\x", + "a/b", + "a\\b", + "c:x", + "C:\\Windows\\win.ini", + ] { + let (status, _, body) = serve_overlay_file(name, &settings); + assert_eq!(status, "400 Bad Request", "{name:?}"); + assert_eq!(body, "invalid overlay name", "{name:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// The second line of defence: a clean name that is a symlink to a file + /// outside the folder must not be served. Creating a symlink on Windows + /// needs Developer Mode or admin rights; without them the check is skipped + /// with a printed note rather than silently passing. + #[test] + fn an_overlay_symlink_cannot_escape_the_folder() { + let dir = unique_tmp_dir("symlink-in"); + let outside = unique_tmp_dir("symlink-out"); + let secret = outside.join("secret.txt"); + std::fs::write(&secret, "PRIVATE-MATERIAL").unwrap(); + let link = dir.join("escape.html"); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_file(&secret, &link); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&secret, &link); + match made { + Err(e) => eprintln!( + "NOTE: skipped the overlay symlink-escape check: cannot create a symlink \ + here ({e}). On Windows this needs Developer Mode or admin rights." + ), + Ok(()) => { + let (status, _, body) = + serve_overlay_file("escape.html", &settings_with_overlays_dir(&dir)); + assert_eq!(status, "403 Forbidden", "{body}"); + assert!(!body.contains("PRIVATE")); + } + } + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&outside); + } } diff --git a/web/alerts.html b/web/alerts.html new file mode 100644 index 0000000..c4bd26d --- /dev/null +++ b/web/alerts.html @@ -0,0 +1,100 @@ + + + + + +InstantClone alerts + + + + + + + diff --git a/web/dock.html b/web/dock.html index b513d1f..2561a8b 100644 --- a/web/dock.html +++ b/web/dock.html @@ -23,12 +23,15 @@ /* Phase chip */ .phase{padding:3px 10px;border-radius:999px;font-size:10.5px;font-weight:700; text-transform:uppercase;letter-spacing:1.2px;border:1px solid var(--line); - background:var(--panel);color:var(--fg-3); + background:var(--panel);color:var(--fg-3);white-space:nowrap;overflow:hidden; transition:color .25s,background-color .25s,border-color .25s,box-shadow .25s} +/* The word is its own box so it can rise in while the chip glides to its width. */ +.phase>span{display:inline-block} .phase.live {color:var(--live); border-color:color-mix(in srgb,var(--live) 40%,var(--line)); background:color-mix(in srgb,var(--live) 12%,var(--panel))} .phase.arming{color:var(--warn); border-color:color-mix(in srgb,var(--warn) 40%,var(--line)); background:color-mix(in srgb,var(--warn) 12%,var(--panel))} .phase.armed {color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--line));background:color-mix(in srgb,var(--accent) 12%,var(--panel))} .phase.active{color:var(--live); border-color:color-mix(in srgb,var(--live) 50%,var(--line)); background:color-mix(in srgb,var(--live) 16%,var(--panel));box-shadow:0 0 0 1px color-mix(in srgb,var(--live) 30%,transparent)} +.phase.holding{color:var(--warn); border-color:color-mix(in srgb,var(--warn) 55%,var(--line)); background:color-mix(in srgb,var(--warn) 14%,var(--panel))} /* Signal pill */ .pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px; @@ -54,11 +57,13 @@ .cur{font-size:clamp(23px,11vw,34px);font-weight:800;letter-spacing:-1.5px; font-variant-numeric:tabular-nums;line-height:1.08; transition:color .25s,transform .25s cubic-bezier(.32,.72,0,1); - display:inline-flex;align-items:baseline} + display:inline-flex;align-items:baseline;justify-content:center;min-width:2.4em} /* The idle setter shares the readout's exact type so nothing jumps when the - dock crosses from passthrough into an armed delay. */ + dock crosses from passthrough into an armed delay. It is sized to its + digits (fitDelayInput) so the unit sits right after them, while the + fixed-width .cur keeps the +/- buttons from moving under the cursor. */ .cur input{font:inherit;letter-spacing:inherit;color:inherit;background:transparent;border:0; - outline:none;text-align:center;width:2.7em;font-variant-numeric:tabular-nums; + outline:none;text-align:center;width:1ch;padding:0;font-variant-numeric:tabular-nums; -moz-appearance:textfield} .cur input::-webkit-outer-spin-button,.cur input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0} .cur .u{font-size:.4em;color:var(--fg-3);font-weight:600;margin-left:2px;letter-spacing:0} @@ -96,6 +101,24 @@ .eg b{color:var(--fg-2);font-weight:600} .eg .sep{color:var(--fg-4)} +/* Crash protection hold: why, how long is left, who is on the reconnect + screen, and End now (two-step). It takes the place of the delay controls + (see applyCfg), which don't apply while OBS is gone. */ +.hold{display:flex;flex-direction:column;gap:6px;padding:9px 10px;border-radius:11px; + border:1px solid color-mix(in srgb,var(--warn) 55%,var(--line)); + background:color-mix(in srgb,var(--warn) 8%,var(--panel-2))} +.hold-top{display:flex;align-items:center;justify-content:space-between;gap:8px} +.hold-why{display:flex;align-items:center;gap:6px;font-size:10.5px;font-weight:700; + letter-spacing:1.2px;text-transform:uppercase;color:var(--warn)} +.hold-why::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor; + animation:blink 1.4s ease-in-out infinite} +@keyframes blink{50%{opacity:.35}} +.hold-clock{font-size:24px;font-weight:800;letter-spacing:-1px;line-height:1;font-variant-numeric:tabular-nums} +.hold-sub{font-size:11px;color:var(--fg-2)} +.hold .hold-end{padding:8px;font-size:12px;color:var(--danger); + border-color:color-mix(in srgb,var(--danger) 45%,var(--line))} +.hold .hold-end.armed,.hold .hold-end.armed:hover:not(:disabled){background:var(--danger);color:#fff;border-color:transparent} + /* Sub-tip with crossfade */ .tip{font-size:10.5px;color:var(--fg-3);text-align:center;min-height:0;line-height:1.3; transition:opacity .15s,transform .15s} @@ -116,6 +139,12 @@ .dest.pc .d-plat{color:color-mix(in srgb,var(--pc) 80%,var(--fg-3))} .dest.pc.alive .d-dot{background:var(--pc);box-shadow:0 0 0 3px color-mix(in srgb,var(--pc) 20%,transparent)} .dest.pc .sw.on{background:var(--pc)} +/* On but not streaming yet (connecting, or waiting for the delay), and + on the crash-protection reconnect screen: amber, so a failing platform + never looks like one that is simply off. */ +.dest.wait .d-dot,.dest.held .d-dot{background:var(--warn);box-shadow:0 0 0 3px color-mix(in srgb,var(--warn) 18%,transparent)} +.dest.wait .d-dot{animation:blink 1.2s ease-in-out infinite} +.dest.wait .d-plat,.dest.held .d-plat{color:var(--warn)} /* iOS-style switch */ .sw{width:34px;height:20px;flex:0 0 auto;border-radius:999px;border:0;background:var(--line-2); position:relative;cursor:pointer;transition:background-color .2s cubic-bezier(.32,.72,0,1)} @@ -153,6 +182,7 @@ .dico.pc{color:color-mix(in srgb,var(--pc) 75%,var(--fg-3))} .dico.pc.on{color:var(--pc);border-color:color-mix(in srgb,var(--pc) 50%,var(--line))} .dico.pc.alive{border-color:color-mix(in srgb,var(--pc) 70%,var(--line));box-shadow:0 0 0 3px color-mix(in srgb,var(--pc) 20%,transparent)} +.dico.wait{border-color:color-mix(in srgb,var(--warn) 55%,var(--line))} .dico.arm,.dico.pc.arm{border-color:var(--warn);color:var(--warn);font-size:15px;animation:dico-pulse 1s ease-in-out infinite} @keyframes dico-pulse{0%,100%{box-shadow:0 0 0 2px color-mix(in srgb,var(--warn) 28%,transparent)}50%{box-shadow:0 0 0 6px color-mix(in srgb,var(--warn) 8%,transparent)}} @@ -247,8 +277,14 @@ .btn.ghost:hover:not(:disabled){background:var(--panel);color:var(--fg)} .btn:disabled{opacity:.4;cursor:not-allowed;transform:none} .btn.full{width:100%} -.spin{display:inline-block;width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:sp .8s linear infinite} -@keyframes sp{to{transform:rotate(360deg)}} +/* Buffering: the main button fills toward Activate (--p is 0..1) and counts + down the wait. Disabled, but at full strength so the countdown reads. */ +.btn.filling{position:relative;overflow:hidden;color:var(--fg);font-variant-numeric:tabular-nums; + border-color:color-mix(in srgb,var(--warn) 40%,var(--line))} +.btn.filling:disabled{opacity:1;cursor:default} +.btn.filling::before{content:"";position:absolute;inset:0;transform-origin:left;transform:scaleX(var(--p,0)); + background:color-mix(in srgb,var(--warn) 20%,transparent);transition:transform .35s cubic-bezier(.32,.72,0,1)} +.btn.filling>span{position:relative} .cta-row{display:flex;gap:6px} .cta-row .btn{flex:1} .cta-row .btn.disarm{flex:0 0 auto;padding:10px 13px} @@ -435,6 +471,11 @@
+
Waiting for your encoder…
diff --git a/web/dock.js b/web/dock.js index a45c0b0..8b0268f 100644 --- a/web/dock.js +++ b/web/dock.js @@ -5,7 +5,25 @@ const $ = id => document.getElementById(id); const PHASE_TO_STATE = { idle: 'off', preparing: 'arming', ready: 'armed', active: 'active' }; -let s = null, lastVal = '0.0', pending = null; +const EASE = 'cubic-bezier(.32,.72,0,1)'; +let s = null, lastVal = '0.0', pending = null, pendingTimer = 0; + +// Guarded writes. The state stream lands about 4 times a second and most +// pushes change nothing the dock shows; writing only real changes keeps +// running animations from restarting and leaves an idle dock untouched. +function setText(el, t) { if (el.textContent !== t) el.textContent = t; } +function setHtml(el, h) { if (el._html !== h) { el._html = h; el.innerHTML = h; } } +function setHidden(el, h) { if (el.hidden !== h) el.hidden = h; } +function setClass(el, c) { if (el.className !== c) el.className = c; } +function setTitle(el, t) { if (el.title !== t) el.title = t; } +function setDisabled(el, d) { if (el.disabled !== d) el.disabled = d; } +const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); +function motionOK() { return !document.hidden && !reducedMotion.matches; } +// "4:03" for a millisecond countdown. +function fmtClock(ms) { + const secs = Math.max(0, Math.ceil(ms / 1000)); + return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`; +} async function fetchJ(u, o) { try { @@ -192,9 +210,20 @@ function bump(n) { const step = cfg.w.number.step || 1; const i = $('d'); i.value = Math.max(0, Math.min(600, (+i.value || 0) + n * step)); - renderCta(); + fitDelayInput(); renderCta(); +} +// Size the idle setter to its digits so the unit sits right after them. +function fitDelayInput() { + const i = $('d'), w = Math.max(1, i.value.length) + 'ch'; + if (i.style.width !== w) i.style.width = w; +} +// Show the requested state until the server confirms it. One timer, so a +// quick second click can't have the first click's timer cut it short. +function setPending(p) { + pending = p; clearTimeout(pendingTimer); + pendingTimer = setTimeout(() => { pending = null; renderCta(); }, 1500); + tick(); } -function setPending(p) { pending = p; setTimeout(() => pending = null, 1500); tick(); } function fmtRate(k) { if (!k || k <= 0) return ''; return k >= 1000 ? (k / 1000).toFixed(2) + ' Mbps' : Math.round(k) + ' kbps'; } function fmt(ms) { if (ms < 100) return '0.0'; const x = Math.round(ms / 100) / 10; return x < 10 ? x.toFixed(1) : Math.round(x).toString(); } // Crossfade helper so the sub-tip swaps smoothly instead of snapping. @@ -224,7 +253,9 @@ async function activate() { else toast('Delay active', 'ok'); tick(); } -async function stop() { setPending('off'); await fetchJ('/stop', { method: 'POST' }); toast('Back to live', 'ok'); tick(); } +// Cutting keeps the delay armed (the buffer keeps filling), so the dock +// goes to Activate, not back to Arm. +async function stop() { setPending('armed'); await fetchJ('/stop', { method: 'POST' }); toast('Back to live', 'ok'); tick(); } async function disarm() { setPending('off'); await fetchJ('/disarm', { method: 'POST' }); toast('Disarmed', 'ok'); tick(); } function mainClick() { @@ -256,6 +287,26 @@ async function cutAfter() { else toast('Mark set - cuts once this airs', 'ok'); tick(); } +// Ending the hold ends the stream everywhere: the first click arms, a second +// within 3 s ends it (OBS docks can't show confirm dialogs). +async function endHold() { + const b = $('hold-end'); + if (!b._armed) { + b._armed = true; b.classList.add('armed'); b.textContent = 'Click again to end'; + b._reset = setTimeout(disarmEndHold, 3000); + return; + } + disarmEndHold(); + const r = await fetchJ('/crash-protection/end', { method: 'POST' }); + if (r.status === 409) toast(r.j?.error || 'Nothing to end', 'info'); + else toast(r.ok ? 'Stream ended on every destination' : 'Could not end the stream', r.ok ? 'ok' : 'err'); + tick(); +} +function disarmEndHold() { + const b = $('hold-end'); + if (!b._armed) return; + clearTimeout(b._reset); b._armed = false; b.classList.remove('armed'); b.textContent = 'End now'; +} async function cutAfterCancel() { await fetchJ('/cut-after/cancel', { method: 'POST' }); toast('Auto-cut cancelled', 'ok'); @@ -276,125 +327,230 @@ function secondaryClick() { function setNumberMode(ds) { const engaged = ds === 'arming' || ds === 'armed' || ds === 'active'; const controls = cfg.w.number.controls && !engaged; - $('d').hidden = engaged; - $('v').hidden = !engaged; - $('minus').hidden = !controls; - $('plus').hidden = !controls; + setHidden($('d'), engaged); + setHidden($('v'), !engaged); + setHidden($('minus'), !controls); + setHidden($('plus'), !controls); } // ------------------------------------------------------------- state paint -- +// What the main button says and does in each state. `key` names the state; +// only a new key rebuilds the button (and animates it in), so a label that +// counts (Ready in 0:23, Arm 30s) just updates its text. +function mainLook(ds) { + const m = $('main'); + if (ds === 'active') return { key: 'cut', cls: 'danger', label: m._cutArm ? 'Tap again to cut' : 'Cut delay' }; + if (ds === 'armed') return { key: 'armed', cls: 'go', icon: '▶', label: 'Activate' }; + if (ds === 'arming') { + // The buffer fills in real time, so the wait is exactly what's missing. + // Right after a click the server may not report the target yet. + const target = s.armed_delay_ms || (+$('d').value || 0) * 1000; + const fill = Math.min(s.buffer_fill_ms || 0, target); + const label = s.ingest_alive ? `Ready in ${fmtClock(target - fill)}` : 'Waiting for OBS'; + return { key: 'arming', cls: 'filling', label, disabled: true, p: target > 0 ? fill / target : 0 }; + } + const sec = +$('d').value || 0; + if (!s.ingest_alive) return { key: 'wait', label: 'Waiting for OBS', disabled: true }; + if (sec <= 0) return { key: 'set', label: 'Set a delay first', disabled: true }; + return { key: 'arm', cls: 'primary', icon: '⏱', label: `Arm ${sec.toFixed(0)}s` }; +} +function paintMain(look) { + const m = $('main'); + setClass(m, 'btn full' + (look.cls ? ' ' + look.cls : '')); + setDisabled(m, !!look.disabled); + if (look.p !== undefined) { + const p = look.p.toFixed(3); + if (m._p !== p) { m._p = p; m.style.setProperty('--p', p); } + } + if (m._key === look.key) { setText(m._label, look.label); return; } + const first = m._key === undefined; + m._key = look.key; + m._label = document.createElement('span'); + m._label.textContent = look.label; + const parts = [m._label]; + if (look.icon) { const i = document.createElement('span'); i.textContent = look.icon; parts.unshift(i); } + m.replaceChildren(...parts); + if (first || !motionOK()) return; + for (const p of parts) p.animate([{ opacity: 0, transform: 'translateY(5px)' }, { opacity: 1, transform: 'none' }], { duration: 220, easing: EASE }); +} +// The button next to the main one: cancels arming, disarms, and schedules or +// cancels the safe cut while the delay is active. A pending cut always shows +// even with the option off - it may have been scheduled from the dashboard, +// and hiding it would lie. +function secondaryLook(ds) { + if (ds === 'arming') return { cls: 'btn ghost disarm', text: '×', title: 'Cancel arming' }; + if (ds === 'armed') return { cls: 'btn ghost disarm', text: '×', title: 'Disarm' }; + if (ds !== 'active') return null; + if (s.safe_cut_pending) { + const cd = Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000)); + return { cls: 'btn ghost cutafter pending', text: `⏱ ~${cd}s ✕`, title: 'Auto-cut armed - tap to cancel' }; + } + if (cfg.w.activate.safecut) return { cls: 'btn ghost cutafter', text: '⏱ Cut after', title: 'Cut to live after everything buffered has aired' }; + return null; +} + function renderCta() { if (!s) return; const m = $('main'), cancel = $('cancel'); const ds = pending || PHASE_TO_STATE[s.phase] || 'off'; - const ingest = s.ingest_alive; // Drop a pending cut-confirm if we left the active state by any route, so a // stale arm can't skip the guard next time. if (ds !== 'active' && m._cutArm) { m._cutArm = false; clearTimeout(m._cutT); } - m.className = 'btn full'; m.disabled = false; - cancel.hidden = true; cancel.className = 'btn ghost disarm'; cancel.innerHTML = '×'; cancel.title = 'Cancel arming'; - if (ds === 'active') { - m.textContent = m._cutArm ? 'Tap again to cut' : 'Cut delay'; m.classList.add('danger'); - // Secondary becomes the safe cut: schedule, then a live countdown a second - // tap cancels. A pending cut always shows even with the option off - it - // may have been scheduled from the dashboard, and hiding it would lie. - if (s.safe_cut_pending) { - const cd = Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000)); - cancel.hidden = false; cancel.className = 'btn ghost cutafter pending'; - cancel.textContent = `⏱ ~${cd}s ✕`; cancel.title = 'Auto-cut armed - tap to cancel'; - } else if (cfg.w.activate.safecut) { - cancel.hidden = false; cancel.className = 'btn ghost cutafter'; - cancel.textContent = '⏱ Cut after'; cancel.title = 'Cut to live after everything buffered has aired'; - } - } else if (ds === 'armed') { - m.innerHTML = '▶ Activate'; m.classList.add('go'); cancel.hidden = false; - } else if (ds === 'arming') { - const tgt = s.armed_delay_ms || 1; - const pct = Math.min(99, Math.round((s.buffer_fill_ms / tgt) * 100)); - m.innerHTML = ` Buffering ${pct}%`; m.disabled = true; cancel.hidden = false; - } else { - const sec = +$('d').value || 0; - if (!ingest) { m.textContent = 'Waiting for encoder…'; m.disabled = true; } - else if (sec <= 0) { m.textContent = 'Set a delay above'; m.disabled = true; } - else { m.innerHTML = `⏱ Arm ${sec.toFixed(0)}s`; m.classList.add('primary'); } + // During a crash hold the hold card is the control: there is nothing to + // arm or cut until OBS is back. + setHidden($('w-cta'), !cfg.w.activate.on || !!s.hold); + paintMain(mainLook(ds)); + const sec = secondaryLook(ds); + setHidden(cancel, !sec); + if (sec) { setClass(cancel, sec.cls); setText(cancel, sec.text); setTitle(cancel, sec.title); } + if (ds === 'off') { // Dead-end nudges read as disabled: can't go below 0 or above the cap. - $('minus').disabled = sec <= 0; - $('plus').disabled = sec >= 600; + const v = +$('d').value || 0; + setDisabled($('minus'), v <= 0); + setDisabled($('plus'), v >= 600); } } -function applyState(j) { - s = j; - updateBitrateHold(); // feed the capacity peak-hold before anything reads it - const ds = PHASE_TO_STATE[s.phase] || 'off'; - setNumberMode(ds); - // Phase chip: idle vs passthrough vs arming/armed/active. - const phMap = { off: s.ingest_alive ? ['live', 'Live'] : ['off', 'Idle'], arming: ['arming', 'Buffering'], armed: ['armed', 'Armed'], active: ['active', 'Active'] }; - const [cls, txt] = phMap[ds]; - $('ph').className = 'phase ' + cls + (cfg.w.phase.style === 'dot' ? ' asdot' : ''); - $('ph').textContent = txt; $('ph').title = txt; - $('src').className = 'pill ' + (s.ingest_alive ? 'on' : 'bad') + (cfg.w.source.style === 'dot' ? ' asdot' : ''); - $('src-lbl').textContent = s.ingest_alive ? 'Live' : 'Offline'; - $('src').title = s.ingest_alive ? 'Live' : 'Offline'; - // Readout: mirror the dashboard - show the armed target while engaged (or - // the fill while arming) and 0 in passthrough. current_delay_ms includes - // the pipeline's own ~1s transit, which would read as a phantom delay. +// The phase chip glides to its new width while the new word rises in. +function setPhase(cls, text) { + const ph = $('ph'), asDot = cfg.w.phase.style === 'dot'; + setClass(ph, 'phase ' + cls + (asDot ? ' asdot' : '')); + if (ph._text === text) return; + const glide = ph._text !== undefined && !asDot && !ph.hidden && motionOK(); + const from = glide ? ph.offsetWidth : 0; + ph._text = text; ph.title = text; + const word = document.createElement('span'); + word.textContent = text; + ph.replaceChildren(word); + if (!glide) return; + const to = ph.offsetWidth; + if (from !== to) ph.animate([{ width: from + 'px' }, { width: to + 'px' }], { duration: 320, easing: EASE }); + word.animate([{ opacity: 0, transform: 'translateY(6px)', filter: 'blur(2px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }], { duration: 240, easing: EASE }); +} +const PHASE_LOOK = { arming: ['arming', 'Buffering'], armed: ['armed', 'Armed'], active: ['active', 'Active'] }; + +function paintSource(alive) { + setClass($('src'), 'pill ' + (alive ? 'on' : 'bad') + (cfg.w.source.style === 'dot' ? ' asdot' : '')); + setText($('src-lbl'), alive ? 'Live' : 'Offline'); + setTitle($('src'), alive ? 'Live' : 'Offline'); +} + +// Readout: mirror the dashboard - show the armed target while engaged (or +// the fill while arming) and 0 in passthrough. current_delay_ms includes +// the pipeline's own ~1s transit, which would read as a phantom delay. +function paintReadout(ds) { const tgtSec = (s.armed_delay_ms || 0) / 1000, fillSec = (s.buffer_fill_ms || 0) / 1000; const showSec = ds === 'arming' ? Math.min(fillSec, tgtSec) : (ds === 'armed' || ds === 'active') ? tgtSec : 0; const nv = fmt(showSec * 1000); - if (nv !== lastVal) { - $('v').textContent = nv; lastVal = nv; - const cur = $('v').parentElement; cur.classList.add('changing'); setTimeout(() => cur.classList.remove('changing'), 350); - } - // Progress bar (+ optional %). + if (nv === lastVal) return; + lastVal = nv; setText($('v'), nv); + // The pop marks a change you made (armed, activated, cut), not the + // buffer counting up while it fills. + if (ds === 'arming' || !motionOK()) return; + const cur = $('cur'); + cur.classList.add('changing'); clearTimeout(cur._t); + cur._t = setTimeout(() => cur.classList.remove('changing'), 350); +} + +function paintBar(ds) { const target = s.armed_delay_ms || s.buffer_target_ms || 0; - const fill = $('bar'); fill.classList.remove('ready', 'active'); - let pct = 0; - if (ds === 'active') { fill.style.width = '100%'; fill.classList.add('active'); pct = 100; } + let pct = 0, cls = 'fill'; + if (ds === 'active') { pct = 100; cls += ' active'; } else if (target > 0) { pct = Math.min(100, (Math.min(s.buffer_fill_ms, target) / target) * 100); - fill.style.width = pct + '%'; if (pct >= 99) fill.classList.add('ready'); - } else fill.style.width = '0%'; - const pctEl = $('pct'); pctEl.hidden = !cfg.w.bar.pct; if (cfg.w.bar.pct) pctEl.textContent = Math.round(pct) + '%'; - // Egress glance, honouring its per-part options. - const eg = $('w-eg'); - if (s.ingest_alive && cfg.w.egress.on) { - const parts = []; - if (cfg.w.egress.dests) parts.push(`▲${s.destinations_alive || 0}/${s.destinations_total || 0} live`); - if (cfg.w.egress.bitrate) { const rate = fmtRate(s.stats && s.stats.bitrate_kbps); if (rate) parts.push(`${rate} in`); } - if (cfg.w.egress.codec && s.video_codec) parts.push(`${esc(s.video_codec)}${s.multitrack_video ? ' · MT' : ''}`); - if (parts.length) { eg.innerHTML = parts.join('·'); eg.hidden = false; } else eg.hidden = true; - } else eg.hidden = true; - // Hint text: full sentences, or errors-only (encoder offline) if opted. - const tip = !s.ingest_alive ? 'Point your encoder at rtmp://…/live' + if (pct >= 99) cls += ' ready'; + } + const fill = $('bar'), w = pct.toFixed(1) + '%'; + setClass(fill, cls); + if (fill._w !== w) { fill._w = w; fill.style.width = w; } + const pctEl = $('pct'); + setHidden(pctEl, !cfg.w.bar.pct); + if (cfg.w.bar.pct) setText(pctEl, Math.round(pct) + '%'); +} + +// Egress glance, honouring its per-part options. +function paintEgress() { + const eg = $('w-eg'), o = cfg.w.egress, parts = []; + if (s.ingest_alive && o.on) { + if (o.dests) parts.push(`▲${s.destinations_alive || 0}/${s.destinations_total || 0} live`); + if (o.bitrate) { const rate = fmtRate(s.stats && s.stats.bitrate_kbps); if (rate) parts.push(`${rate} in`); } + if (o.codec && s.video_codec) parts.push(`${esc(s.video_codec)}${s.multitrack_video ? ' · MT' : ''}`); + } + setHidden(eg, !parts.length); + if (parts.length) setHtml(eg, parts.join('·')); +} + +// Crash protection: the reconnect screen is holding the destinations. +function paintHold(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. The stream ends once your delay has aired.'); + return; + } + if (!hold) { disarmEndHold(); return; } + 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 + ? `${held} destination${held === 1 ? '' : 's'} on the reconnect screen` + : 'Waiting for OBS'); +} + +// Hint text: full sentences, or errors-only (encoder offline) if opted. +function paintTip(ds, hold) { + const tip = hold ? (hold.reason === 'crash' ? 'Reopen OBS in Normal Mode and start streaming to resume.' : 'Start streaming in OBS to resume.') + : s.tail_ms > 0 ? 'End now cuts it short.' + : !s.ingest_alive ? 'Point your encoder at rtmp://…/live' : ds === 'active' && s.safe_cut_pending ? `Auto-cut in ~${Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000))}s - marked footage still airs.` : ds === 'active' ? `Live ${(s.current_delay_ms / 1000).toFixed(1)}s behind real time.` : ds === 'armed' ? 'Buffer full. Hit Activate to go live with delay.' : ds === 'arming' ? 'Filling buffer… you can cancel any time.' : 'Passthrough - no delay applied.'; - const showTip = cfg.w.tips.on && (!cfg.w.tips.errorsOnly || !s.ingest_alive); - $('w-tip').hidden = !showTip; if (showTip) setTip(tip); - // Cross-dock sync: the state stream carries per-dest enabled/alive, so a - // toggle made anywhere (dashboard or another dock) refreshes the strip - // immediately instead of waiting out the 4 s poll. - const dsig = (s.destinations || []).map(d => `${d.id}:${+d.enabled}:${+d.alive}`).join(); - if (dsig !== _destSig) { _destSig = dsig; if (cfg.w.dest.on) fetchDests(); } + const show = cfg.w.tips.on && (!cfg.w.tips.errorsOnly || !s.ingest_alive); + setHidden($('w-tip'), !show); + if (show) setTip(tip); +} + +function applyState(j) { + s = j; + updateBitrateHold(); // feed the capacity peak-hold before anything reads it + const ds = PHASE_TO_STATE[s.phase] || 'off'; + 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); + paintReadout(ds); + paintBar(ds); + paintEgress(); + paintHold(s.hold); + paintTip(ds, s.hold); + renderDests(); renderStats(); renderCta(); } async function tick() { const r = await fetchJ('/state'); - if (!r.ok || !r.j) { $('src').className = 'pill bad'; $('src-lbl').textContent = 'Offline'; return; } + if (!r.ok || !r.j) { paintSource(false); return; } applyState(r.j); } // ---------------------------------------------------------- destination strip -- -let _destTimer = null; -let destCache = []; // last /destinations payload, for instant re-render on option change -let _destSig = ''; // enabled/alive fingerprint from the state stream (sync trigger) +// The strip is drawn from the state stream, which carries every destination's +// enabled / alive / bitrate / hold, so it follows toggles made anywhere with +// no polling. Only the platform (for colours and letters) comes from +// /destinations, fetched when the set of destinations changes. Rows are kept +// and patched in place, so an open "Turn off?" survives every update. +let destPlatforms = {}; // id -> platform +let destIdsSeen = ''; // destination ids the platforms were fetched for // Brand hues so a glance says WHICH platform is live (user feedback: "color // coded by platform"). Reds/greens tuned for the dark panel; custom/sink @@ -402,74 +558,111 @@ let _destSig = ''; // enabled/alive fingerprint from the state stream (sync t const PLATFORM_COLORS = { twitch: '#9146ff', youtube: '#ff4d4d', kick: '#53fc18', trovo: '#21c26d', restream: '#f27a3f', }; -function brandColor(el, platform) { - const pc = cfg.w.dest.brand && PLATFORM_COLORS[platform]; - if (pc) { el.style.setProperty('--pc', pc); el.classList.add('pc'); } -} -function startDests() { if (_destTimer) return; fetchDests(); _destTimer = setInterval(fetchDests, 4000); } -function stopDests() { if (_destTimer) { clearInterval(_destTimer); _destTimer = null; } } -async function fetchDests() { - if ($('w-dests').hidden) return; +async function fetchDestPlatforms() { const r = await fetchJ('/destinations'); - if (r.ok && Array.isArray(r.j)) { destCache = r.j; renderDests(r.j); } -} -function renderDests(list) { - const box = $('w-dests'); if (box.hidden) return; + if (!r.ok || !Array.isArray(r.j)) return; + destPlatforms = {}; + for (const d of r.j) destPlatforms[d.id] = d.platform || ''; + renderDests(); +} +// off, streaming, on but not streaming yet (connecting, or waiting for the +// delay to fill), on the reconnect screen, or on and waiting for OBS. +function destState(d) { + if (!d.enabled) return 'off'; + if (d.on_hold) return 'held'; + if (d.alive && (d.bitrate_kbps || 0) > 0) return 'alive'; + return s.ingest_alive ? 'wait' : 'idle'; +} +function destColor(el, d) { + const pc = (cfg.w.dest.brand && PLATFORM_COLORS[destPlatforms[d.id]]) || ''; + if (el._pc !== pc) { el._pc = pc; if (pc) el.style.setProperty('--pc', pc); else el.style.removeProperty('--pc'); } + return pc ? ' pc' : ''; +} +function renderDests() { + const box = $('w-dests'); if (box.hidden || !s) return; + const all = s.destinations || []; + const ids = all.map(d => d.id).join(); + if (ids !== destIdsSeen) { destIdsSeen = ids; fetchDestPlatforms(); } const icons = cfg.w.dest.view === 'icons'; // Show every configured destination by default so a disabled one can be // toggled back on. "Active only" is the opt-in filter for a clean look. - const show = cfg.w.dest.active ? list.filter(d => d.enabled || d.alive) : list; - box.className = 'dests' + (icons ? ' icons' : ''); - if (!show.length) { - box.innerHTML = `
${list.length ? 'No active destinations right now.' : 'No destinations yet. Add them in the dashboard.'}
`; - return; + const show = cfg.w.dest.active ? all.filter(d => d.enabled || d.alive) : all; + const layout = (icons ? 'icons:' : 'rows:') + show.map(d => d.id).join() + ':' + all.length; + if (box._layout !== layout) { + box._layout = layout; + box.className = 'dests' + (icons ? ' icons' : ''); + box._items = new Map(); + if (!show.length) { + box.innerHTML = `
${all.length ? 'No active destinations right now.' : 'No destinations yet. Add them in the dashboard.'}
`; + return; + } + box.replaceChildren(...show.map(d => { + const el = icons ? destIcon() : destRow(); + box._items.set(d.id, el); + return el; + })); + } + for (const d of show) { + const el = box._items.get(d.id); + if (icons) patchDestIcon(el, d); else patchDestRow(el, d); } - box.innerHTML = ''; - for (const d of show) box.appendChild(icons ? destIcon(d) : destRow(d)); } -function destRow(d) { +function destRow() { const row = document.createElement('div'); - row.className = 'dest' + (d.alive ? ' alive' : ''); - const rate = cfg.w.dest.stats && d.alive ? fmtRate(d.bitrate_kbps) : ''; - const meta = rate ? `${rate}` : `${esc(d.platform || '')}`; - row.innerHTML = `${esc(d.name || 'Destination')}${meta}`; - brandColor(row, d.platform); - const sw = document.createElement('button'); - sw.className = 'sw' + (d.enabled ? ' on' : ''); - sw.title = d.enabled ? 'Turn off' : 'Turn on'; - sw.onclick = () => onDestToggle(d, row, sw); - row.appendChild(sw); + row.innerHTML = ''; + row._name = row.children[1]; row._meta = row.children[2]; + row._sw = document.createElement('button'); + row._sw.onclick = () => onDestToggle(row); + row.appendChild(row._sw); return row; } +function patchDestRow(row, d) { + row._d = d; + const st = destState(d), platform = destPlatforms[d.id] || ''; + setClass(row, 'dest ' + st + destColor(row, d)); + setText(row._name, d.name || 'Destination'); + const rate = cfg.w.dest.stats && st === 'alive' ? fmtRate(d.bitrate_kbps) : ''; + setClass(row._meta, rate ? 'd-rate' : 'd-plat'); + setText(row._meta, st === 'held' ? 'Holding' : st === 'wait' ? 'Connecting…' : rate || platform); + setClass(row._sw, 'sw' + (d.enabled ? ' on' : '')); + setTitle(row._sw, d.enabled ? 'Turn off' : 'Turn on'); +} // Compact circular badge: platform initial, live-glow ring, hover shows the // name (native title). Tap arms it (confirm), tap again commits. -function destIcon(d) { +function destIcon() { const b = document.createElement('button'); - b.className = 'dico' + (d.enabled ? ' on' : ' off') + (d.alive ? ' alive' : ''); - b.textContent = (d.platform || d.name || '?').slice(0, 1).toUpperCase(); - b.title = `${d.name || 'Destination'} · ${d.enabled ? 'on' : 'off'}`; - brandColor(b, d.platform); - b.onclick = () => onDestIcon(d, b); + b.onclick = () => onDestIcon(b); return b; } -function onDestIcon(d, b) { +function patchDestIcon(b, d) { + b._d = d; + if (b._armed) return; // keep "tap again" on screen until it resolves + const st = destState(d); + const look = st === 'alive' ? ' alive' : st === 'wait' || st === 'held' ? ' wait' : ''; + setClass(b, 'dico ' + (d.enabled ? 'on' : 'off') + look + destColor(b, d)); + setText(b, (destPlatforms[d.id] || d.name || '?').slice(0, 1).toUpperCase()); + setTitle(b, `${d.name || 'Destination'} · ${d.enabled ? 'on' : 'off'}`); +} +function onDestIcon(b) { + const d = b._d; if (!cfg.w.dest.confirm) { commitDest(d, !d.enabled); return; } - if (b._armed) { clearTimeout(b._t); commitDest(d, !d.enabled); return; } // 2nd tap commits; the re-render clears the armed look + if (b._armed) { disarmDestIcon(b); commitDest(d, !d.enabled); return; } // Arm: pulse an amber ring and swap the badge to a check so the "tap again // to confirm" intent is unmistakable, not just a colour change. - b._armed = true; b._letter = b.textContent; b.textContent = '✓'; - b.classList.add('arm'); + b._armed = true; + b.classList.add('arm'); b.textContent = '✓'; b.title = d.enabled ? 'Tap again to turn OFF' : 'Tap again to go LIVE'; - clearTimeout(b._t); - b._t = setTimeout(() => { - b._armed = false; b.classList.remove('arm'); b.textContent = b._letter; - b.title = `${d.name || 'Destination'} · ${d.enabled ? 'on' : 'off'}`; - }, 3000); + b._t = setTimeout(() => disarmDestIcon(b), 3000); +} +function disarmDestIcon(b) { + clearTimeout(b._t); b._armed = false; + patchDestIcon(b, b._d); } // Misclick guard: the row swaps its switch for a Sure? / cancel pair that // auto-dismisses after 3s. Toggling a live platform either way matters, so // both directions are guarded. -function onDestToggle(d, row, sw) { +function onDestToggle(row) { + const d = row._d; if (!cfg.w.dest.confirm) { commitDest(d, !d.enabled); return; } if (row._confirm) return; const next = !d.enabled; @@ -477,11 +670,11 @@ function onDestToggle(d, row, sw) { box.innerHTML = `${next ? 'Turn on?' : 'Turn off?'}`; const yes = document.createElement('button'); yes.className = 'cbtn yes'; yes.innerHTML = '✓'; const no = document.createElement('button'); no.className = 'cbtn no'; no.innerHTML = '×'; - const cancel = () => { clearTimeout(row._ct); row._confirm = false; box.replaceWith(sw); }; + const cancel = () => { clearTimeout(row._ct); row._confirm = false; box.replaceWith(row._sw); }; yes.onclick = () => { cancel(); commitDest(d, next); }; no.onclick = cancel; box.append(yes, no); - sw.replaceWith(box); row._confirm = true; row._ct = setTimeout(cancel, 3000); + row._sw.replaceWith(box); row._confirm = true; row._ct = setTimeout(cancel, 3000); } async function commitDest(d, enabled) { const r = await fetchJ('/destinations/toggle', { @@ -490,7 +683,6 @@ async function commitDest(d, enabled) { }); if (!r.ok) toast(r.j?.error || 'Toggle failed', 'err'); else toast(`${d.name || 'Destination'} ${enabled ? 'on' : 'off'}`, 'ok'); - fetchDests(); } // --------------------------------------------------------- extra widgets ---- @@ -529,7 +721,7 @@ function profileSet(sec) { toast(`Buffer too small for ${sec}s - raise it in the dashboard.`, 'err'); return; } - $('d').value = sec; renderCta(); + $('d').value = sec; fitDelayInput(); renderCta(); const ds = s ? (PHASE_TO_STATE[s.phase] || 'off') : 'off'; if (cfg.w.profiles.arm && ds === 'off' && sec > 0) arm(sec * 1000); } @@ -539,7 +731,7 @@ function profileSet(sec) { function renderStats() { const box = $('w-stats'); if (box.hidden || !s) return; const o = cfg.w.stats, st = s.stats || {}, tiles = o.view === 'tiles'; - box.className = 'stats' + (tiles ? ' tiles' : ''); + setClass(box, 'stats' + (tiles ? ' tiles' : '')); const items = []; if (o.cpu) items.push([(s.cpu_pct || 0).toFixed(0) + '%', 'cpu']); if (o.mem) items.push([Math.round((s.rss_bytes || 0) / 1048576), 'MB']); @@ -550,19 +742,18 @@ function renderStats() { ? `${v}${l}` : `${v} ${l}`).join(''); if (s.backpressure) html += '⚠ buffer lag'; - box.innerHTML = html || 'no metrics selected'; + setHtml(box, html || 'no metrics selected'); } // Server settings mirrored on the dock (Auto behavior + Settings widgets). // Read from /config; each boolean flip is a safe partial POST /config. -let srvCfg = { auto_arm_on_connect: false, auto_activate_when_ready: false, tracing_enabled: true, 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(); } @@ -597,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. @@ -689,7 +863,6 @@ function overlayUrl(slug) { const q = cfg.w.overlays.autohide ? '?autohide=off' : ''; return `${location.origin}/overlay/${encodeURIComponent(slug)}.html${q}`; } -function openOverlay(slug) { window.open(overlayUrl(slug), '_blank'); } async function copyOverlay(slug) { const url = overlayUrl(slug); try { await navigator.clipboard.writeText(url); toast('Overlay URL copied', 'ok'); } @@ -725,7 +898,11 @@ function applyCfg(c) { // Apply the saved drag order by re-appending containers in sequence. const wrap = document.querySelector('.wrap'); for (const id of c.order) { const el = $(WEL[id]); if (el) wrap.appendChild(el); } - if (on('dest')) { startDests(); if (destCache.length) renderDests(destCache); } else stopDests(); + // The crash hold card takes the place of the delay controls it stands in + // for. A dock without them (destinations only, overlays) shows it on top, + // so every dock can end the stream without losing its own purpose. + const holdAnchor = on('number') ? $('w-screen') : on('activate') ? $('w-cta') : wrap.firstElementChild; + if (holdAnchor !== $('w-hold')) wrap.insertBefore($('w-hold'), holdAnchor); if (on('profiles')) { fetchProfiles(); if (!bufferMB) fetchBufferMB(); } // capacity gate needs buffer size if (on('behavior') || on('settings')) { renderBehavior(); renderSettings(); fetchServerCfg(); } if (on('overlays')) fetchOverlays(); @@ -964,13 +1141,14 @@ function newDock(addChip) { // -------------------------------------------------------------------- boot -- -$('d').addEventListener('input', renderCta); +$('d').addEventListener('input', () => { fitDelayInput(); renderCta(); }); // Enter in the delay field = press the main button (arm). Keyboard-first flow. $('d').addEventListener('keydown', e => { if (e.key === 'Enter') mainClick(); }); // Staggered first paint (see body.boot CSS); the class comes off after one // pass so later saves/reorders never replay the entrance. document.body.classList.add('boot'); setTimeout(() => document.body.classList.remove('boot'), 800); +fitDelayInput(); applyCfg(cfg); // paint defaults immediately loadCfg(); // then hydrate from url / localStorage / server @@ -1007,7 +1185,7 @@ function refreshAux() { if (cfg.w.profiles.on) { fetchProfiles(); fetchBufferMB(); } // buffer size may have changed in the dashboard if (cfg.w.overlays.on) fetchOverlays(); if (cfg.w.behavior.on || cfg.w.settings.on) fetchServerCfg(); - if (cfg.w.dest.on) fetchDests(); + if (cfg.w.dest.on) fetchDestPlatforms(); } window.addEventListener('focus', () => { if (!_es) tick(); refreshAux(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshAux(); }); diff --git a/web/index.html b/web/index.html index 08ef58c..13a189a 100644 --- a/web/index.html +++ b/web/index.html @@ -106,18 +106,18 @@ /* Unified inline content links: accent colour + animated underline, so every in-body link (help text, tour, notes) matches the app instead of falling back to the default browser blue. */ -.ic-link,.muted a,.tour-body a,.dest-form-help a,#dest-form-format-help a,#dest-form-twitch-native-note a{ +.muted a,.tour-body a,#dest-form-format-help a,#dest-form-twitch-native-note a{ color:var(--accent);text-decoration:none;font-weight:600; border-bottom:1px solid color-mix(in oklch,var(--accent) 32%,transparent); transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} -.ic-link:hover,.muted a:hover,.tour-body a:hover,#dest-form-format-help a:hover,#dest-form-twitch-native-note a:hover{ +.muted a:hover,.tour-body a:hover,#dest-form-format-help a:hover,#dest-form-twitch-native-note a:hover{ color:var(--fg);border-color:var(--fg)} -.ic-link:visited,.muted a:visited,.tour-body a:visited{color:var(--accent)} +.muted a:visited,.tour-body a:visited{color:var(--accent)} .ic-brand-dot{display:inline-block;width:5px;height:5px;border-radius:999px;background:var(--fg-4);margin:0 6px 2px} .ic-header-right{display:flex;gap:6px;align-items:center;margin-left:auto;flex-wrap:wrap;justify-content:flex-end} .ic-pill{display:inline-flex;align-items:center;gap:8px;padding:6px 11px;border-radius:999px; background:var(--surface-3);border:1px solid var(--line);font-size:12px;color:var(--fg-2); - transition:.2s var(--ease-out)} + transition:border-color .2s var(--ease-out),background-color .2s var(--ease-out),color .2s var(--ease-out)} .ic-pill:hover{border-color:var(--line-2)} .ic-pill .dot{width:7px;height:7px;border-radius:999px;background:var(--idle)} .ic-pill.ok .dot{background:var(--live);box-shadow:0 0 0 4px color-mix(in oklch,var(--live) 15%,transparent)} @@ -182,15 +182,24 @@ /* - Card / labels / buttons / inputs (atoms) ------─ */ .ic-card{background:linear-gradient(180deg,var(--surface-2),var(--surface)); border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow-1)} -.ic-label{font-size:10.5px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-3)} +.ic-label{font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase; + color:color-mix(in oklch,var(--fg-2) 45%,var(--fg-3))} .ic-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:8px; padding:10px 16px;border-radius:10px;border:1px solid var(--line-2);background:var(--surface-3); color:var(--fg);font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap; transition:transform .15s var(--ease-out),background .15s var(--ease-out),border-color .15s var(--ease-out), box-shadow .15s var(--ease-out)} -.ic-btn:hover{background:color-mix(in oklch,#fff 4%,var(--surface-3));border-color:rgba(255,255,255,.16)} -.ic-btn:active{transform:translateY(1px)} +.ic-btn:hover:not(:disabled,.ic-btn-disabled){background:color-mix(in oklch,var(--tint) 7%,var(--surface-3)); + border-color:color-mix(in oklch,var(--tint) 38%,rgba(255,255,255,.16)); + box-shadow:0 8px 20px -12px color-mix(in oklch,var(--tint) 70%,transparent);transform:translateY(-1px)} +.ic-btn:active{transform:scale(.96);transition-duration:.08s} +/* Cards ease in, staggered, when their tab opens (see staggerIn). No end + opacity: each card fades to its own, so a switched-off one is dim from + the start instead of bright until the animation ends. */ +.stagger-in{animation:staggerIn .34s var(--ease-out) both;animation-delay:var(--stagger,0ms)} +@keyframes staggerIn{from{opacity:0;transform:translateY(8px);filter:blur(3px)}to{transform:none;filter:none}} +@media (prefers-reduced-motion: reduce){.stagger-in,.dcard.went-live{animation:none}} /* Copy confirmation: the button turns success-green with a check that pops in (scale + blur per motion guidelines). Applied by flashCopied(). */ .ic-btn.is-copied{color:var(--live) !important; @@ -200,10 +209,18 @@ animation:cpIcoIn .3s cubic-bezier(0.2,0,0,1)} @keyframes cpIcoIn{from{opacity:0;transform:scale(.25);filter:blur(4px)} to{opacity:1;transform:none;filter:blur(0)}} -.ic-btn-primary{background:var(--accent);color:var(--accent-ink);border-color:transparent; - box-shadow:0 0 0 1px color-mix(in oklch,var(--accent) 60%,transparent),0 6px 24px var(--accent-glow); - font-weight:600} -.ic-btn-primary:hover{background:color-mix(in oklch,#fff 6%,var(--accent))} +.ic-btn-primary{background:linear-gradient(180deg,color-mix(in oklch,#fff 14%,var(--accent)),var(--accent)); + color:var(--accent-ink);border-color:transparent; + box-shadow:inset 0 1px 0 rgba(255,255,255,.4),0 0 0 1px color-mix(in oklch,var(--accent) 60%,transparent), + 0 8px 26px -6px var(--accent-glow);font-weight:600} +/* Matches the generic .ic-btn hover's specificity, which otherwise wins and + paints dark accent-ink text on the dark surface fill. */ +.ic-btn-primary:hover:not(:disabled,.ic-btn-disabled){border-color:transparent; + background:linear-gradient(180deg,color-mix(in oklch,#fff 22%,var(--accent)),color-mix(in oklch,#fff 6%,var(--accent))); + box-shadow:inset 0 1px 0 rgba(255,255,255,.45),0 0 0 1px color-mix(in oklch,var(--accent) 70%,transparent), + 0 12px 32px -6px color-mix(in oklch,var(--accent) 50%,transparent)} +/* Surfaces catch light on their top edge, like the hero. */ +.ic-card,.pane-card,.tabs,.dcard,.stat-card,.metric-mini,.sys-section,.obs-section{border-top-color:rgba(255,255,255,.11)} /* Disabled state - has to read as "available but inert" even when parented to a dark card. Previous styling (surface-3 at 0.55 alpha) blended into card backgrounds. Now: opaque surface-3 fill + a @@ -212,41 +229,79 @@ .ic-btn-primary:disabled,.ic-btn-disabled{opacity:1;cursor:not-allowed;box-shadow:none; background:var(--surface-3) !important;color:var(--fg-3) !important; border-color:var(--line-2) !important} -.ic-btn-danger{background:color-mix(in oklch,var(--danger) 18%,var(--surface-3));color:var(--danger); - border-color:color-mix(in oklch,var(--danger) 35%,transparent);font-weight:600} -.ic-btn-danger:hover{background:color-mix(in oklch,var(--danger) 25%,var(--surface-3))} +.ic-btn-danger{background:color-mix(in oklch,var(--danger) 18%,var(--bg-2));color:var(--danger); + border-color:color-mix(in oklch,var(--danger) 50%,var(--line));font-weight:600} +.ic-btn-danger:hover:not(:disabled){background:color-mix(in oklch,var(--danger) 25%,var(--surface-3)); + border-color:color-mix(in oklch,var(--danger) 55%,transparent); + box-shadow:0 8px 22px -12px color-mix(in oklch,var(--danger) 80%,transparent)} .ic-btn-ghost{background:transparent;border-color:var(--line);color:var(--fg-2)} -.ic-btn-ghost:hover{background:var(--surface-3);color:var(--fg)} +.ic-btn-ghost:hover{background:color-mix(in oklch,var(--tint) 8%,var(--surface-3));color:var(--fg)} .ic-btn-tiny{appearance:none;background:transparent;border:0;width:28px;height:28px;border-radius:6px; display:inline-flex;align-items:center;justify-content:center;color:var(--fg-3);cursor:pointer; transition:background .15s var(--ease-out),color .15s var(--ease-out);flex:0 0 auto} -.ic-btn-tiny:hover{background:var(--surface-3);color:var(--fg)} +.ic-btn-tiny:hover{background:color-mix(in oklch,var(--tint) 12%,var(--surface-3));color:var(--fg)} .ic-btn.big{padding:12px 22px;font-size:14px} -.ic-input{width:100%;padding:10px 12px;border-radius:10px;background:var(--bg-2); +.ic-input{width:100%;padding:10px 12px;border-radius:10px;background-color:var(--bg-2); border:1px solid var(--line);color:var(--fg);font-size:13px;outline:none; - transition:border-color .15s var(--ease-out),background .15s var(--ease-out)} -.ic-input:hover{border-color:var(--line-2)} -.ic-input:focus{border-color:color-mix(in oklch,var(--accent) 70%,transparent);background:var(--surface)} -.ic-input::placeholder{color:var(--fg-4)} + transition:border-color .2s var(--ease-out),background-color .2s var(--ease-out),box-shadow .25s var(--ease-out)} +.ic-input:hover{border-color:color-mix(in oklch,var(--tint) 32%,var(--line-2)); + background-color:color-mix(in oklch,var(--tint) 3%,var(--bg-2))} +.ic-input:focus{border-color:color-mix(in oklch,var(--tint) 80%,transparent);background-color:var(--surface); + box-shadow:0 0 0 3px color-mix(in oklch,var(--tint) 20%,transparent), + 0 8px 22px -14px color-mix(in oklch,var(--tint) 80%,transparent)} +.ic-input::placeholder{color:var(--fg-4);transition:opacity .2s var(--ease-out)} +.ic-input:focus::placeholder{opacity:.5} +.ic-input.field-error:focus{border-color:var(--danger);box-shadow:0 0 0 3px color-mix(in oklch,var(--danger) 22%,transparent)} +label{transition:color .2s var(--ease-out)} +.dff:focus-within>label,.form-row>div:focus-within>label,.st-field:focus-within>label, +label:has(+ .ic-input:focus),label:has(+ select:focus){color:var(--tint)} +/* Dropdowns: our own chevron everywhere; in browsers with customizable + selects (Chrome/Edge 135+) the list itself becomes a styled popup that + fades and slides in. Older engines (the OBS dock's CEF) keep the native + list, which still works the same. */ +select.ic-input{appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:34px; + background-image:url("data:image/svg+xml;utf8,");background-repeat:no-repeat; + background-position:right 12px center;background-size:12px} +@supports (appearance:base-select){ + select.ic-input,select.ic-input::picker(select){appearance:base-select} + select.ic-input{display:flex;align-items:center;gap:8px;background-image:none;padding-right:12px} + select.ic-input::picker-icon{content:'';flex:none;width:12px;height:12px;margin-left:auto; + background:var(--fg-3);-webkit-mask:url("data:image/svg+xml;utf8,") center/contain no-repeat;mask:url("data:image/svg+xml;utf8,") center/contain no-repeat; + transition:rotate .3s var(--ease-spring),background-color .2s var(--ease-out)} + select.ic-input:hover::picker-icon{background:var(--fg-2)} + select.ic-input:open::picker-icon{rotate:180deg;background:var(--tint)} + select.ic-input::picker(select){margin-block:6px;padding:5px;border-radius:12px;color:var(--fg); + background:var(--surface-2);border:1px solid color-mix(in oklch,var(--tint) 30%,var(--line-2)); + box-shadow:0 1px 0 rgba(255,255,255,.06) inset,0 22px 48px -14px rgba(0,0,0,.7), + 0 10px 30px -18px color-mix(in oklch,var(--tint) 70%,transparent); + opacity:0;transform:translateY(-6px) scale(.98);transform-origin:top center; + transition:opacity .18s var(--ease-out),transform .24s var(--ease-out), + overlay .24s allow-discrete,display .24s allow-discrete} + select.ic-input:open::picker(select){opacity:1;transform:none} + @starting-style{select.ic-input:open::picker(select){opacity:0;transform:translateY(-6px) scale(.98)}} + select.ic-input option{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px; + font-size:13px;color:var(--fg-2);cursor:pointer; + transition:background-color .15s var(--ease-out),color .15s var(--ease-out)} + select.ic-input option:hover,select.ic-input option:focus-visible{outline:none;color:var(--fg); + background:color-mix(in oklch,var(--tint) 14%,transparent)} + select.ic-input option:checked{color:var(--fg);font-weight:600} + select.ic-input option::checkmark{order:1;margin-left:auto;color:var(--tint)} + select.ic-input option:disabled{color:var(--fg-4);cursor:not-allowed;background:none} + /* The first rows ease in one after another as the list opens. */ + select.ic-input:open option{animation:optionIn .26s var(--ease-out) both; + animation-delay:calc(min(sibling-index(),8) * 16ms)} +} +@keyframes optionIn{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion: reduce){ + .ic-btn:hover{transform:none} + select.ic-input::picker(select){transform:none} + select.ic-input:open option{animation:none} +} .ic-input.field-error{border-color:var(--danger);background:color-mix(in oklch,var(--danger) 5%,var(--bg-2)); animation:fieldShake .35s cubic-bezier(.36,.07,.19,.97)} @keyframes fieldShake{10%,90%{transform:translateX(-1px)} 20%,80%{transform:translateX(2px)} 30%,50%,70%{transform:translateX(-3px)} 40%,60%{transform:translateX(3px)}} -.ic-switch{appearance:none;border:0;width:32px;height:18px;background:var(--surface-3); - border:1px solid var(--line);border-radius:999px;position:relative;cursor:pointer; - transition:background .2s var(--ease-out),border-color .2s var(--ease-out);flex:0 0 auto} -.ic-switch>span{position:absolute;top:1px;left:1px;width:14px;height:14px;border-radius:999px; - background:var(--fg-3);transition:transform .2s var(--ease-spring),background .2s var(--ease-out); - display:block} -.ic-switch.on{background:color-mix(in oklch,var(--accent) 75%,transparent);border-color:transparent} -.ic-switch.on>span{transform:translateX(14px);background:#fff} - -.ic-check{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px; - border-radius:5px;border:1px solid var(--line-2);background:var(--bg-2); - color:var(--accent-ink);transition:background .15s var(--ease-out),border-color .15s var(--ease-out);cursor:pointer} -.ic-check.on{background:var(--accent);border-color:transparent} - /* Toast */ /* Above every overlay (modals 1000, tour 300, studio 400) so a toast fired while the destination modal is open isn't hidden behind it. */ @@ -260,11 +315,10 @@ .ic-toast.err .ic-toast-icon{background:color-mix(in oklch,var(--danger) 22%,transparent);color:var(--danger)} .ic-toast.ok .ic-toast-icon{background:color-mix(in oklch,var(--live) 22%,transparent);color:var(--live)} @keyframes toast-in{from{transform:translateY(8px);opacity:0}to{transform:translateY(0);opacity:1}} -.ic-toast.leaving{opacity:0;transform:translateX(12px);transition:.25s var(--ease-out)} +.ic-toast.leaving{opacity:0;transform:translateX(12px);transition:opacity .25s var(--ease-out),transform .25s var(--ease-out)} /* - Wizard (when !configured) --------─ */ .wizard-card{width:min(880px,92vw);max-width:880px;margin:40px auto;padding:38px 44px;display:flex;flex-direction:column;gap:18px} -.wizard-step-sub{color:var(--fg-3);font-size:12px;margin:0 0 6px} /* Cards size to their own content (align-items:start) instead of stretching to the taller one - no dead space padding out the shorter card. */ /* Single column: Register is the recommended primary path; Custom is the @@ -274,7 +328,8 @@ letter-spacing:.16em;text-transform:uppercase;color:var(--fg-4)} .obs-or::before,.obs-or::after{content:"";flex:1;height:1px;background:var(--line)} .wizard-obs-after{margin:0;font-size:12px;color:var(--fg-3);line-height:1.5} -.wizard-obs-card{padding:20px;border:1px solid var(--line);border-radius:12px;background:var(--bg-2);display:flex;flex-direction:column;gap:12px} +.wizard-obs-card{padding:20px;border:1px solid var(--line);border-radius:12px;background:var(--bg-2);display:flex;flex-direction:column;gap:12px; + transition:border-color .18s var(--ease-out),background .18s var(--ease-out),box-shadow .18s var(--ease-out)} .wizard-obs-title{font-size:13.5px;font-weight:600;color:var(--fg);display:flex;justify-content:space-between;align-items:center;gap:8px;line-height:1.3} .wizard-obs-badge{flex:0 0 auto;font-size:9.5px;text-transform:uppercase;letter-spacing:1.2px;color:var(--accent);background:color-mix(in oklch,var(--accent) 14%,var(--surface-3));padding:3px 8px;border-radius:999px} .wizard-obs-body{margin:0;font-size:12.5px;color:var(--fg-3);line-height:1.55} @@ -314,7 +369,6 @@ @keyframes obsNudge{0%,100%{transform:none}20%{transform:translateX(-4px)} 40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(2px)}} /* Method cards: recommended one wears a soft accent frame; both lift on hover. */ -.wizard-obs-card{transition:border-color .18s var(--ease-out),background .18s var(--ease-out),box-shadow .18s var(--ease-out)} .wizard-obs-card:hover{border-color:var(--line-2)} .wizard-obs-card.obs-recommended{border-color:color-mix(in oklch,var(--accent) 30%,var(--line)); background:linear-gradient(180deg,color-mix(in oklch,var(--accent) 7%,var(--bg-2)),var(--bg-2)); @@ -339,10 +393,6 @@ driven by [data-state] (idle -> waiting -> ok). */ .wiz-obs-verify{margin-top:14px;padding:14px 16px;border-radius:12px;background:var(--surface-2); border:1px solid var(--line);transition:border-color .25s var(--ease-out),background .25s var(--ease-out)} -.wov-head{display:flex;align-items:center;gap:8px} -.wov-title{font-weight:600;font-size:13.5px} -.wov-optional{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--fg-4); - border:1px solid var(--line-2);border-radius:999px;padding:1px 7px} .wov-sub{margin:6px 0 12px;font-size:12px;color:var(--fg-3);line-height:1.5;text-wrap:pretty} .wov-body>*{display:none} .wiz-obs-verify[data-state="idle"] .wov-start{display:inline-flex} @@ -361,7 +411,6 @@ background:color-mix(in oklch,var(--live) 8%,var(--surface-2))} .wizard-obs-status{margin:4px 0 0;font-size:12px;line-height:1.5;color:var(--fg-3)} .wizard-obs-status:empty{display:none} -.wizard-obs-tip{margin:0;font-size:11px;color:var(--fg-4)} /* Progressive disclosure primitive - `
` with a `` that reads as an inline link, plus a subtle slide-down on open. Used to keep wizard / OBS / platform-tip @@ -420,8 +469,6 @@ display:block - stacking its checkbox above ALL-CAPS body text. */ .wizard-card label:not(.wiz-feature-toggle){display:block;margin-top:8px;font-size:11px;color:var(--fg-3); text-transform:uppercase;letter-spacing:.12em;font-weight:600} -.wizard-row{display:flex;gap:8px;margin-top:8px} -.wizard-row .ic-btn{flex:1} /* Visual platform picker (Step 2). Tiles reuse the exact per-platform accent (--dc) the destination cards use, so the choice you make here @@ -589,12 +636,12 @@ #w-msg .ok{color:var(--live)} /* - Hero ------------------- */ -.hero-card{padding:0;overflow:hidden} -.hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:stretch;position:relative} +.hero-card{padding:0;overflow:hidden;position:relative} +.hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:stretch;position:relative;isolation:isolate} @media (max-width:880px){.hero{grid-template-columns:1fr}} .hero-readout{display:flex;flex-direction:column;gap:14px;padding:28px 32px;min-width:0; - border-right:1px solid var(--line);position:relative;overflow:hidden} + border-right:1px solid color-mix(in oklch,var(--line) 70%,transparent);position:relative;overflow:hidden} @media (max-width:880px){.hero-readout{border-right:0;border-bottom:1px solid var(--line)}} /* Registered custom properties so the aurora COLORS interpolate on a state change. Without @property, swapping a var() consumed inside a @@ -603,49 +650,70 @@ @property (old CEF) simply fall back to the old hard swap. */ @property --aurora-a{syntax:'';inherits:true;initial-value:#5a7a8a} @property --aurora-b{syntax:'';inherits:true;initial-value:#5a7a8a} -/* The vars live (and transition) on the readout; the ::before/::after - blobs inherit the interpolating value and repaint every frame, so a - state flip reads as one continuous color sweep instead of a cut. */ -.hero-readout{transition:--aurora-a 1.1s var(--ease-out),--aurora-b 1.1s var(--ease-out)} -.hero-readout::before,.hero-readout::after{content:'';position:absolute;pointer-events:none; - border-radius:50%;filter:blur(60px);opacity:.22;z-index:0; - transition:opacity .9s var(--ease-out)} -/* The drift animations are deliberately IDENTICAL in every state and - never overridden per-state: changing animation-duration mid-flight - remaps elapsed time to a different progress fraction, teleporting the - blobs on every arming enter/exit (the old behaviour). State "energy" - is conveyed by the separate .aurora-pulse layer instead. */ -.hero-readout::before{width:360px;height:360px;left:-80px;top:-80px; - background:radial-gradient(circle at 30% 30%,var(--aurora-a,var(--accent)),transparent 70%); - animation:aurora-drift-a 14s ease-in-out infinite alternate} -.hero-readout::after{width:320px;height:320px;right:-60px;bottom:-100px; - background:radial-gradient(circle at 70% 70%,var(--aurora-b,var(--accent)),transparent 70%); - animation:aurora-drift-b 18s ease-in-out infinite alternate} -.hero-readout[data-state="off"] {--aurora-a:oklch(0.55 0.04 230);--aurora-b:oklch(0.55 0.04 250)} -.hero-readout[data-state="arming"]{--aurora-a:var(--warn);--aurora-b:oklch(0.65 0.18 35)} -.hero-readout[data-state="armed"] {--aurora-a:var(--accent);--aurora-b:oklch(0.78 0.13 200)} -.hero-readout[data-state="active"]{--aurora-a:var(--live);--aurora-b:oklch(0.75 0.16 165)} -.hero-readout[data-state="active"]::before,.hero-readout[data-state="active"]::after{opacity:.32} -.hero-readout[data-state="arming"]::before,.hero-readout[data-state="arming"]::after{opacity:.30} -@keyframes aurora-drift-a{0%{transform:translate(0,0) scale(1)} 100%{transform:translate(80px,40px) scale(1.15)}} -@keyframes aurora-drift-b{0%{transform:translate(0,0) scale(1.1)} 100%{transform:translate(-60px,-40px) scale(.9)}} +@property --aurora-glow{syntax:'';inherits:true;initial-value:.16} +/* The stream state lives on (renderHero sets data-stream-state), so + the hero's aurora and the page's ambient light share one color and one + 1.1 s sweep on every state change. --tint (hovers) takes one clean hue + per state: blending the accent with the state colour made a third hue, + so cyan and the arming amber hovered green. */ +html{transition:--aurora-a 1.1s var(--ease-out),--aurora-b 1.1s var(--ease-out),--aurora-glow 1.1s var(--ease-out); + --tint:var(--accent)} +html[data-stream-state="off"]{--aurora-a:oklch(0.55 0.04 230);--aurora-b:oklch(0.5 0.05 260);--aurora-glow:.16} +html[data-stream-state="arming"]{--aurora-a:var(--warn);--aurora-b:oklch(0.65 0.18 35);--aurora-glow:.34;--tint:var(--warn)} +html[data-stream-state="armed"]{--aurora-a:var(--accent);--aurora-b:oklch(0.62 0.16 250);--aurora-glow:.32} +html[data-stream-state="active"]{--aurora-a:var(--live);--aurora-b:oklch(0.7 0.15 185);--aurora-glow:.36;--tint:var(--live)} +/* Ambient light at the top of the page in the state's color, so the whole + dashboard (not just one box) says where the stream is. Fixed and + pointer-less, so scrolling never repaints it. */ +body{background:transparent;position:relative} +body::before{content:'';position:fixed;left:0;right:0;top:0;height:560px;pointer-events:none;z-index:-1; + background:radial-gradient(70% 90% at 50% -25%,color-mix(in oklch,var(--aurora-a) calc(var(--aurora-glow) * 40%),transparent),transparent 72%)} +/* Hero aurora: five pools of light behind both halves of the hero, each on + a slow circular orbit of its own size, speed and direction, and gently + swelling, so the colours keep folding into each other without repeating. + Near pools are smaller and sharper, far ones bigger and softer. Orbits and + swells run the same in every state (retiming one mid-flight would teleport + it): a state change moves only colour and brightness, over the 1.1 s sweep. + Everything that moves animates transform alone, so the loop costs the + compositor, not layout or paint. While buffering, the brightness follows + the buffer fill (--fill, set by renderHero): the light grows as the delay + gets ready. */ +.hero-aurora{position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden;--fill:1} +.aurora-flow{position:absolute;inset:0;opacity:calc(var(--aurora-glow) * 1.3 * (.35 + var(--fill) * .65)); + transition:opacity .45s linear} +.aurora-flow b{position:absolute;left:var(--x);top:var(--y);width:0;height:0;mix-blend-mode:screen;will-change:transform; + animation:aurora-orbit var(--t) linear infinite;animation-direction:var(--dir,normal)} +.aurora-flow i{position:absolute;left:var(--r);width:var(--s);height:var(--s);margin:calc(var(--s) / -2) 0 0 calc(var(--s) / -2); + border-radius:50%;filter:blur(var(--blur));background:radial-gradient(circle,var(--c),transparent 70%);will-change:transform; + animation:aurora-swell calc(var(--t) / 3) ease-in-out infinite alternate} +.aurora-flow b:nth-child(1){--x:18%;--y:30%;--t:34s;--r:70px;--s:520px;--blur:70px;--c:var(--aurora-a)} +.aurora-flow b:nth-child(2){--x:40%;--y:85%;--t:46s;--r:120px;--s:460px;--blur:64px;--c:var(--aurora-b);--dir:reverse} +.aurora-flow b:nth-child(3){--x:8%;--y:80%;--t:28s;--r:50px;--s:300px;--blur:44px; + --c:color-mix(in oklch,var(--aurora-a) 55%,var(--accent))} +.aurora-flow b:nth-child(4){--x:78%;--y:15%;--t:52s;--r:90px;--s:420px;--blur:66px;--dir:reverse; + --c:color-mix(in oklch,var(--aurora-a) 50%,var(--aurora-b))} +.aurora-flow b:nth-child(5){--x:32%;--y:45%;--t:24s;--r:40px;--s:240px;--blur:40px;--c:var(--aurora-a)} +@keyframes aurora-orbit{to{transform:rotate(360deg)}} +@keyframes aurora-swell{0%{transform:scale(.88)}100%{transform:scale(1.12)}} +@media (prefers-reduced-motion: reduce){.aurora-flow b,.aurora-flow i{animation:none}} +/* Film grain over the lights: big blurred gradients band into visible + steps on 8-bit screens; a faint noise dithers them smooth. */ +.hero-aurora::after{content:'';position:absolute;inset:0;opacity:.05;mix-blend-mode:overlay; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} +/* A soft bloom from behind the number whenever the state changes. */ +.aurora-bloom{position:absolute;left:4%;top:-10%;width:60%;height:120%;border-radius:50%;opacity:0; + background:radial-gradient(circle,color-mix(in oklch,var(--aurora-a) 70%,transparent),transparent 65%);filter:blur(30px)} +.aurora-bloom.bloom{animation:aurora-bloom 1.3s var(--ease-out)} +@keyframes aurora-bloom{0%{opacity:.55;transform:scale(.55)}100%{opacity:0;transform:scale(1.25)}} +/* Lit top edge in the state's color. */ +.hero-card::after{content:'';position:absolute;top:0;left:8%;right:8%;height:1px;pointer-events:none;z-index:2; + background:linear-gradient(90deg,transparent,color-mix(in oklch,var(--aurora-a) 85%,transparent),transparent); + opacity:calc(.35 + var(--aurora-glow))} +.hero-readout,.hero-control{position:relative;z-index:1} .hero-readout>*{position:relative;z-index:1} -/* Breathing layer: the "buffer is filling" heartbeat. Fades in/out via - the opacity transition (smooth entry/exit even though its keyframes - restart), and only animates transform - opacity stays owned by the - transition so the two never fight. Sits under the content like the - blobs; the `.hero-readout>*` z-index lift above is overridden here. */ -.hero-readout>.aurora-pulse{position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0; - background:radial-gradient(70% 90% at 50% 115%,var(--aurora-a,var(--accent)),transparent 72%); - filter:blur(46px);transform-origin:50% 100%; - transition:opacity .9s var(--ease-out)} -.hero-readout[data-state="arming"]>.aurora-pulse{opacity:.26; - animation:aurora-breathe 2.4s ease-in-out infinite} -@keyframes aurora-breathe{0%,100%{transform:scaleY(1)}50%{transform:scaleY(1.18)}} .hero-control{display:flex;flex-direction:column;gap:14px;padding:28px 32px;min-width:0; - justify-content:space-between; - background:radial-gradient(400px 200px at 80% 20%,color-mix(in oklch,var(--accent) 4%,transparent),transparent 70%)} + justify-content:space-between} .hero-status-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .state-pill{display:inline-flex;align-items:center;gap:8px;padding:5px 11px;border-radius:999px; @@ -656,6 +724,14 @@ the consuming properties (not the --sc var itself) interpolate, shadowing the aurora's 1.1 s color sweep at a snappier pace. */ transition:color .8s var(--ease-out),background .8s var(--ease-out),border-color .8s var(--ease-out)} +/* The words glide on a state change (setStatePill): the old pair lifts out + over the new one while the pill eases to its new width, so the words move + with the colour sweep instead of snapping ahead of it. */ +.state-pill{position:relative;overflow:hidden;white-space:nowrap} +.state-words{display:inline-flex;align-items:center;gap:8px} +.state-words.leaving{position:absolute;top:50%;translate:0 -50%} +.state-label{font-weight:600;letter-spacing:.08em;font-size:11px} +.state-desc{color:var(--fg-3);font-size:11px} .state-pill .state-dot{width:7px;height:7px;border-radius:999px;background:var(--sc,var(--idle)); transition:background .8s var(--ease-out)} .state-pill .state-dot.pulse{animation:hpulse 1.6s cubic-bezier(.2,.7,.2,1) infinite} @@ -671,9 +747,9 @@ number and the glow change hue together instead of the number snapping a second ahead of the background. */ font-variant-numeric:tabular-nums;transition:color 1.1s var(--ease-out)} -.hero-delay-num.d-off {color:var(--fg-3)} +.hero-delay-num.d-off{color:var(--fg-3)} .hero-delay-num.d-arming{color:var(--warn)} -.hero-delay-num.d-armed {color:var(--accent)} +.hero-delay-num.d-armed{color:var(--accent)} .hero-delay-num.d-active{color:var(--live)} .hero-delay-unit{font-size:32px;color:var(--fg-3);font-weight:400} .hero-delay-of{font-size:22px;color:var(--fg-3);margin-left:6px} @@ -705,6 +781,9 @@ .cp-head{display:flex;align-items:baseline;justify-content:space-between} .cp-cap{color:var(--fg-4);font-size:11px} +/* The range lights up when a stepper or the field runs into it. */ +.cp-cap.flash{animation:cap-flash .8s var(--ease-out)} +@keyframes cap-flash{0%,35%{color:var(--warn)}100%{color:var(--fg-4)}} .cp-cap-warn{margin-top:8px;padding:8px 10px;border:1px solid var(--danger);border-radius:6px;background:color-mix(in oklch,var(--danger) 8%,var(--bg-2));color:var(--fg-2);font-size:11.5px;line-height:1.45;animation:softDrop .2s var(--ease-out)} .cp-cap-warn b{color:var(--danger);font-weight:600} /* Shared soft entrance for elements that appear on state change (capacity @@ -713,17 +792,33 @@ .cp-input.field-error,.cp-input-row.field-error{border-color:var(--danger);color:var(--danger)} .cp-input-row{display:flex;align-items:center;gap:6px;background:var(--bg-2); border:1px solid var(--line);border-radius:10px;padding:4px} -.cp-input-row:focus-within{border-color:color-mix(in oklch,var(--accent) 60%,transparent);background:var(--bg)} +.cp-input-row:focus-within{border-color:color-mix(in oklch,var(--tint) 75%,transparent);background:var(--bg); + box-shadow:0 0 0 3px color-mix(in oklch,var(--tint) 18%,transparent)} .cp-step{appearance:none;background:var(--surface-3);border:0;width:36px;height:36px;border-radius:7px; color:var(--fg-2);cursor:pointer;display:inline-flex;align-items:center;justify-content:center; transition:background .15s var(--ease-out),color .15s var(--ease-out),transform .15s var(--ease-out);flex:0 0 auto;font-size:18px;line-height:1} .cp-step:hover:not(:disabled){background:color-mix(in oklch,var(--accent) 18%,var(--surface-3));color:var(--fg)} .cp-step:active:not(:disabled){transform:scale(.94)} .cp-step:disabled{opacity:.4;cursor:not-allowed} +/* A stepper pushed past 0 or 600 flushes red for a beat. */ +.cp-step.deny{animation:step-deny .45s var(--ease-out)} +@keyframes step-deny{0%,30%{background:color-mix(in oklch,var(--danger) 32%,var(--surface-3));color:var(--fg)}} .cp-input{flex:1;appearance:none;-moz-appearance:textfield;background:transparent;border:0;outline:0; - color:var(--fg);font-size:22px;font-weight:500;text-align:center;padding:0; + color:var(--fg);font-size:22px;font-weight:500;text-align:center;padding:0;line-height:1.25; font-variant-numeric:tabular-nums;letter-spacing:-.01em;min-width:0; font-family:'JetBrains Mono',monospace} +/* Rolling digits: an overlay with the input's exact metrics plays the roll, + then hands back to the real field (see rollDelayInput). */ +.cp-input-wrap{position:relative;flex:1;min-width:0;display:flex} +.cp-input-wrap.rolling .cp-input,.cp-input-wrap.rolling .cp-input::placeholder{color:transparent} +.cp-roll{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none; + color:var(--fg);font:500 22px/1.25 'JetBrains Mono',monospace;font-variant-numeric:tabular-nums} +.cp-roll[hidden]{display:none} +/* One column per character; the input's -.01em tracking narrows each advance. */ +.cp-roll .dg{display:inline-block;width:calc(1ch - .01em);height:1.25em;overflow:hidden;text-align:center} +.cp-roll .dg-strip{display:flex;flex-direction:column} +.cp-roll .dg-strip>span{height:1.25em} +.cp-roll .dg-ph{color:var(--fg-4)} .cp-input::-webkit-outer-spin-button,.cp-input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0} .cp-input::placeholder{color:var(--fg-4)} .cp-input:disabled{color:var(--fg-3)} @@ -731,16 +826,64 @@ .cp-hint{font-size:11.5px;color:var(--fg-3);padding:4px 2px} .cp-hint b{color:var(--fg-2)} .cp-profiles{display:flex;flex-wrap:wrap;gap:6px} -.cp-profile-chip{appearance:none;display:inline-flex;align-items:center;gap:6px;padding:6px 11px; +.cp-profile-chip{appearance:none;position:relative;overflow:hidden;display:inline-flex;align-items:center;gap:6px;padding:6px 11px; border-radius:999px;background:var(--bg-2);border:1px solid var(--line);color:var(--fg-2); font-size:12px;font-weight:500;cursor:pointer;transition:background .15s var(--ease-out),border-color .15s var(--ease-out),color .15s var(--ease-out)} -.cp-profile-chip:hover:not(:disabled){background:var(--surface-3);border-color:var(--line-2);color:var(--fg)} -.cp-profile-chip.on{background:color-mix(in oklch,var(--accent) 14%,var(--surface-3)); - border-color:color-mix(in oklch,var(--accent) 55%,transparent);color:var(--accent)} +.cp-profile-chip>span{position:relative} +.cp-profile-chip:hover:not(:disabled,.too-big){background:var(--surface-3);border-color:var(--line-2);color:var(--fg)} +/* The armed profile fills in from the left. Chips are patched in place (see + renderCpProfiles), so this plays when a profile becomes the armed one and + never on a state tick. */ +.cp-profile-chip::before{content:'';position:absolute;inset:0;border-radius:inherit;pointer-events:none; + background:color-mix(in oklch,var(--accent) 14%,var(--surface-3)); + transform:scaleX(0);transform-origin:left;transition:transform .4s var(--ease-out)} +.cp-profile-chip.on::before{transform:none} +.cp-profile-chip.on{border-color:color-mix(in oklch,var(--accent) 55%,transparent);color:var(--accent)} .cp-profile-chip:disabled{opacity:.4;cursor:not-allowed} -.cp-cta{width:100%;padding:13px 18px;font-size:14px;border-radius:10px} +/* The armed profile is disabled (it's already on), but it must still read as on. */ +.cp-profile-chip.on:disabled{opacity:1;cursor:default} +.cp-profile-chip .bolt{display:inline-block} +/* Profile launch: sparks fly in viewport coordinates, above everything. */ +.cp-spark{position:fixed;left:0;top:0;width:8px;height:8px;margin:-4px 0 0 -4px;border-radius:50%; + pointer-events:none;z-index:1000;background:oklch(0.93 0.12 85); + box-shadow:0 0 10px 3px color-mix(in oklch,var(--warn) 75%,transparent)} +.cp-ripple{position:absolute;left:50%;top:50%;width:20px;height:20px;margin:-10px 0 0 -10px;border-radius:50%; + pointer-events:none;background:oklch(0.97 0.08 85 / .5)} + +/* Main delay button. Two labels can share the grid cell while one hands over + to the next (setCta). The fill is a plain colour so state changes blend; + the primary sheen lives in ::before because gradients can't transition. */ +.cp-cta{width:100%;padding:13px 18px;font-size:14px;border-radius:10px; + display:grid;place-items:center;position:relative;overflow:hidden; + transition:transform .15s var(--ease-out),background-color .25s var(--ease-out),color .25s var(--ease-out), + border-color .25s var(--ease-out),box-shadow .25s var(--ease-out)} +.cp-cta>.cta-label{grid-area:1/1;max-width:100%;overflow:hidden;text-overflow:ellipsis} +.cp-cta .cta-num{display:inline-block} +.cp-cta.ic-btn-primary{background:var(--accent)} +.cp-cta.ic-btn-primary:hover:not(:disabled,.ic-btn-disabled){background:color-mix(in oklch,#fff 8%,var(--accent))} +.cp-cta::before{content:'';position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0; + background:linear-gradient(180deg,rgba(255,255,255,.16),transparent 70%);transition:opacity .25s var(--ease-out)} +.cp-cta.ic-btn-primary::before{opacity:1} #cp-cancel{animation:pillIn .2s var(--ease-out)} .cp-cta-pulse{animation:cta-pulse 1.8s var(--ease-out) infinite} +/* Waiting on OBS: a slow sweep and breathing dots say it's listening. */ +.cp-cta-waiting::after{content:'';position:absolute;inset:0;pointer-events:none;transform:translateX(-100%); + background:linear-gradient(100deg,transparent 35%,rgba(255,255,255,.07) 50%,transparent 65%); + animation:cta-sweep 2.8s ease-in-out infinite} +@keyframes cta-sweep{0%{transform:translateX(-100%)}65%,100%{transform:translateX(100%)}} +.cta-dots i{font-style:normal;opacity:.25;animation:cta-dot 1.4s ease-in-out infinite} +.cta-dots i:nth-child(2){animation-delay:.2s} +.cta-dots i:nth-child(3){animation-delay:.4s} +@keyframes cta-dot{0%,100%{opacity:.25}40%{opacity:1}} +/* OBS connected: one bloom as the button becomes usable. */ +.cp-cta-bloom{animation:cta-bloom .9s var(--ease-out)} +@keyframes cta-bloom{0%{box-shadow:0 0 0 0 color-mix(in oklch,var(--accent) 60%,transparent)} + 100%{box-shadow:0 0 0 18px transparent}} +@media (prefers-reduced-motion: reduce){ + .cp-cta-waiting::after{animation:none;opacity:0} + .cta-dots i{animation:none;opacity:.7} + .cp-profile-chip::before{transition:none} +} /* - "Cut after this airs" + hovercard --------------─ Visible only while a delay is active, sharing the CTA row at a 3:1 split (Cut delay : Cut after) so the control column's height never @@ -782,14 +925,14 @@ border-radius:10px;gap:2px;position:relative;overflow-x:auto;scrollbar-width:none} .tabs::-webkit-scrollbar{display:none} .tab{appearance:none;background:transparent;border:0;display:inline-flex;align-items:center;gap:7px; - padding:7px 14px;border-radius:7px;color:var(--fg-3);font-size:12.5px;font-weight:500;cursor:pointer; + padding:9px 14px;border-radius:7px;color:var(--fg-3);font-size:13px;font-weight:500;cursor:pointer; position:relative;z-index:2;transition:color .2s var(--ease-out);white-space:nowrap; flex:1;justify-content:center} @media (max-width:720px){.tab{flex:0 0 auto}} .tab:hover{color:var(--fg)} .tab.on{color:var(--fg)} -.tab svg{opacity:.7;width:13px;height:13px} -.tab.on svg{opacity:1} +.tab svg{opacity:.85;width:15px;height:15px;transition:color .2s var(--ease-out)} +.tab.on svg{opacity:1;color:var(--accent)} .tab-ind{position:absolute;top:4px;bottom:4px;background:var(--surface-3);border-radius:7px; transition:left .28s var(--ease-spring),width .28s var(--ease-spring);z-index:1;border:1px solid var(--line-2)} @@ -808,8 +951,47 @@ .sub-tab-ind{position:absolute;top:4px;bottom:4px;background:var(--surface-3);border-radius:6px; transition:left .28s var(--ease-spring),width .28s var(--ease-spring);z-index:1;border:1px solid var(--line-2)} .sub-pane{display:flex;flex-direction:column;gap:14px;animation:paneIn .2s var(--ease-out) both} -.sys-foot{display:flex;justify-content:flex-end;align-items:center;gap:10px; - border-top:1px solid var(--line);padding-top:14px;margin-top:4px} +.sub-tab[hidden]{display:none} +/* Save bar: appears only while there are unsaved changes, pinned to the + bottom of the window so Save is never a long scroll away. */ +.sys-foot{position:sticky;bottom:12px;z-index:5;display:flex;align-items:center;gap:10px;margin-top:4px; + padding:9px 10px 9px 16px;border:1px solid var(--line);border-radius:12px; + background:var(--surface-2); + box-shadow:0 10px 30px rgba(0,0,0,.35);border-color:color-mix(in oklch,var(--warn) 55%,var(--line)); + animation:softRise .18s var(--ease-out)} +.sys-foot[hidden]{display:none} +.sys-foot-state{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--fg);font-weight:600;white-space:nowrap} +.sys-foot-state::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--warn)} +@keyframes softRise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} +#s-msg{flex:1;min-width:0;font-size:12px} +/* Find a setting, in the System header. */ +.sys-find{position:relative;display:flex;align-items:center;width:min(320px,100%);margin-left:auto} +.sys-find>svg{position:absolute;left:11px;width:15px;height:15px;color:var(--fg-4);pointer-events:none} +.sys-find input{width:100%;height:36px;padding:0 34px 0 34px;border-radius:10px;background:var(--bg-2);color:var(--fg); + border:1px solid var(--line);font:inherit;font-size:13px;outline:0; + transition:border-color .2s var(--ease-out),box-shadow .25s var(--ease-out)} +.sys-find input:focus{border-color:color-mix(in oklch,var(--tint) 75%,transparent);background:var(--bg); + box-shadow:0 0 0 3px color-mix(in oklch,var(--tint) 18%,transparent)} +.sys-find input::-webkit-search-cancel-button{display:none} +.sys-find-key{position:absolute;right:9px;font:600 10.5px/1 'JetBrains Mono',monospace;color:var(--fg-4); + border:1px solid var(--line-2);border-radius:5px;padding:3px 6px;pointer-events:none} +.sys-find input:focus~.sys-find-key,.sys-find input:not(:placeholder-shown)~.sys-find-key{display:none} +.sys-find-results{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:30;display:flex;flex-direction:column; + padding:5px;border-radius:12px;background:var(--surface-2);border:1px solid var(--line-2); + box-shadow:0 16px 40px rgba(0,0,0,.5);animation:softDrop .15s var(--ease-out)} +.sys-find-results[hidden]{display:none} +.sys-find-option{appearance:none;display:flex;justify-content:space-between;align-items:baseline;gap:12px; + padding:9px 10px;border:0;border-radius:8px;background:transparent;color:var(--fg-2);font:inherit;font-size:13px; + text-align:left;cursor:pointer} +.sys-find-option b{font-weight:600;color:var(--fg)} +.sys-find-option span{font-size:11.5px;color:var(--fg-4);white-space:nowrap} +.sys-find-option:hover,.sys-find-option[aria-selected="true"]{background:var(--surface-3)} +.sys-find-empty{padding:10px;font-size:12.5px;color:var(--fg-3)} +/* The section a search landed on glows briefly. */ +.sys-found{animation:sysFound 1.6s var(--ease-out)} +@keyframes sysFound{0%,35%{box-shadow:0 0 0 2px var(--accent)}100%{box-shadow:0 0 0 2px transparent}} +@media (max-width:640px){.sys-find{width:100%}} +@media (prefers-reduced-motion: reduce){.sys-found,.sys-find-results,.sys-foot{animation:none}} .sys-about-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px} @media (max-width:680px){.sys-about-grid{grid-template-columns:1fr}} .sys-about-card{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px} @@ -832,6 +1014,8 @@ /* Secrets (dock token URL) blurred by default so they can't leak on a stream or screen-share; the Show button reveals on demand. Copy still works blurred. */ .blur-secret{filter:blur(6px);transition:filter .1s ease;user-select:none} +.ic-input.blur-secret{transition:filter .1s ease,border-color .2s var(--ease-out), + background-color .2s var(--ease-out),box-shadow .25s var(--ease-out)} .blur-secret.revealed{filter:none;user-select:text} /* A blurred secret INPUT stays editable: unblur while focused (so typing is never blurred) and while empty (so the placeholder is readable). It only @@ -872,7 +1056,16 @@ /* - Pane ------------------- */ .pane-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius); - padding:22px;min-height:360px;max-height:70vh;overflow-y:auto;overflow-x:hidden} + padding:22px;min-height:360px;max-height:var(--pane-max,70vh);overflow-y:auto;overflow-x:hidden} +/* Scrolling panels keep their scrollbar's room even when nothing scrolls, + so a tab growing past the fold (or its enter animation) never narrows the + content and nudges it sideways. The right padding gives that room back + (22px = 12px + the 10px scrollbar). */ +.pane-card,.dest-form-body{scrollbar-gutter:stable} +/* Phones: the hero fills the screen, so a pane sized to what's left is a + 360px box scrolling inside a scrolling page. Let the page scroll it. */ +@media (max-width:560px){.pane-card{max-height:none}} +@supports (scrollbar-gutter:stable){.pane-card{padding-right:12px}} /* Directional enter: the body slides in from the side the tab moved toward (showTab sets --pane-dx: + for a rightward move, - for leftward, 0 on first paint / sub-panes), echoing the sliding tab indicator. Transform + opacity @@ -884,8 +1077,8 @@ to{opacity:1;transform:translate3d(0,0,0)} } .tab-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap} -.tab-title{font-size:15px;font-weight:600;color:var(--fg);letter-spacing:-.01em} -.tab-sub{color:var(--fg-3);font-size:12.5px;margin-top:3px;max-width:60ch} +.tab-title{font-size:17px;font-weight:650;color:var(--fg);letter-spacing:-.015em} +.tab-sub{color:var(--fg-3);font-size:13px;margin-top:4px;max-width:60ch} /* - Destinations --------------- */ .dest-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(330px,1fr))} @@ -905,17 +1098,19 @@ .dcard.alive::before{opacity:1;box-shadow:0 0 16px var(--dc,var(--accent))} .dcard.off{opacity:.55} .dcard.off::before{opacity:.2} -.dcard.platform-twitch {--dc:#a78bfa} -.dcard.platform-youtube {--dc:#fda4af} -.dcard.platform-kick {--dc:#86efac} -.dcard.platform-trovo {--dc:#67e8f9} -.dcard.platform-restream {--dc:#5eead4} -.dcard.platform-custom {--dc:#fcd34d} -.dcard.platform-sink {--dc:#93c5fd} +.dcard.platform-twitch{--dc:#a78bfa} +.dcard.platform-youtube{--dc:#fda4af} +.dcard.platform-kick{--dc:#86efac} +.dcard.platform-trovo{--dc:#67e8f9} +.dcard.platform-restream{--dc:#5eead4} +.dcard.platform-custom{--dc:#fcd34d} +.dcard.platform-sink{--dc:#93c5fd} .dcard-player{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dc,var(--accent)); text-decoration:none;margin-left:auto} .dcard-player:hover{text-decoration:underline;text-underline-offset:2px} .dcard-head{display:flex;align-items:center;gap:9px} +.dcard-icon svg,.dest-starter-icon svg{width:18px;height:18px} +.w-ptile-badge svg{width:24px;height:24px} .dcard-icon{width:34px;height:34px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center; background:color-mix(in oklch,var(--dc,var(--fg-3)) 16%,var(--surface-3)); border:1px solid color-mix(in oklch,var(--dc,var(--fg-3)) 38%,transparent); @@ -934,9 +1129,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 +1140,25 @@ 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". */ +/* Crash protection hold: the hero banner while the reconnect screen is on + air, with the countdown and End now. Amber, like a waiting destination. */ +.hold-banner{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 14px;padding:12px 14px; + border-radius:10px;border:1px solid color-mix(in oklch,var(--warn,#d6a23a) 55%,var(--line)); + background:color-mix(in oklch,var(--warn,#d6a23a) 8%,var(--bg-2))} +.hold-banner .hold-dot{width:9px;height:9px;border-radius:50%;flex:none;background:var(--warn,#d6a23a); + box-shadow:0 0 8px var(--warn,#d6a23a);animation:hpulse 2.4s infinite;--sc:var(--warn,#d6a23a)} +.hold-copy{flex:1;min-width:220px;display:grid;gap:2px} +.hold-copy span{color:var(--fg-3);font-size:12px;font-variant-numeric:tabular-nums} +.hold-banner .ic-btn{flex:0 0 auto;padding:8px 14px} .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; @@ -968,14 +1174,26 @@ like the landing cards. The sparkline bleeds to the screen's edges. */ .dcard-screen{background:var(--bg-1);border:1px solid color-mix(in oklch,var(--line) 65%,transparent); border-radius:11px;padding:12px 14px 0;overflow:hidden;position:relative} -.dcard-body{display:flex;align-items:baseline;justify-content:space-between;gap:10px} -.dcard-rate-row{display:flex;align-items:baseline;gap:5px} +/* Wraps so a long idle message pushes the counters to their own line + instead of being squeezed into a narrow column beside them. */ +.dcard-body{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:2px 10px} +.dcard-rate-row{display:flex;align-items:baseline;gap:5px;min-width:0;max-width:100%} .dcard-rate{font-size:24px;font-weight:600;color:var(--fg);letter-spacing:-.02em;line-height:1; font-family:'JetBrains Mono',monospace;font-variant-numeric:tabular-nums} .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} +.dcard-foot-meta[hidden]{display:none} +/* A sentence in place of the bitrate while nothing is flowing. */ +.dcard-rate.is-msg{font-size:13px;font-weight:500;color:var(--fg-3);font-family:inherit;letter-spacing:0;line-height:24px; + min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dcard.went-live{animation:wentLive 1.1s var(--ease-out)} +@keyframes wentLive{from{box-shadow:0 0 0 0 color-mix(in oklch,var(--dc,var(--accent)) 60%,transparent)} + to{box-shadow:0 0 0 16px transparent}} /* 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 @@ -995,6 +1213,30 @@ transition:color .15s,background .15s,border-color .15s} .dcard-act:hover{color:var(--fg);border-color:var(--line-2);background:color-mix(in oklch,var(--bg-1) 78%,transparent)} .dcard-act.dcard-del:hover{color:var(--danger);border-color:color-mix(in oklch,var(--danger) 45%,var(--line))} +/* Empty Destinations: a platform picker instead of a lone "+" tile. */ +.dest-empty{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:26px 8px 10px} +.dest-empty[hidden],.dest-grid[hidden]{display:none} +.dest-empty-title{font-size:20px;font-weight:650;letter-spacing:-.01em;color:var(--fg)} +.dest-empty-sub{max-width:52ch;font-size:13px;color:var(--fg-3);line-height:1.5;text-wrap:pretty} +.dest-empty-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;width:min(720px,100%);margin-top:18px} +@media (max-width:640px){.dest-empty-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +.dest-starter{appearance:none;display:flex;flex-direction:column;align-items:flex-start;gap:4px;padding:16px;border-radius:14px; + background:var(--bg-2);border:1px solid var(--line);color:var(--fg);font:inherit;text-align:left;cursor:pointer; + transition:border-color .2s var(--ease-out),box-shadow .2s var(--ease-out),transform .2s var(--ease-out)} +.dest-starter:hover{border-color:color-mix(in oklch,var(--dc,var(--accent)) 45%,var(--line-2));transform:translateY(-2px); + box-shadow:0 18px 40px -24px color-mix(in oklch,var(--dc,var(--accent)) 80%,transparent)} +.dest-starter:active{transform:scale(.96)} +.dest-starter-icon{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;margin-bottom:6px; + background:color-mix(in oklch,var(--dc,var(--fg-3)) 16%,var(--surface-3)); + border:1px solid color-mix(in oklch,var(--dc,var(--fg-3)) 38%,transparent);color:var(--dc,var(--fg-3));font-weight:700} +.dest-starter-name{font-size:14px;font-weight:600} +.dest-starter-note{font-size:12px;color:var(--fg-3)} +.dest-starter.platform-twitch{--dc:#a78bfa} +.dest-starter.platform-youtube{--dc:#fda4af} +.dest-starter.platform-kick{--dc:#86efac} +.dest-starter.platform-restream{--dc:#5eead4} +.dest-starter.platform-custom{--dc:#fcd34d} +.dest-starter.platform-sink{--dc:#93c5fd} .dest-add{appearance:none;background:transparent;border:1px dashed var(--line-2);border-radius:12px; color:var(--fg-3);font-size:13px;font-weight:500;cursor:pointer; display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px; @@ -1002,19 +1244,74 @@ .dest-add:hover{color:var(--accent);border-color:color-mix(in oklch,var(--accent) 50%,transparent); background:color-mix(in oklch,var(--accent) 4%,transparent)} .dest-add-icon{width:36px;height:36px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center; - background:var(--surface-3);border:1px solid var(--line-2);font-size:22px;line-height:1} + background:var(--surface-3);border:1px solid var(--line-2);transition:transform .2s var(--ease-out)} +.dest-add-icon svg{width:18px;height:18px} +.dest-add:hover .dest-add-icon{transform:rotate(90deg)} +/* Header "Add destination": accent-tinted rather than a solid accent + block, so it leads without outshining the live cards beside it. */ +.ic-btn.dest-add-btn{padding:9px 15px 9px 12px;gap:7px;font-weight:600;color:var(--accent); + background:color-mix(in oklch,var(--accent) 13%,var(--surface-3)); + border-color:color-mix(in oklch,var(--accent) 40%,var(--line-2))} +.ic-btn.dest-add-btn svg{width:15px;height:15px;transition:transform .2s var(--ease-out)} +.ic-btn.dest-add-btn:hover{background:color-mix(in oklch,var(--accent) 22%,var(--surface-3)); + border-color:color-mix(in oklch,var(--accent) 65%,transparent); + box-shadow:0 8px 22px -12px var(--accent)} +.ic-btn.dest-add-btn:hover svg{transform:rotate(90deg)} +@media (prefers-reduced-motion: reduce){.ic-btn.dest-add-btn svg,.dest-add-icon{transition:none}} /* Destination editor - a centred floating modal (see .ic-modal shell). Scrolls internally when the VOD/EB section makes it tall. */ -.dest-form{padding:20px 22px;background:linear-gradient(180deg,var(--bg-2),var(--surface)); +.dest-form{padding:0;background:linear-gradient(180deg,var(--bg-2),var(--surface)); border:1px solid color-mix(in oklch,var(--accent) 40%,var(--line));border-radius:var(--radius); box-shadow:0 24px 60px -20px rgba(0,0,0,.6); - animation:modalPop .2s var(--ease-out) both;display:flex;flex-direction:column;gap:9px; - position:relative;width:min(600px,100%);max-height:min(90vh,860px);overflow-y:auto} + animation:modalPop .2s var(--ease-out) both;display:flex;flex-direction:column; + position:relative;width:min(820px,100%);max-height:min(92vh,900px);overflow:hidden} +/* Header and footer stay put; only the body scrolls, so Save is always in + reach however long the Twitch VOD section gets. */ +.dest-form-body{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:18px 12px 20px 22px; + display:flex;flex-direction:column;gap:10px} +.dest-form-foot{display:flex;align-items:center;gap:8px;padding:14px 22px;border-top:1px solid var(--line); + background:color-mix(in oklch,var(--bg-2) 85%,transparent)} +.dest-form-foot .dest-form-msg{flex:1;min-width:0;margin:0} +.dest-form-mark{--dc:var(--fg-3);width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:none; + background:color-mix(in oklch,var(--dc) 16%,var(--surface-3));border:1px solid color-mix(in oklch,var(--dc) 38%,transparent); + color:var(--dc);font-weight:700;transition:color .25s var(--ease-out),background .25s var(--ease-out),border-color .25s var(--ease-out)} +.dest-form-mark svg{width:21px;height:21px} +.dest-form-titles{flex:1;min-width:0} +/* Two columns of fields on wide screens, one on narrow ones. Hidden fields + drop out of the grid, so each platform packs its own set. */ +.dfg{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 18px;align-items:start} +.dff{display:flex;flex-direction:column;gap:6px;min-width:0} +.dest-form-note{display:block;min-width:0;font-size:12px;line-height:1.55;align-self:center} +.dest-form .dff>label,.dest-form .dff>label.muted{margin:0;font-size:12.5px;font-weight:600;color:var(--fg-2); + text-transform:none;letter-spacing:0} +.dest-form .dff>label .muted{font-weight:400;font-size:11.5px} +.dest-form .dff:focus-within>label{color:var(--tint)} +/* Enabled: a switch in the header instead of a checkbox at the bottom. */ +.dest-form .dest-form-enable{display:inline-flex;align-items:center;gap:8px;margin:0;cursor:pointer;flex:none; + font-size:12.5px;font-weight:500;color:var(--fg-2);text-transform:none;letter-spacing:0} +.dest-form-enable input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none} +.dfe-track{position:relative;width:36px;height:20px;border-radius:999px;background:var(--surface-3);border:1px solid var(--line-2); + transition:background .2s var(--ease-out),border-color .2s var(--ease-out)} +.dfe-track>span{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg-3); + transition:transform .25s var(--ease-spring),background .2s var(--ease-out)} +.dest-form-enable input:checked+.dfe-track{background:var(--accent);border-color:transparent} +.dest-form-enable input:checked+.dfe-track>span{transform:translateX(16px);background:#fff} +.dest-form-enable input:focus-visible+.dfe-track{outline:2px solid var(--accent);outline-offset:2px} +@media (max-width:760px){.dfg{grid-template-columns:1fr}} +/* Phones: a full-screen sheet rather than a box floating in a margin. */ +@media (max-width:560px){ + #dest-modal{padding:0} + .dest-form{width:100%;height:100dvh;max-height:100dvh;border-radius:0;border-width:0} + .dest-form-head{padding:14px 16px} + .dest-form-body{padding:14px 6px 18px 16px} + .dest-form-foot{padding:12px 16px calc(12px + env(safe-area-inset-bottom))} + .dest-form-hint{display:none} +} @keyframes modalPop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}} /* Header row: title + one-click close so editing feels like a focused panel you can dismiss without scrolling to the Cancel button. */ -.dest-form-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:2px} +.dest-form-head{display:flex;align-items:center;gap:14px;padding:18px 22px 16px;border-bottom:1px solid var(--line)} .dest-form h4{margin:0;font-size:15px;font-weight:600} .dest-form-hint{font-size:11.5px;color:var(--fg-3);margin-top:3px;line-height:1.4} .dest-form-x{flex:0 0 auto;width:28px;height:28px;border-radius:8px;border:1px solid var(--line); @@ -1061,33 +1358,38 @@ /* - Stats ------------------─ */ .stats-big{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px} @media (max-width:880px){.stats-big{grid-template-columns:1fr}} -.stat-card{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:14px;cursor:help; +.stat-idle{position:absolute;left:14px;right:14px;bottom:24px;text-align:center;font-size:12.5px;color:var(--fg-3)} +.stat-idle[hidden]{display:none} +.profile-empty{display:flex;flex-direction:column;gap:4px;padding:18px;border:1px dashed var(--line-2);border-radius:10px; + color:var(--fg-3);font-size:12.5px;line-height:1.5} +.profile-empty b{color:var(--fg-2);font-size:13.5px} +.stat-card{position:relative;background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:14px;cursor:help; transition:border-color .18s var(--ease-out),background .18s var(--ease-out),box-shadow .18s var(--ease-out)} /* Elevation via shadow + border only - no position change, so the element never moves out from under the cursor (no hover flicker). */ -.stat-card:hover{border-color:color-mix(in oklch,var(--accent) 32%,var(--line-2)); - box-shadow:0 10px 26px -14px color-mix(in oklch,var(--accent) 55%,transparent)} +.stat-card:hover{border-color:color-mix(in oklch,var(--tint) 32%,var(--line-2)); + box-shadow:0 10px 26px -14px color-mix(in oklch,var(--tint) 55%,transparent)} .stat-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px} .stat-label{display:inline-flex;align-items:center;gap:7px} .stat-ic{width:14px;height:14px;color:var(--fg-3);opacity:.85;flex:0 0 auto;transition:color .18s,opacity .18s} -.stat-card:hover .stat-ic{color:var(--accent);opacity:1} -.stat-now{font-size:22px;font-weight:500;color:var(--fg);letter-spacing:-.02em; +.stat-card:hover .stat-ic{color:var(--tint);opacity:1} +.stat-now{font-size:26px;font-weight:500;color:var(--fg);letter-spacing:-.02em; font-family:'JetBrains Mono',monospace;font-variant-numeric:tabular-nums} .stat-unit{color:var(--fg-3);font-size:12px;margin-left:4px;font-family:'JetBrains Mono',monospace} .stat-spark{width:100%;height:70px;display:block} .stats-small{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px} @media (max-width:980px){.stats-small{grid-template-columns:repeat(3,1fr)}} @media (max-width:540px){.stats-small{grid-template-columns:repeat(2,1fr)}} -.metric-mini{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:help; +.metric-mini{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:12px 14px;cursor:help; transition:border-color .18s var(--ease-out),background .18s var(--ease-out),box-shadow .18s var(--ease-out)} -.metric-mini:hover{border-color:color-mix(in oklch,var(--accent) 35%,var(--line-2)); - background:color-mix(in oklch,var(--accent) 4%,var(--bg-2)); - box-shadow:0 8px 20px -14px color-mix(in oklch,var(--accent) 55%,transparent)} +.metric-mini:hover{border-color:color-mix(in oklch,var(--tint) 35%,var(--line-2)); + background:color-mix(in oklch,var(--tint) 4%,var(--bg-2)); + box-shadow:0 8px 20px -14px color-mix(in oklch,var(--tint) 55%,transparent)} .metric-mini-top{display:flex;align-items:center;gap:6px} -.metric-ic{width:13px;height:13px;color:var(--fg-3);opacity:.8;flex:0 0 auto;transition:color .18s,opacity .18s} -.metric-mini:hover .metric-ic{color:var(--accent);opacity:1} -.metric-mini-v{display:flex;align-items:baseline;gap:4px;margin-top:5px} -.metric-mini-v .mono{font-size:18px;font-weight:500;color:var(--fg);letter-spacing:-.01em; +.metric-ic{width:14px;height:14px;color:var(--fg-3);opacity:.85;flex:0 0 auto;transition:color .18s,opacity .18s} +.metric-mini:hover .metric-ic{color:var(--tint);opacity:1} +.metric-mini-v{display:flex;align-items:baseline;gap:4px;margin-top:8px} +.metric-mini-v .mono{font-size:22px;font-weight:500;color:var(--fg);letter-spacing:-.01em; font-variant-numeric:tabular-nums} .metric-unit{color:var(--fg-3);font-size:11px;font-family:'JetBrains Mono',monospace} /* Sparkline strokes draw in on first paint for a touch of life. */ @@ -1126,35 +1428,8 @@ .chat-dock-row input{flex:1 1 200px;min-width:160px} /* - Overlay tab ---------------─ */ -.overlay-grid{display:grid;gap:14px;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr)} -@media (max-width:880px){.overlay-grid{grid-template-columns:1fr}} .overlay-section{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:16px; display:flex;flex-direction:column;gap:6px} -.overlay-stage{margin-top:12px;position:relative; - background:repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%,transparent 0% 50%) 0 0/18px 18px, - linear-gradient(135deg,#161a23,#0a0d12); - border:1px solid var(--line);border-radius:8px;display:flex;align-items:flex-end;justify-content:flex-start; - padding:18px;min-height:160px} -.overlay-files{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-top:10px} -.overlay-file{appearance:none;background:var(--surface);border:1px solid var(--line);border-radius:10px; - padding:10px;display:flex;flex-direction:column;gap:8px;cursor:pointer;text-align:left; - position:relative;transition:border-color .15s var(--ease-out),background .15s var(--ease-out),transform .15s var(--ease-out);color:inherit;font:inherit} -.overlay-file:hover{border-color:var(--line-2);transform:translateY(-1px)} -.overlay-file.selected{border-color:color-mix(in oklch,var(--accent) 60%,transparent); - background:color-mix(in oklch,var(--accent) 6%,var(--surface)); - box-shadow:0 0 0 1px color-mix(in oklch,var(--accent) 25%,transparent),0 6px 18px var(--accent-glow)} -.overlay-file-tag{position:absolute;top:8px;right:8px; - font-size:9px;font-weight:600;letter-spacing:.1em; - padding:2px 6px;border-radius:999px; - background:color-mix(in oklch,var(--accent) 18%,transparent);color:var(--accent)} -.overlay-file{position:relative} -.overlay-file-name{font-size:13px;font-weight:500;color:var(--fg)} -.overlay-file-meta{font-size:10.5px;color:var(--fg-4);font-family:'JetBrains Mono',monospace; - white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.overlay-tip{display:flex;align-items:center;gap:8px;margin-top:12px;padding:10px 12px; - background:color-mix(in oklch,var(--accent) 5%,var(--surface)); - border:1px solid color-mix(in oklch,var(--accent) 22%,var(--line));border-radius:8px; - font-size:12px;color:var(--fg-2)} /* - Overlay gallery --------------- */ .ovg-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:14px;margin-top:6px} @@ -1191,10 +1466,7 @@ /* - Overlay Studio ---------------─ */ .st-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap} -.st-toolbar .ic-input{max-width:200px;height:34px} -.st-grid{display:grid;grid-template-columns:210px minmax(0,1fr) 268px;gap:12px;margin-top:6px; - align-items:stretch;min-height:520px} -@media (max-width:1100px){.st-grid{grid-template-columns:1fr}} +.st-toolbar .ic-input{max-width:160px;height:34px} .st-left,.st-right{background:var(--bg-2);border:1px solid var(--line);border-radius:10px; display:flex;flex-direction:column;overflow:hidden} .st-right{padding:0} @@ -1203,8 +1475,6 @@ font-size:11.5px;font-weight:600;padding:9px 4px;cursor:pointer;letter-spacing:.02em} .st-tabs button.on{color:var(--fg);box-shadow:inset 0 -2px 0 var(--accent)} .st-left-body{overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:7px;flex:1} -.st-palette-group{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-4); - margin:6px 2px 2px} .st-item{appearance:none;text-align:left;background:var(--surface);border:1px solid var(--line); border-radius:8px;padding:9px 11px;cursor:pointer;color:var(--fg-2);font-size:12.5px; display:flex;align-items:center;justify-content:space-between;gap:8px;transition:border-color .12s,transform .12s} @@ -1215,7 +1485,6 @@ .st-item-l{flex:1;text-align:left} .st-item .st-cost{font-size:9px;font-weight:700;letter-spacing:.06em;padding:1px 5px;border-radius:5px; background:color-mix(in oklch,var(--warn) 16%,transparent);color:var(--warn)} -.st-item .st-tag{font-size:9.5px;color:var(--fg-4);font-family:'JetBrains Mono',monospace} .st-canvas-wrap{display:flex;flex-direction:column;gap:10px;min-width:0;min-height:0} .st-canvas-frame{position:relative;flex:1;min-height:0;background:#06080c; border:1px solid var(--line);border-radius:10px;overflow:hidden} @@ -1244,7 +1513,6 @@ .st-head-title{flex:1;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} @media (max-width:760px){.st-head-title{display:none}} .st-modal-head .st-toolbar{margin-left:auto;flex-wrap:nowrap} -.st-toolbar .ic-input{max-width:160px} .st-icobtn{padding:8px 11px;font-size:15px;line-height:1;min-width:38px} .st-icobtn:disabled{opacity:.35;cursor:default} .st-menu{position:relative} @@ -1281,7 +1549,7 @@ /* Left panel: layers + add */ .st-sec{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-4);margin:10px 2px 4px} .st-sec:first-child{margin-top:2px} -.st-layer{display:flex;align-items:center;gap:6px;padding:8px 9px;border:1px solid var(--line); +.st-layer{position:relative;overflow:hidden;display:flex;align-items:center;gap:6px;padding:8px 9px;border:1px solid var(--line); border-radius:8px;background:var(--surface);cursor:pointer;transition:border-color .12s} .st-layer:hover{border-color:var(--line-2)} .st-layer.sel{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)} @@ -1299,9 +1567,6 @@ .st-pgroup-h{font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--fg-3); display:flex;align-items:center;gap:8px;font-weight:700} .st-pgroup-h .dot{width:9px;height:9px;border-radius:50%;flex:none} -.st-vis{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px; - background:var(--surface);border:1px solid var(--line);border-radius:9px} -.st-vis-l{font-size:12.5px;color:var(--fg-2);font-weight:500} .st-vischips{display:flex;gap:5px;flex-wrap:wrap} .st-vischip{appearance:none;background:var(--surface);border:1px solid var(--line);border-radius:7px; color:var(--fg-4);font-size:11px;font-weight:600;padding:6px 10px;cursor:pointer;display:inline-flex;align-items:center;gap:6px; @@ -1328,14 +1593,6 @@ .st-chip{appearance:none;background:color-mix(in oklch,var(--accent) 12%,transparent);border:1px solid color-mix(in oklch,var(--accent) 30%,transparent); color:var(--accent);font-family:'JetBrains Mono',monospace;font-size:10px;padding:2px 6px;border-radius:5px;cursor:pointer;margin:2px 3px 0 0} .st-chip:hover{background:color-mix(in oklch,var(--accent) 22%,transparent)} -/* iOS-style switch */ -.st-switch{position:relative;width:42px;height:24px;flex:none;cursor:pointer} -.st-switch input{position:absolute;opacity:0;width:100%;height:100%;margin:0;cursor:pointer} -.st-switch .tr{position:absolute;inset:0;background:rgba(255,255,255,.14);border-radius:999px;transition:background .15s} -.st-switch .kn{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff; - transition:transform .15s} -.st-switch input:checked + .tr{background:var(--accent)} -.st-switch input:checked + .tr + .kn{transform:translateX(18px)} /* - Live phase caption + microinteractions -----─ */ /* Live phase caption - sits in the chrome (NOT over the canvas, so it @@ -1369,14 +1626,12 @@ .st-st.on .dot{animation:ph-pulse 1.8s ease-in-out infinite} /* selected layer accent stripe */ -.st-layer{position:relative;overflow:hidden} .st-layer::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--accent); transform:scaleY(0);transition:transform .16s var(--ease-out)} .st-layer.sel::before{transform:scaleY(1)} /* gentle fade when panels re-render */ @keyframes st-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} -.st-props-body{animation:st-fade-in .2s var(--ease-out)} .st-canvas-scale iframe{width:1920px;height:1080px;border:0;background:transparent;display:block;pointer-events:none;color-scheme:normal} .st-hit{position:absolute;inset:0} .st-hit .st-box{position:absolute;border:2px solid transparent;cursor:move;border-radius:4px} @@ -1400,18 +1655,16 @@ .st-props-head{padding:12px 14px;border-bottom:1px solid var(--line)} .st-props-title{font-size:13px;font-weight:600;color:var(--fg)} .st-props-sub{font-size:11px;color:var(--fg-4);font-family:'JetBrains Mono',monospace} -.st-props-body{overflow-y:auto;padding:12px 14px;flex:1;display:flex;flex-direction:column;gap:11px} +.st-props-body{overflow-y:auto;padding:12px 14px;flex:1;display:flex;flex-direction:column;gap:11px; + animation:st-fade-in .2s var(--ease-out)} .st-field{display:flex;flex-direction:column;gap:5px} .st-field-row{display:flex;gap:8px} .st-field-row .st-field{flex:1} .st-field label{font-size:11px;color:var(--fg-3);font-weight:500} .st-field .ic-input,.st-field select{padding:8px 10px;font-size:12.5px;line-height:1.35;border-radius:8px} -.st-field select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;utf8,");background-repeat:no-repeat;background-position:right 9px center;padding-right:28px} +.st-field select.ic-input{padding-right:30px;background-position:right 10px center} +@supports (appearance:base-select){.st-field select.ic-input{padding-right:10px}} .st-field textarea.ic-input{height:auto;min-height:60px;font-family:'JetBrains Mono',monospace;font-size:11.5px;line-height:1.5} -.st-statebar{display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:10px} -.st-statebtn{appearance:none;background:var(--surface);border:1px solid var(--line);border-radius:6px; - color:var(--fg-3);font-size:10.5px;font-weight:600;padding:5px 8px;cursor:pointer} -.st-statebtn.on{color:var(--fg);border-color:var(--accent);box-shadow:inset 0 -2px 0 var(--accent)} .st-color{display:flex;align-items:center;gap:8px} .st-color input[type=color]{width:34px;height:30px;border:1px solid var(--line);border-radius:6px;background:transparent;padding:2px;cursor:pointer} .st-check{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--fg-2);cursor:pointer} @@ -1426,11 +1679,6 @@ .ovg-switch input{accent-color:var(--accent,#5ac8fa);cursor:pointer;margin:0} .ovg-switch:hover{border-color:var(--line2,rgba(255,255,255,.18))} .st-danger{color:var(--danger)!important} -.form-block{display:flex;align-items:center;justify-content:space-between;gap:12px; - padding:10px 0;border-bottom:1px solid var(--line)} -.form-block:last-of-type{border-bottom:0} -.form-block-l{font-size:12.5px;color:var(--fg-2);font-weight:500} -.form-block .ic-input{max-width:180px} /* - Profiles ----------------- */ .profile-list{display:flex;flex-direction:column;gap:4px} @@ -1477,6 +1725,64 @@ /* - System ------------------ */ .sys-section{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:16px} +/* Crash protection card. The preview is drawn server-side by the same + renderer that encodes the stream, so it is what viewers will see. Wide + screens put the preview beside the controls and keep it in view while + they scroll; narrow ones stack them. */ +.crash-check{display:flex;align-items:flex-start;gap:10px;cursor:pointer} +.crash-check input{width:16px;height:16px;margin-top:2px;flex:none;cursor:pointer;accent-color:var(--accent)} +.crash-check>span{display:flex;flex-direction:column;gap:2px;min-width:0} +.crash-check-title{font-weight:600} +.crash-master{margin-top:14px} +.crash-body{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:20px 28px;margin-top:18px; + padding-top:18px;border-top:1px solid var(--line);animation:softDrop .2s var(--ease-out)} +.crash-stage{display:flex;flex-direction:column;gap:8px;align-self:start;position:sticky;top:12px} +.crash-stage-head,.crash-field-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +.crash-label{font-weight:600;font-size:12.5px;color:var(--fg-2)} +.crash-note{color:var(--fg-3);font-size:11.5px;line-height:1.45} +.crash-preview-frame{display:grid;place-items:center;background:var(--bg);border:1px solid var(--line); + border-radius:10px;padding:10px;aspect-ratio:16/10} +.crash-preview{display:block;width:100%;border-radius:6px;box-shadow:0 6px 24px rgba(0,0,0,.45);aspect-ratio:16/9; + background:var(--surface) no-repeat 0 0/100% 2400%;animation:crash-loop 2s steps(24,jump-none) infinite} +.crash-preview-frame.vertical .crash-preview{width:auto;height:100%;aspect-ratio:9/16} +/* The preview is one strip of 24 frames (CP_PREVIEW_FRAMES) stacked top to + bottom; sliding it a frame at a time plays the 2 s loop with no script. */ +@keyframes crash-loop{to{background-position:0 100%}} +.crash-controls{display:flex;flex-direction:column;gap:18px;min-width:0} +.crash-field{display:flex;flex-direction:column;gap:8px;min-width:0} +.crash-seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden} +.crash-seg button{appearance:none;border:0;background:var(--bg-2);color:var(--fg-3);font:inherit;font-size:11.5px; + font-weight:600;padding:5px 11px;cursor:pointer} +.crash-seg button[aria-pressed="true"]{background:var(--surface-3);color:var(--accent)} +.crash-themes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} +.crash-theme{appearance:none;display:flex;flex-direction:column;gap:6px;padding:5px 5px 7px;border-radius:9px;cursor:pointer; + background:var(--bg-2);border:1px solid var(--line);color:var(--fg-3);font:inherit;font-size:12px;font-weight:600; + transition:border-color .15s,color .15s,transform .15s var(--ease-out)} +.crash-theme:hover{border-color:var(--line-2);color:var(--fg);transform:translateY(-1px)} +.crash-theme[aria-checked="true"]{border-color:var(--accent);color:var(--fg); + box-shadow:0 0 0 1px var(--accent) inset} +.crash-theme img{display:block;width:100%;aspect-ratio:16/9;border-radius:5px;background:var(--surface)} +.crash-swatches{display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.crash-swatch{appearance:none;width:26px;height:26px;border-radius:50%;padding:0;cursor:pointer;border:2px solid var(--bg-2); + box-shadow:0 0 0 1px var(--line-2);transition:transform .12s var(--ease-out)} +.crash-swatch:hover{transform:scale(1.08)} +.crash-swatch[aria-pressed="true"]{box-shadow:0 0 0 2px var(--fg)} +.crash-inline{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.crash-small-btn{padding:6px 10px;font-size:12px} +.crash-link{appearance:none;background:none;border:0;padding:0;color:var(--accent);font:inherit;font-size:11.5px;cursor:pointer} +.crash-link:hover{text-decoration:underline} +.crash-text-row{position:relative;display:block} +.crash-text-row .ic-input{padding-right:58px} +.crash-count{position:absolute;right:11px;top:50%;transform:translateY(-50%);font-size:10.5px;color:var(--fg-4);pointer-events:none} +.crash-count.full{color:var(--warn)} +.crash-hold-value{font-size:12.5px;color:var(--accent);font-weight:600} +.crash-range{width:100%;accent-color:var(--accent);cursor:pointer} +.crash-advanced>summary{cursor:pointer;color:var(--fg-3);font-size:12.5px} +.crash-advanced>summary:hover{color:var(--fg)} +.crash-advanced .crash-check{margin-top:10px} +@media (max-width:820px){.crash-body{grid-template-columns:1fr}.crash-stage{position:static}} +@media (max-width:420px){.crash-themes{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media (prefers-reduced-motion: reduce){.crash-body,.crash-preview{animation:none}.crash-theme:hover,.crash-swatch:hover{transform:none}} /* Collapsible System section (e.g. legacy OBS quick launch). Native
; the summary carries the section label + a rotating caret. */ details.sys-collapse>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;-webkit-user-select:none;user-select:none} @@ -1497,10 +1803,8 @@ .sys-reset-card .ic-btn{align-self:flex-start;margin-top:6px} .ic-btn-warn{background:color-mix(in oklch,var(--warn) 18%,var(--bg-2)); border:1px solid color-mix(in oklch,var(--warn) 45%,var(--line));color:var(--warn)} -.ic-btn-warn:hover{background:color-mix(in oklch,var(--warn) 28%,var(--bg-2))} -.ic-btn-danger{background:color-mix(in oklch,var(--err) 18%,var(--bg-2)); - border:1px solid color-mix(in oklch,var(--err) 50%,var(--line));color:var(--err);font-weight:600} -.ic-btn-danger:hover:not(:disabled){background:color-mix(in oklch,var(--err) 32%,var(--bg-2))} +.ic-btn-warn:hover:not(:disabled,.ic-btn-disabled){background:color-mix(in oklch,var(--warn) 28%,var(--bg-2)); + border-color:color-mix(in oklch,var(--warn) 60%,var(--line))} .ic-btn-danger:disabled{opacity:.45;cursor:not-allowed} /* Reset modal */ .ic-modal{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px} @@ -1542,17 +1846,53 @@ .st-synced.on{opacity:.9;transform:none} /* - Logs ------------------- */ -.logs-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px} .logs-wrap{position:relative;min-height:420px} -.logs{background:#050608;border:1px solid var(--line);border-radius:10px;padding:10px 14px; +.logs{background:#050608;border:1px solid var(--line);border-radius:10px;padding:6px; font-family:'JetBrains Mono',monospace;font-size:12px; - min-height:420px;max-height:420px;overflow-y:auto;color:var(--fg-2); - line-height:1.6;margin:0} + min-height:420px;max-height:420px;overflow-y:auto;color:var(--fg-2);line-height:1.55;margin:0} +.logs[hidden]{display:none} +/* One event per row: time, source, message; severity tints the row. */ +.log-row{display:grid;grid-template-columns:62px 118px minmax(0,1fr);gap:10px;align-items:baseline; + padding:3px 8px;border-radius:6px;border-left:2px solid transparent} +.log-row[hidden]{display:none} +.log-row:hover{background:rgba(255,255,255,.03)} +.log-t{color:var(--fg-4);text-align:right;font-variant-numeric:tabular-nums} +.log-src{justify-self:start;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; + font-size:10.5px;padding:1px 7px;border-radius:999px;background:var(--surface-3);color:var(--fg-3)} +.log-msg{white-space:pre-wrap;overflow-wrap:anywhere} +.log-row.lvl-err{border-left-color:var(--err);background:color-mix(in oklch,var(--err) 7%,transparent)} +.log-row.lvl-err .log-msg{color:color-mix(in oklch,var(--err) 70%,var(--fg))} +.log-row.lvl-warn{border-left-color:var(--warn)} +.log-row.lvl-warn .log-msg{color:var(--warn)} +.log-row.lvl-ok .log-msg{color:color-mix(in oklch,var(--ok) 45%,var(--fg))} +.logs-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px} +.logs-tools[hidden]{display:none} +.logs-chips{display:inline-flex;gap:4px;padding:3px;border-radius:10px;background:var(--surface-2);border:1px solid var(--line)} +.logs-chip{appearance:none;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:7px;padding:6px 11px; + background:transparent;color:var(--fg-3);font:inherit;font-size:12px;font-weight:600;cursor:pointer; + transition:background .15s var(--ease-out),color .15s var(--ease-out)} +.logs-chip:hover{color:var(--fg)} +.logs-chip[aria-pressed="true"]{background:var(--surface-3);color:var(--fg)} +.logs-n{font:600 10.5px/1 'JetBrains Mono',monospace;padding:2px 5px;border-radius:999px} +.logs-n:empty{display:none} +.logs-chip.lvl-warn .logs-n{background:color-mix(in oklch,var(--warn) 20%,transparent);color:var(--warn)} +.logs-chip.lvl-err .logs-n{background:color-mix(in oklch,var(--err) 20%,transparent);color:var(--err)} +.logs-source{width:auto;max-width:190px;height:36px;padding:0 10px} +.logs-search{flex:1;min-width:160px;height:36px} +.logs-nomatch{position:absolute;inset:0;display:grid;place-items:center;color:var(--fg-3);font-size:13px;pointer-events:none} +.logs-nomatch[hidden]{display:none} +.logs-latest{position:absolute;right:14px;bottom:14px;appearance:none;border:1px solid var(--line-2);border-radius:999px; + padding:7px 13px;background:var(--surface-3);color:var(--fg);font:inherit;font-size:12px;font-weight:600;cursor:pointer; + box-shadow:0 8px 24px rgba(0,0,0,.45);animation:softRise .18s var(--ease-out);transition:transform .15s var(--ease-out)} +.logs-latest:active{transform:scale(.96)} +.logs-latest[hidden]{display:none} .logs-empty{background:var(--bg-2);border:1px dashed var(--line);border-radius:10px; min-height:420px;display:flex;flex-direction:column;align-items:center;justify-content:center; gap:8px;text-align:center;padding:24px;color:var(--fg-3)} .logs-empty[hidden]{display:none} -.logs-empty-icon{font-size:28px;opacity:.7;margin-bottom:6px} +.logs-empty-icon{width:44px;height:44px;display:grid;place-items:center;border-radius:12px;margin-bottom:6px; + background:var(--surface-3);color:var(--fg-3)} +.logs-empty-icon svg{width:22px;height:22px} .logs-empty-title{font-size:14px;font-weight:600;color:var(--fg-2)} .logs-empty-body{font-size:12.5px;max-width:42ch;line-height:1.5} @@ -1690,7 +2030,6 @@ /* utility */ .muted{color:var(--fg-3);font-size:12.5px} -.row-end{display:flex;justify-content:flex-end;gap:8px} .spacer{flex:1} [hidden]{display:none !important} @@ -1814,6 +2153,1093 @@ .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 + (.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-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; + 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;flex-wrap:wrap;max-width:100%} +.ig-seg .sub-tab{flex:0 0 auto;padding:6px 13px;transition:color .2s var(--ease-out),transform .15s var(--ease-out)} +.ig-seg .sub-tab:active{transform:scale(.96)} +.ig-seg .sub-tab-ind{bottom:auto;opacity:0;transition:left .28s var(--ease-spring),top .28s var(--ease-spring),width .28s var(--ease-spring),opacity .2s var(--ease-out)} +.ig-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} +/* 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: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} +.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;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} +.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: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} +.ig-pv-chat .u1{font-weight:700;color:#ff7eb6} +.ig-pv-chat .u2{font-weight:700;color:#5ac8fa} +.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-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} +.ig-pv-marker .bar{position:relative;height:5px;border-radius:3px;background:var(--surface-3)} +.ig-pv-marker .bar i{position:absolute;left:0;top:0;width:70%;height:5px;border-radius:3px;background:var(--fg-3)} +.ig-pv-marker .bar b{position:absolute;top:-4px;width:3px;height:13px;border-radius:2px;background:#a78bfa} +.ig-pv-file{display:flex;align-items:center;gap:10px} +.ig-pv-file code{padding:5px 8px;border-radius:6px;background:var(--surface-3);font-size:10.5px;color:var(--fg-3)} +.ig-pv-file span{padding:5px 9px;border-radius:6px;background:#000;color:#fff;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ig-pv-action{display:flex;align-items:center;gap:10px;color:var(--fg-2);font-size:12px} +.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.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} +/* 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: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} +.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:2px 9px;border-radius:999px;cursor:pointer; + border:1px solid var(--line);background:transparent;color:var(--fg-2);font:500 12px/1.6 inherit; + transition:background-color .15s var(--ease-out),border-color .15s var(--ease-out),color .15s var(--ease-out),transform .15s var(--ease-out)} +.ig-token:hover,.ig-token:focus-visible{border-color:color-mix(in oklch,var(--accent) 50%,transparent);color:color-mix(in oklch,var(--accent) 75%,#fff); + background:color-mix(in oklch,var(--accent) 10%,transparent)} +.ig-token code{font:500 10.5px/1.6 'JetBrains Mono',monospace;color:var(--fg-4)} +.ig-tokens-group{color:var(--fg-4);font-size:11px;margin-left:4px} +.ig-token:active{transform:scale(.96)} +.ig-token.more{border-style:dashed;border-color:var(--line-2);color:var(--fg-3);font-family:inherit} +.ig-tokens{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11.5px;color:var(--fg-3)} +.ig-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; + 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.ig-instead{margin-top:3px} +.ig-sample.ig-instead b{color:#f0b232} +.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{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; + 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)} +.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); + background:var(--surface-3);color:var(--fg);font:inherit;font-size:12.5px;cursor:pointer; + 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-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))} +.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-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{--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; + 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);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; + /* 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)}} +.ig-cat-tile{padding:8px;gap:10px} +.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: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. */ +.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-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); + 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); + 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} +.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: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);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; + 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,.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} +.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 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} +/* 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} +.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} +/* A converted bot command. */ +.ig-converted{display:flex;align-items:flex-start;gap:8px;padding:9px 11px;border-radius:9px;font-size:12.5px;line-height:1.5;color:var(--fg-2); + background:color-mix(in oklch,var(--live) 9%,transparent);border:1px solid color-mix(in oklch,var(--live) 32%,transparent); + animation:igFadeUp .26s var(--ease-out)} +.ig-converted svg{width:13px;height:13px;flex:none;margin-top:3px;color:var(--live);animation:igPop .3s cubic-bezier(.2,0,0,1)} +.ig-converted code{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--warn)} +@media (prefers-reduced-motion: reduce){.ig-converted,.ig-converted svg{animation:none}} +/* 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); + 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} +.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-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;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} +/* Connections. */ +.ig-conn-body{padding:18px 22px 22px;gap:14px} +.ig-conn-body>.ig-seg{align-self:flex-start} +.ig-conn-pane{display:flex;flex-direction:column} +.ig-conn-in{display:flex;flex-direction:column;gap:12px} +.ig-accts{display:flex;flex-direction:column;gap:8px} +.ig-acct{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;background:var(--bg-2);border:1px solid var(--line)} +.ig-acct.bare{padding:0;border:0;background:none} +.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: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)} +/* 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(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)} +.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{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-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(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)} +.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{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-wrap{position:relative} +.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))} +.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} +/* 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)} +/* Discord's self-updating times (), as Discord draws them. */ +.ig-ts{padding:0 4px;border-radius:4px;background:rgba(255,255,255,.07);color:#f2f3f5;font-variant-numeric:tabular-nums} +.ig-live-upd{display:inline-flex;align-items:center;gap:4px;margin-left:4px;font-size:11.5px;color:#949ba4} +.ig-live-upd svg{width:11px;height:11px} +/* "Show on stream": a scene with the alert card on it. */ +.ig-pv-overlay{align-self:stretch;display:flex;align-items:flex-end;justify-content:center;padding:8px;border-radius:8px; + background:radial-gradient(120% 90% at 20% 10%,#2b3a4d 0,transparent 60%),linear-gradient(135deg,#17202b,#231a2d)} +.ig-pv-alert{position:relative;max-width:92%;min-width:0;padding:5px 10px 7px 12px;border-radius:8px;overflow:hidden; + background:rgba(15,18,24,.92);box-shadow:0 0 0 1px rgba(255,255,255,.09),0 6px 14px -6px rgba(0,0,0,.6)} +.ig-pv-alert::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:#5ac8fa} +.ig-pv-alert small{display:block;font-size:8.5px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#5ac8fa} +.ig-pv-alert b{display:block;color:#f4f6f8;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ig-pv-alert i{position:absolute;left:3px;right:30%;bottom:0;height:2px;background:#5ac8fa;opacity:.55} +.ig-pv.big.ig-pv-overlay{min-height:120px;padding:16px} +.ig-pv.big .ig-pv-alert{padding:9px 16px 11px 18px;border-radius:12px} +.ig-pv.big .ig-pv-alert small{font-size:10px} +.ig-pv.big .ig-pv-alert b{font-size:16px;white-space:normal} +/* The last runs, oldest first. */ +.ig-hist{position:absolute;right:7px;bottom:7px;display:flex;gap:3px;padding:4px 6px;border-radius:999px; + background:color-mix(in oklch,var(--bg-1) 72%,transparent);backdrop-filter:blur(6px);pointer-events:auto} +.ig-hist i{width:5px;height:5px;border-radius:50%;background:var(--live)} +.ig-hist i.failed{background:var(--danger)} +.ig-hist i.stopped{background:var(--fg-4)} +.ig-hist i.busy{background:var(--warn)} +.ig-row .ig-hist{position:static;flex:none;padding:0;background:none;backdrop-filter:none} +/* Hotkey and MIDI pad pickers: the Controls tab's capture fields. */ +.ig-keys{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px} +.ig-key-row{display:flex;align-items:center;gap:6px} +.ig-key{flex:1;min-width:0;justify-content:flex-start} +.ig-key .hk-chip{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:inherit} +/* Ready-made website commands. */ +.ig-starts{display:flex;align-items:center;flex-wrap:wrap;gap:6px} +.ig-starts .ic-label{margin-right:4px} +.ig-start{padding:5px 11px;border-radius:999px;border:1px solid var(--line);background:var(--surface-3);color:var(--fg); + font:600 12px/1.3 'JetBrains Mono',monospace;cursor:pointer; + transition:border-color .15s var(--ease-out),background-color .15s var(--ease-out),transform .12s var(--ease-out)} +.ig-start:hover{border-color:color-mix(in oklch,var(--accent) 55%,var(--line))} +.ig-start:active{transform:scale(.96)} +.ig-start:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.ig-start.on{border-color:color-mix(in oklch,var(--accent) 60%,transparent);background:color-mix(in oklch,var(--accent) 14%,var(--surface-3))} +.ig-setup{display:flex;flex-direction:column;gap:10px} +.ig-quiet{display:grid;grid-template-columns:1fr 1fr;gap:10px;max-width:320px} +.ig-insp-quiet{display:flex;flex-direction:column;gap:8px} +.ig-inline-ic{width:13px;height:13px;margin-right:5px;vertical-align:-2px;color:var(--fg-3)} +.ig-kind:disabled,.ig-ttype:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none} +/* 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} + .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>*,.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){ + .ig-bld,.ig-cat{flex-direction:column} + /* Stacked, the palette, canvas and inspector scroll as one page. */ + .ig-bld{overflow-y:auto} + .ig-canvas,.ig-inspector{overflow-y:visible;flex:none} + .ig-palette,.ig-inspector,.ig-cat-nav{width:auto;border:0;border-bottom:1px solid var(--line)} + .ig-row-name{flex-basis:140px}.ig-row-last{display:none} +} +/* Top-anchored: a modal that grows (a chip opens, a taller tab) grows + downward, instead of re-centering and moving what's under the cursor. */ +.ig-modal{align-items:flex-start;padding-top:max(20px,6vh)} +.ig-modal .dest-form{width:var(--ig-w,min(820px,100%));height:var(--ig-h,auto)} +/* Phones: a full-screen sheet, the header kept to one row, the footer's + buttons wrapping with Save last and widest, and the builder's blocks a + strip to swipe instead of a list to scroll past. */ +@media (max-width:560px){ + .ig-modal{padding:0} + .ig-modal .dest-form{width:100%;height:100dvh} + .ig-modal .dest-form-head{gap:10px} + .ig-modal .dest-form-head .dest-form-mark{display:none} + .ig-modal .dest-form-enable>span:last-child{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} + .ig-modal .dest-form-foot{flex-wrap:wrap;row-gap:10px} + .ig-modal .ig-foot-msg{order:-1;flex-basis:100%} + .ig-modal .dest-form-foot .ic-btn{flex:1 1 auto;justify-content:center} + .ig-palette{flex-direction:row;flex-wrap:nowrap;overflow-x:auto;gap:4px;flex:none} + .ig-palette-hint,.ig-palette .ic-label{display:none} + .ig-palette 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} +} @@ -1850,7 +3276,7 @@ - + @@ -2030,7 +3456,7 @@

Welcome to InstantClone

How to turn it on in OBS

In OBS: if you registered InstantClone as a service (previous step), set Settings → Output → Video Encoder to Multi-track Video with Auto - EB engages on its own.

-

On Custom RTMP, open the InstantClone dashboard after setup and use System → Behavior → OBS quick launch - it starts OBS with the one flag EB needs.

+

On Custom RTMP, open the InstantClone dashboard after setup and use System → Twitch & OBS → OBS quick launch - it starts OBS with the one flag EB needs.

@@ -2045,7 +3471,7 @@

Welcome to InstantClone

+ + + + - - -