From 28e75e54788dd04fc370e9d377f1e6c28d9ca998 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:52:32 -0300 Subject: [PATCH 01/17] test(host): add two-host relay probe to unblock #46 make-before-break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #46's make-before-break re-mint is gated on a question that cannot be answered statically: how does the Dev Tunnels relay react to a second in-process host on a tunnel id that is already hosted? This adds a throwaway spike binary (sibling of host_spike, behind the `spike` feature) that brings up host A, then connects host B to the same id and classifies the service behavior from authoritative SDK signals — coexist / evict-old / evict-new / reject — plus a best-effort public-URL serving poll. Result (5/5 reproducible runs against the live relay): the relay ACCEPTS the second host and EVICTS the incumbent the instant B's handshake completes (host A's relay handle resolves Ok(()) within ~16-37us). New-evicts-old, not coexist and not reject. The earlier flaky-DNS (WSANO_DATA 11001) failures are classified as transient/inconclusive, not as a service reject. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 5 + src/bin/two_host_probe.rs | 416 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 src/bin/two_host_probe.rs diff --git a/Cargo.toml b/Cargo.toml index 2302b31..11e3630 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,11 @@ name = "host_spike" path = "src/bin/host_spike.rs" required-features = ["spike"] +[[bin]] +name = "two_host_probe" +path = "src/bin/two_host_probe.rs" +required-features = ["spike"] + [build-dependencies] slint-build = "1.13" diff --git a/src/bin/two_host_probe.rs b/src/bin/two_host_probe.rs new file mode 100644 index 0000000..1d8a3fa --- /dev/null +++ b/src/bin/two_host_probe.rs @@ -0,0 +1,416 @@ +//! Two-host probe (HITL, issue #46): determines how the Dev Tunnels relay reacts +//! to a **second** in-process host connection on a tunnel id that is already being +//! hosted. The answer (coexist / evict / reject) is the go/no-go gate for the +//! make-before-break re-mint described in #46 — it cannot be derived statically or +//! from the single-host E2E, so this throwaway binary measures it live. +//! +//! Flow: +//! 1. Mint host + manage:ports tokens (subprocess `devtunnel token`). +//! 2. Start a local HTTP server (something to forward) and bring up **host A**: +//! connect → add_port. Confirm A is serving. +//! 3. While A is still live, mint fresh tokens and bring up **host B** on the +//! same tunnel id: connect → add_port. +//! 4. Classify the service behavior from authoritative SDK signals: +//! - B `connect` errors → REJECT +//! - A's relay handle resolves after B joins → EVICT (new evicts old) +//! - B's relay handle resolves after connect → EVICT (old evicts new) / soft reject +//! - both handles stay live for the watch window → COEXIST +//! 5. A best-effort HTTP poller curls the public URL throughout and records any +//! serving gap (informational; requires anonymous access on the port). +//! +//! Usage: +//! cargo run --features spike --bin two_host_probe -- +//! +//! The port must already exist on the tunnel (add_port treats 409 as OK). For the +//! HTTP gap measurement, enable anonymous access first: +//! devtunnel access create -p --anonymous + +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tunnels::connections::RelayTunnelHost; +use tunnels::contracts::TunnelPort; +use tunnels::management::{ + new_tunnel_management, Authorization, TunnelLocator, TunnelManagementClient, +}; + +const DEVTUNNEL: &str = "devtunnel"; +const MARKER: &str = "DEVTUNNEL_PROBE_OK"; + +fn devtunnel_bin() -> String { + std::env::var("DEVTUNNEL_BIN").unwrap_or_else(|_| DEVTUNNEL.to_string()) +} + +/// Process creation flag that suppresses the console window Windows would +/// otherwise flash for each subprocess. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Builds a `Command` with the console window suppressed on Windows. +fn command(program: &str) -> Command { + let mut cmd = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + +/// `devtunnel token --scopes -j` → token string. One scope per call: +/// repeating `--scopes` corrupts the first value. +fn mint_token(full_id: &str, scope: &str) -> anyhow::Result { + let out = command(&devtunnel_bin()) + .args(["token", full_id, "--scopes", scope, "-j"]) + .output()?; + if !out.status.success() { + anyhow::bail!( + "devtunnel token ({scope}) failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + let v: serde_json::Value = serde_json::from_slice(&out.stdout)?; + v.get("token") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("'token' field missing from devtunnel token output")) +} + +/// Mints both tokens a host needs (relay `host` + `manage:ports` for add_port). +fn mint_pair(full_id: &str) -> anyhow::Result<(String, String)> { + Ok(( + mint_token(full_id, "host")?, + mint_token(full_id, "manage:ports")?, + )) +} + +/// Fetches the real Public URL (portUri) for the port via `devtunnel show -j`. +fn fetch_port_uri(full_id: &str, port: u16) -> Option { + let out = command(&devtunnel_bin()) + .args(["show", full_id, "-j"]) + .output() + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + let ports = v.get("tunnel")?.get("ports")?.as_array()?; + for p in ports { + if p.get("portNumber").and_then(|n| n.as_u64()) == Some(port as u64) { + return p + .get("portUri") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + } + } + None +} + +/// Builds a host bound to `full_id` and connects it, returning the live host and +/// its relay handle (the handle future resolves when the connection drops). The +/// host MUST stay bound by the caller — dropping it tears the connection down. +async fn bring_up( + full_id: &str, + port: u16, + host_token: &str, + manage_token: String, +) -> anyhow::Result<(RelayTunnelHost, tunnels::connections::RelayHandle)> { + let (id, cluster) = full_id + .rsplit_once('.') + .map(|(i, c)| (i.to_string(), c.to_string())) + .ok_or_else(|| anyhow::anyhow!("tunnel id has no cluster: {full_id}"))?; + + let mut builder = new_tunnel_management("devtunnel-gui-probe/0.1"); + builder.authorization(Authorization::Tunnel(manage_token)); + let mgmt: TunnelManagementClient = builder.into(); + let locator = TunnelLocator::ID { cluster, id }; + + let mut host = RelayTunnelHost::new(locator, mgmt); + let handle = host.connect(host_token).await?; + let tunnel_port = TunnelPort { + port_number: port, + protocol: Some("http".to_string()), + ..Default::default() + }; + host.add_port(&tunnel_port).await?; + Ok((host, handle)) +} + +/// Minimal local HTTP server tagging responses so the poller can tell which side +/// is actually serving. +async fn run_local_server(port: u16) -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind(("127.0.0.1", port)).await?; + log::info!("local test server listening on 127.0.0.1:{port}"); + loop { + let (mut sock, _) = listener.accept().await?; + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let _ = sock.read(&mut buf).await; + let body = format!("{MARKER}\n"); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.flush().await; + }); + } +} + +/// Best-effort serving check: curl the public URL (skipping the anti-phishing +/// interstitial) and report whether our marker came back. Returns `None` when +/// curl itself could not run. +fn curl_serves(uri: &str) -> Option { + let out = command("curl") + .args([ + "-s", + "-m", + "3", + "-H", + "X-Tunnel-Skip-AntiPhishing-Page: true", + uri, + ]) + .output() + .ok()?; + Some(String::from_utf8_lossy(&out.stdout).contains(MARKER)) +} + +/// Polls the public URL until `stop` is set, printing only on serving-state +/// transitions (with elapsed-since-start timestamps) so any gap is visible. +async fn poll_serving(uri: String, start: Instant, stop: Arc) { + let mut last: Option = None; + let mut probed_at_all = false; + while !stop.load(Ordering::Relaxed) { + let serving = curl_serves(&uri); + match serving { + Some(s) => { + probed_at_all = true; + if last != Some(s) { + let t = start.elapsed().as_millis(); + println!( + " [poll +{t:>6}ms] serving = {}", + if s { "YES" } else { "no" } + ); + last = Some(s); + } + } + None => { + if !probed_at_all { + println!(" [poll] curl unavailable — skipping HTTP gap measurement"); + return; + } + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + + let mut args = std::env::args().skip(1); + let full_id = args + .next() + .ok_or_else(|| anyhow::anyhow!("usage: two_host_probe "))?; + let port: u16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(3000); + + println!("== two-host relay probe (issue #46) =="); + println!("tunnel id : {full_id}"); + println!("port : {port}\n"); + + // Local server to forward. + tokio::spawn(async move { + if let Err(e) = run_local_server(port).await { + log::error!("local server failed: {e}"); + } + }); + tokio::time::sleep(Duration::from_millis(300)).await; + + let start = Instant::now(); + + // ---- Host A ------------------------------------------------------------- + println!("[A] minting tokens + connecting…"); + let (a_host_tok, a_manage_tok) = mint_pair(&full_id)?; + let (_host_a, handle_a) = bring_up(&full_id, port, &a_host_tok, a_manage_tok).await?; + println!("[A] connected + port forwarded ✓"); + + // Start the HTTP gap poller (informational). + let stop = Arc::new(AtomicBool::new(false)); + let poller = { + let uri = fetch_port_uri(&full_id, port); + match uri { + Some(u) => { + println!("[A] public URL: {u}"); + let stop = stop.clone(); + Some(tokio::spawn(poll_serving(u, start, stop))) + } + None => { + println!("[A] could not resolve public URL — HTTP gap measurement skipped"); + None + } + } + }; + + // Let A settle and confirm it is serving before the second host joins. + println!("[A] holding 4s to confirm steady-state serving…"); + tokio::time::sleep(Duration::from_secs(4)).await; + + tokio::pin!(handle_a); + // Sanity: A must still be connected at this point. + if let Some(r) = poll_handle(&mut handle_a) { + println!("\n‼ A dropped before B even started: {r:?}"); + finish(stop, poller).await; + println!("\nVERDICT: INCONCLUSIVE (host A unstable on its own)"); + return Ok(()); + } + + // ---- Host B (the experiment) ------------------------------------------- + println!("\n[B] minting fresh tokens + connecting a SECOND host to the same id…"); + let t_b_start = Instant::now(); + let (b_host_tok, b_manage_tok) = mint_pair(&full_id)?; + let b = bring_up(&full_id, port, &b_host_tok, b_manage_tok).await; + + let (_host_b, handle_b) = match b { + Err(e) => { + let msg = e.to_string().to_lowercase(); + // A transient transport failure (Windows WSANO_DATA / DNS, websocket + // IO, EOF) is NOT a service decision — the same flaky lookup makes + // host A retry on connect too. Only a service-level refusal (a status + // code, "forbidden", "conflict", "already hosted") is a real reject. + let transient = msg.contains("11001") + || msg.contains("host não é conhecido") + || msg.contains("host not known") + || msg.contains("io error") + || msg.contains("eof") + || msg.contains("timed out"); + let rejected = msg.contains("403") + || msg.contains("409") + || msg.contains("forbidden") + || msg.contains("conflict") + || msg.contains("already"); + println!("[B] connect FAILED after {:?}: {e}", t_b_start.elapsed()); + let a_after = watch_for(&mut handle_a, Duration::from_secs(5)).await; + finish(stop, poller).await; + if rejected && !transient { + println!("\nVERDICT: REJECT — the relay refuses a second host on one tunnel id."); + println!(" → make-before-break (connect-new-then-drop-old) is impossible as framed."); + println!(" → fall back to minimizing the break window on re-mint."); + } else { + println!( + "\nVERDICT: INCONCLUSIVE — B failed on a transient transport error, not a" + ); + println!(" service rejection. Re-run; this is the same flaky DNS that retries on A."); + } + match a_after { + Some(r) => println!(" note: host A also dropped during B's attempt: {r:?}"), + None => println!(" note: host A kept serving through B's attempt."), + } + return Ok(()); + } + Ok(pair) => { + println!("[B] connected + port forwarded ✓ ({:?})", t_b_start.elapsed()); + pair + } + }; + + // Both connect calls succeeded. Watch both handles for the verdict window. + // Measure any eviction delay from the moment B finished connecting (the + // handover gap that #46 cares about), not from when B started minting. + println!("\n[probe] both hosts connected — watching 15s for eviction…"); + tokio::pin!(handle_b); + let window = Duration::from_secs(15); + let watch_start = Instant::now(); + + let verdict = loop { + if watch_start.elapsed() >= window { + break Verdict::Coexist; + } + tokio::select! { + r = &mut handle_a => break Verdict::EvictOld(format!("{r:?}"), watch_start.elapsed()), + r = &mut handle_b => break Verdict::EvictNew(format!("{r:?}"), watch_start.elapsed()), + _ = tokio::time::sleep(Duration::from_millis(250)) => {} + } + }; + + finish(stop, poller).await; + + println!("\n──────────────────────────────────────────────"); + match verdict { + Verdict::Coexist => { + println!("VERDICT: COEXIST — two hosts served the same tunnel id for 15s."); + println!(" → make-before-break is CLEAN: connect new, verify serving, drop old."); + println!(" → GO on the #46 overlap rewrite of the re-mint path."); + } + Verdict::EvictOld(r, dt) => { + println!("VERDICT: EVICT (new evicts old) — host A dropped {dt:?} after B finished connecting."); + println!(" detail: A handle resolved with {r}"); + println!(" → make-before-break still works, but with a handover window."); + println!(" → GO, but measure the gap from the poll trace above before committing."); + } + Verdict::EvictNew(r, dt) => { + println!("VERDICT: EVICT (old evicts new) — host B dropped {dt:?} after finishing connect."); + println!(" detail: B handle resolved with {r}"); + println!(" → the service keeps the incumbent; a second host cannot take over live."); + println!(" → NO-GO on make-before-break as framed; minimize the break window instead."); + } + } + println!("──────────────────────────────────────────────"); + Ok(()) +} + +enum Verdict { + Coexist, + EvictOld(String, Duration), + EvictNew(String, Duration), +} + +/// Non-blocking peek at a pinned relay handle: `Some(debug)` if it has resolved +/// (connection dropped), `None` if still live. +fn poll_handle( + handle: &mut std::pin::Pin<&mut tunnels::connections::RelayHandle>, +) -> Option { + use std::future::Future; + use std::task::{Context, Poll}; + let waker = futures_noop_waker(); + let mut cx = Context::from_waker(&waker); + match handle.as_mut().poll(&mut cx) { + Poll::Ready(r) => Some(format!("{r:?}")), + Poll::Pending => None, + } +} + +/// Awaits a handle for up to `dur`; returns `Some(debug)` if it resolved within +/// the window, `None` if it stayed live. +async fn watch_for( + handle: &mut std::pin::Pin<&mut tunnels::connections::RelayHandle>, + dur: Duration, +) -> Option { + tokio::select! { + r = handle.as_mut() => Some(format!("{r:?}")), + _ = tokio::time::sleep(dur) => None, + } +} + +/// Stops the poller and awaits its task so the final trace lines flush before the +/// verdict prints. +async fn finish(stop: Arc, poller: Option>) { + stop.store(true, Ordering::Relaxed); + if let Some(p) = poller { + let _ = p.await; + } +} + +/// A no-op waker so we can poll a future once without a runtime scheduling it. +fn futures_noop_waker() -> std::task::Waker { + use std::task::{RawWaker, RawWakerVTable, Waker}; + fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + fn noop(_: *const ()) {} + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop); + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } +} From d766a7f7f302789c5b5988330c509415c16627fc Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:49:58 -0300 Subject: [PATCH 02/17] fix(host): stop infinite "authorizing" loop on a deleted tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tunnel that is deleted or expires while it is in the auto-host set made the keep-alive engine spin forever: the periodic token re-mint hit "Tunnel not found", which `is_fatal_connect_error` did not recognize, so it was classified as Transient and retried on backoff — re-emitting the `Authorizing` phase every cycle and leaving the status pill stuck on "authorizing…". - Classify "tunnel not found" / 404 as fatal so the engine surfaces an error and stops instead of looping identical inputs. - Add `is_missing_tunnel_error` and drop a gone tunnel from the persisted auto-host set in two places: on the engine's mid-session error, and during auto-resume when the tunnel is absent from a successful load (distinguishing "gone" from merely "portless"). Co-Authored-By: Claude Opus 4.8 --- src/devtunnel.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 34 ++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/devtunnel.rs b/src/devtunnel.rs index 197978a..f933b49 100644 --- a/src/devtunnel.rs +++ b/src/devtunnel.rs @@ -251,7 +251,10 @@ pub fn is_auth_error(stderr: &str) -> bool { /// A `400 Bad Request` from the tunnel management API is a request-validation /// failure — e.g. `add_port` rejected with "the tunnel port protocol cannot be /// changed" when the forwarded protocol disagrees with the registered one. These -/// are permanent for identical inputs. Auth failures are handled separately by +/// are permanent for identical inputs. A deleted or expired tunnel surfaces as +/// "Tunnel not found" / `404` while minting tokens; retrying that re-mint can +/// never succeed, so it must stop instead of spinning the reconnect loop forever +/// stuck on the `Authorizing` phase. Auth failures are handled separately by /// [`is_auth_error`] (they have a recovery path: re-login), so callers should /// check that first. #[cfg_attr(not(feature = "hosting"), allow(dead_code))] @@ -260,6 +263,18 @@ pub fn is_fatal_connect_error(stderr: &str) -> bool { lower.contains("400 bad request") || lower.contains("cannot be changed") || lower.contains("invalid arguments") + || is_missing_tunnel_error(stderr) +} + +/// Whether a host error means the tunnel itself no longer exists — `devtunnel +/// token` reports "Tunnel not found" / a `404` for a deleted or expired tunnel. +/// A strict subset of [`is_fatal_connect_error`]: the group should additionally +/// be dropped from the persisted auto-host set, since re-hosting it on the next +/// launch can never succeed. +#[cfg_attr(not(feature = "hosting"), allow(dead_code))] +pub fn is_missing_tunnel_error(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + lower.contains("not found") || lower.contains("404") } /// Runs `devtunnel user login` (interactive — opens the system browser and may @@ -784,7 +799,8 @@ fn tunnel_ports(show: ShowResult) -> Vec<(u16, String)> { mod tests { use super::{ anonymous_ace_args, classify_anonymous_access, classify_install_result, classify_user_show, - is_auth_error, parse_leading_int, parse_rate_bps, parse_size_bytes, sanitize_tunnel_id, + is_auth_error, is_fatal_connect_error, is_missing_tunnel_error, parse_leading_int, + parse_rate_bps, parse_size_bytes, sanitize_tunnel_id, tunnel_ports, update_expiration_args, InstallOutcome, ShowResult, }; @@ -1021,4 +1037,38 @@ mod tests { assert!(!is_auth_error("port number must be between 1 and 65535")); assert!(!is_auth_error("503 Service Unavailable")); } + + #[test] + fn fatal_on_request_validation_errors() { + assert!(is_fatal_connect_error("The request failed: 400 Bad Request")); + assert!(is_fatal_connect_error( + "the tunnel port protocol cannot be changed" + )); + assert!(is_fatal_connect_error("error: invalid arguments")); + } + + #[test] + fn fatal_on_deleted_or_missing_tunnel() { + // A deleted/expired tunnel surfaces while minting the host token; retrying + // can never succeed, so it must stop instead of looping on `Authorizing`. + assert!(is_fatal_connect_error("Tunnel not found in brs: fancy-ocean")); + assert!(is_fatal_connect_error("The request was rejected: 404 Not Found")); + } + + #[test] + fn not_fatal_on_transient_connect_errors() { + assert!(!is_fatal_connect_error("connection timed out")); + assert!(!is_fatal_connect_error("503 Service Unavailable")); + assert!(!is_fatal_connect_error("relay disconnected")); + } + + #[test] + fn missing_tunnel_detects_deleted_or_expired() { + // Drives the auto-host prune: only a genuinely-gone tunnel, not every + // fatal error (a 400 protocol mismatch must keep the group). + assert!(is_missing_tunnel_error("Tunnel not found in brs: fancy-ocean")); + assert!(is_missing_tunnel_error("The request was rejected: 404 Not Found")); + assert!(!is_missing_tunnel_error("400 Bad Request")); + assert!(!is_missing_tunnel_error("the tunnel port protocol cannot be changed")); + } } diff --git a/src/main.rs b/src/main.rs index 2efb35b..a238621 100644 --- a/src/main.rs +++ b/src/main.rs @@ -960,6 +960,21 @@ fn main() -> anyhow::Result<()> { update_tray_icon(&tray, "relogin"); } } + // The tunnel was deleted/expired mid-session: drop + // it from the persisted auto-host set so the next + // launch does not retry a host that can never + // succeed (the loop that left it stuck on + // "authorizing…"). + if devtunnel::is_missing_tunnel_error(msg) { + let mut ps = app_state.borrow_mut(); + if ps.contains_auto_host(&tunnel_id) { + ps.remove_auto_host(&tunnel_id); + ps.save(); + log::info!( + "host: {tunnel_id} no longer exists; removed from auto-host set" + ); + } + } } let id = map_host_state(&hs); let mut st = state.borrow_mut(); @@ -1016,8 +1031,11 @@ fn main() -> anyhow::Result<()> { let ids = app_state.borrow().auto_host.clone(); if !ids.is_empty() { let mut st = state.borrow_mut(); + let mut pruned = false; for id in &ids { - let known = st.rows.iter().any(|r| &r.tunnel_id == id && r.port > 0); + let exists = st.rows.iter().any(|r| &r.tunnel_id == id); + let known = + exists && st.rows.iter().any(|r| &r.tunnel_id == id && r.port > 0); if known { log::info!("auto-resume: hosting {id}"); tunnel_host.send(host::HostCommand::Host { @@ -1025,10 +1043,22 @@ fn main() -> anyhow::Result<()> { }); st.host.insert(id.clone(), "host".to_string()); host_changed = true; + } else if !exists { + // The tunnel no longer exists (deleted/expired + // while the app was closed): drop it so we stop + // carrying a dead entry across launches. + log::info!( + "auto-resume: {id} no longer exists; removing from auto-host set" + ); + app_state.borrow_mut().remove_auto_host(id); + pruned = true; } else { - log::info!("auto-resume: skipping unknown or portless group {id}"); + log::info!("auto-resume: skipping portless group {id}"); } } + if pruned { + app_state.borrow().save(); + } } } From 98e6ec1bddc23ffd00abf390ac3a9c9b4eb836e0 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:16:53 -0300 Subject: [PATCH 03/17] fix(cache): skip stale startup row cache to avoid phantom tunnels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instant-paint row cache (last successful load) was painted unconditionally on launch, so a tunnel deleted while the app was closed flashed as a phantom row — e.g. the header chip showed "1 port" over a "No groups yet" body — for the seconds the slow live `devtunnel list` takes to land. Stamp the cache with a `saved_at` time and skip it on startup once it is older than 24h (or has a future timestamp from clock skew). Quick relaunches keep the snappy paint; after a long gap the UI waits for the live load instead. The old bare-array cache format is treated as unparseable and ignored, self-healing on the next save. Co-Authored-By: Claude Opus 4.8 --- src/state.rs | 69 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/src/state.rs b/src/state.rs index 116309c..f2cc4d3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -152,17 +152,52 @@ fn atomic_write(path: &Path, content: &str) -> anyhow::Result<()> { Ok(()) } -/// Loads the cached rows from the last successful load. Missing or invalid -/// content yields an empty list (the async refresh reconciles shortly after). +/// Discard the instant-paint cache once it is older than this. The cache only +/// exists to paint the last load immediately on a quick relaunch; after a long +/// gap a tunnel deleted meanwhile would otherwise flash as a phantom row for the +/// seconds the live `devtunnel list` takes to land, so we wait for the live load +/// instead. +const CACHE_MAX_AGE_SECS: u64 = 24 * 60 * 60; + +/// The row cache on disk: the last successful load plus when it was written, so +/// a stale cache can be skipped on startup. +#[derive(Debug, Serialize, Deserialize)] +struct RowCache { + /// Unix seconds at write time. + saved_at: u64, + rows: Vec, +} + +/// Current wall-clock time in Unix seconds (0 if the clock predates the epoch). +fn now_unix_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Loads the cached rows from the last successful load. Missing, invalid, or +/// stale content yields an empty list (the async refresh reconciles shortly +/// after). pub fn load_row_cache() -> Vec { - load_row_cache_from(&cache_path()) + load_row_cache_from(&cache_path(), now_unix_secs()) } -fn load_row_cache_from(path: &Path) -> Vec { - match fs::read_to_string(path) { - Ok(text) => serde_json::from_str(&text).unwrap_or_default(), - Err(_) => Vec::new(), +fn load_row_cache_from(path: &Path, now: u64) -> Vec { + let Ok(text) = fs::read_to_string(path) else { + return Vec::new(); + }; + // An unparseable (or pre-timestamp) cache is simply ignored; the live load + // rewrites it in the new format. + let Ok(cache) = serde_json::from_str::(&text) else { + return Vec::new(); + }; + // Skip a cache past its TTL. `saturating_sub` also drops a cache with a + // future timestamp (clock skew), which would otherwise look fresh forever. + if now.saturating_sub(cache.saved_at) > CACHE_MAX_AGE_SECS { + return Vec::new(); } + cache.rows } /// Persists the rows of a successful load so the next startup can paint the @@ -172,7 +207,11 @@ pub fn save_row_cache(rows: &[crate::devtunnel::Row]) { } fn save_row_cache_to(path: &Path, rows: &[crate::devtunnel::Row]) { - let result = serde_json::to_string(rows) + let cache = RowCache { + saved_at: now_unix_secs(), + rows: rows.to_vec(), + }; + let result = serde_json::to_string(&cache) .map_err(anyhow::Error::from) .and_then(|json| atomic_write(path, &json)); if let Err(e) = result { @@ -262,7 +301,7 @@ mod tests { // Missing file -> empty list. let _ = fs::remove_file(&path); - assert!(load_row_cache_from(&path).is_empty()); + assert!(load_row_cache_from(&path, now_unix_secs()).is_empty()); let rows = vec![crate::devtunnel::Row { group: "frontend".into(), @@ -274,14 +313,20 @@ mod tests { host_connections: 0, }]; save_row_cache_to(&path, &rows); - let loaded = load_row_cache_from(&path); + let loaded = load_row_cache_from(&path, now_unix_secs()); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].tunnel_id, "frontend.brs"); assert_eq!(loaded[0].port, 3000); - // Invalid content -> empty list. + // Past the TTL -> skipped so a deleted tunnel cannot flash on startup. + let stale = now_unix_secs() + CACHE_MAX_AGE_SECS + 1; + assert!(load_row_cache_from(&path, stale).is_empty()); + + // Invalid content (and the old pre-timestamp array format) -> empty list. fs::write(&path, "garbage").unwrap(); - assert!(load_row_cache_from(&path).is_empty()); + assert!(load_row_cache_from(&path, now_unix_secs()).is_empty()); + fs::write(&path, r#"[{"tunnel_id":"x.brs","port":1}]"#).unwrap(); + assert!(load_row_cache_from(&path, now_unix_secs()).is_empty()); } #[test] From 80dcb7a881deb23b361272217eb55c9b704b4f7e Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:29:20 -0300 Subject: [PATCH 04/17] fix(i18n): use the loaded FTL's langid so plurals match (0 ports, not 1 port) The Fluent bundle was built with the system locale's langid while always loading en-US strings, so Fluent applied the requested locale's CLDR plural rules to English text. On a pt-BR system, pt classifies 0 as `one`, so `status-port-count` with count 0 selected the `[one]` branch and rendered "1 port" over an empty "No groups yet" list. Resolve the request to the locale we actually ship strings for and build the bundle with that resolved langid, so plural selection always matches the loaded patterns. Add `resolve_lang` (kept in lockstep with `ftl_source`) plus tests covering en-US plurals and the pt-BR regression. Co-Authored-By: Claude Opus 4.8 --- src/locale.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/src/locale.rs b/src/locale.rs index ea30818..71913b1 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -16,11 +16,16 @@ impl Locale { /// Loads the bundle for `lang` (e.g. `"en-US"`). /// Unknown locales fall back to `en-US`. pub fn load(lang: &str) -> Self { - let source = ftl_source(lang); + // The bundle's langid must match the FTL we actually load, not the raw + // request: it drives Fluent's CLDR plural selection. Loading en-US text + // under, say, a pt-BR langid applies Portuguese plural rules to English + // patterns — and pt classifies 0 as `one`, so `status-port-count` with + // count 0 wrongly rendered the `[one]` branch ("1 port") for "0 ports". + let resolved = resolve_lang(lang); + let source = ftl_source(resolved); let res = FluentResource::try_new(source.to_string()).expect("embedded FTL must be valid"); - let langid: LanguageIdentifier = lang - .parse() - .unwrap_or_else(|_| "en-US".parse().expect("en-US is valid")); + let langid: LanguageIdentifier = + resolved.parse().expect("resolved locale tag must be valid"); let mut bundle = FluentBundle::new(vec![langid]); bundle .add_resource(res) @@ -73,7 +78,55 @@ pub fn system_locale() -> String { sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string()) } -fn ftl_source(_lang: &str) -> &'static str { - // Add new locales here; unknown tags fall back to en-US. - include_str!("../i18n/en-US/app.ftl") +/// Resolves a requested BCP-47 tag to the locale we actually ship strings for, +/// so the loaded FTL and the bundle's langid (hence its plural rules) always +/// agree. Until more locales ship, every request resolves to en-US. Add an arm +/// here in lockstep with [`ftl_source`] when adding a locale. +fn resolve_lang(_lang: &str) -> &'static str { + // e.g. "pt-BR" | "pt" => "pt-BR", + "en-US" +} + +fn ftl_source(lang: &str) -> &'static str { + // `lang` is already a resolved tag from [`resolve_lang`]. + match lang { + // "pt-BR" => include_str!("../i18n/pt-BR/app.ftl"), + _ => include_str!("../i18n/en-US/app.ftl"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_args(n: i64) -> FluentArgs<'static> { + let mut args = FluentArgs::new(); + args.set("count", n); + args + } + + /// Strips the bidi isolation marks (FSI/PDI) Fluent wraps around interpolated + /// args; they are invisible in the UI but would break literal comparisons. + fn plain(s: String) -> String { + s.replace(['\u{2068}', '\u{2069}'], "") + } + + #[test] + fn port_count_uses_english_plural_rules() { + // en-US: 0 and 2+ are "other", only 1 is "one". + let loc = Locale::load("en-US"); + assert_eq!(plain(loc.t_args("status-port-count", &count_args(0))), "0 ports"); + assert_eq!(plain(loc.t_args("status-port-count", &count_args(1))), "1 port"); + assert_eq!(plain(loc.t_args("status-port-count", &count_args(3))), "3 ports"); + } + + #[test] + fn pt_br_request_does_not_misplural_english_text() { + // Regression: a pt-BR system locale loaded en-US strings under a pt-BR + // langid, and pt classifies 0 as `one` — so "0 ports" rendered as the + // `[one]` branch ("1 port"). The bundle must use the resolved (en-US) + // langid so plural rules match the loaded text. + let loc = Locale::load("pt-BR"); + assert_eq!(plain(loc.t_args("status-port-count", &count_args(0))), "0 ports"); + } } From aa65c46d5f6aa5956f98b07230a2bad653b2f1ac Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:51:34 -0300 Subject: [PATCH 05/17] feat(ui): open tunnel URL in browser on click, drop open button Clicking the port URL now opens it in the default browser instead of copying it. The redundant standalone open (open-in-browser) button is removed; copy stays available via the copy icon button. Co-Authored-By: Claude Opus 4.8 --- ui/port-row.slint | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/ui/port-row.slint b/ui/port-row.slint index a9f3e8c..df5d71b 100644 --- a/ui/port-row.slint +++ b/ui/port-row.slint @@ -1,5 +1,5 @@ // One port inside a GroupCard: status dot · port · protocol pill · -// prominent monospace URL (click-to-copy) · hover actions (copy ⧉, open ↗) +// prominent monospace URL (click-to-open) · hover actions (copy ⧉) // and a de-emphasised delete action. import { Theme } from "theme.slint"; import { Strings } from "strings.slint"; @@ -47,7 +47,7 @@ export component PortRow inherits Rectangle { // Including the buttons' own hover prevents the flicker that a bare // `hover.has-hover` produces when the pointer moves onto an action. property show-actions: hover.has-hover || copy-btn.hovered - || open-btn.hovered || del-btn.hovered || root.always-show-actions; + || del-btn.hovered || root.always-show-actions; height: Theme.row-height; background: root.selected @@ -92,16 +92,16 @@ export component PortRow inherits Rectangle { Pill { text: pv.protocol == "" ? "—" : pv.protocol; } } - // The URL is the product: prominent, monospace, click-to-copy. Only the - // text itself copies on click; the trailing strip toggles the detail - // panel so metrics/logs stay reachable even when the URL is long. + // The URL is the product: prominent, monospace, click-to-open. Only the + // text itself opens in the browser on click; the trailing strip toggles + // the detail panel so metrics/logs stay reachable even when the URL is long. HorizontalLayout { horizontal-stretch: 1; url-area := TouchArea { enabled: pv.url != ""; mouse-cursor: pv.url == "" ? MouseCursor.default : MouseCursor.pointer; clicked => { - root.copy-url(pv.url); + root.open-url(pv.url); } Text { text: pv.url == "" ? Strings.no-url : pv.url; @@ -138,15 +138,6 @@ export component PortRow inherits Rectangle { root.copy-url(pv.url); } } - open-btn := IconButton { - glyph: Theme.ico-open; - tip: Strings.tooltip-open; - revealed: root.show-actions; - enabled: pv.url != ""; - clicked => { - root.open-url(pv.url); - } - } del-btn := IconButton { glyph: Theme.ico-delete; tip: Strings.btn-del-port; From a97427a4e148f8cfd751eaf70afe0908201b56f8 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:38:56 -0300 Subject: [PATCH 06/17] fix(log): implement non-blocking stderr writer to prevent UI thread stalls --- src/logbuf.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/logbuf.rs b/src/logbuf.rs index 997de6c..590eb4b 100644 --- a/src/logbuf.rs +++ b/src/logbuf.rs @@ -7,7 +7,9 @@ use log::{Level, LevelFilter, Log, Metadata, Record}; use std::collections::VecDeque; -use std::sync::Mutex; +use std::io::Write; +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::sync::{Mutex, OnceLock}; /// Maximum number of captured lines kept in memory. const CAPACITY: usize = 500; @@ -57,6 +59,47 @@ impl Ring { static RING: Mutex = Mutex::new(Ring::new(CAPACITY)); +/// Bounded, non-blocking sink to a dedicated stderr writer thread. +/// +/// Teeing every record straight to stderr with `eprintln!` was a multi-day +/// freeze bug: `eprintln!` is a *blocking* write serialized by the global stderr +/// lock. When the app is launched from a terminal and that console pauses output +/// (a QuickEdit text selection) or its pipe backs up, the writing thread stalls +/// *holding the lock*; the next thread to log — eventually the UI thread — blocks +/// on it and the whole event loop freezes while the process stays alive. +/// +/// The writer thread owns the only blocking `writeln!`; every `log()` call just +/// `try_send`s the formatted line and drops it when the channel is full. A stuck +/// console can therefore stall at most this one background thread and cost a few +/// dropped log lines — never the UI thread. +static SINK: OnceLock> = OnceLock::new(); + +/// Capacity of the stderr writer channel. While the console is paused, lines +/// beyond this are dropped rather than blocking (or unboundedly growing) the +/// threads that emit them. +const SINK_CAPACITY: usize = 1024; + +/// Spawns the background stderr writer thread and stores its non-blocking sender. +/// First caller wins (subsequent calls are no-ops); safe to call once from +/// [`CaptureLogger::install`]. +fn init_stderr_writer() { + let (tx, rx) = sync_channel::(SINK_CAPACITY); + if SINK.set(tx).is_err() { + return; // already initialized + } + let _ = std::thread::Builder::new() + .name("devtunnel-log-writer".to_string()) + .spawn(move || { + let mut out = std::io::stderr(); + // A blocking write here (paused/stuck console) stalls only this + // thread; the bounded channel drops new lines meanwhile, so no + // logging thread ever waits on stderr. + while let Ok(line) = rx.recv() { + let _ = writeln!(out, "{line}"); + } + }); +} + /// Appends a record to the process-wide ring buffer. /// Dormant in v0.1.0 (Logs-tab capture disabled); kept for re-enable + tests. #[allow(dead_code)] @@ -128,6 +171,8 @@ impl CaptureLogger { /// Installs `self` as the global logger and sets the max level to the most /// verbose directive. Errors only if a logger is already installed. pub fn install(self) -> Result<(), log::SetLoggerError> { + // Start the decoupled stderr writer before any record can be emitted. + init_stderr_writer(); let max = self .directives .iter() @@ -166,7 +211,12 @@ impl Log for CaptureLogger { let message = record.args().to_string(); // Technical/diagnostic content — intentionally not localized. let line = format!("{:<5} {} — {}", record.level(), record.target(), message); - eprintln!("{line}"); + // Hand the line to the writer thread without ever blocking: a full + // channel (paused/stuck console) drops the line instead of stalling this + // — possibly the UI — thread. See `SINK` for why this matters. + if let Some(sink) = SINK.get() { + let _ = sink.try_send(line); + } // Logs-tab capture DISABLED for stability (v0.1.0): the detail panel's // Logs view is turned off, so records are no longer accumulated in the // ring (only stderr above is kept). Restore with the panel. From 97dc3b2086bb9fc68c0cb95730d79722a6b8411c Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:01:12 -0300 Subject: [PATCH 07/17] feat(update): implement in-app update checker and banner for new releases --- Cargo.toml | 9 +-- i18n/en-US/app.ftl | 6 ++ src/main.rs | 53 +++++++++++++++ src/state.rs | 6 ++ src/update.rs | 149 +++++++++++++++++++++++++++++++++++++++++ ui/app-window.slint | 22 ++++++ ui/strings.slint | 7 ++ ui/update-banner.slint | 63 +++++++++++++++++ 8 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 src/update.rs create mode 100644 ui/update-banner.slint diff --git a/Cargo.toml b/Cargo.toml index 11e3630..61691e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,9 +28,10 @@ log = "0.4" tunnels = { git = "https://github.com/microsoft/dev-tunnels", features = ["connections", "vendored-openssl"], optional = true } tokio = { version = "1", features = ["full"], optional = true } env_logger = { version = "0.11", optional = true } -# Blocking HTTP client for the health probe (issue #4). rustls avoids a native -# OpenSSL dependency for the probe itself. Optional: only pulled by `hosting`. -ureq = { version = "2", default-features = false, features = ["tls"], optional = true } +# Blocking HTTP client. Used by the startup update checker (GitHub Releases) in +# every build, and by the health probe in the `hosting` build. rustls avoids a +# native OpenSSL dependency, keeping the default build light. +ureq = { version = "2", default-features = false, features = ["tls"] } [target.'cfg(windows)'.dependencies] # Initial dark-mode detection: read the Windows "apps use light theme" setting. @@ -48,7 +49,7 @@ spike = ["dep:tunnels", "dep:tokio", "dep:env_logger"] # `cargo build` stays light (no vendored OpenSSL / heavy toolchain). # env_logger is no longer needed here: the GUI installs its own capturing # logger (src/logbuf.rs) in every build. The spike bin still uses env_logger. -hosting = ["dep:tunnels", "dep:tokio", "dep:ureq"] +hosting = ["dep:tunnels", "dep:tokio"] [[bin]] name = "devtunnel_gui" diff --git a/i18n/en-US/app.ftl b/i18n/en-US/app.ftl index dcf6361..6bf9303 100644 --- a/i18n/en-US/app.ftl +++ b/i18n/en-US/app.ftl @@ -138,6 +138,12 @@ relogin-message = Sign-in expired — sign in again to keep hosting btn-sign-in = Sign in banner-action-open-settings = Open Settings +## Update available banner +update-banner-title = Update available +update-banner-body = Version { $version } is available — you're on an older build. +btn-update-download = View release +btn-update-ignore = Ignore + ## Install CLI progress / outcome install-status-running = Installing… install-status-done = Dev Tunnels CLI installed diff --git a/src/main.rs b/src/main.rs index a238621..d2395dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod model; #[cfg(feature = "hosting")] mod probe; mod state; +mod update; mod view; slint::include_modules!(); @@ -247,6 +248,13 @@ fn main() -> anyhow::Result<()> { let (host_evt_tx, host_evt_rx) = std::sync::mpsc::channel::(); let tunnel_host = host::spawn(host_evt_tx); + // ---- Update checker ---- + // A background thread polls GitHub Releases (startup + every 24 h) and pumps + // an UpdateInfo when a newer version than this build is published; the UI + // pump then shows the in-app update banner. + let (update_tx, update_rx) = std::sync::mpsc::channel::(); + update::spawn(update_tx); + #[cfg(feature = "hosting")] let (probe_evt_rx, probe_cmd_tx) = { let (probe_evt_tx, probe_evt_rx) = std::sync::mpsc::channel::(); @@ -322,6 +330,28 @@ fn main() -> anyhow::Result<()> { }); }); } + // ---- Update banner: open the release page in the browser ---- + { + let weak = app.as_weak(); + app.on_open_update_url(move || { + if let Some(a) = weak.upgrade() { + open_browser(&a.get_update_url()); + } + }); + } + // ---- Update banner: ignore this version (persist + hide the banner) ---- + { + let weak = app.as_weak(); + let app_state = app_state.clone(); + app.on_ignore_update(move || { + if let Some(a) = weak.upgrade() { + let mut st = app_state.borrow_mut(); + st.settings.skipped_update = a.get_update_version().to_string(); + st.save(); + a.set_update_available(false); + } + }); + } // ---- Settings: probe interval + default expiration (issue #6) ---- // Seed the dialog properties from the persisted settings; the handlers // persist edits and (hosting build) re-target the live probe immediately. @@ -831,6 +861,22 @@ fn main() -> anyhow::Result<()> { toggle_window(&weak); } } + // A newer GitHub release was found -> show the update banner, + // unless the user already chose to ignore exactly this version. + while let Ok(info) = update_rx.try_recv() { + if info.version == app_state.borrow().settings.skipped_update { + continue; + } + if let Some(a) = weak.upgrade() { + let mut args = FluentArgs::new(); + args.set("version", info.version.clone()); + a.global::() + .set_update_banner_body(loc.t_args("update-banner-body", &args).into()); + a.set_update_version(info.version.into()); + a.set_update_url(info.url.into()); + a.set_update_available(true); + } + } // CLI install outcomes -> clear "Installing…" and surface a // clear result instead of swallowing failures. while let Ok(outcome) = install_rx.try_recv() { @@ -1777,6 +1823,13 @@ fn apply_strings(app: &AppWindow, loc: &Locale) { s.set_banner_relogin_body(loc.t("banner-relogin-body").into()); s.set_btn_sign_in(loc.t("btn-sign-in").into()); s.set_banner_action_open_settings(loc.t("banner-action-open-settings").into()); + + // Update available banner (update-banner-body is filled from Rust with the + // release version when a newer release is found). + s.set_update_banner_title(loc.t("update-banner-title").into()); + s.set_btn_update_download(loc.t("btn-update-download").into()); + s.set_btn_update_ignore(loc.t("btn-update-ignore").into()); + s.set_install_status_running(loc.t("install-status-running").into()); s.set_install_status_done(loc.t("install-status-done").into()); s.set_install_status_elevation(loc.t("install-status-elevation").into()); diff --git a/src/state.rs b/src/state.rs index f2cc4d3..4903819 100644 --- a/src/state.rs +++ b/src/state.rs @@ -31,6 +31,10 @@ pub struct Settings { /// Minimum severity shown in the port-detail Logs tab: one of /// `error`/`warn`/`info`/`debug`. Defaults to `info` (Debug chatter hidden). pub log_level: String, + /// A release the user chose to ignore via the update banner's "Ignore" + /// button (the release tag, e.g. `v0.2.0`). The banner stays hidden for + /// exactly this version; a later release still notifies. Empty = none. + pub skipped_update: String, } impl Default for Settings { @@ -45,6 +49,8 @@ impl Default for Settings { dark: None, // Show info and above by default; users can widen to debug. log_level: "info".to_string(), + // No release ignored until the user clicks "Ignore" on the banner. + skipped_update: String::new(), } } } diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..48bda5b --- /dev/null +++ b/src/update.rs @@ -0,0 +1,149 @@ +//! Background check for a newer GitHub release. +//! +//! On startup and every 24 h thereafter, a background thread queries the GitHub +//! Releases API for the latest published release and compares its tag against +//! the running build's `GIT_VERSION`. When the release is strictly newer it +//! sends an `UpdateInfo` to the UI thread, which surfaces an in-app banner. +//! +//! The check is best-effort: network failures are logged at debug and retried +//! on the next tick — they never surface to the user or block the UI. + +use std::sync::mpsc::Sender; +use std::time::Duration; + +/// GitHub Releases API for this repo's latest (non-prerelease) release. +const RELEASES_API: &str = + "https://api.github.com/repos/paulocorcino/devtunnel_gui/releases/latest"; + +/// Public release page, used as the click target when the API omits `html_url`. +const RELEASES_PAGE: &str = "https://github.com/paulocorcino/devtunnel_gui/releases/latest"; + +/// How often to re-check after the initial startup check. The app is a tray +/// app that can stay open for days, so a one-shot startup check could never +/// fire for long-running instances. +const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// A release newer than the running build, pumped to the UI thread. +#[derive(Clone, Debug)] +pub struct UpdateInfo { + /// The release tag, as published (e.g. `v0.2.0`). + pub version: String, + /// The release page to open in the browser. + pub url: String, +} + +/// Spawns the background update checker. Sends an `UpdateInfo` on the channel +/// whenever the latest release is newer than the running build, then sleeps +/// until the next check. Stops when the receiver is dropped (UI shut down). +pub fn spawn(tx: Sender) { + // Test hook: force the banner without a live release. `DEVTUNNEL_FAKE_UPDATE` + // is the tag to advertise (e.g. `v9.9.9`); the URL points at the releases + // page. Used to verify the banner UI locally. + if let Ok(tag) = std::env::var("DEVTUNNEL_FAKE_UPDATE") { + let _ = tx.send(UpdateInfo { + version: tag, + url: RELEASES_PAGE.to_string(), + }); + return; + } + + let current = env!("GIT_VERSION"); + std::thread::spawn(move || loop { + match check_latest() { + Ok(Some(info)) if is_newer(&info.version, current) => { + if tx.send(info).is_err() { + return; // Receiver gone — the UI is shutting down. + } + } + Ok(_) => {} + Err(e) => log::debug!("update check failed: {e}"), + } + std::thread::sleep(CHECK_INTERVAL); + }); +} + +/// Queries the GitHub API for the latest release. Returns `Ok(None)` when the +/// response carries no usable tag. +fn check_latest() -> anyhow::Result> { + let resp = ureq::get(RELEASES_API) + // GitHub rejects requests without a User-Agent. + .set("User-Agent", "devtunnel_gui") + .set("Accept", "application/vnd.github+json") + .timeout(Duration::from_secs(10)) + .call()?; + // ureq's `json` feature is off (keeps the default build light); parse the + // body with serde_json directly. + let json: serde_json::Value = serde_json::from_str(&resp.into_string()?)?; + let tag = json.get("tag_name").and_then(|v| v.as_str()).unwrap_or(""); + if tag.is_empty() { + return Ok(None); + } + let url = json + .get("html_url") + .and_then(|v| v.as_str()) + .unwrap_or(RELEASES_PAGE) + .to_string(); + Ok(Some(UpdateInfo { + version: tag.to_string(), + url, + })) +} + +/// Returns true when `candidate` is a strictly newer semantic version than +/// `current`. Both may carry a leading `v` and `-`/`+` build suffixes +/// (e.g. `v0.2.0`, `0.1.0+g05b8b3c-dirty`); only MAJOR.MINOR.PATCH is compared. +/// Anything that cannot be parsed is treated as not-newer (fail closed), so a +/// malformed tag never triggers a spurious "update available". +fn is_newer(candidate: &str, current: &str) -> bool { + match (parse_semver(candidate), parse_semver(current)) { + (Some(c), Some(cur)) => c > cur, + _ => false, + } +} + +/// Extracts `(major, minor, patch)` from a version string, ignoring a leading +/// `v` and any `-`/`+` suffix. Missing minor/patch default to 0. Returns `None` +/// if the numeric core is absent or non-numeric. +fn parse_semver(s: &str) -> Option<(u64, u64, u64)> { + let core = s.trim().trim_start_matches(['v', 'V']); + let core = core.split(['-', '+']).next().unwrap_or(""); + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_detects_bump() { + assert!(is_newer("v0.2.0", "0.1.0+g05b8b3c")); + assert!(is_newer("v0.1.1", "v0.1.0")); + assert!(is_newer("1.0.0", "v0.9.9")); + } + + #[test] + fn not_newer_when_equal_or_older() { + // Untagged dev build of the same release must not self-notify. + assert!(!is_newer("v0.1.0", "0.1.0+g05b8b3c")); + assert!(!is_newer("v0.1.0", "v0.2.0")); + // Commits past the tag on the same MAJOR.MINOR.PATCH are not newer. + assert!(!is_newer("v0.2.0", "v0.2.0-3-gabc1234")); + } + + #[test] + fn unparseable_is_not_newer() { + assert!(!is_newer("nightly", "v0.1.0")); + assert!(!is_newer("v0.2.0", "not-a-version")); + } + + #[test] + fn parses_suffixes() { + assert_eq!(parse_semver("v0.2.0-3-gabc1234"), Some((0, 2, 0))); + assert_eq!(parse_semver("0.1.0+g05b8b3c-dirty"), Some((0, 1, 0))); + assert_eq!(parse_semver("v1.2"), Some((1, 2, 0))); + } +} diff --git a/ui/app-window.slint b/ui/app-window.slint index 6116445..d6b23a3 100644 --- a/ui/app-window.slint +++ b/ui/app-window.slint @@ -9,6 +9,7 @@ import { GroupCard, GroupView, PortView } from "group-card.slint"; import { Toast } from "toast.slint"; import { EmptyState } from "empty-state.slint"; import { PreflightBanner } from "banner.slint"; +import { UpdateBanner } from "update-banner.slint"; import { SettingsDialog } from "settings.slint"; export { Strings, Theme, GroupView, PortView } @@ -77,6 +78,16 @@ export component AppWindow inherits Window { // App version string (from Cargo), shown in the About panel. in property app-version; + // Update available: set by Rust when the background checker finds a newer + // GitHub release. Drives the UpdateBanner at the top of the window. + in property update-available; + in property update-version; + in property update-url; + // The user clicked "View release" — Rust opens `update-url` in the browser. + callback open-update-url(); + // The user clicked "Ignore" — Rust persists `update-version` as skipped. + callback ignore-update(); + // Settings → Requirements checklist: per-item satisfied/not flags, refreshed // by Rust when the dialog opens and after install/auto-start actions. in property req-cli-ok; @@ -208,6 +219,17 @@ export component AppWindow inherits Window { } Rectangle { height: 1px; background: Theme.border; } + // ---- Update available banner (newer GitHub release) ---- + if root.update-available : UpdateBanner { + url: root.update-url; + view-release => { + root.open-update-url(); + } + ignore-update => { + root.ignore-update(); + } + } + // ---- Preflight banner (CLI missing / re-login) — issue #14 ---- if root.app-state != "ready" : PreflightBanner { app-state: root.app-state; diff --git a/ui/strings.slint b/ui/strings.slint index 54efdaf..19b58b2 100644 --- a/ui/strings.slint +++ b/ui/strings.slint @@ -151,6 +151,13 @@ export global Strings { in property btn-sign-in: "Sign in"; in property banner-action-open-settings: "Open Settings"; + // Update available banner (update-banner-body is filled from Rust with the + // release version, so it has no static default). + in property update-banner-title: "Update available"; + in property update-banner-body; + in property btn-update-download: "View release"; + in property btn-update-ignore: "Ignore"; + // Install CLI progress / outcome in property install-status-running: "Installing…"; in property install-status-done: "Dev Tunnels CLI installed"; diff --git a/ui/update-banner.slint b/ui/update-banner.slint new file mode 100644 index 0000000..e1337e0 --- /dev/null +++ b/ui/update-banner.slint @@ -0,0 +1,63 @@ +// In-app banner shown at the top of the window when a newer GitHub release is +// available. Unlike the PreflightBanner it is not tied to the app-state enum — +// it is gated by its own `update-available` flag and can show while ready. +import { Theme } from "theme.slint"; +import { Strings } from "strings.slint"; +import { TxtButton } from "controls.slint"; + +export component UpdateBanner inherits Rectangle { + // Release page opened when the user clicks the action button (Rust reads it + // back from the window; kept here only for symmetry / future use). + in property url; + // User clicked "View release" — Rust opens the release page in the browser. + callback view-release(); + // User clicked "Ignore" — Rust remembers this version and hides the banner. + callback ignore-update(); + + background: Theme.surface; + + VerticalLayout { + HorizontalLayout { + padding: Theme.pad; + spacing: Theme.gap; + + VerticalLayout { + spacing: 2px; + alignment: center; + Text { + text: Strings.update-banner-title; + color: Theme.accent; + font-size: Theme.fs-section; + font-weight: 700; + } + Text { + text: Strings.update-banner-body; + color: Theme.text; + font-size: Theme.fs-body; + wrap: word-wrap; + } + } + + Rectangle { horizontal-stretch: 1; } + + VerticalLayout { + alignment: center; + spacing: Theme.gap-sm; + TxtButton { + text: Strings.btn-update-download; + primary: true; + clicked => { + root.view-release(); + } + } + TxtButton { + text: Strings.btn-update-ignore; + clicked => { + root.ignore-update(); + } + } + } + } + Rectangle { height: 1px; background: Theme.border; } + } +} From 8f53e82a407dd47fb3c50f35dfb6ef5a0b215515 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:50:08 -0300 Subject: [PATCH 08/17] feat(store): add MSIX `store` build feature for Microsoft Store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare for a Microsoft Store (MSIX) release as "TunnelDeck for Dev Tunnels". The MSIX container virtualizes the registry/filesystem and manages install and update itself, so several existing behaviors are broken or against Store policy inside the package. A new `store` cargo feature compiles them out: - Update checker (src/update.rs) becomes a no-op — the Store delivers updates. - Self-install relocation, uninstall, and the HKCU Run-key auto-start are hidden in the UI via Strings.store-build; auto-start moves to the manifest's windows.startupTask (user-managed in Windows Settings). - The winget "Install CLI" button is hidden (can't run from the sandbox). `store` pulls in `hosting` since the Host button is core to the product. Also renames the in-app title/About to "Dev Tunnels Manager". Packaging lives under packaging/msix/: AppxManifest.xml (identity placeholders, full-trust app, startupTask), build-msix.ps1 (build + pack + optional sign/WACK), and a gen_msix_assets bin that renders the tile/logo PNGs from the app icon. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 22 +++++ i18n/en-US/app.ftl | 4 +- packaging/msix/.gitignore | 4 + packaging/msix/AppxManifest.xml | 82 ++++++++++++++++ packaging/msix/build-msix.ps1 | 159 ++++++++++++++++++++++++++++++++ src/bin/gen_msix_assets.rs | 97 +++++++++++++++++++ src/main.rs | 3 + src/update.rs | 20 +++- ui/settings.slint | 24 +++-- ui/strings.slint | 10 +- 10 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 packaging/msix/.gitignore create mode 100644 packaging/msix/AppxManifest.xml create mode 100644 packaging/msix/build-msix.ps1 create mode 100644 src/bin/gen_msix_assets.rs diff --git a/Cargo.toml b/Cargo.toml index 61691e3..ddd7c65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ env_logger = { version = "0.11", optional = true } # every build, and by the health probe in the `hosting` build. rustls avoids a # native OpenSSL dependency, keeping the default build light. ureq = { version = "2", default-features = false, features = ["tls"] } +# PNG encoder for the MSIX visual assets. Only pulled in by the `store` feature, +# which builds the `gen_msix_assets` helper bin; the GUI itself never links it. +ico = { version = "0.3", optional = true } [target.'cfg(windows)'.dependencies] # Initial dark-mode detection: read the Windows "apps use light theme" setting. @@ -50,6 +53,18 @@ spike = ["dep:tunnels", "dep:tokio", "dep:env_logger"] # env_logger is no longer needed here: the GUI installs its own capturing # logger (src/logbuf.rs) in every build. The spike bin still uses env_logger. hosting = ["dep:tunnels", "dep:tokio"] +# Microsoft Store (MSIX) build. The MSIX container virtualizes the registry and +# filesystem and manages install/update itself, so the self-install relocation, +# the HKCU Run-key auto-start, and the GitHub-Releases update checker are all +# either broken or against Store policy inside the package. This feature compiles +# them out: install/update are handled by the MSIX package, and auto-start is +# declared via the `windows.startupTask` manifest extension (user-managed in +# Windows Settings > Startup apps). See docs/store/README.md. +# +# `store` always pulls in `hosting`: the Host button is core to the product, so a +# Store build without it makes no sense. Building it needs NASM + Strawberry Perl +# on PATH (vendored OpenSSL) — see CLAUDE.md. +store = ["dep:ico", "hosting"] [[bin]] name = "devtunnel_gui" @@ -65,6 +80,13 @@ name = "two_host_probe" path = "src/bin/two_host_probe.rs" required-features = ["spike"] +# Renders the MSIX visual assets (tile/logo PNGs) from the procedural app icon so +# the Store package's Assets\ folder is reproducible. Run via packaging/msix/build-msix.ps1. +[[bin]] +name = "gen_msix_assets" +path = "src/bin/gen_msix_assets.rs" +required-features = ["store"] + [build-dependencies] slint-build = "1.13" diff --git a/i18n/en-US/app.ftl b/i18n/en-US/app.ftl index 6bf9303..db607e1 100644 --- a/i18n/en-US/app.ftl +++ b/i18n/en-US/app.ftl @@ -119,7 +119,7 @@ confirm-uninstall = Uninstall DevTunnel GUI? This removes the Start-menu shortcu ## About about-title = About -about-app-name = Dev Tunnels GUI +about-app-name = Dev Tunnels Manager about-version-label = Version about-tagline = Manage Microsoft Dev Tunnels from your Windows tray. about-built-on = Built on Microsoft Dev Tunnels — Microsoft's free, security-focused tunneling service — and its official CLI and SDK. Not affiliated with or endorsed by Microsoft. @@ -172,7 +172,7 @@ badge-stopped = Stopped badge-hosting = Hosting… ## Top bar (redesign) -app-title = Dev Tunnels +app-title = Dev Tunnels Manager pill-connected = Connected tooltip-settings = Toggle dark mode diff --git a/packaging/msix/.gitignore b/packaging/msix/.gitignore new file mode 100644 index 0000000..2f98adf --- /dev/null +++ b/packaging/msix/.gitignore @@ -0,0 +1,4 @@ +# Generated by build-msix.ps1 / gen_msix_assets — reproducible from source. +/layout/ +/out/ +/Assets/ diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml new file mode 100644 index 0000000..a10377c --- /dev/null +++ b/packaging/msix/AppxManifest.xml @@ -0,0 +1,82 @@ + + + + + + + + TunnelDeck for Dev Tunnels + __PUBLISHER_DISPLAY_NAME__ + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/msix/build-msix.ps1 b/packaging/msix/build-msix.ps1 new file mode 100644 index 0000000..39fafda --- /dev/null +++ b/packaging/msix/build-msix.ps1 @@ -0,0 +1,159 @@ +<# +.SYNOPSIS + Builds the Microsoft Store (MSIX) package for TunnelDeck for Dev Tunnels. + +.DESCRIPTION + 1. Compiles the release executable with the `store` cargo feature (self-install, + update-checker and HKCU auto-start compiled out — the package owns those). + 2. Renders the visual assets from the procedural app icon (gen_msix_assets). + 3. Assembles a package layout, substituting the Partner Center identity values + into AppxManifest.xml. + 4. Packs it into an .msix with makeappx.exe from the Windows SDK. + 5. Optionally signs it with a local test certificate for sideload testing, and/or + runs the Windows App Certification Kit (WACK). + + The .msix you upload to Partner Center must be UNSIGNED (the Store re-signs it) — + so only pass -Sign when you want to install/test locally, and produce a separate + unsigned package for submission. + +.PARAMETER IdentityName + Package/Identity/Name from Partner Center (Product identity page). + +.PARAMETER PublisherId + Package/Identity/Publisher from Partner Center, e.g. "CN=1234ABCD-...". + +.PARAMETER PublisherDisplayName + The publisher display name from Partner Center. + +.PARAMETER Version + 4-part version a.b.c.0 (the 4th part must be 0 for the Store). Default 0.1.0.0. + +.PARAMETER Sign + Sign the package with -CertPath for local sideload testing. Do NOT submit a signed + package to the Store. + +.PARAMETER Wack + Run the Windows App Certification Kit against the built package after packing. + +.EXAMPLE + # Submission package (unsigned): + .\build-msix.ps1 -IdentityName 12345Publisher.TunnelDeck ` + -PublisherId "CN=ABCDEF01-2345-6789-ABCD-EF0123456789" ` + -PublisherDisplayName "Paulo Corcino" -Version 0.1.0.0 + +.EXAMPLE + # Local test package, self-signed and validated: + .\build-msix.ps1 -IdentityName 12345Publisher.TunnelDeck ` + -PublisherId "CN=Paulo Corcino" -PublisherDisplayName "Paulo Corcino" ` + -Sign -CertPath .\TunnelDeck-test.pfx -Wack +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $IdentityName, + [Parameter(Mandatory)] [string] $PublisherId, + [Parameter(Mandatory)] [string] $PublisherDisplayName, + [string] $Version = "0.1.0.0", + [switch] $Sign, + [string] $CertPath, + [string] $CertPassword, + [switch] $Wack +) + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..") +$layout = Join-Path $scriptDir "layout" +$outDir = Join-Path $scriptDir "out" +$msixPath = Join-Path $outDir "TunnelDeck-$Version.msix" + +if ($Version -notmatch '^\d+\.\d+\.\d+\.0$') { + throw "Version must be a.b.c.0 (the 4th part must be 0 for the Store); got '$Version'." +} + +# --- Locate the latest Windows SDK bin (makeappx, signtool, appcert) ---------- +function Find-SdkTool([string]$name) { + $roots = @("${env:ProgramFiles(x86)}\Windows Kits\10\bin", "${env:ProgramFiles}\Windows Kits\10\bin") + $found = foreach ($root in $roots) { + if (Test-Path $root) { + Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } + } + } + $tool = $found | Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $tool) { throw "$name not found. Install the Windows 10/11 SDK." } + return $tool.FullName +} + +$makeappx = Find-SdkTool "makeappx.exe" +Write-Host "makeappx: $makeappx" + +# --- 1. Build the store executable ------------------------------------------- +# The `store` feature pulls in `hosting` (Host button) — needs NASM + Strawberry +# Perl on PATH for the vendored-OpenSSL build (see docs/store/README.md). +Write-Host "`n[1/4] Building release executable (--features store)..." +Push-Location $repoRoot +try { + & cargo build --release --features store --bin devtunnel_gui + if ($LASTEXITCODE -ne 0) { throw "cargo build failed." } + + # --- 2. Render visual assets --------------------------------------------- + Write-Host "`n[2/4] Rendering MSIX assets..." + & cargo run --release --features store --bin gen_msix_assets -- (Join-Path $scriptDir "Assets") + if ($LASTEXITCODE -ne 0) { throw "asset generation failed." } +} +finally { Pop-Location } + +$exe = Join-Path $repoRoot "target\release\devtunnel_gui.exe" +if (-not (Test-Path $exe)) { throw "Built executable not found at $exe" } + +# --- 3. Assemble the package layout ------------------------------------------ +Write-Host "`n[3/4] Assembling package layout..." +if (Test-Path $layout) { Remove-Item $layout -Recurse -Force } +New-Item -ItemType Directory -Path $layout | Out-Null +New-Item -ItemType Directory -Path $outDir -Force | Out-Null + +Copy-Item $exe (Join-Path $layout "devtunnel_gui.exe") +Copy-Item (Join-Path $scriptDir "Assets") (Join-Path $layout "Assets") -Recurse + +# Substitute the Partner Center identity values into the manifest. +$manifest = Get-Content (Join-Path $scriptDir "AppxManifest.xml") -Raw +$manifest = $manifest.Replace("__IDENTITY_NAME__", $IdentityName) +$manifest = $manifest.Replace("__PUBLISHER_ID__", $PublisherId) +$manifest = $manifest.Replace("__PUBLISHER_DISPLAY_NAME__", $PublisherDisplayName) +$manifest = $manifest -replace 'Version="[\d.]+"', "Version=`"$Version`"" +Set-Content -Path (Join-Path $layout "AppxManifest.xml") -Value $manifest -Encoding UTF8 + +# --- 4. Pack ----------------------------------------------------------------- +Write-Host "`n[4/4] Packing $msixPath ..." +if (Test-Path $msixPath) { Remove-Item $msixPath -Force } +& $makeappx pack /d $layout /p $msixPath /o +if ($LASTEXITCODE -ne 0) { throw "makeappx pack failed." } +Write-Host "Package built: $msixPath" + +# --- Optional: sign for local sideload testing ------------------------------- +if ($Sign) { + if (-not $CertPath) { throw "-Sign requires -CertPath ." } + $signtool = Find-SdkTool "signtool.exe" + Write-Host "`nSigning (local test only — do NOT submit a signed package)..." + $args = @("sign", "/fd", "SHA256", "/a", "/f", $CertPath) + if ($CertPassword) { $args += @("/p", $CertPassword) } + $args += $msixPath + & $signtool @args + if ($LASTEXITCODE -ne 0) { throw "signtool failed. The cert's subject must equal Identity/@Publisher ($PublisherId)." } + Write-Host "Signed. Install locally with: Add-AppxPackage '$msixPath'" +} + +# --- Optional: Windows App Certification Kit --------------------------------- +if ($Wack) { + $appcert = Find-SdkTool "appcert.exe" + $report = Join-Path $outDir "wack-report.xml" + Write-Host "`nRunning Windows App Certification Kit (may take several minutes)..." + & $appcert reset + & $appcert test -appxpackagepath $msixPath -reportoutputpath $report + Write-Host "WACK report: $report" +} + +Write-Host "`nDone." +if (-not $Sign) { + Write-Host "Upload $msixPath to Partner Center (it must stay UNSIGNED for submission)." +} diff --git a/src/bin/gen_msix_assets.rs b/src/bin/gen_msix_assets.rs new file mode 100644 index 0000000..d932a2f --- /dev/null +++ b/src/bin/gen_msix_assets.rs @@ -0,0 +1,97 @@ +//! Generates the Microsoft Store (MSIX) visual assets from the procedural app +//! icon, so the package's `Assets\` folder is fully reproducible from source +//! (no binary blobs checked in). Reuses `icon_render.rs` — the same renderer that +//! drives the tray icon and the embedded executable icon — and encodes PNGs with +//! the `ico` crate's PNG writer. +//! +//! Usage: `cargo run --features store --bin gen_msix_assets -- ` +//! (defaults to `packaging/msix/Assets` when no argument is given). Invoked by +//! `packaging/msix/build-msix.ps1`. + +// Share the std-only procedural renderer with the crate (same include! the build +// script uses to encode the .ico). +include!("../icon_render.rs"); + +use std::path::{Path, PathBuf}; + +/// One MSIX asset: output filename and the square edge size to render at. +struct Square { + name: &'static str, + size: u32, +} + +/// Square logos the manifest references. Scale-100 baseline — enough for a valid +/// package and WACK pass; add scale-125/150/200/400 variants later for crisper +/// tiles on high-DPI displays (same names with a `.scale-200` infix). +const SQUARES: &[Square] = &[ + // App-list / taskbar / Start small icon. + Square { name: "Square44x44Logo.png", size: 44 }, + // Small tile. + Square { name: "Square71x71Logo.png", size: 71 }, + // Medium tile (required). + Square { name: "Square150x150Logo.png", size: 150 }, + // Large tile. + Square { name: "Square310x310Logo.png", size: 310 }, + // Store listing logo carried inside the package. + Square { name: "StoreLogo.png", size: 50 }, +]; + +/// Wide tile (310x150): the square mark centred on a transparent canvas. +const WIDE: (&str, u32, u32) = ("Wide310x150Logo.png", 310, 150); + +fn main() { + let out = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("packaging/msix/Assets")); + + if let Err(e) = std::fs::create_dir_all(&out) { + eprintln!("error: cannot create {}: {e}", out.display()); + std::process::exit(1); + } + + for sq in SQUARES { + let data = rgba(sq.size, IconVariant::Normal); + if let Err(e) = write_png(&out.join(sq.name), sq.size, sq.size, data) { + eprintln!("error: writing {}: {e}", sq.name); + std::process::exit(1); + } + println!(" {} ({}x{})", sq.name, sq.size, sq.size); + } + + let (name, w, h) = WIDE; + let data = wide_canvas(w, h); + if let Err(e) = write_png(&out.join(name), w, h, data) { + eprintln!("error: writing {name}: {e}"); + std::process::exit(1); + } + println!(" {name} ({w}x{h})"); + + println!("MSIX assets written to {}", out.display()); +} + +/// Builds a `w`x`h` RGBA canvas (transparent) with the square icon centred, +/// sized to the shorter edge. Used for the non-square wide tile. +fn wide_canvas(w: u32, h: u32) -> Vec { + let edge = w.min(h); + let icon = rgba(edge, IconVariant::Normal); + let ox = (w - edge) / 2; + let oy = (h - edge) / 2; + + let mut out = vec![0u8; (w * h * 4) as usize]; + for y in 0..edge { + for x in 0..edge { + let src = ((y * edge + x) * 4) as usize; + let dst = (((y + oy) * w + (x + ox)) * 4) as usize; + out[dst..dst + 4].copy_from_slice(&icon[src..src + 4]); + } + } + out +} + +/// Encodes straight RGBA8 pixels as a PNG file via the `ico` crate's writer. +fn write_png(path: &Path, w: u32, h: u32, rgba: Vec) -> std::io::Result<()> { + let image = ico::IconImage::from_rgba_data(w, h, rgba); + let file = std::fs::File::create(path)?; + image.write_png(file) +} diff --git a/src/main.rs b/src/main.rs index d2395dc..f005030 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1745,6 +1745,9 @@ fn build_tray_menu( /// Call once after constructing `AppWindow`, before showing the UI. fn apply_strings(app: &AppWindow, loc: &Locale) { let s = app.global::(); + // Store (MSIX) builds hide the self-install / uninstall / auto-start controls; + // the package manages those. Compile-time constant so it is stripped in each build. + s.set_store_build(cfg!(feature = "store")); s.set_status_loading(loc.t("status-loading").into()); s.set_status_refreshing(loc.t("status-refreshing").into()); s.set_btn_refresh(loc.t("btn-refresh").into()); diff --git a/src/update.rs b/src/update.rs index 48bda5b..4cb9596 100644 --- a/src/update.rs +++ b/src/update.rs @@ -9,22 +9,29 @@ //! on the next tick — they never surface to the user or block the UI. use std::sync::mpsc::Sender; +#[cfg(not(feature = "store"))] use std::time::Duration; /// GitHub Releases API for this repo's latest (non-prerelease) release. +#[cfg(not(feature = "store"))] const RELEASES_API: &str = "https://api.github.com/repos/paulocorcino/devtunnel_gui/releases/latest"; /// Public release page, used as the click target when the API omits `html_url`. +#[cfg(not(feature = "store"))] const RELEASES_PAGE: &str = "https://github.com/paulocorcino/devtunnel_gui/releases/latest"; /// How often to re-check after the initial startup check. The app is a tray /// app that can stay open for days, so a one-shot startup check could never /// fire for long-running instances. +#[cfg(not(feature = "store"))] const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); /// A release newer than the running build, pumped to the UI thread. #[derive(Clone, Debug)] +// In `store` builds the checker is a no-op, so the fields are never read; the +// type is still referenced by `spawn`'s channel signature. +#[cfg_attr(feature = "store", allow(dead_code))] pub struct UpdateInfo { /// The release tag, as published (e.g. `v0.2.0`). pub version: String, @@ -35,6 +42,14 @@ pub struct UpdateInfo { /// Spawns the background update checker. Sends an `UpdateInfo` on the channel /// whenever the latest release is newer than the running build, then sleeps /// until the next check. Stops when the receiver is dropped (UI shut down). +#[cfg(feature = "store")] +pub fn spawn(_tx: Sender) { + // Store (MSIX) builds are updated through the Microsoft Store, not GitHub + // Releases. Self-directed update prompts are against Store policy, so the + // checker is compiled out entirely — the banner never fires. +} + +#[cfg(not(feature = "store"))] pub fn spawn(tx: Sender) { // Test hook: force the banner without a live release. `DEVTUNNEL_FAKE_UPDATE` // is the tag to advertise (e.g. `v9.9.9`); the URL points at the releases @@ -64,6 +79,7 @@ pub fn spawn(tx: Sender) { /// Queries the GitHub API for the latest release. Returns `Ok(None)` when the /// response carries no usable tag. +#[cfg(not(feature = "store"))] fn check_latest() -> anyhow::Result> { let resp = ureq::get(RELEASES_API) // GitHub rejects requests without a User-Agent. @@ -94,6 +110,7 @@ fn check_latest() -> anyhow::Result> { /// (e.g. `v0.2.0`, `0.1.0+g05b8b3c-dirty`); only MAJOR.MINOR.PATCH is compared. /// Anything that cannot be parsed is treated as not-newer (fail closed), so a /// malformed tag never triggers a spurious "update available". +#[cfg(not(feature = "store"))] fn is_newer(candidate: &str, current: &str) -> bool { match (parse_semver(candidate), parse_semver(current)) { (Some(c), Some(cur)) => c > cur, @@ -104,6 +121,7 @@ fn is_newer(candidate: &str, current: &str) -> bool { /// Extracts `(major, minor, patch)` from a version string, ignoring a leading /// `v` and any `-`/`+` suffix. Missing minor/patch default to 0. Returns `None` /// if the numeric core is absent or non-numeric. +#[cfg(not(feature = "store"))] fn parse_semver(s: &str) -> Option<(u64, u64, u64)> { let core = s.trim().trim_start_matches(['v', 'V']); let core = core.split(['-', '+']).next().unwrap_or(""); @@ -114,7 +132,7 @@ fn parse_semver(s: &str) -> Option<(u64, u64, u64)> { Some((major, minor, patch)) } -#[cfg(test)] +#[cfg(all(test, not(feature = "store")))] mod tests { use super::*; diff --git a/ui/settings.slint b/ui/settings.slint index cef0197..dd19a81 100644 --- a/ui/settings.slint +++ b/ui/settings.slint @@ -217,7 +217,9 @@ export component SettingsDialog inherits Rectangle { // -- General -- if root.section == 0 : VerticalLayout { spacing: Theme.gap; - Check { + // Auto-start is managed by the MSIX package (windows.startupTask, + // toggled in Windows Settings) in Store builds, so hide this toggle there. + if !Strings.store-build: Check { text: Strings.field-auto-start; checked <=> root.auto-start; toggled(on) => { @@ -269,7 +271,10 @@ export component SettingsDialog inherits Rectangle { ReqRow { ok: root.req-cli-ok; label: Strings.req-cli; - if !root.req-cli-ok: TxtButton { + // The winget-based installer can't run from the MSIX sandbox + // (and invoking external installers is against Store policy), + // so Store builds omit the button; the row still flags the CLI. + if !root.req-cli-ok && !Strings.store-build: TxtButton { text: root.installing ? Strings.install-status-running : Strings.btn-install-cli; enabled: !root.installing; clicked => { @@ -289,30 +294,33 @@ export component SettingsDialog inherits Rectangle { } } } - ReqRow { + // Install / shortcut / auto-start state and the uninstall action + // only apply to the portable build. In Store builds the MSIX + // package owns install and removal, so hide this whole block. + if !Strings.store-build: ReqRow { ok: root.req-installed-ok; label: Strings.req-installed; } - ReqRow { + if !Strings.store-build: ReqRow { ok: root.req-shortcut-ok; label: Strings.req-shortcut; } - ReqRow { + if !Strings.store-build: ReqRow { ok: root.req-autostart-ok; label: Strings.req-autostart; } - Text { + if !Strings.store-build: Text { text: Strings.req-install-hint; color: Theme.muted; font-size: Theme.fs-caption; wrap: word-wrap; } // Danger zone: uninstall (only meaningful once installed). - if root.req-installed-ok: Rectangle { + if !Strings.store-build && root.req-installed-ok: Rectangle { height: 1px; background: Theme.border; } - if root.req-installed-ok: HorizontalLayout { + if !Strings.store-build && root.req-installed-ok: HorizontalLayout { alignment: start; TxtButton { text: Strings.btn-uninstall; diff --git a/ui/strings.slint b/ui/strings.slint index 19b58b2..b519836 100644 --- a/ui/strings.slint +++ b/ui/strings.slint @@ -2,6 +2,12 @@ // app.global::().set_*(...). Default values are English fallbacks // so the UI renders correctly even before the locale is applied. export global Strings { + // True in Microsoft Store (MSIX) builds. Hides the self-install, uninstall, + // and HKCU auto-start controls, which the package manages itself (auto-start + // is declared via the manifest's windows.startupTask and toggled in Windows + // Settings > Startup apps). Set from Rust; defaults false for the portable build. + in property store-build: false; + // Status bar in property status-loading: "loading…"; in property status-refreshing: "refreshing…"; @@ -75,7 +81,7 @@ export global Strings { in property badge-hosting: "Hosting…"; // Top bar (redesign) - in property app-title: "Dev Tunnels"; + in property app-title: "Dev Tunnels Manager"; in property pill-connected: "Connected"; in property tooltip-settings: "Toggle dark mode"; @@ -132,7 +138,7 @@ export global Strings { // About in property about-title: "About"; - in property about-app-name: "Dev Tunnels GUI"; + in property about-app-name: "Dev Tunnels Manager"; in property about-version-label: "Version"; in property about-tagline: "Manage Microsoft Dev Tunnels from your Windows tray."; in property about-built-on: "Built on Microsoft Dev Tunnels — Microsoft's free, security-focused tunneling service — and its official CLI and SDK. Not affiliated with or endorsed by Microsoft."; From 2bb2c538b7d2c97e55a46fef87a054c36cdaaa21 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:50:21 -0300 Subject: [PATCH 09/17] docs(store): add Microsoft Store packaging, listing, and runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: end-to-end publishing runbook (Partner Center account, name reservation, identity, build, WACK, submission). - listing.md: Store listing copy — name, description, features, search terms, category, screenshots, and IARC age-rating guidance. - privacy-policy.md: privacy policy to publish and link (required by the Store). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/store/README.md | 120 +++++++++++++++++++++++++ docs/store/listing.md | 165 +++++++++++++++++++++++++++++++++++ docs/store/privacy-policy.md | 62 +++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 docs/store/README.md create mode 100644 docs/store/listing.md create mode 100644 docs/store/privacy-policy.md diff --git a/docs/store/README.md b/docs/store/README.md new file mode 100644 index 0000000..ef47cfb --- /dev/null +++ b/docs/store/README.md @@ -0,0 +1,120 @@ +# Publishing to the Microsoft Store + +End-to-end runbook for shipping **TunnelDeck for Dev Tunnels** to the Microsoft +Store as an MSIX package. Work top to bottom; each step links to the artifact that +implements it. + +| Artifact | Purpose | +|---|---| +| `store` cargo feature | Compiles out self-install, the GitHub update checker, and the HKCU auto-start (all MSIX-incompatible / against policy). Auto-start moves to the manifest. | +| `packaging/msix/AppxManifest.xml` | Package manifest: identity placeholders, full-trust app, `windows.startupTask`. | +| `packaging/msix/build-msix.ps1` | Builds the exe, renders assets, packs the `.msix`, optional sign + WACK. | +| `src/bin/gen_msix_assets.rs` | Renders the tile/logo PNGs from the app icon (run by the script). | +| `docs/store/listing.md` | Store listing copy: name, description, features, keywords, screenshots, age rating. | +| `docs/store/privacy-policy.md` | Privacy policy to publish and link (required). | + +--- + +## Step 1 — Partner Center account & app name + +1. Create a **Microsoft Partner Center** developer account (one-time fee: ~US$19 + individual / US$99 company): https://partner.microsoft.com/dashboard/registration +2. **Apps and games → New product → MSIX or PWA app.** +3. **Reserve the name** `TunnelDeck for Dev Tunnels`. + - The ` for Dev Tunnels` form is used deliberately: it avoids a + trademark rejection for leading with Microsoft's product name. Do **not** + reserve just "Dev Tunnels …". +4. Open **Product → Product identity** and copy these three values — you'll pass + them to `build-msix.ps1`: + - **Package/Identity/Name** → `-IdentityName` + - **Package/Identity/Publisher** (`CN=…`) → `-PublisherId` + - **Publisher display name** → `-PublisherDisplayName` + +## Step 2 — Build the store executable + +The `store` feature strips the MSIX-incompatible bits and **pulls in `hosting`** +(the Host button is core to the product). That builds the `tunnels` SDK + vendored +OpenSSL, which needs **NASM** and **Strawberry Perl** on `PATH` (see the repo +`CLAUDE.md`). On this machine, prepend before building: + +```powershell +$env:PATH = "C:\Strawberry\perl\bin;C:\Strawberry\c\bin;C:\Users\PICHAU\AppData\Local\bin\NASM;$env:PATH" +cargo build --release --features store --bin devtunnel_gui +``` + +`build-msix.ps1` runs this for you. + +## Step 3 — Fill in the manifest identity & package + +`build-msix.ps1` does everything: builds the exe, renders `Assets\`, substitutes +the identity into the manifest, and packs the `.msix`. + +```powershell +cd packaging\msix +.\build-msix.ps1 ` + -IdentityName "" ` + -PublisherId "" ` + -PublisherDisplayName "" ` + -Version 0.1.0.0 +``` + +Output: `packaging\msix\out\TunnelDeck-0.1.0.0.msix` (**unsigned** — correct for +submission; the Store re-signs it). + +## Step 4 — Test locally + certify (WACK) + +The submission package is unsigned, but to **install and test locally** you need a +self-signed cert whose subject exactly equals `Identity/@Publisher`: + +```powershell +# One-time: create a test cert (subject must match your -PublisherId) +$cert = New-SelfSignedCertificate -Type Custom -Subject "CN=Paulo Corcino" ` + -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" ` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") +Export-PfxCertificate -Cert $cert -FilePath .\TunnelDeck-test.pfx ` + -Password (ConvertTo-SecureString -String "test" -Force -AsPlainText) + +# Build a signed test package and run the certification kit: +.\build-msix.ps1 -IdentityName "<...>" -PublisherId "CN=Paulo Corcino" ` + -PublisherDisplayName "Paulo Corcino" ` + -Sign -CertPath .\TunnelDeck-test.pfx -CertPassword test -Wack + +# Install it: +Add-AppxPackage .\out\TunnelDeck-0.1.0.0.msix +``` + +Fix any **WACK** failures before submitting. Then rebuild **without** `-Sign` to +produce the clean unsigned package for upload. + +Smoke-test the installed app: +- Launches to the tray; window opens; tunnels list loads. +- Settings → General shows **no** "Start with Windows" toggle (managed by the + package). Settings → Status shows **no** install/uninstall rows. +- No "update available" banner appears (checker compiled out). +- Enable auto-start via **Windows Settings → Apps → Startup** and confirm it + launches at logon. + +## Step 5 — Create the submission + +In Partner Center, on the reserved product: + +1. **Packages** — upload the unsigned `.msix`. Set device family to **Desktop**. +2. **Store listing** — paste everything from [`listing.md`](listing.md): + name, short + full description, features, search terms, category + (Developer tools), copyright, support email, and screenshots (≥ 1, 1366×768+). +3. **Privacy policy URL** — publish [`privacy-policy.md`](privacy-policy.md) + somewhere public (e.g. GitHub Pages / the repo) and paste the URL. Required. +4. **Age ratings** — complete the IARC questionnaire (see `listing.md`; expected + result: Everyone / PEGI 3). +5. **Pricing and availability** — Free; pick markets. +6. **Submit for certification.** Microsoft's automated + manual review typically + takes hours to a couple of days. If rejected, the report says why — the most + likely notes here are name/trademark or the CLI dependency; address and + resubmit. + +## Recurring: shipping an update + +1. Bump the version (e.g. `-Version 0.2.0.0`; the 4th part must stay `0`). +2. Re-run `build-msix.ps1`, re-test, upload the new unsigned `.msix`. +3. Update **What's new** and submit. The Store delivers the update to users; the + in-app updater stays disabled in this build by design. diff --git a/docs/store/listing.md b/docs/store/listing.md new file mode 100644 index 0000000..4628721 --- /dev/null +++ b/docs/store/listing.md @@ -0,0 +1,165 @@ +# Microsoft Store listing — TunnelDeck for Dev Tunnels + +Copy-paste source for the Partner Center **Store listing** page. All text is in +English (project rule). Character limits are Microsoft's current maximums. + +--- + +## App name (reserved in Partner Center) + +``` +TunnelDeck for Dev Tunnels +``` + +> Uses the ` for Dev Tunnels` pattern so the Store review accepts it: it +> names your independent product first and references the Microsoft service it +> builds on, without implying Microsoft authorship. Keep the in-app "About" +> disclaimer ("Not affiliated with or endorsed by Microsoft"). + +## Short description / subtitle (≤ 100 chars) + +``` +Turn localhost into a secure public HTTPS URL in one click — right from your Windows tray. +``` + +## Description (≤ 10,000 chars) + +``` +Share what you're building — instantly. + +TunnelDeck puts a public, secure HTTPS URL in front of any service running on +your machine, in a single click. Start your local app, pick the port, and hand +a working link to a teammate, a client, or a webhook — no firewall rules, no +router setup, no config files. + +It lives quietly in your Windows tray and stays out of your way until you need +it. When you do, it's a click: create a tunnel, copy the link, and go. + +WHY YOU'LL LIKE IT + +• One click to public — expose a local port as a live HTTPS URL and copy it to + your clipboard, ready to paste anywhere. +• Built for demos and testing — show work-in-progress to anyone, anywhere, + without deploying first. +• Test webhooks the easy way — give Stripe, GitHub, Twilio, or any provider a + reachable endpoint that points straight at your dev machine. +• Cross-device previews — open your site on a phone, tablet, or a colleague's + laptop from the same secure link. +• Keep it alive — TunnelDeck keeps your tunnel running and reconnects for you, + so the link keeps working while you work. +• Private by default — tunnels are authenticated unless you choose to make them + public, so only the people you want can reach your machine. +• Stays tidy — a clean tray app with a focused window. No dashboards to learn, + no clutter. + +HOW IT WORKS + +TunnelDeck is a friendly desktop front end for Microsoft Dev Tunnels — the same +free, security-focused tunneling service used across Visual Studio and VS Code. +Your traffic runs over Microsoft's infrastructure; TunnelDeck just makes it +effortless to create, name, share, and keep tunnels alive from Windows. + +You sign in with your own Microsoft, Entra ID, or GitHub account — the identity +Dev Tunnels already uses — and your tunnels are yours. + +GOOD TO KNOW + +• Requires the free Microsoft Dev Tunnels CLI (devtunnel). If it isn't already + on your machine, TunnelDeck points you to the one-line install. +• No inbound ports are opened on your machine. Traffic flows outbound over + HTTPS only. +• Windows tray app. The Dev Tunnels service itself is free. + +TunnelDeck is an independent client built on top of the official Microsoft Dev +Tunnels service. It is not affiliated with, sponsored by, or endorsed by +Microsoft. +``` + +## Product features (Partner Center "Features", ≤ 20 items, ≤ 200 chars each) + +``` +One click from localhost to a secure public HTTPS URL +Copy-ready links for demos, client previews, and cross-device testing +Point webhooks (Stripe, GitHub, Twilio, …) straight at your dev machine +Keeps tunnels alive and reconnects automatically +Authenticated by default — you decide what's public +Lightweight Windows tray app, no dashboard to learn +Sign in with your own Microsoft, Entra ID, or GitHub account +Outbound HTTPS only — no inbound ports opened +``` + +## Search terms (Partner Center, ≤ 7 terms, ≤ 30 chars each — not shown to users) + +``` +tunnel +localhost +dev tunnel +public url +webhook testing +share localhost +reverse proxy +``` + +## Category + +``` +Developer tools +``` + +(Sub-category: Development kits, or Utilities & tools.) + +## Copyright / additional info + +- **Copyright:** `© 2026 Paulo Corcino` +- **Website:** your GitHub repo or project page (e.g. https://github.com/paulocorcino/devtunnel_gui) +- **Support contact:** paulo@corcino.com.br +- **Privacy policy URL:** required — publish `docs/store/privacy-policy.md` (see below) and paste its public URL. + +## What's new in this version (release notes) + +``` +First Microsoft Store release of TunnelDeck for Dev Tunnels. Create, share, and +keep Microsoft Dev Tunnels alive from your Windows tray. +``` + +--- + +## Screenshots (required: at least 1; recommended 3–5) + +Store requirements for desktop: PNG, **1366 × 768** or larger, 16:9 preferred. +Capture from the running app (light and/or dark theme): + +1. Main window with a couple of tunnels, one showing a live public URL. +2. Creating a tunnel / adding a port. +3. The tray icon + menu. +4. Settings (General) — probe interval, default expiration, log level. +5. About panel (shows the Microsoft attribution + disclaimer). + +Tip: capture at 1920 × 1080 for crisp thumbnails. Store store-side scaling handles +the rest. Add a one-line caption per screenshot in Partner Center. + +## Age rating (IARC questionnaire) + +TunnelDeck is a developer utility with no in-app content, ads, purchases, or +user-generated content that the app itself hosts. Expected answers: + +- Contains violence / sexual / profanity / controlled substances: **No** to all. +- Users can interact / share content / exchange location or personal info: **No** + (the app creates network tunnels for the user's own services; it is not a + social or communication platform). +- Collects/shares personal data for advertising: **No**. + +Expected outcome: **Everyone / PEGI 3 / ESRB Everyone**. Answer the questionnaire +truthfully in Partner Center; IARC assigns the rating automatically. + +## Store submission checklist + +- [ ] App name reserved (`TunnelDeck for Dev Tunnels`). +- [ ] Package identity values copied into the manifest via build-msix.ps1. +- [ ] **Unsigned** .msix uploaded (Store re-signs; a signed package is rejected). +- [ ] WACK passed locally. +- [ ] Description, features, search terms, category filled in. +- [ ] ≥ 1 screenshot (1366×768+). +- [ ] Privacy policy URL live and reachable. +- [ ] Age rating questionnaire completed. +- [ ] Support email + copyright set. diff --git a/docs/store/privacy-policy.md b/docs/store/privacy-policy.md new file mode 100644 index 0000000..7289d6b --- /dev/null +++ b/docs/store/privacy-policy.md @@ -0,0 +1,62 @@ +# Privacy Policy — TunnelDeck for Dev Tunnels + +_Last updated: 2026-07-06_ + +TunnelDeck for Dev Tunnels ("TunnelDeck", "the app") is a Windows desktop client +for Microsoft Dev Tunnels, published by Paulo Corcino ("we", "us"). This policy +explains what the app does and does not do with your data. + +## Summary + +**We do not collect, store, or transmit any personal data to us.** TunnelDeck has +no analytics, no advertising, and no developer-operated servers. It runs entirely +on your machine and talks only to Microsoft's services on your behalf. + +## What the app does + +- **Sign-in and tunnels.** TunnelDeck uses the official Microsoft Dev Tunnels CLI + and SDK to sign you in and to create, list, host, and delete tunnels. You + authenticate directly with Microsoft (Microsoft account, Microsoft Entra ID, or + GitHub). Your credentials and tokens are handled by Microsoft's tooling and are + never sent to us. +- **Local settings.** Your preferences (such as default expiration, log level, + and probe interval) are stored locally on your computer. They never leave your + machine. +- **Network traffic.** When you host a tunnel, traffic between the public URL and + your local service transits Microsoft's Dev Tunnels infrastructure, subject to + Microsoft's own terms and privacy practices. TunnelDeck does not intercept, + log, or forward that traffic to us. + +## Data we collect + +None. TunnelDeck contains no telemetry, crash reporting, analytics, or +advertising SDKs. We operate no servers that receive data from the app. + +## Third-party services + +- **Microsoft Dev Tunnels** — the tunneling service the app is built on. Your use + of it is governed by Microsoft's terms and privacy statement: + https://learn.microsoft.com/azure/developer/dev-tunnels/security +- **Microsoft Store** — handles app distribution and updates, and may collect + usage and diagnostic data under Microsoft's privacy statement, independently of + this app. + +## Your responsibility + +Tunnels you create expose a service running on your computer to the internet. +What that service returns is under your control. Keep tunnels private and +short-lived unless you intend them to be public. + +## Children's privacy + +TunnelDeck is a developer tool and is not directed at children. It collects no +personal information from anyone. + +## Changes + +We may update this policy; the "Last updated" date above reflects the current +version. + +## Contact + +Questions about this policy: paulo@corcino.com.br From 2964a083f358de2bef6b864f1500834e75214935 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:03:01 -0300 Subject: [PATCH 10/17] feat(store): add Pages privacy site, screenshot helper, and .env-driven packaging - Deploy Pages workflow + standalone privacy policy (docs/store/site/index.html) served at https://paulocorcino.github.io/devtunnel_gui/ for the required Store privacy-policy URL. - capture-screenshots.ps1: grabs the app window and composes it on a 1920x1080 canvas for Store screenshots (rejects the tray-sized window with guidance). - build-msix.ps1 now loads the Partner Center identity from a gitignored .env (.env.example template); identity params are optional and override the file. - Fix: use case-sensitive -creplace for the Identity Version so it no longer corrupts the declaration (makeappx validation error). Validated end-to-end: build -> assets -> pack -> sign produces a signed .msix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pages.yml | 39 +++++++ docs/store/README.md | 21 ++-- docs/store/listing.md | 17 ++- docs/store/screenshots/.gitignore | 2 + docs/store/site/index.html | 112 +++++++++++++++++++ packaging/msix/.env.example | 26 +++++ packaging/msix/.gitignore | 4 + packaging/msix/build-msix.ps1 | 50 +++++++-- packaging/msix/capture-screenshots.ps1 | 142 +++++++++++++++++++++++++ 9 files changed, 394 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/store/screenshots/.gitignore create mode 100644 docs/store/site/index.html create mode 100644 packaging/msix/.env.example create mode 100644 packaging/msix/capture-screenshots.ps1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..a09a09a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,39 @@ +# Publishes the privacy policy (and any other static pages under docs/store/site) +# to GitHub Pages. One-time setup: repo Settings > Pages > Source = "GitHub Actions". +# After that, this deploys on every push to main that touches the site folder. +# +# Public URL: https://paulocorcino.github.io/devtunnel_gui/ +name: Deploy Pages + +on: + push: + branches: [main] + paths: + - "docs/store/site/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress run. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/store/site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/store/README.md b/docs/store/README.md index ef47cfb..ff1b196 100644 --- a/docs/store/README.md +++ b/docs/store/README.md @@ -46,18 +46,19 @@ cargo build --release --features store --bin devtunnel_gui ## Step 3 — Fill in the manifest identity & package -`build-msix.ps1` does everything: builds the exe, renders `Assets\`, substitutes -the identity into the manifest, and packs the `.msix`. +Put the three Partner Center identity values into a `.env` file (gitignored), then +run the script with no arguments. `build-msix.ps1` builds the exe, renders +`Assets\`, substitutes the identity into the manifest, and packs the `.msix`. ```powershell cd packaging\msix -.\build-msix.ps1 ` - -IdentityName "" ` - -PublisherId "" ` - -PublisherDisplayName "" ` - -Version 0.1.0.0 +Copy-Item .env.example .env +notepad .env # fill IDENTITY_NAME, PUBLISHER_ID, PUBLISHER_DISPLAY_NAME +.\build-msix.ps1 ``` +(You can still override any value on the command line, e.g. `-Version 0.2.0.0`.) + Output: `packaging\msix\out\TunnelDeck-0.1.0.0.msix` (**unsigned** — correct for submission; the Store re-signs it). @@ -102,8 +103,10 @@ In Partner Center, on the reserved product: 2. **Store listing** — paste everything from [`listing.md`](listing.md): name, short + full description, features, search terms, category (Developer tools), copyright, support email, and screenshots (≥ 1, 1366×768+). -3. **Privacy policy URL** — publish [`privacy-policy.md`](privacy-policy.md) - somewhere public (e.g. GitHub Pages / the repo) and paste the URL. Required. +3. **Privacy policy URL** — `https://paulocorcino.github.io/devtunnel_gui/`. + The `Deploy Pages` workflow publishes [`site/index.html`](site/index.html) + (mirror of [`privacy-policy.md`](privacy-policy.md)). One-time: repo + **Settings → Pages → Source = GitHub Actions**. Required field. 4. **Age ratings** — complete the IARC questionnaire (see `listing.md`; expected result: Everyone / PEGI 3). 5. **Pricing and availability** — Free; pick markets. diff --git a/docs/store/listing.md b/docs/store/listing.md index 4628721..e86dad8 100644 --- a/docs/store/listing.md +++ b/docs/store/listing.md @@ -113,7 +113,9 @@ Developer tools - **Copyright:** `© 2026 Paulo Corcino` - **Website:** your GitHub repo or project page (e.g. https://github.com/paulocorcino/devtunnel_gui) - **Support contact:** paulo@corcino.com.br -- **Privacy policy URL:** required — publish `docs/store/privacy-policy.md` (see below) and paste its public URL. +- **Privacy policy URL:** `https://paulocorcino.github.io/devtunnel_gui/` — served + from `docs/store/site/index.html` by the `Deploy Pages` workflow (enable Pages = + GitHub Actions once). Required field. ## What's new in this version (release notes) @@ -135,8 +137,17 @@ Capture from the running app (light and/or dark theme): 4. Settings (General) — probe interval, default expiration, log level. 5. About panel (shows the Microsoft attribution + disclaimer). -Tip: capture at 1920 × 1080 for crisp thumbnails. Store store-side scaling handles -the rest. Add a one-line caption per screenshot in Partner Center. +Use the helper (from your signed-in session, with the window open — the app +starts in the tray, so click the tray icon first): + +```powershell +packaging\msix\capture-screenshots.ps1 -Name 01-main +packaging\msix\capture-screenshots.ps1 -Name 02-create +packaging\msix\capture-screenshots.ps1 -Name 03-settings +``` + +It composes the window, centred, on a 1920×1080 indigo canvas and writes to +`docs/store/screenshots/`. Add a one-line caption per screenshot in Partner Center. ## Age rating (IARC questionnaire) diff --git a/docs/store/screenshots/.gitignore b/docs/store/screenshots/.gitignore new file mode 100644 index 0000000..84ba93e --- /dev/null +++ b/docs/store/screenshots/.gitignore @@ -0,0 +1,2 @@ +# Generated locally per session; not versioned. +*.png diff --git a/docs/store/site/index.html b/docs/store/site/index.html new file mode 100644 index 0000000..c4a26ba --- /dev/null +++ b/docs/store/site/index.html @@ -0,0 +1,112 @@ + + + + + + Privacy Policy — TunnelDeck for Dev Tunnels + + + + +
+
+

