From 9a98fc7a955b8ff58ec079585ce309d22be30732 Mon Sep 17 00:00:00 2001
From: blocknodes
Date: Tue, 4 Aug 2026 05:07:39 +0000
Subject: [PATCH 1/5] feat(server): add Tailscale serve as a fourth hosting
option
Adds a "tailscale" coordinator_exposure variant alongside Direct URL,
Cloudflare Tunnel, and NGINX. Unlike the bundled cloudflared sidecar, this
detects and drives a system `tailscale` CLI (tailscale serve needs the
privileged tailscaled daemon and a logged-in tailnet, so it can't be a
sidecar).
Backend (src-tauri/src/tailscale.rs):
- `start` runs `tailscale serve --bg --https=443
https+insecure://127.0.0.1:`, putting the loopback frostd behind the
machine's stable MagicDNS name on the tailnet with auto-provisioned public
TLS. `https+insecure` is the tailnet equivalent of cloudflared's
--no-tls-verify; frostd's Noise layer still authenticates end-to-end.
- Uses `serve` (tailnet-only), never `funnel` (public internet).
- MagicDNS name + readiness read from `tailscale status --json`; `available`
/ `detail` explain why it's not ready (not installed / signed out / offline
/ MagicDNS off). Binary resolved from PATH then per-OS install locations.
- `stop`/`stop_serve_blocking` turn off the 443 mapping (it lives in the
daemon, not a child process) on stop, when the sidecar stops, and on app
exit.
- Commands: start_tailscale_serve, stop_tailscale_serve, tailscale_status;
AppState gains a `tailscale` handle.
Frontend (SessionSetup.tsx, ipc/commands.ts):
- Tailscale exposure tab + TailscaleExposure panel (serve/stop, shows the
stable URL, guides install/sign-in via `detail`). The `.ts.net` URL is
stable, so it saves and reuses like a normal server (not ephemeral).
- Participant server-URL guidance and cert notes mention the Tailscale form.
Backend builds, 2 new unit tests pass, tsc clean, no new clippy warnings.
Needs a live run on a Tailscale-signed-in machine to confirm the serve
invocation against the current CLI.
Co-Authored-By: Claude Opus 4.8
---
TODO.md | 64 +++---
src-tauri/src/commands/server.rs | 36 +++-
src-tauri/src/lib.rs | 13 ++
src-tauri/src/state.rs | 4 +
src-tauri/src/tailscale.rs | 347 +++++++++++++++++++++++++++++++
src/ipc/commands.ts | 21 +-
src/screens/SessionSetup.tsx | 131 +++++++++++-
7 files changed, 575 insertions(+), 41 deletions(-)
create mode 100644 src-tauri/src/tailscale.rs
diff --git a/TODO.md b/TODO.md
index 15c6e0c..7959ebe 100644
--- a/TODO.md
+++ b/TODO.md
@@ -53,40 +53,36 @@ current build depends on them.
identity. An identicon derived *from* the pubkey is the one variant that
strengthens rather than weakens this, which argues for it.
-- [ ] **Tailscale `serve` as a fourth hosting option** — alongside Direct URL,
- Cloudflare Tunnel, and NGINX in Session Configuration.
-
- **Feasibility/impact (2026-08-01):** effort Med, impact Med–High, not gated
- by the testnet-send validation. Slots in as a fourth `coordinator_exposure`
- variant reusing the existing exposure plumbing + a status probe; no crypto
- change (frostd's Noise layer still authenticates end-to-end). Structural
- difference from cloudflared: **detect-and-drive a system `tailscale` CLI,
- do NOT bundle** — it needs the `tailscaled` daemon (privileged) and a
- logged-in tailnet, so the sidecar-spawn pattern doesn't apply. Read the
- stable MagicDNS hostname back via `tailscale status --json` as the saved
- server URL. Verdict: **do** — best fix for the disposable-quick-tunnel URL
- pain (stable, savable, auto-TLS, tailnet-scoped).
-
- Why it is attractive: `tailscale serve https / http://127.0.0.1:`
- exposes the loopback frostd over the tailnet with a **stable** MagicDNS
- hostname and an automatically-provisioned, publicly-valid TLS certificate.
- That fixes the two things that hurt most about quick tunnels: the URL is
- **not disposable** (so it can be saved as a group's server and reused), and
- there is no cert-trust step. Access is also restricted to the tailnet rather
- than the whole internet, which is a strictly better default for a signing
- server. (`tailscale funnel` would expose it publicly if a participant is
- outside the tailnet.)
-
- Open questions:
- - Detect an existing `tailscale` binary/daemon, or bundle it? Bundling is
- heavier than `cloudflared` and the daemon needs privileges — detection
- plus a clear "install Tailscale" path is likely the right first cut.
- - Every participant must be on the tailnet (or the coordinator uses Funnel).
- That is a real constraint to surface in the UI, not bury.
- - Reuse the existing exposure plumbing: this is a new `Exposure` variant
- plus a status probe; the trust model is unchanged (frostd's Noise layer
- still authenticates end-to-end, so the transport only provides
- reachability).
+- [x] **Tailscale `serve` as a fourth hosting option** — DONE
+ (`feat/tailscale-serve`). A fourth `coordinator_exposure` variant
+ (`"tailscale"`) in Session Configuration, alongside Direct URL, Cloudflare
+ Tunnel, and NGINX.
+
+ Implementation: `src-tauri/src/tailscale.rs` **detects and drives a system
+ `tailscale` CLI** (not bundled — needs the privileged `tailscaled` daemon +
+ a logged-in tailnet). `tailscale serve --bg --https=443
+ https+insecure://127.0.0.1:` puts the loopback frostd behind the
+ machine's stable MagicDNS name on the tailnet, with auto-provisioned public
+ TLS (so participants connect with system roots — no cert-trust step). We use
+ `serve` (tailnet-only), never `funnel` (public). The MagicDNS name is read
+ from `tailscale status --json` (`Self.DNSName`), and `available`/`detail`
+ surface *why* it's not ready (not installed / signed out / offline).
+ Commands `start_tailscale_serve` / `stop_tailscale_serve` /
+ `tailscale_status`; `AppState.tailscale` handle; serve is torn down when the
+ sidecar stops and on app exit (`stop_serve_blocking`, since the mapping
+ lives in the daemon). UI: a Tailscale tab + `TailscaleExposure` in
+ `SessionSetup.tsx`; the stable `.ts.net` URL is (correctly) *not* treated as
+ ephemeral, so it saves + reuses like a normal server. `https+insecure` is the
+ tailnet equivalent of cloudflared's `--no-tls-verify`; frostd's Noise layer
+ still authenticates end-to-end. Compile-verified + unit tests; **needs a
+ live run** on a machine with Tailscale installed/signed-in to confirm the
+ `serve` invocation against the current CLI.
+
+ Possible follow-ups: bundle/guide an install path if detection proves too
+ bare; surface the "all participants must be on the tailnet" constraint even
+ more prominently; optional `funnel` toggle for a participant outside the
+ tailnet (explicitly public — would need the same loud warning as the
+ Cloudflare tunnel).
## Voting (coinholder polling)
diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs
index 5c6ce14..1efef76 100644
--- a/src-tauri/src/commands/server.rs
+++ b/src-tauri/src/commands/server.rs
@@ -5,6 +5,7 @@ use tauri::{AppHandle, Manager, State};
use crate::error::AppResult;
use crate::sidecar::{self, SidecarStatus};
use crate::state::{AppState, Settings};
+use crate::tailscale::{self, TailscaleStatus};
use crate::tunnel::{self, TunnelStatus};
#[tauri::command]
@@ -169,9 +170,10 @@ pub async fn start_sidecar(
#[tauri::command]
pub async fn stop_sidecar(state: State<'_, AppState>) -> AppResult<()> {
- // The tunnel points at the embedded server; stopping the server makes it
- // dead weight, so tear it down too.
+ // The tunnel and the Tailscale serve mapping both point at the embedded
+ // server; stopping the server makes them dead weight, so tear them down too.
let _ = tunnel::stop(&state).await;
+ let _ = tailscale::stop(&state).await;
sidecar::stop(&state).await
}
@@ -205,6 +207,36 @@ pub async fn tunnel_status(state: State<'_, AppState>) -> AppResult` URL participants on the tailnet can use. Requires
+/// a system Tailscale that is installed, signed in, and online.
+#[tauri::command]
+pub async fn start_tailscale_serve(state: State<'_, AppState>) -> AppResult {
+ let port = {
+ let guard = state.sidecar.lock().await;
+ match guard.as_ref() {
+ Some(handle) => handle.port,
+ None => {
+ return Err(crate::error::AppError::new(
+ "tailscale",
+ "start the embedded server before starting Tailscale serve",
+ ))
+ }
+ }
+ };
+ tailscale::start(&state, port).await
+}
+
+#[tauri::command]
+pub async fn stop_tailscale_serve(state: State<'_, AppState>) -> AppResult<()> {
+ tailscale::stop(&state).await
+}
+
+#[tauri::command]
+pub async fn tailscale_status(state: State<'_, AppState>) -> AppResult {
+ Ok(tailscale::status(&state).await)
+}
+
#[tauri::command]
pub async fn sidecar_status(state: State<'_, AppState>) -> AppResult {
sidecar::status(&state).await
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index b58bc33..eed709f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -2,6 +2,7 @@ pub mod commands;
pub mod error;
pub mod sidecar;
pub mod state;
+pub mod tailscale;
pub mod tunnel;
use state::AppState;
@@ -104,6 +105,9 @@ pub fn run() {
commands::server::start_tunnel,
commands::server::stop_tunnel,
commands::server::tunnel_status,
+ commands::server::start_tailscale_serve,
+ commands::server::stop_tailscale_serve,
+ commands::server::tailscale_status,
commands::dkg::start_dkg,
commands::dkg::cancel_ceremony,
commands::signing::create_signing_session,
@@ -127,6 +131,15 @@ pub fn run() {
let _ = handle.child.kill();
}
}
+ // Tailscale `serve` lives in the tailscaled daemon, not a
+ // child process, so turn off the mapping synchronously if we
+ // set one — otherwise it outlives the app pointing at a dead
+ // port.
+ if let Ok(mut guard) = state.tailscale.try_lock() {
+ if guard.take().is_some() {
+ crate::tailscale::stop_serve_blocking();
+ }
+ }
}
}
});
diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
index d4e4d6e..8188a55 100644
--- a/src-tauri/src/state.rs
+++ b/src-tauri/src/state.rs
@@ -120,6 +120,9 @@ pub struct AppState {
pub sidecar: Mutex
+ Publishes this server to your tailnet at a{" "}
+ stable*.ts.net address with
+ an automatic, publicly-trusted TLS certificate — so the URL can be saved
+ and reused across launches, with no cert-trust step. Access is limited to
+ your tailnet (not the public internet). Requires{" "}
+
+ Tailscale
+ {" "}
+ installed and signed in on this machine, and every participant on the same
+ tailnet.
+
+
+ {loading && !status ? (
+
Checking Tailscale…
+ ) : serving && url ? (
+ <>
+
+ serving on tailnet
+
+
+
{url}
+
+
+
+
+
+
+ This address is stable — save it as the group's server and reuse it
+ next time. Participants must be on your tailnet to reach it.
+
+
+ This machine:{" "}
+ https://{status.dns_name}
+
+ )}
+
+ >
+ ) : (
+
+
+ {status?.detail ??
+ "Tailscale is not available. Install it and sign in, then reopen this tab."}
+
+
+ )}
+
+ );
+}
+
function NginxExposure({ port }: { port: number }) {
const conf = useMemo(
() =>
@@ -591,8 +708,13 @@ function ParticipantPath() {
• https://long-random-words.trycloudflare.com{" "}
— a Cloudflare tunnel
+ • https://their-machine.tailnet.ts.net{" "}
+ — a Tailscale address (you must be on the same tailnet)
+
A Cloudflare tunnel URL is disposable: the coordinator
- gets a new one each time they restart it, so always use the latest.
+ gets a new one each time they restart it, so always use the latest. A
+ Tailscale .ts.net address is{" "}
+ stable — save it once and reuse it.
@@ -609,8 +731,9 @@ function ParticipantPath() {
Self-signed server? Trust its certificate
- Only needed for a Direct-URL coordinator (not for a Cloudflare tunnel or
- an NGINX/domain server, which use publicly trusted TLS). Paste the
+ Only needed for a Direct-URL coordinator (not for a Cloudflare tunnel, a
+ Tailscale address, or an NGINX/domain server, which use publicly trusted
+ TLS). Paste the
certificate PEM the coordinator shared and confirm the fingerprint with
them out-of-band before trusting it.
From a64b339b9de3296f0f5360eff04a329f372f57a8 Mon Sep 17 00:00:00 2001
From: blocknodes
Date: Tue, 4 Aug 2026 12:01:03 +0000
Subject: [PATCH 2/5] docs: add combined UAT checklist for pipelined sync and
Tailscale serve
One check-off document covering both in-flight features, each part labelled
with the branch it needs (feat/sync-optimizations, feat/tailscale-serve).
Part A mirrors the pipelined-sync validation gate (stock-vs-pipelined
equality, incremental, cancel/resume, reorg, send-after-sync, flag-off
regression). Part B covers Tailscale detection states, publish, tailnet
reachability with no cert step, stable save/reuse, teardown on stop/quit,
tailnet-only scoping, and an end-to-end ceremony.
Co-Authored-By: Claude Opus 4.8
---
docs/UAT.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 141 insertions(+)
create mode 100644 docs/UAT.md
diff --git a/docs/UAT.md b/docs/UAT.md
new file mode 100644
index 0000000..3fab098
--- /dev/null
+++ b/docs/UAT.md
@@ -0,0 +1,141 @@
+# UAT — pipelined sync & Tailscale serve
+
+User-acceptance checklists for the two in-flight features. Each part names the
+branch it needs; until both merge to `main`, test each on its own branch build
+(they don't depend on each other).
+
+- **Part A — Pipelined sync** → branch `feat/sync-optimizations`
+- **Part B — Tailscale serve** → branch `feat/tailscale-serve`
+
+Build fresh and launch the built binary each time (`npm run tauri build`, or
+`cargo build` + `npm run tauri dev`) — never a previously installed bundle.
+
+---
+
+# Part A — Experimental pipelined sync (`feat/sync-optimizations`)
+
+Goal: prove the pipelined driver produces the **same wallet state** as the stock
+driver, only faster. Testnet first. Off by default; opt in via `settings.json`
+(`/settings.json`) → `"experimental_pipelined_sync": true|false`.
+Toggling takes effect on the next sync (Sync Now / relaunch). See
+`docs/SYNC_OPTIMIZATION.md` for the design.
+
+## A0. Setup
+- [ ] Fresh build launched (not an installed bundle).
+- [ ] A **testnet** group with real history (funded a few times, ≥1 send).
+- [ ] Know how to edit `experimental_pipelined_sync` in `settings.json`.
+
+## A1. Baseline — stock driver (control)
+- [ ] `experimental_pipelined_sync` is `false`/absent.
+- [ ] Delete the group's wallet db (force full rescan) and sync to tip; note the
+ rough wall-clock time.
+- [ ] Record: total balance + Orchard/Ironwood split; received-note count;
+ transaction history (count + amounts); scanned-to height (= chain tip).
+
+## A2. Pipelined — clean-state equality (the core test)
+- [ ] Set `experimental_pipelined_sync` to `true`.
+- [ ] Delete the wallet db again (same start as A1) and sync to tip.
+- [ ] Log shows **"using experimental pipelined sync driver"** (not a fallback).
+- [ ] Balance **byte-identical to A1** — total, Orchard, and Ironwood all match.
+- [ ] Received-note count matches A1.
+- [ ] Transaction history matches A1 (txids, amounts, memos).
+- [ ] Scanned-to height reaches the chain tip.
+- [ ] Wall-clock sync time is **≤ A1** (bigger win on a high-latency link).
+
+## A3. Incremental sync
+- [ ] From tip, receive a new testnet payment, then Sync Now → only new blocks
+ scanned, new note appears, balance rises by the expected amount.
+- [ ] Sync again with no activity → quick, balance unchanged (no drift/double-count).
+
+## A4. Cancellation / resume
+- [ ] Start a full rescan (delete db), then cancel mid-sync (Sync Now / navigate away).
+- [ ] App stays responsive; no panic; at most an expected "cancelled".
+- [ ] Sync again → resumes and completes at the same balance/height as A2.
+
+## A5. Reorg tolerance (best-effort)
+- [ ] If a reorg occurs during a sync, log shows "chain reorg detected … rewinding"
+ and the sync still finishes at the correct tip/balance. (Opportunistic.)
+
+## A6. Send after a pipelined sync (funds path)
+- [ ] After a pipelined sync, build + FROST-sign + broadcast a small testnet send.
+- [ ] Node accepts it (no branch-id / MissingSpendAuthSig / selection errors).
+- [ ] After confirmation, a re-sync shows the spend and reduced balance.
+
+## A7. Regression — flag off still works
+- [ ] Set the flag back to `false`, sync once → stock path works normally.
+
+## A — Sign-off
+- [ ] A1 vs A2 identical across balance/notes/history/height.
+- [ ] A3, A4, A6 pass on testnet. No panics, no stuck syncs, UI responsive.
+- [ ] Only then: consider flipping the default, and repeat A1/A2/A6 once on
+ **mainnet** with a small balance before recommending broadly.
+
+---
+
+# Part B — Tailscale serve hosting (`feat/tailscale-serve`)
+
+Goal: a coordinator can publish the embedded frostd to their tailnet at a stable
+`*.ts.net` URL, participants on the same tailnet connect with no cert-trust step,
+and the mapping is cleaned up correctly. `serve` is tailnet-only (not public).
+
+## B0. Setup
+- [ ] Fresh build launched on the **coordinator** machine.
+- [ ] Tailscale installed and **signed in** on the coordinator (`tailscale status`
+ shows Running + online).
+- [ ] A **second device on the same tailnet** to act as a participant (another
+ Cyze install, or at least a browser/curl to hit the URL).
+- [ ] (For B6) a device **not** on the tailnet, to confirm scoping.
+
+## B1. Detection states (before serving)
+Open Session Setup → Coordinator → **Tailscale** tab and verify the guidance
+matches reality:
+- [ ] **Signed in & online** → tab shows this machine's `https://.ts.net`
+ and a **"Publish to tailnet"** button.
+- [ ] **Signed out** (`tailscale logout`) → shows an actionable message
+ (sign in with `tailscale up`), no Publish button.
+- [ ] **Tailscale stopped** (`tailscale down`) → shows a "not connected" message.
+- [ ] **Not installed** (test machine without Tailscale, or rename the binary) →
+ shows "not installed, install from tailscale.com".
+
+## B2. Happy path — publish
+- [ ] Start the embedded server (Step 1).
+- [ ] Tailscale tab → **Publish to tailnet** → badge **"serving on tailnet"** and
+ a stable `https://.ts.net` URL (no port).
+- [ ] `tailscale serve status` on the coordinator shows the 443 → 127.0.0.1:
+ mapping (confirms the CLI invocation succeeded — the one flagged risk).
+- [ ] Copy URL works.
+
+## B3. Reachability from a tailnet participant
+- [ ] On the participant device, open the URL / paste it into Participant setup and
+ **Test connection** → succeeds, `tls` reported as **public** (no cert import),
+ reasonable latency.
+- [ ] No certificate-trust step was needed anywhere.
+
+## B4. Stable save & reuse
+- [ ] Save the `.ts.net` URL as the server (it is **not** treated as ephemeral).
+- [ ] Fully quit and relaunch Cyze; re-publish; the URL is the **same** as before.
+- [ ] The saved server still connects after relaunch (contrast: a Cloudflare quick
+ tunnel would have a new URL).
+
+## B5. Teardown paths
+- [ ] **Stop serving** button → badge clears; from the participant the URL no
+ longer reaches frostd; `tailscale serve status` shows the mapping gone.
+- [ ] Re-publish, then **Stop server** (Step 1) → serve mapping is also torn down
+ (sidecar stop cascades to Tailscale).
+- [ ] Re-publish, then **quit the app** → after quit, `tailscale serve status`
+ shows no leftover 443 mapping (exit cleanup ran).
+
+## B6. Tailnet scoping (not public)
+- [ ] From a device **not** on the tailnet, the `.ts.net` URL does **not** resolve/
+ connect (confirms `serve`, not `funnel` — access is tailnet-scoped).
+
+## B7. End-to-end ceremony over Tailscale
+- [ ] With serve up and a participant joined via the `.ts.net` URL, run a real
+ **signing** (or DKG) ceremony to completion over the tailnet transport.
+
+## B — Sign-off
+- [ ] B2–B5 pass; the URL is stable across relaunch and cleaned up on stop/quit.
+- [ ] B6 confirms tailnet-only scoping.
+- [ ] B7 completes a real ceremony over the transport.
+- [ ] Note the Tailscale CLI version tested here: ____________ (so we know which
+ `serve` grammar was validated).
From 26ed1276522555b4ac962c8b768e4b0124b1c0bd Mon Sep 17 00:00:00 2001
From: blocknodes
Date: Thu, 6 Aug 2026 00:42:27 +0000
Subject: [PATCH 3/5] feat(tailscale): Get Tailscale + one-click sign-in to cut
setup friction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reduce the two Tailscale onboarding steps to buttons in the Tailscale exposure
panel:
- "Get Tailscale" (shown when the CLI isn't installed) opens
https://tailscale.com/download in the system browser.
- "Sign in to Tailscale" (shown when installed but signed out) runs
`tailscale up`, captures the login URL from its output, and opens it so the
user can authenticate; the panel then auto-updates to the connected state via
the existing status poll. If `up` needs elevated rights, that message is
surfaced instead of hanging.
Backend: TailscaleStatus gains `installed` (CLI present regardless of sign-in)
so the UI can pick Get-Tailscale vs Sign-in; `tailscale::sign_in` spawns
`tailscale up`, extracts the login URL (bounded read, reaps in background); new
commands `tailscale_sign_in` and `open_url` (http(s)-only, OS default handler —
no deprecated shell API, no new plugin). Unit test for the login-URL parser.
UAT: docs/UAT.md Part B gains B1a (Get Tailscale opens the download page) and
B1b (Sign in drives `tailscale up` and the tab auto-updates), plus sign-off.
Backend builds, 3 tailscale tests pass, tsc clean, no new clippy warnings.
Co-Authored-By: Claude Opus 4.8
---
docs/UAT.md | 27 +++++++--
src-tauri/src/commands/server.rs | 43 ++++++++++++++
src-tauri/src/lib.rs | 2 +
src-tauri/src/tailscale.rs | 98 ++++++++++++++++++++++++++++++++
src/ipc/commands.ts | 12 ++++
src/screens/SessionSetup.tsx | 74 ++++++++++++++++++++----
6 files changed, 242 insertions(+), 14 deletions(-)
diff --git a/docs/UAT.md b/docs/UAT.md
index 3fab098..282e60e 100644
--- a/docs/UAT.md
+++ b/docs/UAT.md
@@ -91,11 +91,28 @@ Open Session Setup → Coordinator → **Tailscale** tab and verify the guidance
matches reality:
- [ ] **Signed in & online** → tab shows this machine's `https://.ts.net`
and a **"Publish to tailnet"** button.
-- [ ] **Signed out** (`tailscale logout`) → shows an actionable message
- (sign in with `tailscale up`), no Publish button.
-- [ ] **Tailscale stopped** (`tailscale down`) → shows a "not connected" message.
+- [ ] **Signed out** (`tailscale logout`) → shows a "not connected" message and a
+ **"Sign in to Tailscale"** button (not the Publish button).
+- [ ] **Tailscale stopped** (`tailscale down`) → shows a "not connected" message
+ with the **Sign in** button.
- [ ] **Not installed** (test machine without Tailscale, or rename the binary) →
- shows "not installed, install from tailscale.com".
+ shows "not installed" and a **"Get Tailscale"** button.
+
+## B1a. Get Tailscale (not-installed friction)
+- [ ] On a machine without Tailscale, click **Get Tailscale** → the system default
+ browser opens `https://tailscale.com/download` (not an in-app webview).
+- [ ] Install Tailscale, then reopen the tab → it now shows the **Sign in** state
+ (installed, not yet connected).
+
+## B1b. Sign in to Tailscale (signed-out friction)
+- [ ] With Tailscale installed but signed out, click **Sign in to Tailscale**.
+- [ ] Either a browser opens to `login.tailscale.com` automatically, **or** an
+ "open the sign-in page" link appears — clicking it opens the login URL.
+- [ ] Complete auth in the browser; within a few seconds the tab **auto-updates**
+ to the signed-in state (shows the `.ts.net` name + Publish button) with no
+ manual refresh.
+- [ ] (Linux note) If `tailscale up` needs elevated rights on this host, the tab
+ surfaces that instead of hanging — the operator/sudo message is shown.
## B2. Happy path — publish
- [ ] Start the embedded server (Step 1).
@@ -134,6 +151,8 @@ matches reality:
**signing** (or DKG) ceremony to completion over the tailnet transport.
## B — Sign-off
+- [ ] B1a/B1b: **Get Tailscale** opens the download page and **Sign in** drives
+ `tailscale up` to a connected state, with the tab auto-updating.
- [ ] B2–B5 pass; the URL is stable across relaunch and cleaned up on stop/quit.
- [ ] B6 confirms tailnet-only scoping.
- [ ] B7 completes a real ceremony over the transport.
diff --git a/src-tauri/src/commands/server.rs b/src-tauri/src/commands/server.rs
index ea76a08..8c6d560 100644
--- a/src-tauri/src/commands/server.rs
+++ b/src-tauri/src/commands/server.rs
@@ -268,6 +268,49 @@ pub async fn tailscale_status(state: State<'_, AppState>) -> AppResult AppResult {
+ tailscale::sign_in().await
+}
+
+/// Open a URL in the user's default browser (e.g. the Tailscale download or the
+/// sign-in link) via the OS default handler. Restricted to http(s) so it can
+/// only ever launch a browser, never an arbitrary program or file.
+#[tauri::command]
+pub async fn open_url(url: String) -> AppResult<()> {
+ if !(url.starts_with("https://") || url.starts_with("http://")) {
+ return Err(crate::error::AppError::new(
+ "open",
+ "refusing to open a non-http(s) URL",
+ ));
+ }
+ #[cfg(target_os = "linux")]
+ let mut command = {
+ let mut c = tokio::process::Command::new("xdg-open");
+ c.arg(&url);
+ c
+ };
+ #[cfg(target_os = "macos")]
+ let mut command = {
+ let mut c = tokio::process::Command::new("open");
+ c.arg(&url);
+ c
+ };
+ #[cfg(target_os = "windows")]
+ let mut command = {
+ // `start` is a cmd builtin; the empty "" is its window-title argument.
+ let mut c = tokio::process::Command::new("cmd");
+ c.args(["/C", "start", "", &url]);
+ c
+ };
+ command
+ .spawn()
+ .map_err(|e| crate::error::AppError::new("open", format!("opening browser: {e}")))?;
+ Ok(())
+}
+
#[tauri::command]
pub async fn sidecar_status(state: State<'_, AppState>) -> AppResult {
sidecar::status(&state).await
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index d53675f..cbf8f44 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -110,6 +110,8 @@ pub fn run() {
commands::server::start_tailscale_serve,
commands::server::stop_tailscale_serve,
commands::server::tailscale_status,
+ commands::server::tailscale_sign_in,
+ commands::server::open_url,
commands::dkg::start_dkg,
commands::dkg::cancel_ceremony,
commands::signing::create_signing_session,
diff --git a/src-tauri/src/tailscale.rs b/src-tauri/src/tailscale.rs
index fab2f98..24d34fe 100644
--- a/src-tauri/src/tailscale.rs
+++ b/src-tauri/src/tailscale.rs
@@ -48,6 +48,10 @@ pub struct TailscaleHandle {
#[derive(Serialize, Clone)]
pub struct TailscaleStatus {
+ /// The `tailscale` CLI was found on this machine (regardless of whether it is
+ /// signed in). Drives whether the UI offers "Get Tailscale" (install) vs
+ /// "Sign in to Tailscale".
+ pub installed: bool,
/// The `tailscale` CLI was found, the daemon is running, the machine is
/// signed in and online, and a MagicDNS name is available — i.e. `serve`
/// can be started.
@@ -69,6 +73,7 @@ pub struct TailscaleStatus {
impl TailscaleStatus {
fn unavailable(detail: impl Into) -> Self {
TailscaleStatus {
+ installed: false,
available: false,
serving: false,
public_url: None,
@@ -79,6 +84,15 @@ impl TailscaleStatus {
}
}
+/// Result of triggering Tailscale sign-in.
+#[derive(Serialize, Clone)]
+pub struct SignInResult {
+ /// A URL the user must open to finish authenticating. `None` means sign-in
+ /// completed without needing one (already signed in, or a desktop Tailscale
+ /// app opened the browser itself) — the status will flip to available shortly.
+ pub login_url: Option,
+}
+
/// Candidate `tailscale` binary locations: PATH first (bare name; the OS
/// resolves it), then the well-known per-platform install paths the GUI apps use
/// but which are often not on a GUI-launched app's PATH (notably macOS).
@@ -238,6 +252,7 @@ pub async fn start(state: &AppState, port: u16) -> AppResult {
});
Ok(TailscaleStatus {
+ installed: true,
available: true,
serving: true,
public_url: Some(public_url),
@@ -257,6 +272,69 @@ pub async fn stop(state: &AppState) -> AppResult<()> {
Ok(())
}
+/// Trigger Tailscale sign-in by running `tailscale up`. When the machine isn't
+/// signed in yet, `tailscale up` prints a login URL and waits for the user to
+/// authenticate in a browser; we capture that URL (with a short timeout) and hand
+/// it back so the UI can open it, letting the `up` process finish in the
+/// background. When it's already signed in (or a desktop app handles the browser),
+/// no URL is produced and the status poll picks up the change.
+pub async fn sign_in() -> AppResult {
+ use tokio::io::{AsyncBufReadExt, BufReader};
+
+ let bin = resolve_bin().await.ok_or_else(|| {
+ AppError::new(
+ "tailscale",
+ "Tailscale CLI not found. Install Tailscale first, then sign in.",
+ )
+ })?;
+
+ let mut child = Command::new(&bin)
+ .arg("up")
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| AppError::new("tailscale", format!("running `tailscale up`: {e}")))?;
+
+ // `tailscale up` prints the login URL to stderr. Read lines until we see it or
+ // the process finishes its output, bounded so we never hang the command.
+ let stderr = child.stderr.take();
+ let login_url = if let Some(stderr) = stderr {
+ let mut lines = BufReader::new(stderr).lines();
+ let mut found = None;
+ // Loop ends when the pattern fails to match: a timeout, EOF (process done
+ // printing), or a read error — any of which means "stop looking".
+ while let Ok(Ok(Some(line))) =
+ tokio::time::timeout(std::time::Duration::from_secs(15), lines.next_line()).await
+ {
+ if let Some(u) = extract_login_url(&line) {
+ found = Some(u);
+ break;
+ }
+ }
+ found
+ } else {
+ None
+ };
+
+ // Reap the child in the background so it can finish authenticating (or exit)
+ // without leaving a zombie, and without us blocking on it here.
+ tokio::spawn(async move {
+ let _ = child.wait().await;
+ });
+
+ Ok(SignInResult { login_url })
+}
+
+/// Extract a `https://login.tailscale.com/...` URL from a line, if present.
+fn extract_login_url(line: &str) -> Option {
+ let start = line.find("https://login.tailscale.com")?;
+ let rest = &line[start..];
+ let end = rest
+ .find(|c: char| c.is_whitespace())
+ .unwrap_or(rest.len());
+ Some(rest[..end].to_string())
+}
+
/// Report Tailscale availability and whether Cyze is currently serving. Safe to
/// call any time (drives the UI); never errors — availability problems are
/// reported in `detail`.
@@ -282,8 +360,11 @@ pub async fn status(state: &AppState) -> TailscaleStatus {
}
};
+ // The binary was found, so Tailscale is installed even when it's not yet
+ // signed in / online.
match probe_dns_name(&bin).await {
Ok(Ok(dns_name)) => TailscaleStatus {
+ installed: true,
available: true,
serving,
public_url,
@@ -293,6 +374,7 @@ pub async fn status(state: &AppState) -> TailscaleStatus {
},
Ok(Err(reason)) => {
let mut s = TailscaleStatus::unavailable(reason);
+ s.installed = true;
s.serving = serving;
s.public_url = public_url;
s.port = port;
@@ -300,6 +382,7 @@ pub async fn status(state: &AppState) -> TailscaleStatus {
}
Err(e) => {
let mut s = TailscaleStatus::unavailable(e.message);
+ s.installed = true;
s.serving = serving;
s.public_url = public_url;
s.port = port;
@@ -344,4 +427,19 @@ mod tests {
assert_eq!(first_line("\n \n hello \nworld"), Some("hello"));
assert_eq!(first_line(" "), None);
}
+
+ #[test]
+ fn extract_login_url_pulls_the_auth_link() {
+ let line = "To authenticate, visit:\n\n\thttps://login.tailscale.com/a/abc123def ";
+ assert_eq!(
+ extract_login_url(line).as_deref(),
+ Some("https://login.tailscale.com/a/abc123def")
+ );
+ assert_eq!(extract_login_url("Success."), None);
+ // Stops at whitespace, so trailing prose doesn't get glued on.
+ assert_eq!(
+ extract_login_url("visit https://login.tailscale.com/a/x then return").as_deref(),
+ Some("https://login.tailscale.com/a/x")
+ );
+ }
}
diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts
index 735e022..47f988c 100644
--- a/src/ipc/commands.ts
+++ b/src/ipc/commands.ts
@@ -68,6 +68,9 @@ export interface TunnelStatus {
}
export interface TailscaleStatus {
+ /** The tailscale CLI is present (may still be signed out). Drives whether the
+ * UI offers "Get Tailscale" vs "Sign in". */
+ installed: boolean;
/** Tailscale is installed, signed in, online — serve can be started. */
available: boolean;
/** Cyze currently has `serve` active in front of the embedded server. */
@@ -368,6 +371,15 @@ export const startTailscaleServe = () =>
invoke("start_tailscale_serve");
export const stopTailscaleServe = () => invoke("stop_tailscale_serve");
export const tailscaleStatus = () => invoke("tailscale_status");
+/** Result of triggering Tailscale sign-in. */
+export interface SignInResult {
+ /** URL to open to finish authenticating, or null if none was needed. */
+ login_url: string | null;
+}
+/** Run `tailscale up`; returns a login URL to open when auth is needed. */
+export const tailscaleSignIn = () => invoke("tailscale_sign_in");
+/** Open a URL in the default browser (http/https only). */
+export const openUrl = (url: string) => invoke("open_url", { url });
// Ceremonies
export type Ciphersuite = "ed25519" | "redpallas";
diff --git a/src/screens/SessionSetup.tsx b/src/screens/SessionSetup.tsx
index 12471b8..de4a50a 100644
--- a/src/screens/SessionSetup.tsx
+++ b/src/screens/SessionSetup.tsx
@@ -13,6 +13,8 @@ import {
tailscaleStatus,
startTailscaleServe,
stopTailscaleServe,
+ tailscaleSignIn,
+ openUrl,
TailscaleStatus,
setServerUrl,
setSessionConfig,
@@ -510,6 +512,25 @@ function TailscaleExposure({
}) {
const serving = status?.serving ?? false;
const url = status?.public_url ?? null;
+ const [loginUrl, setLoginUrl] = useState(null);
+ const [signInNote, setSignInNote] = useState(null);
+
+ const signIn = useMutation({
+ mutationFn: tailscaleSignIn,
+ onSuccess: (r) => {
+ if (r.login_url) {
+ setLoginUrl(r.login_url);
+ setSignInNote(null);
+ openUrl(r.login_url).catch(() => {});
+ } else {
+ // No URL needed: already signed in, or a desktop app opened the browser.
+ setLoginUrl(null);
+ setSignInNote(
+ "Signing in… if a browser didn't open, finish it in the Tailscale app. This updates automatically."
+ );
+ }
+ },
+ });
return (
@@ -519,11 +540,8 @@ function TailscaleExposure({
an automatic, publicly-trusted TLS certificate — so the URL can be saved
and reused across launches, with no cert-trust step. Access is limited to
your tailnet (not the public internet). Requires{" "}
-
- Tailscale
- {" "}
- installed and signed in on this machine, and every participant on the same
- tailnet.
+ Tailscale installed and signed in on this machine, and
+ every participant on the same tailnet.
{loading && !status ? (
@@ -562,12 +580,48 @@ function TailscaleExposure({
{pending ? "Publishing…" : "Publish to tailnet"}
>
+ ) : status?.installed ? (
+ // Installed but not signed in / not online: offer one-click sign-in.
+
-
- {status?.detail ??
- "Tailscale is not available. Install it and sign in, then reopen this tab."}
-
+ // Not installed: link to the download.
+
+
+
+ {status?.detail ??
+ "Tailscale isn't installed on this machine."}
+
+
+
)}
From 06d176c3d07de49660dd5f2053f276b6c366c809 Mon Sep 17 00:00:00 2001
From: blocknodes
Date: Thu, 6 Aug 2026 04:19:16 +0000
Subject: [PATCH 4/5] fix(ui): Tailscale on Server screen, resilient
Get-Tailscale, tidy join list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add a shared TailscalePanel and surface it under "Host a server here" on the
1 · Setup → Server screen, alongside the Cloudflare tunnel option.
- Reuse the same panel in Session Configuration (drops the duplicated inline
TailscaleExposure and its now-dead query/mutations).
- Stop swallowing browser-open failures: Get Tailscale / sign-in links now
surface errors and always show a copyable fallback link.
- Rebuild the participant "I'm joining" URL examples as an aligned two-column
grid instead of a / blob.
Co-Authored-By: Claude Opus 4.8
---
src/components/TailscalePanel.tsx | 202 +++++++++++++++++++++++++++
src/screens/ServerSettings.tsx | 6 +
src/screens/SessionSetup.tsx | 222 +++++-------------------------
3 files changed, 241 insertions(+), 189 deletions(-)
create mode 100644 src/components/TailscalePanel.tsx
diff --git a/src/components/TailscalePanel.tsx b/src/components/TailscalePanel.tsx
new file mode 100644
index 0000000..6339a6c
--- /dev/null
+++ b/src/components/TailscalePanel.tsx
@@ -0,0 +1,202 @@
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ tailscaleStatus,
+ startTailscaleServe,
+ stopTailscaleServe,
+ tailscaleSignIn,
+ openUrl,
+ AppError,
+} from "../ipc/commands";
+
+const DOWNLOAD_URL = "https://tailscale.com/download";
+
+function CopyButton({ text, label }: { text: string; label: string }) {
+ const [copied, setCopied] = useState(false);
+ return (
+
+ );
+}
+
+/** Open a URL in the system browser, surfacing failure instead of swallowing it.
+ * Auto-open can silently do nothing (no `xdg-open`, headless session, blocked
+ * handler); when it does, the caller shows the link so the user can copy it. */
+function useBrowserOpen() {
+ const [failedUrl, setFailedUrl] = useState(null);
+ const open = (url: string) => {
+ setFailedUrl(null);
+ openUrl(url).catch(() => setFailedUrl(url));
+ };
+ return { open, failedUrl };
+}
+
+/** Shared Tailscale "publish this server to your tailnet" panel, used both on the
+ * Server screen and in Session Configuration. Owns its own status polling and
+ * serve/sign-in mutations so callers just drop it in.
+ *
+ * `active` gates polling: the Session Configuration screen only wants to shell
+ * out to the `tailscale` CLI while its tab is showing. */
+export default function TailscalePanel({
+ serverRunning,
+ active = true,
+}: {
+ serverRunning: boolean;
+ active?: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const browser = useBrowserOpen();
+ const [signInNote, setSignInNote] = useState(null);
+ const enabled = active && serverRunning;
+ const status = useQuery({
+ queryKey: ["tailscale"],
+ queryFn: tailscaleStatus,
+ enabled,
+ refetchInterval: enabled ? 5000 : false,
+ });
+
+ const serve = useMutation({
+ mutationFn: startTailscaleServe,
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }),
+ });
+ const stopServe = useMutation({
+ mutationFn: stopTailscaleServe,
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tailscale"] }),
+ });
+ const signIn = useMutation({
+ mutationFn: tailscaleSignIn,
+ onSuccess: (r) => {
+ if (r.login_url) {
+ setSignInNote(null);
+ browser.open(r.login_url);
+ } else {
+ // No URL needed: already signed in, or a desktop app opened the browser.
+ setSignInNote(
+ "Signing in… if a browser didn't open, finish it in the Tailscale app. This updates automatically."
+ );
+ }
+ },
+ });
+
+ const s = status.data ?? null;
+ const serving = s?.serving ?? false;
+ const url = s?.public_url ?? null;
+ const loginUrl = signIn.data?.login_url ?? null;
+
+ return (
+
+
+ Publishes this server to your tailnet at a{" "}
+ stable*.ts.net address with
+ an automatic, publicly-trusted TLS certificate — so the URL can be saved
+ and reused across launches, with no cert-trust step. Access is limited to
+ your tailnet (not the public internet). Requires{" "}
+ Tailscale installed and signed in on this machine, and
+ every participant on the same tailnet.
+
+
+ {serve.isError && (
+
{(serve.error as unknown as AppError).message}
+ )}
+
+ {!serverRunning ? (
+
+ Start the embedded server first, then publish it to your tailnet here.
+
+ ) : status.isLoading && !s ? (
+
Checking Tailscale…
+ ) : serving && url ? (
+ <>
+
+ serving on tailnet
+
+
+
{url}
+
+
+
+
+
+
+ This address is stable — save it as the group's server and reuse it
+ next time. Participants must be on your tailnet to reach it.
+
+
- Publishes this server to your tailnet at a{" "}
- stable*.ts.net address with
- an automatic, publicly-trusted TLS certificate — so the URL can be saved
- and reused across launches, with no cert-trust step. Access is limited to
- your tailnet (not the public internet). Requires{" "}
- Tailscale installed and signed in on this machine, and
- every participant on the same tailnet.
-
-
- {loading && !status ? (
-
Checking Tailscale…
- ) : serving && url ? (
- <>
-
- serving on tailnet
-
-
-
{url}
-
-
-
-
-
-
- This address is stable — save it as the group's server and reuse it
- next time. Participants must be on your tailnet to reach it.
-
-
- ) : (
- // Not installed: link to the download.
-
-
-
- {status?.detail ??
- "Tailscale isn't installed on this machine."}
-
-
-
-
- )}
-
- );
-}
-
function NginxExposure({ port }: { port: number }) {
const conf = useMemo(
() =>
@@ -749,26 +584,35 @@ function ParticipantPath() {
placeholder="https://…"
style={{ width: "100%" }}
/>
-
- Paste the address the coordinator is sharing right now. It looks like one
- of:
-
- • https://frost.example.com{" "}
- — a domain / NGINX server
-
- • https://203.0.113.7:2744{" "}
- — a direct IP and port
-
- • https://long-random-words.trycloudflare.com{" "}
- — a Cloudflare tunnel
-
- • https://their-machine.tailnet.ts.net{" "}
- — a Tailscale address (you must be on the same tailnet)
-
- A Cloudflare tunnel URL is disposable: the coordinator
- gets a new one each time they restart it, so always use the latest. A
- Tailscale .ts.net address is{" "}
- stable — save it once and reuse it.
+
+
+ Paste the address the coordinator is sharing right now. It looks like
+ one of:
+
+
+ https://frost.example.com
+ a domain / NGINX server
+ https://203.0.113.7:2744
+ a direct IP and port
+ https://long-random-words.trycloudflare.com
+ a Cloudflare tunnel
+ https://their-machine.tailnet.ts.net
+ a Tailscale address (you must be on the same tailnet)
+
+
+ A Cloudflare tunnel URL is disposable: the coordinator
+ gets a new one each time they restart it, so always use the latest. A
+ Tailscale .ts.net address is{" "}
+ stable — save it once and reuse it.
+
From 614bb764c8ae6361e0cb877772f4ec41900c8c4d Mon Sep 17 00:00:00 2001
From: blocknodes
Date: Sat, 15 Aug 2026 23:41:07 +0000
Subject: [PATCH 5/5] docs(uat): cover log viewer + this turn's Tailscale fixes
- Part B: note the two Tailscale entry points (Session Config + Server screen);
add copyable-link fallback checks to B1a/B1b (the Get-Tailscale "nothing
happens" fix); new B1c for the Server-screen Tailscale sub-section; new B8 for
the cleaned-up participant "I'm joining" URL list.
- New Part C: in-app Diagnostics log card (Copy all / Refresh / Clear / Live),
live-update, persistence boundary, and a no-secrets spot-check.
Steps verified against the actual UI (LogsCard labels, TailscalePanel states).
Co-Authored-By: Claude Opus 4.8
---
docs/UAT.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 77 insertions(+), 3 deletions(-)
diff --git a/docs/UAT.md b/docs/UAT.md
index 282e60e..2e1b4a2 100644
--- a/docs/UAT.md
+++ b/docs/UAT.md
@@ -87,8 +87,12 @@ and the mapping is cleaned up correctly. `serve` is tailnet-only (not public).
- [ ] (For B6) a device **not** on the tailnet, to confirm scoping.
## B1. Detection states (before serving)
-Open Session Setup → Coordinator → **Tailscale** tab and verify the guidance
-matches reality:
+The Tailscale panel now appears in **two** places (same component, same
+behavior): **Zcash → Session Configuration → Coordinator → Tailscale** tab, and
+**1 · Setup → Server → Host a server here → Tailnet access (Tailscale)**. Run B1
+on the Session Configuration tab; B1c re-checks the Server-screen copy.
+
+Verify the guidance matches reality:
- [ ] **Signed in & online** → tab shows this machine's `https://.ts.net`
and a **"Publish to tailnet"** button.
- [ ] **Signed out** (`tailscale logout`) → shows a "not connected" message and a
@@ -101,6 +105,12 @@ matches reality:
## B1a. Get Tailscale (not-installed friction)
- [ ] On a machine without Tailscale, click **Get Tailscale** → the system default
browser opens `https://tailscale.com/download` (not an in-app webview).
+- [ ] The download URL is **also shown as copyable text** beside the button
+ (`Copy link` works), so it's reachable even if the browser didn't open.
+- [ ] **Fallback path:** if the browser does **not** open automatically, the line
+ reads "Couldn't open your browser automatically…" — not silent (the earlier
+ bug was that clicking did nothing). Copy the link and it opens Tailscale's
+ download page.
- [ ] Install Tailscale, then reopen the tab → it now shows the **Sign in** state
(installed, not yet connected).
@@ -111,9 +121,23 @@ matches reality:
- [ ] Complete auth in the browser; within a few seconds the tab **auto-updates**
to the signed-in state (shows the `.ts.net` name + Publish button) with no
manual refresh.
+- [ ] If the browser can't be opened automatically, a **"Couldn't open your
+ browser automatically. Copy this link:"** line with the login URL + a
+ `Copy link` button appears (no silent no-op).
- [ ] (Linux note) If `tailscale up` needs elevated rights on this host, the tab
surfaces that instead of hanging — the operator/sudo message is shown.
+## B1c. Second entry point — Server screen
+- [ ] Go to **1 · Setup → Server**, expand **Host a server here**. Under the
+ Cloudflare tunnel section there is a **"Tailnet access (Tailscale)"**
+ sub-section.
+- [ ] It shows the **same** state as the Session Configuration tab did in B1
+ (not-installed / signed-out / ready-to-publish), driven by the same status.
+- [ ] With the embedded server **not** started, it prompts to start the server
+ first; with it started, the Publish/Sign-in/Get-Tailscale action matches B1.
+- [ ] Publishing from **either** screen and stopping from the other stays
+ consistent (one shared serve mapping, not two).
+
## B2. Happy path — publish
- [ ] Start the embedded server (Step 1).
- [ ] Tailscale tab → **Publish to tailnet** → badge **"serving on tailnet"** and
@@ -150,11 +174,61 @@ matches reality:
- [ ] With serve up and a participant joined via the `.ts.net` URL, run a real
**signing** (or DKG) ceremony to completion over the tailnet transport.
+## B8. Participant "I'm joining" URL list (formatting)
+- [ ] As a **participant**: Zcash → Session Configuration → **I'm joining** →
+ "Connect to the coordinator's server".
+- [ ] The four example addresses (domain, direct IP, Cloudflare, Tailscale) render
+ as a **clean two-column list** — example URLs in the left column, their
+ descriptions aligned in the right — not a run-on line with `•`/stray spacing
+ (the earlier messy layout).
+- [ ] The block reads correctly at a narrow window width (no horizontal overflow,
+ descriptions stay aligned).
+
## B — Sign-off
- [ ] B1a/B1b: **Get Tailscale** opens the download page and **Sign in** drives
- `tailscale up` to a connected state, with the tab auto-updating.
+ `tailscale up` to a connected state, with the tab auto-updating; the
+ copyable-link fallback shows when the browser can't be opened.
+- [ ] B1c: the Server-screen Tailscale sub-section mirrors the Session
+ Configuration tab and shares one serve mapping.
- [ ] B2–B5 pass; the URL is stable across relaunch and cleaned up on stop/quit.
- [ ] B6 confirms tailnet-only scoping.
- [ ] B7 completes a real ceremony over the transport.
+- [ ] B8: the participant join-URL list is cleanly aligned.
- [ ] Note the Tailscale CLI version tested here: ____________ (so we know which
`serve` grammar was validated).
+
+---
+
+# Part C — In-app log viewer (on `main`; present on both branches)
+
+Goal: the app captures its own `tracing` output to an in-memory buffer and shows
+it in the UI, so a tester can copy logs and share them back without hunting for a
+terminal. Bounded (~3000 lines), in-memory only, cleared on restart. Lives at
+**Zcash → Wallet Settings**, the **"Diagnostics log"** card near the bottom.
+
+## C1. Card shows live output
+- [ ] Open **Zcash → Wallet Settings** and find the **Diagnostics log** card.
+- [ ] It already contains startup lines (the buffer captures from app start, so
+ it is not empty on first open). The header shows an **"N lines · this
+ session"** count.
+- [ ] The **Live** toggle is on by default: do something that logs — e.g. **Sync
+ Now** on the active wallet — and within a couple of seconds new lines appear
+ **without** clicking anything. **Refresh** forces an immediate update.
+- [ ] Lines are oldest-first; the view stays pinned to the newest line while Live,
+ unless you scroll up to read older output.
+- [ ] Un-checking **Live** stops the auto-updates (count holds until Refresh).
+
+## C2. Copy & clear
+- [ ] **Copy all** → button flips to "Copied!"; paste into a scratch file and
+ confirm it matches the text shown (Copy all is disabled when empty).
+- [ ] **Clear** empties the buffer (button disabled when already empty);
+ subsequent activity repopulates it.
+
+## C3. Persistence boundary
+- [ ] Fully quit and relaunch Cyze → the card starts fresh (in-memory only, not
+ persisted across runs). Only new-session lines are present.
+
+## C — Sign-off
+- [ ] Card is populated from app start, auto-updates on activity while Live, and
+ Copy all / Refresh / Clear work. Buffer resets on relaunch. Spot-check the
+ captured lines expose nothing sensitive (no passphrases / key material).