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**. -
http://127.0.0.1:7799/overlay?style=corner&lang=es
+
http://127.0.0.1:7799/overlay/whisper.html
-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**. -
http://127.0.0.1:7799/overlay?style=corner&lang=es
+
http://127.0.0.1:7799/overlay/whisper.html
-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) - -
- - - - - -
- 0.0 - sec -
-
- diff --git a/overlays/minimal.html b/overlays/minimal.html deleted file mode 100644 index b555220..0000000 --- a/overlays/minimal.html +++ /dev/null @@ -1,130 +0,0 @@ - - -Delay (minimal) - -
0.0s
- diff --git a/overlays/strip.html b/overlays/strip.html deleted file mode 100644 index 75de1f2..0000000 --- a/overlays/strip.html +++ /dev/null @@ -1,133 +0,0 @@ - - -Delay (strip) - -
-
-
-
0.0s
-
- 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 @@ - + @@ -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.
+
+
+ +
-
@@ -1515,7 +1514,6 @@

New destination

-
@@ -1556,12 +1554,12 @@

New destination

- + +
@@ -1635,7 +1634,10 @@

New destination

Behavior, network, buffer, notifications, diagnostics.
- +