Privacy Policy

+
TunnelDeck for Dev Tunnels · Last updated: 2026-07-06
+
+ +

TunnelDeck for Dev Tunnels (“TunnelDeck”, “the app”) + is a Windows desktop client for Microsoft Dev Tunnels, published by + Paulo Corcino (“we”, “us”). This policy explains what + the app does and does not do with your data.

+ +
+ Summary. We do not collect, store, or transmit any personal + data to us. TunnelDeck has no analytics, no advertising, and no + developer-operated servers. It runs entirely on your machine and talks only + to Microsoft’s services on your behalf. +
+ +

What the app does

+
    +
  • Sign-in and tunnels. TunnelDeck uses the official + Microsoft Dev Tunnels CLI and SDK to sign you in and to create, list, host, + and delete tunnels. You authenticate directly with Microsoft (Microsoft + account, Microsoft Entra ID, or GitHub). Your credentials and tokens are + handled by Microsoft’s tooling and are never sent to us.
  • +
  • Local settings. Your preferences (such as default + expiration, log level, and probe interval) are stored locally on your + computer. They never leave your machine.
  • +
  • Network traffic. When you host a tunnel, traffic between + the public URL and your local service transits Microsoft’s Dev Tunnels + infrastructure, subject to Microsoft’s own terms and privacy practices. + TunnelDeck does not intercept, log, or forward that traffic to us.
  • +
