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 MB
Hilos
1 tokio + 1 bandeja
Deps runtime
tokio, bytes, ureq
Tests
392 / 392
+
RSS inactivo
~9 MB
Hilos
1 tokio + 1 bandeja
Deps runtime
tokio, bytes, ureq
Tests
900+
-**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 MB
Threads
1 tokio + 1 tray
Runtime deps
tokio, bytes, ureq
Tests
392 / 392
+
Idle RSS
~9 MB
Threads
1 tokio + 1 tray
Runtime deps
tokio, bytes, ureq
Tests
900+
-**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