fix(desktop): surface Tailscale Serve failures and fix AppImage restarts on Linux - #4698
fix(desktop): surface Tailscale Serve failures and fix AppImage restarts on Linux#4698GenKerensky wants to merge 20 commits into
Conversation
Preflight `tailscale serve` when enabling HTTPS from the desktop app so failures (e.g. Serve disabled on the tailnet) show an error toast with the same configure URL the CLI prints, instead of silently restarting with an unchecked toggle.
After a successful Serve preflight, disable Serve when writing desktop settings fails so HTTPS exposure is not left active while UI/settings still report disabled.
Align DesktopTailscaleServeConfigureError with Effect service conventions: preserve the underlying TailscaleCommandError as optional cause, store only safe structural detail/configureUrl fields, and derive message from those attributes instead of a free-form reason string.
Electron's process.execPath inside an AppImage points at the temporary mount, which is unmounted on exit. Use the APPIMAGE env path so restart actually brings the app back up.
Electron.relaunch spawns the new process while the current one still holds the single-instance lock, so the child immediately exits after requestSingleInstanceLock fails. Release the lock first, and preserve AppImage flag args such as --no-sandbox from Gear Lever launches.
Electron.relaunch starts the next AppImage while the current FUSE mount is still tearing down, which surfaces "Cannot mount AppImage". Schedule a detached delayed re-exec of \$APPIMAGE after releasing the single-instance lock instead.
Tailscale Serve only proxies to loopback, so MagicDNS discovery should not depend on LAN network-accessible mode. Always resolve Tailscale endpoints (with the existing status cache) so the settings toggle appears independently.
Keep the pingdotgg#2745 passive gate so Connections mounts do not spawn `tailscale status` while local-only and Serve is off. Always show the Tailscale HTTPS toggle and resolve MagicDNS via a dedicated IPC path when the user enables it, so Serve stays independent of LAN network access.
…unch Surface the main-process CLI guidance in the enable/disable/resolve toasts instead of Electron's raw `Error invoking remote method '<channel>': ...` wrapper, and branch on `openExternal`'s boolean result — it resolves `false` rather than rejecting, so the "Open setup" `window.open` fallback was dead code and the button could silently no-op. Only roll Serve back when the preflight actually created the binding. Over an already-enabled Serve on the same port `tailscale serve --bg` is a no-op, so a settings-write failure would tear down a live HTTPS endpoint that settings still record as enabled. Force a fresh `tailscale status` on user-initiated endpoint resolution. The MagicDNS cache stores a failed lookup as a successful `null` for its TTL, so the toast's "Start Tailscale and try again" advice kept failing for a minute. Anchor the detached AppImage re-exec helper at `cwd: "/"`; its inherited cwd can sit inside the `$APPDIR` FUSE mount unmounted during the sleep. Use a namespace import for `node:child_process` so `vp lint` stops erroring on `t3code(namespace-node-imports)`, drop the never-called `resolveDesktopRelaunchOptions` / `DesktopRelaunchOptions`, and fix the useless-escape warning in both copies of the Serve configure-URL pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
Disabling Tailscale HTTPS relied on the child server's acquireRelease finalizer, which only exists when that child booted with Serve enabled. After a relaunch failure — which `lifecycle.relaunch` logs and swallows — the old child has no finalizer, so toggling off persisted `enabled: false` while `tailscale serve --https=<port>` stayed live on the tailnet. Disable from the main process instead, and surface a failure rather than recording a teardown we could not perform. Route spawn/timeout/output CLI failures through formatTailscaleServeUserMessage so the toast says "Could not run the tailscale CLI. Is Tailscale installed and on PATH?" instead of the generic "on port N" fallback. That is the most common way enabling fails, and it was the one case the new error path did not explain. Await the detached AppImage helper's spawn before exiting. `spawn` reports ENOENT/EAGAIN asynchronously, so a failure previously fell through to exit(0): the app vanished and never came back, with nothing logged. Release the single-instance lock only once the relaunch is committed — both paths start the successor after this process exits, so the ordering is safe, and on failure we stay running with the lock held instead of letting the next launch open a second full instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
…ts dialogs Enabling resolves MagicDNS through the tailscale CLI before any dialog opens, and the eager teardown made disabling a CLI round-trip too. The switch was already gated on `isUpdatingTailscaleServe`, but a dimmed switch was the only feedback during that wait, so add the house `Spinner` beside it — matching the spinner the confirm dialogs already show on their action buttons. Both dialogs already stayed open on failure (they close only after a successful await), but the reason reached the user solely as a toast, which is easy to miss behind a modal. Render the failure inline in each dialog instead, carrying the admin setup link as an action so the fix is one click from the error. Clear the message when a dialog opens so a previous attempt's failure is not waiting there, and reuse one openExternal helper for both the toast and the alert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
The Tailscale handlers wrote to desktopServerExposureMutationError, which is only rendered in the "Network access" row's status slot. A failed Tailscale enable therefore printed its error under the wrong control, pointing the user at a setting that had nothing to do with the failure. Give Tailscale Serve its own error state and render it in the Tailscale HTTPS row. The two settings are independent — network access is LAN exposure, Serve is a tailnet proxy — so they need independent status lines. Found by driving the built desktop app: with `tailscale serve` refused by the tailnet, the message appeared beneath "Limited to this machine." Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
The alert stripped the admin URL out of the CLI message on the grounds that the "Open setup" button already carried it. But the CLI phrases this as "... To enable, visit: <url>", so removing the URL left a dangling colon pointing at nothing — an empty slot next to a button, which reads as a rendering bug. Show the whole message and let `wrap-anywhere` break the URL inside the dialog's narrow column. The button stays a shortcut rather than the only way to reach the link: the URL is now selectable and copyable, and the dialog matches what the toast already displayed. Also wrap the settings-row status line, which can carry the same long URL. Verified in the built desktop app against a tailnet that refuses Serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
Drive the Tailscale HTTPS switch from the persisted setting rather than endpoint reachability. `status` comes from an HTTP probe of the MagicDNS URL, so a cert still provisioning or a momentary tailnet blip rendered the switch off while `tailscale serve` was live — and because the disable flow sits behind the on state, there was then no way to turn it off. This got worse when the row started rendering unconditionally. Always append "It may still be reachable on your tailnet" on the disable path. Routing spawn/timeout failures through formatTailscaleServeUserMessage populated `detail`, which suppressed that branch of the message, so a teardown blocked by a missing CLI reported only "Could not run the tailscale CLI" — reading like a no-op when exposure in fact persists. Regression from the earlier fix; covered by a test now. Replace the window.open fallback with an explicit toast. The main window's setWindowOpenHandler routes window.open back into the same openExternal and denies the popup, so on refusal the button was still a silent no-op — the boolean check alone did not fix it. The URL is in the alert text and selectable, so the toast points there. Reject out-of-range Serve ports at the IPC boundary; settings persistence normalizes invalid values to 443, so a bare Schema.Number let the CLI bind one port while settings recorded another. Round the AppImage helper's sleep to whole seconds, since POSIX specifies sleep as taking an integer and a shell rejecting "1.0" would short-circuit the exec with nothing logged. Gate the helper's test on /bin/sh existing rather than assuming a POSIX dev machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces new capabilities (AppImage relaunch mechanism, Tailscale HTTPS endpoint resolution IPC) and makes significant runtime behavior changes to application lifecycle and network exposure management. Additionally, there is an unresolved high-severity finding about potential network exposure if relaunch fails after a port change. You can customize Macroscope's approvability policy. Learn more. |
…nmount Found by running a real AppImage. The relaunch worked — new instance, no "Cannot mount AppImage" — but each restart left the previous AppImage's FUSE mount in place and its runtime process parked in `fuse_dev_do_read` forever. Two restarts, two stranded mounts. Chromium holds fds open without CLOEXEC (`app.asar`, `icudtl.dat`, `v8_context_snapshot.bin`, the `.pak` files, and the mount directory itself), and `spawn` cannot close them: `stdio: "ignore"` only covers 0-2. The detached helper inherited nine of them and carried them through `exec` into the new runtime, the new Electron, and the backend — all pinning a mount they no longer have any use for. Setting `cwd: "/"` closed the cwd channel; this is the other one. Close every fd above 2 before sleeping, so the unmount happens during the delay rather than never. The multi-digit `exec N>&-` sits inside an `eval` string deliberately: shells disagree about fd numbers above 9, and a parse error in the script body would mean never reaching the exec — an app that quits and never returns. Deferring the parse makes the worst case "fds stay open" rather than "the app is gone". Verified on a packaged AppImage across two relaunch cycles (enable, then disable): one mount and one runtime process throughout, each relaunch logging `kind: appimage-delayed`, Serve re-binding to the new backend port and tearing down cleanly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
Bugbot was right about dash and my reasoning was wrong. `eval "exec 10>&-"` is not parse-protected: dash reads `10` as a command name, and a failed `exec` terminates a non-interactive shell immediately, so `2>/dev/null || true` never runs. On Debian and Ubuntu — where /bin/sh is dash — the helper would die before the re-exec, after the parent had already released the single-instance lock and exited. The app would have vanished for good, which is the exact bug this PR exists to fix. Reproduced in a Debian container with an inherited multi-digit fd: the old command exits 127 without relaunching; the new one reaches the relaunch. The fd cleanup is now emitted only when the helper runs under bash, which every AppImage-shipping distribution has; /bin/sh gets a plain sleep-and-exec that relaunches correctly but leaves the old mount behind. Exit rather than linger when the relaunch cannot be scheduled. `quitting` is already set and the backend already torn down by that point, so staying alive just held the single-instance lock on a dead instance and every later launch focused *this* window instead of starting a usable one. My earlier comment claiming the lock protected the user had it backwards. Re-enable Serve when persisting a disable fails, mirroring the enable path's rollback. Serve was being torn down before the write with no compensation, so a failed write left settings and stateRef advertising an HTTPS endpoint that no longer answered. Route the helper's stderr to a relaunch log. The delayed `exec` fires after we have exited, so a missing or non-executable AppImage could not otherwise be reported at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
Reconciles this branch with pingdotgg#4556, which replaced the free-form `detail` and `configureUrl` fields on TailscaleCommandExitError with a closed `stderrDiagnostic` label set, on the grounds that stderr can carry auth keys and the field is logged. This branch needs to name a Serve failure and link the admin page, so rather than picking a side the label set is extended: - Adds `serve-not-enabled` and `no-https-certs` labels with their stderr patterns, so the two failures this PR reports are classified rather than quoted. - Adds `describeTailscaleStderrDiagnostic`, which turns a label into prose at the UI edge. The shared `message` deliberately stays generic, because that is the string pingdotgg#4556 was protecting — main's test asserting the generic form still holds unchanged. - Restores `configureUrl` as a separate field. It is exempt from the label-only rule because it is not lifted text: it is matched against a fixed pattern and validated to `https://login.tailscale.com/f/serve...`, so it cannot carry stderr contents, and without it there is no way to point the user at the fix. The desktop layer now derives its toast prose from the label via `describeTailscaleServeExitCause` instead of reading `cause.detail`. Note: `apps/desktop` DesktopObservability and DesktopBackendManager tests fail on this merge, and `packages/shared` plus `scripts` have typecheck errors. All are in main's own files — DesktopBackendManager is traceable to main adding `OtlpExporter.layerFlusher` — and are left untouched rather than fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
|
Pushed On the Bugbot dash finding — it was right and my reasoning was wrong, so calling it out rather than burying it in a thread. I had claimed
The cleanup is now emitted only when the helper runs under bash. On the merge — worth flagging because it was not mechanical. #4556 replaced Not mine, but visible on this branch: |
…unch
Both findings from the latest Bugbot pass are real, and both are on the log
redirect I added one commit ago to make delayed exec failures diagnosable.
`{ sleep && exec ...; } 2>>log` fails outright when the log's directory is
missing or unwritable — the shell never reaches the re-exec, and the parent has
already exited. Verified in a Debian container: bash exits 1 and dash exits 2,
neither relaunching. That traded a rare silent failure for a guaranteed one,
which is the opposite of the point.
The same redirection also outlived a *successful* exec, so the relaunched app
would spend its entire run appending Chromium stderr to an unrotated file.
Test for the AppImage instead and append the failure line after the exec, with
`2>/dev/null` so a bad log path cannot matter. Verified in both shells: a
missing log directory still relaunches, a missing AppImage leaves the message
in the log, and fd 2 after a successful exec is the inherited stdio rather than
the log file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4b1bf3c. Configure here.
`tailscale serve --https=<port> off` exits 1 with "handler does not exist"
when nothing is bound on that port. Confirmed against the real CLI:
$ tailscale serve --https=8443 off
error: failed to remove web serve: handler does not exist
exit=1
The eager teardown treated that as a failure, so settings never recorded
`enabled: false`: the toggle stayed on and the user was warned the backend
might still be reachable, when in fact nothing was serving. It bit hardest in
precisely the case the eager teardown was added for — settings saying enabled
while no child ever bound Serve — leaving the user unable to turn it off at all.
Upstream anticipated this: pingdotgg#4556's label set carries `no-existing-handler` with
a comment naming `serve off` on an unmapped port as the case callers should
recognize. Match on it and continue.
The rollback is now armed only when a binding was actually removed. Re-creating
one that never existed would expose the backend rather than restore it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
`Effect.tapError` only observes typed failures. If the fiber is interrupted after `ensureTailscaleServe` succeeded but before or during the settings write — the app quitting mid-toggle is the obvious way — the rollback was skipped and the new binding stayed live on the tailnet while persisted settings still said `enabled: false`. A defect in the settings write had the same effect. That is the silent-exposure case this whole path exists to prevent, so switch to `Effect.onExit` and compensate on every non-success exit. The finalizer covers both directions: removing a preflighted binding, or restoring one the eager teardown removed. Covered by a test that makes the settings write die and asserts the CLI saw `serve --bg` followed by `serve off`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
| // was already enabled on the same port, `tailscale serve --bg` is a | ||
| // no-op and disabling it on a persistence failure would tear down a | ||
| // working HTTPS endpoint that settings still record as enabled. | ||
| preflightedServePort = |
There was a problem hiding this comment.
🟠 High backend/DesktopServerExposure.ts:639
When the user toggles Tailscale Serve enable and specifies a new servePort, ensureTailscaleServe creates a binding on the new port in the preflight, but the preflighted port is only recorded for rollback when a persistence failure occurs. If persistence succeeds but the subsequent lifecycle.relaunch fails (a scenario this code explicitly handles on the disable path), the new-port binding remains live on the tailnet with no child finalizer to remove it — the still-running child only cleans up the old port on shutdown. Consider tracking the preflighted binding through the relaunch step so it is torn down when relaunch fails, or transferring ownership of the new binding without relying solely on a successful relaunch.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/backend/DesktopServerExposure.ts around line 639:
When the user toggles Tailscale Serve enable and specifies a new `servePort`, `ensureTailscaleServe` creates a binding on the new port in the preflight, but the preflighted port is only recorded for rollback when a *persistence failure* occurs. If persistence succeeds but the subsequent `lifecycle.relaunch` fails (a scenario this code explicitly handles on the disable path), the new-port binding remains live on the tailnet with no child finalizer to remove it — the still-running child only cleans up the old port on shutdown. Consider tracking the preflighted binding through the relaunch step so it is torn down when relaunch fails, or transferring ownership of the new binding without relying solely on a successful relaunch.
There was a problem hiding this comment.
I don't think this one is a defect — pushing back rather than changing it, but happy to be shown wrong.
The scenario is: enable with a new servePort, preflight binds it, persistence succeeds, then lifecycle.relaunch fails. Two things make the outcome consistent rather than leaked:
-
The new-port binding matches persisted settings. After a successful write, settings say
enabled: true, port: <new>andstateRefis updated toresult.settings.tailscaleServePort(DesktopServerExposure.ts:738-742). A live binding on that port is the state the user asked for. That is also why the rollback is deliberately scoped to persistence failure: once the write lands, tearing the binding down would be undoing the user's request. The invariant this PR is defending is "settings and the tailnet agree", and here they do. -
The old-port binding is removed by the child that created it. The finalizer disables
configured.servePort— the port that child bound at boot (apps/server/src/server.ts:450) — so the stale binding goes away whenever that child stops, including on the exit path a failed relaunch now takes (689814ae3).
A later disable also targets the right port, since stateRef carries the new one, so the binding never becomes unreachable to the user.
The one edge I'll concede: if the relaunch fails, the app exits and the new-port binding stays live pointing at a backend that is no longer running. That is a refused connection rather than an exposure, and the next launch re-binds it from the same persisted settings — so I don't think it warrants tracking the binding through the relaunch step. If you'd rather it be torn down in that window anyway, say so and I'll add it; it would mean treating a successful persist as provisional until the relaunch lands, which is a bigger design change than I want to make unilaterally.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Two Linux desktop problems, both of which end with the user staring at an app that silently did nothing.
Supersedes #4552 (closed draft); that branch's head is many commits behind this one.
1. Enabling Tailscale HTTPS failed silently
tailscale servewas configured by the child backend on boot. If the tailnet refused — Serve not enabled, no HTTPS certs, CLI missing — the failure landed in a log line the user never sees. The app restarted and the toggle simply stayed off.Enabling now preflights
tailscale servein the main process before persisting, so it can fail loudly with the CLI's own guidance and thelogin.tailscale.com/f/serveadmin link. The failure surfaces three ways: a toast, an inline alert inside the setup dialog (which stays open so you can retry), and a status line on the row. The setup URL is shown in full and is selectable, with an Open setup button as a shortcut.Related fixes in the same area:
acquireReleasefinalizer, which only exists if that child booted with Serve enabled. After a failed relaunch it doesn't, so toggling off persistedenabled: falsewhiletailscale serve --https=<port>stayed live on the tailnet. A teardown that fails now refuses to record itself as done, and says the backend may still be reachable.statuscomes from an HTTP probe of the MagicDNS URL, so a cert still provisioning or a brief tailnet blip rendered the switch off while Serve was live — and since disabling sits behind the on state, there was no way to turn it off.2. AppImage restarts never came back
app.relaunch()re-execsprocess.execPath, which under AppImage points inside the FUSE mount that is unmounted as the process exits — so the restart died with "Cannot mount AppImage, please check your FUSE setup."The AppImage path now schedules a detached, delayed re-exec of
$APPIMAGEafter the current process exits, anchored atcwd: /so it isn't holding the mount. The spawn is awaited, so a failure is logged instead of falling through toexit(0)and vanishing. The single-instance lock is released only once the relaunch is committed, so a failure leaves the app running with the lock rather than letting the next launch open a second instance.Testing
Unit tests cover the new behavior: eager teardown asserts the exact
serve --https=<port> offinvocation, a failed teardown keepsenabled: trueand carries the exposure warning, the missing-binary case produces the PATH hint, and the relaunch helper resolves only after a real spawn.Verified end-to-end by driving the built desktop app against a live tailnet:
https://<magicdns> → proxy http://127.0.0.1:<port>intailscale serve status, the endpoint returned 200, the app relaunched and came back with the toggle on; disable returnedNo serve configwith the endpoint unreachable and the toggle off.openExternalconfirmed to resolvetrue/falserather than reject, which is why the old.catch()fallback was dead code.On a real AppImage
Built with
dist:desktop:linuxand run from the packaged artifact (genuine FUSE mount), across two relaunch cycles:kind: appimage-delayedre-execing$APPIMAGE, and the app came back every time with zero "Cannot mount AppImage" errors.No serve configon disable.This surfaced one more bug, fixed here: the restart worked, but every cycle stranded the previous mount, with its runtime process parked in
fuse_dev_do_readforever. Chromium keeps fds open without CLOEXEC andspawncannot close them, so the detached helper carried nine of them —app.asar,icudtl.dat, the.pakfiles, the mount directory — into the new process tree, pinning a mount nothing needed. The helper now closes every fd above 2 before sleeping. After the fix: one mount and one runtime process across both cycles.🤖 Generated with Claude Code
https://claude.ai/code/session_015ajcLHZtGhRQRD6BwscU9R
Note
High Risk
Changes authentication-adjacent network exposure (Tailscale Serve preflight/teardown/rollback) and desktop lifecycle (relaunch, single-instance lock); incorrect rollback or teardown could leave the tailnet exposed or block restarts.
Overview
Fixes two silent-failure paths on the Linux desktop: Tailscale HTTPS setup and AppImage relaunch.
Tailscale Serve now preflights
tailscale servein the main process before persisting enable, surfacingDesktopTailscaleServeConfigureErrorwith CLI-derived messages and optionallogin.tailscale.com/f/serveURLs (toast, inline alerts in setup/disable dialogs, row status). Disable tears Serve down eagerly in the main process (not only via the child finalizer), treats “no handler” as success, and refuses to persist disabled when teardown fails. Failed persistence rolls back CLI state viaonExit. A newresolveTailscaleHttpsEndpointIPC path resolves MagicDNS on user opt-in (cache invalidated) so Connections can offer the toggle without LAN mode. The UI switch follows persistedtailscaleServeEnabled, not HTTP probe status; enabling always requires relaunch after a successful preflight.AppImage relaunch uses a detached delayed re-exec of
$APPIMAGE(resolveDesktopRelaunchPlan/scheduleAppImageRelaunch), with bash-only inherited-fd cleanup to avoid stranded FUSE mounts. Relaunch awaits helper spawn;releaseSingleInstanceLockruns before exit on success and on relaunch failure.@t3tools/tailscaleadds stderr diagnostics, configure URL extraction, andformatTailscaleServeUserMessage.Reviewed by Cursor Bugbot for commit 689814a. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Surface Tailscale Serve failures with actionable errors and fix AppImage restarts on Linux
setTailscaleServeEnablednow preflights thetailscale serveCLI before persisting settings, maps exit errors to a structuredDesktopTailscaleServeConfigureError(with safe detail text and optional admin configure URL), and performs best-effort rollback if persistence fails.resolveTailscaleHttpsEndpointmethod invalidates the cached tailscale status and resolves a fresh HTTPS endpoint on demand; wired through IPC so the renderer can trigger it before opening the setup dialog.ConnectionsSettingsUI tracks Tailscale Serve errors separately, shows inline error alerts with an optional "Open setup" button, displays a spinner during updates, and derives switch state from persisted settings rather than endpoint reachability.DesktopLifecycle.relaunchnow resolves a relaunch plan, spawns a detached shell helper for AppImage viascheduleAppImageRelaunch, and releases the single-instance lock before exit; failures exit with code 1 instead of lingering.TailscaleCommandExitErrorgains aconfigureUrlfield and two new stderr diagnostic labels (serve-not-enabled,no-https-certs) so callers can surface targeted guidance from CLI output.Macroscope summarized 689814a.