diff --git a/.github/ISSUE_TEMPLATE/integrations.yml b/.github/ISSUE_TEMPLATE/integrations.yml index 9adc3df..586e8b4 100644 --- a/.github/ISSUE_TEMPLATE/integrations.yml +++ b/.github/ISSUE_TEMPLATE/integrations.yml @@ -12,8 +12,8 @@ body: 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" + description: Top-right of the dashboard, or the `v0.1.16` in the .exe name. + placeholder: "v0.1.16" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38a587e..4b712ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,12 @@ jobs: - name: test run: cargo test --release + # The dashboard, dock and alerts page scripts, loaded as shipped into + # Node's own test runner (web/tests/page.mjs): no packages to install. + # Here because the runner's shell expands the glob on any Node version. + - name: dashboard script tests + run: node --test web/tests/*.test.mjs + - name: upload binary for e2e uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fca2c82..1b32e5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,16 @@ permissions: # The Twitch app id the Integrations tab logs in with (Device Code flow, a # public client: the id is not a secret). Baked in at compile time through # option_env!; set it as a repository variable named TWITCH_CLIENT_ID. +# +# INSTANTCLONE_RELEASE_BUILD marks the binaries as release builds: only they +# compare their own SHA-256 with the published one to spot a new build of +# the same version (update_check.rs). A local build never matches it. +# INSTANTCLONE_BUILD_COMMIT is the commit a build came from, so an update +# can list what it brings since (the commits on top of it). env: INSTANTCLONE_TWITCH_CLIENT_ID: ${{ vars.TWITCH_CLIENT_ID }} + INSTANTCLONE_RELEASE_BUILD: "1" + INSTANTCLONE_BUILD_COMMIT: ${{ github.sha }} jobs: # Gate 1: tests must pass on the exact tagged commit before we ship. diff --git a/.gitignore b/.gitignore index 91fc311..fe38b15 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ /instantclone.config.json # Any ring-buffer file (custom buffer_path names used in local/e2e runs). *.buf +# Sounds and pictures uploaded for alerts. +/alerts-media/ # Editor / IDE /.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7af62..6cbefe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,445 @@ 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). +## [0.1.16] - Delays up to 1 hour, Twitch events and alerts, smart lights, and two InstantClones side by side + +### Delays up to 1 hour + +The delay stopped at 10 minutes, however big the buffer was. It now goes up +to 1 hour, and the buffer size is what decides how much of that you can use. + +- **The range above the delay field is your buffer's.** It read "0 - 600s" + for everyone. It now shows how much delay your buffer holds at your + bitrate (planned at 10 Mbps until a stream comes in), so a new install's + 300 MB buffer reads about 3 minutes before you go live and about 6 at + 6 Mbps. + Raise the buffer size in **System** for more: a 15-minute delay at 6 Mbps + needs about 690 MB, a full hour about 2.7 GB. +- **One estimate everywhere.** How long the buffer holds is now worked out + once, by the app, from the real buffer file and the bitrate OBS sends + (averaged over 10 seconds so a keyframe's burst doesn't refuse a delay + that fits, and held at its recent peak so a short dip doesn't unlock one + that won't; each new stream is measured afresh). The dashboard and the + dock used their own guess, which + disagreed with the app's below 1 Mbps and read a new buffer size before + the restart that applies it. +- **Every way in refuses a delay the buffer can't hold**, with the size it + would need: the dashboard, the OBS dock, the HTTP API, and now hotkeys, + MIDI and the integrations' "Set the delay" step too, which used to arm it + and run short. All of them take the new 1 hour top. The refusal starts + once OBS's bitrate is measured; before that, a longer delay is flagged + as likely too long and runs at what the buffer holds. +- **`!setdelay` answers with the real limit.** A new `{delay_max}` variable + holds the longest delay the buffer holds right now, and the chat preset + replies "The buffer holds up to {delay_max}s right now" past it. Copies + added before are upgraded when the app starts, unless you edited that + check; the steps it runs when the delay fits stay as you left them. +- **The big delay number fits its card.** On a narrow window it shrinks just + enough to keep "3599.9 s / 3600.0s" on one line. Before, the "/ target" + part was cut off below about 900 px wide, even at 10 minutes. + +### Updates spot a new build of the same version + +A fix republished under the version you already run was invisible: the +update check only compared version numbers. Now, when GitHub's latest +release is your version, the app also compares its own SHA-256 with the one +in that release's `SHA256SUMS.txt`. If they differ, System -> About and the +header offer "New v0.1.x build", and **Install the new build** downloads, +verifies and swaps it like any other update. Only builds from the release +workflow do this: a build you compile yourself never matches the published +one, so it is never offered a replacement. + +### Updates you hear about, and what they bring + +- **Checks on their own.** With "Check for updates automatically" on, the + app checks shortly after it starts and once a day, even with no dashboard + open. On Windows a quiet tray balloon (no sound, held back during focus + assist) says so once per new version, and the tray menu gets an "Update + available" item. "Tell me in the tray" turns the balloon off. +- **What it brings, before and after.** System -> About lists the changes an + update brings since your build. After updating (in the app or by replacing + the exe), an "Updated to v0.1.x" card says what changed, once. +- **A self-update shuts down cleanly first**, like Restart and Quit: + platforms are told the stream ended, logs and integration counts are + saved, and the new exe's checksum is checked again just before the swap. +- **Restart and Update come back with a dashboard password** (they used to + hang on "taking longer than expected"), and the page follows a Web UI + port change instead of polling the old address. + +### Destinations say why they fail + +A wrong stream key or a refused connection used to read "Connecting..." +forever. The card now says what went wrong in plain words ("Stream key +rejected. Check the key in Edit", "The server refused the connection"), +counts down to the next try, and has **Retry now** and **Details**. The +dock's destination list says "Can't connect", with the reason on hover. + +### Two InstantClones side by side + +- **Run two setups at once** from two folders (two channels, two PCs' worth + of scenes). Each keeps its own config, ports and buffer file; the tray + icon names its folder. When a port belongs to the other copy, the prompt + says so and offers the next free one. +- **Opening the same setup twice is safe now.** The second launch opens the + running copy's dashboard and closes, instead of offering to move its + ports and saving them into the shared config. If the running copy isn't + answering (stuck on a question in another window, say), it says so + instead of opening a tab that can't connect. +- **OBS stays pointed where you left it:** a copy never re-registers OBS + over the other copy's registration, and the OBS tab says which copy OBS + points at. +- **Pick each copy's tray colour** in System -> Behavior: blue (the + default), purple, orange, green or pink. Its dashboard tab icon matches, + so each tab pairs with its tray icon at a glance. Two copies left on the + same colour still never match: the second shows the next free one while + both run. +- **Logins no longer fight.** With a password on both copies, logging in to + one used to log you out of the other (browsers share cookies across + ports), and two docks in OBS did the same. Each copy now has its own + login, and docks in OBS stay signed in through the update. +- **Start with Windows works for both.** Switching it on in the second copy + used to take over the first copy's startup entry, and switching it off + removed it. Each copy now has its own. +- **Two copies starting together** (both set to start with Windows, say) no + longer both claim the same port: one gets it, and the other asks, + naming "another InstantClone that is starting up". +- **Two copies never share a buffer file.** A copy whose buffer file the + other one holds says so at start, instead of quietly opening a second + buffer in the folder it was started from. + +### The OBS tab and the dock + +- **The OBS tab, redone.** Registration shows its real state: registered, + out of date (it updates by itself when OBS closes), not registered, or + "Close OBS first", because OBS rewrites its service list when it quits. + Register and the launch buttons are refused while OBS is open. The control + dock has its own card with setup steps, and the Enhanced Broadcasting + launch buttons moved to a collapsed "Legacy" section. Once registered, the + card is just the three steps to stream through it, with Unregister kept + quietly in its footer, away from the main action. +- **The dock, polished** to match the dashboard. It can now change the delay + while armed ("Re-arm at 1800s") or on air ("Adjust to 1800s"), says + "Waiting for OBS" and "Buffer holds only X" like the dashboard, and + manages its slots: new from this layout, rename, duplicate, delete. Delays + are always in seconds, like the dashboard's big number, and its digits + roll when the delay changes. +- **The dock works with a dashboard password.** Its switches (auto-arm, + auto-activate, trace log, crash protection) save through a dock-only + allow-list, and every dock link handed out carries its access token. + +### Look and feel + +- **Settings are switches.** Every checkbox in the dashboard is now drawn as + the same switch a destination uses for Enabled. +- **Things open and close instead of snapping:** the buffer warning under + the delay, the "Updated" card and its header pill, a destination's + details, the restart banner and the dock's notice. +- **About, reordered.** The version and this session's numbers come first, + then what an update brings as Added / Fixed / Faster badges, then the + update settings. "Tell me in the tray" only shows while automatic checks + are on. +- **The big number shows what really runs.** When the buffer can't hold the + armed delay, the dashboard and the dock show the delay viewers really get, + in amber, and the line under it says how much of the armed delay fits. + While it fills it counts toward that, and Activate names it. +- **A delay too long for the buffer is flagged before OBS connects.** The + warning says it's an estimate at a typical bitrate until OBS sends one. +- **The app's own color picker.** Every color opens a picker that matches + the app instead of the browser's: drag for the shade, a hue bar, the hex + value, your recent colors and the app's palette, and a screen eyedropper + where the browser has one. On a phone it's a sheet along the bottom. +- **A clearer Logs tab.** Each line shows your time of day (point at it for + the time since the app started), its source in its own color, and a dot + for how it went. New lines ease in one after another, lines fade out and + back in as you filter, Clear fades them away, "N new lines" appears while + you read further up, filter text is marked, and on a phone the message + gets the full width. Something being retried is a warning, not an error. +- **Better on a phone.** The header is two rows (the name, then the live + pills in one line), the picked tab slides into view in the tab bars, and + an edge that hides more tabs fades out. The Integrations filters sit on + one row with search beside the view switch. +- **Every fold-out animates,** like "Ways to write a value", and anything + that opens below the edge (an editor option, a connection, a fold-out) + glides into view instead of opening out of sight. +- **Polish pass.** Links match the app instead of turning browser purple + once visited. "Experimental" is the same amber badge everywhere. The About + page's GitHub and license buttons look like buttons again. The setup + wizard lights one button at a time. The integration catalog's + Recommended count matches what it shows. On a phone, the Overlay tab, saved + delays and integration packs fit the screen. Copy reads cleaner throughout. + +### Twitch events and more of OBS in integrations + +- **Follows, subs, resubs, gifted subs, bits, raids, channel point rewards + and ad breaks** start integrations now, with their values (`{user}`, + `{tier}`, `{bits}`, `{viewers}`, `{reward}`...). Pick one reward with "Only + if". InstantClone listens on Twitch only while an integration uses one. +- **Twitch alerts** in the catalog: follows, subs, gifted subs, bits and + raids thanked on stream, in the alerts page you add to OBS once. A chat + welcome for raiders is there to switch on. +- **A login from before this asks once.** Raids work right away; the rest + need Twitch's OK to read them. The Integrations tab, the trigger and + Connections say which, with an **Allow** that logs in again as the same + account. +- **OBS steps do much more:** start or stop recording, save the replay + buffer, take a screenshot, mute or unmute a source, switch a filter on + or off, play or stop a media source, and press any OBS hotkey. Sources, + filters and hotkeys are picked from OBS's own lists. +- **OBS events start integrations:** recording starts or stops, a replay + is saved (with its file), a source is muted or unmuted. +- The Webhook preset sends InstantClone's own events, as before: OBS and + Twitch events stay out of it, so it never asks Twitch for anything. +- **Every Twitch event is listened for, even right after a long break.** + Started with a Twitch login that had run out, the first thing InstantClone + asked Twitch for was refused, and that one (usually follows) stayed off + until the next restart. A call refused for an old login now waits for the + refreshed one and tries again. VOD markers and clips made at that moment + are covered too. +- **No gap when Twitch moves you to another server.** Twitch asks now and + then; the old connection keeps delivering until the new one is up, so no + follow or sub is missed in between. +- **The OBS pill tells the truth.** It read "Check" whenever no integration + used OBS. The open dashboard now keeps OBS connected, so it says + "connected", or "Set up" when OBS's WebSocket server is off and nothing + needs it. + +### Alert themes, sounds and your own files + +- **Seven themes for the alerts page, each its own build:** its own + shape, font, sound, way in, life while up and way out. + - **Island:** a black capsule like a phone's live island. It grows out + of a dot, keeps a small equalizer going, and shrinks back into it. + - **Signal:** an esports HUD. A scan line draws the panel, corner + brackets snap out, the words decode from scrambled letters, and it + glitches away. + - **Banner:** a broadcast lower third. It wipes on, with the words + coming up from under the line. + - **Bubble:** a sticker speech bubble. It boings out with confetti, + each word pops, and it bobs while up. + - **Minimal:** just type. A line draws, the words ink in letter by + letter, and the line is the time left. + - **Paper:** a scrapbook clipping on torn paper. It slaps down, the + tape sticks, the title stamps on, a marker highlights each word, and + it peels off. + - **Cinema:** a film's title card. It opens like a shutter, gold lines + draw out, the words fade up in an italic serif under a little film + grain, and it shutters closed. + + **Alerts look** shows them all on the real page. Point at a theme, or + at an entrance, to see it move; **Play it again** shows it come and go. + It changes any part: where cards sit (on a little screen: pick a spot + and the card slides there), size, the font (each one shown in itself), + text, background and accent colors, background opacity, corners, how + they come in, where a picture goes, the theme sound, and custom CSS for + the rest (down to each word and letter). OBS follows as you change it: + no new address to paste. +- **Ten built-in sounds**, and your own: upload MP3, OGG, WAV, M4A or WEBM + sounds and PNG, JPG, GIF or WEBP pictures (up to 10 MB each), or link to + one. Uploads live in `alerts-media` next to your settings, and only + your own pages can read them, not other websites. +- **Each alert is edited on the real page:** its small title, text, + accent, sound ("Theme sound" by default) with its volume, picture and + how long it stays up, with **Play it** to see its entrance. **Use on + every alert here** gives all of an integration's alerts the same one. +- **Show it on stream** puts the alert you're editing on your alerts page + in OBS, with sample values, so you see and hear it for real. +- **Add to OBS in one click.** The alerts page goes into the scene on air, + as big as the canvas, its sound going through OBS. One already in OBS is + reused, whatever it's called. +- **Shared alerts look like the sharer's.** A recipe with alerts brings + their theme and the sounds and pictures they use (up to 4 MB). On import + you choose whether to use their look; their files join your uploads, + and anything too big to come along falls back to the theme sound, or no + picture. Their custom CSS comes along only if it loads nothing from the + web, and pictures or sounds they linked from the web stay behind too, so + a shared recipe can't reach another site from your stream. +- **The raid alert can be edited.** Its editor showed the chat welcome, + which starts switched off, instead of the alert. +- **The alerts page in OBS updates itself.** After InstantClone updates, + the page reloads on its own instead of drawing new looks with the old + page until the source is refreshed by hand. +- **"1 viewer", not "1 viewers".** `{viewers#viewer#viewers}` writes the + count and the word that fits it, in any language; Twitch alerts use it + for raids, gifted subs, bits and resub months. + +### Connections on one screen + +- Discord, Twitch, Phone, OBS and the alerts page are one list, each + saying how it stands and opening in place, instead of five tabs. The + alerts page used to hide behind the other buttons; the Overlay tab + points to it too. + +### Smart lights and plugs, no hub needed + +- **A Smart light step** turns a light or plug on your network on or off, + colors it, or flashes it. **Find lights on my network** finds WiZ, LIFX, + Govee (LAN Control on), Yeelight (LAN Control on), older TP-Link Kasa, + Shelly and WLED in a few seconds, with no account or cloud; **Flash** + shows which device is which. Each brand is reached the way the + open-source library Home Assistant uses for it does. +- Color and brightness can come from chat (`!light blue`); which light + never can. +- A light that got a new address from the router is found again by its + id, without editing anything. +- **The On-air light** now uses a light on your network. Home Assistant + and Philips Hue are still there, and On-air lights you already have keep + working as they are. +- A device that never answers a command is given up on after a few + seconds, and Shelly and WLED are only ever asked directly: an answer + pointing somewhere else counts as a no. + +### Integrations + +- **Why a run failed:** the editor's "Last ran · failed" and every Recent + activity row open that run's steps, with Copy run log and Report it. +- **Unfinished builds save as drafts**, switched off, instead of failing. +- **Two integrations can't answer the same chat command:** a clash is caught + on save and switch-on, and a second copy of a command is added switched off. +- **Mistyped variables are flagged as you type**, with "Did you mean...?". +- **Check OBS:** a step that keeps what OBS is doing for the steps after + it: `{obs.scene}`, `{obs.streaming}`, `{obs.recording}`, and for a + source `{obs.muted}` and `{obs.visible}`. `!mic` can answer whether the + mic is muted, and an If step can act on it. Each check keeps its answer + under its own name (`obs`, `obs2`, or one you pick), so two checks never + overwrite each other; two steps sharing a name get a one-click fix. +- **Yes or no in words:** `{obs.muted?muted:unmuted}` writes the first + text when the value is yes and the second otherwise, in any template. + Clicking a yes or no value writes it this way, ready to change, and + "Ways to write a value" under the values shows every form. +- **One OBS step, several sources:** mute, unmute, show, hide or play + as many sources as you pick, and Check OBS reads them all: `{obs.muted}` + says whether all are, and each has its own, like `{obs.mic_aux.muted}`. + Scenes, sources, filters and hotkeys are picked from one list with a + find box, and a name OBS doesn't have yet can still be typed. +- **A roomier, more visual builder:** it uses most of the screen; blocks + and steps wear their icon, each step says its kind above what it does, + the settings panel is wider, and the canvas keeps the steps in one + readable column. +- **If / otherwise as two lanes:** Then and Otherwise sit side by side + under the check, green and amber, one over the other when there's no + room. One If holds up to four checks, all or any of them true, and a yes + or no value gets yes and no to click. +- **Drag and drop in the builder, like building blocks:** drag a block + from the list into any place in the steps, or drag a step up, down, into + or out of an If's lanes. The step leaves the stack and shrinks into a + small card in your hand; a socket opens where it would land, pushing the + others aside, and it drops into place when let go. Near the top or bottom + the steps scroll; Escape cancels. A tap still adds or picks, on a touch + screen too. +- **Discard, right next to Save:** it shows as soon as something changes + and puts the blocks back one by one, the added ones out, the moved ones + to their saved places, the removed ones back in, without closing the + editor; the toast has Undo. A new integration that was never saved is + thrown away. +- **An If inside an If inside an If:** the builder now counts an If's own + lanes when nesting, so it no longer lets a fourth level be made that + then can't be saved. +- **The page under the builder no longer scrolls** when a pane reaches its + end or the wheel is over the header. +- **Values grouped by where they come from:** what started the run, each + earlier step under its answer's name, and the always-there ones folded + away. +- **A program's answer:** Run a program can wait for it (10 s at most, no + window) and keep what it printed, `{program.output}`, with + `{program.code}` and `{program.ok}`, like a web request's answer. +- **InstantClone restarting during an Enhanced Broadcasting stream** now + says what to do: Twitch won't take that session back, so the card and + the Restart and Update questions say to stop and start streaming in OBS + (it read as a wrong stream key). +- **OBS connects when its settings file can't be found,** as with a + portable OBS: InstantClone tries OBS's own defaults (port 4455, no + password) instead of giving up. +- OBS WebSocket status in Connections, Duplicate lands next to the original, + pasted recipes preview at once, tests default to a dry run, and copying + works when the dashboard is opened from another device. +- **Scene delay keeps its cut:** going from the lobby to the menu used to run + "Back to live after this airs" twice, and the second run cancelled the + first. An integration now keeps a cut that is already waiting. +- **`!delay` and `{delay}` say "off" with no delay on air.** They used to + read the stream's own second of lag and answer "1s". +- **`!setdelay 0` answers "Delay turned off by ..."** instead of "Delay set + to 0s". Mod controls you already have learn it on the next start. +- **Going back to 0.1.15?** It can't read an integration that uses + something new here (a Smart light step, the new OBS steps or triggers, + Twitch events): it leaves those out, and its next save removes them. + Export them as recipes first, to import them again on 0.1.16. + +### Smaller things + +- **Saving a destination no longer empties OBS's stream settings.** With + the registered InstantClone service picked, a cleanup for a setting + from v0.1.0 to 0.1.2 cut OBS's one-line service file down to nothing; + if OBS was closed at the time, it opened with no stream service. The + cleanup now only touches the old Custom-server leftover. +- **An overlay can be called "Reset".** Saving one with that name wiped + every Studio overlay and still said "Saved" (and "Seeded" saved + nothing). A rename whose save failed also deleted the overlay; it now + keeps it. A second overlay with a name already in use gets its own + file, instead of writing over the first. +- A step inside the third nested check in the builder saves again. It + was refused with "checks nest more than 4 deep", though the builder + allowed it. +- **A delay switched on while the buffer fills now starts by itself.** + The "Toggle delay" hotkey or MIDI button said "the delay starts as + soon as it is ready", and then it only did with "Auto-activate when + buffer ready" on: otherwise it armed and the stream stayed live. It now + starts either way, and so does the HTTP API's one-step `POST /delay`. + A cut or a disarm before then still calls it off. +- Deleting a destination that isn't on air, a saved delay or an + integration happens at once, with **Undo**, instead of asking first. + One that is on air still asks, as Undo can't give its viewers the + stream back. +- **The Logs tab fits the window.** Its console fills the card below the + filters and is the only thing that scrolls. It used to be taller than the + card on most screens, so the card scrolled too, and the wheel over the + console never reached it. +- **Switching tabs is smooth.** The card eases straight to the new tab's + height, including one whose list arrives a moment later, instead of + shrinking first and then jumping. The tab highlight stops on the tab + instead of sliding past it, and the card leaves room for the footer, so + the page itself no longer scrolls. +- A destination whose address can't work says "Not connected" on its card + instead of "Connecting...": nothing tries again until it is edited. +- Click a line in the Logs tab to copy it; **Copy problems** copies every + warning and error at once. +- On a phone, a slim bar keeps the delay and its main button at the + bottom of the screen once the delay card scrolls away. +- Every tab is titled after its name in the tab bar, and System has a + line saying what it holds. +- Messages like "Back to live" and "Disarmed" only show when it really + happened; a failure says why. Error toasts stay longer, can be closed, + and are read out by screen readers. +- An auto-armed delay longer than the buffer holds is now flagged on the + dashboard, the dock and in a quiet tray balloon, instead of only the log. +- **Copy diagnostics** in the Logs tab copies version, settings, state and + logs with every secret removed, ready for a bug report. A crash now leaves + an `instantclone-crash.log` beside the config. +- The tray's Copy RTMP URL gives the same `localhost` address as the + dashboard, its links follow a port change, and its Cut goes through the + normal path (logged and saved). +- Windows: opening the dashboard (from the tray, or on first start) and + "Make a desktop shortcut" no longer flash a console window. +- **Twitch VOD audio is no longer marked Experimental.** +- Notices in the corner are as wide as their text and line up on the right, + with the close button at their end. The crash protection "Hold for" + slider and the alert look's colour dots are drawn like the rest. +- Linux: `systemctl stop` shuts down cleanly, and delay changes from chat or + hotkeys are saved. Windows logoff or shutdown also shuts down cleanly. +- Linux: closing the terminal InstantClone logs to (or a `| tee` that + exits) no longer stops it mid-stream. Its log lines are dropped instead. +- "Show me" in the buffer warning opens the buffer setting with the size it + needs filled in, without saving anything. The restart banner survives a + page reload. +- **Reset settings follows the dashboard** to its default port, instead of + reloading the old address that no longer answers. +- New destinations get a name from their platform, Enter saves the form, + switching off a live destination asks first, and card buttons are + reachable on touch screens. A 15-minute profile is added to new installs. +- Compatibility warnings name the exact OBS setting to change. +- With a delay longer than the buffer holds, the tray's status and the log + name the delay that really runs. Copy diagnostics gives the buffer size + in use, and a size saved for after a restart apart. + ## [0.1.15] - Crash protection, Integrations (experimental), vertical without Twitch, and Enhanced Broadcasting on your GPU ### Crash protection: OBS crashes, the stream stays up diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4810015..7552d0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Open a [Discussion](https://github.com/Soulhackzlol/InstantClone/discussions). I ## Sending a PR - One change per PR makes review easier. -- Before pushing: `cargo fmt`, `cargo clippy --release` clean, `cargo test --release` showing `154 passed; 0 failed` (or higher if the suite has grown since this line was last touched). +- Before pushing: `node scripts/check.mjs` runs what CI runs (format, lint, the Rust tests and the page tests) in one terminal, and has to end with every check passed. - A line on how you tested goes a long way. "Tested with OBS 30.2 against a local RTMP sink for ~5 min with a 15 s armed delay" beats "works on my machine". - For new features, open an issue first so we don't duplicate effort. diff --git a/Cargo.lock b/Cargo.lock index 03ed2d8..869194d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "instantclone" -version = "0.1.15" +version = "0.1.16" dependencies = [ "bytes", "flate2", diff --git a/Cargo.toml b/Cargo.toml index e280172..de31242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "instantclone" -version = "0.1.15" +version = "0.1.16" edition = "2021" authors = ["s1moscs"] license = "GPL-3.0-only" diff --git a/README.es.md b/README.es.md index b9b0fb1..229e858 100644 --- a/README.es.md +++ b/README.es.md @@ -79,7 +79,7 @@ Esa es toda la instalación. En Windows un icono queda en la bandeja mientras co **2 · Apunta OBS aquí** -Pulsa **Registrar con OBS** en el panel, reinicia OBS una vez y en **Ajustes → Emisión** elige: +Cierra OBS, pulsa **Register with OBS** en la pestaña **OBS** del panel, abre OBS y en **Ajustes → Emisión** elige: ```text Servicio: InstantClone @@ -109,7 +109,7 @@ El modo multipista "Auto" funciona de fábrica. Tus claves reales van en la pest > [!IMPORTANT] > El Firewall de Windows preguntará al primer arranque porque el proxy escucha en :1935 (RTMP) y :7799 (web). Permítelo solo en **redes privadas**. -> [!WARNING] +> [!NOTE] > Windows 10/11 y Linux (x86-64) están soportados. En Linux funciona headless en un VPS o en un escritorio Ubuntu; el panel web del navegador es la superficie de control (no hay bandeja nativa). macOS todavía no está soportado.
@@ -213,14 +213,14 @@ Si OBS se cuelga o se congela, todos los destinos siguen en directo con una pant **🧩 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. +InstantClone habla con Discord, el chat de Twitch, tu móvil, OBS y tus propias herramientas. Puede arrancar con follows, subs, subs regaladas, bits, raids, recompensas de puntos de canal y pausas publicitarias, y con la grabación de OBS, las repeticiones o un micro silenciado. Con un clic añades un aviso de crasheo, alertas de Twitch en pantalla, 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, hasta las escenas, filtros, grabación y atajos de OBS, y luces y enchufes inteligentes de tu red (WiZ, LIFX, Govee, Yeelight, Kasa, Shelly, WLED) sin hub. Son nuevas y aún pueden cambiar: cuéntanos lo que falle o confunda con el enlace **Report it** de la pestaña. > [!TIP] -> **Registro de OBS de un clic.** El asistente puede añadir una entrada "InstantClone" al desplegable de Servicio de OBS por ti (escribe `services.json` con un `.bak` antes, se refresca al cambiar el puerto y avisa "cierra OBS primero" cuando el archivo está bloqueado). +> **Registro de OBS de un clic.** El asistente puede añadir una entrada "InstantClone" al desplegable de Servicio de OBS por ti (escribe `services.json` con un `.bak` antes). Solo escribe con OBS cerrado, porque OBS reescribe su lista de servicios al salir; tras un cambio de puerto, una entrada desactualizada se actualiza sola cuando OBS se cierra.
@@ -258,7 +258,7 @@ InstantClone habla con Discord, el chat de Twitch, tu móvil y tus propias herra | | Endpoint | Cuerpo | Qué hace | |:---|---|---|---| -| POST | `/arm` | `ms=15000` | Empieza a llenar un buffer de 15 s. Aún no sale. | +| POST | `/arm` | `ms=15000` | Empieza a llenar un buffer de 15 s, hasta 1 hora (`ms=3600000`). Aún no sale. 409 si OBS no está emitiendo o si el buffer no lo aguanta. | | POST | `/activate` | | Activa el delay armado. 409 si no está listo. | | POST | `/disarm` | | Cancela el armado, descarta el buffer sin salir. | | POST | `/stop` | | Vuelve a directo (igual que el botón **Cortar**). | @@ -303,8 +303,7 @@ Armado de un botón. Añade `/activate` y `/stop` a otros dos botones para contr "egress_alive": true, "destinations_alive": 2, "destinations_total": 3, - "bitrate_kbps": 6020, - "stats": { "tags_sent": 184302, "bytes_sent": 1338294104, "cuts": 1 } + "stats": { "tags_sent": 184302, "bytes_sent": 1338294104, "cuts": 1, "bitrate_kbps": 6020 } } ``` @@ -335,12 +334,12 @@ Armado de un botón. Añade `/activate` y `/stop` a otros dos botones para contr
- +
RSS inactivo~9 MBHilos1 tokio + 1 bandejaDeps runtimetokio, bytes, ureqTests392 / 392
RSS inactivo~9 MBHilos1 tokio + 1 bandejaDeps runtimetokio, bytes, ureqTests900+
-**Buffer.** En disco por defecto (`./instantclone.buf`, 500 MB ≈ 11 min a 6 Mbps, ≈ 6 min 50 s a 10 Mbps), fuera de la RAM porque puede llegar a cientos de MB. Lo único en RAM es el índice de IDR, ~1 MB para 10 minutos a 60 fps. El archivo se reinicia en cada apagado limpio, así que nada se acumula entre sesiones, y la interfaz se niega a armar un delay mayor del que cabe, con un motivo explícito "necesita ≥ N MB". +**Buffer.** En disco por defecto (`./instantclone.buf`; una instalación nueva empieza con 300 MB, menos si el disco está casi lleno: unos 6 min 30 s a 6 Mbps, 3 min 50 s a 10 Mbps), fuera de la RAM porque puede llegar a cientos de MB. Lo único en RAM es el índice de IDR, ~1 MB para 10 minutos a 60 fps. El archivo se reinicia en cada apagado limpio, así que nada se acumula entre sesiones, y toda forma de armarlo (panel, dock, API, atajos, MIDI, chat) se niega a armar un delay que el buffer no aguanta en cuanto mide el bitrate de OBS, y dice qué tamaño necesita. El delay llega hasta 1 hora; el tamaño del buffer decide cuánto de eso puedes usar (un delay de 15 min a 6 Mbps necesita unos 690 MB). -**Compilar.** Rust 1.74+ estable. Sin npm, sin submódulos, sin SDKs de plataforma. +**Compilar.** Rust 1.89+ estable. Sin npm, sin submódulos, sin SDKs de plataforma. ```powershell git clone @@ -353,7 +352,7 @@ El HTML del panel se minifica + gzipea en tiempo de compilación con `build.rs` **E/S de disco síncrona en la ruta caliente de escritura al anillo, por elección.** La escritura con buffer aterriza en la caché de páginas del SO en microsegundos y el kernel vacía en segundo plano, así que la caché de páginas ya es el buffer asíncrono; el índice y los bytes avanzan bajo un solo lock para que un lector nunca vea una etiqueta cuyos bytes aún no están en disco. -**Tests.** `cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR de AVC + Enhanced-RTMP, AMF0 (incluido Strict Array + guardia de recursión), round-trip de settings, expulsión del buffer en anillo con protección de lecturas en vuelo, parseo HTTP, política CSRF, pre-flight de puerto, negociación de contenido, caché de cabeceras de secuencia por pista de Enhanced Broadcasting + selección de etiquetas por TrackId, audio multipista + ruteo por destino, parseo de orientación SPS para la selección vertical, la configuración de Enhanced Broadcasting construida a partir de los lienzos y codificadores GPU de OBS, el parcheador de `services.json`, el parser del check de actualizaciones, el SHA-256 hecho a mano (vectores NIST), el lector/escritor de chunk-stream RTMP, la máquina del corte programado, las tablas de atajos y de mapeos MIDI (incluido el dispositivo que distingue dos controladoras), la protección contra crasheos (distinguir crasheo, parada y congelado, el codificador sin pérdidas de la pantalla de reconexión comprobado con ffmpeg, el empaquetado por pista de Enhanced Broadcasting, el AAC en silencio), 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.** +**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, alertas en el stream, la línea de tiempo del stream, acciones y eventos de OBS, Twitch EventSub), y la descarga de autoactualización + verificación de checksum + intercambio del exe. **873 tests, todos en verde.** Los scripts del dashboard, el dock y la página de alertas tienen 55 más: `node --test web/tests/*.test.mjs`, sin instalar nada. @@ -363,11 +362,11 @@ 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`) + 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). +**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`) + 900+ 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** -- **Sin bandeja nativa en Linux.** En Windows hay un icono en la bandeja del sistema; en Linux la superficie de control es el panel web (Salir y Reiniciar están en su pestaña Sistema). macOS aún no está soportado. +- **Sin bandeja nativa en Linux.** En Windows hay un icono en la bandeja del sistema; en Linux la superficie de control es el panel web (Salir y Reiniciar están en su pestaña System → About). macOS aún no está soportado. - **La escalera de transcodificado no está garantizada sin EB.** Solo los Twitch Partner tienen slot de transcodificado siempre; el resto se queda en Source-Only, donde algunos decodificadores por hardware fallan por encima de ~8 Mbps (es el comportamiento de asignación de Twitch, no del proxy). Para una escalera garantizada usa Enhanced Broadcasting; si no, mantén el bitrate cerca de ~6000 Kbps. - **Servidor HTTP hecho a mano.** Binario más pequeño que con `hyper`, pero soy dueño de toda la superficie HTTP. A revisar si crece. @@ -403,7 +402,7 @@ Sí. El buffer está antes del reparto, así que cada destino reproduce desde la
-El que aguante el buffer. El archivo de 500 MB por defecto son unos 11 minutos a 6 Mbps (menos a bitrates más altos). Puedes hacerlo más grande; el panel rechaza un delay que el buffer no aguanta y te dice cuántos MB necesita, así que nunca se atasca en silencio. +El que aguante el buffer, hasta 1 hora. El archivo de 300 MB de una instalación nueva son unos 6 minutos y medio a 6 Mbps (menos a bitrates más altos). Puedes hacerlo más grande; el panel rechaza un delay que el buffer no aguanta y te dice cuántos MB necesita, así que nunca se atasca en silencio. @@ -432,7 +431,7 @@ En la pestaña **Destinos** de InstantClone, nunca en OBS. OBS solo apunta a Ins
-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. +Sí, ambas cosas. Seis acciones se asignan a un atajo de teclado global, a un pad o knob MIDI, o a los dos, en **System → Controls**: delay on/off, armar, activar, cortar a directo, **cortar cuando esto salga** y terminar la protección contra crasheos. 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. @@ -441,7 +440,7 @@ Sí, ambas cosas. Seis acciones - delay on/off, armar, activar, cortar a directo
-Windows 10/11 y **Linux (x86-64)** lo ejecutan. En Linux funciona headless en un VPS o en un escritorio Ubuntu, gobernado desde el panel web (no hay bandeja nativa, así que Salir y Reiniciar viven en la pestaña Sistema del panel). Para una instalación expuesta a la red, activa la contraseña opcional del panel y acompáñala de TLS mediante un proxy inverso. macOS aún no está soportado. +Windows 10/11 y **Linux (x86-64)** lo ejecutan. En Linux funciona headless en un VPS o en un escritorio Ubuntu, gobernado desde el panel web (no hay bandeja nativa, así que Salir y Reiniciar viven en la pestaña System → About del panel). Para una instalación expuesta a la red, activa la contraseña opcional del panel y acompáñala de TLS mediante un proxy inverso. macOS aún no está soportado. diff --git a/README.md b/README.md index 1a0cd90..9be3fe9 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Linux: chmod +x instantclone-*-linux-x64 → run it. Dashboard opens at http://127.0.0.1:7799 ``` -That's the whole install. On Windows a tray icon sits in the systray while it runs; right-click for the dashboard, dock, one-click **Cut**, or **Quit**. Closing the tab doesn't kill the proxy, only Quit does. Linux has no tray, so the dashboard is the whole control surface and Quit and Restart live in its System tab. +That's the whole install. On Windows a tray icon sits in the systray while it runs; right-click for the dashboard, dock, one-click **Cut**, or **Quit**. Closing the tab doesn't kill the proxy, only Quit does. Linux has no tray, so the dashboard is the whole control surface and Quit and Restart live in its System → About tab. First launch, Windows SmartScreen may say "unknown publisher" because the build isn't code-signed yet. Click **More info → Run anyway**, or check it against the `SHA256SUMS.txt` on the release. @@ -79,7 +79,7 @@ That's the whole install. On Windows a tray icon sits in the systray while it ru **2 · Point OBS at it** -Click **Register with OBS** in the dashboard, restart OBS once, then in **Settings → Stream** pick: +Close OBS, click **Register with OBS** in the dashboard's **OBS** tab, then open OBS and in **Settings → Stream** pick: ```text Service: InstantClone @@ -213,14 +213,14 @@ If OBS crashes or freezes, every destination stays live on a reconnect screen (f **🧩 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. +InstantClone talks to Discord, Twitch chat, your phone, OBS and your own tools. It can start on follows, subs, gifted subs, bits, raids, channel point rewards and ad breaks, and on OBS recording, replays or a muted mic. One click adds a crash alert, on-stream Twitch alerts, 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, down to OBS scenes, filters, recording and hotkeys, and smart lights and plugs on your network (WiZ, LIFX, Govee, Yeelight, Kasa, Shelly, WLED) with no hub. They're new and may still change: please report what breaks or confuses you with the tab's **Report it** link. > [!TIP] -> **One-click OBS registration.** The setup wizard can add an "InstantClone" entry to OBS's Service dropdown for you (writes `services.json` with a `.bak` first, refreshes on port change, and warns "close OBS first" when the file is locked). +> **One-click OBS registration.** The setup wizard can add an "InstantClone" entry to OBS's Service dropdown for you (writes `services.json` with a `.bak` first). It only writes while OBS is closed, because OBS rewrites its service list when it quits; after a port change, an entry left out of date updates by itself once OBS closes.
@@ -258,7 +258,7 @@ InstantClone talks to Discord, Twitch chat, your phone and your own tools. One c | | Endpoint | Body | What it does | |:---|---|---|---| -| POST | `/arm` | `ms=15000` | Start filling a 15 s buffer. Not live yet. | +| POST | `/arm` | `ms=15000` | Start filling a 15 s buffer, up to 1 hour (`ms=3600000`). Not live yet. 409 while OBS isn't streaming or once the buffer can't hold it. | | POST | `/activate` | | Activate the armed delay. 409 if not ready. | | POST | `/disarm` | | Cancel arming, drop the buffer without going live. | | POST | `/stop` | | Cut back to live (same as the **Cut** button). | @@ -303,8 +303,7 @@ One-button arming. Add `/activate` and `/stop` to two more buttons for full dela "egress_alive": true, "destinations_alive": 2, "destinations_total": 3, - "bitrate_kbps": 6020, - "stats": { "tags_sent": 184302, "bytes_sent": 1338294104, "cuts": 1 } + "stats": { "tags_sent": 184302, "bytes_sent": 1338294104, "cuts": 1, "bitrate_kbps": 6020 } } ``` @@ -335,12 +334,12 @@ One-button arming. Add `/activate` and `/stop` to two more buttons for full dela
- +
Idle RSS~9 MBThreads1 tokio + 1 trayRuntime depstokio, bytes, ureqTests392 / 392
Idle RSS~9 MBThreads1 tokio + 1 trayRuntime depstokio, bytes, ureqTests900+
-**Buffer.** Disk-backed by default (`./instantclone.buf`, 500 MB ≈ 11 min at 6 Mbps, ≈ 6 min 50 s at 10 Mbps), kept off RAM because it can run to hundreds of MB. The only thing in RAM is the IDR index, ~1 MB for 10 minutes at 60 fps. The file resets on every clean shutdown, so nothing accumulates between sessions, and the UI refuses to arm a delay larger than the buffer can hold, with an explicit "needs ≥ N MB" reason. +**Buffer.** Disk-backed by default (`./instantclone.buf`; a new install starts at 300 MB, less on a nearly full disk: about 6 min 30 s at 6 Mbps, 3 min 50 s at 10 Mbps), kept off RAM because it can run to hundreds of MB. The only thing in RAM is the IDR index, ~1 MB for 10 minutes at 60 fps. The file resets on every clean shutdown, so nothing accumulates between sessions, and every way of arming (dashboard, dock, API, hotkeys, MIDI, chat) refuses a delay the buffer can't hold once OBS's bitrate is measured, and says what size it needs. Delays go up to 1 hour; the buffer size decides how much of that you can use (a 15 min delay at 6 Mbps needs about 690 MB). -**Build.** Rust 1.74+ stable. No npm, no submodules, no platform SDKs. +**Build.** Rust 1.89+ stable. No npm, no submodules, no platform SDKs. ```powershell git clone @@ -353,7 +352,7 @@ The dashboard HTML is minified + gzipped at build time by `build.rs` (`flate2`, **Sync disk I/O on the ring-append hot path, by choice.** The buffered write lands in the OS page cache in microseconds and the kernel flushes in the background, so the page cache is already the async buffer; the index and the bytes advance under one lock so a reader never sees a tag whose bytes aren't on disk yet. -**Tests.** `cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced-RTMP IDR detection, AMF0 (including Strict Array + recursion guard), settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, multi-track audio + per-destination routing, SPS orientation parsing for vertical selection, the Enhanced Broadcasting config built from OBS's canvases and GPU encoders, the OBS `services.json` patcher, the update-check parser, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer, the scheduled safe-cut state machine, the hotkey and MIDI binding tables (including the device that tells two controllers apart), crash protection (crash vs. stop vs. freeze detection, the lossless reconnect-screen encoder checked against ffmpeg, per-track framing for Enhanced Broadcasting, silent AAC), 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.** +**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, on-stream alerts, the stream timeline, OBS actions and events, Twitch EventSub), and the self-update download + checksum-verify + exe swap. **873 tests, all green.** The dashboard, dock and alerts page scripts have 55 more of their own: `node --test web/tests/*.test.mjs`, nothing to install. @@ -363,11 +362,11 @@ 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`) + 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). +**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`) + 900+ 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** -- **No native tray on Linux.** Windows has a system-tray icon; on Linux the control surface is the web dashboard (Quit and Restart live in its System tab). macOS isn't supported yet. +- **No native tray on Linux.** Windows has a system-tray icon; on Linux the control surface is the web dashboard (Quit and Restart live in its System → About tab). macOS isn't supported yet. - **Transcoded ladder isn't guaranteed without EB.** Only Twitch Partners get a transcode slot every time; everyone else stays Source-Only, where some hardware decoders fail above ~8 Mbps (Twitch's allocation behaviour, not the proxy). For a guaranteed ladder use Enhanced Broadcasting; otherwise keep bitrate near ~6000 Kbps. - **Hand-rolled HTTP server.** Smaller binary than `hyper`, but I own the entire HTTP surface. Worth re-evaluating if it grows. @@ -403,7 +402,7 @@ Yes. The buffer sits before the fan-out, so every destination plays from the sam
-As much as the buffer holds. The default 500 MB file is about 11 minutes at 6 Mbps (less at higher bitrates). You can make it bigger; the dashboard refuses a delay the buffer can't hold and tells you exactly how many MB it needs, so it never stalls silently. +As much as the buffer holds, up to 1 hour. A new install's 300 MB file is about 6 and a half minutes at 6 Mbps (less at higher bitrates). You can make it bigger; the dashboard refuses a delay the buffer can't hold and tells you exactly how many MB it needs, so it never stalls silently. @@ -432,7 +431,7 @@ In InstantClone's **Destinations** tab, never in OBS. OBS only ever points at In
-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. +Yes, both. Six actions bind to a global keyboard shortcut, a MIDI pad or knob, or both, in **System → Controls**: delay on/off, arm, activate, cut to live, **cut after this airs**, and ending crash protection. 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. @@ -441,7 +440,7 @@ Yes, both. Six actions - delay on/off, arm, activate, cut to live, **cut after t
-Windows 10/11 and **Linux (x86-64)** both run it. On Linux it works headless on a VPS or on an Ubuntu desktop, driven by the browser dashboard (there is no native tray, so Quit and Restart live in the dashboard's System tab). For a network-exposed setup, turn on the optional dashboard password and pair it with TLS via a reverse proxy. macOS isn't supported yet. +Windows 10/11 and **Linux (x86-64)** both run it. On Linux it works headless on a VPS or on an Ubuntu desktop, driven by the browser dashboard (there is no native tray, so Quit and Restart live in the dashboard's System → About tab). For a network-exposed setup, turn on the optional dashboard password and pair it with TLS via a reverse proxy. macOS isn't supported yet. diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..fd2d813 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +disallowed-macros = [ + { path = "std::eprintln", reason = "panics once stderr is gone, and a release build aborts on a panic: use log_line!" }, +] diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..2093588 --- /dev/null +++ b/scripts/check.mjs @@ -0,0 +1,364 @@ +// Every check CI runs before a merge, in one terminal: formatting, lint, +// the Rust tests and the page tests. Tests are named on screen as they +// pass, so it shows what is being proven and not only how much is left. +// +// node scripts/check.mjs run every check +// node scripts/check.mjs --report r.json also save the results as JSON +// node scripts/check.mjs --hold wait for a key before closing +// node scripts/check.mjs --also more.mjs add the checks a module exports +// node scripts/check.mjs --only Lint run the named checks (a,b,c) +// +// Away from a terminal (CI, a pipe) it prints one plain line per check. +// The first check to fail ends the run. No dependencies. +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import { spawn } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const FRAME_MS = 80; +/** How long a test's name stays newest in the list, so it can be read. */ +const NAME_MS = 240; +const NAMES_SHOWN = 4; +const BAR_WIDTH = 32; +/** Width of the lit stretch that crosses the bar while no total is known. */ +const SWEEP_WIDTH = 6; +/** Lines of a failed check's output shown under the verdict. */ +const OUTPUT_KEPT = 40; + +const PLAIN = 'NO_COLOR' in process.env || !process.stdout.isTTY; +const INK = { + accent: [90, 200, 250], + good: [120, 220, 150], + bad: [255, 110, 110], + text: [225, 228, 235], + soft: [150, 157, 168], + faint: [92, 99, 110], +}; +/** Newest test name first, each older one a step dimmer. */ +const FADE = [INK.text, INK.soft, INK.faint, [64, 70, 80]]; + +/** + * A check is a command that must exit clean. `proves` says what a pass + * means. `read`, when a check runs tests, is given each line the command + * prints and counts the tests into the check's tally (see `newTally`). + */ +const CHECKS = [ + { + name: 'Format', + proves: 'the code is laid out the one agreed way', + run: ['cargo', 'fmt', '--all', '--', '--check'], + }, + { + name: 'Lint', + proves: "the compiler's strictest advice finds nothing", + run: ['cargo', 'clippy', '--all-targets', '--', '-D', 'warnings'], + }, + { + name: 'Rust tests', + proves: 'streaming, delay, integrations and server behave', + run: ['cargo', 'test', '--release'], + read: readCargoTest, + }, + { + name: 'Page tests', + proves: "the dashboard's own logic holds, without a browser", + run: [process.execPath, '--test', '--test-reporter=tap', 'web/tests/*.test.mjs'], + read: readTap, + }, +]; + +function newTally() { + return { total: 0, passed: 0, skipped: 0, failed: [], names: [], note: '' }; +} + +/** Count one finished test; `outcome` is `passed`, `skipped` or `failed`. */ +function count(tally, outcome, name, area = '') { + if (outcome === 'failed') tally.failed.push(area ? `${area}: ${name}` : name); + else tally[outcome] += 1; + tally.names.push({ name, area }); +} + +/** `test rtmp::chunk::tests::a_split_header_is_joined ... ok`, from libtest. */ +function readCargoTest(line, tally) { + const running = /^running (\d+) tests?$/.exec(line); + if (running) { + tally.total += Number(running[1]); + return; + } + const test = /^test (.+?)(?: - should panic)? \.\.\. (ok|FAILED|ignored)\b/.exec(line); + if (!test) return; + const parts = test[1].split('::'); + const name = parts.pop().replaceAll('_', ' '); + const area = parts.filter((part) => part !== 'tests').join(' › '); + const outcome = { ok: 'passed', FAILED: 'failed', ignored: 'skipped' }[test[2]]; + count(tally, outcome, name, area); +} + +/** `ok 12 - the delay is worded one way`, from Node's TAP reporter. */ +function readTap(line, tally) { + const test = /^(not )?ok \d+ - (.+?)( # SKIP.*)?$/.exec(line); + if (!test) return; + const outcome = test[1] ? 'failed' : test[3] ? 'skipped' : 'passed'; + count(tally, outcome, test[2]); +} + +/** Run one check to its end, feeding its lines to the tally as they come. */ +function runCheck(check, tally) { + return new Promise((resolve) => { + const [command, ...args] = check.run; + const child = spawn(command, args, { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] }); + const output = []; + const keep = (line) => { + output.push(line); + if (output.length > OUTPUT_KEPT) output.shift(); + }; + readline.createInterface({ input: child.stdout }).on('line', (line) => { + keep(line); + check.read?.(line, tally); + }); + // Cargo reports what it is building here: that is the note shown + // while a check has no test to name yet. + readline.createInterface({ input: child.stderr }).on('line', (line) => { + keep(line); + if (line.trim()) tally.note = line.trim(); + }); + child.on('error', (error) => { + keep(`could not run ${command}: ${error.message}`); + resolve({ ok: false, output }); + }); + child.on('close', (code) => resolve({ ok: code === 0 && tally.failed.length === 0, output })); + }); +} + +function clock(ms) { + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} + +function tallied(tally) { + const skipped = tally.skipped ? `, ${tally.skipped} skipped` : ''; + return `${tally.passed} passed${skipped}`; +} + +/** What a check's row says next to its name, for the state it is in. */ +function detail({ check, state, tally }) { + const counted = tally.names.length; + if (state === 'running' && counted) { + return tally.total ? `${counted} of ${tally.total}` : `${counted} so far`; + } + if (state === 'failed') return tally.failed.length ? `${tally.failed.length} failed` : 'failed'; + if (state === 'passed' && counted) return `${tallied(tally)}: ${check.proves}`; + return check.proves; +} + +function paint(text, ink) { + return !ink || PLAIN ? text : `\x1b[38;2;${ink.join(';')}m${text}\x1b[0m`; +} + +/** A row is `[text, ink]` pairs; cut to `width` so it can never wrap. */ +function draw(row, width) { + let room = width; + let drawn = ''; + for (const [text, ink] of row) { + const shown = text.slice(0, room); + room -= shown.length; + drawn += paint(shown, ink); + } + return drawn; +} + +/** The running check's dot: it breathes between faint and the accent. */ +function pulse(now) { + const lit = 0.5 + 0.5 * Math.sin(now / 260); + return INK.faint.map((dim, i) => Math.round(dim + (INK.accent[i] - dim) * lit)); +} + +function checkRow(result, now) { + const { check, state } = result; + const mark = { + waiting: ['○', INK.faint], + running: ['●', pulse(now)], + passed: ['√', INK.good], + failed: ['×', INK.bad], + }[state]; + const took = state === 'waiting' ? '' : clock(state === 'running' ? now - result.began : result.took); + return [ + [' '], + mark, + [` ${check.name.padEnd(12)}`, state === 'waiting' ? INK.soft : INK.text], + [`${took.padStart(5)} `, INK.faint], + [detail(result), state === 'failed' ? INK.bad : INK.soft], + ]; +} + +/** Move the bar toward the real progress, and name a newer test when due. */ +function advance(ticker, now) { + const { names, total } = ticker.of.tally; + if (total) ticker.fraction += (Math.min(1, names.length / total) - ticker.fraction) * 0.3; + if (names.length === ticker.read || now - ticker.at < NAME_MS) return; + ticker.shown.unshift(names[names.length - 1]); + ticker.shown.length = Math.min(ticker.shown.length, NAMES_SHOWN); + ticker.read = names.length; + ticker.at = now; +} + +function progressBar(fraction) { + const filled = Math.round(fraction * BAR_WIDTH); + return [ + ['█'.repeat(filled), INK.accent], + ['░'.repeat(BAR_WIDTH - filled), INK.faint], + [` ${Math.round(fraction * 100)}%`, INK.soft], + ]; +} + +/** Busy with no total to count toward yet: a lit stretch crosses the bar. */ +function sweepBar(now) { + const start = Math.floor(now / FRAME_MS) % (BAR_WIDTH + SWEEP_WIDTH) - SWEEP_WIDTH; + const from = Math.max(0, start); + const to = Math.min(BAR_WIDTH, start + SWEEP_WIDTH); + return [ + ['░'.repeat(from), INK.faint], + ['█'.repeat(to - from), INK.accent], + ['░'.repeat(BAR_WIDTH - to), INK.faint], + ]; +} + +/** + * The rows under the running check: its progress, then the latest tests, + * each with the part of the app it covers. Always the same height, so the + * list below it stays still. + */ +function tickerRows(ticker, now) { + const { check, tally } = ticker.of; + const indent = ' '; + const note = [[indent + tally.note, INK.faint]]; + if (!check.read) return [note]; + const rows = [[[indent], ...(tally.total ? progressBar(ticker.fraction) : sweepBar(now))]]; + for (let age = 0; age < NAMES_SHOWN; age++) { + const test = ticker.shown[age]; + if (!test) rows.push(age === 0 ? note : []); + else rows.push([[indent + test.name, FADE[age]], [` ${test.area}`, age === 0 ? INK.accent : FADE.at(-1)]]); + } + return rows; +} + +/** Redraws the checks in place until stopped; the last frame stays up. */ +function liveView(results, started) { + const out = process.stdout; + const showCursor = () => out.write('\x1b[?25h'); + let drawn = 0; + let ticker = null; + const show = () => { + const now = Date.now(); + const running = results.find((result) => result.state === 'running'); + if (running && ticker?.of !== running) { + ticker = { of: running, shown: [], read: 0, at: 0, fraction: 0 }; + } + if (running) advance(ticker, now); + const rows = [[], [[' InstantClone checks', INK.text], [` ${clock(now - started)}`, INK.faint]], []]; + for (const result of results) { + rows.push(checkRow(result, now)); + if (result === running) rows.push(...tickerRows(ticker, now)); + } + rows.push([]); + const width = (out.columns || 80) - 1; + const up = drawn ? `\x1b[${drawn}A` : ''; + out.write(up + rows.map((row) => `\r\x1b[2K${draw(row, width)}\n`).join('') + '\x1b[J'); + drawn = rows.length; + }; + out.write('\x1b[?25l'); + process.once('exit', showCursor); + process.once('SIGINT', () => process.exit(130)); + const timer = setInterval(show, FRAME_MS); + return { + stop() { + clearInterval(timer); + show(); + showCursor(); + }, + }; +} + +/** The verdict, and for a failure the tests that failed and what it printed. */ +function verdict(results, started) { + const failed = results.find((result) => result.state === 'failed'); + if (!failed) { + return [paint(` All ${results.length} checks passed in ${clock(Date.now() - started)}`, INK.good)]; + } + return [ + paint(` ${failed.check.name} failed`, INK.bad), + ...failed.tally.failed.map((name) => paint(` × ${name}`, INK.bad)), + '', + ...failed.output.map((line) => ` ${line}`), + ]; +} + +function saveReport(file, results, started) { + const checks = results.map(({ check, state, tally, took, output }) => ({ + name: check.name, + state, + seconds: Math.round((took ?? 0) / 1000), + passed: tally.passed, + skipped: tally.skipped, + failed: tally.failed, + output: state === 'failed' ? output : [], + })); + const ok = results.every((result) => result.state === 'passed'); + const seconds = Math.round((Date.now() - started) / 1000); + fs.writeFileSync(file, `${JSON.stringify({ ok, seconds, checks }, null, 2)}\n`); +} + +function keyPress() { + return new Promise((resolve) => { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.once('data', () => { + process.stdin.setRawMode(false); + process.stdin.pause(); + resolve(); + }); + }); +} + +/** Run the checks in order, stopping at the first failure. True when all pass. */ +async function runChecks(checks, { report, hold }) { + const live = process.stdout.isTTY; + const started = Date.now(); + const results = checks.map((check) => ({ check, state: 'waiting', tally: newTally() })); + const view = live ? liveView(results, started) : null; + for (const result of results) { + result.state = 'running'; + result.began = Date.now(); + const { ok, output } = await runCheck(result.check, result.tally); + Object.assign(result, { state: ok ? 'passed' : 'failed', took: Date.now() - result.began, output }); + if (!live) { + const counted = result.tally.names.length ? `: ${tallied(result.tally)}` : ''; + console.log(`${ok ? 'ok ' : 'FAIL'} ${result.check.name} (${clock(result.took)})${counted}`); + } + if (!ok) break; + } + view?.stop(); + console.log(verdict(results, started).join('\n')); + if (report) saveReport(report, results, started); + if (hold && live && process.stdin.isTTY) { + console.log(paint('\n Press any key to close', INK.faint)); + await keyPress(); + } + return results.every((result) => result.state === 'passed'); +} + +const args = process.argv.slice(2); +const valueOf = (flag) => (args.includes(flag) ? args[args.indexOf(flag) + 1] : null); +const also = valueOf('--also'); +const more = also ? (await import(pathToFileURL(path.resolve(also)).href)).default : []; +const only = valueOf('--only')?.split(','); +const checks = [...CHECKS, ...more].filter((check) => !only || only.includes(check.name)); +if (!checks.length) throw new Error(`no check is named ${only}`); +const passed = await runChecks(checks, { + report: valueOf('--report'), + hold: args.includes('--hold'), +}); +process.exitCode = passed ? 0 : 1; diff --git a/src/auth.rs b/src/auth.rs index cc6ed9b..b9a6d41 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -270,59 +270,61 @@ mod tests { ); } - /// Each failure past the threshold doubles the lockout until it reaches - /// `max_lockout`, where it stays. Read back without sleeping: `check` - /// reports how long is left, which right after arming is the armed value. + /// Failed logins lock a client out for longer each time, up to a cap, and never overflow. #[test] - fn lockout_doubles_past_the_threshold_up_to_the_cap() { - let r = RateLimiter::new( - 2, - Duration::from_secs(10), - Duration::from_secs(45), - Duration::from_secs(3600), - ); - let c = "peer"; - r.record_failure(c); - assert!(r.check(c).is_ok(), "one failure is under the threshold"); - for want in [10, 20, 40, 45, 45] { + fn lockouts_start_at_the_threshold_double_and_stay_bounded() { + // lockout doubles past the threshold up to the cap + // Each failure past the threshold doubles the lockout until it reaches + // `max_lockout`, where it stays. Read back without sleeping: `check` + // reports how long is left, which right after arming is the armed value. + { + let r = RateLimiter::new( + 2, + Duration::from_secs(10), + Duration::from_secs(45), + Duration::from_secs(3600), + ); + let c = "peer"; r.record_failure(c); - assert_lockout(&r, c, want); + assert!(r.check(c).is_ok(), "one failure is under the threshold"); + for want in [10, 20, 40, 45, 45] { + r.record_failure(c); + assert_lockout(&r, c, want); + } } - } - - /// A lockout never outlasts the forgiveness window. `check` forgets an - /// entry once it is older than the window, so a longer lock would be - /// wiped early and the two settings would disagree about when a client - /// may try again. - #[test] - fn lockout_is_clamped_to_the_forgiveness_window() { - let r = RateLimiter::new( - 1, - Duration::from_secs(10), - Duration::from_secs(3600), - Duration::from_secs(25), - ); - let c = "peer"; - for want in [10, 20, 25, 25] { - r.record_failure(c); - assert_lockout(&r, c, want); + // lockout is clamped to the forgiveness window + // A lockout never outlasts the forgiveness window. `check` forgets an + // entry once it is older than the window, so a longer lock would be + // wiped early and the two settings would disagree about when a client + // may try again. + { + let r = RateLimiter::new( + 1, + Duration::from_secs(10), + Duration::from_secs(3600), + Duration::from_secs(25), + ); + let c = "peer"; + for want in [10, 20, 25, 25] { + r.record_failure(c); + assert_lockout(&r, c, want); + } } - } - - /// A client that keeps failing must not overflow the doubling. Without the - /// cap on the shift, the 33rd failure past the threshold is `1u32 << 32`: - /// a panic in a debug build, and in release a shift that wraps back to a - /// short lockout, handing the most persistent guesser the fastest retries. - #[test] - fn endless_failures_do_not_overflow_the_doubling() { - let day = Duration::from_secs(24 * 3600); - let r = RateLimiter::new(1, Duration::from_secs(1), day, day); - let c = "peer"; - for _ in 0..100 { - r.record_failure(c); + // endless failures do not overflow the doubling + // A client that keeps failing must not overflow the doubling. Without the + // cap on the shift, the 33rd failure past the threshold is `1u32 << 32`: + // a panic in a debug build, and in release a shift that wraps back to a + // short lockout, handing the most persistent guesser the fastest retries. + { + let day = Duration::from_secs(24 * 3600); + let r = RateLimiter::new(1, Duration::from_secs(1), day, day); + let c = "peer"; + for _ in 0..100 { + r.record_failure(c); + } + // The shift is capped at 6: base * 64. + assert_lockout(&r, c, 64); } - // The shift is capped at 6: base * 64. - assert_lockout(&r, c, 64); } /// The tracked-client map is bounded, and when a flood fills it the @@ -354,23 +356,4 @@ mod tests { assert!(r.check("client-1").is_err(), "newer entries are kept"); assert!(r.check("newcomer").is_err(), "the newcomer is tracked"); } - - #[test] - fn rate_limiter_locks_and_forgives_on_success() { - let r = RateLimiter::new( - 3, - Duration::from_secs(30), - Duration::from_secs(600), - Duration::from_secs(600), - ); - let c = "peer"; - assert!(r.check(c).is_ok()); - r.record_failure(c); - r.record_failure(c); - assert!(r.check(c).is_ok()); // 2 < 3, still allowed - r.record_failure(c); - assert!(r.check(c).is_err()); // 3rd failure locks - r.record_success(c); - assert!(r.check(c).is_ok()); // success clears the lock - } } diff --git a/src/autostart.rs b/src/autostart.rs index 339f373..33b5526 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -1,4 +1,4 @@ -//! "Start with Windows" - one value under the per-user Run key. +//! "Start with Windows" - one value per copy under the per-user Run key. //! //! The registry is the source of truth here, deliberately: it is not //! mirrored into `Settings`. Windows gives users their own switches for @@ -14,7 +14,9 @@ use std::io; /// Value name under the Run key. Stable - renaming it would strand the old -/// entry and silently start InstantClone twice. +/// entry and silently start InstantClone twice. It stays the first name a +/// copy takes (see `value_names`), so a single copy keeps the entry every +/// earlier build wrote. #[cfg(windows)] const VALUE_NAME: &str = "InstantClone"; @@ -36,25 +38,83 @@ fn run_command() -> io::Result { Ok(format!("\"{}\" --no-browser", exe.display())) } -/// Whether InstantClone is currently registered to start at login. -/// -/// Any failure reads as "not enabled": the value being absent is itself -/// the common failure, and a missing Run key on an exotic Windows install -/// means the same thing to the user either way. +/// The Run values a copy may use, in the order it takes them. Two copies +/// from two folders each need their own: with one shared name, switching +/// it on in the second copy repointed the first copy's entry, and +/// switching it off there deleted it. The second name is readable in +/// regedit ("InstantClone (Second channel)"); the third only breaks a tie +/// between two folders of the same name. #[cfg(windows)] -pub fn is_enabled() -> bool { - is_enabled_under(RUN_KEY) +fn value_names(exe: &std::path::Path) -> [String; 3] { + let folder = exe + .parent() + .and_then(|dir| dir.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let path = exe.to_string_lossy().to_lowercase(); + let id = &crate::sha256::hex(path.as_bytes())[..8]; + [ + VALUE_NAME.to_string(), + format!("{VALUE_NAME} ({folder})"), + format!("{VALUE_NAME} ({folder} {id})"), + ] } -/// `is_enabled` against any HKCU subkey. Split out so the round-trip test -/// can use a private key instead of the developer's real Run key. +/// Who a Run value belongs to, judged by the exe its command starts. #[cfg(windows)] -fn is_enabled_under(subkey: &str) -> bool { +#[derive(Debug, PartialEq, Eq)] +enum Owner { + /// No such value. + Nobody, + /// It starts this exe. + Us, + /// It starts an exe that no longer exists: left by a copy that was moved + /// or deleted, free to take over. + Gone, + /// It starts another copy that is still there. Never touched. + OtherCopy, +} + +/// The exe a Run command starts: the quoted path we write, or the first +/// word of a hand-written unquoted one. +#[cfg(windows)] +fn command_exe(command: &str) -> &str { + let command = command.trim(); + match command.strip_prefix('"') { + Some(rest) => rest.split('"').next().unwrap_or(rest), + None => command.split_whitespace().next().unwrap_or(command), + } +} + +/// Pure apart from `exists`, so the rules are testable without a registry. +#[cfg(windows)] +fn owner_of( + command: Option<&str>, + exe: &std::path::Path, + exists: impl Fn(&std::path::Path) -> bool, +) -> Owner { + let Some(command) = command else { + return Owner::Nobody; + }; + let target = std::path::Path::new(command_exe(command)); + // Windows paths compare without case. + if target.to_string_lossy().to_lowercase() == exe.to_string_lossy().to_lowercase() { + Owner::Us + } else if exists(target) { + Owner::OtherCopy + } else { + Owner::Gone + } +} + +/// A string value under an HKCU subkey, or None when it is missing. +#[cfg(windows)] +fn read_value(subkey: &str, name: &str) -> Option { use windows_sys::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_SZ}; let subkey = wide(subkey); - let name = wide(VALUE_NAME); - // Null output buffer + null size: asks only "does this value exist, - // and is it a string?" without reading the data back. + let name = wide(name); + // First ask the size, then read into a buffer of it. + let mut bytes: u32 = 0; let rc = unsafe { RegGetValueW( HKEY_CURRENT_USER, @@ -63,17 +123,68 @@ fn is_enabled_under(subkey: &str) -> bool { RRF_RT_REG_SZ, std::ptr::null_mut(), std::ptr::null_mut(), + &mut bytes, + ) + }; + if rc != 0 || bytes == 0 { + return None; + } + let mut buf = vec![0u16; (bytes as usize).div_ceil(2)]; + let rc = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + name.as_ptr(), + RRF_RT_REG_SZ, std::ptr::null_mut(), + buf.as_mut_ptr().cast(), + &mut bytes, ) }; - rc == 0 + if rc != 0 { + return None; + } + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + Some(String::from_utf16_lossy(&buf[..len])) +} + +/// Each of this copy's candidate value names with its current owner. +#[cfg(windows)] +fn candidates(subkey: &str) -> io::Result> { + let exe = std::env::current_exe()?; + Ok(value_names(&exe) + .into_iter() + .map(|name| { + let owner = owner_of(read_value(subkey, &name).as_deref(), &exe, |p| p.exists()); + (name, owner) + }) + .collect()) +} + +/// Whether this copy is registered to start at login. Another copy's entry +/// doesn't count: each dashboard shows its own switch. +/// +/// Any failure reads as "not enabled": the value being absent is itself +/// the common failure, and a missing Run key on an exotic Windows install +/// means the same thing to the user either way. +#[cfg(windows)] +pub fn is_enabled() -> bool { + is_enabled_under(RUN_KEY) +} + +/// `is_enabled` against any HKCU subkey. Split out so the round-trip test +/// can use a private key instead of the developer's real Run key. +#[cfg(windows)] +fn is_enabled_under(subkey: &str) -> bool { + candidates(subkey).is_ok_and(|names| names.iter().any(|(_, owner)| *owner == Owner::Us)) } -/// Add or remove the Run-key entry. +/// Add or remove this copy's Run entry. /// /// Enabling always rewrites the command rather than checking first, so an /// entry left behind by a previous install location is corrected instead -/// of pointing at an executable that no longer exists. +/// of pointing at an executable that no longer exists. Another copy's +/// entry is never rewritten or removed. #[cfg(windows)] pub fn set(enabled: bool) -> io::Result<()> { set_under(RUN_KEY, enabled) @@ -83,38 +194,45 @@ pub fn set(enabled: bool) -> io::Result<()> { /// a private key instead of the developer's real Run key. #[cfg(windows)] fn set_under(subkey: &str, enabled: bool) -> io::Result<()> { - use windows_sys::Win32::System::Registry::{ - RegDeleteKeyValueW, RegSetKeyValueW, HKEY_CURRENT_USER, REG_SZ, - }; - let subkey = wide(subkey); - let name = wide(VALUE_NAME); - - let rc = if enabled { - let value = wide(&run_command()?); - // Length is in BYTES and must include the terminating null. - let bytes = (value.len() * std::mem::size_of::()) as u32; - unsafe { - RegSetKeyValueW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - name.as_ptr(), - REG_SZ, - value.as_ptr() as *const std::ffi::c_void, - bytes, - ) + let names = candidates(subkey)?; + if !enabled { + for (name, _) in names.iter().filter(|(_, owner)| *owner == Owner::Us) { + delete_value(subkey, name)?; } - } else { - let rc = unsafe { RegDeleteKeyValueW(HKEY_CURRENT_USER, subkey.as_ptr(), name.as_ptr()) }; - // ERROR_FILE_NOT_FOUND - already absent, which is the state the - // caller asked for. Disabling twice is not an error. - const ERROR_FILE_NOT_FOUND: u32 = 2; - if rc == ERROR_FILE_NOT_FOUND { - 0 - } else { - rc - } - }; + return Ok(()); + } + // Ours if we have one, otherwise the first that is free. + let name = names + .iter() + .find(|(_, owner)| *owner == Owner::Us) + .or_else(|| { + names + .iter() + .find(|(_, owner)| matches!(owner, Owner::Nobody | Owner::Gone)) + }) + .map(|(name, _)| name) + .ok_or_else(|| io::Error::other("every startup entry name is taken by another copy"))?; + write_value(subkey, name, &run_command()?) +} +#[cfg(windows)] +fn write_value(subkey: &str, name: &str, command: &str) -> io::Result<()> { + use windows_sys::Win32::System::Registry::{RegSetKeyValueW, HKEY_CURRENT_USER, REG_SZ}; + let subkey = wide(subkey); + let name = wide(name); + let value = wide(command); + // Length is in BYTES and must include the terminating null. + let bytes = (value.len() * std::mem::size_of::()) as u32; + let rc = unsafe { + RegSetKeyValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + name.as_ptr(), + REG_SZ, + value.as_ptr() as *const std::ffi::c_void, + bytes, + ) + }; if rc == 0 { Ok(()) } else { @@ -122,6 +240,22 @@ fn set_under(subkey: &str, enabled: bool) -> io::Result<()> { } } +#[cfg(windows)] +fn delete_value(subkey: &str, name: &str) -> io::Result<()> { + use windows_sys::Win32::System::Registry::{RegDeleteKeyValueW, HKEY_CURRENT_USER}; + let subkey = wide(subkey); + let name = wide(name); + let rc = unsafe { RegDeleteKeyValueW(HKEY_CURRENT_USER, subkey.as_ptr(), name.as_ptr()) }; + // ERROR_FILE_NOT_FOUND - already absent, which is the state the caller + // asked for. Disabling twice is not an error. + const ERROR_FILE_NOT_FOUND: u32 = 2; + if rc == 0 || rc == ERROR_FILE_NOT_FOUND { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(rc as i32)) + } +} + // ── Linux: a freedesktop autostart entry ──────────────────────────── // // The XDG spec's equivalent of the Run key is a `.desktop` file dropped in @@ -315,15 +449,21 @@ mod tests { /// deleting it leaves nothing behind, and never the real Run key: a test /// that touched that could rewrite or drop the developer's own startup /// entry, and a panic midway would leave it that way. - const TEST_KEY: &str = r"Software\InstantCloneAutostartTest"; + /// A private key per test process, so two runs at once never share one. + fn test_key(name: &str) -> String { + format!( + r"Software\InstantCloneAutostartTest{name}{}", + std::process::id() + ) + } - /// Deletes `TEST_KEY` on drop, so a failed assertion still cleans up. - struct TestKeyGuard; + /// Deletes its key on drop, so a failed assertion still cleans up. + struct TestKeyGuard(String); impl Drop for TestKeyGuard { fn drop(&mut self) { use windows_sys::Win32::System::Registry::{RegDeleteTreeW, HKEY_CURRENT_USER}; - let subkey = wide(TEST_KEY); + let subkey = wide(&self.0); unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, subkey.as_ptr()) }; } } @@ -333,25 +473,86 @@ mod tests { /// developer's own startup. #[test] fn enable_disable_round_trip() { - let _cleanup = TestKeyGuard; + let key = test_key(""); + let _cleanup = TestKeyGuard(key.clone()); assert!( - !is_enabled_under(TEST_KEY), + !is_enabled_under(&key), "stale test key from an earlier run" ); - set_under(TEST_KEY, true).expect("enable"); - assert!( - is_enabled_under(TEST_KEY), - "value must exist after enabling" - ); + set_under(&key, true).expect("enable"); + assert!(is_enabled_under(&key), "value must exist after enabling"); - set_under(TEST_KEY, false).expect("disable"); + set_under(&key, false).expect("disable"); assert!( - !is_enabled_under(TEST_KEY), + !is_enabled_under(&key), "value must be gone after disabling" ); // Disabling an already-absent value is a no-op, not an error. - set_under(TEST_KEY, false).expect("disable twice"); + set_under(&key, false).expect("disable twice"); + } + + /// A second copy (another folder) switching autostart on or off + /// leaves the first copy's entry exactly as it was, and an entry left + /// by a copy that no longer exists is taken over. + #[test] + fn each_copy_has_its_own_startup_entry() { + let key = test_key("Copies"); + let _cleanup = TestKeyGuard(key.clone()); + let key = key.as_str(); + // A file that exists and isn't this exe stands in for the other copy. + let other_exe = + std::env::temp_dir().join(format!("ic-other-copy-{}.exe", std::process::id())); + std::fs::write(&other_exe, b"").unwrap(); + let other = format!(r#""{}" --no-browser"#, other_exe.display()); + let other = other.as_str(); + write_value(key, VALUE_NAME, other).unwrap(); + assert!(!is_enabled_under(key), "the other copy's entry isn't ours"); + + set_under(key, true).expect("enable"); + assert!(is_enabled_under(key)); + assert_eq!( + read_value(key, VALUE_NAME).as_deref(), + Some(other), + "left alone" + ); + + set_under(key, false).expect("disable"); + assert!(!is_enabled_under(key)); + assert_eq!( + read_value(key, VALUE_NAME).as_deref(), + Some(other), + "still there" + ); + + let gone = r#""C:\InstantCloneMovedAway\InstantClone.exe" --no-browser"#; + write_value(key, VALUE_NAME, gone).unwrap(); + set_under(key, true).expect("enable over a gone copy"); + assert_eq!(read_value(key, VALUE_NAME), Some(run_command().unwrap())); + let _ = std::fs::remove_file(&other_exe); + } + + #[test] + fn run_commands_are_read_for_their_exe_owner_and_value_name() { + let exe = std::path::Path::new(r"C:\Streams\Main\InstantClone.exe"); + let ours = r#""c:\streams\main\instantclone.exe" --no-browser"#; + assert_eq!( + owner_of(Some(ours), exe, |_| true), + Owner::Us, + "case-insensitive" + ); + let theirs = r#""C:\Streams\Second\InstantClone.exe" --no-browser"#; + assert_eq!(owner_of(Some(theirs), exe, |_| true), Owner::OtherCopy); + assert_eq!(owner_of(Some(theirs), exe, |_| false), Owner::Gone); + assert_eq!(owner_of(None, exe, |_| true), Owner::Nobody); + assert_eq!( + command_exe(r"C:\Tools\ic.exe --no-browser"), + r"C:\Tools\ic.exe" + ); + let names = value_names(exe); + assert_eq!(names[0], "InstantClone"); + assert_eq!(names[1], "InstantClone (Main)"); + assert!(names[2].starts_with("InstantClone (Main ") && names[2].len() == 28); } } diff --git a/src/buffer.rs b/src/buffer.rs index 6b63b06..cf69b67 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -7,7 +7,9 @@ //! //! The in-memory `VecDeque` is the canonical view of *what* is in the //! buffer. Each entry is 32 bytes, so 10 minutes of audio+video indexes in -//! under 2 MB regardless of the bitrate of the underlying media. +//! about 2 MB (an hour, the longest delay, in about 12 MB; two to three times +//! that with Enhanced Broadcasting's extra video tracks) regardless of the +//! bitrate of the underlying media. use crate::sync::Mutex; use std::collections::VecDeque; @@ -34,6 +36,10 @@ pub struct TagMeta { pub struct DiskRing { file: Mutex, + /// The path and size this ring was created with. Settings can name a + /// different buffer, which only takes effect after a restart; the + /// dashboard compares the two to say so. + path: std::path::PathBuf, capacity: u64, inner: Mutex, @@ -113,6 +119,30 @@ impl DiskRing { // with backoff so a 100 ms AV scan doesn't permanently // block the user's cold start. let mut file = open_with_retry(path)?; + // Ours alone for as long as we run. A second InstantClone pointed at + // the same file (two setups sharing one absolute buffer_path) would + // otherwise resize it under the first one's live delay, write over + // its tags, and delete it on quit. Locked before the resize so the + // loser never touches the length. The OS drops the lock with the + // process, so a crash leaves nothing stale. + match file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => { + return Err(std::io::Error::new( + std::io::ErrorKind::ResourceBusy, + "another InstantClone is using this buffer file: give each copy its own \ + buffer_path", + )); + } + // A filesystem without locks (some network or virtual drives) + // ran fine before the lock existed: carry on without it. + Err(std::fs::TryLockError::Error(e)) => { + log_line!( + "buffer: can't lock {} ({e}), carrying on unlocked", + path.display() + ); + } + } set_len_with_retry(&file, capacity)?; // Seek back to 0 so the first append writes at the start. // open_with_retry positions the cursor at end-of-file when @@ -121,6 +151,7 @@ impl DiskRing { let file = file; // freeze rebinding Ok(Self { file: Mutex::new(file), + path: path.to_path_buf(), capacity, inner: Mutex::new(RingInner { write_cursor: 0, @@ -501,7 +532,24 @@ impl DiskRing { /// reaches back no further at this bitrate. A delay longer than that /// can never fill; the controller then delays by what the ring holds. pub fn is_saturated(&self) -> bool { - self.inner.lock().indexed_bytes >= self.capacity - self.capacity / 16 + self.inner.lock().indexed_bytes >= self.usable_bytes() + } + + /// How many bytes of tags the ring holds before it counts as full (see + /// `is_saturated`). What a delay can use, so the controller's capacity + /// estimate is built on it. + pub fn usable_bytes(&self) -> u64 { + self.capacity - self.capacity / 16 + } + + /// The buffer file in use, as the settings named it at startup. + pub fn path(&self) -> &Path { + &self.path + } + + /// The size of the buffer file in use, in bytes. + pub fn capacity(&self) -> u64 { + self.capacity } } @@ -632,6 +680,26 @@ mod tests { assert_eq!(buf, payload); } + /// Two rings on one file would write over each other's tags, and the + /// second one's quit would delete the first one's buffer. The second is + /// refused, with the length untouched, until the first is gone. + #[test] + fn a_buffer_file_in_use_is_refused_to_a_second_ring() { + let t = tmp(4096); + let err = DiskRing::create(&t.1, 8192) + .err() + .expect("the file is taken"); + assert_eq!(err.kind(), std::io::ErrorKind::ResourceBusy); + assert!(err.to_string().contains("another InstantClone")); + assert_eq!(std::fs::metadata(&t.1).unwrap().len(), 4096); + let path = t.1.clone(); + drop(t); + let again = DiskRing::create(&path, 4096); + assert!(again.is_ok(), "free again once the first ring is gone"); + drop(again); + let _ = std::fs::remove_file(&path); + } + #[test] fn seq_headers_bypass_the_ring() { let t = tmp(4096); @@ -781,17 +849,20 @@ mod tests { assert!(t.0.try_read_seq(seqs[2], &mut buf).unwrap().is_some()); } + /// A delay lands on the newest keyframe at or before its moment, never + /// a newer one: exactly on one, between two, and none when every + /// keyframe in the ring is newer. #[test] - fn newest_idr_returns_the_last_one() { + fn newest_idr_at_or_before_never_picks_a_newer_keyframe() { let t = tmp(4096); let idr = primary_idr_payload(30); - t.0.append(9, 100, &idr, true, false).unwrap(); - t.0.append(9, 200, &[0u8; 30], false, false).unwrap(); - t.0.append(9, 300, &idr, true, false).unwrap(); - t.0.append(9, 400, &[0u8; 30], false, false).unwrap(); - let m = t.0.newest_idr().expect("has IDR"); - assert_eq!(m.ts_ms, 300); - assert!(m.is_idr); + t.0.append(9, 1_000, &idr, true, false).unwrap(); + t.0.append(9, 1_500, &[0u8; 30], false, false).unwrap(); + t.0.append(9, 3_000, &idr, true, false).unwrap(); + let at = |ts| t.0.newest_idr_at_or_before(ts).map(|m| m.ts_ms); + assert_eq!(at(3_000), Some(3_000), "exactly on one"); + assert_eq!(at(2_999), Some(1_000), "between two"); + assert_eq!(at(999), None, "every keyframe is newer"); } #[test] @@ -886,19 +957,6 @@ mod tests { // `meta.is_idr` to find its own canvas's first keyframe. assert!(t.0.find_by_seq(rung).unwrap().1.is_idr); } - - #[test] - fn newest_idr_after_skips_stale_publisher_idrs() { - // Models the publisher-reconnect case: there are old IDRs in the - // ring from before the reconnect; we want only the new session's. - let t = tmp(4096); - let idr = primary_idr_payload(30); - let old_seq = t.0.append(9, 100, &idr, true, false).unwrap().unwrap(); - let new_seq = t.0.append(9, 200, &idr, true, false).unwrap().unwrap(); - let m = t.0.newest_idr_after(old_seq).expect("found"); - assert_eq!(m.seq, new_seq); - assert!(t.0.newest_idr_after(new_seq + 100).is_none()); - } } #[cfg(test)] diff --git a/src/compat.rs b/src/compat.rs index fc1311f..e8e14aa 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -44,6 +44,14 @@ const WANTS_2S_KEYFRAME: &[&str] = &["twitch", "youtube", "kick", "trovo", "rest /// accepted but downscaled server-side at best, rejected at worst. const KICK_MAX_HEIGHT: u32 = 1080; +/// Where each fix lives in OBS, so the warning is something to act on. The +/// dashboard adds that OBS applies it the next time streaming starts. +const KEYFRAME_FIX: &str = + "In OBS: Settings → Output → Streaming (Output Mode: Advanced) → Keyframe Interval: 2 s."; +const RESOLUTION_FIX: &str = + "In OBS: Settings → Video → Output (Scaled) Resolution: 1080p or lower."; +const CODEC_FIX: &str = "In OBS: Settings → Output → Streaming → Video Encoder: an H.264 one."; + /// Produce at most one line describing every compatibility problem with /// the current stream, or `None` when there is nothing worth saying. /// @@ -88,7 +96,7 @@ fn keyframe_issue(params: &StreamParams, live: &[&Destination]) -> Option Option Option { return None; } Some(format!( - "Video codec is {}. Kick needs H.264.", + "Video codec is {}. Kick needs H.264. {CODEC_FIX}", params.codec.label() )) } @@ -202,187 +210,182 @@ mod tests { } } - fn params() -> StreamParams { - StreamParams { + /// A healthy 1080p, 2 s keyframe, H.264 stream, changed by `change`. + fn stream(change: impl Fn(&mut StreamParams)) -> StreamParams { + let mut params = StreamParams { width: 1920, height: 1080, keyframe_interval_ms: 2_000, codec: VideoCodec::Avc, - } - } - - #[test] - fn healthy_stream_is_silent() { - let dests = vec![dest("twitch", true), dest("kick", true)]; - assert_eq!(compat_warning(¶ms(), &dests), None); - } - - #[test] - fn no_destinations_is_silent() { - assert_eq!(compat_warning(¶ms(), &[]), None); - } - - /// The whole point of the tolerance: a user who correctly set 2 s - /// measures slightly over it because of encoder jitter, and must not - /// be nagged for it. - #[test] - fn two_second_keyframe_with_jitter_does_not_warn() { - let mut p = params(); - p.keyframe_interval_ms = 2_100; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); - } - - #[test] - fn four_second_keyframe_warns_and_names_platforms() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - let dests = vec![dest("kick", true), dest("youtube", true)]; - assert_eq!( - compat_warning(&p, &dests), - Some("Keyframe interval measures 4.0s. Kick and YouTube expect 2s.".into()) - ); - } - - #[test] - fn single_platform_uses_singular_verb() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Keyframe interval measures 4.0s. Kick expects 2s.".into()) - ); - } - - /// Two destinations on the same platform must not render as - /// "Twitch and Twitch". - #[test] - fn duplicate_platforms_are_deduplicated() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - let mut second = dest("twitch", true); - second.id = "twitch-2".into(); - let dests = vec![dest("twitch", true), second]; - assert_eq!( - compat_warning(&p, &dests), - Some("Keyframe interval measures 4.0s. Twitch expects 2s.".into()) - ); - } - - #[test] - fn disabled_destinations_are_ignored() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - assert_eq!(compat_warning(&p, &[dest("kick", false)]), None); - } - - /// The sink accepts anything - warning about it would be noise. - #[test] - fn sink_alone_never_warns() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - p.height = 1440; - p.codec = VideoCodec::Av1; - assert_eq!(compat_warning(&p, &[dest("sink", true)]), None); - } - - /// A custom ingest has unknown requirements, so guessing is worse - /// than staying quiet. - #[test] - fn custom_platform_has_no_keyframe_opinion() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - assert_eq!(compat_warning(&p, &[dest("custom", true)]), None); + }; + change(&mut params); + params } - #[test] - fn unmeasured_keyframe_interval_is_silent() { - let mut p = params(); - p.keyframe_interval_ms = 0; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); + fn healthy() -> StreamParams { + stream(|_| {}) } - #[test] - fn tall_canvas_warns_only_when_kick_is_enabled() { - let mut p = params(); - p.height = 1440; - assert_eq!(compat_warning(&p, &[dest("twitch", true)]), None); - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Video is 1440p. Kick caps at 1080p.".into()) - ); + fn long_keyframes() -> StreamParams { + stream(|p| p.keyframe_interval_ms = 4_000) } - /// Pins the threshold itself: 2600 ms is the last silent value. Moving - /// it either way changes who gets nagged, so it must be a deliberate - /// edit to `KEYFRAME_WARN_MS`, not a `<` vs `<=` slip. + /// Nothing is said when the stream is fine, when the measurement isn't + /// in yet, or when nobody who would mind is being streamed to. #[test] - fn keyframe_warning_starts_just_past_the_threshold() { - let mut p = params(); - p.keyframe_interval_ms = 2_600; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); - p.keyframe_interval_ms = 2_601; - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Keyframe interval measures 2.6s. Kick expects 2s.".into()) - ); - } - - /// A 9:16 canvas is 1080p on its short side, the same pixel budget as - /// 1920x1080, which Kick's ingest accepts in either orientation. - /// Reading the height alone called it "1920p" and warned falsely. - #[test] - fn portrait_1080p_canvas_does_not_trip_the_kick_cap() { - let mut p = params(); - p.width = 1080; - p.height = 1920; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); - // A portrait canvas past the cap still warns, named by its short side. - p.width = 1440; - p.height = 2560; - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Video is 1440p. Kick caps at 1080p.".into()) - ); - } - - #[test] - fn undecoded_resolution_is_silent() { - let mut p = params(); - p.height = 0; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); + fn stays_silent_when_nothing_is_wrong_or_nobody_minds() { + let kick = [dest("kick", true)]; + let cases: [(&str, StreamParams, &[Destination]); 13] = [ + ( + "a healthy stream", + healthy(), + &[dest("twitch", true), dest("kick", true)], + ), + ("no destinations", healthy(), &[]), + // The whole point of the tolerance: a user who correctly set 2 s + // measures slightly over it because of encoder jitter. + ( + "2 s with encoder jitter", + stream(|p| p.keyframe_interval_ms = 2_100), + &kick, + ), + // Pins the threshold: 2600 ms is the last silent value, so moving + // it is a deliberate edit to KEYFRAME_WARN_MS, not a `<=` slip. + ( + "the last silent interval", + stream(|p| p.keyframe_interval_ms = 2_600), + &kick, + ), + ( + "an interval not measured yet", + stream(|p| p.keyframe_interval_ms = 0), + &kick, + ), + ( + "a switched-off destination", + long_keyframes(), + &[dest("kick", false)], + ), + // A custom ingest has unknown requirements: guessing is worse. + ("a custom server", long_keyframes(), &[dest("custom", true)]), + // The sink accepts anything: warning about it would be noise. + ( + "the test sink, with everything wrong", + stream(|p| { + p.keyframe_interval_ms = 4_000; + p.height = 1440; + p.codec = VideoCodec::Av1; + }), + &[dest("sink", true)], + ), + ( + "1440p to Twitch only", + stream(|p| p.height = 1440), + &[dest("twitch", true)], + ), + // A 9:16 canvas is 1080p on its short side, which Kick accepts. + // Reading the height alone called it "1920p" and warned falsely. + ( + "a portrait 1080p canvas", + stream(|p| (p.width, p.height) = (1080, 1920)), + &kick, + ), + ("a size not decoded yet", stream(|p| p.height = 0), &kick), + ( + "HEVC to YouTube only", + stream(|p| p.codec = VideoCodec::Hevc), + &[dest("youtube", true)], + ), + ( + "a codec not known yet", + stream(|p| p.codec = VideoCodec::Unknown), + &kick, + ), + ]; + for (case, params, dests) in cases { + assert_eq!(compat_warning(¶ms, dests), None, "{case}"); + } } + /// The keyframe warning starts just past the threshold and names each + /// platform that expects 2 s once, however many destinations use it. #[test] - fn non_h264_warns_for_kick_only() { - let mut p = params(); - p.codec = VideoCodec::Hevc; - assert_eq!(compat_warning(&p, &[dest("youtube", true)]), None); - assert_eq!( - compat_warning(&p, &[dest("kick", true)]), - Some("Video codec is HEVC. Kick needs H.264.".into()) - ); + fn a_long_keyframe_interval_names_who_expects_2s() { + let mut second_twitch = dest("twitch", true); + second_twitch.id = "twitch-2".into(); + let cases: [(StreamParams, &[Destination], &str); 3] = [ + ( + long_keyframes(), + &[dest("kick", true), dest("youtube", true)], + "Keyframe interval measures 4.0s. Kick and YouTube expect 2s.", + ), + ( + long_keyframes(), + &[dest("twitch", true), second_twitch], + "Keyframe interval measures 4.0s. Twitch expects 2s.", + ), + ( + stream(|p| p.keyframe_interval_ms = 2_601), + &[dest("kick", true)], + "Keyframe interval measures 2.6s. Kick expects 2s.", + ), + ]; + for (params, dests, said) in cases { + assert_eq!( + compat_warning(¶ms, dests), + Some(format!("{said} {KEYFRAME_FIX}")) + ); + } } + /// Kick caps video at 1080p on its short side, whichever way up and + /// whichever side was decoded, and takes H.264 only. #[test] - fn unknown_codec_is_silent() { - let mut p = params(); - p.codec = VideoCodec::Unknown; - assert_eq!(compat_warning(&p, &[dest("kick", true)]), None); + fn kick_is_warned_about_tall_video_and_other_codecs() { + let too_tall = format!("Video is 1440p. Kick caps at 1080p. {RESOLUTION_FIX}"); + let cases = [ + ("1440p", stream(|p| p.height = 1440), too_tall.clone()), + ( + "portrait 1440p", + stream(|p| (p.width, p.height) = (1440, 2560)), + too_tall.clone(), + ), + ( + "a height without a width", + stream(|p| (p.width, p.height) = (0, 1440)), + too_tall, + ), + ( + "HEVC", + stream(|p| p.codec = VideoCodec::Hevc), + format!("Video codec is HEVC. Kick needs H.264. {CODEC_FIX}"), + ), + ]; + for (case, params, said) in cases { + assert_eq!( + compat_warning(¶ms, &[dest("kick", true)]), + Some(said), + "{case}" + ); + } } /// Multiple problems collapse into ONE line - the clutter guard. #[test] fn multiple_issues_join_into_a_single_line() { - let mut p = params(); - p.keyframe_interval_ms = 4_000; - p.height = 1440; - p.codec = VideoCodec::Av1; - let warning = compat_warning(&p, &[dest("kick", true)]).unwrap(); + let everything = stream(|p| { + p.keyframe_interval_ms = 4_000; + p.height = 1440; + p.codec = VideoCodec::Av1; + }); + let warning = compat_warning(&everything, &[dest("kick", true)]).unwrap(); assert_eq!( warning, - "Keyframe interval measures 4.0s. Kick expects 2s. · \ - Video is 1440p. Kick caps at 1080p. · \ - Video codec is AV1. Kick needs H.264." + format!( + "Keyframe interval measures 4.0s. Kick expects 2s. {KEYFRAME_FIX} · \ + Video is 1440p. Kick caps at 1080p. {RESOLUTION_FIX} · \ + Video codec is AV1. Kick needs H.264. {CODEC_FIX}" + ) ); assert_eq!(warning.lines().count(), 1); } diff --git a/src/config.rs b/src/config.rs index 4d09ff7..13a3d86 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,11 @@ pub const MAX_DOCK_LAYOUT_LEN: usize = 8 * 1024; const MIN_BUFFER_MB: u64 = 50; // 1 TiB. Guards `buffer_bytes()` against overflow from a hand-edited config. const MAX_BUFFER_MB: u64 = 1024 * 1024; +/// Longest delay anything can arm: 1 hour. The buffer size is what really +/// limits a delay (arming refuses one it can't hold at the stream's +/// bitrate); this only stops a typo or a hand-edited config from asking for +/// days. The web pages hold the same number as `DELAY_MAX_S`. +pub const MAX_DELAY_MS: u32 = 3_600_000; /// Ports for the built-in "Local test sink" destination (platform /// `"sink"`). The egress supervisor spawns `instantclone sink` as a @@ -106,12 +111,29 @@ pub struct Settings { /// streamer hits Arm with a non-zero value) so "last used" sticks /// across sessions without a separate UI knob to maintain it. pub auto_arm_delay_ms: u32, - /// Whether the dashboard checks GitHub for a newer release on load. - /// Default true - the check is a passive nicety (cached 10 min - /// server-side, failures silent). Off for the privacy-minded or + /// Whether InstantClone checks GitHub for a newer release: shortly after + /// start and daily (see `update_check::watch_for_updates`), and when the + /// dashboard loads. Default true - the check is a passive nicety (cached + /// 10 min server-side, failures silent). Off for the privacy-minded or /// air-gapped: it is the app's one routine outbound call, so it gets /// an explicit opt-out in System -> About. pub update_check_enabled: bool, + /// Windows: a quiet tray notification when an automatic check finds an + /// update. Default true; the check itself is `update_check_enabled`. + pub update_tray_notify: bool, + /// Windows: the tray icon's colour, an index into + /// `instance_lock::TRAY_COLORS`. Default 0, the brand blue. The colour + /// shown can differ for one run (see `instance_lock::show_tray_color`). + pub tray_color: u8, + /// State: the update the tray last told the user about (a version, or a + /// version and build hash for a new build of it), so each one is + /// announced once, across restarts. + pub update_notified: String, + /// State: the version and build commit of the last build whose "what's + /// new" the user saw (or the first build of a fresh install). A running + /// build that differs is an update, in-app or by hand. + pub last_seen_version: String, + pub last_seen_commit: String, /// Whether a normal launch opens the dashboard in the browser. Default /// true. Turning it off suits streamers who keep InstantClone running /// in the tray (or autostart it) and don't want a tab every launch - @@ -152,6 +174,8 @@ pub struct Settings { pub discord_channels: Vec, /// Phone pushes (ntfy). pub phone: PhoneConnection, + /// Where the alerts page puts its cards and how big they are. + pub alerts_look: crate::integrations::alerts::Look, /// Whether the old single `discord_webhook_url` has been turned into a /// Discord connection plus a "Stream alerts" integration. False only in /// configs written before integrations existed. @@ -773,6 +797,10 @@ impl Settings { ]); writeln!(f, "connection.phone={}", one_line(&v.to_json()))?; } + if self.alerts_look != Default::default() { + let look = self.alerts_look.to_json().to_json(); + writeln!(f, "alerts.look={}", one_line(&look))?; + } for i in &self.integrations { writeln!( f, @@ -885,6 +913,12 @@ impl Settings { name: "Tournament".into(), delay_ms: 300_000, }, + // Needs more than the default buffer at most bitrates: its + // chip says so and leads to the buffer setting. + DelayProfile { + name: "Long".into(), + delay_ms: 900_000, + }, ], destinations: Vec::new(), discord_webhook_url: String::new(), @@ -912,6 +946,11 @@ impl Settings { // behaviour. Each has an explicit opt-out in the System tab. update_check_enabled: true, open_dashboard_on_launch: true, + update_tray_notify: true, + tray_color: 0, + update_notified: String::new(), + last_seen_version: String::new(), + last_seen_commit: String::new(), // Fresh install hasn't seeded the preset overlays yet; the // dashboard does it on first load, then flips this true. overlays_seeded: false, @@ -926,6 +965,7 @@ impl Settings { integrations: Vec::new(), discord_channels: Vec::new(), phone: PhoneConnection::default(), + alerts_look: Default::default(), // A fresh install has no old webhook to migrate; `load` sets // this after its one-time pass either way. integrations_migrated: false, @@ -982,6 +1022,11 @@ impl Settings { }); } s.migrate_discord_webhook(); + // `!setdelay` copies from up to 0.1.15 (a 10 minute limit, and + // "Delay set to 0s"). The file catches up on the next save. + for integration in &mut s.integrations { + crate::integrations::presets::upgrade_setdelay(integration); + } // Clamp / sanitize on load - hand-edited values can otherwise // hit divide-by-zero (buffer_mb=0 → `% capacity` in DiskRing) or // bind two services to the same port (one will silently fail). @@ -1013,14 +1058,14 @@ impl Settings { // least the UI is reachable to fix it. self.web_port = if self.ingest_port == 7799 { 7800 } else { 7799 }; } - // Cap delay values to the same 10-minute ceiling the runtime uses. - self.armed_delay_ms = self.armed_delay_ms.min(600_000); - self.target_delay_ms = self.target_delay_ms.min(600_000); + // Cap delay values to the same ceiling the runtime uses. + self.armed_delay_ms = self.armed_delay_ms.min(MAX_DELAY_MS); + self.target_delay_ms = self.target_delay_ms.min(MAX_DELAY_MS); // auto_arm_delay_ms is the value used when auto-arm fires. Clamp - // to the same 10-minute ceiling; a 0 from a fresh install or a + // to the same ceiling; a 0 from a fresh install or a // hand-edited config falls back to the 15 s default so we never // auto-arm at "0 s of delay" (which would be a no-op + confusing). - self.auto_arm_delay_ms = self.auto_arm_delay_ms.min(600_000); + self.auto_arm_delay_ms = self.auto_arm_delay_ms.min(MAX_DELAY_MS); if self.auto_arm_delay_ms == 0 { self.auto_arm_delay_ms = 15_000; } @@ -1221,6 +1266,21 @@ impl Settings { if !self.open_dashboard_on_launch { writeln!(f, "open_dashboard_on_launch=false")?; } + if !self.update_tray_notify { + writeln!(f, "update_tray_notify=false")?; + } + if self.tray_color != 0 { + writeln!(f, "tray_color={}", self.tray_color)?; + } + if !self.update_notified.is_empty() { + writeln!(f, "update_notified={}", one_line(&self.update_notified))?; + } + if !self.last_seen_version.is_empty() { + writeln!(f, "last_seen_version={}", one_line(&self.last_seen_version))?; + } + if !self.last_seen_commit.is_empty() { + writeln!(f, "last_seen_commit={}", one_line(&self.last_seen_commit))?; + } if self.overlays_seeded { writeln!(f, "overlays_seeded=true")?; } @@ -1378,6 +1438,11 @@ impl Settings { "auto_activate_when_ready" => self.auto_activate_when_ready = value == "true", "update_check_enabled" => self.update_check_enabled = value != "false", "open_dashboard_on_launch" => self.open_dashboard_on_launch = value != "false", + "update_tray_notify" => self.update_tray_notify = value != "false", + "tray_color" => self.tray_color = value.parse().unwrap_or(0), + "update_notified" => self.update_notified = value.into(), + "last_seen_version" => self.last_seen_version = value.into(), + "last_seen_commit" => self.last_seen_commit = value.into(), "overlays_seeded" => self.overlays_seeded = value == "true", "auto_arm_delay_ms" => { if let Ok(v) = value.parse() { @@ -1413,6 +1478,11 @@ impl Settings { k if k.starts_with("connection.discord.") => { self.load_discord_channel(&k["connection.discord.".len()..], value) } + "alerts.look" => { + if let Ok(v) = json::parse(value) { + self.alerts_look = crate::integrations::alerts::Look::from_json(&v); + } + } "connection.phone" => { if let Ok(v) = json::parse(value) { self.phone = PhoneConnection { @@ -1689,8 +1759,22 @@ impl Settings { } else { "" }; + // The colours to pick the tray icon's from, so the page holds no + // copy of the list. + use crate::instance_lock::{shown_tray_color, tray_color_hex, TRAY_COLORS}; + let tray_colors = (0u8..) + .zip(TRAY_COLORS) + .map(|(index, (name, _))| { + format!( + r#"{{"name":{},"hex":{}}}"#, + json_str(name), + json_str(&tray_color_hex(index)) + ) + }) + .collect::>() + .join(","); format!( - r#"{{"configured":{c},"ingest_port":{ip},"ingest_bind_all":{iba},"web_port":{wp},"web_bind_all":{wba},"buffer_mb":{bm},"buffer_path":{bp},"target_delay_ms":{td},"obs_url":{ou},"discord_webhook_url":{dw},"webhook_set":{ws},"overlays_dir":{ov},"tracing_enabled":{te},"auto_arm_on_connect":{aaoc},"auto_activate_when_ready":{aawr},"auto_arm_delay_ms":{aadm},"overlays_seeded":{os},"start_with_windows":{sww},"update_check_enabled":{uce},"open_dashboard_on_launch":{odol},"ingest_key":{ik},"auth_enabled":{ae},"dock_token":{dt},"os":{osname},"version":{ver},"hotkeys":{hotkeys},"midi":{midi},"midi_device":{midid},"crash_protection":{cp},"twitch_client_id":{tci},"twitch_app_built_in":{tbi},"destinations":{dests}}}"#, + r#"{{"configured":{c},"ingest_port":{ip},"ingest_bind_all":{iba},"web_port":{wp},"web_bind_all":{wba},"buffer_mb":{bm},"buffer_path":{bp},"target_delay_ms":{td},"obs_url":{ou},"discord_webhook_url":{dw},"webhook_set":{ws},"overlays_dir":{ov},"tracing_enabled":{te},"auto_arm_on_connect":{aaoc},"auto_activate_when_ready":{aawr},"auto_arm_delay_ms":{aadm},"overlays_seeded":{os},"start_with_windows":{sww},"update_check_enabled":{uce},"update_tray_notify":{utn},"tray_color":{tc},"tray_color_choice":{tcc},"tray_colors":[{tcs}],"open_dashboard_on_launch":{odol},"ingest_key":{ik},"auth_enabled":{ae},"dock_token":{dt},"os":{osname},"version":{ver},"hotkeys":{hotkeys},"midi":{midi},"midi_device":{midid},"crash_protection":{cp},"twitch_client_id":{tci},"twitch_app_built_in":{tbi},"destinations":{dests}}}"#, c = self.configured, sww = start_with_windows, ik = json_str(ik_shown), @@ -1715,6 +1799,10 @@ impl Settings { aadm = self.auto_arm_delay_ms, os = self.overlays_seeded, uce = self.update_check_enabled, + utn = self.update_tray_notify, + tc = json_str(&tray_color_hex(shown_tray_color())), + tcc = self.tray_color, + tcs = tray_colors, odol = self.open_dashboard_on_launch, hotkeys = hotkeys, midi = midi, @@ -1735,7 +1823,7 @@ impl Settings { errs.push("web_port must be > 0".into()); } if self.ingest_port == self.web_port { - errs.push("ingest_port and web_port must differ - they share the same socket otherwise and one will silently fail to bind".into()); + errs.push("ingest_port and web_port must differ: they share the same socket otherwise and one will silently fail to bind".into()); } if self.buffer_mb < MIN_BUFFER_MB { errs.push(format!("buffer_mb must be at least {}", MIN_BUFFER_MB)); @@ -1781,7 +1869,7 @@ impl Settings { if d.platform == "custom" || d.platform == "kick" { if d.custom_egress_url.is_empty() { let need = if d.platform == "kick" { - "Kick Server URL required - copy it from your Kick dashboard (Settings -> Stream)" + "Kick Server URL required. Copy it from your Kick dashboard (Settings -> Stream)" } else { "custom RTMP URL required" }; @@ -1800,7 +1888,7 @@ impl Settings { // path instead, so custom doesn't force a separate key. if d.platform == "kick" && d.stream_key.is_empty() { errs.push(format!( - "{}: Kick stream key required - it's next to the Server field in your dashboard", + "{}: Kick stream key required. It's next to the Server field in your dashboard", d.name )); continue; @@ -2022,9 +2110,26 @@ fn non_empty(s: &String) -> Option<&String> { } } +/// Where a path from the settings file actually lives: a relative one sits +/// beside that settings file, an absolute one stays as it is. +/// +/// Without this a relative path resolves against the working directory, +/// which is the settings folder in every normal launch but not when +/// `CONFIG_PATH` names a settings file elsewhere. Two such copies started +/// from one folder then both picked `./instantclone.buf` there: one buffer +/// file for two live delays, deleted by whichever quit first. The settings +/// folder is always writable (every save writes a `.tmp` beside it), so it +/// is also a place the buffer can always be created. +pub fn beside_config(config_path: &Path, path: &Path) -> PathBuf { + match config_path.parent() { + Some(dir) if !dir.as_os_str().is_empty() => dir.join(path), + _ => path.to_path_buf(), + } +} + /// Compile-time OS tag the dashboard reads to pick platform-correct copy /// (the start-at-login wording, the file-manager name, the tray mention). -fn os_name() -> &'static str { +pub fn os_name() -> &'static str { #[cfg(windows)] { "windows" @@ -2408,6 +2513,41 @@ fn hex_nibble(b: u8) -> Option { #[cfg(test)] mod tests { + /// Two copies started from one folder with CONFIG_PATH pointing at two + /// settings files each get the buffer beside their own settings, never + /// one shared `./instantclone.buf`. The default launch is unchanged. + #[test] + fn a_relative_path_lives_beside_its_settings_file() { + let default = PathBuf::from("./instantclone.buf"); + assert_eq!( + super::beside_config(Path::new("C:/Streams/A/instantclone.config.json"), &default), + Path::new("C:/Streams/A").join(&default) + ); + assert_ne!( + super::beside_config(Path::new("C:/Streams/A/c.json"), &default), + super::beside_config(Path::new("C:/Streams/B/c.json"), &default) + ); + assert_eq!( + super::beside_config(Path::new("./instantclone.config.json"), &default), + Path::new(".").join(&default), + "the default layout resolves where it always did" + ); + assert_eq!( + super::beside_config(Path::new("instantclone.config.json"), &default), + default + ); + let absolute = if cfg!(windows) { + "D:\\ring.buf" + } else { + "/srv/ring.buf" + }; + assert_eq!( + super::beside_config(Path::new("C:/Streams/A/c.json"), Path::new(absolute)), + PathBuf::from(absolute), + "an absolute path stays where the user put it" + ); + } + // Mirrors obs_register: what we tell the user to paste into OBS has to // resolve under either IP Family, so it stays a hostname. #[test] @@ -2478,8 +2618,8 @@ mod tests { /// hash must NEVER appear in the client-facing JSON (only `auth_enabled`). #[test] fn secret_fields_round_trip_and_hash_stays_server_side() { - let dir = std::env::temp_dir().join(format!("ic-cfg-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let scratch = crate::test_support::TestDir::new("ic-cfg"); + let dir = scratch.path().to_path_buf(); let path = dir.join("sec.config.json"); let mut s = Settings::defaults(); @@ -2521,8 +2661,6 @@ mod tests { .unwrap() .dashboard_password_hash .is_empty()); - - let _ = std::fs::remove_dir_all(&dir); } /// A newline smuggled into a string setting must not inject a second @@ -2530,8 +2668,8 @@ mod tests { /// ingest_bind_all). The writer strips control chars. #[test] fn config_write_blocks_newline_injection() { - let dir = std::env::temp_dir().join(format!("ic-inj-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let scratch = crate::test_support::TestDir::new("ic-inj"); + let dir = scratch.path().to_path_buf(); let path = dir.join("inj.config.json"); let mut s = Settings::defaults(); @@ -2546,22 +2684,45 @@ mod tests { let loaded = Settings::load(&path).unwrap(); assert!(!loaded.ingest_bind_all, "injection flipped ingest_bind_all"); assert!(!loaded.discord_webhook_url.contains('\n')); - - let _ = std::fs::remove_dir_all(&dir); } + /// Zero or clashing ports, unsafe paths, and an ingest key that isn't one. #[test] - fn validate_rejects_unsafe_ingest_key() { - let mut s = Settings::defaults(); - s.ingest_key = "good-key_123".into(); - assert!(!s.validate().iter().any(|e| e.contains("ingest key"))); - for bad in ["has space", "has?query", "tab\tinside"] { - s.ingest_key = bad.into(); + fn settings_that_cannot_work_fail_validation() { + // settings validate rejects zero ports and collisions + { + let mut s = Settings::defaults(); + s.ingest_port = 0; + assert!(s.validate().iter().any(|e| e.contains("ingest_port"))); + + let mut s2 = Settings::defaults(); + s2.web_port = s2.ingest_port; + assert!(s2.validate().iter().any(|e| e.contains("must differ"))); + } + // settings validate rejects unsafe paths + { + let mut s = Settings::defaults(); + s.buffer_path = PathBuf::from("../../system32/cmd.exe"); + let errs = s.validate(); assert!( - s.validate().iter().any(|e| e.contains("ingest key")), - "should reject {bad:?}" + errs.iter().any(|e| e.contains("buffer_path looks unsafe")), + "expected buffer_path validation error, got: {:?}", + errs ); } + // validate rejects unsafe ingest key + { + let mut s = Settings::defaults(); + s.ingest_key = "good-key_123".into(); + assert!(!s.validate().iter().any(|e| e.contains("ingest key"))); + for bad in ["has space", "has?query", "tab\tinside"] { + s.ingest_key = bad.into(); + assert!( + s.validate().iter().any(|e| e.contains("ingest key")), + "should reject {bad:?}" + ); + } + } } #[test] @@ -2599,6 +2760,20 @@ mod tests { /// The dashboard swaps platform-specific copy off this field, so it must /// always be present and non-empty. + /// The page draws the tray colour picker from this list and marks the + /// setup's choice in it. + #[test] + fn to_json_lists_the_tray_colours_and_the_one_chosen() { + let mut settings = Settings::defaults(); + settings.tray_color = 3; + let json = settings.to_json(false, true); + assert!(json.contains(r#""tray_color_choice":3"#), "{json}"); + assert!( + json.contains(r##""tray_colors":[{"name":"Blue","hex":"#5ac8fa"},"##), + "{json}" + ); + } + #[test] fn to_json_carries_the_os_tag() { let json = Settings::defaults().to_json(false, true); @@ -2623,228 +2798,256 @@ mod tests { } } + /// Plain pairs, percent and plus encoding, empty values, and a stray percent before a multi-byte character. #[test] - fn parse_form_handles_basic_encoding() { - let h = parse_form("name=Test&platform=twitch&stream_key=abc"); - assert_eq!(h.get("name"), Some(&"Test".to_string())); - assert_eq!(h.get("platform"), Some(&"twitch".to_string())); - assert_eq!(h.get("stream_key"), Some(&"abc".to_string())); - } - - #[test] - fn parse_form_handles_percent_and_plus_encoding() { - // `+` decodes to space, `%21` to `!`, `%C3%A9` to UTF-8 `é`. - let h = parse_form("a=hello+world&b=%21&c=%C3%A9"); - assert_eq!(h.get("a"), Some(&"hello world".to_string())); - assert_eq!(h.get("b"), Some(&"!".to_string())); - assert_eq!(h.get("c"), Some(&"é".to_string())); - } - - /// Regression: a bare `%` before a multi-byte UTF-8 char used to slice the - /// `&str` on a non-char boundary and panic (a remote process abort under - /// panic=abort). It must now decode losslessly and never panic. - #[test] - fn url_decode_survives_percent_before_multibyte_char() { - // `%€`, a stray `%` at the very end, and a truncated `%A`. - assert_eq!(url_decode("%€"), "%€"); - assert_eq!(url_decode("abc%"), "abc%"); - assert_eq!(url_decode("x%Ay"), "x%Ay"); - // A real escape still works, and mixed content round-trips. - assert_eq!(url_decode("a%20b%E2%82%ACc"), "a b€c"); - } - - #[test] - fn parse_form_handles_empty_value() { - let h = parse_form("stream_key=&platform=custom"); - assert_eq!(h.get("stream_key"), Some(&"".to_string())); - assert_eq!(h.get("platform"), Some(&"custom".to_string())); - } - - #[test] - fn is_path_safe_blocks_traversal_and_system_dirs() { - assert!(!is_path_safe(&PathBuf::from("../../../etc/passwd"))); - assert!(!is_path_safe(&PathBuf::from( - "C:\\Windows\\System32\\evil.dll" - ))); - assert!(!is_path_safe(&PathBuf::from("/etc/shadow"))); - assert!(!is_path_safe(&PathBuf::from(""))); - } - - #[test] - fn is_path_safe_allows_normal_paths() { - assert!(is_path_safe(&PathBuf::from("./instantclone.buf"))); - assert!(is_path_safe(&PathBuf::from("D:\\streams\\buffer.bin"))); - assert!(is_path_safe(&PathBuf::from("/home/user/buf"))); - } - - #[test] - fn sink_destination_is_self_contained() { - // No stream key, no custom URL - the local test sink needs - // neither. The URL is the fixed managed-child endpoint, and - // validate() must not demand a key for it. - let d = Destination { - id: "x".into(), - name: "Test sink".into(), - enabled: true, - platform: "sink".into(), - stream_key: String::new(), - custom_egress_url: String::new(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT).as_str()) - ); - assert!(d.is_well_formed()); - let mut s = Settings::defaults(); - s.destinations.push(d); - assert!( - s.validate().is_empty(), - "sink destination must validate without a key: {:?}", - s.validate() - ); - } - - #[test] - fn destination_egress_url_for_twitch_appends_key() { - let d = Destination { - id: "x".into(), - name: "T".into(), - enabled: true, - platform: "twitch".into(), - stream_key: "live_123_abc".into(), - custom_egress_url: String::new(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - let url = d.egress_url().unwrap(); - assert!(url.starts_with("rtmp://live.twitch.tv/app/")); - assert!(url.ends_with("/live_123_abc")); - } - - #[test] - fn destination_egress_url_region_pin_uses_live_dash_slug() { - let d = Destination { - id: "x".into(), - name: "T".into(), - enabled: true, - platform: "twitch".into(), - stream_key: "k".into(), - custom_egress_url: String::new(), - twitch_ingest: "fra".into(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://live-fra.twitch.tv/app/k") - ); - } - - #[test] - fn destination_custom_url_with_embedded_key_is_not_double_appended() { - // A custom URL that already includes app+key (2+ path segments) - // must be passed through verbatim, even if stream_key is set. - let d = Destination { - id: "x".into(), - name: "C".into(), - enabled: true, - platform: "custom".into(), - stream_key: "ignored".into(), - custom_egress_url: "rtmp://my.server/live/secret_key".into(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://my.server/live/secret_key"), - "custom URL with app+key must not get an extra key appended" - ); - } - - #[test] - fn destination_youtube_backup_uses_b_ingest() { - let d = Destination { - id: "x".into(), - name: "Y".into(), - enabled: true, - platform: "youtube".into(), - stream_key: "abcd".into(), - custom_egress_url: String::new(), - twitch_ingest: String::new(), - youtube_ingest: "backup".into(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://b.rtmp.youtube.com/live2/abcd?backup=1"), - "YouTube backup ingest needs the ?backup=1 suffix so their \ - edge enables real fail-over, not just a duplicate stream" - ); + fn form_bodies_decode_like_a_browser_sends_them() { + // parse form handles basic encoding + { + let h = parse_form("name=Test&platform=twitch&stream_key=abc"); + assert_eq!(h.get("name"), Some(&"Test".to_string())); + assert_eq!(h.get("platform"), Some(&"twitch".to_string())); + assert_eq!(h.get("stream_key"), Some(&"abc".to_string())); + } + // parse form handles percent and plus encoding + { + // `+` decodes to space, `%21` to `!`, `%C3%A9` to UTF-8 `é`. + let h = parse_form("a=hello+world&b=%21&c=%C3%A9"); + assert_eq!(h.get("a"), Some(&"hello world".to_string())); + assert_eq!(h.get("b"), Some(&"!".to_string())); + assert_eq!(h.get("c"), Some(&"é".to_string())); + } + // parse form handles empty value + { + let h = parse_form("stream_key=&platform=custom"); + assert_eq!(h.get("stream_key"), Some(&"".to_string())); + assert_eq!(h.get("platform"), Some(&"custom".to_string())); + } + // url decode survives percent before multibyte char + // Regression: a bare `%` before a multi-byte UTF-8 char used to slice the + // `&str` on a non-char boundary and panic (a remote process abort under + // panic=abort). It must now decode losslessly and never panic. + { + // `%€`, a stray `%` at the very end, and a truncated `%A`. + assert_eq!(url_decode("%€"), "%€"); + assert_eq!(url_decode("abc%"), "abc%"); + assert_eq!(url_decode("x%Ay"), "x%Ay"); + // A real escape still works, and mixed content round-trips. + assert_eq!(url_decode("a%20b%E2%82%ACc"), "a b€c"); + } } + /// Where the app may be told to write. #[test] - fn destination_egress_url_for_kick_uses_pasted_server_plus_key() { - // Kick gives each streamer a distinct Server URL (rtmps) in the - // dashboard; we store it in custom_egress_url and append the key. - // The rtmps scheme makes the egress socket upgrade to TLS - the - // old hardcoded rtmp:// host connected on 1935 and silently failed. - let d = Destination { - id: "x".into(), - name: "K".into(), - enabled: true, - platform: "kick".into(), - stream_key: "sk_test_key".into(), - custom_egress_url: "rtmps://fa723fc1b171.global-contribute.live-video.net:443/app" - .into(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - let url = d.egress_url().unwrap(); - assert!(url.starts_with("rtmps://"), "kick must use rtmps: {url}"); - assert!(url.ends_with("/app/sk_test_key")); - assert!(d.is_well_formed()); + fn paths_are_safe_unless_they_escape_or_hit_system_folders() { + // is path safe blocks traversal and system dirs + { + assert!(!is_path_safe(&PathBuf::from("../../../etc/passwd"))); + assert!(!is_path_safe(&PathBuf::from( + "C:\\Windows\\System32\\evil.dll" + ))); + assert!(!is_path_safe(&PathBuf::from("/etc/shadow"))); + assert!(!is_path_safe(&PathBuf::from(""))); + } + // is path safe allows normal paths + { + assert!(is_path_safe(&PathBuf::from("./instantclone.buf"))); + assert!(is_path_safe(&PathBuf::from("D:\\streams\\buffer.bin"))); + assert!(is_path_safe(&PathBuf::from("/home/user/buf"))); + } } + /// Where a destination's stream is sent: per platform, with its key in the right place. #[test] - fn kick_server_url_without_app_path_gets_app_appended() { - // Streamer pastes just the host (or host with a trailing slash), - // leaving off "/app". We add Kick's fixed app so it still resolves - // to a well-formed host/app/key URL instead of erroring. - for server in [ - "rtmps://fa723fc1b171.global-contribute.live-video.net/", - "rtmps://fa723fc1b171.global-contribute.live-video.net", - ] { + fn each_platform_builds_its_egress_url() { + // sink destination is self contained + { + // No stream key, no custom URL - the local test sink needs + // neither. The URL is the fixed managed-child endpoint, and + // validate() must not demand a key for it. + let d = Destination { + id: "x".into(), + name: "Test sink".into(), + enabled: true, + platform: "sink".into(), + stream_key: String::new(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT).as_str()) + ); + assert!(d.is_well_formed()); + let mut s = Settings::defaults(); + s.destinations.push(d); + assert!( + s.validate().is_empty(), + "sink destination must validate without a key: {:?}", + s.validate() + ); + } + // destination egress url for twitch appends key + { + let d = Destination { + id: "x".into(), + name: "T".into(), + enabled: true, + platform: "twitch".into(), + stream_key: "live_123_abc".into(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + let url = d.egress_url().unwrap(); + assert!(url.starts_with("rtmp://live.twitch.tv/app/")); + assert!(url.ends_with("/live_123_abc")); + } + // destination egress url region pin uses live dash slug + { + let d = Destination { + id: "x".into(), + name: "T".into(), + enabled: true, + platform: "twitch".into(), + stream_key: "k".into(), + custom_egress_url: String::new(), + twitch_ingest: "fra".into(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://live-fra.twitch.tv/app/k") + ); + } + // destination custom url with embedded key is not double appended + { + // A custom URL that already includes app+key (2+ path segments) + // must be passed through verbatim, even if stream_key is set. + let d = Destination { + id: "x".into(), + name: "C".into(), + enabled: true, + platform: "custom".into(), + stream_key: "ignored".into(), + custom_egress_url: "rtmp://my.server/live/secret_key".into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://my.server/live/secret_key"), + "custom URL with app+key must not get an extra key appended" + ); + } + // destination youtube backup uses b ingest + { + let d = Destination { + id: "x".into(), + name: "Y".into(), + enabled: true, + platform: "youtube".into(), + stream_key: "abcd".into(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: "backup".into(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://b.rtmp.youtube.com/live2/abcd?backup=1"), + "YouTube backup ingest needs the ?backup=1 suffix so their \ + edge enables real fail-over, not just a duplicate stream" + ); + } + // destination egress url for kick uses pasted server plus key + { + // Kick gives each streamer a distinct Server URL (rtmps) in the + // dashboard; we store it in custom_egress_url and append the key. + // The rtmps scheme makes the egress socket upgrade to TLS - the + // old hardcoded rtmp:// host connected on 1935 and silently failed. let d = Destination { id: "x".into(), name: "K".into(), enabled: true, platform: "kick".into(), stream_key: "sk_test_key".into(), - custom_egress_url: server.into(), + custom_egress_url: "rtmps://fa723fc1b171.global-contribute.live-video.net:443/app" + .into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + let url = d.egress_url().unwrap(); + assert!(url.starts_with("rtmps://"), "kick must use rtmps: {url}"); + assert!(url.ends_with("/app/sk_test_key")); + assert!(d.is_well_formed()); + } + // kick server url without app path gets app appended + { + // Streamer pastes just the host (or host with a trailing slash), + // leaving off "/app". We add Kick's fixed app so it still resolves + // to a well-formed host/app/key URL instead of erroring. + for server in [ + "rtmps://fa723fc1b171.global-contribute.live-video.net/", + "rtmps://fa723fc1b171.global-contribute.live-video.net", + ] { + let d = Destination { + id: "x".into(), + name: "K".into(), + enabled: true, + platform: "kick".into(), + stream_key: "sk_test_key".into(), + custom_egress_url: server.into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some("rtmps://fa723fc1b171.global-contribute.live-video.net/app/sk_test_key"), + "host-only Kick server URL '{server}' should gain /app" + ); + assert!(d.is_well_formed()); + } + } + // destination custom rtmps url is accepted + { + // The Custom platform is the escape hatch for any TLS ingest + // (a non-standard Kick host, Facebook, etc.). rtmps:// must + // validate and pass through, well-formed with app+key. + let d = Destination { + id: "x".into(), + name: "C".into(), + enabled: true, + platform: "custom".into(), + stream_key: "ignored".into(), + custom_egress_url: "rtmps://edge.example.net/app/streamkey".into(), twitch_ingest: String::new(), youtube_ingest: String::new(), vod_audio: false, @@ -2854,10 +3057,12 @@ mod tests { }; assert_eq!( d.egress_url().as_deref(), - Some("rtmps://fa723fc1b171.global-contribute.live-video.net/app/sk_test_key"), - "host-only Kick server URL '{server}' should gain /app" + Some("rtmps://edge.example.net/app/streamkey") ); assert!(d.is_well_formed()); + assert!(is_rtmp_scheme("rtmps://edge.example.net/app/k")); + assert!(is_rtmp_scheme("rtmp://edge.example.net/app/k")); + assert!(!is_rtmp_scheme("https://edge.example.net/app/k")); } } @@ -2910,35 +3115,6 @@ mod tests { assert!(s.validate().iter().any(|e| e.contains("Kick Server URL"))); } - #[test] - fn destination_custom_rtmps_url_is_accepted() { - // The Custom platform is the escape hatch for any TLS ingest - // (a non-standard Kick host, Facebook, etc.). rtmps:// must - // validate and pass through, well-formed with app+key. - let d = Destination { - id: "x".into(), - name: "C".into(), - enabled: true, - platform: "custom".into(), - stream_key: "ignored".into(), - custom_egress_url: "rtmps://edge.example.net/app/streamkey".into(), - twitch_ingest: String::new(), - youtube_ingest: String::new(), - vod_audio: false, - vod_audio_inject_eb: false, - stream_format: "horizontal".into(), - audio_track: "auto".into(), - }; - assert_eq!( - d.egress_url().as_deref(), - Some("rtmps://edge.example.net/app/streamkey") - ); - assert!(d.is_well_formed()); - assert!(is_rtmp_scheme("rtmps://edge.example.net/app/k")); - assert!(is_rtmp_scheme("rtmp://edge.example.net/app/k")); - assert!(!is_rtmp_scheme("https://edge.example.net/app/k")); - } - #[test] fn destination_well_formed_requires_app_and_key() { let bad = Destination { @@ -2957,29 +3133,16 @@ mod tests { audio_track: "auto".into(), }; assert!(!bad.is_well_formed()); - } - - #[test] - fn settings_validate_rejects_zero_ports_and_collisions() { - let mut s = Settings::defaults(); - s.ingest_port = 0; - assert!(s.validate().iter().any(|e| e.contains("ingest_port"))); - - let mut s2 = Settings::defaults(); - s2.web_port = s2.ingest_port; - assert!(s2.validate().iter().any(|e| e.contains("must differ"))); - } - - #[test] - fn settings_validate_rejects_unsafe_paths() { - let mut s = Settings::defaults(); - s.buffer_path = PathBuf::from("../../system32/cmd.exe"); - let errs = s.validate(); - assert!( - errs.iter().any(|e| e.contains("buffer_path looks unsafe")), - "expected buffer_path validation error, got: {:?}", - errs - ); + let no_key = Destination { + custom_egress_url: "rtmp://my.server/app".into(), + ..bad.clone() + }; + assert!(!no_key.is_well_formed(), "an app with no key"); + let whole = Destination { + custom_egress_url: "rtmp://my.server/app/key".into(), + ..bad + }; + assert!(whole.is_well_formed()); } #[test] @@ -2987,15 +3150,8 @@ mod tests { // The save writer skips default values for the behaviour keys // so a fresh install's config file stays clean. Verify by // saving a defaults() snapshot and grepping the file. - let path = std::env::temp_dir().join(format!( - "ic-test-behavior-defaults-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-test-behavior-defaults"); + let path = scratch.join("settings.ini"); let mut s = Settings::defaults(); s.destinations.push(Destination { @@ -3027,14 +3183,12 @@ mod tests { !text.contains("auto_arm_delay_ms"), "default 15000 must not be written" ); - - let _ = std::fs::remove_file(&path); } #[test] fn behavior_sanitize_clamps_and_fills_zero() { // sanitize_load() runs after load(); it should clamp the - // delay to 600s and replace a zero (e.g. from a hand-edited + // delay to MAX_DELAY_MS and replace a zero (e.g. from a hand-edited // legacy config or downgrade roundtrip) with the 15 s default // so auto-arm never tries to arm at 0 s (= disarm, confusing). let mut s = Settings::defaults(); @@ -3049,8 +3203,8 @@ mod tests { s.auto_arm_delay_ms = 9_999_999; s.sanitize_load(); assert_eq!( - s.auto_arm_delay_ms, 600_000, - "above ceiling must clamp to 10 min" + s.auto_arm_delay_ms, MAX_DELAY_MS, + "above ceiling must clamp to 1 hour" ); } @@ -3060,15 +3214,8 @@ mod tests { // file holds the new contents AND no `.tmp` sibling is left on // disk. The latter is the visible sign of a successful rename // (vs. a hypothetical fallback path that wrote in place). - let path = std::env::temp_dir().join(format!( - "ic-test-atomic-save-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-test-atomic-save"); + let path = scratch.join("settings.ini"); // First save: file didn't exist beforehand. let mut a = Settings::defaults(); @@ -3102,8 +3249,6 @@ mod tests { "save() left a stale {} on disk", tmp.display() ); - - let _ = std::fs::remove_file(&path); } /// The actual atomicity guarantee: when the write cannot complete, the @@ -3113,7 +3258,7 @@ mod tests { /// write) would already have destroyed the original at that point. #[test] fn a_failed_save_leaves_the_existing_file_untouched() { - let path = scratch_config_path("atomic"); + let (_scratch, path) = scratch_config("atomic"); let mut s = Settings::defaults(); s.web_port = 9001; s.save(&path).expect("first save"); @@ -3126,7 +3271,6 @@ mod tests { let result = s.save(&path); let after = std::fs::read(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert!(result.is_err(), "the blocked temp write must surface"); assert_eq!(after, before, "the original config must be untouched"); } @@ -3135,10 +3279,9 @@ mod tests { fn missing_config_file_falls_back_to_defaults() { // The cold-start case: nothing to read yet. Defaults, written out // at once so the user can find the file, and normal saving after. - let path = scratch_config_path("missing"); + let (_scratch, path) = scratch_config("missing"); let startup = Settings::load_for_startup(&path).expect("fresh install"); let written = path.exists(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); let s = startup.settings; assert_eq!(s.ingest_port, 1935); assert_eq!(s.web_port, 7799); @@ -3160,7 +3303,7 @@ mod tests { /// and stream key was overwritten with defaults on launch. #[test] fn an_unreadable_config_is_copied_aside_and_not_overwritten() { - let path = scratch_config_path("locked"); + let (_scratch, path) = scratch_config("locked"); let original = b"configured=true\ndestination.0.stream_key=live_abc\n".to_vec(); std::fs::write(&path, &original).unwrap(); let earlier_copy = path.with_file_name("instantclone.cfg.unreadable"); @@ -3171,7 +3314,6 @@ mod tests { let on_disk = std::fs::read(&path).unwrap(); let first_copy = std::fs::read(&earlier_copy).unwrap(); let new_copy = std::fs::read(path.with_file_name("instantclone.cfg.unreadable.2")); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(on_disk, original, "the original must be untouched"); assert_eq!( first_copy, b"an older copy", @@ -3189,11 +3331,10 @@ mod tests { fn an_unreadable_config_that_cannot_be_copied_stops_startup() { // A directory at the config path reads and copies as an error on // every OS, without needing a real lock. - let path = scratch_config_path("dir"); + let (_scratch, path) = scratch_config("dir"); std::fs::create_dir(&path).unwrap(); let result = Settings::load_for_startup_with(&path, locked_read); let still_there = path.is_dir(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert!(result.is_err()); assert!(still_there); } @@ -3202,7 +3343,7 @@ mod tests { /// copy and no notice: a brief antivirus scan is not worth a dialog. #[test] fn a_briefly_locked_config_loads_after_a_retry() { - let path = scratch_config_path("brief"); + let (_scratch, path) = scratch_config("brief"); std::fs::write(&path, "configured=true\nweb_port=9124\n").unwrap(); let reads = std::cell::Cell::new(0); let flaky = |p: &Path| { @@ -3215,7 +3356,6 @@ mod tests { }; let startup = Settings::load_for_startup_with(&path, flaky).expect("load"); let copied = path.with_file_name("instantclone.cfg.unreadable").exists(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(startup.settings.web_port, 9124); assert!(startup.may_save && startup.notice.is_none()); assert!(!copied); @@ -3225,10 +3365,6 @@ mod tests { fn parse_hotkey_accepts_modifier_plus_key_and_canonicalizes() { // Order-independent, case-insensitive, alias-aware; canonical form // is a fixed modifier order so the same combo always renders alike. - assert_eq!( - canonicalize_hotkey("alt+ctrl+d").as_deref(), - Some("Ctrl+Alt+D") - ); assert_eq!( canonicalize_hotkey("CONTROL + SHIFT + f8").as_deref(), Some("Ctrl+Shift+F8") @@ -3359,7 +3495,7 @@ mod tests { #[test] fn no_destination_field_can_inject_a_config_line() { let mut s = Settings::defaults(); - let mut d = Destination { + let d = Destination { id: "d1".into(), name: "Main\nweb_bind_all=true".into(), enabled: true, @@ -3373,17 +3509,10 @@ mod tests { stream_format: String::new(), audio_track: "auto".into(), }; - d.name = d.name.clone(); s.destinations = vec![d]; - let path = std::env::temp_dir().join(format!( - "ic-inject-{}-{}.conf", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); + let scratch = crate::test_support::TestDir::new("ic-inject"); + let path = scratch.join("settings.conf"); s.save(&path).expect("save"); let written = std::fs::read_to_string(&path).expect("read back"); @@ -3406,21 +3535,14 @@ mod tests { assert!(back.dashboard_password_hash.is_empty()); assert!(!back.web_bind_all); assert_eq!(back.web_port, s.web_port); - let _ = std::fs::remove_file(&path); } /// Config files get hand-edited and half-written. Loading one must not /// panic, and must not let a broken line take a good setting with it. #[test] fn a_damaged_config_file_still_loads() { - let path = std::env::temp_dir().join(format!( - "ic-edge-cfg-{}-{}.conf", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); + let scratch = crate::test_support::TestDir::new("ic-edge-cfg"); + let path = scratch.join("settings.conf"); std::fs::write( &path, "\n\ @@ -3433,7 +3555,8 @@ mod tests { hotkey.toggle=Ctrl+Alt+D\n\ hotkey.nonsense=Ctrl+Alt+E\n\ midi.toggle=note:1:200\n\ - target_delay_ms=99999999999999999999\n\ + armed_delay_ms=99999999999999999999\n\ + target_delay_ms=99999999\n\ a truncated final line", ) .expect("write"); @@ -3445,12 +3568,15 @@ mod tests { s.midi.toggle.is_empty(), "an out-of-range signature is dropped, not stored" ); - assert!( - s.target_delay_ms <= 600_000, - "an absurd delay is clamped, got {}", - s.target_delay_ms + assert_eq!( + s.target_delay_ms, MAX_DELAY_MS, + "a delay past the top is clamped to it" + ); + assert_eq!( + s.armed_delay_ms, + Settings::defaults().armed_delay_ms, + "a number too big to read is ignored" ); - let _ = std::fs::remove_file(&path); } /// A stream key, webhook token or egress URL is user-supplied text that @@ -3583,15 +3709,8 @@ mod tests { #[test] fn hotkeys_round_trip_through_save_and_load() { - let path = std::env::temp_dir().join(format!( - "ic-test-hotkeys-roundtrip-{}-{}.ini", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-test-hotkeys-roundtrip"); + let path = scratch.join("settings.ini"); let mut s = Settings::defaults(); // A lowercase, out-of-order input must be stored canonicalized. @@ -3625,20 +3744,15 @@ mod tests { assert!(loaded.hotkeys.activate.is_empty(), "unbound stays unbound"); // A defaults() config must not write any hotkey line. - let bare = - std::env::temp_dir().join(format!("ic-test-hotkeys-bare-{}.ini", std::process::id())); - let _ = std::fs::remove_file(&bare); + let bare = scratch.join("bare.ini"); let mut d = Settings::defaults(); - d.destinations = loaded.destinations.clone(); + d.destinations = loaded.destinations; d.save(&bare).expect("save bare"); let text = std::fs::read_to_string(&bare).expect("read bare"); assert!( !text.contains("hotkey."), "unbound hotkeys must stay out of the config file" ); - - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(&bare); } /// Note 36 on channel 1 is pad 1 on half the controllers ever made, so @@ -3690,20 +3804,11 @@ Name@x" } #[test] - fn midi_signatures_validate_and_route() { - assert_eq!(canonicalize_midi("NOTE:1:36").as_deref(), Some("note:1:36")); + fn midi_bindings_keep_only_valid_signatures_and_route_them() { assert_eq!( canonicalize_midi(" cc : 10 : 20 ").as_deref(), Some("cc:10:20") ); - assert!(canonicalize_midi("note:0:36").is_none(), "channel below 1"); - assert!( - canonicalize_midi("note:17:36").is_none(), - "channel above 16" - ); - assert!(canonicalize_midi("cc:1:128").is_none(), "data above 127"); - assert!(canonicalize_midi("pitch:1:1").is_none(), "unknown kind"); - assert!(canonicalize_midi("note:1").is_none(), "missing data"); assert!(canonicalize_midi("note:1:1:1").is_none(), "trailing junk"); let mut m = MidiBindings::default(); @@ -3717,19 +3822,13 @@ Name@x" assert_eq!(m.action_for(""), None); } - /// Unique scratch path per call, so parallel tests never share a file - /// and none of them can reach the user's real settings. - fn scratch_config_path(tag: &str) -> PathBuf { - use std::sync::atomic::{AtomicU32, Ordering}; - static NEXT: AtomicU32 = AtomicU32::new(0); - let dir = std::env::temp_dir().join(format!( - "ic-cfg-{tag}-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir.join("instantclone.cfg") + /// A unique scratch config path, so parallel tests never share a file and + /// none can reach the user's real settings. Keep the folder guard alive: + /// dropping it removes the folder. + fn scratch_config(tag: &str) -> (crate::test_support::TestDir, PathBuf) { + let dir = crate::test_support::TestDir::new(&format!("cfg-{tag}")); + let path = dir.join("instantclone.cfg"); + (dir, path) } fn test_destination(id: &str, platform: &str, key: &str) -> Destination { @@ -3824,6 +3923,11 @@ Name@x" auto_arm_delay_ms: 25_000, update_check_enabled: false, open_dashboard_on_launch: false, + update_tray_notify: false, + tray_color: 3, + update_notified: "0.1.17".into(), + last_seen_version: "0.1.16".into(), + last_seen_commit: "5493bf1".into(), overlays_seeded: true, // A layout value holding its own `=`: the line parser must split // on the first `=` only. @@ -3883,6 +3987,19 @@ Name@x" server: "https://ntfy.example".into(), topic: "texaz-alerts".into(), }, + alerts_look: crate::integrations::alerts::Look { + theme: "signal".into(), + position: "top".into(), + align: "right".into(), + scale: 140, + font: "Orbitron".into(), + accent: "#ff7eb6".into(), + opacity: Some(80), + radius: Some(0), + sound: "media:ding.mp3".into(), + css: ".card .text { letter-spacing: 1px }".into(), + ..Default::default() + }, integrations_migrated: true, twitch_client_id: "abcdefghij0123456789klmnopqrst".into(), twitch_shared_chat: true, @@ -3916,7 +4033,7 @@ Name@x" /// alerts" integration, once, and a config saved afterwards keeps them. #[test] fn old_discord_webhook_is_migrated_once() { - let path = scratch_config_path("webhook-migration"); + let (_scratch, path) = scratch_config("webhook-migration"); std::fs::write( &path, "discord_webhook_url=https://discord.com/api/webhooks/9/tok @@ -3939,13 +4056,12 @@ Name@x" let again = Settings::load(&path).unwrap(); assert_eq!(again.integrations.len(), 1, "migrated exactly once"); assert_eq!(again.discord_channels, loaded.discord_channels); - let _ = std::fs::remove_file(&path); } /// A damaged integration line is skipped; everything else still loads. #[test] fn a_broken_integration_line_does_not_cost_the_rest() { - let path = scratch_config_path("broken-integration"); + let (_scratch, path) = scratch_config("broken-integration"); let good = crate::integrations::presets::build("delay_command", "good".into(), "") .unwrap() .to_json() @@ -3965,7 +4081,6 @@ integrations_migrated=true assert_eq!(loaded.web_port, 7811); assert_eq!(loaded.integrations.len(), 1); assert_eq!(loaded.integrations[0].id, "good"); - let _ = std::fs::remove_file(&path); } /// Every setting survives save then load unchanged. The one test that @@ -3973,16 +4088,16 @@ integrations_migrated=true /// read, which otherwise shows up as a setting that resets on restart. #[test] fn every_setting_round_trips_through_save_and_load() { - let path = scratch_config_path("full-roundtrip"); + let (_scratch, path) = scratch_config("full-roundtrip"); let original = every_field_non_default(); original.save(&path).unwrap(); let loaded = Settings::load(&path); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(loaded.unwrap(), original); } - /// Guards the guard: if `every_field_non_default` drifted back to a - /// default somewhere, the round trip would stop covering that field. + /// Guards the guard, spot-checking the fields most likely to drift: if + /// `every_field_non_default` set one back to its default, the round trip + /// would stop covering it. #[test] fn the_round_trip_fixture_really_is_non_default() { let s = every_field_non_default(); @@ -4013,7 +4128,7 @@ integrations_migrated=true /// at its default. #[test] fn a_0_1_14_config_loads_intact() { - let path = scratch_config_path("v0114"); + let (_scratch, path) = scratch_config("v0114"); std::fs::write( &path, "# InstantClone - written by the app. Hand edits OK.\n\ @@ -4048,7 +4163,6 @@ integrations_migrated=true ) .unwrap(); let loaded = Settings::load(&path); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); let mut expected = Settings::defaults(); expected.configured = true; @@ -4070,7 +4184,7 @@ integrations_migrated=true /// do not know are skipped without disturbing the ones we do. #[test] fn unknown_keys_from_a_newer_build_do_not_disturb_known_ones() { - let path = scratch_config_path("newer"); + let (_scratch, path) = scratch_config("newer"); let original = every_field_non_default(); original.save(&path).unwrap(); let mut text = std::fs::read_to_string(&path).unwrap(); @@ -4081,7 +4195,6 @@ integrations_migrated=true let loaded = Settings::load(&path).unwrap(); loaded.save(&path).unwrap(); let resaved = std::fs::read_to_string(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert_eq!(loaded, original); // Pinned behavior: unknown lines are dropped on the next save, so a // rollback followed by any settings change loses the newer build's @@ -4103,7 +4216,7 @@ integrations_migrated=true /// every stream key. The damaged character may be lost; nothing else is. #[test] fn a_config_with_non_utf8_bytes_still_loads_its_other_fields() { - let path = scratch_config_path("ansi"); + let (_scratch, path) = scratch_config("ansi"); let mut bytes = b"configured=true\nweb_port=9123\ndestination.0.id=d1\n\ destination.0.enabled=true\ndestination.0.platform=twitch\n\ destination.0.stream_key=live_abc\ndestination.0.name=Espa" @@ -4113,7 +4226,6 @@ integrations_migrated=true std::fs::write(&path, &bytes).unwrap(); let s = Settings::load(&path); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); let s = s.expect("a non-UTF-8 byte must not fail the whole load"); assert!(s.configured); assert_eq!(s.web_port, 9123); @@ -4128,7 +4240,7 @@ integrations_migrated=true /// stream the user removed would go live again after a restart. #[test] fn deleting_the_last_destination_survives_a_restart() { - let path = scratch_config_path("last-dest"); + let (_scratch, path) = scratch_config("last-dest"); let mut s = Settings::defaults(); s.destinations .push(test_destination("d1", "youtube", "secret-key")); @@ -4140,7 +4252,6 @@ integrations_migrated=true loaded.save(&path).unwrap(); let reloaded = Settings::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); assert!( reloaded.destinations.is_empty(), "deleted destination came back: {:?}", @@ -4148,40 +4259,41 @@ integrations_migrated=true ); } - /// A config from before destinations existed (only the legacy - /// single-destination lines) still becomes one enabled destination. - #[test] - fn a_pre_destinations_config_still_migrates() { - let path = scratch_config_path("legacy"); - std::fs::write(&path, "configured=true\nplatform=youtube\nstream_key=abc\n").unwrap(); - let s = Settings::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - assert_eq!(s.destinations.len(), 1); - let d = &s.destinations[0]; - assert_eq!( - (d.platform.as_str(), d.stream_key.as_str()), - ("youtube", "abc") - ); - assert!(d.enabled); - assert_eq!( - d.egress_url().as_deref(), - Some("rtmp://a.rtmp.youtube.com/live2/abc") - ); - } - - /// The custom-URL-only variant: a key embedded in the URL and no - /// `stream_key=` line must migrate too, and keep the URL intact. + /// The single-destination settings of the first versions become a destination. #[test] - fn a_pre_destinations_custom_url_config_still_migrates() { - let path = scratch_config_path("legacy-custom"); - let url = "rtmp://ingest.example.com/app/embedded-key"; - std::fs::write(&path, format!("platform=custom\ncustom_egress_url={url}\n")).unwrap(); - let s = Settings::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - assert_eq!(s.destinations.len(), 1); - assert_eq!(s.destinations[0].platform, "custom"); - assert_eq!(s.destinations[0].custom_egress_url, url); - assert_eq!(s.destinations[0].egress_url().as_deref(), Some(url)); + fn a_config_from_before_destinations_still_migrates() { + // a pre destinations config still migrates + // A config from before destinations existed (only the legacy + // single-destination lines) still becomes one enabled destination. + { + let (_scratch, path) = scratch_config("legacy"); + std::fs::write(&path, "configured=true\nplatform=youtube\nstream_key=abc\n").unwrap(); + let s = Settings::load(&path).unwrap(); + assert_eq!(s.destinations.len(), 1); + let d = &s.destinations[0]; + assert_eq!( + (d.platform.as_str(), d.stream_key.as_str()), + ("youtube", "abc") + ); + assert!(d.enabled); + assert_eq!( + d.egress_url().as_deref(), + Some("rtmp://a.rtmp.youtube.com/live2/abc") + ); + } + // a pre destinations custom url config still migrates + // The custom-URL-only variant: a key embedded in the URL and no + // `stream_key=` line must migrate too, and keep the URL intact. + { + let (_scratch, path) = scratch_config("legacy-custom"); + let url = "rtmp://ingest.example.com/app/embedded-key"; + std::fs::write(&path, format!("platform=custom\ncustom_egress_url={url}\n")).unwrap(); + let s = Settings::load(&path).unwrap(); + assert_eq!(s.destinations.len(), 1); + assert_eq!(s.destinations[0].platform, "custom"); + assert_eq!(s.destinations[0].custom_egress_url, url); + assert_eq!(s.destinations[0].egress_url().as_deref(), Some(url)); + } } /// Other tests trust `is_valid_json` to catch broken hand-written JSON, diff --git a/src/controller.rs b/src/controller.rs index ac93c83..58b7549 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -10,6 +10,8 @@ use crate::buffer::{DiskRing, TagMeta}; use crate::compat::StreamParams; +use crate::config::MAX_DELAY_MS; +use crate::egress_failure::EgressFailure; use crate::h264::{AudioCodec, VideoCodec}; use crate::integrations::host::fmt_duration; use crate::integrations::{Event, EventKind}; @@ -28,19 +30,19 @@ use tokio::sync::{Mutex, Notify}; /// audio / video tag (~150-300 per second per destination); switching /// to `Instant::now()` is a vDSO call (~10ns) and immune to clock /// adjustments. -fn process_now_ms() -> u64 { +pub(crate) fn process_now_ms() -> u64 { static ANCHOR: OnceLock = OnceLock::new(); let start = ANCHOR.get_or_init(Instant::now); Instant::now().saturating_duration_since(*start).as_millis() as u64 } /// Rolling bitrate, lock-free: bytes are summed over windows of at least -/// `RATE_WINDOW_MS` and the rate is the last full window. A window that -/// hasn't rolled for `RATE_STALE_MS` means nothing has arrived since (the -/// stream stopped or froze), so the rate reads 0 rather than holding the -/// last second's value forever. +/// `WINDOW_MS` and the rate is the last full window. A window that hasn't +/// rolled for `RATE_STALE_SLACK_MS` past its length means nothing has +/// arrived since (the stream stopped or froze), so the rate reads 0 rather +/// than holding the last window's value forever. #[derive(Default)] -pub struct RateMeter { +pub struct RateMeter { window_bytes: AtomicU64, /// 0 = no window yet. window_start_ms: AtomicU64, @@ -48,9 +50,16 @@ pub struct RateMeter { } const RATE_WINDOW_MS: u64 = 1_000; -const RATE_STALE_MS: u64 = 2_500; +const RATE_STALE_SLACK_MS: u64 = 1_500; +/// The window capacity estimates plan on: five 2 s keyframe intervals, so a +/// keyframe's burst averages out. A 1 s window reads well above a CBR +/// stream's real rate whenever it holds a keyframe, which would refuse +/// delays the buffer really holds. +const CAPACITY_WINDOW_MS: u64 = 10_000; + +impl RateMeter { + const STALE_MS: u64 = WINDOW_MS + RATE_STALE_SLACK_MS; -impl RateMeter { pub fn note(&self, bytes: usize) { self.note_at(bytes, process_now_ms()); } @@ -60,15 +69,18 @@ impl RateMeter { let start = self.window_start_ms.load(Ordering::Relaxed); // First bytes, or the first after a gap: they mark the start of a // fresh window (so they count toward the time before it), instead - // of the silence being averaged into the rate. - if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS { + // of the silence being averaged into the rate. The rate before the + // gap is forgotten: until this window fills, there is none, rather + // than the last stream's (which the capacity estimate would plan by). + if start == 0 || now_ms.saturating_sub(start) > Self::STALE_MS { self.window_bytes.store(0, Ordering::Relaxed); self.window_start_ms.store(now_ms, Ordering::Relaxed); + self.kbps.store(0, Ordering::Relaxed); return; } let total = self.window_bytes.fetch_add(bytes as u64, Ordering::Relaxed) + bytes as u64; let elapsed = now_ms.saturating_sub(start); - if elapsed >= RATE_WINDOW_MS { + if elapsed >= WINDOW_MS { // bytes * 8 / ms = kbps self.kbps .store(((total * 8) / elapsed) as u32, Ordering::Relaxed); @@ -81,9 +93,17 @@ impl RateMeter { self.kbps_at(process_now_ms()) } + /// Start over, as if nothing had been measured: for a new stream, whose + /// first window must not hold the tail of the one before it. + fn reset(&self) { + self.window_bytes.store(0, Ordering::Relaxed); + self.window_start_ms.store(0, Ordering::Relaxed); + self.kbps.store(0, Ordering::Relaxed); + } + fn kbps_at(&self, now_ms: u64) -> u32 { let start = self.window_start_ms.load(Ordering::Relaxed); - if start == 0 || now_ms.saturating_sub(start) > RATE_STALE_MS { + if start == 0 || now_ms.saturating_sub(start) > Self::STALE_MS { return 0; } self.kbps.load(Ordering::Relaxed) @@ -155,7 +175,7 @@ impl ActivateError { ActivateError::NoIngest => NO_INGEST.to_string(), ActivateError::BufferShort { remaining_ms } => { let secs = ((*remaining_ms + 500) / 1000).max(1); - format!("buffer is still building - wait ~{}s", secs) + format!("buffer is still building, wait ~{}s", secs) } } } @@ -172,7 +192,7 @@ const LOG_LINES_MAX: usize = 1_500; /// a crash-protection hold before a missing picture counts as a freeze. const FIRST_VIDEO_GRACE: Duration = Duration::from_secs(10); -pub const NO_INGEST: &str = "OBS isn't sending anything yet - start streaming first"; +pub const NO_INGEST: &str = "OBS isn't sending anything yet. Start streaming first"; /// One hotkey / MIDI press, as the dashboard renders it: which action ran, /// where it came from, whether it was refused, and a sequence number so a @@ -185,11 +205,74 @@ pub struct FiredAction { pub problem: Option, } +/// The armed delay is longer than the buffer holds at this stream's +/// bitrate, so it runs at what the buffer holds. Arming refuses such a +/// delay once a bitrate is measured; this is the case it couldn't refuse: +/// a delay armed before there was one (auto-arm at connect, the one saved +/// from last time), or a stream that got heavier after arming. +#[derive(Debug, Clone, PartialEq)] +pub struct CapacityShort { + /// The delay that was armed. + pub armed_ms: u32, + /// About how long a delay the buffer holds: what actually runs. + pub holds_ms: u32, + /// "Buffer too small for ...", ready to show the user. + pub message: String, +} + /// Hidden slack beyond what the user sees as the target. Equal to the /// IDR-search tolerance, so a "5s armed" cut can always land on a real /// IDR even if the nearest one happens to be slightly past the boundary. const BUFFER_SLACK_MS: u32 = 2_000; +/// Bitrate a capacity estimate plans for before a stream has been measured: +/// 10 Mbps, so a guess made before going live errs on the short side. +const CAPACITY_REFERENCE_KBPS: u32 = 10_000; +/// Below this the meter is still warming up after a connect (or nothing is +/// arriving), so the estimate plans for the reference instead. +const CAPACITY_MIN_MEASURED_KBPS: f64 = 500.0; +/// How fast the planning bitrate lets go of a peak, per second. A real drop +/// shows within about half a minute; a short dip doesn't unlock a delay the +/// buffer can't hold at the stream's normal bitrate. +const CAPACITY_HOLD_DECAY_PER_S: f64 = 0.94; + +/// The longest delay `usable_bytes` of ring holds at `kbps`: the time they +/// take to fill, less the slack trimming keeps on top of the delay. +fn delay_capacity_ms(usable_bytes: u64, kbps: u32) -> u32 { + // bytes * 8 = bits, and kbps is bits per millisecond. + let fill_ms = usable_bytes * 8 / u64::from(kbps.max(1)); + fill_ms + .saturating_sub(u64::from(BUFFER_SLACK_MS)) + .min(u64::from(u32::MAX)) as u32 +} + +/// The buffer size, in MB, that holds a delay of `ms` at `kbps`: the +/// inverse of `delay_capacity_ms`, with the ring's unusable sixteenth. +/// Rounded up at every step, so the size it names always holds the delay. +fn buffer_mb_for(ms: u32, kbps: u32) -> u64 { + let bytes = (u64::from(ms.saturating_add(BUFFER_SLACK_MS)) * u64::from(kbps)).div_ceil(8); + (bytes * 16).div_ceil(15).div_ceil(1024 * 1024) +} + +/// What every surface says when a delay of `ms` doesn't fit: the buffer +/// holds `capacity` ms at `kbps`, and the size that would hold it. +fn buffer_too_small(ms: u32, capacity: u32, kbps: u32) -> String { + format!( + "Buffer too small for {}: it holds about {} at {:.1} Mbps. \ + Raise the buffer size to at least {} MB in System, then restart.", + fmt_duration(u64::from(ms)), + fmt_duration(u64::from(capacity)), + f64::from(kbps) / 1000.0, + buffer_mb_for(ms, kbps) + ) +} + +/// A held peak bitrate `elapsed_s` seconds on, given what the meter reads +/// now: it rises at once and lets go slowly. +fn hold_peak(held: f64, elapsed_s: f64, measured: f64) -> f64 { + (held * CAPACITY_HOLD_DECAY_PER_S.powf(elapsed_s)).max(measured) +} + /// How many IDR-to-IDR gaps to average before freezing the keyframe-interval /// measurement. Five gaps is ~10 s of a normally-configured stream: long /// enough that one odd GOP at stream start can't skew the mean, short enough @@ -308,6 +391,13 @@ pub struct DestinationState { /// still matches - both reads happen under the override mutex so the /// check can't race the disconnect's clear+bump. pub session_epoch: AtomicU64, + /// Why the last attempt to stream here failed, and when the next one + /// starts, for the dashboard card. Cleared when a pump starts and once a + /// session proves the endpoint works. + pub failure: crate::sync::Mutex>, + /// "Retry now" from the dashboard: cuts the current backoff wait short. + /// Checked by the same 200 ms poll as `shutdown_requested`. + pub retry_now: AtomicBool, } /// The Enhanced Broadcasting config last handed to OBS, and the Twitch @@ -372,6 +462,30 @@ impl DestinationState { vertical_primary_track: AtomicU8::new(0xFF), vod_fetch_pending: AtomicBool::new(false), session_epoch: AtomicU64::new(0), + failure: crate::sync::Mutex::new(None), + retry_now: AtomicBool::new(false), + } + } + + /// Ask a destination waiting out its backoff to try again now. False + /// when it isn't waiting (connecting, streaming, or nothing to retry). + pub fn request_retry(&self) -> bool { + let waiting = self + .failure + .lock() + .as_ref() + .is_some_and(|f| f.retry_at.is_some()); + if waiting { + self.retry_now.store(true, Ordering::Relaxed); + } + waiting + } + + /// The backoff wait is over: the card says "connecting" again, keeping + /// the reason until the attempt succeeds or fails anew. + fn mark_retrying(&self) { + if let Some(f) = self.failure.lock().as_mut() { + f.retry_at = None; } } @@ -540,6 +654,12 @@ pub struct Controller { /// streamer is already in the active state and a subsequent cut /// shouldn't snap back to "active" via auto-activate. auto_activate_pending: AtomicBool, + /// The pending arm was promised to go on by itself once the buffer + /// holds it (`POST /delay`, "Delay on/off" pressed while it fills), so + /// it does even with "Activate when ready" off. Only read while + /// `auto_activate_pending` is set, and reset by every fresh arm, so it + /// never outlives the arm it was asked for. + activate_once_ready: AtomicBool, /// Input-timeline timestamp (u64, expand_ts domain) of a scheduled /// "cut after this airs" mark. 0 = none pending. Set by /// `schedule_safe_cut` to the live-edge ts at the moment the @@ -584,6 +704,15 @@ pub struct Controller { /// "The buffer holds only N s" was logged for the armed delay; cleared /// on the next arm so each new value is checked once. capacity_capped_logged: AtomicBool, + /// The armed delay `check_armed_capacity` has already checked against + /// this stream's measured bitrate (0: none yet). Reset per publisher. + capacity_checked_ms: AtomicU32, + /// The armed delay is longer than the buffer holds, so it runs short. + /// See `capacity_short`. + capacity_short: crate::sync::Mutex>, + /// Bumped each time a new `capacity_short` is reported, so the runtime + /// raises one tray notice per report. + capacity_short_seq: AtomicU64, /// Process ms of the last "rejected publisher" log line (see /// `log_rejected_publish`). last_rejection_log_ms: AtomicU64, @@ -632,6 +761,9 @@ pub struct Controller { // Ingest-side stats ingest_disconnects: AtomicU32, rate_in: RateMeter, // inbound (from OBS) + /// The inbound rate averaged over `CAPACITY_WINDOW_MS`: what capacity + /// estimates plan on. + rate_in_mean: RateMeter, // Video tags only. Viewers decode the video track, so the Twitch // mobile-decoder check compares this, not the total with audio. rate_video_in: RateMeter, @@ -779,6 +911,9 @@ pub struct Controller { // so a dashboard that goes away mid-capture cannot leave them off. #[cfg_attr(not(windows), allow(dead_code))] hotkey_capture_until_ms: AtomicU64, + /// Peak-held inbound kbps and when it was last updated (process ms), for + /// `capacity_kbps`. + capacity_kbps_hold: crate::sync::Mutex<(f64, u64)>, // Actions whose hotkey the OS refused to register, because another app // already owns the combo. Written by the tray on every (re)bind, read by @@ -786,26 +921,24 @@ pub struct Controller { // line alone is somewhere nobody setting a hotkey is looking. hotkey_conflicts: crate::sync::Mutex>, - // Raised when a hotkey or MIDI action moves the delay state. Those two - // paths run outside the web layer, which is where every other state - // change gets written to the config file, so the runtime watches this - // and persists on their behalf. - #[cfg_attr(not(windows), allow(dead_code))] + // Raised when a hotkey, MIDI, tray or integration action moves the delay + // state. Those paths run outside the web layer, which is where every + // other state change gets written to the config file, so the runtime + // watches this and persists on their behalf. state_dirty: Notify, } impl Controller { pub fn new(ring: Arc, initial_armed_delay_ms: u32) -> Self { - // Match arm_delay's clamp. A persisted (or hand-edited) config - // value larger than the cap would otherwise wedge the egress - // pump in `preparing` forever because the buffer can never grow - // to a delay we don't actually allow. - let initial_armed_delay_ms = initial_armed_delay_ms.min(600_000); + // Match arm_delay's clamp, so a hand-edited config can't arm past + // the ceiling at startup either. + let initial_armed_delay_ms = initial_armed_delay_ms.min(MAX_DELAY_MS); Self { ring, armed_delay_ms: AtomicU32::new(initial_armed_delay_ms), target_delay_ms: AtomicU32::new(0), auto_activate_pending: AtomicBool::new(false), + activate_once_ready: AtomicBool::new(false), safe_cut_input_ts: AtomicU64::new(0), ingest_alive: AtomicBool::new(false), buffer_building: AtomicBool::new(false), @@ -817,6 +950,9 @@ impl Controller { hold_tail_end_seq: AtomicU64::new(NO_SEQ), tail_plays_out: AtomicBool::new(false), capacity_capped_logged: AtomicBool::new(false), + capacity_checked_ms: AtomicU32::new(0), + capacity_short: crate::sync::Mutex::new(None), + capacity_short_seq: AtomicU64::new(0), last_rejection_log_ms: AtomicU64::new(0), resumed_after_seq: AtomicU64::new(NO_SEQ), replay_floor_seq: AtomicU64::new(NO_SEQ), @@ -828,6 +964,7 @@ impl Controller { destinations: crate::sync::RwLock::new(HashMap::new()), ingest_disconnects: AtomicU32::new(0), rate_in: RateMeter::default(), + rate_in_mean: RateMeter::default(), rate_video_in: RateMeter::default(), publisher_local: AtomicBool::new(false), integrations: std::sync::OnceLock::new(), @@ -872,6 +1009,7 @@ impl Controller { last_action: crate::sync::Mutex::new(None), last_action_seq: AtomicU64::new(0), hotkey_capture_until_ms: AtomicU64::new(0), + capacity_kbps_hold: crate::sync::Mutex::new((0.0, 0)), hotkey_conflicts: crate::sync::Mutex::new(Vec::new()), state_dirty: Notify::new(), } @@ -949,6 +1087,12 @@ impl Controller { /// single (often misclassified) tag; this version auto-clears as /// soon as multi-track stops, and is always off when ingest is /// not alive. + /// OBS is publishing Enhanced Broadcasting, but no session was handed + /// to it in this run: it is continuing one from before a restart. + pub fn publishing_unbrokered_eb(&self) -> bool { + self.ingest_alive() && self.multitrack_video() && self.eb_session.lock().is_none() + } + pub fn multitrack_video(&self) -> bool { if !self.ingest_alive.load(Ordering::Relaxed) { return false; @@ -1059,8 +1203,9 @@ impl Controller { /// sticky bool used to live on `multitrack_video` itself; we keep it /// here just to throttle the log to once. pub fn note_multitrack_video(&self) { + // 0 means "never": a tag in the process's first millisecond isn't. self.last_multitrack_video_ms - .store(process_now_ms(), Ordering::Relaxed); + .store(process_now_ms().max(1), Ordering::Relaxed); if !self.multitrack_video.swap(true, Ordering::Relaxed) { // Twitch destinations pass the multi-track tag through // bit-faithfully (Enhanced Broadcasting → transcoded @@ -1219,6 +1364,20 @@ impl Controller { self.destinations.write().remove(id); } + /// Why a destination's last attempt failed, if it did. Read-only: unlike + /// `destination_state` it never registers a destination that has no pump. + pub fn destination_failure(&self, id: &str) -> Option { + self.destinations.read().get(id)?.failure.lock().clone() + } + + /// "Retry now" for one destination. False when it isn't waiting to retry. + pub fn retry_destination_now(&self, id: &str) -> bool { + self.destinations + .read() + .get(id) + .is_some_and(|d| d.request_retry()) + } + /// Snapshot of every (id → state) pair. Used by graceful-shutdown /// paths that need to flip flags on every pump in one pass. pub fn all_destination_states(&self) -> Vec<(String, Arc)> { @@ -1282,16 +1441,24 @@ impl Controller { /// jump back to it (there is no gradual rewind - see /// `compute_delay_cut`'s build-buffer-first branch). pub fn arm_delay(&self, ms: u32) { - let ms = ms.min(600_000); + let ms = ms.min(MAX_DELAY_MS); let previous_target = self.target_delay_ms.load(Ordering::Relaxed); - self.capacity_capped_logged.store(false, Ordering::Relaxed); - self.armed_delay_ms.store(ms, Ordering::Relaxed); + // A delay armed anew (another value, or after a disarm) is judged + // afresh, against the bitrate as it is now. Arming the value that is + // already armed (a toggle after a cut) keeps its verdict: clearing it + // would hide a short delay for seconds and repeat its notices. + if self.armed_delay_ms.swap(ms, Ordering::Relaxed) != ms { + self.capacity_capped_logged.store(false, Ordering::Relaxed); + self.capacity_checked_ms.store(0, Ordering::Relaxed); + *self.capacity_short.lock() = None; + } if ms == 0 { // Disarm wipes target as well. self.target_delay_ms.store(0, Ordering::Relaxed); // Disarm clears any pending auto-activate too - there's // nothing to auto-activate into anymore. self.auto_activate_pending.store(false, Ordering::Relaxed); + self.activate_once_ready.store(false, Ordering::Relaxed); // A pending "cut after this airs" mark dies with the delay // it was going to cut. self.safe_cut_input_ts.store(0, Ordering::Relaxed); @@ -1305,11 +1472,29 @@ impl Controller { } else { // Fresh arm: target was 0 (we were disarmed, in cut-hold, // or in passthrough). Mark the auto-activate slot eligible - // - one shot when the buffer hits ready. + // - one shot when the buffer hits ready. A plain arm makes no + // promise to go on by itself; the callers that do say so after. self.auto_activate_pending.store(true, Ordering::Relaxed); + self.activate_once_ready.store(false, Ordering::Relaxed); } } + /// Switch the armed delay on as soon as the buffer holds it, this once, + /// whatever "Activate when ready" is set to. For the callers that tell + /// the streamer it will: without it they would arm, say "starts as soon + /// as it is ready", and leave the stream live for good. + pub fn activate_once_ready(&self) { + self.activate_once_ready.store(true, Ordering::Relaxed); + } + + /// Whether the supervisor should switch the delay on when the buffer is + /// ready: an arm is waiting, and either the "Activate when ready" + /// setting (`setting_on`) or the arm's own caller asked for it. + pub fn auto_activates(&self, setting_on: bool) -> bool { + self.auto_activate_pending() + && (setting_on || self.activate_once_ready.load(Ordering::Relaxed)) + } + /// Switch the armed delay on. Caller-side check: only acts if the /// buffer holds at least the armed amount. Returns Err otherwise. /// The error carries a static prefix the UI maps to a human message; @@ -1382,7 +1567,7 @@ impl Controller { /// on some future activate. pub fn schedule_safe_cut(&self) -> Result { if self.target_delay_ms.load(Ordering::Relaxed) == 0 { - return Err("no delay is active - nothing to schedule"); + return Err("no delay is active, so there is nothing to schedule"); } let Some(latest) = self.ring.latest_ts() else { return Err("no stream data yet"); @@ -1408,6 +1593,22 @@ impl Controller { self.ingest_alive.store(true, Ordering::Relaxed); } + /// Make the inbound meters read `kbps`, as a stream at that bitrate + /// would after its first ten seconds, without waiting them. + #[cfg(test)] + pub fn note_inbound_kbps_for_test(&self, kbps: u32) { + self.ingest_alive.store(true, Ordering::Relaxed); + let now = process_now_ms().max(1); + self.rate_in.note_at(0, now); + self.rate_in + .note_at(kbps as usize * 1000 / 8, now + RATE_WINDOW_MS); + self.rate_in_mean.note_at(0, now); + self.rate_in_mean.note_at( + kbps as usize * CAPACITY_WINDOW_MS as usize / 8, + now + CAPACITY_WINDOW_MS, + ); + } + /// The most recent hotkey / MIDI action, if there has been one this run. pub fn last_action(&self) -> Option { self.last_action.lock().clone() @@ -1508,7 +1709,7 @@ impl Controller { }; match self.slowest_live_consumer_ts() { // Clamp to u32: a u64 delta can't realistically exceed - // 600_000 ms (our hard armed-delay ceiling) but we cap to + // MAX_DELAY_MS (our armed-delay ceiling) but we cap to // be safe - the UI consumes a u32 number anyway. Some(ts) => latest.saturating_sub(ts).min(u32::MAX as u64) as u32, None => 0, @@ -1598,6 +1799,148 @@ impl Controller { pub fn bitrate_kbps(&self) -> u32 { self.rate_in.kbps() } + + /// The highest recent 10 s mean inbound rate, let go of slowly: what + /// capacity estimates plan for. None until a stream has been measured + /// for ten seconds (or once it has faded after one). Every tag OBS + /// sends lands in the ring, so the inbound rate is exactly how fast the + /// buffer fills. + pub fn measured_capacity_kbps(&self) -> Option { + // No stream, no measurement: the last one's fading peak must not + // decide what fits the next (a chat `!setdelay` right after going + // offline). A crash-protection hold is the same stream, waiting. + if !self.ingest_alive() && !self.hold_active() { + return None; + } + let now = process_now_ms(); + let measured = f64::from(self.rate_in_mean.kbps()); + let mut hold = self.capacity_kbps_hold.lock(); + let elapsed_s = now.saturating_sub(hold.1) as f64 / 1000.0; + // No reading (a crash-protection hold, and the first window after + // it) is silence, not a lighter stream: the peak stands as it was. + // Decayed through a 30 s hold, it let a delay that doesn't fit pass. + let held = if measured == 0.0 { + hold.0 + } else { + hold_peak(hold.0, elapsed_s, measured) + }; + *hold = (held, now); + (held >= CAPACITY_MIN_MEASURED_KBPS).then_some(held as u32) + } + + /// The bitrate capacity estimates plan for: the measured one, or the + /// 10 Mbps reference before there is one. + pub fn capacity_kbps(&self) -> u32 { + self.measured_capacity_kbps() + .unwrap_or(CAPACITY_REFERENCE_KBPS) + } + + /// The longest delay the buffer holds at `capacity_kbps`. The one + /// estimate every surface uses: the dashboard and the dock read it from + /// the state, and every way of arming checks it (`capacity_problem`). + pub fn delay_capacity_ms(&self) -> u32 { + delay_capacity_ms(self.ring.usable_bytes(), self.capacity_kbps()) + } + + /// Why the buffer can't hold a delay of `ms`, ready to show the user, or + /// None when it can. Only a measured bitrate refuses: before there is + /// one the estimate is a guess, and a delay that turns out longer than + /// the buffer still runs, at what the buffer holds. + pub fn capacity_problem(&self, ms: u32) -> Option { + let (capacity, kbps) = self.measured_fit(ms)?.err()?; + Some(buffer_too_small(ms, capacity, kbps)) + } + + /// Whether a delay of `ms` fits at the measured bitrate: None before + /// there is a measurement, otherwise Ok, or Err with what the buffer + /// holds instead and the bitrate that decided it. + fn measured_fit(&self, ms: u32) -> Option> { + let kbps = self.measured_capacity_kbps()?; + let capacity = delay_capacity_ms(self.ring.usable_bytes(), kbps); + Some(if ms <= capacity { + Ok(()) + } else { + Err((capacity, kbps)) + }) + } + + /// Check the armed delay against this stream's bitrate, once per armed + /// value per stream, as soon as there is a measurement. Every way of + /// arming refuses a delay the buffer can't hold, but only once a bitrate + /// is measured, and the delay armed at connect never had one: auto-arm + /// fires the moment OBS connects, and the delay saved from last time is + /// armed before OBS connects at all. One `!setdelay 3600` becomes the + /// next stream's auto-arm, so this is the only place that ever says it + /// doesn't fit. Called by the egress supervisor a few seconds into a + /// stream, when the meter has settled. + pub fn check_armed_capacity(&self) { + let armed = self.armed_delay_ms(); + if armed == 0 || self.capacity_checked_ms.load(Ordering::Relaxed) == armed { + return; + } + let Some(fit) = self.measured_fit(armed) else { + return; + }; + self.capacity_checked_ms.store(armed, Ordering::Relaxed); + match fit { + // It fits by the measured bitrate. A short the full buffer + // itself reported stands: that one is what really happened. + Ok(()) => { + if !self.capacity_capped_logged.load(Ordering::Relaxed) { + *self.capacity_short.lock() = None; + } + } + Err((holds_ms, kbps)) => { + // The buffer filled before this check and already said so, + // with the exact figure: one notice is enough. + let told = self.capacity_short.lock().as_ref().map(|s| s.armed_ms); + if told == Some(armed) { + return; + } + let message = buffer_too_small(armed, holds_ms, kbps); + self.log(format!( + "delay: the {} armed doesn't fit. {message}", + fmt_duration(u64::from(armed)) + )); + self.report_capacity_short(CapacityShort { + armed_ms: armed, + holds_ms, + message, + }); + } + } + } + + /// Publish a short delay for the dashboard and the tray notice. + fn report_capacity_short(&self, short: CapacityShort) { + *self.capacity_short.lock() = Some(short); + self.capacity_short_seq.fetch_add(1, Ordering::Relaxed); + } + + /// The armed delay runs short of what was armed, if it does: for the + /// dashboard (`capacity_short` in /state). A report about a delay that + /// is no longer the armed one is over. + pub fn capacity_short(&self) -> Option { + let armed = self.armed_delay_ms(); + self.capacity_short + .lock() + .clone() + .filter(|short| armed > 0 && short.armed_ms == armed) + } + + /// The delay that runs for `armed_ms`: what the buffer holds when it + /// can't hold all of it (see `capacity_short`), else `armed_ms`. + pub fn delay_runs_ms(&self, armed_ms: u32) -> u32 { + self.capacity_short() + .filter(|short| short.armed_ms == armed_ms) + .map_or(armed_ms, |short| short.holds_ms.min(armed_ms)) + } + + /// Bumped on every new short-delay report; see `capacity_short`. + pub fn capacity_short_seq(&self) -> u64 { + self.capacity_short_seq.load(Ordering::Relaxed) + } + /// Inbound video bitrate alone (no audio tracks, no sequence headers). pub fn video_bitrate_kbps(&self) -> u32 { self.rate_video_in.kbps() @@ -1612,6 +1955,7 @@ impl Controller { /// Called from the ingest path on every audio/video tag. pub fn note_inbound_bytes(&self, n: usize) { self.rate_in.note(n); + self.rate_in_mean.note(n); } pub fn note_ingest_disconnect(&self) { @@ -1624,12 +1968,18 @@ impl Controller { /// other - invaluable for diagnosing "the bouncing happened around /// 30 seconds in". pub fn log(&self, line: impl Into) { + let line = line.into(); + // A Linux server's only window on the app is its journal (or the + // terminal it runs in), and the dashboard log lives in memory. The + // journal stamps its own time, so the line goes out bare. + #[cfg(not(windows))] + log_line!("{line}"); let mut q = self.logs.lock(); if q.len() >= LOG_LINES_MAX { q.pop_front(); } let ts_s = process_now_ms() as f64 / 1000.0; - q.push_back(format!("[+{:>8.3}s] {}", ts_s, line.into())); + q.push_back(format!("[+{:>8.3}s] {}", ts_s, line)); } pub fn clear_logs(&self) { @@ -1756,7 +2106,6 @@ impl Controller { // restarts near ts 0, so the mark would sit unreachable and // fire hours later (or never) - clear it with the session. self.safe_cut_input_ts.store(0, Ordering::Relaxed); - // A goodbye from an earlier connection must not mark this session's // end as a clean stop. self.unpublish_received.store(false, Ordering::Relaxed); @@ -1771,6 +2120,17 @@ impl Controller { let resuming = self.hold_active(); if resuming { self.forget_unclaimed_eb_session(); + } else { + // A new stream is measured on its own. The last one's held peak + // (a 20 Mbps stream before a 6 Mbps one) would refuse delays that + // fit for half a minute, and the armed delay gets checked against + // this one's bitrate once it is measured. A resume is the same + // stream: it keeps all of it, so its notice isn't given twice. + self.rate_in_mean.reset(); + *self.capacity_kbps_hold.lock() = (0.0, 0); + self.capacity_checked_ms.store(0, Ordering::Relaxed); + self.capacity_capped_logged.store(false, Ordering::Relaxed); + *self.capacity_short.lock() = None; } // Bump token so any prior egress reader knows it's stale. let token = self.publisher_token.fetch_add(1, Ordering::SeqCst) + 1; @@ -1886,11 +2246,32 @@ impl Controller { } self.capacity_capped_logged.store(true, Ordering::Relaxed); self.log(format!( - "delay: the buffer is full at {} s at this bitrate, short of the {} s armed, so the delay runs at {} s. Raise the buffer size in System for the full delay.", + "delay: the buffer is full at {} s at this bitrate, short of the {} s armed, so the \ + delay runs at {} s. Raise the buffer size in System for the full delay.", fill / 1000, armed / 1000, fill / 1000 )); + // The same news the dashboard and tray get from the check after + // connect. Raised as a new notice only when that check didn't + // already say so (the stream got heavier since); either way the + // dashboard gets what the full buffer actually holds. + // A full buffer spans `fill` ms: the rate that filled it is exact, + // where the planning one falls back to 10 Mbps on a quiet stream. + let filled_kbps = (self.ring.usable_bytes() * 8 / u64::from(fill.max(1))) + .clamp(1, u64::from(u32::MAX)) as u32; + let short = CapacityShort { + armed_ms: armed, + holds_ms: fill, + message: buffer_too_small(armed, fill, filled_kbps), + }; + let mut current = self.capacity_short.lock(); + if current.as_ref().is_some_and(|c| c.armed_ms == armed) { + *current = Some(short); + } else { + drop(current); + self.report_capacity_short(short); + } } /// Slowest consumer across all destinations. Used by trim to ensure @@ -2655,16 +3036,38 @@ impl Controller { problem } + /// Go back to live once what's airing now has aired (an integration's + /// "Back to live after this airs"). Unlike the hotkey, whose second + /// press cancels a waiting cut, this keeps one that is already + /// waiting: a scene rule that fires twice (lobby, then menu) still cuts. + pub fn cut_after_airs(&self, source: &str) -> Option { + if self.safe_cut_pending() { + self.log(format!("[{source}] cut after this airs - already waiting")); + return None; + } + self.run_named_action("cut_after", 0, source) + } + /// Set the delay to `ms` (an integration's "set the delay"): arms it /// when nothing is armed, and changes it in place when a delay is armed /// or on air. Unlike the `arm` hotkey action, which toggles, this never /// disarms, so `!setdelay 30` twice leaves a 30 s delay. - pub fn set_delay_to(&self, ms: u32, source: &str) { - let ms = ms.clamp(1000, 600_000); - self.arm_delay(ms); - self.log(format!("[{source}] delay set to {} s", ms / 1000)); - self.record_fired_action("arm", source, None); + /// + /// Returns why it couldn't, like `run_named_action`: a delay the buffer + /// can't hold is refused rather than armed short. + pub fn set_delay_to(&self, ms: u32, source: &str) -> Option { + let ms = ms.clamp(1000, MAX_DELAY_MS); + let problem = self.capacity_problem(ms); + match &problem { + Some(why) => self.log(format!("[{source}] delay not set - {why}")), + None => { + self.arm_delay(ms); + self.log(format!("[{source}] delay set to {} s", ms / 1000)); + } + } + self.record_fired_action("arm", source, problem.clone()); self.state_dirty.notify_one(); + problem } /// Back to no delay at all (an integration's "Disarm"): live, and @@ -2715,12 +3118,21 @@ impl Controller { let ms = if self.armed_delay_ms() > 0 { self.armed_delay_ms() } else { + // A new delay: the buffer has to hold it. One already armed was + // checked when it was armed. + if let Some(problem) = self.capacity_problem(default_ms) { + self.log(format!("[{source}] delay on ignored - {problem}")); + return Some(problem); + } default_ms }; self.arm_delay(ms); match self.activate_delay() { Ok(d) => { - self.log(format!("[{source}] delay on - {} s", d / 1000)); + self.log(format!( + "[{source}] delay on - {} s", + self.delay_runs_ms(d) / 1000 + )); None } Err(e) => { @@ -2733,10 +3145,13 @@ impl Controller { // case resolves on its own - with nothing publishing, "still // filling" would be a wait that never ends. Some(match e { - ActivateError::BufferShort { .. } => format!( - "Buffer still filling - the {} s delay starts as soon as it is ready.", - ms / 1000 - ), + ActivateError::BufferShort { .. } => { + self.activate_once_ready(); + format!( + "Buffer still filling. The {} s delay starts as soon as it is ready.", + ms / 1000 + ) + } other => other.message(), }) } @@ -2763,6 +3178,10 @@ impl Controller { self.log(format!("[{source}] arm ignored - {NO_INGEST}")); return Some(NO_INGEST.to_string()); } + if let Some(problem) = self.capacity_problem(default_ms) { + self.log(format!("[{source}] arm ignored - {problem}")); + return Some(problem); + } self.arm_delay(default_ms); self.log(format!("[{source}] armed {} s", default_ms / 1000)); None @@ -2771,7 +3190,8 @@ impl Controller { fn action_activate(&self, source: &str) -> Option { match self.activate_delay() { Ok(d) => { - self.log(format!("[{source}] activated - {} s delay", d / 1000)); + let runs = self.delay_runs_ms(d); + self.log(format!("[{source}] activated - {} s delay", runs / 1000)); None } Err(e) => { @@ -2832,13 +3252,21 @@ pub async fn run_egress( url: String, dest: Arc, ) -> io::Result<()> { + // A fresh pump (a new key, a changed URL, switched back on) starts + // without the reason the previous one failed. + *dest.failure.lock() = None; let parsed = match EgressUrl::parse(&url) { Ok(p) => p, Err(e) => { ctrl.log(format!( - "[{}] invalid URL ({}) - fix it in Settings", + "[{}] invalid URL ({}) - fix it with Edit in Destinations", label, e )); + *dest.failure.lock() = Some(EgressFailure::new( + crate::egress_failure::BAD_URL, + &e.to_string(), + None, + )); tokio::time::sleep(Duration::from_secs(3600)).await; return Ok(()); } @@ -2893,9 +3321,12 @@ pub async fn run_egress( continue; } waiting_for_delay = false; - eprintln!( + log_line!( "[egress {}] connecting to {}:{}/{}", - label, parsed.host, parsed.port, parsed.app + label, + parsed.host, + parsed.port, + parsed.app ); ctrl.log(format!( "[{}] connecting to {}:{}", @@ -2904,6 +3335,8 @@ pub async fn run_egress( // Bounded: an edge that accepts TCP and then stalls in the TLS or // RTMP handshake would otherwise hold this destination on // "connecting" forever, never retrying. + // Why this attempt failed, for the card: (advice, scrubbed reason). + let mut failed: Option<(&'static str, String)> = None; let connected = tokio::time::timeout(CONNECT_TIMEOUT, EgressClient::connect(&parsed)) .await .unwrap_or_else(|_| { @@ -2924,7 +3357,18 @@ pub async fn run_egress( ); } let sink = client.spawn_reader_drain(); - let pump_result = pump_dest(&ctrl, &dest, sink).await; + let pump = pump_dest(&ctrl, &dest, sink); + tokio::pin!(pump); + let pump_result = tokio::select! { + ended = &mut pump => ended, + // Up this long, the endpoint works: the reason the last + // try failed is history, and must not sit on a live + // destination until this session ends. + _ = tokio::time::sleep(STABLE_SESSION) => { + *dest.failure.lock() = None; + pump.await + } + }; dest.egress_alive.store(false, Ordering::Relaxed); // Not reading any more: its position must not hold back the // buffer's trim while it reconnects or waits. @@ -2943,6 +3387,7 @@ pub async fn run_egress( let lasted = connected_at.elapsed(); if (!dropped && lasted >= MIN_CLEAN_SESSION) || lasted >= STABLE_SESSION { backoff = Duration::from_secs(1); + *dest.failure.lock() = None; } if let Err(e) = pump_result { // Twitch/etc. sometimes echo the stream key in error @@ -2951,7 +3396,7 @@ pub async fn run_egress( // key shows up in /logs (screen-shareable) and in // the Discord webhook payload. let safe = scrub_secret(&e.to_string(), &parsed.stream_key); - eprintln!("[egress {}] pump error: {}", label, safe); + log_line!("[egress {}] pump error: {}", label, safe); ctrl.log(format!("[{}] disconnected ({})", label, safe)); ctrl.emit( Event::new(EventKind::DestinationDropped) @@ -2962,6 +3407,16 @@ pub async fn run_egress( if ctrl.destination_alive_summary().0 == 0 { ctrl.emit(Event::new(EventKind::AllDestinationsDown)); } + let quick = lasted < crate::egress_failure::QUICK_DROP; + // The lost session is Twitch's: only a Twitch destination + // (the one taking multi-track video) is told so. + let twitch = dest.pass_through_multitrack_video.load(Ordering::Relaxed); + let advice = if quick && twitch && ctrl.publishing_unbrokered_eb() { + crate::egress_failure::EB_SESSION_LOST + } else { + crate::egress_failure::advice_for(&e, quick) + }; + failed = Some((advice, safe)); } // Counted here (instead of at the loop tail) so a fresh // connect or a connect failure never bumps it: "Egress @@ -2973,9 +3428,11 @@ pub async fn run_egress( } Err(e) => { let safe = scrub_secret(&e.to_string(), &parsed.stream_key); - eprintln!( + log_line!( "[egress {}] connect failed: {} (next try in {:?})", - label, safe, backoff + label, + safe, + backoff ); ctrl.log(format!( "[{}] connect failed ({}), retrying in {}s", @@ -2983,11 +3440,18 @@ pub async fn run_egress( safe, backoff.as_secs() )); + failed = Some((crate::egress_failure::advice_for(&e, false), safe)); } } + if let Some((advice, detail)) = failed { + // Reset before the retry time is published, so a "Retry now" + // can only land on this wait, never skip the next one. + dest.retry_now.store(false, Ordering::Relaxed); + *dest.failure.lock() = Some(EgressFailure::new(advice, &detail, Some(backoff))); + } // Cancellable backoff sleep - wake every 200 ms to check the // shutdown flag so a disable doesn't have to wait the full - // 30 s backoff window before stopping. + // 30 s backoff window before stopping, and for "Retry now". let deadline = tokio::time::Instant::now() + backoff; loop { if dest.shutdown_requested.load(Ordering::Relaxed) { @@ -2997,11 +3461,16 @@ pub async fn run_egress( )); return Ok(()); } + if dest.retry_now.swap(false, Ordering::Relaxed) { + ctrl.log(format!("[{label}] retrying now")); + break; + } if tokio::time::Instant::now() >= deadline { break; } tokio::time::sleep(Duration::from_millis(200)).await; } + dest.mark_retrying(); backoff = (backoff * 2).min(max_backoff); } } @@ -3025,8 +3494,10 @@ fn scrub_secret(text: &str, secret: &str) -> String { // so it goes entirely. Platform-issued keys are never this short, but a // custom RTMP destination takes whatever the user pasted - and passing // that through unredacted put it straight into /logs and the Discord - // payload, which is the one thing this function exists to prevent. - if chars.len() < 6 { + // payload, which is the one thing this function exists to prevent. The + // six characters shown must be at most half of it: a 6-character key + // used to come back whole, and an 8-character one minus two. + if chars.len() < 12 { return text.replace(secret, "…"); } let head: String = chars[..3].iter().collect(); @@ -4246,7 +4717,7 @@ mod tests { /// instead of averaging the silence in. #[test] fn a_bitrate_drops_to_zero_when_bytes_stop() { - let meter = RateMeter::default(); + let meter: RateMeter = RateMeter::default(); for ms in (0..=3_000).step_by(100) { meter.note_at(100_000, 10_000 + ms); } @@ -4258,6 +4729,44 @@ mod tests { } assert_eq!(meter.kbps_at(31_000), 4_000, "no silence averaged in"); } + + /// A 6 Mbps CBR stream with a keyframe every 2 s, 20 times a P frame: + /// the 1 s meter reads well above the real rate whenever a window holds + /// the keyframe, while the 10 s planning meter stays near it. Planning + /// on the 1 s peaks refused delays the buffer really held. + #[test] + fn the_planning_meter_averages_out_keyframe_bursts() { + let fast: RateMeter = RateMeter::default(); + let mean: RateMeter = RateMeter::default(); + // 1.5 MB per 60-frame GOP: 59 P frames and one keyframe of 20. + let p_frame = 1_500_000 / 79; + let (mut fast_max, mut mean_lo, mut mean_hi) = (0, u32::MAX, 0); + for frame in 0..(30 * 40) { + let at = 1_000 + frame * 33; + let bytes = if frame % 60 == 0 { + p_frame * 20 + } else { + p_frame + }; + fast.note_at(bytes, at as u64); + mean.note_at(bytes, at as u64); + if at > 12_000 { + fast_max = fast_max.max(fast.kbps_at(at as u64)); + let m = mean.kbps_at(at as u64); + mean_lo = mean_lo.min(m); + mean_hi = mean_hi.max(m); + } + } + // 1.5 MB per 1.98 s is about 6060 kbps. + assert!( + fast_max > 7_000, + "the 1 s meter sees the bursts: {fast_max}" + ); + assert!( + (5_800..=6_400).contains(&mean_lo) && (5_800..=6_400).contains(&mean_hi), + "the planning meter stays near 6 Mbps: {mean_lo}..{mean_hi}" + ); + } use std::env; use std::sync::atomic::{AtomicU32 as TestUniq, Ordering as TestOrd}; @@ -4333,20 +4842,23 @@ mod tests { // ── Shutdown signal (web Quit/Restart + tray Quit converge here) ── + /// The main loop is told which one was asked for. #[tokio::test] - async fn shutdown_signal_reports_restart() { - let h = harness(0); - // notify_one stores a permit, so wait_shutdown resolves immediately - // even though the request fires before we await - no lost wakeup. - h.ctrl.request_restart(); - assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Restart); - } - - #[tokio::test] - async fn shutdown_signal_reports_quit() { - let h = harness(0); - h.ctrl.request_quit(); - assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Quit); + async fn shutdown_says_restart_or_quit() { + // shutdown signal reports restart + { + let h = harness(0); + // notify_one stores a permit, so wait_shutdown resolves immediately + // even though the request fires before we await - no lost wakeup. + h.ctrl.request_restart(); + assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Restart); + } + // shutdown signal reports quit + { + let h = harness(0); + h.ctrl.request_quit(); + assert_eq!(h.ctrl.wait_shutdown().await, ShutdownKind::Quit); + } } // ── Ingest key (optional publisher auth, off by default) ───────── @@ -4639,6 +5151,65 @@ mod tests { ); } + /// "Delay on/off" pressed while the buffer fills tells the streamer the + /// delay starts as soon as it is ready. It has to, with "Activate when + /// ready" off too: that is the default, and the press used to arm, + /// make the promise, and leave the stream live for good. + #[test] + fn a_delay_switched_on_while_the_buffer_fills_goes_on_once_ready() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + h.ctrl.arm_delay(5_000); + assert!( + !h.ctrl.auto_activates(false), + "a plain arm waits for Activate when the setting is off" + ); + assert!(h.ctrl.auto_activates(true), "and goes on with it on"); + + let said = h.ctrl.run_named_action("toggle", 5_000, "hotkey"); + assert!( + said.is_some_and(|s| s.contains("starts as soon as it is ready")), + "the press says the delay will start by itself" + ); + assert!( + h.ctrl.auto_activates(false), + "so it does, with the setting off" + ); + + // The buffer fills and the supervisor's activate succeeds: used up. + feed_seconds(&h.ctrl, 0, 6, 30); + assert!(h.ctrl.activate_delay().is_ok(), "the buffer holds it now"); + assert!(!h.ctrl.auto_activates(false), "one shot"); + + // Cut, then a plain arm: the old promise must not ride along. + h.ctrl.stop_delay(); + h.ctrl.arm_delay(5_000); + assert!( + !h.ctrl.auto_activates(false), + "a later plain arm makes no promise" + ); + } + + /// Anything that calls the promise off does: cutting to live while it + /// fills, and disarming. + #[test] + fn a_promised_delay_is_called_off_by_a_cut_or_a_disarm() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let _ = h.ctrl.run_named_action("toggle", 5_000, "hotkey"); + assert!(h.ctrl.auto_activates(false)); + h.ctrl.stop_delay(); + assert!(!h.ctrl.auto_activates(false), "a cut keeps the stream live"); + + let _ = h.ctrl.run_named_action("toggle", 5_000, "hotkey"); + assert!(h.ctrl.auto_activates(false)); + h.ctrl.arm_delay(0); + assert!( + !h.ctrl.auto_activates(false), + "a disarm leaves nothing to start" + ); + } + #[test] fn cut_consumes_auto_activate_pending_so_it_sticks() { // The bug-of-record: without this clear, the supervisor sees @@ -4737,11 +5308,251 @@ mod tests { #[test] fn arm_clamps_to_max() { - // Hard ceiling at 600 s. A persisted-or-hand-edited 999 s must - // be clamped so the buffer can actually catch up. + // Ceiling at 1 hour, so a typo can't arm a delay of days. + let h = harness(0); + h.ctrl.arm_delay(99_999_999); + assert_eq!(h.ctrl.armed_delay_ms(), MAX_DELAY_MS); + } + + #[test] + fn arm_takes_delays_past_ten_minutes() { + // The old 600 s ceiling is gone: 15 minutes arms as asked, and so + // does the ceiling itself. Whether the buffer holds it is checked + // where the request comes in, from its size and the bitrate. + let h = harness(0); + h.ctrl.arm_delay(900_000); + assert_eq!(h.ctrl.armed_delay_ms(), 900_000); + h.ctrl.set_delay_to(MAX_DELAY_MS, "integration"); + assert_eq!(h.ctrl.armed_delay_ms(), MAX_DELAY_MS); + } + + #[test] + fn the_size_a_refusal_asks_for_holds_the_delay() { + for (ms, kbps) in [ + (900_000, 6_000), + (MAX_DELAY_MS, 20_000), + (15_000, 2_500), + (1_000, 501), + ] { + let capacity = buffer_mb_for(ms, kbps) * 1024 * 1024; + let usable = capacity - capacity / 16; // as DiskRing::usable_bytes + assert!( + delay_capacity_ms(usable, kbps) >= ms, + "{} MB at {kbps} kbps holds {ms} ms", + buffer_mb_for(ms, kbps) + ); + } + // 15 minutes at 6 Mbps, the README's example. + assert_eq!(buffer_mb_for(900_000, 6_000), 689); + } + + #[test] + fn the_planning_bitrate_rises_at_once_and_lets_go_slowly() { + assert_eq!( + hold_peak(3_000.0, 1.0, 8_000.0), + 8_000.0, + "a rise counts at once" + ); + assert!( + hold_peak(8_000.0, 0.25, 3_000.0) > 7_800.0, + "a dip doesn't unlock more" + ); + assert_eq!( + hold_peak(8_000.0, 30.0, 3_000.0), + 3_000.0, + "a real drop shows in 30 s" + ); + } + + #[test] + fn capacity_plans_for_the_reference_until_a_bitrate_is_measured() { + let h = harness(0); + assert_eq!(h.ctrl.capacity_kbps(), CAPACITY_REFERENCE_KBPS); + assert!( + h.ctrl.capacity_problem(600_000).is_none(), + "a guess never refuses" + ); + + h.ctrl.note_inbound_kbps_for_test(8_000); + assert_eq!(h.ctrl.capacity_kbps(), 8_000); + // The harness ring is 4 MB: under 4 s at 8 Mbps. + assert!(h.ctrl.delay_capacity_ms() < 4_000); + let why = h + .ctrl + .capacity_problem(30_000) + .expect("30 s is too long for 4 MB"); + assert!( + why.starts_with("Buffer too small for 30 s") && why.contains("at least 33 MB"), + "{why}" + ); + assert!(h.ctrl.capacity_problem(1_000).is_none()); + } + + /// Arming the value that is already armed (a toggle after a cut) keeps + /// its "doesn't fit" report; a disarm in between has it judged again. + #[test] + fn a_short_delays_report_lasts_until_the_delay_is_armed_anew() { + let h = harness(0); + h.ctrl.note_inbound_kbps_for_test(8_000); + h.ctrl.arm_delay(30_000); + h.ctrl.check_armed_capacity(); + assert!(h.ctrl.capacity_short().is_some(), "4 MB at 8 Mbps"); + h.ctrl.arm_delay(30_000); + assert!(h.ctrl.capacity_short().is_some(), "the same value: kept"); + h.ctrl.arm_delay(0); + h.ctrl.arm_delay(30_000); + assert!( + h.ctrl.capacity_short().is_none(), + "armed anew: not yet judged" + ); + h.ctrl.check_armed_capacity(); + assert!(h.ctrl.capacity_short().is_some(), "and judged again"); + } + + /// Silence is not a lighter stream: through a crash-protection hold (and + /// the first window after it) the peak stands, where its decay used to + /// let a delay that doesn't fit pass right after the resume. + #[test] + fn the_peak_bitrate_is_not_decayed_by_silence() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + // A peak from long ago by the clock, and no reading since. + *h.ctrl.capacity_kbps_hold.lock() = (8_000.0, 0); + assert_eq!(h.ctrl.measured_capacity_kbps(), Some(8_000)); + assert!( + h.ctrl.capacity_problem(30_000).is_some(), + "still doesn't fit" + ); + } + + /// The buffer filling before the 10 s check has already said the delay + /// runs short: the check doesn't say it a second time. + #[test] + fn a_short_delay_the_full_buffer_reported_is_not_reported_again() { + let h = harness(0); + h.ctrl.note_inbound_kbps_for_test(8_000); + h.ctrl.arm_delay(30_000); + h.ctrl.report_capacity_short(CapacityShort { + armed_ms: 30_000, + holds_ms: 4_000, + message: "the buffer is full".into(), + }); + let told = h.ctrl.capacity_short_seq(); + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short_seq(), told, "no second notice"); + assert_eq!( + h.ctrl.capacity_short().unwrap().holds_ms, + 4_000, + "the exact figure stays" + ); + } + + /// Offline, nothing is measured: the stream that just ended must not + /// refuse a delay set for the next one. Back on, its peak still holds. + #[test] + fn with_no_stream_there_is_no_measured_bitrate() { let h = harness(0); - h.ctrl.arm_delay(9_999_999); - assert_eq!(h.ctrl.armed_delay_ms(), 600_000); + h.ctrl.note_inbound_kbps_for_test(8_000); + assert!(h.ctrl.capacity_problem(30_000).is_some(), "4 MB at 8 Mbps"); + h.ctrl.ingest_alive.store(false, Ordering::Relaxed); + assert_eq!(h.ctrl.measured_capacity_kbps(), None); + assert!( + h.ctrl.capacity_problem(30_000).is_none(), + "a guess never refuses" + ); + h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + assert_eq!(h.ctrl.measured_capacity_kbps(), Some(8_000)); + } + + /// After a gap the meter knows no rate until a new window fills. It used + /// to report the last stream's until then, which the capacity estimate + /// planned by (and held as its peak) for the new stream's first 10 s. + #[test] + fn a_rate_meter_forgets_the_rate_from_before_a_gap() { + let meter = RateMeter::::default(); + let start = 1_000; + meter.note_at(0, start); + meter.note_at(25_000_000, start + CAPACITY_WINDOW_MS); + assert_eq!(meter.kbps_at(start + CAPACITY_WINDOW_MS), 20_000); + let back = start + CAPACITY_WINDOW_MS + 60_000; + meter.note_at(10_000, back); + assert_eq!(meter.kbps_at(back), 0, "nothing measured since the gap"); + meter.note_at(7_500_000, back + CAPACITY_WINDOW_MS); + assert_eq!(meter.kbps_at(back + CAPACITY_WINDOW_MS), 6_000); + } + + /// A new stream doesn't inherit the last one's peak bitrate, which used + /// to refuse delays that fit until it decayed. + #[tokio::test] + async fn a_new_stream_drops_the_last_ones_peak_bitrate() { + let h = harness(0); + h.ctrl.note_inbound_kbps_for_test(20_000); + assert_eq!(h.ctrl.measured_capacity_kbps(), Some(20_000)); + // That stream ends, and OBS starts another. + h.ctrl.ingest_alive.store(false, Ordering::Relaxed); + h.ctrl + .begin_publish("literally-anything", "127.0.0.1") + .await + .expect("publishes"); + assert_eq!(h.ctrl.capacity_kbps_hold.lock().0, 0.0); + // Neither the held peak nor the meter's last window carries over: + // OBS restarting within seconds used to mix both streams into one. + assert_eq!(h.ctrl.measured_capacity_kbps(), None); + } + + #[test] + fn hotkeys_and_integrations_refuse_a_delay_the_buffer_cant_hold() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + h.ctrl.note_inbound_kbps_for_test(8_000); + let refused = |why: Option| why.is_some_and(|w| w.starts_with("Buffer too small")); + assert!(refused(h.ctrl.run_named_action("arm", 30_000, "hotkey"))); + assert!(refused(h.ctrl.run_named_action("toggle", 30_000, "midi"))); + assert!(refused(h.ctrl.set_delay_to(30_000, "integration"))); + assert_eq!(h.ctrl.armed_delay_ms(), 0, "nothing was armed short"); + + assert!(h.ctrl.set_delay_to(1_000, "integration").is_none()); + assert_eq!(h.ctrl.armed_delay_ms(), 1_000); + } + + /// A delay armed before there was a bitrate (auto-arm at connect, or + /// the one saved from last time) is checked once one is measured, and + /// a short one is reported once: in the state, the log, and a notice. + #[test] + fn a_delay_armed_before_a_measurement_is_checked_once_one_arrives() { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + h.ctrl.arm_delay(30_000); + + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short(), None, "no measurement, no verdict"); + assert_eq!(h.ctrl.capacity_short_seq(), 0); + + h.ctrl.note_inbound_kbps_for_test(8_000); + h.ctrl.check_armed_capacity(); + let short = h.ctrl.capacity_short().expect("30 s doesn't fit 4 MB"); + assert_eq!(short.armed_ms, 30_000); + assert!(short.holds_ms < 4_000, "{short:?}"); + assert!(short.message.starts_with("Buffer too small for 30 s")); + assert_eq!(h.ctrl.capacity_short_seq(), 1); + assert_eq!(h.ctrl.delay_runs_ms(30_000), short.holds_ms, "what runs"); + assert_eq!(h.ctrl.delay_runs_ms(20_000), 20_000, "about another delay"); + assert!(h + .ctrl + .logs + .lock() + .iter() + .any(|l| l.contains("the 30 s armed doesn't fit"))); + + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short_seq(), 1, "once per armed value"); + + // A delay that fits clears it, and so does disarming. + h.ctrl.arm_delay(1_000); + assert_eq!(h.ctrl.capacity_short(), None, "about another delay"); + h.ctrl.check_armed_capacity(); + assert_eq!(h.ctrl.capacity_short(), None); + assert_eq!(h.ctrl.capacity_short_seq(), 1); } // ── Named action (Hotkey & MIDI) test suite ────────────────────── @@ -4838,6 +5649,7 @@ mod tests { assert_eq!(h.ctrl.run_named_action("arm", 5_000, "hotkey"), None); assert_eq!(h.ctrl.run_named_action("arm", 5_000, "hotkey"), None); assert_eq!(h.ctrl.run_named_action("cut", 0, "hotkey"), None); + // An unknown name is ignored, as `run_named_action` documents. assert_eq!(h.ctrl.run_named_action("nonsense", 0, "hotkey"), None); // Activating with nothing armed cannot work, and says so. @@ -5008,6 +5820,18 @@ mod tests { assert!(!h.ctrl.safe_cut_pending(), "2nd press must cancel safe cut"); } + #[test] + fn an_integration_cut_keeps_one_already_waiting() { + let h = harness(0); + h.ctrl.arm_delay(3_000); + feed_seconds(&h.ctrl, 0, 5, 30); + h.ctrl.activate_delay().unwrap(); + + h.ctrl.cut_after_airs("integration"); + h.ctrl.cut_after_airs("integration"); + assert!(h.ctrl.safe_cut_pending(), "a second run must not cancel it"); + } + #[test] fn named_action_toggle_clears_pending_safe_cut() { let h = harness(0); @@ -5025,105 +5849,106 @@ mod tests { assert!(!h.ctrl.safe_cut_pending()); } - /// A wrap does not arrive as one tidy crossing. Audio buffering means - /// several tags can still be in flight when video crosses, and every one - /// of them has to read back into the epoch it was stamped in. + /// Late, repeated or early timestamps around the 32-bit rollover stay where they belong. #[test] - fn several_late_tags_in_a_row_all_land_in_the_old_epoch() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; + fn the_timeline_moves_to_a_new_epoch_only_on_a_real_wrap() { + // several late tags in a row all land in the old epoch + // A wrap does not arrive as one tidy crossing. Audio buffering means + // several tags can still be in flight when video crosses, and every one + // of them has to read back into the epoch it was stamped in. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; + + h.ctrl.on_tag(9, 0xFFFF_FF00, &v, false, false); + h.ctrl.on_tag(9, 0x0000_0020, &v, false, false); // video crosses + assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x20)); + + // Three stragglers, each later than the one before but all still on + // the old side of the counter. + for wire in [0xFFFF_FFA0u32, 0xFFFF_FFC0, 0xFFFF_FFE0] { + h.ctrl.on_tag(8, wire, &v, false, false); + assert_eq!( + h.ctrl.ring.latest_ts(), + Some(wire as u64), + "straggler {wire:#x} left its epoch" + ); + } - h.ctrl.on_tag(9, 0xFFFF_FF00, &v, false, false); - h.ctrl.on_tag(9, 0x0000_0020, &v, false, false); // video crosses - assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x20)); + // And the counter is still able to cross forward afterwards. + h.ctrl.on_tag(9, 0x0000_0040, &v, false, false); + assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x40)); + } + // tags sharing a timestamp do not move the epoch + // Audio and video routinely carry the same timestamp. Equal is neither + // ahead nor behind, and must not be read as either. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; + + h.ctrl.on_tag(9, 5_000, &v, false, false); + h.ctrl.on_tag(8, 5_000, &v, false, false); + h.ctrl.on_tag(9, 5_000, &v, false, false); + assert_eq!(h.ctrl.ring.latest_ts(), Some(5_000)); + + // Same, but sitting exactly on the wrap boundary. + let h2 = harness(0); + h2.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h2.ctrl.on_tag(9, 0xFFFF_FFFF, &v, false, false); + h2.ctrl.on_tag(8, 0xFFFF_FFFF, &v, false, false); + assert_eq!(h2.ctrl.ring.latest_ts(), Some(0xFFFF_FFFF)); + h2.ctrl.on_tag(9, 0, &v, false, false); + assert_eq!( + h2.ctrl.ring.latest_ts(), + Some(1u64 << 32), + "the very next millisecond is the next epoch" + ); + } + // a jump of exactly half the counter does not move the epoch + // Half the counter apart is the point where "ahead" and "behind" stop + // being distinguishable. Whichever way it is read, one tag must not + // move the timeline by 49.7 days. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; - // Three stragglers, each later than the one before but all still on - // the old side of the counter. - for wire in [0xFFFF_FFA0u32, 0xFFFF_FFC0, 0xFFFF_FFE0] { - h.ctrl.on_tag(8, wire, &v, false, false); + h.ctrl.on_tag(9, 0, &v, false, false); + h.ctrl.on_tag(9, 1u32 << 31, &v, false, false); assert_eq!( h.ctrl.ring.latest_ts(), - Some(wire as u64), - "straggler {wire:#x} left its epoch" + Some(1u64 << 31), + "exactly half the counter ahead is still this epoch" ); } + // a tag from before the first one cannot jump the timeline + // A publisher session starts at epoch 0, and there is no epoch below it. + // A tag stamped before the first one we saw cannot be read as "the + // previous cycle", and must not be read as 49.7 days into the future + // either - that value reaches `trim_older_than` and empties the ring. + { + let h = harness(0); + h.ctrl.mark_ingest_alive_for_test(); + let v = [0x27u8; 64]; - // And the counter is still able to cross forward afterwards. - h.ctrl.on_tag(9, 0x0000_0040, &v, false, false); - assert_eq!(h.ctrl.ring.latest_ts(), Some((1u64 << 32) | 0x40)); - } - - /// Audio and video routinely carry the same timestamp. Equal is neither - /// ahead nor behind, and must not be read as either. - #[test] - fn tags_sharing_a_timestamp_do_not_move_the_epoch() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; - - h.ctrl.on_tag(9, 5_000, &v, false, false); - h.ctrl.on_tag(8, 5_000, &v, false, false); - h.ctrl.on_tag(9, 5_000, &v, false, false); - assert_eq!(h.ctrl.ring.latest_ts(), Some(5_000)); - - // Same, but sitting exactly on the wrap boundary. - let h2 = harness(0); - h2.ctrl.ingest_alive.store(true, Ordering::Relaxed); - h2.ctrl.on_tag(9, 0xFFFF_FFFF, &v, false, false); - h2.ctrl.on_tag(8, 0xFFFF_FFFF, &v, false, false); - assert_eq!(h2.ctrl.ring.latest_ts(), Some(0xFFFF_FFFF)); - h2.ctrl.on_tag(9, 0, &v, false, false); - assert_eq!( - h2.ctrl.ring.latest_ts(), - Some(1u64 << 32), - "the very next millisecond is the next epoch" - ); - } - - /// Half the counter apart is the point where "ahead" and "behind" stop - /// being distinguishable. Whichever way it is read, one tag must not - /// move the timeline by 49.7 days. - #[test] - fn a_jump_of_exactly_half_the_counter_does_not_move_the_epoch() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; - - h.ctrl.on_tag(9, 0, &v, false, false); - h.ctrl.on_tag(9, 1u32 << 31, &v, false, false); - assert_eq!( - h.ctrl.ring.latest_ts(), - Some(1u64 << 31), - "exactly half the counter ahead is still this epoch" - ); - } - - /// A publisher session starts at epoch 0, and there is no epoch below it. - /// A tag stamped before the first one we saw cannot be read as "the - /// previous cycle", and must not be read as 49.7 days into the future - /// either - that value reaches `trim_older_than` and empties the ring. - #[test] - fn a_tag_from_before_the_first_one_cannot_jump_the_timeline() { - let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - let v = [0x27u8; 64]; - - // A short, ordinary session. - h.ctrl.on_tag(9, 0, &v, true, false); - h.ctrl.on_tag(9, 1_000, &v, false, false); - h.ctrl.on_tag(9, 2_000, &v, false, false); - let before = h.ctrl.ring.latest_ts().expect("a populated ring"); + // A short, ordinary session. + h.ctrl.on_tag(9, 0, &v, true, false); + h.ctrl.on_tag(9, 1_000, &v, false, false); + h.ctrl.on_tag(9, 2_000, &v, false, false); + let before = h.ctrl.ring.latest_ts().expect("a populated ring"); - // Now a tag stamped "60 ms before zero", which is what an audio - // track lagging the video would carry if the session began at 0. - h.ctrl.on_tag(8, 0u32.wrapping_sub(60), &v, false, false); - let after = h.ctrl.ring.latest_ts().expect("a populated ring"); + // Now a tag stamped "60 ms before zero", which is what an audio + // track lagging the video would carry if the session began at 0. + h.ctrl.on_tag(8, 0u32.wrapping_sub(60), &v, false, false); + let after = h.ctrl.ring.latest_ts().expect("a populated ring"); - assert!( - after <= before + 60_000, - "a stray early tag moved the timeline to {after} from {before}" - ); + assert!( + after <= before + 60_000, + "a stray early tag moved the timeline to {after} from {before}" + ); + } } /// A pump that reaches the IDR wait has already opened and authenticated @@ -5207,21 +6032,26 @@ mod tests { // Under the floor there is no safe head/tail, so the whole key goes // rather than passing through into the log line untouched. assert_eq!(scrub_secret("failed for abc12", "abc12"), "failed for …"); + // A head and a tail of three would be all of a 6-character key, and + // most of an 8-character one. + assert_eq!(scrub_secret("failed for abc123", "abc123"), "failed for …"); + assert_eq!( + scrub_secret("failed for abcd1234", "abcd1234"), + "failed for …" + ); + assert_eq!( + scrub_secret("failed for abcdef123456", "abcdef123456"), + "failed for abc…456" + ); } - /// Audio and video cross each other by a few milliseconds all the time, - /// so at the moment the u32 wire clock rolls over, one track is past it - /// and the other is not. The straggler must be read in the epoch it was - /// actually stamped in: promoting it puts one tag 49.7 days ahead, and - /// `on_tag` feeds that to the trim, which then measures the whole ring - /// against a cutoff past every frame in it and evicts the lot. /// OBS stops an Enhanced Broadcasting stream with SequenceEnd tags /// stamped 0. They must not rewind the timeline: the buffer's newest /// moment is what the delay still to air is measured against. #[test] fn an_end_of_stream_tag_stamped_zero_does_not_rewind_the_timeline() { let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); let v = [0x27u8; 64]; h.ctrl.on_tag(9, 200_000, &v, false, false); h.ctrl.on_tag(9, 200_033, &v, false, false); @@ -5247,10 +6077,16 @@ mod tests { ); } + /// Audio and video cross each other by a few milliseconds all the time, + /// so at the moment the u32 wire clock rolls over, one track is past it + /// and the other is not. The straggler must be read in the epoch it was + /// actually stamped in: promoting it puts one tag 49.7 days ahead, and + /// `on_tag` feeds that to the trim, which then measures the whole ring + /// against a cutoff past every frame in it and evicts the lot. #[test] fn a_late_tag_at_the_wrap_stays_in_the_epoch_it_came_from() { let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); let v = [0x27u8; 64]; let a = [0xafu8; 64]; @@ -5377,7 +6213,7 @@ mod tests { /// duration passes through the same u32 truncation OBS puts on the wire. fn soak(hours: u64, reconnect_every_ms: u64, interleave_audio: bool) -> SoakStats { let h = harness(0); - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.arm_delay(SOAK_ARMED_MS); // Small payloads: a run this long rewrites the ring's whole capacity @@ -5397,11 +6233,19 @@ mod tests { reconnect_every_ms }; let (mut tags, mut wraps, mut reconnects) = (0u64, 0u64, 0u64); + // A keyframe every 2 s of the session's clock, counted from the last + // one. Read off the clock's phase instead (`session_ms % 2_000`), the + // coarse stretch after a wrap steps past every keyframe, and the trim + // rightly keeps everything since the last one a delay could join on. + let mut next_idr_ms: u64 = 0; let started = std::time::Instant::now(); while stream_ms < total_ms { let session_ms = stream_ms - session_start; - let is_idr = session_ms % 2_000 < FRAME_MS; + let is_idr = session_ms >= next_idr_ms; + if is_idr { + next_idr_ms = session_ms + 2_000; + } let payload: &[u8] = if is_idr { &idr } else { &pframe }; // Video, then the audio tag that was already in flight when the @@ -5430,6 +6274,7 @@ mod tests { if stream_ms >= next_reconnect { soak_restart(&h.ctrl); session_start = stream_ms; + next_idr_ms = 0; prev_seq = None; next_reconnect += reconnect_every_ms; reconnects += 1; @@ -5685,9 +6530,9 @@ mod tests { assert!(h.ctrl.end_hold_now()); h.ctrl.check_ingest_freeze_at(last + 60_000); assert!(!h.ctrl.hold_active(), "still the same freeze"); - // Video comes back, then OBS freezes again: that one is protected. - let last = freeze(&h); - assert!(last > 0); + // Video comes back, then OBS freezes again: that one is protected + // (`freeze` checks that its hold opens). + freeze(&h); } /// With a delay armed, the screen stays up after OBS is back until its @@ -6088,7 +6933,7 @@ mod tests { let s = h.ctrl.destination_state(id); *s.eb_override_url.lock() = Some(format!("rtmps://ivs/{id}")); } - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.mark_ingest_dead(); for (id, s) in h.ctrl.all_destination_states() { assert!( @@ -6098,14 +6943,6 @@ mod tests { } } - /// `try_claim_vod_fetch` is single-flight: exactly one caller wins - /// while a fetch is in flight. The supervisor fires every ~2 s; before - /// this guard every tick spawned a fresh Twitch API call, each - /// allocating a distinct IVS session and forcing an extra egress - /// restart - the multi-session, wrong-broadcast-type symptom seen in - /// Twitch Inspector. The latch is released by the fetch task on - /// completion (modelled here by the explicit store), after which the - /// next tick may claim again (e.g. to retry a failed fetch). /// A hold keeps a destination's Twitch session only when it keeps that /// destination on air: live, and coverable by the screen. One that is /// down, or a vertical one whose canvas isn't known yet (the screen @@ -6144,65 +6981,75 @@ mod tests { } } + /// The latch that stops every supervisor tick from asking Twitch for another session. #[test] - fn try_claim_vod_fetch_admits_one_claimant() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch(), "first caller must win the claim"); - for tick in 0..5 { + fn one_vod_session_fetch_at_a_time_and_only_when_needed() { + // try claim vod fetch admits one claimant + // `try_claim_vod_fetch` is single-flight: exactly one caller wins + // while a fetch is in flight. The supervisor fires every ~2 s; before + // this guard every tick spawned a fresh Twitch API call, each + // allocating a distinct IVS session and forcing an extra egress + // restart - the multi-session, wrong-broadcast-type symptom seen in + // Twitch Inspector. The latch is released by the fetch task on + // completion (modelled here by the explicit store), after which the + // next tick may claim again (e.g. to retry a failed fetch). + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch(), "first caller must win the claim"); + for tick in 0..5 { + assert!( + !s.try_claim_vod_fetch(), + "tick {tick} must see a fetch already in flight" + ); + } + // Fetch task finished (success or failure) - latch released. + s.vod_fetch_pending.store(false, Ordering::Relaxed); + assert!( + s.try_claim_vod_fetch(), + "after the fetch ends the next tick must be able to claim" + ); + } + // try claim vod fetch skips when session already allocated + // The claim re-checks `eb_override_url` under its mutex before + // committing: a fetch that completed on an earlier tick (setting the + // override) must abort a redundant claim AND leave the latch clear, + // so the destination isn't left falsely "fetching" forever. + { + let s = DestinationState::new("main".into()); + *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); assert!( !s.try_claim_vod_fetch(), - "tick {tick} must see a fetch already in flight" + "must not claim when a session already exists" + ); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "a skipped claim must release the latch, not leave it stuck" + ); + } + // try claim vod fetch reclaims after override cleared + // Regression guard for the lockout hole: the latch is decoupled from + // the override's lifecycle. The multitrack-config proxy (web.rs) and + // publisher disconnect both clear `eb_override_url` without touching + // the latch. After a successful fetch (override set, latch clear), + // clearing the override - as those paths do - must let the next tick + // re-claim and fetch a fresh session, not lock the destination into + // the legacy Source-Only ingest forever. + { + let s = DestinationState::new("main".into()); + // Post-success state: session allocated, latch released by the task. + *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); + s.vod_fetch_pending.store(false, Ordering::Relaxed); + assert!( + !s.try_claim_vod_fetch(), + "a live session must block a re-fetch" + ); + // Override cleared elsewhere (proxy cleanup / disconnect). + *s.eb_override_url.lock() = None; + assert!( + s.try_claim_vod_fetch(), + "cleared override must allow a fresh fetch - no permanent lockout" ); } - // Fetch task finished (success or failure) - latch released. - s.vod_fetch_pending.store(false, Ordering::Relaxed); - assert!( - s.try_claim_vod_fetch(), - "after the fetch ends the next tick must be able to claim" - ); - } - - /// The claim re-checks `eb_override_url` under its mutex before - /// committing: a fetch that completed on an earlier tick (setting the - /// override) must abort a redundant claim AND leave the latch clear, - /// so the destination isn't left falsely "fetching" forever. - #[test] - fn try_claim_vod_fetch_skips_when_session_already_allocated() { - let s = DestinationState::new("main".into()); - *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); - assert!( - !s.try_claim_vod_fetch(), - "must not claim when a session already exists" - ); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "a skipped claim must release the latch, not leave it stuck" - ); - } - - /// Regression guard for the lockout hole: the latch is decoupled from - /// the override's lifecycle. The multitrack-config proxy (web.rs) and - /// publisher disconnect both clear `eb_override_url` without touching - /// the latch. After a successful fetch (override set, latch clear), - /// clearing the override - as those paths do - must let the next tick - /// re-claim and fetch a fresh session, not lock the destination into - /// the legacy Source-Only ingest forever. - #[test] - fn try_claim_vod_fetch_reclaims_after_override_cleared() { - let s = DestinationState::new("main".into()); - // Post-success state: session allocated, latch released by the task. - *s.eb_override_url.lock() = Some("rtmps://ivs/session".into()); - s.vod_fetch_pending.store(false, Ordering::Relaxed); - assert!( - !s.try_claim_vod_fetch(), - "a live session must block a re-fetch" - ); - // Override cleared elsewhere (proxy cleanup / disconnect). - *s.eb_override_url.lock() = None; - assert!( - s.try_claim_vod_fetch(), - "cleared override must allow a fresh fetch - no permanent lockout" - ); } /// The single-flight guarantee under real thread contention: when a @@ -6232,122 +7079,114 @@ mod tests { ); } - /// A fetch that returns while still in its own session applies normally. - #[test] - fn apply_vod_session_if_current_writes_when_session_unchanged() { - let s = DestinationState::new("main".into()); - let epoch = s.session_epoch(); - assert!( - s.apply_vod_session_if_current("rtmps://ivs/live".into(), epoch), - "same-session fetch must apply" - ); - assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/live".into())); - } - - /// Regression guard for the late-completion race: a VOD-session fetch - /// spawned in one publisher session must NOT write its IVS URL if OBS - /// disconnected (and bumped the epoch) while the request was in flight. - /// Writing it would point the next stream at a dead session - the - /// Source-Only failure this whole path exists to avoid. - #[test] - fn apply_vod_session_if_current_discards_after_disconnect() { - let s = DestinationState::new("main".into()); - let epoch = s.session_epoch(); // captured when the fetch is spawned - s.invalidate_session_override(); // OBS disconnects mid-fetch - assert!( - !s.apply_vod_session_if_current("rtmps://ivs/stale".into(), epoch), - "a fetch outliving its session must be discarded" - ); - assert!( - s.eb_override_url.lock().is_none(), - "the stale URL must not leak into the next session" - ); - } - - /// `invalidate_session_override` clears the URL and advances the epoch - /// together, so the disconnect both forgets the old session and trips - /// any in-flight fetch's apply-time guard. - #[test] - fn invalidate_session_override_clears_url_and_bumps_epoch() { - let s = DestinationState::new("main".into()); - *s.eb_override_url.lock() = Some("rtmps://ivs/old".into()); - let before = s.session_epoch(); - s.invalidate_session_override(); - assert!(s.eb_override_url.lock().is_none(), "url must clear"); - assert_eq!(s.session_epoch(), before + 1, "epoch must advance"); - } - - /// Across a full disconnect/reconnect, a fetch from the NEW session - /// still applies: the epoch the supervisor captures after reconnect - /// matches the current one, so only the pre-disconnect fetch is stale. - #[test] - fn apply_vod_session_if_current_applies_for_fresh_session_after_reconnect() { - let s = DestinationState::new("main".into()); - s.invalidate_session_override(); // disconnect bumps epoch - let fresh_epoch = s.session_epoch(); // supervisor re-captures post-reconnect - assert!( - s.apply_vod_session_if_current("rtmps://ivs/new".into(), fresh_epoch), - "a fetch from the new session must apply" - ); - assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/new".into())); - } - - /// `complete_vod_fetch` on success applies the URL, reports Applied, - /// and releases the latch so the override (now Some) is what blocks - /// any re-fetch. - #[test] - fn complete_vod_fetch_applies_and_releases_on_success() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch(), "latch held, as in production"); - let epoch = s.session_epoch(); - assert_eq!( - s.complete_vod_fetch(Some("rtmps://ivs/live".into()), epoch), - VodFetchOutcome::Applied - ); - assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/live".into())); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "latch must be released after a successful completion" - ); - } - - /// A result that outlived its session reports DiscardedStale, writes - /// nothing, and STILL releases the latch (the previously untested - /// release-on-every-path invariant). + /// A disconnect bumps the epoch: an answer from before it is dropped, one after it applies. #[test] - fn complete_vod_fetch_discards_stale_and_releases() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch()); - let epoch = s.session_epoch(); - s.invalidate_session_override(); // disconnect mid-fetch - assert_eq!( - s.complete_vod_fetch(Some("rtmps://ivs/stale".into()), epoch), - VodFetchOutcome::DiscardedStale - ); - assert!(s.eb_override_url.lock().is_none()); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "latch must be released even when the result is discarded" - ); + fn a_vod_session_belongs_to_the_publisher_session_that_asked() { + // apply vod session if current discards after disconnect + // Regression guard for the late-completion race: a VOD-session fetch + // spawned in one publisher session must NOT write its IVS URL if OBS + // disconnected (and bumped the epoch) while the request was in flight. + // Writing it would point the next stream at a dead session - the + // Source-Only failure this whole path exists to avoid. + { + let s = DestinationState::new("main".into()); + let epoch = s.session_epoch(); // captured when the fetch is spawned + s.invalidate_session_override(); // OBS disconnects mid-fetch + assert!( + !s.apply_vod_session_if_current("rtmps://ivs/stale".into(), epoch), + "a fetch outliving its session must be discarded" + ); + assert!( + s.eb_override_url.lock().is_none(), + "the stale URL must not leak into the next session" + ); + } + // invalidate session override clears url and bumps epoch + // `invalidate_session_override` clears the URL and advances the epoch + // together, so the disconnect both forgets the old session and trips + // any in-flight fetch's apply-time guard. + { + let s = DestinationState::new("main".into()); + *s.eb_override_url.lock() = Some("rtmps://ivs/old".into()); + let before = s.session_epoch(); + s.invalidate_session_override(); + assert!(s.eb_override_url.lock().is_none(), "url must clear"); + assert_eq!(s.session_epoch(), before + 1, "epoch must advance"); + } + // apply vod session if current applies for fresh session after reconnect + // Across a full disconnect/reconnect, a fetch from the NEW session + // still applies: the epoch the supervisor captures after reconnect + // matches the current one, so only the pre-disconnect fetch is stale. + { + let s = DestinationState::new("main".into()); + s.invalidate_session_override(); // disconnect bumps epoch + let fresh_epoch = s.session_epoch(); // supervisor re-captures post-reconnect + assert!( + s.apply_vod_session_if_current("rtmps://ivs/new".into(), fresh_epoch), + "a fetch from the new session must apply" + ); + assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/new".into())); + } } - /// A failed fetch reports Failed, leaves no override, and releases the - /// latch so the next supervisor tick can retry. + /// Success, a stale answer and a failure each release the latch. #[test] - fn complete_vod_fetch_releases_latch_on_failure() { - let s = DestinationState::new("main".into()); - assert!(s.try_claim_vod_fetch()); - let epoch = s.session_epoch(); - assert_eq!(s.complete_vod_fetch(None, epoch), VodFetchOutcome::Failed); - assert!(s.eb_override_url.lock().is_none()); - assert!( - !s.vod_fetch_pending.load(Ordering::Relaxed), - "latch must be released on failure" - ); - assert!( - s.try_claim_vod_fetch(), - "a released latch lets the next tick retry" - ); + fn a_finished_vod_fetch_applies_only_to_its_own_session_and_always_releases() { + // complete vod fetch applies and releases on success + // `complete_vod_fetch` on success applies the URL, reports Applied, + // and releases the latch so the override (now Some) is what blocks + // any re-fetch. + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch(), "latch held, as in production"); + let epoch = s.session_epoch(); + assert_eq!( + s.complete_vod_fetch(Some("rtmps://ivs/live".into()), epoch), + VodFetchOutcome::Applied + ); + assert_eq!(*s.eb_override_url.lock(), Some("rtmps://ivs/live".into())); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "latch must be released after a successful completion" + ); + } + // complete vod fetch discards stale and releases + // A result that outlived its session reports DiscardedStale, writes + // nothing, and STILL releases the latch (the previously untested + // release-on-every-path invariant). + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch()); + let epoch = s.session_epoch(); + s.invalidate_session_override(); // disconnect mid-fetch + assert_eq!( + s.complete_vod_fetch(Some("rtmps://ivs/stale".into()), epoch), + VodFetchOutcome::DiscardedStale + ); + assert!(s.eb_override_url.lock().is_none()); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "latch must be released even when the result is discarded" + ); + } + // complete vod fetch releases latch on failure + // A failed fetch reports Failed, leaves no override, and releases the + // latch so the next supervisor tick can retry. + { + let s = DestinationState::new("main".into()); + assert!(s.try_claim_vod_fetch()); + let epoch = s.session_epoch(); + assert_eq!(s.complete_vod_fetch(None, epoch), VodFetchOutcome::Failed); + assert!(s.eb_override_url.lock().is_none()); + assert!( + !s.vod_fetch_pending.load(Ordering::Relaxed), + "latch must be released on failure" + ); + assert!( + s.try_claim_vod_fetch(), + "a released latch lets the next tick retry" + ); + } } /// Stress the apply-vs-disconnect race: a late fetch's apply and the @@ -6382,22 +7221,13 @@ mod tests { } } - /// `note_multitrack_video` is sticky-with-decay: once set, the - /// chip stays lit for a short window after the last multi-track - /// tag arrived (so a momentary pause between IDRs doesn't drop - /// the chip), and decays to false once stale. `reset_codec_state` - /// must wipe it immediately so a fresh non-EB publisher session - /// doesn't inherit the previous session's EB flag. + /// `reset_codec_state` wipes the Enhanced Broadcasting chip at once, so + /// a fresh non-EB publisher session doesn't inherit the last one's. #[test] fn eb_chip_clears_on_publisher_reset() { let h = harness(0); - // `multitrack_video()` returns false when ingest is dead, so - // simulate an active publisher session first. Also sleep - // briefly so `process_now_ms()` advances past 0 - the chip - // uses 0 as a sentinel for "never set" and would otherwise - // race the process anchor on a freshly-started test binary. - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); - std::thread::sleep(std::time::Duration::from_millis(2)); + // `multitrack_video()` reads false while ingest is dead. + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.note_multitrack_video(); assert!(h.ctrl.multitrack_video(), "chip must light on first tag"); h.ctrl.reset_codec_state(); @@ -6466,7 +7296,7 @@ mod tests { // 3. Simulate a clean stream teardown or crash. The supervisor invokes // `mark_ingest_dead`, which updates atomic states but leaves headers // intact by design to allow ongoing egress readers to recover. - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); h.ctrl.mark_ingest_dead(); { @@ -6539,7 +7369,7 @@ mod tests { // standing in for a real prior stream. Three tags is enough to // populate the index front - the bug is purely about the ts // values at the front vs back, not tag count. - h.ctrl.ingest_alive.store(true, Ordering::Relaxed); + h.ctrl.mark_ingest_alive_for_test(); for offset in 0u64..3 { h.ctrl .ring @@ -6604,27 +7434,93 @@ mod tests { } } + /// Nothing before the first gap; then the mean, counting only keyframes that move time on. #[test] - fn keyframe_interval_is_zero_before_any_gap_is_measured() { - let h = harness(0); - assert_eq!(h.ctrl.keyframe_interval_ms(), 0); - // One IDR opens the window but is not itself a gap. - feed_idrs(&h.ctrl, 0, 2_000, 1); - assert_eq!(h.ctrl.keyframe_interval_ms(), 0); + fn the_keyframe_interval_is_the_mean_gap_between_real_keyframes() { + // keyframe interval is zero before any gap is measured + { + let h = harness(0); + assert_eq!(h.ctrl.keyframe_interval_ms(), 0); + // One IDR opens the window but is not itself a gap. + feed_idrs(&h.ctrl, 0, 2_000, 1); + assert_eq!(h.ctrl.keyframe_interval_ms(), 0); + } + // keyframe interval measures mean spacing + // Gaps of 2, 4 and 3 s read as their mean, 3 s: not the last gap, the + // first one or the smallest. + { + let h = harness(0); + let mut payload = [0u8; 50]; + payload[0] = 0x17; + for at in [0, 2_000, 6_000, 9_000] { + h.ctrl.on_tag(9, at, &payload, true, false); + } + assert_eq!(h.ctrl.keyframe_interval_ms(), 3_000); + } + // non advancing timestamps do not pollute the mean + // A repeated or backwards timestamp would otherwise register as a + // zero-width gap and drag the mean toward 0, silencing a real warning. + { + let h = harness(0); + let mut payload = [0u8; 50]; + payload[0] = 0x17; + h.ctrl.on_tag(9, 0, &payload, true, false); + h.ctrl.on_tag(9, 4_000, &payload, true, false); + // Same timestamp three times over - a duplicated tag. + for _ in 0..3 { + h.ctrl.on_tag(9, 4_000, &payload, true, false); + } + assert_eq!(h.ctrl.keyframe_interval_ms(), 4_000); + } + // only idr video tags are sampled + // Non-IDR video and audio tags must not be counted as keyframes. + { + let h = harness(0); + let mut idr = [0u8; 50]; + idr[0] = 0x17; + let mut p = [0u8; 50]; + p[0] = 0x27; + h.ctrl.on_tag(9, 0, &idr, true, false); + // P-frames and audio in between must be ignored entirely. + for i in 1..10 { + h.ctrl.on_tag(9, i * 100, &p, false, false); + h.ctrl.on_tag(8, i * 100, &[0xaf, 0x01, 0x21], false, false); + } + h.ctrl.on_tag(9, 2_000, &idr, true, false); + assert_eq!(h.ctrl.keyframe_interval_ms(), 2_000); + } } + /// What each destination is sent: a Twitch one passes multi-track video + /// through; a vertical one sends its canvas, or nothing until that + /// canvas is known; everyone else gets the main picture. #[test] - fn keyframe_interval_measures_mean_spacing() { - let h = harness(0); - feed_idrs(&h.ctrl, 0, 2_000, 4); - assert_eq!(h.ctrl.keyframe_interval_ms(), 2_000); + fn each_destination_gets_the_video_it_asked_for() { + use crate::h264::VideoEgress; + let s = DestinationState::new("d".into()); + assert_eq!(s.video_egress(), Some(VideoEgress::Track(0))); + s.egress_vertical.store(true, Ordering::Relaxed); + s.vertical_primary_track.store(0xFF, Ordering::Relaxed); + assert_eq!(s.video_egress(), None, "no 9:16 canvas yet: wait"); + s.vertical_primary_track.store(1, Ordering::Relaxed); + assert_eq!(s.video_egress(), Some(VideoEgress::Track(1))); + s.pass_through_multitrack_video + .store(true, Ordering::Relaxed); + assert_eq!(s.video_egress(), Some(VideoEgress::Passthrough)); } + /// The second audio track (copyright-safe audio) counts as there only + /// once OBS sends it; the live track always is, a higher one never is. #[test] - fn keyframe_interval_measures_a_four_second_gop() { + fn an_audio_track_is_on_the_wire_only_once_obs_sends_it() { + use crate::h264::AudioEgress; let h = harness(0); - feed_idrs(&h.ctrl, 0, 4_000, 4); - assert_eq!(h.ctrl.keyframe_interval_ms(), 4_000); + assert!(h.ctrl.audio_target_on_wire(AudioEgress::Track(0))); + assert!(h.ctrl.audio_target_on_wire(AudioEgress::Passthrough)); + assert!(!h.ctrl.audio_target_on_wire(AudioEgress::Track(1))); + h.ctrl.second_audio_track.store(true, Ordering::Relaxed); + assert!(h.ctrl.audio_target_on_wire(AudioEgress::Track(1))); + assert!(!h.ctrl.audio_target_on_wire(AudioEgress::Track(2))); } /// The measurement must FREEZE once the sample budget is spent. @@ -6647,40 +7543,6 @@ mod tests { ); } - /// A repeated or backwards timestamp would otherwise register as a - /// zero-width gap and drag the mean toward 0, silencing a real warning. - #[test] - fn non_advancing_timestamps_do_not_pollute_the_mean() { - let h = harness(0); - let mut payload = [0u8; 50]; - payload[0] = 0x17; - h.ctrl.on_tag(9, 0, &payload, true, false); - h.ctrl.on_tag(9, 4_000, &payload, true, false); - // Same timestamp three times over - a duplicated tag. - for _ in 0..3 { - h.ctrl.on_tag(9, 4_000, &payload, true, false); - } - assert_eq!(h.ctrl.keyframe_interval_ms(), 4_000); - } - - /// Non-IDR video and audio tags must not be counted as keyframes. - #[test] - fn only_idr_video_tags_are_sampled() { - let h = harness(0); - let mut idr = [0u8; 50]; - idr[0] = 0x17; - let mut p = [0u8; 50]; - p[0] = 0x27; - h.ctrl.on_tag(9, 0, &idr, true, false); - // P-frames and audio in between must be ignored entirely. - for i in 1..10 { - h.ctrl.on_tag(9, i * 100, &p, false, false); - h.ctrl.on_tag(8, i * 100, &[0xaf, 0x01, 0x21], false, false); - } - h.ctrl.on_tag(9, 2_000, &idr, true, false); - assert_eq!(h.ctrl.keyframe_interval_ms(), 2_000); - } - /// A reconnect may bring a completely different OBS profile, so the /// previous session's measurements must not leak into the new one. #[test] @@ -6698,18 +7560,6 @@ mod tests { assert_eq!((after.width, after.height), (0, 0)); } - /// OBS's sequence header decodes to the resolution the compatibility - /// check sees, width and height each in their own place. - #[test] - fn stream_params_reports_the_resolution_obs_sends() { - let h = harness(0); - assert_eq!(h.ctrl.stream_params().width, 0, "nothing sent yet"); - let header = crate::slate::test_sequence_header(64, 36); - h.ctrl.on_tag(9, 0, &header, false, true); - let p = h.ctrl.stream_params(); - assert_eq!((p.width, p.height), (64, 36)); - } - /// Dead band stays at the tuned 1500 ms for a 2 s GOP (and while /// unmeasured), and widens past half a GOP for long-keyframe encoders so /// the same-IDR re-cut bounce can't reappear at 3-4 s cadences. @@ -6717,8 +7567,7 @@ mod tests { fn recut_dead_band_tracks_keyframe_interval() { assert_eq!(recut_dead_band_ms(0), 1_500, "unmeasured keeps the floor"); assert_eq!(recut_dead_band_ms(2_000), 1_500, "2 s GOP is unchanged"); - assert!(recut_dead_band_ms(4_000) > 2_000, "4 s GOP widens the band"); - assert_eq!(recut_dead_band_ms(4_000), 2_500); + assert_eq!(recut_dead_band_ms(4_000), 2_500, "4 s GOP widens the band"); // Must always clear half a GOP, or the bounce returns. for kf in [1_000u32, 2_000, 3_000, 4_000, 6_000] { assert!( @@ -6818,4 +7667,72 @@ mod tests { .count(); assert_eq!(refusals, 1); } + + /// A destination whose server refuses the connection says why on its + /// card (never with the key), when it retries, and "Retry now" skips + /// the wait instead of sitting out the backoff. + #[tokio::test] + async fn a_refused_destination_reports_why_and_retries_on_request() { + let mut sim = sim::Sim::new(false).await; + // A port nothing listens on: bound once, then released. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let dest = sim.ctrl.destination_state("refused"); + let url = format!("rtmp://127.0.0.1:{port}/app/secret_key_123"); + sim.pumps.push(tokio::spawn(run_egress( + sim.ctrl.clone(), + "refused".into(), + url, + dest.clone(), + ))); + sim.obs_connects().await; + + // The first failure backs off 1 s, the second 2 s: wait for that one. + let mut second = None; + for _ in 0..40 { + sim.obs_sends(200).await; + let f = dest.failure.lock().clone(); + if let Some(f) = f.filter(|f| { + f.retry_at + .is_some_and(|at| at - f.at >= Duration::from_secs(2)) + }) { + second = Some(f); + break; + } + } + let second = second.expect("two failed attempts recorded"); + assert_eq!(second.advice, crate::egress_failure::REFUSED); + assert!( + !second.detail.contains("secret_key_123"), + "{}", + second.detail + ); + + // The wait ends when the card stops counting down: connecting again, + // long before the 2 s backoff is up. (Windows takes ~2 s to report a + // refused port, so the next failure itself would come too late.) + assert!(sim.ctrl.retry_destination_now("refused")); + let asked = Instant::now(); + let mut retried = false; + for _ in 0..8 { + sim.obs_sends(100).await; + if dest + .failure + .lock() + .as_ref() + .is_some_and(|f| f.retry_at.is_none() || f.at > second.at) + { + retried = true; + break; + } + } + assert!(retried, "Retry now started a new attempt"); + assert!(asked.elapsed() < Duration::from_millis(1_000)); + assert!(!sim.ctrl.retry_destination_now("unknown")); + + dest.shutdown_requested.store(true, Ordering::Relaxed); + } } diff --git a/src/controller/delay_sim.rs b/src/controller/delay_sim.rs index 83a6e85..6e42214 100644 --- a/src/controller/delay_sim.rs +++ b/src/controller/delay_sim.rs @@ -7,7 +7,9 @@ //! runs, that nothing airs more often than the delay itself explains, and //! that timestamps never go back. -use super::sim::{assert_strictly_increasing, wait, Frame, Sim, FRAME_MS, GOP_FRAMES}; +use super::sim::{ + assert_strictly_increasing, wait, Frame, Sim, FRAME_MS, GOP_FRAMES, TIMER_SLOP_MS, +}; use super::*; use std::collections::HashSet; @@ -27,20 +29,9 @@ async fn delay_sim() -> Sim { sim } -/// How long after OBS made it `frame` reached the platform. -fn delay_ms(sim: &Sim, frame: &Frame) -> u32 { - frame - .at - .duration_since(sim.produced[&frame.number]) - .as_millis() as u32 -} - /// Every frame in `frames` ran between `min_ms` and `max_ms` behind OBS. fn assert_delays(sim: &Sim, frames: &[Frame], min_ms: u32, max_ms: u32, what: &str) { - let delays: Vec<(u32, u32)> = frames - .iter() - .map(|f| (f.number, delay_ms(sim, f))) - .collect(); + let delays: Vec<(u32, u32)> = frames.iter().map(|f| (f.number, sim.delay_ms(f))).collect(); let off = delays .iter() .find(|(_, ms)| !(min_ms..=max_ms).contains(ms)); @@ -50,9 +41,6 @@ fn assert_delays(sim: &Sim, frames: &[Frame], min_ms: u32, max_ms: u32, what: &s ); } -/// Sleeps can wake a few ms early on a loaded test runner. -const TIMER_SLOP_MS: u32 = 50; - /// A delay is honored: every frame in `frames` ran at least `delay_ms` /// behind OBS (never closer to live than asked) and at most one keyframe /// interval more, which is how far back the newest keyframe that old sits. @@ -344,7 +332,7 @@ async fn raising_the_delay_on_air_jumps_back_exactly_once() { again.is_none(), "{again:?} aired twice after raising the delay" ); - let reached = delay_ms(&sim, after.last().unwrap()); + let reached = sim.delay_ms(after.last().unwrap()); assert!( reached + TIMER_SLOP_MS >= raised, "ran {reached} ms behind, asked for {raised}" @@ -412,6 +400,12 @@ async fn destination_added_mid_delay_on_a_long_gop(sim: &mut Sim) { /// The added destination joins on a keyframe and plays on from there, /// without the jump back a live start followed by a cut to the delay would /// show, at least the delay behind and within a keyframe interval of it. +/// +/// The keyframe nearest the delay when it is added is OBS's first one, 4.3 s +/// back, but the buffer keeps only the armed delay plus 2 s, so it was +/// trimmed as soon as the first destination played past it. The seed used +/// to fall back to OBS's next keyframe, at the live edge: the added +/// destination's viewers watched live while everyone else was delayed. #[tokio::test] async fn a_destination_added_mid_delay_joins_without_a_jump_on_a_long_gop() { let mut sim = delay_sim().await; @@ -426,26 +420,6 @@ async fn a_destination_added_mid_delay_joins_without_a_jump_on_a_long_gop() { sim.assert_timestamps_monotonic(1); } -/// The keyframe nearest the delay when the destination is added is OBS's -/// first one, 4.3 s back, but the buffer keeps only the armed delay plus -/// 2 s, so it was trimmed as soon as the first destination played past it. -/// With no keyframe left to join on, the seed waits for OBS's next one and -/// starts there, at the live edge: the added destination's viewers watch -/// live while the other destination and the dashboard are on the delay. -#[tokio::test] -async fn a_destination_added_mid_delay_on_a_long_gop_never_airs_live() { - let mut sim = delay_sim().await; - destination_added_mid_delay_on_a_long_gop(&mut sim).await; - - let frames = sim.frames(1); - let first = frames.first().expect("the added destination is on air"); - let joined = delay_ms(&sim, first); - assert!( - joined + TIMER_SLOP_MS >= DELAY_MS, - "joined {joined} ms behind OBS with a {DELAY_MS} ms delay on" - ); -} - /// Going delayed with a 4 s keyframe interval: a 2 s delay can only land /// on a keyframe about 0 s or 4 s back, and it takes the one at least the /// delay old, then stays put instead of chasing the exact delay. @@ -710,7 +684,7 @@ async fn obs_restarting_mid_delay_never_puts_viewers_on_live() { let frames = sim.frames(1); let first = frames.first().expect("the destination came back"); - let joined = delay_ms(&sim, first); + let joined = sim.delay_ms(first); assert!( joined + TIMER_SLOP_MS >= DELAY_MS, "came back {joined} ms behind OBS with a {DELAY_MS} ms delay on" @@ -787,7 +761,7 @@ async fn a_delay_longer_than_the_buffer_runs_at_what_it_holds() { frames.len() ); assert_strictly_increasing(&frames, "a delay capped by the buffer"); - let behind = delay_ms(&sim, frames.last().unwrap()); + let behind = sim.delay_ms(frames.last().unwrap()); assert!( (2_500..=7_500).contains(&behind), "delayed by about what the buffer holds, not {behind} ms" @@ -808,6 +782,20 @@ async fn a_delay_longer_than_the_buffer_runs_at_what_it_holds() { .any(|line| line.contains("the buffer is full")), "the log says the delay is capped" ); + // And the dashboard and tray hear it too, once, with what really runs. + let short = sim.ctrl.capacity_short().expect("reported as short"); + assert_eq!(short.armed_ms, 30_000); + assert!( + (2_500..=7_500).contains(&short.holds_ms), + "{}", + short.holds_ms + ); + assert!(short.message.starts_with("Buffer too small for 30 s")); + assert_eq!( + sim.ctrl.capacity_short_seq(), + 1, + "one notice, not one a tag" + ); sim.assert_timestamps_monotonic(0); } @@ -835,9 +823,8 @@ async fn a_longer_keyframe_interval_than_measured_never_replays() { sim.destination_connects("platform").await; sim.obs_sends(10_000).await; - let frames = sim.frames(0); - let back = backward_jumps(&frames); - assert!(back.is_empty(), "the picture jumped back at {back:?}"); - assert_airs_at_most_twice(&frames); + // Already delayed when the destination joins: nothing may jump back, + // and nothing may air twice even in a row. + assert_strictly_increasing(&sim.frames(0), "after the join"); sim.assert_timestamps_monotonic(0); } diff --git a/src/controller/hold_sim.rs b/src/controller/hold_sim.rs index 669f8fe..a4fb239 100644 --- a/src/controller/hold_sim.rs +++ b/src/controller/hold_sim.rs @@ -5,7 +5,7 @@ //! the gap, nothing from before the hold is replayed, and timestamps never //! go back. -use super::sim::{assert_strictly_increasing, wait, Frame, Sim}; +use super::sim::{assert_strictly_increasing, wait, Frame, Sim, TIMER_SLOP_MS}; use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; @@ -40,10 +40,7 @@ async fn crash_screen_then_obs_back_on_the_same_connection() { "rejoins at a keyframe" ); assert_strictly_increasing(&frames, "crash and back"); - assert!( - !sim.commands(0).contains(&"deleteStream".into()), - "never ended" - ); + assert!(!sim.ended(0), "never ended"); assert!(sim.received().iter().all(|m| m.conn == 0), "one connection"); sim.assert_timestamps_monotonic(0); } @@ -68,11 +65,7 @@ async fn a_crash_mid_delay_airs_the_tail_once_and_the_delay_survives_the_return( let crashed = Instant::now(); wait(3_000).await; - let tail: Vec = sim - .frames(0) - .into_iter() - .filter(|f| f.at > crashed) - .collect(); + let tail = sim.frames_after(0, crashed); assert_strictly_increasing(&tail, "the tail"); assert_eq!( tail.last().map(|f| f.number), @@ -93,13 +86,13 @@ async fn a_crash_mid_delay_airs_the_tail_once_and_the_delay_survives_the_return( sim.obs_connects().await; let back = Instant::now(); sim.obs_sends(3_000).await; - let after: Vec = sim.frames(0).into_iter().filter(|f| f.at > back).collect(); + let after = sim.frames_after(0, back); let rejoined = after.first().expect("the stream came back"); assert!(rejoined.keyframe, "rejoins at a keyframe"); - let delayed_by = rejoined.at.duration_since(sim.produced[&rejoined.number]); + let delayed_by = sim.delay_ms(rejoined); assert!( - delayed_by >= Duration::from_millis(1_000), - "still delayed after OBS came back, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "rejoined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); assert_strictly_increasing(&after, "after the return"); sim.assert_timestamps_monotonic(0); @@ -132,10 +125,10 @@ async fn obs_back_mid_tail_never_puts_viewers_on_live() { .find(|f| f.at > back && sim.produced[&f.number] > back) .expect("the stream came back"); assert!(first_new.keyframe, "rejoins at a keyframe"); - let delayed_by = first_new.at.duration_since(sim.produced[&first_new.number]); + let delayed_by = sim.delay_ms(&first_new); assert!( - delayed_by >= Duration::from_millis(2_000), - "OBS's new video must air delayed, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "OBS's new video aired {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); sim.assert_timestamps_monotonic(0); } @@ -154,7 +147,7 @@ async fn end_now_from_a_plain_thread_ends_the_destination_cleanly() { let hotkey = std::thread::spawn(move || ctrl.run_named_action("end_hold", 0, "hotkey")); assert_eq!(hotkey.join().expect("no panic off the runtime"), None); wait(800).await; - assert!(sim.commands(0).contains(&"deleteStream".into())); + assert!(sim.ended(0)); assert!(sim.pumps[0].is_finished(), "the pump ended"); } @@ -209,7 +202,7 @@ async fn stopping_during_a_freeze_ends_the_destination() { sim.obs_stalls(500).await; sim.obs_stops(); wait(800).await; - assert!(sim.commands(0).contains(&"deleteStream".into())); + assert!(sim.ended(0)); } /// Crash protection off: OBS dropping ends the destination at once, as @@ -223,7 +216,7 @@ async fn protection_off_ends_the_destination_when_obs_drops() { sim.obs_crashes(); let crashed = Instant::now(); wait(800).await; - assert!(sim.commands(0).contains(&"deleteStream".into())); + assert!(sim.ended(0)); assert_eq!(sim.screen_tags(0, crashed, Instant::now()), 0, "no screen"); } @@ -253,10 +246,7 @@ async fn obs_stopping_while_the_delay_rebuilds_ends_the_destination() { let back_on_air = sim.frames(0).into_iter().filter(|f| f.at > back).count(); assert_eq!(back_on_air, 0, "OBS's short new video never aired"); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "the stream ended when OBS stopped" - ); + assert!(sim.ended(0), "the stream ended when OBS stopped"); assert!(sim.pumps[0].is_finished(), "the pump ended"); sim.assert_timestamps_monotonic(0); } @@ -296,21 +286,14 @@ async fn a_hold_that_runs_out_mid_tail_airs_the_tail_in_full_then_ends() { sim.ctrl.expire_hold(); wait(3_000).await; - let tail: Vec = sim - .frames(0) - .into_iter() - .filter(|f| f.at > crashed) - .collect(); + let tail = sim.frames_after(0, crashed); assert_eq!( tail.last().map(|f| f.number), Some(last_live), "the whole tail aired" ); assert_strictly_increasing(&tail, "the tail"); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "then it ended" - ); + assert!(sim.ended(0), "then it ended"); assert!(sim.pumps[0].is_finished(), "the pump ended"); sim.assert_timestamps_monotonic(0); } @@ -344,10 +327,7 @@ async fn a_freeze_hold_that_runs_out_never_replays_the_tail() { let frames = sim.frames(0); let from = frames.iter().rposition(|f| f.at < froze).unwrap_or(0); assert_strictly_increasing(&frames[from..], "a frozen OBS past the hold"); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "the destination ended" - ); + assert!(sim.ended(0), "the destination ended"); sim.assert_timestamps_monotonic(0); } @@ -371,7 +351,7 @@ async fn end_now_mid_tail_ends_the_stream_at_once() { assert!(sim.ctrl.end_hold_now(), "a hold was on"); wait(1_000).await; - assert!(sim.commands(0).contains(&"deleteStream".into()), "it ended"); + assert!(sim.ended(0), "it ended"); assert!( sim.frames(0).last().is_some_and(|f| f.number < last_live), "without airing the rest of the tail" @@ -401,7 +381,7 @@ async fn obs_leaves_mid_delay(sim: &mut Sim, stopped: bool) -> (u32, Instant) { /// What viewers see once OBS left: everything it sent, over the delay's /// length, then a clean end with no reconnect screen. fn assert_tail_aired_then_ended(sim: &mut Sim, last_live: u32, left: Instant) { - let tail: Vec = sim.frames(0).into_iter().filter(|f| f.at > left).collect(); + let tail = sim.frames_after(0, left); assert_eq!( tail.last().map(|f| f.number), Some(last_live), @@ -413,10 +393,7 @@ fn assert_tail_aired_then_ended(sim: &mut Sim, last_live: u32, left: Instant) { took >= 1_500, "the tail took the delay to air, not {took} ms" ); - assert!( - sim.commands(0).contains(&"deleteStream".into()), - "then it ended" - ); + assert!(sim.ended(0), "then it ended"); assert_eq!(sim.screen_tags(0, left, Instant::now()), 0, "no screen"); assert!(sim.pumps[0].is_finished(), "the pump ended"); sim.assert_timestamps_monotonic(0); @@ -471,7 +448,7 @@ async fn end_now_after_a_stop_cuts_the_rest_of_the_delay() { assert!(sim.ctrl.end_hold_now(), "the delay was airing"); wait(1_000).await; - assert!(sim.commands(0).contains(&"deleteStream".into()), "it ended"); + assert!(sim.ended(0), "it ended"); assert!( sim.frames(0).last().is_some_and(|f| f.number < last_live), "without airing the rest of the delay" @@ -508,12 +485,9 @@ async fn a_freeze_rejoin_lands_at_least_the_delay_back() { .find(|f| f.at > back && sim.produced[&f.number] > back) .expect("the stream came back"); assert!(rejoined.keyframe, "rejoins at a keyframe"); - let delayed_by = rejoined - .at - .duration_since(sim.produced[&rejoined.number]) - .as_millis() as u32; + let delayed_by = sim.delay_ms(&rejoined); assert!( - delayed_by + 50 >= DELAY_MS, + delayed_by + TIMER_SLOP_MS >= DELAY_MS, "rejoined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); sim.assert_timestamps_monotonic(0); @@ -552,12 +526,9 @@ async fn a_destination_after_an_ended_freeze_never_replays_what_already_aired() frames.iter().all(|f| f.number > last_before_freeze), "nothing from before the freeze airs again" ); - let delayed_by = first - .at - .duration_since(sim.produced[&first.number]) - .as_millis() as u32; + let delayed_by = sim.delay_ms(first); assert!( - sim.produced[&first.number] > back && delayed_by + 50 >= DELAY_MS, + sim.produced[&first.number] > back && delayed_by + TIMER_SLOP_MS >= DELAY_MS, "joins OBS's new video {delayed_by} ms behind, at least the delay" ); assert_strictly_increasing(&frames, "after an ended freeze"); @@ -597,12 +568,9 @@ async fn a_destination_after_a_short_freeze_joins_at_once() { "it waited {:?} to go live", first.at.duration_since(joined) ); - let delayed_by = first - .at - .duration_since(sim.produced[&first.number]) - .as_millis() as u32; + let delayed_by = sim.delay_ms(first); assert!( - delayed_by + 50 >= DELAY_MS, + delayed_by + TIMER_SLOP_MS >= DELAY_MS, "joined {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); } diff --git a/src/controller/sim.rs b/src/controller/sim.rs index 421e5ae..8d11b11 100644 --- a/src/controller/sim.rs +++ b/src/controller/sim.rs @@ -14,6 +14,9 @@ use tokio::sync::mpsc; /// OBS's frame interval (10 fps) and default keyframe cadence (every 500 ms). pub(super) const FRAME_MS: u32 = 100; +/// Sleeps can wake a few ms early on a loaded test runner, so "at least the +/// delay behind" is checked this much short of it. +pub(super) const TIMER_SLOP_MS: u32 = 50; pub(super) const GOP_FRAMES: u32 = 5; /// The stream's resolution: small, so the screen encodes in no time. const WIDTH: usize = 64; @@ -238,6 +241,27 @@ impl Sim { .count() } + /// Connection `conn`'s frames that reached the platform after `at`. + pub(super) fn frames_after(&mut self, conn: usize, at: Instant) -> Vec { + self.frames(conn) + .into_iter() + .filter(|f| f.at > at) + .collect() + } + + /// How long after OBS made it `frame` reached the platform, in ms. + pub(super) fn delay_ms(&self, frame: &Frame) -> u32 { + frame + .at + .duration_since(self.produced[&frame.number]) + .as_millis() as u32 + } + + /// Whether connection `conn`'s stream ended with its goodbye. + pub(super) fn ended(&mut self, conn: usize) -> bool { + self.commands(conn).contains(&"deleteStream".into()) + } + pub(super) fn commands(&mut self, conn: usize) -> Vec { self.received() .iter() diff --git a/src/controller/user_sim.rs b/src/controller/user_sim.rs index 42dafdb..9a6d838 100644 --- a/src/controller/user_sim.rs +++ b/src/controller/user_sim.rs @@ -4,7 +4,7 @@ //! from a new OBS session airs undelayed, timestamps never go back, and a //! stream that ends sends its goodbye (`deleteStream`). -use super::sim::{assert_strictly_increasing, wait, Frame, Sim}; +use super::sim::{assert_strictly_increasing, wait, Frame, Sim, TIMER_SLOP_MS}; use std::collections::HashSet; use std::time::{Duration, Instant}; @@ -21,19 +21,6 @@ async fn delayed_stream(sim: &mut Sim, ms: u32) { sim.obs_sends(ms).await; } -fn frames_after(sim: &mut Sim, conn: usize, at: Instant) -> Vec { - sim.frames(conn).into_iter().filter(|f| f.at > at).collect() -} - -fn ended(sim: &mut Sim, conn: usize) -> bool { - sim.commands(conn).contains(&"deleteStream".into()) -} - -/// How long after OBS made it a frame reached viewers. -fn delay_of(sim: &Sim, frame: &Frame) -> Duration { - frame.at.duration_since(sim.produced[&frame.number]) -} - /// A multistreamer (Twitch and YouTube), no crash protection, stops in /// OBS: both platforms air the whole delay, then both end. #[tokio::test] @@ -54,14 +41,14 @@ async fn multistreamer_stop_airs_the_delay_on_every_platform() { wait(3_000).await; for conn in 0..2 { - let tail = frames_after(&mut sim, conn, stopped); + let tail = sim.frames_after(conn, stopped); assert_eq!( tail.last().map(|f| f.number), Some(last_live), "platform {conn} aired the whole delay" ); assert_strictly_increasing(&tail, "the tail"); - assert!(ended(&mut sim, conn), "platform {conn} ended"); + assert!(sim.ended(conn), "platform {conn} ended"); sim.assert_timestamps_monotonic(conn); } assert_eq!(sim.ctrl.tail_left(), None); @@ -79,39 +66,25 @@ async fn armed_but_not_on_yet_a_stop_ends_at_once() { sim.obs_stops(); wait(800).await; - assert!(ended(&mut sim, 0), "ended within the moment"); + assert!(sim.ended(0), "ended within the moment"); assert_eq!(sim.ctrl.tail_left(), None, "nothing to count down"); } -/// Stops, then starts OBS again a moment later while the delay of the +/// OBS crashes (no crash protection) and comes back while the delay of the /// first stream is still airing: the new stream reaches viewers delayed, -/// never live. -#[tokio::test] -async fn restarting_mid_tail_never_airs_the_new_stream_live() { - restart_mid_tail(true).await; -} - -/// Same, when OBS crashed instead (no crash protection) and came back. +/// never live. (A stop and restart is `obs_restarts_mid_delay`.) #[tokio::test] async fn coming_back_from_a_crash_mid_tail_never_airs_live() { - restart_mid_tail(false).await; -} - -async fn restart_mid_tail(stopped: bool) { let mut sim = Sim::new(false).await; sim.destination_runs("platform"); delayed_stream(&mut sim, 1_000).await; - if stopped { - sim.obs_stops(); - } else { - sim.obs_crashes(); - } + sim.obs_crashes(); wait(600).await; sim.obs_connects().await; let first_new = sim.next_frame; sim.obs_sends(4_500).await; - assert!(ended(&mut sim, 0), "the first stream ended"); + assert!(sim.ended(0), "the first stream ended"); let new: Vec = sim .frames(1) .into_iter() @@ -119,10 +92,10 @@ async fn restart_mid_tail(stopped: bool) { .collect(); let first = new.first().expect("the destination came back"); assert!(first.keyframe, "starts on a keyframe"); - let delayed_by = delay_of(&sim, first); + let delayed_by = sim.delay_ms(first); assert!( - delayed_by >= Duration::from_millis(1_500), - "the new stream airs delayed, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "the new stream airs {delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); sim.assert_timestamps_monotonic(1); } @@ -140,7 +113,7 @@ async fn cut_to_live_during_the_tail_ends_the_stream() { sim.ctrl.stop_delay(); wait(1_200).await; - assert!(ended(&mut sim, 0), "it ended"); + assert!(sim.ended(0), "it ended"); assert!( sim.frames(0).last().is_some_and(|f| f.number < last_live), "without airing the rest" @@ -171,8 +144,8 @@ async fn cut_after_this_airs_then_a_stop() { frames.iter().any(|f| f.number == mark), "the marked moment aired" ); - assert_strictly_increasing(&frames_after(&mut sim, 0, stopped), "after the stop"); - assert!(ended(&mut sim, 0), "it ended"); + assert_strictly_increasing(&sim.frames_after(0, stopped), "after the stop"); + assert!(sim.ended(0), "it ended"); sim.assert_timestamps_monotonic(0); } @@ -187,7 +160,7 @@ async fn end_now_then_a_new_stream_starts_delayed() { wait(300).await; assert!(sim.ctrl.end_hold_now(), "the delay was airing"); wait(500).await; - assert!(ended(&mut sim, 0), "ended at once"); + assert!(sim.ended(0), "ended at once"); sim.obs_connects().await; let first_new = sim.next_frame; sim.obs_sends(4_500).await; @@ -197,10 +170,10 @@ async fn end_now_then_a_new_stream_starts_delayed() { .into_iter() .find(|f| f.number >= first_new) .expect("the new stream aired"); - let delayed_by = delay_of(&sim, &first); + let delayed_by = sim.delay_ms(&first); assert!( - delayed_by >= Duration::from_millis(1_500), - "delayed, not live ({delayed_by:?})" + delayed_by + TIMER_SLOP_MS >= DELAY_MS, + "{delayed_by} ms behind, short of the {DELAY_MS} ms delay" ); } @@ -224,9 +197,9 @@ async fn the_countdown_goes_down_then_away() { assert_eq!(sim.ctrl.tail_left(), None, "gone once it aired"); } -/// A streamer who stops and starts three times in a row (scene setup, -/// a crash of their own, a restart): every stream airs delayed and whole, -/// and nothing airs twice across all of them. +/// A streamer who stops and starts OBS three times in a row (setting up +/// scenes, say): every stream airs whole, and nothing airs twice across +/// all of them. #[tokio::test] async fn three_stops_and_starts_in_a_row() { const SHORT_DELAY_MS: u32 = 1_000; @@ -242,8 +215,12 @@ async fn three_stops_and_starts_in_a_row() { // replays from then on. let activated = Instant::now(); let mut last_lives = Vec::new(); + // Each stream runs long enough for the destination to rejoin it: after + // a stop it airs the 1 s tail, then waits out a backoff that doubles + // per short session (2 s by the third), which left a 1.5 s stream only + // 0.3 s of slack on a busy machine. for round in 0..3 { - sim.obs_sends(1_500).await; + sim.obs_sends(2_500).await; last_lives.push(sim.next_frame - 1); sim.obs_stops(); wait(1_800).await; @@ -279,13 +256,15 @@ async fn a_delay_longer_than_the_buffer_then_a_stop() { sim.obs_connects().await; sim.destination_connects("platform").await; sim.obs_sends(4_000).await; - let _ = sim.ctrl.activate_delay(); + sim.ctrl + .activate_delay() + .expect("activates at what the buffer holds"); sim.obs_sends(2_000).await; sim.obs_stops(); let stopped = Instant::now(); wait(5_000).await; - assert_strictly_increasing(&frames_after(&mut sim, 0, stopped), "after the stop"); - assert!(ended(&mut sim, 0), "it ended"); + assert_strictly_increasing(&sim.frames_after(0, stopped), "after the stop"); + assert!(sim.ended(0), "it ended"); sim.assert_timestamps_monotonic(0); } diff --git a/src/crash_hold.rs b/src/crash_hold.rs index fca863c..8c59f4d 100644 --- a/src/crash_hold.rs +++ b/src/crash_hold.rs @@ -827,41 +827,45 @@ mod tests { tag } + /// During a hold each audio track gets silence in its own format, or none when we can't make it. #[test] - fn silence_matches_the_streams_own_aac_config() { - // AAC-LC, 48 kHz (index 3), stereo: 00010 0011 0010 000. - let stereo = silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])).unwrap(); - assert_eq!(stereo.frame, SILENT_AAC_STEREO); - assert_eq!(stereo.framing, Framing::Legacy); - assert_eq!(audio_tag(&stereo)[..2], [0xAF, AAC_RAW]); - assert!((stereo.frame_ms - 21.333).abs() < 0.01); - // AAC-LC, 44.1 kHz (index 4), mono. - let mono = silent_audio_from_header(&aac_header(0xAE, [0x12, 0x08])).unwrap(); - assert_eq!(mono.frame, SILENT_AAC_MONO); - assert!((mono.frame_ms - 23.22).abs() < 0.01); - } - - #[test] - fn a_vod_audio_track_gets_silence_under_its_own_track_id() { - let header = one_track_header(0x95, b"mp4a", 1, &[0x11, 0x90]); - let silence = silent_audio_from_header(&header).unwrap(); - assert_eq!(silence.framing, Framing::OneTrack(1)); - let tag = audio_tag(&silence); - assert_eq!(tag[..7], [0x95, 0x01, b'm', b'p', b'4', b'a', 1]); - // It flattens back to the legacy silent frame any ingest accepts. - let flat = crate::h264::flatten_multitrack_audio(&tag, 1).unwrap(); - assert_eq!(flat[..2], [0xAF, AAC_RAW]); - assert_eq!(&flat[2..], SILENT_AAC_STEREO); - } - - #[test] - fn unsupported_audio_gets_no_silence() { - // HE-AAC (object type 5), 5.1 channels, a non-AAC tag, Opus. - assert!(silent_audio_from_header(&aac_header(0xAF, [0x29, 0x90])).is_none()); - assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0xB0])).is_none()); - assert!(silent_audio_from_header(&[0x2F, 0x00, 0x11, 0x90]).is_none()); - assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])[..2]).is_none()); - assert!(silent_audio_from_header(&one_track_header(0x95, b"Opus", 1, &[0; 8])).is_none()); + fn silence_matches_the_audio_it_stands_in_for() { + // silence matches the streams own aac config + { + // AAC-LC, 48 kHz (index 3), stereo: 00010 0011 0010 000. + let stereo = silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])).unwrap(); + assert_eq!(stereo.frame, SILENT_AAC_STEREO); + assert_eq!(stereo.framing, Framing::Legacy); + assert_eq!(audio_tag(&stereo)[..2], [0xAF, AAC_RAW]); + assert!((stereo.frame_ms - 21.333).abs() < 0.01); + // AAC-LC, 44.1 kHz (index 4), mono. + let mono = silent_audio_from_header(&aac_header(0xAE, [0x12, 0x08])).unwrap(); + assert_eq!(mono.frame, SILENT_AAC_MONO); + assert!((mono.frame_ms - 23.22).abs() < 0.01); + } + // a vod audio track gets silence under its own track id + { + let header = one_track_header(0x95, b"mp4a", 1, &[0x11, 0x90]); + let silence = silent_audio_from_header(&header).unwrap(); + assert_eq!(silence.framing, Framing::OneTrack(1)); + let tag = audio_tag(&silence); + assert_eq!(tag[..7], [0x95, 0x01, b'm', b'p', b'4', b'a', 1]); + // It flattens back to the legacy silent frame any ingest accepts. + let flat = crate::h264::flatten_multitrack_audio(&tag, 1).unwrap(); + assert_eq!(flat[..2], [0xAF, AAC_RAW]); + assert_eq!(&flat[2..], SILENT_AAC_STEREO); + } + // unsupported audio gets no silence + { + // HE-AAC (object type 5), 5.1 channels, a non-AAC tag, Opus. + assert!(silent_audio_from_header(&aac_header(0xAF, [0x29, 0x90])).is_none()); + assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0xB0])).is_none()); + assert!(silent_audio_from_header(&[0x2F, 0x00, 0x11, 0x90]).is_none()); + assert!(silent_audio_from_header(&aac_header(0xAF, [0x11, 0x90])[..2]).is_none()); + assert!( + silent_audio_from_header(&one_track_header(0x95, b"Opus", 1, &[0; 8])).is_none() + ); + } } fn framing_and_codec(header: &[u8]) -> Option<(Framing, SlateCodec)> { @@ -903,7 +907,7 @@ mod tests { assert_eq!(target.fourcc, *b"hvc1"); assert_eq!((target.shape.width, target.shape.height), (2560, 1440)); - let mut ten_bit = config.clone(); + let mut ten_bit = config; ten_bit[17] = 0xF8 | 2; // bitDepthLumaMinus8 assert_eq!( screen_target(&one_track_header(0x96, b"hvc1", 1, &ten_bit)), @@ -1004,9 +1008,8 @@ mod tests { #[test] fn the_screen_stops_waiting_for_the_delay_after_the_grace() { const DELAY_MS: u64 = 10_000; - let path = - std::env::temp_dir().join(format!("ic-rebuild-grace-{}.buf", std::process::id())); - let _ = std::fs::remove_file(&path); + let scratch = crate::test_support::TestDir::new("ic-rebuild-grace"); + let path = scratch.join("ring.buf"); let ring = Arc::new(crate::buffer::DiskRing::create(&path, 1 << 20).unwrap()); let ctrl = Controller::new(ring, 0); let keyframe = [0x17, 1, 0, 0, 0]; @@ -1026,7 +1029,6 @@ mod tests { let waited = Duration::from_millis(DELAY_MS) + REBUILD_GRACE; rebuild.as_mut().unwrap().since = Instant::now() - waited; assert_eq!(delay_rebuilt(&ctrl, &mut rebuild, DELAY_MS), first_new); - let _ = std::fs::remove_file(&path); } #[test] diff --git a/src/crash_protection.rs b/src/crash_protection.rs index 693d368..9853b8b 100644 --- a/src/crash_protection.rs +++ b/src/crash_protection.rs @@ -283,46 +283,58 @@ mod tests { assert_eq!(loaded, settings); } + /// Whatever the form sends, the settings stay in range and printable. #[test] - fn hold_secs_is_clamped_and_garbage_ignored() { - let mut settings = CrashProtection::default(); - settings.set("hold_secs", "5"); - assert_eq!(settings.hold_secs, HOLD_SECS_MIN); - settings.set("hold_secs", "9999"); - assert_eq!(settings.hold_secs, HOLD_SECS_MAX); - settings.set("hold_secs", "soon"); - assert_eq!(settings.hold_secs, HOLD_SECS_MAX); - } - - #[test] - fn bad_colours_and_themes_are_dropped() { - let mut settings = CrashProtection::default(); - settings.set("accent", "red"); - settings.set("accent", "#12345"); - settings.set("theme", "signal"); - assert_eq!(settings.accent, ACCENT_DEFAULT); - assert_eq!(settings.theme, SlateTheme::Whisper); - } - - #[test] - fn empty_background_means_theme_default() { - let mut settings = CrashProtection::default(); - settings.set("background", "#202020"); - settings.set("background", ""); - assert_eq!(settings.background, None); - assert_eq!( - settings.resolved_background(), - SlateTheme::Whisper.default_background() - ); - } - - #[test] - fn text_loses_control_characters_and_is_capped() { - let mut settings = CrashProtection::default(); - settings.set("headline", " Back\nsoon\t "); - assert_eq!(settings.headline, "Backsoon"); - settings.set("subline", &"x".repeat(100)); - assert_eq!(settings.subline.chars().count(), SUBLINE_MAX_CHARS); + fn bad_values_are_clamped_dropped_or_cleaned() { + // hold secs is clamped and garbage ignored + { + let mut settings = CrashProtection::default(); + settings.set("hold_secs", "5"); + assert_eq!(settings.hold_secs, HOLD_SECS_MIN); + settings.set("hold_secs", "9999"); + assert_eq!(settings.hold_secs, HOLD_SECS_MAX); + settings.set("hold_secs", "soon"); + assert_eq!(settings.hold_secs, HOLD_SECS_MAX); + } + // bad colours and themes are dropped + { + let mut settings = CrashProtection::default(); + settings.set("accent", "#ff8800"); + settings.set("theme", "arcade"); + let good = settings.accent; + assert_ne!(good, ACCENT_DEFAULT, "the good colour was taken"); + settings.set("accent", "red"); + settings.set("accent", "#12345"); + settings.set("theme", "signal"); + assert_eq!( + settings.accent, good, + "a bad colour keeps the last good one" + ); + assert_eq!( + settings.theme, + SlateTheme::Arcade, + "and so does a bad theme" + ); + } + // empty background means theme default + { + let mut settings = CrashProtection::default(); + settings.set("background", "#202020"); + settings.set("background", ""); + assert_eq!(settings.background, None); + assert_eq!( + settings.resolved_background(), + SlateTheme::Whisper.default_background() + ); + } + // text loses control characters and is capped + { + let mut settings = CrashProtection::default(); + settings.set("headline", " Back\nsoon\t "); + assert_eq!(settings.headline, "Backsoon"); + settings.set("subline", &"x".repeat(100)); + assert_eq!(settings.subline.chars().count(), SUBLINE_MAX_CHARS); + } } #[test] diff --git a/src/crypto.rs b/src/crypto.rs index ce1fbb0..00a0b0f 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -186,69 +186,53 @@ fn unhex(s: &str) -> Option> { mod tests { use super::*; - /// RFC 4231 HMAC-SHA256 test case 1: key = 0x0b x20, data = "Hi There". + /// Published HMAC-SHA256 vectors, picked for the branches they reach. #[test] - fn hmac_rfc4231_case1() { - let mac = hmac_sha256(&[0x0b; 20], b"Hi There"); - assert_eq!( - sha256::hex_bytes(&mac), - "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" - ); - } - - /// RFC 4231 test case 2: key = "Jefe", data = "what do ya want for nothing?". - #[test] - fn hmac_rfc4231_case2() { - let mac = hmac_sha256(b"Jefe", b"what do ya want for nothing?"); - assert_eq!( - sha256::hex_bytes(&mac), - "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" - ); - } - - /// RFC 4231 test case 6: a 131-byte key, longer than the 64-byte block, so - /// it must be hashed down first. Cases 1 and 2 use short keys and never - /// reach that branch; a password longer than 64 bytes does. - #[test] - fn hmac_rfc4231_case6_hashes_an_oversized_key() { - let mac = hmac_sha256( - &[0xaa; 131], - b"Test Using Larger Than Block-Size Key - Hash Key First", - ); - assert_eq!( - sha256::hex_bytes(&mac), - "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" - ); - } - - /// RFC 4231 test case 7: an oversized key AND a message longer than one - /// block, so the inner hash spans several blocks. - #[test] - fn hmac_rfc4231_case7_oversized_key_and_message() { - let mac = hmac_sha256( - &[0xaa; 131], - b"This is a test using a larger than block-size key and a larger than \ - block-size data. The key needs to be hashed before being used by the \ - HMAC algorithm.", - ); - assert_eq!( - sha256::hex_bytes(&mac), - "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2" - ); - } - - /// A key of exactly one block is used as-is, not hashed. This is the - /// boundary of the `>` in `hmac_sha256`: turning it into `>=` would hash a - /// 64-byte key and change every MAC made with one. Expected value is the - /// NIST HMAC-SHA256 "keylen=blocklen" example (key = bytes 0x00..0x3f). - #[test] - fn hmac_key_of_exactly_one_block_is_not_hashed() { - let key: Vec = (0u8..64).collect(); - let mac = hmac_sha256(&key, b"Sample message for keylen=blocklen"); - assert_eq!( - sha256::hex_bytes(&mac), - "8bb9a1db9806f20df7f77b82138c7914d174d59e13dc4d0169c9057b133e1d62" - ); + fn hmac_matches_the_published_vectors() { + let one_block_key: Vec = (0u8..64).collect(); + let cases: [(&str, &[u8], &[u8], &str); 5] = [ + ( + "RFC 4231 case 1: a short key", + &[0x0b; 20], + b"Hi There", + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", + ), + ( + "RFC 4231 case 2: a text key", + b"Jefe", + b"what do ya want for nothing?", + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", + ), + // A 131-byte key is longer than the 64-byte block, so it is + // hashed down first: the branch a password over 64 bytes takes. + ( + "RFC 4231 case 6: an oversized key", + &[0xaa; 131], + b"Test Using Larger Than Block-Size Key - Hash Key First", + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54", + ), + // And a message longer than one block: the inner hash spans several. + ( + "RFC 4231 case 7: an oversized key and message", + &[0xaa; 131], + b"This is a test using a larger than block-size key and a larger than \ + block-size data. The key needs to be hashed before being used by the \ + HMAC algorithm.", + "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2", + ), + // A key of exactly one block is used as-is, not hashed: the + // boundary of the `>` in `hmac_sha256`. With `>=` every MAC made + // with a 64-byte key would change. (NIST "keylen=blocklen".) + ( + "NIST: a key of exactly one block", + &one_block_key, + b"Sample message for keylen=blocklen", + "8bb9a1db9806f20df7f77b82138c7914d174d59e13dc4d0169c9057b133e1d62", + ), + ]; + for (case, key, message, mac) in cases { + assert_eq!(sha256::hex_bytes(&hmac_sha256(key, message)), mac, "{case}"); + } } /// Published PBKDF2-HMAC-SHA256 vectors (password "password", salt "salt"). @@ -298,8 +282,6 @@ mod tests { fn verify_rejects_garbage() { assert!(!verify_password("x", "")); assert!(!verify_password("x", "not-a-hash")); - assert!(!verify_password("x", "pbkdf2-sha256$0$aa$bb")); - assert!(!verify_password("x", "pbkdf2-sha256$1$zz$bb")); // non-hex salt } /// Every malformed variant of a hash that is otherwise correct for this @@ -335,6 +317,13 @@ mod tests { "a non-numeric count", ), (STORED_VECTOR.replacen("$1$", "$-1$", 1), "a negative count"), + // Zero rounds would hand back the one-round digest: without the + // guard this very copy would verify. + (STORED_VECTOR.replacen("$1$", "$0$", 1), "a zero count"), + ( + STORED_VECTOR.replacen("73616c74", "7361zz74", 1), + "a salt that isn't hex", + ), ] { assert!(!verify_password("password", &stored), "{why}: {stored}"); } diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..7bd9c9a --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,608 @@ +//! What InstantClone hands over when something goes wrong: the crash log a +//! panic leaves beside the settings, and the plain-text report behind the +//! Logs tab's "Copy diagnostics" (`GET /diagnostics`). +//! +//! Both quote settings and log lines, and both end up pasted into a bug +//! report or a Discord channel, so both go through one scrub on the way out: +//! every secret the settings hold is cut out, and every web address is cut +//! back to its host. +//! +//! Release builds abort on a panic and have no console, so before this the +//! only trace of a crash was the 1 500-line log, which lives in memory and +//! dies with the process. + +use crate::config::Settings; +use crate::controller::Controller; +use crate::integrations::clock::iso_utc; +use crate::integrations::model::Trigger; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// The crash log's file name, beside the settings file. +pub const CRASH_LOG_NAME: &str = "instantclone-crash.log"; +/// Past this the next report starts the file over. A report is ~30 KB, so +/// this keeps the last several crashes and never grows without bound. +const CRASH_LOG_MAX_BYTES: u64 = 256 * 1024; +/// Log lines a crash report keeps: the last minutes before it. +const CRASH_LOG_LINES: usize = 200; +/// Starts every report; the crash time follows it, then ` ====`. +const CRASH_HEADER: &str = "==== InstantClone closed unexpectedly at "; +/// Appended once the next start has told the user, so it says so only once. +const SEEN_MARK: &str = "---- reported on the next start"; +/// What a secret becomes. +const REDACTED: &str = "[redacted]"; + +/// The secrets of the settings in force, kept apart from the settings +/// channel so the panic hook never has to borrow it: a panic inside a +/// settings update would otherwise wait forever on the lock it holds, and a +/// process that should abort would hang instead. +static SECRETS: crate::sync::Mutex> = crate::sync::Mutex::new(Vec::new()); + +/// Refresh what the crash log scrubs. Called at startup and on every +/// settings change. +pub fn remember_secrets(settings: &Settings) { + *SECRETS.lock() = secrets_of(settings); +} + +/// Every secret value the settings hold: stream keys, custom URLs (which +/// can carry a key), the ingest key, the dock token, the password hash, +/// Discord webhooks, the phone topic and the integrations' hook tokens. +/// Longest first, so a secret that contains another goes whole. +fn secrets_of(s: &Settings) -> Vec { + let mut out = Vec::new(); + let mut add = |value: &str| { + let value = value.trim(); + if !value.is_empty() { + out.push(value.to_string()); + } + }; + for value in [ + &s.ingest_key, + &s.stream_key, + &s.custom_egress_url, + &s.dashboard_password_hash, + &s.dock_token, + &s.discord_webhook_url, + &s.phone.topic, + ] { + add(value); + } + for d in &s.destinations { + add(&d.stream_key); + add(&d.custom_egress_url); + } + for channel in &s.discord_channels { + add(&channel.url); + } + for handler in s.integrations.iter().flat_map(|i| &i.handlers) { + if let Trigger::Webhook { token } | Trigger::Shortcut { token, .. } = &handler.trigger { + add(token); + } + } + out.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); + out.dedup(); + out +} + +/// `text` with every secret cut out and every web address cut to its host. +fn scrub(text: &str, secrets: &[String]) -> String { + let mut out = text.to_string(); + for secret in secrets { + if out.contains(secret.as_str()) { + out = out.replace(secret.as_str(), REDACTED); + } + } + crate::integrations::runner::redact_urls(&out) +} + +/// Now, in Unix milliseconds. +fn unix_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +/// A log line with the wall-clock time it was logged in front. Log lines +/// carry the process clock (`[+ 12.345s]`), which says nothing to someone +/// matching them against an OBS log or a viewer's "at 9:14 it froze". +fn with_wall_clock(line: &str, now_unix_ms: u64, now_process_ms: u64) -> String { + let logged_at_s = line + .strip_prefix("[+") + .and_then(|rest| rest.split_once("s]")) + .and_then(|(secs, _)| secs.trim().parse::().ok()); + match logged_at_s { + Some(secs) => { + let started = now_unix_ms.saturating_sub(now_process_ms); + let at_ms = started + (secs * 1000.0) as u64; + format!("{} {line}", iso_utc(at_ms / 1000)) + } + None => line.to_string(), + } +} + +/// The commit this binary was built from (set by the release workflow). +fn build_label() -> String { + let version = crate::update_check::current_version(); + match option_env!("INSTANTCLONE_BUILD_COMMIT").filter(|c| !c.is_empty()) { + Some(commit) => format!("{version} (commit {commit})"), + None => format!("{version} (local build)"), + } +} + +/// The crash log beside the settings file at `config_path`. +pub fn crash_log_path(config_path: &Path) -> PathBuf { + crate::config::beside_config(config_path, Path::new(CRASH_LOG_NAME)) +} + +/// Write a crash report before the process goes down. Release builds +/// abort on a panic, so this runs just before the abort, on whatever thread +/// panicked. It only ever `try_lock`s: that thread may hold the log or the +/// secrets, and waiting on them would turn an abort into a hang. +pub fn install_panic_hook(ctrl: Arc, crash_log: PathBuf) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let message = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "(no message)".to_string()); + let location = info + .location() + .map_or_else(String::new, |l| format!("{}:{}", l.file(), l.line())); + let thread = std::thread::current(); + let lines: Option> = ctrl.logs.try_lock().map(|q| { + let skip = q.len().saturating_sub(CRASH_LOG_LINES); + q.iter().skip(skip).cloned().collect() + }); + let secrets = SECRETS.try_lock().map(|s| s.clone()); + let report = crash_report( + &Crash { + message: &message, + location: &location, + thread: thread.name().unwrap_or("unnamed"), + lines: lines.as_deref(), + secrets: secrets.as_deref(), + }, + unix_now_ms(), + crate::controller::process_now_ms(), + ); + append_bounded(&crash_log, &report); + previous(info); + })); +} + +/// What the panic hook knows about a crash. +struct Crash<'a> { + message: &'a str, + location: &'a str, + thread: &'a str, + /// The last log lines, None when the panicking thread held the log. + lines: Option<&'a [String]>, + /// None when they couldn't be read: the log lines are left out then, + /// since there is nothing to scrub them with. + secrets: Option<&'a [String]>, +} + +fn crash_report(crash: &Crash, now_unix_ms: u64, now_process_ms: u64) -> String { + let secrets = crash.secrets.unwrap_or(&[]); + let mut out = format!( + "{CRASH_HEADER}{} ====\nVersion: {}\nOS: {} {}\nThread: {}\nPanic: {}\nAt: {}\n", + iso_utc(now_unix_ms / 1000), + build_label(), + std::env::consts::OS, + std::env::consts::ARCH, + crash.thread, + crash.message, + crash.location, + ); + match (crash.lines, crash.secrets) { + (Some(lines), Some(_)) => { + out.push_str(&format!("Last {} log lines:\n", lines.len())); + for line in lines { + out.push_str(&with_wall_clock(line, now_unix_ms, now_process_ms)); + out.push('\n'); + } + } + (None, _) => out.push_str("(the log was busy at the moment of the crash)\n"), + (Some(_), None) => out.push_str("(log lines left out: the secrets to hide were busy)\n"), + } + out.push('\n'); + scrub(&out, secrets) +} + +/// Append `report` to the crash log, starting the file over once it has +/// grown past `CRASH_LOG_MAX_BYTES`. Best effort: a crash log that can't be +/// written must not stop the process going down. +fn append_bounded(path: &Path, report: &str) { + let too_big = std::fs::metadata(path).is_ok_and(|m| m.len() > CRASH_LOG_MAX_BYTES); + let file = std::fs::OpenOptions::new() + .create(true) + .append(!too_big) + .write(true) + .truncate(too_big) + .open(path); + if let Ok(mut file) = file { + let _ = file.write_all(report.as_bytes()); + let _ = file.flush(); + } +} + +/// If the last run crashed and nobody has been told, say so once in the +/// log, pointing at the crash log, and mark it told. +pub fn announce_previous_crash(crash_log: &Path, ctrl: &Controller) { + let Ok(text) = std::fs::read_to_string(crash_log) else { + return; + }; + let Some(when) = unreported_crash(&text) else { + return; + }; + let shown = std::path::absolute(crash_log).unwrap_or_else(|_| crash_log.to_path_buf()); + ctrl.log(format!( + "[crash] InstantClone closed unexpectedly at {when} (UTC). What it was doing is in {} - \ + attach that file if you report it.", + shown.display() + )); + if let Ok(mut file) = std::fs::OpenOptions::new().append(true).open(crash_log) { + let _ = writeln!(file, "{SEEN_MARK}, {}", iso_utc(unix_now_ms() / 1000)); + } +} + +/// The time of the newest crash in the log, unless a start already +/// reported it. +fn unreported_crash(text: &str) -> Option<&str> { + let start = text.rfind(CRASH_HEADER)? + CRASH_HEADER.len(); + let rest = &text[start..]; + if rest.contains(SEEN_MARK) { + return None; + } + rest.split_once(" ====").map(|(when, _)| when) +} + +/// When the newest crash in the log happened, reported or not, for the +/// diagnostics header. +fn last_crash(crash_log: &Path) -> Option { + let text = std::fs::read_to_string(crash_log).ok()?; + let start = text.rfind(CRASH_HEADER)? + CRASH_HEADER.len(); + let (when, _) = text[start..].split_once(" ====")?; + Some(when.to_string()) +} + +/// `1h 02m 03s`. +fn hms(secs: u64) -> String { + format!("{}h {:02}m {:02}s", secs / 3600, secs / 60 % 60, secs % 60) +} + +/// The plain-text report behind "Copy diagnostics": who we are, the +/// settings (already redacted for display), what the delay and every +/// destination are doing, and the log with wall-clock times. Scrubbed of +/// every configured secret as the last step, so nothing a section printed +/// can slip through. +pub fn report(ctrl: &Controller, settings: &Settings, config_path: &Path) -> String { + let now_unix_ms = unix_now_ms(); + let now_process_ms = crate::controller::process_now_ms(); + let uptime = ctrl.uptime_secs(); + let mut out = String::with_capacity(64 * 1024); + let mut line = |text: String| { + out.push_str(&text); + out.push('\n'); + }; + + line("InstantClone diagnostics".into()); + line(format!("Version: {}", build_label())); + line(format!( + "OS: {} {}", + std::env::consts::OS, + std::env::consts::ARCH + )); + line(format!( + "Started: {} UTC (up {})", + iso_utc((now_unix_ms / 1000).saturating_sub(uptime)), + hms(uptime) + )); + line(format!("Generated: {} UTC", iso_utc(now_unix_ms / 1000))); + line(format!( + "Last crash: {}", + last_crash(&crash_log_path(config_path)).map_or_else( + || "none on record".to_string(), + |when| format!("{when}, see {CRASH_LOG_NAME}") + ) + )); + + line(String::new()); + line("== State ==".into()); + line(format!( + "Delay: {} | armed {} ms, on air {} ms (target {} ms)", + ctrl.phase(), + ctrl.armed_delay_ms(), + ctrl.current_delay_ms(), + ctrl.target_delay_ms() + )); + // The ring is made once at startup: a size saved since then waits for a + // restart, so report the one running and name the saved one apart. + let running_mb = ctrl.ring.capacity() / (1024 * 1024); + let saved = if running_mb == settings.buffer_mb { + String::new() + } else { + format!( + " (saved {} MB, applies after a restart)", + settings.buffer_mb + ) + }; + line(format!( + "Buffer: {running_mb} MB{saved}, {} ms filled of {} ms wanted, holds about {} ms at {} kbps", + ctrl.buffer_fill_ms(), + ctrl.target_buffer_ms(), + ctrl.delay_capacity_ms(), + ctrl.capacity_kbps() + )); + if let Some(short) = ctrl.capacity_short() { + line(format!( + "Capacity: runs at {} ms. {}", + short.holds_ms, short.message + )); + } + line(format!( + "OBS: {} | {} / {}, {} kbps in ({} video), {} disconnects", + if ctrl.ingest_alive() { + "streaming" + } else { + "not streaming" + }, + ctrl.video_codec().label(), + ctrl.audio_codec().label(), + ctrl.bitrate_kbps(), + ctrl.video_bitrate_kbps(), + ctrl.ingest_disconnects() + )); + if let Some(hold) = ctrl.hold_status() { + line(format!( + "Crash protection: holding ({:?}), {} s left of {} s", + hold.reason, + hold.remaining.as_secs(), + hold.total.as_secs() + )); + } + if let Some(tail) = ctrl.tail_left() { + line(format!( + "Delay tail: {} ms still airing after OBS left", + tail.as_millis() + )); + } + let conflicts = ctrl.hotkey_conflicts(); + if !conflicts.is_empty() { + line(format!( + "Hotkeys another app holds: {}", + conflicts.join(", ") + )); + } + let snapshot = ctrl.destination_snapshot(); + line(format!("Destinations: {}", settings.destinations.len())); + for d in &settings.destinations { + let live = snapshot.iter().find(|t| t.0 == d.id); + let (alive, kbps, reconnects) = live.map_or((false, 0, 0), |t| (t.1, t.3, t.7)); + line(format!( + " - {} [{}{}] {} | {}, {} kbps, {} reconnects", + d.name, + d.platform, + if d.wants_vertical() { ", vertical" } else { "" }, + if d.enabled { "on" } else { "off" }, + if alive { "connected" } else { "not connected" }, + kbps, + reconnects + )); + } + + line(String::new()); + line("== Settings ==".into()); + line(settings.to_json(crate::autostart::is_enabled(), false)); + + let lines: Vec = ctrl.logs.lock().iter().cloned().collect(); + line(String::new()); + line(format!("== Log ({} lines) ==", lines.len())); + for entry in &lines { + line(with_wall_clock(entry, now_unix_ms, now_process_ms)); + } + scrub(&out, &secrets_of(settings)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Destination; + + fn destination(platform: &str, key: &str, url: &str) -> Destination { + Destination { + id: platform.into(), + name: platform.into(), + enabled: true, + platform: platform.into(), + stream_key: key.into(), + custom_egress_url: url.into(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + audio_track: "auto".into(), + } + } + + fn settings_with_secrets() -> Settings { + let mut s = Settings::defaults(); + s.ingest_key = "ingest-secret-1".into(); + s.dock_token = "0123abcd4567ef89".into(); + s.phone.topic = "my-private-topic".into(); + s.destinations + .push(destination("twitch", "live_123456_abcdefSECRET", "")); + s.destinations.push(destination( + "custom", + "", + "rtmp://edge.example/app/customKEY987", + )); + s.discord_channels + .push(crate::integrations::model::DiscordChannel { + id: "d1".into(), + name: "alerts".into(), + url: "https://discord.com/api/webhooks/1/webhookTOKENabc".into(), + }); + s + } + + /// Each place a secret is kept is on the list. The scrub test below + /// can't show this: a URL is cut down to its host anyway, so a webhook + /// would vanish from its text even if nobody listed it. + #[test] + fn every_secret_source_is_listed() { + let mut s = Settings::defaults(); + s.ingest_key = "s-ingest".into(); + s.stream_key = "s-stream".into(); + s.custom_egress_url = "s-egress".into(); + s.dashboard_password_hash = "s-hash".into(); + s.dock_token = "s-dock".into(); + s.discord_webhook_url = "s-webhook".into(); + s.phone.topic = "s-topic".into(); + s.destinations + .push(destination("custom", "s-dest-key", "s-dest-url")); + s.discord_channels + .push(crate::integrations::model::DiscordChannel { + id: "d".into(), + name: "d".into(), + url: "s-channel".into(), + }); + let listed = secrets_of(&s); + for placeholder in [ + "s-ingest", + "s-stream", + "s-egress", + "s-hash", + "s-dock", + "s-webhook", + "s-topic", + "s-dest-key", + "s-dest-url", + "s-channel", + ] { + assert!( + listed.iter().any(|l| l == placeholder), + "{placeholder} is not listed" + ); + } + } + + #[test] + fn every_configured_secret_is_cut_out() { + let s = settings_with_secrets(); + let secrets = secrets_of(&s); + let text = "key live_123456_abcdefSECRET, ingest ingest-secret-1, dock \ + 0123abcd4567ef89, topic my-private-topic, url \ + rtmp://edge.example/app/customKEY987 and \ + https://discord.com/api/webhooks/1/webhookTOKENabc ok"; + let clean = scrub(text, &secrets); + for leaked in [ + "abcdefSECRET", + "ingest-secret-1", + "0123abcd4567ef89", + "my-private-topic", + "customKEY987", + "webhookTOKENabc", + ] { + assert!(!clean.contains(leaked), "{leaked} in {clean}"); + } + assert!(clean.contains("key [redacted]"), "{clean}"); + assert!(clean.ends_with(" ok"), "{clean}"); + } + + /// A key that contains another key goes whole, not half-redacted. + #[test] + fn longer_secrets_are_cut_before_the_ones_they_contain() { + let mut s = Settings::defaults(); + s.ingest_key = "abcd".into(); + s.dock_token = "abcdefgh".into(); + assert_eq!( + secrets_of(&s), + vec!["abcdefgh".to_string(), "abcd".to_string()] + ); + assert_eq!(scrub("t=abcdefgh", &secrets_of(&s)), "t=[redacted]"); + } + + #[test] + fn log_lines_get_the_wall_clock_time_they_were_logged() { + // The process started at 2026-10-07T10:00:00Z and has run 60 s. + let now_unix_ms = 1_791_367_200_000 + 60_000; + let line = "[+ 12.345s] [Main] connected"; + assert_eq!( + with_wall_clock(line, now_unix_ms, 60_000), + "2026-10-07T10:00:12Z [+ 12.345s] [Main] connected" + ); + assert_eq!(with_wall_clock("no stamp", now_unix_ms, 60_000), "no stamp"); + } + + #[test] + fn a_crash_report_is_scrubbed_and_says_when() { + let s = settings_with_secrets(); + let secrets = secrets_of(&s); + let lines = vec!["[+ 1.000s] [Main] key live_123456_abcdefSECRET refused".to_string()]; + let report = crash_report( + &Crash { + message: "index out of bounds in ingest-secret-1", + location: "src/controller.rs:42", + thread: "main", + lines: Some(&lines), + secrets: Some(&secrets), + }, + 1_791_367_200_000, + 5_000, + ); + assert!(report + .starts_with("==== InstantClone closed unexpectedly at 2026-10-07T10:00:00Z ====")); + assert!(report.contains("At: src/controller.rs:42")); + assert!(report.contains("Last 1 log lines:")); + assert!(!report.contains("abcdefSECRET") && !report.contains("ingest-secret-1")); + assert!(!report.contains('\u{2014}'), "no em dashes"); + + // Without the secrets the log lines stay out rather than go raw. + let blind = crash_report( + &Crash { + lines: Some(&lines), + secrets: None, + message: "boom", + location: "", + thread: "tray", + }, + 1_791_367_200_000, + 5_000, + ); + assert!(!blind.contains("abcdefSECRET")); + assert!(blind.contains("log lines left out")); + } + + /// The next start says so once: the mark it leaves silences the start + /// after, until a new crash is appended. + #[test] + fn a_crash_is_reported_once_on_the_next_start() { + let first = format!("{CRASH_HEADER}2026-10-07T10:00:00Z ====\nPanic: x\n\n"); + assert_eq!(unreported_crash(&first), Some("2026-10-07T10:00:00Z")); + let seen = format!("{first}{SEEN_MARK}, 2026-10-07T10:05:00Z\n"); + assert_eq!(unreported_crash(&seen), None); + let again = format!("{seen}{CRASH_HEADER}2026-10-08T09:00:00Z ====\nPanic: y\n\n"); + assert_eq!(unreported_crash(&again), Some("2026-10-08T09:00:00Z")); + assert_eq!(unreported_crash("nothing here"), None); + } + + #[test] + fn the_crash_log_stays_bounded() { + let scratch = crate::test_support::TestDir::new("ic-crash-log"); + let path = scratch.join("crash.log"); + let big = "x".repeat(CRASH_LOG_MAX_BYTES as usize + 10); + append_bounded(&path, &big); + append_bounded(&path, "fresh report\n"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "fresh report\n"); + append_bounded(&path, "second\n"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "fresh report\nsecond\n" + ); + } +} diff --git a/src/egress_failure.rs b/src/egress_failure.rs new file mode 100644 index 0000000..64a3e63 --- /dev/null +++ b/src/egress_failure.rs @@ -0,0 +1,211 @@ +//! Why a destination can't stream, in words a streamer can act on. +//! +//! The egress loop keeps the last failed connection of each destination. The +//! dashboard card shows its advice, the scrubbed technical reason and when +//! the next attempt starts, so a wrong stream key is no longer an endless +//! "Connecting...". + +use std::io; +use std::time::{Duration, Instant}; + +/// A publish the platform accepted and then closed within this long is read +/// as a rejected key: Twitch answers a wrong key that way, not with an RTMP +/// error. +pub const QUICK_DROP: Duration = Duration::from_secs(10); + +/// Longest technical reason kept for the card, in characters. +const MAX_DETAIL_CHARS: usize = 200; + +pub const KEY_REJECTED: &str = "Stream key rejected. Check the key in Edit, and that nothing else \ + is streaming with it."; +pub const CLOSED_AT_START: &str = "The platform closed the stream as soon as it started. This \ + usually means a wrong or expired stream key: check it in Edit."; +pub const REFUSED: &str = + "The server refused the connection. Check the server address and port in Edit."; +pub const NOT_FOUND: &str = + "Server address not found. Check your internet connection and the server address in Edit."; +pub const TLS_FAILED: &str = "The secure connection (RTMPS) failed. Check the server address in \ + Edit and that your PC's date and time are right."; +pub const TIMED_OUT: &str = "The server didn't answer in time. Check your internet connection, \ + or pick another server in Edit."; +pub const UNREACHABLE: &str = "No route to the server. Check your internet connection."; +pub const DROPPED: &str = "The connection to the platform dropped."; +pub const BAD_URL: &str = "This server address isn't valid. Fix it in Edit."; +pub const UNKNOWN: &str = "Couldn't connect to the platform."; +/// OBS is publishing Enhanced Broadcasting with a session this run of +/// InstantClone never handed out (it restarted mid-stream): Twitch only +/// takes the session OBS asks for when it starts streaming. +pub const EB_SESSION_LOST: &str = "InstantClone restarted during an Enhanced Broadcasting stream, \ + and Twitch won't take that session back. Stop and start streaming in OBS to begin a new one."; + +/// The last failed attempt to stream to one destination. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EgressFailure { + /// Plain advice, one of the constants above. + pub advice: &'static str, + /// The technical reason, already scrubbed of the stream key. + pub detail: String, + pub at: Instant, + /// When the next attempt starts. None while it is connecting, and for a + /// failure that retrying can't fix. + pub retry_at: Option, + /// False for a failure no retry fixes (a malformed address): the card + /// then asks for an edit instead of counting down. + pub retries: bool, +} + +impl EgressFailure { + pub fn new(advice: &'static str, detail: &str, retry_in: Option) -> Self { + let at = Instant::now(); + Self { + advice, + detail: detail.chars().take(MAX_DETAIL_CHARS).collect(), + at, + retry_at: retry_in.map(|d| at + d), + retries: retry_in.is_some(), + } + } +} + +/// Plain advice for a failed connection or a dropped session. `quick_drop` +/// is a session the platform ended within `QUICK_DROP` of accepting it. +pub fn advice_for(err: &io::Error, quick_drop: bool) -> &'static str { + let text = err.to_string().to_ascii_lowercase(); + let has = |needles: &[&str]| needles.iter().any(|n| text.contains(n)); + // RTMP status codes and descriptions first: they name the real cause, + // where the socket error that follows them is generic. + if has(&[ + "badname", + "publish.denied", + "unauthorized", + "authentication", + "auth failed", + "forbidden", + "stream key", + ]) { + return KEY_REJECTED; + } + if has(&["tls"]) { + return TLS_FAILED; + } + // Windows' WSAHOST_NOT_FOUND (11001) and WSANO_DATA (11004), and the + // getaddrinfo wording on Linux. + if has(&[ + "failed to lookup", + "no such host", + "os error 11001", + "os error 11004", + "name or service not known", + "temporary failure in name resolution", + "nodename nor servname", + ]) { + return NOT_FOUND; + } + match err.kind() { + io::ErrorKind::ConnectionRefused => return REFUSED, + io::ErrorKind::TimedOut => return TIMED_OUT, + io::ErrorKind::NetworkUnreachable | io::ErrorKind::HostUnreachable => return UNREACHABLE, + _ => {} + } + // Errors rebuilt from text (the pump's) lose their kind; WSAECONNREFUSED + // is 10061, WSAETIMEDOUT 10060. + if has(&["refused", "os error 10061"]) { + return REFUSED; + } + if has(&["timed out", "no answer within", "os error 10060"]) { + return TIMED_OUT; + } + if has(&["unreachable", "os error 10051", "os error 10065"]) { + return UNREACHABLE; + } + if quick_drop { + return CLOSED_AT_START; + } + let dropped = matches!( + err.kind(), + io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::BrokenPipe + | io::ErrorKind::UnexpectedEof + ); + if dropped || has(&["early eof", "reset", "aborted", "broken pipe", "closed"]) { + return DROPPED; + } + UNKNOWN +} + +#[cfg(test)] +mod tests { + use super::*; + + fn other(text: &str) -> io::Error { + io::Error::other(text.to_string()) + } + + /// A refused publish points at the stream key; network failures say what + /// failed on the network; anything else is honestly unknown. + #[test] + fn each_failure_gets_its_own_advice() { + // rtmp publish refusals read as a rejected key + { + for text in [ + "RTMP NetStream.Publish.BadName: Stream already publishing", + "RTMP NetStream.Publish.Denied: denied", + "RTMP _error: Authentication failed", + "RTMP _error: invalid stream key", + ] { + assert_eq!(advice_for(&other(text), false), KEY_REJECTED, "{text}"); + } + } + // a publish closed right away points at the key + { + let reset = io::Error::from(io::ErrorKind::ConnectionReset); + assert_eq!(advice_for(&reset, true), CLOSED_AT_START); + assert_eq!(advice_for(&reset, false), DROPPED); + } + // socket failures get their own advice + { + let refused = io::Error::from(io::ErrorKind::ConnectionRefused); + assert_eq!(advice_for(&refused, false), REFUSED); + let timeout = io::Error::new(io::ErrorKind::TimedOut, "no answer within 20 s"); + assert_eq!(advice_for(&timeout, false), TIMED_OUT); + let dns = other("No such host is known. (os error 11001)"); + assert_eq!(advice_for(&dns, false), NOT_FOUND); + let linux_dns = + other("failed to lookup address information: Name or service not known"); + assert_eq!(advice_for(&linux_dns, false), NOT_FOUND); + let tls = other("TLS handshake failed: certificate expired"); + assert_eq!(advice_for(&tls, false), TLS_FAILED); + } + // errors that lost their kind are read from the text + { + let refused = other("No connection could be made (os error 10061)"); + assert_eq!(advice_for(&refused, false), REFUSED); + let silent = other("operation timed out"); + assert_eq!(advice_for(&silent, false), TIMED_OUT); + } + // a dns failure is not mistaken for a rejected key on a quick drop + { + let dns = other("No such host is known. (os error 11001)"); + assert_eq!(advice_for(&dns, true), NOT_FOUND); + } + // anything else is a plain unknown + { + assert_eq!(advice_for(&other("something odd"), false), UNKNOWN); + } + } + + #[test] + fn the_detail_is_capped_on_a_character_boundary() { + let long = "é".repeat(500); + let failure = EgressFailure::new(UNKNOWN, &long, None); + assert_eq!(failure.detail.chars().count(), MAX_DETAIL_CHARS); + assert!(failure.retry_at.is_none() && !failure.retries); + } + + #[test] + fn a_retry_delay_sets_the_next_attempt() { + let failure = EgressFailure::new(REFUSED, "refused", Some(Duration::from_secs(4))); + assert_eq!(failure.retry_at, Some(failure.at + Duration::from_secs(4))); + } +} diff --git a/src/h264.rs b/src/h264.rs index 9d00e93..2116ae3 100644 --- a/src/h264.rs +++ b/src/h264.rs @@ -1344,6 +1344,7 @@ pub fn is_aac_seq_header(payload: &[u8]) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::test_support::Lcg; /// Builds a length-prefixed NAL unit (the format AVC video tags use /// after the 5-byte FLV/AVC header). `nal_type` is the 5-bit type @@ -1367,76 +1368,162 @@ mod tests { out } + /// Legacy FLV AVC tags: keyframes are found by walking the NALs, not by + /// trusting the frame-type nibble, and damaged tags never panic. #[test] - fn avc_idr_detected_from_nal_walk() { - // packet_type 1 = NALU; contains NAL type 5 (IDR slice) - let tag = avc_tag(1, &[nal(5, 100)]); - let info = classify_video_tag(&tag); - assert!(info.is_idr, "single IDR NAL must be detected"); - assert!(!info.is_seq_header); - assert_eq!(info.codec, VideoCodec::Avc); - } - - #[test] - fn avc_non_idr_not_flagged() { - // NAL type 1 = non-IDR slice; encoder MIGHT lie via the FrameType - // nibble - we trust the NAL walk and refuse to call it an IDR. - let tag = avc_tag(1, &[nal(1, 50)]); - let info = classify_video_tag(&tag); - assert!(!info.is_idr); - } - - #[test] - fn avc_seq_header_flagged_not_idr() { - // packet_type 0 = AVCDecoderConfigurationRecord (SPS/PPS) - let tag = avc_tag(0, &[]); - let info = classify_video_tag(&tag); - assert!(info.is_seq_header); - assert!( - !info.is_idr, - "seq headers carry SPS/PPS, never an IDR slice" - ); - } - - #[test] - fn empty_payload_returns_unknown() { - let info = classify_video_tag(&[]); - assert!(!info.is_idr); - assert!(!info.is_seq_header); - assert_eq!(info.codec, VideoCodec::Unknown); - } - - #[test] - fn truncated_nal_does_not_panic_or_falsely_signal_idr() { - // Length prefix says 100 bytes follow, but only 4 are present. - // The walk must return cleanly (no IDR found) without panicking. - let truncated = avc_tag(1, &[vec![0x00, 0x00, 0x00, 100, 0x05]]); - let info = classify_video_tag(&truncated); - assert!(!info.is_idr); - } - - #[test] - fn enhanced_rtmp_keyframe_detected() { - // Enhanced RTMP: byte0 = IsEx(1) | FrameType(3, keyframe=1) | PacketType(4, CodedFrames=1) - let byte0 = 0x80 | (1u8 << 4) | 1; - let mut payload = vec![byte0]; - payload.extend_from_slice(b"hvc1"); - payload.extend_from_slice(&[0; 16]); - let info = classify_video_tag(&payload); - assert!(info.is_idr); - assert_eq!(info.codec, VideoCodec::Hevc); - } - - #[test] - fn enhanced_rtmp_seq_header_not_idr() { - // PacketType=0 (SequenceStart) - must not be flagged as IDR - let byte0 = 0x80 | (1u8 << 4); - let mut payload = vec![byte0]; - payload.extend_from_slice(b"av01"); - let info = classify_video_tag(&payload); - assert!(info.is_seq_header); - assert!(!info.is_idr); - assert_eq!(info.codec, VideoCodec::Av1); + fn legacy_avc_video_tags_are_classified() { + // avc idr detected from nal walk + { + // packet_type 1 = NALU; contains NAL type 5 (IDR slice) + let tag = avc_tag(1, &[nal(5, 100)]); + let info = classify_video_tag(&tag); + assert!(info.is_idr, "single IDR NAL must be detected"); + assert!(!info.is_seq_header); + assert_eq!(info.codec, VideoCodec::Avc); + } + // avc non idr not flagged + { + // NAL type 1 = non-IDR slice; encoder MIGHT lie via the FrameType + // nibble - we trust the NAL walk and refuse to call it an IDR. + let tag = avc_tag(1, &[nal(1, 50)]); + let info = classify_video_tag(&tag); + assert!(!info.is_idr); + } + // avc seq header flagged not idr + { + // packet_type 0 = AVCDecoderConfigurationRecord (SPS/PPS) + let tag = avc_tag(0, &[]); + let info = classify_video_tag(&tag); + assert!(info.is_seq_header); + assert!( + !info.is_idr, + "seq headers carry SPS/PPS, never an IDR slice" + ); + } + // avc multi nal payload detects idr anywhere + { + // A tag with [SEI, non-IDR slice, IDR slice]. The walk must + // keep going past the first non-IDR and still find the IDR. + let tag = avc_tag(1, &[nal(6, 20), nal(1, 30), nal(5, 50)]); + let info = classify_video_tag(&tag); + assert!(info.is_idr, "IDR in 3rd NAL must be detected"); + } + // truncated nal does not panic or falsely signal idr + { + // Length prefix says 100 bytes follow, but only 4 are present. + // The walk must return cleanly (no IDR found) without panicking. + let truncated = avc_tag(1, &[vec![0x00, 0x00, 0x00, 100, 0x05]]); + let info = classify_video_tag(&truncated); + assert!(!info.is_idr); + } + // a zero length nal at the end of a tag is not an idr and does not panic + { + // A length prefix of 0 as the last 4 bytes passes the "does the NAL + // fit" check (4 + 0 <= 4) and the walk then read the NAL header one + // byte past the slice. That runs on every published video tag, and + // with `panic = "abort"` one malformed frame killed the process. + let bare = [0x17, 0x01, 0, 0, 0, 0, 0, 0, 0]; + assert!(!classify_video_tag(&bare).is_idr); + + let mut after_a_slice = avc_tag(1, &[nal(1, 10)]); + after_a_slice.extend_from_slice(&[0, 0, 0, 0]); + assert!(!classify_video_tag(&after_a_slice).is_idr); + + // An empty NAL in the middle is skipped, so an IDR after it still counts. + let mut idr_after_empty = avc_tag(1, &[]); + idr_after_empty.extend_from_slice(&[0, 0, 0, 0]); + idr_after_empty.extend_from_slice(&nal(5, 8)); + assert!(classify_video_tag(&idr_after_empty).is_idr); + } + // avc legacy with unknown codec id returns unknown + { + // codec_id != 7 (we only understand AVC in legacy mode) + let tag = vec![0x12, 1, 0, 0, 0, 0x05]; // codec_id = 2 (Sorenson) + let info = classify_video_tag(&tag); + assert_eq!(info.codec, VideoCodec::Unknown); + assert!(!info.is_idr); + } + // empty payload returns unknown + { + let info = classify_video_tag(&[]); + assert!(!info.is_idr); + assert!(!info.is_seq_header); + assert_eq!(info.codec, VideoCodec::Unknown); + } + } + + /// Enhanced RTMP tags, single-track and multitrack: keyframes, sequence + /// headers and metadata are each told apart. + #[test] + fn enhanced_rtmp_video_tags_are_classified() { + // enhanced rtmp keyframe detected + { + // Enhanced RTMP: byte0 = IsEx(1) | FrameType(3, keyframe=1) | PacketType(4, CodedFrames=1) + let byte0 = 0x80 | (1u8 << 4) | 1; + let mut payload = vec![byte0]; + payload.extend_from_slice(b"hvc1"); + payload.extend_from_slice(&[0; 16]); + let info = classify_video_tag(&payload); + assert!(info.is_idr); + assert_eq!(info.codec, VideoCodec::Hevc); + } + // enhanced rtmp seq header not idr + { + // PacketType=0 (SequenceStart) - must not be flagged as IDR + let byte0 = 0x80 | (1u8 << 4); + let mut payload = vec![byte0]; + payload.extend_from_slice(b"av01"); + let info = classify_video_tag(&payload); + assert!(info.is_seq_header); + assert!(!info.is_idr); + assert_eq!(info.codec, VideoCodec::Av1); + } + // enhanced rtmp metadata packettype 4 not idr + { + // packet_type 4 = Metadata (mid-stream HDR colorInfo etc.). + // Even if the encoder sets FrameType=1, this packet carries no + // slice data and must not be treated as an IDR cut point. + let byte0 = 0x80 | (1u8 << 4) | 4; + let mut payload = vec![byte0]; + payload.extend_from_slice(b"hvc1"); + payload.extend_from_slice(&[0; 8]); + let info = classify_video_tag(&payload); + assert!(info.is_metadata); + assert!(!info.is_idr, "Metadata packets must not be IDR cuts"); + assert!(!info.is_seq_header); + assert_eq!(info.codec, VideoCodec::Hevc); + } + // enhanced rtmp multitrack codedframes is idr + { + // outer packet_type=6 (Multitrack), nested packet_type=1 + // (CodedFrames). FrameType=1 → real keyframe. + let byte0 = 0x80 | (1u8 << 4) | 6; + let byte1 = 0x01; // nested CodedFrames + let payload = vec![byte0, byte1, 0, 0, 0, 0]; + let info = classify_video_tag(&payload); + assert!(info.is_multitrack); + assert!(info.is_idr); + assert!(!info.is_seq_header); + } + // enhanced rtmp multitrack seqstart caches as seq header + { + // outer packet_type=6 (Multitrack), nested packet_type=0 + // (SequenceStart). This is the decoder-config tag for an + // Enhanced Broadcasting stream - MUST be flagged as seq + // header or the controller never caches it and every cut + // emits a stale or empty config to the destination, which + // then fails to decode the post-cut frames. + let byte0 = 0x80 | (1u8 << 4) | 6; // FrameType=1, packet_type=Multitrack + let byte1 = 0x00; // nested SequenceStart + let payload = vec![byte0, byte1, 0, 0, 0, 0]; + let info = classify_video_tag(&payload); + assert!(info.is_multitrack); + assert!(info.is_seq_header); + // FrameType=1 set on a seq header tag must NOT be flagged + // as IDR - seq headers carry decoder config, never slice + // data. Same rule as the single-track classifier. + assert!(!info.is_idr); + } } #[test] @@ -1488,14 +1575,88 @@ mod tests { ); } - #[test] - fn aac_seq_header_detected() { - // sound_format 10 (AAC), packet_type 0 (AudioSpecificConfig) - let payload = [0xA0, 0x00, 0x12, 0x10]; - let info = classify_audio_tag(&payload); - assert!(info.is_seq_header); - assert_eq!(info.codec, AudioCodec::Aac); - assert!(is_aac_seq_header(&payload)); + /// The audio config tag must be recognised in legacy, Enhanced single-track + /// and multitrack framing, with the track it belongs to. + #[test] + fn audio_sequence_headers_are_told_from_frames_in_every_framing() { + // aac seq header detected + { + // sound_format 10 (AAC), packet_type 0 (AudioSpecificConfig) + let payload = [0xA0, 0x00, 0x12, 0x10]; + let info = classify_audio_tag(&payload); + assert!(info.is_seq_header); + assert_eq!(info.codec, AudioCodec::Aac); + assert!(is_aac_seq_header(&payload)); + } + // enhanced single track seq header is detected + { + // The user-visible regression for issue #9 (post-v0.1.3 manual + // test): with VOD audio enabled, OBS sends the live track as + // Enhanced-RTMP single-track. v0.1.0..0.1.3 read PacketType + // from byte 1 (which is 'm' = 0x6D, low nibble 0x0D) and never + // matched the SequenceStart value - so the AudioSpecificConfig + // tag flowed into the ring like a normal frame and got + // evicted, leaving Twitch's decoder unconfigured. + let tag = enhanced_audio_single_track(0); + let info = classify_audio_tag(&tag); + assert!( + info.is_seq_header, + "Enhanced-RTMP single-track byte 0 = 0x90 must classify as seq header" + ); + assert!(!info.is_multitrack); + assert_eq!(info.codec, AudioCodec::Aac); + // sink.rs asks through the shim, which must agree. + assert!(is_aac_seq_header(&tag)); + } + // enhanced single track coded frame is not seq header + { + let tag = enhanced_audio_single_track(1); + let info = classify_audio_tag(&tag); + assert!(!info.is_seq_header); + assert!(!info.is_multitrack); + assert_eq!(info.codec, AudioCodec::Aac); + } + // multitrack audio seqstart detected as seq header + { + // Pre-Phase-A, this tag would have been treated as a regular + // runtime tag (is_seq_header=false) because the outer + // PacketType is 5 (Multitrack), not 0 (SequenceStart). With + // the nested-packet-type check it correctly reads as a seq + // header that needs caching for replay across cuts. + let tag = enhanced_audio_onetrack(0, 0); + let info = classify_audio_tag(&tag); + assert!( + info.is_seq_header, + "nested PT=0 must be flagged as seq header" + ); + assert!(info.is_multitrack); + assert_eq!(info.codec, AudioCodec::Aac); + } + // multitrack audio codedframes is not seq header + { + let tag = enhanced_audio_onetrack(0, 1); + let info = classify_audio_tag(&tag); + assert!(!info.is_seq_header); + assert!(info.is_multitrack); + } + // audio seq header track id reads onetrack byte 6 + { + // OBS writes TrackId at byte 6 of the audio tag payload + // (byte 0 = 0x95, byte 1 = mt|nested, bytes 2..6 = FourCC, + // byte 6 = TrackId). Our cache keys on this so a publisher + // reconnect can replay every track's config. + for track in 0u8..=3 { + let tag = enhanced_audio_onetrack(track, 0); + assert_eq!(audio_seq_header_track_id(&tag), track); + } + } + // audio seq header track id returns zero for legacy aac + { + // Legacy AAC seq header: sound_format=10, AACPacketType=0. + // No track-id field exists; helper must return 0. + let tag = vec![0xaf, 0x00, 0x12, 0x10, 0x56, 0xe5]; + assert_eq!(audio_seq_header_track_id(&tag), 0); + } } #[test] @@ -1511,64 +1672,6 @@ mod tests { // ── Additional edge cases ────────────────────────────────────── - #[test] - fn avc_multi_nal_payload_detects_idr_anywhere() { - // A tag with [SEI, non-IDR slice, IDR slice]. The walk must - // keep going past the first non-IDR and still find the IDR. - let tag = avc_tag(1, &[nal(6, 20), nal(1, 30), nal(5, 50)]); - let info = classify_video_tag(&tag); - assert!(info.is_idr, "IDR in 3rd NAL must be detected"); - } - - #[test] - fn enhanced_rtmp_metadata_packettype_4_not_idr() { - // packet_type 4 = Metadata (mid-stream HDR colorInfo etc.). - // Even if the encoder sets FrameType=1, this packet carries no - // slice data and must not be treated as an IDR cut point. - let byte0 = 0x80 | (1u8 << 4) | 4; - let mut payload = vec![byte0]; - payload.extend_from_slice(b"hvc1"); - payload.extend_from_slice(&[0; 8]); - let info = classify_video_tag(&payload); - assert!(info.is_metadata); - assert!(!info.is_idr, "Metadata packets must not be IDR cuts"); - assert!(!info.is_seq_header); - assert_eq!(info.codec, VideoCodec::Hevc); - } - - #[test] - fn enhanced_rtmp_multitrack_codedframes_is_idr() { - // outer packet_type=6 (Multitrack), nested packet_type=1 - // (CodedFrames). FrameType=1 → real keyframe. - let byte0 = 0x80 | (1u8 << 4) | 6; - let byte1 = 0x01; // nested CodedFrames - let payload = vec![byte0, byte1, 0, 0, 0, 0]; - let info = classify_video_tag(&payload); - assert!(info.is_multitrack); - assert!(info.is_idr); - assert!(!info.is_seq_header); - } - - #[test] - fn enhanced_rtmp_multitrack_seqstart_caches_as_seq_header() { - // outer packet_type=6 (Multitrack), nested packet_type=0 - // (SequenceStart). This is the decoder-config tag for an - // Enhanced Broadcasting stream - MUST be flagged as seq - // header or the controller never caches it and every cut - // emits a stale or empty config to the destination, which - // then fails to decode the post-cut frames. - let byte0 = 0x80 | (1u8 << 4) | 6; // FrameType=1, packet_type=Multitrack - let byte1 = 0x00; // nested SequenceStart - let payload = vec![byte0, byte1, 0, 0, 0, 0]; - let info = classify_video_tag(&payload); - assert!(info.is_multitrack); - assert!(info.is_seq_header); - // FrameType=1 set on a seq header tag must NOT be flagged - // as IDR - seq headers carry decoder config, never slice - // data. Same rule as the single-track classifier. - assert!(!info.is_idr); - } - // ── select_video_bytes: per-destination flatten policy ─────────── // // The pump uses this helper to decide what bytes to put on the wire @@ -1601,51 +1704,86 @@ mod tests { payload } + /// Twitch gets multitrack video as it came; every other platform gets the + /// main track flattened, and nothing from the other tracks. #[test] - fn select_video_bytes_passes_single_track_through_unchanged_in_both_modes() { - // Invariant 1: single-track tags never get rewritten. The - // pass_through flag is meaningless when there's nothing to - // flatten - and the borrow path means no copy happens either. - let tag = avc_single_track_keyframe_bytes(); - let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) - .expect("single-track always forwards"); - let youtube = - select_video_bytes(&tag, VideoEgress::Track(0)).expect("single-track always forwards"); - assert_eq!(twitch.as_ref(), tag.as_slice()); - assert_eq!(youtube.as_ref(), tag.as_slice()); - assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); - assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); - } - - #[test] - fn select_video_bytes_passes_multitrack_through_for_twitch() { - // Invariant 2: Twitch destinations get the raw multi-track - // bytes verbatim - that's what unlocks the transcoded ladder. - // True for every TrackId, not just the primary. Twitch Dual Format - // rides on this too: the vertical canvas is just more TrackIds on - // the wire (`canvas_index` only exists in the config JSON). - for track in 0u8..=4 { - let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + fn each_destination_gets_the_video_bytes_it_can_take() { + // select video bytes passes single track through unchanged in both modes + { + // Invariant 1: single-track tags never get rewritten. The + // pass_through flag is meaningless when there's nothing to + // flatten - and the borrow path means no copy happens either. + let tag = avc_single_track_keyframe_bytes(); let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) - .unwrap_or_else(|| panic!("twitch must forward track {track}")); + .expect("single-track always forwards"); + let youtube = select_video_bytes(&tag, VideoEgress::Track(0)) + .expect("single-track always forwards"); assert_eq!(twitch.as_ref(), tag.as_slice()); + assert_eq!(youtube.as_ref(), tag.as_slice()); assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); + assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); + } + // select video bytes passes multitrack through for twitch + { + // Invariant 2: Twitch destinations get the raw multi-track + // bytes verbatim - that's what unlocks the transcoded ladder. + // True for every TrackId, not just the primary. Twitch Dual Format + // rides on this too: the vertical canvas is just more TrackIds on + // the wire (`canvas_index` only exists in the config JSON). + for track in 0u8..=4 { + let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) + .unwrap_or_else(|| panic!("twitch must forward track {track}")); + assert_eq!(twitch.as_ref(), tag.as_slice()); + assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); + } + } + // select video bytes flattens track0 multitrack for non twitch + { + // Defensive path: an encoder that sends the *primary* as + // OneTrack-format TrackId 0 (rather than as a separate legacy + // AVC tag) still gets a flattened single-track tag for + // non-Twitch destinations. In the wild OBS sends the primary + // as legacy, so this path rarely fires - but we want a future + // encoder change not to silently blank YouTube. + let tag = enhanced_rtmp_onetrack_video_bytes_track(0); + let youtube = + select_video_bytes(&tag, VideoEgress::Track(0)).expect("track 0 must be forwarded"); + assert_eq!(youtube.as_ref(), flat_hvc1_keyframe().as_slice()); + assert!(matches!(youtube, std::borrow::Cow::Owned(_))); + } + // select video bytes drops nonzero track multitrack for non twitch + { + // The core fix: OBS sends the simulcast ladder as OneTrack + // tags with TrackId 1..N, one tag per resolution per frame. + // Forwarding every one to YouTube delivered N frames per PTS + // → decoder storm → reconnect cascade. These tags must be + // dropped; the primary arrives separately as a legacy / + // Enhanced single-track tag. With Dual Format, TrackId 1+ also + // carries the vertical canvas, which a horizontal dest never wants. + for track in 1u8..=4 { + let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + assert!( + select_video_bytes(&tag, VideoEgress::Track(0)).is_none(), + "TrackId {track} must be dropped for non-Twitch", + ); + } + } + // select video bytes falls back to raw when flatten cannot parse + { + // Pathological multi-track tag (declares multi-track but the + // payload is too short to contain a valid inner structure). + // The pump must NOT crash and must NOT swallow the tag - + // letting it through unchanged matches the old ingest-side + // fallback so the destination gets a chance to surface the + // error properly. Truncated below the TrackId byte means we + // can't tell which track it is; treat that as "forward". + let truncated = vec![0x80 | (1u8 << 4) | 6, 0]; // 2 bytes total + let youtube = select_video_bytes(&truncated, VideoEgress::Track(0)) + .expect("truncated tags must still forward"); + assert_eq!(youtube.as_ref(), truncated.as_slice()); + assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); } - } - - #[test] - fn select_video_bytes_flattens_track0_multitrack_for_non_twitch() { - // Defensive path: an encoder that sends the *primary* as - // OneTrack-format TrackId 0 (rather than as a separate legacy - // AVC tag) still gets a flattened single-track tag for - // non-Twitch destinations. In the wild OBS sends the primary - // as legacy, so this path rarely fires - but we want a future - // encoder change not to silently blank YouTube. - let tag = enhanced_rtmp_onetrack_video_bytes_track(0); - let youtube = - select_video_bytes(&tag, VideoEgress::Track(0)).expect("track 0 must be forwarded"); - assert_eq!(youtube.as_ref(), flat_hvc1_keyframe().as_slice()); - assert!(matches!(youtube, std::borrow::Cow::Owned(_))); } /// What `enhanced_rtmp_onetrack_video_bytes_track(_)` must flatten to, @@ -1707,24 +1845,6 @@ mod tests { ); } - #[test] - fn select_video_bytes_drops_nonzero_track_multitrack_for_non_twitch() { - // The core fix: OBS sends the simulcast ladder as OneTrack - // tags with TrackId 1..N, one tag per resolution per frame. - // Forwarding every one to YouTube delivered N frames per PTS - // → decoder storm → reconnect cascade. These tags must be - // dropped; the primary arrives separately as a legacy / - // Enhanced single-track tag. With Dual Format, TrackId 1+ also - // carries the vertical canvas, which a horizontal dest never wants. - for track in 1u8..=4 { - let tag = enhanced_rtmp_onetrack_video_bytes_track(track); - assert!( - select_video_bytes(&tag, VideoEgress::Track(0)).is_none(), - "TrackId {track} must be dropped for non-Twitch", - ); - } - } - // ── Vertical-canvas selection: SPS orientation + Track(n) routing ── // // A vertical destination forwards ONLY the portrait canvas's track, @@ -1852,20 +1972,71 @@ mod tests { ); } - #[test] - fn sps_dimensions_returns_none_on_unparseable_input() { - // Empty, truncated, and non-AVC inputs must all fail cleanly with - // no panic (this runs on hostile wire bytes). - assert_eq!(sps_dimensions(&[]), None); - assert_eq!(sps_dimensions(&[0x17, 0x00]), None); - let mut truncated = legacy_avc_seq_header(40, 30); - truncated.truncate(9); // cut into the SPS - assert_eq!(sps_dimensions(&truncated), None); - // HEVC single-track seq header (hvc1) is not AVC - unparseable. - let mut hevc = vec![0x90u8]; - hevc.extend_from_slice(b"hvc1"); - hevc.extend_from_slice(&[0u8; 16]); - assert_eq!(sps_dimensions(&hevc), None); + /// A sequence header that can't be read, or lies about its size, yields nothing. + #[test] + fn a_damaged_sps_gives_no_size_and_never_panics() { + // sps dimensions returns none on unparseable input + { + // Empty, truncated, and non-AVC inputs must all fail cleanly with + // no panic (this runs on hostile wire bytes). + assert_eq!(sps_dimensions(&[]), None); + assert_eq!(sps_dimensions(&[0x17, 0x00]), None); + let mut truncated = legacy_avc_seq_header(40, 30); + truncated.truncate(9); // cut into the SPS + assert_eq!(sps_dimensions(&truncated), None); + // HEVC single-track seq header (hvc1) is not AVC - unparseable. + let mut hevc = vec![0x90u8]; + hevc.extend_from_slice(b"hvc1"); + hevc.extend_from_slice(&[0u8; 16]); + assert_eq!(sps_dimensions(&hevc), None); + } + // sps dimensions rejects out of range scaling deltas without panicking + { + // delta_scale is -128..=127 by spec. A hostile SPS can carry up to + // +/-(2^31 - 1), and `last_scale + delta + 256` overflowed i32: a + // panic in debug, a silently wrong parse in release. Nonsense like + // that means the SPS is not one we can size, so the parse gives up. + for bad_delta in [128, -129, i32::MAX, -i32::MAX] { + let mut f = SpsFields::progressive(120, 68); + f.scaling_lists = vec![(0, vec![bad_delta, 0])]; + assert_eq!(rich_sps_dimensions(&f), None, "delta {bad_delta}"); + } + } + // sps dimensions huge crop returns none not panic + { + // A malformed SPS can carry near-u32::MAX frame-crop Exp-Golomb + // values; the crop math must stay checked so this returns None + // instead of overflow-panicking in debug builds. + let mut w = BitWriter::new(); + w.put_bits(66, 8); // baseline profile (skips the high-profile block) + w.put_bits(0, 8); + w.put_bits(30, 8); + w.put_ue(0); // sps_id + w.put_ue(0); // log2_max_frame_num_minus4 + w.put_ue(0); // pic_order_cnt_type + w.put_ue(0); // log2_max_pic_order_cnt_lsb_minus4 + w.put_ue(0); // max_num_ref_frames + w.put_bit(0); // gaps_in_frame_num_value_allowed_flag + w.put_ue(39); // pic_width_in_mbs_minus1 + w.put_ue(21); // pic_height_in_map_units_minus1 + w.put_bit(1); // frame_mbs_only_flag + w.put_bit(0); // direct_8x8_inference_flag + w.put_bit(1); // frame_cropping_flag + w.put_ue(4_000_000_000); // crop_left + w.put_ue(4_000_000_000); // crop_right -> left + right overflows u32 + w.put_ue(0); + w.put_ue(0); + w.put_bit(0); // vui_parameters_present_flag + let mut sps = vec![0x67u8]; + sps.extend(w.finish()); + let mut cfg = vec![1u8, 66, 0, 30, 0xFF, 0xE1]; + cfg.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + cfg.extend_from_slice(&sps); + cfg.push(0); + let mut tag = vec![0x17u8, 0x00, 0, 0, 0]; + tag.extend(cfg); + assert_eq!(sps_dimensions(&tag), None); + } } /// Read back the size from the reconnect screen's own HEVC parameter @@ -2046,19 +2217,6 @@ mod tests { assert_eq!(rich_sps_dimensions(&f), Some((1920, 1080))); } - #[test] - fn sps_dimensions_rejects_out_of_range_scaling_deltas_without_panicking() { - // delta_scale is -128..=127 by spec. A hostile SPS can carry up to - // +/-(2^31 - 1), and `last_scale + delta + 256` overflowed i32: a - // panic in debug, a silently wrong parse in release. Nonsense like - // that means the SPS is not one we can size, so the parse gives up. - for bad_delta in [128, -129, i32::MAX, -i32::MAX] { - let mut f = SpsFields::progressive(120, 68); - f.scaling_lists = vec![(0, vec![bad_delta, 0])]; - assert_eq!(rich_sps_dimensions(&f), None, "delta {bad_delta}"); - } - } - #[test] fn sps_dimensions_applies_cropping_per_chroma_format() { // Portrait 1080x1920 is coded 1088 wide; crop_right 4 x 2 (4:2:0). @@ -2118,100 +2276,6 @@ mod tests { assert_eq!(strip_emulation_prevention(&[0x42, 3]), [0x42, 3]); } - #[test] - fn sps_dimensions_huge_crop_returns_none_not_panic() { - // A malformed SPS can carry near-u32::MAX frame-crop Exp-Golomb - // values; the crop math must stay checked so this returns None - // instead of overflow-panicking in debug builds. - let mut w = BitWriter::new(); - w.put_bits(66, 8); // baseline profile (skips the high-profile block) - w.put_bits(0, 8); - w.put_bits(30, 8); - w.put_ue(0); // sps_id - w.put_ue(0); // log2_max_frame_num_minus4 - w.put_ue(0); // pic_order_cnt_type - w.put_ue(0); // log2_max_pic_order_cnt_lsb_minus4 - w.put_ue(0); // max_num_ref_frames - w.put_bit(0); // gaps_in_frame_num_value_allowed_flag - w.put_ue(39); // pic_width_in_mbs_minus1 - w.put_ue(21); // pic_height_in_map_units_minus1 - w.put_bit(1); // frame_mbs_only_flag - w.put_bit(0); // direct_8x8_inference_flag - w.put_bit(1); // frame_cropping_flag - w.put_ue(4_000_000_000); // crop_left - w.put_ue(4_000_000_000); // crop_right -> left + right overflows u32 - w.put_ue(0); - w.put_ue(0); - w.put_bit(0); // vui_parameters_present_flag - let mut sps = vec![0x67u8]; - sps.extend(w.finish()); - let mut cfg = vec![1u8, 66, 0, 30, 0xFF, 0xE1]; - cfg.extend_from_slice(&(sps.len() as u16).to_be_bytes()); - cfg.extend_from_slice(&sps); - cfg.push(0); - let mut tag = vec![0x17u8, 0x00, 0, 0, 0]; - tag.extend(cfg); - assert_eq!(sps_dimensions(&tag), None); - } - - #[test] - fn a_zero_length_nal_at_the_end_of_a_tag_is_not_an_idr_and_does_not_panic() { - // A length prefix of 0 as the last 4 bytes passes the "does the NAL - // fit" check (4 + 0 <= 4) and the walk then read the NAL header one - // byte past the slice. That runs on every published video tag, and - // with `panic = "abort"` one malformed frame killed the process. - let bare = [0x17, 0x01, 0, 0, 0, 0, 0, 0, 0]; - assert!(!classify_video_tag(&bare).is_idr); - - let mut after_a_slice = avc_tag(1, &[nal(1, 10)]); - after_a_slice.extend_from_slice(&[0, 0, 0, 0]); - assert!(!classify_video_tag(&after_a_slice).is_idr); - - // An empty NAL in the middle is skipped, so an IDR after it still counts. - let mut idr_after_empty = avc_tag(1, &[]); - idr_after_empty.extend_from_slice(&[0, 0, 0, 0]); - idr_after_empty.extend_from_slice(&nal(5, 8)); - assert!(classify_video_tag(&idr_after_empty).is_idr); - } - - /// Deterministic LCG for the fuzz tests: fixed seed, no dependency, and a - /// failure reproduces from the iteration number alone. - struct Lcg(u64); - - impl Lcg { - fn next_u32(&mut self) -> u32 { - self.0 = self - .0 - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - (self.0 >> 33) as u32 - } - - fn below(&mut self, n: u32) -> u32 { - self.next_u32() % n - } - - fn pick(&mut self, items: &[T]) -> T { - items[self.below(items.len() as u32) as usize] - } - - /// Zero- and 0xFF-heavy bytes. Uniform bytes almost never form a - /// zero length prefix or a long Exp-Golomb run, which are exactly the - /// shapes that break parsers. - fn byte(&mut self) -> u8 { - match self.below(8) { - 0..=2 => 0x00, - 3 => 0xFF, - _ => self.next_u32() as u8, - } - } - - fn bytes(&mut self, max_len: u32) -> Vec { - let n = self.below(max_len + 1); - (0..n).map(|_| self.byte()).collect() - } - } - /// Length-prefixed NALs whose declared lengths are sometimes honest, /// sometimes zero and sometimes past the end of the tag. fn fuzz_nal_units(rng: &mut Lcg) -> Vec { @@ -2474,101 +2538,107 @@ mod tests { assert_eq!(seq_header_codec(&[]), VideoCodec::Unknown); } + /// Which track a 9:16 destination follows. #[test] - fn detect_vertical_primary_track_picks_largest_portrait() { - let mut headers = std::collections::BTreeMap::new(); - headers.insert(0u8, legacy_avc_seq_header(120, 68)); // 1920x1088 landscape - headers.insert(3u8, onetrack_avc_seq_header(3, 34, 60)); // 544x960 portrait - headers.insert(4u8, onetrack_avc_seq_header(4, 68, 120)); // 1088x1920 portrait (bigger) - assert_eq!(detect_vertical_primary_track(&headers), Some(4)); - } - - #[test] - fn detect_vertical_primary_track_none_when_all_landscape() { - let mut headers = std::collections::BTreeMap::new(); - headers.insert(0u8, legacy_avc_seq_header(120, 68)); - headers.insert(1u8, onetrack_avc_seq_header(1, 80, 45)); - assert_eq!(detect_vertical_primary_track(&headers), None); + fn the_vertical_track_is_the_largest_portrait_one() { + // detect vertical primary track picks largest portrait + { + let mut headers = std::collections::BTreeMap::new(); + headers.insert(0u8, legacy_avc_seq_header(120, 68)); // 1920x1088 landscape + headers.insert(3u8, onetrack_avc_seq_header(3, 34, 60)); // 544x960 portrait + headers.insert(4u8, onetrack_avc_seq_header(4, 68, 120)); // 1088x1920 portrait (bigger) + assert_eq!(detect_vertical_primary_track(&headers), Some(4)); + } + // detect vertical primary track none when all landscape + { + let mut headers = std::collections::BTreeMap::new(); + headers.insert(0u8, legacy_avc_seq_header(120, 68)); + headers.insert(1u8, onetrack_avc_seq_header(1, 80, 45)); + assert_eq!(detect_vertical_primary_track(&headers), None); + } } + /// A 9:16 destination is sent its canvas's track, rewritten so any platform reads it. #[test] - fn select_video_bytes_vertical_forwards_only_target_track() { - // Track(1) keeps OneTrack TrackId 1 (flattened) and drops every - // other track plus the legacy horizontal primary. - let track1 = enhanced_rtmp_onetrack_video_bytes_track(1); - let track2 = enhanced_rtmp_onetrack_video_bytes_track(2); - let legacy_horizontal = avc_single_track_keyframe_bytes(); + fn a_vertical_destination_gets_only_its_own_canvas() { + // select video bytes vertical forwards only target track + { + // Track(1) keeps OneTrack TrackId 1 (flattened) and drops every + // other track plus the legacy horizontal primary. + let track1 = enhanced_rtmp_onetrack_video_bytes_track(1); + let track2 = enhanced_rtmp_onetrack_video_bytes_track(2); + let legacy_horizontal = avc_single_track_keyframe_bytes(); - let kept = select_video_bytes(&track1, VideoEgress::Track(1)) - .expect("vertical target track must forward"); - assert_eq!(kept.as_ref(), flat_hvc1_keyframe().as_slice()); + let kept = select_video_bytes(&track1, VideoEgress::Track(1)) + .expect("vertical target track must forward"); + assert_eq!(kept.as_ref(), flat_hvc1_keyframe().as_slice()); - assert!( - select_video_bytes(&track2, VideoEgress::Track(1)).is_none(), - "non-target OneTrack rung must be dropped for vertical" - ); - assert!( - select_video_bytes(&legacy_horizontal, VideoEgress::Track(1)).is_none(), - "legacy horizontal primary must be dropped for a vertical destination" - ); - } - - #[test] - fn select_video_bytes_vertical_avc_rewrites_to_legacy() { - // A OneTrack avc1 vertical track must reach a non-Twitch dest as - // LEGACY AVC (0x17/0x27 ...), not Enhanced-RTMP - YouTube's vertical - // ingest is flaky with E-RTMP avc1 and aborts on a ~11 s cycle. - - // Sequence header -> legacy AVC seq header, config record intact. - let seq = onetrack_avc_seq_header(1, 30, 40); - let out = select_video_bytes(&seq, VideoEgress::Track(1)) - .expect("vertical avc seq header forwards"); - assert_eq!(out[0], 0x17, "legacy AVC seq-header frame byte"); - assert_eq!(out[1], 0x00, "AVCPacketType: sequence header"); - assert_eq!(&out[2..5], &[0, 0, 0], "composition time zero"); - assert_eq!(&out[5..], &build_avc_config(30, 40)[..]); - - // CodedFramesX keyframe (no composition time) -> legacy 0x17 NALU. - let mut kf = vec![0x96u8, 0x03]; // key|Multitrack, OneTrack|CodedFramesX - kf.extend_from_slice(b"avc1"); - kf.push(1); - kf.extend_from_slice(&[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); // len-prefixed IDR - let out = select_video_bytes(&kf, VideoEgress::Track(1)).expect("keyframe forwards"); - assert_eq!(out[0], 0x17, "legacy keyframe head"); - assert_eq!(out[1], 0x01, "AVCPacketType: NALU"); - assert_eq!( - &out[2..5], - &[0, 0, 0], - "CodedFramesX carries composition time 0" - ); - assert_eq!(&out[5..], &[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); - - // CodedFrames inter frame preserves the 3-byte composition time. - let mut inter = vec![0xA6u8, 0x01]; // inter|Multitrack, OneTrack|CodedFrames - inter.extend_from_slice(b"avc1"); - inter.push(1); - inter.extend_from_slice(&[0x00, 0x00, 0x10]); // composition time - inter.extend_from_slice(&[0, 0, 0, 3, 0x41, 9, 9]); // len-prefixed P NAL - let out = select_video_bytes(&inter, VideoEgress::Track(1)).expect("inter forwards"); - assert_eq!(out[0], 0x27, "legacy inter head"); - assert_eq!(out[1], 0x01); - assert_eq!( - &out[2..5], - &[0x00, 0x00, 0x10], - "composition time preserved" - ); - assert_eq!(&out[5..], &[0, 0, 0, 3, 0x41, 9, 9]); - - // A OneTrack avc1 metadata/colour packet has no legacy equivalent - // and is dropped rather than mixing E-RTMP into a legacy stream. - let mut meta = vec![0x96u8, 0x04]; // OneTrack | Metadata(4) - meta.extend_from_slice(b"avc1"); - meta.push(1); - meta.extend_from_slice(&[1, 2, 3, 4]); - assert!( - select_video_bytes(&meta, VideoEgress::Track(1)).is_none(), - "avc1 metadata packet must be dropped for a legacy vertical stream" - ); + assert!( + select_video_bytes(&track2, VideoEgress::Track(1)).is_none(), + "non-target OneTrack rung must be dropped for vertical" + ); + assert!( + select_video_bytes(&legacy_horizontal, VideoEgress::Track(1)).is_none(), + "legacy horizontal primary must be dropped for a vertical destination" + ); + } + // select video bytes vertical avc rewrites to legacy + { + // A OneTrack avc1 vertical track must reach a non-Twitch dest as + // LEGACY AVC (0x17/0x27 ...), not Enhanced-RTMP - YouTube's vertical + // ingest is flaky with E-RTMP avc1 and aborts on a ~11 s cycle. + + // Sequence header -> legacy AVC seq header, config record intact. + let seq = onetrack_avc_seq_header(1, 30, 40); + let out = select_video_bytes(&seq, VideoEgress::Track(1)) + .expect("vertical avc seq header forwards"); + assert_eq!(out[0], 0x17, "legacy AVC seq-header frame byte"); + assert_eq!(out[1], 0x00, "AVCPacketType: sequence header"); + assert_eq!(&out[2..5], &[0, 0, 0], "composition time zero"); + assert_eq!(&out[5..], &build_avc_config(30, 40)[..]); + + // CodedFramesX keyframe (no composition time) -> legacy 0x17 NALU. + let mut kf = vec![0x96u8, 0x03]; // key|Multitrack, OneTrack|CodedFramesX + kf.extend_from_slice(b"avc1"); + kf.push(1); + kf.extend_from_slice(&[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); // len-prefixed IDR + let out = select_video_bytes(&kf, VideoEgress::Track(1)).expect("keyframe forwards"); + assert_eq!(out[0], 0x17, "legacy keyframe head"); + assert_eq!(out[1], 0x01, "AVCPacketType: NALU"); + assert_eq!( + &out[2..5], + &[0, 0, 0], + "CodedFramesX carries composition time 0" + ); + assert_eq!(&out[5..], &[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); + + // CodedFrames inter frame preserves the 3-byte composition time. + let mut inter = vec![0xA6u8, 0x01]; // inter|Multitrack, OneTrack|CodedFrames + inter.extend_from_slice(b"avc1"); + inter.push(1); + inter.extend_from_slice(&[0x00, 0x00, 0x10]); // composition time + inter.extend_from_slice(&[0, 0, 0, 3, 0x41, 9, 9]); // len-prefixed P NAL + let out = select_video_bytes(&inter, VideoEgress::Track(1)).expect("inter forwards"); + assert_eq!(out[0], 0x27, "legacy inter head"); + assert_eq!(out[1], 0x01); + assert_eq!( + &out[2..5], + &[0x00, 0x00, 0x10], + "composition time preserved" + ); + assert_eq!(&out[5..], &[0, 0, 0, 3, 0x41, 9, 9]); + + // A OneTrack avc1 metadata/colour packet has no legacy equivalent + // and is dropped rather than mixing E-RTMP into a legacy stream. + let mut meta = vec![0x96u8, 0x04]; // OneTrack | Metadata(4) + meta.extend_from_slice(b"avc1"); + meta.push(1); + meta.extend_from_slice(&[1, 2, 3, 4]); + assert!( + select_video_bytes(&meta, VideoEgress::Track(1)).is_none(), + "avc1 metadata packet must be dropped for a legacy vertical stream" + ); + } } // ── is_primary_video_idr: cut-target gate for EB ladder cuts ── @@ -2580,86 +2650,60 @@ mod tests { // After: only primary-track IDRs are candidates, the legacy // decoder always has its anchor at the cut boundary. - #[test] - fn is_primary_video_idr_accepts_legacy_avc_keyframe() { - let tag = avc_single_track_keyframe_bytes(); - assert!(is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_rejects_legacy_avc_inter_frame() { - // FrameType=2 (inter) + CodecID=7 (AVC) = 0x27. Same shape as - // the keyframe builder above, just with the leading byte flipped. - let mut tag = avc_single_track_keyframe_bytes(); - tag[0] = 0x27; - assert!(!is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_accepts_onetrack_multitrack_track_0() { - // EB ladder primary rung at TrackId=0 - the cut anchor we want. - let tag = enhanced_rtmp_onetrack_video_bytes_track(0); - assert!(is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_rejects_onetrack_multitrack_nonzero_tracks() { - // EB ladder rungs 1..=4 each carry their own IDR cadence but - // must NOT be picked as a cut anchor - if the cut lands here - // the legacy decoder on the destination has no reference. - // Twitch Dual Format's vertical canvas is one of these TrackIds, - // so cuts are anchored to the horizontal canvas only; a vertical - // cut is clean because OBS aligns both canvases' keyframes. If - // cuts ever become canvas-aware, this is the guard that must flip. - for track in 1u8..=4 { - let tag = enhanced_rtmp_onetrack_video_bytes_track(track); - assert!( - !is_primary_video_idr(&tag), - "TrackId {track} must not be a cut candidate" - ); + /// A delay may only land on a keyframe of the main picture, in any framing. + #[test] + fn only_the_main_tracks_keyframes_are_primary_idrs() { + // is primary video idr accepts legacy avc keyframe + { + let tag = avc_single_track_keyframe_bytes(); + assert!(is_primary_video_idr(&tag)); + } + // is primary video idr rejects legacy avc inter frame + { + // FrameType=2 (inter) + CodecID=7 (AVC) = 0x27. Same shape as + // the keyframe builder above, just with the leading byte flipped. + let mut tag = avc_single_track_keyframe_bytes(); + tag[0] = 0x27; + assert!(!is_primary_video_idr(&tag)); + } + // is primary video idr accepts onetrack multitrack track 0 + { + // EB ladder primary rung at TrackId=0 - the cut anchor we want. + let tag = enhanced_rtmp_onetrack_video_bytes_track(0); + assert!(is_primary_video_idr(&tag)); + } + // is primary video idr rejects onetrack multitrack nonzero tracks + { + // EB ladder rungs 1..=4 each carry their own IDR cadence but + // must NOT be picked as a cut anchor - if the cut lands here + // the legacy decoder on the destination has no reference. + // Twitch Dual Format's vertical canvas is one of these TrackIds, + // so cuts are anchored to the horizontal canvas only; a vertical + // cut is clean because OBS aligns both canvases' keyframes. If + // cuts ever become canvas-aware, this is the guard that must flip. + for track in 1u8..=4 { + let tag = enhanced_rtmp_onetrack_video_bytes_track(track); + assert!( + !is_primary_video_idr(&tag), + "TrackId {track} must not be a cut candidate" + ); + } + } + // is primary video idr rejects enhanced p frame + { + // Enhanced-RTMP single-track inter-frame: IsEx | FrameType=2 | + // PacketType=CodedFrames(1) -> bits assembled as 0xA1. + let tag = vec![0x80 | (2u8 << 4) | 1, b'a', b'v', b'c', b'1', 0xAA, 0xBB]; + assert!(!is_primary_video_idr(&tag)); + } + // is primary video idr handles truncated multitrack gracefully + { + // Multitrack header declared but payload too short to read the + // TrackId byte - safer to refuse cut-candidacy than to land on + // an unknown track. + let truncated = vec![0x80 | (1u8 << 4) | 6, 0x01, b'a', b'v', b'c', b'1']; + assert!(!is_primary_video_idr(&truncated)); } - } - - #[test] - fn is_primary_video_idr_rejects_enhanced_p_frame() { - // Enhanced-RTMP single-track inter-frame: IsEx | FrameType=2 | - // PacketType=CodedFrames(1) -> bits assembled as 0xA1. - let tag = vec![0x80 | (2u8 << 4) | 1, b'a', b'v', b'c', b'1', 0xAA, 0xBB]; - assert!(!is_primary_video_idr(&tag)); - } - - #[test] - fn is_primary_video_idr_handles_truncated_multitrack_gracefully() { - // Multitrack header declared but payload too short to read the - // TrackId byte - safer to refuse cut-candidacy than to land on - // an unknown track. - let truncated = vec![0x80 | (1u8 << 4) | 6, 0x01, b'a', b'v', b'c', b'1']; - assert!(!is_primary_video_idr(&truncated)); - } - - #[test] - fn select_video_bytes_falls_back_to_raw_when_flatten_cannot_parse() { - // Pathological multi-track tag (declares multi-track but the - // payload is too short to contain a valid inner structure). - // The pump must NOT crash and must NOT swallow the tag - - // letting it through unchanged matches the old ingest-side - // fallback so the destination gets a chance to surface the - // error properly. Truncated below the TrackId byte means we - // can't tell which track it is; treat that as "forward". - let truncated = vec![0x80 | (1u8 << 4) | 6, 0]; // 2 bytes total - let youtube = select_video_bytes(&truncated, VideoEgress::Track(0)) - .expect("truncated tags must still forward"); - assert_eq!(youtube.as_ref(), truncated.as_slice()); - assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); - } - - #[test] - fn avc_legacy_with_unknown_codec_id_returns_unknown() { - // codec_id != 7 (we only understand AVC in legacy mode) - let tag = vec![0x12, 1, 0, 0, 0, 0x05]; // codec_id = 2 (Sorenson) - let info = classify_video_tag(&tag); - assert_eq!(info.codec, VideoCodec::Unknown); - assert!(!info.is_idr); } // ── Enhanced-RTMP audio (VOD audio path) ─────────────────────── @@ -2719,108 +2763,70 @@ mod tests { payload } + /// The audio twin of the video rule: Twitch gets every track, others get + /// the one they asked for as plain AAC, or the live track when theirs isn't sent. #[test] - fn enhanced_single_track_seq_header_is_detected() { - // The user-visible regression for issue #9 (post-v0.1.3 manual - // test): with VOD audio enabled, OBS sends the live track as - // Enhanced-RTMP single-track. v0.1.0..0.1.3 read PacketType - // from byte 1 (which is 'm' = 0x6D, low nibble 0x0D) and never - // matched the SequenceStart value - so the AudioSpecificConfig - // tag flowed into the ring like a normal frame and got - // evicted, leaving Twitch's decoder unconfigured. - let tag = enhanced_audio_single_track(0); - let info = classify_audio_tag(&tag); - assert!( - info.is_seq_header, - "Enhanced-RTMP single-track byte 0 = 0x90 must classify as seq header" - ); - assert!(!info.is_multitrack); - assert_eq!(info.codec, AudioCodec::Aac); - // sink.rs asks through the shim, which must agree. - assert!(is_aac_seq_header(&tag)); - } - - #[test] - fn enhanced_single_track_coded_frame_is_not_seq_header() { - let tag = enhanced_audio_single_track(1); - let info = classify_audio_tag(&tag); - assert!(!info.is_seq_header); - assert!(!info.is_multitrack); - assert_eq!(info.codec, AudioCodec::Aac); - } - - #[test] - fn multitrack_audio_seqstart_detected_as_seq_header() { - // Pre-Phase-A, this tag would have been treated as a regular - // runtime tag (is_seq_header=false) because the outer - // PacketType is 5 (Multitrack), not 0 (SequenceStart). With - // the nested-packet-type check it correctly reads as a seq - // header that needs caching for replay across cuts. - let tag = enhanced_audio_onetrack(0, 0); - let info = classify_audio_tag(&tag); - assert!( - info.is_seq_header, - "nested PT=0 must be flagged as seq header" - ); - assert!(info.is_multitrack); - assert_eq!(info.codec, AudioCodec::Aac); - } - - #[test] - fn multitrack_audio_codedframes_is_not_seq_header() { - let tag = enhanced_audio_onetrack(0, 1); - let info = classify_audio_tag(&tag); - assert!(!info.is_seq_header); - assert!(info.is_multitrack); - } - - #[test] - fn audio_seq_header_track_id_reads_onetrack_byte_6() { - // OBS writes TrackId at byte 6 of the audio tag payload - // (byte 0 = 0x95, byte 1 = mt|nested, bytes 2..6 = FourCC, - // byte 6 = TrackId). Our cache keys on this so a publisher - // reconnect can replay every track's config. - for track in 0u8..=3 { - let tag = enhanced_audio_onetrack(track, 0); - assert_eq!(audio_seq_header_track_id(&tag), track); + fn each_destination_gets_the_audio_track_it_asked_for() { + // select audio bytes single track always forwards + { + // Legacy AAC / Enhanced single-track is the only audio there is, so it + // forwards unchanged for EVERY policy - including Track(1): a live + // track beats a silent destination when the 2nd track never arrived. + let legacy_aac = vec![0xaf, 0x01, 0x12, 0x10, 0x56]; + for egress in [ + AudioEgress::Passthrough, + AudioEgress::Track(0), + AudioEgress::Track(1), + ] { + let out = select_audio_bytes(&legacy_aac, egress, false) + .expect("single-track audio must forward"); + assert_eq!(out.as_ref(), legacy_aac.as_slice()); + assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + } } - } - - #[test] - fn audio_seq_header_track_id_returns_zero_for_legacy_aac() { - // Legacy AAC seq header: sound_format=10, AACPacketType=0. - // No track-id field exists; helper must return 0. - let tag = vec![0xaf, 0x00, 0x12, 0x10, 0x56, 0xe5]; - assert_eq!(audio_seq_header_track_id(&tag), 0); - } - - #[test] - fn select_audio_bytes_single_track_always_forwards() { - // Legacy AAC / Enhanced single-track is the only audio there is, so it - // forwards unchanged for EVERY policy - including Track(1): a live - // track beats a silent destination when the 2nd track never arrived. - let legacy_aac = vec![0xaf, 0x01, 0x12, 0x10, 0x56]; - for egress in [ - AudioEgress::Passthrough, - AudioEgress::Track(0), - AudioEgress::Track(1), - ] { - let out = select_audio_bytes(&legacy_aac, egress, false) - .expect("single-track audio must forward"); - assert_eq!(out.as_ref(), legacy_aac.as_slice()); - assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + // select audio bytes falls back to live track when target absent + { + // A dest pinned to Track(1) but a stream that only carries the live + // track (OneTrack TrackId 0) still gets audio: the requested track is + // missing, so it falls back to the live track 0, flattened to legacy. + let live_only = enhanced_audio_onetrack(0, 1); + let out = select_audio_bytes(&live_only, AudioEgress::Track(1), false) + .expect("must fall back to the live track, not go silent"); + assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); + } + // select audio bytes passes multitrack through for twitch + { + // Twitch destination (Passthrough): every track's bytes flow + // through bit-faithfully so Twitch's VOD-audio slot keeps its feed. + for track in 0u8..=3 { + let tag = enhanced_audio_onetrack(track, 1); + let out = select_audio_bytes(&tag, AudioEgress::Passthrough, true) + .unwrap_or_else(|| panic!("twitch must forward track {track}")); + assert_eq!(out.as_ref(), tag.as_slice()); + } + } + // select audio bytes drops untargeted track for single track dest + { + // A single-track destination pinned to Track(0) drops every other + // OneTrack rung (e.g. the VOD track), which is meaningless to + // YouTube / Kick. + for track in 1u8..=3 { + let tag = enhanced_audio_onetrack(track, 1); + assert!( + select_audio_bytes(&tag, AudioEgress::Track(0), true).is_none(), + "TrackId {track} must be dropped when the dest wants Track(0)", + ); + } + } + // select audio bytes flattens target track to legacy aac + { + // The clean/second audio track routed to a single-track dest is + // flattened to legacy AAC (0xAF ...), the universally-accepted form. + let live = enhanced_audio_onetrack(0, 1); + let out = select_audio_bytes(&live, AudioEgress::Track(0), true) + .expect("track 0 must reach a Track(0) destination"); + assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); } - } - - #[test] - fn select_audio_bytes_falls_back_to_live_track_when_target_absent() { - // A dest pinned to Track(1) but a stream that only carries the live - // track (OneTrack TrackId 0) still gets audio: the requested track is - // missing, so it falls back to the live track 0, flattened to legacy. - let live_only = enhanced_audio_onetrack(0, 1); - let out = select_audio_bytes(&live_only, AudioEgress::Track(1), false) - .expect("must fall back to the live track, not go silent"); - assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); } /// OBS sends its live track as plain AAC and the VOD / clean track as a @@ -2842,96 +2848,57 @@ mod tests { assert!(select_audio_bytes(&clean, AudioEgress::Track(0), true).is_none()); } + /// One track out of a multitrack audio tag, in every layout, as legacy AAC. #[test] - fn select_audio_bytes_passes_multitrack_through_for_twitch() { - // Twitch destination (Passthrough): every track's bytes flow - // through bit-faithfully so Twitch's VOD-audio slot keeps its feed. - for track in 0u8..=3 { - let tag = enhanced_audio_onetrack(track, 1); - let out = select_audio_bytes(&tag, AudioEgress::Passthrough, true) - .unwrap_or_else(|| panic!("twitch must forward track {track}")); - assert_eq!(out.as_ref(), tag.as_slice()); + fn multitrack_audio_flattens_to_plain_aac() { + // flatten multitrack audio maps seq and frame packet types + { + // SequenceStart (nested 0) -> AACPacketType 0; CodedFrames (1) -> 1. + let seq = enhanced_audio_onetrack(1, 0); + assert_eq!( + flatten_multitrack_audio(&seq, 1).unwrap(), + vec![0xAF, 0x00, 0x12, 0x10, 0x56, 0xe5], + ); + let frame = enhanced_audio_onetrack(1, 1); + assert_eq!( + flatten_multitrack_audio(&frame, 1).unwrap(), + vec![0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5], + ); } - } - - #[test] - fn select_audio_bytes_drops_untargeted_track_for_single_track_dest() { - // A single-track destination pinned to Track(0) drops every other - // OneTrack rung (e.g. the VOD track), which is meaningless to - // YouTube / Kick. - for track in 1u8..=3 { - let tag = enhanced_audio_onetrack(track, 1); + // flatten multitrack audio missing track returns none + { + let tag = enhanced_audio_onetrack(0, 1); assert!( - select_audio_bytes(&tag, AudioEgress::Track(0), true).is_none(), - "TrackId {track} must be dropped when the dest wants Track(0)", + flatten_multitrack_audio(&tag, 2).is_none(), + "a track not present in the tag must return None (drop)", ); } - } - - #[test] - fn select_audio_bytes_flattens_target_track_to_legacy_aac() { - // The clean/second audio track routed to a single-track dest is - // flattened to legacy AAC (0xAF ...), the universally-accepted form. - let live = enhanced_audio_onetrack(0, 1); - let out = select_audio_bytes(&live, AudioEgress::Track(0), true) - .expect("track 0 must reach a Track(0) destination"); - assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); - - let clean = enhanced_audio_onetrack(1, 1); - let out = select_audio_bytes(&clean, AudioEgress::Track(1), true) - .expect("track 1 must reach a Track(1) destination"); - assert_eq!(out.as_ref(), &[0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5]); - } - - #[test] - fn flatten_multitrack_audio_maps_seq_and_frame_packet_types() { - // SequenceStart (nested 0) -> AACPacketType 0; CodedFrames (1) -> 1. - let seq = enhanced_audio_onetrack(1, 0); - assert_eq!( - flatten_multitrack_audio(&seq, 1).unwrap(), - vec![0xAF, 0x00, 0x12, 0x10, 0x56, 0xe5], - ); - let frame = enhanced_audio_onetrack(1, 1); - assert_eq!( - flatten_multitrack_audio(&frame, 1).unwrap(), - vec![0xAF, 0x01, 0x12, 0x10, 0x56, 0xe5], - ); - } - - #[test] - fn flatten_multitrack_audio_missing_track_returns_none() { - let tag = enhanced_audio_onetrack(0, 1); - assert!( - flatten_multitrack_audio(&tag, 2).is_none(), - "a track not present in the tag must return None (drop)", - ); - } + // flatten multitrack audio many tracks layouts + { + // ManyTracks (mt_type 1): [0x95][0x1X][FourCC] then repeated + // [TrackId][Size(3 BE)][payload]. Two AAC tracks; pick track 1. + let mut many = vec![0x95, 0x11, b'm', b'p', b'4', b'a']; + for (tid, data) in [(0u8, [0xAAu8, 0xBB]), (1u8, [0xCC, 0xDD])] { + many.push(tid); + many.extend_from_slice(&[0, 0, data.len() as u8]); + many.extend_from_slice(&data); + } + assert_eq!( + flatten_multitrack_audio(&many, 1).unwrap(), + vec![0xAF, 0x01, 0xCC, 0xDD], + ); - #[test] - fn flatten_multitrack_audio_many_tracks_layouts() { - // ManyTracks (mt_type 1): [0x95][0x1X][FourCC] then repeated - // [TrackId][Size(3 BE)][payload]. Two AAC tracks; pick track 1. - let mut many = vec![0x95, 0x11, b'm', b'p', b'4', b'a']; - for (tid, data) in [(0u8, [0xAAu8, 0xBB]), (1u8, [0xCC, 0xDD])] { - many.push(tid); - many.extend_from_slice(&[0, 0, data.len() as u8]); - many.extend_from_slice(&data); + // ManyTracksManyCodecs (mt_type 2): repeated + // [TrackId][FourCC][Size(3 BE)][payload]. Track 1 is Opus -> + // Enhanced single-track output (0x90 | nested, FourCC, payload). + let mut mtmc = vec![0x95, 0x21]; + mtmc.extend_from_slice(&[0, b'm', b'p', b'4', b'a', 0, 0, 2, 0x11, 0x22]); + mtmc.extend_from_slice(&[1, b'O', b'p', b'u', b's', 0, 0, 2, 0x33, 0x44]); + assert_eq!( + flatten_multitrack_audio(&mtmc, 1).unwrap(), + vec![0x91, b'O', b'p', b'u', b's', 0x33, 0x44], + ); } - assert_eq!( - flatten_multitrack_audio(&many, 1).unwrap(), - vec![0xAF, 0x01, 0xCC, 0xDD], - ); - - // ManyTracksManyCodecs (mt_type 2): repeated - // [TrackId][FourCC][Size(3 BE)][payload]. Track 1 is Opus -> - // Enhanced single-track output (0x90 | nested, FourCC, payload). - let mut mtmc = vec![0x95, 0x21]; - mtmc.extend_from_slice(&[0, b'm', b'p', b'4', b'a', 0, 0, 2, 0x11, 0x22]); - mtmc.extend_from_slice(&[1, b'O', b'p', b'u', b's', 0, 0, 2, 0x33, 0x44]); - assert_eq!( - flatten_multitrack_audio(&mtmc, 1).unwrap(), - vec![0x91, b'O', b'p', b'u', b's', 0x33, 0x44], - ); } /// Enhanced RTMP: only coded frames carry a picture. A SequenceEnd diff --git a/src/instance_lock.rs b/src/instance_lock.rs new file mode 100644 index 0000000..ca22554 --- /dev/null +++ b/src/instance_lock.rs @@ -0,0 +1,372 @@ +//! One running copy per setup. +//! +//! Two InstantClones side by side are fine as long as each has its own +//! folder: its own settings, ports and buffer. Two copies of the SAME setup +//! are never fine. They would fight over the ports, write the same buffer +//! file, and the second one's quit would delete the first one's buffer from +//! under a live stream. The usual way to get there is innocent: double-click +//! the exe while it already sits in the tray. +//! +//! The guard is an OS lock on a file beside the settings, taken before +//! anything else is read or written and held for the life of the process. +//! The OS drops it when the process ends, however it ends, so a crash or a +//! power cut never leaves a stale lock the way a PID file would. +//! +//! Copies side by side also need telling apart at a glance, so each running +//! copy shows its tray icon in its own colour (see `show_tray_color`). + +use std::fs::{File, OpenOptions, TryLockError}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// Holds the lock until dropped, which in practice is process exit. +pub struct InstanceLock { + _file: File, +} + +pub enum Acquired { + /// This process now owns the setup. + Locked(InstanceLock), + /// Another running InstantClone owns it. + HeldElsewhere, + /// The lock file could not be opened (a read-only folder, say). Nothing + /// is known either way; the caller carries on as before this existed. + Unavailable(io::Error), +} + +/// `.lock`, beside the settings file it guards. Named after +/// the settings file rather than fixed, so two settings files in one folder +/// (picked with CONFIG_PATH) are two setups. +pub fn lock_path(config_path: &Path) -> PathBuf { + let mut name = config_path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_else(|| "instantclone.config.json".into()); + name.push(".lock"); + config_path.with_file_name(name) +} + +/// Take the lock for the setup at `config_path`. `wait` is how long to keep +/// trying while another process holds it: zero for a normal launch, a few +/// seconds on a restart, where the outgoing process still holds it until it +/// has finished exiting. +pub fn acquire(config_path: &Path, wait: Duration) -> Acquired { + let file = match open(&lock_path(config_path)) { + Ok(file) => file, + Err(e) => return Acquired::Unavailable(e), + }; + let deadline = Instant::now() + wait; + loop { + match file.try_lock() { + Ok(()) => return Acquired::Locked(InstanceLock { _file: file }), + Err(TryLockError::WouldBlock) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(150)); + } + Err(TryLockError::WouldBlock) => return Acquired::HeldElsewhere, + Err(TryLockError::Error(e)) => return Acquired::Unavailable(e), + } + } +} + +/// Open (creating if needed) without truncating. The file is never deleted: +/// removing it while another process waits on it would let a third one lock +/// a fresh file beside the deleted one, and two copies would run. +fn open(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + // Hidden on Windows: it is an implementation detail sitting next to the + // exe the user double-clicks. Only applies when the file is created. + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + options.attributes(FILE_ATTRIBUTE_HIDDEN); + } + options.open(path) +} + +/// The colours the tray icon comes in, by name and RGB. The first, the brand +/// blue, is every setup's until another is picked in System settings. +pub const TRAY_COLORS: [(&str, [u8; 3]); 5] = [ + ("Blue", [0x5a, 0xc8, 0xfa]), + ("Purple", [0xbf, 0x5a, 0xf2]), + ("Orange", [0xff, 0x9f, 0x0a]), + ("Green", [0x30, 0xd1, 0x58]), + ("Pink", [0xff, 0x37, 0x5f]), +]; + +/// `#rrggbb` for a tray colour, for the dashboard's tab icon. An index out of +/// range reads as the brand colour. +pub fn tray_color_hex(index: u8) -> String { + let (_, [r, g, b]) = TRAY_COLORS + .get(index as usize) + .copied() + .unwrap_or(TRAY_COLORS[0]); + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// Something this copy holds until dropped, which in practice is process +/// exit: a tray colour, or a port it is about to listen on. On Windows a +/// named mutex, which the OS removes with the last handle, so a crash never +/// strands one. Elsewhere there is no tray, and the port check that follows +/// a claim is all there is. +pub struct SessionClaim { + #[cfg(windows)] + handle: windows_sys::Win32::Foundation::HANDLE, +} + +// SAFETY: the handle is only ever closed, and Windows lets any thread close +// a handle its process owns. +#[cfg(windows)] +unsafe impl Send for SessionClaim {} + +#[cfg(windows)] +impl Drop for SessionClaim { + fn drop(&mut self) { + unsafe { windows_sys::Win32::Foundation::CloseHandle(self.handle) }; + } +} + +/// The tray colour this copy shows, and its hold on it while it runs. +static TRAY_COLOR: crate::sync::Mutex<(u8, Option)> = + crate::sync::Mutex::new((0, None)); + +/// Show the tray colour picked in System settings (`chosen`), and return the +/// colour shown. That is another one, for this run only, while a different +/// running copy already shows the chosen one: two icons side by side never +/// match, and the usual case is a folder copied with its settings. `wait` +/// covers a restart, where the outgoing process can hold its colour a moment +/// after it lets go of the setup lock. +pub fn show_tray_color(chosen: u8, wait: Duration) -> u8 { + let mut shown = TRAY_COLOR.lock(); + // Let go first, so picking the colour already shown finds it free. + shown.1 = None; + #[cfg(windows)] + { + *shown = claim_color_in(r"Local\InstantClone.TrayColor", chosen, wait); + } + // No tray here: the colour is only the dashboard's tab icon. + #[cfg(not(windows))] + { + let _ = wait; + shown.0 = chosen; + } + shown.0 +} + +/// The tray colour this copy shows right now (see `show_tray_color`). +pub fn shown_tray_color() -> u8 { + TRAY_COLOR.lock().0 +} + +/// `preferred` when no other running copy shows it, otherwise the first +/// colour none of them shows. With every colour taken, the preferred one is +/// shown unclaimed. +#[cfg(windows)] +fn claim_color_in(prefix: &str, preferred: u8, wait: Duration) -> (u8, Option) { + let count = TRAY_COLORS.len() as u8; + let preferred = if preferred < count { preferred } else { 0 }; + if let Some(claim) = claim_waiting(prefix, preferred.into(), wait) { + return (preferred, Some(claim)); + } + for color in (0..count).filter(|&c| c != preferred) { + if let Some(claim) = try_claim(prefix, color.into()) { + return (color, Some(claim)); + } + } + (preferred, None) +} + +/// Reserve a port this copy is about to listen on, or None when another +/// InstantClone has. Two copies started at the same moment (both set to +/// start with Windows, say) would otherwise both find a shared port free, +/// since neither is listening yet, and the slower one would be left with a +/// port it can never bind. The reservation settles it: one copy gets it, the +/// other gets the usual port-conflict choice. Machine-wide, like ports. +/// `wait` covers a restart, as for the tray colour. +pub fn reserve_port(port: u16, wait: Duration) -> Option { + #[cfg(windows)] + { + claim_waiting(r"Global\InstantClone.Port", port.into(), wait) + } + #[cfg(not(windows))] + { + let _ = (port, wait); + Some(SessionClaim {}) + } +} + +/// `try_claim`, trying again until `wait` is up. +#[cfg(windows)] +fn claim_waiting(prefix: &str, item: u32, wait: Duration) -> Option { + let deadline = Instant::now() + wait; + loop { + if let Some(claim) = try_claim(prefix, item) { + return Some(claim); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Create the item's named mutex, or None when another process already has +/// it (or, under `Global\`, another user's process: then opening it is +/// refused, which counts as taken too). `Local\` names are this Windows +/// session's own, so another user signed in on the PC never decides this +/// user's tray colours. +#[cfg(windows)] +fn try_claim(prefix: &str, item: u32) -> Option { + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS}; + use windows_sys::Win32::System::Threading::CreateMutexW; + let name: Vec = format!("{prefix}.{item}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; + if handle.is_null() { + return None; + } + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + unsafe { CloseHandle(handle) }; + return None; + } + Some(SessionClaim { handle }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_config(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ic-instance-lock-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("instantclone.config.json") + } + + #[test] + fn the_lock_sits_beside_the_settings_file_it_guards() { + assert_eq!( + lock_path(Path::new("C:/Streams/Main/instantclone.config.json")), + PathBuf::from("C:/Streams/Main/instantclone.config.json.lock") + ); + assert_eq!( + lock_path(Path::new("./other.json")), + PathBuf::from("./other.json.lock") + ); + } + + /// The same setup can't be taken twice while the first holder lives, + /// and is free again the moment it is gone. + #[test] + fn a_second_copy_of_the_same_setup_is_refused_until_the_first_ends() { + let config = scratch_config("same"); + let Acquired::Locked(first) = acquire(&config, Duration::ZERO) else { + panic!("the first copy gets the lock"); + }; + assert!(matches!( + acquire(&config, Duration::ZERO), + Acquired::HeldElsewhere + )); + drop(first); + assert!(matches!( + acquire(&config, Duration::ZERO), + Acquired::Locked(_) + )); + let _ = std::fs::remove_dir_all(config.parent().unwrap()); + } + + /// A restart waits for the outgoing process instead of giving up. + #[test] + fn a_restart_waits_for_the_outgoing_copy_to_let_go() { + let config = scratch_config("restart"); + let Acquired::Locked(outgoing) = acquire(&config, Duration::ZERO) else { + panic!("the outgoing copy holds the lock"); + }; + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(outgoing); + }); + assert!(matches!( + acquire(&config, Duration::from_secs(5)), + Acquired::Locked(_) + )); + release.join().unwrap(); + let _ = std::fs::remove_dir_all(config.parent().unwrap()); + } + + /// A setup keeps its colour while nobody else shows it; a copy wanting + /// a colour that is taken gets a free one instead, and every colour is + /// free again once its holder is gone. + #[cfg(windows)] + #[test] + fn copies_side_by_side_never_share_a_tray_colour() { + let prefix = format!(r"Local\InstantCloneTest.TrayColor.{}", std::process::id()); + let (first, held_first) = claim_color_in(&prefix, 2, Duration::ZERO); + assert_eq!(first, 2, "the colour it had last time"); + let (second, held_second) = claim_color_in(&prefix, 2, Duration::ZERO); + assert_eq!(second, 0, "a copied setup takes the first free colour"); + assert!(held_first.is_some() && held_second.is_some()); + + drop(held_first); + let (again, _held) = claim_color_in(&prefix, 2, Duration::ZERO); + assert_eq!(again, 2, "free again once its holder is gone"); + + let (out_of_range, _held) = claim_color_in(&prefix, 99, Duration::ZERO); + assert_eq!(out_of_range, 1, "a bad value reads as cyan, which is taken"); + drop(held_second); + } + + /// A port reserved by one copy is refused to the next until the first + /// lets go of it. In a private namespace, so a real copy running on this + /// PC can't collide with the test. + #[cfg(windows)] + #[test] + fn a_reserved_port_is_refused_to_a_second_copy() { + let prefix = format!(r"Local\InstantCloneTest.Port.{}", std::process::id()); + let port = 7799; + { + let _first = claim_waiting(&prefix, port, Duration::ZERO).expect("free"); + assert!( + claim_waiting(&prefix, port, Duration::ZERO).is_none(), + "taken" + ); + } + assert!( + claim_waiting(&prefix, port, Duration::ZERO).is_some(), + "free again" + ); + } + + /// The tab icon's colour matches the tray's. + #[test] + fn tray_colours_read_as_hex() { + assert_eq!(tray_color_hex(0), "#5ac8fa"); + assert_eq!(tray_color_hex(1), "#bf5af2"); + assert_eq!(tray_color_hex(200), "#5ac8fa"); + } + + /// Different settings files are different setups, even in one folder. + #[test] + fn two_setups_lock_independently() { + let main = scratch_config("two"); + let other = main.with_file_name("second.config.json"); + let Acquired::Locked(first) = acquire(&main, Duration::ZERO) else { + panic!("the first setup locks"); + }; + assert!(matches!( + acquire(&other, Duration::ZERO), + Acquired::Locked(_) + )); + drop(first); + let _ = std::fs::remove_dir_all(main.parent().unwrap()); + } +} diff --git a/src/integrations/alert_media.rs b/src/integrations/alert_media.rs new file mode 100644 index 0000000..ab87b2e --- /dev/null +++ b/src/integrations/alert_media.rs @@ -0,0 +1,256 @@ +//! Sounds and pictures uploaded for alerts, kept in `alerts-media` next to +//! the config and served to the alerts page at `/alerts/media/`. +//! +//! A name is checked against a strict character set and a short list of +//! extensions, so it can never leave the folder, and each file is served +//! with the type its extension says (never sniffed): an upload can only +//! ever be played as a sound or shown as a picture. + +use std::path::{Path, PathBuf}; + +pub const DIR: &str = "alerts-media"; +pub const MAX_BYTES: usize = 10 * 1024 * 1024; +const MAX_FILES: usize = 200; +const MAX_NAME: usize = 64; +/// How a step or the page's look names an upload: `media:ding.mp3`. +pub const REF_PREFIX: &str = "media:"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Sound, + Picture, +} + +impl Kind { + pub fn id(self) -> &'static str { + match self { + Kind::Sound => "sound", + Kind::Picture => "picture", + } + } +} + +/// Extension, kind, and the content type it is served with. +const TYPES: &[(&str, Kind, &str)] = &[ + ("mp3", Kind::Sound, "audio/mpeg"), + ("ogg", Kind::Sound, "audio/ogg"), + ("wav", Kind::Sound, "audio/wav"), + ("m4a", Kind::Sound, "audio/mp4"), + ("webm", Kind::Sound, "audio/webm"), + ("png", Kind::Picture, "image/png"), + ("jpg", Kind::Picture, "image/jpeg"), + ("jpeg", Kind::Picture, "image/jpeg"), + ("gif", Kind::Picture, "image/gif"), + ("webp", Kind::Picture, "image/webp"), +]; + +pub struct Media { + pub name: String, + pub kind: Kind, + pub bytes: u64, +} + +/// Where uploads live: beside the config, like the buffer. +pub fn folder(cfg_path: &Path) -> PathBuf { + crate::config::beside_config(cfg_path, Path::new(DIR)) +} + +/// The kind and content type of a stored file's name, or `None` when the +/// name isn't one this folder could hold. +pub fn type_of(name: &str) -> Option<(Kind, &'static str)> { + let safe = !name.is_empty() + && name.len() <= MAX_NAME + && !name.starts_with('.') + && !name.contains("..") + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)) + && !is_device_name(name.split('.').next().unwrap_or("")); + if !safe { + return None; + } + let (_, ext) = name.rsplit_once('.')?; + TYPES + .iter() + .find(|(e, ..)| *e == ext) + .map(|(_, kind, ctype)| (*kind, *ctype)) +} + +/// A name Windows keeps for a device (`con`, `nul`, `com1`...), whatever +/// follows its first dot: a file saved as `con.mp3` would go to the device. +fn is_device_name(stem: &str) -> bool { + let numbered = |prefix: &str| { + stem.strip_prefix(prefix) + .is_some_and(|n| n.len() == 1 && matches!(n.as_bytes()[0], b'1'..=b'9')) + }; + matches!(stem, "con" | "prn" | "aux" | "nul") || numbered("com") || numbered("lpt") +} + +/// `media:` naming an upload of this kind. +pub fn is_ref(text: &str, kind: Kind) -> bool { + text.strip_prefix(REF_PREFIX) + .and_then(type_of) + .is_some_and(|(k, _)| k == kind) +} + +/// The name an upload is stored under: lower case, spaces as dashes, +/// anything else dropped. `None` when it isn't a type we serve. +pub fn clean_name(original: &str) -> Option { + let base = original + .rsplit(['/', '\\']) + .next() + .unwrap_or("") + .to_ascii_lowercase(); + let (stem, ext) = base.rsplit_once('.')?; + let stem: String = stem + .chars() + .map(|c| if c.is_whitespace() { '-' } else { c }) + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .take(MAX_NAME - 10) + .collect(); + let stem = stem.trim_matches(['-', '_']); + let name = match stem { + "" => format!("file.{ext}"), + device if is_device_name(device) => format!("{device}-file.{ext}"), + _ => format!("{stem}.{ext}"), + }; + type_of(&name).map(|_| name) +} + +/// Every upload, by name. +pub fn list(folder: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(folder) else { + return Vec::new(); + }; + let mut out: Vec = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let (kind, _) = type_of(&name)?; + let bytes = e.metadata().ok().filter(|m| m.is_file())?.len(); + Some(Media { name, kind, bytes }) + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// Keep an upload; returns the name it was saved under (with a number +/// added when that name is taken). +pub fn save(folder: &Path, original: &str, data: &[u8]) -> Result { + let name = clean_name(original) + .ok_or("use an MP3, OGG, WAV, M4A or WEBM sound, or a PNG, JPG, GIF or WEBP picture")?; + if data.is_empty() { + return Err("that file is empty".to_string()); + } + if data.len() > MAX_BYTES { + return Err("that file is over 10 MB".to_string()); + } + if list(folder).len() >= MAX_FILES { + return Err(format!( + "there are already {MAX_FILES} uploads: delete some first" + )); + } + std::fs::create_dir_all(folder).map_err(|e| format!("couldn't make the folder: {e}"))?; + let name = free_name(folder, &name); + // Written aside, then moved in: the page never plays half a file. + let part = folder.join(format!(".{name}.part")); + std::fs::write(&part, data).map_err(|e| format!("couldn't save it: {e}"))?; + std::fs::rename(&part, folder.join(&name)).map_err(|e| { + let _ = std::fs::remove_file(&part); + format!("couldn't save it: {e}") + })?; + Ok(name) +} + +fn free_name(folder: &Path, name: &str) -> String { + if !folder.join(name).exists() { + return name.to_string(); + } + let (stem, ext) = name.rsplit_once('.').unwrap_or((name, "")); + (2..) + .map(|n| format!("{stem}-{n}.{ext}")) + .find(|candidate| !folder.join(candidate).exists()) + .unwrap_or_else(|| name.to_string()) +} + +pub fn delete(folder: &Path, name: &str) -> Result<(), String> { + type_of(name).ok_or("there's no such upload")?; + std::fs::remove_file(folder.join(name)).map_err(|e| format!("couldn't delete it: {e}")) +} + +/// Where an upload is on disk and its content type, for serving. +pub fn locate(folder: &Path, name: &str) -> Option<(PathBuf, &'static str)> { + let (_, ctype) = type_of(name)?; + Some((folder.join(name), ctype)) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Scratch(PathBuf); + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn names_can_never_leave_the_folder() { + for bad in [ + "../config.mp3", + "..\\x.mp3", + "a/b.mp3", + ".hidden.mp3", + "page.html", + "pic.svg", + "Ding.mp3", + "", + "con.mp3", + "nul.tar.png", + "com1.wav", + ] { + assert!(type_of(bad).is_none(), "{bad}"); + } + assert_eq!(type_of("ding-2.mp3").map(|t| t.1), Some("audio/mpeg")); + assert_eq!(type_of("party.gif").map(|t| t.0), Some(Kind::Picture)); + assert!(is_ref("media:party.gif", Kind::Picture)); + assert!(!is_ref("media:party.gif", Kind::Sound)); + assert!(!is_ref("media:../x.mp3", Kind::Sound)); + } + + #[test] + fn uploaded_names_are_cleaned() { + assert_eq!( + clean_name("C:\\Users\\me\\My Follow Sound!.MP3").as_deref(), + Some("my-follow-sound.mp3") + ); + assert_eq!(clean_name("???.png").as_deref(), Some("file.png")); + // Windows device names can't be files: they get a suffix. + assert_eq!(clean_name("CON.mp3").as_deref(), Some("con-file.mp3")); + assert_eq!(clean_name("lpt9.gif").as_deref(), Some("lpt9-file.gif")); + assert_eq!(clean_name("console.mp3").as_deref(), Some("console.mp3")); + assert_eq!(clean_name("evil.html"), None); + assert_eq!(clean_name("noext"), None); + } + + #[test] + fn uploads_are_kept_listed_and_deleted() { + let dir = Scratch( + std::env::temp_dir().join(format!("ic-media-{}", &crate::crypto::random_token()[..8])), + ); + let first = save(&dir.0, "Ding.mp3", b"ID3data").unwrap(); + let second = save(&dir.0, "ding.mp3", b"ID3more").unwrap(); + assert_eq!( + (first.as_str(), second.as_str()), + ("ding.mp3", "ding-2.mp3") + ); + assert!(save(&dir.0, "empty.png", b"").is_err()); + let names: Vec = list(&dir.0).into_iter().map(|m| m.name).collect(); + assert_eq!(names, ["ding-2.mp3", "ding.mp3"]); + delete(&dir.0, "ding.mp3").unwrap(); + assert_eq!(list(&dir.0).len(), 1); + assert!(delete(&dir.0, "../ding-2.mp3").is_err()); + } +} diff --git a/src/integrations/alerts.rs b/src/integrations/alerts.rs index 49801d0..a705a19 100644 --- a/src/integrations/alerts.rs +++ b/src/integrations/alerts.rs @@ -5,12 +5,24 @@ //! id once, in order, so two alerts close together queue instead of //! overlapping. Only what a step chose to show is here: it is on stream //! anyway, which is why the page needs no login. +//! +//! How every card looks is the page's theme (`Look`): a ready-made style +//! with its own sound, then any of its parts changed, down to custom CSS. +//! It is sent along with the alerts, so a change shows in OBS at once. +//! Each alert can still bring its own accent color, sound and picture. +use super::alert_media::{self, Kind}; use crate::json::{self, Value}; use crate::sync::Mutex; use std::collections::VecDeque; +use std::sync::OnceLock; use std::time::{Duration, Instant}; +/// The alerts browser source itself. +const PAGE: &str = include_str!("../../web/alerts.html"); +/// Stands in the page for its own fingerprint (`page_id`). +const PAGE_ID_MARK: &str = "__ALERTS_PAGE_ID__"; + /// Alerts kept for a page that (re)connects. Older ones are never replayed. const KEEP: usize = 8; /// How long an alert stays on the board: a page that reconnects after this @@ -20,12 +32,277 @@ pub const MIN_SECONDS: u64 = 2; pub const MAX_SECONDS: u64 = 30; const MAX_TITLE: usize = 80; const MAX_TEXT: usize = 300; +const MAX_LINK: usize = 500; +const MAX_CSS: usize = 20_000; +pub const DEFAULT_VOLUME: u8 = 70; +/// An alert's sound that plays the theme's. +pub const THEME_SOUND: &str = "theme"; +/// The sounds the alerts page makes itself, with no file to find. +pub const SOUNDS: &[&str] = &[ + "chime", "pop", "bell", "rise", "coin", "sparkle", "whoosh", "fanfare", "zap", "blip", +]; +/// The ready-made looks (web/alerts.html draws them). +pub const THEMES: &[&str] = &[ + "island", "signal", "banner", "bubble", "minimal", "paper", "cinema", +]; +/// Fonts the page can load (from Google Fonts), or `system`. +pub const FONTS: &[&str] = &[ + "system", + "Inter", + "Poppins", + "Montserrat", + "Fredoka", + "Rubik", + "Bebas Neue", + "Oswald", + "Bangers", + "Orbitron", + "Press Start 2P", + "Permanent Marker", + "Roboto Mono", + "Chakra Petch", + "Playfair Display", + "Special Elite", +]; +pub const ANIMATIONS: &[&str] = &["slide", "pop", "fade", "zoom", "bounce", "drop"]; + +/// One alert as a "Show on stream" step (or the dashboard's preview) +/// puts it up. +#[derive(Clone, Debug, PartialEq)] +pub struct Card { + pub title: String, + pub text: String, + pub seconds: u64, + /// `#rrggbb`, or empty for the theme's accent. + pub color: String, + /// `theme`, one of `SOUNDS`, an upload (`media:...`), a web link to a + /// sound file, or empty for none. + pub sound: String, + /// 0 to 100. + pub volume: u8, + /// An upload (`media:...`) or a web link to a picture or GIF, or empty. + pub image: String, +} + +impl Card { + /// A card with just words, in the theme's colors, silent. + pub fn plain(title: &str, text: &str, seconds: u64) -> Card { + Card { + title: title.to_string(), + text: text.to_string(), + seconds, + color: String::new(), + sound: String::new(), + volume: DEFAULT_VOLUME, + image: String::new(), + } + } + + /// What the page can safely show: lengths capped, one line each, and a + /// color, sound or picture that isn't valid left out. + fn cleaned(self) -> Card { + Card { + title: one_line(&self.title, MAX_TITLE), + text: one_line(&self.text, MAX_TEXT), + seconds: self.seconds.clamp(MIN_SECONDS, MAX_SECONDS), + color: color_or_empty(&self.color), + sound: if is_sound(&self.sound) { + self.sound + } else { + String::new() + }, + volume: self.volume.min(100), + image: if is_picture(&self.image) { + self.image + } else { + String::new() + }, + } + } +} + +/// A fingerprint of the page this build serves. The page carries its own +/// and the feed says the current one: OBS keeps a browser source running +/// through an app update, and a page that sees they differ reloads itself +/// instead of drawing new looks with old code. +pub fn page_id() -> &'static str { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| { + // FNV-1a: stable across builds of the same page, no dependency. + let hash = PAGE.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) + }); + format!("{hash:016x}") + }) +} + +/// The page as served, carrying its fingerprint. +pub fn page_html() -> String { + PAGE.replace(PAGE_ID_MARK, page_id()) +} + +/// `#rrggbb`. +pub fn is_color(text: &str) -> bool { + text.len() == 7 && text.starts_with('#') && text[1..].bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn color_or_empty(text: &str) -> String { + if is_color(text) { + text.to_ascii_lowercase() + } else { + String::new() + } +} + +/// A plain `http(s)://` link the page can put in a `src` as it is. +pub fn is_web_link(text: &str) -> bool { + let rest = text + .strip_prefix("https://") + .or_else(|| text.strip_prefix("http://")); + rest.is_some_and(|rest| { + !rest.is_empty() + && text.len() <= MAX_LINK + && !text + .chars() + .any(|c| c.is_whitespace() || c.is_control() || "\"'<>\\`{}".contains(c)) + }) +} + +/// Empty (no sound), the theme's, a built-in one, an upload, or a link to +/// a sound file. +pub fn is_sound(text: &str) -> bool { + text.is_empty() + || text == THEME_SOUND + || SOUNDS.contains(&text) + || alert_media::is_ref(text, Kind::Sound) + || is_web_link(text) +} + +/// An uploaded picture or a link to one. +pub fn is_picture(text: &str) -> bool { + alert_media::is_ref(text, Kind::Picture) || is_web_link(text) +} + +/// How every card on the alerts page looks: a theme, then any part of it +/// changed. Empty strings and `None` keep the theme's own. +#[derive(Clone, Debug, PartialEq)] +pub struct Look { + /// One of `THEMES`. + pub theme: String, + /// `bottom`, `top` or `middle`. + pub position: String, + /// `center`, `left` or `right`. + pub align: String, + /// Size in percent, 50 to 200. + pub scale: u16, + /// One of `FONTS`, or empty. + pub font: String, + /// Text, card background and accent: `#rrggbb` or empty. + pub text: String, + pub background: String, + pub accent: String, + /// The card background's opacity in percent. + pub opacity: Option, + /// Corner roundness in pixels. + pub radius: Option, + /// One of `ANIMATIONS`, or empty. + pub animation: String, + /// Where an alert's picture goes: `left`, `top`, or empty. + pub picture: String, + /// The sound alerts set to "Theme sound" play: one of `SOUNDS`, an + /// upload, a link, or empty for the theme's own. + pub sound: String, + /// Extra CSS, put in the page as it is. + pub css: String, +} + +impl Default for Look { + fn default() -> Look { + Look { + theme: "island".to_string(), + position: "bottom".to_string(), + align: "center".to_string(), + scale: 100, + font: String::new(), + text: String::new(), + background: String::new(), + accent: String::new(), + opacity: None, + radius: None, + animation: String::new(), + picture: String::new(), + sound: String::new(), + css: String::new(), + } + } +} + +impl Look { + /// Read a saved or sent look; anything unknown keeps the theme's. + pub fn from_json(v: &Value) -> Look { + let d = Look::default(); + let pick = |name: &str, allowed: &[&str], fallback: String| { + let value = v.str_or(name, ""); + if allowed.contains(&value) { + value.to_string() + } else { + fallback + } + }; + let number = |name: &str, max: f64| { + v.get(name) + .and_then(Value::as_f64) + .filter(|n| n.is_finite()) + .map(|n| n.clamp(0.0, max).round()) + }; + let sound = v.str_or("sound", ""); + Look { + theme: pick("theme", THEMES, d.theme), + position: pick("position", &["bottom", "top", "middle"], d.position), + align: pick("align", &["center", "left", "right"], d.align), + scale: number("scale", 200.0).map_or(d.scale, |n| (n as u16).max(50)), + font: pick("font", FONTS, d.font), + text: color_or_empty(v.str_or("text", "")), + background: color_or_empty(v.str_or("background", "")), + accent: color_or_empty(v.str_or("accent", "")), + opacity: number("opacity", 100.0).map(|n| n as u8), + radius: number("radius", 48.0).map(|n| n as u8), + animation: pick("animation", ANIMATIONS, d.animation), + picture: pick("picture", &["left", "top"], d.picture), + // "Theme sound" here would point back at itself. + sound: if sound != THEME_SOUND && is_sound(sound) { + sound.to_string() + } else { + String::new() + }, + css: v.str_or("css", "").chars().take(MAX_CSS).collect(), + } + } + + pub fn to_json(&self) -> Value { + let maybe = |n: Option| n.map_or(Value::Null, |n| Value::Num(f64::from(n))); + json::obj([ + ("theme", json::str(&self.theme)), + ("position", json::str(&self.position)), + ("align", json::str(&self.align)), + ("scale", Value::Num(f64::from(self.scale))), + ("font", json::str(&self.font)), + ("text", json::str(&self.text)), + ("background", json::str(&self.background)), + ("accent", json::str(&self.accent)), + ("opacity", maybe(self.opacity)), + ("radius", maybe(self.radius)), + ("animation", json::str(&self.animation)), + ("picture", json::str(&self.picture)), + ("sound", json::str(&self.sound)), + ("css", json::str(&self.css)), + ]) + } +} struct Alert { id: u64, - title: String, - text: String, - ms: u64, + card: Card, at: Instant, } @@ -34,6 +311,8 @@ pub struct Board { /// restart knows the ids started over. boot: String, inner: Mutex, + /// Alerts pages open right now: (in OBS, anywhere else). + pages: Mutex<(usize, usize)>, } #[derive(Default)] @@ -42,23 +321,39 @@ struct Inner { alerts: VecDeque, } +/// Counts one open alerts page for as long as it lives. +pub struct PageOpen<'a> { + board: &'a Board, + in_obs: bool, +} + +impl Drop for PageOpen<'_> { + fn drop(&mut self) { + let mut pages = self.board.pages.lock(); + if self.in_obs { + pages.0 = pages.0.saturating_sub(1); + } else { + pages.1 = pages.1.saturating_sub(1); + } + } +} + impl Board { pub fn new() -> Board { Board { boot: crate::crypto::random_token()[..8].to_string(), inner: Mutex::new(Inner::default()), + pages: Mutex::new((0, 0)), } } - /// Put an alert up for `seconds` (clamped to what reads well). - pub fn show(&self, title: &str, text: &str, seconds: u64) { + /// Put an alert up (cleaned of anything the page can't show). + pub fn show(&self, card: Card) { let mut inner = self.inner.lock(); inner.last_id += 1; let alert = Alert { id: inner.last_id, - title: one_line(title, MAX_TITLE), - text: one_line(text, MAX_TEXT), - ms: seconds.clamp(MIN_SECONDS, MAX_SECONDS) * 1000, + card: card.cleaned(), at: Instant::now(), }; if inner.alerts.len() >= KEEP { @@ -67,29 +362,56 @@ impl Board { inner.alerts.push_back(alert); } - /// The fresh alerts, oldest first. Unchanged between alerts, so the - /// page's event stream only sends when something new is up. - pub fn to_json(&self) -> String { + /// An alerts page connected; it counts until the guard is dropped. + pub fn page_opened(&self, in_obs: bool) -> PageOpen<'_> { + let mut pages = self.pages.lock(); + if in_obs { + pages.0 += 1; + } else { + pages.1 += 1; + } + PageOpen { + board: self, + in_obs, + } + } + + /// How many alerts pages are open, in OBS and anywhere else. + pub fn pages_open(&self) -> (usize, usize) { + *self.pages.lock() + } + + /// The fresh alerts, oldest first, and the page's look. Unchanged + /// between alerts, so the page's event stream only sends when + /// something new is up. + pub fn to_json(&self, look: &Look) -> String { let mut inner = self.inner.lock(); inner.alerts.retain(|a| a.at.elapsed() < FRESH); - let alerts = Value::Arr( - inner - .alerts - .iter() - .map(|a| { - json::obj([ - ("id", Value::Num(a.id as f64)), - ("title", json::str(&a.title)), - ("text", json::str(&a.text)), - ("ms", Value::Num(a.ms as f64)), - ]) - }) - .collect(), - ); - json::obj([("boot", json::str(&self.boot)), ("alerts", alerts)]).to_json() + let alerts = Value::Arr(inner.alerts.iter().map(alert_json).collect()); + json::obj([ + ("boot", json::str(&self.boot)), + ("page", json::str(page_id())), + ("look", look.to_json()), + ("alerts", alerts), + ]) + .to_json() } } +fn alert_json(a: &Alert) -> Value { + let c = &a.card; + json::obj([ + ("id", Value::Num(a.id as f64)), + ("title", json::str(&c.title)), + ("text", json::str(&c.text)), + ("ms", Value::Num((c.seconds * 1000) as f64)), + ("color", json::str(&c.color)), + ("sound", json::str(&c.sound)), + ("volume", Value::Num(f64::from(c.volume))), + ("image", json::str(&c.image)), + ]) +} + fn one_line(text: &str, max: usize) -> String { let flat: String = text .chars() @@ -103,14 +425,22 @@ fn one_line(text: &str, max: usize) -> String { mod tests { use super::*; + fn alerts_of(board: &Board) -> Vec { + let doc = json::parse(&board.to_json(&Look::default())).unwrap(); + doc.get("alerts") + .and_then(Value::as_array) + .unwrap() + .to_vec() + } + #[test] fn alerts_queue_with_rising_ids_and_sane_lengths() { let board = Board::new(); - board.show("Clip!", "by Ana\r\nnice", 1); - board.show("", &"x".repeat(900), 99); - let doc = json::parse(&board.to_json()).unwrap(); + board.show(Card::plain("Clip!", "by Ana\r\nnice", 1)); + board.show(Card::plain("", &"x".repeat(900), 99)); + let doc = json::parse(&board.to_json(&Look::default())).unwrap(); assert_eq!(doc.str_or("boot", "").len(), 8); - let list = doc.get("alerts").and_then(Value::as_array).unwrap(); + let list = alerts_of(&board); assert_eq!(list.len(), 2); assert_eq!(list[0].str_or("text", ""), "by Ana nice"); assert_eq!(list[0].u64_or("ms", 0), MIN_SECONDS * 1000); @@ -123,11 +453,118 @@ mod tests { fn only_the_last_few_are_kept() { let board = Board::new(); for n in 0..20 { - board.show("", &n.to_string(), 5); + board.show(Card::plain("", &n.to_string(), 5)); } - let doc = json::parse(&board.to_json()).unwrap(); - let list = doc.get("alerts").and_then(Value::as_array).unwrap(); + let list = alerts_of(&board); assert_eq!(list.len(), KEEP); assert_eq!(list[0].str_or("text", ""), "12"); } + + /// The page puts the color, sound and picture straight into the card: + /// only values it can use as they are get through. + #[test] + fn a_cards_color_sound_and_picture_are_checked() { + let board = Board::new(); + let mut good = Card::plain("New follower", "Ana", 6); + good.color = "#FF00AA".to_string(); + good.sound = "media:ding.mp3".to_string(); + good.image = "media:party.gif".to_string(); + board.show(good); + let mut bad = Card::plain("", "Ana", 6); + bad.color = "red;background:url(x)".to_string(); + bad.sound = "javascript:alert(1)".to_string(); + bad.image = "https://x.example/a.gif\" onerror=\"x".to_string(); + bad.volume = 250; + board.show(bad); + let list = alerts_of(&board); + assert_eq!(list[0].str_or("color", ""), "#ff00aa"); + assert_eq!(list[0].str_or("sound", ""), "media:ding.mp3"); + assert_eq!(list[0].str_or("image", ""), "media:party.gif"); + assert_eq!( + (list[1].str_or("color", "x"), list[1].str_or("sound", "x")), + ("", "") + ); + assert_eq!(list[1].str_or("image", "x"), ""); + assert_eq!(list[1].u64_or("volume", 0), 100); + assert!(is_sound("https://cdn.example/ding.mp3")); + assert!(is_sound(THEME_SOUND)); + assert!(!is_sound("media:party.gif")); + assert!(!is_picture("media:ding.mp3")); + assert!(!is_web_link("https://")); + assert!(!is_web_link("ftp://x.example/a.mp3")); + } + + #[test] + fn a_look_keeps_only_what_the_page_can_use() { + let sent = json::parse( + r##"{"theme":"signal","position":"top","align":"sideways","scale":900, + "font":"Comic Sans","text":"#FFFFFF","background":"url(x)","opacity":40, + "radius":-5,"animation":"spin","picture":"top","sound":"theme","css":".card{}"}"##, + ) + .unwrap(); + let look = Look::from_json(&sent); + assert_eq!( + ( + look.theme.as_str(), + look.position.as_str(), + look.align.as_str() + ), + ("signal", "top", "center") + ); + assert_eq!( + (look.scale, look.opacity, look.radius), + (200, Some(40), Some(0)) + ); + assert_eq!((look.font.as_str(), look.text.as_str()), ("", "#ffffff")); + assert_eq!( + (look.background.as_str(), look.animation.as_str()), + ("", "") + ); + assert_eq!((look.picture.as_str(), look.sound.as_str()), ("top", "")); + assert_eq!(look.css, ".card{}"); + assert_eq!(Look::from_json(&Value::Null), Look::default()); + let back = Look::from_json(&look.to_json()); + assert_eq!(back, look); + } + + /// The page served and the feed agree on the fingerprint, so a page + /// only ever reloads after a real update. + #[test] + fn the_page_knows_its_own_fingerprint() { + let html = page_html(); + assert!(!html.contains(PAGE_ID_MARK)); + assert!(html.contains(&format!("'{}'", page_id()))); + let doc = json::parse(&Board::new().to_json(&Look::default())).unwrap(); + assert_eq!(doc.str_or("page", ""), page_id()); + } + + /// A theme the dashboard offers is drawn by the page, coming in and + /// going out its own way. + #[test] + fn every_theme_has_its_own_motion() { + // The theme's own rule that names its entrance, wherever it sits + // and in whatever order its properties come. + for theme in THEMES { + let head = format!("body.theme-{theme}{{"); + let motion = PAGE + .match_indices(&head) + .map(|(at, _)| &PAGE[at..at + PAGE[at..].find('}').unwrap_or(PAGE.len() - at)]) + .find(|rule| rule.contains("--theme-in:")); + let rule = motion.unwrap_or_else(|| panic!("{theme} has no entrance")); + assert!(rule.contains("--theme-out:"), "{theme} has no exit"); + } + } + + #[test] + fn open_pages_are_counted_while_they_stay() { + let board = Board::new(); + let obs = board.page_opened(true); + { + let _browser = board.page_opened(false); + assert_eq!(board.pages_open(), (1, 1)); + } + assert_eq!(board.pages_open(), (1, 0)); + drop(obs); + assert_eq!(board.pages_open(), (0, 0)); + } } diff --git a/src/integrations/api.rs b/src/integrations/api.rs index e87eab2..0b6812a 100644 --- a/src/integrations/api.rs +++ b/src/integrations/api.rs @@ -11,7 +11,7 @@ use super::model::{ valid_discord_webhook, valid_id, DiscordChannel, Integration, PhoneConnection, Roles, Trigger, MAX_INTEGRATIONS, }; -use super::{engine, presets, recipe, runner, twitch::Which}; +use super::{alert_media, alerts, engine, lights, obsws, presets, recipe, runner, twitch::Which}; use crate::config::Settings; use crate::controller::Controller; use crate::json::{self, Value}; @@ -74,11 +74,18 @@ pub async fn route( let handle = ctrl.integrations()?.clone(); let req = || json::parse(body).unwrap_or(Value::Null); let reply = match (method, path) { - ("GET", "/integrations") => ok(overview(&handle, &settings.borrow(), local_steps_allowed)), + ("GET", "/integrations") => ok(overview( + &handle, + &settings.borrow(), + cfg_path, + local_steps_allowed, + )), ("GET", "/integrations/activity") => ok(json::obj([ ("activity", handle.activity_json()), ("stats", handle.stats_json()), ("twitch", handle.twitch.status_json()), + ("obs", handle.obs_status()), + ("alerts", alerts_json(&handle, &settings.borrow(), cfg_path)), ])), ("POST", "/integrations/save") => save(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/integrations/add") => add(&req(), settings, cfg_path, local_steps_allowed), @@ -99,16 +106,39 @@ pub async fn route( None => fail("nothing to measure"), }, ("GET", "/integrations/obs") => ok(handle.obs_status()), + ("POST", "/integrations/lights/scan") => scan_lights().await, + ("POST", "/integrations/lights/try") => try_light(&req()).await, ("POST", "/integrations/fetch") => fetch(&req()).await, ("POST", "/integrations/convert") => convert(&req()), ("POST", "/integrations/alerts/test") => { - handle.alerts.show( - "InstantClone", - "This is where your alerts show up on stream.", - 6, - ); + handle.alerts.show(test_card(&req())); done() } + ("POST", "/integrations/alerts/media/upload") => upload_media(&req(), cfg_path), + ("POST", "/integrations/alerts/media/delete") => { + let folder = alert_media::folder(cfg_path); + match alert_media::delete(&folder, req().str_or("name", "")) { + Ok(()) => done(), + Err(e) => fail(e), + } + } + ("POST", "/integrations/alerts/look") => { + let look = alerts::Look::from_json(&req()); + update(settings, cfg_path, |s| { + s.alerts_look = look; + Ok(()) + }) + } + ("POST", "/integrations/alerts/add-to-obs") => { + let action = obsws::Action::BrowserSource { + name: ALERTS_SOURCE.to_string(), + url: alerts_url(settings.borrow().web_port), + }; + match handle.obs_now(action).await { + Ok(()) => done(), + Err(e) => fail(e), + } + } ("POST", "/integrations/midi/learn") => midi_learn(ctrl), ("POST", "/integrations/midi/poll") => { let captured = ctrl.midi().take_captured_for(&[MIDI_LEARN]); @@ -129,7 +159,7 @@ pub async fn route( } done() } - ("POST", "/integrations/export") => export(&req(), &settings.borrow()), + ("POST", "/integrations/export") => export(&req(), &settings.borrow(), cfg_path), ("POST", "/integrations/import") => import(&req(), settings, cfg_path, local_steps_allowed), ("POST", "/connections/discord") => save_discord(&req(), settings, cfg_path), ("POST", "/connections/discord/delete") => delete_discord(&req(), settings, cfg_path), @@ -234,7 +264,89 @@ fn update( done() } -fn overview(handle: &super::Handle, s: &Settings, local_steps_allowed: bool) -> Value { +/// What the alerts browser source is called when the dashboard adds it. +const ALERTS_SOURCE: &str = "InstantClone alerts"; + +/// The alerts page's address for OBS on this PC. +fn alerts_url(web_port: u16) -> String { + format!("http://127.0.0.1:{web_port}/alerts") +} + +/// The alerts page: its look, where it's open right now, and the sounds +/// and pictures uploaded for it. +fn alerts_json(handle: &super::Handle, s: &Settings, cfg_path: &Path) -> Value { + let (in_obs, elsewhere) = handle.alerts.pages_open(); + let media = alert_media::list(&alert_media::folder(cfg_path)) + .into_iter() + .map(|m| { + json::obj([ + ("name", json::str(&m.name)), + ("kind", json::str(m.kind.id())), + ("bytes", Value::Num(m.bytes as f64)), + ]) + }) + .collect(); + json::obj([ + ("look", s.alerts_look.to_json()), + ("in_obs", Value::Num(in_obs as f64)), + ("elsewhere", Value::Num(elsewhere as f64)), + ("url", json::str(alerts_url(s.web_port))), + ("media", Value::Arr(media)), + ]) +} + +/// Keep a sound or picture sent from the dashboard (base64url in JSON: +/// the API's bodies are text). +fn upload_media(req: &Value, cfg_path: &Path) -> Reply { + let Some(data) = recipe::base64url_decode(req.str_or("data", "")) else { + return fail("that upload arrived damaged; try again"); + }; + match alert_media::save( + &alert_media::folder(cfg_path), + req.str_or("name", ""), + &data, + ) { + Ok(name) => ok(json::obj([ + ("ok", Value::Bool(true)), + ("name", json::str(name)), + ])), + Err(e) => fail(e), + } +} + +/// The alert "Show it on stream" sends from the editor, already filled in +/// with sample values; with nothing sent, a generic one. An alert with only +/// a small title is still that alert, not the generic one. +fn test_card(req: &Value) -> alerts::Card { + let text = req.str_or("text", "").trim(); + if text.is_empty() && req.str_or("title", "").trim().is_empty() { + return alerts::Card::plain( + "InstantClone", + "This is where your alerts show up on stream.", + 6, + ); + } + alerts::Card { + title: req.str_or("title", "").to_string(), + text: text.to_string(), + seconds: req.str_or("seconds", "6").trim().parse().unwrap_or(6), + color: req.str_or("color", "").trim().to_string(), + sound: req.str_or("sound", "").trim().to_string(), + volume: req + .str_or("volume", "") + .trim() + .parse() + .unwrap_or(alerts::DEFAULT_VOLUME), + image: req.str_or("image", "").trim().to_string(), + } +} + +fn overview( + handle: &super::Handle, + s: &Settings, + cfg_path: &Path, + local_steps_allowed: bool, +) -> Value { // Previews and "Try it" use the real channel once Twitch is connected. let channel = handle.twitch.channel(); let global = Value::Arr( @@ -305,6 +417,9 @@ fn overview(handle: &super::Handle, s: &Settings, local_steps_allowed: bool) -> if let Err(e) = check_connections(i, s) { p.invalid.push(e); } + if let Some(e) = command_taken(i, s) { + p.invalid.push(e); + } ( i.id.clone(), Value::Arr(p.invalid.into_iter().map(json::str).collect()), @@ -314,6 +429,10 @@ fn overview(handle: &super::Handle, s: &Settings, local_steps_allowed: bool) -> ), ), ("twitch", handle.twitch.status_json()), + ("obs", handle.obs_status()), + ("alerts", alerts_json(handle, s, cfg_path)), + // For the "Report it" links, which fill in the issue form's version. + ("version", json::str(env!("CARGO_PKG_VERSION"))), ("twitch_shared_chat", Value::Bool(s.twitch_shared_chat)), // Markers, clips and chat act on the logged-in channel; say so when // the Twitch destinations stream somewhere else. @@ -400,13 +519,32 @@ fn save( if let Some(e) = shortcut_taken(&integration, &settings.borrow()) { return fail(e); } - // Switched on, it must be ready to run; switched off, it saves as a - // draft and the dashboard lists what is still missing. - if integration.enabled && !problems.incomplete.is_empty() { - return fail(format!( - "{}. Finish it, or switch it off to save it as a draft.", - problems.incomplete.join("; ") - )); + let was_on = settings + .borrow() + .integrations + .iter() + .any(|i| i.id == integration.id && i.enabled); + if integration.enabled { + if let Some(e) = command_taken(&integration, &settings.borrow()) { + if was_on { + return fail(e); + } + problems.incomplete.push(e); + } + } + // Switched on, it must be ready to run. One already running keeps + // running as it was rather than stop over a half-made edit; a new one + // or a draft saves as a draft, switched off, so no work is lost, and + // the dashboard lists what is still missing. + let drafted = integration.enabled && !problems.incomplete.is_empty(); + if drafted { + if was_on { + return fail(format!( + "{}. Finish it, or switch it off to save it as a draft.", + problems.incomplete.join("; ") + )); + } + integration.enabled = false; } let id = integration.id.clone(); let warnings = unknown_vars(&integration); @@ -432,6 +570,7 @@ fn save( ok(json::obj([ ("ok", Value::Bool(true)), ("id", json::str(&id)), + ("drafted", Value::Bool(drafted)), ( "warnings", Value::Arr(warnings.into_iter().map(json::str).collect()), @@ -489,6 +628,32 @@ pub fn unknown_vars(integration: &Integration) -> Vec { "" => "text".to_string(), name => name.to_string(), }), + StepKind::Obs if s.param("action") == "check" => { + let name = match s.param("save_as").trim() { + "" => "obs", + name => name, + }; + let mut fields: Vec = ["scene", "streaming", "recording"] + .map(String::from) + .to_vec(); + let sources = super::obsws::listed_names(s.param("source")); + if !sources.is_empty() { + fields.extend(["muted", "visible"].map(String::from)); + } + if sources.len() > 1 { + for key in super::obsws::source_keys(&sources) { + fields.extend([format!("{key}.muted"), format!("{key}.visible")]); + } + } + known.extend(fields.iter().map(|field| format!("{name}.{field}"))); + } + StepKind::Program if s.param("wait") == "yes" => { + let name = match s.param("save_as").trim() { + "" => "program", + name => name, + }; + known.extend(["output", "code", "ok"].map(|field| format!("{name}.{field}"))); + } _ => {} }); visit_steps(&h.steps, &mut |s| { @@ -548,6 +713,35 @@ fn shortcut_taken(integration: &Integration, s: &Settings) -> Option { }) } +/// A chat command or alias `integration` answers that another switched-on +/// integration answers too: the engine runs every match, so one `!delay` +/// would get two replies. Whether `integration` itself is on is the +/// caller's question. +fn command_taken(integration: &Integration, s: &Settings) -> Option { + fn commands(i: &Integration) -> impl Iterator { + i.handlers + .iter() + .filter(|h| h.enabled) + .filter_map(|h| match &h.trigger { + Trigger::ChatCommand { + command, aliases, .. + } => Some(std::iter::once(command).chain(aliases)), + _ => None, + }) + .flatten() + } + s.integrations + .iter() + .filter(|other| other.enabled && other.id != integration.id) + .find_map(|other| { + let word = commands(integration).find(|w| commands(other).any(|o| o == *w))?; + Some(format!( + "\"{}\" already answers {word}; change the command, or switch that one off", + other.name + )) + }) +} + /// Every Discord step must point at a connection that exists. /// Whether every enabled Twitch destination streams to a channel other /// than `user_id`'s. The channel comes from the id Twitch puts in a stream @@ -618,8 +812,13 @@ fn add( if s.integrations.len() >= MAX_INTEGRATIONS { return Err(format!("you already have {MAX_INTEGRATIONS} integrations")); } - let built = + let mut built = presets::build(preset, new_id(), &channel).ok_or("unknown catalog entry")?; + // "Add another" of a chat command: the copy waits, switched off, + // until it gets a command of its own. + if built.enabled && command_taken(&built, s).is_some() { + built.enabled = false; + } // A catalog entry that writes a file could never be finished. if !local_steps_allowed && built.has_local_effects() { if presets_to_add.len() > 1 { @@ -670,6 +869,9 @@ fn toggle( if let Some(taken) = shortcut_taken(integration, s) { return Err(taken); } + if let Some(taken) = command_taken(integration, s) { + return Err(taken); + } } s.integrations[at].enabled = enabled; Ok(()) @@ -684,12 +886,12 @@ fn duplicate(req: &Value, settings: &Arc>, cfg_path: &Pa if s.integrations.len() >= MAX_INTEGRATIONS { return Err(format!("you already have {MAX_INTEGRATIONS} integrations")); } - let original = s + let at = s .integrations .iter() - .find(|i| i.id == id) - .cloned() + .position(|i| i.id == id) .ok_or("that integration no longer exists")?; + let original = s.integrations[at].clone(); // The copy starts off, with no hotkey or pad and its own web call // link: otherwise one press, command or link would run both. let mut copy = original; @@ -714,7 +916,8 @@ fn duplicate(req: &Value, settings: &Arc>, cfg_path: &Pa _ => {} } } - s.integrations.push(copy); + // Right after the original, where the user is looking. + s.integrations.insert(at + 1, copy); Ok(()) }); with_id(reply, &returned) @@ -789,6 +992,32 @@ fn convert(req: &Value) -> Reply { /// Longest answer the editor's "Send request" shows. const FETCH_SHOWN: usize = 200_000; +/// Look for lights and plugs on the network (a few seconds). +async fn scan_lights() -> Reply { + let found = tokio::task::spawn_blocking(|| lights::scan(lights::SCAN_WAIT)) + .await + .unwrap_or_default(); + ok(json::obj([( + "devices", + Value::Arr(found.iter().map(lights::Device::to_json).collect()), + )])) +} + +/// Flash a device so the user sees which one it is. +async fn try_light(req: &Value) -> Reply { + let device = req.str_or("device", "").to_string(); + let rgb = lights::parse_color(req.str_or("color", "")).unwrap_or([255, 255, 255]); + let flash = lights::Command::Flash { + rgb, + brightness: None, + }; + match tokio::task::spawn_blocking(move || lights::send(&device, flash)).await { + Ok(Ok(note)) => ok(json::obj([("note", json::str(note.unwrap_or_default()))])), + Ok(Err(why)) => fail(why), + Err(_) => fail("the light test crashed"), + } +} + /// The editor's "Send request": make the request a web request step would, /// with the values the user typed, and show what came back. async fn fetch(req: &Value) -> Reply { @@ -831,7 +1060,7 @@ async fn fetch(req: &Value) -> Reply { } } -fn export(req: &Value, s: &Settings) -> Reply { +fn export(req: &Value, s: &Settings, cfg_path: &Path) -> Reply { let ids: Vec<&str> = req .get("ids") .and_then(Value::as_array) @@ -852,9 +1081,15 @@ fn export(req: &Value, s: &Settings) -> Reply { "" => chosen[0].name.clone(), n => n.to_string(), }; + // Alerts bring the theme and their uploads unless that's unticked. + let look = req.bool_or("with_theme", true).then_some(&s.alerts_look); + let folder = alert_media::folder(cfg_path); ok(json::obj([ ("ok", Value::Bool(true)), - ("recipe", json::str(recipe::export(&name, &chosen))), + ( + "recipe", + json::str(recipe::export(&name, &chosen, look, &folder)), + ), ])) } @@ -891,6 +1126,10 @@ fn import( ("items", Value::Arr(items)), ("uses_discord", Value::Bool(parsed.uses_discord())), ("local_effects", Value::Bool(parsed.has_local_effects())), + ( + "alerts", + shared_alerts_review(&parsed, &settings.borrow().alerts_look), + ), ])); } if parsed.has_local_effects() && !local_steps_allowed { @@ -912,6 +1151,21 @@ fn import( recipe::map_discord(&mut parsed, &channel); } let count = parsed.integrations.len(); + if would_overflow(settings, count) { + return fail(format!( + "that would go over {MAX_INTEGRATIONS} integrations" + )); + } + let (theme, added_media) = + match keep_shared_alerts(&mut parsed, req.bool_or("use_theme", false), cfg_path) { + Ok(kept) => kept, + Err(e) => return fail(e), + }; + let ids: Vec = parsed + .integrations + .iter() + .map(|i| json::str(&i.id)) + .collect(); let reply = update(settings, cfg_path, move |s| { if s.integrations.len() + count > MAX_INTEGRATIONS { return Err(format!( @@ -919,17 +1173,113 @@ fn import( )); } s.integrations.extend(parsed.integrations); + if let Some(theme) = theme { + s.alerts_look = theme; + } Ok(()) }); if reply.0 != "200 OK" { + // Nothing was added, so the uploads saved for it go too. + forget_media(&alert_media::folder(cfg_path), &added_media); return reply; } ok(json::obj([ ("ok", Value::Bool(true)), ("added", Value::Num(count as f64)), + ("ids", Value::Arr(ids)), ])) } +/// Whether adding this many integrations goes over the limit; checked +/// again under the write lock, this one keeps uploads from being saved +/// for an import that can't happen. +fn would_overflow(settings: &Arc>, adding: usize) -> bool { + settings.borrow().integrations.len() + adding > MAX_INTEGRATIONS +} + +/// What a recipe's alerts bring, for the import preview: its theme, the +/// uploads that came along and those that didn't, and whether this PC's +/// alerts page still has the plain default theme (then taking theirs is +/// ticked to start). +fn shared_alerts_review(parsed: &recipe::Recipe, mine: &alerts::Look) -> Value { + let Some(shared) = &parsed.alerts else { + return Value::Null; + }; + let named = |name: &str| { + json::obj([ + ("name", json::str(name)), + ("kind", json::str(recipe::media_kind(name))), + ]) + }; + json::obj([ + ("look", shared.look.to_json()), + ( + "media", + Value::Arr(shared.media.iter().map(|(n, _)| named(n)).collect()), + ), + ( + "left_out", + Value::Arr(shared.left_out.iter().map(|n| named(n)).collect()), + ), + ( + "mine_is_default", + Value::Bool(*mine == alerts::Look::default()), + ), + ]) +} + +/// Save the uploads a recipe brought and point its alerts at them. +/// Returns the sharer's theme (pointed at those uploads too) when the +/// importer chose to use it. +fn keep_shared_alerts( + parsed: &mut recipe::Recipe, + use_theme: bool, + cfg_path: &Path, +) -> Result<(Option, Vec), String> { + let Some(shared) = parsed.alerts.take() else { + // Shared without its uploads: an alert naming one falls back (the + // theme sound, no picture) instead of pointing at a file that isn't + // here, or at one of the importer's that happens to share the name. + recipe::point_media(parsed, None, &std::collections::BTreeMap::new()); + return Ok((None, Vec::new())); + }; + let folder = alert_media::folder(cfg_path); + // What was here before: an identical upload is reused, and a failed + // import may only take back the files it saved itself. + let here: std::collections::HashSet = alert_media::list(&folder) + .into_iter() + .map(|m| m.name) + .collect(); + let mut saved = std::collections::BTreeMap::new(); + let mut added = Vec::new(); + for (name, data) in &shared.media { + let kept = match recipe::keep_shared_media(&folder, name, data) { + Ok(kept) => kept, + Err(e) => { + forget_media(&folder, &added); + return Err(format!("couldn't keep {name}: {e}")); + } + }; + if !here.contains(&kept) && !added.contains(&kept) { + added.push(kept.clone()); + } + saved.insert(name.clone(), kept); + } + let mut theme = use_theme.then_some(shared.look); + recipe::point_media(parsed, theme.as_mut(), &saved); + Ok((theme, added)) +} + +/// Remove the uploads a failed import saved, so they don't stay behind +/// unused and count toward the uploads limit. +fn forget_media(folder: &Path, names: &[String]) { + for name in names { + if let Err(e) = alert_media::delete(folder, name) { + log_line!("[integrations] couldn't remove {name} after a failed import: {e}"); + } + } +} + /// Who can start a chat trigger, in words. The broadcaster always can. fn who_can(roles: Roles) -> &'static str { match (roles.everyone, roles.subs, roles.vips, roles.mods) { @@ -991,8 +1341,12 @@ fn effect_review(i: &Integration) -> Vec { add("danger", "Anyone in chat can change your delay") } StepKind::DelayAction => add("caution", "Changes your delay"), - StepKind::Obs => add("caution", "Controls OBS (scenes, sources, text)"), + StepKind::Obs => add( + "caution", + "Controls OBS (scenes, sources, recording, audio)", + ), StepKind::Http => add("caution", "Calls a web address you fill in"), + StepKind::Light => add("caution", "Controls a light or plug on your network"), StepKind::Clip => add("info", "Makes clips"), StepKind::Marker => add("info", "Adds VOD markers"), StepKind::Discord => add("info", "Posts on Discord"), @@ -1037,7 +1391,8 @@ fn summary(i: &Integration) -> String { .iter() .map(|h| super::model::count_steps(&h.steps)) .sum(); - format!("When {} · {steps} steps", triggers.join(", ")) + let unit = if steps == 1 { "step" } else { "steps" }; + format!("When {} · {steps} {unit}", triggers.join(", ")) } fn save_discord(req: &Value, settings: &Arc>, cfg_path: &Path) -> Reply { @@ -1264,15 +1619,35 @@ mod tests { .params .insert("text".into(), "{delya} for {user} at {time}".into()); assert_eq!(unknown_vars(&i), vec!["delya".to_string()]); - for p in presets::PRESETS { - let built = presets::build(p.id, "x".into(), "c").unwrap(); - assert!( - unknown_vars(&built).is_empty(), - "{}: {:?}", - p.id, - unknown_vars(&built) - ); - } + } + + #[test] + fn checks_and_waited_programs_fill_their_values() { + use super::super::model::{Step, StepKind}; + let mut i = presets::build("delay_command", "d".into(), "").unwrap(); + let text = + "{obs.muted?muted:unmuted} {cam.scene} {cam.desk.muted} {rank.output} {program.code}"; + i.handlers[0].steps = vec![ + Step::new(StepKind::Obs, &[("action", "check"), ("source", "Mic")]), + Step::new( + StepKind::Obs, + &[ + ("action", "check"), + ("source", "Desk\nMic"), + ("save_as", "cam"), + ], + ), + Step::new(StepKind::Program, &[("wait", "yes"), ("save_as", "rank")]), + Step::new(StepKind::Chat, &[("text", text)]), + ]; + assert_eq!(unknown_vars(&i), vec!["program.code".to_string()]); + i.handlers[0].steps[0] + .params + .insert("source".into(), String::new()); + assert_eq!( + unknown_vars(&i), + vec!["obs.muted".to_string(), "program.code".to_string()] + ); } #[test] @@ -1303,8 +1678,8 @@ mod tests { #[test] fn programs_and_files_are_locked_when_the_request_may_not_set_them_up() { use super::super::model::{Step, StepKind}; - let dir = std::env::temp_dir().join(format!("ic-local-steps-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let scratch = crate::test_support::TestDir::new("ic-local-steps"); + let dir = scratch.path().to_path_buf(); let cfg = dir.join("instantclone.config.json"); let settings = Arc::new(watch::channel(Settings::defaults()).0); let mut i = presets::build("delay_command", "d".into(), "").unwrap(); @@ -1328,14 +1703,17 @@ mod tests { let shared = json::obj([ ( "recipe", - json::str(recipe::export("kit", std::slice::from_ref(&i))), + json::str(recipe::export( + "kit", + std::slice::from_ref(&i), + None, + Path::new(""), + )), ), ("apply", Value::Bool(true)), ("allow_local", Value::Bool(true)), ]); assert!(locked(import(&shared, &settings, &cfg, false))); - - let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -1370,12 +1748,224 @@ mod tests { assert!(effects.contains(&("danger".into(), "Runs a program on this PC".into()))); } + /// A recipe's uploads are saved before its integrations are added. If + /// that fails, only the files it saved are taken back: an identical file + /// that was already here was reused, and stays. + #[test] + fn a_failed_import_only_takes_back_the_uploads_it_saved() { + let scratch = crate::test_support::TestDir::new("ic-import-media"); + let cfg = scratch.path().join("instantclone.config.json"); + let folder = alert_media::folder(&cfg); + let mine = alert_media::save(&folder, "ding.mp3", b"mine").unwrap(); + let mut parsed = recipe::Recipe { + name: "shared".into(), + integrations: Vec::new(), + alerts: Some(recipe::SharedAlerts { + look: alerts::Look::default(), + media: vec![ + ("ding.mp3".into(), b"mine".to_vec()), + ("pop.mp3".into(), b"theirs".to_vec()), + ], + left_out: Vec::new(), + }), + }; + let (_, added) = keep_shared_alerts(&mut parsed, false, &cfg).unwrap(); + assert_eq!(added, ["pop.mp3"]); + forget_media(&folder, &added); + let left: Vec = alert_media::list(&folder) + .into_iter() + .map(|m| m.name) + .collect(); + assert_eq!(left, [mine]); + } + + /// A recipe shared without its uploads: its alerts fall back instead of + /// naming a file the importer doesn't have (or a different one of + /// theirs with the same name). + #[test] + fn alerts_shared_without_their_uploads_fall_back() { + use crate::integrations::model::{Step, StepKind}; + let scratch = crate::test_support::TestDir::new("ic-import-no-media"); + let cfg = scratch.path().join("instantclone.config.json"); + let mut integration = presets::build("delay_command", "d".into(), "").unwrap(); + integration.handlers[0].steps = vec![Step::new( + StepKind::Overlay, + &[("sound", "media:ding.mp3"), ("image", "media:party.gif")], + )]; + let mut parsed = recipe::Recipe { + name: "shared".into(), + integrations: vec![integration], + alerts: None, + }; + keep_shared_alerts(&mut parsed, false, &cfg).unwrap(); + let step = &parsed.integrations[0].handlers[0].steps[0]; + assert_eq!(step.param("sound"), alerts::THEME_SOUND); + assert_eq!(step.param("image"), ""); + } + + /// "Show it on stream" for an alert with a title and no text used to + /// show the generic card and still say the alert was on the page. + #[test] + fn an_alert_with_only_a_title_is_shown_as_itself() { + let titled = test_card(&json::parse(r#"{"title":"New follower","text":" "}"#).unwrap()); + assert_eq!(titled.title, "New follower"); + assert_eq!(titled.text, ""); + assert_eq!(test_card(&json::parse("{}").unwrap()).title, "InstantClone"); + } + #[test] fn summaries_name_the_triggers() { + // The step count is the preset's own business: only its shape here. let i = presets::build("mod_controls", "m".into(), "").unwrap(); - assert_eq!( - summary(&i), - "When !cut in chat, !setdelay in chat · 8 steps" + let text = summary(&i); + assert!( + text.starts_with("When !cut in chat, !setdelay in chat · "), + "{text}" + ); + assert!(text.ends_with(" steps"), "{text}"); + let one = presets::build("delay_command", "d".into(), "").unwrap(); + assert_eq!(summary(&one), "When !delay in chat · 1 step"); + } + + /// Settings and a config file of their own, for tests that save. + fn scratch(name: &str) -> (Arc>, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("ic-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + ( + Arc::new(watch::channel(Settings::defaults()).0), + dir.join("instantclone.config.json"), + ) + } + + fn stored(settings: &watch::Sender, id: &str) -> Option { + settings + .borrow() + .integrations + .iter() + .find(|i| i.id == id) + .cloned() + } + + #[test] + fn two_integrations_never_answer_the_same_chat_command() { + let (settings, cfg) = scratch("commands"); + let first = presets::build("delay_command", "first".into(), "").unwrap(); + assert_eq!(save(&first.to_json(), &settings, &cfg, true).0, "200 OK"); + + // A second "!delay", switched on: saved, but as a draft. + let second = presets::build("delay_command", "second".into(), "").unwrap(); + let reply = save(&second.to_json(), &settings, &cfg, true); + assert_eq!(reply.0, "200 OK"); + assert!(reply.2.contains(r#""drafted":true"#), "{}", reply.2); + assert!(reply.2.contains("already answers !delay"), "{}", reply.2); + assert!(!stored(&settings, "second").unwrap().enabled); + + // Switching it on is refused, and so is an alias that clashes. + let on = json::obj([("id", json::str("second")), ("enabled", Value::Bool(true))]); + let refused = toggle(&on, &settings, &cfg, true); + assert!( + refused.0 != "200 OK" && refused.2.contains("already answers"), + "{}", + refused.2 ); + let mut alias = second; + if let Trigger::ChatCommand { + command, aliases, .. + } = &mut alias.handlers[0].trigger + { + *command = "!wait".into(); + *aliases = vec!["!delay".into()]; + } + assert!(command_taken(&alias, &settings.borrow()).is_some()); + + // Its own command: no clash, and it switches on. + if let Trigger::ChatCommand { aliases, .. } = &mut alias.handlers[0].trigger { + aliases.clear(); + } + let reply = save(&alias.to_json(), &settings, &cfg, true); + assert!(reply.2.contains(r#""drafted":false"#), "{}", reply.2); + assert!(stored(&settings, "second").unwrap().enabled); + + // A switched-off one is no clash, and one integration never clashes + // with itself. + assert_eq!(command_taken(&first, &settings.borrow()), None); + let off = json::obj([("id", json::str("first")), ("enabled", Value::Bool(false))]); + assert_eq!(toggle(&off, &settings, &cfg, true).0, "200 OK"); + let mut again = alias.clone(); + again.id = "third".into(); + if let Trigger::ChatCommand { command, .. } = &mut again.handlers[0].trigger { + *command = "!delay".into(); + } + assert_eq!(command_taken(&again, &settings.borrow()), None); + + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); + } + + #[test] + fn adding_another_chat_command_adds_it_switched_off() { + let (settings, cfg) = scratch("add-another"); + let add_one = || { + let reply = add( + &json::obj([("preset", json::str("delay_command"))]), + &settings, + &cfg, + true, + ); + assert_eq!(reply.0, "200 OK", "{}", reply.2); + }; + add_one(); + add_one(); + let on: Vec = settings + .borrow() + .integrations + .iter() + .map(|i| i.enabled) + .collect(); + assert_eq!(on, vec![true, false]); + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); + } + + #[test] + fn an_unfinished_save_keeps_the_work_without_stopping_a_running_one() { + let (settings, cfg) = scratch("drafts"); + let mut webhook = presets::build("webhook", "hook".into(), "").unwrap(); + webhook.enabled = true; + let reply = save(&webhook.to_json(), &settings, &cfg, true); + assert_eq!(reply.0, "200 OK", "a new one is kept as a draft"); + assert!(reply.2.contains(r#""drafted":true"#), "{}", reply.2); + assert!(!stored(&settings, "hook").unwrap().enabled); + + // One that runs keeps running as it was: the edit is refused. + let running = presets::build("delay_command", "cmd".into(), "").unwrap(); + assert_eq!(save(&running.to_json(), &settings, &cfg, true).0, "200 OK"); + let mut broken = running.clone(); + broken.handlers[0].steps[0] + .params + .insert("text".into(), String::new()); + let reply = save(&broken.to_json(), &settings, &cfg, true); + assert_ne!(reply.0, "200 OK"); + assert_eq!(stored(&settings, "cmd").unwrap(), running); + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); + } + + #[test] + fn a_copy_lands_right_after_its_original() { + let (settings, cfg) = scratch("duplicate"); + for id in ["a", "b"] { + let i = presets::build("delay_notice", id.into(), "").unwrap(); + assert_eq!(save(&i.to_json(), &settings, &cfg, true).0, "200 OK"); + } + let reply = duplicate(&json::obj([("id", json::str("a"))]), &settings, &cfg); + assert_eq!(reply.0, "200 OK"); + let order: Vec = settings + .borrow() + .integrations + .iter() + .map(|i| i.id.clone()) + .collect(); + assert_eq!(order[0], "a"); + assert!(reply.2.contains(&order[1]), "the copy is second: {order:?}"); + assert_eq!(order[2], "b"); + let _ = std::fs::remove_dir_all(cfg.parent().unwrap()); } } diff --git a/src/integrations/clock.rs b/src/integrations/clock.rs index 456f104..c629574 100644 --- a/src/integrations/clock.rs +++ b/src/integrations/clock.rs @@ -92,10 +92,15 @@ mod tests { } #[test] - fn formats_are_zero_padded_and_plausible() { - let t = super::now(); - assert!(t.year >= 2024 && (1..=12).contains(&t.month) && (1..=31).contains(&t.day)); - assert_eq!(t.hm().len(), 5); - assert_eq!(t.date().len(), 10); + fn formats_are_zero_padded() { + let t = super::LocalTime { + year: 2026, + month: 3, + day: 7, + hour: 9, + minute: 5, + }; + assert_eq!(t.hm(), "09:05"); + assert_eq!(t.date(), "2026-03-07"); } } diff --git a/src/integrations/effects.rs b/src/integrations/effects.rs index 957bd44..e25ae87 100644 --- a/src/integrations/effects.rs +++ b/src/integrations/effects.rs @@ -3,7 +3,7 @@ use super::host::{ ClipInfo, DiscordCard, DiscordMessage, DiscordPosted, Host, HttpRequest, HttpResponse, - LiveState, + LiveState, ProgramOutput, }; use super::twitch::Twitch; use crate::controller::Controller; @@ -28,6 +28,10 @@ const MAX_FILE_TEXT: usize = 1024 * 1024; /// they are reaped (no zombies on Unix) without a waiting thread each, and /// capped so a runaway trigger can't fork the machine to its knees. const MAX_RUNNING_PROGRAMS: usize = 16; +/// How long a step waits for a program whose answer it keeps. +pub const PROGRAM_WAIT: Duration = Duration::from_secs(10); +/// What is kept of a program's answer. +const MAX_PROGRAM_OUTPUT: usize = 64 * 1024; /// Marks every web request InstantClone makes. A step can name this PC's /// own dashboard as its address; arriving with this, the dashboard treats @@ -241,6 +245,11 @@ impl Host for RealHost { destinations_total: total as usize, obs_live: self.ctrl.ingest_alive(), bitrate_kbps: self.ctrl.bitrate_kbps() as u64, + delay_max_s: self + .ctrl + .delay_capacity_ms() + .min(crate::config::MAX_DELAY_MS) + / 1000, channel: self.twitch.channel(), } } @@ -317,15 +326,20 @@ impl Host for RealHost { }; match action { // "Set the delay", not the hotkey's arm/disarm toggle. - "arm" => { - self.ctrl.set_delay_to(default_ms, "integration"); - Ok(()) - } + "arm" => match self.ctrl.set_delay_to(default_ms, "integration") { + None => Ok(()), + Some(problem) => Err(problem), + }, "disarm" => { self.ctrl.disarm("integration"); Ok(()) } - "toggle" | "activate" | "cut" | "cut_after" | "end_hold" => { + // Never cancels a cut already waiting, unlike the hotkey. + "cut_after" => match self.ctrl.cut_after_airs("integration") { + None => Ok(()), + Some(problem) => Err(problem), + }, + "toggle" | "activate" | "cut" | "end_hold" => { match self .ctrl .run_named_action(action, default_ms, "integration") @@ -361,6 +375,13 @@ impl Host for RealHost { Ok(()) } + fn program_output(&self, path: &str, args: &[String]) -> Result { + if path.is_empty() { + return Err("pick a program to run".to_string()); + } + run_and_read(path, args) + } + fn file(&self, path: &str, text: &str, append: bool) -> Result<(), String> { if path.is_empty() { return Err("pick a file".to_string()); @@ -384,11 +405,11 @@ impl Host for RealHost { result.map_err(|e| format!("couldn't write it: {e}")) } - fn overlay(&self, title: &str, text: &str, seconds: u64) { - self.alerts.show(title, text, seconds); + fn overlay(&self, card: super::alerts::Card) { + self.alerts.show(card); } - fn obs(&self, action: super::obsws::Action) -> Result<(), String> { + fn obs(&self, action: super::obsws::Action) -> Result { let (reply, outcome) = std::sync::mpsc::channel(); let wait = super::obsws::CALL_TIMEOUT + Duration::from_secs(1); let deadline = std::time::Instant::now() + wait; @@ -404,6 +425,14 @@ impl Host for RealHost { .recv_timeout(wait) .unwrap_or_else(|_| Err("OBS didn't answer in time".to_string())) } + + fn light( + &self, + device: &str, + command: super::lights::Command, + ) -> Result, String> { + super::lights::send(device, command) + } } fn discord_status(status: u16) -> Result<(), String> { @@ -440,6 +469,66 @@ fn webhook_message_url(url: &str, id: &str) -> String { /// The webhook's picture, so messages carry the InstantClone mark instead /// of Discord's default avatar. Discord fetches it; nothing is uploaded. +/// Run a program with no window and keep what it prints, waiting +/// `PROGRAM_WAIT` at most: one still running then is stopped. Its output +/// is read on a thread of its own, so a program printing more than is kept +/// never blocks on a full pipe. +fn run_and_read(path: &str, args: &[String]) -> Result { + use std::io::Read as _; + let mut command = std::process::Command::new(path); + command + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = command + .spawn() + .map_err(|e| format!("couldn't start it: {e}"))?; + let mut stdout = child.stdout.take().ok_or("couldn't read its answer")?; + let (sent, read) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut kept = Vec::new(); + let mut chunk = [0u8; 8192]; + while let Ok(n) = stdout.read(&mut chunk) { + if n == 0 { + break; + } + let room = MAX_PROGRAM_OUTPUT.saturating_sub(kept.len()); + kept.extend_from_slice(&chunk[..n.min(room)]); + } + let _ = sent.send(kept); + }); + let deadline = std::time::Instant::now() + PROGRAM_WAIT; + let status = loop { + if let Some(status) = child.try_wait().map_err(|e| format!("lost it: {e}"))? { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "it was still running after {} s, so it was stopped", + PROGRAM_WAIT.as_secs() + )); + } + std::thread::sleep(Duration::from_millis(25)); + }; + // Something it started may hold the output open: don't wait on that. + let kept = read + .recv_timeout(Duration::from_secs(1)) + .unwrap_or_default(); + Ok(ProgramOutput { + code: status.code(), + output: String::from_utf8_lossy(&kept).trim().to_string(), + }) +} + const AVATAR_URL: &str = "https://raw.githubusercontent.com/Soulhackzlol/InstantClone/main/docs/icon.png"; @@ -615,6 +704,24 @@ fn mention_rules(ping: &str) -> (String, Value) { mod tests { use super::*; + /// A real program through the system shell: what it printed (trimmed) + /// and how it ended both come back. + #[test] + fn a_programs_answer_and_exit_code_come_back() { + #[cfg(windows)] + let (shell, script) = ("cmd", "/C echo rank: Diamond & exit /b 3"); + #[cfg(not(windows))] + let (shell, script) = ("sh", "-c|echo ' rank: Diamond'; exit 3"); + #[cfg(windows)] + let args: Vec = script.splitn(2, ' ').map(str::to_string).collect(); + #[cfg(not(windows))] + let args: Vec = script.split('|').map(str::to_string).collect(); + let answer = run_and_read(shell, &args).unwrap(); + assert_eq!(answer.output, "rank: Diamond"); + assert_eq!(answer.code, Some(3)); + assert!(run_and_read("no-such-program-ic", &[]).is_err()); + } + fn text(content: &str, ping: &str) -> String { discord_body(&DiscordMessage { content: content.into(), diff --git a/src/integrations/engine.rs b/src/integrations/engine.rs index 3b725a8..fe2fdd2 100644 --- a/src/integrations/engine.rs +++ b/src/integrations/engine.rs @@ -26,7 +26,7 @@ use super::model::{ pad_matches, ChatActivity, DiscordChannel, Integration, MatchMode, PhoneConnection, Roles, Trigger, }; -use super::obsws::{self, SceneNews}; +use super::obsws::{self, ObsNews}; use super::runner::{self, RunContext, RunEnv, RunStatus, StepLog}; use super::session::Session; use super::store::Store; @@ -39,7 +39,7 @@ use crate::json::{self, Value}; use crate::sync::Mutex; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot, watch, Semaphore}; @@ -51,6 +51,41 @@ const PER_INTEGRATION_RUNS: usize = 16; const GLOBAL_STARTED: usize = 64; const ACTIVITY_KEEP: usize = 150; const TICK: Duration = Duration::from_millis(250); +/// Why a run may not start now, before the run limits are asked. +#[derive(Debug, PartialEq)] +enum HeldBack { + /// Inside the integration's quiet hours: silently skipped. + Quiet, + /// Too soon after this trigger's last run. + Cooldown, +} + +/// Quiet hours first, then the trigger's cooldown. `last_run` is when this +/// trigger last started a run; `minute_of_day` is local time. +fn held_back( + integration: &Integration, + last_run: Option, + minute_of_day: u16, + now: Instant, +) -> Option { + if integration.quiet.is_some_and(|q| q.contains(minute_of_day)) { + return Some(HeldBack::Quiet); + } + let cooldown = Duration::from_millis(integration.cooldown_ms); + let too_soon = last_run.is_some_and(|t| now.saturating_duration_since(t) < cooldown); + too_soon.then_some(HeldBack::Cooldown) +} + +/// Whether a viewer used a chat command too recently to use it again. +fn viewer_cooling_down(last: Option, user_cooldown_ms: u64, now: Instant) -> bool { + last.is_some_and(|t| now.saturating_duration_since(t) < Duration::from_millis(user_cooldown_ms)) +} + +/// Each viewer waits out a command's cooldown on their own. +fn viewer_key(integration_id: &str, idx: usize, login: &str) -> String { + format!("{integration_id}#{idx}#{login}") +} + /// How often per-viewer and per-trigger bookkeeping is trimmed. A busy /// chat adds an entry per viewer per command; without trimming, a long /// stream would only ever grow them. @@ -69,9 +104,11 @@ const FLAP_WINDOW: Duration = Duration::from_secs(300); pub const ACTIVITY_WARMUP: Duration = Duration::from_secs(30); /// How often chat activity triggers judge chat at most. const ACTIVITY_READ_EVERY: Duration = Duration::from_millis(250); -/// How long the dashboard keeps the OBS connection open after asking for -/// the scene list, so picking a scene works before anything is saved. +/// How long the dashboard keeps the OBS connection open after it last +/// asked, so its OBS pill and scene lists show the real thing. const OBS_FOR_DASHBOARD: Duration = Duration::from_secs(60); +/// How long a dashboard request waits for OBS to connect. +const OBS_CONNECT_WAIT: Duration = Duration::from_secs(8); enum Input { Event(Event), @@ -88,7 +125,7 @@ enum Input { }, #[cfg_attr(not(windows), allow(dead_code))] Shortcut(Shortcut, String), - Scene(SceneNews), + Obs(ObsNews), Login(Which), CancelLogin, Logout(Which), @@ -184,6 +221,10 @@ pub struct Handle { obs: Arc>, /// Until when (Unix ms) the dashboard wants OBS connected. obs_wanted_until: AtomicU64, + /// Whether an enabled integration uses OBS. + obs_in_use: AtomicBool, + /// Asks OBS for things the dashboard does itself (not a step's). + obs_commands: mpsc::Sender, /// Why chat activity triggers can't fire right now, or empty: see /// `Engine::activity_gate`. For the dashboard's live meter. activity_held: Mutex<&'static str>, @@ -391,13 +432,70 @@ impl Handle { } /// The OBS connection and its scenes. Asking keeps OBS connected for a - /// minute, so the scene list fills in while a trigger is being set up. + /// minute: the open dashboard asks every few seconds, so it shows how + /// OBS really stands, not just whether an integration needed it. `used` + /// says whether one does. pub fn obs_status(&self) -> Value { + self.keep_obs_for_dashboard(); + let mut status = self.obs.lock().clone(); + // Asking connects it on the engine's next tick. + if matches!(status.state, "" | "off") { + status.state = "connecting"; + } + let mut json = status.to_json(); + if let Value::Obj(fields) = &mut json { + let used = self.obs_in_use.load(Ordering::Relaxed); + fields.push(("used".to_string(), Value::Bool(used))); + } + json + } + + fn keep_obs_for_dashboard(&self) { self.obs_wanted_until.store( unix_ms() + OBS_FOR_DASHBOARD.as_millis() as u64, Ordering::Relaxed, ); - self.obs.lock().to_json() + } + + /// Ask OBS for something the dashboard does itself, connecting first + /// when nothing keeps OBS connected. Waits for OBS's answer. + pub async fn obs_now(&self, action: obsws::Action) -> Result<(), String> { + self.keep_obs_for_dashboard(); + let give_up = Instant::now() + OBS_CONNECT_WAIT; + loop { + let (state, problem) = { + let s = self.obs.lock(); + (s.state, s.problem.clone()) + }; + if state == "connected" { + break; + } + if Instant::now() >= give_up { + return Err(if problem.is_empty() { + "OBS isn't connected. Is OBS open?".to_string() + } else { + problem + }); + } + tokio::time::sleep(TICK).await; + } + let (reply, outcome) = std::sync::mpsc::channel(); + let wait = obsws::CALL_TIMEOUT * 3; + self.obs_commands + .try_send(obsws::Command { + action, + reply, + deadline: Instant::now() + wait, + }) + .map_err(|_| "OBS is busy; try again in a moment".to_string())?; + tokio::task::spawn_blocking(move || { + outcome + .recv_timeout(wait) + .unwrap_or_else(|_| Err("OBS didn't answer in time".to_string())) + .map(drop) + }) + .await + .unwrap_or_else(|_| Err("OBS didn't answer in time".to_string())) } } @@ -409,16 +507,18 @@ pub fn start( ) -> std::io::Result> { let (tx, rx) = mpsc::channel(QUEUE); let (chat_tx, chat_rx) = mpsc::channel(256); + let (twitch_events_tx, mut twitch_events) = mpsc::channel(64); let store = Arc::new(Store::open(&data_dir)); let log_ctrl = ctrl.clone(); let twitch = Arc::new(Twitch::new( store.clone(), - chat_tx, + (chat_tx, twitch_events_tx), Box::new(move |line| log_ctrl.log(line)), &settings.borrow().twitch_client_id, )); let alerts = Arc::new(Board::new()); let obs = Arc::new(Mutex::new(obsws::Status::default())); + let (obs_tx, obs_commands) = mpsc::channel(16); let handle = Arc::new(Handle { tx, activity: Mutex::new(VecDeque::new()), @@ -431,6 +531,8 @@ pub fn start( chat: Arc::new(Mutex::new(ChatWindow::default())), obs: obs.clone(), obs_wanted_until: AtomicU64::new(0), + obs_in_use: AtomicBool::new(false), + obs_commands: obs_tx.clone(), activity_held: Mutex::new("offline"), }); let engine_handle = handle.clone(); @@ -449,7 +551,6 @@ pub fn start( }; runtime.block_on(async move { twitch.start(); - let (obs_tx, obs_commands) = mpsc::channel(16); let host = Arc::new(RealHost { ctrl: ctrl.clone(), twitch: twitch.clone(), @@ -460,14 +561,23 @@ pub fn start( onair: Mutex::new("off"), obs: obs_tx, }); - // OBS's scene news reaches the engine like any other input. + // News from OBS reaches the engine like any other input. let (want_obs, wanted) = watch::channel(false); let (news_tx, mut news_rx) = mpsc::channel(64); tokio::spawn(obsws::run(wanted, obs, news_tx, obs_commands)); - let scene_tx = engine_handle.tx.clone(); + // So do Twitch's events (follows, subs, raids...). + let twitch_events_to = engine_handle.tx.clone(); + tokio::spawn(async move { + while let Some(event) = twitch_events.recv().await { + if twitch_events_to.send(Input::Event(event)).await.is_err() { + return; + } + } + }); + let obs_news_tx = engine_handle.tx.clone(); tokio::spawn(async move { while let Some(news) = news_rx.recv().await { - if scene_tx.send(Input::Scene(news)).await.is_err() { + if obs_news_tx.send(Input::Obs(news)).await.is_err() { return; } } @@ -640,6 +750,7 @@ impl Engine { self.update_activity_gate(); self.settle_scenes(); self.update_obs_want(); + self.update_twitch_events(); self.run_timers(); self.save_uses_if_due(); if last_prune.elapsed() >= PRUNE_EVERY { @@ -738,7 +849,8 @@ impl Engine { reply, } => self.test(*integration, handler, run, reply), Input::Shortcut(from, signature) => self.shortcut(from, &signature), - Input::Scene(news) => self.scene(news), + Input::Obs(ObsNews::Happened(event)) => self.event(event), + Input::Obs(news) => self.scene(news), Input::Login(which) => self.handle.twitch.begin_login(which), Input::CancelLogin => self.handle.twitch.cancel_login(), Input::Logout(which) => self.handle.twitch.logout(which), @@ -1092,25 +1204,25 @@ impl Engine { roles, user_cooldown_ms, } if (*command == first || aliases.contains(&first)) && allowed(*roles, &msg) => { - let key = format!("{}#{idx}#{}", i.id, msg.user_login); - let cooled = self + let last = self .viewer_last - .get(&key) - .is_some_and(|t| t.elapsed() < Duration::from_millis(*user_cooldown_ms)); - (!cooled).then(|| (i.clone(), idx, first.clone())) + .get(&viewer_key(&i.id, idx, &msg.user_login)); + let cooling = + viewer_cooling_down(last.copied(), *user_cooldown_ms, Instant::now()); + (!cooling).then(|| (i.clone(), idx, first.clone())) } Trigger::ChatMessage { pattern, mode, roles, } if allowed(*roles, &msg) && text_matches(text, pattern, *mode) => { - Some((i.clone(), idx, "chat".to_string())) + Some((i, idx, "chat".to_string())) } _ => None, }) .collect(); for (integration, idx, label) in candidates { - let key = format!("{}#{idx}#{}", integration.id, msg.user_login); + let key = viewer_key(&integration.id, idx, &msg.user_login); let vars = chat_vars(&msg, &args); let trigger = if label == "chat" { "Chat message".to_string() @@ -1209,10 +1321,11 @@ impl Engine { /// starts that trigger's settle time; it fires once the scene stayed. /// The scene on air when OBS connects counts as a switch to it when a /// stream is already on (the app or OBS restarted mid-stream). - fn scene(&mut self, news: SceneNews) { + fn scene(&mut self, news: ObsNews) { let (name, switched) = match news { - SceneNews::Current(name) => (name, false), - SceneNews::Switched(name) => (name, true), + ObsNews::Current(name) => (name, false), + ObsNews::Switched(name) => (name, true), + ObsNews::Happened(_) => return, }; let previous = std::mem::replace(&mut self.scene_now, name.clone()); if previous == name { @@ -1319,18 +1432,39 @@ impl Engine { self.scene_pending.retain(|_, p| p.scene == now_scene); } - /// Keep OBS connected while a scene trigger or an OBS step is on, or - /// the dashboard is picking a scene. + /// Listen on Twitch for the events enabled integrations start on. + fn update_twitch_events(&mut self) { + let mut wanted: Vec = Vec::new(); + for (_, _, h) in self.enabled_handlers() { + if let Trigger::Event { kind, .. } = &h.trigger { + if super::twitch::eventsub::subscription(*kind).is_some() && !wanted.contains(kind) + { + wanted.push(*kind); + } + } + } + // In a fixed order: the same set must compare equal. + wanted.sort_by_key(|k| EventKind::ALL.iter().position(|a| a == k)); + self.host.twitch.want_events(wanted); + } + + /// Keep OBS connected while a scene trigger, an OBS event trigger or an + /// OBS step is on, or the dashboard is open. fn update_obs_want(&mut self) { let uses_obs = |h: &super::model::Handler| { - let mut found = matches!(h.trigger, Trigger::Scene { .. }); + let mut found = match &h.trigger { + Trigger::Scene { .. } => true, + Trigger::Event { kind, .. } => kind.needs_obs(), + _ => false, + }; super::model::visit_steps(&h.steps, &mut |s| { found |= s.enabled && s.kind == super::model::StepKind::Obs }); found }; - let wanted = self.enabled_handlers().any(|(_, _, h)| uses_obs(h)) - || unix_ms() < self.handle.obs_wanted_until.load(Ordering::Relaxed); + let in_use = self.enabled_handlers().any(|(_, _, h)| uses_obs(h)); + self.handle.obs_in_use.store(in_use, Ordering::Relaxed); + let wanted = in_use || unix_ms() < self.handle.obs_wanted_until.load(Ordering::Relaxed); if *self.want_obs.borrow() != wanted { self.want_obs.send_replace(wanted); } @@ -1496,28 +1630,24 @@ impl Engine { reply_to: Option, trigger: String, ) -> bool { - if let Some(quiet) = integration.quiet { - let now = super::clock::now(); - if quiet.contains((now.hour * 60 + now.minute) as u16) { + let key = format!("{}#{idx}", integration.id); + let clock = super::clock::now(); + let minute_of_day = (clock.hour * 60 + clock.minute) as u16; + let last = self.last_run.get(&key).copied(); + match held_back(&integration, last, minute_of_day, Instant::now()) { + Some(HeldBack::Quiet) => return false, + Some(HeldBack::Cooldown) => { + // A button pressed too soon says so: the press wasn't lost. + // Chat, much busier, doesn't fill the log with them. + if matches!( + integration.handlers[idx].trigger, + Trigger::Shortcut { .. } | Trigger::ChatActivity(_) + ) { + self.turned_away(&integration, trigger, "cooldown"); + } return false; } - } - let key = format!("{}#{idx}", integration.id); - if integration.cooldown_ms > 0 - && self - .last_run - .get(&key) - .is_some_and(|t| t.elapsed() < Duration::from_millis(integration.cooldown_ms)) - { - // A button pressed too soon says so: the press wasn't lost. - // Chat, much busier, doesn't fill the log with them. - if matches!( - integration.handlers[idx].trigger, - Trigger::Shortcut { .. } | Trigger::ChatActivity(_) - ) { - self.turned_away(&integration, trigger, "cooldown"); - } - return false; + None => {} } let (started_limit, active_limit) = self .limits @@ -1965,7 +2095,7 @@ pub const ACTIVITY_SAMPLES: &[(&str, &str)] = &[ ("chatters", "15"), ("word_share", "58"), ("top_word", "pog"), - ("user", "Ana"), + ("user", "Nat"), ("message", "CLIP THAT"), ]; @@ -2048,7 +2178,6 @@ mod tests { !guard.suppress(&Event::new(EventKind::HoldOpened), t0), "other events pass" ); - assert!(!guard.suppress(&Event::new(EventKind::HoldOpened), t0)); } #[test] @@ -2123,10 +2252,93 @@ mod tests { assert!(wildcard(&"a".repeat(200), &"*a".repeat(50))); } + /// An integration for the cooldown and quiet-hours tests. + fn timed(cooldown_ms: u64, quiet: Option<(u16, u16)>) -> Integration { + let mut i = crate::integrations::presets::build("delay_command", "t".into(), "").unwrap(); + i.cooldown_ms = cooldown_ms; + i.quiet = quiet.map(|(from, to)| crate::integrations::model::QuietHours { from, to }); + i + } + + /// A run starts again only once its cooldown has passed, measured from + /// the run before; no cooldown, or no run yet, never holds one back. + #[test] + fn a_cooldown_holds_back_a_run_until_it_passes() { + let now = Instant::now() + Duration::from_secs(60); + let ago = |ms: u64| Some(now - Duration::from_millis(ms)); + let every_10s = timed(10_000, None); + for (last, held, case) in [ + (None, None, "never ran"), + (ago(1_000), Some(HeldBack::Cooldown), "1 s after"), + (ago(9_999), Some(HeldBack::Cooldown), "just short"), + (ago(10_000), None, "exactly the cooldown"), + (ago(30_000), None, "long after"), + ] { + assert_eq!(held_back(&every_10s, last, 600, now), held, "{case}"); + } + assert_eq!( + held_back(&timed(0, None), ago(1), 600, now), + None, + "no cooldown" + ); + } + + /// Quiet hours run from `from` up to `to`, overnight too, and come + /// before the cooldown: a quiet run isn't "too soon", it's skipped. + #[test] + fn quiet_hours_hold_back_every_run_inside_them() { + let now = Instant::now(); + let at = |i: &Integration, minute: u16| held_back(i, None, minute, now); + let office = timed(0, Some((9 * 60, 17 * 60))); + let night = timed(0, Some((23 * 60, 7 * 60))); + for (integration, minute, held, case) in [ + (&office, 8 * 60 + 59, None, "08:59, before"), + (&office, 9 * 60, Some(HeldBack::Quiet), "09:00, from"), + (&office, 16 * 60 + 59, Some(HeldBack::Quiet), "16:59"), + (&office, 17 * 60, None, "17:00, to is not included"), + (&night, 22 * 60 + 59, None, "22:59"), + (&night, 23 * 60, Some(HeldBack::Quiet), "23:00"), + (&night, 0, Some(HeldBack::Quiet), "midnight"), + (&night, 6 * 60 + 59, Some(HeldBack::Quiet), "06:59"), + (&night, 7 * 60, None, "07:00"), + ] { + assert_eq!(at(integration, minute), held, "{case}"); + } + let both = timed(10_000, Some((0, 24 * 60))); + assert_eq!( + held_back(&both, Some(now), 600, now), + Some(HeldBack::Quiet), + "quiet wins over the cooldown" + ); + } + + /// One viewer's cooldown doesn't hold back another, and it ends on time. + #[test] + fn each_viewer_waits_out_a_command_cooldown_alone() { + assert_ne!(viewer_key("i", 0, "ana"), viewer_key("i", 0, "bo")); + assert_ne!( + viewer_key("i", 0, "ana"), + viewer_key("i", 1, "ana"), + "per trigger too" + ); + let now = Instant::now() + Duration::from_secs(60); + let used = Some(now - Duration::from_millis(5_000)); + assert!(viewer_cooling_down(used, 30_000, now)); + assert!( + !viewer_cooling_down(used, 5_000, now), + "over once it has passed" + ); + assert!(!viewer_cooling_down(None, 30_000, now), "first time"); + } + #[test] fn matching_ignores_case() { assert!(text_matches("POG champ", "pog", MatchMode::StartsWith)); assert!(text_matches(" GG ", "gg", MatchMode::Exact)); + } + + #[test] + fn an_empty_pattern_never_matches() { assert!(!text_matches("gg", "", MatchMode::Contains)); } @@ -2166,7 +2378,7 @@ mod tests { } #[test] - fn tests_read_like_the_filtered_event() { + fn test_runs_sample_vars_take_filter_values() { let filters = BTreeMap::from([ ("destination".to_string(), "Kick".to_string()), ("reason".to_string(), "*timeout*".to_string()), diff --git a/src/integrations/event.rs b/src/integrations/event.rs index f7d58ce..7ca5e6c 100644 --- a/src/integrations/event.rs +++ b/src/integrations/event.rs @@ -34,6 +34,20 @@ pub enum EventKind { DestinationStillDown, DestinationUnstable, DestinationSteady, + // From OBS itself, over its WebSocket server (see `obsws`). + ObsRecordingStarted, + ObsRecordingStopped, + ObsReplaySaved, + ObsMuteChanged, + // From Twitch, over EventSub (see `twitch::eventsub`). + TwitchFollow, + TwitchSub, + TwitchResub, + TwitchGift, + TwitchCheer, + TwitchRaid, + TwitchRedeem, + TwitchAd, } /// One variable an event carries: its name and a sample value. @@ -47,7 +61,7 @@ pub type ParamSpec = (&'static str, &'static str, &'static str); const MAX_PARAM: f64 = 1_000_000.0; impl EventKind { - pub const ALL: [EventKind; 24] = [ + pub const ALL: [EventKind; 36] = [ EventKind::ObsConnected, EventKind::ObsDisconnected, EventKind::EbDetected, @@ -73,6 +87,18 @@ impl EventKind { EventKind::DelayChanged, EventKind::DelayOff, EventKind::DelayDisarmed, + EventKind::ObsRecordingStarted, + EventKind::ObsRecordingStopped, + EventKind::ObsReplaySaved, + EventKind::ObsMuteChanged, + EventKind::TwitchFollow, + EventKind::TwitchSub, + EventKind::TwitchResub, + EventKind::TwitchGift, + EventKind::TwitchCheer, + EventKind::TwitchRaid, + EventKind::TwitchRedeem, + EventKind::TwitchAd, ]; pub fn id(self) -> &'static str { @@ -101,6 +127,18 @@ impl EventKind { EventKind::DestinationStillDown => "destination_still_down", EventKind::DestinationUnstable => "destination_unstable", EventKind::DestinationSteady => "destination_steady", + EventKind::ObsRecordingStarted => "obs_recording_started", + EventKind::ObsRecordingStopped => "obs_recording_stopped", + EventKind::ObsReplaySaved => "obs_replay_saved", + EventKind::ObsMuteChanged => "obs_mute_changed", + EventKind::TwitchFollow => "twitch_follow", + EventKind::TwitchSub => "twitch_sub", + EventKind::TwitchResub => "twitch_resub", + EventKind::TwitchGift => "twitch_gift", + EventKind::TwitchCheer => "twitch_cheer", + EventKind::TwitchRaid => "twitch_raid", + EventKind::TwitchRedeem => "twitch_redeem", + EventKind::TwitchAd => "twitch_ad", } } @@ -134,6 +172,18 @@ impl EventKind { EventKind::DestinationStillDown => "A platform stays down", EventKind::DestinationUnstable => "A platform keeps dropping", EventKind::DestinationSteady => "A platform is steady again", + EventKind::ObsRecordingStarted => "OBS starts recording", + EventKind::ObsRecordingStopped => "OBS stops recording", + EventKind::ObsReplaySaved => "OBS saves a replay", + EventKind::ObsMuteChanged => "A source is muted or unmuted", + EventKind::TwitchFollow => "Someone follows", + EventKind::TwitchSub => "Someone subscribes", + EventKind::TwitchResub => "Someone resubscribes", + EventKind::TwitchGift => "Someone gifts subs", + EventKind::TwitchCheer => "Someone cheers bits", + EventKind::TwitchRaid => "A raid comes in", + EventKind::TwitchRedeem => "Someone redeems channel points", + EventKind::TwitchAd => "An ad break starts", } } @@ -163,9 +213,32 @@ impl EventKind { | EventKind::DelayArmed | EventKind::DelayReady | EventKind::DelayDisarmed => "Delay", + EventKind::ObsRecordingStarted + | EventKind::ObsRecordingStopped + | EventKind::ObsReplaySaved + | EventKind::ObsMuteChanged => "OBS", + EventKind::TwitchFollow + | EventKind::TwitchSub + | EventKind::TwitchResub + | EventKind::TwitchGift + | EventKind::TwitchCheer + | EventKind::TwitchRaid + | EventKind::TwitchRedeem + | EventKind::TwitchAd => "Twitch", } } + /// Something InstantClone itself saw happen, not OBS or Twitch telling + /// it (those need a connection to them while a trigger listens). + pub fn is_own(self) -> bool { + !matches!(self.group(), "OBS" | "Twitch") + } + + /// Heard from OBS's WebSocket server, which must then be connected. + pub fn needs_obs(self) -> bool { + self.group() == "OBS" + } + /// Variables the event itself carries, on top of the always-available /// ones in `vars::GLOBAL`. pub fn vars(self) -> &'static [VarSpec] { @@ -234,10 +307,45 @@ impl EventKind { ("drops", "4"), ("down_total", "2m 30s"), ], + // `file`: where OBS saved it. + EventKind::ObsRecordingStopped | EventKind::ObsReplaySaved => { + &[("file", r"C:\Videos\2026-10-08 21-04-11.mkv")] + } + // `obs_source`, not `source`: shortcuts already use that name. + EventKind::ObsMuteChanged => &[("obs_source", "Mic/Aux"), ("muted", "yes")], + // `user` is "Anonymous" for an anonymous gift or cheer. `tier` + // is 1, 2 or 3. + EventKind::TwitchFollow => &[("user", "Nat")], + EventKind::TwitchSub => &[("user", "Nat"), ("tier", "1"), ("gift", "no")], + EventKind::TwitchResub => &[ + ("user", "Nat"), + ("tier", "1"), + ("months", "13"), + ("streak", "4"), + ("message", "13 months, still here"), + ], + EventKind::TwitchGift => &[ + ("user", "Nat"), + ("count", "5"), + ("tier", "1"), + ("total", "42"), + ], + EventKind::TwitchCheer => { + &[("user", "Nat"), ("bits", "500"), ("message", "Cheer500 gg")] + } + EventKind::TwitchRaid => &[("user", "Nat"), ("viewers", "42")], + EventKind::TwitchRedeem => &[ + ("user", "Nat"), + ("reward", "Hydrate"), + ("cost", "500"), + ("input", "with lemon"), + ], + EventKind::TwitchAd => &[("seconds", "90"), ("automatic", "yes")], EventKind::ObsConnected | EventKind::EbDetected | EventKind::AllDestinationsDown - | EventKind::StreamStarted => &[], + | EventKind::StreamStarted + | EventKind::ObsRecordingStarted => &[], } } @@ -378,7 +486,7 @@ mod tests { } #[test] - fn samples_carry_every_declared_var() { + fn a_sample_event_reads_like_a_real_one() { let e = Event::sample(EventKind::DestinationDropped); assert_eq!(e.var("destination"), Some("YouTube")); assert_eq!(e.var("reason"), Some("connection timed out")); @@ -397,9 +505,4 @@ mod tests { let _ = std::time::Duration::from_secs_f64(kind.param(&set("inf"), "after_s") * 60.0); assert!(kind.is_param("after_s") && !kind.is_param("destination")); } - - #[test] - fn catalog_is_valid_json() { - assert!(crate::config::is_valid_json(&catalog_json().to_json())); - } } diff --git a/src/integrations/host.rs b/src/integrations/host.rs index 4812b55..0280d94 100644 --- a/src/integrations/host.rs +++ b/src/integrations/host.rs @@ -21,6 +21,9 @@ pub struct LiveState { /// Whether OBS is streaming to InstantClone right now. pub obs_live: bool, pub bitrate_kbps: u64, + /// The longest delay the buffer holds right now, in whole seconds, + /// never past the top (`config::MAX_DELAY_MS`). + pub delay_max_s: u32, /// The connected Twitch channel's login, or empty. pub channel: String, /// Whether a stream is on (see `session::Session`): between streams @@ -49,6 +52,14 @@ pub struct ClipInfo { pub url: String, } +/// What a program a step waited for answered. +pub struct ProgramOutput { + /// Its exit code; `None` when it ended without one. + pub code: Option, + /// What it printed, trimmed. + pub output: String, +} + /// A Discord message to post: plain `content`, a card, or both (the /// content then sits above the card, where a link unfurls into a player). #[derive(Clone, Debug, Default, PartialEq)] @@ -112,11 +123,22 @@ pub trait Host: Send + Sync { /// delay for `arm`. fn delay_action(&self, action: &str, ms: u32) -> Result<(), String>; fn program(&self, path: &str, args: &[String]) -> Result<(), String>; + /// Run a program and wait for it (a short while at most), keeping what + /// it printed. + fn program_output(&self, path: &str, args: &[String]) -> Result; fn file(&self, path: &str, text: &str, append: bool) -> Result<(), String>; - /// Put a card on the alerts browser source for `seconds`. - fn overlay(&self, title: &str, text: &str, seconds: u64); - /// Ask OBS to do something, through its WebSocket server. - fn obs(&self, action: super::obsws::Action) -> Result<(), String>; + /// Put a card on the alerts browser source. + fn overlay(&self, card: super::alerts::Card); + /// Ask OBS to do something, or what it's doing, through its WebSocket + /// server. A check answers with values; an action with none. + fn obs(&self, action: super::obsws::Action) -> Result; + /// Drive a light or plug on the network (`device` is a reference from + /// `lights`). Says where it found the device when it had moved. + fn light( + &self, + device: &str, + command: super::lights::Command, + ) -> Result, String>; } /// `30s`, `1m 30s`, or empty for no delay, so `{delay|off}` reads naturally. @@ -125,11 +147,14 @@ pub fn fmt_delay(ms: u32) -> String { return String::new(); } let total = (ms + 500) / 1000; - match (total / 60, total % 60) { - (0, s) => format!("{s}s"), - (m, 0) => format!("{m}m"), - (m, s) => format!("{m}m {s}s"), - } + let (h, m, s) = (total / 3600, total % 3600 / 60, total % 60); + let parts = [(h, "h"), (m, "m"), (s, "s")]; + let shown: Vec = parts + .iter() + .filter(|(n, _)| *n > 0) + .map(|(n, unit)| format!("{n}{unit}")) + .collect(); + shown.join(" ") } /// A stream position, the way YouTube chapters and VOD players write it: @@ -175,6 +200,8 @@ mod tests { assert_eq!(fmt_delay(90_000), "1m 30s"); assert_eq!(fmt_delay(120_000), "2m"); assert_eq!(fmt_delay(29_600), "30s"); + assert_eq!(fmt_delay(3_600_000), "1h"); + assert_eq!(fmt_delay(3_930_000), "1h 5m 30s"); } #[test] diff --git a/src/integrations/lights/govee.rs b/src/integrations/lights/govee.rs new file mode 100644 index 0000000..eccc092 --- /dev/null +++ b/src/integrations/lights/govee.rs @@ -0,0 +1,142 @@ +//! Govee's LAN API, as govee-local-api uses it. Each device needs "LAN +//! Control" switched on in the Govee Home app first. +//! +//! A scan goes to the multicast group on port 4001 and devices answer on +//! port 4002 of this PC; commands go to the device on port 4003, which +//! sends no answer to them. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; +use std::time::Duration; + +const GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); +const SCAN_PORT: u16 = 4001; +const ANSWER_PORT: u16 = 4002; +pub const COMMAND_PORT: u16 = 4003; +const SCAN: &[u8] = br#"{"msg":{"cmd":"scan","data":{"account_topic":"reserve"}}}"#; +/// Commands are never answered, so each goes out twice: a lost one on +/// Wi-Fi would otherwise go unnoticed. +const SENDS: usize = 2; +const BETWEEN: Duration = Duration::from_millis(60); + +pub fn scan(wait: Duration) -> Vec { + // Answers come to a fixed port. Another Govee tool on this PC may hold + // it; then there's no hearing them. + let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, ANSWER_PORT)) else { + return Vec::new(); + }; + let _ = socket.send_to(SCAN, (GROUP, SCAN_PORT)); + net::collect(&socket, wait) + .into_iter() + .filter_map(|(from, data)| device_of(from, &data)) + .collect() +} + +/// A scan answer: `{"msg":{"cmd":"scan","data":{"ip":..,"device":..,"sku":..}}}`. +fn device_of(from: Ipv4Addr, data: &[u8]) -> Option { + let v = json::parse(&String::from_utf8_lossy(data)).ok()?; + if v.path("msg.cmd").and_then(Value::as_str) != Some("scan") { + return None; + } + let d = v.path("msg.data")?; + let id = d.str_or("device", "").to_string(); + if id.is_empty() { + return None; + } + let address = d.str_or("ip", "").parse().unwrap_or(from); + let model = d.str_or("sku", ""); + Some(Device { + brand: Brand::Govee, + address, + name: format!("Govee {model} ··{}", net::tail(&id)).replace(" ", " "), + id, + plug: false, + }) +} + +fn command(cmd: &str, data: Value) -> String { + json::obj([("msg", json::obj([("cmd", json::str(cmd)), ("data", data)]))]).to_json() +} + +/// The commands for a state, in order: power, then color, then brightness. +pub fn messages(state: State) -> Vec { + let number = |n: u8| Value::Num(f64::from(n)); + let mut out = vec![command( + "turn", + json::obj([("value", number(u8::from(state.on)))]), + )]; + if !state.on { + return out; + } + if let Some([r, g, b]) = state.color { + let color = json::obj([("r", number(r)), ("g", number(g)), ("b", number(b))]); + out.push(command( + "colorwc", + json::obj([("color", color), ("colorTemInKelvin", number(0))]), + )); + } + if state.color.is_some() || state.brightness.is_some() { + out.push(command( + "brightness", + json::obj([("value", number(super::percent(&state)))]), + )); + } + out +} + +pub fn set(to: SocketAddr, state: State) -> Result<(), Failure> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .map_err(|e| Failure::Unreachable(format!("couldn't open a network socket: {e}")))?; + for message in messages(state) { + for _ in 0..SENDS { + socket + .send_to(message.as_bytes(), to) + .map_err(|e| Failure::Unreachable(format!("couldn't reach {}: {e}", to.ip())))?; + std::thread::sleep(BETWEEN); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scan_answers_name_the_device() { + let answer = br#"{"msg":{"cmd":"scan","data":{"ip":"192.168.1.23","device":"1F:80:C5:32:32:36:72:4E","sku":"H618E","bleVersionHard":"3.01.01","wifiVersionSoft":"1.02.03"}}}"#; + let d = device_of(Ipv4Addr::new(192, 168, 1, 99), answer).unwrap(); + assert_eq!( + d.address, + Ipv4Addr::new(192, 168, 1, 23), + "the address it says" + ); + assert_eq!(d.id, "1F:80:C5:32:32:36:72:4E"); + assert_eq!(d.name, "Govee H618E ··724e"); + assert!(device_of(d.address, br#"{"msg":{"cmd":"devStatus","data":{}}}"#).is_none()); + } + + #[test] + fn a_color_is_power_color_then_brightness() { + let state = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(60), + }; + assert_eq!( + messages(state), + [ + r#"{"msg":{"cmd":"turn","data":{"value":1}}}"#, + r#"{"msg":{"cmd":"colorwc","data":{"color":{"r":255,"g":0,"b":0},"colorTemInKelvin":0}}}"#, + r#"{"msg":{"cmd":"brightness","data":{"value":60}}}"#, + ] + ); + let off = State { + on: false, + color: Some([1, 2, 3]), + brightness: None, + }; + assert_eq!(messages(off).len(), 1, "off is only off"); + } +} diff --git a/src/integrations/lights/http.rs b/src/integrations/lights/http.rs new file mode 100644 index 0000000..0df4d78 --- /dev/null +++ b/src/integrations/lights/http.rs @@ -0,0 +1,297 @@ +//! Shelly relays and plugs, and WLED lights: both answer plain HTTP on the +//! local network, as aioshelly and python-wled talk to them. +//! +//! Neither answers a broadcast, so a scan knocks on port 80 of every +//! address on this PC's network (a /24) and asks whatever answers what it +//! is: Shelly at `/shelly`, WLED at `/json/info`. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::Duration; + +const KNOCK: Duration = Duration::from_millis(300); +const ASK: Duration = Duration::from_millis(1500); +const COMMAND: Duration = Duration::from_secs(3); +/// How many addresses are knocked on at once. +const WORKERS: usize = 32; +const MAX_ANSWER: u64 = 64 * 1024; + +pub fn scan() -> Vec { + let Some(me) = net::local_ipv4() else { + return Vec::new(); + }; + let [a, b, c, _] = me.octets(); + let hosts: Vec = (1..=254u8) + .map(|d| Ipv4Addr::new(a, b, c, d)) + .filter(|ip| *ip != me) + .collect(); + std::thread::scope(|scope| { + let workers: Vec<_> = hosts + .chunks(hosts.len().div_ceil(WORKERS)) + .map(|chunk| { + scope.spawn(move || { + chunk + .iter() + .filter_map(|ip| identify(*ip)) + .collect::>() + }) + }) + .collect(); + workers + .into_iter() + .flat_map(|w| w.join().unwrap_or_default()) + .collect() + }) +} + +/// What answers on port 80 at `ip`, if it's a Shelly or a WLED. +fn identify(ip: Ipv4Addr) -> Option { + TcpStream::connect_timeout(&SocketAddr::from((ip, 80)), KNOCK).ok()?; + if let Some(v) = get(ip, "/shelly", ASK) { + return shelly_of(ip, &v); + } + wled_of(ip, &get(ip, "/json/info", ASK)?) +} + +/// The agent for a device on this network: plain HTTP, giving up after +/// `timeout`, with every status handed back as Ok for `answered` to read. +/// It never follows a redirect, so a device can only answer for itself, +/// never send the call on to another address. +fn lan_agent(timeout: Duration) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(timeout)) + .max_redirects(0) + .http_status_as_error(false) + .build() + .into() +} + +fn get(ip: Ipv4Addr, path: &str, timeout: Duration) -> Option { + let mut resp = lan_agent(timeout) + .get(&format!("http://{ip}{path}")) + .call() + .ok()?; + let text = resp + .body_mut() + .with_config() + .limit(MAX_ANSWER) + .read_to_string() + .ok()?; + json::parse(&text).ok() +} + +/// `/shelly`: generation 2 and later say `gen`; generation 1 says `type`. +fn shelly_of(ip: Ipv4Addr, v: &Value) -> Option { + let mac = v.str_or("mac", "").to_string(); + if mac.is_empty() { + return None; + } + let gen2 = v + .get("gen") + .and_then(Value::as_f64) + .is_some_and(|g| g >= 2.0); + let model = if gen2 { + v.str_or("app", "") + } else { + v.str_or("type", "") + }; + if !gen2 && model.is_empty() { + return None; + } + let name = match v.get("name").and_then(Value::as_str) { + Some(name) if !name.is_empty() => name.to_string(), + _ => format!("Shelly {model} ··{}", net::tail(&mac)), + }; + let brand = if gen2 { Brand::Shelly2 } else { Brand::Shelly1 }; + Some(Device { + brand, + address: ip, + id: mac, + name, + plug: true, + }) +} + +/// `/json/info`: WLED says so in `brand`. +fn wled_of(ip: Ipv4Addr, v: &Value) -> Option { + if v.str_or("brand", "") != "WLED" { + return None; + } + let mac = v.str_or("mac", "").to_string(); + if mac.is_empty() { + return None; + } + let name = match v.str_or("name", "") { + "" => format!("WLED ··{}", net::tail(&mac)), + name => name.to_string(), + }; + Some(Device { + brand: Brand::Wled, + address: ip, + id: mac, + name, + plug: false, + }) +} + +/// What the Shelly switch call is for this generation. +pub fn shelly_path(generation: u8, on: bool) -> String { + if generation >= 2 { + format!("/rpc/Switch.Set?id=0&on={on}") + } else { + format!("/relay/0?turn={}", if on { "on" } else { "off" }) + } +} + +pub fn shelly(ip: Ipv4Addr, generation: u8, on: bool) -> Result<(), Failure> { + let url = format!("http://{ip}{}", shelly_path(generation, on)); + let result = lan_agent(COMMAND).get(&url).call(); + answered(result, "Shelly", ip) +} + +/// WLED's state: on, brightness (0 to 255) and segment 0's color, solid. +pub fn wled_body(state: State) -> String { + let mut body = vec![("on".to_string(), Value::Bool(state.on))]; + if state.on { + if state.color.is_some() || state.brightness.is_some() { + // Percent to 0-255, rounded in whole numbers (50% is 128). + let bri = (u32::from(super::percent(&state)) * 255 + 50) / 100; + body.push(("bri".to_string(), Value::Num(f64::from(bri)))); + } + if let Some(rgb) = state.color { + let col = Value::Arr(vec![Value::Arr( + rgb.iter().map(|c| Value::Num(f64::from(*c))).collect(), + )]); + let segment = json::obj([ + ("id", Value::Num(0.0)), + ("col", col), + ("fx", Value::Num(0.0)), + ]); + body.push(("seg".to_string(), Value::Arr(vec![segment]))); + } + } + Value::Obj(body).to_json() +} + +pub fn wled(ip: Ipv4Addr, state: State) -> Result<(), Failure> { + let result = lan_agent(COMMAND) + .post(&format!("http://{ip}/json/state")) + .header("Content-Type", "application/json") + .send(wled_body(state)); + answered(result, "WLED", ip) +} + +fn answered( + result: Result, ureq::Error>, + what: &str, + ip: Ipv4Addr, +) -> Result<(), Failure> { + // The agent hands back every status as Ok: read it here. + match result.map(|resp| resp.status().as_u16()) { + Ok(200..=299) => Ok(()), + Ok(401) => Err(Failure::Refused(format!( + "the {what} at {ip} has a password. Turn it off in its app or web page to use it here" + ))), + Ok(code) => Err(Failure::Refused(format!( + "the {what} at {ip} said no (HTTP {code})" + ))), + Err(e) => Err(Failure::Unreachable(format!( + "the {what} at {ip} didn't answer ({e})" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shelly_generations_are_told_apart() { + let ip = Ipv4Addr::new(192, 168, 1, 40); + let gen2 = json::parse(r#"{"name":"Ring light","id":"shellyplugsg3-abcdef","mac":"ABCDEF123456","model":"S3PL-00112EU","gen":3,"app":"PlugSG3","auth_en":false}"#).unwrap(); + let d = shelly_of(ip, &gen2).unwrap(); + assert_eq!( + (d.brand, d.name.as_str(), d.plug), + (Brand::Shelly2, "Ring light", true) + ); + let gen1 = + json::parse(r#"{"type":"SHPLG-S","mac":"A4CF12F45678","auth":false,"fw":"20230913"}"#) + .unwrap(); + let d = shelly_of(ip, &gen1).unwrap(); + assert_eq!( + (d.brand, d.name.as_str()), + (Brand::Shelly1, "Shelly SHPLG-S ··5678") + ); + assert_eq!(shelly_path(1, true), "/relay/0?turn=on"); + assert_eq!(shelly_path(3, false), "/rpc/Switch.Set?id=0&on=false"); + } + + #[test] + fn wled_is_known_by_its_brand_and_set_by_segment() { + let ip = Ipv4Addr::new(192, 168, 1, 41); + let info = json::parse( + r#"{"ver":"0.14.0","name":"Desk strip","brand":"WLED","mac":"3c71bf0a1b2c"}"#, + ) + .unwrap(); + assert_eq!(wled_of(ip, &info).unwrap().name, "Desk strip"); + assert!(wled_of(ip, &json::parse(r#"{"brand":"Other","mac":"x"}"#).unwrap()).is_none()); + let red = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(50), + }; + assert_eq!( + wled_body(red), + r#"{"on":true,"bri":128,"seg":[{"id":0,"col":[[255,0,0]],"fx":0}]}"# + ); + assert_eq!( + wled_body(State { + on: false, + color: None, + brightness: None + }), + r#"{"on":false}"# + ); + } + + /// The agent hands every status back as Ok: a device answering 401 or + /// 500 must still read as refused, not as switched. A redirect is an + /// answer too, never followed to the address it names. + #[test] + fn a_device_saying_no_is_not_reported_as_done() { + use std::io::{Read, Write}; + let answer = |status: &'static str| { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf); + // Followed, the redirect would meet a closed port instead. + let reply = format!( + "HTTP/1.1 {status}\r\nLocation: http://127.0.0.1:9/\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n" + ); + sock.write_all(reply.as_bytes()).unwrap(); + }); + let result = lan_agent(COMMAND) + .get(&format!("http://127.0.0.1:{port}/relay/0?turn=on")) + .call(); + answered(result, "Shelly", Ipv4Addr::LOCALHOST) + }; + assert_eq!(answer("200 OK"), Ok(())); + assert!( + matches!(answer("401 Unauthorized"), Err(Failure::Refused(why)) if why.contains("password")), + "a password-protected device" + ); + assert!( + matches!(answer("500 Internal Server Error"), Err(Failure::Refused(why)) if why.contains("HTTP 500")), + "a device error" + ); + assert!( + matches!(answer("302 Found"), Err(Failure::Refused(why)) if why.contains("HTTP 302")), + "a redirect, not followed" + ); + } +} diff --git a/src/integrations/lights/kasa.rs b/src/integrations/lights/kasa.rs new file mode 100644 index 0000000..999e385 --- /dev/null +++ b/src/integrations/lights/kasa.rs @@ -0,0 +1,257 @@ +//! TP-Link Kasa plugs and bulbs that still speak the original local +//! protocol, as python-kasa's XOR transport does: JSON "encrypted" with an +//! autokey XOR starting at 171. A scan broadcasts `get_sysinfo` over UDP +//! on port 9999; commands go over TCP on 9999, each prefixed by its length. +//! +//! Newer Kasa firmware and Tapo devices need a TP-Link account login +//! (their KLAP protocol) and aren't found by this scan. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::Duration; + +pub const PORT: u16 = 9999; +const KEY: u8 = 171; +const SYSINFO: &str = r#"{"system":{"get_sysinfo":{}}}"#; +const TIMEOUT: Duration = Duration::from_secs(3); +/// A Kasa answer is a few kilobytes at most. +const MAX_ANSWER: usize = 64 * 1024; +const LIGHT_SERVICE: &str = "smartlife.iot.smartbulb.lightingservice"; + +fn encrypt(plain: &str) -> Vec { + let mut key = KEY; + plain + .bytes() + .map(|b| { + key ^= b; + key + }) + .collect() +} + +fn decrypt(cipher: &[u8]) -> String { + let mut key = KEY; + let plain: Vec = cipher + .iter() + .map(|&c| { + let p = key ^ c; + key = c; + p + }) + .collect(); + String::from_utf8_lossy(&plain).into_owned() +} + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + // Over UDP the length prefix is left out. + let ask = encrypt(SYSINFO); + for to in net::broadcast_addresses() { + let _ = socket.send_to(&ask, (to, PORT)); + } + net::collect(&socket, wait) + .into_iter() + .filter_map(|(address, data)| device_of(address, &decrypt(&data))) + .collect() +} + +/// A `get_sysinfo` answer as a device: a plug, or a bulb. +fn device_of(address: Ipv4Addr, answer: &str) -> Option { + let v = json::parse(answer).ok()?; + let info = v.path("system.get_sysinfo")?; + let kind = + format!("{}{}", info.str_or("type", ""), info.str_or("mic_type", "")).to_ascii_uppercase(); + let brand = if kind.contains("PLUG") { + Brand::KasaPlug + } else if kind.contains("BULB") { + Brand::KasaBulb + } else { + return None; + }; + let id = info.str_or("deviceId", "").to_string(); + if id.is_empty() { + return None; + } + let name = match info.str_or("alias", "") { + "" => format!("Kasa {}", info.str_or("model", "")) + .trim() + .to_string(), + alias => alias.to_string(), + }; + Some(Device { + brand, + address, + id, + name, + plug: brand == Brand::KasaPlug, + }) +} + +/// The command for a state: a plug's relay, or a bulb's light state. +pub fn request(plug: bool, state: State) -> String { + if plug { + let relay = json::obj([("state", Value::Num(f64::from(u8::from(state.on))))]); + return json::obj([("system", json::obj([("set_relay_state", relay)]))]).to_json(); + } + let mut light = vec![ + ( + "on_off".to_string(), + Value::Num(f64::from(u8::from(state.on))), + ), + ("transition_period".to_string(), Value::Num(300.0)), + ]; + if state.on { + if let Some(rgb) = state.color { + let (hue, saturation, _) = super::to_hsv(rgb); + light.push(("hue".to_string(), Value::Num(hue.round()))); + light.push(( + "saturation".to_string(), + Value::Num((saturation * 100.0).round()), + )); + light.push(("color_temp".to_string(), Value::Num(0.0))); + } + if state.color.is_some() || state.brightness.is_some() { + light.push(( + "brightness".to_string(), + Value::Num(f64::from(super::percent(&state))), + )); + } + light.push(("ignore_default".to_string(), Value::Num(1.0))); + } + json::obj([( + LIGHT_SERVICE, + json::obj([("transition_light_state", Value::Obj(light))]), + )]) + .to_json() +} + +pub fn set(to: SocketAddr, plug: bool, state: State) -> Result<(), Failure> { + let answer = call(to, &request(plug, state))?; + let v = json::parse(&answer).unwrap_or(Value::Null); + // Every module answers with err_code 0 when it did it. + let code = |path: &str| v.path(path).and_then(Value::as_f64); + let result = if plug { + code("system.set_relay_state.err_code") + } else { + code(&format!("{LIGHT_SERVICE}.transition_light_state.err_code")) + }; + match result { + Some(0.0) => Ok(()), + _ => Err(Failure::Refused( + "the Kasa device didn't take it".to_string(), + )), + } +} + +/// One request over TCP: length-prefixed both ways. +fn call(to: SocketAddr, plain: &str) -> Result { + let lost = |e: std::io::Error| { + Failure::Unreachable(format!( + "the Kasa device at {} didn't answer ({e})", + to.ip() + )) + }; + let mut stream = TcpStream::connect_timeout(&to, TIMEOUT).map_err(lost)?; + stream.set_read_timeout(Some(TIMEOUT)).map_err(lost)?; + let body = encrypt(plain); + let mut message = (body.len() as u32).to_be_bytes().to_vec(); + message.extend_from_slice(&body); + stream.write_all(&message).map_err(lost)?; + let mut length = [0u8; 4]; + stream.read_exact(&mut length).map_err(lost)?; + let length = u32::from_be_bytes(length) as usize; + if length > MAX_ANSWER { + return Err(Failure::Refused( + "the Kasa device sent too much".to_string(), + )); + } + let mut answer = vec![0u8; length]; + stream.read_exact(&mut answer).map_err(lost)?; + Ok(decrypt(&answer)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + /// The bytes python-kasa's XorEncryption makes of the discovery query. + #[test] + fn xor_matches_python_kasa() { + let cipher = encrypt(SYSINFO); + let hex: String = cipher.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "d0f281f88bff9af7d5ef94b6d1b4c09fec95e68fe187e8caf08bf68bf6" + ); + assert_eq!(decrypt(&cipher), SYSINFO); + } + + #[test] + fn sysinfo_tells_plugs_from_bulbs() { + let plug = r#"{"system":{"get_sysinfo":{"alias":"Desk lamp","type":"IOT.SMARTPLUGSWITCH","deviceId":"8006AB","model":"HS103(US)","relay_state":0}}}"#; + let d = device_of(Ipv4Addr::new(192, 168, 1, 5), plug).unwrap(); + assert_eq!( + (d.brand, d.name.as_str(), d.plug), + (Brand::KasaPlug, "Desk lamp", true) + ); + let bulb = r#"{"system":{"get_sysinfo":{"alias":"","mic_type":"IOT.SMARTBULB","deviceId":"80121C","model":"KL130(US)","is_color":1}}}"#; + let d = device_of(Ipv4Addr::new(192, 168, 1, 6), bulb).unwrap(); + assert_eq!( + (d.brand, d.name.as_str()), + (Brand::KasaBulb, "Kasa KL130(US)") + ); + assert!(device_of( + d.address, + r#"{"system":{"get_sysinfo":{"type":"IOT.SMARTCAM","deviceId":"x"}}}"# + ) + .is_none()); + } + + #[test] + fn a_plug_switches_over_tcp_with_its_length() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = listener.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut length = [0u8; 4]; + stream.read_exact(&mut length).unwrap(); + let mut body = vec![0u8; u32::from_be_bytes(length) as usize]; + stream.read_exact(&mut body).unwrap(); + let asked = decrypt(&body); + let answer = encrypt(r#"{"system":{"set_relay_state":{"err_code":0}}}"#); + stream + .write_all(&(answer.len() as u32).to_be_bytes()) + .unwrap(); + stream.write_all(&answer).unwrap(); + asked + }); + let on = State { + on: true, + color: None, + brightness: None, + }; + assert_eq!(set(at, true, on), Ok(())); + assert_eq!( + fake.join().unwrap(), + r#"{"system":{"set_relay_state":{"state":1}}}"# + ); + } + + #[test] + fn a_bulb_color_is_hue_saturation_brightness() { + let red = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(70), + }; + assert_eq!( + request(false, red), + r#"{"smartlife.iot.smartbulb.lightingservice":{"transition_light_state":{"on_off":1,"transition_period":300,"hue":0,"saturation":100,"color_temp":0,"brightness":70,"ignore_default":1}}}"# + ); + } +} diff --git a/src/integrations/lights/lifx.rs b/src/integrations/lights/lifx.rs new file mode 100644 index 0000000..5f5e7c0 --- /dev/null +++ b/src/integrations/lights/lifx.rs @@ -0,0 +1,180 @@ +//! LIFX: its binary LAN protocol over UDP on port 56700, as aiolifx packs +//! it. Every packet starts with a 36-byte little-endian header; a scan +//! broadcasts "get the light's state", which answers with its label. + +use super::{net, Brand, Device, Failure, State}; +use std::net::SocketAddr; +use std::time::Duration; + +pub const PORT: u16 = 56700; +const HEADER: usize = 36; +/// The protocol number every packet carries. +const PROTOCOL: u16 = 1024; +/// Any non-zero source: lights then answer us directly, not by broadcast. +const SOURCE: u32 = 0x1C1A_5E7D; +const LIGHT_GET: u16 = 101; +const LIGHT_SET_COLOR: u16 = 102; +const LIGHT_STATE: u16 = 107; +const LIGHT_SET_POWER: u16 = 117; +const ACKNOWLEDGEMENT: u16 = 45; +/// The white point colors are mixed at. +const KELVIN: u16 = 3500; +const TRIES: usize = 3; +const EACH: Duration = Duration::from_millis(400); + +/// A packet: header (frame, frame address, protocol header) and payload. +fn packet(kind: u16, target: [u8; 8], ack: bool, payload: &[u8]) -> Vec { + let tagged = target == [0; 8]; + let mut out = Vec::with_capacity(HEADER + payload.len()); + out.extend_from_slice(&((HEADER + payload.len()) as u16).to_le_bytes()); + let flags = PROTOCOL | (1 << 12) | (u16::from(tagged) << 13); + out.extend_from_slice(&flags.to_le_bytes()); + out.extend_from_slice(&SOURCE.to_le_bytes()); + out.extend_from_slice(&target); + out.extend_from_slice(&[0; 6]); + // ack_required, and res_required for a question. + out.push(if ack { 0b10 } else { 0b01 }); + out.push(0); // sequence + out.extend_from_slice(&[0; 8]); + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&[0; 2]); + out.extend_from_slice(payload); + out +} + +fn kind_of(data: &[u8]) -> Option { + (data.len() >= HEADER).then(|| u16::from_le_bytes([data[32], data[33]])) +} + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + let ask = packet(LIGHT_GET, [0; 8], false, &[]); + for to in net::broadcast_addresses() { + let _ = socket.send_to(&ask, (to, PORT)); + } + net::collect(&socket, wait) + .into_iter() + .filter_map(|(address, data)| { + let (id, label) = light_state(&data)?; + Some(Device { + brand: Brand::Lifx, + address, + name: if label.is_empty() { + format!("LIFX ··{}", net::tail(&id)) + } else { + label + }, + id, + plug: false, + }) + }) + .collect() +} + +/// A LightState answer: the light's id (its MAC, as hex) and label. +fn light_state(data: &[u8]) -> Option<(String, String)> { + if kind_of(data)? != LIGHT_STATE || data.len() < HEADER + 44 { + return None; + } + let id: String = data[8..14].iter().map(|b| format!("{b:02x}")).collect(); + let label = &data[HEADER + 12..HEADER + 44]; + let end = label.iter().position(|&b| b == 0).unwrap_or(label.len()); + Some(( + id, + String::from_utf8_lossy(&label[..end]).trim().to_string(), + )) +} + +/// The 8-byte target of a light: its MAC, then two zero bytes. +fn target(id: &str) -> Option<[u8; 8]> { + let mut out = [0u8; 8]; + for (i, slot) in out.iter_mut().take(6).enumerate() { + *slot = u8::from_str_radix(id.get(i * 2..i * 2 + 2)?, 16).ok()?; + } + Some(out) +} + +/// SetColor's payload: a reserved byte, hue, saturation, brightness and +/// kelvin (each 0 to 65535), then the fade in milliseconds. +fn color_payload(rgb: [u8; 3], brightness: u8) -> Vec { + let (hue, saturation, _) = super::to_hsv(rgb); + let scale = |x: f64| (x * 65535.0).round().clamp(0.0, 65535.0) as u16; + let mut out = vec![0u8]; + out.extend_from_slice(&scale(hue / 360.0).to_le_bytes()); + out.extend_from_slice(&scale(saturation).to_le_bytes()); + out.extend_from_slice(&scale(f64::from(brightness) / 100.0).to_le_bytes()); + out.extend_from_slice(&KELVIN.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out +} + +fn power_payload(on: bool) -> Vec { + let mut out = (if on { u16::MAX } else { 0 }).to_le_bytes().to_vec(); + out.extend_from_slice(&0u32.to_le_bytes()); + out +} + +pub fn set(to: SocketAddr, id: &str, state: State) -> Result<(), Failure> { + let target = + target(id).ok_or_else(|| Failure::Refused("pick the LIFX light again".to_string()))?; + let acked = |r: &[u8]| kind_of(r) == Some(ACKNOWLEDGEMENT); + if let Some(rgb) = state.color.filter(|_| state.on) { + let color = packet( + LIGHT_SET_COLOR, + target, + true, + &color_payload(rgb, super::percent(&state)), + ); + net::request(to, &color, TRIES, EACH, acked)?; + } + let power = packet(LIGHT_SET_POWER, target, true, &power_payload(state.on)); + net::request(to, &power, TRIES, EACH, acked).map(drop) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_header_matches_the_lan_protocol() { + let p = packet(LIGHT_GET, [0; 8], false, &[]); + assert_eq!(p.len(), 36); + assert_eq!(u16::from_le_bytes([p[0], p[1]]), 36, "size"); + // protocol 1024, addressable, tagged (a broadcast) + assert_eq!(u16::from_le_bytes([p[2], p[3]]), 0x3400); + assert_eq!(p[22], 0b01, "res_required"); + assert_eq!(u16::from_le_bytes([p[32], p[33]]), 101); + + let mac = target("d073d5010203").unwrap(); + assert_eq!(mac, [0xd0, 0x73, 0xd5, 1, 2, 3, 0, 0]); + let p = packet(LIGHT_SET_COLOR, mac, true, &color_payload([255, 0, 0], 100)); + assert_eq!(p.len(), 36 + 13); + assert_eq!(u16::from_le_bytes([p[2], p[3]]), 0x1400, "not tagged"); + assert_eq!(&p[8..14], &[0xd0, 0x73, 0xd5, 1, 2, 3]); + assert_eq!(p[22], 0b10, "ack_required"); + // hue 0, saturation and brightness full, 3500 K, no fade + assert_eq!( + &p[36..49], + &[0, 0, 0, 255, 255, 255, 255, 0xac, 0x0d, 0, 0, 0, 0] + ); + } + + #[test] + fn a_light_state_gives_id_and_label() { + let mut answer = packet( + LIGHT_STATE, + target("d073d5010203").unwrap(), + false, + &[0; 52], + ); + answer[HEADER + 12..HEADER + 17].copy_from_slice(b"Desk "); + assert_eq!( + light_state(&answer), + Some(("d073d5010203".to_string(), "Desk".to_string())) + ); + assert_eq!(light_state(&answer[..40]), None, "cut short"); + assert_eq!(target("d073"), None); + } +} diff --git a/src/integrations/lights/mod.rs b/src/integrations/lights/mod.rs new file mode 100644 index 0000000..e37a643 --- /dev/null +++ b/src/integrations/lights/mod.rs @@ -0,0 +1,487 @@ +//! Smart lights and plugs on the local network, found and driven with no +//! hub, account or cloud: WiZ, LIFX, Govee (with LAN Control on), Yeelight +//! (with LAN Control on), older TP-Link Kasa models, Shelly and WLED. +//! +//! Each brand is reached the way the open-source library Home Assistant +//! uses for it does: pywizlight, aiolifx, govee-local-api, python-yeelight, +//! python-kasa, aioshelly and python-wled. +//! +//! A step saves a device as a reference, `wiz:192.168.1.20#a8bb50a4f94d`: +//! the brand, the address it was last seen at, and its own id. A router can +//! hand a device a new address; when one stops answering, its brand is +//! scanned again for that id and the new address is used, and remembered +//! for this run. +//! +//! Everything here blocks: callers run it off the async threads. + +mod govee; +mod http; +mod kasa; +mod lifx; +mod net; +mod wiz; +mod yeelight; + +use crate::json::{self, Value}; +use crate::sync::Mutex; +use std::collections::BTreeMap; +use std::net::Ipv4Addr; +use std::time::Duration; + +/// How long a scan listens for answers. +pub const SCAN_WAIT: Duration = Duration::from_millis(2500); +/// A rescan for a device that moved: shorter, it only needs that one. +const FIND_WAIT: Duration = Duration::from_millis(1800); +/// Flashing: how many times, and how long each on and off lasts. +const FLASHES: usize = 3; +const FLASH_STEP: Duration = Duration::from_millis(350); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Brand { + Wiz, + Lifx, + Govee, + Yeelight, + KasaPlug, + KasaBulb, + Shelly1, + Shelly2, + Wled, +} + +const BRANDS: [Brand; 9] = [ + Brand::Wiz, + Brand::Lifx, + Brand::Govee, + Brand::Yeelight, + Brand::KasaPlug, + Brand::KasaBulb, + Brand::Shelly1, + Brand::Shelly2, + Brand::Wled, +]; + +impl Brand { + fn id(self) -> &'static str { + match self { + Brand::Wiz => "wiz", + Brand::Lifx => "lifx", + Brand::Govee => "govee", + Brand::Yeelight => "yeelight", + Brand::KasaPlug => "kasa_plug", + Brand::KasaBulb => "kasa_bulb", + Brand::Shelly1 => "shelly1", + Brand::Shelly2 => "shelly2", + Brand::Wled => "wled", + } + } + + fn from_id(id: &str) -> Option { + BRANDS.into_iter().find(|b| b.id() == id) + } + + fn label(self) -> &'static str { + match self { + Brand::Wiz => "WiZ", + Brand::Lifx => "LIFX", + Brand::Govee => "Govee", + Brand::Yeelight => "Yeelight", + Brand::KasaPlug | Brand::KasaBulb => "TP-Link Kasa", + Brand::Shelly1 | Brand::Shelly2 => "Shelly", + Brand::Wled => "WLED", + } + } + + /// Which scan finds this brand: one per way of finding devices. + fn family(self) -> Family { + match self { + Brand::Wiz => Family::Wiz, + Brand::Lifx => Family::Lifx, + Brand::Govee => Family::Govee, + Brand::Yeelight => Family::Yeelight, + Brand::KasaPlug | Brand::KasaBulb => Family::Kasa, + Brand::Shelly1 | Brand::Shelly2 | Brand::Wled => Family::Http, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Family { + Wiz, + Lifx, + Govee, + Yeelight, + Kasa, + Http, +} + +const FAMILIES: [Family; 6] = [ + Family::Wiz, + Family::Lifx, + Family::Govee, + Family::Yeelight, + Family::Kasa, + Family::Http, +]; + +/// A light or plug a scan found. +#[derive(Clone, Debug, PartialEq)] +pub struct Device { + pub brand: Brand, + pub address: Ipv4Addr, + /// The device's own id (a MAC address, mostly): it stays when the + /// address changes. + pub id: String, + pub name: String, + /// Only on and off, no color. + pub plug: bool, +} + +impl Device { + pub fn reference(&self) -> String { + format!("{}:{}#{}", self.brand.id(), self.address, self.id) + } + + pub fn to_json(&self) -> Value { + json::obj([ + ("ref", json::str(self.reference())), + ("name", json::str(&self.name)), + ("brand", json::str(self.brand.label())), + ("address", json::str(self.address.to_string())), + ("plug", Value::Bool(self.plug)), + ]) + } +} + +/// A saved reference, read back: brand, last address, id. +pub fn parse_ref(reference: &str) -> Option<(Brand, Ipv4Addr, String)> { + let (brand, rest) = reference.trim().split_once(':')?; + let (address, id) = rest.split_once('#')?; + let id = id.trim(); + (!id.is_empty()).then_some(())?; + Some(( + Brand::from_id(brand)?, + address.parse().ok()?, + id.to_string(), + )) +} + +/// What one command sets a device to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct State { + pub on: bool, + pub color: Option<[u8; 3]>, + /// Percent, 1 to 100. + pub brightness: Option, +} + +/// What a step asks a device to do. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Command { + On { + brightness: Option, + }, + Off, + Color { + rgb: [u8; 3], + brightness: Option, + }, + /// Blink the color a few times, then stay on in it. + Flash { + rgb: [u8; 3], + brightness: Option, + }, +} + +/// A color as people write it: `#ff2020`, `ff2020`, `#f22` or a name. +pub fn parse_color(text: &str) -> Option<[u8; 3]> { + let text = text.trim().to_ascii_lowercase(); + let named = match text.as_str() { + "red" => Some([255, 32, 32]), + "orange" => Some([255, 110, 0]), + "amber" => Some([255, 160, 0]), + "yellow" => Some([255, 220, 0]), + "green" => Some([0, 220, 70]), + "cyan" => Some([0, 210, 255]), + "blue" => Some([30, 80, 255]), + "purple" => Some([150, 60, 255]), + "pink" => Some([255, 70, 170]), + "white" => Some([255, 255, 255]), + "warm" | "warm white" => Some([255, 190, 120]), + _ => None, + }; + if named.is_some() { + return named; + } + let hex = text.strip_prefix('#').unwrap_or(&text); + let digit = |i: usize| u8::from_str_radix(hex.get(i..i + 1)?, 16).ok(); + match hex.len() { + 6 => { + let byte = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok(); + Some([byte(0)?, byte(2)?, byte(4)?]) + } + 3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]), + _ => None, + } +} + +/// Why a command didn't work. +#[derive(Debug, PartialEq)] +pub enum Failure { + /// Nothing answered at that address: the device may have moved. + Unreachable(String), + /// The device answered, and said no. + Refused(String), +} + +impl Failure { + fn text(self) -> String { + match self { + Failure::Unreachable(why) | Failure::Refused(why) => why, + } + } +} + +/// Where devices were last found this run, by id, when that isn't where +/// their step says. +static MOVED: Mutex> = Mutex::new(BTreeMap::new()); + +/// Look for every supported device on the network, all brands at once. +pub fn scan(wait: Duration) -> Vec { + let mut found: Vec = std::thread::scope(|scope| { + let scans: Vec<_> = FAMILIES + .into_iter() + .map(|family| scope.spawn(move || scan_family(family, wait))) + .collect(); + scans + .into_iter() + .flat_map(|scan| scan.join().unwrap_or_default()) + .collect() + }); + found.sort_by(|a, b| (a.brand, &a.name, a.address).cmp(&(b.brand, &b.name, b.address))); + found.dedup_by(|a, b| a.brand == b.brand && a.id == b.id); + { + let mut moved = MOVED.lock(); + for device in &found { + moved.insert(device.id.clone(), device.address); + } + } + found +} + +fn scan_family(family: Family, wait: Duration) -> Vec { + match family { + Family::Wiz => wiz::scan(wait), + Family::Lifx => lifx::scan(wait), + Family::Govee => govee::scan(wait), + Family::Yeelight => yeelight::scan(wait), + Family::Kasa => kasa::scan(wait), + Family::Http => http::scan(), + } +} + +/// Do what `command` says to the device a step saved. Returns a short +/// note when it had to find the device at a new address. +pub fn send(reference: &str, command: Command) -> Result, String> { + let (brand, saved, id) = parse_ref(reference).ok_or("pick the light or plug again")?; + let address = MOVED.lock().get(&id).copied().unwrap_or(saved); + match run(brand, address, &id, command) { + Ok(()) => Ok(None), + Err(Failure::Refused(why)) => Err(why), + Err(Failure::Unreachable(why)) => { + // Moved? Scan its brand for the same id and try there. + let found = scan_family(brand.family(), FIND_WAIT) + .into_iter() + .find(|d| d.brand == brand && d.id == id); + let Some(device) = found else { + return Err(format!( + "{why}. A scan didn't find it either: is it on, and on the same network as this PC?" + )); + }; + MOVED.lock().insert(id.clone(), device.address); + run(brand, device.address, &id, command).map_err(Failure::text)?; + Ok(Some(format!("found it at {}", device.address))) + } + } +} + +fn run(brand: Brand, address: Ipv4Addr, id: &str, command: Command) -> Result<(), Failure> { + let plug = matches!(brand, Brand::KasaPlug | Brand::Shelly1 | Brand::Shelly2); + match command { + Command::Off => set( + brand, + address, + id, + State { + on: false, + color: None, + brightness: None, + }, + ), + Command::On { brightness } => set( + brand, + address, + id, + State { + on: true, + color: None, + brightness, + }, + ), + Command::Color { rgb, brightness } => { + let color = (!plug).then_some(rgb); + set( + brand, + address, + id, + State { + on: true, + color, + brightness, + }, + ) + } + // A plug isn't flashed: switching what's plugged in on and off + // fast can harm it. It just turns on. + Command::Flash { brightness, .. } if plug => set( + brand, + address, + id, + State { + on: true, + color: None, + brightness, + }, + ), + Command::Flash { rgb, brightness } => { + let lit = State { + on: true, + color: Some(rgb), + brightness, + }; + let off = State { + on: false, + color: None, + brightness: None, + }; + let blink = || -> Result<(), Failure> { + for _ in 0..FLASHES { + set(brand, address, id, lit)?; + std::thread::sleep(FLASH_STEP); + set(brand, address, id, off)?; + std::thread::sleep(FLASH_STEP); + } + Ok(()) + }; + // A flash cut short still ends lit: a failure between "off" and + // "on" used to leave the light dark (an on-air light, say). + let flashed = blink(); + let lit_again = set(brand, address, id, lit); + flashed.and(lit_again) + } + } +} + +fn set(brand: Brand, address: Ipv4Addr, id: &str, state: State) -> Result<(), Failure> { + match brand { + Brand::Wiz => wiz::set((address, wiz::PORT).into(), state), + Brand::Lifx => lifx::set((address, lifx::PORT).into(), id, state), + Brand::Govee => govee::set((address, govee::COMMAND_PORT).into(), state), + Brand::Yeelight => yeelight::set((address, yeelight::PORT).into(), state), + Brand::KasaPlug => kasa::set((address, kasa::PORT).into(), true, state), + Brand::KasaBulb => kasa::set((address, kasa::PORT).into(), false, state), + Brand::Shelly1 => http::shelly(address, 1, state.on), + Brand::Shelly2 => http::shelly(address, 2, state.on), + Brand::Wled => http::wled(address, state), + } +} + +/// Red, green and blue as hue (degrees), saturation and value (0 to 1). +fn to_hsv(rgb: [u8; 3]) -> (f64, f64, f64) { + let [r, g, b] = rgb.map(|c| f64::from(c) / 255.0); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let hue = if delta == 0.0 { + 0.0 + } else if max == r { + 60.0 * ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + let saturation = if max == 0.0 { 0.0 } else { delta / max }; + (hue, saturation, max) +} + +/// The brightness a state asks for, in percent, or what its color says. +fn percent(state: &State) -> u8 { + state.brightness.unwrap_or_else(|| { + state + .color + .map_or(100, |rgb| (to_hsv(rgb).2 * 100.0).round() as u8) + .max(1) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn references_round_trip() { + let d = Device { + brand: Brand::KasaPlug, + address: Ipv4Addr::new(192, 168, 1, 20), + id: "8006ABCD".into(), + name: "Desk".into(), + plug: true, + }; + assert_eq!(d.reference(), "kasa_plug:192.168.1.20#8006ABCD"); + assert_eq!( + parse_ref(&d.reference()), + Some((Brand::KasaPlug, d.address, "8006ABCD".into())) + ); + assert_eq!(parse_ref("wiz:not-an-ip#x"), None); + assert_eq!(parse_ref("hue:192.168.1.2#x"), None, "unknown brand"); + assert_eq!(parse_ref("wiz:192.168.1.2#"), None, "no id"); + } + + #[test] + fn colors_read_hex_short_hex_and_names() { + assert_eq!(parse_color("#FF8000"), Some([255, 128, 0])); + assert_eq!(parse_color("ff8000"), Some([255, 128, 0])); + assert_eq!(parse_color("#f80"), Some([255, 136, 0])); + assert_eq!(parse_color(" Red "), Some([255, 32, 32])); + assert_eq!(parse_color("#ggg"), None); + assert_eq!(parse_color("#ff80"), None); + assert_eq!(parse_color(""), None); + } + + #[test] + fn hsv_matches_the_usual_conversion() { + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + let (h, s, v) = to_hsv([255, 0, 0]); + assert!(close(h, 0.0) && close(s, 1.0) && close(v, 1.0)); + let (h, ..) = to_hsv([0, 0, 255]); + assert!(close(h, 240.0)); + let (h, s, v) = to_hsv([128, 128, 128]); + assert!(close(h, 0.0) && close(s, 0.0) && close(v, 0.502)); + let (h, ..) = to_hsv([255, 0, 128]); + assert!(close(h, 329.88)); + } + + #[test] + fn brightness_is_asked_for_or_comes_from_the_color() { + let state = |color, brightness| State { + on: true, + color, + brightness, + }; + assert_eq!(percent(&state(Some([255, 0, 0]), Some(40))), 40); + assert_eq!(percent(&state(Some([128, 0, 0]), None)), 50); + assert_eq!(percent(&state(None, None)), 100); + assert_eq!(percent(&state(Some([0, 0, 0]), None)), 1, "never fully off"); + } +} diff --git a/src/integrations/lights/net.rs b/src/integrations/lights/net.rs new file mode 100644 index 0000000..b1704ec --- /dev/null +++ b/src/integrations/lights/net.rs @@ -0,0 +1,148 @@ +//! What the brands share: this PC's place on the network, broadcasting a +//! question and hearing every answer, and a UDP request sent again until +//! it is answered. + +use super::Failure; +use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; +use std::time::{Duration, Instant}; + +/// The largest datagram any of these devices sends. +const MAX_DATAGRAM: usize = 4096; + +/// This PC's address on the network it reaches the internet through. +/// Connecting a UDP socket only picks the route: nothing is sent. +pub fn local_ipv4() -> Option { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + socket.connect((Ipv4Addr::new(192, 0, 2, 1), 9)).ok()?; + match socket.local_addr().ok()? { + SocketAddr::V4(v4) if !v4.ip().is_loopback() && !v4.ip().is_unspecified() => Some(*v4.ip()), + _ => None, + } +} + +/// Where a broadcast goes: everywhere this PC's network reaches, and that +/// network's own broadcast address. Windows sends 255.255.255.255 out of +/// one adapter only, which with a VPN or a virtual network can be the +/// wrong one. Home networks are /24 almost always. +pub fn broadcast_addresses() -> Vec { + let mut out = vec![Ipv4Addr::BROADCAST]; + if let Some(ip) = local_ipv4() { + let [a, b, c, _] = ip.octets(); + out.push(Ipv4Addr::new(a, b, c, 255)); + } + out +} + +/// A socket for a scan: any port, allowed to broadcast, waiting `wait` at +/// most for each answer. +pub fn scan_socket() -> Option { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + socket.set_broadcast(true).ok()?; + socket.set_multicast_ttl_v4(2).ok()?; + Some(socket) +} + +/// Every datagram that arrives until `wait` has passed, with who sent it. +pub fn collect(socket: &UdpSocket, wait: Duration) -> Vec<(Ipv4Addr, Vec)> { + let deadline = Instant::now() + wait; + let mut out = Vec::new(); + let mut buf = [0u8; MAX_DATAGRAM]; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() || socket.set_read_timeout(Some(left)).is_err() { + return out; + } + match socket.recv_from(&mut buf) { + Ok((n, SocketAddr::V4(from))) => out.push((*from.ip(), buf[..n].to_vec())), + Ok(_) => {} + // A timeout, or Windows reporting an earlier send's ICMP + // "port unreachable" here: keep listening until the deadline. + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {} + Err(_) => return out, + } + } +} + +/// Send `message` to `to` until `answered` likes a reply: `tries` times, +/// waiting `each` for an answer every time. UDP gets lost now and then, +/// on Wi-Fi more than anywhere. +pub fn request( + to: SocketAddr, + message: &[u8], + tries: usize, + each: Duration, + answered: impl Fn(&[u8]) -> bool, +) -> Result, Failure> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) + .map_err(|e| Failure::Unreachable(format!("couldn't open a network socket: {e}")))?; + let mut buf = [0u8; MAX_DATAGRAM]; + for _ in 0..tries { + socket + .send_to(message, to) + .map_err(|e| Failure::Unreachable(format!("couldn't reach {}: {e}", to.ip())))?; + let deadline = Instant::now() + each; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() || socket.set_read_timeout(Some(left)).is_err() { + break; + } + match socket.recv_from(&mut buf) { + Ok((n, from)) if from.ip() == to.ip() && answered(&buf[..n]) => { + return Ok(buf[..n].to_vec()) + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {} + Err(_) => break, + } + } + } + Err(Failure::Unreachable(format!( + "nothing answered at {}", + to.ip() + ))) +} + +/// The last four characters of an id, for a name: "WiZ light ··f94d". +pub fn tail(id: &str) -> String { + let chars: Vec = id.chars().filter(char::is_ascii_alphanumeric).collect(); + let start = chars.len().saturating_sub(4); + chars[start..] + .iter() + .collect::() + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_request_is_sent_again_until_answered() { + let device = UdpSocket::bind("127.0.0.1:0").unwrap(); + let at = device.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let mut buf = [0u8; 64]; + // Drop the first one, as a lossy Wi-Fi might. + let _ = device.recv_from(&mut buf).unwrap(); + let (n, from) = device.recv_from(&mut buf).unwrap(); + device.send_to(&buf[..n], from).unwrap(); + }); + let answer = request(at, b"ping", 3, Duration::from_millis(300), |r| r == b"ping"); + assert_eq!(answer, Ok(b"ping".to_vec())); + fake.join().unwrap(); + } + + #[test] + fn silence_counts_as_unreachable() { + let quiet = UdpSocket::bind("127.0.0.1:0").unwrap(); + let at = quiet.local_addr().unwrap(); + let answer = request(at, b"hello", 2, Duration::from_millis(50), |_| true); + assert!(matches!(answer, Err(Failure::Unreachable(_))), "{answer:?}"); + } + + #[test] + fn names_end_with_the_id() { + assert_eq!(tail("a8:bb:50:A4:F9:4D"), "f94d"); + assert_eq!(tail("ab"), "ab"); + } +} diff --git a/src/integrations/lights/wiz.rs b/src/integrations/lights/wiz.rs new file mode 100644 index 0000000..5f3580c --- /dev/null +++ b/src/integrations/lights/wiz.rs @@ -0,0 +1,156 @@ +//! WiZ (Philips WiZ, and the many brands built on it): JSON over UDP on +//! port 38899, as pywizlight does. A scan broadcasts the app's +//! "registration" question; every bulb and plug answers with its MAC. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::net::SocketAddr; +use std::time::Duration; + +pub const PORT: u16 = 38899; +/// pywizlight's discovery message: a phone registering, which every WiZ +/// device answers. `register:false` keeps it from actually registering. +const REGISTRATION: &[u8] = br#"{"method":"registration","params":{"phoneMac":"AAAAAAAAAAAA","register":false,"phoneIp":"1.2.3.4","id":"1"}}"#; +const TRIES: usize = 4; +const EACH: Duration = Duration::from_millis(500); + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + for to in net::broadcast_addresses() { + let _ = socket.send_to(REGISTRATION, (to, PORT)); + } + let answers = net::collect(&socket, wait); + let mut found: Vec = answers + .into_iter() + .filter_map(|(address, data)| { + let mac = mac_of(&data)?; + Some(Device { + brand: Brand::Wiz, + address, + name: format!("WiZ ··{}", net::tail(&mac)), + id: mac, + plug: false, + }) + }) + .collect(); + // Each answers every broadcast address: ask each device once. + found.sort_by(|a, b| a.id.cmp(&b.id)); + found.dedup_by(|a, b| a.id == b.id); + // Which are plugs: their module name says "SOCKET". + for device in &mut found { + device.plug = + module_name((device.address, PORT).into()).is_some_and(|m| m.contains("SOCKET")); + } + found +} + +fn mac_of(data: &[u8]) -> Option { + let v = json::parse(&String::from_utf8_lossy(data)).ok()?; + let mac = v.path("result.mac").and_then(Value::as_str)?; + (!mac.is_empty()).then(|| mac.to_string()) +} + +fn module_name(to: SocketAddr) -> Option { + let ask = br#"{"method":"getSystemConfig","params":{}}"#; + let answer = net::request(to, ask, 2, Duration::from_millis(300), |r| { + String::from_utf8_lossy(r).contains("getSystemConfig") + }) + .ok()?; + let v = json::parse(&String::from_utf8_lossy(&answer)).ok()?; + v.path("result.moduleName") + .and_then(Value::as_str) + .map(str::to_string) +} + +/// `setPilot`, as the WiZ app sends it. Dimming below 10 isn't allowed. +pub fn message(state: State) -> String { + let mut params = vec![("state".to_string(), Value::Bool(state.on))]; + if state.on { + if let Some([r, g, b]) = state.color { + for (name, c) in [("r", r), ("g", g), ("b", b)] { + params.push((name.to_string(), Value::Num(f64::from(c)))); + } + } + if state.color.is_some() || state.brightness.is_some() { + let dimming = super::percent(&state).clamp(10, 100); + params.push(("dimming".to_string(), Value::Num(f64::from(dimming)))); + } + } + json::obj([ + ("method", json::str("setPilot")), + ("params", Value::Obj(params)), + ]) + .to_json() +} + +pub fn set(to: SocketAddr, state: State) -> Result<(), Failure> { + let answer = net::request(to, message(state).as_bytes(), TRIES, EACH, |r| { + String::from_utf8_lossy(r).contains("setPilot") + })?; + let v = json::parse(&String::from_utf8_lossy(&answer)).unwrap_or(Value::Null); + match v.path("error.message").and_then(Value::as_str) { + Some(why) => Err(Failure::Refused(format!("the WiZ light said no: {why}"))), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::UdpSocket; + + #[test] + fn colors_go_out_as_the_app_sends_them() { + let red = State { + on: true, + color: Some([255, 0, 0]), + // Under WiZ's 10% floor: it goes out as 10. + brightness: Some(5), + }; + assert_eq!( + message(red), + r#"{"method":"setPilot","params":{"state":true,"r":255,"g":0,"b":0,"dimming":10}}"# + ); + let off = State { + on: false, + color: None, + brightness: None, + }; + assert_eq!( + message(off), + r#"{"method":"setPilot","params":{"state":false}}"# + ); + } + + #[test] + fn a_bulb_answering_success_is_set() { + let bulb = UdpSocket::bind("127.0.0.1:0").unwrap(); + let at = bulb.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let mut buf = [0u8; 512]; + let (n, from) = bulb.recv_from(&mut buf).unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).contains("\"state\":true")); + bulb.send_to( + br#"{"method":"setPilot","env":"pro","result":{"success":true}}"#, + from, + ) + .unwrap(); + }); + let on = State { + on: true, + color: None, + brightness: None, + }; + assert_eq!(set(at, on), Ok(())); + fake.join().unwrap(); + } + + #[test] + fn discovery_answers_give_the_mac() { + let answer = br#"{"method":"registration","env":"pro","result":{"mac":"a8bb50a4f94d","success":true}}"#; + assert_eq!(mac_of(answer).as_deref(), Some("a8bb50a4f94d")); + assert_eq!(mac_of(b"{}"), None); + } +} diff --git a/src/integrations/lights/yeelight.rs b/src/integrations/lights/yeelight.rs new file mode 100644 index 0000000..7ed1613 --- /dev/null +++ b/src/integrations/lights/yeelight.rs @@ -0,0 +1,237 @@ +//! Yeelight, as python-yeelight drives it. Each light needs "LAN Control" +//! switched on in the Yeelight app first. +//! +//! A scan is an SSDP-style search on the multicast group, port 1982; +//! lights answer with headers (`Location: yeelight://ip:55443`, `id`, +//! `name`, `model`). Commands are JSON lines over TCP on port 55443. + +use super::{net, Brand, Device, Failure, State}; +use crate::json::{self, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::{Duration, Instant}; + +pub const PORT: u16 = 55443; +const GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); +const SEARCH_PORT: u16 = 1982; +const SEARCH: &[u8] = + b"M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1982\r\nMAN: \"ssdp:discover\"\r\nST: wifi_bulb\r\n"; +const TIMEOUT: Duration = Duration::from_secs(3); +/// The longest line read from a light. Real answers and state notices are +/// well under 1 KB; the cap keeps a device that never ends a line from +/// filling memory. +const MAX_LINE: u64 = 16 * 1024; +/// Changes fade in this long, as the app does. +const FADE_MS: f64 = 300.0; + +pub fn scan(wait: Duration) -> Vec { + let Some(socket) = net::scan_socket() else { + return Vec::new(); + }; + let _ = socket.send_to(SEARCH, (GROUP, SEARCH_PORT)); + net::collect(&socket, wait) + .into_iter() + .filter_map(|(from, data)| device_of(from, &String::from_utf8_lossy(&data))) + .collect() +} + +/// A search answer's headers, as a device. +fn device_of(from: Ipv4Addr, answer: &str) -> Option { + let header = |name: &str| { + answer.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + }; + let location = header("Location")?; + let address = location + .strip_prefix("yeelight://") + .and_then(|rest| rest.split(':').next()) + .and_then(|ip| ip.parse().ok()) + .unwrap_or(from); + let id = header("id").filter(|id| !id.is_empty())?; + let name = match header("name").unwrap_or_default() { + name if !name.is_empty() => name, + _ => format!( + "Yeelight {} ··{}", + header("model").unwrap_or_default(), + net::tail(&id) + ) + .replace(" ", " "), + }; + Some(Device { + brand: Brand::Yeelight, + address, + id, + name, + plug: false, + }) +} + +/// The commands for a state: power first (a light that's off ignores a +/// color), then color, then brightness. +pub fn commands(state: State) -> Vec<(&'static str, Vec)> { + let smooth = || [json::str("smooth"), Value::Num(FADE_MS)]; + let power = if state.on { "on" } else { "off" }; + let mut out = vec![( + "set_power", + [vec![json::str(power)], smooth().to_vec()].concat(), + )]; + if !state.on { + return out; + } + if let Some([r, g, b]) = state.color { + let rgb = (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b); + out.push(( + "set_rgb", + [vec![Value::Num(f64::from(rgb))], smooth().to_vec()].concat(), + )); + } + if state.color.is_some() || state.brightness.is_some() { + let bright = Value::Num(f64::from(super::percent(&state))); + out.push(("set_bright", [vec![bright], smooth().to_vec()].concat())); + } + out +} + +pub fn set(to: SocketAddr, state: State) -> Result<(), Failure> { + let stream = TcpStream::connect_timeout(&to, TIMEOUT).map_err(|e| { + Failure::Unreachable(format!( + "the Yeelight at {} didn't answer ({e}). Is LAN Control on in the Yeelight app?", + to.ip() + )) + })?; + let lost = |e: std::io::Error| Failure::Unreachable(format!("lost the Yeelight: {e}")); + stream.set_read_timeout(Some(TIMEOUT)).map_err(lost)?; + let mut writer = stream.try_clone().map_err(lost)?; + let mut reader = BufReader::new(stream); + for (id, (method, params)) in commands(state).into_iter().enumerate() { + let id = id as f64 + 1.0; + let line = json::obj([ + ("id", Value::Num(id)), + ("method", json::str(method)), + ("params", Value::Arr(params)), + ]) + .to_json(); + writer + .write_all(format!("{line}\r\n").as_bytes()) + .map_err(lost)?; + // The light also sends its state as it changes: read on to the + // answer to this command, for as long as a silent light would get. + // A device that keeps talking but never answers is given up on too. + let deadline = Instant::now() + TIMEOUT; + loop { + if Instant::now() >= deadline { + return Err(Failure::Unreachable( + "the Yeelight never answered the command".to_string(), + )); + } + let mut answer = String::new(); + let line = (&mut reader).take(MAX_LINE).read_line(&mut answer); + if line.map_err(lost)? == 0 { + return Err(Failure::Unreachable( + "the Yeelight closed the connection".to_string(), + )); + } + let Ok(v) = json::parse(answer.trim()) else { + continue; + }; + if v.get("id").and_then(Value::as_f64) != Some(id) { + continue; + } + if let Some(why) = v.path("error.message").and_then(Value::as_str) { + return Err(Failure::Refused(format!("the Yeelight said no: {why}"))); + } + break; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn search_answers_become_devices() { + let answer = "HTTP/1.1 200 OK\r\nCache-Control: max-age=3600\r\nLocation: yeelight://192.168.1.239:55443\r\nid: 0x000000000015243f\r\nmodel: color\r\nname: \r\npower: on\r\n"; + let d = device_of(Ipv4Addr::new(10, 0, 0, 1), answer).unwrap(); + assert_eq!(d.address, Ipv4Addr::new(192, 168, 1, 239)); + assert_eq!(d.id, "0x000000000015243f"); + assert_eq!(d.name, "Yeelight color ··243f"); + assert!(device_of(d.address, "HTTP/1.1 200 OK\r\n").is_none()); + } + + #[test] + fn a_color_powers_on_first_and_waits_for_each_answer() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = listener.local_addr().unwrap(); + let fake = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + let mut seen = Vec::new(); + for id in 1..=3 { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + seen.push(line.trim().to_string()); + // A state notice first, then the answer. + writer + .write_all(b"{\"method\":\"props\",\"params\":{\"power\":\"on\"}}\r\n") + .unwrap(); + writer + .write_all(format!("{{\"id\":{id},\"result\":[\"ok\"]}}\r\n").as_bytes()) + .unwrap(); + } + seen + }); + let state = State { + on: true, + color: Some([255, 0, 0]), + brightness: Some(80), + }; + assert_eq!(set(at, state), Ok(())); + let seen = fake.join().unwrap(); + assert_eq!( + seen[0], + r#"{"id":1,"method":"set_power","params":["on","smooth",300]}"# + ); + assert_eq!( + seen[1], + r#"{"id":2,"method":"set_rgb","params":[16711680,"smooth",300]}"# + ); + assert_eq!( + seen[2], + r#"{"id":3,"method":"set_bright","params":[80,"smooth",300]}"# + ); + } + + /// A device that chatters without ever answering, in lines that never + /// end, used to hold the light's thread for as long as it kept talking. + #[test] + fn a_device_that_never_answers_is_given_up_on() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let at = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + // Until the app hangs up: a state notice, then an endless line. + while stream.write_all(b"{\"method\":\"props\"}\r\n").is_ok() + && stream.write_all(&[b'x'; 4096]).is_ok() + { + std::thread::sleep(Duration::from_millis(20)); + } + }); + let off = State { + on: false, + color: None, + brightness: None, + }; + let started = Instant::now(); + let result = set(at, off); + assert!(matches!(result, Err(Failure::Unreachable(_))), "{result:?}"); + assert!(started.elapsed() < TIMEOUT * 2, "{:?}", started.elapsed()); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 99eeb28..7c44bf0 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -19,8 +19,11 @@ //! - `timeline`: this stream's timeline and YouTube chapters. //! - `activity`: the chat activity trigger's sliding window. //! - `obsws`: the obs-websocket connection scene triggers listen on. +//! - `ws`: the small WebSocket client `obsws` and Twitch EventSub share. +//! - `lights`: smart lights and plugs on the local network, no hub needed. pub mod activity; +pub mod alert_media; pub mod alerts; pub mod api; pub mod botcmd; @@ -29,6 +32,7 @@ pub mod effects; pub mod engine; pub mod event; pub mod host; +pub mod lights; pub mod model; pub mod obsws; pub mod presets; @@ -39,6 +43,7 @@ pub mod store; pub mod template; pub mod timeline; pub mod twitch; +pub mod ws; pub use engine::Handle; /// Only the tray (hotkeys) and the MIDI listener press shortcuts: Windows. diff --git a/src/integrations/model.rs b/src/integrations/model.rs index dcc72b6..9b3e923 100644 --- a/src/integrations/model.rs +++ b/src/integrations/model.rs @@ -284,10 +284,13 @@ pub enum StepKind { Timeline, /// Switch a scene, show or hide a source, or set a text source in OBS. Obs, + /// Turn a light or plug on the local network on or off, or color it + /// (see `lights`). + Light, } impl StepKind { - pub const ALL: [StepKind; 19] = [ + pub const ALL: [StepKind; 20] = [ StepKind::Discord, StepKind::Chat, StepKind::Phone, @@ -307,6 +310,7 @@ impl StepKind { StepKind::EditText, StepKind::Timeline, StepKind::Obs, + StepKind::Light, ]; pub fn id(self) -> &'static str { @@ -330,6 +334,7 @@ impl StepKind { StepKind::EditText => "edit_text", StepKind::Timeline => "timeline", StepKind::Obs => "obs", + StepKind::Light => "light", } } @@ -359,6 +364,10 @@ impl StepKind { StepKind::EditText => &[("op", "pick how to change the text")], StepKind::Timeline => &[("text", "write the timeline line")], StepKind::Obs => &[("action", "pick what to do in OBS")], + StepKind::Light => &[ + ("device", "pick the light or plug"), + ("action", "pick what the light does"), + ], StepKind::WaitDelay | StepKind::Stop | StepKind::Marker | StepKind::Clip => &[], } } @@ -684,6 +693,10 @@ fn validate_steps( errors: &mut Vec, incomplete: &mut Vec, ) { + // An empty branch is no level: only steps that are there count. + if steps.is_empty() { + return; + } if depth > MAX_DEPTH { errors.push(format!("checks nest more than {MAX_DEPTH} deep")); return; @@ -707,6 +720,13 @@ fn validate_steps( missing.push(ask.to_string()); } } + if step.kind == StepKind::If + && condition_names().skip(1).any(|(left, op, _)| { + !step.param(&op).is_empty() && step.param(&left).trim().is_empty() + }) + { + missing.push("say what each check looks at".to_string()); + } let ms = step.param("ms").trim(); if step.kind == StepKind::Wait && !ms.is_empty() @@ -720,10 +740,16 @@ fn validate_steps( missing.push(ask.to_string()); } // Like a program's path: chat must never pick what OBS shows. - if step.param("scene").contains('{') || step.param("source").contains('{') { - errors.push("an OBS step's scene and source can't use variables".to_string()); + if ["scene", "source", "filter", "hotkey"] + .iter() + .any(|name| step.param(name).contains('{')) + { + errors.push("an OBS step's names can't use variables".to_string()); } } + if step.kind == StepKind::Light { + light_step_problems(step, missing, errors); + } // Where a program runs, a file is written or a request goes must be // fixed by the streamer, never filled in from chat or a web call: // otherwise a viewer's message could pick the program or the host. @@ -749,10 +775,25 @@ fn validate_steps( "a Discord card's link and images can't put variables in their site".to_string(), ); } + // What an alert plays and shows comes from the streamer: a viewer + // must not pick a picture or a sound for the stream. + if step.kind == StepKind::Overlay { + let image = step.param("image").trim(); + if !image.is_empty() && !super::alerts::is_picture(image) { + errors.push( + "an alert's picture must be an upload or a web link (https://...)".to_string(), + ); + } + if !super::alerts::is_sound(step.param("sound").trim()) { + errors.push( + "an alert's sound must be a built-in one, an upload or a web link".to_string(), + ); + } + } // The value's name is fixed: a viewer must not overwrite `{user}`. let named = match step.kind { StepKind::SetVar => step.param("name"), - StepKind::EditText => step.param("save_as"), + StepKind::EditText | StepKind::Program | StepKind::Obs => step.param("save_as"), _ => "", }; if named.contains('{') { @@ -787,19 +828,99 @@ fn discord_message_missing(step: &Step) -> Option<&'static str> { } /// The OBS actions a step can take, with what each needs. -pub const OBS_ACTIONS: [&str; 5] = ["scene", "show", "hide", "toggle", "text"]; +pub const OBS_ACTIONS: [&str; 19] = [ + "scene", + "show", + "hide", + "toggle", + "text", + "record_start", + "record_stop", + "replay_save", + "screenshot", + "mute", + "unmute", + "mute_toggle", + "filter_on", + "filter_off", + "filter_toggle", + "media_play", + "media_stop", + "hotkey", + "check", +]; + +/// How many checks one If step holds. The first is `left`, `op` and +/// `right`; the others add their number (`left2`…), and exist once their +/// `op` is set. +pub const MAX_CONDITIONS: usize = 4; + +/// Each check's (left, op, right) param names, the first one first. +pub fn condition_names() -> impl Iterator { + (1..=MAX_CONDITIONS).map(|n| { + let suffix = if n == 1 { String::new() } else { n.to_string() }; + ( + format!("left{suffix}"), + format!("op{suffix}"), + format!("right{suffix}"), + ) + }) +} fn obs_step_missing(step: &Step) -> Option<&'static str> { let blank = |name: &str| step.param(name).trim().is_empty(); match step.param("action") { "" => None, // `required` asks for it "scene" if blank("scene") => Some("pick the OBS scene to switch to"), - "show" | "hide" | "toggle" | "text" if blank("source") => Some("pick the OBS source"), + "show" | "hide" | "toggle" | "text" | "mute" | "unmute" | "mute_toggle" | "media_play" + | "media_stop" + if blank("source") => + { + Some("pick the OBS source") + } + "filter_on" | "filter_off" | "filter_toggle" if blank("source") => { + Some("pick the source or scene with the filter") + } + "filter_on" | "filter_off" | "filter_toggle" if blank("filter") => Some("pick the filter"), + "hotkey" if blank("hotkey") => Some("pick the OBS hotkey"), action if !OBS_ACTIONS.contains(&action) => Some("pick what to do in OBS"), _ => None, } } +pub const LIGHT_ACTIONS: [&str; 4] = ["on", "off", "color", "flash"]; + +/// What a light step lacks (`missing`) or gets wrong (`errors`). The +/// device is fixed, like a program's path: chat never picks which light. +/// A color or brightness from chat is checked when it runs. +fn light_step_problems(step: &Step, missing: &mut Vec, errors: &mut Vec) { + let device = step.param("device"); + if device.contains('{') { + errors.push("a light step's device can't use variables".to_string()); + } else if !device.trim().is_empty() && super::lights::parse_ref(device).is_none() { + missing.push("pick the light or plug again".to_string()); + } + let action = step.param("action"); + if !action.is_empty() && !LIGHT_ACTIONS.contains(&action) { + missing.push("pick what the light does".to_string()); + } + let color = step.param("color").trim(); + if matches!(action, "color" | "flash") { + if color.is_empty() { + missing.push("pick a color".to_string()); + } else if !color.contains('{') && super::lights::parse_color(color).is_none() { + errors.push(format!( + "\"{color}\" isn't a color: use one like #ff2020, or red" + )); + } + } + let brightness = step.param("brightness").trim(); + let fits = |b: &str| b.parse::().is_ok_and(|n| (1..=100).contains(&n)); + if !brightness.is_empty() && !brightness.contains('{') && !fits(brightness) { + errors.push("a light's brightness is 1 to 100".to_string()); + } +} + /// True when the scheme and host of a URL template contain no variable /// (the path and query may). fn host_is_fixed(url: &str) -> bool { @@ -1045,7 +1166,7 @@ fn handler_from_json(v: &Value) -> Result { Ok(Handler { enabled: v.bool_or("enabled", true), trigger, - steps: steps_from_json(v.get("steps"), 0)?, + steps: steps_from_json(v.get("steps"), 1)?, }) } @@ -1072,13 +1193,13 @@ fn steps_json(steps: &[Step]) -> Value { ) } +/// `depth` counts like `validate_steps`: the handler's own steps are 1. fn steps_from_json(v: Option<&Value>, depth: usize) -> Result, String> { - if depth > MAX_DEPTH { + let list = v.and_then(Value::as_array).unwrap_or(&[]); + if !list.is_empty() && depth > MAX_DEPTH { return Err(format!("checks nest more than {MAX_DEPTH} deep")); } - v.and_then(Value::as_array) - .unwrap_or(&[]) - .iter() + list.iter() .map(|s| { let id = s.str_or("type", ""); let kind = StepKind::from_id(id).ok_or_else(|| format!("unknown step \"{id}\""))?; @@ -1249,17 +1370,63 @@ mod tests { assert!(i.validate().is_empty(), "a switched-off step can wait"); } + /// Chat can fill in an alert's words, never what it plays or shows. #[test] - fn nesting_is_capped() { + fn an_alerts_picture_and_sound_are_the_streamers() { + let alert = |image: &str, sound: &str| { + let mut i = sample(); + i.handlers[0].steps = vec![Step::new( + StepKind::Overlay, + &[("text", "{user}"), ("image", image), ("sound", sound)], + )]; + i.validate() + }; + assert!(alert("https://media.example/party.gif", "chime").is_empty()); + assert!(alert("", "https://cdn.example/ding.mp3").is_empty()); + assert!(alert("https://media.example/{user}.gif", "") + .iter() + .any(|e| e.contains("picture"))); + assert!(alert("", "{message}").iter().any(|e| e.contains("sound"))); + } + + /// Every shipped preset survives save and load unchanged: that covers + /// each trigger and step shape a user can start from. + #[test] + fn every_preset_round_trips() { + for p in crate::integrations::presets::PRESETS { + let i = crate::integrations::presets::build(p.id, "x".into(), "c").unwrap(); + let back = Integration::from_json(&i.to_json()) + .unwrap_or_else(|e| panic!("{} doesn't load back: {e}", p.id)); + assert_eq!(back, i, "{}", p.id); + } + } + + /// `ifs` checks nested in each other, with a step inside the last. + fn nested_checks(ifs: usize) -> Integration { let mut deepest = Step::new(StepKind::Stop, &[]); - for _ in 0..MAX_DEPTH + 1 { + for _ in 0..ifs { let mut wrap = Step::new(StepKind::If, &[("left", "a"), ("op", "is")]); wrap.then.push(deepest); deepest = wrap; } let mut i = sample(); i.handlers[0].steps = vec![deepest]; - assert!(i.validate().iter().any(|e| e.contains("nest"))); + i + } + + /// The handler's steps and three checks inside each other make + /// MAX_DEPTH levels, as deep as the builder lets a step go: that saves + /// and loads. One check more is refused, by both. + #[test] + fn nesting_is_capped_where_the_builder_stops() { + let fits = nested_checks(MAX_DEPTH - 1); + assert!(fits.validate().is_empty(), "{:?}", fits.validate()); + let loaded = Integration::from_json(&fits.to_json()).expect("loads back"); + assert_eq!(loaded, fits); + + let too_deep = nested_checks(MAX_DEPTH); + assert!(too_deep.validate().iter().any(|e| e.contains("nest"))); + assert!(Integration::from_json(&too_deep.to_json()).is_err()); } #[test] @@ -1452,7 +1619,7 @@ mod tests { } #[test] - fn only_live_shortcuts_are_listened_for() { + fn only_enabled_integrations_register_shortcuts() { let mut i = sample(); let (keys, pads) = shortcuts(std::slice::from_ref(&i)); assert_eq!(keys, ["Ctrl+Alt+K"]); diff --git a/src/integrations/obsws.rs b/src/integrations/obsws.rs index 35e4820..b16ced5 100644 --- a/src/integrations/obsws.rs +++ b/src/integrations/obsws.rs @@ -1,25 +1,30 @@ //! The connection to OBS's own WebSocket server (obs-websocket 5, built //! into OBS 28 and later). Scene triggers hear from it which scene is on -//! air and when it switches; OBS steps ask it to switch a scene, show or -//! hide a source, or change a text source. +//! air and when it switches, and OBS event triggers when recording starts +//! or stops, a replay is saved or a source is muted. OBS steps ask it to +//! switch a scene, show or hide a source, change a text source, record, +//! save the replay buffer, mute, switch a filter, play media or press one +//! of OBS's hotkeys. //! -//! Only scene and source events are asked for, and OBS is only asked to do -//! what an OBS step says. The address, port and password are read from -//! OBS's own settings file, so the user only has to switch the server on in -//! OBS. +//! Only scene, source, filter and output events are asked for, and OBS is +//! only asked to do what an OBS step says. The address, port and password +//! are read from OBS's own settings file, so the user only has to switch +//! the server on in OBS. //! -//! A minimal WebSocket client (RFC 6455) lives here rather than a crate: -//! one local connection, text frames, no extensions. A reader task owns the -//! socket's read half, so waiting for a message can always be interrupted -//! (by a step's request, or the connection no longer being wanted) without -//! losing half a frame. +//! The WebSocket client is `ws`, shared with Twitch EventSub. A reader +//! task owns the socket's read half, so waiting for a message can always be +//! interrupted (by a step's request, or the connection no longer being +//! wanted) without losing half a frame. +use super::event::{Event, EventKind}; +use super::ws::{self, base64}; use crate::json::{self, Value}; use crate::sync::Mutex; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::tcp::OwnedWriteHalf; use tokio::net::TcpStream; use tokio::sync::{mpsc, watch}; @@ -29,12 +34,17 @@ const RETRY: Duration = Duration::from_secs(5); const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); /// How long a step's request may wait for OBS's answer. pub const CALL_TIMEOUT: Duration = Duration::from_secs(5); -/// A scene list is small; anything this big is not OBS talking. -const MAX_MESSAGE: usize = 4 * 1024 * 1024; -/// obs-websocket's event categories (`EventSubscription`): scenes, and -/// inputs (sources added, removed or renamed). +/// obs-websocket's event categories (`EventSubscription`): scenes, inputs +/// (sources added, removed, renamed or muted), filters (added, removed or +/// renamed) and outputs (recording, the replay buffer). const SCENE_EVENTS: u32 = 1 << 2; const INPUT_EVENTS: u32 = 1 << 3; +const FILTER_EVENTS: u32 = 1 << 5; +const OUTPUT_EVENTS: u32 = 1 << 6; +/// A filter list's request id is this and the source's name. +const FILTERS_ID: &str = "filters:"; +/// OBS's own Screenshot hotkey: it saves to OBS's recording folder. +pub const SCREENSHOT_HOTKEY: &str = "OBSBasic.Screenshot"; const DEFAULT_PORT: u16 = 4455; /// What the dashboard shows about the connection. @@ -47,6 +57,10 @@ pub struct Status { pub scenes: Vec, /// Every source (input) in OBS, for OBS steps to pick from. pub sources: Vec, + /// Each source's and scene's filters, by its name. + pub filters: BTreeMap>, + /// The hotkeys a step can press by name (see `pressable_hotkeys`). + pub hotkeys: Vec, pub current: String, } @@ -70,18 +84,38 @@ impl Status { "sources", Value::Arr(self.sources.iter().map(json::str).collect()), ), + ( + "filters", + Value::Obj( + self.filters + .iter() + .map(|(name, list)| { + ( + name.clone(), + Value::Arr(list.iter().map(json::str).collect()), + ) + }) + .collect(), + ), + ), + ( + "hotkeys", + Value::Arr(self.hotkeys.iter().map(json::str).collect()), + ), ("current", json::str(&self.current)), ]) } } /// What OBS tells the engine. -#[derive(Debug, PartialEq)] -pub enum SceneNews { +#[derive(Debug)] +pub enum ObsNews { /// The scene on air when the connection opened: not a switch. Current(String), /// The program scene switched. Switched(String), + /// Recording started or stopped, a replay was saved, a source muted. + Happened(Event), } /// Something an OBS step asks OBS to do. @@ -98,13 +132,88 @@ pub enum Action { }, /// Set what a text source says. Text { source: String, text: String }, + /// Start (`true`) or stop recording. + Record(bool), + /// Save what the replay buffer holds. + SaveReplay, + /// Mute (`Some(true)`), unmute (`Some(false)`) or flip (`None`) a + /// source's audio. + Mute { source: String, muted: Option }, + /// Switch a source's or scene's filter on, off, or flip it (`None`). + Filter { + source: String, + filter: String, + enabled: Option, + }, + /// Play a media source from the start (`true`), or stop it. + Media { source: String, play: bool }, + /// Press one of OBS's hotkeys by name, bound to keys or not. + Hotkey(String), + /// Show this page in the scene on air, as a browser source as big as + /// the canvas: the dashboard's "Add to OBS" for the alerts page. + BrowserSource { name: String, url: String }, + /// Read what OBS is doing (the scene on air, streaming, recording) and, + /// for each source, whether it is muted and showing in `scene` (blank: + /// the one on air). Changes nothing. + Check { scene: String, sources: Vec }, +} + +/// Longest key a checked source's values get, so `{obs2.key.visible}` +/// stays inside a variable name's 64 characters. +const MAX_SOURCE_KEY: usize = 24; + +/// The names an OBS step lists in one field, one per line: one mute or +/// show can take several sources. Blank lines and repeats are dropped. +pub fn listed_names(field: &str) -> Vec { + let mut names: Vec = Vec::new(); + for name in field.lines().map(str::trim).filter(|name| !name.is_empty()) { + if !names.iter().any(|known| known == name) { + names.push(name.to_string()); + } + } + names +} + +/// What a check that reads several sources names each one's values by: +/// `{obs.mic_aux.muted}`. Lowercase letters, digits and `_`; a clash gets +/// a number. The editor makes the same keys (`sourceKeys`). +pub fn source_keys(sources: &[String]) -> Vec { + let mut keys: Vec = Vec::new(); + for source in sources { + let mut key = String::new(); + for c in source.chars().flat_map(char::to_lowercase) { + if c.is_ascii_alphanumeric() { + key.push(c); + } else if !key.is_empty() && !key.ends_with('_') { + key.push('_'); + } + } + let key: String = key.chars().take(MAX_SOURCE_KEY).collect(); + let base = match key.trim_end_matches('_') { + "" => "source".to_string(), + key => key.to_string(), + }; + let mut key = base.clone(); + let mut n = 2; + while keys.contains(&key) { + key = format!("{base}{n}"); + n += 1; + } + keys.push(key); + } + keys } +/// What OBS said, as (field, value): the step keeps each one under its own +/// answer name, like `{obs.muted}`. Empty for an action that only does +/// something. +pub type Answer = Vec<(String, String)>; + /// An action and where its outcome goes. The answer channel is a std one: /// a step waits for it on a blocking thread. pub struct Command { pub action: Action, - pub reply: std::sync::mpsc::Sender>, + pub reply: std::sync::mpsc::Sender>, /// After this the step has given up: doing it then would switch a /// scene nobody asked for any more. pub deadline: std::time::Instant, @@ -130,7 +239,7 @@ enum Incoming { pub async fn run( mut want: watch::Receiver, status: Arc>, - news: mpsc::Sender, + news: mpsc::Sender, mut commands: mpsc::Receiver, ) { loop { @@ -149,7 +258,7 @@ pub async fn run( Ok(settings) => { let ended = session(&settings, &mut want, &status, &news, &mut commands).await; // No scene is known to be on air any more. - let _ = news.send(SceneNews::Current(String::new())).await; + let _ = news.send(ObsNews::Current(String::new())).await; match ended { Ok(()) => continue, Err(problem) => problem, @@ -191,15 +300,20 @@ fn set_state(status: &Mutex, state: &'static str, problem: &str) { const NO_PASSWORD: &str = "OBS wants a WebSocket password but none is saved. Set one in OBS: Tools > WebSocket Server Settings."; -/// OBS's WebSocket server settings, from the file OBS keeps them in. +/// OBS's WebSocket server settings, from the file OBS keeps them in. A +/// portable OBS keeps that file beside itself, out of reach: OBS's own +/// defaults (port 4455, no password) are tried then. fn read_settings() -> Result { - let path = crate::obs_register::obs_config_dirs() + let Some(path) = crate::obs_register::obs_config_dirs() .into_iter() .map(|dir| dir.join("plugin_config/obs-websocket/config.json")) .find(|p| p.exists()) - .ok_or( - "OBS's WebSocket settings weren't found. In OBS: Tools > WebSocket Server Settings.", - )?; + else { + return Ok(Settings { + port: DEFAULT_PORT, + password: None, + }); + }; let text = std::fs::read_to_string(&path) .map_err(|e| format!("couldn't read OBS's WebSocket settings: {e}"))?; parse_settings(&text) @@ -238,7 +352,7 @@ async fn session( settings: &Settings, want: &mut watch::Receiver, status: &Arc>, - news: &mpsc::Sender, + news: &mpsc::Sender, commands: &mut mpsc::Receiver, ) -> Result<(), String> { let addr = format!("127.0.0.1:{}", settings.port); @@ -293,7 +407,7 @@ struct Link<'a> { writer: &'a mut OwnedWriteHalf, inbox: &'a mut mpsc::Receiver>, status: &'a Arc>, - news: &'a mpsc::Sender, + news: &'a mpsc::Sender, } impl Link<'_> { @@ -316,7 +430,10 @@ impl Link<'_> { if std::time::Instant::now() >= command.deadline { continue; } - let outcome = self.perform(&command.action).await; + let outcome = match &command.action { + Action::Check { scene, sources } => self.check(scene, sources).await, + action => self.perform(action).await.map(|()| Answer::new()), + }; let _ = command.reply.send(outcome); } message = self.inbox.recv() => { @@ -331,18 +448,29 @@ impl Link<'_> { async fn take(&mut self, message: Incoming) -> Result<(), String> { match message { Incoming::Ping(payload) => write_frame(self.writer, 10, &payload).await, - Incoming::Json(v) => { - if handle_message(&v, self.status, self.news).await { - self.request_lists().await?; - } - Ok(()) - } + Incoming::Json(v) => match handle_message(&v, self.status, self.news).await { + Follow::Nothing => Ok(()), + Follow::Lists => self.request_lists().await, + Follow::Filters(names) => self.request_filters(&names).await, + }, } } async fn request_lists(&mut self) -> Result<(), String> { send_request(self.writer, "GetSceneList", "scenes", Value::Null).await?; - send_request(self.writer, "GetInputList", "inputs", Value::Null).await + send_request(self.writer, "GetInputList", "inputs", Value::Null).await?; + send_request(self.writer, "GetHotkeyList", "hotkeys", Value::Null).await + } + + /// Ask for the filters of each named source or scene; the answers + /// land in the status (see `handle_message`). + async fn request_filters(&mut self, names: &[String]) -> Result<(), String> { + for name in names { + let data = json::obj([("sourceName", json::str(name))]); + let id = format!("{FILTERS_ID}{name}"); + send_request(self.writer, "GetSourceFilterList", &id, data).await?; + } + Ok(()) } /// Ask OBS for one thing and wait for its answer, dealing with whatever @@ -387,9 +515,232 @@ impl Link<'_> { source, visible, } => self.show_source(scene, source, *visible).await, + Action::Record(start) => { + let request = if *start { "StartRecord" } else { "StopRecord" }; + self.call(request, Value::Null).await.map(drop) + } + Action::SaveReplay => self.call("SaveReplayBuffer", Value::Null).await.map(drop), + Action::Mute { source, muted } => { + let name = ("inputName", json::str(source)); + match muted { + Some(muted) => { + let data = json::obj([name, ("inputMuted", Value::Bool(*muted))]); + self.call("SetInputMute", data).await.map(drop) + } + None => self + .call("ToggleInputMute", json::obj([name])) + .await + .map(drop), + } + } + Action::Filter { + source, + filter, + enabled, + } => self.switch_filter(source, filter, *enabled).await, + Action::Media { source, play } => { + let action = if *play { + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART" + } else { + "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP" + }; + let data = json::obj([ + ("inputName", json::str(source)), + ("mediaAction", json::str(action)), + ]); + self.call("TriggerMediaInputAction", data).await.map(drop) + } + Action::Hotkey(name) => { + let data = json::obj([("hotkeyName", json::str(name))]); + self.call("TriggerHotkeyByName", data).await.map(drop) + } + Action::BrowserSource { name, url } => self.add_browser_source(name, url).await, + // Answered by `check`, which has values to give back. + Action::Check { .. } => Ok(()), } } + /// What `Action::Check` reads. With sources, `muted` and `visible` say + /// whether all of them are; with several, each also has its own under + /// its key (`source_keys`). A source that has no sound gets no `muted`; + /// one OBS doesn't have at all fails the step, as a typo would + /// otherwise read as "not muted, not showing". + async fn check(&mut self, scene: &str, sources: &[String]) -> Result { + let yes_no = |on: bool| if on { "yes" } else { "no" }.to_string(); + let on_air = self.call("GetCurrentProgramScene", Value::Null).await?; + let current = on_air.str_or("currentProgramSceneName", "").to_string(); + let streaming = self.call("GetStreamStatus", Value::Null).await?; + let recording = self.call("GetRecordStatus", Value::Null).await?; + let mut answer = vec![ + ("scene".to_string(), current.clone()), + ( + "streaming".to_string(), + yes_no(streaming.bool_or("outputActive", false)), + ), + ( + "recording".to_string(), + yes_no(recording.bool_or("outputActive", false)), + ), + ]; + if sources.is_empty() { + return Ok(answer); + } + let inputs = self.call("GetInputList", Value::Null).await?; + let scenes = self.call("GetSceneList", Value::Null).await?; + let known: Vec = names(&inputs, "inputs", "inputName", false) + .into_iter() + .chain(names(&scenes, "scenes", "sceneName", false)) + .collect(); + if let Some(missing) = sources.iter().find(|source| !known.contains(source)) { + return Err(format!("OBS has no source \"{missing}\"")); + } + let scene = match scene.trim() { + "" => current, + name => name.to_string(), + }; + // (muted, if it has sound; showing) for each source. + let mut readings = Vec::new(); + for source in sources { + let asked = json::obj([("inputName", json::str(source))]); + let muted = match self.call("GetInputMute", asked).await { + Ok(mute) => Some(mute.bool_or("inputMuted", false)), + Err(_) => None, + }; + readings.push((muted, self.is_showing(&scene, source).await)); + } + let with_sound: Vec = readings.iter().filter_map(|(muted, _)| *muted).collect(); + if !with_sound.is_empty() { + answer.push(("muted".to_string(), yes_no(with_sound.iter().all(|m| *m)))); + } + let all_showing = readings.iter().all(|(_, showing)| *showing); + answer.push(("visible".to_string(), yes_no(all_showing))); + if sources.len() > 1 { + for (key, (muted, showing)) in source_keys(sources).iter().zip(readings) { + if let Some(muted) = muted { + answer.push((format!("{key}.muted"), yes_no(muted))); + } + answer.push((format!("{key}.visible"), yes_no(showing))); + } + } + Ok(answer) + } + + /// Whether `source` is in `scene` and switched on there. + async fn is_showing(&mut self, scene: &str, source: &str) -> bool { + let at = json::obj([ + ("sceneName", json::str(scene)), + ("sourceName", json::str(source)), + ]); + let Ok(found) = self.call("GetSceneItemId", at).await else { + return false; + }; + let Some(item) = found.get("sceneItemId").and_then(Value::as_f64) else { + return false; + }; + let item = json::obj([ + ("sceneName", json::str(scene)), + ("sceneItemId", Value::Num(item)), + ]); + self.call("GetSceneItemEnabled", item) + .await + .is_ok_and(|v| v.bool_or("sceneItemEnabled", false)) + } + + /// A browser source showing `url` in the scene on air. One already in + /// OBS (by that name, or any showing the same page) is pointed at + /// `url` and added to this scene if it isn't there yet; otherwise a + /// new one is made, canvas-sized, its sound going through OBS so it + /// reaches the stream. + async fn add_browser_source(&mut self, name: &str, url: &str) -> Result<(), String> { + let on_air = self.call("GetCurrentProgramScene", Value::Null).await?; + let scene = on_air.str_or("currentProgramSceneName", "").to_string(); + if let Some(existing) = self.browser_source_for(name, url).await? { + let source = json::str(&existing); + let point = json::obj([ + ("inputName", source.clone()), + ("inputSettings", json::obj([("url", json::str(url))])), + ]); + self.call("SetInputSettings", point).await?; + let item = json::obj([("sceneName", json::str(&scene)), ("sourceName", source)]); + if self.call("GetSceneItemId", item.clone()).await.is_ok() { + return Ok(()); + } + return self.call("CreateSceneItem", item).await.map(drop); + } + let source = json::str(name); + let video = self.call("GetVideoSettings", Value::Null).await?; + let settings = json::obj([ + ("url", json::str(url)), + ("width", Value::Num(video.u64_or("baseWidth", 1920) as f64)), + ( + "height", + Value::Num(video.u64_or("baseHeight", 1080) as f64), + ), + ("reroute_audio", Value::Bool(true)), + ]); + let create = json::obj([ + ("sceneName", json::str(&scene)), + ("inputName", source), + ("inputKind", json::str("browser_source")), + ("inputSettings", settings), + ("sceneItemEnabled", Value::Bool(true)), + ]); + self.call("CreateInput", create).await.map(drop) + } + + /// The browser source named `name`, or else one already showing the + /// page at `url`, so adding the page again never makes a second copy. + async fn browser_source_for( + &mut self, + name: &str, + url: &str, + ) -> Result, String> { + let kind = json::obj([("inputKind", json::str("browser_source"))]); + let list = self.call("GetInputList", kind).await?; + let sources = names(&list, "inputs", "inputName", false); + if sources.iter().any(|s| s == name) { + return Ok(Some(name.to_string())); + } + for source in sources { + let asked = json::obj([("inputName", json::str(&source))]); + let settings = self.call("GetInputSettings", asked).await?; + let shows = settings.path("inputSettings.url").and_then(Value::as_str); + if shows.is_some_and(|shows| same_page(shows, url)) { + return Ok(Some(source)); + } + } + Ok(None) + } + + async fn switch_filter( + &mut self, + source: &str, + filter: &str, + enabled: Option, + ) -> Result<(), String> { + let where_ = || { + vec![ + ("sourceName".to_string(), json::str(source)), + ("filterName".to_string(), json::str(filter)), + ] + }; + let on = match enabled { + Some(on) => on, + None => { + let now = self + .call("GetSourceFilter", Value::Obj(where_())) + .await + .map_err(|_| format!("\"{source}\" has no filter \"{filter}\""))?; + !now.bool_or("filterEnabled", true) + } + }; + let mut set = where_(); + set.push(("filterEnabled".to_string(), Value::Bool(on))); + self.call("SetSourceFilterEnabled", Value::Obj(set)) + .await + .map(drop) + } + async fn show_source( &mut self, scene: &str, @@ -472,36 +823,37 @@ async fn send_request( write_frame(writer, 1, message.as_bytes()).await } -/// Follow one message from OBS. True when the scene and source lists -/// should be asked for again (one was added, removed or renamed). +/// What a message from OBS asks for next. +#[derive(Debug, PartialEq)] +enum Follow { + Nothing, + /// The scene and source lists again: one was added, removed or renamed. + Lists, + /// The filters of these sources or scenes. + Filters(Vec), +} + +/// Follow one message from OBS. async fn handle_message( message: &Value, status: &Arc>, - news: &mpsc::Sender, -) -> bool { + news: &mpsc::Sender, +) -> Follow { let d = message.get("d").cloned().unwrap_or(Value::Null); match message.u64_or("op", 99) { - // Event - 5 => match d.str_or("eventType", "") { - "CurrentProgramSceneChanged" => { - let name = d - .get("eventData") - .map(|e| e.str_or("sceneName", "").to_string()) - .unwrap_or_default(); - status.lock().current = name.clone(); - let _ = news.send(SceneNews::Switched(name)).await; - false - } - "SceneListChanged" | "SceneNameChanged" | "SceneCreated" | "SceneRemoved" - | "InputCreated" | "InputRemoved" | "InputNameChanged" => true, - _ => false, - }, + 5 => handle_event(&d, status, news).await, // Request response: one of the lists. 7 => { let Some(data) = d.get("responseData") else { - return false; + return Follow::Nothing; }; - match d.str_or("requestId", "") { + let id = d.str_or("requestId", ""); + if let Some(name) = id.strip_prefix(FILTERS_ID) { + let list = names(data, "filters", "filterName", false); + status.lock().filters.insert(name.to_string(), list); + return Follow::Nothing; + } + match id { "scenes" => { let current = data.str_or("currentProgramSceneName", "").to_string(); // OBS lists scenes bottom to top; the dashboard shows them @@ -509,21 +861,125 @@ async fn handle_message( let scenes = names(data, "scenes", "sceneName", true); { let mut s = status.lock(); - s.scenes = scenes; + s.scenes = scenes.clone(); s.current = current.clone(); + forget_gone_filters(&mut s); } - let _ = news.send(SceneNews::Current(current)).await; + let _ = news.send(ObsNews::Current(current)).await; + Follow::Filters(scenes) } - "inputs" => status.lock().sources = names(data, "inputs", "inputName", false), - _ => {} + "inputs" => { + let sources = names(data, "inputs", "inputName", false); + let mut s = status.lock(); + s.sources = sources.clone(); + forget_gone_filters(&mut s); + Follow::Filters(sources) + } + "hotkeys" => { + status.lock().hotkeys = pressable_hotkeys(data); + Follow::Nothing + } + _ => Follow::Nothing, } - false } - _ => false, + _ => Follow::Nothing, } } +/// An event from OBS: a scene switch, a list that changed, or one of the +/// OBS events triggers can start on. +async fn handle_event( + d: &Value, + status: &Arc>, + news: &mpsc::Sender, +) -> Follow { + let data = d.get("eventData").cloned().unwrap_or(Value::Null); + let happened = match d.str_or("eventType", "") { + "CurrentProgramSceneChanged" => { + let name = data.str_or("sceneName", "").to_string(); + status.lock().current = name.clone(); + let _ = news.send(ObsNews::Switched(name)).await; + return Follow::Nothing; + } + "SceneListChanged" | "SceneNameChanged" | "SceneCreated" | "SceneRemoved" + | "InputCreated" | "InputRemoved" | "InputNameChanged" => return Follow::Lists, + "SourceFilterCreated" | "SourceFilterRemoved" | "SourceFilterNameChanged" => { + return Follow::Filters(vec![data.str_or("sourceName", "").to_string()]); + } + "RecordStateChanged" => match data.str_or("outputState", "") { + "OBS_WEBSOCKET_OUTPUT_STARTED" => Event::new(EventKind::ObsRecordingStarted), + "OBS_WEBSOCKET_OUTPUT_STOPPED" => Event::new(EventKind::ObsRecordingStopped) + .with("file", data.str_or("outputPath", "")), + _ => return Follow::Nothing, + }, + "ReplayBufferSaved" => { + Event::new(EventKind::ObsReplaySaved).with("file", data.str_or("savedReplayPath", "")) + } + "InputMuteStateChanged" => Event::new(EventKind::ObsMuteChanged) + .with("obs_source", data.str_or("inputName", "")) + .with( + "muted", + if data.bool_or("inputMuted", false) { + "yes" + } else { + "no" + }, + ), + _ => return Follow::Nothing, + }; + let _ = news.send(ObsNews::Happened(happened)).await; + Follow::Nothing +} + +/// Drop the filter lists of sources and scenes that are gone. +fn forget_gone_filters(s: &mut Status) { + let Status { + scenes, + sources, + filters, + .. + } = s; + filters.retain(|name, _| scenes.contains(name) || sources.contains(name)); +} + +/// The hotkeys a step can press by name alone: each listed once (a +/// hotkey every source or scene has, like mute or "switch to scene", +/// can't say which one by name), and none of libobs' per-source ones. +fn pressable_hotkeys(data: &Value) -> Vec { + let all: Vec<&str> = data + .get("hotkeys") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(Value::as_str) + .collect(); + let mut out: Vec = all + .iter() + .filter(|name| { + !name.starts_with("libobs.") && all.iter().filter(|n| n == name).count() == 1 + }) + .map(|name| name.to_string()) + .collect(); + out.sort(); + out +} + /// The `field` of each item in the `list` of an answer. +/// Two addresses of the same page on this PC: `localhost` and 127.0.0.1 +/// are one host, and the query (`?at=top`) doesn't make another page. +fn same_page(a: &str, b: &str) -> bool { + let page = |url: &str| { + let url = url.trim().to_ascii_lowercase(); + let url = url + .split(['?', '#']) + .next() + .unwrap_or("") + .trim_end_matches('/'); + url.replacen("://localhost:", "://127.0.0.1:", 1) + }; + page(a) == page(b) +} + fn names(data: &Value, list: &str, field: &str, reversed: bool) -> Vec { let mut out: Vec = data .get(list) @@ -549,7 +1005,9 @@ fn identify_message(hello: &Value, password: Option<&str>) -> Result String { } async fn handshake(stream: &mut TcpStream, port: u16) -> Result<(), String> { - let mut key = [0u8; 16]; - crate::crypto::os_random(&mut key); - let request = format!( - "GET / HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ - Sec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: obswebsocket.json\r\n\r\n", - base64(&key) - ); - stream - .write_all(request.as_bytes()) - .await - .map_err(|e| format!("lost OBS: {e}"))?; - // Read the answer's head, a byte at a time: it is short, and the first - // frame may follow right behind it. - let mut head = Vec::new(); - while !head.ends_with(b"\r\n\r\n") { - if head.len() > 8192 { - return Err("that isn't OBS's WebSocket server".to_string()); - } - head.push( - stream - .read_u8() - .await - .map_err(|e| format!("lost OBS: {e}"))?, - ); - } - let status_line = String::from_utf8_lossy(&head); - if !status_line.starts_with("HTTP/1.1 101") { - return Err("OBS's WebSocket server refused the connection".to_string()); - } - Ok(()) + let host = format!("127.0.0.1:{port}"); + ws::handshake(stream, (&host, "/"), Some("obswebsocket.json"), "OBS").await } /// The next whole text message during the handshake, as JSON, answering @@ -625,27 +1055,12 @@ async fn read_json(stream: &mut TcpStream) -> Result { /// The next whole message. A close or a broken connection is an error. async fn read_message(stream: &mut (impl AsyncRead + Unpin)) -> Result { - let mut message = Vec::new(); - loop { - let (fin, opcode, payload) = read_frame(stream).await?; - match opcode { - // Continuation, text, binary. - 0..=2 => { - if message.len() + payload.len() > MAX_MESSAGE { - return Err("OBS sent too much at once".to_string()); - } - message.extend_from_slice(&payload); - if fin { - let text = String::from_utf8_lossy(&message); - return json::parse(&text) - .map(Incoming::Json) - .map_err(|_| "OBS sent something unreadable".to_string()); - } - } - 8 => return Err(close_reason(&payload)), - 9 => return Ok(Incoming::Ping(payload)), - _ => {} - } + match ws::read_message(stream, "OBS").await? { + ws::Message::Text(text) => json::parse(&text) + .map(Incoming::Json) + .map_err(|_| "OBS sent something unreadable".to_string()), + ws::Message::Ping(payload) => Ok(Incoming::Ping(payload)), + ws::Message::Close(payload) => Err(close_reason(&payload)), } } @@ -661,104 +1076,18 @@ fn close_reason(payload: &[u8]) -> String { } } -async fn read_frame(stream: &mut (impl AsyncRead + Unpin)) -> Result<(bool, u8, Vec), String> { - let lost = |e: std::io::Error| format!("lost OBS: {e}"); - let b0 = stream.read_u8().await.map_err(lost)?; - let b1 = stream.read_u8().await.map_err(lost)?; - let len = match b1 & 0x7f { - 126 => u64::from(stream.read_u16().await.map_err(lost)?), - 127 => stream.read_u64().await.map_err(lost)?, - n => u64::from(n), - }; - if len > MAX_MESSAGE as u64 { - return Err("OBS sent too much at once".to_string()); - } - let mask = if b1 & 0x80 != 0 { - let mut m = [0u8; 4]; - stream.read_exact(&mut m).await.map_err(lost)?; - Some(m) - } else { - None - }; - let mut payload = vec![0u8; len as usize]; - stream.read_exact(&mut payload).await.map_err(lost)?; - if let Some(m) = mask { - payload - .iter_mut() - .enumerate() - .for_each(|(i, b)| *b ^= m[i % 4]); - } - Ok((b0 & 0x80 != 0, b0 & 0x0f, payload)) -} - -/// One frame from us: always final, always masked (clients must mask). async fn write_frame( stream: &mut (impl AsyncWrite + Unpin), opcode: u8, payload: &[u8], ) -> Result<(), String> { - stream - .write_all(&encode_frame(opcode, payload)) - .await - .map_err(|e| format!("lost OBS: {e}")) -} - -fn encode_frame(opcode: u8, payload: &[u8]) -> Vec { - let mut mask = [0u8; 4]; - crate::crypto::os_random(&mut mask); - let mut out = Vec::with_capacity(payload.len() + 14); - out.push(0x80 | opcode); - match payload.len() { - n if n < 126 => out.push(0x80 | n as u8), - n if n <= 0xffff => { - out.push(0x80 | 126); - out.extend_from_slice(&(n as u16).to_be_bytes()); - } - n => { - out.push(0x80 | 127); - out.extend_from_slice(&(n as u64).to_be_bytes()); - } - } - out.extend_from_slice(&mask); - out.extend(payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4])); - out -} - -/// Standard base64 with padding, as obs-websocket and the handshake use. -fn base64(data: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for chunk in data.chunks(3) { - let b = [ - chunk[0], - *chunk.get(1).unwrap_or(&0), - *chunk.get(2).unwrap_or(&0), - ]; - let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); - for i in 0..4 { - if i <= chunk.len() { - out.push(ALPHABET[(n >> (18 - 6 * i)) as usize & 63] as char); - } else { - out.push('='); - } - } - } - out + ws::write_frame(stream, opcode, payload, "OBS").await } #[cfg(test)] mod tests { use super::*; - #[test] - fn base64_matches_the_standard() { - assert_eq!(base64(b""), ""); - assert_eq!(base64(b"f"), "Zg=="); - assert_eq!(base64(b"fo"), "Zm8="); - assert_eq!(base64(b"foo"), "Zm9v"); - assert_eq!(base64(b"foobar"), "Zm9vYmFy"); - } - /// The worked example from the obs-websocket protocol document. #[test] fn the_password_proof_matches_obs_websockets_example() { @@ -772,6 +1101,45 @@ mod tests { ); } + #[test] + fn a_step_can_list_several_sources() { + assert_eq!( + listed_names(" Mic/Aux \n\nDesktop Audio\nMic/Aux\n"), + vec!["Mic/Aux".to_string(), "Desktop Audio".to_string()] + ); + assert!(listed_names(" \n ").is_empty()); + let sources = [ + "Mic/Aux", + "mic aux", + "Cámara 1", + "!!", + "A very long source name for the stream", + ] + .map(String::from); + assert_eq!( + source_keys(&sources), + vec![ + "mic_aux", + "mic_aux2", + "c_mara_1", + "source", + "a_very_long_source_name" + ] + ); + } + + #[test] + fn the_alerts_page_is_found_under_any_of_its_addresses() { + let ours = "http://127.0.0.1:7799/alerts"; + assert!(same_page("http://localhost:7799/alerts?at=top", ours)); + assert!(same_page("HTTP://127.0.0.1:7799/alerts/", ours)); + assert!(!same_page("http://127.0.0.1:7800/alerts", ours)); + assert!(!same_page( + "http://127.0.0.1:7799/overlay/pulse-dot.html", + ours + )); + } + #[test] fn settings_need_the_server_switched_on() { let on = r#"{"server_enabled":true,"server_port":4456,"auth_required":true,"server_password":"pw"}"#; @@ -796,8 +1164,8 @@ mod tests { assert!(msg.path("d.authentication").is_none()); assert_eq!( msg.path("d.eventSubscriptions").and_then(Value::as_f64), - Some(12.0), - "scenes and sources" + Some(108.0), + "scenes, sources, filters and outputs" ); let locked = json::parse( r#"{"op":0,"d":{"rpcVersion":1,"authentication":{"salt":"s","challenge":"c"}}}"#, @@ -811,18 +1179,6 @@ mod tests { assert!(identify_message(&no_challenge, Some("pw")).is_err_and(|e| e.contains("challenge"))); } - #[test] - fn frames_are_masked_and_sized() { - let small = encode_frame(1, b"hi"); - assert_eq!(small[0], 0x81); - assert_eq!(small[1], 0x80 | 2); - let mask = [small[2], small[3], small[4], small[5]]; - assert_eq!([small[6] ^ mask[0], small[7] ^ mask[1]], *b"hi"); - let mid = encode_frame(1, &[0u8; 300]); - assert_eq!(mid[1], 0x80 | 126); - assert_eq!(u16::from_be_bytes([mid[2], mid[3]]), 300); - } - #[test] fn answers_carry_their_data_or_why_obs_said_no() { let yes = json::parse( @@ -855,8 +1211,19 @@ mod tests { r#"{"op":7,"d":{"requestId":"inputs","requestStatus":{"result":true},"responseData":{"inputs":[{"inputName":"Cam"},{"inputName":"Title"}]}}}"#, ) .unwrap(); - assert!(!handle_message(&scenes, &status, &news).await); - assert!(!handle_message(&inputs, &status, &news).await); + assert_eq!( + handle_message(&scenes, &status, &news).await, + Follow::Filters(vec!["Lobby".into(), "Game".into()]) + ); + assert_eq!( + handle_message(&inputs, &status, &news).await, + Follow::Filters(vec!["Cam".into(), "Title".into()]) + ); + let blur = json::parse( + r#"{"op":7,"d":{"requestId":"filters:Cam","requestStatus":{"result":true},"responseData":{"filters":[{"filterName":"Blur"}]}}}"#, + ) + .unwrap(); + assert_eq!(handle_message(&blur, &status, &news).await, Follow::Nothing); let s = status.lock().clone(); assert_eq!( s.scenes, @@ -864,7 +1231,74 @@ mod tests { "as OBS's scene dock shows them" ); assert_eq!(s.sources, ["Cam", "Title"]); - assert_eq!(heard.recv().await, Some(SceneNews::Current("Game".into()))); + assert_eq!( + s.filters.get("Cam").map(Vec::as_slice), + Some(&["Blur".to_string()][..]) + ); + assert!(matches!(heard.recv().await, Some(ObsNews::Current(name)) if name == "Game")); + } + + #[tokio::test] + async fn obs_events_become_integration_events() { + let status = Arc::new(Mutex::new(Status::default())); + let (news, mut heard) = mpsc::channel(4); + let event = |kind: &str, data: &str| { + json::parse(&format!( + r#"{{"op":5,"d":{{"eventType":"{kind}","eventData":{data}}}}}"# + )) + .unwrap() + }; + let stopped = event( + "RecordStateChanged", + r#"{"outputActive":false,"outputState":"OBS_WEBSOCKET_OUTPUT_STOPPED","outputPath":"C:/v/a.mkv"}"#, + ); + handle_message(&stopped, &status, &news).await; + let Some(ObsNews::Happened(e)) = heard.recv().await else { + panic!("a recording event"); + }; + assert_eq!( + (e.kind, e.var("file")), + (EventKind::ObsRecordingStopped, Some("C:/v/a.mkv")) + ); + + let stopping = event( + "RecordStateChanged", + r#"{"outputState":"OBS_WEBSOCKET_OUTPUT_STOPPING"}"#, + ); + handle_message(&stopping, &status, &news).await; + let muted = event( + "InputMuteStateChanged", + r#"{"inputName":"Mic","inputMuted":true}"#, + ); + handle_message(&muted, &status, &news).await; + let Some(ObsNews::Happened(e)) = heard.recv().await else { + panic!("a mute event, and nothing for stopping"); + }; + assert_eq!( + (e.var("obs_source"), e.var("muted")), + (Some("Mic"), Some("yes")) + ); + + let filter_added = event( + "SourceFilterCreated", + r#"{"sourceName":"Cam","filterName":"Blur"}"#, + ); + assert_eq!( + handle_message(&filter_added, &status, &news).await, + Follow::Filters(vec!["Cam".into()]) + ); + } + + #[test] + fn only_hotkeys_a_name_can_press_are_offered() { + let data = json::parse( + r#"{"hotkeys":["OBSBasic.StartRecording","libobs.mute","libobs.mute","OBSBasic.SelectScene","OBSBasic.SelectScene","OBSBasic.Screenshot"]}"#, + ) + .unwrap(); + assert_eq!( + pressable_hotkeys(&data), + ["OBSBasic.Screenshot", "OBSBasic.StartRecording"] + ); } #[test] diff --git a/src/integrations/presets.rs b/src/integrations/presets.rs index 25f2806..728ab49 100644 --- a/src/integrations/presets.rs +++ b/src/integrations/presets.rs @@ -18,7 +18,8 @@ pub struct Preset { pub category: &'static str, pub description: &'static str, /// What it can't work without: `discord`, `twitch`, `phone`, `web` - /// (an address to send to), `file` or `obs`. The first is the main one. + /// (an address to send to), `file`, `obs` or `light` (one on the + /// network). The first is the main one. pub needs: &'static [&'static str], pub recommended: bool, /// What the catalog card previews: (kind, sample text, sample chat line). @@ -26,6 +27,15 @@ pub struct Preset { } pub const PRESETS: &[Preset] = &[ + Preset { + id: "twitch_alerts", + name: "Twitch alerts", + category: "alerts", + description: "Follows, subs, gifted subs, bits and raids thanked on stream, in the alerts page you add to OBS once. No other alert service needed.", + needs: &["twitch"], + recommended: true, + preview: ("overlay", "Nat is raiding with 42 viewers", ""), + }, Preset { id: "crash_alert", name: "OBS crash alert", @@ -186,16 +196,16 @@ pub const PRESETS: &[Preset] = &[ id: "on_air_light", name: "On-air light", category: "auto", - description: "A real light by your door: red when live, amber with the delay on, flashing if OBS crashed. Home Assistant, WLED, Hue.", - needs: &["web"], + description: "A real light by your door: red when live, amber with the delay on, flashing if OBS crashed. Finds WiZ, LIFX, Govee, Yeelight, Kasa, Shelly and WLED on your network, no hub needed.", + needs: &["light"], recommended: false, - preview: ("json", "", ""), + preview: ("light", "", ""), }, Preset { id: "scene_delay", name: "Scene delay", category: "auto", - description: "Your OBS scene runs the delay: on in game, cut back to live in the lobby. Waits a moment, so flicking through scenes does nothing.", + description: "Your OBS scene runs the delay: armed in game, back to live in the lobby. Turn on Auto-activate so it goes on air by itself. Waits a moment, so flicking through scenes does nothing.", needs: &["obs"], recommended: false, preview: ("action", "", ""), @@ -222,7 +232,7 @@ pub const PRESETS: &[Preset] = &[ id: "webhook", name: "Webhook", category: "auto", - description: "Every event as JSON to n8n, Home Assistant, Zapier or your own tool.", + description: "Every InstantClone event as JSON to n8n, Home Assistant, Zapier or your own tool.", needs: &["web"], recommended: false, preview: ("json", "", ""), @@ -405,6 +415,104 @@ fn check(left: &str, op: &str, right: &str, then: Vec, otherwise: Vec) -> Step { + check( + "{arg1}", + "greater", + "{delay_max}", + vec![reply( + "The buffer holds up to {delay_max}s right now. Usage: !setdelay 30", + )], + arm, + ) +} + +/// `!setdelay 0`: turns the delay off and says so, ahead of the checks +/// that would otherwise answer "Delay set to 0s". +fn setdelay_off() -> Step { + check( + "{arg1}", + "is", + "0", + vec![ + Step::new(StepKind::DelayAction, &[("action", "disarm")]), + reply("Delay turned off by {user}."), + Step::new(StepKind::Stop, &[]), + ], + Vec::new(), + ) +} + +/// The answer `!setdelay` shipped with up to 0.1.15, also for 0. +const SETDELAY_REPLY: &str = "Delay set to {arg1}s by {user}."; + +/// The limit check `!setdelay` shipped with up to 0.1.15: a fixed 600 s. +const OLD_SETDELAY_TOP: &str = "601"; +const OLD_SETDELAY_REPLY: &str = "The delay goes up to 600s. Usage: !setdelay 30"; + +/// Bring a `!setdelay` from up to 0.1.15 up to date, each part only while +/// it is still exactly as shipped: the fixed 600 s limit becomes the +/// buffer-aware one (the steps it runs when the delay fits are kept, so +/// edits there survive), and its answer says when 0 turned the delay +/// off. Returns whether anything changed. +pub fn upgrade_setdelay(integration: &mut Integration) -> bool { + if integration.preset != "mod_controls" { + return false; + } + let mut changed = false; + for handler in &mut integration.handlers { + changed |= upgrade_setdelay_steps(&mut handler.steps); + let is_setdelay = matches!(&handler.trigger, + Trigger::ChatCommand { command, .. } if command == "!setdelay"); + // Anywhere in the command counts: a check the user moved is theirs + // to keep where they put it, not to get a second copy of. + if is_setdelay + && !has_step(&handler.steps, &setdelay_off()) + && has_step(&handler.steps, &reply(SETDELAY_REPLY)) + { + handler.steps.insert(0, setdelay_off()); + changed = true; + } + } + changed +} + +/// Whether `wanted` is among `steps`, inside checks too. +fn has_step(steps: &[Step], wanted: &Step) -> bool { + steps.iter().any(|step| { + step == wanted || has_step(&step.then, wanted) || has_step(&step.otherwise, wanted) + }) +} + +fn upgrade_setdelay_steps(steps: &mut [Step]) -> bool { + let mut changed = false; + for step in steps { + if is_old_setdelay_limit(step) { + *step = setdelay_limit(std::mem::take(&mut step.then)); + changed = true; + } else { + changed |= upgrade_setdelay_steps(&mut step.then); + changed |= upgrade_setdelay_steps(&mut step.otherwise); + } + } + changed +} + +fn is_old_setdelay_limit(step: &Step) -> bool { + let refusal_as_shipped = match step.otherwise.as_slice() { + [only] => only.kind == StepKind::Chat && only.param("text") == OLD_SETDELAY_REPLY, + _ => false, + }; + step.kind == StepKind::If + && step.enabled + && step.param("left") == "{arg1}" + && step.param("op") == "less" + && step.param("right") == OLD_SETDELAY_TOP + && refusal_as_shipped +} + /// Build a catalog entry. `discord_channel` is the id of the Discord /// connection to post to (empty when none exists yet). pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option { @@ -524,27 +632,24 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option { @@ -615,8 +720,11 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option EventKind::ALL .iter() + .filter(|kind| kind.is_own()) .map(|kind| { let body = webhook_body(*kind); on( @@ -628,6 +736,7 @@ pub fn build(preset_id: &str, id: String, discord_channel: &str) -> Option twitch_alerts(), "crash_counter" => vec![on( EventKind::HoldOpened, vec![ @@ -1112,24 +1221,82 @@ fn hype_clip(d: &str) -> Vec { }] } -/// The on-air state as a light. Bodies follow Home Assistant's webhook -/// style; the editor's device presets rewrite them for WLED or Hue. +/// One "Show on stream" alert per Twitch event, each playing the alerts +/// page's theme sound. A gifted sub reaches each viewer as a sub too: +/// those are left to the one gift alert. +fn twitch_alerts() -> Vec { + let alert = |title: &str, text: &str| { + Step::new( + StepKind::Overlay, + &[ + ("title", title), + ("text", text), + ("seconds", "6"), + ("sound", super::alerts::THEME_SOUND), + ], + ) + }; + // Welcoming raiders in chat is the streamer's call: off to start. + let mut welcome = chat("Welcome, raiders from {user}!"); + welcome.enabled = false; + vec![ + on( + EventKind::TwitchFollow, + vec![alert("New follower", "{user}")], + ), + on_filtered( + EventKind::TwitchSub, + &[("gift", "no")], + vec![alert("New sub", "{user} just subscribed")], + ), + on( + EventKind::TwitchResub, + vec![alert("Resub", "{user}: {months#month#months}")], + ), + on( + EventKind::TwitchGift, + vec![alert("Gifted subs", "{user} gifted {count#sub#subs}")], + ), + on( + EventKind::TwitchCheer, + vec![alert("Bits", "{user} cheered {bits#bit#bits}")], + ), + on( + EventKind::TwitchRaid, + vec![ + alert("Raid", "{user} is raiding with {viewers#viewer#viewers}"), + welcome, + ], + ), + ] +} + +/// The on-air state as a light on the network, picked in the editor. +/// The editor can switch it to Home Assistant or Hue instead, which take +/// a web request (see `LIGHT_STARTS`). fn on_air_light() -> Vec { - ["live", "delay", "crash", "off"] - .iter() - .map(|state| { - let body = format!(r#"{{"state":"{state}"}}"#); - let mut h = on( - EventKind::OnAirChanged, - vec![Step::new( - StepKind::Http, - &[("method", "POST"), ("url", ""), ("body", &body)], - )], - ); - set_filter(&mut h, "state", state); - h - }) - .collect() + [ + ("live", "color", "red"), + ("delay", "color", "amber"), + ("crash", "flash", "red"), + ("off", "off", ""), + ] + .iter() + .map(|(state, action, color)| { + let light = Step::new( + StepKind::Light, + &[ + ("device", ""), + ("name", ""), + ("action", action), + ("color", color), + ], + ); + let mut h = on(EventKind::OnAirChanged, vec![light]); + set_filter(&mut h, "state", state); + h + }) + .collect() } /// Two scene rules to fill in: a delay for the game scene, live again for @@ -1466,6 +1633,104 @@ pub fn catalog_json() -> Value { mod tests { use super::*; + /// Mod controls with `limit` in place of `!setdelay`'s limit check. + fn mod_controls_with_limit(limit: Step) -> Integration { + let mut i = build("mod_controls", "mc".into(), "").expect("builds"); + let setdelay = i + .handlers + .iter_mut() + .find(|h| matches!(&h.trigger, Trigger::ChatCommand { command, .. } if command == "!setdelay")) + .expect("has !setdelay"); + // Its check for whole seconds; the limit sits inside. + let whole = setdelay + .steps + .iter_mut() + .find(|s| s.param("op") == "whole_number") + .expect("checks for whole seconds"); + whole.then = vec![limit]; + i + } + + /// The limit check as 0.1.15 shipped it, running `arm` when it passes. + fn old_limit(arm: Vec, refusal: &str) -> Step { + check( + "{arg1}", + "less", + OLD_SETDELAY_TOP, + arm, + vec![reply(refusal)], + ) + } + + #[test] + fn an_old_setdelay_limit_upgrades_and_keeps_its_arm_steps() { + let arm = vec![reply("Custom: delay is now {arg1}s")]; + let mut old = mod_controls_with_limit(old_limit(arm.clone(), OLD_SETDELAY_REPLY)); + assert!(upgrade_setdelay(&mut old)); + assert_eq!( + old, + mod_controls_with_limit(setdelay_limit(arm)), + "the new limit, with the edited arm steps kept" + ); + assert!( + !upgrade_setdelay(&mut old), + "running it again changes nothing" + ); + } + + #[test] + fn an_old_setdelay_learns_that_0_turns_the_delay_off() { + let fresh = build("mod_controls", "mc".into(), "").expect("builds"); + let mut old = fresh.clone(); + for h in &mut old.handlers { + if h.steps.first() == Some(&setdelay_off()) { + h.steps.remove(0); + } + } + assert_ne!(old, fresh); + assert!(upgrade_setdelay(&mut old)); + assert_eq!(old, fresh); + assert!( + !upgrade_setdelay(&mut old), + "running it again changes nothing" + ); + } + + /// The user moved "0 turns it off" below another step: it stays there, + /// and no second copy is added at the top. + #[test] + fn a_moved_off_check_is_kept_where_the_user_put_it() { + let mut moved = build("mod_controls", "mc".into(), "").expect("builds"); + for h in &mut moved.handlers { + if h.steps.first() == Some(&setdelay_off()) && h.steps.len() > 1 { + h.steps.swap(0, 1); + } + } + let before = moved.clone(); + assert!(!upgrade_setdelay(&mut moved)); + assert_eq!(moved, before); + } + + #[test] + fn an_edited_setdelay_limit_is_left_alone() { + let arm = vec![reply("Delay is {arg1}s now")]; + let mut edited = mod_controls_with_limit(old_limit(arm, "Max is 600, chat!")); + let before = edited.clone(); + assert!(!upgrade_setdelay(&mut edited)); + assert_eq!(edited, before); + } + + #[test] + fn a_fresh_setdelay_is_already_buffer_aware() { + let mut fresh = build("mod_controls", "mc".into(), "").expect("builds"); + assert!(!upgrade_setdelay(&mut fresh)); + let text = fresh.to_json().to_json(); + assert!( + text.contains("{delay_max}") && !text.contains("601"), + "{text}" + ); + } + #[test] fn every_preset_builds_and_validates_once_connected() { for p in PRESETS { @@ -1492,6 +1757,10 @@ mod tests { s.params.insert(key.into(), "https://example.com/x".into()); } } + if s.params.get("device").is_some_and(|v| v.is_empty()) { + s.params + .insert("device".into(), "wiz:192.168.1.20#a8bb50a4f94d".into()); + } fill_blank_paths(&mut s.then); fill_blank_paths(&mut s.otherwise); } @@ -1637,9 +1906,4 @@ mod tests { fn legacy_alerts_validate() { assert!(legacy_alerts("legacy".into(), "chan").validate().is_empty()); } - - #[test] - fn catalog_is_valid_json() { - assert!(crate::config::is_valid_json(&catalog_json().to_json())); - } } diff --git a/src/integrations/recipe.rs b/src/integrations/recipe.rs index ed0d46b..3b0c237 100644 --- a/src/integrations/recipe.rs +++ b/src/integrations/recipe.rs @@ -10,22 +10,45 @@ //! - Everything imported arrives switched off. //! - Steps that run programs or write files are flagged, and the import //! needs an explicit yes for them. +//! - Alerts come with the sharer's alerts theme and the sounds and pictures +//! they uploaded for them (up to `MAX_SHARED_MEDIA`). The importer +//! chooses whether to use the theme; an upload that didn't come along +//! falls back to the theme sound, or no picture. //! //! Import cleans exactly what export does, so a recipe written by hand //! can't bring in a web address, a program or a hotkey the user never set. -use super::model::{visit_steps, Integration, StepKind, Trigger}; +use super::alert_media::{self, Kind, REF_PREFIX}; +use super::alerts::{is_web_link, Look, THEME_SOUND}; +use super::model::{visit_steps, Integration, Step, StepKind, Trigger}; use crate::json::{self, Value}; +use std::collections::BTreeMap; +use std::path::Path; const PREFIX: &str = "ic-recipe:1:"; /// Placeholder a shared Discord step points at until the importer maps it. pub const DISCORD_PLACEHOLDER: &str = "@discord"; -const MAX_RECIPE_LEN: usize = 256 * 1024; +/// Uploaded sounds and pictures make a recipe big: room for a few. Each +/// upload is encoded twice (in the recipe, then the recipe as a whole), so +/// `MAX_SHARED_MEDIA` of them grows by 16/9; 1 MB more is the integrations. +const MAX_RECIPE_LEN: usize = MAX_SHARED_MEDIA * 16 / 9 + 1024 * 1024; const MAX_RECIPE_INTEGRATIONS: usize = 20; +/// The most uploads a recipe brings along, all together. +const MAX_SHARED_MEDIA: usize = 4 * 1024 * 1024; pub struct Recipe { pub name: String, pub integrations: Vec, + pub alerts: Option, +} + +/// What alerts in a recipe bring: the sharer's theme and their uploads. +pub struct SharedAlerts { + pub look: Look, + /// (name, file) for each upload the shared alerts use. + pub media: Vec<(String, Vec)>, + /// Uploads they use that were too big to come along. + pub left_out: Vec, } impl Recipe { @@ -44,7 +67,14 @@ impl Recipe { } } -pub fn export(name: &str, integrations: &[Integration]) -> String { +/// The recipe text. With `look`, alerts among `integrations` bring that +/// theme and the uploads (from `media_folder`) they play or show. +pub fn export( + name: &str, + integrations: &[Integration], + look: Option<&Look>, + media_folder: &Path, +) -> String { let cleaned: Vec = integrations .iter() .map(|i| { @@ -53,11 +83,77 @@ pub fn export(name: &str, integrations: &[Integration]) -> String { i.to_json() }) .collect(); - let doc = json::obj([ - ("name", json::str(name)), - ("integrations", Value::Arr(cleaned)), - ]); - format!("{PREFIX}{}", base64url_encode(doc.to_json().as_bytes())) + let mut doc = vec![ + ("name".to_string(), json::str(name)), + ("integrations".to_string(), Value::Arr(cleaned)), + ]; + if let Some(look) = look.filter(|_| integrations.iter().any(shows_alerts)) { + doc.push(( + "alerts".to_string(), + alerts_json(integrations, look, media_folder), + )); + } + format!( + "{PREFIX}{}", + base64url_encode(Value::Obj(doc).to_json().as_bytes()) + ) +} + +fn shows_alerts(i: &Integration) -> bool { + let mut found = false; + for h in &i.handlers { + visit_steps(&h.steps, &mut |s| found |= s.kind == StepKind::Overlay); + } + found +} + +/// The uploads alerts among `integrations` use, and the theme's own sound. +fn media_used(integrations: &[Integration], look: &Look) -> Vec { + let mut names = Vec::new(); + let mut note = |value: &str| { + if let Some(name) = value.strip_prefix(REF_PREFIX) { + if !names.iter().any(|n| n == name) { + names.push(name.to_string()); + } + } + }; + note(&look.sound); + for i in integrations { + for h in &i.handlers { + visit_steps(&h.steps, &mut |s| { + if s.kind == StepKind::Overlay { + note(s.param("sound")); + note(s.param("image")); + } + }); + } + } + names +} + +fn alerts_json(integrations: &[Integration], look: &Look, media_folder: &Path) -> Value { + let mut room = MAX_SHARED_MEDIA; + let mut media = Vec::new(); + let mut left_out = Vec::new(); + for name in media_used(integrations, look) { + let file = + alert_media::locate(media_folder, &name).and_then(|(path, _)| std::fs::read(path).ok()); + match file { + Some(data) if data.len() <= room => { + room -= data.len(); + media.push(json::obj([ + ("name", json::str(&name)), + ("data", json::str(base64url_encode(&data))), + ])); + } + _ => left_out.push(json::str(&name)), + } + } + json::obj([ + ("look", look.to_json()), + ("media", Value::Arr(media)), + ("left_out", Value::Arr(left_out)), + ]) } /// Blank whatever a recipe must never carry: see `strip_private`, plus web @@ -119,6 +215,7 @@ fn strip_private(steps: &mut [super::model::Step]) { StepKind::Obs => { s.params.insert("scene".into(), String::new()); s.params.insert("source".into(), String::new()); + s.params.remove("filter"); } StepKind::Program => { s.params.insert("path".into(), String::new()); @@ -127,6 +224,23 @@ fn strip_private(steps: &mut [super::model::Step]) { StepKind::File => { s.params.insert("path".into(), String::new()); } + // A picture or sound linked from the web would load from that + // site on stream (the streamer's IP, and a file it can change + // later): it falls back to no picture, or the theme sound, as an + // upload too big to come along does. + StepKind::Overlay => { + if is_web_link(s.param("image")) { + s.params.insert("image".into(), String::new()); + } + if is_web_link(s.param("sound")) { + s.params.insert("sound".into(), THEME_SOUND.into()); + } + } + // A light on someone's own network: the color stays. + StepKind::Light => { + s.params.insert("device".into(), String::new()); + s.params.insert("name".into(), String::new()); + } _ => {} } strip_private(&mut s.then); @@ -186,6 +300,134 @@ pub fn parse(text: &str, mut new_id: impl FnMut() -> String) -> Result Result { + let damaged = || "the recipe's alerts are damaged; copy it again".to_string(); + let mut media = Vec::new(); + let mut total = 0; + for m in v.get("media").and_then(Value::as_array).unwrap_or(&[]) { + let name = m.str_or("name", ""); + let data = base64url_decode(m.str_or("data", "")).ok_or_else(damaged)?; + total += data.len(); + let fits = data.len() <= alert_media::MAX_BYTES && total <= MAX_SHARED_MEDIA; + if alert_media::type_of(name).is_none() || data.is_empty() || !fits { + return Err(damaged()); + } + media.push((name.to_string(), data)); + } + let left_out = v + .get("left_out") + .and_then(Value::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(Value::as_str) + .filter(|n| alert_media::type_of(n).is_some()) + .take(50) + .map(str::to_string) + .collect(); + let mut look = Look::from_json(v.get("look").unwrap_or(&Value::Null)); + look.css = css_that_stays_here(&look.css); + // A theme sound linked from the web: as for an alert's own, see + // `strip_private`. + if is_web_link(&look.sound) { + look.sound = Look::default().sound; + } + Ok(SharedAlerts { + look, + media, + left_out, + }) +} + +/// A shared theme's custom CSS, kept only if it can't fetch anything. The +/// alerts page plays in OBS on stream: a `url(` or `@import` in someone +/// else's CSS would show that site the streamer's IP, and let it change +/// what goes on stream later. A backslash drops it too, as a CSS escape can +/// spell `url(` in a way a plain search can't see. The rest of the theme +/// (colors, font, place, motion) still comes along. +fn css_that_stays_here(css: &str) -> String { + let lower = css.to_ascii_lowercase(); + let fetches = ["url(", "@import", "image-set(", "\\"] + .iter() + .any(|token| lower.contains(token)); + if fetches { + String::new() + } else { + css.to_string() + } +} + +/// Point the imported alerts (and `look`, when the importer takes it) at +/// where their uploads were saved: `saved` maps the sharer's names to +/// this PC's. An upload that didn't come along falls back to the theme +/// sound, or no picture, so the alert still plays. +pub fn point_media(recipe: &mut Recipe, look: Option<&mut Look>, saved: &BTreeMap) { + let moved = |value: &str| -> Option { + let name = value.strip_prefix(REF_PREFIX)?; + Some( + saved + .get(name) + .map_or(String::new(), |n| format!("{REF_PREFIX}{n}")), + ) + }; + for i in &mut recipe.integrations { + for h in &mut i.handlers { + point_steps(&mut h.steps, &moved); + } + } + if let Some(look) = look { + if let Some(sound) = moved(&look.sound) { + look.sound = sound; + } + } +} + +fn point_steps(steps: &mut [Step], moved: &impl Fn(&str) -> Option) { + for s in steps { + if s.kind == StepKind::Overlay { + if let Some(sound) = moved(s.param("sound")) { + let sound = if sound.is_empty() { + THEME_SOUND.to_string() + } else { + sound + }; + s.params.insert("sound".into(), sound); + } + if let Some(image) = moved(s.param("image")) { + s.params.insert("image".into(), image); + } + } + point_steps(&mut s.then, moved); + point_steps(&mut s.otherwise, moved); + } +} + +/// Keep a shared upload: a file here with the same bytes (and kind) is +/// used as it is, so importing twice adds nothing; otherwise it's saved +/// like any upload. +pub fn keep_shared_media(folder: &Path, name: &str, data: &[u8]) -> Result { + let kind = alert_media::type_of(name).map(|(kind, _)| kind); + let same = alert_media::list(folder).into_iter().find(|m| { + Some(m.kind) == kind + && m.bytes == data.len() as u64 + && std::fs::read(folder.join(&m.name)).is_ok_and(|here| here == data) + }); + match same { + Some(m) => Ok(m.name), + None => alert_media::save(folder, name, data), + } +} + +/// Whether an upload is a sound or a picture, for the import preview. +pub fn media_kind(name: &str) -> &'static str { + alert_media::type_of(name).map_or("file", |(kind, _)| match kind { + Kind::Sound => "sound", + Kind::Picture => "picture", }) } @@ -221,7 +463,7 @@ fn base64url_encode(data: &[u8]) -> String { out } -fn base64url_decode(text: &str) -> Option> { +pub(super) fn base64url_decode(text: &str) -> Option> { let mut out = Vec::with_capacity(text.len() * 3 / 4); let mut buf = 0u32; let mut bits = 0; @@ -246,6 +488,140 @@ mod tests { use super::super::presets; use super::*; + /// An alert's picture or sound linked from the web would load from that + /// site on the importer's stream: it falls back as a missing upload does. + #[test] + fn alerts_linked_from_the_web_stay_behind() { + let mut steps = vec![Step::new( + StepKind::Overlay, + &[ + ("image", "https://x.example/y.gif"), + ("sound", "https://x.example/s.mp3"), + ("text", "{user} followed"), + ], + )]; + strip_private(&mut steps); + assert_eq!(steps[0].param("image"), ""); + assert_eq!(steps[0].param("sound"), THEME_SOUND); + assert_eq!(steps[0].param("text"), "{user} followed"); + let mut upload = vec![Step::new(StepKind::Overlay, &[("sound", "media:ding.mp3")])]; + strip_private(&mut upload); + assert_eq!( + upload[0].param("sound"), + "media:ding.mp3", + "an upload stays" + ); + } + + /// The largest recipe export writes must be one import takes. With the + /// uploads encoded twice, 4 MB of them used to come to 7.1 MB of text, + /// over the 6 MB import refused. + #[test] + fn the_largest_export_fits_the_import_limit() { + let encoded = |n: usize| n.div_ceil(3) * 4; + let integrations = 512 * 1024; + let largest = PREFIX.len() + encoded(encoded(MAX_SHARED_MEDIA) + integrations); + assert!(largest <= MAX_RECIPE_LEN, "{largest} > {MAX_RECIPE_LEN}"); + } + + /// Someone else's CSS could fetch from their server while the alerts + /// page is on stream: any way of fetching drops it, styling stays. + #[test] + fn shared_css_that_could_fetch_anything_is_left_out() { + let styling = ".card{border-radius:4px;color:#fff}"; + assert_eq!(css_that_stays_here(styling), styling); + for fetching in [ + ".card{background:url(https://x.example/y.png)}", + "@import 'https://x.example/f.css';", + ".card{background:URL(//x.example/y.png)}", + ".card{background-image:image-set('y.png' 1x)}", + r".card{background:\75rl(https://x.example/y.png)}", + ] { + assert_eq!(css_that_stays_here(fetching), "", "{fetching}"); + } + } + + struct Scratch(std::path::PathBuf); + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn scratch(tag: &str) -> Scratch { + Scratch(std::env::temp_dir().join(format!( + "ic-recipe-{tag}-{}", + &crate::crypto::random_token()[..8] + ))) + } + + fn first_alert(i: &mut Integration) -> &mut Step { + i.handlers[0] + .steps + .iter_mut() + .find(|s| s.kind == StepKind::Overlay) + .unwrap() + } + + /// A friend's alerts arrive looking and sounding like theirs: their + /// theme comes along, and so do their uploads, saved here (beside a + /// different file of the same name) and pointed at. One too big to + /// come along falls back instead of pointing at nothing. + #[test] + fn alerts_bring_their_theme_and_uploads() { + let (theirs, mine) = (scratch("theirs"), scratch("mine")); + alert_media::save(&theirs.0, "ding.mp3", b"ID3ding").unwrap(); + alert_media::save(&mine.0, "ding.mp3", b"ID3other").unwrap(); + let mut alert = presets::build("twitch_alerts", "a1".into(), "").unwrap(); + let step = first_alert(&mut alert); + step.params.insert("sound".into(), "media:ding.mp3".into()); + step.params.insert("image".into(), "media:gone.gif".into()); + let look = Look { + theme: "signal".into(), + sound: "media:ding.mp3".into(), + css: ".card{letter-spacing:1px}".into(), + ..Look::default() + }; + let text = export("Alerts", &[alert], Some(&look), &theirs.0); + let mut recipe = parse(&text, || "fresh1234567890ab".into()).unwrap(); + let shared = recipe.alerts.take().unwrap(); + assert_eq!(shared.look, look); + assert_eq!(shared.left_out, ["gone.gif"]); + let (name, data) = &shared.media[0]; + let kept = keep_shared_media(&mine.0, name, data).unwrap(); + assert_eq!(kept, "ding-2.mp3"); + assert_eq!( + keep_shared_media(&mine.0, name, data).unwrap(), + "ding-2.mp3" + ); + let saved = BTreeMap::from([(name.clone(), kept)]); + let mut theme = shared.look.clone(); + point_media(&mut recipe, Some(&mut theme), &saved); + let step = first_alert(&mut recipe.integrations[0]); + assert_eq!(step.param("sound"), "media:ding-2.mp3"); + assert_eq!(step.param("image"), ""); + assert_eq!(theme.sound, "media:ding-2.mp3"); + } + + /// A recipe can't smuggle in a page or a script as an "upload", and + /// one without alerts brings no theme. + #[test] + fn shared_uploads_must_be_sounds_or_pictures() { + let forged = json::obj([( + "media", + Value::Arr(vec![json::obj([ + ("name", json::str("page.html")), + ("data", json::str(base64url_encode(b" diff --git a/web/dock.js b/web/dock.js index 8b0268f..5d3752e 100644 --- a/web/dock.js +++ b/web/dock.js @@ -6,6 +6,9 @@ const $ = id => document.getElementById(id); const PHASE_TO_STATE = { idle: 'off', preparing: 'arming', ready: 'armed', active: 'active' }; const EASE = 'cubic-bezier(.32,.72,0,1)'; +// The longest delay anything can arm, config::MAX_DELAY_MS in the backend. +const DELAY_MAX_S = 3600; +const FORM = { 'content-type': 'application/x-www-form-urlencoded' }; let s = null, lastVal = '0.0', pending = null, pendingTimer = 0; // Guarded writes. The state stream lands about 4 times a second and most @@ -24,28 +27,78 @@ function fmtClock(ms) { const secs = Math.max(0, Math.ceil(ms / 1000)); return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`; } +// A delay in the dock's one unit, seconds: the number, the field and every +// label agree (Activate 900s under a 900), whatever the length. +function fmtDelay(ms) { + return Math.round(ms / 1000) + 's'; +} +// What a dock opened by a link without the access token sees once a +// dashboard password is on: every call answers 401. +const AUTH_MSG = "This dock link is missing its access token. Copy the dock URL again from the dashboard's OBS tab."; +let authBlocked = false; async function fetchJ(u, o) { try { const r = await fetch(u, o); - const j = r.headers.get('content-type')?.includes('json') ? await r.json() : null; + const j = r.headers.get('content-type')?.includes('json') ? await r.json().catch(() => null) : null; + noteAuth(r.status === 401); return { ok: r.ok, status: r.status, j }; } catch (_) { return { ok: false, status: 0, j: null }; } } -function toast(msg, kind) { - const t = $('toast'); t.className = 'show ' + (kind || ''); t.textContent = msg; - clearTimeout(t._h); t._h = setTimeout(() => t.className = '', 1800); +// The message for a failed call: the server's own reason when it gave one. +function failText(r, fallback) { + if (r.status === 401) return AUTH_MSG; + if (r.status === 0) return "Can't reach InstantClone. Is it running?"; + return r.j?.error || fallback; +} +function noteAuth(blocked) { + if (authBlocked === blocked) return; + authBlocked = blocked; + paintNote(); } +// One toast at a time. Errors stay long enough to read twice, and a hover +// holds any toast until the pointer leaves. +function toast(msg, kind) { + const t = $('toast'), k = kind || 'info'; + t.className = 'show ' + k; + t.firstElementChild.textContent = { ok: '✓', err: '!' }[k] || 'i'; + t.lastElementChild.textContent = msg; + t._ms = k === 'err' ? 4500 : 2200; + armToast(t); +} +function armToast(t) { + clearTimeout(t._h); + if (!t._hover) t._h = setTimeout(() => t.classList.remove('show'), t._ms); +} +$('toast').addEventListener('mouseenter', e => { e.currentTarget._hover = true; clearTimeout(e.currentTarget._h); }); +$('toast').addEventListener('mouseleave', e => { e.currentTarget._hover = false; armToast(e.currentTarget); }); +// Safe in text and in either kind of quoted attribute, as integrations.js's. function esc(v) { - return String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + return String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} +// OBS's dock browser can refuse the async clipboard (no focus, no +// permission); the older copy command still works there. +async function copyText(text) { + try { await navigator.clipboard.writeText(text); return true; } catch (_) {} + const ta = document.createElement('textarea'); + ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none'; + document.body.appendChild(ta); ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (_) {} + ta.remove(); + return ok; } // ---------------------------------------------------------------- config ---- +const params = new URLSearchParams(location.search); // The dock slot this page is (?dock=). Sanitised to match the server's // allowed key charset so a hand-typed slot can round-trip to storage. -const SLOT = ((new URLSearchParams(location.search).get('dock') || 'default') - .replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40)) || 'default'; +const SLOT = ((params.get('dock') || 'default').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40)) || 'default'; +// The access token this dock was opened with, carried into every dock link +// it copies so a password-protected dashboard lets those docks in too. +const TOKEN = (params.get('token') || '').replace(/[^a-zA-Z0-9]/g, ''); +const DOCKS_MAX = 64; // config::MAX_DOCKS // Widget catalog: id -> label + default options (on + per-widget knobs). const WIDGETS = { @@ -110,7 +163,8 @@ const OPTS = { ], profiles: [ { k: 'view', label: 'Layout', modes: [['chips', 'Chips'], ['list', 'List']] }, - { k: 'arm', label: 'Arm on tap' }, { k: 'value', label: 'Show seconds' }, + // Off, a tap only picks the delay and the main button confirms it. + { k: 'arm', label: 'Apply on tap' }, { k: 'value', label: 'Show delay' }, ], stats: [ { k: 'view', label: 'Layout', modes: [['row', 'Row'], ['tiles', 'Tiles']] }, @@ -126,7 +180,7 @@ const OPTS = { function defaultCfg() { const w = {}; for (const id of ALL) w[id] = { ...WIDGETS[id].def }; - return { v: 1, preset: 'full', density: 'comfy', accent: 'cyan', order: [...ROWS], w }; + return { v: 1, name: '', preset: 'full', density: 'comfy', accent: 'cyan', order: [...ROWS], w }; } // Accent presets: [--accent, --accent-ink]. Ink is the dark text drawn on // filled accent buttons, tuned per hue so it stays legible. @@ -138,6 +192,9 @@ const ACCENTS = { pink: ['#f472b6', '#24040f'], blue: ['#6aa8ff', '#04101c'], }; +// A dock's display name: what the Docks list calls it. The slot id in its +// URL never changes, so renaming can't strand the OBS dock that uses it. +function cleanName(v) { return String(v || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 40); } // Keep only known row ids, drop dupes, and append any rows a newer version // added so an old saved order still renders every widget. function sanitizeOrder(raw) { @@ -152,6 +209,7 @@ function sanitizeOrder(raw) { function mergeCfg(raw) { const base = defaultCfg(); if (!raw || typeof raw !== 'object') return base; + base.name = cleanName(raw.name); if (raw.preset) base.preset = raw.preset; if (raw.density === 'compact' || raw.density === 'comfy') base.density = raw.density; if (ACCENTS[raw.accent]) base.accent = raw.accent; @@ -167,56 +225,156 @@ let cfg = defaultCfg(); // -------------------------------------------------------- capacity gate ---- // The disk ring holds a finite amount of video. A delay bigger than it can -// fill at the current bitrate silently stalls in "arming" forever, which -// reads as a bug. Mirror the dashboard's estimate so the dock greys out (and -// refuses to arm) delays the buffer can't hold. Soft estimate: ~160 kbps -// audio, ~5% RTMP framing, a little headroom, and a peak-hold on bitrate so -// the gate doesn't flicker a chip in/out on a momentary dip. The server -// enforces the same wall on /arm, so this is UX, not the last line of defence. -const BUF_AUDIO_KBPS = 160, BUF_OVERHEAD_PCT = 5, BUF_HEADROOM_PCT = 3, BUF_REF_KBPS = 10000; -let bufferMB = 0; // persisted buffer size, from /config -let bitrateHoldKbps = 0; // worst-case recent bitrate (peak-hold, slow decay) -function updateBitrateHold() { - const m = s && s.stats && s.stats.bitrate_kbps; - if (!m || m <= 1000) return; // no signal - keep last hold - bitrateHoldKbps = Math.max(m, (bitrateHoldKbps || m) * 0.985); -} -function planningKbps() { - if (bitrateHoldKbps > 1000) return bitrateHoldKbps; - const m = s && s.stats && s.stats.bitrate_kbps; - return (m && m > 1000) ? m : BUF_REF_KBPS; // plan for 10 Mbps before we know -} -function capMsFor(mb, kbps) { - if (!mb || mb <= 0) return 0; - const total = (Math.max(1, kbps) + BUF_AUDIO_KBPS) * (1 + BUF_OVERHEAD_PCT / 100); - return Math.floor((mb * 8192) / total * (1 - BUF_HEADROOM_PCT / 100)) * 1000; -} -function currentCapMs() { return bufferMB > 0 ? capMsFor(bufferMB, planningKbps()) : 0; } +// fill at the current bitrate would only run at what the buffer holds. The +// dock greys out (and refuses to arm) delays the buffer can't hold, from the +// server's own estimate (state.buffer_capacity_ms_est, held at the recent +// peak bitrate), so it greys out exactly what /arm would refuse. +const BUF_SLACK_MS = 2000; // kept on top of the delay (BUFFER_SLACK_MS) +function currentCapMs() { return (s && s.buffer_capacity_ms_est) || 0; } +// The buffer size, in MB, that holds a delay of `ms` at the bitrate the +// server plans for, rounded up like the server's (buffer_mb_for). function requiredMB(ms) { - if (!ms || ms <= 0) return 0; - const total = (planningKbps() + BUF_AUDIO_KBPS) * (1 + BUF_OVERHEAD_PCT / 100); - return Math.ceil((ms / 1000) * total / (1 - BUF_HEADROOM_PCT / 100) / 8192); + if (!ms || ms <= 0 || !s || !s.capacity_kbps) return 0; + const bytes = Math.ceil((ms + BUF_SLACK_MS) * s.capacity_kbps / 8); + return Math.ceil(Math.ceil(bytes * 16 / 15) / 1048576); } function overCap(ms) { const c = currentCapMs(); return c > 0 && ms > c; } -async function fetchBufferMB() { - const r = await fetchJ('/config'); - if (r.ok && r.j && typeof r.j.buffer_mb === 'number') bufferMB = r.j.buffer_mb; - renderProfiles(); // re-gate chips now that capacity is known +// Why a delay doesn't fit: firm once OBS's bitrate is measured, an +// estimate at a typical rate before that. +function capAdvice(ms) { + return s && s.capacity_measured + ? `Buffer too small for ${fmtDelay(ms)}. Raise the buffer size to at least ${requiredMB(ms)} MB in System, then restart.` + : `Likely too long: at a typical rate the buffer holds about ${fmtDelay(currentCapMs())}. Your real bitrate decides once OBS connects.`; +} +function capToast(ms) { + toast(capAdvice(ms), 'err'); +} +// What the profile chips were last drawn for: which ones the buffer can't +// hold, the phase and the running delay. Chips redraw only when it moves. +let profileGate = ''; +function profileGateKey() { + const ds = s ? PHASE_TO_STATE[s.phase] || 'off' : 'off'; + return ds + ':' + (s?.armed_delay_ms || 0) + ':' + profileCache.map(p => (overCap(p.delay_ms || 0) ? 1 : 0)).join(''); } // ---------------------------------------------------------- delay controls -- +function phaseNow() { return s ? PHASE_TO_STATE[s.phase] || 'off' : 'off'; } +function isEngaged(ds) { return ds === 'armed' || ds === 'active'; } +// The delay in the field, in ms, held to what the server accepts so a label +// never offers a value the click would not send. +function inputMs() { + const sec = Math.round(+$('d').value || 0); + return Math.max(0, Math.min(DELAY_MAX_S, sec)) * 1000; +} + +// While a delay is armed or running the number is a readout. A tap on it, +// a +/- or a profile opens it for a new value, and the main button then +// offers Adjust / Re-arm. Esc, "Keep" or leaving it unchanged closes it. +let editing = false; +function startEdit(focus) { + const ds = phaseNow(); + if (!isEngaged(ds)) return; + if (!editing) { + editing = true; + $('d').value = Math.round((s.armed_delay_ms || 0) / 1000); + fitDelayInput(); + } + setNumberMode(ds); renderCta(); + if (focus) { $('d').focus(); $('d').select(); } +} +function endEdit() { + if (!editing) return; + editing = false; + if (!s) return; + const ds = phaseNow(); + setNumberMode(ds); renderCta(); + $('d').blur(); +} + function bump(n) { + if (isEngaged(phaseNow())) startEdit(false); const step = cfg.w.number.step || 1; const i = $('d'); - i.value = Math.max(0, Math.min(600, (+i.value || 0) + n * step)); + setDelayField(Math.max(0, Math.min(DELAY_MAX_S, Math.round(+i.value || 0) + n * step))); +} +// Put a new number in the field, rolling its digits like the dashboard's +// (not while someone is typing in it). +function setDelayField(sec) { + const i = $('d'), was = i.value || '0', now = String(sec); + i.value = now; fitDelayInput(); renderCta(); + if (was === now || !motionOK() || document.activeElement === i) return; + const cur = $('cur'), roll = $('d-roll'); + cur.classList.add('d-rolling'); + rollDigits(roll, was, now, () => cur.classList.remove('d-rolling')); +} +// Rolling digits, as on the dashboard: each changed digit rolls up (more) +// or down (less), the right-most first like a counter, and a digit that +// appears or goes away grows or shrinks its column instead of shoving the +// number sideways. Plays in `el`; `done` hands back to the plain text. +// Clicked again mid-roll, it runs at double speed so fast clicks keep up. +function rollDigits(el, from, to, done) { + const quick = el.classList.contains('rolling'); + const duration = quick ? 170 : 340, stagger = quick ? 20 : 40; + const n = Math.max(from.length, to.length); + const A = from.padStart(n, ' '), B = to.padStart(n, ' '); + const up = parseFloat(to) > parseFloat(from); + const glyph = ch => { const c = document.createElement('span'); c.textContent = ch === ' ' ? '\u00a0' : ch; return c; }; + const cols = [], starts = []; + let longest = 0; + for (let i = 0; i < n; i++) { + const col = document.createElement('span'); + cols.push(col); + if (A[i] === B[i]) { col.textContent = B[i]; continue; } + col.className = 'dg'; + const strip = document.createElement('span'); + strip.className = 'dg-strip'; + if (up) strip.append(glyph(A[i]), glyph(B[i])); else strip.append(glyph(B[i]), glyph(A[i])); + col.append(strip); + const delay = (n - 1 - i) * stagger; + longest = Math.max(longest, delay + duration); + starts.push(() => { + strip.animate([{ transform: `translateY(${up ? '0%' : '-50%'})` }, { transform: `translateY(${up ? '-50%' : '0%'})` }], + { duration, delay, easing: 'cubic-bezier(.3,1.3,.5,1)', fill: 'both' }); + if (A[i] === ' ') col.animate([{ width: '0px' }, { width: 'var(--dg-w)' }], { duration: duration * .7, delay, fill: 'both' }); + if (B[i] === ' ') col.animate([{ width: 'var(--dg-w)' }, { width: '0px' }], { duration: duration * .7, delay, fill: 'both' }); + }); + } + el.replaceChildren(...cols); + el.classList.add('rolling'); + starts.forEach(start => start()); + clearTimeout(el._roll); + el._rollDone = done; + el._roll = setTimeout(() => endRoll(el), longest + 20); +} +// Finish a roll now: typing, a focus or a fill tick takes over at once. +function endRoll(el) { + clearTimeout(el._roll); + if (!el.classList.contains('rolling')) return; + el.classList.remove('rolling'); + const done = el._rollDone; + el._rollDone = null; + if (done) done(); } // Size the idle setter to its digits so the unit sits right after them. function fitDelayInput() { const i = $('d'), w = Math.max(1, i.value.length) + 'ch'; if (i.style.width !== w) i.style.width = w; } +// A typed value past the range settles on the limit shortly after typing +// stops, or at once on Enter or leaving the field (as on the dashboard). +let settleTimer = 0; +function settleDelayInput() { + clearTimeout(settleTimer); + const i = $('d'); + if (i.value === '') return; + const settled = String(inputMs() / 1000); + if (settled === i.value) return; + i.value = settled; + fitDelayInput(); renderCta(); + if (motionOK()) $('cur').animate([0, -5, 5, -3, 3, 0].map(x => ({ transform: `translateX(${x}px)` })), { duration: 320, easing: 'ease-out' }); +} // Show the requested state until the server confirms it. One timer, so a // quick second click can't have the first click's timer cut it short. function setPending(p) { @@ -224,6 +382,7 @@ function setPending(p) { pendingTimer = setTimeout(() => { pending = null; renderCta(); }, 1500); tick(); } +function clearPending() { pending = null; clearTimeout(pendingTimer); } function fmtRate(k) { if (!k || k <= 0) return ''; return k >= 1000 ? (k / 1000).toFixed(2) + ' Mbps' : Math.round(k) + ' kbps'; } function fmt(ms) { if (ms < 100) return '0.0'; const x = Math.round(ms / 100) / 10; return x < 10 ? x.toFixed(1) : Math.round(x).toString(); } // Crossfade helper so the sub-tip swaps smoothly instead of snapping. @@ -233,36 +392,62 @@ function setTip(text) { el._t = setTimeout(() => { el.textContent = text; el.classList.remove('fade'); }, 140); } +function postArm(ms) { return fetchJ('/arm', { method: 'POST', headers: FORM, body: 'ms=' + ms }); } async function arm(ms) { - // Capacity guard, matched by the server's own /arm check. Skip for ms===0 - // (disarm). The toast names the buffer size the delay actually needs. - if (ms > 0 && overCap(ms)) { - toast(`Buffer too small - ${(ms / 1000).toFixed(0)}s needs about ${requiredMB(ms)} MB. Raise it in the dashboard.`, 'err'); - return; - } + // Capacity guard, matched by the server's own /arm check. The toast names + // the buffer size the delay actually needs. + if (overCap(ms)) { capToast(ms); return; } setPending('arming'); - const r = await fetchJ('/arm', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'ms=' + ms }); - if (!r.ok) { toast(r.j?.error || 'Failed to arm', 'err'); pending = null; } - else toast(`Arming ${(ms / 1000).toFixed(0)}s`, 'ok'); + const r = await postArm(ms); + if (!r.ok) { clearPending(); toast(failText(r, "Couldn't arm the delay"), 'err'); } + else toast(`Arming ${fmtDelay(ms)}`, 'ok'); tick(); } +// A new delay while one is engaged. /arm re-arms an armed delay, and moves +// a running one: shorter at once, longer once the buffer has the footage. +async function changeDelay(ms) { + if (overCap(ms)) { capToast(ms); return; } + const wasActive = phaseNow() === 'active'; + const r = await postArm(ms); + if (!r.ok) { toast(failText(r, "Couldn't change the delay"), 'err'); tick(); return; } + editing = false; + toast(wasActive ? `Delay moving to ${fmtDelay(ms)}` : `Re-armed at ${fmtDelay(ms)}`, 'ok'); + if (r.j && r.j.phase) applyState(r.j); else tick(); +} async function activate() { setPending('active'); const r = await fetchJ('/activate', { method: 'POST' }); - if (!r.ok) { toast(r.j?.error || 'Cannot activate yet', 'err'); pending = null; } + if (!r.ok) { clearPending(); toast(failText(r, "Can't activate yet"), 'err'); } else toast('Delay active', 'ok'); tick(); } // Cutting keeps the delay armed (the buffer keeps filling), so the dock // goes to Activate, not back to Arm. -async function stop() { setPending('armed'); await fetchJ('/stop', { method: 'POST' }); toast('Back to live', 'ok'); tick(); } -async function disarm() { setPending('off'); await fetchJ('/disarm', { method: 'POST' }); toast('Disarmed', 'ok'); tick(); } +async function stop() { + setPending('armed'); + const r = await fetchJ('/stop', { method: 'POST' }); + if (!r.ok) { clearPending(); toast(failText(r, "Couldn't cut the delay. It is still running."), 'err'); } + else toast('Back to live', 'ok'); + tick(); +} +async function disarm() { + setPending('off'); + const r = await fetchJ('/disarm', { method: 'POST' }); + if (!r.ok) { clearPending(); toast(failText(r, "Couldn't disarm"), 'err'); } + else toast('Disarmed', 'ok'); + tick(); +} function mainClick() { - const m = $('main'); if (m._busy || m.disabled) return; + const m = $('main'); if (m._busy || m.disabled || !s) return; m._busy = true; setTimeout(() => m._busy = false, 300); - if (!s) return; + settleDelayInput(); const ds = PHASE_TO_STATE[s.phase] || 'off'; + if (editing && isEngaged(ds)) { + const ms = inputMs(); + if (ms > 0 && ms !== (s.armed_delay_ms || 0)) changeDelay(ms); else endEdit(); + return; + } if (ds === 'active') { // Cutting a live delay drops you back to real time instantly - guard it // behind a two-tap confirm when the option is on. @@ -275,16 +460,16 @@ function mainClick() { } if (ds === 'armed') return activate(); if (ds === 'arming') return disarm(); // double-click safety: arming primary disarms too - const ms = (+$('d').value || 0) * 1000; - if (ms > 0) arm(ms); else toast('Enter a delay', 'err'); + const ms = inputMs(); + if (ms > 0) arm(ms); else toast('Enter a delay first', 'err'); } // Scheduled safe cut ("cut after this airs"): mark now, keep forwarding the // buffered footage, auto-cut to live once the mark has aired everywhere. // Same endpoints as the dashboard's Cut-after; state carries the countdown. async function cutAfter() { const r = await fetchJ('/cut-after', { method: 'POST' }); - if (!r.ok) toast(r.j?.error || 'Cannot schedule cut', 'err'); - else toast('Mark set - cuts once this airs', 'ok'); + if (!r.ok) toast(failText(r, "Couldn't schedule the cut"), 'err'); + else toast('Mark set: cuts once this airs', 'ok'); tick(); } // Ending the hold ends the stream everywhere: the first click arms, a second @@ -299,7 +484,8 @@ async function endHold() { disarmEndHold(); const r = await fetchJ('/crash-protection/end', { method: 'POST' }); if (r.status === 409) toast(r.j?.error || 'Nothing to end', 'info'); - else toast(r.ok ? 'Stream ended on every destination' : 'Could not end the stream', r.ok ? 'ok' : 'err'); + else if (r.ok) toast('Stream ended on every destination', 'ok'); + else toast(failText(r, "Couldn't end the stream"), 'err'); tick(); } function disarmEndHold() { @@ -308,8 +494,9 @@ function disarmEndHold() { clearTimeout(b._reset); b._armed = false; b.classList.remove('armed'); b.textContent = 'End now'; } async function cutAfterCancel() { - await fetchJ('/cut-after/cancel', { method: 'POST' }); - toast('Auto-cut cancelled', 'ok'); + const r = await fetchJ('/cut-after/cancel', { method: 'POST' }); + if (!r.ok) toast(failText(r, "Couldn't cancel the auto-cut. It will still cut."), 'err'); + else toast('Auto-cut cancelled', 'ok'); tick(); } // The secondary button next to the main CTA: cancels arming while the buffer @@ -323,14 +510,27 @@ function secondaryClick() { } // The big number is an editable setter while idle and a readout once a delay -// is engaged - the +/- retire so the screen reads clean (the feedback ask). +// is engaged, until a tap opens it for a new value (see startEdit). function setNumberMode(ds) { - const engaged = ds === 'arming' || ds === 'armed' || ds === 'active'; - const controls = cfg.w.number.controls && !engaged; - setHidden($('d'), engaged); - setHidden($('v'), !engaged); + if (editing && !isEngaged(ds)) editing = false; + const readout = (ds === 'arming' || isEngaged(ds)) && !editing; + const controls = cfg.w.number.controls && ds !== 'arming'; + setHidden($('d'), readout); + setHidden($('v'), !readout); setHidden($('minus'), !controls); setHidden($('plus'), !controls); + $('cur').classList.toggle('tap', readout && ds !== 'arming'); + $('w-screen').classList.toggle('editing', editing); + const cap = $('edit-cap'); + setHidden(cap, !editing); + // The label is set in capitals; the delay keeps its lowercase unit. + if (editing && cap._ms !== s.armed_delay_ms) { + cap._ms = s.armed_delay_ms; + const now = document.createElement('span'); + now.className = 'edit-cap-num'; + now.textContent = fmtDelay(s.armed_delay_ms || 0); + cap.replaceChildren('New delay · now ', now); + } } // ------------------------------------------------------------- state paint -- @@ -340,20 +540,40 @@ function setNumberMode(ds) { // counts (Ready in 0:23, Arm 30s) just updates its text. function mainLook(ds) { const m = $('main'); - if (ds === 'active') return { key: 'cut', cls: 'danger', label: m._cutArm ? 'Tap again to cut' : 'Cut delay' }; - if (ds === 'armed') return { key: 'armed', cls: 'go', icon: '▶', label: 'Activate' }; + const ms = inputMs(), armedMs = s.armed_delay_ms || 0; + // Over the buffer's reach: the button says so instead of failing on click. + const capLook = { key: 'cap', label: `Buffer holds only ${fmtDelay(currentCapMs())}`, disabled: true }; + if (editing && isEngaged(ds)) { + if (ms <= 0 || ms === armedMs) return { key: 'keep', cls: 'ghost', label: `Keep ${fmtDelay(armedMs)}` }; + if (overCap(ms)) return capLook; + return ds === 'active' + ? { key: 'adjust', cls: 'primary', icon: '↻', label: `Adjust to ${fmtDelay(ms)}` } + : { key: 'rearm', cls: 'primary', icon: '↻', label: `Re-arm at ${fmtDelay(ms)}` }; + } + if (ds === 'active') return { key: 'cut', cls: 'danger' + (m._cutArm ? ' armed' : ''), label: m._cutArm ? 'Tap again to cut' : 'Cut delay' }; + if (ds === 'armed') { + // Nothing arriving from OBS: /activate would only refuse, so wait. + if (!s.ingest_alive) return { key: 'wait-armed', label: 'Waiting for OBS', disabled: true }; + const runs = s.capacity_short ? Math.min(armedMs, s.capacity_short.holds_ms) : armedMs; + return { key: 'armed', cls: 'go', icon: '▶', label: `Activate ${fmtDelay(runs)}` }; + } if (ds === 'arming') { // The buffer fills in real time, so the wait is exactly what's missing. // Right after a click the server may not report the target yet. - const target = s.armed_delay_ms || (+$('d').value || 0) * 1000; + const target = armedMs || ms; const fill = Math.min(s.buffer_fill_ms || 0, target); const label = s.ingest_alive ? `Ready in ${fmtClock(target - fill)}` : 'Waiting for OBS'; return { key: 'arming', cls: 'filling', label, disabled: true, p: target > 0 ? fill / target : 0 }; } - const sec = +$('d').value || 0; + // Too long for the buffer is said now, not first when OBS connects. + // Before a bitrate is measured the reach is a typical one: "about". + if (!s.ingest_alive && ms > 0 && overCap(ms)) { + return { key: 'wait-cap', label: `Buffer holds about ${fmtDelay(currentCapMs())}`, disabled: true }; + } if (!s.ingest_alive) return { key: 'wait', label: 'Waiting for OBS', disabled: true }; - if (sec <= 0) return { key: 'set', label: 'Set a delay first', disabled: true }; - return { key: 'arm', cls: 'primary', icon: '⏱', label: `Arm ${sec.toFixed(0)}s` }; + if (ms <= 0) return { key: 'set', label: 'Set a delay first', disabled: true }; + if (overCap(ms)) return capLook; + return { key: 'arm', cls: 'primary', icon: '⏱', label: `Arm ${fmtDelay(ms)}` }; } function paintMain(look) { const m = $('main'); @@ -369,7 +589,7 @@ function paintMain(look) { m._label = document.createElement('span'); m._label.textContent = look.label; const parts = [m._label]; - if (look.icon) { const i = document.createElement('span'); i.textContent = look.icon; parts.unshift(i); } + if (look.icon) { const i = document.createElement('span'); i.textContent = look.icon; i.setAttribute('aria-hidden', 'true'); parts.unshift(i); } m.replaceChildren(...parts); if (first || !motionOK()) return; for (const p of parts) p.animate([{ opacity: 0, transform: 'translateY(5px)' }, { opacity: 1, transform: 'none' }], { duration: 220, easing: EASE }); @@ -384,7 +604,7 @@ function secondaryLook(ds) { if (ds !== 'active') return null; if (s.safe_cut_pending) { const cd = Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000)); - return { cls: 'btn ghost cutafter pending', text: `⏱ ~${cd}s ✕`, title: 'Auto-cut armed - tap to cancel' }; + return { cls: 'btn ghost cutafter pending', text: `⏱ ~${fmtDelay(cd * 1000)} ✕`, title: 'Auto-cut armed. Tap to cancel.' }; } if (cfg.w.activate.safecut) return { cls: 'btn ghost cutafter', text: '⏱ Cut after', title: 'Cut to live after everything buffered has aired' }; return null; @@ -403,12 +623,15 @@ function renderCta() { paintMain(mainLook(ds)); const sec = secondaryLook(ds); setHidden(cancel, !sec); - if (sec) { setClass(cancel, sec.cls); setText(cancel, sec.text); setTitle(cancel, sec.title); } - if (ds === 'off') { + if (sec) { + setClass(cancel, sec.cls); setText(cancel, sec.text); setTitle(cancel, sec.title); + if (cancel.getAttribute('aria-label') !== sec.title) cancel.setAttribute('aria-label', sec.title); + } + if (ds !== 'arming') { // Dead-end nudges read as disabled: can't go below 0 or above the cap. - const v = +$('d').value || 0; - setDisabled($('minus'), v <= 0); - setDisabled($('plus'), v >= 600); + const v = inputMs() / 1000; + setDisabled($('minus'), (editing || ds === 'off') && v <= 0); + setDisabled($('plus'), (editing || ds === 'off') && v >= DELAY_MAX_S); } } @@ -433,28 +656,45 @@ const PHASE_LOOK = { arming: ['arming', 'Buffering'], armed: ['armed', 'Armed'], function paintSource(alive) { setClass($('src'), 'pill ' + (alive ? 'on' : 'bad') + (cfg.w.source.style === 'dot' ? ' asdot' : '')); setText($('src-lbl'), alive ? 'Live' : 'Offline'); - setTitle($('src'), alive ? 'Live' : 'Offline'); + setTitle($('src'), alive ? 'OBS is sending' : 'Nothing from OBS'); } -// Readout: mirror the dashboard - show the armed target while engaged (or +// How far the delay can reach: the armed one, or what the buffer holds of +// it when it can't hold all of it (the server measured it: capacity_short). +function reachMs() { + const tgt = s.armed_delay_ms || 0, short = s.capacity_short; + return short && short.armed_ms === tgt ? Math.min(tgt, short.holds_ms) : tgt; +} + +// Readout: mirror the dashboard: show the armed target while engaged (or // the fill while arming) and 0 in passthrough. current_delay_ms includes // the pipeline's own ~1s transit, which would read as a phantom delay. +// Always in seconds, like the dashboard's big number: 900, never 15m. function paintReadout(ds) { - const tgtSec = (s.armed_delay_ms || 0) / 1000, fillSec = (s.buffer_fill_ms || 0) / 1000; - const showSec = ds === 'arming' ? Math.min(fillSec, tgtSec) : (ds === 'armed' || ds === 'active') ? tgtSec : 0; - const nv = fmt(showSec * 1000); + const tgt = s.armed_delay_ms || 0, fill = s.buffer_fill_ms || 0; + // Running short (the buffer can't hold the armed delay): the readout + // shows what really runs, in amber, like the dashboard. + const short = isEngaged(ds) && s.capacity_short ? s.capacity_short : null; + $('cur').classList.toggle('short', !!short); + const show = ds === 'arming' ? Math.min(fill, reachMs()) + : short ? Math.min(tgt, short.holds_ms) + : isEngaged(ds) ? tgt : 0; + const nv = fmt(show); if (nv === lastVal) return; - lastVal = nv; setText($('v'), nv); - // The pop marks a change you made (armed, activated, cut), not the - // buffer counting up while it fills. - if (ds === 'arming' || !motionOK()) return; + const was = lastVal; + lastVal = nv; + // The roll and the pop mark a change you made (armed, activated, cut), + // not the buffer counting up while it fills. + if (ds === 'arming' || !motionOK()) { endRoll($('v')); $('v').textContent = nv; return; } + rollDigits($('v'), was, nv, () => { $('v').textContent = lastVal; }); const cur = $('cur'); cur.classList.add('changing'); clearTimeout(cur._t); cur._t = setTimeout(() => cur.classList.remove('changing'), 350); } function paintBar(ds) { - const target = s.armed_delay_ms || s.buffer_target_ms || 0; + // Toward what the buffer can reach: a bar stuck halfway reads as broken. + const target = reachMs() || s.buffer_target_ms || 0; let pct = 0, cls = 'fill'; if (ds === 'active') { pct = 100; cls += ' active'; } else if (target > 0) { @@ -500,32 +740,88 @@ function paintHold(hold) { : 'Waiting for OBS'); } +// "4.2s" under a minute, whole seconds past it, for the live-behind hint. +function fmtBehind(ms) { return ms < 60000 ? (ms / 1000).toFixed(1) + 's' : fmtDelay(ms); } // Hint text: full sentences, or errors-only (encoder offline) if opted. function paintTip(ds, hold) { const tip = hold ? (hold.reason === 'crash' ? 'Reopen OBS in Normal Mode and start streaming to resume.' : 'Start streaming in OBS to resume.') : s.tail_ms > 0 ? 'End now cuts it short.' - : !s.ingest_alive ? 'Point your encoder at rtmp://…/live' - : ds === 'active' && s.safe_cut_pending ? `Auto-cut in ~${Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000))}s - marked footage still airs.` - : ds === 'active' ? `Live ${(s.current_delay_ms / 1000).toFixed(1)}s behind real time.` + : !s.ingest_alive ? 'Start streaming in OBS to InstantClone.' + : editing ? 'Pick a new delay, then confirm it below.' + : ds === 'active' && s.safe_cut_pending ? `Auto-cut in ~${fmtDelay(s.safe_cut_remaining_ms || 0)}. Marked footage still airs.` + : ds === 'active' ? `Live ${fmtBehind(s.current_delay_ms || 0)} behind real time.` + : ds === 'armed' && s.capacity_short ? `The buffer holds only ${fmtDelay(s.capacity_short.holds_ms)}. Activate runs that much.` : ds === 'armed' ? 'Buffer full. Hit Activate to go live with delay.' : ds === 'arming' ? 'Filling buffer… you can cancel any time.' - : 'Passthrough - no delay applied.'; + : 'Passthrough: no delay applied.'; const show = cfg.w.tips.on && (!cfg.w.tips.errorsOnly || !s.ingest_alive); setHidden($('w-tip'), !show); if (show) setTip(tip); } +// The notice line: a dock link the password turns away comes first, then +// an armed delay the buffer can't hold, then an encoder setting a +// destination won't take. The last two dismiss until they change. +let dismissedNote = ''; +function currentNote() { + return (s && s.capacity_short && s.capacity_short.message) || (s && s.compat_warning) || ''; +} +// Open or close the notice with a short height-and-fade, from wherever it +// is on screen, so a dismiss folds it away instead of snapping. +const NOTE_CLOSED = { height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px', paddingTop: '0px', paddingBottom: '0px' }; +function foldNote(box, hide) { + const goal = hide ? 'closed' : 'open'; + if ((box._anim ? box._goal : (box.hidden ? 'closed' : 'open')) === goal) return; + const snap = () => { const cs = getComputedStyle(box); return Object.fromEntries(Object.keys(NOTE_CLOSED).map(p => [p, cs[p]])); }; + const midway = box._anim ? snap() : null; + if (box._anim) box._anim.cancel(); + box._anim = null; + if (!motionOK()) { box.hidden = hide; return; } + box.hidden = false; + const open = snap(); + // The gap below it in the dock's column closes with it, so nothing snaps. + const ps = getComputedStyle(box.parentElement); + const gap = ps.display.includes('flex') || ps.display.includes('grid') ? parseFloat(ps.rowGap) || 0 : 0; + const closed = { ...NOTE_CLOSED, marginBottom: -gap + 'px' }; + box.style.overflow = 'hidden'; + box._goal = goal; + // Fade first, then close (open is the reverse). + const frames = midway ? [midway, hide ? closed : open] + : hide ? [open, { opacity: 0, offset: 0.45 }, closed] + : [closed, { opacity: 0, offset: 0.5 }, open]; + const anim = box.animate(frames, { duration: hide ? 280 : 340, easing: 'cubic-bezier(.4,0,.2,1)' }); + box._anim = anim; + anim.onfinish = () => { + if (box._anim !== anim) return; + box._anim = null; + box.style.overflow = ''; + box.hidden = hide; + }; +} +function paintNote() { + const box = $('w-note'); + const note = currentNote(); + const text = authBlocked ? AUTH_MSG : note !== dismissedNote ? note : ''; + foldNote(box, !text); + if (!text) return; + setClass(box, 'note' + (authBlocked ? ' bad' : '')); + setText($('note-text'), text); + setHidden($('note-x'), authBlocked); +} +$('note-x').onclick = () => { dismissedNote = currentNote(); paintNote(); }; + function applyState(j) { s = j; - updateBitrateHold(); // feed the capacity peak-hold before anything reads it + if (profileGateKey() !== profileGate) renderProfiles(); // phase, delay or buffer reach moved const ds = PHASE_TO_STATE[s.phase] || 'off'; - if (pending && pending === ds) { pending = null; clearTimeout(pendingTimer); } // the server caught up + if (pending && pending === ds) clearPending(); // the server caught up setNumberMode(ds); const [cls, text] = s.hold ? ['holding', 'Holding'] : s.tail_ms > 0 ? ['holding', 'Ending'] : PHASE_LOOK[ds] || (s.ingest_alive ? ['live', 'Live'] : ['off', 'Idle']); setPhase(cls, text); paintSource(!!s.ingest_alive); + paintNote(); paintReadout(ds); paintBar(ds); paintEgress(); @@ -612,6 +908,7 @@ function destRow() { row.innerHTML = ''; row._name = row.children[1]; row._meta = row.children[2]; row._sw = document.createElement('button'); + row._sw.setAttribute('role', 'switch'); row._sw.onclick = () => onDestToggle(row); row.appendChild(row._sw); return row; @@ -623,9 +920,16 @@ function patchDestRow(row, d) { setText(row._name, d.name || 'Destination'); const rate = cfg.w.dest.stats && st === 'alive' ? fmtRate(d.bitrate_kbps) : ''; setClass(row._meta, rate ? 'd-rate' : 'd-plat'); - setText(row._meta, st === 'held' ? 'Holding' : st === 'wait' ? 'Connecting…' : rate || platform); + // A failing destination says so; why, and the next try, on hover. The + // last reason is kept while it connects again, never once it is live. + const fail = st === 'wait' ? d.failure : null; + setText(row._meta, st === 'held' ? 'Holding' : st === 'wait' ? (fail ? 'Can\'t connect' : 'Connecting…') : rate || platform); + setTitle(row, fail ? fail.advice + (fail.retry_in_ms != null ? ` Next try in ${Math.ceil(fail.retry_in_ms / 1000)}s.` : '') : ''); setClass(row._sw, 'sw' + (d.enabled ? ' on' : '')); setTitle(row._sw, d.enabled ? 'Turn off' : 'Turn on'); + const checked = String(!!d.enabled); + if (row._sw.getAttribute('aria-checked') !== checked) row._sw.setAttribute('aria-checked', checked); + if (row._sw.getAttribute('aria-label') !== (d.name || 'Destination')) row._sw.setAttribute('aria-label', d.name || 'Destination'); } // Compact circular badge: platform initial, live-glow ring, hover shows the // name (native title). Tap arms it (confirm), tap again commits. @@ -642,6 +946,7 @@ function patchDestIcon(b, d) { setClass(b, 'dico ' + (d.enabled ? 'on' : 'off') + look + destColor(b, d)); setText(b, (destPlatforms[d.id] || d.name || '?').slice(0, 1).toUpperCase()); setTitle(b, `${d.name || 'Destination'} · ${d.enabled ? 'on' : 'off'}`); + if (b.getAttribute('aria-label') !== b.title) b.setAttribute('aria-label', b.title); } function onDestIcon(b) { const d = b._d; @@ -668,62 +973,79 @@ function onDestToggle(row) { const next = !d.enabled; const box = document.createElement('span'); box.className = 'confirm'; box.innerHTML = `${next ? 'Turn on?' : 'Turn off?'}`; - const yes = document.createElement('button'); yes.className = 'cbtn yes'; yes.innerHTML = '✓'; - const no = document.createElement('button'); no.className = 'cbtn no'; no.innerHTML = '×'; + const yes = document.createElement('button'); yes.className = 'cbtn yes'; yes.innerHTML = '✓'; yes.setAttribute('aria-label', 'Confirm'); + const no = document.createElement('button'); no.className = 'cbtn no'; no.innerHTML = '×'; no.setAttribute('aria-label', 'Cancel'); const cancel = () => { clearTimeout(row._ct); row._confirm = false; box.replaceWith(row._sw); }; yes.onclick = () => { cancel(); commitDest(d, next); }; no.onclick = cancel; box.append(yes, no); row._sw.replaceWith(box); row._confirm = true; row._ct = setTimeout(cancel, 3000); + yes.focus(); } async function commitDest(d, enabled) { const r = await fetchJ('/destinations/toggle', { - method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', headers: FORM, body: `id=${encodeURIComponent(d.id)}&enabled=${enabled ? 'on' : 'off'}`, }); - if (!r.ok) toast(r.j?.error || 'Toggle failed', 'err'); + if (!r.ok) toast(failText(r, `Couldn't turn ${d.name || 'it'} ${enabled ? 'on' : 'off'}`), 'err'); else toast(`${d.name || 'Destination'} ${enabled ? 'on' : 'off'}`, 'ok'); } // --------------------------------------------------------- extra widgets ---- // Delay profiles: one-tap chips from the user's saved profiles (same list as -// the dashboard). Tapping fills the delay and optionally arms it. +// the dashboard). Idle, a tap picks the delay (and arms it with "Apply on +// tap"); armed or running, it moves to that delay the same way. let profileCache = []; async function fetchProfiles() { const r = await fetchJ('/profiles'); if (r.ok && Array.isArray(r.j)) { profileCache = r.j; renderProfiles(); } } +function profileTitle(name, ms, ds, isCur) { + if (isCur) return `${name}: running now`; + if (ds === 'arming') return 'Wait for the buffer to fill, or cancel arming'; + const apply = cfg.w.profiles.arm; + if (ds === 'active') return apply ? `Adjust to ${fmtDelay(ms)}` : `Pick ${fmtDelay(ms)}, then Adjust`; + if (ds === 'armed') return apply ? `Re-arm at ${fmtDelay(ms)}` : `Pick ${fmtDelay(ms)}, then Re-arm`; + return apply ? `Arm ${fmtDelay(ms)}` : `Set ${fmtDelay(ms)}`; +} function renderProfiles() { + profileGate = profileGateKey(); const box = $('w-profiles'); if (box.hidden) return; const list = cfg.w.profiles.view === 'list'; box.className = 'profiles' + (list ? ' plist' : ''); if (!profileCache.length) { box.innerHTML = '
No profiles yet. Add them in the dashboard.
'; return; } + const ds = phaseNow(), target = s?.armed_delay_ms || 0; box.innerHTML = ''; for (const p of profileCache) { - const ms = p.delay_ms || 0, sec = Math.round(ms / 1000); + const ms = p.delay_ms || 0, name = p.name || fmtDelay(ms); // Too big for the buffer at the current bitrate: greyed + disabled, with // a tooltip naming the size it needs. Same wall the arm path enforces. const tooBig = overCap(ms); + const isCur = ds !== 'off' && ms === target; const b = document.createElement('button'); - b.className = (list ? 'prow' : 'pchip') + (tooBig ? ' toobig' : ''); - b.disabled = tooBig; - b.title = tooBig ? `Buffer too small - needs about ${requiredMB(ms)} MB. Raise it in the dashboard.` : `Set ${sec}s delay`; + b.className = (list ? 'prow' : 'pchip') + (tooBig ? ' toobig' : '') + (isCur ? ' on' : ''); + b.disabled = tooBig || isCur || ds === 'arming'; + b.title = tooBig ? capAdvice(ms) : profileTitle(name, ms, ds, isCur); + if (isCur) b.setAttribute('aria-current', 'true'); b.innerHTML = list - ? `${esc(p.name || sec + 's')}${sec}s` - : esc(p.name || sec + 's') + (cfg.w.profiles.value ? `${sec}s` : ''); - b.onclick = () => profileSet(sec); + ? `${esc(name)}${fmtDelay(ms)}` + : esc(name) + (cfg.w.profiles.value ? `${fmtDelay(ms)}` : ''); + b.onclick = () => profileSet(ms); box.appendChild(b); } } -function profileSet(sec) { - if (overCap(sec * 1000)) { - toast(`Buffer too small for ${sec}s - raise it in the dashboard.`, 'err'); - return; +function profileSet(ms) { + if (overCap(ms)) { capToast(ms); return; } + const ds = phaseNow(), sec = Math.round(ms / 1000); + if (ds === 'arming') return; + if (isEngaged(ds)) { + if (ms === (s.armed_delay_ms || 0)) return; + if (cfg.w.profiles.arm) { changeDelay(ms); return; } + startEdit(false); } - $('d').value = sec; fitDelayInput(); renderCta(); - const ds = s ? (PHASE_TO_STATE[s.phase] || 'off') : 'off'; - if (cfg.w.profiles.arm && ds === 'off' && sec > 0) arm(sec * 1000); + setDelayField(sec); + if (ds === 'off' && cfg.w.profiles.arm && sec > 0) arm(ms); } // Health stats: CPU / RAM / reconnects, plus a backpressure warning. All read @@ -745,49 +1067,60 @@ function renderStats() { setHtml(box, html || 'no metrics selected'); } -// Server settings mirrored on the dock (Auto behavior + Settings widgets). -// Read from /config; each boolean flip is a safe partial POST /config. -let srvCfg = { auto_arm_on_connect: false, auto_activate_when_ready: false, tracing_enabled: true }; +// The dock's own switches (Auto behavior + Settings widgets), read and +// written through /dock/settings: a fixed set of on/off settings the dock +// token may change even with a dashboard password on. auth_enabled tells +// the dock whether the links it copies need the token. +let srvCfg = { auto_arm_on_connect: false, auto_activate_when_ready: false, tracing_enabled: false, 'crash_protection.enabled': false, auth_enabled: false }; +let srvLoaded = false; async function fetchServerCfg() { - const r = await fetchJ('/config'); + const r = await fetchJ('/dock/settings'); if (!r.ok || !r.j) return; - srvCfg.auto_arm_on_connect = !!r.j.auto_arm_on_connect; - srvCfg.auto_activate_when_ready = !!r.j.auto_activate_when_ready; - srvCfg.tracing_enabled = !!r.j.tracing_enabled; - if (typeof r.j.buffer_mb === 'number') { bufferMB = r.j.buffer_mb; renderProfiles(); } + srvCfg = { ...srvCfg, ...r.j }; srvLoaded = true; renderBehavior(); renderSettings(); } -// A label + switch row bound to a boolean server-config key. -function toggleRow(label, key) { +function paintSwitch(sw, on) { + sw.className = 'sw sm' + (on ? ' on' : ''); + sw.setAttribute('aria-checked', String(on)); +} +// A label + switch row bound to one dock switch. +function toggleRow(label, key, hint) { const row = document.createElement('div'); row.className = 'brow'; + if (hint) row.title = hint; const lb = document.createElement('span'); lb.className = 'blbl'; lb.textContent = label; - const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'sw sm' + (srvCfg[key] ? ' on' : ''); - sw.onclick = () => setServer(key, !srvCfg[key], sw); + const sw = document.createElement('button'); sw.type = 'button'; sw.setAttribute('role', 'switch'); sw.setAttribute('aria-label', label); + paintSwitch(sw, !!srvCfg[key]); + sw.disabled = !srvLoaded; // never flip a switch whose real state is unknown + sw.onclick = () => setServer(key, !srvCfg[key], sw, label); row.append(lb, sw); return row; } -async function setServer(key, val, sw) { - srvCfg[key] = val; if (sw) sw.className = 'sw sm' + (val ? ' on' : ''); - const r = await fetchJ('/config', { - method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: `${key}=${val ? 'true' : 'false'}`, - }); - if (!r.ok) { toast('Save failed', 'err'); fetchServerCfg(); } - else toast(val ? 'On' : 'Off', 'ok'); +async function setServer(key, val, sw, label) { + srvCfg[key] = val; paintSwitch(sw, val); sw.disabled = true; + const r = await fetchJ('/dock/settings', { method: 'POST', headers: FORM, body: `${encodeURIComponent(key)}=${val}` }); + sw.disabled = false; + if (!r.ok) { + srvCfg[key] = !val; paintSwitch(sw, !val); + toast(failText(r, `Couldn't change ${label}`), 'err'); + return; + } + if (r.j) srvCfg = { ...srvCfg, ...r.j }; + toast(`${label} ${val ? 'on' : 'off'}`, 'ok'); } function renderBehavior() { const box = $('w-behavior'); if (box.hidden) return; - box.innerHTML = ''; - box.appendChild(toggleRow('Auto-arm on connect', 'auto_arm_on_connect')); - box.appendChild(toggleRow('Auto-activate when full', 'auto_activate_when_ready')); + box.replaceChildren( + toggleRow('Pre-arm on stream start', 'auto_arm_on_connect', 'Arms your Default delay (System › General) when OBS starts streaming'), + toggleRow('Auto-activate when ready', 'auto_activate_when_ready', 'Every arm goes on air once the buffer is full')); } // Settings: the live-safe knobs (things that don't need a restart). Buffer / // ports stay in the dashboard because changing them restarts the pipeline. function renderSettings() { const box = $('w-settings'); if (box.hidden) return; - box.innerHTML = ''; - box.appendChild(toggleRow('Wire trace log', 'tracing_enabled')); + box.replaceChildren( + toggleRow('Crash protection', 'crash_protection.enabled', 'If OBS drops, viewers see a reconnect screen instead of the stream ending'), + toggleRow('Trace log', 'tracing_enabled', 'A detailed log for bug reports. Leave it off otherwise')); // Discord, chat and phone alerts are integrations, set up in the // dashboard: they take secret links, awkward to paste into a dock. const ig = document.createElement('div'); ig.className = 'brow'; @@ -800,11 +1133,12 @@ function renderSettings() { function confirmOpenDashboard(btns) { btns.innerHTML = ''; const q = document.createElement('span'); q.className = 'cq'; q.textContent = 'Open dashboard?'; - q.title = 'Opens the Integrations tab of the InstantClone dashboard in a new tab.'; + q.title = 'Opens the Integrations tab of the InstantClone dashboard in a new tab. With a password on, sign in there first.'; const yes = document.createElement('button'); yes.className = 'minibtn'; yes.textContent = 'Yes'; yes.onclick = () => { window.open(location.origin + '/#integrations', '_blank'); toast('Opening dashboard…', 'ok'); renderSettings(); }; const no = document.createElement('button'); no.className = 'minibtn'; no.textContent = 'No'; no.onclick = renderSettings; btns.append(q, yes, no); + yes.focus(); } // Overlays: list the OBS browser-source overlays and copy their URLs, so a @@ -823,6 +1157,7 @@ function renderOverlays() { // re-renders on keystroke, so the input keeps focus + caret. if (cfg.w.overlays.search) { const inp = document.createElement('input'); inp.className = 'osearch'; inp.placeholder = 'Search overlays…'; inp.value = overlayQuery; + inp.setAttribute('aria-label', 'Search overlays'); inp.oninput = () => { overlayQuery = inp.value; renderOverlayList(); }; box.appendChild(inp); } @@ -852,7 +1187,8 @@ function overlayRow(o) { const row = document.createElement('div'); row.className = 'orow'; const nm = document.createElement('span'); nm.className = 'o-name'; nm.textContent = o.name || o.slug; if (o.studio && !cfg.w.overlays.group) { const tag = document.createElement('span'); tag.className = 'o-tag'; tag.textContent = 'studio'; nm.appendChild(tag); } - const copy = document.createElement('button'); copy.className = 'minibtn'; copy.innerHTML = '🔗'; copy.title = 'Copy browser-source URL'; copy.onclick = () => copyOverlay(o.slug); + const copy = document.createElement('button'); copy.className = 'minibtn'; copy.textContent = 'Copy URL'; + copy.title = 'Copy the browser-source URL'; copy.onclick = () => copyOverlay(o.slug); row.append(nm, copy); return row; } @@ -864,9 +1200,9 @@ function overlayUrl(slug) { return `${location.origin}/overlay/${encodeURIComponent(slug)}.html${q}`; } async function copyOverlay(slug) { + // Overlay URLs carry no secret, so a failed copy can show the URL itself. const url = overlayUrl(slug); - try { await navigator.clipboard.writeText(url); toast('Overlay URL copied', 'ok'); } - catch (_) { toast(url, 'ok'); } + if (await copyText(url)) toast('Overlay URL copied', 'ok'); else toast(url, 'info'); } // -------------------------------------------------------------- layout apply -- @@ -888,7 +1224,7 @@ function applyCfg(c) { // the number (its toggle lives under the Number row), so turning the number // off hides the whole screen, bar included. $('w-screen').hidden = !on('number'); - $('w-screen').className = 'screen sz-' + (c.w.number.size || 'md'); + $('w-screen').className = 'screen sz-' + (c.w.number.size || 'md') + (editing ? ' editing' : ''); $('cur').classList.toggle('tint', !!c.w.number.tint); $('w-bar').hidden = !on('bar'); $('w-bar').classList.toggle('thick', !!c.w.bar.thick); @@ -900,25 +1236,37 @@ function applyCfg(c) { for (const id of c.order) { const el = $(WEL[id]); if (el) wrap.appendChild(el); } // The crash hold card takes the place of the delay controls it stands in // for. A dock without them (destinations only, overlays) shows it on top, - // so every dock can end the stream without losing its own purpose. + // so every dock can end the stream without losing its own purpose. The + // notice line sits right below it, above the controls it is about. const holdAnchor = on('number') ? $('w-screen') : on('activate') ? $('w-cta') : wrap.firstElementChild; if (holdAnchor !== $('w-hold')) wrap.insertBefore($('w-hold'), holdAnchor); - if (on('profiles')) { fetchProfiles(); if (!bufferMB) fetchBufferMB(); } // capacity gate needs buffer size + wrap.insertBefore($('w-note'), $('w-hold').nextSibling); + if (on('profiles')) fetchProfiles(); if (on('behavior') || on('settings')) { renderBehavior(); renderSettings(); fetchServerCfg(); } if (on('overlays')) fetchOverlays(); if (s) applyState(s); else renderCta(); // re-render with the new options } -function persist() { +function dockKey(id) { return 'ic.dock.' + id; } +function saveLayout(id, json) { + return fetchJ('/docks/' + encodeURIComponent(id), { method: 'POST', headers: { 'content-type': 'application/json' }, body: json }); +} +// Mirror a layout into localStorage too: every OBS dock shares one browser +// profile, so a dock showing that slot adopts it live (the storage event). +function mirrorLayout(id, json) { + try { if (json) localStorage.setItem(dockKey(id), json); else localStorage.removeItem(dockKey(id)); } catch (_) {} +} +async function persist() { const json = JSON.stringify(cfg); - try { localStorage.setItem('ic.dock.' + SLOT, json); } catch (_) {} - fetchJ('/docks/' + encodeURIComponent(SLOT), { method: 'POST', headers: { 'content-type': 'application/json' }, body: json }); + mirrorLayout(SLOT, json); + const r = await saveLayout(SLOT, json); + if (!r.ok) toast(failText(r, "Couldn't save this layout"), 'err'); } function saveAndApply() { applyCfg(cfg); persist(); } async function loadCfg() { // 1) A ?cfg= blob (shared / imported URL) wins and is saved to this slot. - const p = new URLSearchParams(location.search).get('cfg'); + const p = params.get('cfg'); if (p) { try { cfg = mergeCfg(JSON.parse(decodeURIComponent(escape(atob(p))))); @@ -928,12 +1276,12 @@ async function loadCfg() { } catch (_) {} } // 2) localStorage mirror: instant paint before the server answers. - try { const ls = localStorage.getItem('ic.dock.' + SLOT); if (ls) { cfg = mergeCfg(JSON.parse(ls)); applyCfg(cfg); } } catch (_) {} + try { const ls = localStorage.getItem(dockKey(SLOT)); if (ls) { cfg = mergeCfg(JSON.parse(ls)); applyCfg(cfg); } } catch (_) {} // 3) Server copy is the durable source of truth; reconcile when it lands. const r = await fetchJ('/docks/' + encodeURIComponent(SLOT)); if (r.ok && r.j && typeof r.j === 'object') { cfg = mergeCfg(r.j); applyCfg(cfg); - try { localStorage.setItem('ic.dock.' + SLOT, JSON.stringify(cfg)); } catch (_) {} + mirrorLayout(SLOT, JSON.stringify(cfg)); } } @@ -943,18 +1291,12 @@ let dragId = null; // Which widget rows are expanded. Kept across the frequent buildEditor() // rebuilds so flipping an option doesn't snap its panel shut. const openRows = new Set(); -// Saved dock slots the server knows about (for the Docks switcher). -let knownSlots = []; - -async function fetchSlots() { - const r = await fetchJ('/docks'); - if (r.ok && Array.isArray(r.j)) { knownSlots = r.j; if (!$('editor').hidden) buildEditor(); } -} function ensureEditorRoot() { let el = $('editor'); if (!el) { el = document.createElement('div'); el.id = 'editor'; el.className = 'editor'; el.hidden = true; + el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-label', 'Customize dock'); el.innerHTML = '
'; el.onclick = e => { if (e.target === el) closeEditor(); }; document.body.appendChild(el); @@ -964,11 +1306,14 @@ function ensureEditorRoot() { function openEditor() { const root = ensureEditorRoot(); buildEditor(); root.hidden = false; requestAnimationFrame(() => root.classList.add('show')); - fetchSlots(); // populate the Docks switcher + fetchSlots(); // populate the Docks list + root.querySelector('.eclose').focus(); } function closeEditor() { const root = ensureEditorRoot(); root.classList.remove('show'); + dockUi = { open: '', form: null, confirm: '' }; setTimeout(() => root.hidden = true, 220); // let the slide-out finish + $('gear').focus(); } // A row of segmented buttons (the "mode" control). `current` is the selected @@ -977,7 +1322,9 @@ function segControl(current, items, onPick) { const seg = document.createElement('div'); seg.className = 'seg'; for (const [val, lab] of items) { const b = document.createElement('button'); b.type = 'button'; - b.className = 'segb' + (String(current) === String(val) ? ' sel' : ''); b.textContent = lab; + const sel = String(current) === String(val); + b.className = 'segb' + (sel ? ' sel' : ''); b.textContent = lab; + b.setAttribute('aria-pressed', String(sel)); b.onclick = () => onPick(val); seg.appendChild(b); } @@ -994,7 +1341,8 @@ function optionEls(id) { const items = sp.modes || sp.sel.map(v => [v, String(v)]); row.appendChild(segControl(cfg.w[t][sp.k], items, val => { cfg.w[t][sp.k] = val; cfg.preset = 'custom'; saveAndApply(); buildEditor(); })); } else { - const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'sw sm' + (cfg.w[t][sp.k] ? ' on' : ''); + const sw = document.createElement('button'); sw.type = 'button'; sw.setAttribute('role', 'switch'); sw.setAttribute('aria-label', sp.label); + paintSwitch(sw, !!cfg.w[t][sp.k]); sw.onclick = () => { cfg.w[t][sp.k] = !cfg.w[t][sp.k]; cfg.preset = 'custom'; saveAndApply(); buildEditor(); }; row.appendChild(sw); } @@ -1016,11 +1364,13 @@ function widgetRow(id) { row.ondrop = e => { e.preventDefault(); row.classList.remove('drop'); if (dragId) reorder(dragId, id); }; const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = WIDGETS[id].label; const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'sw' + (w.on ? ' on' : ''); + sw.setAttribute('role', 'switch'); sw.setAttribute('aria-checked', String(!!w.on)); sw.setAttribute('aria-label', WIDGETS[id].label); sw.onclick = e => { e.stopPropagation(); w.on = !w.on; cfg.preset = 'custom'; saveAndApply(); buildEditor(); }; const opts = optionEls(id); head.append(grip, nm); if (opts) { - const cv = document.createElement('span'); cv.className = 'cv'; cv.innerHTML = '›'; head.appendChild(cv); + row.classList.add('has-opts'); + const cv = document.createElement('span'); cv.className = 'cv'; cv.innerHTML = '›'; cv.setAttribute('aria-hidden', 'true'); head.appendChild(cv); if (openRows.has(id)) row.classList.add('open'); head.onclick = e => { if (e.target === sw || e.target === grip) return; @@ -1036,16 +1386,18 @@ function sectionLabel(text, inline) { const el = document.createElement('div'); el.className = 'lbl' + (inline ? ' inline' : ''); el.textContent = text; return el; } function buildEditor() { - const body = $('sheet-body'); body.innerHTML = ''; + const body = $('sheet-body'); if (!body) return; + body.innerHTML = ''; const head = document.createElement('div'); head.className = 'ehead'; - head.innerHTML = `

Customize dock

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

Customize dock

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

Welcome to InstantClone

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

New destination

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

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

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

New destination

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

New destination

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

New destination

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

New destination

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

New destination

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

New destination

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

New destination

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

New destination

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

New destination

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