+ +

Data we collect

+

None. TunnelDeck contains no telemetry, crash reporting, analytics, or + advertising SDKs. We operate no servers that receive data from the app.

+ +

Third-party services

+
    +
  • Microsoft Dev Tunnels — the tunneling service the app is + built on. Your use of it is governed by Microsoft’s terms and privacy + statement: + learn.microsoft.com/azure/developer/dev-tunnels/security.
  • +
  • Microsoft Store — handles app distribution and updates, + and may collect usage and diagnostic data under Microsoft’s privacy + statement, independently of this app.
  • +
+ +

Your responsibility

+

Tunnels you create expose a service running on your computer to the + internet. What that service returns is under your control. Keep tunnels private + and short-lived unless you intend them to be public.

+ +

Children’s privacy

+

TunnelDeck is a developer tool and is not directed at children. It collects + no personal information from anyone.

+ +

Changes

+

We may update this policy; the “Last updated” date above reflects + the current version.

+ +

Contact

+

Questions about this policy: paulo@corcino.com.br

+ +
+ TunnelDeck is an independent client built on top of the official Microsoft + Dev Tunnels service. It is not affiliated with, sponsored by, or endorsed by + Microsoft. +
+
+ + diff --git a/packaging/msix/.env.example b/packaging/msix/.env.example new file mode 100644 index 0000000..99a99fd --- /dev/null +++ b/packaging/msix/.env.example @@ -0,0 +1,26 @@ +# TunnelDeck — Microsoft Store package identity. +# +# Copy this file to `.env` in the same folder and fill in the values from +# Partner Center (Product > Product identity). Then just run: +# +# .\build-msix.ps1 +# +# `.env` is gitignored — your identity values are never committed. + +# Package/Identity/Name (e.g. 12345Publisher.TunnelDeckforDevTunnels) +IDENTITY_NAME= + +# Package/Identity/Publisher (the full CN=... string, e.g. CN=ABCDEF01-2345-6789-ABCD-EF0123456789) +PUBLISHER_ID= + +# Publisher display name (e.g. Paulo Corcino) +PUBLISHER_DISPLAY_NAME= + +# 4-part version; the 4th part MUST be 0 for the Store. Defaults to 0.1.0.0. +VERSION=0.1.0.0 + +# --- Local sideload testing only (optional) ---------------------------------- +# Path to a self-signed .pfx whose subject equals PUBLISHER_ID above, used with +# -Sign. Leave blank for the (unsigned) submission package. See docs/store/README.md. +CERT_PATH= +CERT_PASSWORD= diff --git a/packaging/msix/.gitignore b/packaging/msix/.gitignore index 2f98adf..17fed1b 100644 --- a/packaging/msix/.gitignore +++ b/packaging/msix/.gitignore @@ -2,3 +2,7 @@ /layout/ /out/ /Assets/ + +# Local identity + test certs — never commit. +.env +*.pfx diff --git a/packaging/msix/build-msix.ps1 b/packaging/msix/build-msix.ps1 index 39fafda..631520e 100644 --- a/packaging/msix/build-msix.ps1 +++ b/packaging/msix/build-msix.ps1 @@ -49,14 +49,17 @@ #> [CmdletBinding()] param( - [Parameter(Mandatory)] [string] $IdentityName, - [Parameter(Mandatory)] [string] $PublisherId, - [Parameter(Mandatory)] [string] $PublisherDisplayName, - [string] $Version = "0.1.0.0", + [string] $IdentityName, + [string] $PublisherId, + [string] $PublisherDisplayName, + [string] $Version, [switch] $Sign, [string] $CertPath, [string] $CertPassword, - [switch] $Wack + [switch] $Wack, + # .env file with the Partner Center identity values. Any parameter you pass + # explicitly wins over the file. + [string] $EnvFile ) $ErrorActionPreference = "Stop" @@ -64,7 +67,38 @@ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Resolve-Path (Join-Path $scriptDir "..\..") $layout = Join-Path $scriptDir "layout" $outDir = Join-Path $scriptDir "out" -$msixPath = Join-Path $outDir "TunnelDeck-$Version.msix" + +# --- Load identity from .env (parameters passed explicitly take precedence) ---- +# Fill in packaging\msix\.env (copy from .env.example) so you can just run +# `.\build-msix.ps1` with no arguments. +if (-not $EnvFile) { $EnvFile = Join-Path $scriptDir ".env" } +$envMap = @{} +if (Test-Path $EnvFile) { + Write-Host "Loading identity from $EnvFile" + foreach ($line in Get-Content $EnvFile) { + $t = $line.Trim() + if ($t -eq "" -or $t.StartsWith("#")) { continue } + $kv = $t -split '=', 2 + if ($kv.Count -eq 2) { $envMap[$kv[0].Trim()] = $kv[1].Trim().Trim('"') } + } +} +if (-not $IdentityName) { $IdentityName = $envMap["IDENTITY_NAME"] } +if (-not $PublisherId) { $PublisherId = $envMap["PUBLISHER_ID"] } +if (-not $PublisherDisplayName) { $PublisherDisplayName = $envMap["PUBLISHER_DISPLAY_NAME"] } +if (-not $Version) { $Version = $envMap["VERSION"] } +if (-not $CertPath) { $CertPath = $envMap["CERT_PATH"] } +if (-not $CertPassword) { $CertPassword = $envMap["CERT_PASSWORD"] } +if (-not $Version) { $Version = "0.1.0.0" } + +$missing = @() +if (-not $IdentityName) { $missing += "IdentityName / IDENTITY_NAME" } +if (-not $PublisherId) { $missing += "PublisherId / PUBLISHER_ID" } +if (-not $PublisherDisplayName) { $missing += "PublisherDisplayName / PUBLISHER_DISPLAY_NAME" } +if ($missing.Count -gt 0) { + throw "Missing identity value(s): $($missing -join ', '). Set them in $EnvFile (copy .env.example) or pass as parameters. Get them from Partner Center > Product identity." +} + +$msixPath = Join-Path $outDir "TunnelDeck-$Version.msix" if ($Version -notmatch '^\d+\.\d+\.\d+\.0$') { throw "Version must be a.b.c.0 (the 4th part must be 0 for the Store); got '$Version'." @@ -120,7 +154,9 @@ $manifest = Get-Content (Join-Path $scriptDir "AppxManifest.xml") -Raw $manifest = $manifest.Replace("__IDENTITY_NAME__", $IdentityName) $manifest = $manifest.Replace("__PUBLISHER_ID__", $PublisherId) $manifest = $manifest.Replace("__PUBLISHER_DISPLAY_NAME__", $PublisherDisplayName) -$manifest = $manifest -replace 'Version="[\d.]+"', "Version=`"$Version`"" +# Case-sensitive so it targets Identity's Version="..." and NOT the lowercase +# version="1.0" in the declaration. +$manifest = $manifest -creplace 'Version="[\d.]+"', "Version=`"$Version`"" Set-Content -Path (Join-Path $layout "AppxManifest.xml") -Value $manifest -Encoding UTF8 # --- 4. Pack ----------------------------------------------------------------- diff --git a/packaging/msix/capture-screenshots.ps1 b/packaging/msix/capture-screenshots.ps1 new file mode 100644 index 0000000..4c55c7a --- /dev/null +++ b/packaging/msix/capture-screenshots.ps1 @@ -0,0 +1,142 @@ +<# +.SYNOPSIS + Captures the app window for Microsoft Store screenshots. + +.DESCRIPTION + Grabs the TunnelDeck window and composes it, centred, on a 16:9 canvas with a + soft indigo backdrop — the format the Store expects (min 1366x768; this defaults + to 1920x1080). Saves both the raw window PNG and the composed canvas PNG to + docs/store/screenshots/. + + Run this from your signed-in session, once per state you want to show (main + list, creating a tunnel, settings, about), passing a distinct -Name each time: + + .\capture-screenshots.ps1 -Name 01-main + .\capture-screenshots.ps1 -Name 02-settings + + Use -Launch to start the app first (otherwise it captures the already-running + instance). The window must be visible and not minimized to the tray. + +.PARAMETER Name + Base filename (no extension) for this capture. + +.PARAMETER Launch + Start the executable and wait for its window before capturing. + +.PARAMETER Exe + Path to the executable. Defaults to target\release\devtunnel_gui.exe. + +.PARAMETER CanvasWidth / CanvasHeight + Composed canvas size. Default 1920x1080 (16:9). Store minimum is 1366x768. +#> +[CmdletBinding()] +param( + [string] $Name = "01-main", + [switch] $Launch, + [string] $Exe, + [int] $CanvasWidth = 1920, + [int] $CanvasHeight = 1080 +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..") +if (-not $Exe) { $Exe = Join-Path $repoRoot "target\release\devtunnel_gui.exe" } +$outDir = Join-Path $repoRoot "docs\store\screenshots" +New-Item -ItemType Directory -Path $outDir -Force | Out-Null + +# --- Win32 interop: GetWindowRect + SetForegroundWindow ----------------------- +if (-not ("Win32Native" -as [type])) { + Add-Type @" +using System; +using System.Runtime.InteropServices; +public struct RECT { public int Left, Top, Right, Bottom; } +public static class Win32Native { + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int n); + [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int v); +} +"@ + # Capture in physical pixels so the window isn't scaled/blurred on high DPI. + try { [Win32Native]::SetProcessDpiAwareness(2) | Out-Null } catch {} +} + +# --- Find (or launch) the app window ----------------------------------------- +function Get-AppProcess { + Get-Process -Name "devtunnel_gui" -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 +} + +if ($Launch) { + if (-not (Test-Path $Exe)) { throw "Executable not found: $Exe (build it first)." } + Start-Process $Exe | Out-Null +} + +$proc = $null +for ($i = 0; $i -lt 30 -and -not $proc; $i++) { + $proc = Get-AppProcess + if (-not $proc) { Start-Sleep -Milliseconds 500 } +} +if (-not $proc) { + throw "No visible TunnelDeck window found. Start the app (or pass -Launch) and make sure it isn't minimized to the tray." +} + +$hwnd = $proc.MainWindowHandle +[Win32Native]::SetForegroundWindow($hwnd) | Out-Null +Start-Sleep -Milliseconds 400 + +$rect = New-Object RECT +[Win32Native]::GetWindowRect($hwnd, [ref]$rect) | Out-Null +$w = $rect.Right - $rect.Left +$h = $rect.Bottom - $rect.Top +# TunnelDeck starts minimized to the tray, so its only window may be a tiny +# hidden one. Refuse anything too small to be the real UI and tell the user to +# open the window first (click the tray icon). +if ($w -lt 300 -or $h -lt 300) { + throw "Only a ${w}x${h} window was found — TunnelDeck is minimized to the tray. " + + "Click the tray icon to open the main window (sign in for real content), then re-run this script." +} + +# --- Capture the window ------------------------------------------------------ +$shot = New-Object System.Drawing.Bitmap $w, $h +$g = [System.Drawing.Graphics]::FromImage($shot) +$g.CopyFromScreen($rect.Left, $rect.Top, 0, 0, (New-Object System.Drawing.Size $w, $h)) +$g.Dispose() + +$rawPath = Join-Path $outDir "$Name-window.png" +$shot.Save($rawPath, [System.Drawing.Imaging.ImageFormat]::Png) + +# --- Compose onto a 16:9 canvas with an indigo gradient ---------------------- +$canvas = New-Object System.Drawing.Bitmap $CanvasWidth, $CanvasHeight +$cg = [System.Drawing.Graphics]::FromImage($canvas) +$cg.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias +$cg.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + +$rectF = New-Object System.Drawing.Rectangle 0, 0, $CanvasWidth, $CanvasHeight +$c1 = [System.Drawing.Color]::FromArgb(255, 108, 111, 245) # #6c6ff5 (icon top) +$c2 = [System.Drawing.Color]::FromArgb(255, 79, 70, 229) # #4f46e5 (icon bottom) +$brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush $rectF, $c1, $c2, 90.0 +$cg.FillRectangle($brush, $rectF) + +# Scale the window down if it exceeds ~78% of the canvas, keeping aspect. +$maxW = [int]($CanvasWidth * 0.78); $maxH = [int]($CanvasHeight * 0.82) +$scale = [Math]::Min([Math]::Min($maxW / $w, $maxH / $h), 1.0) +$dw = [int]($w * $scale); $dh = [int]($h * $scale) +$dx = [int](($CanvasWidth - $dw) / 2); $dy = [int](($CanvasHeight - $dh) / 2) + +# Soft shadow behind the window. +$shadow = New-Object System.Drawing.Drawing2D.GraphicsPath +$sr = New-Object System.Drawing.Rectangle ($dx + 8), ($dy + 14), $dw, $dh +$cg.FillRectangle((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(60, 0, 0, 0))), $sr) +$cg.DrawImage($shot, $dx, $dy, $dw, $dh) +$cg.Dispose() + +$outPath = Join-Path $outDir "$Name.png" +$canvas.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png) +$shot.Dispose(); $canvas.Dispose() + +Write-Host "Captured window: $rawPath (${w}x${h})" +Write-Host "Store screenshot: $outPath (${CanvasWidth}x${CanvasHeight})" From 921ab37a73df1f69ad25cddf8968913ec5ef060d Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:06:32 -0300 Subject: [PATCH 11/17] fix(store): resolve a relative CERT_PATH against repo root So the .env's CERT_PATH works regardless of the directory the packaging script is invoked from. Co-Authored-By: Claude Opus 4.8 (1M context) --- packaging/msix/build-msix.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packaging/msix/build-msix.ps1 b/packaging/msix/build-msix.ps1 index 631520e..55ee698 100644 --- a/packaging/msix/build-msix.ps1 +++ b/packaging/msix/build-msix.ps1 @@ -90,6 +90,12 @@ if (-not $CertPath) { $CertPath = $envMap["CERT_PATH"] } if (-not $CertPassword) { $CertPassword = $envMap["CERT_PASSWORD"] } if (-not $Version) { $Version = "0.1.0.0" } +# A relative CERT_PATH is resolved against the repo root, so the .env value works +# no matter which directory you run the script from. +if ($CertPath -and -not [System.IO.Path]::IsPathRooted($CertPath)) { + $CertPath = Join-Path $repoRoot $CertPath +} + $missing = @() if (-not $IdentityName) { $missing += "IdentityName / IDENTITY_NAME" } if (-not $PublisherId) { $missing += "PublisherId / PUBLISHER_ID" } From 438d3f461bfca1db0af936b3edb4850416769a6d Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:09:10 -0300 Subject: [PATCH 12/17] fix(store): locate appcert in App Certification Kit folder; require elevation for -Wack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Find-SdkTool only searched Windows Kits\10\bin, but appcert.exe (WACK) lives in the 'App Certification Kit' folder — so -Wack could never find it. Look there explicitly and fail fast with a clear message when the prompt isn't elevated (WACK requires Administrator). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/store/README.md | 14 +++++++++----- packaging/msix/build-msix.ps1 | 13 ++++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/store/README.md b/docs/store/README.md index ff1b196..a20b164 100644 --- a/docs/store/README.md +++ b/docs/store/README.md @@ -75,13 +75,17 @@ $cert = New-SelfSignedCertificate -Type Custom -Subject "CN=Paulo Corcino" ` Export-PfxCertificate -Cert $cert -FilePath .\TunnelDeck-test.pfx ` -Password (ConvertTo-SecureString -String "test" -Force -AsPlainText) -# Build a signed test package and run the certification kit: -.\build-msix.ps1 -IdentityName "<...>" -PublisherId "CN=Paulo Corcino" ` - -PublisherDisplayName "Paulo Corcino" ` - -Sign -CertPath .\TunnelDeck-test.pfx -CertPassword test -Wack +# Build a signed test package (identity comes from .env): +.\build-msix.ps1 -Sign -# Install it: +# Install it (self-signed → first trust the cert; needs an ELEVATED prompt): +Import-PfxCertificate -FilePath .\TunnelDeck-test.pfx ` + -CertStoreLocation Cert:\LocalMachine\TrustedPeople ` + -Password (ConvertTo-SecureString "test" -Force -AsPlainText) Add-AppxPackage .\out\TunnelDeck-0.1.0.0.msix + +# Run the certification kit — WACK requires an ELEVATED (Administrator) prompt: +.\build-msix.ps1 -Sign -Wack ``` Fix any **WACK** failures before submitting. Then rebuild **without** `-Sign` to diff --git a/packaging/msix/build-msix.ps1 b/packaging/msix/build-msix.ps1 index 55ee698..1b287e0 100644 --- a/packaging/msix/build-msix.ps1 +++ b/packaging/msix/build-msix.ps1 @@ -187,7 +187,18 @@ if ($Sign) { # --- Optional: Windows App Certification Kit --------------------------------- if ($Wack) { - $appcert = Find-SdkTool "appcert.exe" + # appcert.exe lives in the "App Certification Kit" folder, not bin\. + $appcert = $null + foreach ($base in @("${env:ProgramFiles(x86)}\Windows Kits\10", "${env:ProgramFiles}\Windows Kits\10")) { + $c = Join-Path $base "App Certification Kit\appcert.exe" + if (Test-Path $c) { $appcert = $c; break } + } + if (-not $appcert) { throw "appcert.exe not found. Install the Windows App Certification Kit (part of the Windows SDK)." } + + # WACK requires elevation. + $elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if (-not $elevated) { throw "WACK (-Wack) requires an elevated (Administrator) PowerShell. Re-run this script from an admin prompt." } + $report = Join-Path $outDir "wack-report.xml" Write-Host "`nRunning Windows App Certification Kit (may take several minutes)..." & $appcert reset From fd332ec6e5e163ad3d7e9e5041c72c6b815a940b Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:11:44 -0300 Subject: [PATCH 13/17] fix(store): keep packaging scripts ASCII-only for Windows PowerShell 5.1 Windows PowerShell 5.1 reads BOM-less scripts as ANSI, so a UTF-8 em-dash inside a string literal ('local test only - do NOT ...') was mangled and broke parsing. Replace all em-dashes with ASCII hyphens in build-msix.ps1 and capture-screenshots.ps1. Co-Authored-By: Claude Opus 4.8 (1M context) --- packaging/msix/build-msix.ps1 | 8 ++++---- packaging/msix/capture-screenshots.ps1 | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packaging/msix/build-msix.ps1 b/packaging/msix/build-msix.ps1 index 1b287e0..2575dfd 100644 --- a/packaging/msix/build-msix.ps1 +++ b/packaging/msix/build-msix.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION 1. Compiles the release executable with the `store` cargo feature (self-install, - update-checker and HKCU auto-start compiled out — the package owns those). + update-checker and HKCU auto-start compiled out - the package owns those). 2. Renders the visual assets from the procedural app icon (gen_msix_assets). 3. Assembles a package layout, substituting the Partner Center identity values into AppxManifest.xml. @@ -12,7 +12,7 @@ 5. Optionally signs it with a local test certificate for sideload testing, and/or runs the Windows App Certification Kit (WACK). - The .msix you upload to Partner Center must be UNSIGNED (the Store re-signs it) — + The .msix you upload to Partner Center must be UNSIGNED (the Store re-signs it) - so only pass -Sign when you want to install/test locally, and produce a separate unsigned package for submission. @@ -128,7 +128,7 @@ $makeappx = Find-SdkTool "makeappx.exe" Write-Host "makeappx: $makeappx" # --- 1. Build the store executable ------------------------------------------- -# The `store` feature pulls in `hosting` (Host button) — needs NASM + Strawberry +# The `store` feature pulls in `hosting` (Host button) - needs NASM + Strawberry # Perl on PATH for the vendored-OpenSSL build (see docs/store/README.md). Write-Host "`n[1/4] Building release executable (--features store)..." Push-Location $repoRoot @@ -176,7 +176,7 @@ Write-Host "Package built: $msixPath" if ($Sign) { if (-not $CertPath) { throw "-Sign requires -CertPath ." } $signtool = Find-SdkTool "signtool.exe" - Write-Host "`nSigning (local test only — do NOT submit a signed package)..." + Write-Host "`nSigning (local test only - do NOT submit a signed package)..." $args = @("sign", "/fd", "SHA256", "/a", "/f", $CertPath) if ($CertPassword) { $args += @("/p", $CertPassword) } $args += $msixPath diff --git a/packaging/msix/capture-screenshots.ps1 b/packaging/msix/capture-screenshots.ps1 index 4c55c7a..af2e0be 100644 --- a/packaging/msix/capture-screenshots.ps1 +++ b/packaging/msix/capture-screenshots.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION Grabs the TunnelDeck window and composes it, centred, on a 16:9 canvas with a - soft indigo backdrop — the format the Store expects (min 1366x768; this defaults + soft indigo backdrop - the format the Store expects (min 1366x768; this defaults to 1920x1080). Saves both the raw window PNG and the composed canvas PNG to docs/store/screenshots/. @@ -96,7 +96,7 @@ $h = $rect.Bottom - $rect.Top # hidden one. Refuse anything too small to be the real UI and tell the user to # open the window first (click the tray icon). if ($w -lt 300 -or $h -lt 300) { - throw "Only a ${w}x${h} window was found — TunnelDeck is minimized to the tray. " + + throw "Only a ${w}x${h} window was found - TunnelDeck is minimized to the tray. " + "Click the tray icon to open the main window (sign in for real content), then re-run this script." } From baba6e285ac429c227faf28012198fad2b3f2e4a Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:24:53 -0300 Subject: [PATCH 14/17] feat(ui): rename in-app branding to TunnelDeck to match the Store name Align the in-app identity with the Microsoft Store product name: window title and header show "TunnelDeck"; the About panel shows the full "TunnelDeck for Dev Tunnels". Resolves the contradiction with the old "Dev Tunnels Manager" label. Co-Authored-By: Claude Opus 4.8 (1M context) --- i18n/en-US/app.ftl | 4 ++-- ui/strings.slint | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/i18n/en-US/app.ftl b/i18n/en-US/app.ftl index db607e1..5c82516 100644 --- a/i18n/en-US/app.ftl +++ b/i18n/en-US/app.ftl @@ -119,7 +119,7 @@ confirm-uninstall = Uninstall DevTunnel GUI? This removes the Start-menu shortcu ## About about-title = About -about-app-name = Dev Tunnels Manager +about-app-name = TunnelDeck for Dev Tunnels about-version-label = Version about-tagline = Manage Microsoft Dev Tunnels from your Windows tray. about-built-on = Built on Microsoft Dev Tunnels — Microsoft's free, security-focused tunneling service — and its official CLI and SDK. Not affiliated with or endorsed by Microsoft. @@ -172,7 +172,7 @@ badge-stopped = Stopped badge-hosting = Hosting… ## Top bar (redesign) -app-title = Dev Tunnels Manager +app-title = TunnelDeck pill-connected = Connected tooltip-settings = Toggle dark mode diff --git a/ui/strings.slint b/ui/strings.slint index b519836..a582250 100644 --- a/ui/strings.slint +++ b/ui/strings.slint @@ -81,7 +81,7 @@ export global Strings { in property badge-hosting: "Hosting…"; // Top bar (redesign) - in property app-title: "Dev Tunnels Manager"; + in property app-title: "TunnelDeck"; in property pill-connected: "Connected"; in property tooltip-settings: "Toggle dark mode"; @@ -138,7 +138,7 @@ export global Strings { // About in property about-title: "About"; - in property about-app-name: "Dev Tunnels Manager"; + in property about-app-name: "TunnelDeck for Dev Tunnels"; in property about-version-label: "Version"; in property about-tagline: "Manage Microsoft Dev Tunnels from your Windows tray."; in property about-built-on: "Built on Microsoft Dev Tunnels — Microsoft's free, security-focused tunneling service — and its official CLI and SDK. Not affiliated with or endorsed by Microsoft."; From 090a9a3477882d9bf72a3d60792753315698b7b3 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:32:14 -0300 Subject: [PATCH 15/17] fix(store): make screenshot capture reliable (correct window, raise, tight bounds) - Enumerate the process's top-level windows and pick the largest visible one; the Slint UI is a separate 'Window Class' window, not MainWindowHandle (which is a 16x16 winit helper, the source of the earlier tray-sized capture). - Pin the window TOPMOST before grabbing so the screen capture isn't of whatever was covering it (SetForegroundWindow alone is blocked from a background process). - Use DwmGetWindowAttribute(EXTENDED_FRAME_BOUNDS) so the grab excludes the invisible DWM resize border / drop shadow (no more edge bleed). Co-Authored-By: Claude Opus 4.8 (1M context) --- packaging/msix/capture-screenshots.ps1 | 94 ++++++++++++++++++-------- 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/packaging/msix/capture-screenshots.ps1 b/packaging/msix/capture-screenshots.ps1 index af2e0be..9c0313a 100644 --- a/packaging/msix/capture-screenshots.ps1 +++ b/packaging/msix/capture-screenshots.ps1 @@ -47,27 +47,61 @@ if (-not $Exe) { $Exe = Join-Path $repoRoot "target\release\devtunnel_gui.exe" } $outDir = Join-Path $repoRoot "docs\store\screenshots" New-Item -ItemType Directory -Path $outDir -Force | Out-Null -# --- Win32 interop: GetWindowRect + SetForegroundWindow ----------------------- -if (-not ("Win32Native" -as [type])) { +# --- Win32 interop ----------------------------------------------------------- +# The Slint UI window is a separate top-level window (class 'Window Class'); the +# process's MainWindowHandle points at a tiny 16x16 winit helper window, so we +# enumerate all top-level windows for the PID and pick the largest visible one. +if (-not ("WinCap" -as [type])) { Add-Type @" using System; +using System.Collections.Generic; using System.Runtime.InteropServices; public struct RECT { public int Left, Top, Right, Bottom; } -public static class Win32Native { - [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r); - [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); - [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int n); +public static class WinCap { + public delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr h, int attr, out RECT r, int size); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + // True visible bounds, excluding the invisible DWM resize border / drop shadow + // that GetWindowRect includes on Windows 10/11 (DWMWA_EXTENDED_FRAME_BOUNDS=9). + public static RECT VisibleRect(IntPtr h) { + RECT r; + if (DwmGetWindowAttribute(h, 9, out r, Marshal.SizeOf(typeof(RECT))) == 0) return r; + GetWindowRect(h, out r); return r; + } + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int v); + public static List ForPid(uint target) { + var res = new List(); + EnumWindows((h,l)=>{ uint p; GetWindowThreadProcessId(h, out p); if(p==target) res.Add(h); return true; }, IntPtr.Zero); + return res; + } } "@ # Capture in physical pixels so the window isn't scaled/blurred on high DPI. - try { [Win32Native]::SetProcessDpiAwareness(2) | Out-Null } catch {} + try { [WinCap]::SetProcessDpiAwareness(2) | Out-Null } catch {} } # --- Find (or launch) the app window ----------------------------------------- -function Get-AppProcess { - Get-Process -Name "devtunnel_gui" -ErrorAction SilentlyContinue | - Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 +function Find-AppWindow { + $proc = Get-Process -Name "devtunnel_gui" -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $proc) { return $null } + $best = [IntPtr]::Zero; $bestArea = 0; $bestRect = $null + foreach ($h in [WinCap]::ForPid([uint32]$proc.Id)) { + if (-not [WinCap]::IsWindowVisible($h)) { continue } + $r = [WinCap]::VisibleRect($h) + $w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top + $area = $w * $ht + if ($w -ge 300 -and $ht -ge 300 -and $area -gt $bestArea) { + $best = $h; $bestArea = $area; $bestRect = $r + } + } + if ($best -eq [IntPtr]::Zero) { return $null } + return @{ Hwnd = $best; Rect = $bestRect } } if ($Launch) { @@ -75,30 +109,29 @@ if ($Launch) { Start-Process $Exe | Out-Null } -$proc = $null -for ($i = 0; $i -lt 30 -and -not $proc; $i++) { - $proc = Get-AppProcess - if (-not $proc) { Start-Sleep -Milliseconds 500 } +$win = $null +for ($i = 0; $i -lt 30 -and -not $win; $i++) { + $win = Find-AppWindow + if (-not $win) { Start-Sleep -Milliseconds 500 } } -if (-not $proc) { - throw "No visible TunnelDeck window found. Start the app (or pass -Launch) and make sure it isn't minimized to the tray." +if (-not $win) { + throw "No visible TunnelDeck window (>=300x300) found. Open the window from the tray icon, then re-run this script." } -$hwnd = $proc.MainWindowHandle -[Win32Native]::SetForegroundWindow($hwnd) | Out-Null -Start-Sleep -Milliseconds 400 - -$rect = New-Object RECT -[Win32Native]::GetWindowRect($hwnd, [ref]$rect) | Out-Null +$hwnd = $win.Hwnd +# Raise the window above everything else so the screen grab isn't of whatever is +# covering it. SetForegroundWindow alone is unreliable from a background process +# (foreground lock), so pin it TOPMOST, capture, then release. +$HWND_TOPMOST = [IntPtr](-1); $HWND_NOTOPMOST = [IntPtr](-2) +$SWP = 0x0001 -bor 0x0002 -bor 0x0040 # NOSIZE | NOMOVE | SHOWWINDOW +[WinCap]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE +[WinCap]::SetWindowPos($hwnd, $HWND_TOPMOST, 0, 0, 0, 0, $SWP) | Out-Null +[WinCap]::SetForegroundWindow($hwnd) | Out-Null +Start-Sleep -Milliseconds 700 + +$rect = [WinCap]::VisibleRect($hwnd) $w = $rect.Right - $rect.Left $h = $rect.Bottom - $rect.Top -# TunnelDeck starts minimized to the tray, so its only window may be a tiny -# hidden one. Refuse anything too small to be the real UI and tell the user to -# open the window first (click the tray icon). -if ($w -lt 300 -or $h -lt 300) { - throw "Only a ${w}x${h} window was found - TunnelDeck is minimized to the tray. " + - "Click the tray icon to open the main window (sign in for real content), then re-run this script." -} # --- Capture the window ------------------------------------------------------ $shot = New-Object System.Drawing.Bitmap $w, $h @@ -106,6 +139,9 @@ $g = [System.Drawing.Graphics]::FromImage($shot) $g.CopyFromScreen($rect.Left, $rect.Top, 0, 0, (New-Object System.Drawing.Size $w, $h)) $g.Dispose() +# Release the topmost pin now that the grab is done. +[WinCap]::SetWindowPos($hwnd, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP) | Out-Null + $rawPath = Join-Path $outDir "$Name-window.png" $shot.Save($rawPath, [System.Drawing.Imaging.ImageFormat]::Png) From 5bbd450cb11734ef3c0908ef54aa017221f75c13 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:56:09 -0300 Subject: [PATCH 16/17] style: cargo fmt (expand gen_msix_assets tables; pre-existing drift) CI runs cargo fmt --all --check; apply it. Expands the Square asset table in gen_msix_assets.rs and fixes pre-existing formatting drift in devtunnel.rs, locale.rs, and two_host_probe.rs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bin/gen_msix_assets.rs | 25 ++++++++++++++++++++----- src/bin/two_host_probe.rs | 21 ++++++++++++++++----- src/devtunnel.rs | 28 ++++++++++++++++++++-------- src/locale.rs | 20 ++++++++++++++++---- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/bin/gen_msix_assets.rs b/src/bin/gen_msix_assets.rs index d932a2f..b66a3dd 100644 --- a/src/bin/gen_msix_assets.rs +++ b/src/bin/gen_msix_assets.rs @@ -25,15 +25,30 @@ struct Square { /// tiles on high-DPI displays (same names with a `.scale-200` infix). const SQUARES: &[Square] = &[ // App-list / taskbar / Start small icon. - Square { name: "Square44x44Logo.png", size: 44 }, + Square { + name: "Square44x44Logo.png", + size: 44, + }, // Small tile. - Square { name: "Square71x71Logo.png", size: 71 }, + Square { + name: "Square71x71Logo.png", + size: 71, + }, // Medium tile (required). - Square { name: "Square150x150Logo.png", size: 150 }, + Square { + name: "Square150x150Logo.png", + size: 150, + }, // Large tile. - Square { name: "Square310x310Logo.png", size: 310 }, + Square { + name: "Square310x310Logo.png", + size: 310, + }, // Store listing logo carried inside the package. - Square { name: "StoreLogo.png", size: 50 }, + Square { + name: "StoreLogo.png", + size: 50, + }, ]; /// Wide tile (310x150): the square mark centred on a transparent canvas. diff --git a/src/bin/two_host_probe.rs b/src/bin/two_host_probe.rs index 1d8a3fa..656e7a5 100644 --- a/src/bin/two_host_probe.rs +++ b/src/bin/two_host_probe.rs @@ -297,13 +297,17 @@ async fn main() -> anyhow::Result<()> { finish(stop, poller).await; if rejected && !transient { println!("\nVERDICT: REJECT — the relay refuses a second host on one tunnel id."); - println!(" → make-before-break (connect-new-then-drop-old) is impossible as framed."); + println!( + " → make-before-break (connect-new-then-drop-old) is impossible as framed." + ); println!(" → fall back to minimizing the break window on re-mint."); } else { println!( "\nVERDICT: INCONCLUSIVE — B failed on a transient transport error, not a" ); - println!(" service rejection. Re-run; this is the same flaky DNS that retries on A."); + println!( + " service rejection. Re-run; this is the same flaky DNS that retries on A." + ); } match a_after { Some(r) => println!(" note: host A also dropped during B's attempt: {r:?}"), @@ -312,7 +316,10 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } Ok(pair) => { - println!("[B] connected + port forwarded ✓ ({:?})", t_b_start.elapsed()); + println!( + "[B] connected + port forwarded ✓ ({:?})", + t_b_start.elapsed() + ); pair } }; @@ -352,10 +359,14 @@ async fn main() -> anyhow::Result<()> { println!(" → GO, but measure the gap from the poll trace above before committing."); } Verdict::EvictNew(r, dt) => { - println!("VERDICT: EVICT (old evicts new) — host B dropped {dt:?} after finishing connect."); + println!( + "VERDICT: EVICT (old evicts new) — host B dropped {dt:?} after finishing connect." + ); println!(" detail: B handle resolved with {r}"); println!(" → the service keeps the incumbent; a second host cannot take over live."); - println!(" → NO-GO on make-before-break as framed; minimize the break window instead."); + println!( + " → NO-GO on make-before-break as framed; minimize the break window instead." + ); } } println!("──────────────────────────────────────────────"); diff --git a/src/devtunnel.rs b/src/devtunnel.rs index f933b49..45c76fd 100644 --- a/src/devtunnel.rs +++ b/src/devtunnel.rs @@ -800,8 +800,8 @@ mod tests { use super::{ anonymous_ace_args, classify_anonymous_access, classify_install_result, classify_user_show, is_auth_error, is_fatal_connect_error, is_missing_tunnel_error, parse_leading_int, - parse_rate_bps, parse_size_bytes, sanitize_tunnel_id, - tunnel_ports, update_expiration_args, InstallOutcome, ShowResult, + parse_rate_bps, parse_size_bytes, sanitize_tunnel_id, tunnel_ports, update_expiration_args, + InstallOutcome, ShowResult, }; #[test] @@ -1040,7 +1040,9 @@ mod tests { #[test] fn fatal_on_request_validation_errors() { - assert!(is_fatal_connect_error("The request failed: 400 Bad Request")); + assert!(is_fatal_connect_error( + "The request failed: 400 Bad Request" + )); assert!(is_fatal_connect_error( "the tunnel port protocol cannot be changed" )); @@ -1051,8 +1053,12 @@ mod tests { fn fatal_on_deleted_or_missing_tunnel() { // A deleted/expired tunnel surfaces while minting the host token; retrying // can never succeed, so it must stop instead of looping on `Authorizing`. - assert!(is_fatal_connect_error("Tunnel not found in brs: fancy-ocean")); - assert!(is_fatal_connect_error("The request was rejected: 404 Not Found")); + assert!(is_fatal_connect_error( + "Tunnel not found in brs: fancy-ocean" + )); + assert!(is_fatal_connect_error( + "The request was rejected: 404 Not Found" + )); } #[test] @@ -1066,9 +1072,15 @@ mod tests { fn missing_tunnel_detects_deleted_or_expired() { // Drives the auto-host prune: only a genuinely-gone tunnel, not every // fatal error (a 400 protocol mismatch must keep the group). - assert!(is_missing_tunnel_error("Tunnel not found in brs: fancy-ocean")); - assert!(is_missing_tunnel_error("The request was rejected: 404 Not Found")); + assert!(is_missing_tunnel_error( + "Tunnel not found in brs: fancy-ocean" + )); + assert!(is_missing_tunnel_error( + "The request was rejected: 404 Not Found" + )); assert!(!is_missing_tunnel_error("400 Bad Request")); - assert!(!is_missing_tunnel_error("the tunnel port protocol cannot be changed")); + assert!(!is_missing_tunnel_error( + "the tunnel port protocol cannot be changed" + )); } } diff --git a/src/locale.rs b/src/locale.rs index 71913b1..12c8567 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -115,9 +115,18 @@ mod tests { fn port_count_uses_english_plural_rules() { // en-US: 0 and 2+ are "other", only 1 is "one". let loc = Locale::load("en-US"); - assert_eq!(plain(loc.t_args("status-port-count", &count_args(0))), "0 ports"); - assert_eq!(plain(loc.t_args("status-port-count", &count_args(1))), "1 port"); - assert_eq!(plain(loc.t_args("status-port-count", &count_args(3))), "3 ports"); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(0))), + "0 ports" + ); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(1))), + "1 port" + ); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(3))), + "3 ports" + ); } #[test] @@ -127,6 +136,9 @@ mod tests { // `[one]` branch ("1 port"). The bundle must use the resolved (en-US) // langid so plural rules match the loaded text. let loc = Locale::load("pt-BR"); - assert_eq!(plain(loc.t_args("status-port-count", &count_args(0))), "0 ports"); + assert_eq!( + plain(loc.t_args("status-port-count", &count_args(0))), + "0 ports" + ); } } From f850c89fe1725e5bfa95dd9ef171b5e50b1946b2 Mon Sep 17 00:00:00 2001 From: Paulo Corcino <7800501+paulocorcino@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:14:04 -0300 Subject: [PATCH 17/17] fix(clippy): allow match_single_binding in ftl_source locale scaffold ftl_source keeps a match on the resolved lang tag on purpose - adding a locale is a one-line arm (per CLAUDE.md i18n). Allow the single-binding lint until a second locale ships. Pre-existing code, surfaced now that PR CI runs clippy -D warnings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/locale.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/locale.rs b/src/locale.rs index 12c8567..d5cc585 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -87,6 +87,10 @@ fn resolve_lang(_lang: &str) -> &'static str { "en-US" } +// Kept as a `match` on purpose: adding a locale is a one-line arm here (see the +// i18n section of CLAUDE.md), so we tolerate the single-binding form until a +// second locale ships. +#[allow(clippy::match_single_binding)] fn ftl_source(lang: &str) -> &'static str { // `lang` is already a resolved tag from [`resolve_lang`]. match lang {