diff --git a/.gitignore b/.gitignore
index 18597d1..71861b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,5 +23,12 @@ Thumbs.db
.DS_Store
desktop.ini
+# Studio + seeded overlays are generated per-install at runtime (the
+# dashboard bakes the built-in presets on first run). Only the shipped
+# hand-write example is tracked; everything else here is local.
+overlays/*.html
+!overlays/custom-template.html
+overlays/*/
+
# Misc
*.log
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75a5542..8aa5ded 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,80 @@ All notable changes will land here. Format loosely follows
Nothing yet.
+## [0.1.6] - Overlay flow: one list, copy a URL
+
+Reworks the Overlay tab around a single idea: **everything is just an
+overlay with a URL you copy into OBS.**
+
+**One list, presets pre-installed.** The old preset / editable / legacy
+split (and its type filter) is gone. On first run the built-in presets
+are baked to real overlay files, so they show up in the same list as
+anything you make - each with a working URL, no hidden "materialize on
+copy" step. A one-time `overlays_seeded` flag keeps deleted presets from
+reappearing.
+
+**Three real actions.** Per overlay: **Copy URL**, **Edit in Studio**,
+**Duplicate**, **Delete** (hand-written legacy files: Copy URL + Delete).
+The standalone "Open Studio" button is replaced by **+ New overlay**. In
+the Studio, save is now two clear buttons: **Save** (overwrite) and
+**Save as new**.
+
+**Auto-hide as a quick toggle.** A per-overlay **Auto-hide when live**
+switch; turning it off appends `?autohide=off` to the copied URL so the
+overlay stays up, no re-bake. The per-state control in the Studio remains
+the baked default.
+
+**Restore defaults.** A button (and the factory reset) wipes your Studio
+overlays and reinstalls the built-in set, with a confirmation that says
+so plainly. Legacy hand-written files are left untouched.
+
+**Legacy overlays retired.** The bundled `minimal` / `corner` / `strip`
+hand-written overlays are removed (superseded by the Studio presets); the
+startup no longer drops them. Serving still works - any `.html` you put in
+`overlays/` is served, and the older `/overlay?style=…` URLs still render.
+The `custom-template.html` hand-write example stays.
+
+**Calmer graph meter.** The line widget now smooths its signal (a
+**Smoothing** slider, on by default) and floors its vertical scale, so a
+steady stream reads as a calm line instead of amplifying tiny jitter into
+a full-height sawtooth - while a real drop still swings the whole graph.
+
+**One-click updates.** When the About tab finds a newer release, an
+**Update** button now does it for you: download the new build, verify it
+against the release's published SHA-256, swap it in, and relaunch - your
+settings, destinations, and overlays are kept. Updating stays optional,
+and the manual download link is still there for anyone who prefers it (or
+when antivirus blocks the swap). Saving settings also shows an in-flight
+state and reports network failures instead of silently doing nothing.
+
+**Effects you can tune.** Liquid fill gained real controls - **Fill level**
+(follow the buffer or a fixed %), **Wave**, **Speed**, and **Smoothness** -
+instead of a hardwired behavior. Heartbeat gained **Speed**; the buffer bar
+and ring gained **Smoothness**. Untuned widgets look exactly as before.
+
+**Studio preview stays in sync.** Editing a setting now re-arms all auto-hide
+timers together immediately (a brief "synced" tag confirms it), instead of
+drifting until the next preview cycle. The **theme colour picker** also no
+longer slams shut the moment you drag the hue.
+
+**Auto-hide exits actually animate.** When a widget hid after its delay, it
+used to just blink out regardless of the chosen exit (fade / slide / pop):
+the browser collapsed the animation-stop and the fade into one frame and
+skipped the transition. The exit now plays its full animation. The Studio
+preview also adapts its replay timing so a hide longer than 5 s plays its
+exit instead of looking like it never fires.
+
+**Unsaved overlay changes are guarded.** The Studio's Save button shows a dot
+when there are unsaved edits, and leaving (Back, switching tabs, or closing
+the page) now asks before discarding them.
+
+**Restart from the dashboard.** The "restart required" banner (and the
+About tab) now has a **Restart now** button that relaunches the app and
+reconnects the dashboard on its own, instead of asking you to find and
+relaunch the exe by hand. **Show in Explorer** buttons on the Buffer and
+Diagnostics sections open the buffer file, trace log, and overlays folder
+straight in Explorer for troubleshooting.
+
## [0.1.5] - Overlay Studio (experimental)
A no-code visual editor for building OBS browser-source overlays that
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2802944..4810015 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -20,7 +20,7 @@ Open a [Discussion](https://github.com/Soulhackzlol/InstantClone/discussions). I
## Stuff I'd love help with
- Cross-platform support (macOS, Linux). The code is mostly portable; [tray.rs](src/tray.rs), [portcheck.rs](src/portcheck.rs), and [sysstat.rs](src/sysstat.rs) have Windows-specific paths that need parallel implementations.
-- New overlay styles. Drop new `.html` files into [overlays/](overlays/) and they're served automatically.
+- New overlay presets + Studio widgets. Built-in overlays are the `PRESETS` in [web/overlay-runtime.js](web/overlay-runtime.js) (the dashboard seeds them on first run); new widgets/animations live in the same file. A hand-written `.html` dropped into [overlays/](overlays/) is still served as-is.
- Real-world bug reports from streaming with the proxy in the loop.
## Probably not
diff --git a/Cargo.lock b/Cargo.lock
index 24d292f..482bffb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -218,7 +218,7 @@ dependencies = [
[[package]]
name = "instantclone"
-version = "0.1.5"
+version = "0.1.6"
dependencies = [
"bytes",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index 13a576f..16f486d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "instantclone"
-version = "0.1.5"
+version = "0.1.6"
edition = "2021"
authors = ["s1moscs"]
license = "GPL-3.0-only"
diff --git a/README.es.md b/README.es.md
index 5deac62..2768715 100644
--- a/README.es.md
+++ b/README.es.md
@@ -186,15 +186,13 @@ Un panel de 280×340 con el indicador, los controles de armar/activar/desarmar/c
### Overlays como browser-source
-Mete uno como browser-source para mostrar el delay en directo:
+La pestaña **Overlay** es un Studio sin código. Elige un overlay ya hecho, copia su URL y métela en OBS — o abre cualquiera en el Studio para rediseñarlo (colores por estado, widgets, animaciones) y darle a **Save** o **Save as new**.
-
-Estilos `minimal` · `corner` · `strip` · `focus` · `broadcast` · `ticker`
+¿Sin configurar? Los estilos rápidos de siempre siguen funcionando desde la URL: /overlay?style=corner&lang=es(`minimal · corner · strip · focus · broadcast · ticker`, idiomas `en · es · pt · fr · de`)
-Idiomas `en` · `es` · `pt` · `fr` · `de`
-
-Suelta cualquier `.html` en `./overlays/` y se sirve en `/overlay/tu-archivo.html`.
+O suelta cualquier `.html` en `./overlays/` y se sirve en `/overlay/tu-archivo.html`.
diff --git a/README.md b/README.md
index 9a2a14b..afecbb8 100644
--- a/README.md
+++ b/README.md
@@ -186,15 +186,13 @@ A 280×340 panel with the readout, arm / activate / disarm / cut controls, and l
### Browser-source overlays
-Drop one in as a browser-source for an on-stream readout:
+The **Overlay** tab is a no-code Studio. Pick a ready-made overlay, copy its URL, and drop it into OBS — or open any in the Studio to redesign it (per-state colours, widgets, animations) and **Save** or **Save as new**.
-
-Styles `minimal` · `corner` · `strip` · `focus` · `broadcast` · `ticker`
+No setup? The older quick styles still work straight from a URL: /overlay?style=corner&lang=es(`minimal · corner · strip · focus · broadcast · ticker`, langs `en · es · pt · fr · de`)
-Languages `en` · `es` · `pt` · `fr` · `de`
-
-Drop any `.html` into `./overlays/` and it's served at `/overlay/your-file.html`.
+Or drop any `.html` into `./overlays/` and it's served at `/overlay/your-file.html`.
diff --git a/overlays/README.md b/overlays/README.md
index e505da6..cfff9d5 100644
--- a/overlays/README.md
+++ b/overlays/README.md
@@ -1,14 +1,24 @@
-# InstantClone overlay plugins
+# InstantClone overlays
-Drop any `.html` file into this folder and it becomes an OBS browser-source
-overlay. The dashboard's *Overlay* tab lists everything in here next to the
-six built-in styles, and gives you the URL to paste into OBS:
+On-stream browser-source widgets that show your delay state.
- http://127.0.0.1:7799/overlay/your-file.html?lang=en
+Most people never touch this folder. The dashboard's **Overlay** tab is a
+no-code **Studio**: it ships with ready-made overlays you copy a URL from
+and drop into OBS, and you can open any of them to redesign (per-state
+colours, widgets, animations) and **Save** or **Save as new**. The
+built-in overlays are baked to real files in this folder on first run, so
+everything you see in the tab is a plain `.html` with its own URL:
+
+ http://127.0.0.1:7799/overlay/your-file.html
+
+You can still hand-write one: any `.html` you drop in here shows up in the
+Overlay tab as a "legacy" (hand-written) overlay. The only contract is the
+URL above and the `/state` JSON below.
## What the proxy will tell you
-Every overlay can fetch live state from `GET /state`:
+Every overlay can fetch live state from `GET /state` (or subscribe to the
+`GET /events` SSE stream, which pushes the same JSON on every change):
{
"phase": "idle" | "preparing" | "ready" | "active",
@@ -20,8 +30,7 @@ Every overlay can fetch live state from `GET /state`:
"egress_alive": , // at least one destination forwarding
"destinations_alive": ,
"destinations_total": ,
- "bitrate_kbps": , // ingest bitrate
- "stats": { "tags_sent", "bytes_sent", "cuts" }
+ "stats": { "tags_sent", "bytes_sent", "cuts", "bitrate_kbps" }
}
Typical poll loop:
@@ -37,15 +46,20 @@ Typical poll loop:
tick(); setInterval(tick, 500);
-## Starting points
+## Writing your own
-- **`custom-template.html`** - copy this. It's a small working overlay
+- **Easiest:** the Studio. Pick an overlay in the Overlay tab, **Edit in
+ Studio**, then **Save** (overwrite) or **Save as new**.
+- **By hand:** copy **`custom-template.html`** - a small working overlay
with comments explaining every part of the contract above. Rename the
- copy to whatever you want; it'll appear in the dashboard.
-- **The six built-in styles** (`minimal`, `corner`, `strip`, `focus`,
- `broadcast`, `ticker`) are served from `/overlay?style=…` and live in
- [`src/web.rs`](../src/web.rs)'s `overlay_html()`. Open one in DevTools
- if you want to crib the auto-dim, number-tween, or phase-halo
- behaviour into your own design.
-
-The only contract is the URL above and the `/state` JSON.
+ copy to whatever you want; it appears in the Overlay tab.
+- **No file at all:** the legacy quick styles still work straight from a
+ URL - `/overlay?style=minimal|corner|strip|focus|broadcast|ticker` with
+ an optional `?lang=en|es|pt|fr|de`. They live in
+ [`src/web.rs`](../src/web.rs)'s `overlay_html()`; open one in DevTools to
+ crib the auto-dim, number-tween, or phase-halo behaviour.
+
+> The Studio's built-in overlays are seeded once and then yours to edit or
+> delete; **Restore default overlays** (in the Overlay tab) reinstalls
+> them. Seeded/Studio overlays are local, per-install files and aren't
+> tracked in git - only the hand-written ones here are.
diff --git a/overlays/corner.html b/overlays/corner.html
deleted file mode 100644
index 33f2094..0000000
--- a/overlays/corner.html
+++ /dev/null
@@ -1,133 +0,0 @@
-
-
-Delay (corner)
-
-
-
diff --git a/src/config.rs b/src/config.rs
index 1e0c1ea..68cccde 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -70,6 +70,12 @@ 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,
+ /// State: whether the built-in preset overlays have been seeded to the
+ /// overlays folder as real files yet. Set true after the dashboard bakes
+ /// them on first run, so deleting a seeded overlay doesn't bring it back.
+ /// Reset to false (and the overlays wiped) by "Restore default overlays"
+ /// and the factory reset, which re-seeds on the next dashboard load.
+ pub overlays_seeded: bool,
}
#[derive(Debug, Clone)]
@@ -252,6 +258,9 @@ impl Settings {
// auto_arm_on_connect without otherwise customising lands
// on a familiar number.
auto_arm_delay_ms: 15_000,
+ // Fresh install hasn't seeded the preset overlays yet; the
+ // dashboard does it on first load, then flips this true.
+ overlays_seeded: false,
}
}
@@ -448,6 +457,9 @@ impl Settings {
if self.auto_arm_delay_ms != 15_000 {
writeln!(f, "auto_arm_delay_ms={}", self.auto_arm_delay_ms)?;
}
+ if self.overlays_seeded {
+ writeln!(f, "overlays_seeded=true")?;
+ }
for (i, p) in self.profiles.iter().enumerate() {
writeln!(f, "profile.{}.name={}", i, p.name)?;
writeln!(f, "profile.{}.delay_ms={}", i, p.delay_ms)?;
@@ -524,6 +536,7 @@ impl Settings {
"tracing_enabled" => self.tracing_enabled = value.parse().unwrap_or(false),
"auto_arm_on_connect" => self.auto_arm_on_connect = value == "true",
"auto_activate_when_ready" => self.auto_activate_when_ready = value == "true",
+ "overlays_seeded" => self.overlays_seeded = value == "true",
"auto_arm_delay_ms" => {
if let Ok(v) = value.parse() {
self.auto_arm_delay_ms = v;
@@ -700,7 +713,7 @@ impl Settings {
}
dests.push(']');
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},"initial_delay_ms":{id},"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},"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},"initial_delay_ms":{id},"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},"destinations":{dests}}}"#,
c = self.configured,
ip = self.ingest_port,
iba = self.ingest_bind_all,
@@ -718,6 +731,7 @@ impl Settings {
aaoc = self.auto_arm_on_connect,
aawr = self.auto_activate_when_ready,
aadm = self.auto_arm_delay_ms,
+ os = self.overlays_seeded,
dests = dests,
)
}
@@ -1286,6 +1300,7 @@ mod tests {
s.auto_arm_on_connect = true;
s.auto_activate_when_ready = true;
s.auto_arm_delay_ms = 30_000;
+ s.overlays_seeded = true;
// Need a destination so save() doesn't bail on the
// configured-but-empty path.
s.destinations.push(Destination {
@@ -1315,6 +1330,10 @@ mod tests {
loaded.auto_arm_delay_ms, 30_000,
"auto_arm_delay_ms must survive round-trip"
);
+ assert!(
+ loaded.overlays_seeded,
+ "overlays_seeded must survive round-trip"
+ );
let _ = std::fs::remove_file(&path);
}
diff --git a/src/main.rs b/src/main.rs
index b77c3a0..6042fd1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -33,6 +33,8 @@ mod obs_register;
#[cfg(windows)]
mod portcheck;
mod rtmp;
+mod self_update;
+mod sha256;
mod sink;
mod sysstat;
mod trace;
@@ -80,6 +82,21 @@ fn main() -> std::io::Result<()> {
let suppress_browser = raw_args.iter().any(|a| a == "--no-browser")
|| std::env::var("INSTANTCLONE_NO_BROWSER").ok().as_deref() == Some("1");
+ // Sweep any `.old` / `.new` exe left over from a self-update swap. When
+ // we got here via the relauncher, this deletes the previous version we
+ // just replaced; otherwise it's a harmless no-op.
+ self_update::sweep_leftovers();
+
+ // Relaunch handshake: a restart / self-update spawns us with
+ // `--await-pid `. Wait for that instance to fully exit (and release
+ // the ingest/web ports) before the port pre-flight + bind below, so the
+ // two never collide. Capped so a stuck old process can't wedge us.
+ if let Some(i) = raw_args.iter().position(|a| a == "--await-pid") {
+ if let Some(pid) = raw_args.get(i + 1).and_then(|s| s.parse::().ok()) {
+ self_update::wait_for_pid_exit(pid, 10_000);
+ }
+ }
+
let cfg_path: PathBuf = std::env::var("CONFIG_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./instantclone.config.json"));
@@ -109,12 +126,21 @@ fn main() -> std::io::Result<()> {
// and pop a native modal asking the user to switch or quit.
#[cfg(windows)]
{
+ // When relaunched by a restart / self-update, the outgoing instance
+ // may still be releasing its listening sockets (the OS can report the
+ // port as busy - even owned by the now-dead PID - for a brief window
+ // after exit). Poll through that window instead of popping the
+ // "switch port?" modal, which would otherwise block the relaunch.
+ let relaunched = raw_args.iter().any(|a| a == "--await-pid");
let host_ingest = if settings.ingest_bind_all {
"0.0.0.0"
} else {
"127.0.0.1"
};
- if !portcheck::is_port_free(&format!("{}:{}", host_ingest, settings.ingest_port)) {
+ if !port_free_with_grace(
+ &format!("{}:{}", host_ingest, settings.ingest_port),
+ relaunched,
+ ) {
match preflight_resolve("RTMP port", settings.ingest_port, host_ingest) {
Some(new_port) => settings.ingest_port = new_port,
None => return Ok(()),
@@ -125,7 +151,7 @@ fn main() -> std::io::Result<()> {
} else {
"127.0.0.1"
};
- if !portcheck::is_port_free(&format!("{}:{}", host_web, settings.web_port)) {
+ if !port_free_with_grace(&format!("{}:{}", host_web, settings.web_port), relaunched) {
match preflight_resolve("Web port", settings.web_port, host_web) {
Some(new_port) => settings.web_port = new_port,
None => return Ok(()),
@@ -627,6 +653,20 @@ fn preflight_resolve(label: &str, port: u16, host: &str) -> Option {
}
}
+/// Is `addr` bindable? When `grace` is set (we were relaunched by a restart /
+/// self-update), wait out the outgoing instance's socket-release window before
+/// deciding it's taken - so a normal handoff never pops the "switch port?"
+/// modal. Returns the moment the port frees, so the common case is instant; a
+/// genuine conflict still falls through after the window.
+#[cfg(windows)]
+fn port_free_with_grace(addr: &str, grace: bool) -> bool {
+ if grace {
+ portcheck::wait_until_bindable(addr, Duration::from_secs(20))
+ } else {
+ portcheck::is_port_free(addr)
+ }
+}
+
fn open_browser(url: &str) {
#[cfg(target_os = "windows")]
let _ = Command::new("cmd").args(["/c", "start", "", url]).spawn();
@@ -636,10 +676,11 @@ fn open_browser(url: &str) {
let _ = Command::new("xdg-open").arg(url).spawn();
}
-/// Create the overlays directory (if missing) and write any of the three
-/// built-in templates that don't already exist on disk. Idempotent - safe
-/// to call on every startup. Users can freely edit the files; the next
-/// run won't overwrite them.
+/// Create the overlays directory (if missing) and drop the folder README
+/// plus the hand-write example, if absent. Idempotent - safe on every
+/// startup; user edits are never overwritten. The built-in *overlays*
+/// themselves are no longer dropped here: the dashboard bakes the Studio
+/// presets to real files on first load (see `seedPresets`).
fn ensure_overlays_dir(dir: &std::path::Path) {
let _ = std::fs::create_dir_all(dir);
let drop = |name: &str, body: &str| {
@@ -648,48 +689,12 @@ fn ensure_overlays_dir(dir: &std::path::Path) {
let _ = std::fs::write(&path, body);
}
};
- drop("minimal.html", OVERLAY_MINIMAL);
- drop("corner.html", OVERLAY_CORNER);
- drop("strip.html", OVERLAY_STRIP);
drop("README.md", OVERLAY_README);
+ drop("custom-template.html", OVERLAY_CUSTOM_TEMPLATE);
}
-const OVERLAY_README: &str = r#"# InstantClone overlay plugins
-
-Any `.html` file you drop into this folder becomes an OBS browser-source
-overlay. The dashboard's *Overlay* tab lists them and gives you the URL
-to paste into OBS.
-
-Each overlay can fetch live state from the proxy:
-
- GET /state → JSON {
- phase: "idle"|"preparing"|"ready"|"active",
- current_delay_ms, armed_delay_ms, buffer_fill_ms, ...
- ingest_alive, egress_alive,
- destinations_alive, destinations_total,
- destinations: [ { id, name, enabled, alive, bitrate_kbps, ... } ],
- stats: { bitrate_kbps, cuts, ingest_disconnects, ... }
- }
-
-Recommended pattern:
-
-
-
-The three bundled overlays (minimal, corner, strip) are useful starting
-points - copy one and modify.
-"#;
-
-// Overlay templates are embedded straight from the canonical files in
-// ../overlays/ so a fresh install drops the *same* HTML that lives in
-// the repo. include_str! keeps the constant and the on-disk template
-// in lockstep automatically - no risk of the two drifting silently.
-const OVERLAY_MINIMAL: &str = include_str!("../overlays/minimal.html");
-const OVERLAY_CORNER: &str = include_str!("../overlays/corner.html");
-const OVERLAY_STRIP: &str = include_str!("../overlays/strip.html");
+// Embedded straight from the canonical files in ../overlays/ so a binary
+// install drops the *same* content that lives in the repo. include_str!
+// keeps them in lockstep - no risk of drifting silently.
+const OVERLAY_README: &str = include_str!("../overlays/README.md");
+const OVERLAY_CUSTOM_TEMPLATE: &str = include_str!("../overlays/custom-template.html");
diff --git a/src/portcheck.rs b/src/portcheck.rs
index 96e370a..594e37b 100644
--- a/src/portcheck.rs
+++ b/src/portcheck.rs
@@ -114,6 +114,27 @@ pub fn is_port_free(addr: &str) -> bool {
TcpListener::bind(addr).is_ok()
}
+/// Poll until `addr` becomes bindable, up to `timeout`. Returns `true` the
+/// instant a bind succeeds, `false` if the whole window elapses still busy.
+///
+/// Used on a restart / self-update relaunch: the outgoing instance can take a
+/// moment to release its listening socket (Windows may even still attribute
+/// the port to the now-dead PID), and we'd rather wait that out silently than
+/// pop the "switch port?" modal mid-handoff. The window is generous because a
+/// genuine conflict is rare and a spurious modal on every restart is worse.
+pub fn wait_until_bindable(addr: &str, timeout: std::time::Duration) -> bool {
+ let deadline = std::time::Instant::now() + timeout;
+ loop {
+ if is_port_free(addr) {
+ return true;
+ }
+ if std::time::Instant::now() >= deadline {
+ return false;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(150));
+ }
+}
+
/// Find the first port in `[start ..= start+range]` that we can bind
/// on `host`. Returns `None` if every port in the window is busy.
pub fn find_free_port(host: &str, start: u16, range: u16) -> Option {
@@ -234,6 +255,33 @@ mod tests {
panic!("released port did not become free");
}
+ #[test]
+ fn wait_until_bindable_returns_when_socket_released() {
+ use std::time::Duration;
+ let (held, port) = bind_eph();
+ let addr = format!("127.0.0.1:{port}");
+ // Release the socket after a short hold, on another thread.
+ std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(500));
+ drop(held);
+ });
+ assert!(
+ wait_until_bindable(&addr, Duration::from_secs(5)),
+ "must report bindable once the holder releases within the window"
+ );
+ }
+
+ #[test]
+ fn wait_until_bindable_times_out_on_a_held_port() {
+ use std::time::Duration;
+ let (_held, port) = bind_eph(); // held for the whole test
+ let addr = format!("127.0.0.1:{port}");
+ assert!(
+ !wait_until_bindable(&addr, Duration::from_millis(400)),
+ "a permanently held port must time out as not bindable"
+ );
+ }
+
#[test]
fn find_free_port_skips_held_and_returns_next() {
// Hold a known port and ask find_free_port to start there.
diff --git a/src/rtmp/server.rs b/src/rtmp/server.rs
index c80a206..a1e173a 100644
--- a/src/rtmp/server.rs
+++ b/src/rtmp/server.rs
@@ -19,6 +19,9 @@ use tokio::net::TcpListener;
pub async fn run(addr: String, ctrl: Arc) -> io::Result<()> {
let listener = TcpListener::bind(&addr).await?;
+ // Keep this listener out of a restart/self-update child (else the ingest
+ // port stays bound after we exit and the new instance can't reclaim it).
+ crate::self_update::dont_inherit(&listener);
eprintln!("[ingest] listening on {}", addr);
loop {
let (sock, peer) = listener.accept().await?;
diff --git a/src/self_update.rs b/src/self_update.rs
new file mode 100644
index 0000000..354ae1a
--- /dev/null
+++ b/src/self_update.rs
@@ -0,0 +1,422 @@
+//! One-click self-update for the portable Windows build.
+//!
+//! Updating is optional - the About tab always links the manual download
+//! too - but for users who'd rather not fish a new exe out of GitHub, the
+//! "Update now" button drives this two-step flow:
+//!
+//! 1. [`prepare`]: hit the latest GitHub release, download the new exe
+//! and the published `SHA256SUMS.txt`, verify the hash, and stage the
+//! bytes next to the running exe as `instantclone.exe.new`. Everything
+//! that can fail - offline, rate-limited, hash mismatch, unwritable
+//! folder - fails HERE, before the live exe is touched, and surfaces a
+//! message to the UI. Nothing is committed.
+//!
+//! 2. [`commit_and_relaunch`]: rename the running exe to `.old` (Windows
+//! lets you rename, but not delete/overwrite, a running image), move
+//! the staged `.new` into its place, spawn a tiny detached `cmd` that
+//! waits ~2 s for our TCP ports to free and then relaunches the fresh
+//! exe, and hard-exit. The `.old` / `.new` leftovers are swept on the
+//! next startup (see [`sweep_leftovers`]).
+//!
+//! Why the checksum matters: we download an executable and then run it, so
+//! verifying it against the hash the release pipeline published (over a
+//! separate TLS-protected request) is the line between "convenient" and
+//! "remote code execution". A mismatch aborts with zero changes made.
+//!
+//! The user's config lives in a separate file, so settings, destinations,
+//! and overlays are untouched by the swap.
+
+use std::path::{Path, PathBuf};
+
+const RELEASE_DL_BASE: &str = "https://github.com/Soulhackzlol/InstantClone/releases/download";
+
+/// A verified update staged on disk, ready for [`commit_and_relaunch`].
+pub struct Staged {
+ /// Path of the currently running exe (the swap target).
+ pub exe: PathBuf,
+ /// The downloaded-and-verified `*.new` file sitting beside it.
+ pub staged: PathBuf,
+ /// The version we're moving to (no leading `v`), for UI display.
+ pub version: String,
+}
+
+/// Sibling path `exe` + `.` + `ext`, e.g. `instantclone.exe.new`.
+fn sidecar(exe: &Path, ext: &str) -> PathBuf {
+ let mut s = exe.as_os_str().to_owned();
+ s.push(".");
+ s.push(ext);
+ PathBuf::from(s)
+}
+
+/// Delete any `.old` / `.new` leftovers from a previous update swap. Called
+/// once at startup: a fresh process that just relaunched sweeps the `.old`
+/// copy of the version it replaced, and a stale `.new` (from an update that
+/// was staged but never committed) is harmless to drop - it re-downloads.
+pub fn sweep_leftovers() {
+ if let Ok(exe) = std::env::current_exe() {
+ for ext in ["old", "new"] {
+ let _ = std::fs::remove_file(sidecar(&exe, ext));
+ }
+ }
+}
+
+/// Download + verify + stage the latest release. Returns a [`Staged`] on
+/// success, or a user-facing error string on any failure (nothing is
+/// committed in the error case).
+pub fn prepare() -> Result {
+ let current = crate::update_check::current_version();
+ let info = crate::update_check::check_update();
+ let latest = info
+ .latest
+ .ok_or_else(|| info.error.unwrap_or_else(|| "couldn't reach GitHub".into()))?;
+ if !info.update_available {
+ return Err(format!("Already on the latest release (v{current})."));
+ }
+
+ let agent = crate::https::https_agent();
+ let bytes = fetch_verified(&agent, RELEASE_DL_BASE, &latest)?;
+
+ let exe = std::env::current_exe().map_err(|e| format!("can't locate the running exe: {e}"))?;
+ let staged = sidecar(&exe, "new");
+ std::fs::write(&staged, &bytes)
+ .map_err(|e| format!("can't write the update next to the exe: {e}"))?;
+
+ Ok(Staged {
+ exe,
+ staged,
+ version: latest,
+ })
+}
+
+/// Download the release exe for `version` from `base` and verify it against
+/// the release's published `SHA256SUMS.txt`, returning the verified bytes.
+/// Split out from [`prepare`] so a localhost mock can exercise the full
+/// download-and-verify path with no GitHub round-trip (see tests).
+fn fetch_verified(agent: &ureq::Agent, base: &str, version: &str) -> Result, String> {
+ let tag = format!("v{version}");
+ let exe_name = format!("instantclone-{tag}-windows-x64.exe");
+ let exe_url = format!("{base}/{tag}/{exe_name}");
+ let sums_url = format!("{base}/{tag}/SHA256SUMS.txt");
+
+ // Checksum first - cheap, and it tells us what to expect.
+ let sums = http_get_text(agent, &sums_url)?;
+ let expected = parse_checksum(&sums, &exe_name)
+ .ok_or_else(|| format!("no checksum for {exe_name} in SHA256SUMS.txt"))?;
+
+ // Then the binary, which we verify before it ever lands at a runnable
+ // path.
+ let bytes = http_get_bytes(agent, &exe_url)?;
+ let got = crate::sha256::hex(&bytes);
+ if !got.eq_ignore_ascii_case(&expected) {
+ return Err("downloaded file failed its checksum - aborting, no changes made".to_string());
+ }
+ Ok(bytes)
+}
+
+/// Swap the staged update into place and relaunch. Does not return on
+/// success (the process exits). On a swap failure it logs to stderr and
+/// returns, leaving the running version intact.
+pub fn commit_and_relaunch(s: Staged) {
+ if let Err(e) = swap_in_place(&s.exe, &s.staged) {
+ eprintln!("self-update: swap failed, staying on the current version: {e}");
+ return;
+ }
+ spawn_relauncher(&s.exe);
+ // Hard exit so our ports + buffer-file handle release immediately; the
+ // relauncher's short delay covers the OS freeing them before the new
+ // process binds. Config was already persisted on every change.
+ std::process::exit(0);
+}
+
+/// Move `staged` into `exe`'s place, parking the running image at `.old`.
+/// On Windows you can rename (but not delete/overwrite) a running exe, so
+/// this rename pair is the move that makes in-place update possible. Rolls
+/// back the first rename if the second fails, so a half-swap never leaves
+/// the install without a runnable exe. Pure filesystem work, unit-tested.
+fn swap_in_place(exe: &Path, staged: &Path) -> std::io::Result<()> {
+ let old = sidecar(exe, "old");
+ let _ = std::fs::remove_file(&old); // clear any stale .old first
+ std::fs::rename(exe, &old)?;
+ if let Err(e) = std::fs::rename(staged, exe) {
+ let _ = std::fs::rename(&old, exe); // best-effort rollback
+ return Err(e);
+ }
+ Ok(())
+}
+
+/// Relaunch the running exe and exit - the same mechanism as the update
+/// swap, minus the swap. Backs the "Restart now" button (e.g. to apply a
+/// buffer-size / port change that needs a fresh process). Does not return.
+pub fn restart_now() -> ! {
+ if let Ok(exe) = std::env::current_exe() {
+ spawn_relauncher(&exe);
+ }
+ std::process::exit(0);
+}
+
+/// Relaunch the fresh exe, handing it our PID so it waits for us to exit
+/// (and release the ingest/web ports) before it binds. We spawn the real exe
+/// directly - no `cmd`/`ping` shim - so there's no console flash and no blind
+/// sleep that can race the port. `--no-browser` stops it from popping a
+/// second dashboard tab; the already-open dashboard reconnects on its own.
+fn spawn_relauncher(exe: &Path) {
+ let pid = std::process::id().to_string();
+ let mut cmd = std::process::Command::new(exe);
+ cmd.arg("--await-pid").arg(&pid).arg("--no-browser");
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+ const DETACHED_PROCESS: u32 = 0x0000_0008;
+ cmd.creation_flags(DETACHED_PROCESS);
+ }
+ let _ = cmd.spawn();
+}
+
+/// Clear the inheritable flag on a listening socket so a relaunched child
+/// never inherits it. Without this, a restart/self-update spawns the new
+/// instance as our child, which inherits our listening sockets; when we then
+/// exit, the OS keeps the ports bound (still attributed to our dead PID) via
+/// the child's inherited copy, and the child can never bind them - the restart
+/// hangs and eventually shows a bogus "port in use" prompt.
+#[cfg(windows)]
+pub fn dont_inherit(sock: &S) {
+ use windows_sys::Win32::Foundation::{SetHandleInformation, HANDLE};
+ const HANDLE_FLAG_INHERIT: u32 = 0x1;
+ unsafe {
+ SetHandleInformation(sock.as_raw_socket() as HANDLE, HANDLE_FLAG_INHERIT, 0);
+ }
+}
+#[cfg(not(windows))]
+pub fn dont_inherit(_sock: &S) {}
+
+/// Block until process `pid` exits (or `timeout_ms` elapses) so a relaunched
+/// instance never races the outgoing one for the ingest/web ports. Returns
+/// at once if the process is already gone.
+#[cfg(windows)]
+pub fn wait_for_pid_exit(pid: u32, timeout_ms: u32) {
+ use windows_sys::Win32::Foundation::CloseHandle;
+ use windows_sys::Win32::System::Threading::{OpenProcess, WaitForSingleObject};
+ const PROCESS_SYNCHRONIZE: u32 = 0x0010_0000;
+ unsafe {
+ let h = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
+ if h.is_null() {
+ return; // already exited (or not visible) - nothing to wait on
+ }
+ WaitForSingleObject(h, timeout_ms);
+ CloseHandle(h);
+ }
+}
+#[cfg(not(windows))]
+pub fn wait_for_pid_exit(_pid: u32, _timeout_ms: u32) {}
+
+/// Find the hash for `name` in a `SHA256SUMS.txt` body. Each line is
+/// `<64-hex-hash> `; we match the filename and return the hash.
+fn parse_checksum(sums: &str, name: &str) -> Option {
+ for line in sums.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ let mut it = line.split_whitespace();
+ let hash = it.next()?;
+ let file = it.next()?;
+ // The filename column may carry a leading `*` (binary-mode marker).
+ let file = file.trim_start_matches('*');
+ if file == name && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return Some(hash.to_ascii_lowercase());
+ }
+ }
+ None
+}
+
+fn user_agent() -> String {
+ format!("InstantClone/{}", crate::update_check::current_version())
+}
+
+fn http_get_text(agent: &ureq::Agent, url: &str) -> Result {
+ let mut resp = agent
+ .get(url)
+ .header("User-Agent", user_agent())
+ .call()
+ .map_err(|e| format!("download failed: {e}"))?;
+ if resp.status() != 200 {
+ return Err(format!("server returned HTTP {}", resp.status()));
+ }
+ resp.body_mut()
+ .read_to_string()
+ .map_err(|e| format!("read failed: {e}"))
+}
+
+fn http_get_bytes(agent: &ureq::Agent, url: &str) -> Result, String> {
+ let mut resp = agent
+ .get(url)
+ .header("User-Agent", user_agent())
+ .call()
+ .map_err(|e| format!("download failed: {e}"))?;
+ if resp.status() != 200 {
+ return Err(format!("server returned HTTP {}", resp.status()));
+ }
+ // The release exe is ~1.2 MB; lift the reader's default cap well above
+ // that so a future, larger build still downloads.
+ resp.body_mut()
+ .with_config()
+ .limit(64 * 1024 * 1024)
+ .read_to_vec()
+ .map_err(|e| format!("read failed: {e}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::parse_checksum;
+
+ const NAME: &str = "instantclone-v0.1.6-windows-x64.exe";
+
+ #[test]
+ fn parses_standard_sha256sums_line() {
+ let body = format!("{} {NAME}\n", "a".repeat(64));
+ assert_eq!(parse_checksum(&body, NAME), Some("a".repeat(64)));
+ }
+
+ #[test]
+ fn picks_the_right_file_among_several() {
+ let body = format!(
+ "{} other-file.exe\n{} {NAME}\n",
+ "b".repeat(64),
+ "c".repeat(64)
+ );
+ assert_eq!(parse_checksum(&body, NAME), Some("c".repeat(64)));
+ }
+
+ #[test]
+ fn tolerates_binary_mode_star_and_uppercase() {
+ let body = format!("{} *{NAME}\n", "A".repeat(64));
+ assert_eq!(parse_checksum(&body, NAME), Some("a".repeat(64)));
+ }
+
+ #[test]
+ fn rejects_non_hex_and_wrong_length() {
+ let body = format!("{} {NAME}\n", "z".repeat(64));
+ assert_eq!(parse_checksum(&body, NAME), None);
+ let short = format!("{} {NAME}\n", "a".repeat(40));
+ assert_eq!(parse_checksum(&short, NAME), None);
+ }
+
+ #[test]
+ fn none_when_file_absent() {
+ let body = format!("{} some-other.exe\n", "a".repeat(64));
+ assert_eq!(parse_checksum(&body, NAME), None);
+ }
+}
+
+// End-to-end tests of the two risky parts - the download+verify and the
+// on-disk swap - without GitHub or a live process. The mock stands in for
+// the release server, so this is exactly the "build a fake old version and
+// try to update" check, just driven from a unit test.
+#[cfg(test)]
+mod e2e {
+ use super::{fetch_verified, sidecar, swap_in_place};
+ use std::io::{Read, Write};
+ use std::net::TcpListener;
+
+ /// Spin up a one-shot localhost HTTP server that answers the two GETs
+ /// `fetch_verified` makes: the checksum file (path contains
+ /// `SHA256SUMS`) and the exe (anything else). Returns its port.
+ fn serve_release(exe_body: Vec, sums_body: String) -> u16 {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let port = listener.local_addr().unwrap().port();
+ std::thread::spawn(move || {
+ for _ in 0..2 {
+ let (mut sock, _) = match listener.accept() {
+ Ok(x) => x,
+ Err(_) => break,
+ };
+ let mut buf = [0u8; 2048];
+ let n = sock.read(&mut buf).unwrap_or(0);
+ let req = String::from_utf8_lossy(&buf[..n]);
+ let first = req.lines().next().unwrap_or("");
+ let body: Vec = if first.contains("SHA256SUMS") {
+ sums_body.clone().into_bytes()
+ } else {
+ exe_body.clone()
+ };
+ let head = format!(
+ "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ );
+ let _ = sock.write_all(head.as_bytes());
+ let _ = sock.write_all(&body);
+ let _ = sock.flush();
+ }
+ });
+ port
+ }
+
+ #[test]
+ fn fetch_verified_accepts_matching_checksum() {
+ let exe = b"FAKE-INSTANTCLONE-EXE-BODY-v9.9.9".to_vec();
+ let hash = crate::sha256::hex(&exe);
+ let sums = format!("{hash} instantclone-v9.9.9-windows-x64.exe\n");
+ let port = serve_release(exe.clone(), sums);
+ let base = format!("http://127.0.0.1:{port}");
+ let agent = crate::https::https_agent();
+ let got = fetch_verified(&agent, &base, "9.9.9").expect("genuine download should verify");
+ assert_eq!(got, exe);
+ }
+
+ #[test]
+ fn fetch_verified_rejects_tampered_body() {
+ // Publish the checksum of the genuine file, but serve a different
+ // (tampered) body - verification must reject it.
+ let genuine = b"GENUINE-BODY".to_vec();
+ let hash = crate::sha256::hex(&genuine);
+ let sums = format!("{hash} instantclone-v9.9.9-windows-x64.exe\n");
+ let tampered = b"TAMPERED-PAYLOAD-PRETENDING-TO-BE-THE-RELEASE".to_vec();
+ let port = serve_release(tampered, sums);
+ let base = format!("http://127.0.0.1:{port}");
+ let agent = crate::https::https_agent();
+ let err = fetch_verified(&agent, &base, "9.9.9").unwrap_err();
+ assert!(
+ err.contains("checksum"),
+ "expected checksum rejection, got: {err}"
+ );
+ }
+
+ // Real network: downloads the actual latest release exe from GitHub and
+ // verifies it against the real published SHA256SUMS.txt. Ignored by
+ // default (needs a published release + outbound HTTPS); run on demand
+ // with `cargo test --release real_release -- --ignored` to prove the
+ // production download+verify path before tagging.
+ #[test]
+ #[ignore = "hits real GitHub releases; run explicitly with --ignored"]
+ fn real_release_downloads_and_verifies() {
+ let info = crate::update_check::check_update();
+ let latest = info
+ .latest
+ .expect("no published release / couldn't reach GitHub");
+ let agent = crate::https::https_agent();
+ let bytes = super::fetch_verified(&agent, super::RELEASE_DL_BASE, &latest)
+ .expect("the real published release should pass its own checksum");
+ assert!(
+ bytes.len() > 100_000,
+ "a real release exe should be >100 KB, got {} bytes",
+ bytes.len()
+ );
+ }
+
+ #[test]
+ fn swap_in_place_replaces_exe_and_parks_old() {
+ let dir = std::env::temp_dir().join(format!("ic-swap-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let exe = dir.join("instantclone.exe");
+ let staged = sidecar(&exe, "new");
+ std::fs::write(&exe, b"OLD-VERSION").unwrap();
+ std::fs::write(&staged, b"NEW-VERSION").unwrap();
+
+ swap_in_place(&exe, &staged).unwrap();
+
+ assert_eq!(std::fs::read(&exe).unwrap(), b"NEW-VERSION");
+ assert_eq!(std::fs::read(sidecar(&exe, "old")).unwrap(), b"OLD-VERSION");
+ assert!(!staged.exists(), "staged .new should have been consumed");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/src/sha256.rs b/src/sha256.rs
new file mode 100644
index 0000000..0ba0dc6
--- /dev/null
+++ b/src/sha256.rs
@@ -0,0 +1,146 @@
+//! Minimal, dependency-free SHA-256 (FIPS 180-4).
+//!
+//! We need exactly one thing from this: verify that a freshly downloaded
+//! release binary matches the SHA-256 published in the release's
+//! `SHA256SUMS.txt` before we swap it in (see [`crate::self_update`]).
+//! Pulling in the `sha2` crate and its `generic-array` / `cpufeatures`
+//! chain for a single hash would bloat a binary we deliberately keep lean,
+//! so we hand-roll the core here. Correctness is pinned by the NIST test
+//! vectors in the test module - do not edit the round function without
+//! re-running them.
+
+const K: [u32; 64] = [
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+];
+
+/// Lowercase hex of the SHA-256 digest of `data`.
+pub fn hex(data: &[u8]) -> String {
+ let d = digest(data);
+ let mut s = String::with_capacity(64);
+ for b in d {
+ s.push_str(&format!("{b:02x}"));
+ }
+ s
+}
+
+/// Raw 32-byte SHA-256 digest of `data`.
+pub fn digest(data: &[u8]) -> [u8; 32] {
+ let mut h: [u32; 8] = [
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
+ 0x5be0cd19,
+ ];
+
+ // Pad: 0x80, zeros to a 56 mod 64 boundary, then the 64-bit big-endian
+ // bit length.
+ let bit_len = (data.len() as u64).wrapping_mul(8);
+ let mut msg = data.to_vec();
+ msg.push(0x80);
+ while msg.len() % 64 != 56 {
+ msg.push(0);
+ }
+ msg.extend_from_slice(&bit_len.to_be_bytes());
+
+ for chunk in msg.chunks_exact(64) {
+ let mut w = [0u32; 64];
+ for (i, word) in w.iter_mut().take(16).enumerate() {
+ *word = u32::from_be_bytes([
+ chunk[i * 4],
+ chunk[i * 4 + 1],
+ chunk[i * 4 + 2],
+ chunk[i * 4 + 3],
+ ]);
+ }
+ for i in 16..64 {
+ let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
+ let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
+ w[i] = w[i - 16]
+ .wrapping_add(s0)
+ .wrapping_add(w[i - 7])
+ .wrapping_add(s1);
+ }
+
+ let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
+ (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
+ for i in 0..64 {
+ let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
+ let ch = (e & f) ^ ((!e) & g);
+ let t1 = hh
+ .wrapping_add(s1)
+ .wrapping_add(ch)
+ .wrapping_add(K[i])
+ .wrapping_add(w[i]);
+ let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
+ let maj = (a & b) ^ (a & c) ^ (b & c);
+ let t2 = s0.wrapping_add(maj);
+ hh = g;
+ g = f;
+ f = e;
+ e = d.wrapping_add(t1);
+ d = c;
+ c = b;
+ b = a;
+ a = t1.wrapping_add(t2);
+ }
+ h[0] = h[0].wrapping_add(a);
+ h[1] = h[1].wrapping_add(b);
+ h[2] = h[2].wrapping_add(c);
+ h[3] = h[3].wrapping_add(d);
+ h[4] = h[4].wrapping_add(e);
+ h[5] = h[5].wrapping_add(f);
+ h[6] = h[6].wrapping_add(g);
+ h[7] = h[7].wrapping_add(hh);
+ }
+
+ let mut out = [0u8; 32];
+ for (i, word) in h.iter().enumerate() {
+ out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::hex;
+
+ #[test]
+ fn empty_string() {
+ assert_eq!(
+ hex(b""),
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ );
+ }
+
+ #[test]
+ fn abc() {
+ assert_eq!(
+ hex(b"abc"),
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
+ );
+ }
+
+ #[test]
+ fn two_block_nist_vector() {
+ // The classic 448-bit NIST vector that straddles a block boundary,
+ // exercising the padding path.
+ assert_eq!(
+ hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
+ "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
+ );
+ }
+
+ #[test]
+ fn one_million_a() {
+ let data = vec![b'a'; 1_000_000];
+ assert_eq!(
+ hex(&data),
+ "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
+ );
+ }
+}
diff --git a/src/web.rs b/src/web.rs
index e5ca92e..6203c62 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -33,6 +33,9 @@ pub async fn run(
cfg_path: PathBuf,
) -> io::Result<()> {
let listener = TcpListener::bind(&addr).await?;
+ // Don't let a restart/self-update child inherit this listener, or the port
+ // stays bound after we exit and the new instance can't reclaim it.
+ crate::self_update::dont_inherit(&listener);
eprintln!("[web] listening on http://{}", addr);
// Single shared sampler: CPU% needs the previous sample to compute a
// delta, so we cannot construct one per request.
@@ -264,8 +267,17 @@ async fn route(
// Overlay Studio writes: POST /overlays/ saves a baked overlay,
// POST /overlays//delete removes it. The slug is restricted to a
// safe charset (no separators) so no path can escape overlays_dir.
+ // POST /overlays/seeded marks the built-in presets as installed; POST
+ // /overlays/reset wipes the Studio overlays and clears that flag so the
+ // dashboard re-seeds the defaults on its next load.
if method == "POST" && bare_path.starts_with("/overlays/") {
let rest = &bare_path["/overlays/".len()..];
+ if rest == "seeded" {
+ return overlays_mark_seeded(settings, cfg_path);
+ }
+ if rest == "reset" {
+ return overlays_reset(settings, cfg_path);
+ }
if let Some(slug) = rest.strip_suffix("/delete") {
return overlay_delete(slug, settings);
}
@@ -402,6 +414,68 @@ async fn route(
});
("200 OK", "application/json", info.to_json())
}
+ ("POST", "/update/apply") => {
+ // Stage (download + verify) inline so any failure surfaces as a
+ // clean JSON error the About tab can show. The actual swap +
+ // relaunch + process exit is deferred to a detached thread so
+ // this 200 flushes to the browser FIRST - the browser then polls
+ // /config until we're back and reloads.
+ match tokio::task::spawn_blocking(crate::self_update::prepare).await {
+ Ok(Ok(staged)) => {
+ let v = staged.version.replace('"', "'");
+ std::thread::spawn(move || {
+ std::thread::sleep(std::time::Duration::from_millis(1200));
+ crate::self_update::commit_and_relaunch(staged);
+ });
+ (
+ "200 OK",
+ "application/json",
+ format!(r#"{{"ok":true,"restarting":true,"version":"{v}"}}"#),
+ )
+ }
+ Ok(Err(msg)) => (
+ "200 OK",
+ "application/json",
+ format!(
+ r#"{{"ok":false,"error":"{}"}}"#,
+ msg.replace('\\', "\\\\").replace('"', "'")
+ ),
+ ),
+ Err(_) => (
+ "200 OK",
+ "application/json",
+ r#"{"ok":false,"error":"update task panicked"}"#.to_string(),
+ ),
+ }
+ }
+ ("POST", "/app/restart") => {
+ // Relaunch from a detached thread so this 200 flushes first; the
+ // dashboard then polls /config and reloads when we're back.
+ std::thread::spawn(|| {
+ std::thread::sleep(std::time::Duration::from_millis(600));
+ crate::self_update::restart_now();
+ });
+ (
+ "200 OK",
+ "application/json",
+ r#"{"ok":true,"restarting":true}"#.to_string(),
+ )
+ }
+ // Reveal one of our known files in the OS file browser. The keyword
+ // is fixed (not a client-supplied path), so this can only ever open
+ // our own buffer / overlays / trace - never an arbitrary location.
+ ("POST", "/reveal/buffer") => {
+ reveal_path(&settings.borrow().buffer_path.clone());
+ ("200 OK", "application/json", r#"{"ok":true}"#.to_string())
+ }
+ ("POST", "/reveal/overlays") => {
+ reveal_path(&settings.borrow().overlays_dir.clone());
+ ("200 OK", "application/json", r#"{"ok":true}"#.to_string())
+ }
+ ("POST", "/reveal/trace") => {
+ reveal_path(Path::new("./instantclone-trace.log"));
+ ("200 OK", "application/json", r#"{"ok":true}"#.to_string())
+ }
("GET", "/obs/launch-status") => {
let exe = crate::obs_register::find_obs_executable();
(
@@ -1483,6 +1557,10 @@ async fn post_config_reset(
// event-log tab match the "fresh install" feel.
ctrl.arm_delay(0);
ctrl.clear_logs();
+ // Wipe the Studio overlays (from the still-current dir, before the
+ // send below swaps in defaults). The seeded flag is back to false in
+ // `next`, so the dashboard re-bakes the presets on its next load.
+ wipe_studio_overlays(&settings.borrow().overlays_dir);
}
ctrl.log(format!("config reset (scope={})", scope));
reconcile_obs_vod_files(&next, ctrl);
@@ -2025,7 +2103,7 @@ fn generate_dest_id() -> String {
/// ones are still listed and usable as browser sources.
fn list_overlays(settings: &Arc>) -> String {
let dir = settings.borrow().overlays_dir.clone();
- let mut items: Vec<(String, String, bool)> = Vec::new();
+ let mut items: Vec<(String, String, bool, bool)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for e in entries.flatten() {
let fname = match e.file_name().into_string() {
@@ -2043,21 +2121,25 @@ fn list_overlays(settings: &Arc>) -> String {
// detect the ic-doc marker is cheap and keeps the list honest.
let content = std::fs::read_to_string(e.path()).unwrap_or_default();
let studio = content.contains("S\
+ ",
+ )
+ .unwrap();
+ // A hand-written legacy file: no marker, no auto-hide.
+ std::fs::write(
+ dir.join("legacy.html"),
+ "Lhi",
+ )
+ .unwrap();
+
+ // The list reports studio + autohide per file.
+ let settings = settings_with_overlays_dir(&dir);
+ let listed = list_overlays(&settings);
+ assert!(listed.contains(r#""slug":"studio","name":"S","studio":true,"autohide":true"#));
+ assert!(listed.contains(r#""slug":"legacy","name":"L","studio":false,"autohide":false"#));
+
+ // Restore-defaults wipes only the Studio (ic-doc) file; legacy stays.
+ wipe_studio_overlays(&dir);
+ assert!(!dir.join("studio.html").exists());
+ assert!(dir.join("legacy.html").is_file());
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn extract_title_pulls_first_title() {
assert_eq!(
diff --git a/web/index.html b/web/index.html
index 4ce6ce9..54ca91b 100644
--- a/web/index.html
+++ b/web/index.html
@@ -610,12 +610,6 @@
.ovg-scale{position:absolute;top:0;left:0;width:1920px;height:1080px;transform-origin:top left}
.ovg-scale iframe{width:1920px;height:1080px;border:0;background:transparent;display:block}
.ovg-list{display:flex;flex-direction:column;gap:7px;padding:10px 14px 14px;overflow-y:auto;max-height:520px}
-.ovg-cat{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-4);margin:8px 2px 2px}
-.ovg-filter{display:flex;gap:6px;flex-wrap:wrap;margin:2px 0 8px}
-.ovg-fchip{appearance:none;background:var(--surface);border:1px solid var(--line);border-radius:999px;
- color:var(--fg-3);font-size:11px;font-weight:600;padding:5px 12px;cursor:pointer;transition:.12s}
-.ovg-fchip:hover{border-color:var(--line-2);color:var(--fg)}
-.ovg-fchip.on{background:var(--accent);border-color:transparent;color:var(--accent-ink)}
.ovg-card{appearance:none;text-align:left;background:var(--surface);border:1px solid var(--line);
border-radius:10px;padding:11px 13px;cursor:pointer;display:flex;flex-direction:column;gap:3px;
transition:border-color .12s,transform .12s;position:relative}
@@ -625,10 +619,9 @@
.ovg-card-name{font-size:13px;font-weight:600;color:var(--fg)}
.ovg-card-hint{font-size:10.5px;color:var(--fg-4);line-height:1.4}
.ovg-card-tag{position:absolute;top:9px;right:11px;font-size:9px;font-weight:700;letter-spacing:.06em;
- padding:2px 6px;border-radius:6px;background:rgba(255,255,255,.06);color:var(--fg-3)}
-.ovg-card-tag.editable{background:color-mix(in oklch,var(--accent) 16%,transparent);color:var(--accent)}
-.ovg-card-tag.preset{background:color-mix(in oklch,var(--live) 15%,transparent);color:var(--live)}
-.ovg-card-tag.legacy{background:rgba(255,255,255,.06);color:var(--fg-4)}
+ padding:2px 6px;border-radius:6px;background:rgba(255,255,255,.06);color:var(--fg-4)}
+.ovg-card-tag:empty{display:none}
+.ovg-empty{padding:26px 16px;text-align:center;color:var(--fg-4);font-size:13px;line-height:1.6}
/* ── Overlay Studio ─────────────────────────────── */
.st-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
@@ -657,9 +650,6 @@
.st-item .st-cost{font-size:9px;font-weight:700;letter-spacing:.06em;padding:1px 5px;border-radius:5px;
background:color-mix(in oklch,var(--warn) 16%,transparent);color:var(--warn)}
.st-item .st-tag{font-size:9.5px;color:var(--fg-4);font-family:'JetBrains Mono',monospace}
-.st-preset{flex-direction:column;align-items:flex-start;gap:3px}
-.st-preset .st-preset-name{font-size:13px;font-weight:600;color:var(--fg)}
-.st-preset .st-preset-hint{font-size:10.5px;color:var(--fg-4);line-height:1.35}
.st-canvas-wrap{display:flex;flex-direction:column;gap:10px;min-width:0;min-height:0}
.st-canvas-frame{position:relative;flex:1;min-height:0;background:#06080c;
border:1px solid var(--line);border-radius:10px;overflow:hidden}
@@ -720,7 +710,7 @@
.st-st:hover{color:var(--fg);border-color:var(--line-2)}
.st-st .dot{width:9px;height:9px;border-radius:50%;flex:none}
.st-st.on{color:var(--fg);border-color:var(--accent);background:color-mix(in oklch,var(--accent) 13%,var(--surface))}
-.st-state-actions{display:flex;gap:6px}
+.st-state-actions{display:flex;align-items:center;gap:6px}
/* Left panel: layers + add */
.st-sec{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-4);margin:10px 2px 4px}
@@ -954,8 +944,14 @@
.restart-banner{padding:10px 14px;border-radius:8px;
background:color-mix(in oklch,var(--warn) 8%,var(--bg-2));
border:1px solid color-mix(in oklch,var(--warn) 30%,transparent);
- color:var(--warn);font-size:12.5px;display:flex;align-items:center;gap:8px}
+ color:var(--warn);font-size:12.5px;display:flex;align-items:center;gap:10px;justify-content:space-between}
.restart-banner.hidden{display:none}
+/* Unsaved-changes cue on the Studio Save button. */
+.st-save-dirty{box-shadow:0 0 0 2px color-mix(in oklch,var(--accent) 55%,transparent);font-weight:700}
+/* Brief "preview re-synced" pulse after a live edit. */
+.st-synced{font-size:11px;color:var(--accent);opacity:0;transform:translateY(-2px);
+ transition:opacity .2s ease,transform .2s ease;pointer-events:none;white-space:nowrap}
+.st-synced.on{opacity:.9;transform:none}
/* ── Logs ────────────────────────────────────── */
.logs-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px}
@@ -1135,7 +1131,7 @@
⚡ Enhanced Broadcasting⚠ Upload bottleneck⚠ Twitch · Source-Only at 10 Mbps
- ⬆ update available
+ ⬆ update available
@@ -1494,9 +1490,12 @@
New destination
On-stream overlay
-
A browser-source widget that shows your delay. Pick one, drop the URL into OBS - or open the Studio to design your own.
+
Browser-source widgets that show your delay. Pick one, copy its URL into OBS - or edit any in the Studio.