diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 32e35d7cafa..75274095a12 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -93,6 +93,8 @@ Examples: A known environment may or may not know the target `environmentId` before first successful connect. +In the hosted web app, known environments are browser-local. A hosted pairing URL can create the saved entry, but it does not give the hosted app a server-side control plane or a copy of the session state. + ### AccessEndpoint An `AccessEndpoint` is one concrete way to reach a known environment. @@ -108,6 +110,67 @@ A single environment may have many endpoints: The environment stays the same. Only the access path changes. +### AdvertisedEndpoint + +An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for an environment. It is how the backend tells the client which URLs may be useful for pairing and reconnecting. + +`AdvertisedEndpoint` is deliberately narrower than the full access model: + +- it describes a concrete HTTP and WebSocket base URL pair +- it can mark the endpoint as default, available, or unavailable +- it includes reachability hints such as loopback, LAN, private, public, or tunnel +- it includes compatibility hints such as whether the endpoint can be used from the hosted HTTPS app + +Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable. + +The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration. + +Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity. + +When no user default is saved, endpoint selection should prefer: + +1. endpoints compatible with the hosted HTTPS app +2. explicitly default endpoints +3. non-loopback endpoints +4. loopback endpoints only for same-machine clients + +This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model. + +### Endpoint providers + +Endpoint providers are add-ons that contribute advertised endpoints for the current environment. + +The provider boundary is intentionally outside the core environment model: + +- core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle +- providers discover or synthesize endpoints +- providers return normalized `AdvertisedEndpoint` records +- the UI and pairing logic select from those records without knowing provider-specific commands + +The first provider is Tailscale. It can discover Tailnet IP and MagicDNS addresses from the local machine and publish them as additional endpoint candidates. Future providers, such as a hosted tunnel service, should plug into the same shape rather than adding a separate remote environment path. + +Provider-specific confidence should remain a hint. A Tailscale endpoint still needs a successful browser or desktop connection before the client treats it as connected. + +### Hosted pairing request + +A hosted pairing request is a bootstrap URL for the static web app, not a transport. + +Example: + +```text +https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE +``` + +The hosted app reads the `host` parameter and pairing token, exchanges the token directly with that backend, then saves the resulting environment record in browser local storage. + +Important constraints: + +- the hosted app does not proxy HTTP or WebSocket traffic +- the backend must still be reachable directly from the browser +- HTTPS pages can only connect to HTTPS/WSS backends +- HTTP LAN endpoints should keep using direct desktop or CLI pairing URLs +- the token belongs in the URL hash so it is not sent to the hosted app origin + ### RepositoryIdentity `RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. @@ -151,6 +214,8 @@ Benefits: - no client-specific process management required - best fit for hosted or self-managed remote T3 deployments +Browser security rules are part of this access method. A hosted HTTPS web client can connect to `wss://` backends, but it cannot connect to plain `ws://` or `http://` LAN backends because that would be mixed content. + ### 2. Tunneled WebSocket access Examples: @@ -170,6 +235,8 @@ This is especially useful when: - mobile must reach a desktop-hosted environment - a machine should be reachable without exposing raw LAN or public ports +Tailscale-backed access sits here architecturally even though the current implementation is endpoint discovery rather than a T3-managed tunnel. It contributes private-network endpoints and lets the existing HTTP/WebSocket client path do the actual connection. + ### 3. Desktop-managed SSH access SSH is an access and launch helper, not a separate environment type. @@ -185,6 +252,8 @@ After that, the renderer should still connect using an ordinary WebSocket URL ag This keeps the renderer transport model consistent with every other access method. +The desktop main process owns the SSH bridge because it can spawn local SSH processes, manage askpass prompts, write temporary launch scripts, and clean up forwards. The renderer receives a saved environment record and connects through the forwarded URL; it should not need SSH-specific RPC paths for normal environment traffic. + ## Launch methods Launch methods answer a different question: @@ -227,6 +296,15 @@ The recommended T3 flow is: 4. Desktop establishes local port forwarding. 5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. +The saved environment should remember that it was created by desktop SSH launch only for reconnect and lifecycle UX. That metadata should not change the server protocol or the environment identity model. + +Failure handling should be explicit: + +- SSH authentication failure should surface before any environment is saved +- remote launch failure should include remote logs or the launcher command output when available +- forwarded-port failure should leave the saved environment disconnected rather than falling back to an unrelated endpoint +- reconnect should attempt to restore the SSH bridge before reconnecting the normal WebSocket client + ### 3. Client-managed local publish This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. @@ -267,6 +345,8 @@ T3 already supports a WebSocket auth token on the server. That should become a f For publicly reachable environments, authenticated access should be treated as required. +Hosted pairing should be treated as a client-side convenience only. The hosted app must not receive pairing tokens through query parameters, must not store pairing state server-side, and must not imply that an HTTP backend is safe or reachable from an HTTPS browser context. + ## Relationship to Zed Zed is a useful reference implementation for managed remote launch and reconnect behavior. diff --git a/.gitignore b/.gitignore index 6c48782f9ac..9e14e917910 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__ __screenshots__/ .tanstack squashfs-root/ +.vercel diff --git a/.plans/19-remote-endpoints-hosted-static.md b/.plans/19-remote-endpoints-hosted-static.md new file mode 100644 index 00000000000..2fa0bc70211 --- /dev/null +++ b/.plans/19-remote-endpoints-hosted-static.md @@ -0,0 +1,350 @@ +# Remote Endpoints and Hosted Static App Plan + +## Purpose + +Make remote access feel first-class while keeping the free DIY path open. + +The immediate product goal is: + +- users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel +- users can generate a hosted pairing link for `app.t3.codes` +- the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin +- all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX + +This plan intentionally leaves the paid T3 cloud tunnel fabric out of scope. It defines the OSS foundation that T3 Tunnel should later plug into. + +## Current State + +Already present or in progress: + +- Server auth distinguishes bootstrap credentials from session credentials. +- One-time pairing credentials can be exchanged for browser sessions or bearer sessions. +- Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token. +- Remote environment WebSocket connections use a short-lived WebSocket token. +- Pairing URLs can carry tokens in the URL fragment. +- Hosted `/pair?host=...#token=...` can add a saved environment. +- Hosted static startup can avoid assuming the page origin is the backend. + +Main gaps: + +- Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs. +- Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model. +- Tailscale/MagicDNS endpoints are not detected or surfaced. +- Hosted-static empty/offline states are still thin. +- Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure. + +## Core Decision: Add `AdvertisedEndpoint` + +Add a new first-class contract instead of extending the environment descriptor. + +### Why not extend `ExecutionEnvironmentDescriptor` + +`ExecutionEnvironmentDescriptor` answers: "What environment is this?" + +Examples: + +- environment id +- label +- platform +- server version +- capabilities + +`AdvertisedEndpoint` answers: "How can a client reach this environment right now?" + +Examples: + +- loopback URL +- LAN URL +- Tailscale IP URL +- MagicDNS/Serve URL +- manual URL +- future T3 Tunnel URL +- browser compatibility and exposure level + +Those are different lifecycles. One environment can have many endpoints, endpoints can appear/disappear as network interfaces change, and the same descriptor is returned regardless of which endpoint the client used. Extending the descriptor would blur environment identity with transport reachability and make saved environments harder to reason about. + +### Target Contract + +Add a schema in `packages/contracts`, likely `remoteAccess.ts`: + +```ts +type AdvertisedEndpointProvider = + | "loopback" + | "lan" + | "tailscale-ip" + | "tailscale-magicdns" + | "manual" + | "t3-tunnel"; + +type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public"; + +type AdvertisedEndpointCompatibility = { + hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown"; + desktopApp: "compatible" | "unknown"; +}; + +type AdvertisedEndpoint = { + id: string; + provider: AdvertisedEndpointProvider; + label: string; + httpBaseUrl: string; + wsBaseUrl: string; + visibility: AdvertisedEndpointVisibility; + compatibility: AdvertisedEndpointCompatibility; + source: "server" | "desktop" | "user"; + status: "available" | "unavailable" | "unknown"; + isDefault?: boolean; +}; +``` + +Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`. + +## HTTP/WS and HTTPS/WSS Readiness + +The codebase is partially ready, but the UX and compatibility model are not explicit enough. + +What is ready: + +- Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`. +- Saved environments store both HTTP and WebSocket base URLs. +- Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable. +- WebSocket connections can use a dynamically issued `wsToken`. +- Server CORS support exists for browser remote auth endpoints. + +What is not solved by code alone: + +- `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content. +- `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust. +- LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app. +- The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible. + +Policy: + +- Support both HTTP/WS and HTTPS/WSS at the runtime layer. +- Mark endpoint compatibility at the product layer. +- Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback. + +## Architecture + +### Endpoint Sources + +Endpoint records can come from several providers: + +1. **Server runtime** + - headless bind host and port + - server-known explicit advertised host config + +2. **Desktop shell** + - loopback backend URL + - LAN exposure state + - network interface discovery + - Tailscale CLI/status discovery + +3. **User configuration** + - manually added hostnames + - preferred endpoint labels + - hidden/disabled endpoints + +4. **Future cloud provider** + - T3 Tunnel endpoint + - billing/account status + - tunnel lifecycle state + +### Endpoint Registry + +Create a central runtime registry: + +- `packages/contracts/src/remoteAccess.ts` +- `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification +- `apps/server/src/remoteAccess/*` for server/headless endpoints +- `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints +- `apps/web/src/environments/endpoints/*` for client-side display and pairing selection + +The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel. + +### Pairing Link Generation + +Move hosted pairing link generation to endpoint-driven input: + +```ts +buildHostedPairingUrl({ + endpoint: AdvertisedEndpoint, + token, +}); +``` + +Generated URL: + +```text +https://app.t3.codes/pair?host=#token= +``` + +Use fragment tokens by default. Continue accepting `?token=` for compatibility. + +## Phase 1: Endpoint Abstraction + +### Goals + +- Centralize URL normalization, protocol derivation, and compatibility checks. +- Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection. +- Preserve all current remote behavior. + +### Tasks + +1. Add `AdvertisedEndpoint` schemas to `packages/contracts`. +2. Add shared helpers: + - normalize HTTP base URL + - derive WebSocket base URL + - classify loopback/private/LAN/Tailscale/public host + - classify hosted HTTPS compatibility +3. Add server endpoint discovery: + - loopback endpoint + - configured non-loopback endpoint + - explicit advertised host override +4. Add desktop endpoint discovery: + - local loopback + - LAN exposure endpoint + - endpoint status labels +5. Add WebSocket/API method or existing config field for endpoint snapshots. +6. Refactor settings connections UI: + - render endpoint rows + - endpoint picker for pairing link copy + - show compatibility warnings +7. Refactor hosted link builder to accept endpoint records. +8. Add tests for URL normalization and compatibility classification. + +### Acceptance Criteria + +- Existing LAN/network access UI still works. +- Pairing links are generated from endpoint records. +- Loopback endpoints never produce hosted pairing links silently. +- HTTP private-network endpoints are marked incompatible with `app.t3.codes`. +- No remote environment runtime changes are required for existing saved environments. + +## Phase 2: BYO Tailscale/MagicDNS + +### Goals + +- Detect free DIY Tailscale reachability. +- Surface Tailscale endpoints as normal advertised endpoints. +- Keep users in control of their own tailnet. + +### Tasks + +1. Detect Tailscale IPs from network interfaces: + - IPv4 `100.64.0.0/10` + - mark as `provider: "tailscale-ip"` +2. Add optional desktop-side `tailscale status --json` discovery: + - MagicDNS hostname + - Tailscale Serve/Funnel HTTPS endpoint if discoverable + - graceful failure if CLI is missing +3. Add manual Tailscale endpoint override: + - hostname + - label + - preferred/default flag +4. Show Tailscale endpoint rows in settings: + - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked + - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS +5. Generate pairing links using selected Tailscale endpoint. +6. Document DIY setup: + - local desktop-to-desktop over Tailscale + - hosted app requirements + - why HTTPS matters + +### Acceptance Criteria + +- A machine on Tailscale shows a Tailscale endpoint without paid features. +- Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible. +- Users can still copy token-only/manual values when endpoint compatibility is unknown. +- Tailscale is optional and never required for regular LAN/loopback use. + +## Phase 3: Hosted Static App Completion + +### Goals + +- `app.t3.codes` works as a real client shell. +- It can pair, persist, reconnect, and clearly explain offline/incompatible states. + +### Tasks + +1. Finish hosted-static root behavior: + - no primary backend required + - saved environment hydration before initial routing decisions + - first saved environment selected as active +2. Add hosted empty state: + - no saved environments + - paste pairing URL + - add host + token +3. Add offline saved environment UI: + - last connected + - reconnect + - remove + - copy/add alternate endpoint +4. Audit primary-backend assumptions: + - command palette + - settings pages + - server config atom defaults + - keybindings + - provider/model lists + - update/desktop-only affordances +5. Add route tests for: + - hosted `/pair?host=...#token=...` + - hosted root with no saved environments + - hosted root with saved environment + - primary backend unavailable but saved environment present +6. Add deployment hardening: + - SPA fallback + - strict CSP + - no third-party scripts + - no query token logging + - disable or hide source maps in production if needed +7. Add browser error messages: + - mixed content + - unreachable backend + - CORS failure + - certificate failure + +### Acceptance Criteria + +- `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload. +- A saved environment can be used without any backend at `app.t3.codes`. +- Offline machines show a useful state instead of a generic boot error. +- HTTP endpoints are still supported in desktop/native/local contexts. +- Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints. + +## Phase 4: Future T3 Tunnel Provider + +Not part of the current implementation, but the endpoint abstraction should make it straightforward. + +Future tunnel provider responsibilities: + +- create endpoint with `provider: "t3-tunnel"` +- surface tunnel status +- provide stable HTTPS URL +- use existing backend pairing/session auth +- never bypass server auth + +The tunnel fabric can later be Pipenet-derived, Tailscale-derived, or another reverse tunnel implementation. The rest of T3 Code should only see an `AdvertisedEndpoint`. + +## Security Checklist + +- Pairing tokens are short-lived and one-time. +- Generated hosted pairing links put tokens in the fragment. +- The backend remains the authorization boundary. +- Endpoint discovery never disables backend auth. +- Hosted app does not silently downgrade to HTTP. +- Tunnel/public endpoints require explicit user action. +- Client sessions remain revocable. +- Endpoint URLs and request logs must avoid recording pairing tokens. +- Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing. + +## Verification + +Each implementation PR should run: + +- `bun fmt` +- `bun lint` +- `bun typecheck` +- focused tests for changed backend/web behavior +- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` + diff --git a/REMOTE.md b/REMOTE.md index 30dc562792f..61a1cc3b5cc 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -22,9 +22,41 @@ If you are already running the desktop app and want to make it reachable from ot 1. Open **Settings** → **Connections**. 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. -3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). +3. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints. 4. Use **Create Link** to generate a pairing link you can share with another device. +The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. + +When no user default is saved, the app uses the built-in LAN endpoint for pairing links when +available. You can set another endpoint as the default from the expanded endpoint list. + +- HTTPS/WSS-compatible endpoints work from `https://app.t3.codes`, but are not made the default + automatically. +- Non-loopback HTTP endpoints are useful for direct LAN pairing. +- Loopback-only endpoints are not useful for another device unless that device is the same machine. + +If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link. + +### Tailscale Endpoints + +When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. + +Depending on your Tailscale setup, this may include: + +- the machine's `100.x.y.z` Tailnet IP +- a MagicDNS name +- an HTTPS MagicDNS endpoint when Tailscale Serve is configured for this backend + +The Tailscale HTTPS endpoint uses the clean MagicDNS URL, such as +`https://machine.tailnet.ts.net/`, and is disabled until the app verifies that the URL reaches this +backend. Use **Setup** on the Tailscale HTTPS row to opt in. The desktop app restarts the backend +with the same server-side behavior as `t3 serve --tailscale-serve`, then the server asks Tailscale +Serve to proxy HTTPS traffic to the local backend. + +The Tailscale support is an endpoint provider add-on. The core remote model still works without Tailscale: LAN HTTP endpoints, custom HTTPS endpoints, future tunnels, and SSH-launched environments all use the same saved environment and pairing flow. + +For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A plain `http://100.x.y.z:3773` endpoint can still work from a desktop client or another browser page served over HTTP, but it will not work from the hosted HTTPS app because of browser mixed-content rules. + ### Option 2: Headless Server (CLI) Use this when you want to run the server without a GUI, for example on a remote machine over SSH. @@ -47,14 +79,42 @@ From there, connect from another device in either of these ways: - scan the QR code on your phone - in the desktop app, enter the full pairing URL - in the desktop app, enter the host and token separately +- in the hosted web app, open a hosted pairing URL when the backend is reachable over HTTPS Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. +For hosted web pairing over Tailscale HTTPS, opt in to Tailscale Serve: + +```bash +npx t3 serve --tailscale-serve +``` + +By default this configures Tailscale Serve on HTTPS port 443 and advertises +`https://machine.tailnet.ts.net/`. Advanced users can choose a different HTTPS port: + +```bash +npx t3 serve --tailscale-serve --tailscale-serve-port 8443 +``` + > Note > The GUIs do not currently support adding projects on remote environments. > For now, use `t3 project ...` on the server machine instead. > Full GUI support for remote project management is coming soon. +### Option 3: Desktop-Managed SSH Launch + +Use this when you want the desktop app to start or reuse T3 Code on another machine over SSH. + +1. Open **Settings** → **Connections**. +2. Under **Remote Environments**, choose **Add environment**. +3. Select the SSH launch flow. +4. Enter the SSH target, such as `user@example.com`. +5. Confirm the launch. The desktop app probes the host, starts or reuses a remote T3 server, opens a local port forward, and saves the environment. + +After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. The remote host still owns the actual T3 server, projects, files, git state, terminals, and provider sessions. + +SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments. + ## How Pairing Works The remote device does not need a long-lived secret up front. @@ -67,6 +127,20 @@ Instead: After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. +## Hosted Web App Pairing + +The hosted web app at `https://app.t3.codes` can save a remote backend in browser local storage from a URL like: + +```text +https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE +``` + +Use hosted pairing when the backend is reachable from the browser over HTTPS/WSS. This includes a backend behind a trusted HTTPS tunnel or another HTTPS endpoint you operate. + +Do not use hosted pairing for plain HTTP LAN URLs such as `http://192.168.x.y:3773`. Browsers block an HTTPS page from connecting to an insecure HTTP or WS backend. For those endpoints, use the direct pairing URL shown by the desktop app or CLI from a client that can open that HTTP URL directly. + +Hosted pairing does not proxy traffic through T3 Code. The browser still connects directly to the backend URL in the pairing link. + ## Managing Access Later Use `t3 auth` to manage access after the initial pairing flow. @@ -84,4 +158,5 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference - Treat pairing URLs and pairing tokens like passwords. - Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. - Anyone with a valid pairing credential can create a session until that credential expires or is revoked. +- Hosted pairing links keep the credential in the URL hash so it is not sent to the hosted app server, but it can still be exposed through browser history, screenshots, logs, or copy/paste. - Use `t3 auth` to revoke credentials or sessions you no longer trust. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 34a061ffc70..9e4e5eec28c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,13 +15,17 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { + "@effect/platform-node": "catalog:", "effect": "catalog:", "electron": "40.9.3", "electron-updater": "^6.6.2" }, "devDependencies": { + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/ssh": "workspace:*", + "@t3tools/tailscale": "workspace:*", "@types/node": "catalog:", "effect-acp": "workspace:*", "tsdown": "catalog:", diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index a3cfec5ed33..d4c4768d2c8 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -72,6 +72,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { wsBaseUrl: "wss://remote.example.com/", createdAt: "2026-04-09T00:00:00.000Z", lastConnectedAt: "2026-04-09T01:00:00.000Z", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, }; describe("clientPersistence", () => { diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts index ad08a0036f1..09a3494dbc7 100644 --- a/apps/desktop/src/clientPersistence.ts +++ b/apps/desktop/src/clientPersistence.ts @@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord( typeof value.wsBaseUrl === "string" && typeof value.createdAt === "string" && (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && + (value.desktopSsh === undefined || + (Predicate.isObject(value.desktopSsh) && + typeof value.desktopSsh.alias === "string" && + typeof value.desktopSsh.hostname === "string" && + (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") && + (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) && (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") ); } @@ -77,7 +83,7 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen function toPersistedSavedEnvironmentRecord( record: PersistedSavedEnvironmentStorageRecord, ): PersistedSavedEnvironmentRecord { - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -85,6 +91,7 @@ function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; } export function readClientSettings(settingsPath: string): ClientSettings | null { @@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry( wsBaseUrl: record.wsBaseUrl, createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), encryptedBearerToken, } : record; @@ -189,7 +197,7 @@ export function writeSavedEnvironmentSecret(input: { const encryptedBearerToken = input.secretStorage .encryptString(input.secret) .toString("base64"); - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -197,7 +205,8 @@ export function writeSavedEnvironmentSecret(input: { createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, encryptedBearerToken, - } satisfies PersistedSavedEnvironmentStorageRecord; + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; }), } satisfies SavedEnvironmentRegistryDocument); return found; diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index 9b467d22cab..cc58dc810ef 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -9,6 +9,7 @@ import { readDesktopSettings, resolveDefaultDesktopSettings, setDesktopServerExposurePreference, + setDesktopTailscaleServePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, } from "./desktopSettings.ts"; @@ -35,6 +36,8 @@ describe("desktopSettings", () => { it("defaults packaged nightly builds to the nightly update channel", () => { expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, }); @@ -45,12 +48,16 @@ describe("desktopSettings", () => { writeDesktopSettings(settingsPath, { serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, }); expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, }); @@ -61,6 +68,8 @@ describe("desktopSettings", () => { setDesktopServerExposurePreference( { serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, }, @@ -68,6 +77,50 @@ describe("desktopSettings", () => { ), ).toEqual({ serverExposureMode: "network-accessible", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }); + + it("persists the requested Tailscale Serve preference", () => { + expect( + setDesktopTailscaleServePreference( + { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + { enabled: true, port: 8443 }, + ), + ).toEqual({ + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }); + + it("preserves the configured Tailscale Serve port when no new port is requested", () => { + expect( + setDesktopTailscaleServePreference( + { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + { enabled: true }, + ), + ).toEqual({ + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, }); @@ -78,6 +131,8 @@ describe("desktopSettings", () => { setDesktopUpdateChannelPreference( { serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, }, @@ -85,6 +140,8 @@ describe("desktopSettings", () => { ), ).toEqual({ serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: true, }); @@ -103,6 +160,8 @@ describe("desktopSettings", () => { expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, }); @@ -121,6 +180,8 @@ describe("desktopSettings", () => { expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, }); @@ -140,8 +201,30 @@ describe("desktopSettings", () => { expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, }); }); + + it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + tailscaleServeEnabled: true, + tailscaleServePort: 0, + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }); }); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index 6ece5189cce..5a61faef803 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -6,12 +6,18 @@ import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; } +export const DEFAULT_TAILSCALE_SERVE_PORT = 443; + export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", updateChannelConfiguredByUser: false, }; @@ -35,6 +41,29 @@ export function setDesktopServerExposurePreference( }; } +export function setDesktopTailscaleServePreference( + settings: DesktopSettings, + input: { readonly enabled: boolean; readonly port?: number }, +): DesktopSettings { + const port = + input.port === undefined + ? settings.tailscaleServePort + : normalizeTailscaleServePort(input.port); + return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port + ? settings + : { + ...settings, + tailscaleServeEnabled: input.enabled, + tailscaleServePort: port, + }; +} + +export function normalizeTailscaleServePort(value: unknown): number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 + ? value + : DEFAULT_TAILSCALE_SERVE_PORT; +} + export function setDesktopUpdateChannelPreference( settings: DesktopSettings, requestedChannel: DesktopUpdateChannel, @@ -57,6 +86,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D const raw = FS.readFileSync(settingsPath, "utf8"); const parsed = JSON.parse(raw) as { readonly serverExposureMode?: unknown; + readonly tailscaleServeEnabled?: unknown; + readonly tailscaleServePort?: unknown; readonly updateChannel?: unknown; readonly updateChannelConfiguredByUser?: unknown; }; @@ -72,6 +103,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, + tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), updateChannel: updateChannelConfiguredByUser && parsedUpdateChannel !== null ? parsedUpdateChannel diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c5507c6fb03..9c097fc9bd1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -36,11 +36,14 @@ import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; import { + type DesktopSettings, DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, setDesktopServerExposurePreference, + setDesktopTailscaleServePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, } from "./desktopSettings.ts"; @@ -55,7 +58,11 @@ import { } from "./clientPersistence.ts"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; -import { resolveDesktopServerExposure } from "./serverExposure.ts"; +import { + resolveDesktopCoreAdvertisedEndpoints, + resolveDesktopServerExposure, +} from "./serverExposure.ts"; +import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; @@ -76,6 +83,7 @@ import { import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; import { resolveDesktopAppBranding } from "./appBranding.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; syncShellEnvironment(); @@ -102,6 +110,8 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; +const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -110,6 +120,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); +// Dev-only SSH launcher override. Set this to an absolute path on the SSH host +// for a built server entry, for example: +// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs" +const DEV_REMOTE_T3_SERVER_ENTRY_PATH = + process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? ""; const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ isDevelopment, appVersion: app.getVersion(), @@ -297,6 +312,9 @@ function backendChildEnv(): NodeJS.ProcessEnv { delete env.T3CODE_DESKTOP_WS_URL; delete env.T3CODE_DESKTOP_LAN_ACCESS; delete env.T3CODE_DESKTOP_LAN_HOST; + delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; + delete env.T3CODE_TAILSCALE_SERVE; + delete env.T3CODE_TAILSCALE_SERVE_PORT; return env; } @@ -305,9 +323,32 @@ function getDesktopServerExposureState(): DesktopServerExposureState { mode: desktopServerExposureMode, endpointUrl: backendEndpointUrl, advertisedHost: backendAdvertisedHost, + tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, + tailscaleServePort: desktopSettings.tailscaleServePort, }; } +async function getDesktopAdvertisedEndpoints() { + const exposure = resolveDesktopServerExposure({ + mode: desktopServerExposureMode, + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), + }); + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ + port: backendPort, + exposure, + customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), + }); + const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ + port: backendPort, + serveEnabled: desktopSettings.tailscaleServeEnabled, + servePort: desktopSettings.tailscaleServePort, + networkInterfaces: OS.networkInterfaces(), + }); + return [...coreEndpoints, ...tailscaleEndpoints]; +} + function getDesktopSecretStorage() { return { isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), @@ -321,9 +362,19 @@ function resolveAdvertisedHostOverride(): string | undefined { return override && override.length > 0 ? override : undefined; } +function resolveCustomHttpsEndpointUrls(): readonly string[] { + return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + async function applyDesktopServerExposureMode( mode: DesktopServerExposureMode, - options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, + options?: { + readonly persist?: boolean; + readonly rejectIfUnavailable?: boolean; + }, ): Promise { const advertisedHostOverride = resolveAdvertisedHostOverride(); const requestedMode = mode; @@ -361,6 +412,17 @@ async function applyDesktopServerExposureMode( return getDesktopServerExposureState(); } +async function applyDesktopTailscaleServeEnabled( + nextSettings: DesktopSettings, +): Promise { + desktopSettings = nextSettings; + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + relaunchDesktopApp( + desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", + ); + return getDesktopServerExposureState(); +} + function relaunchDesktopApp(reason: string): void { writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); setImmediate(() => { @@ -373,6 +435,7 @@ function relaunchDesktopApp(reason: string): void { `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, ); }) + .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) .finally(() => { restoreStdIoCapture?.(); if (isDevelopment) { @@ -629,6 +692,22 @@ let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({ + getMainWindow: () => mainWindow, + resolveCliRunner: (): RemoteT3RunnerOptions => { + if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) { + return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH }; + } + return { + packageSpec: resolveRemoteT3CliPackageSpec({ + appVersion: app.getVersion(), + updateChannel: desktopSettings.updateChannel, + isDevelopment, + }), + }; + }, +}); + function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateInstallInFlight) return "install"; if (updateDownloadInFlight) return "download"; @@ -1180,7 +1259,10 @@ async function checkForUpdates(reason: string): Promise { } } -async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> { +async function downloadAvailableUpdate(): Promise<{ + accepted: boolean; + completed: boolean; +}> { if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { return { accepted: false, completed: false }; } @@ -1202,7 +1284,10 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed } } -async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> { +async function installDownloadedUpdate(): Promise<{ + accepted: boolean; + completed: boolean; +}> { if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { return { accepted: false, completed: false }; } @@ -1402,6 +1487,8 @@ function startBackend(): void { t3Home: BASE_DIR, host: backendBindHost, desktopBootstrapToken: backendBootstrapToken, + tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, + tailscaleServePort: desktopSettings.tailscaleServePort, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), @@ -1647,6 +1734,8 @@ function registerIpcHandlers(): void { }, ); + desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); @@ -1669,6 +1758,31 @@ function registerIpcHandlers(): void { return nextState; }); + ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL); + ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => { + if (typeof rawInput !== "object" || rawInput === null) { + throw new Error("Invalid Tailscale Serve input."); + } + const input = rawInput as { + readonly enabled?: unknown; + readonly port?: unknown; + }; + if (typeof input.enabled !== "boolean") { + throw new Error("Invalid Tailscale Serve input."); + } + const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { + enabled: input.enabled, + ...(typeof input.port === "number" ? { port: input.port } : {}), + }); + if (nextSettings === desktopSettings) { + return getDesktopServerExposureState(); + } + return applyDesktopTailscaleServeEnabled(nextSettings); + }); + + ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); + ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints()); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1959,7 +2073,10 @@ function createWindow(): BrowserWindow { const externalUrl = getSafeExternalUrl(params.linkURL); if (externalUrl) { menuTemplate.push( - { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) }, + { + label: "Copy Link", + click: () => clipboard.writeText(params.linkURL), + }, { type: "separator" }, ); } @@ -2019,6 +2136,9 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { + desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( + "SSH authentication was cancelled because the app window closed.", + ); if (mainWindow === window) { mainWindow = null; } @@ -2110,6 +2230,7 @@ app.on("before-quit", () => { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); }); @@ -2159,6 +2280,7 @@ if (process.platform !== "win32") { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); @@ -2169,6 +2291,7 @@ if (process.platform !== "win32") { writeDesktopLogHeader("SIGTERM received"); clearUpdatePollTimer(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a6756048725..b3b553fe214 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -22,8 +22,36 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; +const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; +const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; + +function unwrapEnsureSshEnvironmentResult(result: unknown) { + if ( + typeof result === "object" && + result !== null && + "type" in result && + result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT + ) { + const message = + "message" in result && typeof result.message === "string" + ? result.message + : "SSH authentication cancelled."; + throw new Error(message); + } + return result as Awaited>; +} contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { @@ -51,8 +79,39 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), + ensureSshEnvironment: async (target, options) => + unwrapEnsureSshEnvironmentResult( + await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options), + ), + disconnectSshEnvironment: (target) => + ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), + fetchSshEnvironmentDescriptor: (httpBaseUrl) => + ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl), + bootstrapSshBearerSession: (httpBaseUrl, credential) => + ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential), + fetchSshSessionState: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken), + issueSshWebSocketToken: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken), + onSshPasswordPrompt: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + + ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + }; + }, + resolveSshPasswordPrompt: (requestId, password) => + ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + setTailscaleServeEnabled: (input) => + ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), + getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts index c83bbc210e0..4e284ef42bd 100644 --- a/apps/desktop/src/serverExposure.test.ts +++ b/apps/desktop/src/serverExposure.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; +import { + resolveDesktopCoreAdvertisedEndpoints, + resolveDesktopServerExposure, + resolveLanAdvertisedHost, +} from "./serverExposure.ts"; describe("resolveLanAdvertisedHost", () => { it("prefers an explicit host override", () => { @@ -74,6 +78,121 @@ describe("resolveLanAdvertisedHost", () => { }); }); +describe("resolveDesktopCoreAdvertisedEndpoints", () => { + it("advertises loopback and LAN endpoints without provider-specific assumptions", () => { + const exposure = resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + }); + + expect( + resolveDesktopCoreAdvertisedEndpoints({ + port: 3773, + exposure, + customHttpsEndpointUrls: [ + "https://desktop.example.ts.net", + "http://desktop.example.test:3773", + "not-a-url", + ], + }), + ).toEqual([ + { + id: "desktop-loopback:3773", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }, + { + id: "desktop-lan:http://192.168.1.44:3773", + label: "Local network", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://192.168.1.44:3773/", + wsBaseUrl: "ws://192.168.1.44:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }, + { + id: "manual:https://desktop.example.ts.net", + label: "Custom HTTPS", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "https://desktop.example.ts.net/", + wsBaseUrl: "wss://desktop.example.ts.net/", + reachability: "public", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }, + { + id: "manual:http://desktop.example.test:3773", + label: "Custom endpoint", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "http://desktop.example.test:3773/", + wsBaseUrl: "ws://desktop.example.test:3773/", + reachability: "public", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured endpoint for this desktop backend.", + }, + ]); + }); +}); + describe("resolveDesktopServerExposure", () => { it("keeps the desktop server loopback-only when local-only mode is selected", () => { expect( diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts index 65c99b60e13..b73b850ad13 100644 --- a/apps/desktop/src/serverExposure.ts +++ b/apps/desktop/src/serverExposure.ts @@ -1,5 +1,13 @@ import type { NetworkInterfaceInfo } from "node:os"; -import type { DesktopServerExposureMode } from "@t3tools/contracts"; +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; +import type { + AdvertisedEndpoint, + AdvertisedEndpointProvider, + DesktopServerExposureMode, +} from "@t3tools/contracts"; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; @@ -13,6 +21,26 @@ export interface DesktopServerExposure { readonly advertisedHost: string | null; } +export interface DesktopAdvertisedEndpointInput { + readonly port: number; + readonly exposure: DesktopServerExposure; + readonly customHttpsEndpointUrls?: readonly string[]; +} + +const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +}; + +const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, +}; + const normalizeOptionalHost = (value: string | undefined): string | undefined => { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : undefined; @@ -21,6 +49,14 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("127.") && !address.startsWith("169.254."); +function isHttpsEndpointUrl(value: string): boolean { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + export function resolveLanAdvertisedHost( networkInterfaces: NodeJS.Dict, explicitHost: string | undefined, @@ -78,3 +114,75 @@ export function resolveDesktopServerExposure(input: { advertisedHost, }; } + +function createDesktopEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_CORE_ENDPOINT_PROVIDER, + source: "desktop-core", + }); +} + +function createManualEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, + source: "user", + }); +} + +export function resolveDesktopCoreAdvertisedEndpoints( + input: DesktopAdvertisedEndpointInput, +): readonly AdvertisedEndpoint[] { + const endpoints: AdvertisedEndpoint[] = [ + createDesktopEndpoint({ + id: `desktop-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: input.exposure.localHttpUrl, + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this desktop app.", + }), + ]; + + if (input.exposure.endpointUrl) { + endpoints.push( + createDesktopEndpoint({ + id: `desktop-lan:${input.exposure.endpointUrl}`, + label: "Local network", + httpBaseUrl: input.exposure.endpointUrl, + reachability: "lan", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }), + ); + } + + for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { + try { + const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); + endpoints.push( + createManualEndpoint({ + id: `manual:${customEndpointUrl}`, + label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", + httpBaseUrl: customEndpointUrl, + reachability: "public", + ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), + status: "unknown", + description: isHttpsEndpoint + ? "User-configured HTTPS endpoint for this desktop backend." + : "User-configured endpoint for this desktop backend.", + }), + ); + } catch { + // Ignore malformed user-configured endpoints without dropping valid endpoints. + } + } + + return endpoints; +} diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts new file mode 100644 index 00000000000..d22e09957d1 --- /dev/null +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -0,0 +1,98 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { SshPasswordPromptError } from "@t3tools/ssh/errors"; + +import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + FS.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeTempHomeDir(): string { + const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-")); + tempDirectories.push(directory); + return directory; +} + +describe("sshEnvironment", () => { + it("treats password prompt timeouts as cancellable authentication prompts", () => { + expect( + isSshPasswordPromptCancellation( + new SshPasswordPromptError({ + message: "SSH authentication timed out for devbox.", + }), + ), + ).toBe(true); + }); + + it("wires desktop host discovery through the ssh package runtime", async () => { + const homeDir = makeTempHomeDir(); + const sshDir = Path.join(homeDir, ".ssh"); + FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true }); + FS.writeFileSync( + Path.join(sshDir, "config"), + ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), + "utf8", + ); + FS.writeFileSync( + Path.join(sshDir, "config.d", "team.conf"), + [ + "Host staging", + " HostName staging.example.com", + "Host *", + " ServerAliveInterval 30", + "", + ].join("\n"), + "utf8", + ); + FS.writeFileSync( + Path.join(sshDir, "known_hosts"), + [ + "known.example.com ssh-ed25519 AAAA", + "|1|hashed|entry ssh-ed25519 AAAA", + "[bastion.example.com]:2222 ssh-ed25519 AAAA", + "", + ].join("\n"), + "utf8", + ); + + await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([ + { + alias: "bastion.example.com", + hostname: "bastion.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + source: "ssh-config", + }, + { + alias: "known.example.com", + hostname: "known.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "staging", + hostname: "staging", + username: null, + port: null, + source: "ssh-config", + }, + ]); + }); +}); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts new file mode 100644 index 00000000000..e847e07d498 --- /dev/null +++ b/apps/desktop/src/sshEnvironment.ts @@ -0,0 +1,420 @@ +import * as Crypto from "node:crypto"; + +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { NetService } from "@t3tools/shared/Net"; +import type { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, + DesktopDiscoveredSshHost, + DesktopSshEnvironmentTarget, + DesktopSshPasswordPromptRequest, + ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import { + SshPasswordPrompt, + type SshPasswordPromptShape, + type SshPasswordRequest, +} from "@t3tools/ssh/auth"; +import { discoverSshHosts } from "@t3tools/ssh/config"; +import { SshPasswordPromptError } from "@t3tools/ssh/errors"; +import { + fetchLoopbackSshJson, + SshEnvironmentManager, + type RemoteT3RunnerOptions, +} from "@t3tools/ssh/tunnel"; +import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; + +export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; + +const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; +const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; +const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; + +interface DesktopSshEnvironmentManagerOptions { + readonly passwordProvider?: (request: SshPasswordRequest) => Promise; + readonly resolveCliPackageSpec?: () => string; + readonly resolveCliRunner?: () => RemoteT3RunnerOptions; +} + +const sshRuntime = ManagedRuntime.make( + Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer), +); + +function createDesktopSshRuntime( + passwordPrompt: SshPasswordPromptShape, + scope: Scope.Scope, + options: DesktopSshEnvironmentManagerOptions, +) { + return ManagedRuntime.make( + Layer.mergeAll( + NodeServices.layer, + NodeHttpClient.layerUndici, + NetService.layer, + Layer.succeed(Scope.Scope, scope), + Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)), + SshEnvironmentManager.layer({ + ...(options.resolveCliPackageSpec === undefined + ? {} + : { resolveCliPackageSpec: options.resolveCliPackageSpec }), + ...(options.resolveCliRunner === undefined + ? {} + : { resolveCliRunner: options.resolveCliRunner }), + }), + ), + ); +} + +export async function discoverDesktopSshHosts(input?: { + readonly homeDir?: string; +}): Promise { + return await sshRuntime.runPromise(discoverSshHosts(input ?? {})); +} + +export class DesktopSshEnvironmentManager { + private readonly runtime: ReturnType; + private readonly scope: Scope.Scope; + + constructor(options: DesktopSshEnvironmentManagerOptions = {}) { + const passwordPrompt: SshPasswordPromptShape = { + isAvailable: options.passwordProvider !== undefined, + request: (request) => { + const passwordProvider = options.passwordProvider; + if (!passwordProvider) { + return Effect.succeed(null); + } + + return Effect.tryPromise({ + try: () => passwordProvider(request), + catch: (cause) => + new SshPasswordPromptError({ + message: cause instanceof Error ? cause.message : "SSH password prompt failed.", + cause, + }), + }); + }, + }; + this.scope = Effect.runSync(Scope.make()); + this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options); + } + + async discoverHosts(): Promise { + return await discoverDesktopSshHosts(); + } + + async ensureEnvironment( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) { + return await this.runtime.runPromise( + Effect.service(SshEnvironmentManager).pipe( + Effect.flatMap((manager) => manager.ensureEnvironment(target, options)), + ), + ); + } + + async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise { + await this.runtime.runPromise( + Effect.service(SshEnvironmentManager).pipe( + Effect.flatMap((manager) => manager.disconnectEnvironment(target)), + ), + ); + } + + async dispose(): Promise { + await this.runtime.runPromise(Scope.close(this.scope, Exit.void)); + await this.runtime.dispose(); + } +} + +function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { + if (typeof rawTarget !== "object" || rawTarget === null) { + return null; + } + + const target = rawTarget as Partial; + if (typeof target.alias !== "string" || typeof target.hostname !== "string") { + return null; + } + if ( + target.username !== null && + target.username !== undefined && + typeof target.username !== "string" + ) { + return null; + } + if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { + return null; + } + + const alias = target.alias.trim(); + const hostname = target.hostname.trim(); + if (alias.length === 0 || hostname.length === 0) { + return null; + } + + return { + alias, + hostname, + username: target.username?.trim() || null, + port: target.port ?? null, + }; +} + +/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */ +export interface DesktopSshBridgeWindow { + isDestroyed(): boolean; + isMinimized(): boolean; + restore(): void; + focus(): void; + readonly webContents: { + send(channel: string, ...args: readonly unknown[]): void; + }; +} + +/** Minimal subset of Electron's ipcMain used by the SSH bridge. */ +export interface DesktopSshBridgeIpcMain { + removeHandler(channel: string): void; + handle( + channel: string, + listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise, + ): void; +} + +export interface DesktopSshEnvironmentBridgeOptions { + readonly getMainWindow: () => DesktopSshBridgeWindow | null; + readonly resolveCliPackageSpec?: () => string; + readonly resolveCliRunner?: () => RemoteT3RunnerOptions; + readonly passwordPromptTimeoutMs?: number; +} + +interface PendingSshPasswordPrompt { + readonly resolve: (password: string | null) => void; + readonly reject: (error: Error) => void; + readonly timeout: ReturnType; +} + +export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError { + const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : ""; + return ( + error instanceof SshPasswordPromptError && + (message.includes("cancelled") || message.includes("timed out")) + ); +} + +/** + * Wires the SSH environment manager to Electron IPC, owning the renderer-facing + * password prompt state so `main.ts` only needs to register, cancel, and dispose. + */ +export class DesktopSshEnvironmentBridge { + private readonly options: DesktopSshEnvironmentBridgeOptions; + private readonly manager: DesktopSshEnvironmentManager; + private readonly pendingPrompts = new Map(); + private readonly passwordPromptTimeoutMs: number; + + constructor(options: DesktopSshEnvironmentBridgeOptions) { + this.options = options; + this.passwordPromptTimeoutMs = + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; + this.manager = new DesktopSshEnvironmentManager({ + passwordProvider: (request) => this.requestPasswordFromRenderer(request), + ...(options.resolveCliPackageSpec === undefined + ? {} + : { resolveCliPackageSpec: options.resolveCliPackageSpec }), + ...(options.resolveCliRunner === undefined + ? {} + : { resolveCliRunner: options.resolveCliRunner }), + }); + } + + registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { + ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); + ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts()); + + ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); + ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => { + const target = getSafeDesktopSshTarget(rawTarget); + if (!target) { + throw new Error("Invalid desktop SSH target."); + } + + const issuePairingToken = + typeof rawOptions === "object" && + rawOptions !== null && + "issuePairingToken" in rawOptions && + (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; + + try { + return await this.manager.ensureEnvironment(target, { + issuePairingToken, + }); + } catch (error) { + if (isSshPasswordPromptCancellation(error)) { + return { + type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, + message: error.message, + }; + } + throw error; + } + }); + + ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL); + ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => { + const target = getSafeDesktopSshTarget(rawTarget); + if (!target) { + throw new Error("Invalid desktop SSH target."); + } + + await this.manager.disconnectEnvironment(target); + }); + + ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); + ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) => + sshRuntime.runPromise( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/.well-known/t3/environment", + }), + ), + ); + + ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); + ipcMain.handle( + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + async (_event, rawHttpBaseUrl, rawCredential) => + sshRuntime.runPromise( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { credential: rawCredential }, + }), + ), + ); + + ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); + ipcMain.handle( + FETCH_SSH_SESSION_STATE_CHANNEL, + async (_event, rawHttpBaseUrl, rawBearerToken) => + sshRuntime.runPromise( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/session", + bearerToken: rawBearerToken, + }), + ), + ); + + ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); + ipcMain.handle( + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + async (_event, rawHttpBaseUrl, rawBearerToken) => + sshRuntime.runPromise( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken: rawBearerToken, + }), + ), + ); + + ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); + ipcMain.handle( + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + async (_event, rawRequestId, rawPassword) => { + if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { + throw new Error("Invalid SSH password prompt id."); + } + if (rawPassword !== null && typeof rawPassword !== "string") { + throw new Error("Invalid SSH password prompt response."); + } + + const pending = this.pendingPrompts.get(rawRequestId); + if (!pending) { + throw new Error("SSH password prompt expired. Try connecting again."); + } + + clearTimeout(pending.timeout); + this.pendingPrompts.delete(rawRequestId); + pending.resolve(rawPassword); + }, + ); + } + + cancelPendingPasswordPrompts(reason: string): void { + for (const [requestId, pending] of this.pendingPrompts) { + clearTimeout(pending.timeout); + this.pendingPrompts.delete(requestId); + pending.reject(new Error(reason)); + } + } + + async dispose(): Promise { + this.cancelPendingPasswordPrompts("SSH environment bridge disposed."); + await this.manager.dispose(); + } + + private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise { + const window = this.options.getMainWindow(); + if (!window || window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + + const request: DesktopSshPasswordPromptRequest = { + requestId: Crypto.randomUUID(), + destination: input.destination, + username: input.username, + prompt: input.prompt, + expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(), + }; + + return await new Promise((resolve, reject) => { + const rejectPrompt = (error: Error) => { + clearTimeout(timeout); + this.pendingPrompts.delete(request.requestId); + reject(error); + }; + const timeout = setTimeout(() => { + this.pendingPrompts.delete(request.requestId); + reject(new Error(`SSH authentication timed out for ${input.destination}.`)); + }, this.passwordPromptTimeoutMs); + timeout.unref(); + + this.pendingPrompts.set(request.requestId, { resolve, reject, timeout }); + + try { + if (window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); + if (window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + if (window.isMinimized()) { + window.restore(); + } + if (window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + window.focus(); + } catch (error) { + rejectPrompt( + error instanceof Error + ? error + : new Error("T3 Code window is not available for SSH authentication."), + ); + } + }); + } +} diff --git a/apps/desktop/src/tailscaleEndpointProvider.test.ts b/apps/desktop/src/tailscaleEndpointProvider.test.ts new file mode 100644 index 00000000000..2e92b7ee5d3 --- /dev/null +++ b/apps/desktop/src/tailscaleEndpointProvider.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + resolveTailscaleAdvertisedEndpoints, +} from "./tailscaleEndpointProvider.ts"; + +describe("tailscale endpoint provider", () => { + it("detects Tailnet IPv4 addresses", () => { + expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true); + expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true); + expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false); + expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false); + }); + + it("parses MagicDNS names from tailscale status", async () => { + expect( + Effect.runSync( + parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })), + ), + ).toBe("desktop.tail.ts.net"); + expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull(); + await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined(); + }); + + it("resolves Tailscale endpoints as add-on advertised endpoints", async () => { + await expect( + resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + tailscale0: [ + { + address: "100.100.100.100", + family: "IPv4", + internal: false, + netmask: "255.192.0.0", + cidr: "100.100.100.100/10", + mac: "00:00:00:00:00:00", + }, + ], + }, + statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), + }), + ).resolves.toEqual([ + { + id: "tailscale-ip:http://100.100.100.100:3773", + label: "Tailscale IP", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "http://100.100.100.100:3773/", + wsBaseUrl: "ws://100.100.100.100:3773/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }, + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "requires-configuration", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "unavailable", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }, + ]); + }); + + it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => { + await expect( + resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: {}, + statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), + serveEnabled: true, + probe: async () => true, + }), + ).resolves.toEqual([ + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "HTTPS endpoint served by Tailscale Serve.", + }, + ]); + }); +}); diff --git a/apps/desktop/src/tailscaleEndpointProvider.ts b/apps/desktop/src/tailscaleEndpointProvider.ts new file mode 100644 index 00000000000..053eac5d442 --- /dev/null +++ b/apps/desktop/src/tailscaleEndpointProvider.ts @@ -0,0 +1,142 @@ +import type { NetworkInterfaceInfo } from "node:os"; + +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { + buildTailscaleHttpsBaseUrl, + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import { Effect, Layer } from "effect"; + +export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; + +const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici); + +const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, +}; + +function createTailscaleEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + }); +} + +export function resolveTailscaleIpAdvertisedEndpoints(input: { + readonly port: number; + readonly networkInterfaces: NodeJS.Dict; +}): readonly AdvertisedEndpoint[] { + const seen = new Set(); + const endpoints: AdvertisedEndpoint[] = []; + + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isTailscaleIpv4Address(address.address)) continue; + if (seen.has(address.address)) continue; + seen.add(address.address); + + endpoints.push( + createTailscaleEndpoint({ + id: `tailscale-ip:http://${address.address}:${input.port}`, + label: "Tailscale IP", + httpBaseUrl: `http://${address.address}:${input.port}`, + reachability: "private-network", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }), + ); + } + } + + return endpoints; +} + +export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: { + readonly dnsName: string | null; + readonly serveEnabled: boolean; + readonly servePort?: number; + readonly probe?: (baseUrl: string) => Promise; +}): Promise { + if (!input.dnsName) { + return null; + } + + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName: input.dnsName, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + }); + const isReachable = input.serveEnabled + ? await (input.probe?.(httpBaseUrl) ?? + Effect.runPromise( + probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( + Effect.provide(TailscaleDesktopLayer), + ), + )) + : false; + + return createTailscaleEndpoint({ + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + reachability: "private-network", + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", + status: isReachable ? "available" : "unavailable", + description: isReachable + ? "HTTPS endpoint served by Tailscale Serve." + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }); +} + +export async function resolveTailscaleAdvertisedEndpoints(input: { + readonly port: number; + readonly serveEnabled?: boolean; + readonly servePort?: number; + readonly networkInterfaces: NodeJS.Dict; + readonly statusJson?: string | null; + readonly probe?: (baseUrl: string) => Promise; +}): Promise { + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const dnsName = + input.statusJson === undefined + ? await Effect.runPromise( + readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.catch(() => Effect.succeed(null)), + Effect.provide(TailscaleDesktopLayer), + ), + ) + : input.statusJson + ? await Effect.runPromise( + parseTailscaleMagicDnsName(input.statusJson).pipe( + Effect.catch(() => Effect.succeed(null)), + ), + ) + : null; + const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName, + serveEnabled: input.serveEnabled === true, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.probe === undefined ? {} : { probe: input.probe }), + }); + + return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; +} diff --git a/apps/server/package.json b/apps/server/package.json index 13a8124cb26..c562e0401e4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -40,6 +40,7 @@ "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 5adece73020..2e13d33aabf 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -46,6 +46,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -87,6 +89,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -108,6 +112,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), + tailscaleServeEnabled: Option.some(true), + tailscaleServePort: Option.some(8443), }, Option.some("Debug"), ).pipe( @@ -149,6 +155,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + tailscaleServeEnabled: true, + tailscaleServePort: 8443, }); }), ); @@ -161,6 +169,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); @@ -176,6 +186,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(false), logWebSocketEvents: Option.some(false), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -229,6 +243,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", }); @@ -246,6 +262,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -281,6 +299,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); assert.equal(join(baseDir, "dev"), resolved.stateDir); }), @@ -305,6 +325,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -346,6 +368,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: false, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); @@ -361,6 +385,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.some("Debug"), ).pipe( @@ -399,6 +425,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -432,6 +460,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), ).pipe( @@ -463,6 +493,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); @@ -485,6 +517,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, Option.none(), { @@ -522,6 +556,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, }); }), ); diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index 7ebde01067a..3ef8c441959 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -76,6 +76,8 @@ const makeCliTestServerConfig = (baseDir: string) => desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, } satisfies ServerConfigShape; }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 4fc23a1ded0..bcf8bee861c 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -78,6 +78,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({ desktopBootstrapToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), + tailscaleServeEnabled: Schema.optional(Schema.Boolean), + tailscaleServePort: Schema.optional(PortSchema), otlpTracesUrl: Schema.optional(Schema.String), otlpMetricsUrl: Schema.optional(Schema.String), }); @@ -126,6 +128,17 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( Flag.withAlias("log-ws-events"), Flag.optional, ); +const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( + Flag.withDescription( + "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", + ), + Flag.optional, +); +const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), + Flag.optional, +); const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), @@ -174,6 +187,14 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); interface CliServerFlags { @@ -187,6 +208,8 @@ interface CliServerFlags { readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; + readonly tailscaleServeEnabled: Option.Option; + readonly tailscaleServePort: Option.Option; } interface CliAuthLocationFlags { @@ -233,6 +256,8 @@ export const resolveServerConfig = ( bootstrapFd: flags.bootstrapFd ?? Option.none(), autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), + tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), + tailscaleServePort: flags.tailscaleServePort ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -323,6 +348,22 @@ export const resolveServerConfig = ( ), () => Boolean(devUrl), ); + const tailscaleServeEnabled = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServeEnabled, + Option.fromUndefinedOr(env.tailscaleServeEnabled), + Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), + ), + () => false, + ); + const tailscaleServePort = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServePort, + Option.fromUndefinedOr(env.tailscaleServePort), + Option.fromUndefinedOr(bootstrap?.tailscaleServePort), + ), + () => 443, + ); const staticDir = devUrl ? undefined : yield* resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -365,6 +406,8 @@ export const resolveServerConfig = ( desktopBootstrapToken, autoBootstrapProjectFromCwd, logWebSocketEvents, + tailscaleServeEnabled, + tailscaleServePort, }; return config; @@ -386,6 +429,8 @@ const resolveCliAuthConfig = ( bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), }, cliLogLevel, ); @@ -766,6 +811,8 @@ const sharedServerCommandFlags = { bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, + tailscaleServeEnabled: tailscaleServeFlag, + tailscaleServePort: tailscaleServePortFlag, } as const; const authLocationFlags = sharedServerLocationFlags; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 7840c761151..4d8e1cb7e79 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -65,6 +65,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; } export const deriveServerPaths = Effect.fn(function* ( @@ -158,6 +160,8 @@ export class ServerConfig extends Context.Service = [ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, - { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, - { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, - { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, - { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, - { key: "mod+o", command: "editor.openFavorite" }, - { key: "mod+shift+[", command: "thread.previous" }, - { key: "mod+shift+]", command: "thread.next" }, - ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ - key: `mod+${index + 1}`, - command, - })), - ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ - key: `mod+${index + 1}`, - command, - when: "modelPickerOpen", - })), -]; - -function normalizeKeyToken(token: string): string { - if (token === "space") return " "; - if (token === "esc") return "escape"; - return token; -} - -/** @internal - Exported for testing */ -export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { - const rawTokens = value - .toLowerCase() - .split("+") - .map((token) => token.trim()); - const tokens = [...rawTokens]; - let trailingEmptyCount = 0; - while (tokens[tokens.length - 1] === "") { - trailingEmptyCount += 1; - tokens.pop(); - } - if (trailingEmptyCount > 0) { - tokens.push("+"); - } - if (tokens.some((token) => token.length === 0)) { - return null; - } - if (tokens.length === 0) return null; - - let key: string | null = null; - let metaKey = false; - let ctrlKey = false; - let shiftKey = false; - let altKey = false; - let modKey = false; - - for (const token of tokens) { - switch (token) { - case "cmd": - case "meta": - metaKey = true; - break; - case "ctrl": - case "control": - ctrlKey = true; - break; - case "shift": - shiftKey = true; - break; - case "alt": - case "option": - altKey = true; - break; - case "mod": - modKey = true; - break; - default: { - if (key !== null) return null; - key = normalizeKeyToken(token); - } - } - } - - if (key === null) return null; - return { - key, - metaKey, - ctrlKey, - shiftKey, - altKey, - modKey, - }; -} - -function tokenizeWhenExpression(expression: string): WhenToken[] | null { - const tokens: WhenToken[] = []; - let index = 0; - - while (index < expression.length) { - const current = expression[index]; - if (!current) break; - - if (/\s/.test(current)) { - index += 1; - continue; - } - if (expression.startsWith("&&", index)) { - tokens.push({ type: "and" }); - index += 2; - continue; - } - if (expression.startsWith("||", index)) { - tokens.push({ type: "or" }); - index += 2; - continue; - } - if (current === "!") { - tokens.push({ type: "not" }); - index += 1; - continue; - } - if (current === "(") { - tokens.push({ type: "lparen" }); - index += 1; - continue; - } - if (current === ")") { - tokens.push({ type: "rparen" }); - index += 1; - continue; - } - - const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); - if (!identifier) { - return null; - } - tokens.push({ type: "identifier", value: identifier[0] }); - index += identifier[0].length; - } - - return tokens; -} - -function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { - const tokens = tokenizeWhenExpression(expression); - if (!tokens || tokens.length === 0) return null; - let index = 0; - - const parsePrimary = (depth: number): KeybindingWhenNode | null => { - if (depth > MAX_WHEN_EXPRESSION_DEPTH) { - return null; - } - const token = tokens[index]; - if (!token) return null; - - if (token.type === "identifier") { - index += 1; - return { type: "identifier", name: token.value }; - } - - if (token.type === "lparen") { - index += 1; - const expressionNode = parseOr(depth + 1); - const closeToken = tokens[index]; - if (!expressionNode || !closeToken || closeToken.type !== "rparen") { - return null; - } - index += 1; - return expressionNode; - } - - return null; - }; - - const parseUnary = (depth: number): KeybindingWhenNode | null => { - let notCount = 0; - while (tokens[index]?.type === "not") { - index += 1; - notCount += 1; - if (notCount > MAX_WHEN_EXPRESSION_DEPTH) { - return null; - } - } - - let node = parsePrimary(depth); - if (!node) return null; - - while (notCount > 0) { - node = { type: "not", node }; - notCount -= 1; - } - - return node; - }; - - const parseAnd = (depth: number): KeybindingWhenNode | null => { - let left = parseUnary(depth); - if (!left) return null; - - while (tokens[index]?.type === "and") { - index += 1; - const right = parseUnary(depth); - if (!right) return null; - left = { type: "and", left, right }; - } - - return left; - }; - - const parseOr = (depth: number): KeybindingWhenNode | null => { - let left = parseAnd(depth); - if (!left) return null; - - while (tokens[index]?.type === "or") { - index += 1; - const right = parseAnd(depth); - if (!right) return null; - left = { type: "or", left, right }; - } - - return left; - }; - - const ast = parseOr(0); - if (!ast || index !== tokens.length) return null; - return ast; -} - -/** @internal - Exported for testing */ -export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { - const shortcut = parseKeybindingShortcut(rule.key); - if (!shortcut) return null; - - if (rule.when !== undefined) { - const whenAst = parseKeybindingWhenExpression(rule.when); - if (!whenAst) return null; - return { - command: rule.command, - shortcut, - whenAst, - }; - } - - return { - command: rule.command, - shortcut, - }; -} - -export function compileResolvedKeybindingsConfig( - config: KeybindingsConfig, -): ResolvedKeybindingsConfig { - const compiled: Mutable = []; - for (const rule of config) { - const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule); - if (result._tag === "Success") { - compiled.push(result.value); - } - } - return compiled; -} +import { + DEFAULT_KEYBINDINGS, + DEFAULT_RESOLVED_KEYBINDINGS, + compileResolvedKeybindingRule, + compileResolvedKeybindingsConfig, + parseKeybindingShortcut, +} from "@t3tools/shared/keybindings"; + +export { + DEFAULT_KEYBINDINGS, + compileResolvedKeybindingRule, + compileResolvedKeybindingsConfig, + parseKeybindingShortcut, +}; export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( Schema.decodeTo( @@ -412,8 +150,6 @@ function encodeWhenAst(node: KeybindingWhenNode): string { } } -const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); - const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const PrettyJsonString = SchemaGetter.parseJson().compose( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index dbe90e63adc..36af229d4c8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -75,6 +75,7 @@ import { type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; +import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -217,10 +218,8 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -const authTestLayer = ServerAuthLive.pipe( - Layer.provide(SqlitePersistenceMemory), - Layer.provide(ServerSecretStoreLive), -); +const makeAuthTestLayer = () => + ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive)); const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { @@ -378,6 +377,8 @@ const buildAppUnderTest = (options?: { desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); @@ -653,7 +654,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), - Layer.provideMerge(authTestLayer), + Layer.provideMerge(makeAuthTestLayer()), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -2021,7 +2022,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(second, { version: 1, type: "keybindingsUpdated", - payload: { issues: [] }, + payload: { keybindings: [], issues: [] }, }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3110,6 +3111,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc orchestration shell snapshot errors", () => + Effect.gen(function* () { + const projectionError = new PersistenceSqlError({ + operation: "ProjectionSnapshotQuery.getShellSnapshot:test", + detail: "failed to read projection shell snapshot", + }); + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getShellSnapshot: () => Effect.fail(projectionError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe(Stream.runCollect), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); + assertTrue(result.failure.cause instanceof Error); + assert.include(result.failure.cause.message, projectionError.message); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("enriches replayed project events with repository identity metadata", () => Effect.gen(function* () { const repositoryIdentity = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 49e18ffefaa..f06e0e78ed1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -83,6 +83,7 @@ import { orchestrationSnapshotRouteLayer, } from "./orchestration/http.ts"; import { NetService } from "@t3tools/shared/Net"; +import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -334,6 +335,57 @@ export const makeServerLayer = Layer.unwrap( () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), ), ); + const tailscaleServeLayer = config.tailscaleServeEnabled + ? Layer.effectDiscard( + Effect.acquireRelease( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return null; + } + + const localPort = address.port; + return yield* ensureTailscaleServe({ + localPort, + servePort: config.tailscaleServePort, + localHost: "127.0.0.1", + }).pipe( + Effect.as({ localPort, servePort: config.tailscaleServePort }), + Effect.tap(() => + Effect.logInfo("Tailscale Serve configured", { + localPort, + servePort: config.tailscaleServePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to configure Tailscale Serve", { + cause, + localPort, + servePort: config.tailscaleServePort, + }).pipe(Effect.as(null)), + ), + ); + }), + (configured) => + configured + ? disableTailscaleServe({ servePort: configured.servePort }).pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve disabled", { + servePort: configured.servePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { + cause, + servePort: configured.servePort, + }), + ), + ) + : Effect.void, + ), + ) + : Layer.empty; const serverApplicationLayer = Layer.mergeAll( HttpRouter.serve(makeRoutesLayer, { @@ -341,6 +393,7 @@ export const makeServerLayer = Layer.unwrap( }), httpListeningLayer, runtimeStateLayer, + tailscaleServeLayer, ); return serverApplicationLayer.pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d7015a54cae..665d65e3357 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -672,6 +672,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ORCHESTRATION_WS_METHODS.subscribeShell, Effect.gen(function* () { const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.tapError((cause) => + Effect.logError("orchestration shell snapshot load failed", { cause }), + ), Effect.mapError( (cause) => new OrchestrationGetSnapshotError({ @@ -1017,6 +1020,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => version: 1 as const, type: "keybindingsUpdated" as const, payload: { + keybindings: event.keybindings, issues: event.issues, }, })), diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 99775a4c55d..061d5b78a8a 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -15,4 +15,4 @@ export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; -export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const APP_VERSION = "0.0.20"; // import.meta.env.APP_VERSION || "0.0.0"; diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index a74ce18ac30..e02168e09b3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -10,6 +10,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { wsBaseUrl: "wss://remote.example.com/", createdAt: "2026-04-09T00:00:00.000Z", lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, }; function createLocalStorageStub(): Storage { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 70f51d5c30a..30c949b37ac 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ wsBaseUrl: Schema.String, createdAt: Schema.String, lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey( + Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + }), + ), bearerToken: Schema.optionalKey(Schema.String), }); type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; @@ -37,7 +45,7 @@ function hasWindow(): boolean { function toPersistedSavedEnvironmentRecord( record: PersistedSavedEnvironmentRecord, ): PersistedSavedEnvironmentRecord { - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; } export function readBrowserClientSettings(): ClientSettings | null { @@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry( wsBaseUrl: record.wsBaseUrl, createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), bearerToken, } : toPersistedSavedEnvironmentRecord(record); @@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret( return record; } found = true; - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, bearerToken: secret, - } satisfies BrowserSavedEnvironmentRecord; + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; }), }); return found; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 48c1d89218b..27c5c311c60 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -80,8 +80,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], ); - const environmentLabel = activeEnvironment?.label ?? "Run on"; - const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; const WorkspaceIcon = effectiveEnvMode === "worktree" ? FolderGit2Icon @@ -93,6 +91,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); + const isLocked = envLocked || envModeLocked; + const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; + const icon = showEnvironmentPicker ? ( + // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px + // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. + + + + + ) : ( + + ); + const triggerContent = ( + <> + {icon} + + {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + + + ); + + if (isLocked) { + return ( + + {triggerContent} + + ); + } return ( @@ -100,17 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ render={ + + + ), + }); + } + if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { + items.push({ + id: `version-mismatch:${versionMismatchDismissKey}`, + variant: "warning", + icon: , + title: "Client and server versions differ", + description: ( + <> + Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} + {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. + + ), + dismissLabel: "Dismiss version mismatch warning", + onDismiss: () => { + dismissVersionMismatch(versionMismatchDismissKey); + setDismissedVersionMismatchKey(versionMismatchDismissKey); + }, + }); + } + return items; + }, [ + activeEnvironmentUnavailableState, + handleReconnectActiveEnvironment, + navigate, + reconnectingEnvironmentId, + showVersionMismatchBanner, + versionMismatch, + versionMismatchDismissKey, + versionMismatchServerLabel, + ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, @@ -2354,6 +2554,13 @@ export default function ChatView(props: ChatViewProps) { const localApi = readLocalApi(); if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; + if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { + setThreadError( + activeThread.id, + `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + ); + return; + } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; @@ -2389,6 +2596,8 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThread, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableLabel, environmentId, isConnecting, isRevertingCheckpoint, @@ -2401,7 +2610,15 @@ export default function ChatView(props: ChatViewProps) { const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if ( + !api || + !activeThread || + isSendBusy || + isConnecting || + activeEnvironmentUnavailable || + sendInFlightRef.current + ) + return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -3022,6 +3239,7 @@ export default function ChatView(props: ChatViewProps) { !isServerThread || isSendBusy || isConnecting || + activeEnvironmentUnavailable || sendInFlightRef.current ) { return; @@ -3136,6 +3354,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadBranch, activeThread, beginLocalDispatch, + activeEnvironmentUnavailable, isConnecting, isSendBusy, isServerThread, @@ -3382,74 +3601,82 @@ export default function ChatView(props: ChatViewProps) { : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", )} > - +
+ +
+ +
+
{isGitRepo && ( )} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index c9d081b3fa6..b6df9712bb1 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -289,7 +289,7 @@ function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { version: 1, type: "keybindingsUpdated", - payload: { issues }, + payload: { keybindings: fixture.serverConfig.keybindings, issues }, }); } diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 38e07f59ced..ad47e01bb11 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -10,15 +10,29 @@ export function ProjectFavicon(input: { cwd: string; className?: string; }) { - const src = resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd }, - }); + const src = (() => { + try { + return resolveEnvironmentHttpUrl({ + environmentId: input.environmentId, + pathname: "/api/project-favicon", + searchParams: { cwd: input.cwd }, + }); + } catch { + return null; + } + })(); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", ); + if (!src) { + return ( + + ); + } + return ( <> {status !== "loaded" ? ( diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts index aed81ab01c7..9a7e484dc20 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +++ b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts @@ -8,6 +8,7 @@ function makeStatus(overrides: Partial = {}): WsConnectionSt attemptCount: 0, closeCode: null, closeReason: null, + connectionLabel: null, connectedAt: null, disconnectedAt: null, hasConnected: false, diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index e0bb560980a..b54bd865c8b 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -53,8 +53,16 @@ function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } -function buildReconnectTitle(_status: WsConnectionStatus): string { - return "Disconnected from T3 Server"; +function getConnectionDisplayName(status: WsConnectionStatus): string { + return status.connectionLabel?.trim() || "T3 Server"; +} + +function buildReconnectTitle(status: WsConnectionStatus): string { + return `Disconnected from ${getConnectionDisplayName(status)}`; +} + +function buildRecoveredTitle(status: WsConnectionStatus): string { + return `Reconnected to ${getConnectionDisplayName(status)}`; } function describeRecoveredToast( @@ -297,7 +305,7 @@ export function WebSocketConnectionCoordinator() { }, description: describeExhaustedToast(), timeout: 0, - title: "Disconnected from T3 Server", + title: buildReconnectTitle(status), type: "error", }) : stackedThreadToast({ @@ -334,7 +342,7 @@ export function WebSocketConnectionCoordinator() { ) { const successToast = { description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), - title: "Reconnected to T3 Server", + title: buildRecoveredTitle(status), type: "success" as const, timeout: 0, data: { diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index f583af72ec4..65e9c6dd8eb 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts"; import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; +import { addSavedEnvironment } from "../../environments/runtime"; import { peekPairingTokenFromUrl, stripPairingTokenFromUrl, submitServerAuthCredential, } from "../../environments/primary"; +import { readHostedPairingRequest } from "../../hostedPairing"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -159,6 +161,127 @@ export function PairingRouteSurface({ ); } +export function HostedPairingRouteSurface() { + const hostedPairingRequestRef = useRef(readHostedPairingRequest()); + const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => + hostedPairingRequestRef.current ? "pairing" : "error", + ); + const [message, setMessage] = useState(() => + hostedPairingRequestRef.current + ? "Connecting to this backend." + : "This pairing link is missing its backend host or token.", + ); + const [canRetry, setCanRetry] = useState(false); + const submitAttemptedRef = useRef(false); + const tokenSubmittedRef = useRef(false); + + const submitHostedPairingRequest = useCallback(async () => { + const request = hostedPairingRequestRef.current; + + if (!request) { + setStatus("error"); + setMessage("This pairing link is missing its backend host or token."); + setCanRetry(false); + return; + } + + if (tokenSubmittedRef.current) { + setStatus("error"); + setMessage("This one-time pairing token was already submitted. Request a new pairing link."); + setCanRetry(false); + return; + } + + setStatus("pairing"); + setMessage("Connecting to this backend."); + setCanRetry(false); + tokenSubmittedRef.current = true; + + try { + const record = await addSavedEnvironment({ + label: request.label, + host: request.host, + pairingCode: request.token, + }); + setStatus("paired"); + setMessage(`${record.label} is saved in this browser.`); + } catch (error) { + tokenSubmittedRef.current = false; + setStatus("error"); + setCanRetry(true); + setMessage( + `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, + ); + } + }, []); + + useEffect(() => { + if (submitAttemptedRef.current) { + return; + } + submitAttemptedRef.current = true; + + stripPairingTokenFromUrl(); + void submitHostedPairingRequest(); + }, [submitHostedPairingRequest]); + + const request = hostedPairingRequestRef.current; + + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ {status === "paired" + ? "Backend paired" + : status === "error" + ? "Pairing failed" + : "Pairing backend"} +

+

{message}

+ + {request ? ( +
+ Host: {request.host} +
+ ) : null} + + {status === "error" ? ( +
+ Verify the backend is reachable from this browser, supports CORS for hosted clients, and + is served over HTTPS when opening this page from HTTPS. +
+ ) : null} + +
+ {status === "pairing" ? ( + + ) : canRetry ? ( + + ) : null} + {status === "paired" ? ( + + ) : null} +
+
+
+ ); +} + function errorMessageFromUnknown(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6be51f5d835..d64d0316684 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -301,6 +301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( promptHasText: boolean; isSendBusy: boolean; isConnecting: boolean; + isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; @@ -321,6 +322,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} isConnecting={props.isConnecting} + isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} @@ -393,6 +395,10 @@ export interface ChatComposerProps { isConnecting: boolean; isSendBusy: boolean; isPreparingWorktree: boolean; + environmentUnavailable: { + readonly label: string; + readonly connectionState: "connecting" | "disconnected" | "error"; + } | null; // Pending approvals / inputs activePendingApproval: PendingApproval | null; @@ -500,6 +506,7 @@ export const ChatComposer = memo( isConnecting, isSendBusy, isPreparingWorktree, + environmentUnavailable, activePendingApproval, pendingApprovals, pendingUserInputs, @@ -1959,6 +1966,7 @@ export const ChatComposer = memo( className={cn( "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", + environmentUnavailable ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, )} onFocusCapture={(event) => { @@ -2070,6 +2078,7 @@ export const ChatComposer = memo( promptHasText={false} isSendBusy={isSendBusy} isConnecting={isConnecting} + isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={false} hasSendableContent={false} preserveComposerFocusOnPointerDown @@ -2250,11 +2259,21 @@ export const ChatComposer = memo( ? "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, or use / to show available commands" + : environmentUnavailable + ? `${environmentUnavailable.label} is ${ + environmentUnavailable.connectionState === "connecting" + ? "connecting" + : "disconnected" + }` + : phase === "disconnected" + ? "Ask for follow-up changes or attach images" + : "Ask anything, @tag files/folders, or use / to show available commands" + } + disabled={ + isConnecting || + isComposerApprovalState || + (environmentUnavailable !== null && activePendingProgress === null) } - disabled={isConnecting || isComposerApprovalState} /> {showMobilePendingAnswerActions ? (
0} isSendBusy={isSendBusy} isConnecting={isConnecting} + isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts new file mode 100644 index 00000000000..1b7b219bdb4 --- /dev/null +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -0,0 +1,48 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { shouldShowOpenInPicker } from "./ChatHeader"; + +describe("shouldShowOpenInPicker", () => { + const primaryEnvironmentId = EnvironmentId.make("environment-primary"); + + it("shows the picker for projects in the primary environment", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: primaryEnvironmentId, + primaryEnvironmentId, + }), + ).toBe(true); + }); + + it("hides the picker when hosted static mode has no primary environment", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), + primaryEnvironmentId: null, + }), + ).toBe(false); + }); + + it("hides the picker for remote environments", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), + primaryEnvironmentId, + }), + ).toBe(false); + }); + + it("hides the picker when there is no active project", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: undefined, + activeThreadEnvironmentId: primaryEnvironmentId, + primaryEnvironmentId, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 5d7c9292474..3cd4576ab88 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -44,6 +44,18 @@ interface ChatHeaderProps { onToggleDiff: () => void; } +export function shouldShowOpenInPicker(input: { + readonly activeProjectName: string | undefined; + readonly activeThreadEnvironmentId: EnvironmentId; + readonly primaryEnvironmentId: EnvironmentId | null; +}): boolean { + return ( + Boolean(input.activeProjectName) && + input.primaryEnvironmentId !== null && + input.activeThreadEnvironmentId === input.primaryEnvironmentId + ); +} + export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, @@ -70,8 +82,11 @@ export const ChatHeader = memo(function ChatHeader({ onToggleDiff, }: ChatHeaderProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteEnvironment = - primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId; + const showOpenInPicker = shouldShowOpenInPicker({ + activeProjectName, + activeThreadEnvironmentId, + primaryEnvironmentId, + }); return (
@@ -106,7 +121,7 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteScript={onDeleteProjectScript} /> )} - {activeProjectName && !isRemoteEnvironment && ( + {showOpenInPicker && ( void; +} + +interface ComposerBannerStackProps { + readonly className?: string; + readonly items: ReadonlyArray; +} + +export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { + const [exitingItemId, setExitingItemId] = useState(null); + const dismissTimeoutRef = useRef | null>(null); + + useEffect(() => { + if (exitingItemId && !items.some((item) => item.id === exitingItemId)) { + setExitingItemId(null); + } + }, [exitingItemId, items]); + + useEffect(() => { + return () => { + if (dismissTimeoutRef.current) { + clearTimeout(dismissTimeoutRef.current); + } + }; + }, []); + + if (items.length === 0) { + return null; + } + + const frontItem = items[0]; + if (!frontItem) { + return null; + } + const stackedItems = items.slice(1); + const hasStack = stackedItems.length > 0; + const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; + + const requestDismiss = (item: ComposerBannerStackItem) => { + if (!item.onDismiss || exitingItemId) { + return; + } + setExitingItemId(item.id); + if (dismissTimeoutRef.current) { + clearTimeout(dismissTimeoutRef.current); + } + dismissTimeoutRef.current = setTimeout(() => { + dismissTimeoutRef.current = null; + item.onDismiss?.(); + }, DISMISS_TRANSITION_MS); + }; + + return ( +
+
+ {showCollapsedStackCap ? ( + +
+ ); +} + +function ComposerBannerStackAlert({ + item, + exiting, + onDismissRequest, +}: { + readonly item: ComposerBannerStackItem; + readonly exiting: boolean; + readonly onDismissRequest: () => void; +}) { + const dismissOnly = item.onDismiss && !item.actions; + + return ( + + {item.icon} + {item.title} + {item.description ? {item.description} : null} + {item.actions || item.onDismiss ? ( + + {item.actions} + {item.onDismiss ? ( + + ) : null} + + ) : null} + + ); +} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 9e60671ab5b..fbeb9de30b8 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -20,6 +20,7 @@ interface ComposerPrimaryActionsProps { promptHasText: boolean; isSendBusy: boolean; isConnecting: boolean; + isEnvironmentUnavailable: boolean; isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; @@ -58,6 +59,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ promptHasText, isSendBusy, isConnecting, + isEnvironmentUnavailable, isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, @@ -104,6 +106,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ className={cn("rounded-full", compact ? "px-3" : "px-4")} {...pointerFocusProps} disabled={ + isEnvironmentUnavailable || pendingAction.isResponding || (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) } @@ -143,7 +146,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} {...pointerFocusProps} - disabled={isSendBusy || isConnecting} + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} > {isConnecting || isSendBusy ? "Sending..." : "Refine"} @@ -157,7 +160,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" {...pointerFocusProps} - disabled={isSendBusy || isConnecting} + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} > {isConnecting || isSendBusy ? "Sending..." : "Implement"} @@ -170,7 +173,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" aria-label="Implementation actions" {...pointerFocusProps} - disabled={isSendBusy || isConnecting} + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} /> } > @@ -178,7 +181,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ void onImplementPlanInNewThread()} > Implement in a new thread @@ -194,15 +197,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="submit" className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || !hasSendableContent} + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} aria-label={ - isConnecting - ? "Connecting" - : isPreparingWorktree - ? "Preparing worktree" - : isSendBusy - ? "Sending" - : "Send message" + isEnvironmentUnavailable + ? "Environment disconnected" + : isConnecting + ? "Connecting" + : isPreparingWorktree + ? "Preparing worktree" + : isSendBusy + ? "Sending" + : "Send message" } > {isConnecting || isSendBusy ? ( diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx new file mode 100644 index 00000000000..7a20badf02b --- /dev/null +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -0,0 +1,221 @@ +import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; +import { useEffect, useId, useRef, useState } from "react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; + +function describeSshTarget(request: DesktopSshPasswordPromptRequest): string { + return request.username ? `${request.username}@${request.destination}` : request.destination; +} + +function formatRemainingSeconds(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + +function getPromptErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : "SSH password prompt failed."; + return message.includes("expired") || message.includes("no longer pending") + ? "This SSH password prompt expired. Try connecting again." + : message; +} + +export function SshPasswordPromptDialog() { + const [queue, setQueue] = useState([]); + const [password, setPassword] = useState(""); + const [isResponding, setIsResponding] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const [responseError, setResponseError] = useState(null); + const currentRequest = queue[0] ?? null; + const inputRef = useRef(null); + const isRespondingRef = useRef(false); + const formId = useId(); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.onSshPasswordPrompt) { + return; + } + + return bridge.onSshPasswordPrompt((request) => { + setQueue((currentQueue) => [...currentQueue, request]); + }); + }, []); + + useEffect(() => { + setPassword(""); + setResponseError(null); + if (!currentRequest) { + return; + } + + setNow(Date.now()); + const frame = window.requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [currentRequest]); + + useEffect(() => { + if (!currentRequest) { + return; + } + + const interval = window.setInterval(() => { + setNow(Date.now()); + }, 1_000); + return () => { + window.clearInterval(interval); + }; + }, [currentRequest]); + + const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; + const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; + const isExpired = remainingMs !== null && remainingMs <= 0; + const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); + const remainingLabel = + remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); + + useEffect(() => { + if (isExpired) { + setResponseError("This SSH password prompt expired. Try connecting again."); + } + }, [isExpired]); + + const removeCurrentPrompt = (requestId: string) => { + setQueue((currentQueue) => + currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, + ); + setPassword(""); + setResponseError(null); + }; + + const respond = async (nextPassword: string | null) => { + if (!currentRequest || isRespondingRef.current) { + return; + } + + const requestId = currentRequest.requestId; + if (nextPassword !== null && isExpired) { + setResponseError("This SSH password prompt expired. Try connecting again."); + return; + } + + isRespondingRef.current = true; + setIsResponding(true); + setResponseError(null); + try { + await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); + removeCurrentPrompt(requestId); + } catch (error) { + if (nextPassword === null) { + removeCurrentPrompt(requestId); + } else { + setResponseError(getPromptErrorMessage(error)); + } + } finally { + isRespondingRef.current = false; + setIsResponding(false); + } + }; + + const dismissExpiredPrompt = () => { + if (currentRequest) { + removeCurrentPrompt(currentRequest.requestId); + } + }; + + const cancelPrompt = () => { + if (isExpired) { + dismissExpiredPrompt(); + return; + } + void respond(null); + }; + + const target = currentRequest ? describeSshTarget(currentRequest) : null; + + return ( + { + if (!open) { + cancelPrompt(); + } + }} + > + + + SSH Password Required + + T3 needs your SSH password to connect to{" "} + {target ? {target} : "the remote host"}. The password is passed to the + local SSH process for this connection attempt and is not saved by T3 Code. + + + +
{ + event.preventDefault(); + void respond(password); + }} + > +
+
+

{currentRequest?.prompt}

+ {remainingLabel ? ( + + {isExpired ? "Expired" : remainingLabel} + + ) : null} +
+ setPassword(event.target.value)} + /> +
+ {responseError ? ( +

{responseError}

+ ) : ( +

+ Use SSH keys to avoid repeated password prompts on new SSH sessions. +

+ )} +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 880c4376e2c..fe0a0cb5816 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,8 +1,28 @@ -import { PlusIcon, QrCodeIcon } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { + ChevronDownIcon, + ChevronsLeftRightEllipsisIcon, + PlusIcon, + QrCodeIcon, + RefreshCwIcon, + TerminalIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { + type ReactNode, + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { type AuthClientSession, type AuthPairingLink, + type AdvertisedEndpoint, + type DesktopDiscoveredSshHost, + type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; @@ -11,6 +31,7 @@ import { DateTime } from "effect"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; +import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { SettingsPageContainer, SettingsRow, @@ -20,6 +41,7 @@ import { import { Input } from "../ui/input"; import { Dialog, + DialogClose, DialogFooter, DialogDescription, DialogHeader, @@ -28,6 +50,7 @@ import { DialogTitle, DialogTrigger, } from "../ui/dialog"; +import { ScrollArea } from "../ui/scroll-area"; import { AlertDialog, AlertDialogClose, @@ -44,8 +67,19 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; import { Textarea } from "../ui/textarea"; -import { setPairingTokenOnUrl } from "../../pairingUrl"; +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; +import { readHostedPairingRequest } from "../../hostedPairing"; import { createServerPairingCredential, fetchSessionState, @@ -63,10 +97,17 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, addSavedEnvironment, + connectDesktopSshEnvironment, + disconnectSavedEnvironment, getPrimaryEnvironmentConnection, reconnectSavedEnvironment, removeSavedEnvironment, } from "~/environments/runtime"; +import { useUiStateStore } from "~/uiStateStore"; +import { resolveServerConfigVersionMismatch } from "~/versionSkew"; +import { useServerConfig } from "~/rpc/serverState"; + +const DEFAULT_TAILSCALE_SERVE_PORT = 443; const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -135,6 +176,62 @@ function ConnectionStatusDot({ ); } +function AnimatedHeight({ children }: { readonly children: ReactNode }) { + const contentRef = useRef(null); + const [height, setHeight] = useState(null); + + useLayoutEffect(() => { + const element = contentRef.current; + if (!element) return; + let firstFrameId: number | null = null; + let secondFrameId: number | null = null; + + const updateHeight = () => { + const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); + setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); + }; + const cancelPendingFrames = () => { + if (firstFrameId !== null) { + window.cancelAnimationFrame(firstFrameId); + firstFrameId = null; + } + if (secondFrameId !== null) { + window.cancelAnimationFrame(secondFrameId); + secondFrameId = null; + } + }; + const updateHeightAfterPaint = () => { + cancelPendingFrames(); + updateHeight(); + firstFrameId = window.requestAnimationFrame(() => { + firstFrameId = null; + updateHeight(); + secondFrameId = window.requestAnimationFrame(() => { + secondFrameId = null; + updateHeight(); + }); + }); + }; + + updateHeightAfterPaint(); + const resizeObserver = new ResizeObserver(updateHeightAfterPaint); + resizeObserver.observe(element); + return () => { + resizeObserver.disconnect(); + cancelPendingFrames(); + }; + }, []); + + return ( +
+
{children}
+
+ ); +} + function getSavedBackendStatusTooltip( runtime: SavedEnvironmentRuntimeState | null, record: SavedEnvironmentRecord, @@ -160,12 +257,153 @@ function getSavedBackendStatusTooltip( : "Not connected yet."; } +function formatDesktopSshTarget(target: NonNullable): string { + const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; + return target.port ? `${authority}:${target.port}` : authority; +} + +function parseManualDesktopSshTarget(input: { + readonly host: string; + readonly username: string; + readonly port: string; +}): DesktopSshEnvironmentTarget { + const rawHost = input.host.trim(); + if (rawHost.length === 0) { + throw new Error("SSH host or alias is required."); + } + + let hostname = rawHost; + let username = input.username.trim() || null; + let port: number | null = null; + + const atIndex = hostname.lastIndexOf("@"); + if (atIndex > 0) { + const inlineUsername = hostname.slice(0, atIndex).trim(); + hostname = hostname.slice(atIndex + 1).trim(); + if (!username && inlineUsername.length > 0) { + username = inlineUsername; + } + } + + const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); + if (bracketedHostMatch) { + hostname = bracketedHostMatch[1]!.trim(); + if (bracketedHostMatch[2]) { + port = Number.parseInt(bracketedHostMatch[2], 10); + } + } else { + const colonSegments = hostname.split(":"); + if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { + hostname = colonSegments[0]!.trim(); + port = Number.parseInt(colonSegments[1]!, 10); + } + } + + const rawPort = input.port.trim(); + if (rawPort.length > 0) { + port = Number.parseInt(rawPort, 10); + } + + if (hostname.length === 0) { + throw new Error("SSH host or alias is required."); + } + + if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { + throw new Error("SSH port must be between 1 and 65535."); + } + + return { + alias: hostname, + hostname, + username, + port, + }; +} + +function parsePairingUrlFields( + input: string, +): { readonly host: string; readonly pairingCode: string } | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const urlLikeInput = + /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") + ? trimmed + : `https://${trimmed}`; + const url = new URL(urlLikeInput, window.location.origin); + const hostedPairingRequest = readHostedPairingRequest(url); + if (hostedPairingRequest) { + return { + host: hostedPairingRequest.host, + pairingCode: hostedPairingRequest.token, + }; + } + + const pairingCode = getPairingTokenFromUrl(url); + if (!pairingCode) return null; + return { + host: url.origin, + pairingCode, + }; + } catch { + return null; + } +} + +function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): { + readonly host: string; + readonly pairingCode: string; +} { + const parsedPairingUrl = parsePairingUrlFields(input.host); + if (parsedPairingUrl) return parsedPairingUrl; + + const host = input.host.trim(); + const pairingCode = input.pairingCode.trim(); + if (!host) { + throw new Error("Enter a backend host."); + } + if (!pairingCode) { + throw new Error("Enter a pairing code."); + } + return { host, pairingCode }; +} + +function formatDesktopSshConnectionError(error: unknown): string { + const fallback = "Failed to connect SSH host."; + const rawMessage = error instanceof Error ? error.message : fallback; + const withoutIpcPrefix = rawMessage.replace( + /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u, + "", + ); + const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, ""); + return withoutTaggedErrorPrefix.trim() || fallback; +} + /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; +const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; const ITEM_ROW_INNER_CLASSNAME = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; +type AccessSectionPresentation = "current" | "endpoint-rail"; + +function accessRowClassName(_presentation: AccessSectionPresentation) { + return ITEM_ROW_CLASSNAME; +} + +function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { + if (presentation === "endpoint-rail") { + return cn( + "relative border-t border-border/60 px-4 py-3 first:border-t-0 sm:px-5", + !isAvailable && "bg-muted/20", + ); + } + + return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); +} + function sortDesktopPairingLinks(links: ReadonlyArray) { return [...links].toSorted( (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), @@ -243,10 +481,66 @@ function removeDesktopClientSession( return current.filter((clientSession) => clientSession.sessionId !== sessionId); } -function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { - const url = new URL(endpointUrl); - url.pathname = "/pair"; - return setPairingTokenOnUrl(url, credential).toString(); +function selectPairingEndpoint( + endpoints: ReadonlyArray, + defaultEndpointKey?: string | null, +): AdvertisedEndpoint | null { + const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); + if (defaultEndpointKey) { + const selectedEndpoint = availableEndpoints.find( + (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, + ); + if (selectedEndpoint) { + return selectedEndpoint; + } + } + return ( + availableEndpoints.find((endpoint) => endpoint.isDefault) ?? + availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? + availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? + null + ); +} + +function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { + return endpoint.id.startsWith("tailscale-magicdns:"); +} + +function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { + if (endpoint.id.startsWith("desktop-loopback:")) { + return "desktop-core:loopback:http"; + } + if (endpoint.id.startsWith("desktop-lan:")) { + return "desktop-core:lan:http"; + } + if (endpoint.id.startsWith("tailscale-ip:")) { + return "tailscale:ip:http"; + } + if (isTailscaleHttpsEndpoint(endpoint)) { + return "tailscale:magicdns:https"; + } + + let scheme = "unknown"; + try { + scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); + } catch { + // Keep the stored preference stable even if a custom endpoint is malformed. + } + + return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; +} + +function resolveAdvertisedEndpointPairingUrl( + endpoint: AdvertisedEndpoint, + credential: string, +): string { + if (endpoint.compatibility.hostedHttpsApp === "compatible") { + return ( + resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? + resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential) + ); + } + return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); } function resolveCurrentOriginPairingUrl(credential: string): string { @@ -254,9 +548,21 @@ function resolveCurrentOriginPairingUrl(credential: string): string { return setPairingTokenOnUrl(url, credential).toString(); } +function isHostedAppPairingUrl(value: string): boolean { + try { + const url = new URL(value); + return url.pathname === "/pair" && url.searchParams.has("host"); + } catch { + return false; + } +} + type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; endpointUrl: string | null | undefined; + endpoints: ReadonlyArray; + defaultEndpointKey: string | null; + presentation?: AccessSectionPresentation; revokingPairingLinkId: string | null; onRevoke: (id: string) => void; }; @@ -264,6 +570,9 @@ type PairingLinkListRowProps = { const PairingLinkListRow = memo(function PairingLinkListRow({ pairingLink, endpointUrl, + endpoints, + defaultEndpointKey, + presentation = "current", revokingPairingLinkId, onRevoke, }: PairingLinkListRowProps) { @@ -278,55 +587,197 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ () => resolveCurrentOriginPairingUrl(pairingLink.credential), [pairingLink.credential], ); + const hostedPairingUrl = useMemo( + () => + endpointUrl != null && endpointUrl !== "" + ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential) + : null, + [endpointUrl, pairingLink.credential], + ); + const endpointPairingUrl = useMemo(() => { + const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); + return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null; + }, [defaultEndpointKey, endpoints, pairingLink.credential]); + const endpointCopyOptions = useMemo( + () => + endpoints + .filter((endpoint) => endpoint.status !== "unavailable") + .map((endpoint) => { + const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); + return { + key: endpointDefaultPreferenceKey(endpoint), + label: endpoint.label, + url, + detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL", + }; + }), + [endpoints, pairingLink.credential], + ); const shareablePairingUrl = - endpointUrl != null && endpointUrl !== "" - ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) + endpointPairingUrl ?? + (endpointUrl != null && endpointUrl !== "" + ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) : isLoopbackHostname(window.location.hostname) ? null - : currentOriginPairingUrl; - const copyValue = shareablePairingUrl ?? pairingLink.credential; + : currentOriginPairingUrl); + const revealValue = shareablePairingUrl ?? pairingLink.credential; + const isShareableHostedAppPairingUrl = + shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); const canCopyToClipboard = typeof window !== "undefined" && window.isSecureContext && navigator.clipboard?.writeText != null; - const { copyToClipboard, isCopied } = useCopyToClipboard({ - onCopy: () => { + const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ + onCopy: (kind) => { toastManager.add({ type: "success", - title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", - description: shareablePairingUrl - ? "Open it in the client you want to pair to this environment." - : "Paste it into another client with this backend's reachable host.", + title: + kind === "hosted-link" + ? "Hosted app link copied" + : kind === "link" + ? "Pairing URL copied" + : "Pairing code copied", + description: + kind === "hosted-link" + ? "Open it in the browser on the device you want to connect." + : kind === "link" + ? "Open it in the client you want to pair to this environment." + : "Paste it into another client to finish pairing.", }); }, - onError: (error) => { + onError: (error, kind) => { setIsRevealDialogOpen(true); toastManager.add( stackedThreadToast({ type: "error", - title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", + title: canCopyToClipboard + ? kind === "hosted-link" + ? "Could not copy hosted app link" + : kind === "link" + ? "Could not copy pairing URL" + : "Could not copy pairing code" + : "Clipboard copy unavailable", description: canCopyToClipboard ? error.message : "Showing the full value instead.", }), ); }, }); - const handleCopy = useCallback(() => { - copyToClipboard(copyValue, undefined); - }, [copyToClipboard, copyValue]); + const copyPairingValue = useCallback( + (value: string, kind: "code" | "hosted-link" | "link") => { + copyToClipboard(value, kind); + }, + [copyToClipboard], + ); + + const copyKindForUrl = useCallback( + (url: string): "hosted-link" | "link" => (isHostedAppPairingUrl(url) ? "hosted-link" : "link"), + [], + ); + + const handleCopyCode = useCallback(() => { + copyPairingValue(pairingLink.credential, "code"); + }, [copyPairingValue, pairingLink.credential]); + + const handleCopyDefaultLink = useCallback(() => { + if (!shareablePairingUrl) return; + copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl)); + }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]); const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; const primaryLabel = pairingLink.label ?? `${roleLabel} link`; + const defaultEndpointCopyOption = + endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? + endpointCopyOptions[0] ?? + null; + const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL"; + const backendEndpointCopyOptions = endpointCopyOptions.filter( + (option) => !isHostedAppPairingUrl(option.url), + ); + const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) => + isHostedAppPairingUrl(option.url), + ); + const renderEndpointMenuItems = ( + options: typeof endpointCopyOptions = endpointCopyOptions, + renderDetail = true, + ) => + options.map((option) => ( + copyPairingValue(option.url, copyKindForUrl(option.url))} + > + + {option.label} + {renderDetail ? ( + + {option.detail} + + ) : null} + + + )); + const renderPairingCodeMenuItem = (renderDetail = true) => ( + + + Copy code + {renderDetail ? ( + Token only + ) : null} + + + ); + const renderCompactEndpointGroup = ( + label: string, + options: typeof endpointCopyOptions, + includeSeparator: boolean, + ) => + options.length > 0 ? ( + <> + {includeSeparator ? : null} + + {label} + {renderEndpointMenuItems(options, false)} + + + ) : null; + const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => ( + <> + {options?.codeFirst ? ( + <> + + Pairing code + {renderPairingCodeMenuItem(false)} + + {endpointCopyOptions.length > 0 ? : null} + + ) : null} + {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)} + {renderCompactEndpointGroup( + "Hosted app link", + hostedEndpointCopyOptions, + backendEndpointCopyOptions.length > 0, + )} + {!options?.codeFirst ? ( + <> + {endpointCopyOptions.length > 0 ? : null} + + Pairing code + {renderPairingCodeMenuItem(false)} + + + ) : null} + + ); if (expiresAtMs <= nowMs) { return null; } return ( -
+
@@ -377,27 +828,70 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
{canCopyToClipboard ? ( - + <> + {shareablePairingUrl ? ( + + + + + + } + > + + + + {renderGroupedCopyMenuItems()} + + + + ) : ( + + )} + ) : ( }> - {shareablePairingUrl ? "Show link" : "Show token"} + {shareablePairingUrl ? "Show link" : "Show code"} )} - {shareablePairingUrl ? "Pairing link" : "Pairing token"} + + {shareablePairingUrl + ? isShareableHostedAppPairingUrl + ? "Hosted app pairing link" + : "Pairing link" + : "Pairing code"} + {shareablePairingUrl - ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." - : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} + ? isShareableHostedAppPairingUrl + ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." + : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." + : "Clipboard copy is unavailable here. Manually copy this code into another client."}