| author | |
| committer | |
| log | 3772fa127ccfe1de9326067ee6ed91e079456e97 |
| tree | 17086f4a588a830ba0dc2cad67439c265c3e7a10 |
| parent | 92e340d80cff793bdb06ed9d6761dfb1bde35c52 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>119 files changed, 12687 insertions(+), 1040 deletions(-)
.docs/remote-architecture.md+80| ... | @@ -93,6 +93,8 @@ Examples: | ... | @@ -93,6 +93,8 @@ Examples: |
| 93 | 93 | ||
| 94 | A known environment may or may not know the target `environmentId` before first successful connect. | 94 | A known environment may or may not know the target `environmentId` before first successful connect. |
| 95 | 95 | ||
| 96 | 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. | ||
| 97 | |||
| 96 | ### AccessEndpoint | 98 | ### AccessEndpoint |
| 97 | 99 | ||
| 98 | An `AccessEndpoint` is one concrete way to reach a known environment. | 100 | An `AccessEndpoint` is one concrete way to reach a known environment. |
| ... | @@ -108,6 +110,67 @@ A single environment may have many endpoints: | ... | @@ -108,6 +110,67 @@ A single environment may have many endpoints: |
| 108 | 110 | ||
| 109 | The environment stays the same. Only the access path changes. | 111 | The environment stays the same. Only the access path changes. |
| 110 | 112 | ||
| 113 | ### AdvertisedEndpoint | ||
| 114 | |||
| 115 | 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. | ||
| 116 | |||
| 117 | `AdvertisedEndpoint` is deliberately narrower than the full access model: | ||
| 118 | |||
| 119 | - it describes a concrete HTTP and WebSocket base URL pair | ||
| 120 | - it can mark the endpoint as default, available, or unavailable | ||
| 121 | - it includes reachability hints such as loopback, LAN, private, public, or tunnel | ||
| 122 | - it includes compatibility hints such as whether the endpoint can be used from the hosted HTTPS app | ||
| 123 | |||
| 124 | 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. | ||
| 125 | |||
| 126 | 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. | ||
| 127 | |||
| 128 | 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. | ||
| 129 | |||
| 130 | When no user default is saved, endpoint selection should prefer: | ||
| 131 | |||
| 132 | 1. endpoints compatible with the hosted HTTPS app | ||
| 133 | 2. explicitly default endpoints | ||
| 134 | 3. non-loopback endpoints | ||
| 135 | 4. loopback endpoints only for same-machine clients | ||
| 136 | |||
| 137 | This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model. | ||
| 138 | |||
| 139 | ### Endpoint providers | ||
| 140 | |||
| 141 | Endpoint providers are add-ons that contribute advertised endpoints for the current environment. | ||
| 142 | |||
| 143 | The provider boundary is intentionally outside the core environment model: | ||
| 144 | |||
| 145 | - core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle | ||
| 146 | - providers discover or synthesize endpoints | ||
| 147 | - providers return normalized `AdvertisedEndpoint` records | ||
| 148 | - the UI and pairing logic select from those records without knowing provider-specific commands | ||
| 149 | |||
| 150 | 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. | ||
| 151 | |||
| 152 | 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. | ||
| 153 | |||
| 154 | ### Hosted pairing request | ||
| 155 | |||
| 156 | A hosted pairing request is a bootstrap URL for the static web app, not a transport. | ||
| 157 | |||
| 158 | Example: | ||
| 159 | |||
| 160 | ```text | ||
| 161 | https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE | ||
| 162 | ``` | ||
| 163 | |||
| 164 | 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. | ||
| 165 | |||
| 166 | Important constraints: | ||
| 167 | |||
| 168 | - the hosted app does not proxy HTTP or WebSocket traffic | ||
| 169 | - the backend must still be reachable directly from the browser | ||
| 170 | - HTTPS pages can only connect to HTTPS/WSS backends | ||
| 171 | - HTTP LAN endpoints should keep using direct desktop or CLI pairing URLs | ||
| 172 | - the token belongs in the URL hash so it is not sent to the hosted app origin | ||
| 173 | |||
| 111 | ### RepositoryIdentity | 174 | ### RepositoryIdentity |
| 112 | 175 | ||
| 113 | `RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. | 176 | `RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. |
| ... | @@ -151,6 +214,8 @@ Benefits: | ... | @@ -151,6 +214,8 @@ Benefits: |
| 151 | - no client-specific process management required | 214 | - no client-specific process management required |
| 152 | - best fit for hosted or self-managed remote T3 deployments | 215 | - best fit for hosted or self-managed remote T3 deployments |
| 153 | 216 | ||
| 217 | 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. | ||
| 218 | |||
| 154 | ### 2. Tunneled WebSocket access | 219 | ### 2. Tunneled WebSocket access |
| 155 | 220 | ||
| 156 | Examples: | 221 | Examples: |
| ... | @@ -170,6 +235,8 @@ This is especially useful when: | ... | @@ -170,6 +235,8 @@ This is especially useful when: |
| 170 | - mobile must reach a desktop-hosted environment | 235 | - mobile must reach a desktop-hosted environment |
| 171 | - a machine should be reachable without exposing raw LAN or public ports | 236 | - a machine should be reachable without exposing raw LAN or public ports |
| 172 | 237 | ||
| 238 | 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. | ||
| 239 | |||
| 173 | ### 3. Desktop-managed SSH access | 240 | ### 3. Desktop-managed SSH access |
| 174 | 241 | ||
| 175 | SSH is an access and launch helper, not a separate environment type. | 242 | 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 | ... | @@ -185,6 +252,8 @@ After that, the renderer should still connect using an ordinary WebSocket URL ag |
| 185 | 252 | ||
| 186 | This keeps the renderer transport model consistent with every other access method. | 253 | This keeps the renderer transport model consistent with every other access method. |
| 187 | 254 | ||
| 255 | 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. | ||
| 256 | |||
| 188 | ## Launch methods | 257 | ## Launch methods |
| 189 | 258 | ||
| 190 | Launch methods answer a different question: | 259 | Launch methods answer a different question: |
| ... | @@ -227,6 +296,15 @@ The recommended T3 flow is: | ... | @@ -227,6 +296,15 @@ The recommended T3 flow is: |
| 227 | 4. Desktop establishes local port forwarding. | 296 | 4. Desktop establishes local port forwarding. |
| 228 | 5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. | 297 | 5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. |
| 229 | 298 | ||
| 299 | 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. | ||
| 300 | |||
| 301 | Failure handling should be explicit: | ||
| 302 | |||
| 303 | - SSH authentication failure should surface before any environment is saved | ||
| 304 | - remote launch failure should include remote logs or the launcher command output when available | ||
| 305 | - forwarded-port failure should leave the saved environment disconnected rather than falling back to an unrelated endpoint | ||
| 306 | - reconnect should attempt to restore the SSH bridge before reconnecting the normal WebSocket client | ||
| 307 | |||
| 230 | ### 3. Client-managed local publish | 308 | ### 3. Client-managed local publish |
| 231 | 309 | ||
| 232 | This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. | 310 | 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 | ... | @@ -267,6 +345,8 @@ T3 already supports a WebSocket auth token on the server. That should become a f |
| 267 | 345 | ||
| 268 | For publicly reachable environments, authenticated access should be treated as required. | 346 | For publicly reachable environments, authenticated access should be treated as required. |
| 269 | 347 | ||
| 348 | 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. | ||
| 349 | |||
| 270 | ## Relationship to Zed | 350 | ## Relationship to Zed |
| 271 | 351 | ||
| 272 | Zed is a useful reference implementation for managed remote launch and reconnect behavior. | 352 | Zed is a useful reference implementation for managed remote launch and reconnect behavior. |
.gitignore+1| ... | @@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__ | ... | @@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__ |
| 22 | __screenshots__/ | 22 | __screenshots__/ |
| 23 | .tanstack | 23 | .tanstack |
| 24 | squashfs-root/ | 24 | squashfs-root/ |
| 25 | .vercel |
.plans/19-remote-endpoints-hosted-static.md created+350| ... | @@ -0,0 +1,350 @@ | ||
| 1 | # Remote Endpoints and Hosted Static App Plan | ||
| 2 | |||
| 3 | ## Purpose | ||
| 4 | |||
| 5 | Make remote access feel first-class while keeping the free DIY path open. | ||
| 6 | |||
| 7 | The immediate product goal is: | ||
| 8 | |||
| 9 | - users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel | ||
| 10 | - users can generate a hosted pairing link for `app.t3.codes` | ||
| 11 | - the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin | ||
| 12 | - all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX | ||
| 13 | |||
| 14 | 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. | ||
| 15 | |||
| 16 | ## Current State | ||
| 17 | |||
| 18 | Already present or in progress: | ||
| 19 | |||
| 20 | - Server auth distinguishes bootstrap credentials from session credentials. | ||
| 21 | - One-time pairing credentials can be exchanged for browser sessions or bearer sessions. | ||
| 22 | - Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token. | ||
| 23 | - Remote environment WebSocket connections use a short-lived WebSocket token. | ||
| 24 | - Pairing URLs can carry tokens in the URL fragment. | ||
| 25 | - Hosted `/pair?host=...#token=...` can add a saved environment. | ||
| 26 | - Hosted static startup can avoid assuming the page origin is the backend. | ||
| 27 | |||
| 28 | Main gaps: | ||
| 29 | |||
| 30 | - Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs. | ||
| 31 | - Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model. | ||
| 32 | - Tailscale/MagicDNS endpoints are not detected or surfaced. | ||
| 33 | - Hosted-static empty/offline states are still thin. | ||
| 34 | - Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure. | ||
| 35 | |||
| 36 | ## Core Decision: Add `AdvertisedEndpoint` | ||
| 37 | |||
| 38 | Add a new first-class contract instead of extending the environment descriptor. | ||
| 39 | |||
| 40 | ### Why not extend `ExecutionEnvironmentDescriptor` | ||
| 41 | |||
| 42 | `ExecutionEnvironmentDescriptor` answers: "What environment is this?" | ||
| 43 | |||
| 44 | Examples: | ||
| 45 | |||
| 46 | - environment id | ||
| 47 | - label | ||
| 48 | - platform | ||
| 49 | - server version | ||
| 50 | - capabilities | ||
| 51 | |||
| 52 | `AdvertisedEndpoint` answers: "How can a client reach this environment right now?" | ||
| 53 | |||
| 54 | Examples: | ||
| 55 | |||
| 56 | - loopback URL | ||
| 57 | - LAN URL | ||
| 58 | - Tailscale IP URL | ||
| 59 | - MagicDNS/Serve URL | ||
| 60 | - manual URL | ||
| 61 | - future T3 Tunnel URL | ||
| 62 | - browser compatibility and exposure level | ||
| 63 | |||
| 64 | 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. | ||
| 65 | |||
| 66 | ### Target Contract | ||
| 67 | |||
| 68 | Add a schema in `packages/contracts`, likely `remoteAccess.ts`: | ||
| 69 | |||
| 70 | ```ts | ||
| 71 | type AdvertisedEndpointProvider = | ||
| 72 | | "loopback" | ||
| 73 | | "lan" | ||
| 74 | | "tailscale-ip" | ||
| 75 | | "tailscale-magicdns" | ||
| 76 | | "manual" | ||
| 77 | | "t3-tunnel"; | ||
| 78 | |||
| 79 | type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public"; | ||
| 80 | |||
| 81 | type AdvertisedEndpointCompatibility = { | ||
| 82 | hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown"; | ||
| 83 | desktopApp: "compatible" | "unknown"; | ||
| 84 | }; | ||
| 85 | |||
| 86 | type AdvertisedEndpoint = { | ||
| 87 | id: string; | ||
| 88 | provider: AdvertisedEndpointProvider; | ||
| 89 | label: string; | ||
| 90 | httpBaseUrl: string; | ||
| 91 | wsBaseUrl: string; | ||
| 92 | visibility: AdvertisedEndpointVisibility; | ||
| 93 | compatibility: AdvertisedEndpointCompatibility; | ||
| 94 | source: "server" | "desktop" | "user"; | ||
| 95 | status: "available" | "unavailable" | "unknown"; | ||
| 96 | isDefault?: boolean; | ||
| 97 | }; | ||
| 98 | ``` | ||
| 99 | |||
| 100 | Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`. | ||
| 101 | |||
| 102 | ## HTTP/WS and HTTPS/WSS Readiness | ||
| 103 | |||
| 104 | The codebase is partially ready, but the UX and compatibility model are not explicit enough. | ||
| 105 | |||
| 106 | What is ready: | ||
| 107 | |||
| 108 | - Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`. | ||
| 109 | - Saved environments store both HTTP and WebSocket base URLs. | ||
| 110 | - Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable. | ||
| 111 | - WebSocket connections can use a dynamically issued `wsToken`. | ||
| 112 | - Server CORS support exists for browser remote auth endpoints. | ||
| 113 | |||
| 114 | What is not solved by code alone: | ||
| 115 | |||
| 116 | - `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content. | ||
| 117 | - `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust. | ||
| 118 | - LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app. | ||
| 119 | - The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible. | ||
| 120 | |||
| 121 | Policy: | ||
| 122 | |||
| 123 | - Support both HTTP/WS and HTTPS/WSS at the runtime layer. | ||
| 124 | - Mark endpoint compatibility at the product layer. | ||
| 125 | - Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback. | ||
| 126 | |||
| 127 | ## Architecture | ||
| 128 | |||
| 129 | ### Endpoint Sources | ||
| 130 | |||
| 131 | Endpoint records can come from several providers: | ||
| 132 | |||
| 133 | 1. **Server runtime** | ||
| 134 | - headless bind host and port | ||
| 135 | - server-known explicit advertised host config | ||
| 136 | |||
| 137 | 2. **Desktop shell** | ||
| 138 | - loopback backend URL | ||
| 139 | - LAN exposure state | ||
| 140 | - network interface discovery | ||
| 141 | - Tailscale CLI/status discovery | ||
| 142 | |||
| 143 | 3. **User configuration** | ||
| 144 | - manually added hostnames | ||
| 145 | - preferred endpoint labels | ||
| 146 | - hidden/disabled endpoints | ||
| 147 | |||
| 148 | 4. **Future cloud provider** | ||
| 149 | - T3 Tunnel endpoint | ||
| 150 | - billing/account status | ||
| 151 | - tunnel lifecycle state | ||
| 152 | |||
| 153 | ### Endpoint Registry | ||
| 154 | |||
| 155 | Create a central runtime registry: | ||
| 156 | |||
| 157 | - `packages/contracts/src/remoteAccess.ts` | ||
| 158 | - `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification | ||
| 159 | - `apps/server/src/remoteAccess/*` for server/headless endpoints | ||
| 160 | - `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints | ||
| 161 | - `apps/web/src/environments/endpoints/*` for client-side display and pairing selection | ||
| 162 | |||
| 163 | The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel. | ||
| 164 | |||
| 165 | ### Pairing Link Generation | ||
| 166 | |||
| 167 | Move hosted pairing link generation to endpoint-driven input: | ||
| 168 | |||
| 169 | ```ts | ||
| 170 | buildHostedPairingUrl({ | ||
| 171 | endpoint: AdvertisedEndpoint, | ||
| 172 | token, | ||
| 173 | }); | ||
| 174 | ``` | ||
| 175 | |||
| 176 | Generated URL: | ||
| 177 | |||
| 178 | ```text | ||
| 179 | https://app.t3.codes/pair?host=<encoded endpoint httpBaseUrl>#token=<one-time token> | ||
| 180 | ``` | ||
| 181 | |||
| 182 | Use fragment tokens by default. Continue accepting `?token=` for compatibility. | ||
| 183 | |||
| 184 | ## Phase 1: Endpoint Abstraction | ||
| 185 | |||
| 186 | ### Goals | ||
| 187 | |||
| 188 | - Centralize URL normalization, protocol derivation, and compatibility checks. | ||
| 189 | - Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection. | ||
| 190 | - Preserve all current remote behavior. | ||
| 191 | |||
| 192 | ### Tasks | ||
| 193 | |||
| 194 | 1. Add `AdvertisedEndpoint` schemas to `packages/contracts`. | ||
| 195 | 2. Add shared helpers: | ||
| 196 | - normalize HTTP base URL | ||
| 197 | - derive WebSocket base URL | ||
| 198 | - classify loopback/private/LAN/Tailscale/public host | ||
| 199 | - classify hosted HTTPS compatibility | ||
| 200 | 3. Add server endpoint discovery: | ||
| 201 | - loopback endpoint | ||
| 202 | - configured non-loopback endpoint | ||
| 203 | - explicit advertised host override | ||
| 204 | 4. Add desktop endpoint discovery: | ||
| 205 | - local loopback | ||
| 206 | - LAN exposure endpoint | ||
| 207 | - endpoint status labels | ||
| 208 | 5. Add WebSocket/API method or existing config field for endpoint snapshots. | ||
| 209 | 6. Refactor settings connections UI: | ||
| 210 | - render endpoint rows | ||
| 211 | - endpoint picker for pairing link copy | ||
| 212 | - show compatibility warnings | ||
| 213 | 7. Refactor hosted link builder to accept endpoint records. | ||
| 214 | 8. Add tests for URL normalization and compatibility classification. | ||
| 215 | |||
| 216 | ### Acceptance Criteria | ||
| 217 | |||
| 218 | - Existing LAN/network access UI still works. | ||
| 219 | - Pairing links are generated from endpoint records. | ||
| 220 | - Loopback endpoints never produce hosted pairing links silently. | ||
| 221 | - HTTP private-network endpoints are marked incompatible with `app.t3.codes`. | ||
| 222 | - No remote environment runtime changes are required for existing saved environments. | ||
| 223 | |||
| 224 | ## Phase 2: BYO Tailscale/MagicDNS | ||
| 225 | |||
| 226 | ### Goals | ||
| 227 | |||
| 228 | - Detect free DIY Tailscale reachability. | ||
| 229 | - Surface Tailscale endpoints as normal advertised endpoints. | ||
| 230 | - Keep users in control of their own tailnet. | ||
| 231 | |||
| 232 | ### Tasks | ||
| 233 | |||
| 234 | 1. Detect Tailscale IPs from network interfaces: | ||
| 235 | - IPv4 `100.64.0.0/10` | ||
| 236 | - mark as `provider: "tailscale-ip"` | ||
| 237 | 2. Add optional desktop-side `tailscale status --json` discovery: | ||
| 238 | - MagicDNS hostname | ||
| 239 | - Tailscale Serve/Funnel HTTPS endpoint if discoverable | ||
| 240 | - graceful failure if CLI is missing | ||
| 241 | 3. Add manual Tailscale endpoint override: | ||
| 242 | - hostname | ||
| 243 | - label | ||
| 244 | - preferred/default flag | ||
| 245 | 4. Show Tailscale endpoint rows in settings: | ||
| 246 | - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked | ||
| 247 | - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS | ||
| 248 | 5. Generate pairing links using selected Tailscale endpoint. | ||
| 249 | 6. Document DIY setup: | ||
| 250 | - local desktop-to-desktop over Tailscale | ||
| 251 | - hosted app requirements | ||
| 252 | - why HTTPS matters | ||
| 253 | |||
| 254 | ### Acceptance Criteria | ||
| 255 | |||
| 256 | - A machine on Tailscale shows a Tailscale endpoint without paid features. | ||
| 257 | - Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible. | ||
| 258 | - Users can still copy token-only/manual values when endpoint compatibility is unknown. | ||
| 259 | - Tailscale is optional and never required for regular LAN/loopback use. | ||
| 260 | |||
| 261 | ## Phase 3: Hosted Static App Completion | ||
| 262 | |||
| 263 | ### Goals | ||
| 264 | |||
| 265 | - `app.t3.codes` works as a real client shell. | ||
| 266 | - It can pair, persist, reconnect, and clearly explain offline/incompatible states. | ||
| 267 | |||
| 268 | ### Tasks | ||
| 269 | |||
| 270 | 1. Finish hosted-static root behavior: | ||
| 271 | - no primary backend required | ||
| 272 | - saved environment hydration before initial routing decisions | ||
| 273 | - first saved environment selected as active | ||
| 274 | 2. Add hosted empty state: | ||
| 275 | - no saved environments | ||
| 276 | - paste pairing URL | ||
| 277 | - add host + token | ||
| 278 | 3. Add offline saved environment UI: | ||
| 279 | - last connected | ||
| 280 | - reconnect | ||
| 281 | - remove | ||
| 282 | - copy/add alternate endpoint | ||
| 283 | 4. Audit primary-backend assumptions: | ||
| 284 | - command palette | ||
| 285 | - settings pages | ||
| 286 | - server config atom defaults | ||
| 287 | - keybindings | ||
| 288 | - provider/model lists | ||
| 289 | - update/desktop-only affordances | ||
| 290 | 5. Add route tests for: | ||
| 291 | - hosted `/pair?host=...#token=...` | ||
| 292 | - hosted root with no saved environments | ||
| 293 | - hosted root with saved environment | ||
| 294 | - primary backend unavailable but saved environment present | ||
| 295 | 6. Add deployment hardening: | ||
| 296 | - SPA fallback | ||
| 297 | - strict CSP | ||
| 298 | - no third-party scripts | ||
| 299 | - no query token logging | ||
| 300 | - disable or hide source maps in production if needed | ||
| 301 | 7. Add browser error messages: | ||
| 302 | - mixed content | ||
| 303 | - unreachable backend | ||
| 304 | - CORS failure | ||
| 305 | - certificate failure | ||
| 306 | |||
| 307 | ### Acceptance Criteria | ||
| 308 | |||
| 309 | - `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload. | ||
| 310 | - A saved environment can be used without any backend at `app.t3.codes`. | ||
| 311 | - Offline machines show a useful state instead of a generic boot error. | ||
| 312 | - HTTP endpoints are still supported in desktop/native/local contexts. | ||
| 313 | - Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints. | ||
| 314 | |||
| 315 | ## Phase 4: Future T3 Tunnel Provider | ||
| 316 | |||
| 317 | Not part of the current implementation, but the endpoint abstraction should make it straightforward. | ||
| 318 | |||
| 319 | Future tunnel provider responsibilities: | ||
| 320 | |||
| 321 | - create endpoint with `provider: "t3-tunnel"` | ||
| 322 | - surface tunnel status | ||
| 323 | - provide stable HTTPS URL | ||
| 324 | - use existing backend pairing/session auth | ||
| 325 | - never bypass server auth | ||
| 326 | |||
| 327 | 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`. | ||
| 328 | |||
| 329 | ## Security Checklist | ||
| 330 | |||
| 331 | - Pairing tokens are short-lived and one-time. | ||
| 332 | - Generated hosted pairing links put tokens in the fragment. | ||
| 333 | - The backend remains the authorization boundary. | ||
| 334 | - Endpoint discovery never disables backend auth. | ||
| 335 | - Hosted app does not silently downgrade to HTTP. | ||
| 336 | - Tunnel/public endpoints require explicit user action. | ||
| 337 | - Client sessions remain revocable. | ||
| 338 | - Endpoint URLs and request logs must avoid recording pairing tokens. | ||
| 339 | - Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing. | ||
| 340 | |||
| 341 | ## Verification | ||
| 342 | |||
| 343 | Each implementation PR should run: | ||
| 344 | |||
| 345 | - `bun fmt` | ||
| 346 | - `bun lint` | ||
| 347 | - `bun typecheck` | ||
| 348 | - focused tests for changed backend/web behavior | ||
| 349 | - backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` | ||
| 350 | |||
REMOTE.md+76-1| ... | @@ -22,9 +22,41 @@ If you are already running the desktop app and want to make it reachable from ot | ... | @@ -22,9 +22,41 @@ If you are already running the desktop app and want to make it reachable from ot |
| 22 | 22 | ||
| 23 | 1. Open **Settings** → **Connections**. | 23 | 1. Open **Settings** → **Connections**. |
| 24 | 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. | 24 | 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. |
| 25 | 3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). | 25 | 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. |
| 26 | 4. Use **Create Link** to generate a pairing link you can share with another device. | 26 | 4. Use **Create Link** to generate a pairing link you can share with another device. |
| 27 | 27 | ||
| 28 | 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. | ||
| 29 | |||
| 30 | When no user default is saved, the app uses the built-in LAN endpoint for pairing links when | ||
| 31 | available. You can set another endpoint as the default from the expanded endpoint list. | ||
| 32 | |||
| 33 | - HTTPS/WSS-compatible endpoints work from `https://app.t3.codes`, but are not made the default | ||
| 34 | automatically. | ||
| 35 | - Non-loopback HTTP endpoints are useful for direct LAN pairing. | ||
| 36 | - Loopback-only endpoints are not useful for another device unless that device is the same machine. | ||
| 37 | |||
| 38 | 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. | ||
| 39 | |||
| 40 | ### Tailscale Endpoints | ||
| 41 | |||
| 42 | When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. | ||
| 43 | |||
| 44 | Depending on your Tailscale setup, this may include: | ||
| 45 | |||
| 46 | - the machine's `100.x.y.z` Tailnet IP | ||
| 47 | - a MagicDNS name | ||
| 48 | - an HTTPS MagicDNS endpoint when Tailscale Serve is configured for this backend | ||
| 49 | |||
| 50 | The Tailscale HTTPS endpoint uses the clean MagicDNS URL, such as | ||
| 51 | `https://machine.tailnet.ts.net/`, and is disabled until the app verifies that the URL reaches this | ||
| 52 | backend. Use **Setup** on the Tailscale HTTPS row to opt in. The desktop app restarts the backend | ||
| 53 | with the same server-side behavior as `t3 serve --tailscale-serve`, then the server asks Tailscale | ||
| 54 | Serve to proxy HTTPS traffic to the local backend. | ||
| 55 | |||
| 56 | 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. | ||
| 57 | |||
| 58 | 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. | ||
| 59 | |||
| 28 | ### Option 2: Headless Server (CLI) | 60 | ### Option 2: Headless Server (CLI) |
| 29 | 61 | ||
| 30 | Use this when you want to run the server without a GUI, for example on a remote machine over SSH. | 62 | 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: | ... | @@ -47,14 +79,42 @@ From there, connect from another device in either of these ways: |
| 47 | - scan the QR code on your phone | 79 | - scan the QR code on your phone |
| 48 | - in the desktop app, enter the full pairing URL | 80 | - in the desktop app, enter the full pairing URL |
| 49 | - in the desktop app, enter the host and token separately | 81 | - in the desktop app, enter the host and token separately |
| 82 | - in the hosted web app, open a hosted pairing URL when the backend is reachable over HTTPS | ||
| 50 | 83 | ||
| 51 | 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. | 84 | 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. |
| 52 | 85 | ||
| 86 | For hosted web pairing over Tailscale HTTPS, opt in to Tailscale Serve: | ||
| 87 | |||
| 88 | ```bash | ||
| 89 | npx t3 serve --tailscale-serve | ||
| 90 | ``` | ||
| 91 | |||
| 92 | By default this configures Tailscale Serve on HTTPS port 443 and advertises | ||
| 93 | `https://machine.tailnet.ts.net/`. Advanced users can choose a different HTTPS port: | ||
| 94 | |||
| 95 | ```bash | ||
| 96 | npx t3 serve --tailscale-serve --tailscale-serve-port 8443 | ||
| 97 | ``` | ||
| 98 | |||
| 53 | > Note | 99 | > Note |
| 54 | > The GUIs do not currently support adding projects on remote environments. | 100 | > The GUIs do not currently support adding projects on remote environments. |
| 55 | > For now, use `t3 project ...` on the server machine instead. | 101 | > For now, use `t3 project ...` on the server machine instead. |
| 56 | > Full GUI support for remote project management is coming soon. | 102 | > Full GUI support for remote project management is coming soon. |
| 57 | 103 | ||
| 104 | ### Option 3: Desktop-Managed SSH Launch | ||
| 105 | |||
| 106 | Use this when you want the desktop app to start or reuse T3 Code on another machine over SSH. | ||
| 107 | |||
| 108 | 1. Open **Settings** → **Connections**. | ||
| 109 | 2. Under **Remote Environments**, choose **Add environment**. | ||
| 110 | 3. Select the SSH launch flow. | ||
| 111 | 4. Enter the SSH target, such as `user@example.com`. | ||
| 112 | 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. | ||
| 113 | |||
| 114 | 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. | ||
| 115 | |||
| 116 | 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. | ||
| 117 | |||
| 58 | ## How Pairing Works | 118 | ## How Pairing Works |
| 59 | 119 | ||
| 60 | The remote device does not need a long-lived secret up front. | 120 | The remote device does not need a long-lived secret up front. |
| ... | @@ -67,6 +127,20 @@ Instead: | ... | @@ -67,6 +127,20 @@ Instead: |
| 67 | 127 | ||
| 68 | After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. | 128 | After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. |
| 69 | 129 | ||
| 130 | ## Hosted Web App Pairing | ||
| 131 | |||
| 132 | The hosted web app at `https://app.t3.codes` can save a remote backend in browser local storage from a URL like: | ||
| 133 | |||
| 134 | ```text | ||
| 135 | https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE | ||
| 136 | ``` | ||
| 137 | |||
| 138 | 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. | ||
| 139 | |||
| 140 | 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. | ||
| 141 | |||
| 142 | Hosted pairing does not proxy traffic through T3 Code. The browser still connects directly to the backend URL in the pairing link. | ||
| 143 | |||
| 70 | ## Managing Access Later | 144 | ## Managing Access Later |
| 71 | 145 | ||
| 72 | Use `t3 auth` to manage access after the initial pairing flow. | 146 | 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 | ... | @@ -84,4 +158,5 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference |
| 84 | - Treat pairing URLs and pairing tokens like passwords. | 158 | - Treat pairing URLs and pairing tokens like passwords. |
| 85 | - Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. | 159 | - Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. |
| 86 | - Anyone with a valid pairing credential can create a session until that credential expires or is revoked. | 160 | - Anyone with a valid pairing credential can create a session until that credential expires or is revoked. |
| 161 | - 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. | ||
| 87 | - Use `t3 auth` to revoke credentials or sessions you no longer trust. | 162 | - Use `t3 auth` to revoke credentials or sessions you no longer trust. |
apps/desktop/package.json+4| ... | @@ -15,13 +15,17 @@ | ... | @@ -15,13 +15,17 @@ |
| 15 | "smoke-test": "node scripts/smoke-test.mjs" | 15 | "smoke-test": "node scripts/smoke-test.mjs" |
| 16 | }, | 16 | }, |
| 17 | "dependencies": { | 17 | "dependencies": { |
| 18 | "@effect/platform-node": "catalog:", | ||
| 18 | "effect": "catalog:", | 19 | "effect": "catalog:", |
| 19 | "electron": "40.9.3", | 20 | "electron": "40.9.3", |
| 20 | "electron-updater": "^6.6.2" | 21 | "electron-updater": "^6.6.2" |
| 21 | }, | 22 | }, |
| 22 | "devDependencies": { | 23 | "devDependencies": { |
| 24 | "@t3tools/client-runtime": "workspace:*", | ||
| 23 | "@t3tools/contracts": "workspace:*", | 25 | "@t3tools/contracts": "workspace:*", |
| 24 | "@t3tools/shared": "workspace:*", | 26 | "@t3tools/shared": "workspace:*", |
| 27 | "@t3tools/ssh": "workspace:*", | ||
| 28 | "@t3tools/tailscale": "workspace:*", | ||
| 25 | "@types/node": "catalog:", | 29 | "@types/node": "catalog:", |
| 26 | "effect-acp": "workspace:*", | 30 | "effect-acp": "workspace:*", |
| 27 | "tsdown": "catalog:", | 31 | "tsdown": "catalog:", |
apps/desktop/src/clientPersistence.test.ts+6| ... | @@ -72,6 +72,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | ... | @@ -72,6 +72,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { |
| 72 | wsBaseUrl: "wss://remote.example.com/", | 72 | wsBaseUrl: "wss://remote.example.com/", |
| 73 | createdAt: "2026-04-09T00:00:00.000Z", | 73 | createdAt: "2026-04-09T00:00:00.000Z", |
| 74 | lastConnectedAt: "2026-04-09T01:00:00.000Z", | 74 | lastConnectedAt: "2026-04-09T01:00:00.000Z", |
| 75 | desktopSsh: { | ||
| 76 | alias: "devbox", | ||
| 77 | hostname: "devbox.example.com", | ||
| 78 | username: "julius", | ||
| 79 | port: 22, | ||
| 80 | }, | ||
| 75 | }; | 81 | }; |
| 76 | 82 | ||
| 77 | describe("clientPersistence", () => { | 83 | describe("clientPersistence", () => { |
apps/desktop/src/clientPersistence.ts+12-3| ... | @@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord( | ... | @@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord( |
| 57 | typeof value.wsBaseUrl === "string" && | 57 | typeof value.wsBaseUrl === "string" && |
| 58 | typeof value.createdAt === "string" && | 58 | typeof value.createdAt === "string" && |
| 59 | (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && | 59 | (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && |
| 60 | (value.desktopSsh === undefined || | ||
| 61 | (Predicate.isObject(value.desktopSsh) && | ||
| 62 | typeof value.desktopSsh.alias === "string" && | ||
| 63 | typeof value.desktopSsh.hostname === "string" && | ||
| 64 | (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") && | ||
| 65 | (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) && | ||
| 60 | (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") | 66 | (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") |
| 61 | ); | 67 | ); |
| 62 | } | 68 | } |
| ... | @@ -77,7 +83,7 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen | ... | @@ -77,7 +83,7 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen |
| 77 | function toPersistedSavedEnvironmentRecord( | 83 | function toPersistedSavedEnvironmentRecord( |
| 78 | record: PersistedSavedEnvironmentStorageRecord, | 84 | record: PersistedSavedEnvironmentStorageRecord, |
| 79 | ): PersistedSavedEnvironmentRecord { | 85 | ): PersistedSavedEnvironmentRecord { |
| 80 | return { | 86 | const nextRecord = { |
| 81 | environmentId: record.environmentId, | 87 | environmentId: record.environmentId, |
| 82 | label: record.label, | 88 | label: record.label, |
| 83 | httpBaseUrl: record.httpBaseUrl, | 89 | httpBaseUrl: record.httpBaseUrl, |
| ... | @@ -85,6 +91,7 @@ function toPersistedSavedEnvironmentRecord( | ... | @@ -85,6 +91,7 @@ function toPersistedSavedEnvironmentRecord( |
| 85 | createdAt: record.createdAt, | 91 | createdAt: record.createdAt, |
| 86 | lastConnectedAt: record.lastConnectedAt, | 92 | lastConnectedAt: record.lastConnectedAt, |
| 87 | }; | 93 | }; |
| 94 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | ||
| 88 | } | 95 | } |
| 89 | 96 | ||
| 90 | export function readClientSettings(settingsPath: string): ClientSettings | null { | 97 | export function readClientSettings(settingsPath: string): ClientSettings | null { |
| ... | @@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry( | ... | @@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry( |
| 134 | wsBaseUrl: record.wsBaseUrl, | 141 | wsBaseUrl: record.wsBaseUrl, |
| 135 | createdAt: record.createdAt, | 142 | createdAt: record.createdAt, |
| 136 | lastConnectedAt: record.lastConnectedAt, | 143 | lastConnectedAt: record.lastConnectedAt, |
| 144 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 137 | encryptedBearerToken, | 145 | encryptedBearerToken, |
| 138 | } | 146 | } |
| 139 | : record; | 147 | : record; |
| ... | @@ -189,7 +197,7 @@ export function writeSavedEnvironmentSecret(input: { | ... | @@ -189,7 +197,7 @@ export function writeSavedEnvironmentSecret(input: { |
| 189 | const encryptedBearerToken = input.secretStorage | 197 | const encryptedBearerToken = input.secretStorage |
| 190 | .encryptString(input.secret) | 198 | .encryptString(input.secret) |
| 191 | .toString("base64"); | 199 | .toString("base64"); |
| 192 | return { | 200 | const nextRecord = { |
| 193 | environmentId: record.environmentId, | 201 | environmentId: record.environmentId, |
| 194 | label: record.label, | 202 | label: record.label, |
| 195 | httpBaseUrl: record.httpBaseUrl, | 203 | httpBaseUrl: record.httpBaseUrl, |
| ... | @@ -197,7 +205,8 @@ export function writeSavedEnvironmentSecret(input: { | ... | @@ -197,7 +205,8 @@ export function writeSavedEnvironmentSecret(input: { |
| 197 | createdAt: record.createdAt, | 205 | createdAt: record.createdAt, |
| 198 | lastConnectedAt: record.lastConnectedAt, | 206 | lastConnectedAt: record.lastConnectedAt, |
| 199 | encryptedBearerToken, | 207 | encryptedBearerToken, |
| 200 | } satisfies PersistedSavedEnvironmentStorageRecord; | 208 | }; |
| 209 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | ||
| 201 | }), | 210 | }), |
| 202 | } satisfies SavedEnvironmentRegistryDocument); | 211 | } satisfies SavedEnvironmentRegistryDocument); |
| 203 | return found; | 212 | return found; |
apps/desktop/src/desktopSettings.test.ts+83| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | readDesktopSettings, | 9 | readDesktopSettings, |
| 10 | resolveDefaultDesktopSettings, | 10 | resolveDefaultDesktopSettings, |
| 11 | setDesktopServerExposurePreference, | 11 | setDesktopServerExposurePreference, |
| 12 | setDesktopTailscaleServePreference, | ||
| 12 | setDesktopUpdateChannelPreference, | 13 | setDesktopUpdateChannelPreference, |
| 13 | writeDesktopSettings, | 14 | writeDesktopSettings, |
| 14 | } from "./desktopSettings.ts"; | 15 | } from "./desktopSettings.ts"; |
| ... | @@ -35,6 +36,8 @@ describe("desktopSettings", () => { | ... | @@ -35,6 +36,8 @@ describe("desktopSettings", () => { |
| 35 | it("defaults packaged nightly builds to the nightly update channel", () => { | 36 | it("defaults packaged nightly builds to the nightly update channel", () => { |
| 36 | expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ | 37 | expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ |
| 37 | serverExposureMode: "local-only", | 38 | serverExposureMode: "local-only", |
| 39 | tailscaleServeEnabled: false, | ||
| 40 | tailscaleServePort: 443, | ||
| 38 | updateChannel: "nightly", | 41 | updateChannel: "nightly", |
| 39 | updateChannelConfiguredByUser: false, | 42 | updateChannelConfiguredByUser: false, |
| 40 | }); | 43 | }); |
| ... | @@ -45,12 +48,16 @@ describe("desktopSettings", () => { | ... | @@ -45,12 +48,16 @@ describe("desktopSettings", () => { |
| 45 | 48 | ||
| 46 | writeDesktopSettings(settingsPath, { | 49 | writeDesktopSettings(settingsPath, { |
| 47 | serverExposureMode: "network-accessible", | 50 | serverExposureMode: "network-accessible", |
| 51 | tailscaleServeEnabled: true, | ||
| 52 | tailscaleServePort: 8443, | ||
| 48 | updateChannel: "latest", | 53 | updateChannel: "latest", |
| 49 | updateChannelConfiguredByUser: true, | 54 | updateChannelConfiguredByUser: true, |
| 50 | }); | 55 | }); |
| 51 | 56 | ||
| 52 | expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ | 57 | expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ |
| 53 | serverExposureMode: "network-accessible", | 58 | serverExposureMode: "network-accessible", |
| 59 | tailscaleServeEnabled: true, | ||
| 60 | tailscaleServePort: 8443, | ||
| 54 | updateChannel: "latest", | 61 | updateChannel: "latest", |
| 55 | updateChannelConfiguredByUser: true, | 62 | updateChannelConfiguredByUser: true, |
| 56 | }); | 63 | }); |
| ... | @@ -61,6 +68,8 @@ describe("desktopSettings", () => { | ... | @@ -61,6 +68,8 @@ describe("desktopSettings", () => { |
| 61 | setDesktopServerExposurePreference( | 68 | setDesktopServerExposurePreference( |
| 62 | { | 69 | { |
| 63 | serverExposureMode: "local-only", | 70 | serverExposureMode: "local-only", |
| 71 | tailscaleServeEnabled: false, | ||
| 72 | tailscaleServePort: 443, | ||
| 64 | updateChannel: "latest", | 73 | updateChannel: "latest", |
| 65 | updateChannelConfiguredByUser: false, | 74 | updateChannelConfiguredByUser: false, |
| 66 | }, | 75 | }, |
| ... | @@ -68,6 +77,50 @@ describe("desktopSettings", () => { | ... | @@ -68,6 +77,50 @@ describe("desktopSettings", () => { |
| 68 | ), | 77 | ), |
| 69 | ).toEqual({ | 78 | ).toEqual({ |
| 70 | serverExposureMode: "network-accessible", | 79 | serverExposureMode: "network-accessible", |
| 80 | tailscaleServeEnabled: false, | ||
| 81 | tailscaleServePort: 443, | ||
| 82 | updateChannel: "latest", | ||
| 83 | updateChannelConfiguredByUser: false, | ||
| 84 | }); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("persists the requested Tailscale Serve preference", () => { | ||
| 88 | expect( | ||
| 89 | setDesktopTailscaleServePreference( | ||
| 90 | { | ||
| 91 | serverExposureMode: "local-only", | ||
| 92 | tailscaleServeEnabled: false, | ||
| 93 | tailscaleServePort: 443, | ||
| 94 | updateChannel: "latest", | ||
| 95 | updateChannelConfiguredByUser: false, | ||
| 96 | }, | ||
| 97 | { enabled: true, port: 8443 }, | ||
| 98 | ), | ||
| 99 | ).toEqual({ | ||
| 100 | serverExposureMode: "local-only", | ||
| 101 | tailscaleServeEnabled: true, | ||
| 102 | tailscaleServePort: 8443, | ||
| 103 | updateChannel: "latest", | ||
| 104 | updateChannelConfiguredByUser: false, | ||
| 105 | }); | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("preserves the configured Tailscale Serve port when no new port is requested", () => { | ||
| 109 | expect( | ||
| 110 | setDesktopTailscaleServePreference( | ||
| 111 | { | ||
| 112 | serverExposureMode: "local-only", | ||
| 113 | tailscaleServeEnabled: false, | ||
| 114 | tailscaleServePort: 8443, | ||
| 115 | updateChannel: "latest", | ||
| 116 | updateChannelConfiguredByUser: false, | ||
| 117 | }, | ||
| 118 | { enabled: true }, | ||
| 119 | ), | ||
| 120 | ).toEqual({ | ||
| 121 | serverExposureMode: "local-only", | ||
| 122 | tailscaleServeEnabled: true, | ||
| 123 | tailscaleServePort: 8443, | ||
| 71 | updateChannel: "latest", | 124 | updateChannel: "latest", |
| 72 | updateChannelConfiguredByUser: false, | 125 | updateChannelConfiguredByUser: false, |
| 73 | }); | 126 | }); |
| ... | @@ -78,6 +131,8 @@ describe("desktopSettings", () => { | ... | @@ -78,6 +131,8 @@ describe("desktopSettings", () => { |
| 78 | setDesktopUpdateChannelPreference( | 131 | setDesktopUpdateChannelPreference( |
| 79 | { | 132 | { |
| 80 | serverExposureMode: "local-only", | 133 | serverExposureMode: "local-only", |
| 134 | tailscaleServeEnabled: false, | ||
| 135 | tailscaleServePort: 443, | ||
| 81 | updateChannel: "latest", | 136 | updateChannel: "latest", |
| 82 | updateChannelConfiguredByUser: false, | 137 | updateChannelConfiguredByUser: false, |
| 83 | }, | 138 | }, |
| ... | @@ -85,6 +140,8 @@ describe("desktopSettings", () => { | ... | @@ -85,6 +140,8 @@ describe("desktopSettings", () => { |
| 85 | ), | 140 | ), |
| 86 | ).toEqual({ | 141 | ).toEqual({ |
| 87 | serverExposureMode: "local-only", | 142 | serverExposureMode: "local-only", |
| 143 | tailscaleServeEnabled: false, | ||
| 144 | tailscaleServePort: 443, | ||
| 88 | updateChannel: "nightly", | 145 | updateChannel: "nightly", |
| 89 | updateChannelConfiguredByUser: true, | 146 | updateChannelConfiguredByUser: true, |
| 90 | }); | 147 | }); |
| ... | @@ -103,6 +160,8 @@ describe("desktopSettings", () => { | ... | @@ -103,6 +160,8 @@ describe("desktopSettings", () => { |
| 103 | 160 | ||
| 104 | expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | 161 | expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ |
| 105 | serverExposureMode: "local-only", | 162 | serverExposureMode: "local-only", |
| 163 | tailscaleServeEnabled: false, | ||
| 164 | tailscaleServePort: 443, | ||
| 106 | updateChannel: "nightly", | 165 | updateChannel: "nightly", |
| 107 | updateChannelConfiguredByUser: false, | 166 | updateChannelConfiguredByUser: false, |
| 108 | }); | 167 | }); |
| ... | @@ -121,6 +180,8 @@ describe("desktopSettings", () => { | ... | @@ -121,6 +180,8 @@ describe("desktopSettings", () => { |
| 121 | 180 | ||
| 122 | expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | 181 | expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ |
| 123 | serverExposureMode: "local-only", | 182 | serverExposureMode: "local-only", |
| 183 | tailscaleServeEnabled: false, | ||
| 184 | tailscaleServePort: 443, | ||
| 124 | updateChannel: "nightly", | 185 | updateChannel: "nightly", |
| 125 | updateChannelConfiguredByUser: false, | 186 | updateChannelConfiguredByUser: false, |
| 126 | }); | 187 | }); |
| ... | @@ -140,8 +201,30 @@ describe("desktopSettings", () => { | ... | @@ -140,8 +201,30 @@ describe("desktopSettings", () => { |
| 140 | 201 | ||
| 141 | expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | 202 | expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ |
| 142 | serverExposureMode: "local-only", | 203 | serverExposureMode: "local-only", |
| 204 | tailscaleServeEnabled: false, | ||
| 205 | tailscaleServePort: 443, | ||
| 143 | updateChannel: "latest", | 206 | updateChannel: "latest", |
| 144 | updateChannelConfiguredByUser: true, | 207 | updateChannelConfiguredByUser: true, |
| 145 | }); | 208 | }); |
| 146 | }); | 209 | }); |
| 210 | |||
| 211 | it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => { | ||
| 212 | const settingsPath = makeSettingsPath(); | ||
| 213 | fs.writeFileSync( | ||
| 214 | settingsPath, | ||
| 215 | JSON.stringify({ | ||
| 216 | tailscaleServeEnabled: true, | ||
| 217 | tailscaleServePort: 0, | ||
| 218 | }), | ||
| 219 | "utf8", | ||
| 220 | ); | ||
| 221 | |||
| 222 | expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ | ||
| 223 | serverExposureMode: "local-only", | ||
| 224 | tailscaleServeEnabled: true, | ||
| 225 | tailscaleServePort: 443, | ||
| 226 | updateChannel: "latest", | ||
| 227 | updateChannelConfiguredByUser: false, | ||
| 228 | }); | ||
| 229 | }); | ||
| 147 | }); | 230 | }); |
apps/desktop/src/desktopSettings.ts+33| ... | @@ -6,12 +6,18 @@ import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; | ... | @@ -6,12 +6,18 @@ import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; |
| 6 | 6 | ||
| 7 | export interface DesktopSettings { | 7 | export interface DesktopSettings { |
| 8 | readonly serverExposureMode: DesktopServerExposureMode; | 8 | readonly serverExposureMode: DesktopServerExposureMode; |
| 9 | readonly tailscaleServeEnabled: boolean; | ||
| 10 | readonly tailscaleServePort: number; | ||
| 9 | readonly updateChannel: DesktopUpdateChannel; | 11 | readonly updateChannel: DesktopUpdateChannel; |
| 10 | readonly updateChannelConfiguredByUser: boolean; | 12 | readonly updateChannelConfiguredByUser: boolean; |
| 11 | } | 13 | } |
| 12 | 14 | ||
| 15 | export const DEFAULT_TAILSCALE_SERVE_PORT = 443; | ||
| 16 | |||
| 13 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | 17 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 14 | serverExposureMode: "local-only", | 18 | serverExposureMode: "local-only", |
| 19 | tailscaleServeEnabled: false, | ||
| 20 | tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, | ||
| 15 | updateChannel: "latest", | 21 | updateChannel: "latest", |
| 16 | updateChannelConfiguredByUser: false, | 22 | updateChannelConfiguredByUser: false, |
| 17 | }; | 23 | }; |
| ... | @@ -35,6 +41,29 @@ export function setDesktopServerExposurePreference( | ... | @@ -35,6 +41,29 @@ export function setDesktopServerExposurePreference( |
| 35 | }; | 41 | }; |
| 36 | } | 42 | } |
| 37 | 43 | ||
| 44 | export function setDesktopTailscaleServePreference( | ||
| 45 | settings: DesktopSettings, | ||
| 46 | input: { readonly enabled: boolean; readonly port?: number }, | ||
| 47 | ): DesktopSettings { | ||
| 48 | const port = | ||
| 49 | input.port === undefined | ||
| 50 | ? settings.tailscaleServePort | ||
| 51 | : normalizeTailscaleServePort(input.port); | ||
| 52 | return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port | ||
| 53 | ? settings | ||
| 54 | : { | ||
| 55 | ...settings, | ||
| 56 | tailscaleServeEnabled: input.enabled, | ||
| 57 | tailscaleServePort: port, | ||
| 58 | }; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function normalizeTailscaleServePort(value: unknown): number { | ||
| 62 | return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 | ||
| 63 | ? value | ||
| 64 | : DEFAULT_TAILSCALE_SERVE_PORT; | ||
| 65 | } | ||
| 66 | |||
| 38 | export function setDesktopUpdateChannelPreference( | 67 | export function setDesktopUpdateChannelPreference( |
| 39 | settings: DesktopSettings, | 68 | settings: DesktopSettings, |
| 40 | requestedChannel: DesktopUpdateChannel, | 69 | requestedChannel: DesktopUpdateChannel, |
| ... | @@ -57,6 +86,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D | ... | @@ -57,6 +86,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D |
| 57 | const raw = FS.readFileSync(settingsPath, "utf8"); | 86 | const raw = FS.readFileSync(settingsPath, "utf8"); |
| 58 | const parsed = JSON.parse(raw) as { | 87 | const parsed = JSON.parse(raw) as { |
| 59 | readonly serverExposureMode?: unknown; | 88 | readonly serverExposureMode?: unknown; |
| 89 | readonly tailscaleServeEnabled?: unknown; | ||
| 90 | readonly tailscaleServePort?: unknown; | ||
| 60 | readonly updateChannel?: unknown; | 91 | readonly updateChannel?: unknown; |
| 61 | readonly updateChannelConfiguredByUser?: unknown; | 92 | readonly updateChannelConfiguredByUser?: unknown; |
| 62 | }; | 93 | }; |
| ... | @@ -72,6 +103,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D | ... | @@ -72,6 +103,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D |
| 72 | return { | 103 | return { |
| 73 | serverExposureMode: | 104 | serverExposureMode: |
| 74 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", | 105 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", |
| 106 | tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, | ||
| 107 | tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), | ||
| 75 | updateChannel: | 108 | updateChannel: |
| 76 | updateChannelConfiguredByUser && parsedUpdateChannel !== null | 109 | updateChannelConfiguredByUser && parsedUpdateChannel !== null |
| 77 | ? parsedUpdateChannel | 110 | ? parsedUpdateChannel |
apps/desktop/src/main.ts+128-5| ... | @@ -36,11 +36,14 @@ import { autoUpdater } from "electron-updater"; | ... | @@ -36,11 +36,14 @@ import { autoUpdater } from "electron-updater"; |
| 36 | import type { ContextMenuItem } from "@t3tools/contracts"; | 36 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 37 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 37 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 38 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 38 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 39 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; | ||
| 39 | import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; | 40 | import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; |
| 40 | import { | 41 | import { |
| 42 | type DesktopSettings, | ||
| 41 | DEFAULT_DESKTOP_SETTINGS, | 43 | DEFAULT_DESKTOP_SETTINGS, |
| 42 | readDesktopSettings, | 44 | readDesktopSettings, |
| 43 | setDesktopServerExposurePreference, | 45 | setDesktopServerExposurePreference, |
| 46 | setDesktopTailscaleServePreference, | ||
| 44 | setDesktopUpdateChannelPreference, | 47 | setDesktopUpdateChannelPreference, |
| 45 | writeDesktopSettings, | 48 | writeDesktopSettings, |
| 46 | } from "./desktopSettings.ts"; | 49 | } from "./desktopSettings.ts"; |
| ... | @@ -55,7 +58,11 @@ import { | ... | @@ -55,7 +58,11 @@ import { |
| 55 | } from "./clientPersistence.ts"; | 58 | } from "./clientPersistence.ts"; |
| 56 | import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; | 59 | import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; |
| 57 | import { showDesktopConfirmDialog } from "./confirmDialog.ts"; | 60 | import { showDesktopConfirmDialog } from "./confirmDialog.ts"; |
| 58 | import { resolveDesktopServerExposure } from "./serverExposure.ts"; | 61 | import { |
| 62 | resolveDesktopCoreAdvertisedEndpoints, | ||
| 63 | resolveDesktopServerExposure, | ||
| 64 | } from "./serverExposure.ts"; | ||
| 65 | import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; | ||
| 59 | import { syncShellEnvironment } from "./syncShellEnvironment.ts"; | 66 | import { syncShellEnvironment } from "./syncShellEnvironment.ts"; |
| 60 | import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; | 67 | import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; |
| 61 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; | 68 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; |
| ... | @@ -76,6 +83,7 @@ import { | ... | @@ -76,6 +83,7 @@ import { |
| 76 | import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; | 83 | import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; |
| 77 | import { resolveDesktopAppBranding } from "./appBranding.ts"; | 84 | import { resolveDesktopAppBranding } from "./appBranding.ts"; |
| 78 | import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; | 85 | import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; |
| 86 | import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; | ||
| 79 | 87 | ||
| 80 | syncShellEnvironment(); | 88 | syncShellEnvironment(); |
| 81 | 89 | ||
| ... | @@ -102,6 +110,8 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr | ... | @@ -102,6 +110,8 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr |
| 102 | const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | 110 | const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; |
| 103 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | 111 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; |
| 104 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | 112 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 113 | const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; | ||
| 114 | const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; | ||
| 105 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); | 115 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 106 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); | 116 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 107 | const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); | 117 | 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 | ... | @@ -110,6 +120,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments |
| 110 | const DESKTOP_SCHEME = "t3"; | 120 | const DESKTOP_SCHEME = "t3"; |
| 111 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); | 121 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 112 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 122 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 123 | // Dev-only SSH launcher override. Set this to an absolute path on the SSH host | ||
| 124 | // for a built server entry, for example: | ||
| 125 | // "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs" | ||
| 126 | const DEV_REMOTE_T3_SERVER_ENTRY_PATH = | ||
| 127 | process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? ""; | ||
| 113 | const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ | 128 | const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ |
| 114 | isDevelopment, | 129 | isDevelopment, |
| 115 | appVersion: app.getVersion(), | 130 | appVersion: app.getVersion(), |
| ... | @@ -297,6 +312,9 @@ function backendChildEnv(): NodeJS.ProcessEnv { | ... | @@ -297,6 +312,9 @@ function backendChildEnv(): NodeJS.ProcessEnv { |
| 297 | delete env.T3CODE_DESKTOP_WS_URL; | 312 | delete env.T3CODE_DESKTOP_WS_URL; |
| 298 | delete env.T3CODE_DESKTOP_LAN_ACCESS; | 313 | delete env.T3CODE_DESKTOP_LAN_ACCESS; |
| 299 | delete env.T3CODE_DESKTOP_LAN_HOST; | 314 | delete env.T3CODE_DESKTOP_LAN_HOST; |
| 315 | delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; | ||
| 316 | delete env.T3CODE_TAILSCALE_SERVE; | ||
| 317 | delete env.T3CODE_TAILSCALE_SERVE_PORT; | ||
| 300 | return env; | 318 | return env; |
| 301 | } | 319 | } |
| 302 | 320 | ||
| ... | @@ -305,9 +323,32 @@ function getDesktopServerExposureState(): DesktopServerExposureState { | ... | @@ -305,9 +323,32 @@ function getDesktopServerExposureState(): DesktopServerExposureState { |
| 305 | mode: desktopServerExposureMode, | 323 | mode: desktopServerExposureMode, |
| 306 | endpointUrl: backendEndpointUrl, | 324 | endpointUrl: backendEndpointUrl, |
| 307 | advertisedHost: backendAdvertisedHost, | 325 | advertisedHost: backendAdvertisedHost, |
| 326 | tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, | ||
| 327 | tailscaleServePort: desktopSettings.tailscaleServePort, | ||
| 308 | }; | 328 | }; |
| 309 | } | 329 | } |
| 310 | 330 | ||
| 331 | async function getDesktopAdvertisedEndpoints() { | ||
| 332 | const exposure = resolveDesktopServerExposure({ | ||
| 333 | mode: desktopServerExposureMode, | ||
| 334 | port: backendPort, | ||
| 335 | networkInterfaces: OS.networkInterfaces(), | ||
| 336 | ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), | ||
| 337 | }); | ||
| 338 | const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ | ||
| 339 | port: backendPort, | ||
| 340 | exposure, | ||
| 341 | customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), | ||
| 342 | }); | ||
| 343 | const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ | ||
| 344 | port: backendPort, | ||
| 345 | serveEnabled: desktopSettings.tailscaleServeEnabled, | ||
| 346 | servePort: desktopSettings.tailscaleServePort, | ||
| 347 | networkInterfaces: OS.networkInterfaces(), | ||
| 348 | }); | ||
| 349 | return [...coreEndpoints, ...tailscaleEndpoints]; | ||
| 350 | } | ||
| 351 | |||
| 311 | function getDesktopSecretStorage() { | 352 | function getDesktopSecretStorage() { |
| 312 | return { | 353 | return { |
| 313 | isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), | 354 | isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), |
| ... | @@ -321,9 +362,19 @@ function resolveAdvertisedHostOverride(): string | undefined { | ... | @@ -321,9 +362,19 @@ function resolveAdvertisedHostOverride(): string | undefined { |
| 321 | return override && override.length > 0 ? override : undefined; | 362 | return override && override.length > 0 ? override : undefined; |
| 322 | } | 363 | } |
| 323 | 364 | ||
| 365 | function resolveCustomHttpsEndpointUrls(): readonly string[] { | ||
| 366 | return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") | ||
| 367 | .split(",") | ||
| 368 | .map((entry) => entry.trim()) | ||
| 369 | .filter((entry) => entry.length > 0); | ||
| 370 | } | ||
| 371 | |||
| 324 | async function applyDesktopServerExposureMode( | 372 | async function applyDesktopServerExposureMode( |
| 325 | mode: DesktopServerExposureMode, | 373 | mode: DesktopServerExposureMode, |
| 326 | options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, | 374 | options?: { |
| 375 | readonly persist?: boolean; | ||
| 376 | readonly rejectIfUnavailable?: boolean; | ||
| 377 | }, | ||
| 327 | ): Promise<DesktopServerExposureState> { | 378 | ): Promise<DesktopServerExposureState> { |
| 328 | const advertisedHostOverride = resolveAdvertisedHostOverride(); | 379 | const advertisedHostOverride = resolveAdvertisedHostOverride(); |
| 329 | const requestedMode = mode; | 380 | const requestedMode = mode; |
| ... | @@ -361,6 +412,17 @@ async function applyDesktopServerExposureMode( | ... | @@ -361,6 +412,17 @@ async function applyDesktopServerExposureMode( |
| 361 | return getDesktopServerExposureState(); | 412 | return getDesktopServerExposureState(); |
| 362 | } | 413 | } |
| 363 | 414 | ||
| 415 | async function applyDesktopTailscaleServeEnabled( | ||
| 416 | nextSettings: DesktopSettings, | ||
| 417 | ): Promise<DesktopServerExposureState> { | ||
| 418 | desktopSettings = nextSettings; | ||
| 419 | writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | ||
| 420 | relaunchDesktopApp( | ||
| 421 | desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", | ||
| 422 | ); | ||
| 423 | return getDesktopServerExposureState(); | ||
| 424 | } | ||
| 425 | |||
| 364 | function relaunchDesktopApp(reason: string): void { | 426 | function relaunchDesktopApp(reason: string): void { |
| 365 | writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); | 427 | writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); |
| 366 | setImmediate(() => { | 428 | setImmediate(() => { |
| ... | @@ -373,6 +435,7 @@ function relaunchDesktopApp(reason: string): void { | ... | @@ -373,6 +435,7 @@ function relaunchDesktopApp(reason: string): void { |
| 373 | `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, | 435 | `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, |
| 374 | ); | 436 | ); |
| 375 | }) | 437 | }) |
| 438 | .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) | ||
| 376 | .finally(() => { | 439 | .finally(() => { |
| 377 | restoreStdIoCapture?.(); | 440 | restoreStdIoCapture?.(); |
| 378 | if (isDevelopment) { | 441 | if (isDevelopment) { |
| ... | @@ -629,6 +692,22 @@ let updateInstallInFlight = false; | ... | @@ -629,6 +692,22 @@ let updateInstallInFlight = false; |
| 629 | let updaterConfigured = false; | 692 | let updaterConfigured = false; |
| 630 | let updateState: DesktopUpdateState = initialUpdateState(); | 693 | let updateState: DesktopUpdateState = initialUpdateState(); |
| 631 | 694 | ||
| 695 | const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({ | ||
| 696 | getMainWindow: () => mainWindow, | ||
| 697 | resolveCliRunner: (): RemoteT3RunnerOptions => { | ||
| 698 | if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) { | ||
| 699 | return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH }; | ||
| 700 | } | ||
| 701 | return { | ||
| 702 | packageSpec: resolveRemoteT3CliPackageSpec({ | ||
| 703 | appVersion: app.getVersion(), | ||
| 704 | updateChannel: desktopSettings.updateChannel, | ||
| 705 | isDevelopment, | ||
| 706 | }), | ||
| 707 | }; | ||
| 708 | }, | ||
| 709 | }); | ||
| 710 | |||
| 632 | function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { | 711 | function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { |
| 633 | if (updateInstallInFlight) return "install"; | 712 | if (updateInstallInFlight) return "install"; |
| 634 | if (updateDownloadInFlight) return "download"; | 713 | if (updateDownloadInFlight) return "download"; |
| ... | @@ -1180,7 +1259,10 @@ async function checkForUpdates(reason: string): Promise<boolean> { | ... | @@ -1180,7 +1259,10 @@ async function checkForUpdates(reason: string): Promise<boolean> { |
| 1180 | } | 1259 | } |
| 1181 | } | 1260 | } |
| 1182 | 1261 | ||
| 1183 | async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> { | 1262 | async function downloadAvailableUpdate(): Promise<{ |
| 1263 | accepted: boolean; | ||
| 1264 | completed: boolean; | ||
| 1265 | }> { | ||
| 1184 | if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { | 1266 | if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { |
| 1185 | return { accepted: false, completed: false }; | 1267 | return { accepted: false, completed: false }; |
| 1186 | } | 1268 | } |
| ... | @@ -1202,7 +1284,10 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed | ... | @@ -1202,7 +1284,10 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed |
| 1202 | } | 1284 | } |
| 1203 | } | 1285 | } |
| 1204 | 1286 | ||
| 1205 | async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> { | 1287 | async function installDownloadedUpdate(): Promise<{ |
| 1288 | accepted: boolean; | ||
| 1289 | completed: boolean; | ||
| 1290 | }> { | ||
| 1206 | if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { | 1291 | if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { |
| 1207 | return { accepted: false, completed: false }; | 1292 | return { accepted: false, completed: false }; |
| 1208 | } | 1293 | } |
| ... | @@ -1402,6 +1487,8 @@ function startBackend(): void { | ... | @@ -1402,6 +1487,8 @@ function startBackend(): void { |
| 1402 | t3Home: BASE_DIR, | 1487 | t3Home: BASE_DIR, |
| 1403 | host: backendBindHost, | 1488 | host: backendBindHost, |
| 1404 | desktopBootstrapToken: backendBootstrapToken, | 1489 | desktopBootstrapToken: backendBootstrapToken, |
| 1490 | tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, | ||
| 1491 | tailscaleServePort: desktopSettings.tailscaleServePort, | ||
| 1405 | ...(backendObservabilitySettings.otlpTracesUrl | 1492 | ...(backendObservabilitySettings.otlpTracesUrl |
| 1406 | ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } | 1493 | ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } |
| 1407 | : {}), | 1494 | : {}), |
| ... | @@ -1647,6 +1734,8 @@ function registerIpcHandlers(): void { | ... | @@ -1647,6 +1734,8 @@ function registerIpcHandlers(): void { |
| 1647 | }, | 1734 | }, |
| 1648 | ); | 1735 | ); |
| 1649 | 1736 | ||
| 1737 | desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); | ||
| 1738 | |||
| 1650 | ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); | 1739 | ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); |
| 1651 | ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); | 1740 | ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); |
| 1652 | 1741 | ||
| ... | @@ -1669,6 +1758,31 @@ function registerIpcHandlers(): void { | ... | @@ -1669,6 +1758,31 @@ function registerIpcHandlers(): void { |
| 1669 | return nextState; | 1758 | return nextState; |
| 1670 | }); | 1759 | }); |
| 1671 | 1760 | ||
| 1761 | ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL); | ||
| 1762 | ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => { | ||
| 1763 | if (typeof rawInput !== "object" || rawInput === null) { | ||
| 1764 | throw new Error("Invalid Tailscale Serve input."); | ||
| 1765 | } | ||
| 1766 | const input = rawInput as { | ||
| 1767 | readonly enabled?: unknown; | ||
| 1768 | readonly port?: unknown; | ||
| 1769 | }; | ||
| 1770 | if (typeof input.enabled !== "boolean") { | ||
| 1771 | throw new Error("Invalid Tailscale Serve input."); | ||
| 1772 | } | ||
| 1773 | const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { | ||
| 1774 | enabled: input.enabled, | ||
| 1775 | ...(typeof input.port === "number" ? { port: input.port } : {}), | ||
| 1776 | }); | ||
| 1777 | if (nextSettings === desktopSettings) { | ||
| 1778 | return getDesktopServerExposureState(); | ||
| 1779 | } | ||
| 1780 | return applyDesktopTailscaleServeEnabled(nextSettings); | ||
| 1781 | }); | ||
| 1782 | |||
| 1783 | ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); | ||
| 1784 | ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints()); | ||
| 1785 | |||
| 1672 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); | 1786 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1673 | ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { | 1787 | ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { |
| 1674 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; | 1788 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| ... | @@ -1959,7 +2073,10 @@ function createWindow(): BrowserWindow { | ... | @@ -1959,7 +2073,10 @@ function createWindow(): BrowserWindow { |
| 1959 | const externalUrl = getSafeExternalUrl(params.linkURL); | 2073 | const externalUrl = getSafeExternalUrl(params.linkURL); |
| 1960 | if (externalUrl) { | 2074 | if (externalUrl) { |
| 1961 | menuTemplate.push( | 2075 | menuTemplate.push( |
| 1962 | { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) }, | 2076 | { |
| 2077 | label: "Copy Link", | ||
| 2078 | click: () => clipboard.writeText(params.linkURL), | ||
| 2079 | }, | ||
| 1963 | { type: "separator" }, | 2080 | { type: "separator" }, |
| 1964 | ); | 2081 | ); |
| 1965 | } | 2082 | } |
| ... | @@ -2019,6 +2136,9 @@ function createWindow(): BrowserWindow { | ... | @@ -2019,6 +2136,9 @@ function createWindow(): BrowserWindow { |
| 2019 | } | 2136 | } |
| 2020 | 2137 | ||
| 2021 | window.on("closed", () => { | 2138 | window.on("closed", () => { |
| 2139 | desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( | ||
| 2140 | "SSH authentication was cancelled because the app window closed.", | ||
| 2141 | ); | ||
| 2022 | if (mainWindow === window) { | 2142 | if (mainWindow === window) { |
| 2023 | mainWindow = null; | 2143 | mainWindow = null; |
| 2024 | } | 2144 | } |
| ... | @@ -2110,6 +2230,7 @@ app.on("before-quit", () => { | ... | @@ -2110,6 +2230,7 @@ app.on("before-quit", () => { |
| 2110 | clearUpdatePollTimer(); | 2230 | clearUpdatePollTimer(); |
| 2111 | cancelBackendReadinessWait(); | 2231 | cancelBackendReadinessWait(); |
| 2112 | stopBackend(); | 2232 | stopBackend(); |
| 2233 | void desktopSshEnvironmentBridge.dispose().catch(() => undefined); | ||
| 2113 | restoreStdIoCapture?.(); | 2234 | restoreStdIoCapture?.(); |
| 2114 | }); | 2235 | }); |
| 2115 | 2236 | ||
| ... | @@ -2159,6 +2280,7 @@ if (process.platform !== "win32") { | ... | @@ -2159,6 +2280,7 @@ if (process.platform !== "win32") { |
| 2159 | clearUpdatePollTimer(); | 2280 | clearUpdatePollTimer(); |
| 2160 | cancelBackendReadinessWait(); | 2281 | cancelBackendReadinessWait(); |
| 2161 | stopBackend(); | 2282 | stopBackend(); |
| 2283 | void desktopSshEnvironmentBridge.dispose().catch(() => undefined); | ||
| 2162 | restoreStdIoCapture?.(); | 2284 | restoreStdIoCapture?.(); |
| 2163 | app.quit(); | 2285 | app.quit(); |
| 2164 | }); | 2286 | }); |
| ... | @@ -2169,6 +2291,7 @@ if (process.platform !== "win32") { | ... | @@ -2169,6 +2291,7 @@ if (process.platform !== "win32") { |
| 2169 | writeDesktopLogHeader("SIGTERM received"); | 2291 | writeDesktopLogHeader("SIGTERM received"); |
| 2170 | clearUpdatePollTimer(); | 2292 | clearUpdatePollTimer(); |
| 2171 | stopBackend(); | 2293 | stopBackend(); |
| 2294 | void desktopSshEnvironmentBridge.dispose().catch(() => undefined); | ||
| 2172 | restoreStdIoCapture?.(); | 2295 | restoreStdIoCapture?.(); |
| 2173 | app.quit(); | 2296 | app.quit(); |
| 2174 | }); | 2297 | }); |
apps/desktop/src/preload.ts+59| ... | @@ -22,8 +22,36 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re | ... | @@ -22,8 +22,36 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re |
| 22 | const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | 22 | const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; |
| 23 | const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | 23 | const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; |
| 24 | const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | 24 | const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; |
| 25 | const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; | ||
| 26 | const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; | ||
| 27 | const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; | ||
| 28 | const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; | ||
| 29 | const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; | ||
| 30 | const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; | ||
| 31 | const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; | ||
| 32 | const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; | ||
| 33 | const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; | ||
| 25 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | 34 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; |
| 26 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | 35 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 36 | const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; | ||
| 37 | const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; | ||
| 38 | const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; | ||
| 39 | |||
| 40 | function unwrapEnsureSshEnvironmentResult(result: unknown) { | ||
| 41 | if ( | ||
| 42 | typeof result === "object" && | ||
| 43 | result !== null && | ||
| 44 | "type" in result && | ||
| 45 | result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT | ||
| 46 | ) { | ||
| 47 | const message = | ||
| 48 | "message" in result && typeof result.message === "string" | ||
| 49 | ? result.message | ||
| 50 | : "SSH authentication cancelled."; | ||
| 51 | throw new Error(message); | ||
| 52 | } | ||
| 53 | return result as Awaited<ReturnType<DesktopBridge["ensureSshEnvironment"]>>; | ||
| 54 | } | ||
| 27 | 55 | ||
| 28 | contextBridge.exposeInMainWorld("desktopBridge", { | 56 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 29 | getAppBranding: () => { | 57 | getAppBranding: () => { |
| ... | @@ -51,8 +79,39 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -51,8 +79,39 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 51 | ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), | 79 | ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), |
| 52 | removeSavedEnvironmentSecret: (environmentId) => | 80 | removeSavedEnvironmentSecret: (environmentId) => |
| 53 | ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | 81 | ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), |
| 82 | discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), | ||
| 83 | ensureSshEnvironment: async (target, options) => | ||
| 84 | unwrapEnsureSshEnvironmentResult( | ||
| 85 | await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options), | ||
| 86 | ), | ||
| 87 | disconnectSshEnvironment: (target) => | ||
| 88 | ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), | ||
| 89 | fetchSshEnvironmentDescriptor: (httpBaseUrl) => | ||
| 90 | ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl), | ||
| 91 | bootstrapSshBearerSession: (httpBaseUrl, credential) => | ||
| 92 | ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential), | ||
| 93 | fetchSshSessionState: (httpBaseUrl, bearerToken) => | ||
| 94 | ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken), | ||
| 95 | issueSshWebSocketToken: (httpBaseUrl, bearerToken) => | ||
| 96 | ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken), | ||
| 97 | onSshPasswordPrompt: (listener) => { | ||
| 98 | const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { | ||
| 99 | if (typeof request !== "object" || request === null) return; | ||
| 100 | listener(request as Parameters<typeof listener>[0]); | ||
| 101 | }; | ||
| 102 | |||
| 103 | ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); | ||
| 104 | return () => { | ||
| 105 | ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); | ||
| 106 | }; | ||
| 107 | }, | ||
| 108 | resolveSshPasswordPrompt: (requestId, password) => | ||
| 109 | ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password), | ||
| 54 | getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), | 110 | getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), |
| 55 | setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), | 111 | setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), |
| 112 | setTailscaleServeEnabled: (input) => | ||
| 113 | ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), | ||
| 114 | getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), | ||
| 56 | pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), | 115 | pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), |
| 57 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), | 116 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 58 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), | 117 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), |
apps/desktop/src/serverExposure.test.ts+120-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; | 3 | import { |
| 4 | resolveDesktopCoreAdvertisedEndpoints, | ||
| 5 | resolveDesktopServerExposure, | ||
| 6 | resolveLanAdvertisedHost, | ||
| 7 | } from "./serverExposure.ts"; | ||
| 4 | 8 | ||
| 5 | describe("resolveLanAdvertisedHost", () => { | 9 | describe("resolveLanAdvertisedHost", () => { |
| 6 | it("prefers an explicit host override", () => { | 10 | it("prefers an explicit host override", () => { |
| ... | @@ -74,6 +78,121 @@ describe("resolveLanAdvertisedHost", () => { | ... | @@ -74,6 +78,121 @@ describe("resolveLanAdvertisedHost", () => { |
| 74 | }); | 78 | }); |
| 75 | }); | 79 | }); |
| 76 | 80 | ||
| 81 | describe("resolveDesktopCoreAdvertisedEndpoints", () => { | ||
| 82 | it("advertises loopback and LAN endpoints without provider-specific assumptions", () => { | ||
| 83 | const exposure = resolveDesktopServerExposure({ | ||
| 84 | mode: "network-accessible", | ||
| 85 | port: 3773, | ||
| 86 | networkInterfaces: { | ||
| 87 | en0: [ | ||
| 88 | { | ||
| 89 | address: "192.168.1.44", | ||
| 90 | family: "IPv4", | ||
| 91 | internal: false, | ||
| 92 | netmask: "255.255.255.0", | ||
| 93 | cidr: "192.168.1.44/24", | ||
| 94 | mac: "00:00:00:00:00:00", | ||
| 95 | }, | ||
| 96 | ], | ||
| 97 | }, | ||
| 98 | }); | ||
| 99 | |||
| 100 | expect( | ||
| 101 | resolveDesktopCoreAdvertisedEndpoints({ | ||
| 102 | port: 3773, | ||
| 103 | exposure, | ||
| 104 | customHttpsEndpointUrls: [ | ||
| 105 | "https://desktop.example.ts.net", | ||
| 106 | "http://desktop.example.test:3773", | ||
| 107 | "not-a-url", | ||
| 108 | ], | ||
| 109 | }), | ||
| 110 | ).toEqual([ | ||
| 111 | { | ||
| 112 | id: "desktop-loopback:3773", | ||
| 113 | label: "This machine", | ||
| 114 | provider: { | ||
| 115 | id: "desktop-core", | ||
| 116 | label: "Desktop", | ||
| 117 | kind: "core", | ||
| 118 | isAddon: false, | ||
| 119 | }, | ||
| 120 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 121 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 122 | reachability: "loopback", | ||
| 123 | compatibility: { | ||
| 124 | hostedHttpsApp: "mixed-content-blocked", | ||
| 125 | desktopApp: "compatible", | ||
| 126 | }, | ||
| 127 | source: "desktop-core", | ||
| 128 | status: "available", | ||
| 129 | description: "Loopback endpoint for this desktop app.", | ||
| 130 | }, | ||
| 131 | { | ||
| 132 | id: "desktop-lan:http://192.168.1.44:3773", | ||
| 133 | label: "Local network", | ||
| 134 | provider: { | ||
| 135 | id: "desktop-core", | ||
| 136 | label: "Desktop", | ||
| 137 | kind: "core", | ||
| 138 | isAddon: false, | ||
| 139 | }, | ||
| 140 | httpBaseUrl: "http://192.168.1.44:3773/", | ||
| 141 | wsBaseUrl: "ws://192.168.1.44:3773/", | ||
| 142 | reachability: "lan", | ||
| 143 | compatibility: { | ||
| 144 | hostedHttpsApp: "mixed-content-blocked", | ||
| 145 | desktopApp: "compatible", | ||
| 146 | }, | ||
| 147 | source: "desktop-core", | ||
| 148 | status: "available", | ||
| 149 | isDefault: true, | ||
| 150 | description: "Reachable from devices on the same network.", | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | id: "manual:https://desktop.example.ts.net", | ||
| 154 | label: "Custom HTTPS", | ||
| 155 | provider: { | ||
| 156 | id: "manual", | ||
| 157 | label: "Manual", | ||
| 158 | kind: "manual", | ||
| 159 | isAddon: false, | ||
| 160 | }, | ||
| 161 | httpBaseUrl: "https://desktop.example.ts.net/", | ||
| 162 | wsBaseUrl: "wss://desktop.example.ts.net/", | ||
| 163 | reachability: "public", | ||
| 164 | compatibility: { | ||
| 165 | hostedHttpsApp: "compatible", | ||
| 166 | desktopApp: "compatible", | ||
| 167 | }, | ||
| 168 | source: "user", | ||
| 169 | status: "unknown", | ||
| 170 | description: "User-configured HTTPS endpoint for this desktop backend.", | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | id: "manual:http://desktop.example.test:3773", | ||
| 174 | label: "Custom endpoint", | ||
| 175 | provider: { | ||
| 176 | id: "manual", | ||
| 177 | label: "Manual", | ||
| 178 | kind: "manual", | ||
| 179 | isAddon: false, | ||
| 180 | }, | ||
| 181 | httpBaseUrl: "http://desktop.example.test:3773/", | ||
| 182 | wsBaseUrl: "ws://desktop.example.test:3773/", | ||
| 183 | reachability: "public", | ||
| 184 | compatibility: { | ||
| 185 | hostedHttpsApp: "mixed-content-blocked", | ||
| 186 | desktopApp: "compatible", | ||
| 187 | }, | ||
| 188 | source: "user", | ||
| 189 | status: "unknown", | ||
| 190 | description: "User-configured endpoint for this desktop backend.", | ||
| 191 | }, | ||
| 192 | ]); | ||
| 193 | }); | ||
| 194 | }); | ||
| 195 | |||
| 77 | describe("resolveDesktopServerExposure", () => { | 196 | describe("resolveDesktopServerExposure", () => { |
| 78 | it("keeps the desktop server loopback-only when local-only mode is selected", () => { | 197 | it("keeps the desktop server loopback-only when local-only mode is selected", () => { |
| 79 | expect( | 198 | expect( |
apps/desktop/src/serverExposure.ts+109-1| ... | @@ -1,5 +1,13 @@ | ... | @@ -1,5 +1,13 @@ |
| 1 | import type { NetworkInterfaceInfo } from "node:os"; | 1 | import type { NetworkInterfaceInfo } from "node:os"; |
| 2 | import type { DesktopServerExposureMode } from "@t3tools/contracts"; | 2 | import { |
| 3 | createAdvertisedEndpoint, | ||
| 4 | type CreateAdvertisedEndpointInput, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import type { | ||
| 7 | AdvertisedEndpoint, | ||
| 8 | AdvertisedEndpointProvider, | ||
| 9 | DesktopServerExposureMode, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 3 | 11 | ||
| 4 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | 12 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 5 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; | 13 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; |
| ... | @@ -13,6 +21,26 @@ export interface DesktopServerExposure { | ... | @@ -13,6 +21,26 @@ export interface DesktopServerExposure { |
| 13 | readonly advertisedHost: string | null; | 21 | readonly advertisedHost: string | null; |
| 14 | } | 22 | } |
| 15 | 23 | ||
| 24 | export interface DesktopAdvertisedEndpointInput { | ||
| 25 | readonly port: number; | ||
| 26 | readonly exposure: DesktopServerExposure; | ||
| 27 | readonly customHttpsEndpointUrls?: readonly string[]; | ||
| 28 | } | ||
| 29 | |||
| 30 | const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | ||
| 31 | id: "desktop-core", | ||
| 32 | label: "Desktop", | ||
| 33 | kind: "core", | ||
| 34 | isAddon: false, | ||
| 35 | }; | ||
| 36 | |||
| 37 | const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | ||
| 38 | id: "manual", | ||
| 39 | label: "Manual", | ||
| 40 | kind: "manual", | ||
| 41 | isAddon: false, | ||
| 42 | }; | ||
| 43 | |||
| 16 | const normalizeOptionalHost = (value: string | undefined): string | undefined => { | 44 | const normalizeOptionalHost = (value: string | undefined): string | undefined => { |
| 17 | const normalized = value?.trim(); | 45 | const normalized = value?.trim(); |
| 18 | return normalized && normalized.length > 0 ? normalized : undefined; | 46 | return normalized && normalized.length > 0 ? normalized : undefined; |
| ... | @@ -21,6 +49,14 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => | ... | @@ -21,6 +49,14 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => |
| 21 | const isUsableLanIpv4Address = (address: string): boolean => | 49 | const isUsableLanIpv4Address = (address: string): boolean => |
| 22 | !address.startsWith("127.") && !address.startsWith("169.254."); | 50 | !address.startsWith("127.") && !address.startsWith("169.254."); |
| 23 | 51 | ||
| 52 | function isHttpsEndpointUrl(value: string): boolean { | ||
| 53 | try { | ||
| 54 | return new URL(value).protocol === "https:"; | ||
| 55 | } catch { | ||
| 56 | return false; | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 24 | export function resolveLanAdvertisedHost( | 60 | export function resolveLanAdvertisedHost( |
| 25 | networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>, | 61 | networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>, |
| 26 | explicitHost: string | undefined, | 62 | explicitHost: string | undefined, |
| ... | @@ -78,3 +114,75 @@ export function resolveDesktopServerExposure(input: { | ... | @@ -78,3 +114,75 @@ export function resolveDesktopServerExposure(input: { |
| 78 | advertisedHost, | 114 | advertisedHost, |
| 79 | }; | 115 | }; |
| 80 | } | 116 | } |
| 117 | |||
| 118 | function createDesktopEndpoint( | ||
| 119 | input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | ||
| 120 | ): AdvertisedEndpoint { | ||
| 121 | return createAdvertisedEndpoint({ | ||
| 122 | ...input, | ||
| 123 | provider: DESKTOP_CORE_ENDPOINT_PROVIDER, | ||
| 124 | source: "desktop-core", | ||
| 125 | }); | ||
| 126 | } | ||
| 127 | |||
| 128 | function createManualEndpoint( | ||
| 129 | input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | ||
| 130 | ): AdvertisedEndpoint { | ||
| 131 | return createAdvertisedEndpoint({ | ||
| 132 | ...input, | ||
| 133 | provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, | ||
| 134 | source: "user", | ||
| 135 | }); | ||
| 136 | } | ||
| 137 | |||
| 138 | export function resolveDesktopCoreAdvertisedEndpoints( | ||
| 139 | input: DesktopAdvertisedEndpointInput, | ||
| 140 | ): readonly AdvertisedEndpoint[] { | ||
| 141 | const endpoints: AdvertisedEndpoint[] = [ | ||
| 142 | createDesktopEndpoint({ | ||
| 143 | id: `desktop-loopback:${input.port}`, | ||
| 144 | label: "This machine", | ||
| 145 | httpBaseUrl: input.exposure.localHttpUrl, | ||
| 146 | reachability: "loopback", | ||
| 147 | status: "available", | ||
| 148 | description: "Loopback endpoint for this desktop app.", | ||
| 149 | }), | ||
| 150 | ]; | ||
| 151 | |||
| 152 | if (input.exposure.endpointUrl) { | ||
| 153 | endpoints.push( | ||
| 154 | createDesktopEndpoint({ | ||
| 155 | id: `desktop-lan:${input.exposure.endpointUrl}`, | ||
| 156 | label: "Local network", | ||
| 157 | httpBaseUrl: input.exposure.endpointUrl, | ||
| 158 | reachability: "lan", | ||
| 159 | status: "available", | ||
| 160 | isDefault: true, | ||
| 161 | description: "Reachable from devices on the same network.", | ||
| 162 | }), | ||
| 163 | ); | ||
| 164 | } | ||
| 165 | |||
| 166 | for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { | ||
| 167 | try { | ||
| 168 | const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); | ||
| 169 | endpoints.push( | ||
| 170 | createManualEndpoint({ | ||
| 171 | id: `manual:${customEndpointUrl}`, | ||
| 172 | label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", | ||
| 173 | httpBaseUrl: customEndpointUrl, | ||
| 174 | reachability: "public", | ||
| 175 | ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), | ||
| 176 | status: "unknown", | ||
| 177 | description: isHttpsEndpoint | ||
| 178 | ? "User-configured HTTPS endpoint for this desktop backend." | ||
| 179 | : "User-configured endpoint for this desktop backend.", | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | } catch { | ||
| 183 | // Ignore malformed user-configured endpoints without dropping valid endpoints. | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | return endpoints; | ||
| 188 | } |
apps/desktop/src/sshEnvironment.test.ts created+98| ... | @@ -0,0 +1,98 @@ | ||
| 1 | import * as FS from "node:fs"; | ||
| 2 | import * as OS from "node:os"; | ||
| 3 | import * as Path from "node:path"; | ||
| 4 | |||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 6 | |||
| 7 | import { SshPasswordPromptError } from "@t3tools/ssh/errors"; | ||
| 8 | |||
| 9 | import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts"; | ||
| 10 | |||
| 11 | const tempDirectories: string[] = []; | ||
| 12 | |||
| 13 | afterEach(() => { | ||
| 14 | for (const directory of tempDirectories.splice(0)) { | ||
| 15 | FS.rmSync(directory, { recursive: true, force: true }); | ||
| 16 | } | ||
| 17 | }); | ||
| 18 | |||
| 19 | function makeTempHomeDir(): string { | ||
| 20 | const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-")); | ||
| 21 | tempDirectories.push(directory); | ||
| 22 | return directory; | ||
| 23 | } | ||
| 24 | |||
| 25 | describe("sshEnvironment", () => { | ||
| 26 | it("treats password prompt timeouts as cancellable authentication prompts", () => { | ||
| 27 | expect( | ||
| 28 | isSshPasswordPromptCancellation( | ||
| 29 | new SshPasswordPromptError({ | ||
| 30 | message: "SSH authentication timed out for devbox.", | ||
| 31 | }), | ||
| 32 | ), | ||
| 33 | ).toBe(true); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("wires desktop host discovery through the ssh package runtime", async () => { | ||
| 37 | const homeDir = makeTempHomeDir(); | ||
| 38 | const sshDir = Path.join(homeDir, ".ssh"); | ||
| 39 | FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true }); | ||
| 40 | FS.writeFileSync( | ||
| 41 | Path.join(sshDir, "config"), | ||
| 42 | ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), | ||
| 43 | "utf8", | ||
| 44 | ); | ||
| 45 | FS.writeFileSync( | ||
| 46 | Path.join(sshDir, "config.d", "team.conf"), | ||
| 47 | [ | ||
| 48 | "Host staging", | ||
| 49 | " HostName staging.example.com", | ||
| 50 | "Host *", | ||
| 51 | " ServerAliveInterval 30", | ||
| 52 | "", | ||
| 53 | ].join("\n"), | ||
| 54 | "utf8", | ||
| 55 | ); | ||
| 56 | FS.writeFileSync( | ||
| 57 | Path.join(sshDir, "known_hosts"), | ||
| 58 | [ | ||
| 59 | "known.example.com ssh-ed25519 AAAA", | ||
| 60 | "|1|hashed|entry ssh-ed25519 AAAA", | ||
| 61 | "[bastion.example.com]:2222 ssh-ed25519 AAAA", | ||
| 62 | "", | ||
| 63 | ].join("\n"), | ||
| 64 | "utf8", | ||
| 65 | ); | ||
| 66 | |||
| 67 | await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([ | ||
| 68 | { | ||
| 69 | alias: "bastion.example.com", | ||
| 70 | hostname: "bastion.example.com", | ||
| 71 | username: null, | ||
| 72 | port: null, | ||
| 73 | source: "known-hosts", | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | alias: "devbox", | ||
| 77 | hostname: "devbox", | ||
| 78 | username: null, | ||
| 79 | port: null, | ||
| 80 | source: "ssh-config", | ||
| 81 | }, | ||
| 82 | { | ||
| 83 | alias: "known.example.com", | ||
| 84 | hostname: "known.example.com", | ||
| 85 | username: null, | ||
| 86 | port: null, | ||
| 87 | source: "known-hosts", | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | alias: "staging", | ||
| 91 | hostname: "staging", | ||
| 92 | username: null, | ||
| 93 | port: null, | ||
| 94 | source: "ssh-config", | ||
| 95 | }, | ||
| 96 | ]); | ||
| 97 | }); | ||
| 98 | }); | ||
apps/desktop/src/sshEnvironment.ts created+420| ... | @@ -0,0 +1,420 @@ | ||
| 1 | import * as Crypto from "node:crypto"; | ||
| 2 | |||
| 3 | import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { NetService } from "@t3tools/shared/Net"; | ||
| 6 | import type { | ||
| 7 | AuthBearerBootstrapResult, | ||
| 8 | AuthSessionState, | ||
| 9 | AuthWebSocketTokenResult, | ||
| 10 | DesktopDiscoveredSshHost, | ||
| 11 | DesktopSshEnvironmentTarget, | ||
| 12 | DesktopSshPasswordPromptRequest, | ||
| 13 | ExecutionEnvironmentDescriptor, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { | ||
| 16 | SshPasswordPrompt, | ||
| 17 | type SshPasswordPromptShape, | ||
| 18 | type SshPasswordRequest, | ||
| 19 | } from "@t3tools/ssh/auth"; | ||
| 20 | import { discoverSshHosts } from "@t3tools/ssh/config"; | ||
| 21 | import { SshPasswordPromptError } from "@t3tools/ssh/errors"; | ||
| 22 | import { | ||
| 23 | fetchLoopbackSshJson, | ||
| 24 | SshEnvironmentManager, | ||
| 25 | type RemoteT3RunnerOptions, | ||
| 26 | } from "@t3tools/ssh/tunnel"; | ||
| 27 | import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; | ||
| 28 | |||
| 29 | export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; | ||
| 30 | |||
| 31 | const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; | ||
| 32 | const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; | ||
| 33 | const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; | ||
| 34 | const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; | ||
| 35 | const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; | ||
| 36 | const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; | ||
| 37 | const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; | ||
| 38 | const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; | ||
| 39 | const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; | ||
| 40 | const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; | ||
| 41 | const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; | ||
| 42 | |||
| 43 | interface DesktopSshEnvironmentManagerOptions { | ||
| 44 | readonly passwordProvider?: (request: SshPasswordRequest) => Promise<string | null>; | ||
| 45 | readonly resolveCliPackageSpec?: () => string; | ||
| 46 | readonly resolveCliRunner?: () => RemoteT3RunnerOptions; | ||
| 47 | } | ||
| 48 | |||
| 49 | const sshRuntime = ManagedRuntime.make( | ||
| 50 | Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer), | ||
| 51 | ); | ||
| 52 | |||
| 53 | function createDesktopSshRuntime( | ||
| 54 | passwordPrompt: SshPasswordPromptShape, | ||
| 55 | scope: Scope.Scope, | ||
| 56 | options: DesktopSshEnvironmentManagerOptions, | ||
| 57 | ) { | ||
| 58 | return ManagedRuntime.make( | ||
| 59 | Layer.mergeAll( | ||
| 60 | NodeServices.layer, | ||
| 61 | NodeHttpClient.layerUndici, | ||
| 62 | NetService.layer, | ||
| 63 | Layer.succeed(Scope.Scope, scope), | ||
| 64 | Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)), | ||
| 65 | SshEnvironmentManager.layer({ | ||
| 66 | ...(options.resolveCliPackageSpec === undefined | ||
| 67 | ? {} | ||
| 68 | : { resolveCliPackageSpec: options.resolveCliPackageSpec }), | ||
| 69 | ...(options.resolveCliRunner === undefined | ||
| 70 | ? {} | ||
| 71 | : { resolveCliRunner: options.resolveCliRunner }), | ||
| 72 | }), | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | |||
| 77 | export async function discoverDesktopSshHosts(input?: { | ||
| 78 | readonly homeDir?: string; | ||
| 79 | }): Promise<readonly DesktopDiscoveredSshHost[]> { | ||
| 80 | return await sshRuntime.runPromise(discoverSshHosts(input ?? {})); | ||
| 81 | } | ||
| 82 | |||
| 83 | export class DesktopSshEnvironmentManager { | ||
| 84 | private readonly runtime: ReturnType<typeof createDesktopSshRuntime>; | ||
| 85 | private readonly scope: Scope.Scope; | ||
| 86 | |||
| 87 | constructor(options: DesktopSshEnvironmentManagerOptions = {}) { | ||
| 88 | const passwordPrompt: SshPasswordPromptShape = { | ||
| 89 | isAvailable: options.passwordProvider !== undefined, | ||
| 90 | request: (request) => { | ||
| 91 | const passwordProvider = options.passwordProvider; | ||
| 92 | if (!passwordProvider) { | ||
| 93 | return Effect.succeed(null); | ||
| 94 | } | ||
| 95 | |||
| 96 | return Effect.tryPromise({ | ||
| 97 | try: () => passwordProvider(request), | ||
| 98 | catch: (cause) => | ||
| 99 | new SshPasswordPromptError({ | ||
| 100 | message: cause instanceof Error ? cause.message : "SSH password prompt failed.", | ||
| 101 | cause, | ||
| 102 | }), | ||
| 103 | }); | ||
| 104 | }, | ||
| 105 | }; | ||
| 106 | this.scope = Effect.runSync(Scope.make()); | ||
| 107 | this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options); | ||
| 108 | } | ||
| 109 | |||
| 110 | async discoverHosts(): Promise<readonly DesktopDiscoveredSshHost[]> { | ||
| 111 | return await discoverDesktopSshHosts(); | ||
| 112 | } | ||
| 113 | |||
| 114 | async ensureEnvironment( | ||
| 115 | target: DesktopSshEnvironmentTarget, | ||
| 116 | options?: { readonly issuePairingToken?: boolean }, | ||
| 117 | ) { | ||
| 118 | return await this.runtime.runPromise( | ||
| 119 | Effect.service(SshEnvironmentManager).pipe( | ||
| 120 | Effect.flatMap((manager) => manager.ensureEnvironment(target, options)), | ||
| 121 | ), | ||
| 122 | ); | ||
| 123 | } | ||
| 124 | |||
| 125 | async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise<void> { | ||
| 126 | await this.runtime.runPromise( | ||
| 127 | Effect.service(SshEnvironmentManager).pipe( | ||
| 128 | Effect.flatMap((manager) => manager.disconnectEnvironment(target)), | ||
| 129 | ), | ||
| 130 | ); | ||
| 131 | } | ||
| 132 | |||
| 133 | async dispose(): Promise<void> { | ||
| 134 | await this.runtime.runPromise(Scope.close(this.scope, Exit.void)); | ||
| 135 | await this.runtime.dispose(); | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { | ||
| 140 | if (typeof rawTarget !== "object" || rawTarget === null) { | ||
| 141 | return null; | ||
| 142 | } | ||
| 143 | |||
| 144 | const target = rawTarget as Partial<DesktopSshEnvironmentTarget>; | ||
| 145 | if (typeof target.alias !== "string" || typeof target.hostname !== "string") { | ||
| 146 | return null; | ||
| 147 | } | ||
| 148 | if ( | ||
| 149 | target.username !== null && | ||
| 150 | target.username !== undefined && | ||
| 151 | typeof target.username !== "string" | ||
| 152 | ) { | ||
| 153 | return null; | ||
| 154 | } | ||
| 155 | if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { | ||
| 156 | return null; | ||
| 157 | } | ||
| 158 | |||
| 159 | const alias = target.alias.trim(); | ||
| 160 | const hostname = target.hostname.trim(); | ||
| 161 | if (alias.length === 0 || hostname.length === 0) { | ||
| 162 | return null; | ||
| 163 | } | ||
| 164 | |||
| 165 | return { | ||
| 166 | alias, | ||
| 167 | hostname, | ||
| 168 | username: target.username?.trim() || null, | ||
| 169 | port: target.port ?? null, | ||
| 170 | }; | ||
| 171 | } | ||
| 172 | |||
| 173 | /** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */ | ||
| 174 | export interface DesktopSshBridgeWindow { | ||
| 175 | isDestroyed(): boolean; | ||
| 176 | isMinimized(): boolean; | ||
| 177 | restore(): void; | ||
| 178 | focus(): void; | ||
| 179 | readonly webContents: { | ||
| 180 | send(channel: string, ...args: readonly unknown[]): void; | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | |||
| 184 | /** Minimal subset of Electron's ipcMain used by the SSH bridge. */ | ||
| 185 | export interface DesktopSshBridgeIpcMain { | ||
| 186 | removeHandler(channel: string): void; | ||
| 187 | handle( | ||
| 188 | channel: string, | ||
| 189 | listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise<unknown>, | ||
| 190 | ): void; | ||
| 191 | } | ||
| 192 | |||
| 193 | export interface DesktopSshEnvironmentBridgeOptions { | ||
| 194 | readonly getMainWindow: () => DesktopSshBridgeWindow | null; | ||
| 195 | readonly resolveCliPackageSpec?: () => string; | ||
| 196 | readonly resolveCliRunner?: () => RemoteT3RunnerOptions; | ||
| 197 | readonly passwordPromptTimeoutMs?: number; | ||
| 198 | } | ||
| 199 | |||
| 200 | interface PendingSshPasswordPrompt { | ||
| 201 | readonly resolve: (password: string | null) => void; | ||
| 202 | readonly reject: (error: Error) => void; | ||
| 203 | readonly timeout: ReturnType<typeof setTimeout>; | ||
| 204 | } | ||
| 205 | |||
| 206 | export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError { | ||
| 207 | const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : ""; | ||
| 208 | return ( | ||
| 209 | error instanceof SshPasswordPromptError && | ||
| 210 | (message.includes("cancelled") || message.includes("timed out")) | ||
| 211 | ); | ||
| 212 | } | ||
| 213 | |||
| 214 | /** | ||
| 215 | * Wires the SSH environment manager to Electron IPC, owning the renderer-facing | ||
| 216 | * password prompt state so `main.ts` only needs to register, cancel, and dispose. | ||
| 217 | */ | ||
| 218 | export class DesktopSshEnvironmentBridge { | ||
| 219 | private readonly options: DesktopSshEnvironmentBridgeOptions; | ||
| 220 | private readonly manager: DesktopSshEnvironmentManager; | ||
| 221 | private readonly pendingPrompts = new Map<string, PendingSshPasswordPrompt>(); | ||
| 222 | private readonly passwordPromptTimeoutMs: number; | ||
| 223 | |||
| 224 | constructor(options: DesktopSshEnvironmentBridgeOptions) { | ||
| 225 | this.options = options; | ||
| 226 | this.passwordPromptTimeoutMs = | ||
| 227 | options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; | ||
| 228 | this.manager = new DesktopSshEnvironmentManager({ | ||
| 229 | passwordProvider: (request) => this.requestPasswordFromRenderer(request), | ||
| 230 | ...(options.resolveCliPackageSpec === undefined | ||
| 231 | ? {} | ||
| 232 | : { resolveCliPackageSpec: options.resolveCliPackageSpec }), | ||
| 233 | ...(options.resolveCliRunner === undefined | ||
| 234 | ? {} | ||
| 235 | : { resolveCliRunner: options.resolveCliRunner }), | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | |||
| 239 | registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { | ||
| 240 | ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); | ||
| 241 | ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts()); | ||
| 242 | |||
| 243 | ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); | ||
| 244 | ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => { | ||
| 245 | const target = getSafeDesktopSshTarget(rawTarget); | ||
| 246 | if (!target) { | ||
| 247 | throw new Error("Invalid desktop SSH target."); | ||
| 248 | } | ||
| 249 | |||
| 250 | const issuePairingToken = | ||
| 251 | typeof rawOptions === "object" && | ||
| 252 | rawOptions !== null && | ||
| 253 | "issuePairingToken" in rawOptions && | ||
| 254 | (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; | ||
| 255 | |||
| 256 | try { | ||
| 257 | return await this.manager.ensureEnvironment(target, { | ||
| 258 | issuePairingToken, | ||
| 259 | }); | ||
| 260 | } catch (error) { | ||
| 261 | if (isSshPasswordPromptCancellation(error)) { | ||
| 262 | return { | ||
| 263 | type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, | ||
| 264 | message: error.message, | ||
| 265 | }; | ||
| 266 | } | ||
| 267 | throw error; | ||
| 268 | } | ||
| 269 | }); | ||
| 270 | |||
| 271 | ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL); | ||
| 272 | ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => { | ||
| 273 | const target = getSafeDesktopSshTarget(rawTarget); | ||
| 274 | if (!target) { | ||
| 275 | throw new Error("Invalid desktop SSH target."); | ||
| 276 | } | ||
| 277 | |||
| 278 | await this.manager.disconnectEnvironment(target); | ||
| 279 | }); | ||
| 280 | |||
| 281 | ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); | ||
| 282 | ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) => | ||
| 283 | sshRuntime.runPromise( | ||
| 284 | fetchLoopbackSshJson<ExecutionEnvironmentDescriptor>({ | ||
| 285 | httpBaseUrl: rawHttpBaseUrl, | ||
| 286 | pathname: "/.well-known/t3/environment", | ||
| 287 | }), | ||
| 288 | ), | ||
| 289 | ); | ||
| 290 | |||
| 291 | ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); | ||
| 292 | ipcMain.handle( | ||
| 293 | BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, | ||
| 294 | async (_event, rawHttpBaseUrl, rawCredential) => | ||
| 295 | sshRuntime.runPromise( | ||
| 296 | fetchLoopbackSshJson<AuthBearerBootstrapResult>({ | ||
| 297 | httpBaseUrl: rawHttpBaseUrl, | ||
| 298 | pathname: "/api/auth/bootstrap/bearer", | ||
| 299 | method: "POST", | ||
| 300 | body: { credential: rawCredential }, | ||
| 301 | }), | ||
| 302 | ), | ||
| 303 | ); | ||
| 304 | |||
| 305 | ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); | ||
| 306 | ipcMain.handle( | ||
| 307 | FETCH_SSH_SESSION_STATE_CHANNEL, | ||
| 308 | async (_event, rawHttpBaseUrl, rawBearerToken) => | ||
| 309 | sshRuntime.runPromise( | ||
| 310 | fetchLoopbackSshJson<AuthSessionState>({ | ||
| 311 | httpBaseUrl: rawHttpBaseUrl, | ||
| 312 | pathname: "/api/auth/session", | ||
| 313 | bearerToken: rawBearerToken, | ||
| 314 | }), | ||
| 315 | ), | ||
| 316 | ); | ||
| 317 | |||
| 318 | ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); | ||
| 319 | ipcMain.handle( | ||
| 320 | ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, | ||
| 321 | async (_event, rawHttpBaseUrl, rawBearerToken) => | ||
| 322 | sshRuntime.runPromise( | ||
| 323 | fetchLoopbackSshJson<AuthWebSocketTokenResult>({ | ||
| 324 | httpBaseUrl: rawHttpBaseUrl, | ||
| 325 | pathname: "/api/auth/ws-token", | ||
| 326 | method: "POST", | ||
| 327 | bearerToken: rawBearerToken, | ||
| 328 | }), | ||
| 329 | ), | ||
| 330 | ); | ||
| 331 | |||
| 332 | ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); | ||
| 333 | ipcMain.handle( | ||
| 334 | RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, | ||
| 335 | async (_event, rawRequestId, rawPassword) => { | ||
| 336 | if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { | ||
| 337 | throw new Error("Invalid SSH password prompt id."); | ||
| 338 | } | ||
| 339 | if (rawPassword !== null && typeof rawPassword !== "string") { | ||
| 340 | throw new Error("Invalid SSH password prompt response."); | ||
| 341 | } | ||
| 342 | |||
| 343 | const pending = this.pendingPrompts.get(rawRequestId); | ||
| 344 | if (!pending) { | ||
| 345 | throw new Error("SSH password prompt expired. Try connecting again."); | ||
| 346 | } | ||
| 347 | |||
| 348 | clearTimeout(pending.timeout); | ||
| 349 | this.pendingPrompts.delete(rawRequestId); | ||
| 350 | pending.resolve(rawPassword); | ||
| 351 | }, | ||
| 352 | ); | ||
| 353 | } | ||
| 354 | |||
| 355 | cancelPendingPasswordPrompts(reason: string): void { | ||
| 356 | for (const [requestId, pending] of this.pendingPrompts) { | ||
| 357 | clearTimeout(pending.timeout); | ||
| 358 | this.pendingPrompts.delete(requestId); | ||
| 359 | pending.reject(new Error(reason)); | ||
| 360 | } | ||
| 361 | } | ||
| 362 | |||
| 363 | async dispose(): Promise<void> { | ||
| 364 | this.cancelPendingPasswordPrompts("SSH environment bridge disposed."); | ||
| 365 | await this.manager.dispose(); | ||
| 366 | } | ||
| 367 | |||
| 368 | private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise<string | null> { | ||
| 369 | const window = this.options.getMainWindow(); | ||
| 370 | if (!window || window.isDestroyed()) { | ||
| 371 | throw new Error("T3 Code window is not available for SSH authentication."); | ||
| 372 | } | ||
| 373 | |||
| 374 | const request: DesktopSshPasswordPromptRequest = { | ||
| 375 | requestId: Crypto.randomUUID(), | ||
| 376 | destination: input.destination, | ||
| 377 | username: input.username, | ||
| 378 | prompt: input.prompt, | ||
| 379 | expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(), | ||
| 380 | }; | ||
| 381 | |||
| 382 | return await new Promise<string | null>((resolve, reject) => { | ||
| 383 | const rejectPrompt = (error: Error) => { | ||
| 384 | clearTimeout(timeout); | ||
| 385 | this.pendingPrompts.delete(request.requestId); | ||
| 386 | reject(error); | ||
| 387 | }; | ||
| 388 | const timeout = setTimeout(() => { | ||
| 389 | this.pendingPrompts.delete(request.requestId); | ||
| 390 | reject(new Error(`SSH authentication timed out for ${input.destination}.`)); | ||
| 391 | }, this.passwordPromptTimeoutMs); | ||
| 392 | timeout.unref(); | ||
| 393 | |||
| 394 | this.pendingPrompts.set(request.requestId, { resolve, reject, timeout }); | ||
| 395 | |||
| 396 | try { | ||
| 397 | if (window.isDestroyed()) { | ||
| 398 | throw new Error("T3 Code window is not available for SSH authentication."); | ||
| 399 | } | ||
| 400 | window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); | ||
| 401 | if (window.isDestroyed()) { | ||
| 402 | throw new Error("T3 Code window is not available for SSH authentication."); | ||
| 403 | } | ||
| 404 | if (window.isMinimized()) { | ||
| 405 | window.restore(); | ||
| 406 | } | ||
| 407 | if (window.isDestroyed()) { | ||
| 408 | throw new Error("T3 Code window is not available for SSH authentication."); | ||
| 409 | } | ||
| 410 | window.focus(); | ||
| 411 | } catch (error) { | ||
| 412 | rejectPrompt( | ||
| 413 | error instanceof Error | ||
| 414 | ? error | ||
| 415 | : new Error("T3 Code window is not available for SSH authentication."), | ||
| 416 | ); | ||
| 417 | } | ||
| 418 | }); | ||
| 419 | } | ||
| 420 | } | ||
apps/desktop/src/tailscaleEndpointProvider.test.ts created+122| ... | @@ -0,0 +1,122 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | isTailscaleIpv4Address, | ||
| 6 | parseTailscaleMagicDnsName, | ||
| 7 | resolveTailscaleAdvertisedEndpoints, | ||
| 8 | } from "./tailscaleEndpointProvider.ts"; | ||
| 9 | |||
| 10 | describe("tailscale endpoint provider", () => { | ||
| 11 | it("detects Tailnet IPv4 addresses", () => { | ||
| 12 | expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true); | ||
| 13 | expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true); | ||
| 14 | expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false); | ||
| 15 | expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("parses MagicDNS names from tailscale status", async () => { | ||
| 19 | expect( | ||
| 20 | Effect.runSync( | ||
| 21 | parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })), | ||
| 22 | ), | ||
| 23 | ).toBe("desktop.tail.ts.net"); | ||
| 24 | expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull(); | ||
| 25 | await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined(); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("resolves Tailscale endpoints as add-on advertised endpoints", async () => { | ||
| 29 | await expect( | ||
| 30 | resolveTailscaleAdvertisedEndpoints({ | ||
| 31 | port: 3773, | ||
| 32 | networkInterfaces: { | ||
| 33 | tailscale0: [ | ||
| 34 | { | ||
| 35 | address: "100.100.100.100", | ||
| 36 | family: "IPv4", | ||
| 37 | internal: false, | ||
| 38 | netmask: "255.192.0.0", | ||
| 39 | cidr: "100.100.100.100/10", | ||
| 40 | mac: "00:00:00:00:00:00", | ||
| 41 | }, | ||
| 42 | ], | ||
| 43 | }, | ||
| 44 | statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), | ||
| 45 | }), | ||
| 46 | ).resolves.toEqual([ | ||
| 47 | { | ||
| 48 | id: "tailscale-ip:http://100.100.100.100:3773", | ||
| 49 | label: "Tailscale IP", | ||
| 50 | provider: { | ||
| 51 | id: "tailscale", | ||
| 52 | label: "Tailscale", | ||
| 53 | kind: "private-network", | ||
| 54 | isAddon: true, | ||
| 55 | }, | ||
| 56 | httpBaseUrl: "http://100.100.100.100:3773/", | ||
| 57 | wsBaseUrl: "ws://100.100.100.100:3773/", | ||
| 58 | reachability: "private-network", | ||
| 59 | compatibility: { | ||
| 60 | hostedHttpsApp: "mixed-content-blocked", | ||
| 61 | desktopApp: "compatible", | ||
| 62 | }, | ||
| 63 | source: "desktop-addon", | ||
| 64 | status: "available", | ||
| 65 | description: "Reachable from devices on the same Tailnet.", | ||
| 66 | }, | ||
| 67 | { | ||
| 68 | id: "tailscale-magicdns:https://desktop.tail.ts.net/", | ||
| 69 | label: "Tailscale HTTPS", | ||
| 70 | provider: { | ||
| 71 | id: "tailscale", | ||
| 72 | label: "Tailscale", | ||
| 73 | kind: "private-network", | ||
| 74 | isAddon: true, | ||
| 75 | }, | ||
| 76 | httpBaseUrl: "https://desktop.tail.ts.net/", | ||
| 77 | wsBaseUrl: "wss://desktop.tail.ts.net/", | ||
| 78 | reachability: "private-network", | ||
| 79 | compatibility: { | ||
| 80 | hostedHttpsApp: "requires-configuration", | ||
| 81 | desktopApp: "compatible", | ||
| 82 | }, | ||
| 83 | source: "desktop-addon", | ||
| 84 | status: "unavailable", | ||
| 85 | description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", | ||
| 86 | }, | ||
| 87 | ]); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => { | ||
| 91 | await expect( | ||
| 92 | resolveTailscaleAdvertisedEndpoints({ | ||
| 93 | port: 3773, | ||
| 94 | networkInterfaces: {}, | ||
| 95 | statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), | ||
| 96 | serveEnabled: true, | ||
| 97 | probe: async () => true, | ||
| 98 | }), | ||
| 99 | ).resolves.toEqual([ | ||
| 100 | { | ||
| 101 | id: "tailscale-magicdns:https://desktop.tail.ts.net/", | ||
| 102 | label: "Tailscale HTTPS", | ||
| 103 | provider: { | ||
| 104 | id: "tailscale", | ||
| 105 | label: "Tailscale", | ||
| 106 | kind: "private-network", | ||
| 107 | isAddon: true, | ||
| 108 | }, | ||
| 109 | httpBaseUrl: "https://desktop.tail.ts.net/", | ||
| 110 | wsBaseUrl: "wss://desktop.tail.ts.net/", | ||
| 111 | reachability: "private-network", | ||
| 112 | compatibility: { | ||
| 113 | hostedHttpsApp: "compatible", | ||
| 114 | desktopApp: "compatible", | ||
| 115 | }, | ||
| 116 | source: "desktop-addon", | ||
| 117 | status: "available", | ||
| 118 | description: "HTTPS endpoint served by Tailscale Serve.", | ||
| 119 | }, | ||
| 120 | ]); | ||
| 121 | }); | ||
| 122 | }); | ||
apps/desktop/src/tailscaleEndpointProvider.ts created+142| ... | @@ -0,0 +1,142 @@ | ||
| 1 | import type { NetworkInterfaceInfo } from "node:os"; | ||
| 2 | |||
| 3 | import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { | ||
| 6 | createAdvertisedEndpoint, | ||
| 7 | type CreateAdvertisedEndpointInput, | ||
| 8 | } from "@t3tools/client-runtime"; | ||
| 9 | import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; | ||
| 10 | import { | ||
| 11 | buildTailscaleHttpsBaseUrl, | ||
| 12 | isTailscaleIpv4Address, | ||
| 13 | parseTailscaleMagicDnsName, | ||
| 14 | probeTailscaleHttpsEndpoint, | ||
| 15 | readTailscaleStatus, | ||
| 16 | } from "@t3tools/tailscale"; | ||
| 17 | import { Effect, Layer } from "effect"; | ||
| 18 | |||
| 19 | export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; | ||
| 20 | |||
| 21 | const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici); | ||
| 22 | |||
| 23 | const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | ||
| 24 | id: "tailscale", | ||
| 25 | label: "Tailscale", | ||
| 26 | kind: "private-network", | ||
| 27 | isAddon: true, | ||
| 28 | }; | ||
| 29 | |||
| 30 | function createTailscaleEndpoint( | ||
| 31 | input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | ||
| 32 | ): AdvertisedEndpoint { | ||
| 33 | return createAdvertisedEndpoint({ | ||
| 34 | ...input, | ||
| 35 | provider: TAILSCALE_ENDPOINT_PROVIDER, | ||
| 36 | source: "desktop-addon", | ||
| 37 | }); | ||
| 38 | } | ||
| 39 | |||
| 40 | export function resolveTailscaleIpAdvertisedEndpoints(input: { | ||
| 41 | readonly port: number; | ||
| 42 | readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>; | ||
| 43 | }): readonly AdvertisedEndpoint[] { | ||
| 44 | const seen = new Set<string>(); | ||
| 45 | const endpoints: AdvertisedEndpoint[] = []; | ||
| 46 | |||
| 47 | for (const interfaceAddresses of Object.values(input.networkInterfaces)) { | ||
| 48 | if (!interfaceAddresses) continue; | ||
| 49 | |||
| 50 | for (const address of interfaceAddresses) { | ||
| 51 | if (address.internal) continue; | ||
| 52 | if (address.family !== "IPv4") continue; | ||
| 53 | if (!isTailscaleIpv4Address(address.address)) continue; | ||
| 54 | if (seen.has(address.address)) continue; | ||
| 55 | seen.add(address.address); | ||
| 56 | |||
| 57 | endpoints.push( | ||
| 58 | createTailscaleEndpoint({ | ||
| 59 | id: `tailscale-ip:http://${address.address}:${input.port}`, | ||
| 60 | label: "Tailscale IP", | ||
| 61 | httpBaseUrl: `http://${address.address}:${input.port}`, | ||
| 62 | reachability: "private-network", | ||
| 63 | status: "available", | ||
| 64 | description: "Reachable from devices on the same Tailnet.", | ||
| 65 | }), | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | return endpoints; | ||
| 71 | } | ||
| 72 | |||
| 73 | export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: { | ||
| 74 | readonly dnsName: string | null; | ||
| 75 | readonly serveEnabled: boolean; | ||
| 76 | readonly servePort?: number; | ||
| 77 | readonly probe?: (baseUrl: string) => Promise<boolean>; | ||
| 78 | }): Promise<AdvertisedEndpoint | null> { | ||
| 79 | if (!input.dnsName) { | ||
| 80 | return null; | ||
| 81 | } | ||
| 82 | |||
| 83 | const httpBaseUrl = buildTailscaleHttpsBaseUrl({ | ||
| 84 | magicDnsName: input.dnsName, | ||
| 85 | ...(input.servePort === undefined ? {} : { servePort: input.servePort }), | ||
| 86 | }); | ||
| 87 | const isReachable = input.serveEnabled | ||
| 88 | ? await (input.probe?.(httpBaseUrl) ?? | ||
| 89 | Effect.runPromise( | ||
| 90 | probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( | ||
| 91 | Effect.provide(TailscaleDesktopLayer), | ||
| 92 | ), | ||
| 93 | )) | ||
| 94 | : false; | ||
| 95 | |||
| 96 | return createTailscaleEndpoint({ | ||
| 97 | id: `tailscale-magicdns:${httpBaseUrl}`, | ||
| 98 | label: "Tailscale HTTPS", | ||
| 99 | httpBaseUrl, | ||
| 100 | reachability: "private-network", | ||
| 101 | hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", | ||
| 102 | status: isReachable ? "available" : "unavailable", | ||
| 103 | description: isReachable | ||
| 104 | ? "HTTPS endpoint served by Tailscale Serve." | ||
| 105 | : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", | ||
| 106 | }); | ||
| 107 | } | ||
| 108 | |||
| 109 | export async function resolveTailscaleAdvertisedEndpoints(input: { | ||
| 110 | readonly port: number; | ||
| 111 | readonly serveEnabled?: boolean; | ||
| 112 | readonly servePort?: number; | ||
| 113 | readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>; | ||
| 114 | readonly statusJson?: string | null; | ||
| 115 | readonly probe?: (baseUrl: string) => Promise<boolean>; | ||
| 116 | }): Promise<readonly AdvertisedEndpoint[]> { | ||
| 117 | const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); | ||
| 118 | const dnsName = | ||
| 119 | input.statusJson === undefined | ||
| 120 | ? await Effect.runPromise( | ||
| 121 | readTailscaleStatus.pipe( | ||
| 122 | Effect.map((status) => status.magicDnsName), | ||
| 123 | Effect.catch(() => Effect.succeed(null)), | ||
| 124 | Effect.provide(TailscaleDesktopLayer), | ||
| 125 | ), | ||
| 126 | ) | ||
| 127 | : input.statusJson | ||
| 128 | ? await Effect.runPromise( | ||
| 129 | parseTailscaleMagicDnsName(input.statusJson).pipe( | ||
| 130 | Effect.catch(() => Effect.succeed(null)), | ||
| 131 | ), | ||
| 132 | ) | ||
| 133 | : null; | ||
| 134 | const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({ | ||
| 135 | dnsName, | ||
| 136 | serveEnabled: input.serveEnabled === true, | ||
| 137 | ...(input.servePort === undefined ? {} : { servePort: input.servePort }), | ||
| 138 | ...(input.probe === undefined ? {} : { probe: input.probe }), | ||
| 139 | }); | ||
| 140 | |||
| 141 | return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; | ||
| 142 | } | ||
apps/server/package.json+1| ... | @@ -40,6 +40,7 @@ | ... | @@ -40,6 +40,7 @@ |
| 40 | "@effect/vitest": "catalog:", | 40 | "@effect/vitest": "catalog:", |
| 41 | "@t3tools/contracts": "workspace:*", | 41 | "@t3tools/contracts": "workspace:*", |
| 42 | "@t3tools/shared": "workspace:*", | 42 | "@t3tools/shared": "workspace:*", |
| 43 | "@t3tools/tailscale": "workspace:*", | ||
| 43 | "@t3tools/web": "workspace:*", | 44 | "@t3tools/web": "workspace:*", |
| 44 | "@types/bun": "catalog:", | 45 | "@types/bun": "catalog:", |
| 45 | "@types/node": "catalog:", | 46 | "@types/node": "catalog:", |
apps/server/src/cli-config.test.ts+36| ... | @@ -46,6 +46,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -46,6 +46,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 46 | bootstrapFd: Option.none(), | 46 | bootstrapFd: Option.none(), |
| 47 | autoBootstrapProjectFromCwd: Option.none(), | 47 | autoBootstrapProjectFromCwd: Option.none(), |
| 48 | logWebSocketEvents: Option.none(), | 48 | logWebSocketEvents: Option.none(), |
| 49 | tailscaleServeEnabled: Option.none(), | ||
| 50 | tailscaleServePort: Option.none(), | ||
| 49 | }, | 51 | }, |
| 50 | Option.none(), | 52 | Option.none(), |
| 51 | ).pipe( | 53 | ).pipe( |
| ... | @@ -87,6 +89,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -87,6 +89,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 87 | desktopBootstrapToken: undefined, | 89 | desktopBootstrapToken: undefined, |
| 88 | autoBootstrapProjectFromCwd: false, | 90 | autoBootstrapProjectFromCwd: false, |
| 89 | logWebSocketEvents: true, | 91 | logWebSocketEvents: true, |
| 92 | tailscaleServeEnabled: false, | ||
| 93 | tailscaleServePort: 443, | ||
| 90 | }); | 94 | }); |
| 91 | }), | 95 | }), |
| 92 | ); | 96 | ); |
| ... | @@ -108,6 +112,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -108,6 +112,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 108 | bootstrapFd: Option.none(), | 112 | bootstrapFd: Option.none(), |
| 109 | autoBootstrapProjectFromCwd: Option.some(true), | 113 | autoBootstrapProjectFromCwd: Option.some(true), |
| 110 | logWebSocketEvents: Option.some(true), | 114 | logWebSocketEvents: Option.some(true), |
| 115 | tailscaleServeEnabled: Option.some(true), | ||
| 116 | tailscaleServePort: Option.some(8443), | ||
| 111 | }, | 117 | }, |
| 112 | Option.some("Debug"), | 118 | Option.some("Debug"), |
| 113 | ).pipe( | 119 | ).pipe( |
| ... | @@ -149,6 +155,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -149,6 +155,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 149 | desktopBootstrapToken: undefined, | 155 | desktopBootstrapToken: undefined, |
| 150 | autoBootstrapProjectFromCwd: true, | 156 | autoBootstrapProjectFromCwd: true, |
| 151 | logWebSocketEvents: true, | 157 | logWebSocketEvents: true, |
| 158 | tailscaleServeEnabled: true, | ||
| 159 | tailscaleServePort: 8443, | ||
| 152 | }); | 160 | }); |
| 153 | }), | 161 | }), |
| 154 | ); | 162 | ); |
| ... | @@ -161,6 +169,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -161,6 +169,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 161 | noBrowser: true, | 169 | noBrowser: true, |
| 162 | autoBootstrapProjectFromCwd: true, | 170 | autoBootstrapProjectFromCwd: true, |
| 163 | logWebSocketEvents: true, | 171 | logWebSocketEvents: true, |
| 172 | tailscaleServeEnabled: false, | ||
| 173 | tailscaleServePort: 443, | ||
| 164 | }); | 174 | }); |
| 165 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | 175 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); |
| 166 | 176 | ||
| ... | @@ -176,6 +186,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -176,6 +186,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 176 | bootstrapFd: Option.none(), | 186 | bootstrapFd: Option.none(), |
| 177 | autoBootstrapProjectFromCwd: Option.some(false), | 187 | autoBootstrapProjectFromCwd: Option.some(false), |
| 178 | logWebSocketEvents: Option.some(false), | 188 | logWebSocketEvents: Option.some(false), |
| 189 | tailscaleServeEnabled: Option.none(), | ||
| 190 | tailscaleServePort: Option.none(), | ||
| 179 | }, | 191 | }, |
| 180 | Option.none(), | 192 | Option.none(), |
| 181 | ).pipe( | 193 | ).pipe( |
| ... | @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 212 | desktopBootstrapToken: undefined, | 224 | desktopBootstrapToken: undefined, |
| 213 | autoBootstrapProjectFromCwd: false, | 225 | autoBootstrapProjectFromCwd: false, |
| 214 | logWebSocketEvents: false, | 226 | logWebSocketEvents: false, |
| 227 | tailscaleServeEnabled: false, | ||
| 228 | tailscaleServePort: 443, | ||
| 215 | }); | 229 | }); |
| 216 | }), | 230 | }), |
| 217 | ); | 231 | ); |
| ... | @@ -229,6 +243,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -229,6 +243,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 229 | noBrowser: true, | 243 | noBrowser: true, |
| 230 | autoBootstrapProjectFromCwd: false, | 244 | autoBootstrapProjectFromCwd: false, |
| 231 | logWebSocketEvents: true, | 245 | logWebSocketEvents: true, |
| 246 | tailscaleServeEnabled: false, | ||
| 247 | tailscaleServePort: 443, | ||
| 232 | otlpTracesUrl: "http://localhost:4318/v1/traces", | 248 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 233 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | 249 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 234 | }); | 250 | }); |
| ... | @@ -246,6 +262,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -246,6 +262,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 246 | bootstrapFd: Option.none(), | 262 | bootstrapFd: Option.none(), |
| 247 | autoBootstrapProjectFromCwd: Option.none(), | 263 | autoBootstrapProjectFromCwd: Option.none(), |
| 248 | logWebSocketEvents: Option.none(), | 264 | logWebSocketEvents: Option.none(), |
| 265 | tailscaleServeEnabled: Option.none(), | ||
| 266 | tailscaleServePort: Option.none(), | ||
| 249 | }, | 267 | }, |
| 250 | Option.none(), | 268 | Option.none(), |
| 251 | ).pipe( | 269 | ).pipe( |
| ... | @@ -281,6 +299,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -281,6 +299,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 281 | desktopBootstrapToken: undefined, | 299 | desktopBootstrapToken: undefined, |
| 282 | autoBootstrapProjectFromCwd: false, | 300 | autoBootstrapProjectFromCwd: false, |
| 283 | logWebSocketEvents: true, | 301 | logWebSocketEvents: true, |
| 302 | tailscaleServeEnabled: false, | ||
| 303 | tailscaleServePort: 443, | ||
| 284 | }); | 304 | }); |
| 285 | assert.equal(join(baseDir, "dev"), resolved.stateDir); | 305 | assert.equal(join(baseDir, "dev"), resolved.stateDir); |
| 286 | }), | 306 | }), |
| ... | @@ -305,6 +325,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -305,6 +325,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 305 | bootstrapFd: Option.none(), | 325 | bootstrapFd: Option.none(), |
| 306 | autoBootstrapProjectFromCwd: Option.none(), | 326 | autoBootstrapProjectFromCwd: Option.none(), |
| 307 | logWebSocketEvents: Option.none(), | 327 | logWebSocketEvents: Option.none(), |
| 328 | tailscaleServeEnabled: Option.none(), | ||
| 329 | tailscaleServePort: Option.none(), | ||
| 308 | }, | 330 | }, |
| 309 | Option.none(), | 331 | Option.none(), |
| 310 | ).pipe( | 332 | ).pipe( |
| ... | @@ -346,6 +368,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -346,6 +368,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 346 | noBrowser: false, | 368 | noBrowser: false, |
| 347 | autoBootstrapProjectFromCwd: false, | 369 | autoBootstrapProjectFromCwd: false, |
| 348 | logWebSocketEvents: false, | 370 | logWebSocketEvents: false, |
| 371 | tailscaleServeEnabled: false, | ||
| 372 | tailscaleServePort: 443, | ||
| 349 | }); | 373 | }); |
| 350 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | 374 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); |
| 351 | 375 | ||
| ... | @@ -361,6 +385,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -361,6 +385,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 361 | bootstrapFd: Option.none(), | 385 | bootstrapFd: Option.none(), |
| 362 | autoBootstrapProjectFromCwd: Option.none(), | 386 | autoBootstrapProjectFromCwd: Option.none(), |
| 363 | logWebSocketEvents: Option.none(), | 387 | logWebSocketEvents: Option.none(), |
| 388 | tailscaleServeEnabled: Option.none(), | ||
| 389 | tailscaleServePort: Option.none(), | ||
| 364 | }, | 390 | }, |
| 365 | Option.some("Debug"), | 391 | Option.some("Debug"), |
| 366 | ).pipe( | 392 | ).pipe( |
| ... | @@ -399,6 +425,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -399,6 +425,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 399 | desktopBootstrapToken: undefined, | 425 | desktopBootstrapToken: undefined, |
| 400 | autoBootstrapProjectFromCwd: true, | 426 | autoBootstrapProjectFromCwd: true, |
| 401 | logWebSocketEvents: true, | 427 | logWebSocketEvents: true, |
| 428 | tailscaleServeEnabled: false, | ||
| 429 | tailscaleServePort: 443, | ||
| 402 | }); | 430 | }); |
| 403 | }), | 431 | }), |
| 404 | ); | 432 | ); |
| ... | @@ -432,6 +460,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -432,6 +460,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 432 | bootstrapFd: Option.none(), | 460 | bootstrapFd: Option.none(), |
| 433 | autoBootstrapProjectFromCwd: Option.none(), | 461 | autoBootstrapProjectFromCwd: Option.none(), |
| 434 | logWebSocketEvents: Option.none(), | 462 | logWebSocketEvents: Option.none(), |
| 463 | tailscaleServeEnabled: Option.none(), | ||
| 464 | tailscaleServePort: Option.none(), | ||
| 435 | }, | 465 | }, |
| 436 | Option.none(), | 466 | Option.none(), |
| 437 | ).pipe( | 467 | ).pipe( |
| ... | @@ -463,6 +493,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -463,6 +493,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 463 | desktopBootstrapToken: undefined, | 493 | desktopBootstrapToken: undefined, |
| 464 | autoBootstrapProjectFromCwd: false, | 494 | autoBootstrapProjectFromCwd: false, |
| 465 | logWebSocketEvents: false, | 495 | logWebSocketEvents: false, |
| 496 | tailscaleServeEnabled: false, | ||
| 497 | tailscaleServePort: 443, | ||
| 466 | }); | 498 | }); |
| 467 | }), | 499 | }), |
| 468 | ); | 500 | ); |
| ... | @@ -485,6 +517,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -485,6 +517,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 485 | bootstrapFd: Option.none(), | 517 | bootstrapFd: Option.none(), |
| 486 | autoBootstrapProjectFromCwd: Option.none(), | 518 | autoBootstrapProjectFromCwd: Option.none(), |
| 487 | logWebSocketEvents: Option.none(), | 519 | logWebSocketEvents: Option.none(), |
| 520 | tailscaleServeEnabled: Option.none(), | ||
| 521 | tailscaleServePort: Option.none(), | ||
| 488 | }, | 522 | }, |
| 489 | Option.none(), | 523 | Option.none(), |
| 490 | { | 524 | { |
| ... | @@ -522,6 +556,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -522,6 +556,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 522 | desktopBootstrapToken: undefined, | 556 | desktopBootstrapToken: undefined, |
| 523 | autoBootstrapProjectFromCwd: false, | 557 | autoBootstrapProjectFromCwd: false, |
| 524 | logWebSocketEvents: false, | 558 | logWebSocketEvents: false, |
| 559 | tailscaleServeEnabled: false, | ||
| 560 | tailscaleServePort: 443, | ||
| 525 | }); | 561 | }); |
| 526 | }), | 562 | }), |
| 527 | ); | 563 | ); |
apps/server/src/cli.test.ts+2| ... | @@ -76,6 +76,8 @@ const makeCliTestServerConfig = (baseDir: string) => | ... | @@ -76,6 +76,8 @@ const makeCliTestServerConfig = (baseDir: string) => |
| 76 | desktopBootstrapToken: undefined, | 76 | desktopBootstrapToken: undefined, |
| 77 | autoBootstrapProjectFromCwd: false, | 77 | autoBootstrapProjectFromCwd: false, |
| 78 | logWebSocketEvents: false, | 78 | logWebSocketEvents: false, |
| 79 | tailscaleServeEnabled: false, | ||
| 80 | tailscaleServePort: 443, | ||
| 79 | } satisfies ServerConfigShape; | 81 | } satisfies ServerConfigShape; |
| 80 | }); | 82 | }); |
| 81 | 83 |
apps/server/src/cli.ts+47| ... | @@ -78,6 +78,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({ | ... | @@ -78,6 +78,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({ |
| 78 | desktopBootstrapToken: Schema.optional(Schema.String), | 78 | desktopBootstrapToken: Schema.optional(Schema.String), |
| 79 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | 79 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), |
| 80 | logWebSocketEvents: Schema.optional(Schema.Boolean), | 80 | logWebSocketEvents: Schema.optional(Schema.Boolean), |
| 81 | tailscaleServeEnabled: Schema.optional(Schema.Boolean), | ||
| 82 | tailscaleServePort: Schema.optional(PortSchema), | ||
| 81 | otlpTracesUrl: Schema.optional(Schema.String), | 83 | otlpTracesUrl: Schema.optional(Schema.String), |
| 82 | otlpMetricsUrl: Schema.optional(Schema.String), | 84 | otlpMetricsUrl: Schema.optional(Schema.String), |
| 83 | }); | 85 | }); |
| ... | @@ -126,6 +128,17 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( | ... | @@ -126,6 +128,17 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( |
| 126 | Flag.withAlias("log-ws-events"), | 128 | Flag.withAlias("log-ws-events"), |
| 127 | Flag.optional, | 129 | Flag.optional, |
| 128 | ); | 130 | ); |
| 131 | const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( | ||
| 132 | Flag.withDescription( | ||
| 133 | "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", | ||
| 134 | ), | ||
| 135 | Flag.optional, | ||
| 136 | ); | ||
| 137 | const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( | ||
| 138 | Flag.withSchema(PortSchema), | ||
| 139 | Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), | ||
| 140 | Flag.optional, | ||
| 141 | ); | ||
| 129 | 142 | ||
| 130 | const EnvServerConfig = Config.all({ | 143 | const EnvServerConfig = Config.all({ |
| 131 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), | 144 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), |
| ... | @@ -174,6 +187,14 @@ const EnvServerConfig = Config.all({ | ... | @@ -174,6 +187,14 @@ const EnvServerConfig = Config.all({ |
| 174 | Config.option, | 187 | Config.option, |
| 175 | Config.map(Option.getOrUndefined), | 188 | Config.map(Option.getOrUndefined), |
| 176 | ), | 189 | ), |
| 190 | tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe( | ||
| 191 | Config.option, | ||
| 192 | Config.map(Option.getOrUndefined), | ||
| 193 | ), | ||
| 194 | tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe( | ||
| 195 | Config.option, | ||
| 196 | Config.map(Option.getOrUndefined), | ||
| 197 | ), | ||
| 177 | }); | 198 | }); |
| 178 | 199 | ||
| 179 | interface CliServerFlags { | 200 | interface CliServerFlags { |
| ... | @@ -187,6 +208,8 @@ interface CliServerFlags { | ... | @@ -187,6 +208,8 @@ interface CliServerFlags { |
| 187 | readonly bootstrapFd: Option.Option<number>; | 208 | readonly bootstrapFd: Option.Option<number>; |
| 188 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | 209 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; |
| 189 | readonly logWebSocketEvents: Option.Option<boolean>; | 210 | readonly logWebSocketEvents: Option.Option<boolean>; |
| 211 | readonly tailscaleServeEnabled: Option.Option<boolean>; | ||
| 212 | readonly tailscaleServePort: Option.Option<number>; | ||
| 190 | } | 213 | } |
| 191 | 214 | ||
| 192 | interface CliAuthLocationFlags { | 215 | interface CliAuthLocationFlags { |
| ... | @@ -233,6 +256,8 @@ export const resolveServerConfig = ( | ... | @@ -233,6 +256,8 @@ export const resolveServerConfig = ( |
| 233 | bootstrapFd: flags.bootstrapFd ?? Option.none(), | 256 | bootstrapFd: flags.bootstrapFd ?? Option.none(), |
| 234 | autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), | 257 | autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), |
| 235 | logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), | 258 | logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), |
| 259 | tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), | ||
| 260 | tailscaleServePort: flags.tailscaleServePort ?? Option.none(), | ||
| 236 | } satisfies CliServerFlags; | 261 | } satisfies CliServerFlags; |
| 237 | const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; | 262 | const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; |
| 238 | const bootstrapEnvelope = | 263 | const bootstrapEnvelope = |
| ... | @@ -323,6 +348,22 @@ export const resolveServerConfig = ( | ... | @@ -323,6 +348,22 @@ export const resolveServerConfig = ( |
| 323 | ), | 348 | ), |
| 324 | () => Boolean(devUrl), | 349 | () => Boolean(devUrl), |
| 325 | ); | 350 | ); |
| 351 | const tailscaleServeEnabled = Option.getOrElse( | ||
| 352 | resolveOptionPrecedence( | ||
| 353 | normalizedFlags.tailscaleServeEnabled, | ||
| 354 | Option.fromUndefinedOr(env.tailscaleServeEnabled), | ||
| 355 | Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), | ||
| 356 | ), | ||
| 357 | () => false, | ||
| 358 | ); | ||
| 359 | const tailscaleServePort = Option.getOrElse( | ||
| 360 | resolveOptionPrecedence( | ||
| 361 | normalizedFlags.tailscaleServePort, | ||
| 362 | Option.fromUndefinedOr(env.tailscaleServePort), | ||
| 363 | Option.fromUndefinedOr(bootstrap?.tailscaleServePort), | ||
| 364 | ), | ||
| 365 | () => 443, | ||
| 366 | ); | ||
| 326 | const staticDir = devUrl ? undefined : yield* resolveStaticDir(); | 367 | const staticDir = devUrl ? undefined : yield* resolveStaticDir(); |
| 327 | const host = Option.getOrElse( | 368 | const host = Option.getOrElse( |
| 328 | resolveOptionPrecedence( | 369 | resolveOptionPrecedence( |
| ... | @@ -365,6 +406,8 @@ export const resolveServerConfig = ( | ... | @@ -365,6 +406,8 @@ export const resolveServerConfig = ( |
| 365 | desktopBootstrapToken, | 406 | desktopBootstrapToken, |
| 366 | autoBootstrapProjectFromCwd, | 407 | autoBootstrapProjectFromCwd, |
| 367 | logWebSocketEvents, | 408 | logWebSocketEvents, |
| 409 | tailscaleServeEnabled, | ||
| 410 | tailscaleServePort, | ||
| 368 | }; | 411 | }; |
| 369 | 412 | ||
| 370 | return config; | 413 | return config; |
| ... | @@ -386,6 +429,8 @@ const resolveCliAuthConfig = ( | ... | @@ -386,6 +429,8 @@ const resolveCliAuthConfig = ( |
| 386 | bootstrapFd: Option.none(), | 429 | bootstrapFd: Option.none(), |
| 387 | autoBootstrapProjectFromCwd: Option.none(), | 430 | autoBootstrapProjectFromCwd: Option.none(), |
| 388 | logWebSocketEvents: Option.none(), | 431 | logWebSocketEvents: Option.none(), |
| 432 | tailscaleServeEnabled: Option.none(), | ||
| 433 | tailscaleServePort: Option.none(), | ||
| 389 | }, | 434 | }, |
| 390 | cliLogLevel, | 435 | cliLogLevel, |
| 391 | ); | 436 | ); |
| ... | @@ -766,6 +811,8 @@ const sharedServerCommandFlags = { | ... | @@ -766,6 +811,8 @@ const sharedServerCommandFlags = { |
| 766 | bootstrapFd: bootstrapFdFlag, | 811 | bootstrapFd: bootstrapFdFlag, |
| 767 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | 812 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, |
| 768 | logWebSocketEvents: logWebSocketEventsFlag, | 813 | logWebSocketEvents: logWebSocketEventsFlag, |
| 814 | tailscaleServeEnabled: tailscaleServeFlag, | ||
| 815 | tailscaleServePort: tailscaleServePortFlag, | ||
| 769 | } as const; | 816 | } as const; |
| 770 | 817 | ||
| 771 | const authLocationFlags = sharedServerLocationFlags; | 818 | const authLocationFlags = sharedServerLocationFlags; |
apps/server/src/config.ts+4| ... | @@ -65,6 +65,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { | ... | @@ -65,6 +65,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { |
| 65 | readonly desktopBootstrapToken: string | undefined; | 65 | readonly desktopBootstrapToken: string | undefined; |
| 66 | readonly autoBootstrapProjectFromCwd: boolean; | 66 | readonly autoBootstrapProjectFromCwd: boolean; |
| 67 | readonly logWebSocketEvents: boolean; | 67 | readonly logWebSocketEvents: boolean; |
| 68 | readonly tailscaleServeEnabled: boolean; | ||
| 69 | readonly tailscaleServePort: number; | ||
| 68 | } | 70 | } |
| 69 | 71 | ||
| 70 | export const deriveServerPaths = Effect.fn(function* ( | 72 | export const deriveServerPaths = Effect.fn(function* ( |
| ... | @@ -158,6 +160,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap | ... | @@ -158,6 +160,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap |
| 158 | mode: "web", | 160 | mode: "web", |
| 159 | autoBootstrapProjectFromCwd: false, | 161 | autoBootstrapProjectFromCwd: false, |
| 160 | logWebSocketEvents: false, | 162 | logWebSocketEvents: false, |
| 163 | tailscaleServeEnabled: false, | ||
| 164 | tailscaleServePort: 443, | ||
| 161 | port: 0, | 165 | port: 0, |
| 162 | host: undefined, | 166 | host: undefined, |
| 163 | desktopBootstrapToken: undefined, | 167 | desktopBootstrapToken: undefined, |
apps/server/src/environment/Layers/ServerEnvironment.test.ts+2| ... | @@ -30,6 +30,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { | ... | @@ -30,6 +30,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 30 | mode: "web", | 30 | mode: "web", |
| 31 | autoBootstrapProjectFromCwd: false, | 31 | autoBootstrapProjectFromCwd: false, |
| 32 | logWebSocketEvents: false, | 32 | logWebSocketEvents: false, |
| 33 | tailscaleServeEnabled: false, | ||
| 34 | tailscaleServePort: 443, | ||
| 33 | port: 0, | 35 | port: 0, |
| 34 | host: undefined, | 36 | host: undefined, |
| 35 | desktopBootstrapToken: undefined, | 37 | desktopBootstrapToken: undefined, |
apps/server/src/keybindings.ts+14-278| ... | @@ -13,14 +13,10 @@ import { | ... | @@ -13,14 +13,10 @@ import { |
| 13 | KeybindingShortcut, | 13 | KeybindingShortcut, |
| 14 | KeybindingWhenNode, | 14 | KeybindingWhenNode, |
| 15 | MAX_KEYBINDINGS_COUNT, | 15 | MAX_KEYBINDINGS_COUNT, |
| 16 | MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | ||
| 17 | MAX_WHEN_EXPRESSION_DEPTH, | ||
| 18 | ResolvedKeybindingRule, | 16 | ResolvedKeybindingRule, |
| 19 | ResolvedKeybindingsConfig, | 17 | ResolvedKeybindingsConfig, |
| 20 | THREAD_JUMP_KEYBINDING_COMMANDS, | ||
| 21 | type ServerConfigIssue, | 18 | type ServerConfigIssue, |
| 22 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 23 | import type { Mutable } from "effect/Types"; | ||
| 24 | import { | 20 | import { |
| 25 | Array, | 21 | Array, |
| 26 | Cache, | 22 | Cache, |
| ... | @@ -48,278 +44,20 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -48,278 +44,20 @@ import * as Semaphore from "effect/Semaphore"; |
| 48 | import { ServerConfig } from "./config.ts"; | 44 | import { ServerConfig } from "./config.ts"; |
| 49 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | 45 | import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 50 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | 46 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 51 | 47 | import { | |
| 52 | type WhenToken = | 48 | DEFAULT_KEYBINDINGS, |
| 53 | | { type: "identifier"; value: string } | 49 | DEFAULT_RESOLVED_KEYBINDINGS, |
| 54 | | { type: "not" } | 50 | compileResolvedKeybindingRule, |
| 55 | | { type: "and" } | 51 | compileResolvedKeybindingsConfig, |
| 56 | | { type: "or" } | 52 | parseKeybindingShortcut, |
| 57 | | { type: "lparen" } | 53 | } from "@t3tools/shared/keybindings"; |
| 58 | | { type: "rparen" }; | 54 | |
| 59 | 55 | export { | |
| 60 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | 56 | DEFAULT_KEYBINDINGS, |
| 61 | { key: "mod+j", command: "terminal.toggle" }, | 57 | compileResolvedKeybindingRule, |
| 62 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | 58 | compileResolvedKeybindingsConfig, |
| 63 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | 59 | parseKeybindingShortcut, |
| 64 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | 60 | }; |
| 65 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, | ||
| 66 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, | ||
| 67 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | ||
| 68 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | ||
| 69 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | ||
| 70 | { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, | ||
| 71 | { key: "mod+o", command: "editor.openFavorite" }, | ||
| 72 | { key: "mod+shift+[", command: "thread.previous" }, | ||
| 73 | { key: "mod+shift+]", command: "thread.next" }, | ||
| 74 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | ||
| 75 | key: `mod+${index + 1}`, | ||
| 76 | command, | ||
| 77 | })), | ||
| 78 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | ||
| 79 | key: `mod+${index + 1}`, | ||
| 80 | command, | ||
| 81 | when: "modelPickerOpen", | ||
| 82 | })), | ||
| 83 | ]; | ||
| 84 | |||
| 85 | function normalizeKeyToken(token: string): string { | ||
| 86 | if (token === "space") return " "; | ||
| 87 | if (token === "esc") return "escape"; | ||
| 88 | return token; | ||
| 89 | } | ||
| 90 | |||
| 91 | /** @internal - Exported for testing */ | ||
| 92 | export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { | ||
| 93 | const rawTokens = value | ||
| 94 | .toLowerCase() | ||
| 95 | .split("+") | ||
| 96 | .map((token) => token.trim()); | ||
| 97 | const tokens = [...rawTokens]; | ||
| 98 | let trailingEmptyCount = 0; | ||
| 99 | while (tokens[tokens.length - 1] === "") { | ||
| 100 | trailingEmptyCount += 1; | ||
| 101 | tokens.pop(); | ||
| 102 | } | ||
| 103 | if (trailingEmptyCount > 0) { | ||
| 104 | tokens.push("+"); | ||
| 105 | } | ||
| 106 | if (tokens.some((token) => token.length === 0)) { | ||
| 107 | return null; | ||
| 108 | } | ||
| 109 | if (tokens.length === 0) return null; | ||
| 110 | |||
| 111 | let key: string | null = null; | ||
| 112 | let metaKey = false; | ||
| 113 | let ctrlKey = false; | ||
| 114 | let shiftKey = false; | ||
| 115 | let altKey = false; | ||
| 116 | let modKey = false; | ||
| 117 | |||
| 118 | for (const token of tokens) { | ||
| 119 | switch (token) { | ||
| 120 | case "cmd": | ||
| 121 | case "meta": | ||
| 122 | metaKey = true; | ||
| 123 | break; | ||
| 124 | case "ctrl": | ||
| 125 | case "control": | ||
| 126 | ctrlKey = true; | ||
| 127 | break; | ||
| 128 | case "shift": | ||
| 129 | shiftKey = true; | ||
| 130 | break; | ||
| 131 | case "alt": | ||
| 132 | case "option": | ||
| 133 | altKey = true; | ||
| 134 | break; | ||
| 135 | case "mod": | ||
| 136 | modKey = true; | ||
| 137 | break; | ||
| 138 | default: { | ||
| 139 | if (key !== null) return null; | ||
| 140 | key = normalizeKeyToken(token); | ||
| 141 | } | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | if (key === null) return null; | ||
| 146 | return { | ||
| 147 | key, | ||
| 148 | metaKey, | ||
| 149 | ctrlKey, | ||
| 150 | shiftKey, | ||
| 151 | altKey, | ||
| 152 | modKey, | ||
| 153 | }; | ||
| 154 | } | ||
| 155 | |||
| 156 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | ||
| 157 | const tokens: WhenToken[] = []; | ||
| 158 | let index = 0; | ||
| 159 | |||
| 160 | while (index < expression.length) { | ||
| 161 | const current = expression[index]; | ||
| 162 | if (!current) break; | ||
| 163 | |||
| 164 | if (/\s/.test(current)) { | ||
| 165 | index += 1; | ||
| 166 | continue; | ||
| 167 | } | ||
| 168 | if (expression.startsWith("&&", index)) { | ||
| 169 | tokens.push({ type: "and" }); | ||
| 170 | index += 2; | ||
| 171 | continue; | ||
| 172 | } | ||
| 173 | if (expression.startsWith("||", index)) { | ||
| 174 | tokens.push({ type: "or" }); | ||
| 175 | index += 2; | ||
| 176 | continue; | ||
| 177 | } | ||
| 178 | if (current === "!") { | ||
| 179 | tokens.push({ type: "not" }); | ||
| 180 | index += 1; | ||
| 181 | continue; | ||
| 182 | } | ||
| 183 | if (current === "(") { | ||
| 184 | tokens.push({ type: "lparen" }); | ||
| 185 | index += 1; | ||
| 186 | continue; | ||
| 187 | } | ||
| 188 | if (current === ")") { | ||
| 189 | tokens.push({ type: "rparen" }); | ||
| 190 | index += 1; | ||
| 191 | continue; | ||
| 192 | } | ||
| 193 | |||
| 194 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | ||
| 195 | if (!identifier) { | ||
| 196 | return null; | ||
| 197 | } | ||
| 198 | tokens.push({ type: "identifier", value: identifier[0] }); | ||
| 199 | index += identifier[0].length; | ||
| 200 | } | ||
| 201 | |||
| 202 | return tokens; | ||
| 203 | } | ||
| 204 | |||
| 205 | function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { | ||
| 206 | const tokens = tokenizeWhenExpression(expression); | ||
| 207 | if (!tokens || tokens.length === 0) return null; | ||
| 208 | let index = 0; | ||
| 209 | |||
| 210 | const parsePrimary = (depth: number): KeybindingWhenNode | null => { | ||
| 211 | if (depth > MAX_WHEN_EXPRESSION_DEPTH) { | ||
| 212 | return null; | ||
| 213 | } | ||
| 214 | const token = tokens[index]; | ||
| 215 | if (!token) return null; | ||
| 216 | |||
| 217 | if (token.type === "identifier") { | ||
| 218 | index += 1; | ||
| 219 | return { type: "identifier", name: token.value }; | ||
| 220 | } | ||
| 221 | |||
| 222 | if (token.type === "lparen") { | ||
| 223 | index += 1; | ||
| 224 | const expressionNode = parseOr(depth + 1); | ||
| 225 | const closeToken = tokens[index]; | ||
| 226 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | ||
| 227 | return null; | ||
| 228 | } | ||
| 229 | index += 1; | ||
| 230 | return expressionNode; | ||
| 231 | } | ||
| 232 | |||
| 233 | return null; | ||
| 234 | }; | ||
| 235 | |||
| 236 | const parseUnary = (depth: number): KeybindingWhenNode | null => { | ||
| 237 | let notCount = 0; | ||
| 238 | while (tokens[index]?.type === "not") { | ||
| 239 | index += 1; | ||
| 240 | notCount += 1; | ||
| 241 | if (notCount > MAX_WHEN_EXPRESSION_DEPTH) { | ||
| 242 | return null; | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | let node = parsePrimary(depth); | ||
| 247 | if (!node) return null; | ||
| 248 | |||
| 249 | while (notCount > 0) { | ||
| 250 | node = { type: "not", node }; | ||
| 251 | notCount -= 1; | ||
| 252 | } | ||
| 253 | |||
| 254 | return node; | ||
| 255 | }; | ||
| 256 | |||
| 257 | const parseAnd = (depth: number): KeybindingWhenNode | null => { | ||
| 258 | let left = parseUnary(depth); | ||
| 259 | if (!left) return null; | ||
| 260 | |||
| 261 | while (tokens[index]?.type === "and") { | ||
| 262 | index += 1; | ||
| 263 | const right = parseUnary(depth); | ||
| 264 | if (!right) return null; | ||
| 265 | left = { type: "and", left, right }; | ||
| 266 | } | ||
| 267 | |||
| 268 | return left; | ||
| 269 | }; | ||
| 270 | |||
| 271 | const parseOr = (depth: number): KeybindingWhenNode | null => { | ||
| 272 | let left = parseAnd(depth); | ||
| 273 | if (!left) return null; | ||
| 274 | |||
| 275 | while (tokens[index]?.type === "or") { | ||
| 276 | index += 1; | ||
| 277 | const right = parseAnd(depth); | ||
| 278 | if (!right) return null; | ||
| 279 | left = { type: "or", left, right }; | ||
| 280 | } | ||
| 281 | |||
| 282 | return left; | ||
| 283 | }; | ||
| 284 | |||
| 285 | const ast = parseOr(0); | ||
| 286 | if (!ast || index !== tokens.length) return null; | ||
| 287 | return ast; | ||
| 288 | } | ||
| 289 | |||
| 290 | /** @internal - Exported for testing */ | ||
| 291 | export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { | ||
| 292 | const shortcut = parseKeybindingShortcut(rule.key); | ||
| 293 | if (!shortcut) return null; | ||
| 294 | |||
| 295 | if (rule.when !== undefined) { | ||
| 296 | const whenAst = parseKeybindingWhenExpression(rule.when); | ||
| 297 | if (!whenAst) return null; | ||
| 298 | return { | ||
| 299 | command: rule.command, | ||
| 300 | shortcut, | ||
| 301 | whenAst, | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | return { | ||
| 306 | command: rule.command, | ||
| 307 | shortcut, | ||
| 308 | }; | ||
| 309 | } | ||
| 310 | |||
| 311 | export function compileResolvedKeybindingsConfig( | ||
| 312 | config: KeybindingsConfig, | ||
| 313 | ): ResolvedKeybindingsConfig { | ||
| 314 | const compiled: Mutable<ResolvedKeybindingsConfig> = []; | ||
| 315 | for (const rule of config) { | ||
| 316 | const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule); | ||
| 317 | if (result._tag === "Success") { | ||
| 318 | compiled.push(result.value); | ||
| 319 | } | ||
| 320 | } | ||
| 321 | return compiled; | ||
| 322 | } | ||
| 323 | 61 | ||
| 324 | export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( | 62 | export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( |
| 325 | Schema.decodeTo( | 63 | Schema.decodeTo( |
| ... | @@ -412,8 +150,6 @@ function encodeWhenAst(node: KeybindingWhenNode): string { | ... | @@ -412,8 +150,6 @@ function encodeWhenAst(node: KeybindingWhenNode): string { |
| 412 | } | 150 | } |
| 413 | } | 151 | } |
| 414 | 152 | ||
| 415 | const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); | ||
| 416 | |||
| 417 | const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); | 153 | const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); |
| 418 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); | 154 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); |
| 419 | const PrettyJsonString = SchemaGetter.parseJson<string>().compose( | 155 | const PrettyJsonString = SchemaGetter.parseJson<string>().compose( |
apps/server/src/server.test.ts+35-6| ... | @@ -75,6 +75,7 @@ import { | ... | @@ -75,6 +75,7 @@ import { |
| 75 | type ProjectionSnapshotQueryShape, | 75 | type ProjectionSnapshotQueryShape, |
| 76 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 76 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 77 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; | 77 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 78 | import { PersistenceSqlError } from "./persistence/Errors.ts"; | ||
| 78 | import { | 79 | import { |
| 79 | ProviderRegistry, | 80 | ProviderRegistry, |
| 80 | type ProviderRegistryShape, | 81 | type ProviderRegistryShape, |
| ... | @@ -217,10 +218,8 @@ const browserOtlpTracingLayer = Layer.mergeAll( | ... | @@ -217,10 +218,8 @@ const browserOtlpTracingLayer = Layer.mergeAll( |
| 217 | Layer.succeed(HttpClient.TracerDisabledWhen, () => true), | 218 | Layer.succeed(HttpClient.TracerDisabledWhen, () => true), |
| 218 | ); | 219 | ); |
| 219 | 220 | ||
| 220 | const authTestLayer = ServerAuthLive.pipe( | 221 | const makeAuthTestLayer = () => |
| 221 | Layer.provide(SqlitePersistenceMemory), | 222 | ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive)); |
| 222 | Layer.provide(ServerSecretStoreLive), | ||
| 223 | ); | ||
| 224 | 223 | ||
| 225 | const makeBrowserOtlpPayload = (spanName: string) => | 224 | const makeBrowserOtlpPayload = (spanName: string) => |
| 226 | Effect.gen(function* () { | 225 | Effect.gen(function* () { |
| ... | @@ -378,6 +377,8 @@ const buildAppUnderTest = (options?: { | ... | @@ -378,6 +377,8 @@ const buildAppUnderTest = (options?: { |
| 378 | desktopBootstrapToken: defaultDesktopBootstrapToken, | 377 | desktopBootstrapToken: defaultDesktopBootstrapToken, |
| 379 | autoBootstrapProjectFromCwd: false, | 378 | autoBootstrapProjectFromCwd: false, |
| 380 | logWebSocketEvents: false, | 379 | logWebSocketEvents: false, |
| 380 | tailscaleServeEnabled: false, | ||
| 381 | tailscaleServePort: 443, | ||
| 381 | ...options?.config, | 382 | ...options?.config, |
| 382 | }; | 383 | }; |
| 383 | const layerConfig = Layer.succeed(ServerConfig, config); | 384 | const layerConfig = Layer.succeed(ServerConfig, config); |
| ... | @@ -653,7 +654,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -653,7 +654,7 @@ const buildAppUnderTest = (options?: { |
| 653 | ...options?.layers?.repositoryIdentityResolver, | 654 | ...options?.layers?.repositoryIdentityResolver, |
| 654 | }), | 655 | }), |
| 655 | ), | 656 | ), |
| 656 | Layer.provideMerge(authTestLayer), | 657 | Layer.provideMerge(makeAuthTestLayer()), |
| 657 | Layer.provide(workspaceAndProjectServicesLayer), | 658 | Layer.provide(workspaceAndProjectServicesLayer), |
| 658 | Layer.provideMerge(FetchHttpClient.layer), | 659 | Layer.provideMerge(FetchHttpClient.layer), |
| 659 | Layer.provide(layerConfig), | 660 | Layer.provide(layerConfig), |
| ... | @@ -2021,7 +2022,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2021,7 +2022,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2021 | assert.deepEqual(second, { | 2022 | assert.deepEqual(second, { |
| 2022 | version: 1, | 2023 | version: 1, |
| 2023 | type: "keybindingsUpdated", | 2024 | type: "keybindingsUpdated", |
| 2024 | payload: { issues: [] }, | 2025 | payload: { keybindings: [], issues: [] }, |
| 2025 | }); | 2026 | }); |
| 2026 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 2027 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2027 | ); | 2028 | ); |
| ... | @@ -3110,6 +3111,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3110,6 +3111,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3110 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 3111 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3111 | ); | 3112 | ); |
| 3112 | 3113 | ||
| 3114 | it.effect("routes websocket rpc orchestration shell snapshot errors", () => | ||
| 3115 | Effect.gen(function* () { | ||
| 3116 | const projectionError = new PersistenceSqlError({ | ||
| 3117 | operation: "ProjectionSnapshotQuery.getShellSnapshot:test", | ||
| 3118 | detail: "failed to read projection shell snapshot", | ||
| 3119 | }); | ||
| 3120 | yield* buildAppUnderTest({ | ||
| 3121 | layers: { | ||
| 3122 | projectionSnapshotQuery: { | ||
| 3123 | getShellSnapshot: () => Effect.fail(projectionError), | ||
| 3124 | }, | ||
| 3125 | }, | ||
| 3126 | }); | ||
| 3127 | |||
| 3128 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 3129 | const result = yield* Effect.scoped( | ||
| 3130 | withWsRpcClient(wsUrl, (client) => | ||
| 3131 | client[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe(Stream.runCollect), | ||
| 3132 | ).pipe(Effect.result), | ||
| 3133 | ); | ||
| 3134 | |||
| 3135 | assertTrue(result._tag === "Failure"); | ||
| 3136 | assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); | ||
| 3137 | assertTrue(result.failure.cause instanceof Error); | ||
| 3138 | assert.include(result.failure.cause.message, projectionError.message); | ||
| 3139 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3140 | ); | ||
| 3141 | |||
| 3113 | it.effect("enriches replayed project events with repository identity metadata", () => | 3142 | it.effect("enriches replayed project events with repository identity metadata", () => |
| 3114 | Effect.gen(function* () { | 3143 | Effect.gen(function* () { |
| 3115 | const repositoryIdentity = { | 3144 | const repositoryIdentity = { |
apps/server/src/server.ts+53| ... | @@ -83,6 +83,7 @@ import { | ... | @@ -83,6 +83,7 @@ import { |
| 83 | orchestrationSnapshotRouteLayer, | 83 | orchestrationSnapshotRouteLayer, |
| 84 | } from "./orchestration/http.ts"; | 84 | } from "./orchestration/http.ts"; |
| 85 | import { NetService } from "@t3tools/shared/Net"; | 85 | import { NetService } from "@t3tools/shared/Net"; |
| 86 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; | ||
| 86 | 87 | ||
| 87 | const PtyAdapterLive = Layer.unwrap( | 88 | const PtyAdapterLive = Layer.unwrap( |
| 88 | Effect.gen(function* () { | 89 | Effect.gen(function* () { |
| ... | @@ -334,6 +335,57 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -334,6 +335,57 @@ export const makeServerLayer = Layer.unwrap( |
| 334 | () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), | 335 | () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), |
| 335 | ), | 336 | ), |
| 336 | ); | 337 | ); |
| 338 | const tailscaleServeLayer = config.tailscaleServeEnabled | ||
| 339 | ? Layer.effectDiscard( | ||
| 340 | Effect.acquireRelease( | ||
| 341 | Effect.gen(function* () { | ||
| 342 | const server = yield* HttpServer.HttpServer; | ||
| 343 | const address = server.address; | ||
| 344 | if (typeof address === "string" || !("port" in address)) { | ||
| 345 | return null; | ||
| 346 | } | ||
| 347 | |||
| 348 | const localPort = address.port; | ||
| 349 | return yield* ensureTailscaleServe({ | ||
| 350 | localPort, | ||
| 351 | servePort: config.tailscaleServePort, | ||
| 352 | localHost: "127.0.0.1", | ||
| 353 | }).pipe( | ||
| 354 | Effect.as({ localPort, servePort: config.tailscaleServePort }), | ||
| 355 | Effect.tap(() => | ||
| 356 | Effect.logInfo("Tailscale Serve configured", { | ||
| 357 | localPort, | ||
| 358 | servePort: config.tailscaleServePort, | ||
| 359 | }), | ||
| 360 | ), | ||
| 361 | Effect.catch((cause) => | ||
| 362 | Effect.logWarning("Failed to configure Tailscale Serve", { | ||
| 363 | cause, | ||
| 364 | localPort, | ||
| 365 | servePort: config.tailscaleServePort, | ||
| 366 | }).pipe(Effect.as(null)), | ||
| 367 | ), | ||
| 368 | ); | ||
| 369 | }), | ||
| 370 | (configured) => | ||
| 371 | configured | ||
| 372 | ? disableTailscaleServe({ servePort: configured.servePort }).pipe( | ||
| 373 | Effect.tap(() => | ||
| 374 | Effect.logInfo("Tailscale Serve disabled", { | ||
| 375 | servePort: configured.servePort, | ||
| 376 | }), | ||
| 377 | ), | ||
| 378 | Effect.catch((cause) => | ||
| 379 | Effect.logWarning("Failed to disable Tailscale Serve", { | ||
| 380 | cause, | ||
| 381 | servePort: configured.servePort, | ||
| 382 | }), | ||
| 383 | ), | ||
| 384 | ) | ||
| 385 | : Effect.void, | ||
| 386 | ), | ||
| 387 | ) | ||
| 388 | : Layer.empty; | ||
| 337 | 389 | ||
| 338 | const serverApplicationLayer = Layer.mergeAll( | 390 | const serverApplicationLayer = Layer.mergeAll( |
| 339 | HttpRouter.serve(makeRoutesLayer, { | 391 | HttpRouter.serve(makeRoutesLayer, { |
| ... | @@ -341,6 +393,7 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -341,6 +393,7 @@ export const makeServerLayer = Layer.unwrap( |
| 341 | }), | 393 | }), |
| 342 | httpListeningLayer, | 394 | httpListeningLayer, |
| 343 | runtimeStateLayer, | 395 | runtimeStateLayer, |
| 396 | tailscaleServeLayer, | ||
| 344 | ); | 397 | ); |
| 345 | 398 | ||
| 346 | return serverApplicationLayer.pipe( | 399 | return serverApplicationLayer.pipe( |
apps/server/src/ws.ts+4| ... | @@ -672,6 +672,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -672,6 +672,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 672 | ORCHESTRATION_WS_METHODS.subscribeShell, | 672 | ORCHESTRATION_WS_METHODS.subscribeShell, |
| 673 | Effect.gen(function* () { | 673 | Effect.gen(function* () { |
| 674 | const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( | 674 | const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( |
| 675 | Effect.tapError((cause) => | ||
| 676 | Effect.logError("orchestration shell snapshot load failed", { cause }), | ||
| 677 | ), | ||
| 675 | Effect.mapError( | 678 | Effect.mapError( |
| 676 | (cause) => | 679 | (cause) => |
| 677 | new OrchestrationGetSnapshotError({ | 680 | new OrchestrationGetSnapshotError({ |
| ... | @@ -1017,6 +1020,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -1017,6 +1020,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 1017 | version: 1 as const, | 1020 | version: 1 as const, |
| 1018 | type: "keybindingsUpdated" as const, | 1021 | type: "keybindingsUpdated" as const, |
| 1019 | payload: { | 1022 | payload: { |
| 1023 | keybindings: event.keybindings, | ||
| 1020 | issues: event.issues, | 1024 | issues: event.issues, |
| 1021 | }, | 1025 | }, |
| 1022 | })), | 1026 | })), |
apps/web/src/branding.ts+1-1| ... | @@ -15,4 +15,4 @@ export const APP_STAGE_LABEL = | ... | @@ -15,4 +15,4 @@ export const APP_STAGE_LABEL = |
| 15 | injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); | 15 | injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); |
| 16 | export const APP_DISPLAY_NAME = | 16 | export const APP_DISPLAY_NAME = |
| 17 | injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; | 17 | injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; |
| 18 | export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; | 18 | export const APP_VERSION = "0.0.20"; // import.meta.env.APP_VERSION || "0.0.0"; |
apps/web/src/clientPersistenceStorage.test.ts+6| ... | @@ -10,6 +10,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | ... | @@ -10,6 +10,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { |
| 10 | wsBaseUrl: "wss://remote.example.com/", | 10 | wsBaseUrl: "wss://remote.example.com/", |
| 11 | createdAt: "2026-04-09T00:00:00.000Z", | 11 | createdAt: "2026-04-09T00:00:00.000Z", |
| 12 | lastConnectedAt: null, | 12 | lastConnectedAt: null, |
| 13 | desktopSsh: { | ||
| 14 | alias: "devbox", | ||
| 15 | hostname: "devbox.example.com", | ||
| 16 | username: "julius", | ||
| 17 | port: 22, | ||
| 18 | }, | ||
| 13 | }; | 19 | }; |
| 14 | 20 | ||
| 15 | function createLocalStorageStub(): Storage { | 21 | function createLocalStorageStub(): Storage { |
apps/web/src/clientPersistenceStorage.ts+14-3| ... | @@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ | ... | @@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ |
| 19 | wsBaseUrl: Schema.String, | 19 | wsBaseUrl: Schema.String, |
| 20 | createdAt: Schema.String, | 20 | createdAt: Schema.String, |
| 21 | lastConnectedAt: Schema.NullOr(Schema.String), | 21 | lastConnectedAt: Schema.NullOr(Schema.String), |
| 22 | desktopSsh: Schema.optionalKey( | ||
| 23 | Schema.Struct({ | ||
| 24 | alias: Schema.String, | ||
| 25 | hostname: Schema.String, | ||
| 26 | username: Schema.NullOr(Schema.String), | ||
| 27 | port: Schema.NullOr(Schema.Number), | ||
| 28 | }), | ||
| 29 | ), | ||
| 22 | bearerToken: Schema.optionalKey(Schema.String), | 30 | bearerToken: Schema.optionalKey(Schema.String), |
| 23 | }); | 31 | }); |
| 24 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; | 32 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; |
| ... | @@ -37,7 +45,7 @@ function hasWindow(): boolean { | ... | @@ -37,7 +45,7 @@ function hasWindow(): boolean { |
| 37 | function toPersistedSavedEnvironmentRecord( | 45 | function toPersistedSavedEnvironmentRecord( |
| 38 | record: PersistedSavedEnvironmentRecord, | 46 | record: PersistedSavedEnvironmentRecord, |
| 39 | ): PersistedSavedEnvironmentRecord { | 47 | ): PersistedSavedEnvironmentRecord { |
| 40 | return { | 48 | const nextRecord = { |
| 41 | environmentId: record.environmentId, | 49 | environmentId: record.environmentId, |
| 42 | label: record.label, | 50 | label: record.label, |
| 43 | httpBaseUrl: record.httpBaseUrl, | 51 | httpBaseUrl: record.httpBaseUrl, |
| ... | @@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord( | ... | @@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord( |
| 45 | createdAt: record.createdAt, | 53 | createdAt: record.createdAt, |
| 46 | lastConnectedAt: record.lastConnectedAt, | 54 | lastConnectedAt: record.lastConnectedAt, |
| 47 | }; | 55 | }; |
| 56 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | ||
| 48 | } | 57 | } |
| 49 | 58 | ||
| 50 | export function readBrowserClientSettings(): ClientSettings | null { | 59 | export function readBrowserClientSettings(): ClientSettings | null { |
| ... | @@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry( | ... | @@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry( |
| 135 | wsBaseUrl: record.wsBaseUrl, | 144 | wsBaseUrl: record.wsBaseUrl, |
| 136 | createdAt: record.createdAt, | 145 | createdAt: record.createdAt, |
| 137 | lastConnectedAt: record.lastConnectedAt, | 146 | lastConnectedAt: record.lastConnectedAt, |
| 147 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 138 | bearerToken, | 148 | bearerToken, |
| 139 | } | 149 | } |
| 140 | : toPersistedSavedEnvironmentRecord(record); | 150 | : toPersistedSavedEnvironmentRecord(record); |
| ... | @@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret( | ... | @@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret( |
| 166 | return record; | 176 | return record; |
| 167 | } | 177 | } |
| 168 | found = true; | 178 | found = true; |
| 169 | return { | 179 | const nextRecord = { |
| 170 | environmentId: record.environmentId, | 180 | environmentId: record.environmentId, |
| 171 | label: record.label, | 181 | label: record.label, |
| 172 | httpBaseUrl: record.httpBaseUrl, | 182 | httpBaseUrl: record.httpBaseUrl, |
| ... | @@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret( | ... | @@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret( |
| 174 | createdAt: record.createdAt, | 184 | createdAt: record.createdAt, |
| 175 | lastConnectedAt: record.lastConnectedAt, | 185 | lastConnectedAt: record.lastConnectedAt, |
| 176 | bearerToken: secret, | 186 | bearerToken: secret, |
| 177 | } satisfies BrowserSavedEnvironmentRecord; | 187 | }; |
| 188 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | ||
| 178 | }), | 189 | }), |
| 179 | }); | 190 | }); |
| 180 | return found; | 191 | return found; |
apps/web/src/components/BranchToolbar.tsx+29-13| ... | @@ -80,8 +80,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -80,8 +80,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 80 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, | 80 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, |
| 81 | [availableEnvironments, environmentId], | 81 | [availableEnvironments, environmentId], |
| 82 | ); | 82 | ); |
| 83 | const environmentLabel = activeEnvironment?.label ?? "Run on"; | ||
| 84 | const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; | ||
| 85 | const WorkspaceIcon = | 83 | const WorkspaceIcon = |
| 86 | effectiveEnvMode === "worktree" | 84 | effectiveEnvMode === "worktree" |
| 87 | ? FolderGit2Icon | 85 | ? FolderGit2Icon |
| ... | @@ -93,6 +91,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -93,6 +91,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 93 | : effectiveEnvMode === "worktree" | 91 | : effectiveEnvMode === "worktree" |
| 94 | ? resolveEnvModeLabel("worktree") | 92 | ? resolveEnvModeLabel("worktree") |
| 95 | : resolveCurrentWorkspaceLabel(activeWorktreePath); | 93 | : resolveCurrentWorkspaceLabel(activeWorktreePath); |
| 94 | const isLocked = envLocked || envModeLocked; | ||
| 95 | const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; | ||
| 96 | const icon = showEnvironmentPicker ? ( | ||
| 97 | // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px | ||
| 98 | // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. | ||
| 99 | <span className="inline-flex shrink-0 items-center gap-0.5"> | ||
| 100 | <EnvironmentIcon className="size-3 shrink-0 mx-0!" /> | ||
| 101 | <WorkspaceIcon className="size-3 shrink-0 mx-0!" /> | ||
| 102 | </span> | ||
| 103 | ) : ( | ||
| 104 | <WorkspaceIcon className="size-3 shrink-0" /> | ||
| 105 | ); | ||
| 106 | const triggerContent = ( | ||
| 107 | <> | ||
| 108 | {icon} | ||
| 109 | <span className="min-w-0 truncate"> | ||
| 110 | {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} | ||
| 111 | </span> | ||
| 112 | </> | ||
| 113 | ); | ||
| 114 | |||
| 115 | if (isLocked) { | ||
| 116 | return ( | ||
| 117 | <span className="inline-flex min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 md:hidden"> | ||
| 118 | {triggerContent} | ||
| 119 | </span> | ||
| 120 | ); | ||
| 121 | } | ||
| 96 | 122 | ||
| 97 | return ( | 123 | return ( |
| 98 | <Menu> | 124 | <Menu> |
| ... | @@ -100,17 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -100,17 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 100 | render={<Button variant="ghost" size="xs" />} | 126 | render={<Button variant="ghost" size="xs" />} |
| 101 | className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" | 127 | className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" |
| 102 | > | 128 | > |
| 103 | {showEnvironmentPicker ? ( | 129 | {triggerContent} |
| 104 | <> | ||
| 105 | <EnvironmentIcon className="size-3 shrink-0" /> | ||
| 106 | <span className="min-w-0 truncate">{environmentLabel}</span> | ||
| 107 | </> | ||
| 108 | ) : ( | ||
| 109 | <> | ||
| 110 | <WorkspaceIcon className="size-3 shrink-0" /> | ||
| 111 | <span className="min-w-0 truncate">{workspaceLabel}</span> | ||
| 112 | </> | ||
| 113 | )} | ||
| 114 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> | 130 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> |
| 115 | </MenuTrigger> | 131 | </MenuTrigger> |
| 116 | <MenuPopup align="start" side="top" className="w-64"> | 132 | <MenuPopup align="start" side="top" className="w-64"> |
apps/web/src/components/ChatView.browser.tsx+68| ... | @@ -1663,6 +1663,74 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1663,6 +1663,74 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1663 | customWsRpcResolver = null; | 1663 | customWsRpcResolver = null; |
| 1664 | document.body.innerHTML = ""; | 1664 | document.body.innerHTML = ""; |
| 1665 | }); | 1665 | }); |
| 1666 | |||
| 1667 | it("renders locked single-environment mobile run context as a static workspace label", async () => { | ||
| 1668 | const mounted = await mountChatView({ | ||
| 1669 | viewport: COMPACT_FOOTER_VIEWPORT, | ||
| 1670 | snapshot: createSnapshotForTargetUser({ | ||
| 1671 | targetMessageId: "msg-user-mobile-locked-workspace" as MessageId, | ||
| 1672 | targetText: "locked mobile workspace", | ||
| 1673 | }), | ||
| 1674 | }); | ||
| 1675 | |||
| 1676 | try { | ||
| 1677 | await waitForElement( | ||
| 1678 | () => | ||
| 1679 | Array.from(document.querySelectorAll<HTMLElement>("span")).find( | ||
| 1680 | (element) => element.textContent?.trim() === "Local checkout", | ||
| 1681 | ) ?? null, | ||
| 1682 | "Unable to find static mobile workspace label.", | ||
| 1683 | ); | ||
| 1684 | |||
| 1685 | expect(findButtonByText("Local checkout")).toBeNull(); | ||
| 1686 | } finally { | ||
| 1687 | await mounted.cleanup(); | ||
| 1688 | } | ||
| 1689 | }); | ||
| 1690 | |||
| 1691 | it("keeps dismiss-only composer banners aligned on mobile", async () => { | ||
| 1692 | const mounted = await mountChatView({ | ||
| 1693 | viewport: COMPACT_FOOTER_VIEWPORT, | ||
| 1694 | snapshot: createSnapshotForTargetUser({ | ||
| 1695 | targetMessageId: "msg-user-mobile-version-banner" as MessageId, | ||
| 1696 | targetText: "mobile version banner", | ||
| 1697 | }), | ||
| 1698 | configureFixture: (nextFixture) => { | ||
| 1699 | nextFixture.serverConfig = { | ||
| 1700 | ...nextFixture.serverConfig, | ||
| 1701 | environment: { | ||
| 1702 | ...nextFixture.serverConfig.environment, | ||
| 1703 | serverVersion: "9.9.9", | ||
| 1704 | }, | ||
| 1705 | }; | ||
| 1706 | }, | ||
| 1707 | }); | ||
| 1708 | |||
| 1709 | try { | ||
| 1710 | const banner = await waitForElement( | ||
| 1711 | () => | ||
| 1712 | Array.from(document.querySelectorAll<HTMLElement>('[data-slot="alert"]')).find( | ||
| 1713 | (element) => element.textContent?.includes("Client and server versions differ"), | ||
| 1714 | ) ?? null, | ||
| 1715 | "Unable to find version mismatch banner.", | ||
| 1716 | ); | ||
| 1717 | const title = banner.querySelector<HTMLElement>('[data-slot="alert-title"]'); | ||
| 1718 | const description = banner.querySelector<HTMLElement>('[data-slot="alert-description"]'); | ||
| 1719 | const dismissButton = banner.querySelector<HTMLButtonElement>( | ||
| 1720 | 'button[aria-label="Dismiss version mismatch warning"]', | ||
| 1721 | ); | ||
| 1722 | |||
| 1723 | expect(title).toBeTruthy(); | ||
| 1724 | expect(description).toBeTruthy(); | ||
| 1725 | expect(dismissButton).toBeTruthy(); | ||
| 1726 | expect(dismissButton!.getBoundingClientRect().top).toBeLessThan( | ||
| 1727 | description!.getBoundingClientRect().top, | ||
| 1728 | ); | ||
| 1729 | } finally { | ||
| 1730 | await mounted.cleanup(); | ||
| 1731 | } | ||
| 1732 | }); | ||
| 1733 | |||
| 1666 | it("re-expands the bootstrap project using its logical key", async () => { | 1734 | it("re-expands the bootstrap project using its logical key", async () => { |
| 1667 | useUiStateStore.setState({ | 1735 | useUiStateStore.setState({ |
| 1668 | projectExpandedById: { | 1736 | projectExpandedById: { |
apps/web/src/components/ChatView.tsx+299-76| ... | @@ -104,7 +104,7 @@ import { BranchToolbar } from "./BranchToolbar"; | ... | @@ -104,7 +104,7 @@ import { BranchToolbar } from "./BranchToolbar"; |
| 104 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 104 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 105 | import PlanSidebar from "./PlanSidebar"; | 105 | import PlanSidebar from "./PlanSidebar"; |
| 106 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 106 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 107 | import { ChevronDownIcon } from "lucide-react"; | 107 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 108 | import { cn, randomUUID } from "~/lib/utils"; | 108 | import { cn, randomUUID } from "~/lib/utils"; |
| 109 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 109 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 110 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 110 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| ... | @@ -121,6 +121,7 @@ import { resolveAppModelSelectionForInstance } from "../modelSelection"; | ... | @@ -121,6 +121,7 @@ import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 121 | import { isTerminalFocused } from "../lib/terminalFocus"; | 121 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 122 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; | 122 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; |
| 123 | import { | 123 | import { |
| 124 | reconnectSavedEnvironment, | ||
| 124 | useSavedEnvironmentRegistryStore, | 125 | useSavedEnvironmentRegistryStore, |
| 125 | useSavedEnvironmentRuntimeStore, | 126 | useSavedEnvironmentRuntimeStore, |
| 126 | } from "../environments/runtime"; | 127 | } from "../environments/runtime"; |
| ... | @@ -148,6 +149,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; | ... | @@ -148,6 +149,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 148 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 149 | import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 149 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | 150 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 150 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 151 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 152 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | ||
| 151 | import { | 153 | import { |
| 152 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 154 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 153 | buildExpiredTerminalContextToastCopy, | 155 | buildExpiredTerminalContextToastCopy, |
| ... | @@ -180,6 +182,13 @@ import { | ... | @@ -180,6 +182,13 @@ import { |
| 180 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; | 182 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 181 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; | 183 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; |
| 182 | import { RightPanelSheet } from "./RightPanelSheet"; | 184 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 185 | import { Button } from "./ui/button"; | ||
| 186 | import { | ||
| 187 | buildVersionMismatchDismissalKey, | ||
| 188 | dismissVersionMismatch, | ||
| 189 | isVersionMismatchDismissed, | ||
| 190 | resolveServerConfigVersionMismatch, | ||
| 191 | } from "../versionSkew"; | ||
| 183 | 192 | ||
| 184 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 193 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 185 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 194 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| ... | @@ -187,6 +196,11 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | ... | @@ -187,6 +196,11 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 187 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; | 196 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; |
| 188 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 197 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 189 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 198 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 199 | type EnvironmentUnavailableState = { | ||
| 200 | readonly environmentId: EnvironmentId; | ||
| 201 | readonly label: string; | ||
| 202 | readonly connectionState: "connecting" | "disconnected" | "error"; | ||
| 203 | }; | ||
| 190 | 204 | ||
| 191 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; | 205 | type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">; |
| 192 | 206 | ||
| ... | @@ -855,6 +869,78 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -855,6 +869,78 @@ export default function ChatView(props: ChatViewProps) { |
| 855 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 869 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 856 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); | 870 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); |
| 857 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); | 871 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); |
| 872 | const activeSavedEnvironmentRecord = | ||
| 873 | activeThread && activeThread.environmentId !== primaryEnvironmentId | ||
| 874 | ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null) | ||
| 875 | : null; | ||
| 876 | const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord | ||
| 877 | ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null) | ||
| 878 | : null; | ||
| 879 | const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord | ||
| 880 | ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected") | ||
| 881 | : "connected"; | ||
| 882 | const activeEnvironmentUnavailable = | ||
| 883 | activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected"; | ||
| 884 | const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null; | ||
| 885 | const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord | ||
| 886 | ? resolveEnvironmentOptionLabel({ | ||
| 887 | isPrimary: false, | ||
| 888 | environmentId: activeSavedEnvironmentRecord.environmentId, | ||
| 889 | runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null, | ||
| 890 | savedLabel: activeSavedEnvironmentRecord.label, | ||
| 891 | }) | ||
| 892 | : null; | ||
| 893 | const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => { | ||
| 894 | if ( | ||
| 895 | !activeEnvironmentUnavailable || | ||
| 896 | !activeEnvironmentUnavailableLabel || | ||
| 897 | !activeSavedEnvironmentId | ||
| 898 | ) { | ||
| 899 | return null; | ||
| 900 | } | ||
| 901 | |||
| 902 | return { | ||
| 903 | environmentId: activeSavedEnvironmentId, | ||
| 904 | label: activeEnvironmentUnavailableLabel, | ||
| 905 | connectionState: | ||
| 906 | activeSavedEnvironmentConnectionState === "connecting" || | ||
| 907 | activeSavedEnvironmentConnectionState === "error" | ||
| 908 | ? activeSavedEnvironmentConnectionState | ||
| 909 | : "disconnected", | ||
| 910 | }; | ||
| 911 | }, [ | ||
| 912 | activeEnvironmentUnavailable, | ||
| 913 | activeEnvironmentUnavailableLabel, | ||
| 914 | activeSavedEnvironmentConnectionState, | ||
| 915 | activeSavedEnvironmentId, | ||
| 916 | ]); | ||
| 917 | const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState<EnvironmentId | null>( | ||
| 918 | null, | ||
| 919 | ); | ||
| 920 | const handleReconnectActiveEnvironment = useCallback( | ||
| 921 | async (environmentId: EnvironmentId, label: string) => { | ||
| 922 | setReconnectingEnvironmentId(environmentId); | ||
| 923 | try { | ||
| 924 | await reconnectSavedEnvironment(environmentId); | ||
| 925 | toastManager.add({ | ||
| 926 | type: "success", | ||
| 927 | title: "Environment reconnected", | ||
| 928 | description: `${label} is ready.`, | ||
| 929 | }); | ||
| 930 | } catch (error) { | ||
| 931 | toastManager.add( | ||
| 932 | stackedThreadToast({ | ||
| 933 | type: "error", | ||
| 934 | title: "Could not reconnect environment", | ||
| 935 | description: error instanceof Error ? error.message : "Failed to reconnect.", | ||
| 936 | }), | ||
| 937 | ); | ||
| 938 | } finally { | ||
| 939 | setReconnectingEnvironmentId(null); | ||
| 940 | } | ||
| 941 | }, | ||
| 942 | [], | ||
| 943 | ); | ||
| 858 | const projectGroupingSettings = useSettings((settings) => ({ | 944 | const projectGroupingSettings = useSettings((settings) => ({ |
| 859 | sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, | 945 | sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, |
| 860 | sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, | 946 | sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, |
| ... | @@ -1055,6 +1141,120 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1055,6 +1141,120 @@ export default function ChatView(props: ChatViewProps) { |
| 1055 | primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId | 1141 | primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId |
| 1056 | ? primaryServerConfig | 1142 | ? primaryServerConfig |
| 1057 | : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); | 1143 | : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); |
| 1144 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); | ||
| 1145 | const versionMismatchDismissKey = | ||
| 1146 | versionMismatch && activeThread | ||
| 1147 | ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) | ||
| 1148 | : null; | ||
| 1149 | const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState<string | null>( | ||
| 1150 | null, | ||
| 1151 | ); | ||
| 1152 | const versionMismatchDismissed = | ||
| 1153 | versionMismatchDismissKey === dismissedVersionMismatchKey || | ||
| 1154 | isVersionMismatchDismissed(versionMismatchDismissKey); | ||
| 1155 | const showVersionMismatchBanner = | ||
| 1156 | versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; | ||
| 1157 | const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; | ||
| 1158 | const versionMismatchServerLabel = useMemo(() => { | ||
| 1159 | if (!hasMultipleRegisteredEnvironments || !activeThread) { | ||
| 1160 | return "server"; | ||
| 1161 | } | ||
| 1162 | |||
| 1163 | const isPrimary = activeThread.environmentId === primaryEnvironmentId; | ||
| 1164 | const savedRecord = savedEnvironmentRegistry[activeThread.environmentId]; | ||
| 1165 | const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId]; | ||
| 1166 | return `${resolveEnvironmentOptionLabel({ | ||
| 1167 | isPrimary, | ||
| 1168 | environmentId: activeThread.environmentId, | ||
| 1169 | runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null, | ||
| 1170 | savedLabel: savedRecord?.label ?? null, | ||
| 1171 | })} server`; | ||
| 1172 | }, [ | ||
| 1173 | activeThread, | ||
| 1174 | hasMultipleRegisteredEnvironments, | ||
| 1175 | primaryEnvironmentId, | ||
| 1176 | savedEnvironmentRegistry, | ||
| 1177 | savedEnvironmentRuntimeById, | ||
| 1178 | serverConfig?.environment.label, | ||
| 1179 | ]); | ||
| 1180 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | ||
| 1181 | const items: ComposerBannerStackItem[] = []; | ||
| 1182 | if (activeEnvironmentUnavailableState) { | ||
| 1183 | items.push({ | ||
| 1184 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, | ||
| 1185 | variant: | ||
| 1186 | activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", | ||
| 1187 | icon: <WifiOffIcon />, | ||
| 1188 | title: ( | ||
| 1189 | <> | ||
| 1190 | {activeEnvironmentUnavailableState.label} is{" "} | ||
| 1191 | {activeEnvironmentUnavailableState.connectionState === "connecting" | ||
| 1192 | ? "connecting" | ||
| 1193 | : "disconnected"} | ||
| 1194 | </> | ||
| 1195 | ), | ||
| 1196 | description: "Reconnect this environment before sending messages or running actions.", | ||
| 1197 | actions: ( | ||
| 1198 | <> | ||
| 1199 | <Button | ||
| 1200 | size="xs" | ||
| 1201 | disabled={ | ||
| 1202 | activeEnvironmentUnavailableState.connectionState === "connecting" || | ||
| 1203 | reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId | ||
| 1204 | } | ||
| 1205 | onClick={() => | ||
| 1206 | void handleReconnectActiveEnvironment( | ||
| 1207 | activeEnvironmentUnavailableState.environmentId, | ||
| 1208 | activeEnvironmentUnavailableState.label, | ||
| 1209 | ) | ||
| 1210 | } | ||
| 1211 | > | ||
| 1212 | {activeEnvironmentUnavailableState.connectionState === "connecting" || | ||
| 1213 | reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId | ||
| 1214 | ? "Reconnecting..." | ||
| 1215 | : "Reconnect"} | ||
| 1216 | </Button> | ||
| 1217 | <Button | ||
| 1218 | size="xs" | ||
| 1219 | variant="outline" | ||
| 1220 | onClick={() => void navigate({ to: "/settings/connections" })} | ||
| 1221 | > | ||
| 1222 | Connections | ||
| 1223 | </Button> | ||
| 1224 | </> | ||
| 1225 | ), | ||
| 1226 | }); | ||
| 1227 | } | ||
| 1228 | if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { | ||
| 1229 | items.push({ | ||
| 1230 | id: `version-mismatch:${versionMismatchDismissKey}`, | ||
| 1231 | variant: "warning", | ||
| 1232 | icon: <TriangleAlertIcon />, | ||
| 1233 | title: "Client and server versions differ", | ||
| 1234 | description: ( | ||
| 1235 | <> | ||
| 1236 | Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} | ||
| 1237 | {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. | ||
| 1238 | </> | ||
| 1239 | ), | ||
| 1240 | dismissLabel: "Dismiss version mismatch warning", | ||
| 1241 | onDismiss: () => { | ||
| 1242 | dismissVersionMismatch(versionMismatchDismissKey); | ||
| 1243 | setDismissedVersionMismatchKey(versionMismatchDismissKey); | ||
| 1244 | }, | ||
| 1245 | }); | ||
| 1246 | } | ||
| 1247 | return items; | ||
| 1248 | }, [ | ||
| 1249 | activeEnvironmentUnavailableState, | ||
| 1250 | handleReconnectActiveEnvironment, | ||
| 1251 | navigate, | ||
| 1252 | reconnectingEnvironmentId, | ||
| 1253 | showVersionMismatchBanner, | ||
| 1254 | versionMismatch, | ||
| 1255 | versionMismatchDismissKey, | ||
| 1256 | versionMismatchServerLabel, | ||
| 1257 | ]); | ||
| 1058 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; | 1258 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; |
| 1059 | const unlockedSelectedProvider = resolveSelectableProvider( | 1259 | const unlockedSelectedProvider = resolveSelectableProvider( |
| 1060 | providerStatuses, | 1260 | providerStatuses, |
| ... | @@ -2354,6 +2554,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2354,6 +2554,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2354 | const localApi = readLocalApi(); | 2554 | const localApi = readLocalApi(); |
| 2355 | if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; | 2555 | if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; |
| 2356 | 2556 | ||
| 2557 | if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { | ||
| 2558 | setThreadError( | ||
| 2559 | activeThread.id, | ||
| 2560 | `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, | ||
| 2561 | ); | ||
| 2562 | return; | ||
| 2563 | } | ||
| 2357 | if (phase === "running" || isSendBusy || isConnecting) { | 2564 | if (phase === "running" || isSendBusy || isConnecting) { |
| 2358 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); | 2565 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 2359 | return; | 2566 | return; |
| ... | @@ -2389,6 +2596,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2389,6 +2596,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2389 | }, | 2596 | }, |
| 2390 | [ | 2597 | [ |
| 2391 | activeThread, | 2598 | activeThread, |
| 2599 | activeEnvironmentUnavailable, | ||
| 2600 | activeEnvironmentUnavailableLabel, | ||
| 2392 | environmentId, | 2601 | environmentId, |
| 2393 | isConnecting, | 2602 | isConnecting, |
| 2394 | isRevertingCheckpoint, | 2603 | isRevertingCheckpoint, |
| ... | @@ -2401,7 +2610,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2401,7 +2610,15 @@ export default function ChatView(props: ChatViewProps) { |
| 2401 | const onSend = async (e?: { preventDefault: () => void }) => { | 2610 | const onSend = async (e?: { preventDefault: () => void }) => { |
| 2402 | e?.preventDefault(); | 2611 | e?.preventDefault(); |
| 2403 | const api = readEnvironmentApi(environmentId); | 2612 | const api = readEnvironmentApi(environmentId); |
| 2404 | if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; | 2613 | if ( |
| 2614 | !api || | ||
| 2615 | !activeThread || | ||
| 2616 | isSendBusy || | ||
| 2617 | isConnecting || | ||
| 2618 | activeEnvironmentUnavailable || | ||
| 2619 | sendInFlightRef.current | ||
| 2620 | ) | ||
| 2621 | return; | ||
| 2405 | if (activePendingProgress) { | 2622 | if (activePendingProgress) { |
| 2406 | onAdvanceActivePendingUserInput(); | 2623 | onAdvanceActivePendingUserInput(); |
| 2407 | return; | 2624 | return; |
| ... | @@ -3022,6 +3239,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3022,6 +3239,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3022 | !isServerThread || | 3239 | !isServerThread || |
| 3023 | isSendBusy || | 3240 | isSendBusy || |
| 3024 | isConnecting || | 3241 | isConnecting || |
| 3242 | activeEnvironmentUnavailable || | ||
| 3025 | sendInFlightRef.current | 3243 | sendInFlightRef.current |
| 3026 | ) { | 3244 | ) { |
| 3027 | return; | 3245 | return; |
| ... | @@ -3136,6 +3354,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3136,6 +3354,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3136 | activeThreadBranch, | 3354 | activeThreadBranch, |
| 3137 | activeThread, | 3355 | activeThread, |
| 3138 | beginLocalDispatch, | 3356 | beginLocalDispatch, |
| 3357 | activeEnvironmentUnavailable, | ||
| 3139 | isConnecting, | 3358 | isConnecting, |
| 3140 | isSendBusy, | 3359 | isSendBusy, |
| 3141 | isServerThread, | 3360 | isServerThread, |
| ... | @@ -3382,74 +3601,82 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3382,74 +3601,82 @@ export default function ChatView(props: ChatViewProps) { |
| 3382 | : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", | 3601 | : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", |
| 3383 | )} | 3602 | )} |
| 3384 | > | 3603 | > |
| 3385 | <ChatComposer | 3604 | <div className="relative isolate"> |
| 3386 | ref={composerRef} | 3605 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> |
| 3387 | composerDraftTarget={composerDraftTarget} | 3606 | <div className="relative z-10"> |
| 3388 | environmentId={environmentId} | 3607 | <ChatComposer |
| 3389 | routeKind={routeKind} | 3608 | ref={composerRef} |
| 3390 | routeThreadRef={routeThreadRef} | 3609 | composerDraftTarget={composerDraftTarget} |
| 3391 | draftId={draftId} | 3610 | environmentId={environmentId} |
| 3392 | activeThreadId={activeThreadId} | 3611 | routeKind={routeKind} |
| 3393 | activeThreadEnvironmentId={activeThread?.environmentId} | 3612 | routeThreadRef={routeThreadRef} |
| 3394 | activeThread={activeThread} | 3613 | draftId={draftId} |
| 3395 | isServerThread={isServerThread} | 3614 | activeThreadId={activeThreadId} |
| 3396 | isLocalDraftThread={isLocalDraftThread} | 3615 | activeThreadEnvironmentId={activeThread?.environmentId} |
| 3397 | phase={phase} | 3616 | activeThread={activeThread} |
| 3398 | isConnecting={isConnecting} | 3617 | isServerThread={isServerThread} |
| 3399 | isSendBusy={isSendBusy} | 3618 | isLocalDraftThread={isLocalDraftThread} |
| 3400 | isPreparingWorktree={isPreparingWorktree} | 3619 | phase={phase} |
| 3401 | activePendingApproval={activePendingApproval} | 3620 | isConnecting={isConnecting} |
| 3402 | pendingApprovals={pendingApprovals} | 3621 | isSendBusy={isSendBusy} |
| 3403 | pendingUserInputs={pendingUserInputs} | 3622 | isPreparingWorktree={isPreparingWorktree} |
| 3404 | activePendingProgress={activePendingProgress} | 3623 | environmentUnavailable={activeEnvironmentUnavailableState} |
| 3405 | activePendingResolvedAnswers={activePendingResolvedAnswers} | 3624 | activePendingApproval={activePendingApproval} |
| 3406 | activePendingIsResponding={activePendingIsResponding} | 3625 | pendingApprovals={pendingApprovals} |
| 3407 | activePendingDraftAnswers={activePendingDraftAnswers} | 3626 | pendingUserInputs={pendingUserInputs} |
| 3408 | activePendingQuestionIndex={activePendingQuestionIndex} | 3627 | activePendingProgress={activePendingProgress} |
| 3409 | respondingRequestIds={respondingRequestIds} | 3628 | activePendingResolvedAnswers={activePendingResolvedAnswers} |
| 3410 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} | 3629 | activePendingIsResponding={activePendingIsResponding} |
| 3411 | activeProposedPlan={activeProposedPlan} | 3630 | activePendingDraftAnswers={activePendingDraftAnswers} |
| 3412 | activePlan={activePlan as { turnId?: TurnId } | null} | 3631 | activePendingQuestionIndex={activePendingQuestionIndex} |
| 3413 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | 3632 | respondingRequestIds={respondingRequestIds} |
| 3414 | planSidebarLabel={planSidebarLabel} | 3633 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} |
| 3415 | planSidebarOpen={planSidebarOpen} | 3634 | activeProposedPlan={activeProposedPlan} |
| 3416 | runtimeMode={runtimeMode} | 3635 | activePlan={activePlan as { turnId?: TurnId } | null} |
| 3417 | interactionMode={interactionMode} | 3636 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} |
| 3418 | lockedProvider={lockedProvider} | 3637 | planSidebarLabel={planSidebarLabel} |
| 3419 | providerStatuses={providerStatuses as ServerProvider[]} | 3638 | planSidebarOpen={planSidebarOpen} |
| 3420 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} | 3639 | runtimeMode={runtimeMode} |
| 3421 | activeThreadModelSelection={activeThread?.modelSelection} | 3640 | interactionMode={interactionMode} |
| 3422 | activeThreadActivities={activeThread?.activities} | 3641 | lockedProvider={lockedProvider} |
| 3423 | resolvedTheme={resolvedTheme} | 3642 | providerStatuses={providerStatuses as ServerProvider[]} |
| 3424 | settings={settings} | 3643 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} |
| 3425 | keybindings={keybindings} | 3644 | activeThreadModelSelection={activeThread?.modelSelection} |
| 3426 | terminalOpen={Boolean(terminalState.terminalOpen)} | 3645 | activeThreadActivities={activeThread?.activities} |
| 3427 | gitCwd={gitCwd} | 3646 | resolvedTheme={resolvedTheme} |
| 3428 | promptRef={promptRef} | 3647 | settings={settings} |
| 3429 | composerImagesRef={composerImagesRef} | 3648 | keybindings={keybindings} |
| 3430 | composerTerminalContextsRef={composerTerminalContextsRef} | 3649 | terminalOpen={Boolean(terminalState.terminalOpen)} |
| 3431 | shouldAutoScrollRef={isAtEndRef} | 3650 | gitCwd={gitCwd} |
| 3432 | scheduleStickToBottom={scrollToEnd} | 3651 | promptRef={promptRef} |
| 3433 | onSend={onSend} | 3652 | composerImagesRef={composerImagesRef} |
| 3434 | onInterrupt={onInterrupt} | 3653 | composerTerminalContextsRef={composerTerminalContextsRef} |
| 3435 | onImplementPlanInNewThread={onImplementPlanInNewThread} | 3654 | shouldAutoScrollRef={isAtEndRef} |
| 3436 | onRespondToApproval={onRespondToApproval} | 3655 | scheduleStickToBottom={scrollToEnd} |
| 3437 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} | 3656 | onSend={onSend} |
| 3438 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} | 3657 | onInterrupt={onInterrupt} |
| 3439 | onPreviousActivePendingUserInputQuestion={onPreviousActivePendingUserInputQuestion} | 3658 | onImplementPlanInNewThread={onImplementPlanInNewThread} |
| 3440 | onChangeActivePendingUserInputCustomAnswer={ | 3659 | onRespondToApproval={onRespondToApproval} |
| 3441 | onChangeActivePendingUserInputCustomAnswer | 3660 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} |
| 3442 | } | 3661 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} |
| 3443 | onProviderModelSelect={onProviderModelSelect} | 3662 | onPreviousActivePendingUserInputQuestion={ |
| 3444 | toggleInteractionMode={toggleInteractionMode} | 3663 | onPreviousActivePendingUserInputQuestion |
| 3445 | handleRuntimeModeChange={handleRuntimeModeChange} | 3664 | } |
| 3446 | handleInteractionModeChange={handleInteractionModeChange} | 3665 | onChangeActivePendingUserInputCustomAnswer={ |
| 3447 | togglePlanSidebar={togglePlanSidebar} | 3666 | onChangeActivePendingUserInputCustomAnswer |
| 3448 | focusComposer={focusComposer} | 3667 | } |
| 3449 | scheduleComposerFocus={scheduleComposerFocus} | 3668 | onProviderModelSelect={onProviderModelSelect} |
| 3450 | setThreadError={setThreadError} | 3669 | toggleInteractionMode={toggleInteractionMode} |
| 3451 | onExpandImage={onExpandTimelineImage} | 3670 | handleRuntimeModeChange={handleRuntimeModeChange} |
| 3452 | /> | 3671 | handleInteractionModeChange={handleInteractionModeChange} |
| 3672 | togglePlanSidebar={togglePlanSidebar} | ||
| 3673 | focusComposer={focusComposer} | ||
| 3674 | scheduleComposerFocus={scheduleComposerFocus} | ||
| 3675 | setThreadError={setThreadError} | ||
| 3676 | onExpandImage={onExpandTimelineImage} | ||
| 3677 | /> | ||
| 3678 | </div> | ||
| 3679 | </div> | ||
| 3453 | {isGitRepo && ( | 3680 | {isGitRepo && ( |
| 3454 | <BranchToolbar | 3681 | <BranchToolbar |
| 3455 | environmentId={activeThread.environmentId} | 3682 | environmentId={activeThread.environmentId} |
| ... | @@ -3468,12 +3695,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3468,12 +3695,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3468 | {...(canCheckoutPullRequestIntoThread | 3695 | {...(canCheckoutPullRequestIntoThread |
| 3469 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | 3696 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } |
| 3470 | : {})} | 3697 | : {})} |
| 3471 | {...(hasMultipleEnvironments | 3698 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} |
| 3472 | ? { | 3699 | availableEnvironments={logicalProjectEnvironments} |
| 3473 | availableEnvironments: logicalProjectEnvironments, | ||
| 3474 | onEnvironmentChange, | ||
| 3475 | } | ||
| 3476 | : {})} | ||
| 3477 | /> | 3700 | /> |
| 3478 | )} | 3701 | )} |
| 3479 | </div> | 3702 | </div> |
apps/web/src/components/KeybindingsToast.browser.tsx+1-1| ... | @@ -289,7 +289,7 @@ function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { | ... | @@ -289,7 +289,7 @@ function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { |
| 289 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { | 289 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { |
| 290 | version: 1, | 290 | version: 1, |
| 291 | type: "keybindingsUpdated", | 291 | type: "keybindingsUpdated", |
| 292 | payload: { issues }, | 292 | payload: { keybindings: fixture.serverConfig.keybindings, issues }, |
| 293 | }); | 293 | }); |
| 294 | } | 294 | } |
| 295 | 295 |
apps/web/src/components/ProjectFavicon.tsx+20-6| ... | @@ -10,15 +10,29 @@ export function ProjectFavicon(input: { | ... | @@ -10,15 +10,29 @@ export function ProjectFavicon(input: { |
| 10 | cwd: string; | 10 | cwd: string; |
| 11 | className?: string; | 11 | className?: string; |
| 12 | }) { | 12 | }) { |
| 13 | const src = resolveEnvironmentHttpUrl({ | 13 | const src = (() => { |
| 14 | environmentId: input.environmentId, | 14 | try { |
| 15 | pathname: "/api/project-favicon", | 15 | return resolveEnvironmentHttpUrl({ |
| 16 | searchParams: { cwd: input.cwd }, | 16 | environmentId: input.environmentId, |
| 17 | }); | 17 | pathname: "/api/project-favicon", |
| 18 | searchParams: { cwd: input.cwd }, | ||
| 19 | }); | ||
| 20 | } catch { | ||
| 21 | return null; | ||
| 22 | } | ||
| 23 | })(); | ||
| 18 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | 24 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 19 | loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", | 25 | src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", |
| 20 | ); | 26 | ); |
| 21 | 27 | ||
| 28 | if (!src) { | ||
| 29 | return ( | ||
| 30 | <FolderIcon | ||
| 31 | className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`} | ||
| 32 | /> | ||
| 33 | ); | ||
| 34 | } | ||
| 35 | |||
| 22 | return ( | 36 | return ( |
| 23 | <> | 37 | <> |
| 24 | {status !== "loaded" ? ( | 38 | {status !== "loaded" ? ( |
apps/web/src/components/WebSocketConnectionSurface.logic.test.ts+1| ... | @@ -8,6 +8,7 @@ function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionSt | ... | @@ -8,6 +8,7 @@ function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionSt |
| 8 | attemptCount: 0, | 8 | attemptCount: 0, |
| 9 | closeCode: null, | 9 | closeCode: null, |
| 10 | closeReason: null, | 10 | closeReason: null, |
| 11 | connectionLabel: null, | ||
| 11 | connectedAt: null, | 12 | connectedAt: null, |
| 12 | disconnectedAt: null, | 13 | disconnectedAt: null, |
| 13 | hasConnected: false, | 14 | hasConnected: false, |
apps/web/src/components/WebSocketConnectionSurface.tsx+12-4| ... | @@ -53,8 +53,16 @@ function describeExhaustedToast(): string { | ... | @@ -53,8 +53,16 @@ function describeExhaustedToast(): string { |
| 53 | return "Retries exhausted trying to reconnect"; | 53 | return "Retries exhausted trying to reconnect"; |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | function buildReconnectTitle(_status: WsConnectionStatus): string { | 56 | function getConnectionDisplayName(status: WsConnectionStatus): string { |
| 57 | return "Disconnected from T3 Server"; | 57 | return status.connectionLabel?.trim() || "T3 Server"; |
| 58 | } | ||
| 59 | |||
| 60 | function buildReconnectTitle(status: WsConnectionStatus): string { | ||
| 61 | return `Disconnected from ${getConnectionDisplayName(status)}`; | ||
| 62 | } | ||
| 63 | |||
| 64 | function buildRecoveredTitle(status: WsConnectionStatus): string { | ||
| 65 | return `Reconnected to ${getConnectionDisplayName(status)}`; | ||
| 58 | } | 66 | } |
| 59 | 67 | ||
| 60 | function describeRecoveredToast( | 68 | function describeRecoveredToast( |
| ... | @@ -297,7 +305,7 @@ export function WebSocketConnectionCoordinator() { | ... | @@ -297,7 +305,7 @@ export function WebSocketConnectionCoordinator() { |
| 297 | }, | 305 | }, |
| 298 | description: describeExhaustedToast(), | 306 | description: describeExhaustedToast(), |
| 299 | timeout: 0, | 307 | timeout: 0, |
| 300 | title: "Disconnected from T3 Server", | 308 | title: buildReconnectTitle(status), |
| 301 | type: "error", | 309 | type: "error", |
| 302 | }) | 310 | }) |
| 303 | : stackedThreadToast({ | 311 | : stackedThreadToast({ |
| ... | @@ -334,7 +342,7 @@ export function WebSocketConnectionCoordinator() { | ... | @@ -334,7 +342,7 @@ export function WebSocketConnectionCoordinator() { |
| 334 | ) { | 342 | ) { |
| 335 | const successToast = { | 343 | const successToast = { |
| 336 | description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), | 344 | description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), |
| 337 | title: "Reconnected to T3 Server", | 345 | title: buildRecoveredTitle(status), |
| 338 | type: "success" as const, | 346 | type: "success" as const, |
| 339 | timeout: 0, | 347 | timeout: 0, |
| 340 | data: { | 348 | data: { |
apps/web/src/components/auth/PairingRouteSurface.tsx+123| ... | @@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts"; | ... | @@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts"; |
| 2 | import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; | 2 | import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; |
| 3 | 3 | ||
| 4 | import { APP_DISPLAY_NAME } from "../../branding"; | 4 | import { APP_DISPLAY_NAME } from "../../branding"; |
| 5 | import { addSavedEnvironment } from "../../environments/runtime"; | ||
| 5 | import { | 6 | import { |
| 6 | peekPairingTokenFromUrl, | 7 | peekPairingTokenFromUrl, |
| 7 | stripPairingTokenFromUrl, | 8 | stripPairingTokenFromUrl, |
| 8 | submitServerAuthCredential, | 9 | submitServerAuthCredential, |
| 9 | } from "../../environments/primary"; | 10 | } from "../../environments/primary"; |
| 11 | import { readHostedPairingRequest } from "../../hostedPairing"; | ||
| 10 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| 11 | import { Input } from "../ui/input"; | 13 | import { Input } from "../ui/input"; |
| 12 | 14 | ||
| ... | @@ -159,6 +161,127 @@ export function PairingRouteSurface({ | ... | @@ -159,6 +161,127 @@ export function PairingRouteSurface({ |
| 159 | ); | 161 | ); |
| 160 | } | 162 | } |
| 161 | 163 | ||
| 164 | export function HostedPairingRouteSurface() { | ||
| 165 | const hostedPairingRequestRef = useRef(readHostedPairingRequest()); | ||
| 166 | const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => | ||
| 167 | hostedPairingRequestRef.current ? "pairing" : "error", | ||
| 168 | ); | ||
| 169 | const [message, setMessage] = useState(() => | ||
| 170 | hostedPairingRequestRef.current | ||
| 171 | ? "Connecting to this backend." | ||
| 172 | : "This pairing link is missing its backend host or token.", | ||
| 173 | ); | ||
| 174 | const [canRetry, setCanRetry] = useState(false); | ||
| 175 | const submitAttemptedRef = useRef(false); | ||
| 176 | const tokenSubmittedRef = useRef(false); | ||
| 177 | |||
| 178 | const submitHostedPairingRequest = useCallback(async () => { | ||
| 179 | const request = hostedPairingRequestRef.current; | ||
| 180 | |||
| 181 | if (!request) { | ||
| 182 | setStatus("error"); | ||
| 183 | setMessage("This pairing link is missing its backend host or token."); | ||
| 184 | setCanRetry(false); | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | |||
| 188 | if (tokenSubmittedRef.current) { | ||
| 189 | setStatus("error"); | ||
| 190 | setMessage("This one-time pairing token was already submitted. Request a new pairing link."); | ||
| 191 | setCanRetry(false); | ||
| 192 | return; | ||
| 193 | } | ||
| 194 | |||
| 195 | setStatus("pairing"); | ||
| 196 | setMessage("Connecting to this backend."); | ||
| 197 | setCanRetry(false); | ||
| 198 | tokenSubmittedRef.current = true; | ||
| 199 | |||
| 200 | try { | ||
| 201 | const record = await addSavedEnvironment({ | ||
| 202 | label: request.label, | ||
| 203 | host: request.host, | ||
| 204 | pairingCode: request.token, | ||
| 205 | }); | ||
| 206 | setStatus("paired"); | ||
| 207 | setMessage(`${record.label} is saved in this browser.`); | ||
| 208 | } catch (error) { | ||
| 209 | tokenSubmittedRef.current = false; | ||
| 210 | setStatus("error"); | ||
| 211 | setCanRetry(true); | ||
| 212 | setMessage( | ||
| 213 | `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, | ||
| 214 | ); | ||
| 215 | } | ||
| 216 | }, []); | ||
| 217 | |||
| 218 | useEffect(() => { | ||
| 219 | if (submitAttemptedRef.current) { | ||
| 220 | return; | ||
| 221 | } | ||
| 222 | submitAttemptedRef.current = true; | ||
| 223 | |||
| 224 | stripPairingTokenFromUrl(); | ||
| 225 | void submitHostedPairingRequest(); | ||
| 226 | }, [submitHostedPairingRequest]); | ||
| 227 | |||
| 228 | const request = hostedPairingRequestRef.current; | ||
| 229 | |||
| 230 | return ( | ||
| 231 | <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6"> | ||
| 232 | <div className="pointer-events-none absolute inset-0 opacity-80"> | ||
| 233 | <div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-emerald-500)_14%,transparent),transparent)]" /> | ||
| 234 | <div className="absolute inset-y-0 left-0 w-72 bg-[radial-gradient(28rem_18rem_at_left,color-mix(in_srgb,var(--color-sky-500)_10%,transparent),transparent)]" /> | ||
| 235 | <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_90%,var(--color-black))_0%,var(--background)_55%)]" /> | ||
| 236 | </div> | ||
| 237 | |||
| 238 | <section className="relative w-full max-w-xl rounded-2xl border border-border/80 bg-card/90 p-6 shadow-2xl shadow-black/20 backdrop-blur-md sm:p-8"> | ||
| 239 | <p className="text-[11px] font-semibold tracking-[0.18em] text-muted-foreground uppercase"> | ||
| 240 | {APP_DISPLAY_NAME} | ||
| 241 | </p> | ||
| 242 | <h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl"> | ||
| 243 | {status === "paired" | ||
| 244 | ? "Backend paired" | ||
| 245 | : status === "error" | ||
| 246 | ? "Pairing failed" | ||
| 247 | : "Pairing backend"} | ||
| 248 | </h1> | ||
| 249 | <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{message}</p> | ||
| 250 | |||
| 251 | {request ? ( | ||
| 252 | <div className="mt-5 rounded-lg border border-border/70 bg-background/55 px-3 py-3 text-xs leading-relaxed text-muted-foreground"> | ||
| 253 | Host: <span className="font-mono text-foreground/80">{request.host}</span> | ||
| 254 | </div> | ||
| 255 | ) : null} | ||
| 256 | |||
| 257 | {status === "error" ? ( | ||
| 258 | <div className="mt-5 rounded-lg border border-destructive/30 bg-destructive/6 px-3 py-2 text-sm text-destructive"> | ||
| 259 | Verify the backend is reachable from this browser, supports CORS for hosted clients, and | ||
| 260 | is served over HTTPS when opening this page from HTTPS. | ||
| 261 | </div> | ||
| 262 | ) : null} | ||
| 263 | |||
| 264 | <div className="mt-6 flex flex-wrap gap-2"> | ||
| 265 | {status === "pairing" ? ( | ||
| 266 | <Button disabled size="sm"> | ||
| 267 | Pairing... | ||
| 268 | </Button> | ||
| 269 | ) : canRetry ? ( | ||
| 270 | <Button size="sm" onClick={() => void submitHostedPairingRequest()}> | ||
| 271 | Try again | ||
| 272 | </Button> | ||
| 273 | ) : null} | ||
| 274 | {status === "paired" ? ( | ||
| 275 | <Button size="sm" variant="outline" onClick={() => (window.location.href = "/")}> | ||
| 276 | Open app | ||
| 277 | </Button> | ||
| 278 | ) : null} | ||
| 279 | </div> | ||
| 280 | </section> | ||
| 281 | </div> | ||
| 282 | ); | ||
| 283 | } | ||
| 284 | |||
| 162 | function errorMessageFromUnknown(error: unknown): string { | 285 | function errorMessageFromUnknown(error: unknown): string { |
| 163 | if (error instanceof Error && error.message.trim().length > 0) { | 286 | if (error instanceof Error && error.message.trim().length > 0) { |
| 164 | return error.message; | 287 | return error.message; |
apps/web/src/components/chat/ChatComposer.tsx+25-4| ... | @@ -301,6 +301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -301,6 +301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 301 | promptHasText: boolean; | 301 | promptHasText: boolean; |
| 302 | isSendBusy: boolean; | 302 | isSendBusy: boolean; |
| 303 | isConnecting: boolean; | 303 | isConnecting: boolean; |
| 304 | isEnvironmentUnavailable: boolean; | ||
| 304 | hasSendableContent: boolean; | 305 | hasSendableContent: boolean; |
| 305 | preserveComposerFocusOnPointerDown?: boolean; | 306 | preserveComposerFocusOnPointerDown?: boolean; |
| 306 | onPreviousPendingQuestion: () => void; | 307 | onPreviousPendingQuestion: () => void; |
| ... | @@ -321,6 +322,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -321,6 +322,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 321 | promptHasText={props.promptHasText} | 322 | promptHasText={props.promptHasText} |
| 322 | isSendBusy={props.isSendBusy} | 323 | isSendBusy={props.isSendBusy} |
| 323 | isConnecting={props.isConnecting} | 324 | isConnecting={props.isConnecting} |
| 325 | isEnvironmentUnavailable={props.isEnvironmentUnavailable} | ||
| 324 | isPreparingWorktree={props.isPreparingWorktree} | 326 | isPreparingWorktree={props.isPreparingWorktree} |
| 325 | hasSendableContent={props.hasSendableContent} | 327 | hasSendableContent={props.hasSendableContent} |
| 326 | preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} | 328 | preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} |
| ... | @@ -393,6 +395,10 @@ export interface ChatComposerProps { | ... | @@ -393,6 +395,10 @@ export interface ChatComposerProps { |
| 393 | isConnecting: boolean; | 395 | isConnecting: boolean; |
| 394 | isSendBusy: boolean; | 396 | isSendBusy: boolean; |
| 395 | isPreparingWorktree: boolean; | 397 | isPreparingWorktree: boolean; |
| 398 | environmentUnavailable: { | ||
| 399 | readonly label: string; | ||
| 400 | readonly connectionState: "connecting" | "disconnected" | "error"; | ||
| 401 | } | null; | ||
| 396 | 402 | ||
| 397 | // Pending approvals / inputs | 403 | // Pending approvals / inputs |
| 398 | activePendingApproval: PendingApproval | null; | 404 | activePendingApproval: PendingApproval | null; |
| ... | @@ -500,6 +506,7 @@ export const ChatComposer = memo( | ... | @@ -500,6 +506,7 @@ export const ChatComposer = memo( |
| 500 | isConnecting, | 506 | isConnecting, |
| 501 | isSendBusy, | 507 | isSendBusy, |
| 502 | isPreparingWorktree, | 508 | isPreparingWorktree, |
| 509 | environmentUnavailable, | ||
| 503 | activePendingApproval, | 510 | activePendingApproval, |
| 504 | pendingApprovals, | 511 | pendingApprovals, |
| 505 | pendingUserInputs, | 512 | pendingUserInputs, |
| ... | @@ -1959,6 +1966,7 @@ export const ChatComposer = memo( | ... | @@ -1959,6 +1966,7 @@ export const ChatComposer = memo( |
| 1959 | className={cn( | 1966 | className={cn( |
| 1960 | "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", | 1967 | "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", |
| 1961 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", | 1968 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", |
| 1969 | environmentUnavailable ? "opacity-75" : null, | ||
| 1962 | composerProviderState.composerSurfaceClassName, | 1970 | composerProviderState.composerSurfaceClassName, |
| 1963 | )} | 1971 | )} |
| 1964 | onFocusCapture={(event) => { | 1972 | onFocusCapture={(event) => { |
| ... | @@ -2070,6 +2078,7 @@ export const ChatComposer = memo( | ... | @@ -2070,6 +2078,7 @@ export const ChatComposer = memo( |
| 2070 | promptHasText={false} | 2078 | promptHasText={false} |
| 2071 | isSendBusy={isSendBusy} | 2079 | isSendBusy={isSendBusy} |
| 2072 | isConnecting={isConnecting} | 2080 | isConnecting={isConnecting} |
| 2081 | isEnvironmentUnavailable={environmentUnavailable !== null} | ||
| 2073 | isPreparingWorktree={false} | 2082 | isPreparingWorktree={false} |
| 2074 | hasSendableContent={false} | 2083 | hasSendableContent={false} |
| 2075 | preserveComposerFocusOnPointerDown | 2084 | preserveComposerFocusOnPointerDown |
| ... | @@ -2250,11 +2259,21 @@ export const ChatComposer = memo( | ... | @@ -2250,11 +2259,21 @@ export const ChatComposer = memo( |
| 2250 | ? "Type your own answer, or leave this blank to use the selected option" | 2259 | ? "Type your own answer, or leave this blank to use the selected option" |
| 2251 | : showPlanFollowUpPrompt && activeProposedPlan | 2260 | : showPlanFollowUpPrompt && activeProposedPlan |
| 2252 | ? "Add feedback to refine the plan, or leave this blank to implement it" | 2261 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| 2253 | : phase === "disconnected" | 2262 | : environmentUnavailable |
| 2254 | ? "Ask for follow-up changes or attach images" | 2263 | ? `${environmentUnavailable.label} is ${ |
| 2255 | : "Ask anything, @tag files/folders, or use / to show available commands" | 2264 | environmentUnavailable.connectionState === "connecting" |
| 2265 | ? "connecting" | ||
| 2266 | : "disconnected" | ||
| 2267 | }` | ||
| 2268 | : phase === "disconnected" | ||
| 2269 | ? "Ask for follow-up changes or attach images" | ||
| 2270 | : "Ask anything, @tag files/folders, or use / to show available commands" | ||
| 2271 | } | ||
| 2272 | disabled={ | ||
| 2273 | isConnecting || | ||
| 2274 | isComposerApprovalState || | ||
| 2275 | (environmentUnavailable !== null && activePendingProgress === null) | ||
| 2256 | } | 2276 | } |
| 2257 | disabled={isConnecting || isComposerApprovalState} | ||
| 2258 | /> | 2277 | /> |
| 2259 | {showMobilePendingAnswerActions ? ( | 2278 | {showMobilePendingAnswerActions ? ( |
| 2260 | <div | 2279 | <div |
| ... | @@ -2269,6 +2288,7 @@ export const ChatComposer = memo( | ... | @@ -2269,6 +2288,7 @@ export const ChatComposer = memo( |
| 2269 | promptHasText={false} | 2288 | promptHasText={false} |
| 2270 | isSendBusy={isSendBusy} | 2289 | isSendBusy={isSendBusy} |
| 2271 | isConnecting={isConnecting} | 2290 | isConnecting={isConnecting} |
| 2291 | isEnvironmentUnavailable={environmentUnavailable !== null} | ||
| 2272 | isPreparingWorktree={false} | 2292 | isPreparingWorktree={false} |
| 2273 | hasSendableContent={false} | 2293 | hasSendableContent={false} |
| 2274 | preserveComposerFocusOnPointerDown | 2294 | preserveComposerFocusOnPointerDown |
| ... | @@ -2384,6 +2404,7 @@ export const ChatComposer = memo( | ... | @@ -2384,6 +2404,7 @@ export const ChatComposer = memo( |
| 2384 | promptHasText={prompt.trim().length > 0} | 2404 | promptHasText={prompt.trim().length > 0} |
| 2385 | isSendBusy={isSendBusy} | 2405 | isSendBusy={isSendBusy} |
| 2386 | isConnecting={isConnecting} | 2406 | isConnecting={isConnecting} |
| 2407 | isEnvironmentUnavailable={environmentUnavailable !== null} | ||
| 2387 | isPreparingWorktree={isPreparingWorktree} | 2408 | isPreparingWorktree={isPreparingWorktree} |
| 2388 | hasSendableContent={composerSendState.hasSendableContent} | 2409 | hasSendableContent={composerSendState.hasSendableContent} |
| 2389 | preserveComposerFocusOnPointerDown={isMobileViewport} | 2410 | preserveComposerFocusOnPointerDown={isMobileViewport} |
apps/web/src/components/chat/ChatHeader.test.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { shouldShowOpenInPicker } from "./ChatHeader"; | ||
| 5 | |||
| 6 | describe("shouldShowOpenInPicker", () => { | ||
| 7 | const primaryEnvironmentId = EnvironmentId.make("environment-primary"); | ||
| 8 | |||
| 9 | it("shows the picker for projects in the primary environment", () => { | ||
| 10 | expect( | ||
| 11 | shouldShowOpenInPicker({ | ||
| 12 | activeProjectName: "codething-mvp", | ||
| 13 | activeThreadEnvironmentId: primaryEnvironmentId, | ||
| 14 | primaryEnvironmentId, | ||
| 15 | }), | ||
| 16 | ).toBe(true); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("hides the picker when hosted static mode has no primary environment", () => { | ||
| 20 | expect( | ||
| 21 | shouldShowOpenInPicker({ | ||
| 22 | activeProjectName: "codething-mvp", | ||
| 23 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), | ||
| 24 | primaryEnvironmentId: null, | ||
| 25 | }), | ||
| 26 | ).toBe(false); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("hides the picker for remote environments", () => { | ||
| 30 | expect( | ||
| 31 | shouldShowOpenInPicker({ | ||
| 32 | activeProjectName: "codething-mvp", | ||
| 33 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), | ||
| 34 | primaryEnvironmentId, | ||
| 35 | }), | ||
| 36 | ).toBe(false); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("hides the picker when there is no active project", () => { | ||
| 40 | expect( | ||
| 41 | shouldShowOpenInPicker({ | ||
| 42 | activeProjectName: undefined, | ||
| 43 | activeThreadEnvironmentId: primaryEnvironmentId, | ||
| 44 | primaryEnvironmentId, | ||
| 45 | }), | ||
| 46 | ).toBe(false); | ||
| 47 | }); | ||
| 48 | }); | ||
apps/web/src/components/chat/ChatHeader.tsx+18-3| ... | @@ -44,6 +44,18 @@ interface ChatHeaderProps { | ... | @@ -44,6 +44,18 @@ interface ChatHeaderProps { |
| 44 | onToggleDiff: () => void; | 44 | onToggleDiff: () => void; |
| 45 | } | 45 | } |
| 46 | 46 | ||
| 47 | export function shouldShowOpenInPicker(input: { | ||
| 48 | readonly activeProjectName: string | undefined; | ||
| 49 | readonly activeThreadEnvironmentId: EnvironmentId; | ||
| 50 | readonly primaryEnvironmentId: EnvironmentId | null; | ||
| 51 | }): boolean { | ||
| 52 | return ( | ||
| 53 | Boolean(input.activeProjectName) && | ||
| 54 | input.primaryEnvironmentId !== null && | ||
| 55 | input.activeThreadEnvironmentId === input.primaryEnvironmentId | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 47 | export const ChatHeader = memo(function ChatHeader({ | 59 | export const ChatHeader = memo(function ChatHeader({ |
| 48 | activeThreadEnvironmentId, | 60 | activeThreadEnvironmentId, |
| 49 | activeThreadId, | 61 | activeThreadId, |
| ... | @@ -70,8 +82,11 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -70,8 +82,11 @@ export const ChatHeader = memo(function ChatHeader({ |
| 70 | onToggleDiff, | 82 | onToggleDiff, |
| 71 | }: ChatHeaderProps) { | 83 | }: ChatHeaderProps) { |
| 72 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 84 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 73 | const isRemoteEnvironment = | 85 | const showOpenInPicker = shouldShowOpenInPicker({ |
| 74 | primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId; | 86 | activeProjectName, |
| 87 | activeThreadEnvironmentId, | ||
| 88 | primaryEnvironmentId, | ||
| 89 | }); | ||
| 75 | 90 | ||
| 76 | return ( | 91 | return ( |
| 77 | <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2"> | 92 | <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2"> |
| ... | @@ -106,7 +121,7 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -106,7 +121,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 106 | onDeleteScript={onDeleteProjectScript} | 121 | onDeleteScript={onDeleteProjectScript} |
| 107 | /> | 122 | /> |
| 108 | )} | 123 | )} |
| 109 | {activeProjectName && !isRemoteEnvironment && ( | 124 | {showOpenInPicker && ( |
| 110 | <OpenInPicker | 125 | <OpenInPicker |
| 111 | keybindings={keybindings} | 126 | keybindings={keybindings} |
| 112 | availableEditors={availableEditors} | 127 | availableEditors={availableEditors} |
apps/web/src/components/chat/ComposerBannerStack.tsx created+195| ... | @@ -0,0 +1,195 @@ | ||
| 1 | import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; | ||
| 2 | import { XIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import { cn } from "~/lib/utils"; | ||
| 5 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; | ||
| 6 | import { Button } from "../ui/button"; | ||
| 7 | |||
| 8 | const DISMISS_TRANSITION_MS = 220; | ||
| 9 | const frontExitStyle = { | ||
| 10 | opacity: 0, | ||
| 11 | transform: "translate3d(0, 4rem, 0)", | ||
| 12 | } satisfies CSSProperties; | ||
| 13 | const stackedExitStyle = { | ||
| 14 | opacity: 0, | ||
| 15 | transform: "translate3d(0, 7rem, 0)", | ||
| 16 | } satisfies CSSProperties; | ||
| 17 | const restingStyle = { | ||
| 18 | opacity: 1, | ||
| 19 | transform: "translate3d(0, 0, 0)", | ||
| 20 | } satisfies CSSProperties; | ||
| 21 | const exitTransitionStyle = { | ||
| 22 | transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, | ||
| 23 | willChange: "transform, opacity", | ||
| 24 | } satisfies CSSProperties; | ||
| 25 | |||
| 26 | export interface ComposerBannerStackItem { | ||
| 27 | readonly id: string; | ||
| 28 | readonly variant: "error" | "info" | "success" | "warning"; | ||
| 29 | readonly icon: ReactNode; | ||
| 30 | readonly title: ReactNode; | ||
| 31 | readonly description?: ReactNode; | ||
| 32 | readonly actions?: ReactNode; | ||
| 33 | readonly dismissLabel?: string; | ||
| 34 | readonly onDismiss?: () => void; | ||
| 35 | } | ||
| 36 | |||
| 37 | interface ComposerBannerStackProps { | ||
| 38 | readonly className?: string; | ||
| 39 | readonly items: ReadonlyArray<ComposerBannerStackItem>; | ||
| 40 | } | ||
| 41 | |||
| 42 | export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { | ||
| 43 | const [exitingItemId, setExitingItemId] = useState<string | null>(null); | ||
| 44 | const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 45 | |||
| 46 | useEffect(() => { | ||
| 47 | if (exitingItemId && !items.some((item) => item.id === exitingItemId)) { | ||
| 48 | setExitingItemId(null); | ||
| 49 | } | ||
| 50 | }, [exitingItemId, items]); | ||
| 51 | |||
| 52 | useEffect(() => { | ||
| 53 | return () => { | ||
| 54 | if (dismissTimeoutRef.current) { | ||
| 55 | clearTimeout(dismissTimeoutRef.current); | ||
| 56 | } | ||
| 57 | }; | ||
| 58 | }, []); | ||
| 59 | |||
| 60 | if (items.length === 0) { | ||
| 61 | return null; | ||
| 62 | } | ||
| 63 | |||
| 64 | const frontItem = items[0]; | ||
| 65 | if (!frontItem) { | ||
| 66 | return null; | ||
| 67 | } | ||
| 68 | const stackedItems = items.slice(1); | ||
| 69 | const hasStack = stackedItems.length > 0; | ||
| 70 | const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; | ||
| 71 | |||
| 72 | const requestDismiss = (item: ComposerBannerStackItem) => { | ||
| 73 | if (!item.onDismiss || exitingItemId) { | ||
| 74 | return; | ||
| 75 | } | ||
| 76 | setExitingItemId(item.id); | ||
| 77 | if (dismissTimeoutRef.current) { | ||
| 78 | clearTimeout(dismissTimeoutRef.current); | ||
| 79 | } | ||
| 80 | dismissTimeoutRef.current = setTimeout(() => { | ||
| 81 | dismissTimeoutRef.current = null; | ||
| 82 | item.onDismiss?.(); | ||
| 83 | }, DISMISS_TRANSITION_MS); | ||
| 84 | }; | ||
| 85 | |||
| 86 | return ( | ||
| 87 | <div className={cn("group/banner-stack mx-auto mb-2 max-w-208", className)}> | ||
| 88 | <div | ||
| 89 | className={cn( | ||
| 90 | "relative", | ||
| 91 | hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null, | ||
| 92 | )} | ||
| 93 | > | ||
| 94 | {showCollapsedStackCap ? ( | ||
| 95 | <div | ||
| 96 | className={cn( | ||
| 97 | "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-xl", | ||
| 98 | "border border-b-0 border-warning/24 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]", | ||
| 99 | "transition-opacity duration-150 ease-out", | ||
| 100 | "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0", | ||
| 101 | )} | ||
| 102 | style={{ width: "96%" }} | ||
| 103 | aria-hidden="true" | ||
| 104 | /> | ||
| 105 | ) : null} | ||
| 106 | <div | ||
| 107 | className={cn( | ||
| 108 | "relative z-10", | ||
| 109 | exitingItemId === frontItem.id ? "pointer-events-none" : null, | ||
| 110 | )} | ||
| 111 | style={{ | ||
| 112 | ...exitTransitionStyle, | ||
| 113 | ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle), | ||
| 114 | }} | ||
| 115 | > | ||
| 116 | <ComposerBannerStackAlert | ||
| 117 | item={frontItem} | ||
| 118 | exiting={exitingItemId === frontItem.id} | ||
| 119 | onDismissRequest={() => requestDismiss(frontItem)} | ||
| 120 | /> | ||
| 121 | </div> | ||
| 122 | {hasStack ? ( | ||
| 123 | <div | ||
| 124 | className={cn( | ||
| 125 | "pointer-events-none absolute inset-x-0 bottom-[calc(100%+0.5rem)] z-20 space-y-2 opacity-0", | ||
| 126 | "transition-[opacity,transform] duration-150 ease-out", | ||
| 127 | "translate-y-1 transform-gpu will-change-[opacity,transform]", | ||
| 128 | "group-hover/banner-stack:pointer-events-auto group-hover/banner-stack:translate-y-0 group-hover/banner-stack:opacity-100", | ||
| 129 | "group-focus-within/banner-stack:pointer-events-auto group-focus-within/banner-stack:translate-y-0 group-focus-within/banner-stack:opacity-100", | ||
| 130 | )} | ||
| 131 | > | ||
| 132 | {stackedItems.map((item) => ( | ||
| 133 | <div | ||
| 134 | key={item.id} | ||
| 135 | className={cn(exitingItemId === item.id ? "pointer-events-none" : null)} | ||
| 136 | style={{ | ||
| 137 | ...exitTransitionStyle, | ||
| 138 | ...(exitingItemId === item.id ? stackedExitStyle : restingStyle), | ||
| 139 | }} | ||
| 140 | > | ||
| 141 | <ComposerBannerStackAlert | ||
| 142 | item={item} | ||
| 143 | exiting={exitingItemId === item.id} | ||
| 144 | onDismissRequest={() => requestDismiss(item)} | ||
| 145 | /> | ||
| 146 | </div> | ||
| 147 | ))} | ||
| 148 | </div> | ||
| 149 | ) : null} | ||
| 150 | </div> | ||
| 151 | </div> | ||
| 152 | ); | ||
| 153 | } | ||
| 154 | |||
| 155 | function ComposerBannerStackAlert({ | ||
| 156 | item, | ||
| 157 | exiting, | ||
| 158 | onDismissRequest, | ||
| 159 | }: { | ||
| 160 | readonly item: ComposerBannerStackItem; | ||
| 161 | readonly exiting: boolean; | ||
| 162 | readonly onDismissRequest: () => void; | ||
| 163 | }) { | ||
| 164 | const dismissOnly = item.onDismiss && !item.actions; | ||
| 165 | |||
| 166 | return ( | ||
| 167 | <Alert variant={item.variant}> | ||
| 168 | {item.icon} | ||
| 169 | <AlertTitle>{item.title}</AlertTitle> | ||
| 170 | {item.description ? <AlertDescription>{item.description}</AlertDescription> : null} | ||
| 171 | {item.actions || item.onDismiss ? ( | ||
| 172 | <AlertAction | ||
| 173 | className={ | ||
| 174 | dismissOnly | ||
| 175 | ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start" | ||
| 176 | : undefined | ||
| 177 | } | ||
| 178 | > | ||
| 179 | {item.actions} | ||
| 180 | {item.onDismiss ? ( | ||
| 181 | <Button | ||
| 182 | size="icon-xs" | ||
| 183 | variant="ghost" | ||
| 184 | aria-label={item.dismissLabel ?? "Dismiss warning"} | ||
| 185 | disabled={exiting} | ||
| 186 | onClick={onDismissRequest} | ||
| 187 | > | ||
| 188 | <XIcon className="size-3.5" /> | ||
| 189 | </Button> | ||
| 190 | ) : null} | ||
| 191 | </AlertAction> | ||
| 192 | ) : null} | ||
| 193 | </Alert> | ||
| 194 | ); | ||
| 195 | } | ||
apps/web/src/components/chat/ComposerPrimaryActions.tsx+17-12| ... | @@ -20,6 +20,7 @@ interface ComposerPrimaryActionsProps { | ... | @@ -20,6 +20,7 @@ interface ComposerPrimaryActionsProps { |
| 20 | promptHasText: boolean; | 20 | promptHasText: boolean; |
| 21 | isSendBusy: boolean; | 21 | isSendBusy: boolean; |
| 22 | isConnecting: boolean; | 22 | isConnecting: boolean; |
| 23 | isEnvironmentUnavailable: boolean; | ||
| 23 | isPreparingWorktree: boolean; | 24 | isPreparingWorktree: boolean; |
| 24 | hasSendableContent: boolean; | 25 | hasSendableContent: boolean; |
| 25 | preserveComposerFocusOnPointerDown?: boolean; | 26 | preserveComposerFocusOnPointerDown?: boolean; |
| ... | @@ -58,6 +59,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -58,6 +59,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 58 | promptHasText, | 59 | promptHasText, |
| 59 | isSendBusy, | 60 | isSendBusy, |
| 60 | isConnecting, | 61 | isConnecting, |
| 62 | isEnvironmentUnavailable, | ||
| 61 | isPreparingWorktree, | 63 | isPreparingWorktree, |
| 62 | hasSendableContent, | 64 | hasSendableContent, |
| 63 | preserveComposerFocusOnPointerDown = false, | 65 | preserveComposerFocusOnPointerDown = false, |
| ... | @@ -104,6 +106,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -104,6 +106,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 104 | className={cn("rounded-full", compact ? "px-3" : "px-4")} | 106 | className={cn("rounded-full", compact ? "px-3" : "px-4")} |
| 105 | {...pointerFocusProps} | 107 | {...pointerFocusProps} |
| 106 | disabled={ | 108 | disabled={ |
| 109 | isEnvironmentUnavailable || | ||
| 107 | pendingAction.isResponding || | 110 | pendingAction.isResponding || |
| 108 | (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) | 111 | (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) |
| 109 | } | 112 | } |
| ... | @@ -143,7 +146,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -143,7 +146,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 143 | size="sm" | 146 | size="sm" |
| 144 | className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} | 147 | className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} |
| 145 | {...pointerFocusProps} | 148 | {...pointerFocusProps} |
| 146 | disabled={isSendBusy || isConnecting} | 149 | disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} |
| 147 | > | 150 | > |
| 148 | {isConnecting || isSendBusy ? "Sending..." : "Refine"} | 151 | {isConnecting || isSendBusy ? "Sending..." : "Refine"} |
| 149 | </Button> | 152 | </Button> |
| ... | @@ -157,7 +160,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -157,7 +160,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 157 | size="sm" | 160 | size="sm" |
| 158 | className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" | 161 | className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" |
| 159 | {...pointerFocusProps} | 162 | {...pointerFocusProps} |
| 160 | disabled={isSendBusy || isConnecting} | 163 | disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} |
| 161 | > | 164 | > |
| 162 | {isConnecting || isSendBusy ? "Sending..." : "Implement"} | 165 | {isConnecting || isSendBusy ? "Sending..." : "Implement"} |
| 163 | </Button> | 166 | </Button> |
| ... | @@ -170,7 +173,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -170,7 +173,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 170 | className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" | 173 | className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" |
| 171 | aria-label="Implementation actions" | 174 | aria-label="Implementation actions" |
| 172 | {...pointerFocusProps} | 175 | {...pointerFocusProps} |
| 173 | disabled={isSendBusy || isConnecting} | 176 | disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} |
| 174 | /> | 177 | /> |
| 175 | } | 178 | } |
| 176 | > | 179 | > |
| ... | @@ -178,7 +181,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -178,7 +181,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 178 | </MenuTrigger> | 181 | </MenuTrigger> |
| 179 | <MenuPopup align="end" side="top"> | 182 | <MenuPopup align="end" side="top"> |
| 180 | <MenuItem | 183 | <MenuItem |
| 181 | disabled={isSendBusy || isConnecting} | 184 | disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} |
| 182 | onClick={() => void onImplementPlanInNewThread()} | 185 | onClick={() => void onImplementPlanInNewThread()} |
| 183 | > | 186 | > |
| 184 | Implement in a new thread | 187 | Implement in a new thread |
| ... | @@ -194,15 +197,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -194,15 +197,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 194 | type="submit" | 197 | type="submit" |
| 195 | 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" | 198 | 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" |
| 196 | {...pointerFocusProps} | 199 | {...pointerFocusProps} |
| 197 | disabled={isSendBusy || isConnecting || !hasSendableContent} | 200 | disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} |
| 198 | aria-label={ | 201 | aria-label={ |
| 199 | isConnecting | 202 | isEnvironmentUnavailable |
| 200 | ? "Connecting" | 203 | ? "Environment disconnected" |
| 201 | : isPreparingWorktree | 204 | : isConnecting |
| 202 | ? "Preparing worktree" | 205 | ? "Connecting" |
| 203 | : isSendBusy | 206 | : isPreparingWorktree |
| 204 | ? "Sending" | 207 | ? "Preparing worktree" |
| 205 | : "Send message" | 208 | : isSendBusy |
| 209 | ? "Sending" | ||
| 210 | : "Send message" | ||
| 206 | } | 211 | } |
| 207 | > | 212 | > |
| 208 | {isConnecting || isSendBusy ? ( | 213 | {isConnecting || isSendBusy ? ( |
apps/web/src/components/desktop/SshPasswordPromptDialog.tsx created+221| ... | @@ -0,0 +1,221 @@ | ||
| 1 | import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; | ||
| 2 | import { useEffect, useId, useRef, useState } from "react"; | ||
| 3 | |||
| 4 | import { Button } from "../ui/button"; | ||
| 5 | import { | ||
| 6 | Dialog, | ||
| 7 | DialogDescription, | ||
| 8 | DialogFooter, | ||
| 9 | DialogHeader, | ||
| 10 | DialogPanel, | ||
| 11 | DialogPopup, | ||
| 12 | DialogTitle, | ||
| 13 | } from "../ui/dialog"; | ||
| 14 | import { Input } from "../ui/input"; | ||
| 15 | |||
| 16 | function describeSshTarget(request: DesktopSshPasswordPromptRequest): string { | ||
| 17 | return request.username ? `${request.username}@${request.destination}` : request.destination; | ||
| 18 | } | ||
| 19 | |||
| 20 | function formatRemainingSeconds(seconds: number): string { | ||
| 21 | return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; | ||
| 22 | } | ||
| 23 | |||
| 24 | function getPromptErrorMessage(error: unknown): string { | ||
| 25 | const message = error instanceof Error ? error.message : "SSH password prompt failed."; | ||
| 26 | return message.includes("expired") || message.includes("no longer pending") | ||
| 27 | ? "This SSH password prompt expired. Try connecting again." | ||
| 28 | : message; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function SshPasswordPromptDialog() { | ||
| 32 | const [queue, setQueue] = useState<readonly DesktopSshPasswordPromptRequest[]>([]); | ||
| 33 | const [password, setPassword] = useState(""); | ||
| 34 | const [isResponding, setIsResponding] = useState(false); | ||
| 35 | const [now, setNow] = useState(() => Date.now()); | ||
| 36 | const [responseError, setResponseError] = useState<string | null>(null); | ||
| 37 | const currentRequest = queue[0] ?? null; | ||
| 38 | const inputRef = useRef<HTMLInputElement | null>(null); | ||
| 39 | const isRespondingRef = useRef(false); | ||
| 40 | const formId = useId(); | ||
| 41 | |||
| 42 | useEffect(() => { | ||
| 43 | const bridge = window.desktopBridge; | ||
| 44 | if (!bridge?.onSshPasswordPrompt) { | ||
| 45 | return; | ||
| 46 | } | ||
| 47 | |||
| 48 | return bridge.onSshPasswordPrompt((request) => { | ||
| 49 | setQueue((currentQueue) => [...currentQueue, request]); | ||
| 50 | }); | ||
| 51 | }, []); | ||
| 52 | |||
| 53 | useEffect(() => { | ||
| 54 | setPassword(""); | ||
| 55 | setResponseError(null); | ||
| 56 | if (!currentRequest) { | ||
| 57 | return; | ||
| 58 | } | ||
| 59 | |||
| 60 | setNow(Date.now()); | ||
| 61 | const frame = window.requestAnimationFrame(() => { | ||
| 62 | inputRef.current?.focus(); | ||
| 63 | inputRef.current?.select(); | ||
| 64 | }); | ||
| 65 | return () => { | ||
| 66 | window.cancelAnimationFrame(frame); | ||
| 67 | }; | ||
| 68 | }, [currentRequest]); | ||
| 69 | |||
| 70 | useEffect(() => { | ||
| 71 | if (!currentRequest) { | ||
| 72 | return; | ||
| 73 | } | ||
| 74 | |||
| 75 | const interval = window.setInterval(() => { | ||
| 76 | setNow(Date.now()); | ||
| 77 | }, 1_000); | ||
| 78 | return () => { | ||
| 79 | window.clearInterval(interval); | ||
| 80 | }; | ||
| 81 | }, [currentRequest]); | ||
| 82 | |||
| 83 | const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN; | ||
| 84 | const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null; | ||
| 85 | const isExpired = remainingMs !== null && remainingMs <= 0; | ||
| 86 | const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000); | ||
| 87 | const remainingLabel = | ||
| 88 | remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds); | ||
| 89 | |||
| 90 | useEffect(() => { | ||
| 91 | if (isExpired) { | ||
| 92 | setResponseError("This SSH password prompt expired. Try connecting again."); | ||
| 93 | } | ||
| 94 | }, [isExpired]); | ||
| 95 | |||
| 96 | const removeCurrentPrompt = (requestId: string) => { | ||
| 97 | setQueue((currentQueue) => | ||
| 98 | currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue, | ||
| 99 | ); | ||
| 100 | setPassword(""); | ||
| 101 | setResponseError(null); | ||
| 102 | }; | ||
| 103 | |||
| 104 | const respond = async (nextPassword: string | null) => { | ||
| 105 | if (!currentRequest || isRespondingRef.current) { | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | |||
| 109 | const requestId = currentRequest.requestId; | ||
| 110 | if (nextPassword !== null && isExpired) { | ||
| 111 | setResponseError("This SSH password prompt expired. Try connecting again."); | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | |||
| 115 | isRespondingRef.current = true; | ||
| 116 | setIsResponding(true); | ||
| 117 | setResponseError(null); | ||
| 118 | try { | ||
| 119 | await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); | ||
| 120 | removeCurrentPrompt(requestId); | ||
| 121 | } catch (error) { | ||
| 122 | if (nextPassword === null) { | ||
| 123 | removeCurrentPrompt(requestId); | ||
| 124 | } else { | ||
| 125 | setResponseError(getPromptErrorMessage(error)); | ||
| 126 | } | ||
| 127 | } finally { | ||
| 128 | isRespondingRef.current = false; | ||
| 129 | setIsResponding(false); | ||
| 130 | } | ||
| 131 | }; | ||
| 132 | |||
| 133 | const dismissExpiredPrompt = () => { | ||
| 134 | if (currentRequest) { | ||
| 135 | removeCurrentPrompt(currentRequest.requestId); | ||
| 136 | } | ||
| 137 | }; | ||
| 138 | |||
| 139 | const cancelPrompt = () => { | ||
| 140 | if (isExpired) { | ||
| 141 | dismissExpiredPrompt(); | ||
| 142 | return; | ||
| 143 | } | ||
| 144 | void respond(null); | ||
| 145 | }; | ||
| 146 | |||
| 147 | const target = currentRequest ? describeSshTarget(currentRequest) : null; | ||
| 148 | |||
| 149 | return ( | ||
| 150 | <Dialog | ||
| 151 | open={currentRequest !== null} | ||
| 152 | onOpenChange={(open) => { | ||
| 153 | if (!open) { | ||
| 154 | cancelPrompt(); | ||
| 155 | } | ||
| 156 | }} | ||
| 157 | > | ||
| 158 | <DialogPopup className="max-w-md" showCloseButton={false}> | ||
| 159 | <DialogHeader> | ||
| 160 | <DialogTitle>SSH Password Required</DialogTitle> | ||
| 161 | <DialogDescription> | ||
| 162 | T3 needs your SSH password to connect to{" "} | ||
| 163 | {target ? <code>{target}</code> : "the remote host"}. The password is passed to the | ||
| 164 | local SSH process for this connection attempt and is not saved by T3 Code. | ||
| 165 | </DialogDescription> | ||
| 166 | </DialogHeader> | ||
| 167 | <DialogPanel className="space-y-3" scrollFade={false}> | ||
| 168 | <form | ||
| 169 | className="space-y-3" | ||
| 170 | id={formId} | ||
| 171 | onSubmit={(event) => { | ||
| 172 | event.preventDefault(); | ||
| 173 | void respond(password); | ||
| 174 | }} | ||
| 175 | > | ||
| 176 | <div className="space-y-2"> | ||
| 177 | <div className="flex items-center justify-between gap-3"> | ||
| 178 | <p className="text-sm font-medium text-foreground">{currentRequest?.prompt}</p> | ||
| 179 | {remainingLabel ? ( | ||
| 180 | <span | ||
| 181 | className={ | ||
| 182 | isExpired | ||
| 183 | ? "shrink-0 text-xs font-medium text-destructive" | ||
| 184 | : "shrink-0 text-xs text-muted-foreground" | ||
| 185 | } | ||
| 186 | > | ||
| 187 | {isExpired ? "Expired" : remainingLabel} | ||
| 188 | </span> | ||
| 189 | ) : null} | ||
| 190 | </div> | ||
| 191 | <Input | ||
| 192 | ref={inputRef} | ||
| 193 | autoComplete="current-password" | ||
| 194 | disabled={isResponding || isExpired} | ||
| 195 | name="ssh-password" | ||
| 196 | type="password" | ||
| 197 | value={password} | ||
| 198 | onChange={(event) => setPassword(event.target.value)} | ||
| 199 | /> | ||
| 200 | </div> | ||
| 201 | {responseError ? ( | ||
| 202 | <p className="text-sm text-destructive">{responseError}</p> | ||
| 203 | ) : ( | ||
| 204 | <p className="text-sm text-muted-foreground"> | ||
| 205 | Use SSH keys to avoid repeated password prompts on new SSH sessions. | ||
| 206 | </p> | ||
| 207 | )} | ||
| 208 | </form> | ||
| 209 | </DialogPanel> | ||
| 210 | <DialogFooter> | ||
| 211 | <Button disabled={isResponding} type="button" variant="outline" onClick={cancelPrompt}> | ||
| 212 | {isExpired ? "Dismiss" : "Cancel"} | ||
| 213 | </Button> | ||
| 214 | <Button disabled={isResponding || isExpired} form={formId} type="submit"> | ||
| 215 | Continue | ||
| 216 | </Button> | ||
| 217 | </DialogFooter> | ||
| 218 | </DialogPopup> | ||
| 219 | </Dialog> | ||
| 220 | ); | ||
| 221 | } | ||
apps/web/src/components/settings/ConnectionsSettings.tsx+1636-293| ... | @@ -1,8 +1,28 @@ | ... | @@ -1,8 +1,28 @@ |
| 1 | import { PlusIcon, QrCodeIcon } from "lucide-react"; | 1 | import { |
| 2 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | 2 | ChevronDownIcon, |
| 3 | ChevronsLeftRightEllipsisIcon, | ||
| 4 | PlusIcon, | ||
| 5 | QrCodeIcon, | ||
| 6 | RefreshCwIcon, | ||
| 7 | TerminalIcon, | ||
| 8 | TriangleAlertIcon, | ||
| 9 | } from "lucide-react"; | ||
| 10 | import { | ||
| 11 | type ReactNode, | ||
| 12 | memo, | ||
| 13 | useCallback, | ||
| 14 | useEffect, | ||
| 15 | useLayoutEffect, | ||
| 16 | useMemo, | ||
| 17 | useRef, | ||
| 18 | useState, | ||
| 19 | } from "react"; | ||
| 3 | import { | 20 | import { |
| 4 | type AuthClientSession, | 21 | type AuthClientSession, |
| 5 | type AuthPairingLink, | 22 | type AuthPairingLink, |
| 23 | type AdvertisedEndpoint, | ||
| 24 | type DesktopDiscoveredSshHost, | ||
| 25 | type DesktopSshEnvironmentTarget, | ||
| 6 | type DesktopServerExposureState, | 26 | type DesktopServerExposureState, |
| 7 | type EnvironmentId, | 27 | type EnvironmentId, |
| 8 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| ... | @@ -11,6 +31,7 @@ import { DateTime } from "effect"; | ... | @@ -11,6 +31,7 @@ import { DateTime } from "effect"; |
| 11 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 31 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 12 | import { cn } from "../../lib/utils"; | 32 | import { cn } from "../../lib/utils"; |
| 13 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; | 33 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 34 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; | ||
| 14 | import { | 35 | import { |
| 15 | SettingsPageContainer, | 36 | SettingsPageContainer, |
| 16 | SettingsRow, | 37 | SettingsRow, |
| ... | @@ -20,6 +41,7 @@ import { | ... | @@ -20,6 +41,7 @@ import { |
| 20 | import { Input } from "../ui/input"; | 41 | import { Input } from "../ui/input"; |
| 21 | import { | 42 | import { |
| 22 | Dialog, | 43 | Dialog, |
| 44 | DialogClose, | ||
| 23 | DialogFooter, | 45 | DialogFooter, |
| 24 | DialogDescription, | 46 | DialogDescription, |
| 25 | DialogHeader, | 47 | DialogHeader, |
| ... | @@ -28,6 +50,7 @@ import { | ... | @@ -28,6 +50,7 @@ import { |
| 28 | DialogTitle, | 50 | DialogTitle, |
| 29 | DialogTrigger, | 51 | DialogTrigger, |
| 30 | } from "../ui/dialog"; | 52 | } from "../ui/dialog"; |
| 53 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 31 | import { | 54 | import { |
| 32 | AlertDialog, | 55 | AlertDialog, |
| 33 | AlertDialogClose, | 56 | AlertDialogClose, |
| ... | @@ -44,8 +67,19 @@ import { Switch } from "../ui/switch"; | ... | @@ -44,8 +67,19 @@ import { Switch } from "../ui/switch"; |
| 44 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 67 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 68 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 46 | import { Button } from "../ui/button"; | 69 | import { Button } from "../ui/button"; |
| 70 | import { Group, GroupSeparator } from "../ui/group"; | ||
| 71 | import { | ||
| 72 | Menu, | ||
| 73 | MenuGroup, | ||
| 74 | MenuGroupLabel, | ||
| 75 | MenuItem, | ||
| 76 | MenuPopup, | ||
| 77 | MenuSeparator, | ||
| 78 | MenuTrigger, | ||
| 79 | } from "../ui/menu"; | ||
| 47 | import { Textarea } from "../ui/textarea"; | 80 | import { Textarea } from "../ui/textarea"; |
| 48 | import { setPairingTokenOnUrl } from "../../pairingUrl"; | 81 | import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; |
| 82 | import { readHostedPairingRequest } from "../../hostedPairing"; | ||
| 49 | import { | 83 | import { |
| 50 | createServerPairingCredential, | 84 | createServerPairingCredential, |
| 51 | fetchSessionState, | 85 | fetchSessionState, |
| ... | @@ -63,10 +97,17 @@ import { | ... | @@ -63,10 +97,17 @@ import { |
| 63 | useSavedEnvironmentRegistryStore, | 97 | useSavedEnvironmentRegistryStore, |
| 64 | useSavedEnvironmentRuntimeStore, | 98 | useSavedEnvironmentRuntimeStore, |
| 65 | addSavedEnvironment, | 99 | addSavedEnvironment, |
| 100 | connectDesktopSshEnvironment, | ||
| 101 | disconnectSavedEnvironment, | ||
| 66 | getPrimaryEnvironmentConnection, | 102 | getPrimaryEnvironmentConnection, |
| 67 | reconnectSavedEnvironment, | 103 | reconnectSavedEnvironment, |
| 68 | removeSavedEnvironment, | 104 | removeSavedEnvironment, |
| 69 | } from "~/environments/runtime"; | 105 | } from "~/environments/runtime"; |
| 106 | import { useUiStateStore } from "~/uiStateStore"; | ||
| 107 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; | ||
| 108 | import { useServerConfig } from "~/rpc/serverState"; | ||
| 109 | |||
| 110 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; | ||
| 70 | 111 | ||
| 71 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { | 112 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { |
| 72 | dateStyle: "medium", | 113 | dateStyle: "medium", |
| ... | @@ -135,6 +176,62 @@ function ConnectionStatusDot({ | ... | @@ -135,6 +176,62 @@ function ConnectionStatusDot({ |
| 135 | ); | 176 | ); |
| 136 | } | 177 | } |
| 137 | 178 | ||
| 179 | function AnimatedHeight({ children }: { readonly children: ReactNode }) { | ||
| 180 | const contentRef = useRef<HTMLDivElement>(null); | ||
| 181 | const [height, setHeight] = useState<number | null>(null); | ||
| 182 | |||
| 183 | useLayoutEffect(() => { | ||
| 184 | const element = contentRef.current; | ||
| 185 | if (!element) return; | ||
| 186 | let firstFrameId: number | null = null; | ||
| 187 | let secondFrameId: number | null = null; | ||
| 188 | |||
| 189 | const updateHeight = () => { | ||
| 190 | const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height); | ||
| 191 | setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); | ||
| 192 | }; | ||
| 193 | const cancelPendingFrames = () => { | ||
| 194 | if (firstFrameId !== null) { | ||
| 195 | window.cancelAnimationFrame(firstFrameId); | ||
| 196 | firstFrameId = null; | ||
| 197 | } | ||
| 198 | if (secondFrameId !== null) { | ||
| 199 | window.cancelAnimationFrame(secondFrameId); | ||
| 200 | secondFrameId = null; | ||
| 201 | } | ||
| 202 | }; | ||
| 203 | const updateHeightAfterPaint = () => { | ||
| 204 | cancelPendingFrames(); | ||
| 205 | updateHeight(); | ||
| 206 | firstFrameId = window.requestAnimationFrame(() => { | ||
| 207 | firstFrameId = null; | ||
| 208 | updateHeight(); | ||
| 209 | secondFrameId = window.requestAnimationFrame(() => { | ||
| 210 | secondFrameId = null; | ||
| 211 | updateHeight(); | ||
| 212 | }); | ||
| 213 | }); | ||
| 214 | }; | ||
| 215 | |||
| 216 | updateHeightAfterPaint(); | ||
| 217 | const resizeObserver = new ResizeObserver(updateHeightAfterPaint); | ||
| 218 | resizeObserver.observe(element); | ||
| 219 | return () => { | ||
| 220 | resizeObserver.disconnect(); | ||
| 221 | cancelPendingFrames(); | ||
| 222 | }; | ||
| 223 | }, []); | ||
| 224 | |||
| 225 | return ( | ||
| 226 | <div | ||
| 227 | className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none" | ||
| 228 | style={height === null ? undefined : { height }} | ||
| 229 | > | ||
| 230 | <div ref={contentRef}>{children}</div> | ||
| 231 | </div> | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | |||
| 138 | function getSavedBackendStatusTooltip( | 235 | function getSavedBackendStatusTooltip( |
| 139 | runtime: SavedEnvironmentRuntimeState | null, | 236 | runtime: SavedEnvironmentRuntimeState | null, |
| 140 | record: SavedEnvironmentRecord, | 237 | record: SavedEnvironmentRecord, |
| ... | @@ -160,12 +257,153 @@ function getSavedBackendStatusTooltip( | ... | @@ -160,12 +257,153 @@ function getSavedBackendStatusTooltip( |
| 160 | : "Not connected yet."; | 257 | : "Not connected yet."; |
| 161 | } | 258 | } |
| 162 | 259 | ||
| 260 | function formatDesktopSshTarget(target: NonNullable<SavedEnvironmentRecord["desktopSsh"]>): string { | ||
| 261 | const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; | ||
| 262 | return target.port ? `${authority}:${target.port}` : authority; | ||
| 263 | } | ||
| 264 | |||
| 265 | function parseManualDesktopSshTarget(input: { | ||
| 266 | readonly host: string; | ||
| 267 | readonly username: string; | ||
| 268 | readonly port: string; | ||
| 269 | }): DesktopSshEnvironmentTarget { | ||
| 270 | const rawHost = input.host.trim(); | ||
| 271 | if (rawHost.length === 0) { | ||
| 272 | throw new Error("SSH host or alias is required."); | ||
| 273 | } | ||
| 274 | |||
| 275 | let hostname = rawHost; | ||
| 276 | let username = input.username.trim() || null; | ||
| 277 | let port: number | null = null; | ||
| 278 | |||
| 279 | const atIndex = hostname.lastIndexOf("@"); | ||
| 280 | if (atIndex > 0) { | ||
| 281 | const inlineUsername = hostname.slice(0, atIndex).trim(); | ||
| 282 | hostname = hostname.slice(atIndex + 1).trim(); | ||
| 283 | if (!username && inlineUsername.length > 0) { | ||
| 284 | username = inlineUsername; | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); | ||
| 289 | if (bracketedHostMatch) { | ||
| 290 | hostname = bracketedHostMatch[1]!.trim(); | ||
| 291 | if (bracketedHostMatch[2]) { | ||
| 292 | port = Number.parseInt(bracketedHostMatch[2], 10); | ||
| 293 | } | ||
| 294 | } else { | ||
| 295 | const colonSegments = hostname.split(":"); | ||
| 296 | if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { | ||
| 297 | hostname = colonSegments[0]!.trim(); | ||
| 298 | port = Number.parseInt(colonSegments[1]!, 10); | ||
| 299 | } | ||
| 300 | } | ||
| 301 | |||
| 302 | const rawPort = input.port.trim(); | ||
| 303 | if (rawPort.length > 0) { | ||
| 304 | port = Number.parseInt(rawPort, 10); | ||
| 305 | } | ||
| 306 | |||
| 307 | if (hostname.length === 0) { | ||
| 308 | throw new Error("SSH host or alias is required."); | ||
| 309 | } | ||
| 310 | |||
| 311 | if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { | ||
| 312 | throw new Error("SSH port must be between 1 and 65535."); | ||
| 313 | } | ||
| 314 | |||
| 315 | return { | ||
| 316 | alias: hostname, | ||
| 317 | hostname, | ||
| 318 | username, | ||
| 319 | port, | ||
| 320 | }; | ||
| 321 | } | ||
| 322 | |||
| 323 | function parsePairingUrlFields( | ||
| 324 | input: string, | ||
| 325 | ): { readonly host: string; readonly pairingCode: string } | null { | ||
| 326 | const trimmed = input.trim(); | ||
| 327 | if (!trimmed) return null; | ||
| 328 | |||
| 329 | try { | ||
| 330 | const urlLikeInput = | ||
| 331 | /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//") | ||
| 332 | ? trimmed | ||
| 333 | : `https://${trimmed}`; | ||
| 334 | const url = new URL(urlLikeInput, window.location.origin); | ||
| 335 | const hostedPairingRequest = readHostedPairingRequest(url); | ||
| 336 | if (hostedPairingRequest) { | ||
| 337 | return { | ||
| 338 | host: hostedPairingRequest.host, | ||
| 339 | pairingCode: hostedPairingRequest.token, | ||
| 340 | }; | ||
| 341 | } | ||
| 342 | |||
| 343 | const pairingCode = getPairingTokenFromUrl(url); | ||
| 344 | if (!pairingCode) return null; | ||
| 345 | return { | ||
| 346 | host: url.origin, | ||
| 347 | pairingCode, | ||
| 348 | }; | ||
| 349 | } catch { | ||
| 350 | return null; | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): { | ||
| 355 | readonly host: string; | ||
| 356 | readonly pairingCode: string; | ||
| 357 | } { | ||
| 358 | const parsedPairingUrl = parsePairingUrlFields(input.host); | ||
| 359 | if (parsedPairingUrl) return parsedPairingUrl; | ||
| 360 | |||
| 361 | const host = input.host.trim(); | ||
| 362 | const pairingCode = input.pairingCode.trim(); | ||
| 363 | if (!host) { | ||
| 364 | throw new Error("Enter a backend host."); | ||
| 365 | } | ||
| 366 | if (!pairingCode) { | ||
| 367 | throw new Error("Enter a pairing code."); | ||
| 368 | } | ||
| 369 | return { host, pairingCode }; | ||
| 370 | } | ||
| 371 | |||
| 372 | function formatDesktopSshConnectionError(error: unknown): string { | ||
| 373 | const fallback = "Failed to connect SSH host."; | ||
| 374 | const rawMessage = error instanceof Error ? error.message : fallback; | ||
| 375 | const withoutIpcPrefix = rawMessage.replace( | ||
| 376 | /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u, | ||
| 377 | "", | ||
| 378 | ); | ||
| 379 | const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, ""); | ||
| 380 | return withoutTaggedErrorPrefix.trim() || fallback; | ||
| 381 | } | ||
| 382 | |||
| 163 | /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ | 383 | /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ |
| 164 | const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; | 384 | const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; |
| 385 | const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; | ||
| 165 | 386 | ||
| 166 | const ITEM_ROW_INNER_CLASSNAME = | 387 | const ITEM_ROW_INNER_CLASSNAME = |
| 167 | "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; | 388 | "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; |
| 168 | 389 | ||
| 390 | type AccessSectionPresentation = "current" | "endpoint-rail"; | ||
| 391 | |||
| 392 | function accessRowClassName(_presentation: AccessSectionPresentation) { | ||
| 393 | return ITEM_ROW_CLASSNAME; | ||
| 394 | } | ||
| 395 | |||
| 396 | function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { | ||
| 397 | if (presentation === "endpoint-rail") { | ||
| 398 | return cn( | ||
| 399 | "relative border-t border-border/60 px-4 py-3 first:border-t-0 sm:px-5", | ||
| 400 | !isAvailable && "bg-muted/20", | ||
| 401 | ); | ||
| 402 | } | ||
| 403 | |||
| 404 | return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); | ||
| 405 | } | ||
| 406 | |||
| 169 | function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) { | 407 | function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) { |
| 170 | return [...links].toSorted( | 408 | return [...links].toSorted( |
| 171 | (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), | 409 | (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), |
| ... | @@ -243,10 +481,66 @@ function removeDesktopClientSession( | ... | @@ -243,10 +481,66 @@ function removeDesktopClientSession( |
| 243 | return current.filter((clientSession) => clientSession.sessionId !== sessionId); | 481 | return current.filter((clientSession) => clientSession.sessionId !== sessionId); |
| 244 | } | 482 | } |
| 245 | 483 | ||
| 246 | function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { | 484 | function selectPairingEndpoint( |
| 247 | const url = new URL(endpointUrl); | 485 | endpoints: ReadonlyArray<AdvertisedEndpoint>, |
| 248 | url.pathname = "/pair"; | 486 | defaultEndpointKey?: string | null, |
| 249 | return setPairingTokenOnUrl(url, credential).toString(); | 487 | ): AdvertisedEndpoint | null { |
| 488 | const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); | ||
| 489 | if (defaultEndpointKey) { | ||
| 490 | const selectedEndpoint = availableEndpoints.find( | ||
| 491 | (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, | ||
| 492 | ); | ||
| 493 | if (selectedEndpoint) { | ||
| 494 | return selectedEndpoint; | ||
| 495 | } | ||
| 496 | } | ||
| 497 | return ( | ||
| 498 | availableEndpoints.find((endpoint) => endpoint.isDefault) ?? | ||
| 499 | availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? | ||
| 500 | availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? | ||
| 501 | null | ||
| 502 | ); | ||
| 503 | } | ||
| 504 | |||
| 505 | function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean { | ||
| 506 | return endpoint.id.startsWith("tailscale-magicdns:"); | ||
| 507 | } | ||
| 508 | |||
| 509 | function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { | ||
| 510 | if (endpoint.id.startsWith("desktop-loopback:")) { | ||
| 511 | return "desktop-core:loopback:http"; | ||
| 512 | } | ||
| 513 | if (endpoint.id.startsWith("desktop-lan:")) { | ||
| 514 | return "desktop-core:lan:http"; | ||
| 515 | } | ||
| 516 | if (endpoint.id.startsWith("tailscale-ip:")) { | ||
| 517 | return "tailscale:ip:http"; | ||
| 518 | } | ||
| 519 | if (isTailscaleHttpsEndpoint(endpoint)) { | ||
| 520 | return "tailscale:magicdns:https"; | ||
| 521 | } | ||
| 522 | |||
| 523 | let scheme = "unknown"; | ||
| 524 | try { | ||
| 525 | scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); | ||
| 526 | } catch { | ||
| 527 | // Keep the stored preference stable even if a custom endpoint is malformed. | ||
| 528 | } | ||
| 529 | |||
| 530 | return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; | ||
| 531 | } | ||
| 532 | |||
| 533 | function resolveAdvertisedEndpointPairingUrl( | ||
| 534 | endpoint: AdvertisedEndpoint, | ||
| 535 | credential: string, | ||
| 536 | ): string { | ||
| 537 | if (endpoint.compatibility.hostedHttpsApp === "compatible") { | ||
| 538 | return ( | ||
| 539 | resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? | ||
| 540 | resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential) | ||
| 541 | ); | ||
| 542 | } | ||
| 543 | return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); | ||
| 250 | } | 544 | } |
| 251 | 545 | ||
| 252 | function resolveCurrentOriginPairingUrl(credential: string): string { | 546 | function resolveCurrentOriginPairingUrl(credential: string): string { |
| ... | @@ -254,9 +548,21 @@ function resolveCurrentOriginPairingUrl(credential: string): string { | ... | @@ -254,9 +548,21 @@ function resolveCurrentOriginPairingUrl(credential: string): string { |
| 254 | return setPairingTokenOnUrl(url, credential).toString(); | 548 | return setPairingTokenOnUrl(url, credential).toString(); |
| 255 | } | 549 | } |
| 256 | 550 | ||
| 551 | function isHostedAppPairingUrl(value: string): boolean { | ||
| 552 | try { | ||
| 553 | const url = new URL(value); | ||
| 554 | return url.pathname === "/pair" && url.searchParams.has("host"); | ||
| 555 | } catch { | ||
| 556 | return false; | ||
| 557 | } | ||
| 558 | } | ||
| 559 | |||
| 257 | type PairingLinkListRowProps = { | 560 | type PairingLinkListRowProps = { |
| 258 | pairingLink: ServerPairingLinkRecord; | 561 | pairingLink: ServerPairingLinkRecord; |
| 259 | endpointUrl: string | null | undefined; | 562 | endpointUrl: string | null | undefined; |
| 563 | endpoints: ReadonlyArray<AdvertisedEndpoint>; | ||
| 564 | defaultEndpointKey: string | null; | ||
| 565 | presentation?: AccessSectionPresentation; | ||
| 260 | revokingPairingLinkId: string | null; | 566 | revokingPairingLinkId: string | null; |
| 261 | onRevoke: (id: string) => void; | 567 | onRevoke: (id: string) => void; |
| 262 | }; | 568 | }; |
| ... | @@ -264,6 +570,9 @@ type PairingLinkListRowProps = { | ... | @@ -264,6 +570,9 @@ type PairingLinkListRowProps = { |
| 264 | const PairingLinkListRow = memo(function PairingLinkListRow({ | 570 | const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 265 | pairingLink, | 571 | pairingLink, |
| 266 | endpointUrl, | 572 | endpointUrl, |
| 573 | endpoints, | ||
| 574 | defaultEndpointKey, | ||
| 575 | presentation = "current", | ||
| 267 | revokingPairingLinkId, | 576 | revokingPairingLinkId, |
| 268 | onRevoke, | 577 | onRevoke, |
| 269 | }: PairingLinkListRowProps) { | 578 | }: PairingLinkListRowProps) { |
| ... | @@ -278,55 +587,197 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -278,55 +587,197 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 278 | () => resolveCurrentOriginPairingUrl(pairingLink.credential), | 587 | () => resolveCurrentOriginPairingUrl(pairingLink.credential), |
| 279 | [pairingLink.credential], | 588 | [pairingLink.credential], |
| 280 | ); | 589 | ); |
| 590 | const hostedPairingUrl = useMemo( | ||
| 591 | () => | ||
| 592 | endpointUrl != null && endpointUrl !== "" | ||
| 593 | ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential) | ||
| 594 | : null, | ||
| 595 | [endpointUrl, pairingLink.credential], | ||
| 596 | ); | ||
| 597 | const endpointPairingUrl = useMemo(() => { | ||
| 598 | const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); | ||
| 599 | return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null; | ||
| 600 | }, [defaultEndpointKey, endpoints, pairingLink.credential]); | ||
| 601 | const endpointCopyOptions = useMemo( | ||
| 602 | () => | ||
| 603 | endpoints | ||
| 604 | .filter((endpoint) => endpoint.status !== "unavailable") | ||
| 605 | .map((endpoint) => { | ||
| 606 | const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); | ||
| 607 | return { | ||
| 608 | key: endpointDefaultPreferenceKey(endpoint), | ||
| 609 | label: endpoint.label, | ||
| 610 | url, | ||
| 611 | detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL", | ||
| 612 | }; | ||
| 613 | }), | ||
| 614 | [endpoints, pairingLink.credential], | ||
| 615 | ); | ||
| 281 | const shareablePairingUrl = | 616 | const shareablePairingUrl = |
| 282 | endpointUrl != null && endpointUrl !== "" | 617 | endpointPairingUrl ?? |
| 283 | ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) | 618 | (endpointUrl != null && endpointUrl !== "" |
| 619 | ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) | ||
| 284 | : isLoopbackHostname(window.location.hostname) | 620 | : isLoopbackHostname(window.location.hostname) |
| 285 | ? null | 621 | ? null |
| 286 | : currentOriginPairingUrl; | 622 | : currentOriginPairingUrl); |
| 287 | const copyValue = shareablePairingUrl ?? pairingLink.credential; | 623 | const revealValue = shareablePairingUrl ?? pairingLink.credential; |
| 624 | const isShareableHostedAppPairingUrl = | ||
| 625 | shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); | ||
| 288 | const canCopyToClipboard = | 626 | const canCopyToClipboard = |
| 289 | typeof window !== "undefined" && | 627 | typeof window !== "undefined" && |
| 290 | window.isSecureContext && | 628 | window.isSecureContext && |
| 291 | navigator.clipboard?.writeText != null; | 629 | navigator.clipboard?.writeText != null; |
| 292 | 630 | ||
| 293 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | 631 | const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ |
| 294 | onCopy: () => { | 632 | onCopy: (kind) => { |
| 295 | toastManager.add({ | 633 | toastManager.add({ |
| 296 | type: "success", | 634 | type: "success", |
| 297 | title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", | 635 | title: |
| 298 | description: shareablePairingUrl | 636 | kind === "hosted-link" |
| 299 | ? "Open it in the client you want to pair to this environment." | 637 | ? "Hosted app link copied" |
| 300 | : "Paste it into another client with this backend's reachable host.", | 638 | : kind === "link" |
| 639 | ? "Pairing URL copied" | ||
| 640 | : "Pairing code copied", | ||
| 641 | description: | ||
| 642 | kind === "hosted-link" | ||
| 643 | ? "Open it in the browser on the device you want to connect." | ||
| 644 | : kind === "link" | ||
| 645 | ? "Open it in the client you want to pair to this environment." | ||
| 646 | : "Paste it into another client to finish pairing.", | ||
| 301 | }); | 647 | }); |
| 302 | }, | 648 | }, |
| 303 | onError: (error) => { | 649 | onError: (error, kind) => { |
| 304 | setIsRevealDialogOpen(true); | 650 | setIsRevealDialogOpen(true); |
| 305 | toastManager.add( | 651 | toastManager.add( |
| 306 | stackedThreadToast({ | 652 | stackedThreadToast({ |
| 307 | type: "error", | 653 | type: "error", |
| 308 | title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", | 654 | title: canCopyToClipboard |
| 655 | ? kind === "hosted-link" | ||
| 656 | ? "Could not copy hosted app link" | ||
| 657 | : kind === "link" | ||
| 658 | ? "Could not copy pairing URL" | ||
| 659 | : "Could not copy pairing code" | ||
| 660 | : "Clipboard copy unavailable", | ||
| 309 | description: canCopyToClipboard ? error.message : "Showing the full value instead.", | 661 | description: canCopyToClipboard ? error.message : "Showing the full value instead.", |
| 310 | }), | 662 | }), |
| 311 | ); | 663 | ); |
| 312 | }, | 664 | }, |
| 313 | }); | 665 | }); |
| 314 | 666 | ||
| 315 | const handleCopy = useCallback(() => { | 667 | const copyPairingValue = useCallback( |
| 316 | copyToClipboard(copyValue, undefined); | 668 | (value: string, kind: "code" | "hosted-link" | "link") => { |
| 317 | }, [copyToClipboard, copyValue]); | 669 | copyToClipboard(value, kind); |
| 670 | }, | ||
| 671 | [copyToClipboard], | ||
| 672 | ); | ||
| 673 | |||
| 674 | const copyKindForUrl = useCallback( | ||
| 675 | (url: string): "hosted-link" | "link" => (isHostedAppPairingUrl(url) ? "hosted-link" : "link"), | ||
| 676 | [], | ||
| 677 | ); | ||
| 678 | |||
| 679 | const handleCopyCode = useCallback(() => { | ||
| 680 | copyPairingValue(pairingLink.credential, "code"); | ||
| 681 | }, [copyPairingValue, pairingLink.credential]); | ||
| 682 | |||
| 683 | const handleCopyDefaultLink = useCallback(() => { | ||
| 684 | if (!shareablePairingUrl) return; | ||
| 685 | copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl)); | ||
| 686 | }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]); | ||
| 318 | 687 | ||
| 319 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); | 688 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); |
| 320 | 689 | ||
| 321 | const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; | 690 | const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; |
| 322 | const primaryLabel = pairingLink.label ?? `${roleLabel} link`; | 691 | const primaryLabel = pairingLink.label ?? `${roleLabel} link`; |
| 692 | const defaultEndpointCopyOption = | ||
| 693 | endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? | ||
| 694 | endpointCopyOptions[0] ?? | ||
| 695 | null; | ||
| 696 | const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL"; | ||
| 697 | const backendEndpointCopyOptions = endpointCopyOptions.filter( | ||
| 698 | (option) => !isHostedAppPairingUrl(option.url), | ||
| 699 | ); | ||
| 700 | const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) => | ||
| 701 | isHostedAppPairingUrl(option.url), | ||
| 702 | ); | ||
| 703 | const renderEndpointMenuItems = ( | ||
| 704 | options: typeof endpointCopyOptions = endpointCopyOptions, | ||
| 705 | renderDetail = true, | ||
| 706 | ) => | ||
| 707 | options.map((option) => ( | ||
| 708 | <MenuItem | ||
| 709 | key={option.key} | ||
| 710 | onClick={() => copyPairingValue(option.url, copyKindForUrl(option.url))} | ||
| 711 | > | ||
| 712 | <span className="min-w-0 flex-1"> | ||
| 713 | <span className="block truncate">{option.label}</span> | ||
| 714 | {renderDetail ? ( | ||
| 715 | <span className="block truncate text-[11px] text-muted-foreground"> | ||
| 716 | {option.detail} | ||
| 717 | </span> | ||
| 718 | ) : null} | ||
| 719 | </span> | ||
| 720 | </MenuItem> | ||
| 721 | )); | ||
| 722 | const renderPairingCodeMenuItem = (renderDetail = true) => ( | ||
| 723 | <MenuItem onClick={handleCopyCode}> | ||
| 724 | <span className="min-w-0 flex-1"> | ||
| 725 | <span className="block truncate">Copy code</span> | ||
| 726 | {renderDetail ? ( | ||
| 727 | <span className="block truncate text-[11px] text-muted-foreground">Token only</span> | ||
| 728 | ) : null} | ||
| 729 | </span> | ||
| 730 | </MenuItem> | ||
| 731 | ); | ||
| 732 | const renderCompactEndpointGroup = ( | ||
| 733 | label: string, | ||
| 734 | options: typeof endpointCopyOptions, | ||
| 735 | includeSeparator: boolean, | ||
| 736 | ) => | ||
| 737 | options.length > 0 ? ( | ||
| 738 | <> | ||
| 739 | {includeSeparator ? <MenuSeparator /> : null} | ||
| 740 | <MenuGroup> | ||
| 741 | <MenuGroupLabel>{label}</MenuGroupLabel> | ||
| 742 | {renderEndpointMenuItems(options, false)} | ||
| 743 | </MenuGroup> | ||
| 744 | </> | ||
| 745 | ) : null; | ||
| 746 | const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => ( | ||
| 747 | <> | ||
| 748 | {options?.codeFirst ? ( | ||
| 749 | <> | ||
| 750 | <MenuGroup> | ||
| 751 | <MenuGroupLabel>Pairing code</MenuGroupLabel> | ||
| 752 | {renderPairingCodeMenuItem(false)} | ||
| 753 | </MenuGroup> | ||
| 754 | {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null} | ||
| 755 | </> | ||
| 756 | ) : null} | ||
| 757 | {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)} | ||
| 758 | {renderCompactEndpointGroup( | ||
| 759 | "Hosted app link", | ||
| 760 | hostedEndpointCopyOptions, | ||
| 761 | backendEndpointCopyOptions.length > 0, | ||
| 762 | )} | ||
| 763 | {!options?.codeFirst ? ( | ||
| 764 | <> | ||
| 765 | {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null} | ||
| 766 | <MenuGroup> | ||
| 767 | <MenuGroupLabel>Pairing code</MenuGroupLabel> | ||
| 768 | {renderPairingCodeMenuItem(false)} | ||
| 769 | </MenuGroup> | ||
| 770 | </> | ||
| 771 | ) : null} | ||
| 772 | </> | ||
| 773 | ); | ||
| 323 | 774 | ||
| 324 | if (expiresAtMs <= nowMs) { | 775 | if (expiresAtMs <= nowMs) { |
| 325 | return null; | 776 | return null; |
| 326 | } | 777 | } |
| 327 | 778 | ||
| 328 | return ( | 779 | return ( |
| 329 | <div className={ITEM_ROW_CLASSNAME}> | 780 | <div className={accessRowClassName(presentation)}> |
| 330 | <div className={ITEM_ROW_INNER_CLASSNAME}> | 781 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| 331 | <div className="min-w-0 flex-1 space-y-1"> | 782 | <div className="min-w-0 flex-1 space-y-1"> |
| 332 | <div className="flex min-h-5 items-center gap-1.5"> | 783 | <div className="flex min-h-5 items-center gap-1.5"> |
| ... | @@ -377,27 +828,70 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -377,27 +828,70 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 377 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 828 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 378 | <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}> | 829 | <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}> |
| 379 | {canCopyToClipboard ? ( | 830 | {canCopyToClipboard ? ( |
| 380 | <Button size="xs" variant="outline" onClick={handleCopy}> | 831 | <> |
| 381 | {isCopied ? "Copied" : shareablePairingUrl ? "Copy" : "Copy token"} | 832 | {shareablePairingUrl ? ( |
| 382 | </Button> | 833 | <Group aria-label="Copy selected endpoint"> |
| 834 | <Button | ||
| 835 | size="xs" | ||
| 836 | variant="outline" | ||
| 837 | className="max-w-56" | ||
| 838 | title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`} | ||
| 839 | onClick={handleCopyDefaultLink} | ||
| 840 | > | ||
| 841 | <span className="truncate"> | ||
| 842 | Copy pairing URL for: {defaultEndpointCopyLabel} | ||
| 843 | </span> | ||
| 844 | </Button> | ||
| 845 | <GroupSeparator /> | ||
| 846 | <Menu> | ||
| 847 | <MenuTrigger | ||
| 848 | render={ | ||
| 849 | <Button | ||
| 850 | size="icon-xs" | ||
| 851 | variant="outline" | ||
| 852 | aria-label="Choose endpoint to copy" | ||
| 853 | /> | ||
| 854 | } | ||
| 855 | > | ||
| 856 | <ChevronDownIcon className="size-3.5" /> | ||
| 857 | </MenuTrigger> | ||
| 858 | <MenuPopup align="end" className="min-w-60"> | ||
| 859 | {renderGroupedCopyMenuItems()} | ||
| 860 | </MenuPopup> | ||
| 861 | </Menu> | ||
| 862 | </Group> | ||
| 863 | ) : ( | ||
| 864 | <Button size="xs" variant="outline" onClick={handleCopyCode}> | ||
| 865 | Copy code | ||
| 866 | </Button> | ||
| 867 | )} | ||
| 868 | </> | ||
| 383 | ) : ( | 869 | ) : ( |
| 384 | <DialogTrigger render={<Button size="xs" variant="outline" />}> | 870 | <DialogTrigger render={<Button size="xs" variant="outline" />}> |
| 385 | {shareablePairingUrl ? "Show link" : "Show token"} | 871 | {shareablePairingUrl ? "Show link" : "Show code"} |
| 386 | </DialogTrigger> | 872 | </DialogTrigger> |
| 387 | )} | 873 | )} |
| 388 | <DialogPopup className="max-w-md"> | 874 | <DialogPopup className="max-w-md"> |
| 389 | <DialogHeader> | 875 | <DialogHeader> |
| 390 | <DialogTitle>{shareablePairingUrl ? "Pairing link" : "Pairing token"}</DialogTitle> | 876 | <DialogTitle> |
| 877 | {shareablePairingUrl | ||
| 878 | ? isShareableHostedAppPairingUrl | ||
| 879 | ? "Hosted app pairing link" | ||
| 880 | : "Pairing link" | ||
| 881 | : "Pairing code"} | ||
| 882 | </DialogTitle> | ||
| 391 | <DialogDescription> | 883 | <DialogDescription> |
| 392 | {shareablePairingUrl | 884 | {shareablePairingUrl |
| 393 | ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." | 885 | ? isShareableHostedAppPairingUrl |
| 394 | : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} | 886 | ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." |
| 887 | : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." | ||
| 888 | : "Clipboard copy is unavailable here. Manually copy this code into another client."} | ||
| 395 | </DialogDescription> | 889 | </DialogDescription> |
| 396 | </DialogHeader> | 890 | </DialogHeader> |
| 397 | <DialogPanel className="space-y-4"> | 891 | <DialogPanel className="space-y-4"> |
| 398 | <Textarea | 892 | <Textarea |
| 399 | readOnly | 893 | readOnly |
| 400 | value={copyValue} | 894 | value={revealValue} |
| 401 | rows={shareablePairingUrl ? 4 : 3} | 895 | rows={shareablePairingUrl ? 4 : 3} |
| 402 | className="text-xs leading-relaxed" | 896 | className="text-xs leading-relaxed" |
| 403 | onFocus={(event) => event.currentTarget.select()} | 897 | onFocus={(event) => event.currentTarget.select()} |
| ... | @@ -420,8 +914,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -420,8 +914,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 420 | Done | 914 | Done |
| 421 | </Button> | 915 | </Button> |
| 422 | {canCopyToClipboard ? ( | 916 | {canCopyToClipboard ? ( |
| 423 | <Button variant="outline" size="xs" onClick={handleCopy}> | 917 | <Button variant="outline" size="xs" onClick={handleCopyCode}> |
| 424 | {isCopied ? "Copied" : "Copy again"} | 918 | Copy code |
| 425 | </Button> | 919 | </Button> |
| 426 | ) : null} | 920 | ) : null} |
| 427 | </DialogFooter> | 921 | </DialogFooter> |
| ... | @@ -443,12 +937,14 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -443,12 +937,14 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 443 | 937 | ||
| 444 | type ConnectedClientListRowProps = { | 938 | type ConnectedClientListRowProps = { |
| 445 | clientSession: ServerClientSessionRecord; | 939 | clientSession: ServerClientSessionRecord; |
| 940 | presentation?: AccessSectionPresentation; | ||
| 446 | revokingClientSessionId: string | null; | 941 | revokingClientSessionId: string | null; |
| 447 | onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; | 942 | onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; |
| 448 | }; | 943 | }; |
| 449 | 944 | ||
| 450 | const ConnectedClientListRow = memo(function ConnectedClientListRow({ | 945 | const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 451 | clientSession, | 946 | clientSession, |
| 947 | presentation = "current", | ||
| 452 | revokingClientSessionId, | 948 | revokingClientSessionId, |
| 453 | onRevokeSession, | 949 | onRevokeSession, |
| 454 | }: ConnectedClientListRowProps) { | 950 | }: ConnectedClientListRowProps) { |
| ... | @@ -477,7 +973,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ | ... | @@ -477,7 +973,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ |
| 477 | clientSession.subject); | 973 | clientSession.subject); |
| 478 | 974 | ||
| 479 | return ( | 975 | return ( |
| 480 | <div className={ITEM_ROW_CLASSNAME}> | 976 | <div className={accessRowClassName(presentation)}> |
| 481 | <div className={ITEM_ROW_INNER_CLASSNAME}> | 977 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| 482 | <div className="min-w-0 flex-1 space-y-1"> | 978 | <div className="min-w-0 flex-1 space-y-1"> |
| 483 | <div className="flex min-h-5 items-center gap-1.5"> | 979 | <div className="flex min-h-5 items-center gap-1.5"> |
| ... | @@ -620,6 +1116,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio | ... | @@ -620,6 +1116,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio |
| 620 | 1116 | ||
| 621 | type PairingClientsListProps = { | 1117 | type PairingClientsListProps = { |
| 622 | endpointUrl: string | null | undefined; | 1118 | endpointUrl: string | null | undefined; |
| 1119 | endpoints: ReadonlyArray<AdvertisedEndpoint>; | ||
| 1120 | defaultEndpointKey: string | null; | ||
| 1121 | presentation?: AccessSectionPresentation; | ||
| 623 | isLoading: boolean; | 1122 | isLoading: boolean; |
| 624 | pairingLinks: ReadonlyArray<ServerPairingLinkRecord>; | 1123 | pairingLinks: ReadonlyArray<ServerPairingLinkRecord>; |
| 625 | clientSessions: ReadonlyArray<ServerClientSessionRecord>; | 1124 | clientSessions: ReadonlyArray<ServerClientSessionRecord>; |
| ... | @@ -631,6 +1130,9 @@ type PairingClientsListProps = { | ... | @@ -631,6 +1130,9 @@ type PairingClientsListProps = { |
| 631 | 1130 | ||
| 632 | const PairingClientsList = memo(function PairingClientsList({ | 1131 | const PairingClientsList = memo(function PairingClientsList({ |
| 633 | endpointUrl, | 1132 | endpointUrl, |
| 1133 | endpoints, | ||
| 1134 | defaultEndpointKey, | ||
| 1135 | presentation = "current", | ||
| 634 | isLoading, | 1136 | isLoading, |
| 635 | pairingLinks, | 1137 | pairingLinks, |
| 636 | clientSessions, | 1138 | clientSessions, |
| ... | @@ -646,6 +1148,9 @@ const PairingClientsList = memo(function PairingClientsList({ | ... | @@ -646,6 +1148,9 @@ const PairingClientsList = memo(function PairingClientsList({ |
| 646 | key={pairingLink.id} | 1148 | key={pairingLink.id} |
| 647 | pairingLink={pairingLink} | 1149 | pairingLink={pairingLink} |
| 648 | endpointUrl={endpointUrl} | 1150 | endpointUrl={endpointUrl} |
| 1151 | endpoints={endpoints} | ||
| 1152 | defaultEndpointKey={defaultEndpointKey} | ||
| 1153 | presentation={presentation} | ||
| 649 | revokingPairingLinkId={revokingPairingLinkId} | 1154 | revokingPairingLinkId={revokingPairingLinkId} |
| 650 | onRevoke={onRevokePairingLink} | 1155 | onRevoke={onRevokePairingLink} |
| 651 | /> | 1156 | /> |
| ... | @@ -655,13 +1160,14 @@ const PairingClientsList = memo(function PairingClientsList({ | ... | @@ -655,13 +1160,14 @@ const PairingClientsList = memo(function PairingClientsList({ |
| 655 | <ConnectedClientListRow | 1160 | <ConnectedClientListRow |
| 656 | key={clientSession.sessionId} | 1161 | key={clientSession.sessionId} |
| 657 | clientSession={clientSession} | 1162 | clientSession={clientSession} |
| 1163 | presentation={presentation} | ||
| 658 | revokingClientSessionId={revokingClientSessionId} | 1164 | revokingClientSessionId={revokingClientSessionId} |
| 659 | onRevokeSession={onRevokeClientSession} | 1165 | onRevokeSession={onRevokeClientSession} |
| 660 | /> | 1166 | /> |
| 661 | ))} | 1167 | ))} |
| 662 | 1168 | ||
| 663 | {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? ( | 1169 | {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? ( |
| 664 | <div className={ITEM_ROW_CLASSNAME}> | 1170 | <div className={accessRowClassName(presentation)}> |
| 665 | <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p> | 1171 | <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p> |
| 666 | </div> | 1172 | </div> |
| 667 | ) : null} | 1173 | ) : null} |
| ... | @@ -669,19 +1175,156 @@ const PairingClientsList = memo(function PairingClientsList({ | ... | @@ -669,19 +1175,156 @@ const PairingClientsList = memo(function PairingClientsList({ |
| 669 | ); | 1175 | ); |
| 670 | }); | 1176 | }); |
| 671 | 1177 | ||
| 1178 | type AdvertisedEndpointListRowProps = { | ||
| 1179 | endpoint: AdvertisedEndpoint; | ||
| 1180 | isDefault: boolean; | ||
| 1181 | presentation?: AccessSectionPresentation; | ||
| 1182 | onSetDefault: (endpoint: AdvertisedEndpoint) => void; | ||
| 1183 | onSetupTailscaleServe: (endpoint: AdvertisedEndpoint) => void; | ||
| 1184 | onDisableTailscaleServe: (endpoint: AdvertisedEndpoint) => void; | ||
| 1185 | isUpdatingTailscaleServe: boolean; | ||
| 1186 | }; | ||
| 1187 | |||
| 1188 | const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ | ||
| 1189 | endpoint, | ||
| 1190 | isDefault, | ||
| 1191 | presentation = "current", | ||
| 1192 | onSetDefault, | ||
| 1193 | onSetupTailscaleServe, | ||
| 1194 | onDisableTailscaleServe, | ||
| 1195 | isUpdatingTailscaleServe, | ||
| 1196 | }: AdvertisedEndpointListRowProps) { | ||
| 1197 | const isAvailable = endpoint.status === "available"; | ||
| 1198 | const needsTailscaleSetup = isTailscaleHttpsEndpoint(endpoint) && endpoint.status !== "available"; | ||
| 1199 | const canDisableTailscaleServe = | ||
| 1200 | isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available"; | ||
| 1201 | const shouldShowEndpointUrl = !needsTailscaleSetup; | ||
| 1202 | const isEndpointRail = presentation === "endpoint-rail"; | ||
| 1203 | return ( | ||
| 1204 | <div className={endpointRowClassName(presentation, isAvailable)}> | ||
| 1205 | {isEndpointRail && isDefault ? ( | ||
| 1206 | <span className="absolute inset-y-2 left-0 w-1 rounded-r-full bg-primary" aria-hidden /> | ||
| 1207 | ) : null} | ||
| 1208 | <div className="flex min-h-6 min-w-0 flex-col gap-2 sm:-my-0.5 sm:flex-row sm:items-center"> | ||
| 1209 | <div className="flex min-w-0 items-baseline gap-3"> | ||
| 1210 | <h3 className="shrink-0 text-sm leading-5 font-medium text-foreground"> | ||
| 1211 | {endpoint.label} | ||
| 1212 | </h3> | ||
| 1213 | {shouldShowEndpointUrl ? ( | ||
| 1214 | <p | ||
| 1215 | className="min-w-0 truncate text-xs leading-5 text-muted-foreground" | ||
| 1216 | title={endpoint.httpBaseUrl} | ||
| 1217 | > | ||
| 1218 | {endpoint.httpBaseUrl} | ||
| 1219 | </p> | ||
| 1220 | ) : null} | ||
| 1221 | {!isAvailable ? ( | ||
| 1222 | <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground"> | ||
| 1223 | Setup required | ||
| 1224 | </span> | ||
| 1225 | ) : null} | ||
| 1226 | </div> | ||
| 1227 | <div className="ml-auto flex min-h-6 shrink-0 items-center justify-end gap-2"> | ||
| 1228 | {isDefault ? ( | ||
| 1229 | <span className="rounded-md border border-primary/30 bg-primary/10 px-1 py-0.5 text-[10px] text-primary"> | ||
| 1230 | Default | ||
| 1231 | </span> | ||
| 1232 | ) : null} | ||
| 1233 | {needsTailscaleSetup ? ( | ||
| 1234 | <Button | ||
| 1235 | size="xs" | ||
| 1236 | variant="outline" | ||
| 1237 | onClick={() => onSetupTailscaleServe(endpoint)} | ||
| 1238 | disabled={isUpdatingTailscaleServe} | ||
| 1239 | > | ||
| 1240 | {isUpdatingTailscaleServe ? "Restarting…" : "Setup"} | ||
| 1241 | </Button> | ||
| 1242 | ) : null} | ||
| 1243 | {canDisableTailscaleServe ? ( | ||
| 1244 | <Button | ||
| 1245 | size="xs" | ||
| 1246 | variant="destructive-outline" | ||
| 1247 | onClick={() => onDisableTailscaleServe(endpoint)} | ||
| 1248 | disabled={isUpdatingTailscaleServe} | ||
| 1249 | > | ||
| 1250 | {isUpdatingTailscaleServe ? "Restarting…" : "Disable"} | ||
| 1251 | </Button> | ||
| 1252 | ) : null} | ||
| 1253 | {!needsTailscaleSetup && !isDefault ? ( | ||
| 1254 | <Button size="xs" variant="outline" onClick={() => onSetDefault(endpoint)}> | ||
| 1255 | Set as default | ||
| 1256 | </Button> | ||
| 1257 | ) : null} | ||
| 1258 | </div> | ||
| 1259 | </div> | ||
| 1260 | </div> | ||
| 1261 | ); | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | function NetworkAccessDescription({ | ||
| 1265 | endpoint, | ||
| 1266 | hiddenEndpointCount, | ||
| 1267 | expanded, | ||
| 1268 | onToggleExpanded, | ||
| 1269 | fallback, | ||
| 1270 | }: { | ||
| 1271 | endpoint: AdvertisedEndpoint | null; | ||
| 1272 | hiddenEndpointCount: number; | ||
| 1273 | expanded: boolean; | ||
| 1274 | onToggleExpanded: () => void; | ||
| 1275 | fallback: ReactNode; | ||
| 1276 | }) { | ||
| 1277 | if (!endpoint) { | ||
| 1278 | return fallback; | ||
| 1279 | } | ||
| 1280 | |||
| 1281 | const summary = ( | ||
| 1282 | <> | ||
| 1283 | <span className="min-w-0 truncate">{endpoint.httpBaseUrl}</span> | ||
| 1284 | {hiddenEndpointCount > 0 ? ( | ||
| 1285 | <span className="shrink-0 text-xs font-medium"> | ||
| 1286 | {expanded ? "Hide" : `+${hiddenEndpointCount}`} | ||
| 1287 | </span> | ||
| 1288 | ) : null} | ||
| 1289 | </> | ||
| 1290 | ); | ||
| 1291 | |||
| 1292 | return ( | ||
| 1293 | <span className="inline-flex min-w-0 max-w-full items-baseline gap-1"> | ||
| 1294 | <span className="shrink-0">Reachable at</span> | ||
| 1295 | {hiddenEndpointCount > 0 ? ( | ||
| 1296 | <button | ||
| 1297 | type="button" | ||
| 1298 | className="inline-flex min-w-0 max-w-full items-baseline gap-2 border-b border-dotted border-muted-foreground/60 text-left text-muted-foreground underline-offset-4 hover:border-foreground hover:text-foreground" | ||
| 1299 | onClick={onToggleExpanded} | ||
| 1300 | aria-expanded={expanded} | ||
| 1301 | > | ||
| 1302 | {summary} | ||
| 1303 | </button> | ||
| 1304 | ) : ( | ||
| 1305 | <span className="inline-flex min-w-0 max-w-full items-baseline gap-2">{summary}</span> | ||
| 1306 | )} | ||
| 1307 | </span> | ||
| 1308 | ); | ||
| 1309 | } | ||
| 1310 | |||
| 672 | type SavedBackendListRowProps = { | 1311 | type SavedBackendListRowProps = { |
| 673 | environmentId: EnvironmentId; | 1312 | environmentId: EnvironmentId; |
| 674 | reconnectingEnvironmentId: EnvironmentId | null; | 1313 | reconnectingEnvironmentId: EnvironmentId | null; |
| 1314 | disconnectingEnvironmentId: EnvironmentId | null; | ||
| 675 | removingEnvironmentId: EnvironmentId | null; | 1315 | removingEnvironmentId: EnvironmentId | null; |
| 676 | onReconnect: (environmentId: EnvironmentId) => void; | 1316 | onConnect: (environmentId: EnvironmentId) => void; |
| 1317 | onDisconnect: (environmentId: EnvironmentId) => void; | ||
| 677 | onRemove: (environmentId: EnvironmentId) => void; | 1318 | onRemove: (environmentId: EnvironmentId) => void; |
| 678 | }; | 1319 | }; |
| 679 | 1320 | ||
| 680 | function SavedBackendListRow({ | 1321 | function SavedBackendListRow({ |
| 681 | environmentId, | 1322 | environmentId, |
| 682 | reconnectingEnvironmentId, | 1323 | reconnectingEnvironmentId, |
| 1324 | disconnectingEnvironmentId, | ||
| 683 | removingEnvironmentId, | 1325 | removingEnvironmentId, |
| 684 | onReconnect, | 1326 | onConnect, |
| 1327 | onDisconnect, | ||
| 685 | onRemove, | 1328 | onRemove, |
| 686 | }: SavedBackendListRowProps) { | 1329 | }: SavedBackendListRowProps) { |
| 687 | const nowMs = useRelativeTimeTick(1_000); | 1330 | const nowMs = useRelativeTimeTick(1_000); |
| ... | @@ -693,6 +1336,10 @@ function SavedBackendListRow({ | ... | @@ -693,6 +1336,10 @@ function SavedBackendListRow({ |
| 693 | } | 1336 | } |
| 694 | 1337 | ||
| 695 | const connectionState = runtime?.connectionState ?? "disconnected"; | 1338 | const connectionState = runtime?.connectionState ?? "disconnected"; |
| 1339 | const isConnected = connectionState === "connected"; | ||
| 1340 | const isConnecting = | ||
| 1341 | connectionState === "connecting" || reconnectingEnvironmentId === environmentId; | ||
| 1342 | const isDisconnecting = disconnectingEnvironmentId === environmentId; | ||
| 696 | const stateDotClassName = | 1343 | const stateDotClassName = |
| 697 | connectionState === "connected" | 1344 | connectionState === "connected" |
| 698 | ? "bg-success" | 1345 | ? "bg-success" |
| ... | @@ -703,8 +1350,11 @@ function SavedBackendListRow({ | ... | @@ -703,8 +1350,11 @@ function SavedBackendListRow({ |
| 703 | : "bg-muted-foreground/40"; | 1350 | : "bg-muted-foreground/40"; |
| 704 | const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null; | 1351 | const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null; |
| 705 | const descriptorLabel = runtime?.descriptor?.label ?? null; | 1352 | const descriptorLabel = runtime?.descriptor?.label ?? null; |
| 1353 | const displayLabel = descriptorLabel ?? record.label; | ||
| 706 | const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); | 1354 | const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); |
| 1355 | const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); | ||
| 707 | const metadataBits = [ | 1356 | const metadataBits = [ |
| 1357 | record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, | ||
| 708 | roleLabel, | 1358 | roleLabel, |
| 709 | record.lastConnectedAt | 1359 | record.lastConnectedAt |
| 710 | ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` | 1360 | ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` |
| ... | @@ -723,23 +1373,35 @@ function SavedBackendListRow({ | ... | @@ -723,23 +1373,35 @@ function SavedBackendListRow({ |
| 723 | connectionState === "connecting" ? "bg-warning/60 duration-2000" : null | 1373 | connectionState === "connecting" ? "bg-warning/60 duration-2000" : null |
| 724 | } | 1374 | } |
| 725 | /> | 1375 | /> |
| 726 | <h3 className="text-sm font-medium text-foreground">{record.label}</h3> | 1376 | <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3> |
| 727 | </div> | 1377 | </div> |
| 728 | {metadataBits.length > 0 ? ( | 1378 | {metadataBits.length > 0 ? ( |
| 729 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> | 1379 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> |
| 730 | ) : null} | 1380 | ) : null} |
| 731 | {descriptorLabel && descriptorLabel !== record.label ? ( | 1381 | {versionMismatch ? ( |
| 732 | <p className="text-xs text-muted-foreground">Server label: {descriptorLabel}</p> | 1382 | <p className="flex items-center gap-1 text-warning text-xs"> |
| 1383 | <TriangleAlertIcon className="size-3.5 shrink-0" /> | ||
| 1384 | Version drift: client {versionMismatch.clientVersion}, server{" "} | ||
| 1385 | {versionMismatch.serverVersion}. | ||
| 1386 | </p> | ||
| 733 | ) : null} | 1387 | ) : null} |
| 734 | </div> | 1388 | </div> |
| 735 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 1389 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 736 | <Button | 1390 | <Button |
| 737 | size="xs" | 1391 | size="xs" |
| 738 | variant="outline" | 1392 | variant="outline" |
| 739 | disabled={reconnectingEnvironmentId === environmentId} | 1393 | disabled={isConnected ? isDisconnecting : isConnecting} |
| 740 | onClick={() => void onReconnect(environmentId)} | 1394 | onClick={() => |
| 1395 | void (isConnected ? onDisconnect(environmentId) : onConnect(environmentId)) | ||
| 1396 | } | ||
| 741 | > | 1397 | > |
| 742 | {reconnectingEnvironmentId === environmentId ? "Reconnecting…" : "Reconnect"} | 1398 | {isConnected |
| 1399 | ? isDisconnecting | ||
| 1400 | ? "Disconnecting…" | ||
| 1401 | : "Disconnect" | ||
| 1402 | : isConnecting | ||
| 1403 | ? "Connecting…" | ||
| 1404 | : "Connect"} | ||
| 743 | </Button> | 1405 | </Button> |
| 744 | <Button | 1406 | <Button |
| 745 | size="xs" | 1407 | size="xs" |
| ... | @@ -755,6 +1417,46 @@ function SavedBackendListRow({ | ... | @@ -755,6 +1417,46 @@ function SavedBackendListRow({ |
| 755 | ); | 1417 | ); |
| 756 | } | 1418 | } |
| 757 | 1419 | ||
| 1420 | interface DesktopSshHostRowProps { | ||
| 1421 | target: DesktopDiscoveredSshHost; | ||
| 1422 | connectingHostAlias: string | null; | ||
| 1423 | onConnect: (target: DesktopDiscoveredSshHost) => void; | ||
| 1424 | } | ||
| 1425 | |||
| 1426 | const DesktopSshHostRow = memo(function DesktopSshHostRow({ | ||
| 1427 | target, | ||
| 1428 | connectingHostAlias, | ||
| 1429 | onConnect, | ||
| 1430 | }: DesktopSshHostRowProps) { | ||
| 1431 | const address = formatDesktopSshTarget(target); | ||
| 1432 | const showAddress = address !== target.alias; | ||
| 1433 | const buttonLabel = connectingHostAlias === target.alias ? "Adding…" : "Add environment"; | ||
| 1434 | |||
| 1435 | return ( | ||
| 1436 | <div className="border-t border-border/60 px-4 py-3 first:border-t-0 sm:px-5"> | ||
| 1437 | <div className={ITEM_ROW_INNER_CLASSNAME}> | ||
| 1438 | <div className="min-w-0 flex-1"> | ||
| 1439 | <h3 className="truncate text-sm font-medium text-foreground">{target.alias}</h3> | ||
| 1440 | {showAddress ? <p className="truncate text-xs text-muted-foreground">{address}</p> : null} | ||
| 1441 | </div> | ||
| 1442 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | ||
| 1443 | <Button | ||
| 1444 | size="xs" | ||
| 1445 | variant="outline" | ||
| 1446 | disabled={connectingHostAlias === target.alias} | ||
| 1447 | onClick={() => onConnect(target)} | ||
| 1448 | > | ||
| 1449 | {connectingHostAlias === target.alias ? ( | ||
| 1450 | <RefreshCwIcon className="size-3 animate-spin" /> | ||
| 1451 | ) : null} | ||
| 1452 | {buttonLabel} | ||
| 1453 | </Button> | ||
| 1454 | </div> | ||
| 1455 | </div> | ||
| 1456 | </div> | ||
| 1457 | ); | ||
| 1458 | }); | ||
| 1459 | |||
| 758 | export function ConnectionsSettings() { | 1460 | export function ConnectionsSettings() { |
| 759 | const desktopBridge = window.desktopBridge; | 1461 | const desktopBridge = window.desktopBridge; |
| 760 | const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>( | 1462 | const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>( |
| ... | @@ -771,9 +1473,42 @@ export function ConnectionsSettings() { | ... | @@ -771,9 +1473,42 @@ export function ConnectionsSettings() { |
| 771 | .map((record) => record.environmentId), | 1473 | .map((record) => record.environmentId), |
| 772 | [savedEnvironmentsById], | 1474 | [savedEnvironmentsById], |
| 773 | ); | 1475 | ); |
| 1476 | const savedDesktopSshEnvironmentsByAlias = useMemo( | ||
| 1477 | () => | ||
| 1478 | Object.values(savedEnvironmentsById).reduce<Record<string, SavedEnvironmentRecord>>( | ||
| 1479 | (accumulator, record) => { | ||
| 1480 | if (record.desktopSsh?.alias) { | ||
| 1481 | accumulator[record.desktopSsh.alias] = record; | ||
| 1482 | } | ||
| 1483 | return accumulator; | ||
| 1484 | }, | ||
| 1485 | {}, | ||
| 1486 | ), | ||
| 1487 | [savedEnvironmentsById], | ||
| 1488 | ); | ||
| 1489 | const savedDesktopSshEnvironmentKeys = useMemo(() => { | ||
| 1490 | const keys = new Set<string>(); | ||
| 1491 | for (const record of Object.values(savedEnvironmentsById)) { | ||
| 1492 | const target = record.desktopSsh; | ||
| 1493 | if (!target) continue; | ||
| 1494 | keys.add(target.alias); | ||
| 1495 | keys.add(formatDesktopSshTarget(target)); | ||
| 1496 | } | ||
| 1497 | return keys; | ||
| 1498 | }, [savedEnvironmentsById]); | ||
| 1499 | const [discoveredSshHosts, setDiscoveredSshHosts] = useState< | ||
| 1500 | ReadonlyArray<DesktopDiscoveredSshHost> | ||
| 1501 | >([]); | ||
| 1502 | const [hasLoadedDiscoveredSshHosts, setHasLoadedDiscoveredSshHosts] = useState(false); | ||
| 1503 | const [isLoadingDiscoveredSshHosts, setIsLoadingDiscoveredSshHosts] = useState(false); | ||
| 1504 | const [discoveredSshHostsError, setDiscoveredSshHostsError] = useState<string | null>(null); | ||
| 1505 | const [connectingSshHostAlias, setConnectingSshHostAlias] = useState<string | null>(null); | ||
| 774 | 1506 | ||
| 775 | const [desktopServerExposureState, setDesktopServerExposureState] = | 1507 | const [desktopServerExposureState, setDesktopServerExposureState] = |
| 776 | useState<DesktopServerExposureState | null>(null); | 1508 | useState<DesktopServerExposureState | null>(null); |
| 1509 | const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState< | ||
| 1510 | ReadonlyArray<AdvertisedEndpoint> | ||
| 1511 | >([]); | ||
| 777 | const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null); | 1512 | const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null); |
| 778 | const [desktopPairingLinks, setDesktopPairingLinks] = useState< | 1513 | const [desktopPairingLinks, setDesktopPairingLinks] = useState< |
| 779 | ReadonlyArray<ServerPairingLinkRecord> | 1514 | ReadonlyArray<ServerPairingLinkRecord> |
| ... | @@ -793,27 +1528,78 @@ export function ConnectionsSettings() { | ... | @@ -793,27 +1528,78 @@ export function ConnectionsSettings() { |
| 793 | >(null); | 1528 | >(null); |
| 794 | const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); | 1529 | const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); |
| 795 | const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); | 1530 | const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); |
| 796 | const [savedBackendMode, setSavedBackendMode] = useState<"pairing-url" | "host-code">( | 1531 | const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote"); |
| 797 | "pairing-url", | ||
| 798 | ); | ||
| 799 | const [savedBackendLabel, setSavedBackendLabel] = useState(""); | ||
| 800 | const [savedBackendPairingUrl, setSavedBackendPairingUrl] = useState(""); | ||
| 801 | const [savedBackendHost, setSavedBackendHost] = useState(""); | 1532 | const [savedBackendHost, setSavedBackendHost] = useState(""); |
| 802 | const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); | 1533 | const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); |
| 1534 | const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); | ||
| 1535 | const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); | ||
| 1536 | const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); | ||
| 803 | const [savedBackendError, setSavedBackendError] = useState<string | null>(null); | 1537 | const [savedBackendError, setSavedBackendError] = useState<string | null>(null); |
| 804 | const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); | 1538 | const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); |
| 1539 | const unsavedDiscoveredSshHosts = useMemo( | ||
| 1540 | () => | ||
| 1541 | discoveredSshHosts.filter((target) => { | ||
| 1542 | const address = formatDesktopSshTarget(target); | ||
| 1543 | return ( | ||
| 1544 | !savedDesktopSshEnvironmentKeys.has(target.alias) && | ||
| 1545 | !savedDesktopSshEnvironmentKeys.has(address) | ||
| 1546 | ); | ||
| 1547 | }), | ||
| 1548 | [discoveredSshHosts, savedDesktopSshEnvironmentKeys], | ||
| 1549 | ); | ||
| 805 | const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] = | 1550 | const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] = |
| 806 | useState<EnvironmentId | null>(null); | 1551 | useState<EnvironmentId | null>(null); |
| 1552 | const [disconnectingSavedEnvironmentId, setDisconnectingSavedEnvironmentId] = | ||
| 1553 | useState<EnvironmentId | null>(null); | ||
| 807 | const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = | 1554 | const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = |
| 808 | useState<EnvironmentId | null>(null); | 1555 | useState<EnvironmentId | null>(null); |
| 809 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); | 1556 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); |
| 1557 | const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); | ||
| 1558 | const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); | ||
| 1559 | const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] = | ||
| 1560 | useState<AdvertisedEndpoint | null>(null); | ||
| 1561 | const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false); | ||
| 1562 | const [tailscaleServePortInput, setTailscaleServePortInput] = useState( | ||
| 1563 | String(DEFAULT_TAILSCALE_SERVE_PORT), | ||
| 1564 | ); | ||
| 810 | const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< | 1565 | const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< |
| 811 | DesktopServerExposureState["mode"] | null | 1566 | DesktopServerExposureState["mode"] | null |
| 812 | >(null); | 1567 | >(null); |
| 1568 | const primaryServerConfig = useServerConfig(); | ||
| 1569 | const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); | ||
| 1570 | const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false); | ||
| 1571 | const defaultAdvertisedEndpointKey = useUiStateStore( | ||
| 1572 | (state) => state.defaultAdvertisedEndpointKey, | ||
| 1573 | ); | ||
| 1574 | const setDefaultAdvertisedEndpointKey = useUiStateStore( | ||
| 1575 | (state) => state.setDefaultAdvertisedEndpointKey, | ||
| 1576 | ); | ||
| 813 | const canManageLocalBackend = currentSessionRole === "owner"; | 1577 | const canManageLocalBackend = currentSessionRole === "owner"; |
| 814 | const isLocalBackendNetworkAccessible = desktopBridge | 1578 | const isLocalBackendNetworkAccessible = desktopBridge |
| 815 | ? desktopServerExposureState?.mode === "network-accessible" | 1579 | ? desktopServerExposureState?.mode === "network-accessible" |
| 816 | : currentAuthPolicy === "remote-reachable"; | 1580 | : currentAuthPolicy === "remote-reachable"; |
| 1581 | const trimmedTailscaleServePortInput = tailscaleServePortInput.trim(); | ||
| 1582 | const parsedTailscaleServePort = Number(trimmedTailscaleServePortInput); | ||
| 1583 | const isTailscaleServePortValid = | ||
| 1584 | /^\d+$/u.test(trimmedTailscaleServePortInput) && | ||
| 1585 | Number.isInteger(parsedTailscaleServePort) && | ||
| 1586 | parsedTailscaleServePort >= 1 && | ||
| 1587 | parsedTailscaleServePort <= 65_535; | ||
| 1588 | |||
| 1589 | const pendingTailscaleServeBaseUrl = useMemo(() => { | ||
| 1590 | if (!pendingTailscaleServeEndpoint) return null; | ||
| 1591 | if (!isTailscaleServePortValid) return pendingTailscaleServeEndpoint.httpBaseUrl; | ||
| 1592 | if (parsedTailscaleServePort === DEFAULT_TAILSCALE_SERVE_PORT) { | ||
| 1593 | return pendingTailscaleServeEndpoint.httpBaseUrl; | ||
| 1594 | } | ||
| 1595 | try { | ||
| 1596 | const url = new URL(pendingTailscaleServeEndpoint.httpBaseUrl); | ||
| 1597 | url.port = String(parsedTailscaleServePort); | ||
| 1598 | return url.toString().replace(/\/$/u, ""); | ||
| 1599 | } catch { | ||
| 1600 | return pendingTailscaleServeEndpoint.httpBaseUrl; | ||
| 1601 | } | ||
| 1602 | }, [isTailscaleServePortValid, parsedTailscaleServePort, pendingTailscaleServeEndpoint]); | ||
| 817 | 1603 | ||
| 818 | const handleDesktopServerExposureChange = useCallback( | 1604 | const handleDesktopServerExposureChange = useCallback( |
| 819 | async (checked: boolean) => { | 1605 | async (checked: boolean) => { |
| ... | @@ -825,12 +1611,12 @@ export function ConnectionsSettings() { | ... | @@ -825,12 +1611,12 @@ export function ConnectionsSettings() { |
| 825 | checked ? "network-accessible" : "local-only", | 1611 | checked ? "network-accessible" : "local-only", |
| 826 | ); | 1612 | ); |
| 827 | setDesktopServerExposureState(nextState); | 1613 | setDesktopServerExposureState(nextState); |
| 828 | setPendingDesktopServerExposureMode(null); | 1614 | setIsDesktopServerExposureDialogOpen(false); |
| 829 | setIsUpdatingDesktopServerExposure(false); | 1615 | setIsUpdatingDesktopServerExposure(false); |
| 830 | } catch (error) { | 1616 | } catch (error) { |
| 831 | const message = | 1617 | const message = |
| 832 | error instanceof Error ? error.message : "Failed to update network exposure."; | 1618 | error instanceof Error ? error.message : "Failed to update network exposure."; |
| 833 | setPendingDesktopServerExposureMode(null); | 1619 | setIsDesktopServerExposureDialogOpen(false); |
| 834 | setDesktopServerExposureError(message); | 1620 | setDesktopServerExposureError(message); |
| 835 | toastManager.add( | 1621 | toastManager.add( |
| 836 | stackedThreadToast({ | 1622 | stackedThreadToast({ |
| ... | @@ -851,6 +1637,74 @@ export function ConnectionsSettings() { | ... | @@ -851,6 +1637,74 @@ export function ConnectionsSettings() { |
| 851 | void handleDesktopServerExposureChange(checked); | 1637 | void handleDesktopServerExposureChange(checked); |
| 852 | }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); | 1638 | }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); |
| 853 | 1639 | ||
| 1640 | const handleConfirmTailscaleServeSetup = useCallback(async () => { | ||
| 1641 | if (!desktopBridge) return; | ||
| 1642 | if (!isTailscaleServePortValid) return; | ||
| 1643 | setIsUpdatingTailscaleServe(true); | ||
| 1644 | setDesktopServerExposureError(null); | ||
| 1645 | try { | ||
| 1646 | const nextState = await desktopBridge.setTailscaleServeEnabled({ | ||
| 1647 | enabled: true, | ||
| 1648 | port: parsedTailscaleServePort, | ||
| 1649 | }); | ||
| 1650 | setDesktopServerExposureState(nextState); | ||
| 1651 | setPendingTailscaleServeEndpoint(null); | ||
| 1652 | } catch (error) { | ||
| 1653 | const message = | ||
| 1654 | error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS."; | ||
| 1655 | setDesktopServerExposureError(message); | ||
| 1656 | toastManager.add( | ||
| 1657 | stackedThreadToast({ | ||
| 1658 | type: "error", | ||
| 1659 | title: "Could not set up Tailscale HTTPS", | ||
| 1660 | description: message, | ||
| 1661 | }), | ||
| 1662 | ); | ||
| 1663 | } finally { | ||
| 1664 | setIsUpdatingTailscaleServe(false); | ||
| 1665 | } | ||
| 1666 | }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort]); | ||
| 1667 | |||
| 1668 | const handleStartTailscaleServeSetup = useCallback( | ||
| 1669 | (endpoint: AdvertisedEndpoint) => { | ||
| 1670 | setTailscaleServePortInput( | ||
| 1671 | String(desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT), | ||
| 1672 | ); | ||
| 1673 | setPendingTailscaleServeEndpoint(endpoint); | ||
| 1674 | }, | ||
| 1675 | [desktopServerExposureState?.tailscaleServePort], | ||
| 1676 | ); | ||
| 1677 | |||
| 1678 | const handleConfirmTailscaleServeDisable = useCallback(async () => { | ||
| 1679 | if (!desktopBridge) return; | ||
| 1680 | setIsUpdatingTailscaleServe(true); | ||
| 1681 | setDesktopServerExposureError(null); | ||
| 1682 | try { | ||
| 1683 | const nextState = await desktopBridge.setTailscaleServeEnabled({ | ||
| 1684 | enabled: false, | ||
| 1685 | port: desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT, | ||
| 1686 | }); | ||
| 1687 | setDesktopServerExposureState(nextState); | ||
| 1688 | setDisableTailscaleServeDialogOpen(false); | ||
| 1689 | } catch (error) { | ||
| 1690 | const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS."; | ||
| 1691 | setDesktopServerExposureError(message); | ||
| 1692 | toastManager.add( | ||
| 1693 | stackedThreadToast({ | ||
| 1694 | type: "error", | ||
| 1695 | title: "Could not disable Tailscale HTTPS", | ||
| 1696 | description: message, | ||
| 1697 | }), | ||
| 1698 | ); | ||
| 1699 | } finally { | ||
| 1700 | setIsUpdatingTailscaleServe(false); | ||
| 1701 | } | ||
| 1702 | }, [desktopBridge, desktopServerExposureState?.tailscaleServePort]); | ||
| 1703 | |||
| 1704 | const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => { | ||
| 1705 | setDisableTailscaleServeDialogOpen(true); | ||
| 1706 | }, []); | ||
| 1707 | |||
| 854 | const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { | 1708 | const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { |
| 855 | setRevokingDesktopPairingLinkId(id); | 1709 | setRevokingDesktopPairingLinkId(id); |
| 856 | setDesktopAccessManagementError(null); | 1710 | setDesktopAccessManagementError(null); |
| ... | @@ -920,22 +1774,53 @@ export function ConnectionsSettings() { | ... | @@ -920,22 +1774,53 @@ export function ConnectionsSettings() { |
| 920 | }, []); | 1774 | }, []); |
| 921 | 1775 | ||
| 922 | const handleAddSavedBackend = useCallback(async () => { | 1776 | const handleAddSavedBackend = useCallback(async () => { |
| 1777 | if (savedBackendMode === "ssh") { | ||
| 1778 | setIsAddingSavedBackend(true); | ||
| 1779 | setSavedBackendError(null); | ||
| 1780 | try { | ||
| 1781 | const target = parseManualDesktopSshTarget({ | ||
| 1782 | host: savedBackendSshHost, | ||
| 1783 | username: savedBackendSshUsername, | ||
| 1784 | port: savedBackendSshPort, | ||
| 1785 | }); | ||
| 1786 | const record = await connectDesktopSshEnvironment(target, { label: "" }); | ||
| 1787 | setSavedBackendHost(""); | ||
| 1788 | setSavedBackendPairingCode(""); | ||
| 1789 | setSavedBackendSshHost(""); | ||
| 1790 | setSavedBackendSshUsername(""); | ||
| 1791 | setSavedBackendSshPort(""); | ||
| 1792 | |||
| 1793 | setAddBackendDialogOpen(false); | ||
| 1794 | toastManager.add({ | ||
| 1795 | type: "success", | ||
| 1796 | title: "Environment connected", | ||
| 1797 | description: `${record.label} is ready over an SSH-managed tunnel.`, | ||
| 1798 | }); | ||
| 1799 | } catch (error) { | ||
| 1800 | const message = formatDesktopSshConnectionError(error); | ||
| 1801 | setSavedBackendError(message); | ||
| 1802 | } finally { | ||
| 1803 | setIsAddingSavedBackend(false); | ||
| 1804 | } | ||
| 1805 | return; | ||
| 1806 | } | ||
| 1807 | |||
| 923 | setIsAddingSavedBackend(true); | 1808 | setIsAddingSavedBackend(true); |
| 924 | setSavedBackendError(null); | 1809 | setSavedBackendError(null); |
| 925 | try { | 1810 | try { |
| 1811 | const remotePairingInput = parseRemotePairingFields({ | ||
| 1812 | host: savedBackendHost, | ||
| 1813 | pairingCode: savedBackendPairingCode, | ||
| 1814 | }); | ||
| 926 | const record = await addSavedEnvironment({ | 1815 | const record = await addSavedEnvironment({ |
| 927 | label: savedBackendLabel, | 1816 | label: "", |
| 928 | ...(savedBackendMode === "pairing-url" | 1817 | ...remotePairingInput, |
| 929 | ? { pairingUrl: savedBackendPairingUrl } | ||
| 930 | : { | ||
| 931 | host: savedBackendHost, | ||
| 932 | pairingCode: savedBackendPairingCode, | ||
| 933 | }), | ||
| 934 | }); | 1818 | }); |
| 935 | setSavedBackendLabel(""); | ||
| 936 | setSavedBackendPairingUrl(""); | ||
| 937 | setSavedBackendHost(""); | 1819 | setSavedBackendHost(""); |
| 938 | setSavedBackendPairingCode(""); | 1820 | setSavedBackendPairingCode(""); |
| 1821 | setSavedBackendSshHost(""); | ||
| 1822 | setSavedBackendSshUsername(""); | ||
| 1823 | setSavedBackendSshPort(""); | ||
| 939 | setAddBackendDialogOpen(false); | 1824 | setAddBackendDialogOpen(false); |
| 940 | toastManager.add({ | 1825 | toastManager.add({ |
| 941 | type: "success", | 1826 | type: "success", |
| ... | @@ -957,24 +1842,25 @@ export function ConnectionsSettings() { | ... | @@ -957,24 +1842,25 @@ export function ConnectionsSettings() { |
| 957 | } | 1842 | } |
| 958 | }, [ | 1843 | }, [ |
| 959 | savedBackendHost, | 1844 | savedBackendHost, |
| 960 | savedBackendLabel, | ||
| 961 | savedBackendMode, | 1845 | savedBackendMode, |
| 962 | savedBackendPairingCode, | 1846 | savedBackendPairingCode, |
| 963 | savedBackendPairingUrl, | 1847 | savedBackendSshHost, |
| 1848 | savedBackendSshPort, | ||
| 1849 | savedBackendSshUsername, | ||
| 964 | ]); | 1850 | ]); |
| 965 | 1851 | ||
| 966 | const handleReconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | 1852 | const handleConnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => { |
| 967 | setReconnectingSavedEnvironmentId(environmentId); | 1853 | setReconnectingSavedEnvironmentId(environmentId); |
| 968 | setSavedBackendError(null); | 1854 | setSavedBackendError(null); |
| 969 | try { | 1855 | try { |
| 970 | await reconnectSavedEnvironment(environmentId); | 1856 | await reconnectSavedEnvironment(environmentId); |
| 971 | } catch (error) { | 1857 | } catch (error) { |
| 972 | const message = error instanceof Error ? error.message : "Failed to reconnect backend."; | 1858 | const message = error instanceof Error ? error.message : "Failed to connect backend."; |
| 973 | setSavedBackendError(message); | 1859 | setSavedBackendError(message); |
| 974 | toastManager.add( | 1860 | toastManager.add( |
| 975 | stackedThreadToast({ | 1861 | stackedThreadToast({ |
| 976 | type: "error", | 1862 | type: "error", |
| 977 | title: "Could not reconnect backend", | 1863 | title: "Could not connect backend", |
| 978 | description: message, | 1864 | description: message, |
| 979 | }), | 1865 | }), |
| 980 | ); | 1866 | ); |
| ... | @@ -983,6 +1869,26 @@ export function ConnectionsSettings() { | ... | @@ -983,6 +1869,26 @@ export function ConnectionsSettings() { |
| 983 | } | 1869 | } |
| 984 | }, []); | 1870 | }, []); |
| 985 | 1871 | ||
| 1872 | const handleDisconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | ||
| 1873 | setDisconnectingSavedEnvironmentId(environmentId); | ||
| 1874 | setSavedBackendError(null); | ||
| 1875 | try { | ||
| 1876 | await disconnectSavedEnvironment(environmentId); | ||
| 1877 | } catch (error) { | ||
| 1878 | const message = error instanceof Error ? error.message : "Failed to disconnect backend."; | ||
| 1879 | setSavedBackendError(message); | ||
| 1880 | toastManager.add( | ||
| 1881 | stackedThreadToast({ | ||
| 1882 | type: "error", | ||
| 1883 | title: "Could not disconnect backend", | ||
| 1884 | description: message, | ||
| 1885 | }), | ||
| 1886 | ); | ||
| 1887 | } finally { | ||
| 1888 | setDisconnectingSavedEnvironmentId(null); | ||
| 1889 | } | ||
| 1890 | }, []); | ||
| 1891 | |||
| 986 | const handleRemoveSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | 1892 | const handleRemoveSavedBackend = useCallback(async (environmentId: EnvironmentId) => { |
| 987 | setRemovingSavedEnvironmentId(environmentId); | 1893 | setRemovingSavedEnvironmentId(environmentId); |
| 988 | setSavedBackendError(null); | 1894 | setSavedBackendError(null); |
| ... | @@ -1003,6 +1909,84 @@ export function ConnectionsSettings() { | ... | @@ -1003,6 +1909,84 @@ export function ConnectionsSettings() { |
| 1003 | } | 1909 | } |
| 1004 | }, []); | 1910 | }, []); |
| 1005 | 1911 | ||
| 1912 | const loadDiscoveredSshHosts = useCallback(async () => { | ||
| 1913 | if (!desktopBridge) { | ||
| 1914 | setDiscoveredSshHosts([]); | ||
| 1915 | setHasLoadedDiscoveredSshHosts(false); | ||
| 1916 | setDiscoveredSshHostsError(null); | ||
| 1917 | return; | ||
| 1918 | } | ||
| 1919 | |||
| 1920 | setIsLoadingDiscoveredSshHosts(true); | ||
| 1921 | setDiscoveredSshHostsError(null); | ||
| 1922 | try { | ||
| 1923 | const hosts = await desktopBridge.discoverSshHosts(); | ||
| 1924 | setDiscoveredSshHosts(hosts); | ||
| 1925 | setHasLoadedDiscoveredSshHosts(true); | ||
| 1926 | } catch (error) { | ||
| 1927 | const message = error instanceof Error ? error.message : "Failed to discover SSH hosts."; | ||
| 1928 | setDiscoveredSshHostsError(message); | ||
| 1929 | setHasLoadedDiscoveredSshHosts(true); | ||
| 1930 | } finally { | ||
| 1931 | setIsLoadingDiscoveredSshHosts(false); | ||
| 1932 | } | ||
| 1933 | }, [desktopBridge]); | ||
| 1934 | |||
| 1935 | const handleConnectSshHost = useCallback( | ||
| 1936 | async (target: DesktopSshEnvironmentTarget, label?: string) => { | ||
| 1937 | setConnectingSshHostAlias(target.alias); | ||
| 1938 | if (savedBackendMode === "ssh") { | ||
| 1939 | setSavedBackendError(null); | ||
| 1940 | } else { | ||
| 1941 | setDiscoveredSshHostsError(null); | ||
| 1942 | } | ||
| 1943 | try { | ||
| 1944 | const record = await connectDesktopSshEnvironment( | ||
| 1945 | target, | ||
| 1946 | label === undefined ? undefined : { label }, | ||
| 1947 | ); | ||
| 1948 | setSavedBackendSshHost(""); | ||
| 1949 | setSavedBackendSshUsername(""); | ||
| 1950 | setSavedBackendSshPort(""); | ||
| 1951 | setAddBackendDialogOpen(false); | ||
| 1952 | toastManager.add({ | ||
| 1953 | type: "success", | ||
| 1954 | title: savedDesktopSshEnvironmentsByAlias[target.alias] | ||
| 1955 | ? "Environment reconnected" | ||
| 1956 | : "Environment connected", | ||
| 1957 | description: `${record.label} is ready over an SSH-managed tunnel.`, | ||
| 1958 | }); | ||
| 1959 | } catch (error) { | ||
| 1960 | const message = formatDesktopSshConnectionError(error); | ||
| 1961 | if (savedBackendMode === "ssh") { | ||
| 1962 | setSavedBackendError(message); | ||
| 1963 | } else { | ||
| 1964 | setDiscoveredSshHostsError(message); | ||
| 1965 | } | ||
| 1966 | } finally { | ||
| 1967 | setConnectingSshHostAlias(null); | ||
| 1968 | } | ||
| 1969 | }, | ||
| 1970 | [savedBackendMode, savedDesktopSshEnvironmentsByAlias], | ||
| 1971 | ); | ||
| 1972 | |||
| 1973 | useEffect(() => { | ||
| 1974 | if (!desktopBridge || !addBackendDialogOpen || savedBackendMode !== "ssh") { | ||
| 1975 | return; | ||
| 1976 | } | ||
| 1977 | if (hasLoadedDiscoveredSshHosts || isLoadingDiscoveredSshHosts) { | ||
| 1978 | return; | ||
| 1979 | } | ||
| 1980 | void loadDiscoveredSshHosts(); | ||
| 1981 | }, [ | ||
| 1982 | addBackendDialogOpen, | ||
| 1983 | desktopBridge, | ||
| 1984 | hasLoadedDiscoveredSshHosts, | ||
| 1985 | isLoadingDiscoveredSshHosts, | ||
| 1986 | loadDiscoveredSshHosts, | ||
| 1987 | savedBackendMode, | ||
| 1988 | ]); | ||
| 1989 | |||
| 1006 | useEffect(() => { | 1990 | useEffect(() => { |
| 1007 | if (desktopBridge) { | 1991 | if (desktopBridge) { |
| 1008 | setCurrentSessionRole("owner"); | 1992 | setCurrentSessionRole("owner"); |
| ... | @@ -1105,8 +2089,21 @@ export function ConnectionsSettings() { | ... | @@ -1105,8 +2089,21 @@ export function ConnectionsSettings() { |
| 1105 | error instanceof Error ? error.message : "Failed to load network exposure state."; | 2089 | error instanceof Error ? error.message : "Failed to load network exposure state."; |
| 1106 | setDesktopServerExposureError(message); | 2090 | setDesktopServerExposureError(message); |
| 1107 | }); | 2091 | }); |
| 2092 | void desktopBridge | ||
| 2093 | .getAdvertisedEndpoints() | ||
| 2094 | .then((endpoints) => { | ||
| 2095 | if (cancelled) return; | ||
| 2096 | setDesktopAdvertisedEndpoints(endpoints); | ||
| 2097 | }) | ||
| 2098 | .catch((error: unknown) => { | ||
| 2099 | if (cancelled) return; | ||
| 2100 | const message = | ||
| 2101 | error instanceof Error ? error.message : "Failed to load reachable endpoints."; | ||
| 2102 | setDesktopServerExposureError(message); | ||
| 2103 | }); | ||
| 1108 | } else { | 2104 | } else { |
| 1109 | setDesktopServerExposureState(null); | 2105 | setDesktopServerExposureState(null); |
| 2106 | setDesktopAdvertisedEndpoints([]); | ||
| 1110 | setDesktopServerExposureError(null); | 2107 | setDesktopServerExposureError(null); |
| 1111 | } | 2108 | } |
| 1112 | 2109 | ||
| ... | @@ -1123,131 +2120,420 @@ export function ConnectionsSettings() { | ... | @@ -1123,131 +2120,420 @@ export function ConnectionsSettings() { |
| 1123 | setDesktopClientSessions([]); | 2120 | setDesktopClientSessions([]); |
| 1124 | setDesktopAccessManagementError(null); | 2121 | setDesktopAccessManagementError(null); |
| 1125 | setDesktopServerExposureState(null); | 2122 | setDesktopServerExposureState(null); |
| 2123 | setDesktopAdvertisedEndpoints([]); | ||
| 1126 | setDesktopServerExposureError(null); | 2124 | setDesktopServerExposureError(null); |
| 1127 | }, [canManageLocalBackend]); | 2125 | }, [canManageLocalBackend]); |
| 1128 | const visibleDesktopPairingLinks = useMemo( | 2126 | const visibleDesktopPairingLinks = useMemo( |
| 1129 | () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"), | 2127 | () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"), |
| 1130 | [desktopPairingLinks], | 2128 | [desktopPairingLinks], |
| 1131 | ); | 2129 | ); |
| 2130 | const tailscaleHttpsEndpoint = useMemo( | ||
| 2131 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, | ||
| 2132 | [desktopAdvertisedEndpoints], | ||
| 2133 | ); | ||
| 2134 | const visibleDesktopNetworkAdvertisedEndpoints = useMemo( | ||
| 2135 | () => | ||
| 2136 | isLocalBackendNetworkAccessible | ||
| 2137 | ? desktopAdvertisedEndpoints.filter((endpoint) => !isTailscaleHttpsEndpoint(endpoint)) | ||
| 2138 | : [], | ||
| 2139 | [desktopAdvertisedEndpoints, isLocalBackendNetworkAccessible], | ||
| 2140 | ); | ||
| 2141 | const visibleDesktopAdvertisedEndpoints = useMemo( | ||
| 2142 | () => | ||
| 2143 | tailscaleHttpsEndpoint | ||
| 2144 | ? [...visibleDesktopNetworkAdvertisedEndpoints, tailscaleHttpsEndpoint] | ||
| 2145 | : visibleDesktopNetworkAdvertisedEndpoints, | ||
| 2146 | [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints], | ||
| 2147 | ); | ||
| 2148 | const isLocalBackendRemotelyReachable = | ||
| 2149 | isLocalBackendNetworkAccessible || tailscaleHttpsEndpoint?.status === "available"; | ||
| 2150 | const defaultDesktopNetworkAdvertisedEndpoint = useMemo( | ||
| 2151 | () => | ||
| 2152 | selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey), | ||
| 2153 | [defaultAdvertisedEndpointKey, visibleDesktopNetworkAdvertisedEndpoints], | ||
| 2154 | ); | ||
| 2155 | const defaultDesktopAdvertisedEndpoint = useMemo( | ||
| 2156 | () => | ||
| 2157 | defaultDesktopNetworkAdvertisedEndpoint ?? | ||
| 2158 | selectPairingEndpoint( | ||
| 2159 | tailscaleHttpsEndpoint ? [tailscaleHttpsEndpoint] : [], | ||
| 2160 | defaultAdvertisedEndpointKey, | ||
| 2161 | ), | ||
| 2162 | [defaultAdvertisedEndpointKey, defaultDesktopNetworkAdvertisedEndpoint, tailscaleHttpsEndpoint], | ||
| 2163 | ); | ||
| 2164 | const defaultDesktopAdvertisedEndpointKey = defaultDesktopAdvertisedEndpoint | ||
| 2165 | ? endpointDefaultPreferenceKey(defaultDesktopAdvertisedEndpoint) | ||
| 2166 | : null; | ||
| 2167 | const handleSetDefaultAdvertisedEndpoint = useCallback( | ||
| 2168 | (endpoint: AdvertisedEndpoint) => { | ||
| 2169 | setDefaultAdvertisedEndpointKey(endpointDefaultPreferenceKey(endpoint)); | ||
| 2170 | }, | ||
| 2171 | [setDefaultAdvertisedEndpointKey], | ||
| 2172 | ); | ||
| 2173 | const handleSavedBackendHostChange = useCallback((value: string) => { | ||
| 2174 | const parsedPairingUrl = parsePairingUrlFields(value); | ||
| 2175 | if (parsedPairingUrl) { | ||
| 2176 | setSavedBackendHost(parsedPairingUrl.host); | ||
| 2177 | setSavedBackendPairingCode(parsedPairingUrl.pairingCode); | ||
| 2178 | return; | ||
| 2179 | } | ||
| 2180 | setSavedBackendHost(value); | ||
| 2181 | }, []); | ||
| 2182 | |||
| 2183 | const renderConnectionModeCard = (input: { | ||
| 2184 | readonly mode: "remote" | "ssh"; | ||
| 2185 | readonly title: string; | ||
| 2186 | readonly description: string; | ||
| 2187 | readonly icon?: ReactNode; | ||
| 2188 | }) => { | ||
| 2189 | const selected = savedBackendMode === input.mode; | ||
| 2190 | return ( | ||
| 2191 | <button | ||
| 2192 | type="button" | ||
| 2193 | aria-pressed={selected} | ||
| 2194 | className={cn( | ||
| 2195 | "group flex min-h-24 items-start gap-3 rounded-lg border p-4 text-left", | ||
| 2196 | selected ? "border-primary/50 bg-primary/5" : "border-border/60 hover:bg-muted/40", | ||
| 2197 | )} | ||
| 2198 | disabled={isAddingSavedBackend} | ||
| 2199 | onClick={() => { | ||
| 2200 | setSavedBackendMode(input.mode); | ||
| 2201 | }} | ||
| 2202 | > | ||
| 2203 | {input.icon ? ( | ||
| 2204 | <span | ||
| 2205 | className={cn( | ||
| 2206 | "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border", | ||
| 2207 | selected | ||
| 2208 | ? "border-primary/30 bg-primary/10 text-primary" | ||
| 2209 | : "border-border/70 bg-background text-muted-foreground group-hover:text-foreground", | ||
| 2210 | )} | ||
| 2211 | > | ||
| 2212 | {input.icon} | ||
| 2213 | </span> | ||
| 2214 | ) : null} | ||
| 2215 | <span className="min-w-0"> | ||
| 2216 | <span className="block text-sm font-medium text-foreground">{input.title}</span> | ||
| 2217 | <span className="mt-1 block text-xs leading-relaxed text-muted-foreground"> | ||
| 2218 | {input.description} | ||
| 2219 | </span> | ||
| 2220 | </span> | ||
| 2221 | </button> | ||
| 2222 | ); | ||
| 2223 | }; | ||
| 2224 | |||
| 2225 | const renderRemoteFields = () => ( | ||
| 2226 | <div className="space-y-3"> | ||
| 2227 | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]"> | ||
| 2228 | <label className="block"> | ||
| 2229 | <span className="mb-1.5 block text-xs font-medium text-foreground">Host</span> | ||
| 2230 | <Input | ||
| 2231 | value={savedBackendHost} | ||
| 2232 | onChange={(event) => handleSavedBackendHostChange(event.target.value)} | ||
| 2233 | placeholder="backend.example.com" | ||
| 2234 | disabled={isAddingSavedBackend} | ||
| 2235 | spellCheck={false} | ||
| 2236 | /> | ||
| 2237 | </label> | ||
| 2238 | <label className="block"> | ||
| 2239 | <span className="mb-1.5 block text-xs font-medium text-foreground">Pairing code</span> | ||
| 2240 | <Input | ||
| 2241 | value={savedBackendPairingCode} | ||
| 2242 | onChange={(event) => setSavedBackendPairingCode(event.target.value)} | ||
| 2243 | placeholder="PAIRCODE" | ||
| 2244 | disabled={isAddingSavedBackend} | ||
| 2245 | spellCheck={false} | ||
| 2246 | /> | ||
| 2247 | </label> | ||
| 2248 | </div> | ||
| 2249 | <div> | ||
| 2250 | <span className="mt-1 block text-[11px] text-muted-foreground"> | ||
| 2251 | Paste a full pairing URL here to fill both fields automatically. | ||
| 2252 | </span> | ||
| 2253 | </div> | ||
| 2254 | </div> | ||
| 2255 | ); | ||
| 2256 | const renderRemoteModeBody = () => ( | ||
| 2257 | <div className="space-y-4"> | ||
| 2258 | {renderRemoteFields()} | ||
| 2259 | {savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null} | ||
| 2260 | <Button | ||
| 2261 | variant="outline" | ||
| 2262 | className="w-full" | ||
| 2263 | disabled={isAddingSavedBackend} | ||
| 2264 | onClick={() => void handleAddSavedBackend()} | ||
| 2265 | > | ||
| 2266 | <PlusIcon className="size-3.5" /> | ||
| 2267 | {isAddingSavedBackend ? "Adding…" : "Add environment"} | ||
| 2268 | </Button> | ||
| 2269 | </div> | ||
| 2270 | ); | ||
| 2271 | const renderSshFields = () => ( | ||
| 2272 | <div className="space-y-4"> | ||
| 2273 | <div className="space-y-3"> | ||
| 2274 | <label className="block"> | ||
| 2275 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | ||
| 2276 | SSH host or alias | ||
| 2277 | </span> | ||
| 2278 | <Input | ||
| 2279 | value={savedBackendSshHost} | ||
| 2280 | onChange={(event) => setSavedBackendSshHost(event.target.value)} | ||
| 2281 | placeholder="Search hosts or type devbox" | ||
| 2282 | disabled={isAddingSavedBackend} | ||
| 2283 | spellCheck={false} | ||
| 2284 | /> | ||
| 2285 | </label> | ||
| 2286 | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem]"> | ||
| 2287 | <label className="block"> | ||
| 2288 | <span className="mb-1.5 block text-xs font-medium text-foreground">Username</span> | ||
| 2289 | <Input | ||
| 2290 | value={savedBackendSshUsername} | ||
| 2291 | onChange={(event) => setSavedBackendSshUsername(event.target.value)} | ||
| 2292 | placeholder="julius" | ||
| 2293 | disabled={isAddingSavedBackend} | ||
| 2294 | spellCheck={false} | ||
| 2295 | /> | ||
| 2296 | </label> | ||
| 2297 | <label className="block"> | ||
| 2298 | <span className="mb-1.5 block text-xs font-medium text-foreground">Port</span> | ||
| 2299 | <Input | ||
| 2300 | value={savedBackendSshPort} | ||
| 2301 | onChange={(event) => setSavedBackendSshPort(event.target.value)} | ||
| 2302 | placeholder="22" | ||
| 2303 | inputMode="numeric" | ||
| 2304 | disabled={isAddingSavedBackend} | ||
| 2305 | spellCheck={false} | ||
| 2306 | /> | ||
| 2307 | </label> | ||
| 2308 | </div> | ||
| 2309 | {savedBackendError || discoveredSshHostsError ? ( | ||
| 2310 | <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"> | ||
| 2311 | {savedBackendError ?? discoveredSshHostsError} | ||
| 2312 | </div> | ||
| 2313 | ) : null} | ||
| 2314 | <Button | ||
| 2315 | variant="outline" | ||
| 2316 | className="w-full" | ||
| 2317 | disabled={isAddingSavedBackend} | ||
| 2318 | onClick={() => void handleAddSavedBackend()} | ||
| 2319 | > | ||
| 2320 | <PlusIcon className="size-3.5" /> | ||
| 2321 | {isAddingSavedBackend ? "Adding…" : "Add environment"} | ||
| 2322 | </Button> | ||
| 2323 | </div> | ||
| 2324 | <div className="overflow-hidden rounded-lg border border-border/60"> | ||
| 2325 | <div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2"> | ||
| 2326 | <div className="min-w-0"> | ||
| 2327 | <p className="text-xs font-medium text-foreground">Suggested hosts</p> | ||
| 2328 | <p className="text-[11px] text-muted-foreground">From SSH config and known hosts</p> | ||
| 2329 | </div> | ||
| 2330 | <Button | ||
| 2331 | size="xs" | ||
| 2332 | variant="ghost" | ||
| 2333 | disabled={isLoadingDiscoveredSshHosts} | ||
| 2334 | onClick={() => void loadDiscoveredSshHosts()} | ||
| 2335 | > | ||
| 2336 | {isLoadingDiscoveredSshHosts ? ( | ||
| 2337 | <RefreshCwIcon className="size-3 animate-spin" /> | ||
| 2338 | ) : ( | ||
| 2339 | <RefreshCwIcon className="size-3" /> | ||
| 2340 | )} | ||
| 2341 | Refresh | ||
| 2342 | </Button> | ||
| 2343 | </div> | ||
| 2344 | <ScrollArea scrollFade className="max-h-56"> | ||
| 2345 | <div> | ||
| 2346 | {unsavedDiscoveredSshHosts.map((target) => ( | ||
| 2347 | <DesktopSshHostRow | ||
| 2348 | key={`${target.alias}:${target.hostname}:${target.port ?? ""}`} | ||
| 2349 | target={target} | ||
| 2350 | connectingHostAlias={connectingSshHostAlias} | ||
| 2351 | onConnect={(nextTarget) => void handleConnectSshHost(nextTarget)} | ||
| 2352 | /> | ||
| 2353 | ))} | ||
| 2354 | {hasLoadedDiscoveredSshHosts && | ||
| 2355 | !isLoadingDiscoveredSshHosts && | ||
| 2356 | unsavedDiscoveredSshHosts.length === 0 ? ( | ||
| 2357 | <div className={ITEM_ROW_CLASSNAME}> | ||
| 2358 | <p className="text-xs text-muted-foreground">No new SSH hosts were discovered.</p> | ||
| 2359 | </div> | ||
| 2360 | ) : null} | ||
| 2361 | </div> | ||
| 2362 | </ScrollArea> | ||
| 2363 | </div> | ||
| 2364 | </div> | ||
| 2365 | ); | ||
| 2366 | const renderNetworkAccessToggle = () => ( | ||
| 2367 | <Switch | ||
| 2368 | checked={desktopServerExposureState?.mode === "network-accessible"} | ||
| 2369 | disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure} | ||
| 2370 | onCheckedChange={(checked) => { | ||
| 2371 | setPendingDesktopServerExposureMode(checked ? "network-accessible" : "local-only"); | ||
| 2372 | setIsDesktopServerExposureDialogOpen(true); | ||
| 2373 | }} | ||
| 2374 | aria-label="Enable network access" | ||
| 2375 | /> | ||
| 2376 | ); | ||
| 2377 | const renderEndpointRows = (presentation: AccessSectionPresentation) => | ||
| 2378 | isAdvertisedEndpointListExpanded | ||
| 2379 | ? visibleDesktopNetworkAdvertisedEndpoints.map((endpoint) => { | ||
| 2380 | const endpointKey = endpointDefaultPreferenceKey(endpoint); | ||
| 2381 | return ( | ||
| 2382 | <AdvertisedEndpointListRow | ||
| 2383 | key={endpoint.id} | ||
| 2384 | endpoint={endpoint} | ||
| 2385 | isDefault={endpointKey === defaultDesktopAdvertisedEndpointKey} | ||
| 2386 | presentation={presentation} | ||
| 2387 | onSetDefault={handleSetDefaultAdvertisedEndpoint} | ||
| 2388 | onSetupTailscaleServe={handleStartTailscaleServeSetup} | ||
| 2389 | onDisableTailscaleServe={handleStartTailscaleServeDisable} | ||
| 2390 | isUpdatingTailscaleServe={isUpdatingTailscaleServe} | ||
| 2391 | /> | ||
| 2392 | ); | ||
| 2393 | }) | ||
| 2394 | : null; | ||
| 2395 | const renderTailscaleRow = () => ( | ||
| 2396 | <SettingsRow | ||
| 2397 | title="Tailscale HTTPS" | ||
| 2398 | description={ | ||
| 2399 | tailscaleHttpsEndpoint | ||
| 2400 | ? tailscaleHttpsEndpoint.status === "available" | ||
| 2401 | ? tailscaleHttpsEndpoint.httpBaseUrl | ||
| 2402 | : "Use Tailscale Serve to expose this backend through a MagicDNS HTTPS URL." | ||
| 2403 | : "Start Tailscale to set up HTTPS access through MagicDNS." | ||
| 2404 | } | ||
| 2405 | control={ | ||
| 2406 | tailscaleHttpsEndpoint ? ( | ||
| 2407 | <Switch | ||
| 2408 | checked={tailscaleHttpsEndpoint.status === "available"} | ||
| 2409 | disabled={isUpdatingTailscaleServe} | ||
| 2410 | onCheckedChange={(checked) => { | ||
| 2411 | if (checked) { | ||
| 2412 | handleStartTailscaleServeSetup(tailscaleHttpsEndpoint); | ||
| 2413 | return; | ||
| 2414 | } | ||
| 2415 | handleStartTailscaleServeDisable(tailscaleHttpsEndpoint); | ||
| 2416 | }} | ||
| 2417 | aria-label="Enable Tailscale HTTPS" | ||
| 2418 | /> | ||
| 2419 | ) : null | ||
| 2420 | } | ||
| 2421 | /> | ||
| 2422 | ); | ||
| 2423 | const renderAuthorizedClients = (presentation: AccessSectionPresentation) => ( | ||
| 2424 | <> | ||
| 2425 | {desktopAccessManagementError ? ( | ||
| 2426 | <div className={accessRowClassName(presentation)}> | ||
| 2427 | <p className="text-xs text-destructive">{desktopAccessManagementError}</p> | ||
| 2428 | </div> | ||
| 2429 | ) : null} | ||
| 2430 | <PairingClientsList | ||
| 2431 | endpointUrl={desktopServerExposureState?.endpointUrl} | ||
| 2432 | endpoints={visibleDesktopAdvertisedEndpoints} | ||
| 2433 | defaultEndpointKey={defaultDesktopAdvertisedEndpointKey} | ||
| 2434 | presentation={presentation} | ||
| 2435 | isLoading={isLoadingDesktopAccessManagement} | ||
| 2436 | pairingLinks={visibleDesktopPairingLinks} | ||
| 2437 | clientSessions={desktopClientSessions} | ||
| 2438 | revokingPairingLinkId={revokingDesktopPairingLinkId} | ||
| 2439 | revokingClientSessionId={revokingDesktopClientSessionId} | ||
| 2440 | onRevokePairingLink={handleRevokeDesktopPairingLink} | ||
| 2441 | onRevokeClientSession={handleRevokeDesktopClientSession} | ||
| 2442 | /> | ||
| 2443 | </> | ||
| 2444 | ); | ||
| 2445 | const renderNetworkAccessRow = () => ( | ||
| 2446 | <SettingsRow | ||
| 2447 | title="Network access" | ||
| 2448 | description={ | ||
| 2449 | isLocalBackendNetworkAccessible ? ( | ||
| 2450 | <NetworkAccessDescription | ||
| 2451 | endpoint={defaultDesktopNetworkAdvertisedEndpoint} | ||
| 2452 | hiddenEndpointCount={Math.max(visibleDesktopNetworkAdvertisedEndpoints.length - 1, 0)} | ||
| 2453 | expanded={isAdvertisedEndpointListExpanded} | ||
| 2454 | onToggleExpanded={() => setIsAdvertisedEndpointListExpanded((expanded) => !expanded)} | ||
| 2455 | fallback={ | ||
| 2456 | desktopServerExposureState?.endpointUrl | ||
| 2457 | ? `Reachable at ${desktopServerExposureState.endpointUrl}` | ||
| 2458 | : desktopServerExposureState?.advertisedHost | ||
| 2459 | ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.` | ||
| 2460 | : "Exposed on all interfaces." | ||
| 2461 | } | ||
| 2462 | /> | ||
| 2463 | ) : desktopServerExposureState ? ( | ||
| 2464 | "Limited to this machine." | ||
| 2465 | ) : ( | ||
| 2466 | "Loading…" | ||
| 2467 | ) | ||
| 2468 | } | ||
| 2469 | status={ | ||
| 2470 | desktopServerExposureError ? ( | ||
| 2471 | <span className="block text-destructive">{desktopServerExposureError}</span> | ||
| 2472 | ) : null | ||
| 2473 | } | ||
| 2474 | control={renderNetworkAccessToggle()} | ||
| 2475 | /> | ||
| 2476 | ); | ||
| 2477 | const renderDisabledNetworkAccessRow = () => ( | ||
| 2478 | <SettingsRow | ||
| 2479 | title="Network access" | ||
| 2480 | description={ | ||
| 2481 | currentAuthPolicy === "remote-reachable" | ||
| 2482 | ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched." | ||
| 2483 | : "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing." | ||
| 2484 | } | ||
| 2485 | control={ | ||
| 2486 | <Tooltip> | ||
| 2487 | <TooltipTrigger | ||
| 2488 | render={ | ||
| 2489 | <span className="inline-flex"> | ||
| 2490 | <Switch | ||
| 2491 | checked={isLocalBackendNetworkAccessible} | ||
| 2492 | disabled | ||
| 2493 | aria-label="Enable network access" | ||
| 2494 | /> | ||
| 2495 | </span> | ||
| 2496 | } | ||
| 2497 | /> | ||
| 2498 | <TooltipPopup side="top"> | ||
| 2499 | Network exposure changes restart the backend and must be controlled where the server | ||
| 2500 | process is launched. | ||
| 2501 | </TooltipPopup> | ||
| 2502 | </Tooltip> | ||
| 2503 | } | ||
| 2504 | /> | ||
| 2505 | ); | ||
| 2506 | |||
| 1132 | return ( | 2507 | return ( |
| 1133 | <SettingsPageContainer> | 2508 | <SettingsPageContainer> |
| 1134 | {canManageLocalBackend ? ( | 2509 | {canManageLocalBackend ? ( |
| 1135 | <> | 2510 | <> |
| 1136 | <SettingsSection title="Manage local backend"> | 2511 | <SettingsSection title="Manage local backend"> |
| 1137 | {desktopBridge ? ( | 2512 | {primaryVersionMismatch ? ( |
| 1138 | <SettingsRow | 2513 | <SettingsRow |
| 1139 | title="Network access" | 2514 | title="Version drift" |
| 1140 | description={ | 2515 | description={ |
| 1141 | desktopServerExposureState?.endpointUrl | 2516 | <span className="flex items-center gap-1 text-warning"> |
| 1142 | ? `Reachable at ${desktopServerExposureState.endpointUrl}` | 2517 | <TriangleAlertIcon className="size-3.5 shrink-0" /> |
| 1143 | : desktopServerExposureState?.mode === "network-accessible" | 2518 | Client {primaryVersionMismatch.clientVersion}, server{" "} |
| 1144 | ? desktopServerExposureState.advertisedHost | 2519 | {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects |
| 1145 | ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.` | 2520 | fail. |
| 1146 | : "Exposed on all interfaces." | 2521 | </span> |
| 1147 | : desktopServerExposureState | ||
| 1148 | ? "Limited to this machine." | ||
| 1149 | : "Loading…" | ||
| 1150 | } | ||
| 1151 | status={ | ||
| 1152 | desktopServerExposureError ? ( | ||
| 1153 | <span className="block text-destructive">{desktopServerExposureError}</span> | ||
| 1154 | ) : null | ||
| 1155 | } | ||
| 1156 | control={ | ||
| 1157 | <AlertDialog | ||
| 1158 | open={pendingDesktopServerExposureMode !== null} | ||
| 1159 | onOpenChange={(open) => { | ||
| 1160 | if (isUpdatingDesktopServerExposure) return; | ||
| 1161 | if (!open) setPendingDesktopServerExposureMode(null); | ||
| 1162 | }} | ||
| 1163 | > | ||
| 1164 | <Switch | ||
| 1165 | checked={desktopServerExposureState?.mode === "network-accessible"} | ||
| 1166 | disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure} | ||
| 1167 | onCheckedChange={(checked) => { | ||
| 1168 | setPendingDesktopServerExposureMode( | ||
| 1169 | checked ? "network-accessible" : "local-only", | ||
| 1170 | ); | ||
| 1171 | }} | ||
| 1172 | aria-label="Enable network access" | ||
| 1173 | /> | ||
| 1174 | <AlertDialogPopup> | ||
| 1175 | <AlertDialogHeader> | ||
| 1176 | <AlertDialogTitle> | ||
| 1177 | {pendingDesktopServerExposureMode === "network-accessible" | ||
| 1178 | ? "Enable network access?" | ||
| 1179 | : "Disable network access?"} | ||
| 1180 | </AlertDialogTitle> | ||
| 1181 | <AlertDialogDescription> | ||
| 1182 | {pendingDesktopServerExposureMode === "network-accessible" | ||
| 1183 | ? "T3 Code will restart to expose this environment over the network." | ||
| 1184 | : "T3 Code will restart and limit this environment back to this machine."} | ||
| 1185 | </AlertDialogDescription> | ||
| 1186 | </AlertDialogHeader> | ||
| 1187 | <AlertDialogFooter> | ||
| 1188 | <AlertDialogClose | ||
| 1189 | disabled={isUpdatingDesktopServerExposure} | ||
| 1190 | render={ | ||
| 1191 | <Button variant="outline" disabled={isUpdatingDesktopServerExposure} /> | ||
| 1192 | } | ||
| 1193 | > | ||
| 1194 | Cancel | ||
| 1195 | </AlertDialogClose> | ||
| 1196 | <Button | ||
| 1197 | onClick={handleConfirmDesktopServerExposureChange} | ||
| 1198 | disabled={ | ||
| 1199 | pendingDesktopServerExposureMode === null || | ||
| 1200 | isUpdatingDesktopServerExposure | ||
| 1201 | } | ||
| 1202 | > | ||
| 1203 | {isUpdatingDesktopServerExposure ? ( | ||
| 1204 | <> | ||
| 1205 | <Spinner className="size-3.5" /> | ||
| 1206 | Restarting… | ||
| 1207 | </> | ||
| 1208 | ) : pendingDesktopServerExposureMode === "network-accessible" ? ( | ||
| 1209 | "Restart and enable" | ||
| 1210 | ) : ( | ||
| 1211 | "Restart and disable" | ||
| 1212 | )} | ||
| 1213 | </Button> | ||
| 1214 | </AlertDialogFooter> | ||
| 1215 | </AlertDialogPopup> | ||
| 1216 | </AlertDialog> | ||
| 1217 | } | 2522 | } |
| 1218 | /> | 2523 | /> |
| 2524 | ) : null} | ||
| 2525 | {desktopBridge ? ( | ||
| 2526 | <> | ||
| 2527 | {renderNetworkAccessRow()} | ||
| 2528 | {renderEndpointRows("endpoint-rail")} | ||
| 2529 | {renderTailscaleRow()} | ||
| 2530 | </> | ||
| 1219 | ) : ( | 2531 | ) : ( |
| 1220 | <SettingsRow | 2532 | renderDisabledNetworkAccessRow() |
| 1221 | title="Network access" | ||
| 1222 | description={ | ||
| 1223 | currentAuthPolicy === "remote-reachable" | ||
| 1224 | ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched." | ||
| 1225 | : "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing." | ||
| 1226 | } | ||
| 1227 | control={ | ||
| 1228 | <Tooltip> | ||
| 1229 | <TooltipTrigger | ||
| 1230 | render={ | ||
| 1231 | <span className="inline-flex"> | ||
| 1232 | <Switch | ||
| 1233 | checked={isLocalBackendNetworkAccessible} | ||
| 1234 | disabled | ||
| 1235 | aria-label="Enable network access" | ||
| 1236 | /> | ||
| 1237 | </span> | ||
| 1238 | } | ||
| 1239 | /> | ||
| 1240 | <TooltipPopup side="top"> | ||
| 1241 | Network exposure changes restart the backend and must be controlled where the | ||
| 1242 | server process is launched. | ||
| 1243 | </TooltipPopup> | ||
| 1244 | </Tooltip> | ||
| 1245 | } | ||
| 1246 | /> | ||
| 1247 | )} | 2533 | )} |
| 1248 | </SettingsSection> | 2534 | </SettingsSection> |
| 1249 | 2535 | ||
| 1250 | {isLocalBackendNetworkAccessible ? ( | 2536 | {isLocalBackendRemotelyReachable ? ( |
| 1251 | <SettingsSection | 2537 | <SettingsSection |
| 1252 | title="Authorized clients" | 2538 | title="Authorized clients" |
| 1253 | headerAction={ | 2539 | headerAction={ |
| ... | @@ -1258,23 +2544,166 @@ export function ConnectionsSettings() { | ... | @@ -1258,23 +2544,166 @@ export function ConnectionsSettings() { |
| 1258 | /> | 2544 | /> |
| 1259 | } | 2545 | } |
| 1260 | > | 2546 | > |
| 1261 | {desktopAccessManagementError ? ( | 2547 | {renderAuthorizedClients("current")} |
| 1262 | <div className={ITEM_ROW_CLASSNAME}> | ||
| 1263 | <p className="text-xs text-destructive">{desktopAccessManagementError}</p> | ||
| 1264 | </div> | ||
| 1265 | ) : null} | ||
| 1266 | <PairingClientsList | ||
| 1267 | endpointUrl={desktopServerExposureState?.endpointUrl} | ||
| 1268 | isLoading={isLoadingDesktopAccessManagement} | ||
| 1269 | pairingLinks={visibleDesktopPairingLinks} | ||
| 1270 | clientSessions={desktopClientSessions} | ||
| 1271 | revokingPairingLinkId={revokingDesktopPairingLinkId} | ||
| 1272 | revokingClientSessionId={revokingDesktopClientSessionId} | ||
| 1273 | onRevokePairingLink={handleRevokeDesktopPairingLink} | ||
| 1274 | onRevokeClientSession={handleRevokeDesktopClientSession} | ||
| 1275 | /> | ||
| 1276 | </SettingsSection> | 2548 | </SettingsSection> |
| 1277 | ) : null} | 2549 | ) : null} |
| 2550 | <AlertDialog | ||
| 2551 | open={isDesktopServerExposureDialogOpen} | ||
| 2552 | onOpenChange={(open) => { | ||
| 2553 | if (isUpdatingDesktopServerExposure) return; | ||
| 2554 | setIsDesktopServerExposureDialogOpen(open); | ||
| 2555 | }} | ||
| 2556 | onOpenChangeComplete={(open) => { | ||
| 2557 | if (!open) setPendingDesktopServerExposureMode(null); | ||
| 2558 | }} | ||
| 2559 | > | ||
| 2560 | <AlertDialogPopup> | ||
| 2561 | <AlertDialogHeader> | ||
| 2562 | <AlertDialogTitle> | ||
| 2563 | {pendingDesktopServerExposureMode === "network-accessible" | ||
| 2564 | ? "Enable network access?" | ||
| 2565 | : "Disable network access?"} | ||
| 2566 | </AlertDialogTitle> | ||
| 2567 | <AlertDialogDescription> | ||
| 2568 | {pendingDesktopServerExposureMode === "network-accessible" | ||
| 2569 | ? "T3 Code will restart to expose this environment over the network." | ||
| 2570 | : "T3 Code will restart and limit this environment back to this machine."} | ||
| 2571 | </AlertDialogDescription> | ||
| 2572 | </AlertDialogHeader> | ||
| 2573 | <AlertDialogFooter> | ||
| 2574 | <AlertDialogClose | ||
| 2575 | disabled={isUpdatingDesktopServerExposure} | ||
| 2576 | render={<Button variant="outline" disabled={isUpdatingDesktopServerExposure} />} | ||
| 2577 | > | ||
| 2578 | Cancel | ||
| 2579 | </AlertDialogClose> | ||
| 2580 | <Button | ||
| 2581 | variant={ | ||
| 2582 | pendingDesktopServerExposureMode === "local-only" ? "destructive" : "default" | ||
| 2583 | } | ||
| 2584 | onClick={handleConfirmDesktopServerExposureChange} | ||
| 2585 | disabled={ | ||
| 2586 | pendingDesktopServerExposureMode === null || isUpdatingDesktopServerExposure | ||
| 2587 | } | ||
| 2588 | > | ||
| 2589 | {isUpdatingDesktopServerExposure ? ( | ||
| 2590 | <> | ||
| 2591 | <Spinner className="size-3.5" /> | ||
| 2592 | Restarting… | ||
| 2593 | </> | ||
| 2594 | ) : pendingDesktopServerExposureMode === "network-accessible" ? ( | ||
| 2595 | "Restart and enable" | ||
| 2596 | ) : ( | ||
| 2597 | "Restart and disable" | ||
| 2598 | )} | ||
| 2599 | </Button> | ||
| 2600 | </AlertDialogFooter> | ||
| 2601 | </AlertDialogPopup> | ||
| 2602 | </AlertDialog> | ||
| 2603 | <AlertDialog | ||
| 2604 | open={disableTailscaleServeDialogOpen} | ||
| 2605 | onOpenChange={(open) => { | ||
| 2606 | if (isUpdatingTailscaleServe) return; | ||
| 2607 | setDisableTailscaleServeDialogOpen(open); | ||
| 2608 | }} | ||
| 2609 | > | ||
| 2610 | <AlertDialogPopup> | ||
| 2611 | <AlertDialogHeader> | ||
| 2612 | <AlertDialogTitle>Disable Tailscale HTTPS?</AlertDialogTitle> | ||
| 2613 | <AlertDialogDescription> | ||
| 2614 | T3 Code will restart the local backend without Tailscale Serve. | ||
| 2615 | </AlertDialogDescription> | ||
| 2616 | </AlertDialogHeader> | ||
| 2617 | <AlertDialogFooter> | ||
| 2618 | <AlertDialogClose | ||
| 2619 | disabled={isUpdatingTailscaleServe} | ||
| 2620 | render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />} | ||
| 2621 | > | ||
| 2622 | Cancel | ||
| 2623 | </AlertDialogClose> | ||
| 2624 | <Button | ||
| 2625 | variant="destructive" | ||
| 2626 | onClick={() => void handleConfirmTailscaleServeDisable()} | ||
| 2627 | disabled={isUpdatingTailscaleServe} | ||
| 2628 | > | ||
| 2629 | {isUpdatingTailscaleServe ? ( | ||
| 2630 | <> | ||
| 2631 | <Spinner className="size-3.5" /> | ||
| 2632 | Restarting… | ||
| 2633 | </> | ||
| 2634 | ) : ( | ||
| 2635 | "Restart and disable" | ||
| 2636 | )} | ||
| 2637 | </Button> | ||
| 2638 | </AlertDialogFooter> | ||
| 2639 | </AlertDialogPopup> | ||
| 2640 | </AlertDialog> | ||
| 2641 | <Dialog | ||
| 2642 | open={pendingTailscaleServeEndpoint !== null} | ||
| 2643 | onOpenChange={(open) => { | ||
| 2644 | if (isUpdatingTailscaleServe) return; | ||
| 2645 | if (!open) setPendingTailscaleServeEndpoint(null); | ||
| 2646 | }} | ||
| 2647 | > | ||
| 2648 | <DialogPopup className="max-w-md"> | ||
| 2649 | <DialogHeader> | ||
| 2650 | <DialogTitle>Set up Tailscale HTTPS?</DialogTitle> | ||
| 2651 | <DialogDescription> | ||
| 2652 | T3 Code will restart the local backend with Tailscale Serve enabled and ask | ||
| 2653 | Tailscale to proxy HTTPS traffic to this backend. | ||
| 2654 | </DialogDescription> | ||
| 2655 | </DialogHeader> | ||
| 2656 | <DialogPanel className="space-y-4"> | ||
| 2657 | <label className="block"> | ||
| 2658 | <span className="text-sm font-medium text-foreground">HTTPS port</span> | ||
| 2659 | <Input | ||
| 2660 | className="mt-2" | ||
| 2661 | type="number" | ||
| 2662 | inputMode="numeric" | ||
| 2663 | min={1} | ||
| 2664 | max={65_535} | ||
| 2665 | step={1} | ||
| 2666 | value={tailscaleServePortInput} | ||
| 2667 | onChange={(event) => setTailscaleServePortInput(event.target.value)} | ||
| 2668 | disabled={isUpdatingTailscaleServe} | ||
| 2669 | /> | ||
| 2670 | </label> | ||
| 2671 | {!isTailscaleServePortValid ? ( | ||
| 2672 | <p className="mt-2 text-xs text-destructive">Enter a port from 1 to 65535.</p> | ||
| 2673 | ) : null} | ||
| 2674 | <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2"> | ||
| 2675 | <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p> | ||
| 2676 | <p | ||
| 2677 | className="mt-1 truncate text-sm text-foreground" | ||
| 2678 | title={pendingTailscaleServeBaseUrl ?? undefined} | ||
| 2679 | > | ||
| 2680 | {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} | ||
| 2681 | </p> | ||
| 2682 | </div> | ||
| 2683 | </DialogPanel> | ||
| 2684 | <DialogFooter> | ||
| 2685 | <DialogClose | ||
| 2686 | disabled={isUpdatingTailscaleServe} | ||
| 2687 | render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />} | ||
| 2688 | > | ||
| 2689 | Cancel | ||
| 2690 | </DialogClose> | ||
| 2691 | <Button | ||
| 2692 | onClick={() => void handleConfirmTailscaleServeSetup()} | ||
| 2693 | disabled={isUpdatingTailscaleServe || !isTailscaleServePortValid} | ||
| 2694 | > | ||
| 2695 | {isUpdatingTailscaleServe ? ( | ||
| 2696 | <> | ||
| 2697 | <Spinner className="size-3.5" /> | ||
| 2698 | Restarting… | ||
| 2699 | </> | ||
| 2700 | ) : ( | ||
| 2701 | "Enable" | ||
| 2702 | )} | ||
| 2703 | </Button> | ||
| 2704 | </DialogFooter> | ||
| 2705 | </DialogPopup> | ||
| 2706 | </Dialog> | ||
| 1278 | </> | 2707 | </> |
| 1279 | ) : ( | 2708 | ) : ( |
| 1280 | <SettingsSection title="Local backend access"> | 2709 | <SettingsSection title="Local backend access"> |
| ... | @@ -1305,120 +2734,32 @@ export function ConnectionsSettings() { | ... | @@ -1305,120 +2734,32 @@ export function ConnectionsSettings() { |
| 1305 | </Button> | 2734 | </Button> |
| 1306 | } | 2735 | } |
| 1307 | /> | 2736 | /> |
| 1308 | <DialogPopup> | 2737 | <DialogPopup className="max-h-[80dvh] sm:max-w-3xl"> |
| 1309 | <DialogHeader> | 2738 | <DialogHeader> |
| 1310 | <DialogTitle>Add Environment</DialogTitle> | 2739 | <DialogTitle>Add Environment</DialogTitle> |
| 1311 | <DialogDescription>Pair another environment to this client.</DialogDescription> | 2740 | <DialogDescription>Pair another environment to this client.</DialogDescription> |
| 1312 | <div className="flex gap-1 rounded-lg border border-border/60 bg-muted/50 p-1"> | ||
| 1313 | <button | ||
| 1314 | type="button" | ||
| 1315 | className={cn( | ||
| 1316 | "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors", | ||
| 1317 | savedBackendMode === "pairing-url" | ||
| 1318 | ? "bg-background text-foreground shadow-xs" | ||
| 1319 | : "text-muted-foreground hover:text-foreground", | ||
| 1320 | )} | ||
| 1321 | disabled={isAddingSavedBackend} | ||
| 1322 | onClick={() => setSavedBackendMode("pairing-url")} | ||
| 1323 | > | ||
| 1324 | Pairing URL | ||
| 1325 | </button> | ||
| 1326 | <button | ||
| 1327 | type="button" | ||
| 1328 | className={cn( | ||
| 1329 | "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors", | ||
| 1330 | savedBackendMode === "host-code" | ||
| 1331 | ? "bg-background text-foreground shadow-xs" | ||
| 1332 | : "text-muted-foreground hover:text-foreground", | ||
| 1333 | )} | ||
| 1334 | disabled={isAddingSavedBackend} | ||
| 1335 | onClick={() => setSavedBackendMode("host-code")} | ||
| 1336 | > | ||
| 1337 | Host + code | ||
| 1338 | </button> | ||
| 1339 | </div> | ||
| 1340 | </DialogHeader> | 2741 | </DialogHeader> |
| 1341 | <DialogPanel> | 2742 | <DialogPanel> |
| 1342 | <div className="space-y-4"> | 2743 | <div className="space-y-4"> |
| 1343 | {savedBackendMode === "pairing-url" ? ( | 2744 | <div className="grid gap-3 sm:grid-cols-2"> |
| 1344 | <p className="text-xs text-muted-foreground"> | 2745 | {renderConnectionModeCard({ |
| 1345 | Enter the full pairing URL from the environment you want to connect to. | 2746 | mode: "remote", |
| 1346 | </p> | 2747 | title: "Remote link", |
| 1347 | ) : ( | 2748 | description: "Enter a backend host and pairing code.", |
| 1348 | <p className="text-xs text-muted-foreground"> | 2749 | icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />, |
| 1349 | Enter the backend host and pairing code separately. | 2750 | })} |
| 1350 | </p> | 2751 | {desktopBridge |
| 1351 | )} | 2752 | ? renderConnectionModeCard({ |
| 1352 | <div className="space-y-3"> | 2753 | mode: "ssh", |
| 1353 | <label className="block"> | 2754 | title: "SSH", |
| 1354 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | 2755 | description: "Use local SSH config, agent, and tunnels for the backend.", |
| 1355 | Label | 2756 | icon: <TerminalIcon aria-hidden className="size-4" />, |
| 1356 | </span> | 2757 | }) |
| 1357 | <Input | 2758 | : null} |
| 1358 | value={savedBackendLabel} | ||
| 1359 | onChange={(event) => setSavedBackendLabel(event.target.value)} | ||
| 1360 | placeholder="My backend (optional)" | ||
| 1361 | disabled={isAddingSavedBackend} | ||
| 1362 | spellCheck={false} | ||
| 1363 | /> | ||
| 1364 | </label> | ||
| 1365 | {savedBackendMode === "pairing-url" ? ( | ||
| 1366 | <label className="block"> | ||
| 1367 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | ||
| 1368 | Pairing URL | ||
| 1369 | </span> | ||
| 1370 | <Input | ||
| 1371 | value={savedBackendPairingUrl} | ||
| 1372 | onChange={(event) => setSavedBackendPairingUrl(event.target.value)} | ||
| 1373 | placeholder="https://backend.example.com/pair#token=..." | ||
| 1374 | disabled={isAddingSavedBackend} | ||
| 1375 | spellCheck={false} | ||
| 1376 | /> | ||
| 1377 | <span className="mt-1 block text-[11px] text-muted-foreground"> | ||
| 1378 | The full URL including the pairing token. | ||
| 1379 | </span> | ||
| 1380 | </label> | ||
| 1381 | ) : ( | ||
| 1382 | <> | ||
| 1383 | <label className="block"> | ||
| 1384 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | ||
| 1385 | Host | ||
| 1386 | </span> | ||
| 1387 | <Input | ||
| 1388 | value={savedBackendHost} | ||
| 1389 | onChange={(event) => setSavedBackendHost(event.target.value)} | ||
| 1390 | placeholder="https://backend.example.com" | ||
| 1391 | disabled={isAddingSavedBackend} | ||
| 1392 | spellCheck={false} | ||
| 1393 | /> | ||
| 1394 | </label> | ||
| 1395 | <label className="block"> | ||
| 1396 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | ||
| 1397 | Pairing code | ||
| 1398 | </span> | ||
| 1399 | <Input | ||
| 1400 | value={savedBackendPairingCode} | ||
| 1401 | onChange={(event) => setSavedBackendPairingCode(event.target.value)} | ||
| 1402 | placeholder="Pairing code" | ||
| 1403 | disabled={isAddingSavedBackend} | ||
| 1404 | spellCheck={false} | ||
| 1405 | /> | ||
| 1406 | </label> | ||
| 1407 | </> | ||
| 1408 | )} | ||
| 1409 | </div> | 2759 | </div> |
| 1410 | {savedBackendError ? ( | 2760 | <AnimatedHeight> |
| 1411 | <p className="text-xs text-destructive">{savedBackendError}</p> | 2761 | {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} |
| 1412 | ) : null} | 2762 | </AnimatedHeight> |
| 1413 | <Button | ||
| 1414 | variant="outline" | ||
| 1415 | className="w-full" | ||
| 1416 | disabled={isAddingSavedBackend} | ||
| 1417 | onClick={() => void handleAddSavedBackend()} | ||
| 1418 | > | ||
| 1419 | <PlusIcon className="size-3.5" /> | ||
| 1420 | {isAddingSavedBackend ? "Adding…" : "Add Backend"} | ||
| 1421 | </Button> | ||
| 1422 | </div> | 2763 | </div> |
| 1423 | </DialogPanel> | 2764 | </DialogPanel> |
| 1424 | </DialogPopup> | 2765 | </DialogPopup> |
| ... | @@ -1430,8 +2771,10 @@ export function ConnectionsSettings() { | ... | @@ -1430,8 +2771,10 @@ export function ConnectionsSettings() { |
| 1430 | key={environmentId} | 2771 | key={environmentId} |
| 1431 | environmentId={environmentId} | 2772 | environmentId={environmentId} |
| 1432 | reconnectingEnvironmentId={reconnectingSavedEnvironmentId} | 2773 | reconnectingEnvironmentId={reconnectingSavedEnvironmentId} |
| 2774 | disconnectingEnvironmentId={disconnectingSavedEnvironmentId} | ||
| 1433 | removingEnvironmentId={removingSavedEnvironmentId} | 2775 | removingEnvironmentId={removingSavedEnvironmentId} |
| 1434 | onReconnect={handleReconnectSavedBackend} | 2776 | onConnect={handleConnectSavedBackend} |
| 2777 | onDisconnect={handleDisconnectSavedBackend} | ||
| 1435 | onRemove={handleRemoveSavedBackend} | 2778 | onRemove={handleRemoveSavedBackend} |
| 1436 | /> | 2779 | /> |
| 1437 | ))} | 2780 | ))} |
apps/web/src/components/settings/SettingsPanels.browser.tsx+309-2| ... | @@ -21,6 +21,7 @@ import { render } from "vitest-browser-react"; | ... | @@ -21,6 +21,7 @@ import { render } from "vitest-browser-react"; |
| 21 | import { __resetLocalApiForTests } from "../../localApi"; | 21 | import { __resetLocalApiForTests } from "../../localApi"; |
| 22 | import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; | 22 | import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; |
| 23 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; | 23 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 24 | import { useUiStateStore } from "../../uiStateStore"; | ||
| 24 | import { ConnectionsSettings } from "./ConnectionsSettings"; | 25 | import { ConnectionsSettings } from "./ConnectionsSettings"; |
| 25 | import { GeneralSettingsPanel } from "./SettingsPanels"; | 26 | import { GeneralSettingsPanel } from "./SettingsPanels"; |
| 26 | import { SourceControlSettingsPanel } from "./SourceControlSettings"; | 27 | import { SourceControlSettingsPanel } from "./SourceControlSettings"; |
| ... | @@ -115,6 +116,8 @@ const authAccessHarness = vi.hoisted(() => { | ... | @@ -115,6 +116,8 @@ const authAccessHarness = vi.hoisted(() => { |
| 115 | }; | 116 | }; |
| 116 | }); | 117 | }); |
| 117 | 118 | ||
| 119 | const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); | ||
| 120 | |||
| 118 | vi.mock("../../environments/runtime", () => { | 121 | vi.mock("../../environments/runtime", () => { |
| 119 | const primaryConnection = { | 122 | const primaryConnection = { |
| 120 | kind: "primary" as const, | 123 | kind: "primary" as const, |
| ... | @@ -152,6 +155,7 @@ vi.mock("../../environments/runtime", () => { | ... | @@ -152,6 +155,7 @@ vi.mock("../../environments/runtime", () => { |
| 152 | new URL(path, "http://localhost:3000").toString(), | 155 | new URL(path, "http://localhost:3000").toString(), |
| 153 | waitForSavedEnvironmentRegistryHydration: async () => undefined, | 156 | waitForSavedEnvironmentRegistryHydration: async () => undefined, |
| 154 | addSavedEnvironment: vi.fn(), | 157 | addSavedEnvironment: vi.fn(), |
| 158 | connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, | ||
| 155 | disconnectSavedEnvironment: vi.fn(), | 159 | disconnectSavedEnvironment: vi.fn(), |
| 156 | ensureEnvironmentConnectionBootstrapped: async () => undefined, | 160 | ensureEnvironmentConnectionBootstrapped: async () => undefined, |
| 157 | getPrimaryEnvironmentConnection: () => primaryConnection, | 161 | getPrimaryEnvironmentConnection: () => primaryConnection, |
| ... | @@ -259,7 +263,9 @@ function makeClientSession(input: { | ... | @@ -259,7 +263,9 @@ function makeClientSession(input: { |
| 259 | } | 263 | } |
| 260 | 264 | ||
| 261 | const createDesktopBridgeStub = (overrides?: { | 265 | const createDesktopBridgeStub = (overrides?: { |
| 266 | readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"]; | ||
| 262 | readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>; | 267 | readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>; |
| 268 | readonly advertisedEndpoints?: Awaited<ReturnType<DesktopBridge["getAdvertisedEndpoints"]>>; | ||
| 263 | readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; | 269 | readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; |
| 264 | readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; | 270 | readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; |
| 265 | }): DesktopBridge => { | 271 | }): DesktopBridge => { |
| ... | @@ -295,11 +301,58 @@ const createDesktopBridgeStub = (overrides?: { | ... | @@ -295,11 +301,58 @@ const createDesktopBridgeStub = (overrides?: { |
| 295 | getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), | 301 | getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), |
| 296 | setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), | 302 | setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), |
| 297 | removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), | 303 | removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), |
| 304 | discoverSshHosts: overrides?.discoverSshHosts ?? vi.fn().mockResolvedValue([]), | ||
| 305 | ensureSshEnvironment: vi.fn().mockImplementation(async (target) => ({ | ||
| 306 | target, | ||
| 307 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 308 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 309 | pairingToken: "ssh-pairing-token", | ||
| 310 | })), | ||
| 311 | disconnectSshEnvironment: vi.fn().mockResolvedValue(undefined), | ||
| 312 | fetchSshEnvironmentDescriptor: vi.fn().mockResolvedValue({ | ||
| 313 | environmentId: "environment-ssh", | ||
| 314 | label: "SSH environment", | ||
| 315 | platform: { | ||
| 316 | os: "linux", | ||
| 317 | arch: "x64", | ||
| 318 | }, | ||
| 319 | serverVersion: "0.0.0-test", | ||
| 320 | capabilities: { | ||
| 321 | repositoryIdentity: true, | ||
| 322 | }, | ||
| 323 | }), | ||
| 324 | bootstrapSshBearerSession: vi.fn().mockResolvedValue({ | ||
| 325 | authenticated: true, | ||
| 326 | role: "owner", | ||
| 327 | sessionMethod: "bearer-session-token", | ||
| 328 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 329 | sessionToken: "ssh-bearer-token", | ||
| 330 | }), | ||
| 331 | fetchSshSessionState: vi.fn().mockResolvedValue({ | ||
| 332 | authenticated: true, | ||
| 333 | auth: { | ||
| 334 | policy: "remote-reachable", | ||
| 335 | bootstrapMethods: ["one-time-token"], | ||
| 336 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | ||
| 337 | sessionCookieName: "t3_session", | ||
| 338 | }, | ||
| 339 | role: "owner", | ||
| 340 | sessionMethod: "bearer-session-token", | ||
| 341 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 342 | }), | ||
| 343 | issueSshWebSocketToken: vi.fn().mockResolvedValue({ | ||
| 344 | token: "ssh-ws-token", | ||
| 345 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 346 | }), | ||
| 347 | onSshPasswordPrompt: vi.fn(() => () => {}), | ||
| 348 | resolveSshPasswordPrompt: vi.fn().mockResolvedValue(undefined), | ||
| 298 | getServerExposureState: vi.fn().mockResolvedValue( | 349 | getServerExposureState: vi.fn().mockResolvedValue( |
| 299 | overrides?.serverExposureState ?? { | 350 | overrides?.serverExposureState ?? { |
| 300 | mode: "local-only", | 351 | mode: "local-only", |
| 301 | endpointUrl: null, | 352 | endpointUrl: null, |
| 302 | advertisedHost: null, | 353 | advertisedHost: null, |
| 354 | tailscaleServeEnabled: false, | ||
| 355 | tailscaleServePort: 443, | ||
| 303 | }, | 356 | }, |
| 304 | ), | 357 | ), |
| 305 | setServerExposureMode: | 358 | setServerExposureMode: |
| ... | @@ -308,7 +361,17 @@ const createDesktopBridgeStub = (overrides?: { | ... | @@ -308,7 +361,17 @@ const createDesktopBridgeStub = (overrides?: { |
| 308 | mode, | 361 | mode, |
| 309 | endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, | 362 | endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, |
| 310 | advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, | 363 | advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, |
| 364 | tailscaleServeEnabled: false, | ||
| 365 | tailscaleServePort: 443, | ||
| 311 | })), | 366 | })), |
| 367 | setTailscaleServeEnabled: vi.fn().mockImplementation(async (input) => ({ | ||
| 368 | mode: overrides?.serverExposureState?.mode ?? "network-accessible", | ||
| 369 | endpointUrl: overrides?.serverExposureState?.endpointUrl ?? "http://192.168.1.44:3773", | ||
| 370 | advertisedHost: overrides?.serverExposureState?.advertisedHost ?? "192.168.1.44", | ||
| 371 | tailscaleServeEnabled: input.enabled, | ||
| 372 | tailscaleServePort: input.port ?? 443, | ||
| 373 | })), | ||
| 374 | getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []), | ||
| 312 | pickFolder: vi.fn().mockResolvedValue(null), | 375 | pickFolder: vi.fn().mockResolvedValue(null), |
| 313 | confirm: vi.fn().mockResolvedValue(false), | 376 | confirm: vi.fn().mockResolvedValue(false), |
| 314 | setTheme: vi.fn().mockResolvedValue(undefined), | 377 | setTheme: vi.fn().mockResolvedValue(undefined), |
| ... | @@ -345,7 +408,9 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -345,7 +408,9 @@ describe("GeneralSettingsPanel observability", () => { |
| 345 | resetServerStateForTests(); | 408 | resetServerStateForTests(); |
| 346 | await __resetLocalApiForTests(); | 409 | await __resetLocalApiForTests(); |
| 347 | localStorage.clear(); | 410 | localStorage.clear(); |
| 411 | useUiStateStore.setState({ defaultAdvertisedEndpointKey: null }); | ||
| 348 | authAccessHarness.reset(); | 412 | authAccessHarness.reset(); |
| 413 | mockConnectDesktopSshEnvironment.mockReset(); | ||
| 349 | }); | 414 | }); |
| 350 | 415 | ||
| 351 | afterEach(async () => { | 416 | afterEach(async () => { |
| ... | @@ -431,6 +496,176 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -431,6 +496,176 @@ describe("GeneralSettingsPanel observability", () => { |
| 431 | .toBeInTheDocument(); | 496 | .toBeInTheDocument(); |
| 432 | }); | 497 | }); |
| 433 | 498 | ||
| 499 | it("hides advertised endpoint rows when desktop network access is disabled", async () => { | ||
| 500 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 501 | serverExposureState: { | ||
| 502 | mode: "local-only", | ||
| 503 | endpointUrl: null, | ||
| 504 | advertisedHost: null, | ||
| 505 | tailscaleServeEnabled: false, | ||
| 506 | tailscaleServePort: 443, | ||
| 507 | }, | ||
| 508 | advertisedEndpoints: [ | ||
| 509 | { | ||
| 510 | id: "loopback", | ||
| 511 | label: "This machine", | ||
| 512 | provider: { | ||
| 513 | id: "desktop-core", | ||
| 514 | label: "Desktop", | ||
| 515 | kind: "manual", | ||
| 516 | isAddon: false, | ||
| 517 | }, | ||
| 518 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 519 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 520 | reachability: "loopback", | ||
| 521 | compatibility: { | ||
| 522 | hostedHttpsApp: "mixed-content-blocked", | ||
| 523 | desktopApp: "compatible", | ||
| 524 | }, | ||
| 525 | source: "desktop-core", | ||
| 526 | status: "available", | ||
| 527 | isDefault: true, | ||
| 528 | }, | ||
| 529 | { | ||
| 530 | id: "tailscale-ip", | ||
| 531 | label: "Tailscale IP", | ||
| 532 | provider: { | ||
| 533 | id: "tailscale", | ||
| 534 | label: "Tailscale", | ||
| 535 | kind: "private-network", | ||
| 536 | isAddon: true, | ||
| 537 | }, | ||
| 538 | httpBaseUrl: "http://100.105.39.17:3773/", | ||
| 539 | wsBaseUrl: "ws://100.105.39.17:3773/", | ||
| 540 | reachability: "private-network", | ||
| 541 | compatibility: { | ||
| 542 | hostedHttpsApp: "mixed-content-blocked", | ||
| 543 | desktopApp: "compatible", | ||
| 544 | }, | ||
| 545 | source: "desktop-addon", | ||
| 546 | status: "available", | ||
| 547 | }, | ||
| 548 | ], | ||
| 549 | }); | ||
| 550 | authAccessHarness.setSnapshot({ | ||
| 551 | pairingLinks: [], | ||
| 552 | clientSessions: [], | ||
| 553 | }); | ||
| 554 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 555 | |||
| 556 | mounted = await render( | ||
| 557 | <AppAtomRegistryProvider> | ||
| 558 | <ConnectionsSettings /> | ||
| 559 | </AppAtomRegistryProvider>, | ||
| 560 | ); | ||
| 561 | |||
| 562 | await expect.element(page.getByText("Limited to this machine.")).toBeInTheDocument(); | ||
| 563 | await expect | ||
| 564 | .element(page.getByRole("heading", { name: "This machine", exact: true })) | ||
| 565 | .not.toBeInTheDocument(); | ||
| 566 | await expect | ||
| 567 | .element(page.getByRole("heading", { name: "Tailscale IP", exact: true })) | ||
| 568 | .not.toBeInTheDocument(); | ||
| 569 | }); | ||
| 570 | |||
| 571 | it("collapses advertised endpoints behind the network access summary", async () => { | ||
| 572 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 573 | serverExposureState: { | ||
| 574 | mode: "network-accessible", | ||
| 575 | endpointUrl: "http://192.168.86.39:3773", | ||
| 576 | advertisedHost: "192.168.86.39", | ||
| 577 | tailscaleServeEnabled: false, | ||
| 578 | tailscaleServePort: 443, | ||
| 579 | }, | ||
| 580 | advertisedEndpoints: [ | ||
| 581 | { | ||
| 582 | id: "desktop-loopback:3773", | ||
| 583 | label: "This machine", | ||
| 584 | provider: { | ||
| 585 | id: "desktop-core", | ||
| 586 | label: "Desktop", | ||
| 587 | kind: "manual", | ||
| 588 | isAddon: false, | ||
| 589 | }, | ||
| 590 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 591 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 592 | reachability: "loopback", | ||
| 593 | compatibility: { | ||
| 594 | hostedHttpsApp: "mixed-content-blocked", | ||
| 595 | desktopApp: "compatible", | ||
| 596 | }, | ||
| 597 | source: "desktop-core", | ||
| 598 | status: "available", | ||
| 599 | }, | ||
| 600 | { | ||
| 601 | id: "desktop-lan:http://192.168.86.39:3773", | ||
| 602 | label: "Local network", | ||
| 603 | provider: { | ||
| 604 | id: "desktop-core", | ||
| 605 | label: "Desktop", | ||
| 606 | kind: "manual", | ||
| 607 | isAddon: false, | ||
| 608 | }, | ||
| 609 | httpBaseUrl: "http://192.168.86.39:3773/", | ||
| 610 | wsBaseUrl: "ws://192.168.86.39:3773/", | ||
| 611 | reachability: "lan", | ||
| 612 | compatibility: { | ||
| 613 | hostedHttpsApp: "mixed-content-blocked", | ||
| 614 | desktopApp: "compatible", | ||
| 615 | }, | ||
| 616 | source: "desktop-core", | ||
| 617 | status: "available", | ||
| 618 | isDefault: true, | ||
| 619 | }, | ||
| 620 | { | ||
| 621 | id: "tailscale-ip:http://100.105.39.17:3773", | ||
| 622 | label: "Tailscale IP", | ||
| 623 | provider: { | ||
| 624 | id: "tailscale", | ||
| 625 | label: "Tailscale", | ||
| 626 | kind: "private-network", | ||
| 627 | isAddon: true, | ||
| 628 | }, | ||
| 629 | httpBaseUrl: "http://100.105.39.17:3773/", | ||
| 630 | wsBaseUrl: "ws://100.105.39.17:3773/", | ||
| 631 | reachability: "private-network", | ||
| 632 | compatibility: { | ||
| 633 | hostedHttpsApp: "mixed-content-blocked", | ||
| 634 | desktopApp: "compatible", | ||
| 635 | }, | ||
| 636 | source: "desktop-addon", | ||
| 637 | status: "available", | ||
| 638 | }, | ||
| 639 | ], | ||
| 640 | }); | ||
| 641 | authAccessHarness.setSnapshot({ | ||
| 642 | pairingLinks: [], | ||
| 643 | clientSessions: [], | ||
| 644 | }); | ||
| 645 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 646 | |||
| 647 | mounted = await render( | ||
| 648 | <AppAtomRegistryProvider> | ||
| 649 | <ConnectionsSettings /> | ||
| 650 | </AppAtomRegistryProvider>, | ||
| 651 | ); | ||
| 652 | |||
| 653 | await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument(); | ||
| 654 | await expect.element(page.getByRole("button", { name: "+2" })).toBeInTheDocument(); | ||
| 655 | await expect | ||
| 656 | .element(page.getByRole("heading", { name: "Local network", exact: true })) | ||
| 657 | .not.toBeInTheDocument(); | ||
| 658 | |||
| 659 | await page.getByRole("button", { name: "+2" }).click(); | ||
| 660 | |||
| 661 | await expect | ||
| 662 | .element(page.getByRole("heading", { name: "Local network", exact: true })) | ||
| 663 | .toBeInTheDocument(); | ||
| 664 | await expect.element(page.getByText("Default", { exact: true })).toBeInTheDocument(); | ||
| 665 | await page.getByRole("button", { name: "Set as default" }).first().click(); | ||
| 666 | await expect.element(page.getByText("http://127.0.0.1:3773/").first()).toBeInTheDocument(); | ||
| 667 | }); | ||
| 668 | |||
| 434 | it("shows diagnostics inside About with a single logs-folder action", async () => { | 669 | it("shows diagnostics inside About with a single logs-folder action", async () => { |
| 435 | setServerConfigSnapshot(createBaseServerConfig()); | 670 | setServerConfigSnapshot(createBaseServerConfig()); |
| 436 | 671 | ||
| ... | @@ -461,6 +696,8 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -461,6 +696,8 @@ describe("GeneralSettingsPanel observability", () => { |
| 461 | mode: "network-accessible", | 696 | mode: "network-accessible", |
| 462 | endpointUrl: "http://192.168.1.44:3773", | 697 | endpointUrl: "http://192.168.1.44:3773", |
| 463 | advertisedHost: "192.168.1.44", | 698 | advertisedHost: "192.168.1.44", |
| 699 | tailscaleServeEnabled: false, | ||
| 700 | tailscaleServePort: 443, | ||
| 464 | }, | 701 | }, |
| 465 | }); | 702 | }); |
| 466 | let pairingLinks: Array<AuthAccessSnapshot["pairingLinks"][number]> = []; | 703 | let pairingLinks: Array<AuthAccessSnapshot["pairingLinks"][number]> = []; |
| ... | @@ -566,7 +803,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -566,7 +803,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 566 | .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88")) | 803 | .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88")) |
| 567 | .toBeInTheDocument(); | 804 | .toBeInTheDocument(); |
| 568 | await expect | 805 | await expect |
| 569 | .element(page.getByRole("button", { name: /^(Copy|Show link)$/ })) | 806 | .element(page.getByRole("button", { name: /^Copy pairing URL for:/ })) |
| 570 | .toBeInTheDocument(); | 807 | .toBeInTheDocument(); |
| 571 | await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); | 808 | await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); |
| 572 | }); | 809 | }); |
| ... | @@ -577,6 +814,8 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -577,6 +814,8 @@ describe("GeneralSettingsPanel observability", () => { |
| 577 | mode: "network-accessible", | 814 | mode: "network-accessible", |
| 578 | endpointUrl: "http://192.168.1.44:3773", | 815 | endpointUrl: "http://192.168.1.44:3773", |
| 579 | advertisedHost: "192.168.1.44", | 816 | advertisedHost: "192.168.1.44", |
| 817 | tailscaleServeEnabled: false, | ||
| 818 | tailscaleServePort: 443, | ||
| 580 | }, | 819 | }, |
| 581 | }); | 820 | }); |
| 582 | let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [ | 821 | let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [ |
| ... | @@ -677,9 +916,77 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -677,9 +916,77 @@ describe("GeneralSettingsPanel observability", () => { |
| 677 | await vi.waitFor(() => { | 916 | await vi.waitFor(() => { |
| 678 | expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible"); | 917 | expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible"); |
| 679 | }); | 918 | }); |
| 919 | await expect.element(page.getByText("http://192.168.1.44:3773")).toBeInTheDocument(); | ||
| 920 | }); | ||
| 921 | |||
| 922 | it("adds desktop ssh environments from the add-environment dialog", async () => { | ||
| 923 | const discoverSshHosts = vi.fn().mockResolvedValue([ | ||
| 924 | { | ||
| 925 | alias: "devbox", | ||
| 926 | hostname: "devbox.example.com", | ||
| 927 | username: "julius", | ||
| 928 | port: 22, | ||
| 929 | source: "ssh-config" as const, | ||
| 930 | }, | ||
| 931 | ]); | ||
| 932 | window.desktopBridge = createDesktopBridgeStub({ | ||
| 933 | discoverSshHosts, | ||
| 934 | }); | ||
| 935 | mockConnectDesktopSshEnvironment.mockResolvedValue({ | ||
| 936 | environmentId: EnvironmentId.make("environment-devbox"), | ||
| 937 | label: "Build box", | ||
| 938 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 939 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 940 | createdAt: "2036-04-07T00:00:00.000Z", | ||
| 941 | lastConnectedAt: "2036-04-07T00:00:00.000Z", | ||
| 942 | desktopSsh: { | ||
| 943 | alias: "devbox.example.com", | ||
| 944 | hostname: "devbox.example.com", | ||
| 945 | username: "julius", | ||
| 946 | port: 2222, | ||
| 947 | }, | ||
| 948 | }); | ||
| 949 | |||
| 950 | setServerConfigSnapshot(createBaseServerConfig()); | ||
| 951 | |||
| 952 | mounted = await render( | ||
| 953 | <AppAtomRegistryProvider> | ||
| 954 | <ConnectionsSettings /> | ||
| 955 | </AppAtomRegistryProvider>, | ||
| 956 | ); | ||
| 957 | |||
| 958 | await page.getByRole("button", { name: "Add environment", exact: true }).click(); | ||
| 959 | const addEnvironmentDialog = page.getByRole("dialog", { name: "Add Environment" }); | ||
| 960 | await expect | ||
| 961 | .element(addEnvironmentDialog.getByRole("heading", { name: "Add Environment", exact: true })) | ||
| 962 | .toBeInTheDocument(); | ||
| 963 | await addEnvironmentDialog.getByRole("button", { name: /^SSH\b/ }).click(); | ||
| 964 | await vi.waitFor(() => { | ||
| 965 | expect(discoverSshHosts).toHaveBeenCalledTimes(1); | ||
| 966 | }); | ||
| 680 | await expect | 967 | await expect |
| 681 | .element(page.getByText("Reachable at http://192.168.1.44:3773")) | 968 | .element(page.getByRole("heading", { name: "devbox", exact: true })) |
| 682 | .toBeInTheDocument(); | 969 | .toBeInTheDocument(); |
| 970 | |||
| 971 | await addEnvironmentDialog.getByLabelText("SSH host or alias").fill("devbox.example.com"); | ||
| 972 | await addEnvironmentDialog.getByLabelText("Username").fill("julius"); | ||
| 973 | await addEnvironmentDialog.getByLabelText("Port").fill("2222"); | ||
| 974 | await addEnvironmentDialog | ||
| 975 | .getByRole("button", { name: "Add environment", exact: true }) | ||
| 976 | .first() | ||
| 977 | .click(); | ||
| 978 | |||
| 979 | await vi.waitFor(() => { | ||
| 980 | expect(mockConnectDesktopSshEnvironment).toHaveBeenCalledWith( | ||
| 981 | { | ||
| 982 | alias: "devbox.example.com", | ||
| 983 | hostname: "devbox.example.com", | ||
| 984 | username: "julius", | ||
| 985 | port: 2222, | ||
| 986 | }, | ||
| 987 | { label: "" }, | ||
| 988 | ); | ||
| 989 | }); | ||
| 683 | }); | 990 | }); |
| 684 | 991 | ||
| 685 | it("opens the logs folder in the preferred editor", async () => { | 992 | it("opens the logs folder in the preferred editor", async () => { |
apps/web/src/components/settings/pairingUrls.test.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; | ||
| 4 | |||
| 5 | describe("settings pairing URL helpers", () => { | ||
| 6 | afterEach(() => { | ||
| 7 | vi.unstubAllEnvs(); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("uses direct backend pairing URLs for HTTP endpoints", () => { | ||
| 11 | expect(resolveHostedPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBeNull(); | ||
| 12 | expect(resolveDesktopPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBe( | ||
| 13 | "http://192.168.1.44:3773/pair#token=PAIRCODE", | ||
| 14 | ); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("uses hosted pairing URLs for HTTPS endpoints", () => { | ||
| 18 | vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); | ||
| 19 | |||
| 20 | expect(resolveHostedPairingUrl("https://host.tailnet.example.ts.net:3773", "PAIRCODE")).toBe( | ||
| 21 | "https://preview.t3.codes/pair?host=https%3A%2F%2Fhost.tailnet.example.ts.net%3A3773#token=PAIRCODE", | ||
| 22 | ); | ||
| 23 | }); | ||
| 24 | }); | ||
apps/web/src/components/settings/pairingUrls.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import { buildHostedPairingUrl } from "../../hostedPairing"; | ||
| 2 | import { setPairingTokenOnUrl } from "../../pairingUrl"; | ||
| 3 | |||
| 4 | export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { | ||
| 5 | const url = new URL(endpointUrl); | ||
| 6 | url.pathname = "/pair"; | ||
| 7 | return setPairingTokenOnUrl(url, credential).toString(); | ||
| 8 | } | ||
| 9 | |||
| 10 | export function resolveHostedPairingUrl(endpointUrl: string, credential: string): string | null { | ||
| 11 | const url = new URL(endpointUrl); | ||
| 12 | if (url.protocol !== "https:") { | ||
| 13 | return null; | ||
| 14 | } | ||
| 15 | |||
| 16 | return buildHostedPairingUrl({ | ||
| 17 | host: endpointUrl, | ||
| 18 | token: credential, | ||
| 19 | }); | ||
| 20 | } | ||
apps/web/src/components/settings/settingsLayout.tsx+1-1| ... | @@ -52,7 +52,7 @@ export function SettingsRow({ | ... | @@ -52,7 +52,7 @@ export function SettingsRow({ |
| 52 | children, | 52 | children, |
| 53 | }: { | 53 | }: { |
| 54 | title: ReactNode; | 54 | title: ReactNode; |
| 55 | description: string; | 55 | description: ReactNode; |
| 56 | status?: ReactNode; | 56 | status?: ReactNode; |
| 57 | resetAction?: ReactNode; | 57 | resetAction?: ReactNode; |
| 58 | control?: ReactNode; | 58 | control?: ReactNode; |
apps/web/src/components/ui/alert-dialog.tsx+2-2| ... | @@ -18,7 +18,7 @@ function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backd | ... | @@ -18,7 +18,7 @@ function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backd |
| 18 | return ( | 18 | return ( |
| 19 | <AlertDialogPrimitive.Backdrop | 19 | <AlertDialogPrimitive.Backdrop |
| 20 | className={cn( | 20 | className={cn( |
| 21 | "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0", | 21 | "fixed inset-0 z-50 transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0", |
| 22 | className, | 22 | className, |
| 23 | )} | 23 | )} |
| 24 | data-slot="alert-dialog-backdrop" | 24 | data-slot="alert-dialog-backdrop" |
| ... | @@ -31,7 +31,7 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp | ... | @@ -31,7 +31,7 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp |
| 31 | return ( | 31 | return ( |
| 32 | <AlertDialogPrimitive.Viewport | 32 | <AlertDialogPrimitive.Viewport |
| 33 | className={cn( | 33 | className={cn( |
| 34 | "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center p-4", | 34 | "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center bg-white/48 p-4 backdrop-blur-[2px] dark:bg-black/28", |
| 35 | className, | 35 | className, |
| 36 | )} | 36 | )} |
| 37 | data-slot="alert-dialog-viewport" | 37 | data-slot="alert-dialog-viewport" |
apps/web/src/components/ui/dialog.tsx+2-2| ... | @@ -24,7 +24,7 @@ function DialogBackdrop({ className, ...props }: DialogPrimitive.Backdrop.Props) | ... | @@ -24,7 +24,7 @@ function DialogBackdrop({ className, ...props }: DialogPrimitive.Backdrop.Props) |
| 24 | return ( | 24 | return ( |
| 25 | <DialogPrimitive.Backdrop | 25 | <DialogPrimitive.Backdrop |
| 26 | className={cn( | 26 | className={cn( |
| 27 | "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0", | 27 | "fixed inset-0 z-50 transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0", |
| 28 | className, | 28 | className, |
| 29 | )} | 29 | )} |
| 30 | data-slot="dialog-backdrop" | 30 | data-slot="dialog-backdrop" |
| ... | @@ -37,7 +37,7 @@ function DialogViewport({ className, ...props }: DialogPrimitive.Viewport.Props) | ... | @@ -37,7 +37,7 @@ function DialogViewport({ className, ...props }: DialogPrimitive.Viewport.Props) |
| 37 | return ( | 37 | return ( |
| 38 | <DialogPrimitive.Viewport | 38 | <DialogPrimitive.Viewport |
| 39 | className={cn( | 39 | className={cn( |
| 40 | "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center p-4", | 40 | "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center bg-white/48 p-4 backdrop-blur-[2px] dark:bg-black/28", |
| 41 | className, | 41 | className, |
| 42 | )} | 42 | )} |
| 43 | data-slot="dialog-viewport" | 43 | data-slot="dialog-viewport" |
apps/web/src/components/ui/scroll-area.tsx+1-1| ... | @@ -23,7 +23,7 @@ function ScrollArea({ | ... | @@ -23,7 +23,7 @@ function ScrollArea({ |
| 23 | > | 23 | > |
| 24 | <ScrollAreaPrimitive.Viewport | 24 | <ScrollAreaPrimitive.Viewport |
| 25 | className={cn( | 25 | className={cn( |
| 26 | "h-full overflow-y-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", | 26 | "h-full max-h-[inherit] overflow-y-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", |
| 27 | scrollFade && | 27 | scrollFade && |
| 28 | "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", | 28 | "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", |
| 29 | scrollbarGutter && "[scrollbar-gutter:stable]", | 29 | scrollbarGutter && "[scrollbar-gutter:stable]", |
apps/web/src/environments/remote/api.test.ts+13| ... | @@ -48,6 +48,19 @@ describe("remote environment api", () => { | ... | @@ -48,6 +48,19 @@ describe("remote environment api", () => { |
| 48 | }); | 48 | }); |
| 49 | }); | 49 | }); |
| 50 | 50 | ||
| 51 | it("derives backend urls and token from a hosted app pairing link", () => { | ||
| 52 | expect( | ||
| 53 | resolveRemotePairingTarget({ | ||
| 54 | pairingUrl: | ||
| 55 | "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token", | ||
| 56 | }), | ||
| 57 | ).toEqual({ | ||
| 58 | credential: "pairing-token", | ||
| 59 | httpBaseUrl: "https://desktop.tailnet.ts.net:44342/", | ||
| 60 | wsBaseUrl: "wss://desktop.tailnet.ts.net:44342/", | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 51 | it("derives backend urls from a host and pairing code", () => { | 64 | it("derives backend urls from a host and pairing code", () => { |
| 52 | expect( | 65 | expect( |
| 53 | resolveRemotePairingTarget({ | 66 | resolveRemotePairingTarget({ |
apps/web/src/environments/remote/api.ts+6| ... | @@ -15,6 +15,12 @@ class RemoteEnvironmentAuthHttpError extends Error { | ... | @@ -15,6 +15,12 @@ class RemoteEnvironmentAuthHttpError extends Error { |
| 15 | } | 15 | } |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | export function isRemoteEnvironmentAuthHttpError( | ||
| 19 | error: unknown, | ||
| 20 | ): error is RemoteEnvironmentAuthHttpError { | ||
| 21 | return error instanceof RemoteEnvironmentAuthHttpError; | ||
| 22 | } | ||
| 23 | |||
| 18 | function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string { | 24 | function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string { |
| 19 | const url = new URL(httpBaseUrl); | 25 | const url = new URL(httpBaseUrl); |
| 20 | url.pathname = pathname; | 26 | url.pathname = pathname; |
apps/web/src/environments/remote/target.ts+11| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { getPairingTokenFromUrl } from "../../pairingUrl"; | 1 | import { getPairingTokenFromUrl } from "../../pairingUrl"; |
| 2 | import { readHostedPairingRequest } from "../../hostedPairing"; | ||
| 2 | 3 | ||
| 3 | export interface ResolvedRemotePairingTarget { | 4 | export interface ResolvedRemotePairingTarget { |
| 4 | readonly credential: string; | 5 | readonly credential: string; |
| ... | @@ -57,6 +58,16 @@ export function resolveRemotePairingTarget(input: { | ... | @@ -57,6 +58,16 @@ export function resolveRemotePairingTarget(input: { |
| 57 | const pairingUrl = input.pairingUrl?.trim() ?? ""; | 58 | const pairingUrl = input.pairingUrl?.trim() ?? ""; |
| 58 | if (pairingUrl.length > 0) { | 59 | if (pairingUrl.length > 0) { |
| 59 | const url = new URL(pairingUrl, window.location.origin); | 60 | const url = new URL(pairingUrl, window.location.origin); |
| 61 | const hostedPairingRequest = readHostedPairingRequest(url); | ||
| 62 | if (hostedPairingRequest) { | ||
| 63 | const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host); | ||
| 64 | return { | ||
| 65 | credential: hostedPairingRequest.token, | ||
| 66 | httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), | ||
| 67 | wsBaseUrl: toWsBaseUrl(hostedBackendUrl), | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 60 | const credential = getPairingTokenFromUrl(url) ?? ""; | 71 | const credential = getPairingTokenFromUrl(url) ?? ""; |
| 61 | if (!credential) { | 72 | if (!credential) { |
| 62 | throw new Error("Pairing URL is missing its token."); | 73 | throw new Error("Pairing URL is missing its token."); |
apps/web/src/environments/runtime/catalog.ts+21-1| ... | @@ -18,6 +18,7 @@ export interface SavedEnvironmentRecord { | ... | @@ -18,6 +18,7 @@ export interface SavedEnvironmentRecord { |
| 18 | readonly httpBaseUrl: string; | 18 | readonly httpBaseUrl: string; |
| 19 | readonly createdAt: string; | 19 | readonly createdAt: string; |
| 20 | readonly lastConnectedAt: string | null; | 20 | readonly lastConnectedAt: string | null; |
| 21 | readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; | ||
| 21 | } | 22 | } |
| 22 | 23 | ||
| 23 | interface SavedEnvironmentRegistryState { | 24 | interface SavedEnvironmentRegistryState { |
| ... | @@ -28,13 +29,14 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { | ... | @@ -28,13 +29,14 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { |
| 28 | readonly upsert: (record: SavedEnvironmentRecord) => void; | 29 | readonly upsert: (record: SavedEnvironmentRecord) => void; |
| 29 | readonly remove: (environmentId: EnvironmentId) => void; | 30 | readonly remove: (environmentId: EnvironmentId) => void; |
| 30 | readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void; | 31 | readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void; |
| 32 | readonly rename: (environmentId: EnvironmentId, label: string) => void; | ||
| 31 | readonly reset: () => void; | 33 | readonly reset: () => void; |
| 32 | } | 34 | } |
| 33 | 35 | ||
| 34 | let savedEnvironmentRegistryHydrated = false; | 36 | let savedEnvironmentRegistryHydrated = false; |
| 35 | let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null; | 37 | let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null; |
| 36 | 38 | ||
| 37 | function toPersistedSavedEnvironmentRecord( | 39 | export function toPersistedSavedEnvironmentRecord( |
| 38 | record: SavedEnvironmentRecord, | 40 | record: SavedEnvironmentRecord, |
| 39 | ): PersistedSavedEnvironmentRecord { | 41 | ): PersistedSavedEnvironmentRecord { |
| 40 | return { | 42 | return { |
| ... | @@ -44,6 +46,7 @@ function toPersistedSavedEnvironmentRecord( | ... | @@ -44,6 +46,7 @@ function toPersistedSavedEnvironmentRecord( |
| 44 | wsBaseUrl: record.wsBaseUrl, | 46 | wsBaseUrl: record.wsBaseUrl, |
| 45 | createdAt: record.createdAt, | 47 | createdAt: record.createdAt, |
| 46 | lastConnectedAt: record.lastConnectedAt, | 48 | lastConnectedAt: record.lastConnectedAt, |
| 49 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 47 | }; | 50 | }; |
| 48 | } | 51 | } |
| 49 | 52 | ||
| ... | @@ -148,6 +151,23 @@ export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryS | ... | @@ -148,6 +151,23 @@ export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryS |
| 148 | persistSavedEnvironmentRegistryState(byId); | 151 | persistSavedEnvironmentRegistryState(byId); |
| 149 | return { byId }; | 152 | return { byId }; |
| 150 | }), | 153 | }), |
| 154 | rename: (environmentId, label) => | ||
| 155 | set((state) => { | ||
| 156 | const existing = state.byId[environmentId]; | ||
| 157 | const nextLabel = label.trim(); | ||
| 158 | if (!existing || nextLabel.length === 0 || existing.label === nextLabel) { | ||
| 159 | return state; | ||
| 160 | } | ||
| 161 | const byId = { | ||
| 162 | ...state.byId, | ||
| 163 | [environmentId]: { | ||
| 164 | ...existing, | ||
| 165 | label: nextLabel, | ||
| 166 | }, | ||
| 167 | }; | ||
| 168 | persistSavedEnvironmentRegistryState(byId); | ||
| 169 | return { byId }; | ||
| 170 | }), | ||
| 151 | reset: () => { | 171 | reset: () => { |
| 152 | persistSavedEnvironmentRegistryState({}); | 172 | persistSavedEnvironmentRegistryState({}); |
| 153 | set({ | 173 | set({ |
apps/web/src/environments/runtime/index.ts+1| ... | @@ -16,6 +16,7 @@ export { | ... | @@ -16,6 +16,7 @@ export { |
| 16 | 16 | ||
| 17 | export { | 17 | export { |
| 18 | addSavedEnvironment, | 18 | addSavedEnvironment, |
| 19 | connectDesktopSshEnvironment, | ||
| 19 | disconnectSavedEnvironment, | 20 | disconnectSavedEnvironment, |
| 20 | ensureEnvironmentConnectionBootstrapped, | 21 | ensureEnvironmentConnectionBootstrapped, |
| 21 | getPrimaryEnvironmentConnection, | 22 | getPrimaryEnvironmentConnection, |
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+819-18| ... | @@ -1,14 +1,60 @@ | ... | @@ -1,14 +1,60 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | |||
| 4 | let mockSavedRecords: Array<Record<string, unknown>> = []; | ||
| 3 | 5 | ||
| 4 | const mockResolveRemotePairingTarget = vi.fn(); | 6 | const mockResolveRemotePairingTarget = vi.fn(); |
| 5 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); | 7 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); |
| 6 | const mockBootstrapRemoteBearerSession = vi.fn(); | 8 | const mockBootstrapRemoteBearerSession = vi.fn(); |
| 9 | const mockFetchRemoteSessionState = vi.fn(); | ||
| 10 | const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); | ||
| 11 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); | ||
| 12 | const mockBootstrapSshBearerSession = vi.fn(); | ||
| 13 | const mockFetchSshSessionState = vi.fn(); | ||
| 7 | const mockPersistSavedEnvironmentRecord = vi.fn(); | 14 | const mockPersistSavedEnvironmentRecord = vi.fn(); |
| 8 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); | 15 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); |
| 9 | const mockSetSavedEnvironmentRegistry = vi.fn(); | 16 | const mockSetSavedEnvironmentRegistry = vi.fn(); |
| 10 | const mockUpsert = vi.fn(); | 17 | const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { |
| 11 | const mockListSavedEnvironmentRecords = vi.fn(); | 18 | return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; |
| 19 | }); | ||
| 20 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | ||
| 21 | const mockRemoveSavedEnvironmentBearerToken = vi.fn(); | ||
| 22 | const mockPatchRuntime = vi.fn(); | ||
| 23 | const mockClearRuntime = vi.fn(); | ||
| 24 | const mockRegistrySetState = vi.fn((next: { byId: Record<string, Record<string, unknown>> }) => { | ||
| 25 | mockSavedRecords = Object.values(next.byId); | ||
| 26 | }); | ||
| 27 | const mockRemove = vi.fn((environmentId: EnvironmentId) => { | ||
| 28 | mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); | ||
| 29 | }); | ||
| 30 | const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => { | ||
| 31 | mockSavedRecords = mockSavedRecords.map((record) => | ||
| 32 | record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record, | ||
| 33 | ); | ||
| 34 | }); | ||
| 35 | const mockRename = vi.fn((environmentId: EnvironmentId, label: string) => { | ||
| 36 | mockSavedRecords = mockSavedRecords.map((record) => | ||
| 37 | record.environmentId === environmentId ? { ...record, label } : record, | ||
| 38 | ); | ||
| 39 | }); | ||
| 40 | const mockUpsert = vi.fn((record: Record<string, unknown>) => { | ||
| 41 | mockSavedRecords = [ | ||
| 42 | ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId), | ||
| 43 | record, | ||
| 44 | ]; | ||
| 45 | }); | ||
| 46 | const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords); | ||
| 47 | const mockEnsureSshEnvironment = vi.fn(); | ||
| 48 | const mockDisconnectSshEnvironment = vi.fn(); | ||
| 49 | const mockFetchSshEnvironmentDescriptor = vi.fn(); | ||
| 50 | const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record); | ||
| 51 | const mockCreateEnvironmentConnection = vi.fn(); | ||
| 52 | const mockClientGetConfig = vi.fn(async () => ({ | ||
| 53 | environment: { | ||
| 54 | environmentId: EnvironmentId.make("environment-1"), | ||
| 55 | label: "Remote environment", | ||
| 56 | }, | ||
| 57 | })); | ||
| 12 | 58 | ||
| 13 | vi.mock("../remote/target", () => ({ | 59 | vi.mock("../remote/target", () => ({ |
| 14 | resolveRemotePairingTarget: mockResolveRemotePairingTarget, | 60 | resolveRemotePairingTarget: mockResolveRemotePairingTarget, |
| ... | @@ -17,8 +63,9 @@ vi.mock("../remote/target", () => ({ | ... | @@ -17,8 +63,9 @@ vi.mock("../remote/target", () => ({ |
| 17 | vi.mock("../remote/api", () => ({ | 63 | vi.mock("../remote/api", () => ({ |
| 18 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, | 64 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, |
| 19 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | 65 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, |
| 20 | fetchRemoteSessionState: vi.fn(), | 66 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 21 | resolveRemoteWebSocketConnectionUrl: vi.fn(), | 67 | isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, |
| 68 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 22 | })); | 69 | })); |
| 23 | 70 | ||
| 24 | vi.mock("~/localApi", () => ({ | 71 | vi.mock("~/localApi", () => ({ |
| ... | @@ -30,24 +77,28 @@ vi.mock("~/localApi", () => ({ | ... | @@ -30,24 +77,28 @@ vi.mock("~/localApi", () => ({ |
| 30 | })); | 77 | })); |
| 31 | 78 | ||
| 32 | vi.mock("./catalog", () => ({ | 79 | vi.mock("./catalog", () => ({ |
| 33 | getSavedEnvironmentRecord: vi.fn(), | 80 | getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, |
| 34 | hasSavedEnvironmentRegistryHydrated: vi.fn(), | 81 | hasSavedEnvironmentRegistryHydrated: vi.fn(), |
| 35 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 82 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 36 | persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, | 83 | persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, |
| 37 | readSavedEnvironmentBearerToken: vi.fn(), | 84 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 38 | removeSavedEnvironmentBearerToken: vi.fn(), | 85 | removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, |
| 86 | toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, | ||
| 39 | useSavedEnvironmentRegistryStore: { | 87 | useSavedEnvironmentRegistryStore: { |
| 40 | getState: () => ({ | 88 | getState: () => ({ |
| 41 | upsert: mockUpsert, | 89 | upsert: mockUpsert, |
| 42 | remove: vi.fn(), | 90 | remove: mockRemove, |
| 43 | markConnected: vi.fn(), | 91 | markConnected: mockMarkConnected, |
| 92 | rename: mockRename, | ||
| 44 | }), | 93 | }), |
| 94 | setState: mockRegistrySetState, | ||
| 95 | subscribe: vi.fn(() => () => {}), | ||
| 45 | }, | 96 | }, |
| 46 | useSavedEnvironmentRuntimeStore: { | 97 | useSavedEnvironmentRuntimeStore: { |
| 47 | getState: () => ({ | 98 | getState: () => ({ |
| 48 | ensure: vi.fn(), | 99 | ensure: vi.fn(), |
| 49 | patch: vi.fn(), | 100 | patch: mockPatchRuntime, |
| 50 | clear: vi.fn(), | 101 | clear: mockClearRuntime, |
| 51 | }), | 102 | }), |
| 52 | }, | 103 | }, |
| 53 | waitForSavedEnvironmentRegistryHydration: vi.fn(), | 104 | waitForSavedEnvironmentRegistryHydration: vi.fn(), |
| ... | @@ -55,18 +106,58 @@ vi.mock("./catalog", () => ({ | ... | @@ -55,18 +106,58 @@ vi.mock("./catalog", () => ({ |
| 55 | })); | 106 | })); |
| 56 | 107 | ||
| 57 | vi.mock("./connection", () => ({ | 108 | vi.mock("./connection", () => ({ |
| 58 | createEnvironmentConnection: vi.fn(), | 109 | createEnvironmentConnection: mockCreateEnvironmentConnection, |
| 110 | })); | ||
| 111 | |||
| 112 | vi.mock("../../rpc/wsRpcClient", () => ({ | ||
| 113 | createWsRpcClient: vi.fn(() => ({ | ||
| 114 | server: { | ||
| 115 | getConfig: mockClientGetConfig, | ||
| 116 | }, | ||
| 117 | orchestration: { | ||
| 118 | subscribeThread: vi.fn(() => () => {}), | ||
| 119 | }, | ||
| 120 | })), | ||
| 121 | })); | ||
| 122 | |||
| 123 | vi.mock("../../rpc/wsTransport", () => ({ | ||
| 124 | WsTransport: vi.fn(), | ||
| 59 | })); | 125 | })); |
| 60 | 126 | ||
| 61 | describe("addSavedEnvironment", () => { | 127 | describe("addSavedEnvironment", () => { |
| 128 | afterEach(() => { | ||
| 129 | vi.useRealTimers(); | ||
| 130 | }); | ||
| 131 | |||
| 62 | beforeEach(() => { | 132 | beforeEach(() => { |
| 63 | vi.resetModules(); | 133 | vi.resetModules(); |
| 64 | vi.clearAllMocks(); | 134 | vi.clearAllMocks(); |
| 65 | mockResolveRemotePairingTarget.mockReturnValue({ | 135 | mockSavedRecords = []; |
| 66 | httpBaseUrl: "https://remote.example.com/", | 136 | vi.stubGlobal("window", { |
| 67 | wsBaseUrl: "wss://remote.example.com/", | 137 | desktopBridge: { |
| 68 | credential: "pairing-code", | 138 | ensureSshEnvironment: mockEnsureSshEnvironment, |
| 139 | disconnectSshEnvironment: mockDisconnectSshEnvironment, | ||
| 140 | fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, | ||
| 141 | bootstrapSshBearerSession: mockBootstrapSshBearerSession, | ||
| 142 | fetchSshSessionState: mockFetchSshSessionState, | ||
| 143 | issueSshWebSocketToken: vi.fn(), | ||
| 144 | }, | ||
| 69 | }); | 145 | }); |
| 146 | mockResolveRemotePairingTarget.mockImplementation( | ||
| 147 | (input: { host?: string; pairingCode?: string }) => ({ | ||
| 148 | httpBaseUrl: input.host | ||
| 149 | ? input.host.endsWith("/") | ||
| 150 | ? input.host | ||
| 151 | : `${input.host}/` | ||
| 152 | : "https://remote.example.com/", | ||
| 153 | wsBaseUrl: input.host | ||
| 154 | ? input.host.replace(/^http/u, "ws").endsWith("/") | ||
| 155 | ? input.host.replace(/^http/u, "ws") | ||
| 156 | : `${input.host.replace(/^http/u, "ws")}/` | ||
| 157 | : "wss://remote.example.com/", | ||
| 158 | credential: input.pairingCode ?? "pairing-code", | ||
| 159 | }), | ||
| 160 | ); | ||
| 70 | mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ | 161 | mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ |
| 71 | environmentId: EnvironmentId.make("environment-1"), | 162 | environmentId: EnvironmentId.make("environment-1"), |
| 72 | label: "Remote environment", | 163 | label: "Remote environment", |
| ... | @@ -75,10 +166,60 @@ describe("addSavedEnvironment", () => { | ... | @@ -75,10 +166,60 @@ describe("addSavedEnvironment", () => { |
| 75 | sessionToken: "bearer-token", | 166 | sessionToken: "bearer-token", |
| 76 | role: "owner", | 167 | role: "owner", |
| 77 | }); | 168 | }); |
| 169 | mockFetchRemoteSessionState.mockResolvedValue({ | ||
| 170 | authenticated: true, | ||
| 171 | role: "owner", | ||
| 172 | }); | ||
| 173 | mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false); | ||
| 174 | mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( | ||
| 175 | "wss://remote.example.com/?wsToken=remote-token", | ||
| 176 | ); | ||
| 177 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | ||
| 178 | environmentId: EnvironmentId.make("environment-1"), | ||
| 179 | label: "Remote environment", | ||
| 180 | }); | ||
| 181 | mockBootstrapSshBearerSession.mockResolvedValue({ | ||
| 182 | sessionToken: "ssh-bearer-token", | ||
| 183 | role: "owner", | ||
| 184 | }); | ||
| 78 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); | 185 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); |
| 79 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); | 186 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); |
| 80 | mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); | 187 | mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); |
| 81 | mockListSavedEnvironmentRecords.mockReturnValue([]); | 188 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); |
| 189 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); | ||
| 190 | mockFetchSshSessionState.mockResolvedValue({ | ||
| 191 | authenticated: true, | ||
| 192 | role: "owner", | ||
| 193 | }); | ||
| 194 | mockCreateEnvironmentConnection.mockImplementation( | ||
| 195 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ | ||
| 196 | kind: "saved", | ||
| 197 | environmentId: input.knownEnvironment.environmentId, | ||
| 198 | knownEnvironment: input.knownEnvironment, | ||
| 199 | client: input.client, | ||
| 200 | ensureBootstrapped: async () => undefined, | ||
| 201 | reconnect: async () => undefined, | ||
| 202 | dispose: async () => undefined, | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | mockClientGetConfig.mockResolvedValue({ | ||
| 206 | environment: { | ||
| 207 | environmentId: EnvironmentId.make("environment-1"), | ||
| 208 | label: "Remote environment", | ||
| 209 | }, | ||
| 210 | }); | ||
| 211 | mockEnsureSshEnvironment.mockResolvedValue({ | ||
| 212 | target: { | ||
| 213 | alias: "devbox", | ||
| 214 | hostname: "devbox.example.com", | ||
| 215 | username: "julius", | ||
| 216 | port: 22, | ||
| 217 | }, | ||
| 218 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 219 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 220 | pairingToken: "ssh-pairing-code", | ||
| 221 | }); | ||
| 222 | mockDisconnectSshEnvironment.mockResolvedValue(undefined); | ||
| 82 | }); | 223 | }); |
| 83 | 224 | ||
| 84 | it("rolls back persisted metadata when bearer token persistence fails", async () => { | 225 | it("rolls back persisted metadata when bearer token persistence fails", async () => { |
| ... | @@ -102,4 +243,664 @@ describe("addSavedEnvironment", () => { | ... | @@ -102,4 +243,664 @@ describe("addSavedEnvironment", () => { |
| 102 | 243 | ||
| 103 | await resetEnvironmentServiceForTests(); | 244 | await resetEnvironmentServiceForTests(); |
| 104 | }); | 245 | }); |
| 246 | |||
| 247 | it("restores unrelated saved environments when credential persistence rollback runs", async () => { | ||
| 248 | mockSavedRecords = [ | ||
| 249 | { | ||
| 250 | environmentId: EnvironmentId.make("environment-existing"), | ||
| 251 | label: "Existing environment", | ||
| 252 | httpBaseUrl: "https://existing.example.com/", | ||
| 253 | wsBaseUrl: "wss://existing.example.com/", | ||
| 254 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 255 | lastConnectedAt: null, | ||
| 256 | }, | ||
| 257 | ]; | ||
| 258 | |||
| 259 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 260 | |||
| 261 | await expect( | ||
| 262 | addSavedEnvironment({ | ||
| 263 | label: "Remote environment", | ||
| 264 | host: "remote.example.com", | ||
| 265 | pairingCode: "123456", | ||
| 266 | }), | ||
| 267 | ).rejects.toThrow("Unable to persist saved environment credentials."); | ||
| 268 | |||
| 269 | expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([ | ||
| 270 | expect.objectContaining({ | ||
| 271 | environmentId: EnvironmentId.make("environment-existing"), | ||
| 272 | }), | ||
| 273 | ]); | ||
| 274 | |||
| 275 | await resetEnvironmentServiceForTests(); | ||
| 276 | }); | ||
| 277 | |||
| 278 | it("persists the server label after saved environment metadata refresh", async () => { | ||
| 279 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 280 | mockClientGetConfig.mockResolvedValue({ | ||
| 281 | environment: { | ||
| 282 | environmentId: EnvironmentId.make("environment-1"), | ||
| 283 | label: "Julius's Mac mini", | ||
| 284 | }, | ||
| 285 | }); | ||
| 286 | |||
| 287 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 288 | |||
| 289 | await expect( | ||
| 290 | addSavedEnvironment({ | ||
| 291 | label: "100.65.180.100", | ||
| 292 | host: "remote.example.com", | ||
| 293 | pairingCode: "123456", | ||
| 294 | }), | ||
| 295 | ).resolves.toMatchObject({ | ||
| 296 | environmentId: EnvironmentId.make("environment-1"), | ||
| 297 | }); | ||
| 298 | |||
| 299 | expect(mockRename).toHaveBeenCalledWith( | ||
| 300 | EnvironmentId.make("environment-1"), | ||
| 301 | "Julius's Mac mini", | ||
| 302 | ); | ||
| 303 | expect(mockSavedRecords).toEqual([ | ||
| 304 | expect.objectContaining({ | ||
| 305 | environmentId: EnvironmentId.make("environment-1"), | ||
| 306 | label: "Julius's Mac mini", | ||
| 307 | }), | ||
| 308 | ]); | ||
| 309 | |||
| 310 | await resetEnvironmentServiceForTests(); | ||
| 311 | }); | ||
| 312 | |||
| 313 | it("removes an older ssh record when the same target returns a new environment id", async () => { | ||
| 314 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 315 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | ||
| 316 | environmentId: EnvironmentId.make("environment-2"), | ||
| 317 | label: "Remote environment", | ||
| 318 | }); | ||
| 319 | mockSavedRecords = [ | ||
| 320 | { | ||
| 321 | environmentId: EnvironmentId.make("environment-1"), | ||
| 322 | label: "Old ssh environment", | ||
| 323 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 324 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 325 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 326 | lastConnectedAt: null, | ||
| 327 | desktopSsh: { | ||
| 328 | alias: "devbox", | ||
| 329 | hostname: "devbox.example.com", | ||
| 330 | username: "julius", | ||
| 331 | port: 22, | ||
| 332 | }, | ||
| 333 | }, | ||
| 334 | ]; | ||
| 335 | |||
| 336 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 337 | |||
| 338 | await expect( | ||
| 339 | addSavedEnvironment({ | ||
| 340 | label: "Remote environment", | ||
| 341 | host: "http://127.0.0.1:3774/", | ||
| 342 | pairingCode: "ssh-pairing-code", | ||
| 343 | desktopSsh: { | ||
| 344 | alias: "devbox", | ||
| 345 | hostname: "devbox.example.com", | ||
| 346 | username: "julius", | ||
| 347 | port: 22, | ||
| 348 | }, | ||
| 349 | }), | ||
| 350 | ).resolves.toMatchObject({ | ||
| 351 | environmentId: EnvironmentId.make("environment-2"), | ||
| 352 | }); | ||
| 353 | |||
| 354 | expect(mockUpsert).toHaveBeenCalledWith( | ||
| 355 | expect.objectContaining({ | ||
| 356 | environmentId: EnvironmentId.make("environment-2"), | ||
| 357 | }), | ||
| 358 | ); | ||
| 359 | expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); | ||
| 360 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 361 | EnvironmentId.make("environment-1"), | ||
| 362 | ); | ||
| 363 | |||
| 364 | await resetEnvironmentServiceForTests(); | ||
| 365 | }); | ||
| 366 | |||
| 367 | it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => { | ||
| 368 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 369 | mockBootstrapSshBearerSession | ||
| 370 | .mockResolvedValueOnce({ | ||
| 371 | sessionToken: "ssh-bearer-token", | ||
| 372 | role: "owner", | ||
| 373 | }) | ||
| 374 | .mockResolvedValueOnce({ | ||
| 375 | sessionToken: "ssh-bearer-token-2", | ||
| 376 | role: "owner", | ||
| 377 | }); | ||
| 378 | mockFetchSshSessionState | ||
| 379 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) | ||
| 380 | .mockResolvedValueOnce({ | ||
| 381 | authenticated: true, | ||
| 382 | role: "owner", | ||
| 383 | }); | ||
| 384 | |||
| 385 | const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = | ||
| 386 | await import("./service"); | ||
| 387 | |||
| 388 | await expect( | ||
| 389 | connectDesktopSshEnvironment({ | ||
| 390 | alias: "devbox", | ||
| 391 | hostname: "devbox", | ||
| 392 | username: null, | ||
| 393 | port: null, | ||
| 394 | }), | ||
| 395 | ).resolves.toMatchObject({ | ||
| 396 | environmentId: EnvironmentId.make("environment-1"), | ||
| 397 | }); | ||
| 398 | |||
| 399 | expect(mockEnsureSshEnvironment).toHaveBeenCalled(); | ||
| 400 | expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2); | ||
| 401 | expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2); | ||
| 402 | |||
| 403 | await resetEnvironmentServiceForTests(); | ||
| 404 | }); | ||
| 405 | |||
| 406 | it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { | ||
| 407 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 408 | const authError = { | ||
| 409 | status: 401, | ||
| 410 | message: "Unauthorized", | ||
| 411 | }; | ||
| 412 | mockFetchRemoteSessionState.mockRejectedValueOnce(authError); | ||
| 413 | mockIsRemoteEnvironmentAuthHttpError.mockImplementation( | ||
| 414 | (error: unknown) => error === authError, | ||
| 415 | ); | ||
| 416 | |||
| 417 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 418 | |||
| 419 | await expect( | ||
| 420 | addSavedEnvironment({ | ||
| 421 | label: "Remote environment", | ||
| 422 | host: "remote.example.com", | ||
| 423 | pairingCode: "123456", | ||
| 424 | }), | ||
| 425 | ).rejects.toThrow("Saved environment credential expired. Pair it again."); | ||
| 426 | |||
| 427 | expect(mockEnsureSshEnvironment).not.toHaveBeenCalled(); | ||
| 428 | expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); | ||
| 429 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 430 | EnvironmentId.make("environment-1"), | ||
| 431 | ); | ||
| 432 | |||
| 433 | await resetEnvironmentServiceForTests(); | ||
| 434 | }); | ||
| 435 | |||
| 436 | it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => { | ||
| 437 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 438 | mockBootstrapSshBearerSession | ||
| 439 | .mockResolvedValueOnce({ | ||
| 440 | sessionToken: "ssh-bearer-token", | ||
| 441 | role: "owner", | ||
| 442 | }) | ||
| 443 | .mockResolvedValueOnce({ | ||
| 444 | sessionToken: "ssh-bearer-token-2", | ||
| 445 | role: "owner", | ||
| 446 | }); | ||
| 447 | mockFetchSshSessionState | ||
| 448 | .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) | ||
| 449 | .mockResolvedValueOnce({ | ||
| 450 | authenticated: true, | ||
| 451 | role: "owner", | ||
| 452 | }); | ||
| 453 | |||
| 454 | const createdConnections: Array<{ | ||
| 455 | readonly environmentId: EnvironmentId; | ||
| 456 | readonly dispose: ReturnType<typeof vi.fn>; | ||
| 457 | }> = []; | ||
| 458 | mockCreateEnvironmentConnection.mockImplementation( | ||
| 459 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => { | ||
| 460 | const connection = { | ||
| 461 | kind: "saved" as const, | ||
| 462 | environmentId: input.knownEnvironment.environmentId, | ||
| 463 | knownEnvironment: input.knownEnvironment, | ||
| 464 | client: input.client, | ||
| 465 | ensureBootstrapped: async () => undefined, | ||
| 466 | reconnect: async () => undefined, | ||
| 467 | dispose: vi.fn(async () => undefined), | ||
| 468 | }; | ||
| 469 | createdConnections.push(connection); | ||
| 470 | return connection; | ||
| 471 | }, | ||
| 472 | ); | ||
| 473 | |||
| 474 | const { | ||
| 475 | connectDesktopSshEnvironment, | ||
| 476 | listEnvironmentConnections, | ||
| 477 | resetEnvironmentServiceForTests, | ||
| 478 | } = await import("./service"); | ||
| 479 | |||
| 480 | await connectDesktopSshEnvironment({ | ||
| 481 | alias: "devbox", | ||
| 482 | hostname: "devbox", | ||
| 483 | username: null, | ||
| 484 | port: null, | ||
| 485 | }); | ||
| 486 | |||
| 487 | expect(createdConnections).toHaveLength(2); | ||
| 488 | expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1); | ||
| 489 | expect(listEnvironmentConnections()).toHaveLength(1); | ||
| 490 | expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]); | ||
| 491 | |||
| 492 | await resetEnvironmentServiceForTests(); | ||
| 493 | }); | ||
| 494 | |||
| 495 | it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { | ||
| 496 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 497 | |||
| 498 | const connection = { | ||
| 499 | kind: "saved" as const, | ||
| 500 | environmentId: EnvironmentId.make("environment-1"), | ||
| 501 | knownEnvironment: { | ||
| 502 | environmentId: EnvironmentId.make("environment-1"), | ||
| 503 | }, | ||
| 504 | client: {}, | ||
| 505 | ensureBootstrapped: async () => undefined, | ||
| 506 | reconnect: vi.fn(async () => { | ||
| 507 | throw new Error("socket closed"); | ||
| 508 | }), | ||
| 509 | dispose: async () => undefined, | ||
| 510 | }; | ||
| 511 | mockCreateEnvironmentConnection.mockReturnValue(connection); | ||
| 512 | |||
| 513 | const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 514 | await import("./service"); | ||
| 515 | |||
| 516 | await addSavedEnvironment({ | ||
| 517 | label: "Remote environment", | ||
| 518 | host: "http://127.0.0.1:3774/", | ||
| 519 | pairingCode: "ssh-pairing-code", | ||
| 520 | desktopSsh: { | ||
| 521 | alias: "devbox", | ||
| 522 | hostname: "devbox.example.com", | ||
| 523 | username: "julius", | ||
| 524 | port: 22, | ||
| 525 | }, | ||
| 526 | }); | ||
| 527 | |||
| 528 | mockSavedRecords = [ | ||
| 529 | { | ||
| 530 | environmentId: EnvironmentId.make("environment-1"), | ||
| 531 | label: "Remote environment", | ||
| 532 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 533 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 534 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 535 | lastConnectedAt: null, | ||
| 536 | desktopSsh: { | ||
| 537 | alias: "devbox", | ||
| 538 | hostname: "devbox.example.com", | ||
| 539 | username: "julius", | ||
| 540 | port: 22, | ||
| 541 | }, | ||
| 542 | }, | ||
| 543 | ]; | ||
| 544 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); | ||
| 545 | |||
| 546 | await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( | ||
| 547 | "Unable to persist saved environment credentials.", | ||
| 548 | ); | ||
| 549 | |||
| 550 | expect(mockPatchRuntime).toHaveBeenCalledWith( | ||
| 551 | EnvironmentId.make("environment-1"), | ||
| 552 | expect.objectContaining({ | ||
| 553 | connectionState: "error", | ||
| 554 | lastError: "Unable to persist saved environment credentials.", | ||
| 555 | }), | ||
| 556 | ); | ||
| 557 | |||
| 558 | await resetEnvironmentServiceForTests(); | ||
| 559 | }); | ||
| 560 | |||
| 561 | it("bootstraps a desktop ssh environment through the desktop bridge", async () => { | ||
| 562 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 563 | |||
| 564 | const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = | ||
| 565 | await import("./service"); | ||
| 566 | |||
| 567 | await expect( | ||
| 568 | connectDesktopSshEnvironment({ | ||
| 569 | alias: "devbox", | ||
| 570 | hostname: "devbox", | ||
| 571 | username: null, | ||
| 572 | port: null, | ||
| 573 | }), | ||
| 574 | ).resolves.toMatchObject({ | ||
| 575 | environmentId: EnvironmentId.make("environment-1"), | ||
| 576 | }); | ||
| 577 | |||
| 578 | expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( | ||
| 579 | { | ||
| 580 | alias: "devbox", | ||
| 581 | hostname: "devbox", | ||
| 582 | username: null, | ||
| 583 | port: null, | ||
| 584 | }, | ||
| 585 | { issuePairingToken: true }, | ||
| 586 | ); | ||
| 587 | expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({ | ||
| 588 | host: "http://127.0.0.1:3774/", | ||
| 589 | pairingCode: "ssh-pairing-code", | ||
| 590 | }); | ||
| 591 | expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/"); | ||
| 592 | expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( | ||
| 593 | "http://127.0.0.1:3774/", | ||
| 594 | "ssh-pairing-code", | ||
| 595 | ); | ||
| 596 | expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled(); | ||
| 597 | expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled(); | ||
| 598 | expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan( | ||
| 599 | mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, | ||
| 600 | ); | ||
| 601 | |||
| 602 | await resetEnvironmentServiceForTests(); | ||
| 603 | }); | ||
| 604 | |||
| 605 | it("disconnects the desktop ssh process before removing a saved ssh environment", async () => { | ||
| 606 | mockSavedRecords = [ | ||
| 607 | { | ||
| 608 | environmentId: EnvironmentId.make("environment-1"), | ||
| 609 | label: "Remote environment", | ||
| 610 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 611 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 612 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 613 | lastConnectedAt: null, | ||
| 614 | desktopSsh: { | ||
| 615 | alias: "devbox", | ||
| 616 | hostname: "devbox.example.com", | ||
| 617 | username: "julius", | ||
| 618 | port: 22, | ||
| 619 | }, | ||
| 620 | }, | ||
| 621 | ]; | ||
| 622 | |||
| 623 | const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | ||
| 624 | |||
| 625 | await removeSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 626 | |||
| 627 | expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ | ||
| 628 | alias: "devbox", | ||
| 629 | hostname: "devbox.example.com", | ||
| 630 | username: "julius", | ||
| 631 | port: 22, | ||
| 632 | }); | ||
| 633 | expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); | ||
| 634 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 635 | EnvironmentId.make("environment-1"), | ||
| 636 | ); | ||
| 637 | expect(mockDisconnectSshEnvironment.mock.invocationCallOrder[0]).toBeLessThan( | ||
| 638 | mockRemove.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, | ||
| 639 | ); | ||
| 640 | |||
| 641 | await resetEnvironmentServiceForTests(); | ||
| 642 | }); | ||
| 643 | |||
| 644 | it("disconnects a saved ssh environment without removing its saved record", async () => { | ||
| 645 | mockSavedRecords = [ | ||
| 646 | { | ||
| 647 | environmentId: EnvironmentId.make("environment-1"), | ||
| 648 | label: "Remote environment", | ||
| 649 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 650 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 651 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 652 | lastConnectedAt: null, | ||
| 653 | desktopSsh: { | ||
| 654 | alias: "devbox", | ||
| 655 | hostname: "devbox.example.com", | ||
| 656 | username: "julius", | ||
| 657 | port: 22, | ||
| 658 | }, | ||
| 659 | }, | ||
| 660 | ]; | ||
| 661 | |||
| 662 | const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 663 | await import("./service"); | ||
| 664 | |||
| 665 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 666 | |||
| 667 | expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ | ||
| 668 | alias: "devbox", | ||
| 669 | hostname: "devbox.example.com", | ||
| 670 | username: "julius", | ||
| 671 | port: 22, | ||
| 672 | }); | ||
| 673 | expect(mockRemove).not.toHaveBeenCalled(); | ||
| 674 | expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 675 | EnvironmentId.make("environment-1"), | ||
| 676 | ); | ||
| 677 | |||
| 678 | await resetEnvironmentServiceForTests(); | ||
| 679 | }); | ||
| 680 | |||
| 681 | it("keeps remote environment credentials when disconnecting a non-ssh saved environment", async () => { | ||
| 682 | mockSavedRecords = [ | ||
| 683 | { | ||
| 684 | environmentId: EnvironmentId.make("environment-1"), | ||
| 685 | label: "Remote environment", | ||
| 686 | httpBaseUrl: "https://remote.example.com/", | ||
| 687 | wsBaseUrl: "wss://remote.example.com/", | ||
| 688 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 689 | lastConnectedAt: null, | ||
| 690 | }, | ||
| 691 | ]; | ||
| 692 | |||
| 693 | const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 694 | await import("./service"); | ||
| 695 | |||
| 696 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 697 | |||
| 698 | expect(mockDisconnectSshEnvironment).not.toHaveBeenCalled(); | ||
| 699 | expect(mockRemove).not.toHaveBeenCalled(); | ||
| 700 | expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled(); | ||
| 701 | |||
| 702 | await resetEnvironmentServiceForTests(); | ||
| 703 | }); | ||
| 704 | |||
| 705 | it("cancels a pending saved environment connection when disconnected", async () => { | ||
| 706 | mockSavedRecords = [ | ||
| 707 | { | ||
| 708 | environmentId: EnvironmentId.make("environment-1"), | ||
| 709 | label: "Remote environment", | ||
| 710 | httpBaseUrl: "https://remote.example.com/", | ||
| 711 | wsBaseUrl: "wss://remote.example.com/", | ||
| 712 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 713 | lastConnectedAt: null, | ||
| 714 | }, | ||
| 715 | ]; | ||
| 716 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); | ||
| 717 | const dispose = vi.fn(async () => undefined); | ||
| 718 | mockCreateEnvironmentConnection.mockImplementation( | ||
| 719 | (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ | ||
| 720 | kind: "saved" as const, | ||
| 721 | environmentId: input.knownEnvironment.environmentId, | ||
| 722 | knownEnvironment: input.knownEnvironment, | ||
| 723 | client: input.client, | ||
| 724 | ensureBootstrapped: async () => undefined, | ||
| 725 | reconnect: async () => undefined, | ||
| 726 | dispose, | ||
| 727 | }), | ||
| 728 | ); | ||
| 729 | let resolveSessionState!: (value: { | ||
| 730 | readonly authenticated: true; | ||
| 731 | readonly role: "owner"; | ||
| 732 | }) => void; | ||
| 733 | mockFetchRemoteSessionState.mockReturnValue( | ||
| 734 | new Promise((resolve) => { | ||
| 735 | resolveSessionState = resolve; | ||
| 736 | }), | ||
| 737 | ); | ||
| 738 | |||
| 739 | const { | ||
| 740 | disconnectSavedEnvironment, | ||
| 741 | listEnvironmentConnections, | ||
| 742 | reconnectSavedEnvironment, | ||
| 743 | resetEnvironmentServiceForTests, | ||
| 744 | } = await import("./service"); | ||
| 745 | |||
| 746 | const reconnectPromise = reconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 747 | await vi.waitFor(() => { | ||
| 748 | expect(mockFetchRemoteSessionState).toHaveBeenCalledOnce(); | ||
| 749 | }); | ||
| 750 | |||
| 751 | await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 752 | resolveSessionState({ | ||
| 753 | authenticated: true, | ||
| 754 | role: "owner", | ||
| 755 | }); | ||
| 756 | await expect(reconnectPromise).resolves.toBeUndefined(); | ||
| 757 | |||
| 758 | expect(listEnvironmentConnections()).toHaveLength(0); | ||
| 759 | expect(dispose).toHaveBeenCalledOnce(); | ||
| 760 | expect(mockPatchRuntime).not.toHaveBeenCalledWith( | ||
| 761 | EnvironmentId.make("environment-1"), | ||
| 762 | expect.objectContaining({ | ||
| 763 | connectionState: "error", | ||
| 764 | }), | ||
| 765 | ); | ||
| 766 | |||
| 767 | await resetEnvironmentServiceForTests(); | ||
| 768 | }); | ||
| 769 | |||
| 770 | it("reissues ssh pairing credentials when connecting after a manual ssh disconnect", async () => { | ||
| 771 | mockSavedRecords = [ | ||
| 772 | { | ||
| 773 | environmentId: EnvironmentId.make("environment-1"), | ||
| 774 | label: "Remote environment", | ||
| 775 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 776 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 777 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 778 | lastConnectedAt: null, | ||
| 779 | desktopSsh: { | ||
| 780 | alias: "devbox", | ||
| 781 | hostname: "devbox.example.com", | ||
| 782 | username: "julius", | ||
| 783 | port: 22, | ||
| 784 | }, | ||
| 785 | }, | ||
| 786 | ]; | ||
| 787 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 788 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | ||
| 789 | |||
| 790 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 791 | await import("./service"); | ||
| 792 | |||
| 793 | await reconnectSavedEnvironment(EnvironmentId.make("environment-1")); | ||
| 794 | |||
| 795 | expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( | ||
| 796 | { | ||
| 797 | alias: "devbox", | ||
| 798 | hostname: "devbox.example.com", | ||
| 799 | username: "julius", | ||
| 800 | port: 22, | ||
| 801 | }, | ||
| 802 | { issuePairingToken: true }, | ||
| 803 | ); | ||
| 804 | expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( | ||
| 805 | "http://127.0.0.1:3774/", | ||
| 806 | "ssh-pairing-code", | ||
| 807 | ); | ||
| 808 | expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( | ||
| 809 | EnvironmentId.make("environment-1"), | ||
| 810 | "ssh-bearer-token", | ||
| 811 | ); | ||
| 812 | |||
| 813 | await resetEnvironmentServiceForTests(); | ||
| 814 | }); | ||
| 815 | |||
| 816 | it("rolls back ssh registry metadata when pairing token issuance fails", async () => { | ||
| 817 | const originalRecord = { | ||
| 818 | environmentId: EnvironmentId.make("environment-1"), | ||
| 819 | label: "Remote environment", | ||
| 820 | httpBaseUrl: "http://127.0.0.1:3773/", | ||
| 821 | wsBaseUrl: "ws://127.0.0.1:3773/", | ||
| 822 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 823 | lastConnectedAt: null, | ||
| 824 | desktopSsh: { | ||
| 825 | alias: "devbox", | ||
| 826 | hostname: "devbox.example.com", | ||
| 827 | username: "julius", | ||
| 828 | port: 22, | ||
| 829 | }, | ||
| 830 | }; | ||
| 831 | mockSavedRecords = [originalRecord]; | ||
| 832 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 833 | mockEnsureSshEnvironment.mockResolvedValue({ | ||
| 834 | target: { | ||
| 835 | alias: "devbox", | ||
| 836 | hostname: "devbox.example.com", | ||
| 837 | username: "julius", | ||
| 838 | port: 22, | ||
| 839 | }, | ||
| 840 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 841 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 842 | pairingToken: null, | ||
| 843 | }); | ||
| 844 | |||
| 845 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 846 | await import("./service"); | ||
| 847 | |||
| 848 | await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( | ||
| 849 | "Desktop SSH launch did not return a pairing token.", | ||
| 850 | ); | ||
| 851 | |||
| 852 | expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledWith( | ||
| 853 | expect.objectContaining({ | ||
| 854 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 855 | }), | ||
| 856 | ); | ||
| 857 | expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([originalRecord]); | ||
| 858 | expect(mockSavedRecords).toEqual([originalRecord]); | ||
| 859 | expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); | ||
| 860 | |||
| 861 | await resetEnvironmentServiceForTests(); | ||
| 862 | }); | ||
| 863 | |||
| 864 | it("surfaces desktop ssh bootstrap failures during saved ssh reconnect", async () => { | ||
| 865 | mockSavedRecords = [ | ||
| 866 | { | ||
| 867 | environmentId: EnvironmentId.make("environment-1"), | ||
| 868 | label: "Remote environment", | ||
| 869 | httpBaseUrl: "http://127.0.0.1:3774/", | ||
| 870 | wsBaseUrl: "ws://127.0.0.1:3774/", | ||
| 871 | createdAt: "2026-04-14T00:00:00.000Z", | ||
| 872 | lastConnectedAt: null, | ||
| 873 | desktopSsh: { | ||
| 874 | alias: "devbox", | ||
| 875 | hostname: "devbox.example.com", | ||
| 876 | username: "julius", | ||
| 877 | port: 22, | ||
| 878 | }, | ||
| 879 | }, | ||
| 880 | ]; | ||
| 881 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 882 | mockEnsureSshEnvironment.mockRejectedValue(new Error("SSH command timed out after 60000ms.")); | ||
| 883 | |||
| 884 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 885 | await import("./service"); | ||
| 886 | |||
| 887 | await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( | ||
| 888 | "SSH command timed out after 60000ms.", | ||
| 889 | ); | ||
| 890 | expect(mockPatchRuntime).toHaveBeenCalledWith( | ||
| 891 | EnvironmentId.make("environment-1"), | ||
| 892 | expect.objectContaining({ | ||
| 893 | connectionState: "connecting", | ||
| 894 | }), | ||
| 895 | ); | ||
| 896 | expect(mockPatchRuntime).toHaveBeenCalledWith( | ||
| 897 | EnvironmentId.make("environment-1"), | ||
| 898 | expect.objectContaining({ | ||
| 899 | connectionState: "error", | ||
| 900 | lastError: "SSH command timed out after 60000ms.", | ||
| 901 | }), | ||
| 902 | ); | ||
| 903 | |||
| 904 | await resetEnvironmentServiceForTests(); | ||
| 905 | }); | ||
| 105 | }); | 906 | }); |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+183-22| ... | @@ -15,31 +15,36 @@ const mockCreateEnvironmentConnection = vi.fn(); | ... | @@ -15,31 +15,36 @@ const mockCreateEnvironmentConnection = vi.fn(); |
| 15 | const mockCreateWsRpcClient = vi.fn(); | 15 | const mockCreateWsRpcClient = vi.fn(); |
| 16 | const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); | 16 | const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); |
| 17 | const mockListSavedEnvironmentRecords = vi.fn(); | 17 | const mockListSavedEnvironmentRecords = vi.fn(); |
| 18 | const mockGetSavedEnvironmentRecord = vi.fn(); | ||
| 19 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | ||
| 18 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | 20 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); |
| 21 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); | ||
| 22 | const mockFetchRemoteSessionState = vi.fn(); | ||
| 23 | const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = []; | ||
| 24 | let savedEnvironmentRegistryListener: (() => void) | null = null; | ||
| 19 | 25 | ||
| 20 | function MockWsTransport() { | 26 | function MockWsTransport() { |
| 21 | return undefined; | 27 | return undefined; |
| 22 | } | 28 | } |
| 23 | 29 | ||
| 24 | vi.mock("../primary", () => ({ | 30 | vi.mock("../primary", () => ({ |
| 25 | getPrimaryKnownEnvironment: vi.fn(() => ({ | 31 | getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, |
| 26 | id: "env-1", | 32 | })); |
| 27 | label: "Primary environment", | 33 | |
| 28 | source: "window-origin", | 34 | vi.mock("../remote/api", () => ({ |
| 29 | target: { | 35 | bootstrapRemoteBearerSession: vi.fn(), |
| 30 | httpBaseUrl: "http://127.0.0.1:3000/", | 36 | fetchRemoteEnvironmentDescriptor: vi.fn(), |
| 31 | wsBaseUrl: "ws://127.0.0.1:3000/", | 37 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 32 | }, | 38 | isRemoteEnvironmentAuthHttpError: vi.fn(() => false), |
| 33 | environmentId: EnvironmentId.make("env-1"), | 39 | resolveRemoteWebSocketConnectionUrl: vi.fn(async () => "ws://remote.example.test/ws"), |
| 34 | })), | ||
| 35 | })); | 40 | })); |
| 36 | 41 | ||
| 37 | vi.mock("./catalog", () => ({ | 42 | vi.mock("./catalog", () => ({ |
| 38 | getSavedEnvironmentRecord: vi.fn(), | 43 | getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, |
| 39 | hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), | 44 | hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), |
| 40 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 45 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 41 | persistSavedEnvironmentRecord: vi.fn(), | 46 | persistSavedEnvironmentRecord: vi.fn(), |
| 42 | readSavedEnvironmentBearerToken: vi.fn(), | 47 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 43 | removeSavedEnvironmentBearerToken: vi.fn(), | 48 | removeSavedEnvironmentBearerToken: vi.fn(), |
| 44 | useSavedEnvironmentRegistryStore: { | 49 | useSavedEnvironmentRegistryStore: { |
| 45 | subscribe: mockSavedEnvironmentRegistrySubscribe, | 50 | subscribe: mockSavedEnvironmentRegistrySubscribe, |
| ... | @@ -47,6 +52,7 @@ vi.mock("./catalog", () => ({ | ... | @@ -47,6 +52,7 @@ vi.mock("./catalog", () => ({ |
| 47 | upsert: vi.fn(), | 52 | upsert: vi.fn(), |
| 48 | remove: vi.fn(), | 53 | remove: vi.fn(), |
| 49 | markConnected: vi.fn(), | 54 | markConnected: vi.fn(), |
| 55 | rename: vi.fn(), | ||
| 50 | }), | 56 | }), |
| 51 | }, | 57 | }, |
| 52 | useSavedEnvironmentRuntimeStore: { | 58 | useSavedEnvironmentRuntimeStore: { |
| ... | @@ -145,31 +151,72 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -145,31 +151,72 @@ describe("retainThreadDetailSubscription", () => { |
| 145 | vi.useFakeTimers(); | 151 | vi.useFakeTimers(); |
| 146 | vi.resetModules(); | 152 | vi.resetModules(); |
| 147 | vi.clearAllMocks(); | 153 | vi.clearAllMocks(); |
| 154 | mockGetPrimaryKnownEnvironment.mockReturnValue({ | ||
| 155 | id: "env-1", | ||
| 156 | label: "Primary environment", | ||
| 157 | source: "window-origin", | ||
| 158 | target: { | ||
| 159 | httpBaseUrl: "http://127.0.0.1:3000/", | ||
| 160 | wsBaseUrl: "ws://127.0.0.1:3000/", | ||
| 161 | }, | ||
| 162 | environmentId: EnvironmentId.make("env-1"), | ||
| 163 | }); | ||
| 148 | 164 | ||
| 149 | mockThreadUnsubscribe.mockImplementation(() => undefined); | 165 | mockThreadUnsubscribe.mockImplementation(() => undefined); |
| 150 | mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); | 166 | mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); |
| 151 | mockCreateWsRpcClient.mockReturnValue({ | 167 | mockCreateWsRpcClient.mockReturnValue({ |
| 168 | server: { | ||
| 169 | getConfig: vi.fn(async () => ({ | ||
| 170 | environment: { | ||
| 171 | environmentId: EnvironmentId.make("env-remote"), | ||
| 172 | label: "Remote env", | ||
| 173 | platform: { os: "darwin", arch: "arm64" }, | ||
| 174 | serverVersion: "0.0.0-test", | ||
| 175 | capabilities: { repositoryIdentity: true }, | ||
| 176 | }, | ||
| 177 | })), | ||
| 178 | }, | ||
| 152 | orchestration: { | 179 | orchestration: { |
| 153 | subscribeThread: mockSubscribeThread, | 180 | subscribeThread: mockSubscribeThread, |
| 154 | }, | 181 | }, |
| 155 | }); | 182 | }); |
| 156 | mockCreateEnvironmentConnection.mockImplementation((input) => ({ | 183 | mockCreateEnvironmentConnection.mockImplementation((input) => { |
| 157 | kind: input.kind, | 184 | const reconnect = vi.fn(async () => undefined); |
| 158 | environmentId: input.knownEnvironment.environmentId, | 185 | mockConnectionReconnects.push(reconnect); |
| 159 | knownEnvironment: input.knownEnvironment, | 186 | return { |
| 160 | client: input.client, | 187 | kind: input.kind, |
| 161 | ensureBootstrapped: vi.fn(async () => undefined), | 188 | environmentId: input.knownEnvironment.environmentId, |
| 162 | reconnect: vi.fn(async () => undefined), | 189 | knownEnvironment: input.knownEnvironment, |
| 163 | dispose: vi.fn(async () => undefined), | 190 | client: input.client, |
| 164 | })); | 191 | ensureBootstrapped: vi.fn(async () => undefined), |
| 165 | mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); | 192 | reconnect, |
| 193 | dispose: vi.fn(async () => undefined), | ||
| 194 | }; | ||
| 195 | }); | ||
| 196 | savedEnvironmentRegistryListener = null; | ||
| 197 | mockSavedEnvironmentRegistrySubscribe.mockImplementation((listener: () => void) => { | ||
| 198 | savedEnvironmentRegistryListener = listener; | ||
| 199 | return () => { | ||
| 200 | if (savedEnvironmentRegistryListener === listener) { | ||
| 201 | savedEnvironmentRegistryListener = null; | ||
| 202 | } | ||
| 203 | }; | ||
| 204 | }); | ||
| 166 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); | 205 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); |
| 167 | mockListSavedEnvironmentRecords.mockReturnValue([]); | 206 | mockListSavedEnvironmentRecords.mockReturnValue([]); |
| 207 | mockGetSavedEnvironmentRecord.mockReturnValue(null); | ||
| 208 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | ||
| 209 | mockFetchRemoteSessionState.mockResolvedValue({ | ||
| 210 | authenticated: true, | ||
| 211 | role: "client", | ||
| 212 | }); | ||
| 213 | mockConnectionReconnects.length = 0; | ||
| 168 | }); | 214 | }); |
| 169 | 215 | ||
| 170 | afterEach(async () => { | 216 | afterEach(async () => { |
| 171 | const { resetEnvironmentServiceForTests } = await import("./service"); | 217 | const { resetEnvironmentServiceForTests } = await import("./service"); |
| 172 | await resetEnvironmentServiceForTests(); | 218 | await resetEnvironmentServiceForTests(); |
| 219 | vi.unstubAllGlobals(); | ||
| 173 | vi.useRealTimers(); | 220 | vi.useRealTimers(); |
| 174 | }); | 221 | }); |
| 175 | 222 | ||
| ... | @@ -204,6 +251,31 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -204,6 +251,31 @@ describe("retainThreadDetailSubscription", () => { |
| 204 | await resetEnvironmentServiceForTests(); | 251 | await resetEnvironmentServiceForTests(); |
| 205 | }); | 252 | }); |
| 206 | 253 | ||
| 254 | it("does not start the primary connection until the known environment has an id", async () => { | ||
| 255 | mockGetPrimaryKnownEnvironment.mockReturnValue({ | ||
| 256 | id: "env-1", | ||
| 257 | label: "Primary environment", | ||
| 258 | source: "window-origin", | ||
| 259 | target: { | ||
| 260 | httpBaseUrl: "http://127.0.0.1:3000/", | ||
| 261 | wsBaseUrl: "ws://127.0.0.1:3000/", | ||
| 262 | }, | ||
| 263 | }); | ||
| 264 | const { | ||
| 265 | listEnvironmentConnections, | ||
| 266 | resetEnvironmentServiceForTests, | ||
| 267 | startEnvironmentConnectionService, | ||
| 268 | } = await import("./service"); | ||
| 269 | |||
| 270 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 271 | |||
| 272 | expect(mockCreateEnvironmentConnection).not.toHaveBeenCalled(); | ||
| 273 | expect(listEnvironmentConnections()).toEqual([]); | ||
| 274 | |||
| 275 | stop(); | ||
| 276 | await resetEnvironmentServiceForTests(); | ||
| 277 | }); | ||
| 278 | |||
| 207 | it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { | 279 | it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { |
| 208 | const { | 280 | const { |
| 209 | retainThreadDetailSubscription, | 281 | retainThreadDetailSubscription, |
| ... | @@ -253,6 +325,95 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -253,6 +325,95 @@ describe("retainThreadDetailSubscription", () => { |
| 253 | await resetEnvironmentServiceForTests(); | 325 | await resetEnvironmentServiceForTests(); |
| 254 | }); | 326 | }); |
| 255 | 327 | ||
| 328 | it("reattaches retained thread detail subscriptions after a saved environment reconnect replaces the client", async () => { | ||
| 329 | const environmentId = EnvironmentId.make("env-remote"); | ||
| 330 | const threadId = ThreadId.make("thread-reconnect"); | ||
| 331 | const record = { | ||
| 332 | environmentId, | ||
| 333 | label: "Remote env", | ||
| 334 | httpBaseUrl: "http://remote.example.test", | ||
| 335 | wsBaseUrl: "ws://remote.example.test", | ||
| 336 | createdAt: "2026-05-01T00:00:00.000Z", | ||
| 337 | lastConnectedAt: "2026-05-01T00:00:00.000Z", | ||
| 338 | }; | ||
| 339 | mockListSavedEnvironmentRecords.mockReturnValue([record]); | ||
| 340 | mockGetSavedEnvironmentRecord.mockReturnValue(record); | ||
| 341 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); | ||
| 342 | |||
| 343 | const { | ||
| 344 | disconnectSavedEnvironment, | ||
| 345 | listEnvironmentConnections, | ||
| 346 | reconnectSavedEnvironment, | ||
| 347 | retainThreadDetailSubscription, | ||
| 348 | startEnvironmentConnectionService, | ||
| 349 | resetEnvironmentServiceForTests, | ||
| 350 | } = await import("./service"); | ||
| 351 | |||
| 352 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 353 | savedEnvironmentRegistryListener?.(); | ||
| 354 | await vi.waitFor(() => { | ||
| 355 | expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(2); | ||
| 356 | expect( | ||
| 357 | listEnvironmentConnections().some( | ||
| 358 | (connection) => connection.environmentId === environmentId, | ||
| 359 | ), | ||
| 360 | ).toBe(true); | ||
| 361 | }); | ||
| 362 | |||
| 363 | const release = retainThreadDetailSubscription(environmentId, threadId); | ||
| 364 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); | ||
| 365 | |||
| 366 | await disconnectSavedEnvironment(environmentId); | ||
| 367 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); | ||
| 368 | expect( | ||
| 369 | listEnvironmentConnections().some((connection) => connection.environmentId === environmentId), | ||
| 370 | ).toBe(false); | ||
| 371 | |||
| 372 | await reconnectSavedEnvironment(environmentId); | ||
| 373 | await vi.waitFor(() => { | ||
| 374 | expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3); | ||
| 375 | expect(mockSubscribeThread).toHaveBeenCalledTimes(2); | ||
| 376 | }); | ||
| 377 | |||
| 378 | release(); | ||
| 379 | stop(); | ||
| 380 | await resetEnvironmentServiceForTests(); | ||
| 381 | }); | ||
| 382 | |||
| 383 | it("reconnects environment streams when the browser resumes from the background", async () => { | ||
| 384 | let visibilityState: DocumentVisibilityState = "visible"; | ||
| 385 | const documentTarget = new EventTarget(); | ||
| 386 | const windowTarget = new EventTarget(); | ||
| 387 | vi.stubGlobal("document", { | ||
| 388 | addEventListener: documentTarget.addEventListener.bind(documentTarget), | ||
| 389 | removeEventListener: documentTarget.removeEventListener.bind(documentTarget), | ||
| 390 | get visibilityState() { | ||
| 391 | return visibilityState; | ||
| 392 | }, | ||
| 393 | }); | ||
| 394 | vi.stubGlobal("window", { | ||
| 395 | addEventListener: windowTarget.addEventListener.bind(windowTarget), | ||
| 396 | removeEventListener: windowTarget.removeEventListener.bind(windowTarget), | ||
| 397 | }); | ||
| 398 | |||
| 399 | const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = | ||
| 400 | await import("./service"); | ||
| 401 | |||
| 402 | const stop = startEnvironmentConnectionService(new QueryClient()); | ||
| 403 | expect(mockConnectionReconnects).toHaveLength(1); | ||
| 404 | |||
| 405 | visibilityState = "hidden"; | ||
| 406 | documentTarget.dispatchEvent(new Event("visibilitychange")); | ||
| 407 | expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); | ||
| 408 | |||
| 409 | visibilityState = "visible"; | ||
| 410 | documentTarget.dispatchEvent(new Event("visibilitychange")); | ||
| 411 | expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1); | ||
| 412 | |||
| 413 | stop(); | ||
| 414 | await resetEnvironmentServiceForTests(); | ||
| 415 | }); | ||
| 416 | |||
| 256 | it("allows a larger idle cache before capacity eviction starts", async () => { | 417 | it("allows a larger idle cache before capacity eviction starts", async () => { |
| 257 | const { | 418 | const { |
| 258 | retainThreadDetailSubscription, | 419 | retainThreadDetailSubscription, |
apps/web/src/environments/runtime/service.ts+647-111| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | type AuthSessionRole, | 2 | type AuthSessionRole, |
| 3 | type DesktopSshEnvironmentBootstrap, | ||
| 4 | type DesktopSshEnvironmentTarget, | ||
| 3 | type EnvironmentId, | 5 | type EnvironmentId, |
| 4 | type OrchestrationEvent, | 6 | type OrchestrationEvent, |
| 5 | type OrchestrationShellSnapshot, | 7 | type OrchestrationShellSnapshot, |
| ... | @@ -33,6 +35,7 @@ import { | ... | @@ -33,6 +35,7 @@ import { |
| 33 | bootstrapRemoteBearerSession, | 35 | bootstrapRemoteBearerSession, |
| 34 | fetchRemoteEnvironmentDescriptor, | 36 | fetchRemoteEnvironmentDescriptor, |
| 35 | fetchRemoteSessionState, | 37 | fetchRemoteSessionState, |
| 38 | isRemoteEnvironmentAuthHttpError, | ||
| 36 | resolveRemoteWebSocketConnectionUrl, | 39 | resolveRemoteWebSocketConnectionUrl, |
| 37 | } from "../remote/api"; | 40 | } from "../remote/api"; |
| 38 | import { resolveRemotePairingTarget } from "../remote/target"; | 41 | import { resolveRemotePairingTarget } from "../remote/target"; |
| ... | @@ -44,6 +47,7 @@ import { | ... | @@ -44,6 +47,7 @@ import { |
| 44 | readSavedEnvironmentBearerToken, | 47 | readSavedEnvironmentBearerToken, |
| 45 | removeSavedEnvironmentBearerToken, | 48 | removeSavedEnvironmentBearerToken, |
| 46 | type SavedEnvironmentRecord, | 49 | type SavedEnvironmentRecord, |
| 50 | toPersistedSavedEnvironmentRecord, | ||
| 47 | useSavedEnvironmentRegistryStore, | 51 | useSavedEnvironmentRegistryStore, |
| 48 | useSavedEnvironmentRuntimeStore, | 52 | useSavedEnvironmentRuntimeStore, |
| 49 | waitForSavedEnvironmentRegistryHydration, | 53 | waitForSavedEnvironmentRegistryHydration, |
| ... | @@ -59,8 +63,11 @@ import { | ... | @@ -59,8 +63,11 @@ import { |
| 59 | } from "~/store"; | 63 | } from "~/store"; |
| 60 | import { useTerminalStateStore } from "~/terminalStateStore"; | 64 | import { useTerminalStateStore } from "~/terminalStateStore"; |
| 61 | import { useUiStateStore } from "~/uiStateStore"; | 65 | import { useUiStateStore } from "~/uiStateStore"; |
| 66 | import type { WsProtocolCloseContext } from "../../rpc/protocol"; | ||
| 67 | import { getServerConfig } from "../../rpc/serverState"; | ||
| 62 | import { WsTransport } from "../../rpc/wsTransport"; | 68 | import { WsTransport } from "../../rpc/wsTransport"; |
| 63 | import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; | 69 | import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; |
| 70 | import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew"; | ||
| 64 | import { | 71 | import { |
| 65 | deriveLogicalProjectKeyFromSettings, | 72 | deriveLogicalProjectKeyFromSettings, |
| 66 | derivePhysicalProjectKey, | 73 | derivePhysicalProjectKey, |
| ... | @@ -85,6 +92,28 @@ type ThreadDetailSubscriptionEntry = { | ... | @@ -85,6 +92,28 @@ type ThreadDetailSubscriptionEntry = { |
| 85 | }; | 92 | }; |
| 86 | 93 | ||
| 87 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); | 94 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 95 | class SavedEnvironmentConnectionCancelledError extends Error { | ||
| 96 | constructor(environmentId: EnvironmentId) { | ||
| 97 | super(`Saved environment ${environmentId} connection was cancelled.`); | ||
| 98 | this.name = "SavedEnvironmentConnectionCancelledError"; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | function isSavedEnvironmentConnectionCancelledError( | ||
| 103 | error: unknown, | ||
| 104 | ): error is SavedEnvironmentConnectionCancelledError { | ||
| 105 | return error instanceof SavedEnvironmentConnectionCancelledError; | ||
| 106 | } | ||
| 107 | |||
| 108 | interface PendingSavedEnvironmentConnection { | ||
| 109 | cancelled: boolean; | ||
| 110 | readonly promise: Promise<EnvironmentConnection>; | ||
| 111 | } | ||
| 112 | |||
| 113 | const pendingSavedEnvironmentConnections = new Map< | ||
| 114 | EnvironmentId, | ||
| 115 | PendingSavedEnvironmentConnection | ||
| 116 | >(); | ||
| 88 | const environmentConnectionListeners = new Set<() => void>(); | 117 | const environmentConnectionListeners = new Set<() => void>(); |
| 89 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); | 118 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); |
| 90 | const lastAppliedProjectionVersionByEnvironment = new Map< | 119 | const lastAppliedProjectionVersionByEnvironment = new Map< |
| ... | @@ -97,6 +126,8 @@ const lastAppliedProjectionVersionByEnvironment = new Map< | ... | @@ -97,6 +126,8 @@ const lastAppliedProjectionVersionByEnvironment = new Map< |
| 97 | 126 | ||
| 98 | let activeService: EnvironmentServiceState | null = null; | 127 | let activeService: EnvironmentServiceState | null = null; |
| 99 | let needsProviderInvalidation = false; | 128 | let needsProviderInvalidation = false; |
| 129 | let lastBrowserHiddenAt: number | null = null; | ||
| 130 | let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; | ||
| 100 | 131 | ||
| 101 | // Thread detail subscription cache policy: | 132 | // Thread detail subscription cache policy: |
| 102 | // - Active consumers keep a subscription retained via refCount. | 133 | // - Active consumers keep a subscription retained via refCount. |
| ... | @@ -107,7 +138,9 @@ let needsProviderInvalidation = false; | ... | @@ -107,7 +138,9 @@ let needsProviderInvalidation = false; |
| 107 | // - Capacity eviction only targets idle cached subscriptions. | 138 | // - Capacity eviction only targets idle cached subscriptions. |
| 108 | const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; | 139 | const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; |
| 109 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; | 140 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; |
| 141 | const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000; | ||
| 110 | const NOOP = () => undefined; | 142 | const NOOP = () => undefined; |
| 143 | const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; | ||
| 111 | 144 | ||
| 112 | function compareAppliedProjectionVersion( | 145 | function compareAppliedProjectionVersion( |
| 113 | left: { readonly sequence: number; readonly updatedAt: string | null }, | 146 | left: { readonly sequence: number; readonly updatedAt: string | null }, |
| ... | @@ -327,6 +360,25 @@ function disposeThreadDetailSubscriptionsForEnvironment(environmentId: Environme | ... | @@ -327,6 +360,25 @@ function disposeThreadDetailSubscriptionsForEnvironment(environmentId: Environme |
| 327 | } | 360 | } |
| 328 | } | 361 | } |
| 329 | 362 | ||
| 363 | function detachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { | ||
| 364 | for (const entry of threadDetailSubscriptions.values()) { | ||
| 365 | if (entry.environmentId !== environmentId) { | ||
| 366 | continue; | ||
| 367 | } | ||
| 368 | entry.unsubscribe(); | ||
| 369 | entry.unsubscribe = NOOP; | ||
| 370 | watchThreadDetailSubscriptionConnection(entry); | ||
| 371 | } | ||
| 372 | } | ||
| 373 | |||
| 374 | function attachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { | ||
| 375 | for (const entry of threadDetailSubscriptions.values()) { | ||
| 376 | if (entry.environmentId === environmentId) { | ||
| 377 | attachThreadDetailSubscription(entry); | ||
| 378 | } | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 330 | function reconcileThreadDetailSubscriptionsForEnvironment( | 382 | function reconcileThreadDetailSubscriptionsForEnvironment( |
| 331 | environmentId: EnvironmentId, | 383 | environmentId: EnvironmentId, |
| 332 | threadIds: ReadonlyArray<ThreadId>, | 384 | threadIds: ReadonlyArray<ThreadId>, |
| ... | @@ -491,6 +543,227 @@ function isoNow(): string { | ... | @@ -491,6 +543,227 @@ function isoNow(): string { |
| 491 | return new Date().toISOString(); | 543 | return new Date().toISOString(); |
| 492 | } | 544 | } |
| 493 | 545 | ||
| 546 | function readSshHttpErrorStatus(error: unknown): number | null { | ||
| 547 | if (!(error instanceof Error)) { | ||
| 548 | return null; | ||
| 549 | } | ||
| 550 | |||
| 551 | const match = SSH_HTTP_STATUS_RE.exec(error.message); | ||
| 552 | if (!match) { | ||
| 553 | return null; | ||
| 554 | } | ||
| 555 | |||
| 556 | const parsed = Number.parseInt(match[1] ?? "", 10); | ||
| 557 | return Number.isInteger(parsed) ? parsed : null; | ||
| 558 | } | ||
| 559 | |||
| 560 | function isSshHttpAuthError(error: unknown, status: number): boolean { | ||
| 561 | return readSshHttpErrorStatus(error) === status; | ||
| 562 | } | ||
| 563 | |||
| 564 | function isDesktopSshTargetEqual( | ||
| 565 | left: DesktopSshEnvironmentTarget | undefined, | ||
| 566 | right: DesktopSshEnvironmentTarget | undefined, | ||
| 567 | ): boolean { | ||
| 568 | if (!left || !right) { | ||
| 569 | return false; | ||
| 570 | } | ||
| 571 | |||
| 572 | return ( | ||
| 573 | left.alias === right.alias && | ||
| 574 | left.hostname === right.hostname && | ||
| 575 | left.username === right.username && | ||
| 576 | left.port === right.port | ||
| 577 | ); | ||
| 578 | } | ||
| 579 | |||
| 580 | function findSavedEnvironmentRecordByDesktopSshTarget( | ||
| 581 | target: DesktopSshEnvironmentTarget | undefined, | ||
| 582 | ): SavedEnvironmentRecord | null { | ||
| 583 | if (!target) { | ||
| 584 | return null; | ||
| 585 | } | ||
| 586 | |||
| 587 | return ( | ||
| 588 | listSavedEnvironmentRecords().find((record) => | ||
| 589 | isDesktopSshTargetEqual(record.desktopSsh, target), | ||
| 590 | ) ?? null | ||
| 591 | ); | ||
| 592 | } | ||
| 593 | |||
| 594 | function buildSavedEnvironmentRegistryById( | ||
| 595 | records: ReadonlyArray<SavedEnvironmentRecord>, | ||
| 596 | ): Record<EnvironmentId, SavedEnvironmentRecord> { | ||
| 597 | return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record< | ||
| 598 | EnvironmentId, | ||
| 599 | SavedEnvironmentRecord | ||
| 600 | >; | ||
| 601 | } | ||
| 602 | |||
| 603 | type SavedEnvironmentRegistrySnapshot = ReadonlyMap<EnvironmentId, SavedEnvironmentRecord | null>; | ||
| 604 | |||
| 605 | function snapshotSavedEnvironmentRegistry( | ||
| 606 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 607 | ): SavedEnvironmentRegistrySnapshot { | ||
| 608 | return new Map( | ||
| 609 | environmentIds.map((environmentId) => [ | ||
| 610 | environmentId, | ||
| 611 | getSavedEnvironmentRecord(environmentId) ?? null, | ||
| 612 | ]), | ||
| 613 | ); | ||
| 614 | } | ||
| 615 | |||
| 616 | async function persistSavedEnvironmentRegistryRollback( | ||
| 617 | snapshot: SavedEnvironmentRegistrySnapshot, | ||
| 618 | ): Promise<void> { | ||
| 619 | const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); | ||
| 620 | for (const [environmentId, record] of snapshot) { | ||
| 621 | if (record) { | ||
| 622 | byId[environmentId] = record; | ||
| 623 | continue; | ||
| 624 | } | ||
| 625 | delete byId[environmentId]; | ||
| 626 | } | ||
| 627 | const records = Object.values(byId); | ||
| 628 | await ensureLocalApi().persistence.setSavedEnvironmentRegistry( | ||
| 629 | records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), | ||
| 630 | ); | ||
| 631 | useSavedEnvironmentRegistryStore.setState({ | ||
| 632 | byId, | ||
| 633 | }); | ||
| 634 | } | ||
| 635 | |||
| 636 | async function resolveDesktopSshEnvironmentBootstrap( | ||
| 637 | target: DesktopSshEnvironmentTarget, | ||
| 638 | options?: { readonly issuePairingToken?: boolean }, | ||
| 639 | ): Promise<DesktopSshEnvironmentBootstrap> { | ||
| 640 | const desktopBridge = window.desktopBridge; | ||
| 641 | if (!desktopBridge) { | ||
| 642 | throw new Error("SSH launch is only available in the desktop app."); | ||
| 643 | } | ||
| 644 | |||
| 645 | return await desktopBridge.ensureSshEnvironment(target, options); | ||
| 646 | } | ||
| 647 | |||
| 648 | function getDesktopSshBridge() { | ||
| 649 | const desktopBridge = window.desktopBridge; | ||
| 650 | if (!desktopBridge) { | ||
| 651 | throw new Error("SSH launch is only available in the desktop app."); | ||
| 652 | } | ||
| 653 | return desktopBridge; | ||
| 654 | } | ||
| 655 | |||
| 656 | async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) { | ||
| 657 | return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl); | ||
| 658 | } | ||
| 659 | |||
| 660 | async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) { | ||
| 661 | return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); | ||
| 662 | } | ||
| 663 | |||
| 664 | async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { | ||
| 665 | return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); | ||
| 666 | } | ||
| 667 | |||
| 668 | async function resolveDesktopSshWebSocketConnectionUrl( | ||
| 669 | wsBaseUrl: string, | ||
| 670 | httpBaseUrl: string, | ||
| 671 | bearerToken: string, | ||
| 672 | ) { | ||
| 673 | const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken); | ||
| 674 | const url = new URL(wsBaseUrl, window.location.origin); | ||
| 675 | url.searchParams.set("wsToken", issued.token); | ||
| 676 | return url.toString(); | ||
| 677 | } | ||
| 678 | |||
| 679 | async function prepareSavedEnvironmentRecordForConnection( | ||
| 680 | record: SavedEnvironmentRecord, | ||
| 681 | options?: { readonly issuePairingToken?: boolean }, | ||
| 682 | ): Promise<{ | ||
| 683 | readonly record: SavedEnvironmentRecord; | ||
| 684 | readonly pairingToken: string | null; | ||
| 685 | readonly remotePort: number | null; | ||
| 686 | readonly remoteServerKind: "external" | "managed" | null; | ||
| 687 | }> { | ||
| 688 | if (!record.desktopSsh) { | ||
| 689 | return { | ||
| 690 | record, | ||
| 691 | pairingToken: null, | ||
| 692 | remotePort: null, | ||
| 693 | remoteServerKind: null, | ||
| 694 | }; | ||
| 695 | } | ||
| 696 | |||
| 697 | const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options); | ||
| 698 | const nextRecord: SavedEnvironmentRecord = { | ||
| 699 | ...record, | ||
| 700 | httpBaseUrl: bootstrap.httpBaseUrl, | ||
| 701 | wsBaseUrl: bootstrap.wsBaseUrl, | ||
| 702 | desktopSsh: bootstrap.target, | ||
| 703 | }; | ||
| 704 | |||
| 705 | if ( | ||
| 706 | nextRecord.httpBaseUrl !== record.httpBaseUrl || | ||
| 707 | nextRecord.wsBaseUrl !== record.wsBaseUrl || | ||
| 708 | !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh) | ||
| 709 | ) { | ||
| 710 | await persistSavedEnvironmentRecord(nextRecord); | ||
| 711 | useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); | ||
| 712 | } | ||
| 713 | |||
| 714 | return { | ||
| 715 | record: nextRecord, | ||
| 716 | pairingToken: bootstrap.pairingToken, | ||
| 717 | remotePort: bootstrap.remotePort ?? null, | ||
| 718 | remoteServerKind: bootstrap.remoteServerKind ?? null, | ||
| 719 | }; | ||
| 720 | } | ||
| 721 | |||
| 722 | async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ | ||
| 723 | readonly record: SavedEnvironmentRecord; | ||
| 724 | readonly bearerToken: string; | ||
| 725 | readonly role: AuthSessionRole | null; | ||
| 726 | }> { | ||
| 727 | const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); | ||
| 728 | const prepared = await prepareSavedEnvironmentRecordForConnection(record, { | ||
| 729 | issuePairingToken: true, | ||
| 730 | }); | ||
| 731 | if (!prepared.pairingToken) { | ||
| 732 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | ||
| 733 | throw new Error("Desktop SSH launch did not return a pairing token."); | ||
| 734 | } | ||
| 735 | |||
| 736 | const bearerSession = await bootstrapDesktopSshBearerSession( | ||
| 737 | prepared.record.httpBaseUrl, | ||
| 738 | prepared.pairingToken, | ||
| 739 | ).catch(async (error) => { | ||
| 740 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | ||
| 741 | const detail = [ | ||
| 742 | `local ${prepared.record.httpBaseUrl}`, | ||
| 743 | `remote port ${prepared.remotePort ?? "unknown"}`, | ||
| 744 | prepared.remoteServerKind ? `remote server ${prepared.remoteServerKind}` : null, | ||
| 745 | ] | ||
| 746 | .filter(Boolean) | ||
| 747 | .join(", "); | ||
| 748 | const message = error instanceof Error ? error.message : String(error); | ||
| 749 | throw new Error(`${message} (${detail})`); | ||
| 750 | }); | ||
| 751 | const didPersistBearerToken = await writeSavedEnvironmentBearerToken( | ||
| 752 | prepared.record.environmentId, | ||
| 753 | bearerSession.sessionToken, | ||
| 754 | ); | ||
| 755 | if (!didPersistBearerToken) { | ||
| 756 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); | ||
| 757 | throw new Error("Unable to persist saved environment credentials."); | ||
| 758 | } | ||
| 759 | |||
| 760 | return { | ||
| 761 | record: prepared.record, | ||
| 762 | bearerToken: bearerSession.sessionToken, | ||
| 763 | role: bearerSession.role ?? null, | ||
| 764 | }; | ||
| 765 | } | ||
| 766 | |||
| 494 | function setRuntimeConnecting(environmentId: EnvironmentId) { | 767 | function setRuntimeConnecting(environmentId: EnvironmentId) { |
| 495 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | 768 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { |
| 496 | connectionState: "connecting", | 769 | connectionState: "connecting", |
| ... | @@ -797,40 +1070,80 @@ function createPrimaryEnvironmentClient( | ... | @@ -797,40 +1070,80 @@ function createPrimaryEnvironmentClient( |
| 797 | `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`, | 1070 | `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`, |
| 798 | ); | 1071 | ); |
| 799 | } | 1072 | } |
| 1073 | const connectionLabel = knownEnvironment?.label ?? null; | ||
| 800 | 1074 | ||
| 801 | return createWsRpcClient(new WsTransport(wsBaseUrl)); | 1075 | return createWsRpcClient( |
| 1076 | new WsTransport(wsBaseUrl, { | ||
| 1077 | getConnectionLabel: () => connectionLabel, | ||
| 1078 | getVersionMismatchHint: () => | ||
| 1079 | resolveServerConfigVersionMismatch(getServerConfig())?.hint ?? null, | ||
| 1080 | }), | ||
| 1081 | ); | ||
| 802 | } | 1082 | } |
| 803 | 1083 | ||
| 804 | function createSavedEnvironmentClient( | 1084 | function createSavedEnvironmentClient( |
| 805 | record: SavedEnvironmentRecord, | 1085 | environmentId: EnvironmentId, |
| 806 | bearerToken: string, | 1086 | bearerToken: string, |
| 807 | ): WsRpcClient { | 1087 | ): WsRpcClient { |
| 808 | useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); | 1088 | useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); |
| 809 | 1089 | ||
| 810 | return createWsRpcClient( | 1090 | return createWsRpcClient( |
| 811 | new WsTransport( | 1091 | new WsTransport( |
| 812 | () => | 1092 | async () => { |
| 813 | resolveRemoteWebSocketConnectionUrl({ | 1093 | const record = getSavedEnvironmentRecord(environmentId); |
| 814 | wsBaseUrl: record.wsBaseUrl, | 1094 | if (!record) { |
| 815 | httpBaseUrl: record.httpBaseUrl, | 1095 | throw new Error(`Saved environment ${environmentId} not found.`); |
| 816 | bearerToken, | 1096 | } |
| 817 | }), | 1097 | return record.desktopSsh |
| 1098 | ? await resolveDesktopSshWebSocketConnectionUrl( | ||
| 1099 | record.wsBaseUrl, | ||
| 1100 | record.httpBaseUrl, | ||
| 1101 | bearerToken, | ||
| 1102 | ) | ||
| 1103 | : await resolveRemoteWebSocketConnectionUrl({ | ||
| 1104 | wsBaseUrl: record.wsBaseUrl, | ||
| 1105 | httpBaseUrl: record.httpBaseUrl, | ||
| 1106 | bearerToken, | ||
| 1107 | }); | ||
| 1108 | }, | ||
| 818 | { | 1109 | { |
| 1110 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, | ||
| 1111 | getVersionMismatchHint: () => | ||
| 1112 | resolveServerConfigVersionMismatch( | ||
| 1113 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, | ||
| 1114 | )?.hint ?? null, | ||
| 819 | onAttempt: () => { | 1115 | onAttempt: () => { |
| 820 | setRuntimeConnecting(record.environmentId); | 1116 | setRuntimeConnecting(environmentId); |
| 821 | }, | 1117 | }, |
| 822 | onOpen: () => { | 1118 | onOpen: () => { |
| 823 | setRuntimeConnected(record.environmentId); | 1119 | setRuntimeConnected(environmentId); |
| 824 | }, | 1120 | }, |
| 825 | onError: (message: string) => { | 1121 | onError: (message: string) => { |
| 826 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1122 | const mismatch = resolveServerConfigVersionMismatch( |
| 1123 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, | ||
| 1124 | ); | ||
| 1125 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | ||
| 827 | connectionState: "error", | 1126 | connectionState: "error", |
| 828 | lastError: message, | 1127 | lastError: appendVersionMismatchHint(message, mismatch), |
| 829 | lastErrorAt: isoNow(), | 1128 | lastErrorAt: isoNow(), |
| 830 | }); | 1129 | }); |
| 831 | }, | 1130 | }, |
| 832 | onClose: (details: { readonly code: number; readonly reason: string }) => { | 1131 | onClose: ( |
| 833 | setRuntimeDisconnected(record.environmentId, details.reason); | 1132 | details: { readonly code: number; readonly reason: string }, |
| 1133 | context: WsProtocolCloseContext, | ||
| 1134 | ) => { | ||
| 1135 | if (context.intentional) { | ||
| 1136 | return; | ||
| 1137 | } | ||
| 1138 | setRuntimeDisconnected( | ||
| 1139 | environmentId, | ||
| 1140 | appendVersionMismatchHint( | ||
| 1141 | details.reason, | ||
| 1142 | resolveServerConfigVersionMismatch( | ||
| 1143 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, | ||
| 1144 | ), | ||
| 1145 | ), | ||
| 1146 | ); | ||
| 834 | }, | 1147 | }, |
| 835 | }, | 1148 | }, |
| 836 | ), | 1149 | ), |
| ... | @@ -838,18 +1151,25 @@ function createSavedEnvironmentClient( | ... | @@ -838,18 +1151,25 @@ function createSavedEnvironmentClient( |
| 838 | } | 1151 | } |
| 839 | 1152 | ||
| 840 | async function refreshSavedEnvironmentMetadata( | 1153 | async function refreshSavedEnvironmentMetadata( |
| 841 | record: SavedEnvironmentRecord, | 1154 | environmentId: EnvironmentId, |
| 842 | bearerToken: string, | 1155 | bearerToken: string, |
| 843 | client: WsRpcClient, | 1156 | client: WsRpcClient, |
| 844 | roleHint?: AuthSessionRole | null, | 1157 | roleHint?: AuthSessionRole | null, |
| 845 | configHint?: ServerConfig | null, | 1158 | configHint?: ServerConfig | null, |
| 846 | ): Promise<void> { | 1159 | ): Promise<void> { |
| 1160 | const record = getSavedEnvironmentRecord(environmentId); | ||
| 1161 | if (!record) { | ||
| 1162 | throw new Error(`Saved environment ${environmentId} not found.`); | ||
| 1163 | } | ||
| 1164 | |||
| 847 | const [serverConfig, sessionState] = await Promise.all([ | 1165 | const [serverConfig, sessionState] = await Promise.all([ |
| 848 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), | 1166 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), |
| 849 | fetchRemoteSessionState({ | 1167 | record.desktopSsh |
| 850 | httpBaseUrl: record.httpBaseUrl, | 1168 | ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) |
| 851 | bearerToken, | 1169 | : fetchRemoteSessionState({ |
| 852 | }), | 1170 | httpBaseUrl: record.httpBaseUrl, |
| 1171 | bearerToken, | ||
| 1172 | }), | ||
| 853 | ]); | 1173 | ]); |
| 854 | 1174 | ||
| 855 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1175 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| ... | @@ -858,6 +1178,9 @@ async function refreshSavedEnvironmentMetadata( | ... | @@ -858,6 +1178,9 @@ async function refreshSavedEnvironmentMetadata( |
| 858 | serverConfig, | 1178 | serverConfig, |
| 859 | role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null, | 1179 | role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null, |
| 860 | }); | 1180 | }); |
| 1181 | useSavedEnvironmentRegistryStore | ||
| 1182 | .getState() | ||
| 1183 | .rename(record.environmentId, serverConfig.environment.label); | ||
| 861 | } | 1184 | } |
| 862 | 1185 | ||
| 863 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { | 1186 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { |
| ... | @@ -866,6 +1189,7 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne | ... | @@ -866,6 +1189,7 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne |
| 866 | throw new Error(`Environment ${connection.environmentId} already has an active connection.`); | 1189 | throw new Error(`Environment ${connection.environmentId} already has an active connection.`); |
| 867 | } | 1190 | } |
| 868 | environmentConnections.set(connection.environmentId, connection); | 1191 | environmentConnections.set(connection.environmentId, connection); |
| 1192 | attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); | ||
| 869 | emitEnvironmentConnectionRegistryChange(); | 1193 | emitEnvironmentConnectionRegistryChange(); |
| 870 | return connection; | 1194 | return connection; |
| 871 | } | 1195 | } |
| ... | @@ -876,10 +1200,10 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> | ... | @@ -876,10 +1200,10 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 876 | return false; | 1200 | return false; |
| 877 | } | 1201 | } |
| 878 | 1202 | ||
| 879 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); | ||
| 880 | lastAppliedProjectionVersionByEnvironment.delete(environmentId); | 1203 | lastAppliedProjectionVersionByEnvironment.delete(environmentId); |
| 881 | environmentConnections.delete(environmentId); | 1204 | environmentConnections.delete(environmentId); |
| 882 | emitEnvironmentConnectionRegistryChange(); | 1205 | emitEnvironmentConnectionRegistryChange(); |
| 1206 | detachThreadDetailSubscriptionsForEnvironment(environmentId); | ||
| 883 | await connection.dispose(); | 1207 | await connection.dispose(); |
| 884 | return true; | 1208 | return true; |
| 885 | } | 1209 | } |
| ... | @@ -905,6 +1229,10 @@ function createPrimaryEnvironmentConnection(): EnvironmentConnection { | ... | @@ -905,6 +1229,10 @@ function createPrimaryEnvironmentConnection(): EnvironmentConnection { |
| 905 | ); | 1229 | ); |
| 906 | } | 1230 | } |
| 907 | 1231 | ||
| 1232 | function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null { | ||
| 1233 | return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null; | ||
| 1234 | } | ||
| 1235 | |||
| 908 | async function ensureSavedEnvironmentConnection( | 1236 | async function ensureSavedEnvironmentConnection( |
| 909 | record: SavedEnvironmentRecord, | 1237 | record: SavedEnvironmentRecord, |
| 910 | options?: { | 1238 | options?: { |
| ... | @@ -919,69 +1247,144 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -919,69 +1247,144 @@ async function ensureSavedEnvironmentConnection( |
| 919 | return existing; | 1247 | return existing; |
| 920 | } | 1248 | } |
| 921 | 1249 | ||
| 922 | const bearerToken = | 1250 | const pending = pendingSavedEnvironmentConnections.get(record.environmentId); |
| 923 | options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); | 1251 | if (pending) { |
| 924 | if (!bearerToken) { | 1252 | return pending.promise; |
| 925 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | ||
| 926 | authState: "requires-auth", | ||
| 927 | role: null, | ||
| 928 | connectionState: "disconnected", | ||
| 929 | lastError: "Saved environment is missing its saved credential. Pair it again.", | ||
| 930 | lastErrorAt: isoNow(), | ||
| 931 | }); | ||
| 932 | throw new Error("Saved environment is missing its saved credential."); | ||
| 933 | } | 1253 | } |
| 934 | 1254 | ||
| 935 | const client = options?.client ?? createSavedEnvironmentClient(record, bearerToken); | 1255 | const pendingEntry: PendingSavedEnvironmentConnection = { |
| 936 | const knownEnvironment = createKnownEnvironment({ | 1256 | cancelled: false, |
| 937 | id: record.environmentId, | 1257 | promise: Promise.resolve().then(async () => { |
| 938 | label: record.label, | 1258 | let activeRecord = record; |
| 939 | source: "manual", | 1259 | let roleHint = options?.role ?? null; |
| 940 | target: { | 1260 | let bearerToken = |
| 941 | httpBaseUrl: record.httpBaseUrl, | 1261 | options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); |
| 942 | wsBaseUrl: record.wsBaseUrl, | 1262 | if (!bearerToken) { |
| 943 | }, | 1263 | if (record.desktopSsh) { |
| 944 | }); | 1264 | const issued = await issueDesktopSshBearerSession(record); |
| 945 | const connection = createEnvironmentConnection({ | 1265 | activeRecord = issued.record; |
| 946 | kind: "saved", | 1266 | bearerToken = issued.bearerToken; |
| 947 | knownEnvironment: { | 1267 | roleHint = issued.role; |
| 948 | ...knownEnvironment, | 1268 | } else { |
| 949 | environmentId: record.environmentId, | 1269 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| 950 | }, | 1270 | authState: "requires-auth", |
| 951 | client, | 1271 | role: null, |
| 952 | refreshMetadata: async () => { | 1272 | connectionState: "disconnected", |
| 953 | await refreshSavedEnvironmentMetadata(record, bearerToken, client); | 1273 | lastError: "Saved environment is missing its saved credential. Pair it again.", |
| 954 | }, | 1274 | lastErrorAt: isoNow(), |
| 955 | onConfigSnapshot: (config) => { | 1275 | }); |
| 956 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1276 | throw new Error("Saved environment is missing its saved credential."); |
| 957 | descriptor: config.environment, | 1277 | } |
| 958 | serverConfig: config, | 1278 | } else { |
| 1279 | const prepared = await prepareSavedEnvironmentRecordForConnection(record); | ||
| 1280 | activeRecord = prepared.record; | ||
| 1281 | } | ||
| 1282 | |||
| 1283 | const activeBearerToken = bearerToken; | ||
| 1284 | const client = | ||
| 1285 | options?.client ?? | ||
| 1286 | createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken); | ||
| 1287 | const knownEnvironment = createKnownEnvironment({ | ||
| 1288 | id: activeRecord.environmentId, | ||
| 1289 | label: activeRecord.label, | ||
| 1290 | source: "manual", | ||
| 1291 | target: { | ||
| 1292 | httpBaseUrl: activeRecord.httpBaseUrl, | ||
| 1293 | wsBaseUrl: activeRecord.wsBaseUrl, | ||
| 1294 | }, | ||
| 959 | }); | 1295 | }); |
| 960 | }, | 1296 | const connection = createEnvironmentConnection({ |
| 961 | onWelcome: (payload) => { | 1297 | kind: "saved", |
| 962 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1298 | knownEnvironment: { |
| 963 | descriptor: payload.environment, | 1299 | ...knownEnvironment, |
| 1300 | environmentId: activeRecord.environmentId, | ||
| 1301 | }, | ||
| 1302 | client, | ||
| 1303 | refreshMetadata: async () => { | ||
| 1304 | await refreshSavedEnvironmentMetadata( | ||
| 1305 | activeRecord.environmentId, | ||
| 1306 | activeBearerToken, | ||
| 1307 | client, | ||
| 1308 | ); | ||
| 1309 | }, | ||
| 1310 | onConfigSnapshot: (config) => { | ||
| 1311 | useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { | ||
| 1312 | descriptor: config.environment, | ||
| 1313 | serverConfig: config, | ||
| 1314 | }); | ||
| 1315 | }, | ||
| 1316 | onWelcome: (payload) => { | ||
| 1317 | useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { | ||
| 1318 | descriptor: payload.environment, | ||
| 1319 | }); | ||
| 1320 | }, | ||
| 1321 | ...createEnvironmentConnectionHandlers(), | ||
| 964 | }); | 1322 | }); |
| 965 | }, | ||
| 966 | ...createEnvironmentConnectionHandlers(), | ||
| 967 | }); | ||
| 968 | 1323 | ||
| 969 | registerConnection(connection); | 1324 | try { |
| 1325 | try { | ||
| 1326 | await refreshSavedEnvironmentMetadata( | ||
| 1327 | activeRecord.environmentId, | ||
| 1328 | activeBearerToken, | ||
| 1329 | client, | ||
| 1330 | roleHint, | ||
| 1331 | options?.serverConfig ?? null, | ||
| 1332 | ); | ||
| 1333 | } catch (error) { | ||
| 1334 | const isAuthError = activeRecord.desktopSsh | ||
| 1335 | ? isSshHttpAuthError(error, 401) | ||
| 1336 | : isRemoteEnvironmentAuthHttpError(error) && error.status === 401; | ||
| 1337 | if (!isAuthError) { | ||
| 1338 | throw error; | ||
| 1339 | } | ||
| 1340 | if (!activeRecord.desktopSsh) { | ||
| 1341 | await removeSavedEnvironmentBearerToken(activeRecord.environmentId); | ||
| 1342 | throw new Error("Saved environment credential expired. Pair it again.", { | ||
| 1343 | cause: error, | ||
| 1344 | }); | ||
| 1345 | } | ||
| 1346 | |||
| 1347 | const issued = await issueDesktopSshBearerSession(activeRecord); | ||
| 1348 | activeRecord = issued.record; | ||
| 1349 | bearerToken = issued.bearerToken; | ||
| 1350 | roleHint = issued.role; | ||
| 1351 | await connection.dispose().catch(() => undefined); | ||
| 1352 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | ||
| 1353 | return await ensureSavedEnvironmentConnection(activeRecord, { | ||
| 1354 | bearerToken, | ||
| 1355 | role: roleHint, | ||
| 1356 | serverConfig: options?.serverConfig ?? null, | ||
| 1357 | }); | ||
| 1358 | } | ||
| 1359 | if ( | ||
| 1360 | pendingEntry.cancelled || | ||
| 1361 | pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry | ||
| 1362 | ) { | ||
| 1363 | await connection.dispose().catch(() => undefined); | ||
| 1364 | throw new SavedEnvironmentConnectionCancelledError(activeRecord.environmentId); | ||
| 1365 | } | ||
| 1366 | registerConnection(connection); | ||
| 1367 | return connection; | ||
| 1368 | } catch (error) { | ||
| 1369 | if (error instanceof SavedEnvironmentConnectionCancelledError) { | ||
| 1370 | throw error; | ||
| 1371 | } | ||
| 1372 | setRuntimeError(activeRecord.environmentId, error); | ||
| 1373 | const removed = await removeConnection(activeRecord.environmentId).catch(() => false); | ||
| 1374 | if (!removed) { | ||
| 1375 | await connection.dispose().catch(() => undefined); | ||
| 1376 | } | ||
| 1377 | throw error; | ||
| 1378 | } | ||
| 1379 | }), | ||
| 1380 | }; | ||
| 970 | 1381 | ||
| 971 | try { | 1382 | pendingSavedEnvironmentConnections.set(record.environmentId, pendingEntry); |
| 972 | await refreshSavedEnvironmentMetadata( | 1383 | return await pendingEntry.promise.finally(() => { |
| 973 | record, | 1384 | if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) { |
| 974 | bearerToken, | 1385 | pendingSavedEnvironmentConnections.delete(record.environmentId); |
| 975 | client, | 1386 | } |
| 976 | options?.role ?? null, | 1387 | }); |
| 977 | options?.serverConfig ?? null, | ||
| 978 | ); | ||
| 979 | return connection; | ||
| 980 | } catch (error) { | ||
| 981 | setRuntimeError(record.environmentId, error); | ||
| 982 | await removeConnection(record.environmentId).catch(() => false); | ||
| 983 | throw error; | ||
| 984 | } | ||
| 985 | } | 1388 | } |
| 986 | 1389 | ||
| 987 | async function syncSavedEnvironmentConnections( | 1390 | async function syncSavedEnvironmentConnections( |
| ... | @@ -1006,6 +1409,55 @@ function stopActiveService() { | ... | @@ -1006,6 +1409,55 @@ function stopActiveService() { |
| 1006 | activeService = null; | 1409 | activeService = null; |
| 1007 | } | 1410 | } |
| 1008 | 1411 | ||
| 1412 | function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void { | ||
| 1413 | const now = Date.now(); | ||
| 1414 | if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) { | ||
| 1415 | return; | ||
| 1416 | } | ||
| 1417 | lastBrowserResumeReconnectAt = now; | ||
| 1418 | |||
| 1419 | for (const connection of environmentConnections.values()) { | ||
| 1420 | void connection.reconnect().catch((error) => { | ||
| 1421 | console.warn("Environment reconnect after browser resume failed", { | ||
| 1422 | environmentId: connection.environmentId, | ||
| 1423 | reason, | ||
| 1424 | error: error instanceof Error ? error.message : String(error), | ||
| 1425 | }); | ||
| 1426 | }); | ||
| 1427 | } | ||
| 1428 | } | ||
| 1429 | |||
| 1430 | function subscribeBrowserResumeReconnects(): () => void { | ||
| 1431 | if (typeof document === "undefined" || typeof window === "undefined") { | ||
| 1432 | return NOOP; | ||
| 1433 | } | ||
| 1434 | |||
| 1435 | const handleVisibilityChange = () => { | ||
| 1436 | if (document.visibilityState === "hidden") { | ||
| 1437 | lastBrowserHiddenAt = Date.now(); | ||
| 1438 | return; | ||
| 1439 | } | ||
| 1440 | if (document.visibilityState === "visible" && lastBrowserHiddenAt !== null) { | ||
| 1441 | lastBrowserHiddenAt = null; | ||
| 1442 | reconnectEnvironmentConnectionsAfterBrowserResume("visibilitychange"); | ||
| 1443 | } | ||
| 1444 | }; | ||
| 1445 | |||
| 1446 | const handlePageShow = (event: PageTransitionEvent) => { | ||
| 1447 | if (event.persisted || lastBrowserHiddenAt !== null) { | ||
| 1448 | lastBrowserHiddenAt = null; | ||
| 1449 | reconnectEnvironmentConnectionsAfterBrowserResume("pageshow"); | ||
| 1450 | } | ||
| 1451 | }; | ||
| 1452 | |||
| 1453 | document.addEventListener("visibilitychange", handleVisibilityChange); | ||
| 1454 | window.addEventListener("pageshow", handlePageShow); | ||
| 1455 | return () => { | ||
| 1456 | document.removeEventListener("visibilitychange", handleVisibilityChange); | ||
| 1457 | window.removeEventListener("pageshow", handlePageShow); | ||
| 1458 | }; | ||
| 1459 | } | ||
| 1460 | |||
| 1009 | export function subscribeEnvironmentConnections(listener: () => void): () => void { | 1461 | export function subscribeEnvironmentConnections(listener: () => void): () => void { |
| 1010 | environmentConnectionListeners.add(listener); | 1462 | environmentConnectionListeners.add(listener); |
| 1011 | return () => { | 1463 | return () => { |
| ... | @@ -1036,13 +1488,23 @@ export function getPrimaryEnvironmentConnection(): EnvironmentConnection { | ... | @@ -1036,13 +1488,23 @@ export function getPrimaryEnvironmentConnection(): EnvironmentConnection { |
| 1036 | } | 1488 | } |
| 1037 | 1489 | ||
| 1038 | export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | 1490 | export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { |
| 1491 | const record = getSavedEnvironmentRecord(environmentId); | ||
| 1492 | const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); | ||
| 1493 | if (pendingConnection) { | ||
| 1494 | pendingConnection.cancelled = true; | ||
| 1495 | pendingSavedEnvironmentConnections.delete(environmentId); | ||
| 1496 | } | ||
| 1039 | const connection = environmentConnections.get(environmentId); | 1497 | const connection = environmentConnections.get(environmentId); |
| 1040 | if (connection?.kind !== "saved") { | 1498 | |
| 1041 | return; | 1499 | if (connection?.kind === "saved") { |
| 1500 | await removeConnection(environmentId).catch(() => false); | ||
| 1042 | } | 1501 | } |
| 1502 | setRuntimeDisconnected(environmentId); | ||
| 1043 | 1503 | ||
| 1044 | useSavedEnvironmentRuntimeStore.getState().clear(environmentId); | 1504 | if (record?.desktopSsh && typeof window !== "undefined") { |
| 1045 | await removeConnection(environmentId).catch(() => false); | 1505 | await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh); |
| 1506 | await removeSavedEnvironmentBearerToken(environmentId); | ||
| 1507 | } | ||
| 1046 | } | 1508 | } |
| 1047 | 1509 | ||
| 1048 | export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | 1510 | export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { |
| ... | @@ -1053,23 +1515,57 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P | ... | @@ -1053,23 +1515,57 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P |
| 1053 | 1515 | ||
| 1054 | const connection = environmentConnections.get(environmentId); | 1516 | const connection = environmentConnections.get(environmentId); |
| 1055 | if (!connection) { | 1517 | if (!connection) { |
| 1056 | await ensureSavedEnvironmentConnection(record); | 1518 | setRuntimeConnecting(environmentId); |
| 1057 | return; | 1519 | try { |
| 1520 | await ensureSavedEnvironmentConnection(record); | ||
| 1521 | return; | ||
| 1522 | } catch (error) { | ||
| 1523 | if (isSavedEnvironmentConnectionCancelledError(error)) { | ||
| 1524 | return; | ||
| 1525 | } | ||
| 1526 | setRuntimeError(environmentId, error); | ||
| 1527 | throw error; | ||
| 1528 | } | ||
| 1058 | } | 1529 | } |
| 1059 | 1530 | ||
| 1060 | setRuntimeConnecting(environmentId); | 1531 | setRuntimeConnecting(environmentId); |
| 1061 | try { | 1532 | try { |
| 1533 | if (record.desktopSsh) { | ||
| 1534 | await prepareSavedEnvironmentRecordForConnection(record); | ||
| 1535 | } | ||
| 1062 | await connection.reconnect(); | 1536 | await connection.reconnect(); |
| 1063 | } catch (error) { | 1537 | } catch (error) { |
| 1538 | if (record.desktopSsh) { | ||
| 1539 | try { | ||
| 1540 | const issued = await issueDesktopSshBearerSession( | ||
| 1541 | getSavedEnvironmentRecord(environmentId) ?? record, | ||
| 1542 | ); | ||
| 1543 | await removeConnection(environmentId).catch(() => false); | ||
| 1544 | await ensureSavedEnvironmentConnection(issued.record, { | ||
| 1545 | bearerToken: issued.bearerToken, | ||
| 1546 | role: issued.role, | ||
| 1547 | }); | ||
| 1548 | return; | ||
| 1549 | } catch (recoveryError) { | ||
| 1550 | if (isSavedEnvironmentConnectionCancelledError(recoveryError)) { | ||
| 1551 | return; | ||
| 1552 | } | ||
| 1553 | setRuntimeError(environmentId, recoveryError); | ||
| 1554 | throw recoveryError; | ||
| 1555 | } | ||
| 1556 | } | ||
| 1064 | setRuntimeError(environmentId, error); | 1557 | setRuntimeError(environmentId, error); |
| 1065 | throw error; | 1558 | throw error; |
| 1066 | } | 1559 | } |
| 1067 | } | 1560 | } |
| 1068 | 1561 | ||
| 1069 | export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | 1562 | export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> { |
| 1563 | await disconnectSavedEnvironment(environmentId); | ||
| 1564 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); | ||
| 1070 | useSavedEnvironmentRegistryStore.getState().remove(environmentId); | 1565 | useSavedEnvironmentRegistryStore.getState().remove(environmentId); |
| 1566 | useSavedEnvironmentRuntimeStore.getState().clear(environmentId); | ||
| 1567 | useStore.getState().removeEnvironmentState(environmentId); | ||
| 1071 | await removeSavedEnvironmentBearerToken(environmentId); | 1568 | await removeSavedEnvironmentBearerToken(environmentId); |
| 1072 | await disconnectSavedEnvironment(environmentId); | ||
| 1073 | } | 1569 | } |
| 1074 | 1570 | ||
| 1075 | export async function addSavedEnvironment(input: { | 1571 | export async function addSavedEnvironment(input: { |
| ... | @@ -1077,33 +1573,43 @@ export async function addSavedEnvironment(input: { | ... | @@ -1077,33 +1573,43 @@ export async function addSavedEnvironment(input: { |
| 1077 | readonly pairingUrl?: string; | 1573 | readonly pairingUrl?: string; |
| 1078 | readonly host?: string; | 1574 | readonly host?: string; |
| 1079 | readonly pairingCode?: string; | 1575 | readonly pairingCode?: string; |
| 1576 | readonly desktopSsh?: DesktopSshEnvironmentTarget; | ||
| 1080 | }): Promise<SavedEnvironmentRecord> { | 1577 | }): Promise<SavedEnvironmentRecord> { |
| 1081 | const resolvedTarget = resolveRemotePairingTarget({ | 1578 | const resolvedTarget = resolveRemotePairingTarget({ |
| 1082 | ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), | 1579 | ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), |
| 1083 | ...(input.host !== undefined ? { host: input.host } : {}), | 1580 | ...(input.host !== undefined ? { host: input.host } : {}), |
| 1084 | ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), | 1581 | ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), |
| 1085 | }); | 1582 | }); |
| 1086 | const descriptor = await fetchRemoteEnvironmentDescriptor({ | 1583 | const descriptor = input.desktopSsh |
| 1087 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1584 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) |
| 1088 | }); | 1585 | : await fetchRemoteEnvironmentDescriptor({ |
| 1586 | httpBaseUrl: resolvedTarget.httpBaseUrl, | ||
| 1587 | }); | ||
| 1089 | const environmentId = descriptor.environmentId; | 1588 | const environmentId = descriptor.environmentId; |
| 1090 | 1589 | const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); | |
| 1091 | if (environmentConnections.has(environmentId)) { | 1590 | const existingRecord = |
| 1092 | throw new Error("This environment is already connected."); | 1591 | getSavedEnvironmentRecord(environmentId) ?? |
| 1093 | } | 1592 | findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); |
| 1094 | 1593 | const staleDesktopSshRecord = | |
| 1095 | const bearerSession = await bootstrapRemoteBearerSession({ | 1594 | existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null; |
| 1096 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1595 | |
| 1097 | credential: resolvedTarget.credential, | 1596 | const bearerSession = input.desktopSsh |
| 1098 | }); | 1597 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) |
| 1598 | : await bootstrapRemoteBearerSession({ | ||
| 1599 | httpBaseUrl: resolvedTarget.httpBaseUrl, | ||
| 1600 | credential: resolvedTarget.credential, | ||
| 1601 | }); | ||
| 1099 | 1602 | ||
| 1100 | const record: SavedEnvironmentRecord = { | 1603 | const record: SavedEnvironmentRecord = { |
| 1101 | environmentId, | 1604 | environmentId, |
| 1102 | label: input.label.trim() || descriptor.label, | 1605 | label: input.label.trim() || existingRecord?.label || descriptor.label, |
| 1103 | wsBaseUrl: resolvedTarget.wsBaseUrl, | 1606 | wsBaseUrl: resolvedTarget.wsBaseUrl, |
| 1104 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1607 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 1105 | createdAt: isoNow(), | 1608 | createdAt: existingRecord?.createdAt ?? isoNow(), |
| 1106 | lastConnectedAt: isoNow(), | 1609 | lastConnectedAt: isoNow(), |
| 1610 | ...((input.desktopSsh ?? existingRecord?.desktopSsh) | ||
| 1611 | ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh } | ||
| 1612 | : {}), | ||
| 1107 | }; | 1613 | }; |
| 1108 | 1614 | ||
| 1109 | await persistSavedEnvironmentRecord(record); | 1615 | await persistSavedEnvironmentRecord(record); |
| ... | @@ -1112,26 +1618,50 @@ export async function addSavedEnvironment(input: { | ... | @@ -1112,26 +1618,50 @@ export async function addSavedEnvironment(input: { |
| 1112 | bearerSession.sessionToken, | 1618 | bearerSession.sessionToken, |
| 1113 | ); | 1619 | ); |
| 1114 | if (!didPersistBearerToken) { | 1620 | if (!didPersistBearerToken) { |
| 1115 | await ensureLocalApi().persistence.setSavedEnvironmentRegistry( | 1621 | await persistSavedEnvironmentRegistryRollback(registrySnapshot); |
| 1116 | listSavedEnvironmentRecords().map((entry) => ({ | ||
| 1117 | environmentId: entry.environmentId, | ||
| 1118 | label: entry.label, | ||
| 1119 | httpBaseUrl: entry.httpBaseUrl, | ||
| 1120 | wsBaseUrl: entry.wsBaseUrl, | ||
| 1121 | createdAt: entry.createdAt, | ||
| 1122 | lastConnectedAt: entry.lastConnectedAt, | ||
| 1123 | })), | ||
| 1124 | ); | ||
| 1125 | throw new Error("Unable to persist saved environment credentials."); | 1622 | throw new Error("Unable to persist saved environment credentials."); |
| 1126 | } | 1623 | } |
| 1624 | useSavedEnvironmentRegistryStore.getState().upsert(record); | ||
| 1625 | if (staleDesktopSshRecord) { | ||
| 1626 | await removeSavedEnvironment(staleDesktopSshRecord.environmentId); | ||
| 1627 | } | ||
| 1628 | await removeConnection(environmentId).catch(() => false); | ||
| 1127 | await ensureSavedEnvironmentConnection(record, { | 1629 | await ensureSavedEnvironmentConnection(record, { |
| 1128 | bearerToken: bearerSession.sessionToken, | 1630 | bearerToken: bearerSession.sessionToken, |
| 1129 | role: bearerSession.role, | 1631 | role: bearerSession.role, |
| 1130 | }); | 1632 | }); |
| 1131 | useSavedEnvironmentRegistryStore.getState().upsert(record); | ||
| 1132 | return record; | 1633 | return record; |
| 1133 | } | 1634 | } |
| 1134 | 1635 | ||
| 1636 | export async function connectDesktopSshEnvironment( | ||
| 1637 | target: DesktopSshEnvironmentTarget, | ||
| 1638 | options?: { label?: string }, | ||
| 1639 | ): Promise<SavedEnvironmentRecord> { | ||
| 1640 | const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, { | ||
| 1641 | issuePairingToken: true, | ||
| 1642 | }); | ||
| 1643 | if (!bootstrap.pairingToken) { | ||
| 1644 | throw new Error("Desktop SSH launch did not return a pairing token."); | ||
| 1645 | } | ||
| 1646 | |||
| 1647 | return await addSavedEnvironment({ | ||
| 1648 | label: options?.label?.trim() || bootstrap.target.alias, | ||
| 1649 | host: bootstrap.httpBaseUrl, | ||
| 1650 | pairingCode: bootstrap.pairingToken, | ||
| 1651 | desktopSsh: bootstrap.target, | ||
| 1652 | }).catch((error) => { | ||
| 1653 | const detail = [ | ||
| 1654 | `local ${bootstrap.httpBaseUrl}`, | ||
| 1655 | `remote port ${bootstrap.remotePort ?? "unknown"}`, | ||
| 1656 | bootstrap.remoteServerKind ? `remote server ${bootstrap.remoteServerKind}` : null, | ||
| 1657 | ] | ||
| 1658 | .filter(Boolean) | ||
| 1659 | .join(", "); | ||
| 1660 | const message = error instanceof Error ? error.message : String(error); | ||
| 1661 | throw new Error(`${message} (${detail})`); | ||
| 1662 | }); | ||
| 1663 | } | ||
| 1664 | |||
| 1135 | export async function ensureEnvironmentConnectionBootstrapped( | 1665 | export async function ensureEnvironmentConnectionBootstrapped( |
| 1136 | environmentId: EnvironmentId, | 1666 | environmentId: EnvironmentId, |
| 1137 | ): Promise<void> { | 1667 | ): Promise<void> { |
| ... | @@ -1170,7 +1700,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () | ... | @@ -1170,7 +1700,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 1170 | }, | 1700 | }, |
| 1171 | ); | 1701 | ); |
| 1172 | 1702 | ||
| 1173 | createPrimaryEnvironmentConnection(); | 1703 | maybeCreatePrimaryEnvironmentConnection(); |
| 1174 | 1704 | ||
| 1175 | const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { | 1705 | const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { |
| 1176 | if (!hasSavedEnvironmentRegistryHydrated()) { | 1706 | if (!hasSavedEnvironmentRegistryHydrated()) { |
| ... | @@ -1183,12 +1713,15 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () | ... | @@ -1183,12 +1713,15 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 1183 | .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords())) | 1713 | .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords())) |
| 1184 | .catch(() => undefined); | 1714 | .catch(() => undefined); |
| 1185 | 1715 | ||
| 1716 | const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects(); | ||
| 1717 | |||
| 1186 | activeService = { | 1718 | activeService = { |
| 1187 | queryClient, | 1719 | queryClient, |
| 1188 | queryInvalidationThrottler, | 1720 | queryInvalidationThrottler, |
| 1189 | refCount: 1, | 1721 | refCount: 1, |
| 1190 | stop: () => { | 1722 | stop: () => { |
| 1191 | unsubscribeSavedEnvironments(); | 1723 | unsubscribeSavedEnvironments(); |
| 1724 | unsubscribeBrowserResumeReconnects(); | ||
| 1192 | queryInvalidationThrottler.cancel(); | 1725 | queryInvalidationThrottler.cancel(); |
| 1193 | }, | 1726 | }, |
| 1194 | }; | 1727 | }; |
| ... | @@ -1206,7 +1739,10 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () | ... | @@ -1206,7 +1739,10 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 1206 | 1739 | ||
| 1207 | export async function resetEnvironmentServiceForTests(): Promise<void> { | 1740 | export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 1208 | stopActiveService(); | 1741 | stopActiveService(); |
| 1742 | lastBrowserHiddenAt = null; | ||
| 1743 | lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; | ||
| 1209 | lastAppliedProjectionVersionByEnvironment.clear(); | 1744 | lastAppliedProjectionVersionByEnvironment.clear(); |
| 1745 | pendingSavedEnvironmentConnections.clear(); | ||
| 1210 | for (const key of Array.from(threadDetailSubscriptions.keys())) { | 1746 | for (const key of Array.from(threadDetailSubscriptions.keys())) { |
| 1211 | disposeThreadDetailSubscriptionByKey(key); | 1747 | disposeThreadDetailSubscriptionByKey(key); |
| 1212 | } | 1748 | } |
apps/web/src/hostedPairing.test.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | buildHostedPairingUrl, | ||
| 5 | hasHostedPairingRequest, | ||
| 6 | isHostedStaticApp, | ||
| 7 | readHostedPairingRequest, | ||
| 8 | } from "./hostedPairing"; | ||
| 9 | |||
| 10 | describe("hostedPairing", () => { | ||
| 11 | afterEach(() => { | ||
| 12 | vi.unstubAllEnvs(); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("reads hosted pairing host and query token parameters", () => { | ||
| 16 | const url = new URL("https://app.t3.codes/pair?host=100.64.1.2:3773&token=ABCD1234"); | ||
| 17 | |||
| 18 | expect(readHostedPairingRequest(url)).toEqual({ | ||
| 19 | host: "100.64.1.2:3773", | ||
| 20 | token: "ABCD1234", | ||
| 21 | label: "", | ||
| 22 | }); | ||
| 23 | expect(hasHostedPairingRequest(url)).toBe(true); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("prefers hash tokens so generated hosted links do not put credentials in search params", () => { | ||
| 27 | vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); | ||
| 28 | |||
| 29 | const url = new URL( | ||
| 30 | buildHostedPairingUrl({ | ||
| 31 | host: "https://backend.example.com:3773", | ||
| 32 | token: "pairing-token", | ||
| 33 | label: "Workstation", | ||
| 34 | }), | ||
| 35 | ); | ||
| 36 | |||
| 37 | expect(url.origin).toBe("https://preview.t3.codes"); | ||
| 38 | expect(url.pathname).toBe("/pair"); | ||
| 39 | expect(url.searchParams.get("host")).toBe("https://backend.example.com:3773"); | ||
| 40 | expect(url.searchParams.get("label")).toBe("Workstation"); | ||
| 41 | expect(url.searchParams.has("token")).toBe(false); | ||
| 42 | expect(url.hash).toBe("#token=pairing-token"); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("ignores incomplete hosted pairing requests", () => { | ||
| 46 | expect( | ||
| 47 | hasHostedPairingRequest(new URL("https://app.t3.codes/pair?host=backend.example.com")), | ||
| 48 | ).toBe(false); | ||
| 49 | expect(hasHostedPairingRequest(new URL("https://app.t3.codes/pair?token=ABCD1234"))).toBe( | ||
| 50 | false, | ||
| 51 | ); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("detects the hosted static app only when no backend URL is configured", () => { | ||
| 55 | vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); | ||
| 56 | vi.stubEnv("VITE_HTTP_URL", ""); | ||
| 57 | vi.stubEnv("VITE_WS_URL", ""); | ||
| 58 | |||
| 59 | expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(true); | ||
| 60 | expect(isHostedStaticApp(new URL("https://preview.t3.codes/pair"))).toBe(true); | ||
| 61 | expect(isHostedStaticApp(new URL("https://backend.example.com/"))).toBe(false); | ||
| 62 | |||
| 63 | vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com"); | ||
| 64 | expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(false); | ||
| 65 | }); | ||
| 66 | }); | ||
apps/web/src/hostedPairing.ts created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; | ||
| 2 | |||
| 3 | const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; | ||
| 4 | |||
| 5 | export interface HostedPairingRequest { | ||
| 6 | readonly host: string; | ||
| 7 | readonly token: string; | ||
| 8 | readonly label: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | function configuredHostedAppUrl(): string { | ||
| 12 | return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; | ||
| 13 | } | ||
| 14 | |||
| 15 | function configuredBackendUrl(): string { | ||
| 16 | return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || ""; | ||
| 17 | } | ||
| 18 | |||
| 19 | function originFromUrl(value: string): string | null { | ||
| 20 | try { | ||
| 21 | return new URL(value).origin; | ||
| 22 | } catch { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | export function isHostedStaticApp(url: URL = new URL(window.location.href)): boolean { | ||
| 28 | if (configuredBackendUrl()) { | ||
| 29 | return false; | ||
| 30 | } | ||
| 31 | |||
| 32 | const hostedOrigin = originFromUrl(configuredHostedAppUrl()); | ||
| 33 | return hostedOrigin !== null && url.origin === hostedOrigin; | ||
| 34 | } | ||
| 35 | |||
| 36 | export function readHostedPairingRequest(url: URL = new URL(window.location.href)) { | ||
| 37 | const host = url.searchParams.get("host")?.trim() ?? ""; | ||
| 38 | const token = getPairingTokenFromUrl(url)?.trim() ?? ""; | ||
| 39 | const label = url.searchParams.get("label")?.trim() ?? ""; | ||
| 40 | |||
| 41 | if (!host || !token) { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | |||
| 45 | return { | ||
| 46 | host, | ||
| 47 | token, | ||
| 48 | label, | ||
| 49 | } satisfies HostedPairingRequest; | ||
| 50 | } | ||
| 51 | |||
| 52 | export function hasHostedPairingRequest(url: URL = new URL(window.location.href)): boolean { | ||
| 53 | return readHostedPairingRequest(url) !== null; | ||
| 54 | } | ||
| 55 | |||
| 56 | export function buildHostedPairingUrl(input: { | ||
| 57 | readonly host: string; | ||
| 58 | readonly token: string; | ||
| 59 | readonly label?: string | null; | ||
| 60 | }): string { | ||
| 61 | const url = new URL("/pair", configuredHostedAppUrl()); | ||
| 62 | url.searchParams.set("host", input.host); | ||
| 63 | |||
| 64 | const label = input.label?.trim(); | ||
| 65 | if (label) { | ||
| 66 | url.searchParams.set("label", label); | ||
| 67 | } | ||
| 68 | |||
| 69 | return setPairingTokenOnUrl(url, input.token).toString(); | ||
| 70 | } | ||
apps/web/src/localApi.test.ts+31| ... | @@ -178,16 +178,47 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg | ... | @@ -178,16 +178,47 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg |
| 178 | getSavedEnvironmentSecret: async () => null, | 178 | getSavedEnvironmentSecret: async () => null, |
| 179 | setSavedEnvironmentSecret: async () => true, | 179 | setSavedEnvironmentSecret: async () => true, |
| 180 | removeSavedEnvironmentSecret: async () => undefined, | 180 | removeSavedEnvironmentSecret: async () => undefined, |
| 181 | discoverSshHosts: async () => [], | ||
| 182 | ensureSshEnvironment: async () => { | ||
| 183 | throw new Error("ensureSshEnvironment not implemented in test"); | ||
| 184 | }, | ||
| 185 | disconnectSshEnvironment: async () => undefined, | ||
| 186 | fetchSshEnvironmentDescriptor: async () => { | ||
| 187 | throw new Error("fetchSshEnvironmentDescriptor not implemented in test"); | ||
| 188 | }, | ||
| 189 | bootstrapSshBearerSession: async () => { | ||
| 190 | throw new Error("bootstrapSshBearerSession not implemented in test"); | ||
| 191 | }, | ||
| 192 | fetchSshSessionState: async () => { | ||
| 193 | throw new Error("fetchSshSessionState not implemented in test"); | ||
| 194 | }, | ||
| 195 | issueSshWebSocketToken: async () => { | ||
| 196 | throw new Error("issueSshWebSocketToken not implemented in test"); | ||
| 197 | }, | ||
| 198 | onSshPasswordPrompt: () => () => undefined, | ||
| 199 | resolveSshPasswordPrompt: async () => undefined, | ||
| 181 | getServerExposureState: async () => ({ | 200 | getServerExposureState: async () => ({ |
| 182 | mode: "local-only", | 201 | mode: "local-only", |
| 183 | endpointUrl: null, | 202 | endpointUrl: null, |
| 184 | advertisedHost: null, | 203 | advertisedHost: null, |
| 204 | tailscaleServeEnabled: false, | ||
| 205 | tailscaleServePort: 443, | ||
| 185 | }), | 206 | }), |
| 186 | setServerExposureMode: async () => ({ | 207 | setServerExposureMode: async () => ({ |
| 187 | mode: "local-only", | 208 | mode: "local-only", |
| 188 | endpointUrl: null, | 209 | endpointUrl: null, |
| 189 | advertisedHost: null, | 210 | advertisedHost: null, |
| 211 | tailscaleServeEnabled: false, | ||
| 212 | tailscaleServePort: 443, | ||
| 213 | }), | ||
| 214 | setTailscaleServeEnabled: async (input) => ({ | ||
| 215 | mode: "local-only", | ||
| 216 | endpointUrl: null, | ||
| 217 | advertisedHost: null, | ||
| 218 | tailscaleServeEnabled: input.enabled, | ||
| 219 | tailscaleServePort: input.port ?? 443, | ||
| 190 | }), | 220 | }), |
| 221 | getAdvertisedEndpoints: async () => [], | ||
| 191 | pickFolder: async () => null, | 222 | pickFolder: async () => null, |
| 192 | confirm: async () => true, | 223 | confirm: async () => true, |
| 193 | setTheme: async () => undefined, | 224 | setTheme: async () => undefined, |
apps/web/src/localApi.ts+38-9| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | getPrimaryEnvironmentConnection, | 13 | getPrimaryEnvironmentConnection, |
| 14 | resetEnvironmentServiceForTests, | 14 | resetEnvironmentServiceForTests, |
| 15 | } from "./environments/runtime"; | 15 | } from "./environments/runtime"; |
| 16 | import { getPrimaryKnownEnvironment } from "./environments/primary"; | ||
| 16 | import { type WsRpcClient } from "./rpc/wsRpcClient"; | 17 | import { type WsRpcClient } from "./rpc/wsRpcClient"; |
| 17 | import { showContextMenuFallback } from "./contextMenuFallback"; | 18 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 18 | import { | 19 | import { |
| ... | @@ -27,7 +28,11 @@ import { | ... | @@ -27,7 +28,11 @@ import { |
| 27 | 28 | ||
| 28 | let cachedApi: LocalApi | undefined; | 29 | let cachedApi: LocalApi | undefined; |
| 29 | 30 | ||
| 30 | export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | 31 | function unavailableLocalBackendError(): Error { |
| 32 | return new Error("Local backend API is unavailable before a backend is paired."); | ||
| 33 | } | ||
| 34 | |||
| 35 | function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { | ||
| 31 | return { | 36 | return { |
| 32 | dialogs: { | 37 | dialogs: { |
| 33 | pickFolder: async (options) => { | 38 | pickFolder: async (options) => { |
| ... | @@ -42,7 +47,10 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | ... | @@ -42,7 +47,10 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { |
| 42 | }, | 47 | }, |
| 43 | }, | 48 | }, |
| 44 | shell: { | 49 | shell: { |
| 45 | openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), | 50 | openInEditor: (cwd, editor) => |
| 51 | rpcClient | ||
| 52 | ? rpcClient.shell.openInEditor({ cwd, editor }) | ||
| 53 | : Promise.reject(unavailableLocalBackendError()), | ||
| 46 | openExternal: async (url) => { | 54 | openExternal: async (url) => { |
| 47 | if (window.desktopBridge) { | 55 | if (window.desktopBridge) { |
| 48 | const opened = await window.desktopBridge.openExternal(url); | 56 | const opened = await window.desktopBridge.openExternal(url); |
| ... | @@ -111,16 +119,34 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | ... | @@ -111,16 +119,34 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { |
| 111 | }, | 119 | }, |
| 112 | }, | 120 | }, |
| 113 | server: { | 121 | server: { |
| 114 | getConfig: rpcClient.server.getConfig, | 122 | getConfig: () => |
| 115 | refreshProviders: rpcClient.server.refreshProviders, | 123 | rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), |
| 116 | upsertKeybinding: rpcClient.server.upsertKeybinding, | 124 | refreshProviders: () => |
| 117 | getSettings: rpcClient.server.getSettings, | 125 | rpcClient |
| 118 | updateSettings: rpcClient.server.updateSettings, | 126 | ? rpcClient.server.refreshProviders() |
| 119 | discoverSourceControl: rpcClient.server.discoverSourceControl, | 127 | : Promise.reject(unavailableLocalBackendError()), |
| 128 | upsertKeybinding: (input) => | ||
| 129 | rpcClient | ||
| 130 | ? rpcClient.server.upsertKeybinding(input) | ||
| 131 | : Promise.reject(unavailableLocalBackendError()), | ||
| 132 | getSettings: () => | ||
| 133 | rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), | ||
| 134 | updateSettings: (patch) => | ||
| 135 | rpcClient | ||
| 136 | ? rpcClient.server.updateSettings(patch) | ||
| 137 | : Promise.reject(unavailableLocalBackendError()), | ||
| 138 | discoverSourceControl: () => | ||
| 139 | rpcClient | ||
| 140 | ? rpcClient.server.discoverSourceControl() | ||
| 141 | : Promise.reject(unavailableLocalBackendError()), | ||
| 120 | }, | 142 | }, |
| 121 | }; | 143 | }; |
| 122 | } | 144 | } |
| 123 | 145 | ||
| 146 | export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | ||
| 147 | return createBrowserLocalApi(rpcClient); | ||
| 148 | } | ||
| 149 | |||
| 124 | export function readLocalApi(): LocalApi | undefined { | 150 | export function readLocalApi(): LocalApi | undefined { |
| 125 | if (typeof window === "undefined") return undefined; | 151 | if (typeof window === "undefined") return undefined; |
| 126 | if (cachedApi) return cachedApi; | 152 | if (cachedApi) return cachedApi; |
| ... | @@ -130,7 +156,10 @@ export function readLocalApi(): LocalApi | undefined { | ... | @@ -130,7 +156,10 @@ export function readLocalApi(): LocalApi | undefined { |
| 130 | return cachedApi; | 156 | return cachedApi; |
| 131 | } | 157 | } |
| 132 | 158 | ||
| 133 | cachedApi = createLocalApi(getPrimaryEnvironmentConnection().client); | 159 | const primaryEnvironment = getPrimaryKnownEnvironment(); |
| 160 | cachedApi = primaryEnvironment | ||
| 161 | ? createLocalApi(getPrimaryEnvironmentConnection().client) | ||
| 162 | : createBrowserLocalApi(); | ||
| 134 | return cachedApi; | 163 | return cachedApi; |
| 135 | } | 164 | } |
| 136 | 165 |
apps/web/src/routes/__root.tsx+80-15| ... | @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; | ... | @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 13 | import { APP_DISPLAY_NAME } from "../branding"; | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 15 | import { CommandPalette } from "../components/CommandPalette"; | 15 | import { CommandPalette } from "../components/CommandPalette"; |
| 16 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | ||
| 16 | import { | 17 | import { |
| 17 | SlowRpcAckToastCoordinator, | 18 | SlowRpcAckToastCoordinator, |
| 18 | WebSocketConnectionCoordinator, | 19 | WebSocketConnectionCoordinator, |
| ... | @@ -46,19 +47,41 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; | ... | @@ -46,19 +47,41 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 46 | import { | 47 | import { |
| 47 | ensureEnvironmentConnectionBootstrapped, | 48 | ensureEnvironmentConnectionBootstrapped, |
| 48 | getPrimaryEnvironmentConnection, | 49 | getPrimaryEnvironmentConnection, |
| 50 | listSavedEnvironmentRecords, | ||
| 51 | waitForSavedEnvironmentRegistryHydration, | ||
| 49 | startEnvironmentConnectionService, | 52 | startEnvironmentConnectionService, |
| 53 | useSavedEnvironmentRegistryStore, | ||
| 50 | } from "../environments/runtime"; | 54 | } from "../environments/runtime"; |
| 51 | import { configureClientTracing } from "../observability/clientTracing"; | 55 | import { configureClientTracing } from "../observability/clientTracing"; |
| 52 | import { | 56 | import { |
| 53 | ensurePrimaryEnvironmentReady, | 57 | ensurePrimaryEnvironmentReady, |
| 58 | getPrimaryKnownEnvironment, | ||
| 54 | resolveInitialServerAuthGateState, | 59 | resolveInitialServerAuthGateState, |
| 55 | updatePrimaryEnvironmentDescriptor, | 60 | updatePrimaryEnvironmentDescriptor, |
| 56 | } from "../environments/primary"; | 61 | } from "../environments/primary"; |
| 62 | import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; | ||
| 57 | 63 | ||
| 58 | export const Route = createRootRouteWithContext<{ | 64 | export const Route = createRootRouteWithContext<{ |
| 59 | queryClient: QueryClient; | 65 | queryClient: QueryClient; |
| 60 | }>()({ | 66 | }>()({ |
| 61 | beforeLoad: async () => { | 67 | beforeLoad: async ({ location }) => { |
| 68 | if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { | ||
| 69 | return { | ||
| 70 | authGateState: { | ||
| 71 | status: "hosted-pairing", | ||
| 72 | } as const, | ||
| 73 | }; | ||
| 74 | } | ||
| 75 | |||
| 76 | if (isHostedStaticApp(new URL(window.location.href))) { | ||
| 77 | await waitForSavedEnvironmentRegistryHydration(); | ||
| 78 | return { | ||
| 79 | authGateState: { | ||
| 80 | status: "hosted-static", | ||
| 81 | } as const, | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | |||
| 62 | const [, authGateState] = await Promise.all([ | 85 | const [, authGateState] = await Promise.all([ |
| 63 | ensurePrimaryEnvironmentReady(), | 86 | ensurePrimaryEnvironmentReady(), |
| 64 | resolveInitialServerAuthGateState(), | 87 | resolveInitialServerAuthGateState(), |
| ... | @@ -77,6 +100,7 @@ export const Route = createRootRouteWithContext<{ | ... | @@ -77,6 +100,7 @@ export const Route = createRootRouteWithContext<{ |
| 77 | function RootRouteView() { | 100 | function RootRouteView() { |
| 78 | const pathname = useLocation({ select: (location) => location.pathname }); | 101 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 79 | const { authGateState } = Route.useRouteContext(); | 102 | const { authGateState } = Route.useRouteContext(); |
| 103 | const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; | ||
| 80 | 104 | ||
| 81 | useEffect(() => { | 105 | useEffect(() => { |
| 82 | const frame = window.requestAnimationFrame(() => { | 106 | const frame = window.requestAnimationFrame(() => { |
| ... | @@ -91,30 +115,65 @@ function RootRouteView() { | ... | @@ -91,30 +115,65 @@ function RootRouteView() { |
| 91 | return <Outlet />; | 115 | return <Outlet />; |
| 92 | } | 116 | } |
| 93 | 117 | ||
| 94 | if (authGateState.status !== "authenticated") { | 118 | if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { |
| 95 | return <Outlet />; | 119 | return <Outlet />; |
| 96 | } | 120 | } |
| 121 | |||
| 122 | const appShell = ( | ||
| 123 | <CommandPalette> | ||
| 124 | <AppSidebarLayout> | ||
| 125 | <Outlet /> | ||
| 126 | </AppSidebarLayout> | ||
| 127 | </CommandPalette> | ||
| 128 | ); | ||
| 129 | |||
| 97 | return ( | 130 | return ( |
| 98 | <ToastProvider> | 131 | <ToastProvider> |
| 99 | <AnchoredToastProvider> | 132 | <AnchoredToastProvider> |
| 100 | <AuthenticatedTracingBootstrap /> | 133 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 101 | <ServerStateBootstrap /> | 134 | {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null} |
| 102 | <EnvironmentConnectionManagerBootstrap /> | 135 | <EnvironmentConnectionManagerBootstrap /> |
| 103 | <EventRouter /> | 136 | <SshPasswordPromptDialog /> |
| 104 | <WebSocketConnectionCoordinator /> | 137 | <HostedStaticEnvironmentBootstrap /> |
| 105 | <SlowRpcAckToastCoordinator /> | 138 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 106 | <WebSocketConnectionSurface> | 139 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} |
| 107 | <CommandPalette> | 140 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} |
| 108 | <AppSidebarLayout> | 141 | {primaryEnvironmentAuthenticated ? ( |
| 109 | <Outlet /> | 142 | <WebSocketConnectionSurface>{appShell}</WebSocketConnectionSurface> |
| 110 | </AppSidebarLayout> | 143 | ) : ( |
| 111 | </CommandPalette> | 144 | appShell |
| 112 | </WebSocketConnectionSurface> | 145 | )} |
| 113 | </AnchoredToastProvider> | 146 | </AnchoredToastProvider> |
| 114 | </ToastProvider> | 147 | </ToastProvider> |
| 115 | ); | 148 | ); |
| 116 | } | 149 | } |
| 117 | 150 | ||
| 151 | function HostedStaticEnvironmentBootstrap() { | ||
| 152 | const savedEnvironmentCount = useSavedEnvironmentRegistryStore( | ||
| 153 | (state) => Object.keys(state.byId).length, | ||
| 154 | ); | ||
| 155 | |||
| 156 | useEffect(() => { | ||
| 157 | if (getPrimaryKnownEnvironment()) { | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | |||
| 161 | const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId; | ||
| 162 | if (currentActiveEnvironmentId) { | ||
| 163 | return; | ||
| 164 | } | ||
| 165 | |||
| 166 | const firstSavedEnvironment = listSavedEnvironmentRecords()[0]; | ||
| 167 | if (!firstSavedEnvironment) { | ||
| 168 | return; | ||
| 169 | } | ||
| 170 | |||
| 171 | useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId); | ||
| 172 | }, [savedEnvironmentCount]); | ||
| 173 | |||
| 174 | return null; | ||
| 175 | } | ||
| 176 | |||
| 118 | function RootRouteErrorView({ error, reset }: ErrorComponentProps) { | 177 | function RootRouteErrorView({ error, reset }: ErrorComponentProps) { |
| 119 | const message = errorMessage(error); | 178 | const message = errorMessage(error); |
| 120 | const details = errorDetails(error); | 179 | const details = errorDetails(error); |
| ... | @@ -187,7 +246,13 @@ function errorDetails(error: unknown): string { | ... | @@ -187,7 +246,13 @@ function errorDetails(error: unknown): string { |
| 187 | } | 246 | } |
| 188 | 247 | ||
| 189 | function ServerStateBootstrap() { | 248 | function ServerStateBootstrap() { |
| 190 | useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []); | 249 | useEffect(() => { |
| 250 | if (!getPrimaryKnownEnvironment()) { | ||
| 251 | return; | ||
| 252 | } | ||
| 253 | |||
| 254 | return startServerStateSync(getPrimaryEnvironmentConnection().client.server); | ||
| 255 | }, []); | ||
| 191 | 256 | ||
| 192 | return null; | 257 | return null; |
| 193 | } | 258 | } |
apps/web/src/routes/_chat.index.tsx+55| ... | @@ -1,11 +1,66 @@ | ... | @@ -1,11 +1,66 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { LinkIcon, PlusIcon } from "lucide-react"; | ||
| 2 | 3 | ||
| 3 | import { NoActiveThreadState } from "../components/NoActiveThreadState"; | 4 | import { NoActiveThreadState } from "../components/NoActiveThreadState"; |
| 5 | import { Button } from "../components/ui/button"; | ||
| 6 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; | ||
| 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | ||
| 8 | import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; | ||
| 9 | import { APP_DISPLAY_NAME } from "~/branding"; | ||
| 4 | 10 | ||
| 5 | function ChatIndexRouteView() { | 11 | function ChatIndexRouteView() { |
| 12 | const { authGateState } = Route.useRouteContext(); | ||
| 13 | const savedEnvironmentCount = useSavedEnvironmentRegistryStore( | ||
| 14 | (state) => Object.keys(state.byId).length, | ||
| 15 | ); | ||
| 16 | |||
| 17 | if (authGateState.status === "hosted-static" && savedEnvironmentCount === 0) { | ||
| 18 | return <HostedStaticOnboardingState />; | ||
| 19 | } | ||
| 20 | |||
| 6 | return <NoActiveThreadState />; | 21 | return <NoActiveThreadState />; |
| 7 | } | 22 | } |
| 8 | 23 | ||
| 9 | export const Route = createFileRoute("/_chat/")({ | 24 | export const Route = createFileRoute("/_chat/")({ |
| 10 | component: ChatIndexRouteView, | 25 | component: ChatIndexRouteView, |
| 11 | }); | 26 | }); |
| 27 | |||
| 28 | function HostedStaticOnboardingState() { | ||
| 29 | return ( | ||
| 30 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | ||
| 31 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> | ||
| 32 | <header className="border-b border-border px-3 py-2 sm:px-5 sm:py-3"> | ||
| 33 | <div className="flex items-center gap-2"> | ||
| 34 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | ||
| 35 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> | ||
| 36 | {APP_DISPLAY_NAME} | ||
| 37 | </span> | ||
| 38 | </div> | ||
| 39 | </header> | ||
| 40 | |||
| 41 | <Empty className="flex-1"> | ||
| 42 | <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5"> | ||
| 43 | <EmptyHeader className="max-w-none"> | ||
| 44 | <div className="mx-auto mb-5 flex size-11 items-center justify-center rounded-xl border border-border/70 bg-background/70 text-muted-foreground"> | ||
| 45 | <LinkIcon className="size-5" /> | ||
| 46 | </div> | ||
| 47 | <EmptyTitle className="text-foreground text-xl"> | ||
| 48 | Connect an environment to get started | ||
| 49 | </EmptyTitle> | ||
| 50 | <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78"> | ||
| 51 | Open a pairing link from your T3 Code desktop app or add a reachable backend | ||
| 52 | manually. Your saved environments stay in this browser. | ||
| 53 | </EmptyDescription> | ||
| 54 | <div className="mt-6 flex justify-center"> | ||
| 55 | <Button render={<a href="/settings/connections" />} size="sm"> | ||
| 56 | <PlusIcon className="size-4" /> | ||
| 57 | Add environment | ||
| 58 | </Button> | ||
| 59 | </div> | ||
| 60 | </EmptyHeader> | ||
| 61 | </div> | ||
| 62 | </Empty> | ||
| 63 | </div> | ||
| 64 | </SidebarInset> | ||
| 65 | ); | ||
| 66 | } |
apps/web/src/routes/_chat.tsx+4-1| ... | @@ -108,7 +108,10 @@ function ChatRouteLayout() { | ... | @@ -108,7 +108,10 @@ function ChatRouteLayout() { |
| 108 | 108 | ||
| 109 | export const Route = createFileRoute("/_chat")({ | 109 | export const Route = createFileRoute("/_chat")({ |
| 110 | beforeLoad: async ({ context }) => { | 110 | beforeLoad: async ({ context }) => { |
| 111 | if (context.authGateState.status !== "authenticated") { | 111 | if ( |
| 112 | context.authGateState.status !== "authenticated" && | ||
| 113 | context.authGateState.status !== "hosted-static" | ||
| 114 | ) { | ||
| 112 | throw redirect({ to: "/pair", replace: true }); | 115 | throw redirect({ to: "/pair", replace: true }); |
| 113 | } | 116 | } |
| 114 | }, | 117 | }, |
apps/web/src/routes/pair.tsx+16-2| ... | @@ -1,11 +1,21 @@ | ... | @@ -1,11 +1,21 @@ |
| 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; | 3 | import { |
| 4 | HostedPairingRouteSurface, | ||
| 5 | PairingPendingSurface, | ||
| 6 | PairingRouteSurface, | ||
| 7 | } from "../components/auth/PairingRouteSurface"; | ||
| 4 | 8 | ||
| 5 | export const Route = createFileRoute("/pair")({ | 9 | export const Route = createFileRoute("/pair")({ |
| 6 | beforeLoad: async ({ context }) => { | 10 | beforeLoad: async ({ context }) => { |
| 7 | const { authGateState } = context; | 11 | const { authGateState } = context; |
| 8 | if (authGateState.status === "authenticated") { | 12 | if (authGateState.status === "hosted-pairing") { |
| 13 | return { | ||
| 14 | authGateState, | ||
| 15 | }; | ||
| 16 | } | ||
| 17 | |||
| 18 | if (authGateState.status === "authenticated" || authGateState.status === "hosted-static") { | ||
| 9 | throw redirect({ to: "/", replace: true }); | 19 | throw redirect({ to: "/", replace: true }); |
| 10 | } | 20 | } |
| 11 | return { | 21 | return { |
| ... | @@ -24,6 +34,10 @@ function PairRouteView() { | ... | @@ -24,6 +34,10 @@ function PairRouteView() { |
| 24 | return null; | 34 | return null; |
| 25 | } | 35 | } |
| 26 | 36 | ||
| 37 | if (authGateState.status === "hosted-pairing") { | ||
| 38 | return <HostedPairingRouteSurface />; | ||
| 39 | } | ||
| 40 | |||
| 27 | return ( | 41 | return ( |
| 28 | <PairingRouteSurface | 42 | <PairingRouteSurface |
| 29 | auth={authGateState.auth} | 43 | auth={authGateState.auth} |
apps/web/src/routes/settings.tsx+4-1| ... | @@ -104,7 +104,10 @@ function SettingsRouteLayout() { | ... | @@ -104,7 +104,10 @@ function SettingsRouteLayout() { |
| 104 | 104 | ||
| 105 | export const Route = createFileRoute("/settings")({ | 105 | export const Route = createFileRoute("/settings")({ |
| 106 | beforeLoad: async ({ context, location }) => { | 106 | beforeLoad: async ({ context, location }) => { |
| 107 | if (context.authGateState.status !== "authenticated") { | 107 | if ( |
| 108 | context.authGateState.status !== "authenticated" && | ||
| 109 | context.authGateState.status !== "hosted-static" | ||
| 110 | ) { | ||
| 108 | throw redirect({ to: "/pair", replace: true }); | 111 | throw redirect({ to: "/pair", replace: true }); |
| 109 | } | 112 | } |
| 110 | 113 |
apps/web/src/rpc/protocol.ts+159-27| ... | @@ -14,15 +14,45 @@ import { | ... | @@ -14,15 +14,45 @@ import { |
| 14 | recordWsConnectionClosed, | 14 | recordWsConnectionClosed, |
| 15 | recordWsConnectionErrored, | 15 | recordWsConnectionErrored, |
| 16 | recordWsConnectionOpened, | 16 | recordWsConnectionOpened, |
| 17 | type WsConnectionMetadata, | ||
| 17 | WS_RECONNECT_MAX_RETRIES, | 18 | WS_RECONNECT_MAX_RETRIES, |
| 18 | } from "./wsConnectionState"; | 19 | } from "./wsConnectionState"; |
| 19 | 20 | ||
| 21 | export interface WsProtocolCloseContext { | ||
| 22 | readonly intentional: boolean; | ||
| 23 | } | ||
| 24 | |||
| 20 | export interface WsProtocolLifecycleHandlers { | 25 | export interface WsProtocolLifecycleHandlers { |
| 26 | readonly getConnectionLabel?: () => string | null; | ||
| 27 | readonly getVersionMismatchHint?: () => string | null; | ||
| 28 | readonly isCloseIntentional?: () => boolean; | ||
| 21 | readonly isActive?: () => boolean; | 29 | readonly isActive?: () => boolean; |
| 22 | readonly onAttempt?: (socketUrl: string) => void; | 30 | readonly onAttempt?: (socketUrl: string) => void; |
| 23 | readonly onOpen?: () => void; | 31 | readonly onOpen?: () => void; |
| 32 | readonly onHeartbeatPing?: () => void; | ||
| 33 | readonly onHeartbeatPong?: () => void; | ||
| 34 | readonly onHeartbeatTimeout?: () => void; | ||
| 35 | readonly onRequestStart?: (info: { | ||
| 36 | readonly id: string; | ||
| 37 | readonly tag: string; | ||
| 38 | readonly stream: boolean; | ||
| 39 | }) => void; | ||
| 40 | readonly onRequestChunk?: (info: { | ||
| 41 | readonly id: string; | ||
| 42 | readonly tag: string; | ||
| 43 | readonly chunkCount: number; | ||
| 44 | }) => void; | ||
| 45 | readonly onRequestExit?: (info: { | ||
| 46 | readonly id: string; | ||
| 47 | readonly tag: string; | ||
| 48 | readonly stream: boolean; | ||
| 49 | }) => void; | ||
| 50 | readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; | ||
| 24 | readonly onError?: (message: string) => void; | 51 | readonly onError?: (message: string) => void; |
| 25 | readonly onClose?: (details: { readonly code: number; readonly reason: string }) => void; | 52 | readonly onClose?: ( |
| 53 | details: { readonly code: number; readonly reason: string }, | ||
| 54 | context: WsProtocolCloseContext, | ||
| 55 | ) => void; | ||
| 26 | } | 56 | } |
| 27 | 57 | ||
| 28 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | 58 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); |
| ... | @@ -48,27 +78,47 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { | ... | @@ -48,27 +78,47 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { |
| 48 | return resolved.toString(); | 78 | return resolved.toString(); |
| 49 | } | 79 | } |
| 50 | 80 | ||
| 51 | function defaultLifecycleHandlers(): Required<WsProtocolLifecycleHandlers> { | 81 | function resolveConnectionMetadata(handlers?: WsProtocolLifecycleHandlers): WsConnectionMetadata { |
| 82 | return { | ||
| 83 | connectionLabel: handlers?.getConnectionLabel?.() ?? null, | ||
| 84 | versionMismatchHint: handlers?.getVersionMismatchHint?.() ?? null, | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | type ComposedWsProtocolLifecycleHandlers = Required< | ||
| 89 | Pick<WsProtocolLifecycleHandlers, "isActive" | "onAttempt" | "onOpen" | "onError" | "onClose"> | ||
| 90 | >; | ||
| 91 | |||
| 92 | function defaultLifecycleHandlers( | ||
| 93 | handlers?: WsProtocolLifecycleHandlers, | ||
| 94 | ): ComposedWsProtocolLifecycleHandlers { | ||
| 52 | return { | 95 | return { |
| 53 | isActive: () => true, | 96 | isActive: () => true, |
| 54 | onAttempt: recordWsConnectionAttempt, | 97 | onAttempt: (socketUrl) => { |
| 55 | onOpen: recordWsConnectionOpened, | 98 | recordWsConnectionAttempt(socketUrl, resolveConnectionMetadata(handlers)); |
| 99 | }, | ||
| 100 | onOpen: () => { | ||
| 101 | recordWsConnectionOpened(resolveConnectionMetadata(handlers)); | ||
| 102 | }, | ||
| 56 | onError: (message) => { | 103 | onError: (message) => { |
| 57 | clearAllTrackedRpcRequests(); | 104 | clearAllTrackedRpcRequests(); |
| 58 | recordWsConnectionErrored(message); | 105 | recordWsConnectionErrored(message, resolveConnectionMetadata(handlers)); |
| 59 | }, | 106 | }, |
| 60 | onClose: (details) => { | 107 | onClose: (details, context) => { |
| 61 | clearAllTrackedRpcRequests(); | 108 | clearAllTrackedRpcRequests(); |
| 62 | recordWsConnectionClosed(details); | 109 | if (context.intentional) { |
| 110 | return; | ||
| 111 | } | ||
| 112 | recordWsConnectionClosed(details, resolveConnectionMetadata(handlers)); | ||
| 63 | }, | 113 | }, |
| 64 | }; | 114 | }; |
| 65 | } | 115 | } |
| 66 | 116 | ||
| 67 | function composeLifecycleHandlers( | 117 | function composeLifecycleHandlers( |
| 68 | handlers?: WsProtocolLifecycleHandlers, | 118 | handlers?: WsProtocolLifecycleHandlers, |
| 69 | ): Required<WsProtocolLifecycleHandlers> { | 119 | ): ComposedWsProtocolLifecycleHandlers { |
| 70 | const defaults = defaultLifecycleHandlers(); | 120 | const defaults = defaultLifecycleHandlers(handlers); |
| 71 | const isActive = handlers?.isActive ?? (() => true); | 121 | const isActive = handlers?.isActive ?? defaults.isActive; |
| 72 | 122 | ||
| 73 | return { | 123 | return { |
| 74 | isActive, | 124 | isActive, |
| ... | @@ -93,12 +143,12 @@ function composeLifecycleHandlers( | ... | @@ -93,12 +143,12 @@ function composeLifecycleHandlers( |
| 93 | defaults.onError(message); | 143 | defaults.onError(message); |
| 94 | handlers?.onError?.(message); | 144 | handlers?.onError?.(message); |
| 95 | }, | 145 | }, |
| 96 | onClose: (details) => { | 146 | onClose: (details, context) => { |
| 97 | if (!isActive()) { | 147 | if (!isActive()) { |
| 98 | return; | 148 | return; |
| 99 | } | 149 | } |
| 100 | defaults.onClose(details); | 150 | defaults.onClose(details, context); |
| 101 | handlers?.onClose?.(details); | 151 | handlers?.onClose?.(details, context); |
| 102 | }, | 152 | }, |
| 103 | }; | 153 | }; |
| 104 | } | 154 | } |
| ... | @@ -144,10 +194,15 @@ export function createWsRpcProtocolLayer( | ... | @@ -144,10 +194,15 @@ export function createWsRpcProtocolLayer( |
| 144 | socket.addEventListener( | 194 | socket.addEventListener( |
| 145 | "close", | 195 | "close", |
| 146 | (event) => { | 196 | (event) => { |
| 147 | lifecycle.onClose({ | 197 | lifecycle.onClose( |
| 148 | code: event.code, | 198 | { |
| 149 | reason: event.reason, | 199 | code: event.code, |
| 150 | }); | 200 | reason: event.reason, |
| 201 | }, | ||
| 202 | { | ||
| 203 | intentional: handlers?.isCloseIntentional?.() ?? false, | ||
| 204 | }, | ||
| 205 | ); | ||
| 151 | }, | 206 | }, |
| 152 | { once: true }, | 207 | { once: true }, |
| 153 | ); | 208 | ); |
| ... | @@ -172,22 +227,99 @@ export function createWsRpcProtocolLayer( | ... | @@ -172,22 +227,99 @@ export function createWsRpcProtocolLayer( |
| 172 | ...protocol, | 227 | ...protocol, |
| 173 | run: (clientId, writeResponse) => | 228 | run: (clientId, writeResponse) => |
| 174 | protocol.run(clientId, (response) => { | 229 | protocol.run(clientId, (response) => { |
| 175 | if (response._tag === "Chunk" || response._tag === "Exit") { | 230 | if (response._tag === "ClientProtocolError" || response._tag === "Defect") { |
| 176 | acknowledgeRpcRequest(response.requestId); | ||
| 177 | } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { | ||
| 178 | clearAllTrackedRpcRequests(); | 231 | clearAllTrackedRpcRequests(); |
| 179 | } | 232 | } |
| 180 | return writeResponse(response); | 233 | return writeResponse(response); |
| 181 | }), | 234 | }), |
| 182 | send: (clientId, request, transferables) => { | ||
| 183 | if (request._tag === "Request") { | ||
| 184 | trackRpcRequestSent(request.id, request.tag); | ||
| 185 | } | ||
| 186 | return protocol.send(clientId, request, transferables); | ||
| 187 | }, | ||
| 188 | }), | 235 | }), |
| 189 | ), | 236 | ), |
| 190 | ); | 237 | ); |
| 238 | const requestHooksLayer = Layer.succeed( | ||
| 239 | RpcClient.RequestHooks, | ||
| 240 | RpcClient.RequestHooks.of({ | ||
| 241 | onRequestStart: (info) => | ||
| 242 | Effect.sync(() => { | ||
| 243 | if (!lifecycle.isActive()) { | ||
| 244 | return; | ||
| 245 | } | ||
| 246 | handlers?.onRequestStart?.({ | ||
| 247 | id: String(info.id), | ||
| 248 | tag: info.tag, | ||
| 249 | stream: info.stream, | ||
| 250 | }); | ||
| 251 | trackRpcRequestSent(String(info.id), info.tag); | ||
| 252 | }), | ||
| 253 | onRequestChunk: (info) => | ||
| 254 | Effect.sync(() => { | ||
| 255 | if (!lifecycle.isActive()) { | ||
| 256 | return; | ||
| 257 | } | ||
| 258 | handlers?.onRequestChunk?.({ | ||
| 259 | id: String(info.id), | ||
| 260 | tag: info.tag, | ||
| 261 | chunkCount: info.chunkCount, | ||
| 262 | }); | ||
| 263 | acknowledgeRpcRequest(String(info.id)); | ||
| 264 | }), | ||
| 265 | onRequestExit: (info) => | ||
| 266 | Effect.sync(() => { | ||
| 267 | if (!lifecycle.isActive()) { | ||
| 268 | return; | ||
| 269 | } | ||
| 270 | handlers?.onRequestExit?.({ | ||
| 271 | id: String(info.id), | ||
| 272 | tag: info.tag, | ||
| 273 | stream: info.stream, | ||
| 274 | }); | ||
| 275 | acknowledgeRpcRequest(String(info.id)); | ||
| 276 | }), | ||
| 277 | onRequestInterrupt: (info) => | ||
| 278 | Effect.sync(() => { | ||
| 279 | if (!lifecycle.isActive()) { | ||
| 280 | return; | ||
| 281 | } | ||
| 282 | handlers?.onRequestInterrupt?.({ | ||
| 283 | id: String(info.id), | ||
| 284 | ...(info.tag === undefined ? {} : { tag: info.tag }), | ||
| 285 | }); | ||
| 286 | acknowledgeRpcRequest(String(info.id)); | ||
| 287 | }), | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | const connectionHooksLayer = Layer.succeed( | ||
| 291 | RpcClient.ConnectionHooks, | ||
| 292 | RpcClient.ConnectionHooks.of({ | ||
| 293 | onConnect: Effect.void, | ||
| 294 | onDisconnect: Effect.void, | ||
| 295 | onPing: Effect.sync(() => { | ||
| 296 | if (lifecycle.isActive()) { | ||
| 297 | handlers?.onHeartbeatPing?.(); | ||
| 298 | } | ||
| 299 | }), | ||
| 300 | onPong: Effect.sync(() => { | ||
| 301 | if (lifecycle.isActive()) { | ||
| 302 | handlers?.onHeartbeatPong?.(); | ||
| 303 | } | ||
| 304 | }), | ||
| 305 | onPingTimeout: Effect.sync(() => { | ||
| 306 | if (lifecycle.isActive()) { | ||
| 307 | clearAllTrackedRpcRequests(); | ||
| 308 | recordWsConnectionErrored( | ||
| 309 | "WebSocket heartbeat timed out.", | ||
| 310 | resolveConnectionMetadata(handlers), | ||
| 311 | ); | ||
| 312 | handlers?.onHeartbeatTimeout?.(); | ||
| 313 | } | ||
| 314 | }), | ||
| 315 | }), | ||
| 316 | ); | ||
| 191 | 317 | ||
| 192 | return protocolLayer.pipe(Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson))); | 318 | return Layer.mergeAll( |
| 319 | protocolLayer.pipe( | ||
| 320 | Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), | ||
| 321 | ), | ||
| 322 | requestHooksLayer, | ||
| 323 | connectionHooksLayer, | ||
| 324 | ); | ||
| 193 | } | 325 | } |
apps/web/src/rpc/serverState.test.ts+23| ... | @@ -10,10 +10,12 @@ import { | ... | @@ -10,10 +10,12 @@ import { |
| 10 | type ServerLifecycleStreamEvent, | 10 | type ServerLifecycleStreamEvent, |
| 11 | type ServerProvider, | 11 | type ServerProvider, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 13 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 14 | 15 | ||
| 15 | import { | 16 | import { |
| 16 | getServerConfig, | 17 | getServerConfig, |
| 18 | getServerKeybindings, | ||
| 17 | onProvidersUpdated, | 19 | onProvidersUpdated, |
| 18 | onServerConfigUpdated, | 20 | onServerConfigUpdated, |
| 19 | onWelcome, | 21 | onWelcome, |
| ... | @@ -141,6 +143,11 @@ afterEach(() => { | ... | @@ -141,6 +143,11 @@ afterEach(() => { |
| 141 | }); | 143 | }); |
| 142 | 144 | ||
| 143 | describe("serverState", () => { | 145 | describe("serverState", () => { |
| 146 | it("uses default keybindings before a server config snapshot is available", () => { | ||
| 147 | expect(getServerConfig()).toBeNull(); | ||
| 148 | expect(getServerKeybindings()).toEqual(DEFAULT_RESOLVED_KEYBINDINGS); | ||
| 149 | }); | ||
| 150 | |||
| 144 | it("bootstraps the server config snapshot and replays it to late subscribers", async () => { | 151 | it("bootstraps the server config snapshot and replays it to late subscribers", async () => { |
| 145 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); | 152 | serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); |
| 146 | 153 | ||
| ... | @@ -271,10 +278,25 @@ describe("serverState", () => { | ... | @@ -271,10 +278,25 @@ describe("serverState", () => { |
| 271 | }, | 278 | }, |
| 272 | ]; | 279 | ]; |
| 273 | 280 | ||
| 281 | const nextKeybindings = [ | ||
| 282 | { | ||
| 283 | command: "commandPalette.toggle", | ||
| 284 | shortcut: { | ||
| 285 | key: "p", | ||
| 286 | metaKey: false, | ||
| 287 | ctrlKey: false, | ||
| 288 | shiftKey: false, | ||
| 289 | altKey: false, | ||
| 290 | modKey: true, | ||
| 291 | }, | ||
| 292 | }, | ||
| 293 | ] as const; | ||
| 294 | |||
| 274 | emitServerConfigEvent({ | 295 | emitServerConfigEvent({ |
| 275 | version: 1, | 296 | version: 1, |
| 276 | type: "keybindingsUpdated", | 297 | type: "keybindingsUpdated", |
| 277 | payload: { | 298 | payload: { |
| 299 | keybindings: nextKeybindings, | ||
| 278 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | 300 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 279 | }, | 301 | }, |
| 280 | }); | 302 | }); |
| ... | @@ -299,6 +321,7 @@ describe("serverState", () => { | ... | @@ -299,6 +321,7 @@ describe("serverState", () => { |
| 299 | await waitFor(() => { | 321 | await waitFor(() => { |
| 300 | expect(getServerConfig()).toEqual({ | 322 | expect(getServerConfig()).toEqual({ |
| 301 | ...baseServerConfig, | 323 | ...baseServerConfig, |
| 324 | keybindings: nextKeybindings, | ||
| 302 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | 325 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 303 | providers: nextProviders, | 326 | providers: nextProviders, |
| 304 | settings: { | 327 | settings: { |
apps/web/src/rpc/serverState.ts+8-2| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | type ServerProviderUpdatedPayload, | 10 | type ServerProviderUpdatedPayload, |
| 11 | type ServerSettings, | 11 | type ServerSettings, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 13 | import { Atom } from "effect/unstable/reactivity"; | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 14 | import { useCallback, useRef } from "react"; | 15 | import { useCallback, useRef } from "react"; |
| 15 | 16 | ||
| ... | @@ -42,12 +43,12 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated | ... | @@ -42,12 +43,12 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated |
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; | 45 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; |
| 45 | const EMPTY_KEYBINDINGS: ServerConfig["keybindings"] = []; | ||
| 46 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | 46 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; |
| 47 | 47 | ||
| 48 | const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> => | 48 | const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> => |
| 49 | config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; | 49 | config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; |
| 50 | const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? EMPTY_KEYBINDINGS; | 50 | const selectKeybindings = (config: ServerConfig | null) => |
| 51 | config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 51 | const selectKeybindingsConfigPath = (config: ServerConfig | null) => | 52 | const selectKeybindingsConfigPath = (config: ServerConfig | null) => |
| 52 | config?.keybindingsConfigPath ?? null; | 53 | config?.keybindingsConfigPath ?? null; |
| 53 | const selectObservability = (config: ServerConfig | null) => config?.observability ?? null; | 54 | const selectObservability = (config: ServerConfig | null) => config?.observability ?? null; |
| ... | @@ -74,6 +75,10 @@ export function getServerConfig(): ServerConfig | null { | ... | @@ -74,6 +75,10 @@ export function getServerConfig(): ServerConfig | null { |
| 74 | return appAtomRegistry.get(serverConfigAtom); | 75 | return appAtomRegistry.get(serverConfigAtom); |
| 75 | } | 76 | } |
| 76 | 77 | ||
| 78 | export function getServerKeybindings(): ServerConfig["keybindings"] { | ||
| 79 | return selectKeybindings(getServerConfig()); | ||
| 80 | } | ||
| 81 | |||
| 77 | export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { | 82 | export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { |
| 78 | return appAtomRegistry.get(serverConfigUpdatedAtom); | 83 | return appAtomRegistry.get(serverConfigUpdatedAtom); |
| 79 | } | 84 | } |
| ... | @@ -97,6 +102,7 @@ export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { | ... | @@ -97,6 +102,7 @@ export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { |
| 97 | } | 102 | } |
| 98 | const nextConfig = { | 103 | const nextConfig = { |
| 99 | ...latestServerConfig, | 104 | ...latestServerConfig, |
| 105 | keybindings: event.payload.keybindings, | ||
| 100 | issues: event.payload.issues, | 106 | issues: event.payload.issues, |
| 101 | } satisfies ServerConfig; | 107 | } satisfies ServerConfig; |
| 102 | resolveServerConfig(nextConfig); | 108 | resolveServerConfig(nextConfig); |
apps/web/src/rpc/wsConnectionState.test.ts+33-1| ... | @@ -45,7 +45,9 @@ describe("wsConnectionState", () => { | ... | @@ -45,7 +45,9 @@ describe("wsConnectionState", () => { |
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | it("schedules the next retry after a failed websocket attempt", () => { | 47 | it("schedules the next retry after a failed websocket attempt", () => { |
| 48 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | 48 | recordWsConnectionAttempt("ws://localhost:3020/ws", { |
| 49 | connectionLabel: "Remote Mac", | ||
| 50 | }); | ||
| 49 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); | 51 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); |
| 50 | 52 | ||
| 51 | const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0); | 53 | const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0); |
| ... | @@ -54,12 +56,42 @@ describe("wsConnectionState", () => { | ... | @@ -54,12 +56,42 @@ describe("wsConnectionState", () => { |
| 54 | } | 56 | } |
| 55 | 57 | ||
| 56 | expect(getWsConnectionStatus()).toMatchObject({ | 58 | expect(getWsConnectionStatus()).toMatchObject({ |
| 59 | connectionLabel: "Remote Mac", | ||
| 57 | nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(), | 60 | nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(), |
| 58 | reconnectAttemptCount: 1, | 61 | reconnectAttemptCount: 1, |
| 59 | reconnectPhase: "waiting", | 62 | reconnectPhase: "waiting", |
| 60 | }); | 63 | }); |
| 61 | }); | 64 | }); |
| 62 | 65 | ||
| 66 | it("adds a version mismatch hint to websocket errors when metadata includes one", () => { | ||
| 67 | recordWsConnectionAttempt("ws://localhost:3020/ws", { | ||
| 68 | connectionLabel: "Remote Mac", | ||
| 69 | }); | ||
| 70 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.", { | ||
| 71 | versionMismatchHint: "Version mismatch. Try syncing the client and server.", | ||
| 72 | }); | ||
| 73 | |||
| 74 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 75 | lastError: | ||
| 76 | "Unable to connect to the T3 server WebSocket. Hint: Version mismatch. Try syncing the client and server.", | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("adds a version mismatch hint to websocket close reasons when metadata includes one", () => { | ||
| 81 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | ||
| 82 | recordWsConnectionOpened(); | ||
| 83 | recordWsConnectionClosed( | ||
| 84 | { code: 1006, reason: "socket closed" }, | ||
| 85 | { | ||
| 86 | versionMismatchHint: "Version mismatch. Try syncing the client and server.", | ||
| 87 | }, | ||
| 88 | ); | ||
| 89 | |||
| 90 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 91 | closeReason: "socket closed Hint: Version mismatch. Try syncing the client and server.", | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | |||
| 63 | it("marks the reconnect cycle as exhausted after the final attempt fails", () => { | 95 | it("marks the reconnect cycle as exhausted after the final attempt fails", () => { |
| 64 | for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) { | 96 | for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) { |
| 65 | recordWsConnectionAttempt("ws://localhost:3020/ws"); | 97 | recordWsConnectionAttempt("ws://localhost:3020/ws"); |
apps/web/src/rpc/wsConnectionState.ts+57-12| ... | @@ -16,6 +16,7 @@ export interface WsConnectionStatus { | ... | @@ -16,6 +16,7 @@ export interface WsConnectionStatus { |
| 16 | readonly attemptCount: number; | 16 | readonly attemptCount: number; |
| 17 | readonly closeCode: number | null; | 17 | readonly closeCode: number | null; |
| 18 | readonly closeReason: string | null; | 18 | readonly closeReason: string | null; |
| 19 | readonly connectionLabel: string | null; | ||
| 19 | readonly connectedAt: string | null; | 20 | readonly connectedAt: string | null; |
| 20 | readonly disconnectedAt: string | null; | 21 | readonly disconnectedAt: string | null; |
| 21 | readonly hasConnected: boolean; | 22 | readonly hasConnected: boolean; |
| ... | @@ -34,6 +35,7 @@ const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({ | ... | @@ -34,6 +35,7 @@ const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({ |
| 34 | attemptCount: 0, | 35 | attemptCount: 0, |
| 35 | closeCode: null, | 36 | closeCode: null, |
| 36 | closeReason: null, | 37 | closeReason: null, |
| 38 | connectionLabel: null, | ||
| 37 | connectedAt: null, | 39 | connectedAt: null, |
| 38 | disconnectedAt: null, | 40 | disconnectedAt: null, |
| 39 | hasConnected: false, | 41 | hasConnected: false, |
| ... | @@ -65,6 +67,16 @@ function updateWsConnectionStatus( | ... | @@ -65,6 +67,16 @@ function updateWsConnectionStatus( |
| 65 | return nextStatus; | 67 | return nextStatus; |
| 66 | } | 68 | } |
| 67 | 69 | ||
| 70 | export interface WsConnectionMetadata { | ||
| 71 | readonly connectionLabel?: string | null; | ||
| 72 | readonly versionMismatchHint?: string | null; | ||
| 73 | } | ||
| 74 | |||
| 75 | function normalizeConnectionLabel(label: string | null | undefined): string | null { | ||
| 76 | const normalized = label?.trim(); | ||
| 77 | return normalized ? normalized : null; | ||
| 78 | } | ||
| 79 | |||
| 68 | export function getWsConnectionStatus(): WsConnectionStatus { | 80 | export function getWsConnectionStatus(): WsConnectionStatus { |
| 69 | return appAtomRegistry.get(wsConnectionStatusAtom); | 81 | return appAtomRegistry.get(wsConnectionStatusAtom); |
| 70 | } | 82 | } |
| ... | @@ -85,10 +97,15 @@ export function getWsConnectionUiState(status: WsConnectionStatus): WsConnection | ... | @@ -85,10 +97,15 @@ export function getWsConnectionUiState(status: WsConnectionStatus): WsConnection |
| 85 | return "reconnecting"; | 97 | return "reconnecting"; |
| 86 | } | 98 | } |
| 87 | 99 | ||
| 88 | export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus { | 100 | export function recordWsConnectionAttempt( |
| 101 | socketUrl: string, | ||
| 102 | metadata?: WsConnectionMetadata, | ||
| 103 | ): WsConnectionStatus { | ||
| 104 | const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); | ||
| 89 | return updateWsConnectionStatus((current) => ({ | 105 | return updateWsConnectionStatus((current) => ({ |
| 90 | ...current, | 106 | ...current, |
| 91 | attemptCount: current.attemptCount + 1, | 107 | attemptCount: current.attemptCount + 1, |
| 108 | connectionLabel: connectionLabel ?? current.connectionLabel, | ||
| 92 | nextRetryAt: null, | 109 | nextRetryAt: null, |
| 93 | phase: "connecting", | 110 | phase: "connecting", |
| 94 | reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1, | 111 | reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1, |
| ... | @@ -97,11 +114,13 @@ export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus | ... | @@ -97,11 +114,13 @@ export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus |
| 97 | })); | 114 | })); |
| 98 | } | 115 | } |
| 99 | 116 | ||
| 100 | export function recordWsConnectionOpened(): WsConnectionStatus { | 117 | export function recordWsConnectionOpened(metadata?: WsConnectionMetadata): WsConnectionStatus { |
| 118 | const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); | ||
| 101 | return updateWsConnectionStatus((current) => ({ | 119 | return updateWsConnectionStatus((current) => ({ |
| 102 | ...current, | 120 | ...current, |
| 103 | closeCode: null, | 121 | closeCode: null, |
| 104 | closeReason: null, | 122 | closeReason: null, |
| 123 | connectionLabel: connectionLabel ?? current.connectionLabel, | ||
| 105 | connectedAt: isoNow(), | 124 | connectedAt: isoNow(), |
| 106 | disconnectedAt: null, | 125 | disconnectedAt: null, |
| 107 | hasConnected: true, | 126 | hasConnected: true, |
| ... | @@ -112,24 +131,48 @@ export function recordWsConnectionOpened(): WsConnectionStatus { | ... | @@ -112,24 +131,48 @@ export function recordWsConnectionOpened(): WsConnectionStatus { |
| 112 | })); | 131 | })); |
| 113 | } | 132 | } |
| 114 | 133 | ||
| 115 | export function recordWsConnectionErrored(message?: string | null): WsConnectionStatus { | 134 | function appendHint(message: string | null | undefined, hint: string | null | undefined) { |
| 135 | const normalizedMessage = message?.trim(); | ||
| 136 | const normalizedHint = hint?.trim(); | ||
| 137 | if (!normalizedMessage) { | ||
| 138 | return normalizedHint ? `Hint: ${normalizedHint}` : null; | ||
| 139 | } | ||
| 140 | return normalizedHint ? `${normalizedMessage} Hint: ${normalizedHint}` : normalizedMessage; | ||
| 141 | } | ||
| 142 | |||
| 143 | export function recordWsConnectionErrored( | ||
| 144 | message?: string | null, | ||
| 145 | metadata?: WsConnectionMetadata, | ||
| 146 | ): WsConnectionStatus { | ||
| 116 | return updateWsConnectionStatus((current) => | 147 | return updateWsConnectionStatus((current) => |
| 117 | applyDisconnectState(current, { | 148 | applyDisconnectState(current, { |
| 118 | lastError: message?.trim() ? message : current.lastError, | 149 | lastError: |
| 150 | appendHint(message, metadata?.versionMismatchHint) ?? | ||
| 151 | appendHint(current.lastError, metadata?.versionMismatchHint), | ||
| 119 | lastErrorAt: isoNow(), | 152 | lastErrorAt: isoNow(), |
| 120 | }), | 153 | }), |
| 121 | ); | 154 | ); |
| 122 | } | 155 | } |
| 123 | 156 | ||
| 124 | export function recordWsConnectionClosed(details?: { | 157 | export function recordWsConnectionClosed( |
| 125 | readonly code?: number; | 158 | details?: { |
| 126 | readonly reason?: string; | 159 | readonly code?: number; |
| 127 | }): WsConnectionStatus { | 160 | readonly reason?: string; |
| 161 | }, | ||
| 162 | metadata?: WsConnectionMetadata, | ||
| 163 | ): WsConnectionStatus { | ||
| 164 | const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); | ||
| 128 | return updateWsConnectionStatus((current) => | 165 | return updateWsConnectionStatus((current) => |
| 129 | applyDisconnectState(current, { | 166 | applyDisconnectState( |
| 130 | closeCode: details?.code ?? current.closeCode, | 167 | current, |
| 131 | closeReason: details?.reason?.trim() ? details.reason : current.closeReason, | 168 | { |
| 132 | }), | 169 | closeCode: details?.code ?? current.closeCode, |
| 170 | closeReason: | ||
| 171 | appendHint(details?.reason, metadata?.versionMismatchHint) ?? | ||
| 172 | appendHint(current.closeReason, metadata?.versionMismatchHint), | ||
| 173 | }, | ||
| 174 | connectionLabel === null ? undefined : { connectionLabel }, | ||
| 175 | ), | ||
| 133 | ); | 176 | ); |
| 134 | } | 177 | } |
| 135 | 178 | ||
| ... | @@ -173,6 +216,7 @@ function applyDisconnectState( | ... | @@ -173,6 +216,7 @@ function applyDisconnectState( |
| 173 | updates: Partial< | 216 | updates: Partial< |
| 174 | Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt"> | 217 | Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt"> |
| 175 | >, | 218 | >, |
| 219 | metadata?: WsConnectionMetadata, | ||
| 176 | ): WsConnectionStatus { | 220 | ): WsConnectionStatus { |
| 177 | const disconnectedAt = current.disconnectedAt ?? isoNow(); | 221 | const disconnectedAt = current.disconnectedAt ?? isoNow(); |
| 178 | const nextRetryDelayMs = | 222 | const nextRetryDelayMs = |
| ... | @@ -183,6 +227,7 @@ function applyDisconnectState( | ... | @@ -183,6 +227,7 @@ function applyDisconnectState( |
| 183 | return { | 227 | return { |
| 184 | ...current, | 228 | ...current, |
| 185 | ...updates, | 229 | ...updates, |
| 230 | connectionLabel: normalizeConnectionLabel(metadata?.connectionLabel) ?? current.connectionLabel, | ||
| 186 | disconnectedAt, | 231 | disconnectedAt, |
| 187 | nextRetryAt: | 232 | nextRetryAt: |
| 188 | nextRetryDelayMs === null | 233 | nextRetryDelayMs === null |
apps/web/src/rpc/wsRpcClient.ts+19-23| ... | @@ -155,11 +155,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -155,11 +155,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 155 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), | 155 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), |
| 156 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), | 156 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), |
| 157 | onEvent: (listener, options) => | 157 | onEvent: (listener, options) => |
| 158 | transport.subscribe( | 158 | transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener, { |
| 159 | (client) => client[WS_METHODS.subscribeTerminalEvents]({}), | 159 | ...options, |
| 160 | listener, | 160 | tag: WS_METHODS.subscribeTerminalEvents, |
| 161 | options, | 161 | }), |
| 162 | ), | ||
| 163 | }, | 162 | }, |
| 164 | projects: { | 163 | projects: { |
| 165 | searchEntries: (input) => | 164 | searchEntries: (input) => |
| ... | @@ -194,7 +193,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -194,7 +193,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 194 | current = applyGitStatusStreamEvent(current, event); | 193 | current = applyGitStatusStreamEvent(current, event); |
| 195 | listener(current); | 194 | listener(current); |
| 196 | }, | 195 | }, |
| 197 | options, | 196 | { ...options, tag: WS_METHODS.subscribeVcsStatus }, |
| 198 | ); | 197 | ); |
| 199 | }, | 198 | }, |
| 200 | listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), | 199 | listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), |
| ... | @@ -243,23 +242,20 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -243,23 +242,20 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 243 | discoverSourceControl: () => | 242 | discoverSourceControl: () => |
| 244 | transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), | 243 | transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), |
| 245 | subscribeConfig: (listener, options) => | 244 | subscribeConfig: (listener, options) => |
| 246 | transport.subscribe( | 245 | transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener, { |
| 247 | (client) => client[WS_METHODS.subscribeServerConfig]({}), | 246 | ...options, |
| 248 | listener, | 247 | tag: WS_METHODS.subscribeServerConfig, |
| 249 | options, | 248 | }), |
| 250 | ), | ||
| 251 | subscribeLifecycle: (listener, options) => | 249 | subscribeLifecycle: (listener, options) => |
| 252 | transport.subscribe( | 250 | transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener, { |
| 253 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | 251 | ...options, |
| 254 | listener, | 252 | tag: WS_METHODS.subscribeServerLifecycle, |
| 255 | options, | 253 | }), |
| 256 | ), | ||
| 257 | subscribeAuthAccess: (listener, options) => | 254 | subscribeAuthAccess: (listener, options) => |
| 258 | transport.subscribe( | 255 | transport.subscribe((client) => client[WS_METHODS.subscribeAuthAccess]({}), listener, { |
| 259 | (client) => client[WS_METHODS.subscribeAuthAccess]({}), | 256 | ...options, |
| 260 | listener, | 257 | tag: WS_METHODS.subscribeAuthAccess, |
| 261 | options, | 258 | }), |
| 262 | ), | ||
| 263 | }, | 259 | }, |
| 264 | orchestration: { | 260 | orchestration: { |
| 265 | dispatchCommand: (input) => | 261 | dispatchCommand: (input) => |
| ... | @@ -272,13 +268,13 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -272,13 +268,13 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 272 | transport.subscribe( | 268 | transport.subscribe( |
| 273 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), | 269 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), |
| 274 | listener, | 270 | listener, |
| 275 | options, | 271 | { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeShell }, |
| 276 | ), | 272 | ), |
| 277 | subscribeThread: (input, listener, options) => | 273 | subscribeThread: (input, listener, options) => |
| 278 | transport.subscribe( | 274 | transport.subscribe( |
| 279 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), | 275 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), |
| 280 | listener, | 276 | listener, |
| 281 | options, | 277 | { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeThread }, |
| 282 | ), | 278 | ), |
| 283 | }, | 279 | }, |
| 284 | }; | 280 | }; |
apps/web/src/rpc/wsTransport.test.ts+75-4| ... | @@ -284,10 +284,15 @@ describe("WsTransport", () => { | ... | @@ -284,10 +284,15 @@ describe("WsTransport", () => { |
| 284 | socket.close(1012, "service restart"); | 284 | socket.close(1012, "service restart"); |
| 285 | 285 | ||
| 286 | await waitFor(() => { | 286 | await waitFor(() => { |
| 287 | expect(onClose).toHaveBeenCalledWith({ | 287 | expect(onClose).toHaveBeenCalledWith( |
| 288 | code: 1012, | 288 | { |
| 289 | reason: "service restart", | 289 | code: 1012, |
| 290 | }); | 290 | reason: "service restart", |
| 291 | }, | ||
| 292 | { | ||
| 293 | intentional: false, | ||
| 294 | }, | ||
| 295 | ); | ||
| 291 | expect(getWsConnectionStatus()).toMatchObject({ | 296 | expect(getWsConnectionStatus()).toMatchObject({ |
| 292 | attemptCount: 2, | 297 | attemptCount: 2, |
| 293 | closeReason: "service restart", | 298 | closeReason: "service restart", |
| ... | @@ -298,6 +303,38 @@ describe("WsTransport", () => { | ... | @@ -298,6 +303,38 @@ describe("WsTransport", () => { |
| 298 | await transport.dispose(); | 303 | await transport.dispose(); |
| 299 | }); | 304 | }); |
| 300 | 305 | ||
| 306 | it("does not report an intentional dispose as a reconnectable disconnect", async () => { | ||
| 307 | const onClose = vi.fn(); | ||
| 308 | const transport = createTransport("ws://localhost:3020", { | ||
| 309 | getConnectionLabel: () => "Remote Mac mini", | ||
| 310 | onClose, | ||
| 311 | }); | ||
| 312 | |||
| 313 | await waitFor(() => { | ||
| 314 | expect(sockets).toHaveLength(1); | ||
| 315 | }); | ||
| 316 | |||
| 317 | const socket = getSocket(); | ||
| 318 | socket.open(); | ||
| 319 | |||
| 320 | await waitFor(() => { | ||
| 321 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 322 | connectionLabel: "Remote Mac mini", | ||
| 323 | hasConnected: true, | ||
| 324 | phase: "connected", | ||
| 325 | }); | ||
| 326 | }); | ||
| 327 | |||
| 328 | await transport.dispose(); | ||
| 329 | |||
| 330 | expect(onClose).not.toHaveBeenCalled(); | ||
| 331 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 332 | connectionLabel: "Remote Mac mini", | ||
| 333 | phase: "connected", | ||
| 334 | reconnectPhase: "idle", | ||
| 335 | }); | ||
| 336 | }); | ||
| 337 | |||
| 301 | it("reconnects the websocket session without disposing the transport", async () => { | 338 | it("reconnects the websocket session without disposing the transport", async () => { |
| 302 | const transport = createTransport("ws://localhost:3020"); | 339 | const transport = createTransport("ws://localhost:3020"); |
| 303 | 340 | ||
| ... | @@ -418,6 +455,40 @@ describe("WsTransport", () => { | ... | @@ -418,6 +455,40 @@ describe("WsTransport", () => { |
| 418 | await transport.dispose(); | 455 | await transport.dispose(); |
| 419 | }); | 456 | }); |
| 420 | 457 | ||
| 458 | it("ignores stale socket close events after the transport is disposed", async () => { | ||
| 459 | const onClose = vi.fn(); | ||
| 460 | const transport = createTransport("ws://localhost:3020", { onClose }); | ||
| 461 | |||
| 462 | await waitFor(() => { | ||
| 463 | expect(sockets).toHaveLength(1); | ||
| 464 | }); | ||
| 465 | |||
| 466 | const firstSocket = getSocket(); | ||
| 467 | firstSocket.open(); | ||
| 468 | |||
| 469 | await waitFor(() => { | ||
| 470 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 471 | hasConnected: true, | ||
| 472 | phase: "connected", | ||
| 473 | }); | ||
| 474 | }); | ||
| 475 | |||
| 476 | await transport.reconnect(); | ||
| 477 | |||
| 478 | await waitFor(() => { | ||
| 479 | expect(sockets).toHaveLength(2); | ||
| 480 | }); | ||
| 481 | |||
| 482 | const secondSocket = getSocket(); | ||
| 483 | secondSocket.open(); | ||
| 484 | await transport.dispose(); | ||
| 485 | |||
| 486 | onClose.mockClear(); | ||
| 487 | firstSocket.close(1006, "stale close after dispose"); | ||
| 488 | |||
| 489 | expect(onClose).not.toHaveBeenCalled(); | ||
| 490 | }); | ||
| 491 | |||
| 421 | it("marks unary requests as slow until the first server ack arrives", async () => { | 492 | it("marks unary requests as slow until the first server ack arrives", async () => { |
| 422 | const slowAckThresholdMs = 25; | 493 | const slowAckThresholdMs = 25; |
| 423 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); | 494 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); |
apps/web/src/rpc/wsTransport.ts+73-8| ... | @@ -25,6 +25,7 @@ import { isTransportConnectionErrorMessage } from "./transportError"; | ... | @@ -25,6 +25,7 @@ import { isTransportConnectionErrorMessage } from "./transportError"; |
| 25 | interface SubscribeOptions { | 25 | interface SubscribeOptions { |
| 26 | readonly retryDelay?: Duration.Input; | 26 | readonly retryDelay?: Duration.Input; |
| 27 | readonly onResubscribe?: () => void; | 27 | readonly onResubscribe?: () => void; |
| 28 | readonly tag?: string; | ||
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | interface RequestOptions { | 31 | interface RequestOptions { |
| ... | @@ -40,6 +41,12 @@ interface TransportSession { | ... | @@ -40,6 +41,12 @@ interface TransportSession { |
| 40 | readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | 41 | readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; |
| 41 | } | 42 | } |
| 42 | 43 | ||
| 44 | interface StreamRequestStartInfo { | ||
| 45 | readonly id: string; | ||
| 46 | readonly tag: string; | ||
| 47 | readonly stream: boolean; | ||
| 48 | } | ||
| 49 | |||
| 43 | function formatErrorMessage(error: unknown): string { | 50 | function formatErrorMessage(error: unknown): string { |
| 44 | if (error instanceof Error && error.message.trim().length > 0) { | 51 | if (error instanceof Error && error.message.trim().length > 0) { |
| 45 | return error.message; | 52 | return error.message; |
| ... | @@ -52,10 +59,13 @@ export class WsTransport { | ... | @@ -52,10 +59,13 @@ export class WsTransport { |
| 52 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; | 59 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; |
| 53 | private disposed = false; | 60 | private disposed = false; |
| 54 | private hasReportedTransportDisconnect = false; | 61 | private hasReportedTransportDisconnect = false; |
| 62 | private intentionalCloseDepth = 0; | ||
| 55 | private reconnectChain: Promise<void> = Promise.resolve(); | 63 | private reconnectChain: Promise<void> = Promise.resolve(); |
| 56 | private nextSessionId = 0; | 64 | private nextSessionId = 0; |
| 57 | private activeSessionId = 0; | 65 | private activeSessionId = 0; |
| 58 | private session: TransportSession; | 66 | private session: TransportSession; |
| 67 | private lastHeartbeatPongAt = 0; | ||
| 68 | private readonly streamRequestStartListeners = new Set<(info: StreamRequestStartInfo) => void>(); | ||
| 59 | 69 | ||
| 60 | constructor( | 70 | constructor( |
| 61 | url: WsRpcProtocolSocketUrlProvider, | 71 | url: WsRpcProtocolSocketUrlProvider, |
| ... | @@ -126,18 +136,24 @@ export class WsTransport { | ... | @@ -126,18 +136,24 @@ export class WsTransport { |
| 126 | 136 | ||
| 127 | const session = this.session; | 137 | const session = this.session; |
| 128 | try { | 138 | try { |
| 129 | if (hasReceivedValue) { | ||
| 130 | try { | ||
| 131 | options?.onResubscribe?.(); | ||
| 132 | } catch { | ||
| 133 | // Swallow reconnect hook errors so the stream can recover. | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | const runningStream = this.runStreamOnSession( | 139 | const runningStream = this.runStreamOnSession( |
| 138 | session, | 140 | session, |
| 139 | connect, | 141 | connect, |
| 140 | listener, | 142 | listener, |
| 143 | { | ||
| 144 | ...(options?.tag === undefined ? {} : { tag: options.tag }), | ||
| 145 | ...(hasReceivedValue | ||
| 146 | ? { | ||
| 147 | onStarted: () => { | ||
| 148 | try { | ||
| 149 | options?.onResubscribe?.(); | ||
| 150 | } catch { | ||
| 151 | // Swallow reconnect hook errors so the stream can recover. | ||
| 152 | } | ||
| 153 | }, | ||
| 154 | } | ||
| 155 | : {}), | ||
| 156 | }, | ||
| 141 | () => active, | 157 | () => active, |
| 142 | () => { | 158 | () => { |
| 143 | this.hasReportedTransportDisconnect = false; | 159 | this.hasReportedTransportDisconnect = false; |
| ... | @@ -193,6 +209,7 @@ export class WsTransport { | ... | @@ -193,6 +209,7 @@ export class WsTransport { |
| 193 | } | 209 | } |
| 194 | 210 | ||
| 195 | clearAllTrackedRpcRequests(); | 211 | clearAllTrackedRpcRequests(); |
| 212 | this.lastHeartbeatPongAt = 0; | ||
| 196 | const previousSession = this.session; | 213 | const previousSession = this.session; |
| 197 | this.session = this.createSession(); | 214 | this.session = this.createSession(); |
| 198 | await this.closeSession(previousSession); | 215 | await this.closeSession(previousSession); |
| ... | @@ -202,6 +219,10 @@ export class WsTransport { | ... | @@ -202,6 +219,10 @@ export class WsTransport { |
| 202 | await reconnectOperation; | 219 | await reconnectOperation; |
| 203 | } | 220 | } |
| 204 | 221 | ||
| 222 | isHeartbeatFresh(maxAgeMs = 15_000): boolean { | ||
| 223 | return this.lastHeartbeatPongAt > 0 && Date.now() - this.lastHeartbeatPongAt <= maxAgeMs; | ||
| 224 | } | ||
| 225 | |||
| 205 | async dispose() { | 226 | async dispose() { |
| 206 | if (this.disposed) { | 227 | if (this.disposed) { |
| 207 | return; | 228 | return; |
| ... | @@ -211,7 +232,9 @@ export class WsTransport { | ... | @@ -211,7 +232,9 @@ export class WsTransport { |
| 211 | } | 232 | } |
| 212 | 233 | ||
| 213 | private closeSession(session: TransportSession) { | 234 | private closeSession(session: TransportSession) { |
| 235 | this.intentionalCloseDepth += 1; | ||
| 214 | return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { | 236 | return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { |
| 237 | this.intentionalCloseDepth -= 1; | ||
| 215 | session.runtime.dispose(); | 238 | session.runtime.dispose(); |
| 216 | }); | 239 | }); |
| 217 | } | 240 | } |
| ... | @@ -225,6 +248,23 @@ export class WsTransport { | ... | @@ -225,6 +248,23 @@ export class WsTransport { |
| 225 | createWsRpcProtocolLayer(this.url, { | 248 | createWsRpcProtocolLayer(this.url, { |
| 226 | ...this.lifecycleHandlers, | 249 | ...this.lifecycleHandlers, |
| 227 | isActive: () => !this.disposed && this.activeSessionId === sessionId, | 250 | isActive: () => !this.disposed && this.activeSessionId === sessionId, |
| 251 | isCloseIntentional: () => | ||
| 252 | this.disposed || | ||
| 253 | this.intentionalCloseDepth > 0 || | ||
| 254 | this.lifecycleHandlers?.isCloseIntentional?.() === true, | ||
| 255 | onHeartbeatPong: () => { | ||
| 256 | this.lastHeartbeatPongAt = Date.now(); | ||
| 257 | this.lifecycleHandlers?.onHeartbeatPong?.(); | ||
| 258 | }, | ||
| 259 | onRequestStart: (info) => { | ||
| 260 | this.lifecycleHandlers?.onRequestStart?.(info); | ||
| 261 | if (!info.stream) { | ||
| 262 | return; | ||
| 263 | } | ||
| 264 | for (const listener of this.streamRequestStartListeners) { | ||
| 265 | listener(info); | ||
| 266 | } | ||
| 267 | }, | ||
| 228 | }), | 268 | }), |
| 229 | ClientTracingLive, | 269 | ClientTracingLive, |
| 230 | ), | 270 | ), |
| ... | @@ -241,6 +281,10 @@ export class WsTransport { | ... | @@ -241,6 +281,10 @@ export class WsTransport { |
| 241 | session: TransportSession, | 281 | session: TransportSession, |
| 242 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | 282 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, |
| 243 | listener: (value: TValue) => void, | 283 | listener: (value: TValue) => void, |
| 284 | requestStart: { | ||
| 285 | readonly tag?: string; | ||
| 286 | readonly onStarted?: () => void; | ||
| 287 | }, | ||
| 244 | isActive: () => boolean, | 288 | isActive: () => boolean, |
| 245 | markValueReceived: () => void, | 289 | markValueReceived: () => void, |
| 246 | ): { | 290 | ): { |
| ... | @@ -253,6 +297,23 @@ export class WsTransport { | ... | @@ -253,6 +297,23 @@ export class WsTransport { |
| 253 | resolveCompleted = resolve; | 297 | resolveCompleted = resolve; |
| 254 | rejectCompleted = reject; | 298 | rejectCompleted = reject; |
| 255 | }); | 299 | }); |
| 300 | let requestStartListener: ((info: StreamRequestStartInfo) => void) | null = null; | ||
| 301 | if (requestStart.onStarted) { | ||
| 302 | requestStartListener = (info) => { | ||
| 303 | if (!isActive() || !info.stream) { | ||
| 304 | return; | ||
| 305 | } | ||
| 306 | if (requestStart.tag !== undefined && info.tag !== requestStart.tag) { | ||
| 307 | return; | ||
| 308 | } | ||
| 309 | requestStart.onStarted?.(); | ||
| 310 | if (requestStartListener) { | ||
| 311 | this.streamRequestStartListeners.delete(requestStartListener); | ||
| 312 | requestStartListener = null; | ||
| 313 | } | ||
| 314 | }; | ||
| 315 | this.streamRequestStartListeners.add(requestStartListener); | ||
| 316 | } | ||
| 256 | const cancel = session.runtime.runCallback( | 317 | const cancel = session.runtime.runCallback( |
| 257 | Effect.promise(() => session.clientPromise).pipe( | 318 | Effect.promise(() => session.clientPromise).pipe( |
| 258 | Effect.flatMap((client) => | 319 | Effect.flatMap((client) => |
| ... | @@ -274,6 +335,10 @@ export class WsTransport { | ... | @@ -274,6 +335,10 @@ export class WsTransport { |
| 274 | ), | 335 | ), |
| 275 | { | 336 | { |
| 276 | onExit: (exit) => { | 337 | onExit: (exit) => { |
| 338 | if (requestStartListener) { | ||
| 339 | this.streamRequestStartListeners.delete(requestStartListener); | ||
| 340 | requestStartListener = null; | ||
| 341 | } | ||
| 277 | if (Exit.isSuccess(exit)) { | 342 | if (Exit.isSuccess(exit)) { |
| 278 | resolveCompleted(); | 343 | resolveCompleted(); |
| 279 | return; | 344 | return; |
apps/web/src/store.test.ts+34| ... | @@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest"; | ... | @@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest"; |
| 16 | import { | 16 | import { |
| 17 | applyOrchestrationEvent, | 17 | applyOrchestrationEvent, |
| 18 | applyOrchestrationEvents, | 18 | applyOrchestrationEvents, |
| 19 | removeEnvironmentState, | ||
| 19 | selectEnvironmentState, | 20 | selectEnvironmentState, |
| 20 | selectProjectsAcrossEnvironments, | 21 | selectProjectsAcrossEnvironments, |
| 21 | selectThreadByRef, | 22 | selectThreadByRef, |
| ... | @@ -246,6 +247,39 @@ function makeEvent<T extends OrchestrationEvent["type"]>( | ... | @@ -246,6 +247,39 @@ function makeEvent<T extends OrchestrationEvent["type"]>( |
| 246 | } as Extract<OrchestrationEvent, { type: T }>; | 247 | } as Extract<OrchestrationEvent, { type: T }>; |
| 247 | } | 248 | } |
| 248 | 249 | ||
| 250 | describe("environment state removal", () => { | ||
| 251 | it("drops local state for removed environments", () => { | ||
| 252 | const removedThread = makeThread({ | ||
| 253 | environmentId: remoteEnvironmentId, | ||
| 254 | id: ThreadId.make("thread-removed"), | ||
| 255 | }); | ||
| 256 | const keptThread = makeThread({ id: ThreadId.make("thread-kept") }); | ||
| 257 | const removedState = makeState(removedThread).environmentStateById[remoteEnvironmentId]!; | ||
| 258 | const keptState = makeState(keptThread).environmentStateById[localEnvironmentId]!; | ||
| 259 | const state: AppState = { | ||
| 260 | activeEnvironmentId: remoteEnvironmentId, | ||
| 261 | environmentStateById: { | ||
| 262 | [remoteEnvironmentId]: removedState, | ||
| 263 | [localEnvironmentId]: keptState, | ||
| 264 | }, | ||
| 265 | }; | ||
| 266 | |||
| 267 | const next = removeEnvironmentState(state, remoteEnvironmentId); | ||
| 268 | |||
| 269 | expect(next.activeEnvironmentId).toBeNull(); | ||
| 270 | expect(next.environmentStateById[remoteEnvironmentId]).toBeUndefined(); | ||
| 271 | expect(next.environmentStateById[localEnvironmentId]).toBe(keptState); | ||
| 272 | }); | ||
| 273 | |||
| 274 | it("preserves active environment when removing a different environment", () => { | ||
| 275 | const state = makeState(makeThread()); | ||
| 276 | |||
| 277 | const next = removeEnvironmentState(state, remoteEnvironmentId); | ||
| 278 | |||
| 279 | expect(next).toBe(state); | ||
| 280 | }); | ||
| 281 | }); | ||
| 282 | |||
| 249 | describe("thread selection memoization", () => { | 283 | describe("thread selection memoization", () => { |
| 250 | it("returns stable thread references for repeated reads of the same state", () => { | 284 | it("returns stable thread references for repeated reads of the same state", () => { |
| 251 | const thread = makeThread({ | 285 | const thread = makeThread({ |
apps/web/src/store.ts+17| ... | @@ -1905,6 +1905,20 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme | ... | @@ -1905,6 +1905,20 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme |
| 1905 | }; | 1905 | }; |
| 1906 | } | 1906 | } |
| 1907 | 1907 | ||
| 1908 | export function removeEnvironmentState(state: AppState, environmentId: EnvironmentId): AppState { | ||
| 1909 | if (!state.environmentStateById[environmentId] && state.activeEnvironmentId !== environmentId) { | ||
| 1910 | return state; | ||
| 1911 | } | ||
| 1912 | |||
| 1913 | const { [environmentId]: _removed, ...environmentStateById } = state.environmentStateById; | ||
| 1914 | return { | ||
| 1915 | ...state, | ||
| 1916 | activeEnvironmentId: | ||
| 1917 | state.activeEnvironmentId === environmentId ? null : state.activeEnvironmentId, | ||
| 1918 | environmentStateById, | ||
| 1919 | }; | ||
| 1920 | } | ||
| 1921 | |||
| 1908 | export function setThreadBranch( | 1922 | export function setThreadBranch( |
| 1909 | state: AppState, | 1923 | state: AppState, |
| 1910 | threadRef: ScopedThreadRef, | 1924 | threadRef: ScopedThreadRef, |
| ... | @@ -1930,6 +1944,7 @@ export function setThreadBranch( | ... | @@ -1930,6 +1944,7 @@ export function setThreadBranch( |
| 1930 | 1944 | ||
| 1931 | interface AppStore extends AppState { | 1945 | interface AppStore extends AppState { |
| 1932 | setActiveEnvironmentId: (environmentId: EnvironmentId) => void; | 1946 | setActiveEnvironmentId: (environmentId: EnvironmentId) => void; |
| 1947 | removeEnvironmentState: (environmentId: EnvironmentId) => void; | ||
| 1933 | syncServerShellSnapshot: ( | 1948 | syncServerShellSnapshot: ( |
| 1934 | snapshot: OrchestrationShellSnapshot, | 1949 | snapshot: OrchestrationShellSnapshot, |
| 1935 | environmentId: EnvironmentId, | 1950 | environmentId: EnvironmentId, |
| ... | @@ -1953,6 +1968,8 @@ export const useStore = create<AppStore>((set) => ({ | ... | @@ -1953,6 +1968,8 @@ export const useStore = create<AppStore>((set) => ({ |
| 1953 | ...initialState, | 1968 | ...initialState, |
| 1954 | setActiveEnvironmentId: (environmentId) => | 1969 | setActiveEnvironmentId: (environmentId) => |
| 1955 | set((state) => setActiveEnvironmentId(state, environmentId)), | 1970 | set((state) => setActiveEnvironmentId(state, environmentId)), |
| 1971 | removeEnvironmentState: (environmentId) => | ||
| 1972 | set((state) => removeEnvironmentState(state, environmentId)), | ||
| 1956 | syncServerShellSnapshot: (snapshot, environmentId) => | 1973 | syncServerShellSnapshot: (snapshot, environmentId) => |
| 1957 | set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), | 1974 | set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), |
| 1958 | syncServerThreadDetail: (thread, environmentId) => | 1975 | syncServerThreadDetail: (thread, environmentId) => |
apps/web/src/uiStateStore.test.ts+25| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | type PersistedUiState, | 10 | type PersistedUiState, |
| 11 | persistState, | 11 | persistState, |
| 12 | reorderProjects, | 12 | reorderProjects, |
| 13 | setDefaultAdvertisedEndpointKey, | ||
| 13 | setProjectExpanded, | 14 | setProjectExpanded, |
| 14 | setThreadChangedFilesExpanded, | 15 | setThreadChangedFilesExpanded, |
| 15 | syncProjects, | 16 | syncProjects, |
| ... | @@ -23,6 +24,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { | ... | @@ -23,6 +24,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState { |
| 23 | projectOrder: [], | 24 | projectOrder: [], |
| 24 | threadLastVisitedAtById: {}, | 25 | threadLastVisitedAtById: {}, |
| 25 | threadChangedFilesExpandedById: {}, | 26 | threadChangedFilesExpandedById: {}, |
| 27 | defaultAdvertisedEndpointKey: null, | ||
| 26 | ...overrides, | 28 | ...overrides, |
| 27 | }; | 29 | }; |
| 28 | } | 30 | } |
| ... | @@ -102,6 +104,18 @@ describe("uiStateStore pure functions", () => { | ... | @@ -102,6 +104,18 @@ describe("uiStateStore pure functions", () => { |
| 102 | expect(next).toBe(initialState); | 104 | expect(next).toBe(initialState); |
| 103 | }); | 105 | }); |
| 104 | 106 | ||
| 107 | it("setDefaultAdvertisedEndpointKey stores endpoint preference by stable key", () => { | ||
| 108 | const initialState = makeUiState(); | ||
| 109 | |||
| 110 | const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http"); | ||
| 111 | |||
| 112 | expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); | ||
| 113 | expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next); | ||
| 114 | expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({ | ||
| 115 | defaultAdvertisedEndpointKey: null, | ||
| 116 | }); | ||
| 117 | }); | ||
| 118 | |||
| 105 | it("reorderProjects moves all member keys of a multi-member group together", () => { | 119 | it("reorderProjects moves all member keys of a multi-member group together", () => { |
| 106 | const keyALocal = "env-local:proj-a"; | 120 | const keyALocal = "env-local:proj-a"; |
| 107 | const keyARemote = "env-remote:proj-a"; | 121 | const keyARemote = "env-remote:proj-a"; |
| ... | @@ -554,6 +568,17 @@ describe("uiStateStore persistence round-trip", () => { | ... | @@ -554,6 +568,17 @@ describe("uiStateStore persistence round-trip", () => { |
| 554 | expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); | 568 | expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); |
| 555 | }); | 569 | }); |
| 556 | 570 | ||
| 571 | it("persists the default advertised endpoint preference", () => { | ||
| 572 | const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http"); | ||
| 573 | |||
| 574 | persistState(state); | ||
| 575 | |||
| 576 | const persisted = JSON.parse( | ||
| 577 | localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", | ||
| 578 | ) as PersistedUiState; | ||
| 579 | expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http"); | ||
| 580 | }); | ||
| 581 | |||
| 557 | it("preserves expand state across restart when project's logical key changes", () => { | 582 | it("preserves expand state across restart when project's logical key changes", () => { |
| 558 | // After restart, in-memory previousExpandedById is empty, so the | 583 | // After restart, in-memory previousExpandedById is empty, so the |
| 559 | // previousLogicalKey-to-state bridge in syncProjects cannot help. The | 584 | // previousLogicalKey-to-state bridge in syncProjects cannot help. The |
apps/web/src/uiStateStore.ts+27-1| ... | @@ -19,6 +19,7 @@ export interface PersistedUiState { | ... | @@ -19,6 +19,7 @@ export interface PersistedUiState { |
| 19 | collapsedProjectCwds?: string[]; | 19 | collapsedProjectCwds?: string[]; |
| 20 | expandedProjectCwds?: string[]; | 20 | expandedProjectCwds?: string[]; |
| 21 | projectOrderCwds?: string[]; | 21 | projectOrderCwds?: string[]; |
| 22 | defaultAdvertisedEndpointKey?: string | null; | ||
| 22 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; | 23 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 23 | } | 24 | } |
| 24 | 25 | ||
| ... | @@ -32,7 +33,11 @@ export interface UiThreadState { | ... | @@ -32,7 +33,11 @@ export interface UiThreadState { |
| 32 | threadChangedFilesExpandedById: Record<string, Record<string, boolean>>; | 33 | threadChangedFilesExpandedById: Record<string, Record<string, boolean>>; |
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | export interface UiState extends UiProjectState, UiThreadState {} | 36 | export interface UiEndpointState { |
| 37 | defaultAdvertisedEndpointKey: string | null; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} | ||
| 36 | 41 | ||
| 37 | export interface SyncProjectInput { | 42 | export interface SyncProjectInput { |
| 38 | /** Physical project key (env + cwd). Used for manual sort order. */ | 43 | /** Physical project key (env + cwd). Used for manual sort order. */ |
| ... | @@ -52,6 +57,7 @@ const initialState: UiState = { | ... | @@ -52,6 +57,7 @@ const initialState: UiState = { |
| 52 | projectOrder: [], | 57 | projectOrder: [], |
| 53 | threadLastVisitedAtById: {}, | 58 | threadLastVisitedAtById: {}, |
| 54 | threadChangedFilesExpandedById: {}, | 59 | threadChangedFilesExpandedById: {}, |
| 60 | defaultAdvertisedEndpointKey: null, | ||
| 55 | }; | 61 | }; |
| 56 | 62 | ||
| 57 | const persistedCollapsedProjectCwds = new Set<string>(); | 63 | const persistedCollapsedProjectCwds = new Set<string>(); |
| ... | @@ -88,6 +94,11 @@ function readPersistedState(): UiState { | ... | @@ -88,6 +94,11 @@ function readPersistedState(): UiState { |
| 88 | hydratePersistedProjectState(parsed); | 94 | hydratePersistedProjectState(parsed); |
| 89 | return { | 95 | return { |
| 90 | ...initialState, | 96 | ...initialState, |
| 97 | defaultAdvertisedEndpointKey: | ||
| 98 | typeof parsed.defaultAdvertisedEndpointKey === "string" && | ||
| 99 | parsed.defaultAdvertisedEndpointKey.length > 0 | ||
| 100 | ? parsed.defaultAdvertisedEndpointKey | ||
| 101 | : null, | ||
| 91 | threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( | 102 | threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( |
| 92 | parsed.threadChangedFilesExpandedById, | 103 | parsed.threadChangedFilesExpandedById, |
| 93 | ), | 104 | ), |
| ... | @@ -179,6 +190,7 @@ export function persistState(state: UiState): void { | ... | @@ -179,6 +190,7 @@ export function persistState(state: UiState): void { |
| 179 | collapsedProjectCwds, | 190 | collapsedProjectCwds, |
| 180 | expandedProjectCwds, | 191 | expandedProjectCwds, |
| 181 | projectOrderCwds, | 192 | projectOrderCwds, |
| 193 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, | ||
| 182 | threadChangedFilesExpandedById, | 194 | threadChangedFilesExpandedById, |
| 183 | } satisfies PersistedUiState), | 195 | } satisfies PersistedUiState), |
| 184 | ); | 196 | ); |
| ... | @@ -532,6 +544,17 @@ export function setThreadChangedFilesExpanded( | ... | @@ -532,6 +544,17 @@ export function setThreadChangedFilesExpanded( |
| 532 | }; | 544 | }; |
| 533 | } | 545 | } |
| 534 | 546 | ||
| 547 | export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState { | ||
| 548 | const nextKey = key && key.length > 0 ? key : null; | ||
| 549 | if (state.defaultAdvertisedEndpointKey === nextKey) { | ||
| 550 | return state; | ||
| 551 | } | ||
| 552 | return { | ||
| 553 | ...state, | ||
| 554 | defaultAdvertisedEndpointKey: nextKey, | ||
| 555 | }; | ||
| 556 | } | ||
| 557 | |||
| 535 | export function toggleProject(state: UiState, projectId: string): UiState { | 558 | export function toggleProject(state: UiState, projectId: string): UiState { |
| 536 | const expanded = state.projectExpandedById[projectId] ?? true; | 559 | const expanded = state.projectExpandedById[projectId] ?? true; |
| 537 | return { | 560 | return { |
| ... | @@ -606,6 +629,7 @@ interface UiStateStore extends UiState { | ... | @@ -606,6 +629,7 @@ interface UiStateStore extends UiState { |
| 606 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; | 629 | markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; |
| 607 | clearThreadUi: (threadId: string) => void; | 630 | clearThreadUi: (threadId: string) => void; |
| 608 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; | 631 | setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; |
| 632 | setDefaultAdvertisedEndpointKey: (key: string | null) => void; | ||
| 609 | toggleProject: (projectId: string) => void; | 633 | toggleProject: (projectId: string) => void; |
| 610 | setProjectExpanded: (projectId: string, expanded: boolean) => void; | 634 | setProjectExpanded: (projectId: string, expanded: boolean) => void; |
| 611 | reorderProjects: ( | 635 | reorderProjects: ( |
| ... | @@ -625,6 +649,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ | ... | @@ -625,6 +649,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ |
| 625 | clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), | 649 | clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), |
| 626 | setThreadChangedFilesExpanded: (threadId, turnId, expanded) => | 650 | setThreadChangedFilesExpanded: (threadId, turnId, expanded) => |
| 627 | set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), | 651 | set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), |
| 652 | setDefaultAdvertisedEndpointKey: (key) => | ||
| 653 | set((state) => setDefaultAdvertisedEndpointKey(state, key)), | ||
| 628 | toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), | 654 | toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), |
| 629 | setProjectExpanded: (projectId, expanded) => | 655 | setProjectExpanded: (projectId, expanded) => |
| 630 | set((state) => setProjectExpanded(state, projectId, expanded)), | 656 | set((state) => setProjectExpanded(state, projectId, expanded)), |
apps/web/src/versionSkew.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { APP_VERSION } from "./branding"; | ||
| 5 | import { | ||
| 6 | appendVersionMismatchHint, | ||
| 7 | buildVersionMismatchDismissalKey, | ||
| 8 | dismissVersionMismatch, | ||
| 9 | isVersionMismatchDismissed, | ||
| 10 | resolveServerConfigVersionMismatch, | ||
| 11 | resolveVersionMismatch, | ||
| 12 | } from "./versionSkew"; | ||
| 13 | |||
| 14 | describe("versionSkew", () => { | ||
| 15 | it("does not warn when versions match", () => { | ||
| 16 | expect(resolveVersionMismatch(APP_VERSION)).toBeNull(); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("returns a mismatch when the server version differs from the client", () => { | ||
| 20 | expect(resolveVersionMismatch("9.9.9")).toEqual({ | ||
| 21 | clientVersion: APP_VERSION, | ||
| 22 | serverVersion: "9.9.9", | ||
| 23 | hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.", | ||
| 24 | }); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("reads the server version from config descriptors", () => { | ||
| 28 | expect( | ||
| 29 | resolveServerConfigVersionMismatch({ | ||
| 30 | environment: { | ||
| 31 | environmentId: EnvironmentId.make("environment-1"), | ||
| 32 | label: "Remote", | ||
| 33 | platform: { | ||
| 34 | os: "darwin", | ||
| 35 | arch: "arm64", | ||
| 36 | }, | ||
| 37 | serverVersion: "9.9.9", | ||
| 38 | capabilities: { | ||
| 39 | repositoryIdentity: true, | ||
| 40 | }, | ||
| 41 | }, | ||
| 42 | }), | ||
| 43 | ).toMatchObject({ | ||
| 44 | serverVersion: "9.9.9", | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("keys dismissals by environment, client version, and server version", () => { | ||
| 49 | const environmentId = EnvironmentId.make("environment-dismissal"); | ||
| 50 | const key = buildVersionMismatchDismissalKey(environmentId, { | ||
| 51 | clientVersion: APP_VERSION, | ||
| 52 | serverVersion: "9.9.9", | ||
| 53 | }); | ||
| 54 | |||
| 55 | expect(key).toBe(`${environmentId}:${APP_VERSION}:9.9.9`); | ||
| 56 | expect(isVersionMismatchDismissed(key)).toBe(false); | ||
| 57 | |||
| 58 | dismissVersionMismatch(key); | ||
| 59 | |||
| 60 | expect(isVersionMismatchDismissed(key)).toBe(true); | ||
| 61 | expect( | ||
| 62 | isVersionMismatchDismissed( | ||
| 63 | buildVersionMismatchDismissalKey(environmentId, { | ||
| 64 | clientVersion: APP_VERSION, | ||
| 65 | serverVersion: "9.9.10", | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | ).toBe(false); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("appends a hint to connection errors when versions differ", () => { | ||
| 72 | const mismatch = resolveVersionMismatch("9.9.9"); | ||
| 73 | |||
| 74 | expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe( | ||
| 75 | "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.", | ||
| 76 | ); | ||
| 77 | }); | ||
| 78 | }); | ||
apps/web/src/versionSkew.ts created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | import { APP_VERSION } from "./branding"; | ||
| 5 | import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; | ||
| 6 | |||
| 7 | export interface VersionMismatch { | ||
| 8 | readonly clientVersion: string; | ||
| 9 | readonly serverVersion: string; | ||
| 10 | readonly hint: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; | ||
| 14 | |||
| 15 | const VersionMismatchDismissalsSchema = Schema.Struct({ | ||
| 16 | keys: Schema.Array(Schema.String), | ||
| 17 | }); | ||
| 18 | |||
| 19 | type VersionMismatchDismissals = typeof VersionMismatchDismissalsSchema.Type; | ||
| 20 | |||
| 21 | function normalizeVersion(version: string | null | undefined): string | null { | ||
| 22 | const trimmed = version?.trim(); | ||
| 23 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function resolveVersionMismatch( | ||
| 27 | serverVersion: string | null | undefined, | ||
| 28 | ): VersionMismatch | null { | ||
| 29 | const normalizedClientVersion = normalizeVersion(APP_VERSION); | ||
| 30 | const normalizedServerVersion = normalizeVersion(serverVersion); | ||
| 31 | if ( | ||
| 32 | !normalizedClientVersion || | ||
| 33 | !normalizedServerVersion || | ||
| 34 | normalizedClientVersion === normalizedServerVersion | ||
| 35 | ) { | ||
| 36 | return null; | ||
| 37 | } | ||
| 38 | |||
| 39 | return { | ||
| 40 | clientVersion: normalizedClientVersion, | ||
| 41 | serverVersion: normalizedServerVersion, | ||
| 42 | hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.", | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function resolveServerConfigVersionMismatch( | ||
| 47 | serverConfig: Pick<ServerConfig, "environment"> | null | undefined, | ||
| 48 | ): VersionMismatch | null { | ||
| 49 | return resolveVersionMismatch(serverConfig?.environment.serverVersion); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function buildVersionMismatchDismissalKey( | ||
| 53 | environmentId: EnvironmentId, | ||
| 54 | mismatch: Pick<VersionMismatch, "clientVersion" | "serverVersion">, | ||
| 55 | ): string { | ||
| 56 | return `${environmentId}:${mismatch.clientVersion}:${mismatch.serverVersion}`; | ||
| 57 | } | ||
| 58 | |||
| 59 | function readVersionMismatchDismissals(): VersionMismatchDismissals { | ||
| 60 | try { | ||
| 61 | return ( | ||
| 62 | getLocalStorageItem( | ||
| 63 | VERSION_MISMATCH_DISMISSALS_STORAGE_KEY, | ||
| 64 | VersionMismatchDismissalsSchema, | ||
| 65 | ) ?? { keys: [] } | ||
| 66 | ); | ||
| 67 | } catch { | ||
| 68 | return { keys: [] }; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | function writeVersionMismatchDismissals(document: VersionMismatchDismissals): void { | ||
| 73 | try { | ||
| 74 | setLocalStorageItem( | ||
| 75 | VERSION_MISMATCH_DISMISSALS_STORAGE_KEY, | ||
| 76 | document, | ||
| 77 | VersionMismatchDismissalsSchema, | ||
| 78 | ); | ||
| 79 | } catch { | ||
| 80 | // Dismissal state is best-effort UI state; a storage failure should not block the banner. | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | export function isVersionMismatchDismissed(dismissalKey: string | null | undefined): boolean { | ||
| 85 | if (!dismissalKey) { | ||
| 86 | return false; | ||
| 87 | } | ||
| 88 | return readVersionMismatchDismissals().keys.includes(dismissalKey); | ||
| 89 | } | ||
| 90 | |||
| 91 | export function dismissVersionMismatch(dismissalKey: string | null | undefined): void { | ||
| 92 | if (!dismissalKey) { | ||
| 93 | return; | ||
| 94 | } | ||
| 95 | const document = readVersionMismatchDismissals(); | ||
| 96 | if (document.keys.includes(dismissalKey)) { | ||
| 97 | return; | ||
| 98 | } | ||
| 99 | writeVersionMismatchDismissals({ | ||
| 100 | keys: [...document.keys, dismissalKey], | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | |||
| 104 | export function appendVersionMismatchHint( | ||
| 105 | message: string | null | undefined, | ||
| 106 | mismatch: VersionMismatch | null | undefined, | ||
| 107 | ): string | null { | ||
| 108 | const normalizedMessage = normalizeVersion(message); | ||
| 109 | if (!normalizedMessage) { | ||
| 110 | return mismatch?.hint ?? null; | ||
| 111 | } | ||
| 112 | if (!mismatch) { | ||
| 113 | return normalizedMessage; | ||
| 114 | } | ||
| 115 | return `${normalizedMessage} Hint: ${mismatch.hint}`; | ||
| 116 | } | ||
apps/web/src/vite-env.d.ts+3| ... | @@ -3,6 +3,9 @@ | ... | @@ -3,6 +3,9 @@ |
| 3 | import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; | 3 | import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | interface ImportMetaEnv { | 5 | interface ImportMetaEnv { |
| 6 | readonly VITE_HTTP_URL: string; | ||
| 7 | readonly VITE_WS_URL: string; | ||
| 8 | readonly VITE_HOSTED_APP_URL: string; | ||
| 6 | readonly APP_VERSION: string; | 9 | readonly APP_VERSION: string; |
| 7 | } | 10 | } |
| 8 | 11 |
apps/web/vercel.json created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | { | ||
| 2 | "buildCommand": "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production", | ||
| 3 | "installCommand": "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'", | ||
| 4 | "rewrites": [ | ||
| 5 | { | ||
| 6 | "source": "/(.*)", | ||
| 7 | "destination": "/index.html" | ||
| 8 | } | ||
| 9 | ] | ||
| 10 | } | ||
apps/web/vite.config.ts+10| ... | @@ -9,6 +9,15 @@ const port = Number(process.env.PORT ?? 5733); | ... | @@ -9,6 +9,15 @@ const port = Number(process.env.PORT ?? 5733); |
| 9 | const host = process.env.HOST?.trim() || "localhost"; | 9 | const host = process.env.HOST?.trim() || "localhost"; |
| 10 | const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); | 10 | const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); |
| 11 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); | 11 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); |
| 12 | const configuredHostedAppUrl = (() => { | ||
| 13 | if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { | ||
| 14 | return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; | ||
| 15 | } | ||
| 16 | if (process.env.VERCEL_URL) { | ||
| 17 | return `https://${process.env.VERCEL_URL}`; | ||
| 18 | } | ||
| 19 | return process.env.VITE_HOSTED_APP_URL?.trim(); | ||
| 20 | })(); | ||
| 12 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); | 21 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); |
| 13 | 22 | ||
| 14 | const buildSourcemap = | 23 | const buildSourcemap = |
| ... | @@ -68,6 +77,7 @@ export default defineConfig({ | ... | @@ -68,6 +77,7 @@ export default defineConfig({ |
| 68 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), | 77 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), |
| 69 | // In dev mode, tell the web app where the WebSocket server lives | 78 | // In dev mode, tell the web app where the WebSocket server lives |
| 70 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), | 79 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 80 | "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), | ||
| 71 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), | 81 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), |
| 72 | }, | 82 | }, |
| 73 | resolve: { | 83 | resolve: { |
bun.lock+68-20| ... | @@ -16,13 +16,17 @@ | ... | @@ -16,13 +16,17 @@ |
| 16 | "name": "@t3tools/desktop", | 16 | "name": "@t3tools/desktop", |
| 17 | "version": "0.0.21", | 17 | "version": "0.0.21", |
| 18 | "dependencies": { | 18 | "dependencies": { |
| 19 | "@effect/platform-node": "catalog:", | ||
| 19 | "effect": "catalog:", | 20 | "effect": "catalog:", |
| 20 | "electron": "40.9.3", | 21 | "electron": "40.9.3", |
| 21 | "electron-updater": "^6.6.2", | 22 | "electron-updater": "^6.6.2", |
| 22 | }, | 23 | }, |
| 23 | "devDependencies": { | 24 | "devDependencies": { |
| 25 | "@t3tools/client-runtime": "workspace:*", | ||
| 24 | "@t3tools/contracts": "workspace:*", | 26 | "@t3tools/contracts": "workspace:*", |
| 25 | "@t3tools/shared": "workspace:*", | 27 | "@t3tools/shared": "workspace:*", |
| 28 | "@t3tools/ssh": "workspace:*", | ||
| 29 | "@t3tools/tailscale": "workspace:*", | ||
| 26 | "@types/node": "catalog:", | 30 | "@types/node": "catalog:", |
| 27 | "effect-acp": "workspace:*", | 31 | "effect-acp": "workspace:*", |
| 28 | "tsdown": "catalog:", | 32 | "tsdown": "catalog:", |
| ... | @@ -63,6 +67,7 @@ | ... | @@ -63,6 +67,7 @@ |
| 63 | "@effect/vitest": "catalog:", | 67 | "@effect/vitest": "catalog:", |
| 64 | "@t3tools/contracts": "workspace:*", | 68 | "@t3tools/contracts": "workspace:*", |
| 65 | "@t3tools/shared": "workspace:*", | 69 | "@t3tools/shared": "workspace:*", |
| 70 | "@t3tools/tailscale": "workspace:*", | ||
| 66 | "@t3tools/web": "workspace:*", | 71 | "@t3tools/web": "workspace:*", |
| 67 | "@types/bun": "catalog:", | 72 | "@types/bun": "catalog:", |
| 68 | "@types/node": "catalog:", | 73 | "@types/node": "catalog:", |
| ... | @@ -198,6 +203,38 @@ | ... | @@ -198,6 +203,38 @@ |
| 198 | "vitest": "catalog:", | 203 | "vitest": "catalog:", |
| 199 | }, | 204 | }, |
| 200 | }, | 205 | }, |
| 206 | "packages/ssh": { | ||
| 207 | "name": "@t3tools/ssh", | ||
| 208 | "version": "0.0.0-alpha.1", | ||
| 209 | "dependencies": { | ||
| 210 | "@t3tools/contracts": "workspace:*", | ||
| 211 | "@t3tools/shared": "workspace:*", | ||
| 212 | "effect": "catalog:", | ||
| 213 | }, | ||
| 214 | "devDependencies": { | ||
| 215 | "@effect/language-service": "catalog:", | ||
| 216 | "@effect/platform-node": "catalog:", | ||
| 217 | "@effect/vitest": "catalog:", | ||
| 218 | "@types/node": "catalog:", | ||
| 219 | "typescript": "catalog:", | ||
| 220 | "vitest": "catalog:", | ||
| 221 | }, | ||
| 222 | }, | ||
| 223 | "packages/tailscale": { | ||
| 224 | "name": "@t3tools/tailscale", | ||
| 225 | "version": "0.0.0-alpha.1", | ||
| 226 | "dependencies": { | ||
| 227 | "@effect/platform-node": "catalog:", | ||
| 228 | "effect": "catalog:", | ||
| 229 | }, | ||
| 230 | "devDependencies": { | ||
| 231 | "@effect/language-service": "catalog:", | ||
| 232 | "@effect/vitest": "catalog:", | ||
| 233 | "@types/node": "catalog:", | ||
| 234 | "typescript": "catalog:", | ||
| 235 | "vitest": "catalog:", | ||
| 236 | }, | ||
| 237 | }, | ||
| 201 | "scripts": { | 238 | "scripts": { |
| 202 | "name": "@t3tools/scripts", | 239 | "name": "@t3tools/scripts", |
| 203 | "dependencies": { | 240 | "dependencies": { |
| ... | @@ -220,6 +257,9 @@ | ... | @@ -220,6 +257,9 @@ |
| 220 | "electron", | 257 | "electron", |
| 221 | "node-pty", | 258 | "node-pty", |
| 222 | ], | 259 | ], |
| 260 | "patchedDependencies": { | ||
| 261 | "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch", | ||
| 262 | }, | ||
| 223 | "overrides": { | 263 | "overrides": { |
| 224 | "@effect/atom-react": "catalog:", | 264 | "@effect/atom-react": "catalog:", |
| 225 | "@effect/platform-bun": "catalog:", | 265 | "@effect/platform-bun": "catalog:", |
| ... | @@ -231,17 +271,17 @@ | ... | @@ -231,17 +271,17 @@ |
| 231 | "vite": "^8.0.0", | 271 | "vite": "^8.0.0", |
| 232 | }, | 272 | }, |
| 233 | "catalog": { | 273 | "catalog": { |
| 234 | "@effect/atom-react": "4.0.0-beta.45", | 274 | "@effect/atom-react": "4.0.0-beta.59", |
| 235 | "@effect/language-service": "0.84.2", | 275 | "@effect/language-service": "0.84.2", |
| 236 | "@effect/openapi-generator": "4.0.0-beta.45", | 276 | "@effect/openapi-generator": "4.0.0-beta.59", |
| 237 | "@effect/platform-bun": "4.0.0-beta.45", | 277 | "@effect/platform-bun": "4.0.0-beta.59", |
| 238 | "@effect/platform-node": "4.0.0-beta.45", | 278 | "@effect/platform-node": "4.0.0-beta.59", |
| 239 | "@effect/platform-node-shared": "4.0.0-beta.45", | 279 | "@effect/platform-node-shared": "4.0.0-beta.59", |
| 240 | "@effect/sql-sqlite-bun": "4.0.0-beta.45", | 280 | "@effect/sql-sqlite-bun": "4.0.0-beta.59", |
| 241 | "@effect/vitest": "4.0.0-beta.45", | 281 | "@effect/vitest": "4.0.0-beta.59", |
| 242 | "@types/bun": "^1.3.11", | 282 | "@types/bun": "^1.3.11", |
| 243 | "@types/node": "^24.10.13", | 283 | "@types/node": "^24.10.13", |
| 244 | "effect": "4.0.0-beta.45", | 284 | "effect": "4.0.0-beta.59", |
| 245 | "tsdown": "^0.20.3", | 285 | "tsdown": "^0.20.3", |
| 246 | "typescript": "^5.7.3", | 286 | "typescript": "^5.7.3", |
| 247 | "vitest": "^4.0.0", | 287 | "vitest": "^4.0.0", |
| ... | @@ -329,21 +369,21 @@ | ... | @@ -329,21 +369,21 @@ |
| 329 | 369 | ||
| 330 | "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], | 370 | "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], |
| 331 | 371 | ||
| 332 | "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45", "react": "^19.2.4", "scheduler": "*" } }, "sha512-jCoiae9UNeis9Yoal1p5V/P4eJK9nX89wORom7X5LpR2Ok920SZJc8dIeYkRXRAp9BCYJ/yWcO8gGLf7do71vg=="], | 372 | "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.59", "", { "peerDependencies": { "effect": "^4.0.0-beta.59", "react": "^19.2.4", "scheduler": "*" } }, "sha512-VkznQz5c+Z/BLxX+hQNPzPOyUnLQjnbppFSNP7tbPru7HKR4ihzCDC6Xjbx87156MOrZ+JOa6shTMbmvGT5W0w=="], |
| 333 | 373 | ||
| 334 | "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], | 374 | "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], |
| 335 | 375 | ||
| 336 | "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.45", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.45", "effect": "^4.0.0-beta.45" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-zT1UPzapV6mZebMuYhoDPQj/FKNn4R+oDgz1hEcQwazal4+lVDyEYGhRl0PgZroy6CP78fAOWNgW7TriGqSlOQ=="], | 376 | "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.59", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.59", "effect": "^4.0.0-beta.59" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-y1KF6mEb3pA5AdQsNGeWxV42Xi1uBt9Wzo5xMsp06j/WkOM2vWFmvSXw6xvwINAUi1HVjdOLo3xnTXtSv9YdRg=="], |
| 337 | 377 | ||
| 338 | "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45" }, "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-dZsbkJ+o4JfKE1OIAlMq8YWhmEfXxtTYennYADK/M/XlOtkFLsc2jkZppDvMznVni6JpFxWc7wd5rUf8jBVP5g=="], | 378 | "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.59", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.59" }, "peerDependencies": { "effect": "^4.0.0-beta.59" } }, "sha512-1jXCIx34X80ojiK9ACO9Q7RST9CXudRmlAbsP4kPqjUo6aqOuGSWXq9ueBO4LbaIZiioRxtTciom35U9ldfTkQ=="], |
| 339 | 379 | ||
| 340 | "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.45", "ioredis": "^5.7.0" } }, "sha512-P07NMG4eoy62iLX9szak0hkr7hrwgq5+XMkm7URVug/3zqfZVxEG2kxn9JzVK1lF5WbaVrEKwjt3IkEJxzSPtg=="], | 380 | "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.59", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.59", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.59", "ioredis": "^5.7.0" } }, "sha512-jHRW0l953FjYNhQHexr48jFiBu5iGEZH5nmKD6Ha+lPtm1MrKG2V4njfWA3Fv0nUmd3VN87eBJ557wU0twN1Hg=="], |
| 341 | 381 | ||
| 342 | "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.45", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-0T4RG18o+aCVUSlmPxA7uAjHJkyE3MS/uRrR5kCNSZQNG3X71wdIbu82wE/MnV6+6YSSPDx/6TVdZWYkJF0JWw=="], | 382 | "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.59", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.59" } }, "sha512-fGwFJuG0Te9U/ZeqeDZ2HcSKZBhX5wLjX2/Rxb5+yaOkvvFAN9MvIh05R0QQK5DCcERvnbhHSl1CjSIAN4aEwQ=="], |
| 343 | 383 | ||
| 344 | "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-sJBBztwI4YxjdjhdyjWelh3KKoZucmmZS2ONeNDIzYgqFlsrwEEUrhVkWf5ch9Io+kUUzeb6q7/SedFoLUWO7g=="], | 384 | "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.59", "", { "peerDependencies": { "effect": "^4.0.0-beta.59" } }, "sha512-qZWt2HeLgSY8ygEfwbldK9lJ5xg/WP4lgCp1VLlyE8BImSB5g1h+UbhitXr+pOIf+ry4Xmc4ycp7YwTBOFH5/A=="], |
| 345 | 385 | ||
| 346 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-38JA5Z6c2FoEhpVKbl7rjTrMU8Al5T30Kwtb6N3B9kOkyr2SsmvNsP9T65bh03IBh3ak9U//acrkmjS3hLQwKA=="], | 386 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.59", "", { "peerDependencies": { "effect": "^4.0.0-beta.59", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-jhpJbpDs1ED58SmFzcfmqLXxle84fiexaMEhV8SBl8WC2GM3FT5DW0WJYFjbZIH5/735brp3iGdjY5/uAxS7Bg=="], |
| 347 | 387 | ||
| 348 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], | 388 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], |
| 349 | 389 | ||
| ... | @@ -741,6 +781,10 @@ | ... | @@ -741,6 +781,10 @@ |
| 741 | 781 | ||
| 742 | "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"], | 782 | "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"], |
| 743 | 783 | ||
| 784 | "@t3tools/ssh": ["@t3tools/ssh@workspace:packages/ssh"], | ||
| 785 | |||
| 786 | "@t3tools/tailscale": ["@t3tools/tailscale@workspace:packages/tailscale"], | ||
| 787 | |||
| 744 | "@t3tools/web": ["@t3tools/web@workspace:apps/web"], | 788 | "@t3tools/web": ["@t3tools/web@workspace:apps/web"], |
| 745 | 789 | ||
| 746 | "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], | 790 | "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], |
| ... | @@ -1097,7 +1141,7 @@ | ... | @@ -1097,7 +1141,7 @@ |
| 1097 | 1141 | ||
| 1098 | "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], | 1142 | "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], |
| 1099 | 1143 | ||
| 1100 | "effect": ["effect@4.0.0-beta.45", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-vvNrUWqnzBIW1hRMa+zw0CLRW6HLgdu7hQ6K7PT/rS+UY/73Ma11O+Oi9oc9zwL8KcN37M47UDseAdlF0bGNWw=="], | 1144 | "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], |
| 1101 | 1145 | ||
| 1102 | "effect-acp": ["effect-acp@workspace:packages/effect-acp"], | 1146 | "effect-acp": ["effect-acp@workspace:packages/effect-acp"], |
| 1103 | 1147 | ||
| ... | @@ -1855,7 +1899,7 @@ | ... | @@ -1855,7 +1899,7 @@ |
| 1855 | 1899 | ||
| 1856 | "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], | 1900 | "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], |
| 1857 | 1901 | ||
| 1858 | "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], | 1902 | "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], |
| 1859 | 1903 | ||
| 1860 | "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], | 1904 | "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], |
| 1861 | 1905 | ||
| ... | @@ -1909,7 +1953,7 @@ | ... | @@ -1909,7 +1953,7 @@ |
| 1909 | 1953 | ||
| 1910 | "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], | 1954 | "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], |
| 1911 | 1955 | ||
| 1912 | "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], | 1956 | "undici": ["undici@8.2.0", "", {}, "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ=="], |
| 1913 | 1957 | ||
| 1914 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | 1958 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], |
| 1915 | 1959 | ||
| ... | @@ -2017,7 +2061,7 @@ | ... | @@ -2017,7 +2061,7 @@ |
| 2017 | 2061 | ||
| 2018 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], | 2062 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], |
| 2019 | 2063 | ||
| 2020 | "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], | 2064 | "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], |
| 2021 | 2065 | ||
| 2022 | "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], | 2066 | "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], |
| 2023 | 2067 | ||
| ... | @@ -2027,7 +2071,7 @@ | ... | @@ -2027,7 +2071,7 @@ |
| 2027 | 2071 | ||
| 2028 | "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | 2072 | "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], |
| 2029 | 2073 | ||
| 2030 | "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], | 2074 | "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], |
| 2031 | 2075 | ||
| 2032 | "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], | 2076 | "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], |
| 2033 | 2077 | ||
| ... | @@ -2053,6 +2097,8 @@ | ... | @@ -2053,6 +2097,8 @@ |
| 2053 | 2097 | ||
| 2054 | "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], | 2098 | "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], |
| 2055 | 2099 | ||
| 2100 | "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], | ||
| 2101 | |||
| 2056 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], | 2102 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], |
| 2057 | 2103 | ||
| 2058 | "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], | 2104 | "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], |
| ... | @@ -2117,6 +2163,8 @@ | ... | @@ -2117,6 +2163,8 @@ |
| 2117 | 2163 | ||
| 2118 | "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], | 2164 | "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], |
| 2119 | 2165 | ||
| 2166 | "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], | ||
| 2167 | |||
| 2120 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | 2168 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| 2121 | 2169 | ||
| 2122 | "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], | 2170 | "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], |
package.json+11-8| ... | @@ -8,14 +8,14 @@ | ... | @@ -8,14 +8,14 @@ |
| 8 | "scripts" | 8 | "scripts" |
| 9 | ], | 9 | ], |
| 10 | "catalog": { | 10 | "catalog": { |
| 11 | "effect": "4.0.0-beta.45", | 11 | "effect": "4.0.0-beta.59", |
| 12 | "@effect/atom-react": "4.0.0-beta.45", | 12 | "@effect/atom-react": "4.0.0-beta.59", |
| 13 | "@effect/openapi-generator": "4.0.0-beta.45", | 13 | "@effect/openapi-generator": "4.0.0-beta.59", |
| 14 | "@effect/platform-bun": "4.0.0-beta.45", | 14 | "@effect/platform-bun": "4.0.0-beta.59", |
| 15 | "@effect/platform-node": "4.0.0-beta.45", | 15 | "@effect/platform-node": "4.0.0-beta.59", |
| 16 | "@effect/platform-node-shared": "4.0.0-beta.45", | 16 | "@effect/platform-node-shared": "4.0.0-beta.59", |
| 17 | "@effect/sql-sqlite-bun": "4.0.0-beta.45", | 17 | "@effect/sql-sqlite-bun": "4.0.0-beta.59", |
| 18 | "@effect/vitest": "4.0.0-beta.45", | 18 | "@effect/vitest": "4.0.0-beta.59", |
| 19 | "@effect/language-service": "0.84.2", | 19 | "@effect/language-service": "0.84.2", |
| 20 | "@types/bun": "^1.3.11", | 20 | "@types/bun": "^1.3.11", |
| 21 | "@types/node": "^24.10.13", | 21 | "@types/node": "^24.10.13", |
| ... | @@ -84,6 +84,9 @@ | ... | @@ -84,6 +84,9 @@ |
| 84 | "apps/web/public" | 84 | "apps/web/public" |
| 85 | ] | 85 | ] |
| 86 | }, | 86 | }, |
| 87 | "patchedDependencies": { | ||
| 88 | "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch" | ||
| 89 | }, | ||
| 87 | "trustedDependencies": [ | 90 | "trustedDependencies": [ |
| 88 | "node-pty", | 91 | "node-pty", |
| 89 | "electron" | 92 | "electron" |
packages/client-runtime/src/advertisedEndpoint.test.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | classifyHostedHttpsCompatibility, | ||
| 5 | createAdvertisedEndpoint, | ||
| 6 | deriveWsBaseUrl, | ||
| 7 | normalizeHttpBaseUrl, | ||
| 8 | } from "./advertisedEndpoint.ts"; | ||
| 9 | |||
| 10 | const coreProvider = { | ||
| 11 | id: "desktop-core", | ||
| 12 | label: "Desktop", | ||
| 13 | kind: "core", | ||
| 14 | isAddon: false, | ||
| 15 | } as const; | ||
| 16 | |||
| 17 | describe("advertised endpoint helpers", () => { | ||
| 18 | it("normalizes HTTP and WebSocket base URLs", () => { | ||
| 19 | expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/"); | ||
| 20 | expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/"); | ||
| 21 | expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/"); | ||
| 22 | expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { | ||
| 26 | expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( | ||
| 27 | "mixed-content-blocked", | ||
| 28 | ); | ||
| 29 | expect(classifyHostedHttpsCompatibility("https://desktop.example.com", "compatible")).toBe( | ||
| 30 | "compatible", | ||
| 31 | ); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("creates provider-neutral endpoint records", () => { | ||
| 35 | expect( | ||
| 36 | createAdvertisedEndpoint({ | ||
| 37 | id: "lan:http://192.168.1.44:3773", | ||
| 38 | label: "LAN", | ||
| 39 | provider: coreProvider, | ||
| 40 | httpBaseUrl: "http://192.168.1.44:3773", | ||
| 41 | reachability: "lan", | ||
| 42 | source: "desktop-core", | ||
| 43 | isDefault: true, | ||
| 44 | }), | ||
| 45 | ).toEqual({ | ||
| 46 | id: "lan:http://192.168.1.44:3773", | ||
| 47 | label: "LAN", | ||
| 48 | provider: coreProvider, | ||
| 49 | httpBaseUrl: "http://192.168.1.44:3773/", | ||
| 50 | wsBaseUrl: "ws://192.168.1.44:3773/", | ||
| 51 | reachability: "lan", | ||
| 52 | compatibility: { | ||
| 53 | hostedHttpsApp: "mixed-content-blocked", | ||
| 54 | desktopApp: "compatible", | ||
| 55 | }, | ||
| 56 | source: "desktop-core", | ||
| 57 | status: "available", | ||
| 58 | isDefault: true, | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | }); | ||
packages/client-runtime/src/advertisedEndpoint.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import type { | ||
| 2 | AdvertisedEndpoint, | ||
| 3 | AdvertisedEndpointHostedHttpsCompatibility, | ||
| 4 | AdvertisedEndpointProvider, | ||
| 5 | AdvertisedEndpointReachability, | ||
| 6 | AdvertisedEndpointSource, | ||
| 7 | AdvertisedEndpointStatus, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | export interface CreateAdvertisedEndpointInput { | ||
| 11 | readonly id: string; | ||
| 12 | readonly label: string; | ||
| 13 | readonly provider: AdvertisedEndpointProvider; | ||
| 14 | readonly httpBaseUrl: string; | ||
| 15 | readonly reachability: AdvertisedEndpointReachability; | ||
| 16 | readonly hostedHttpsCompatibility?: AdvertisedEndpointHostedHttpsCompatibility; | ||
| 17 | readonly desktopCompatibility?: "compatible" | "unknown"; | ||
| 18 | readonly source: AdvertisedEndpointSource; | ||
| 19 | readonly status?: AdvertisedEndpointStatus; | ||
| 20 | readonly isDefault?: boolean; | ||
| 21 | readonly description?: string; | ||
| 22 | } | ||
| 23 | |||
| 24 | export function normalizeHttpBaseUrl(rawValue: string): string { | ||
| 25 | const url = new URL(rawValue); | ||
| 26 | if (url.protocol === "ws:") { | ||
| 27 | url.protocol = "http:"; | ||
| 28 | } else if (url.protocol === "wss:") { | ||
| 29 | url.protocol = "https:"; | ||
| 30 | } | ||
| 31 | |||
| 32 | if (url.protocol !== "http:" && url.protocol !== "https:") { | ||
| 33 | throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`); | ||
| 34 | } | ||
| 35 | |||
| 36 | url.pathname = "/"; | ||
| 37 | url.search = ""; | ||
| 38 | url.hash = ""; | ||
| 39 | return url.toString(); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function deriveWsBaseUrl(httpBaseUrl: string): string { | ||
| 43 | const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); | ||
| 44 | url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; | ||
| 45 | return url.toString(); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function classifyHostedHttpsCompatibility( | ||
| 49 | httpBaseUrl: string, | ||
| 50 | fallback: AdvertisedEndpointHostedHttpsCompatibility = "unknown", | ||
| 51 | ): AdvertisedEndpointHostedHttpsCompatibility { | ||
| 52 | const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); | ||
| 53 | if (url.protocol === "http:") { | ||
| 54 | return "mixed-content-blocked"; | ||
| 55 | } | ||
| 56 | return fallback === "mixed-content-blocked" ? "unknown" : fallback; | ||
| 57 | } | ||
| 58 | |||
| 59 | export function createAdvertisedEndpoint(input: CreateAdvertisedEndpointInput): AdvertisedEndpoint { | ||
| 60 | const httpBaseUrl = normalizeHttpBaseUrl(input.httpBaseUrl); | ||
| 61 | return { | ||
| 62 | id: input.id, | ||
| 63 | label: input.label, | ||
| 64 | provider: input.provider, | ||
| 65 | httpBaseUrl, | ||
| 66 | wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), | ||
| 67 | reachability: input.reachability, | ||
| 68 | compatibility: { | ||
| 69 | hostedHttpsApp: | ||
| 70 | input.hostedHttpsCompatibility ?? classifyHostedHttpsCompatibility(httpBaseUrl), | ||
| 71 | desktopApp: input.desktopCompatibility ?? "compatible", | ||
| 72 | }, | ||
| 73 | source: input.source, | ||
| 74 | status: input.status ?? "available", | ||
| 75 | ...(input.isDefault === undefined ? {} : { isDefault: input.isDefault }), | ||
| 76 | ...(input.description === undefined ? {} : { description: input.description }), | ||
| 77 | }; | ||
| 78 | } | ||
packages/client-runtime/src/index.ts+1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | export * from "./advertisedEndpoint.ts"; | ||
| 1 | export * from "./knownEnvironment.ts"; | 2 | export * from "./knownEnvironment.ts"; |
| 2 | export * from "./scoped.ts"; | 3 | export * from "./scoped.ts"; |
| 3 | export * from "./sourceControlDiscoveryState.ts"; | 4 | export * from "./sourceControlDiscoveryState.ts"; |
packages/contracts/src/index.ts+1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | export * from "./baseSchemas.ts"; | 1 | export * from "./baseSchemas.ts"; |
| 2 | export * from "./auth.ts"; | 2 | export * from "./auth.ts"; |
| 3 | export * from "./environment.ts"; | 3 | export * from "./environment.ts"; |
| 4 | export * from "./remoteAccess.ts"; | ||
| 4 | export * from "./ipc.ts"; | 5 | export * from "./ipc.ts"; |
| 5 | export * from "./terminal.ts"; | 6 | export * from "./terminal.ts"; |
| 6 | export * from "./provider.ts"; | 7 | export * from "./provider.ts"; |
packages/contracts/src/ipc.ts+64-1| ... | @@ -53,8 +53,15 @@ import type { | ... | @@ -53,8 +53,15 @@ import type { |
| 53 | OrchestrationThreadStreamItem, | 53 | OrchestrationThreadStreamItem, |
| 54 | } from "./orchestration.ts"; | 54 | } from "./orchestration.ts"; |
| 55 | import type { EnvironmentId } from "./baseSchemas.ts"; | 55 | import type { EnvironmentId } from "./baseSchemas.ts"; |
| 56 | import type { | ||
| 57 | AuthBearerBootstrapResult, | ||
| 58 | AuthSessionState, | ||
| 59 | AuthWebSocketTokenResult, | ||
| 60 | } from "./auth.ts"; | ||
| 61 | import type { AdvertisedEndpoint } from "./remoteAccess.ts"; | ||
| 56 | import { EditorId } from "./editor.ts"; | 62 | import { EditorId } from "./editor.ts"; |
| 57 | import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; | 63 | import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 64 | import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; | ||
| 58 | import type { | 65 | import type { |
| 59 | SourceControlCloneRepositoryInput, | 66 | SourceControlCloneRepositoryInput, |
| 60 | SourceControlCloneRepositoryResult, | 67 | SourceControlCloneRepositoryResult, |
| ... | @@ -135,6 +142,36 @@ export interface DesktopEnvironmentBootstrap { | ... | @@ -135,6 +142,36 @@ export interface DesktopEnvironmentBootstrap { |
| 135 | bootstrapToken?: string; | 142 | bootstrapToken?: string; |
| 136 | } | 143 | } |
| 137 | 144 | ||
| 145 | export interface DesktopSshEnvironmentTarget { | ||
| 146 | alias: string; | ||
| 147 | hostname: string; | ||
| 148 | username: string | null; | ||
| 149 | port: number | null; | ||
| 150 | } | ||
| 151 | |||
| 152 | export type DesktopSshHostSource = "ssh-config" | "known-hosts"; | ||
| 153 | |||
| 154 | export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget { | ||
| 155 | source: DesktopSshHostSource; | ||
| 156 | } | ||
| 157 | |||
| 158 | export interface DesktopSshEnvironmentBootstrap { | ||
| 159 | target: DesktopSshEnvironmentTarget; | ||
| 160 | httpBaseUrl: string; | ||
| 161 | wsBaseUrl: string; | ||
| 162 | pairingToken: string | null; | ||
| 163 | remotePort?: number; | ||
| 164 | remoteServerKind?: "external" | "managed"; | ||
| 165 | } | ||
| 166 | |||
| 167 | export interface DesktopSshPasswordPromptRequest { | ||
| 168 | requestId: string; | ||
| 169 | destination: string; | ||
| 170 | username: string | null; | ||
| 171 | prompt: string; | ||
| 172 | expiresAt: string; | ||
| 173 | } | ||
| 174 | |||
| 138 | export interface PersistedSavedEnvironmentRecord { | 175 | export interface PersistedSavedEnvironmentRecord { |
| 139 | environmentId: EnvironmentId; | 176 | environmentId: EnvironmentId; |
| 140 | label: string; | 177 | label: string; |
| ... | @@ -142,6 +179,7 @@ export interface PersistedSavedEnvironmentRecord { | ... | @@ -142,6 +179,7 @@ export interface PersistedSavedEnvironmentRecord { |
| 142 | httpBaseUrl: string; | 179 | httpBaseUrl: string; |
| 143 | createdAt: string; | 180 | createdAt: string; |
| 144 | lastConnectedAt: string | null; | 181 | lastConnectedAt: string | null; |
| 182 | desktopSsh?: DesktopSshEnvironmentTarget; | ||
| 145 | } | 183 | } |
| 146 | 184 | ||
| 147 | export type DesktopServerExposureMode = "local-only" | "network-accessible"; | 185 | export type DesktopServerExposureMode = "local-only" | "network-accessible"; |
| ... | @@ -150,6 +188,8 @@ export interface DesktopServerExposureState { | ... | @@ -150,6 +188,8 @@ export interface DesktopServerExposureState { |
| 150 | mode: DesktopServerExposureMode; | 188 | mode: DesktopServerExposureMode; |
| 151 | endpointUrl: string | null; | 189 | endpointUrl: string | null; |
| 152 | advertisedHost: string | null; | 190 | advertisedHost: string | null; |
| 191 | tailscaleServeEnabled: boolean; | ||
| 192 | tailscaleServePort: number; | ||
| 153 | } | 193 | } |
| 154 | 194 | ||
| 155 | export interface PickFolderOptions { | 195 | export interface PickFolderOptions { |
| ... | @@ -168,8 +208,31 @@ export interface DesktopBridge { | ... | @@ -168,8 +208,31 @@ export interface DesktopBridge { |
| 168 | getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>; | 208 | getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>; |
| 169 | setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>; | 209 | setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>; |
| 170 | removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>; | 210 | removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>; |
| 211 | discoverSshHosts: () => Promise<readonly DesktopDiscoveredSshHost[]>; | ||
| 212 | ensureSshEnvironment: ( | ||
| 213 | target: DesktopSshEnvironmentTarget, | ||
| 214 | options?: { issuePairingToken?: boolean }, | ||
| 215 | ) => Promise<DesktopSshEnvironmentBootstrap>; | ||
| 216 | disconnectSshEnvironment: (target: DesktopSshEnvironmentTarget) => Promise<void>; | ||
| 217 | fetchSshEnvironmentDescriptor: (httpBaseUrl: string) => Promise<ExecutionEnvironmentDescriptor>; | ||
| 218 | bootstrapSshBearerSession: ( | ||
| 219 | httpBaseUrl: string, | ||
| 220 | credential: string, | ||
| 221 | ) => Promise<AuthBearerBootstrapResult>; | ||
| 222 | fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>; | ||
| 223 | issueSshWebSocketToken: ( | ||
| 224 | httpBaseUrl: string, | ||
| 225 | bearerToken: string, | ||
| 226 | ) => Promise<AuthWebSocketTokenResult>; | ||
| 227 | onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; | ||
| 228 | resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>; | ||
| 171 | getServerExposureState: () => Promise<DesktopServerExposureState>; | 229 | getServerExposureState: () => Promise<DesktopServerExposureState>; |
| 172 | setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>; | 230 | setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>; |
| 231 | setTailscaleServeEnabled: (input: { | ||
| 232 | readonly enabled: boolean; | ||
| 233 | readonly port?: number; | ||
| 234 | }) => Promise<DesktopServerExposureState>; | ||
| 235 | getAdvertisedEndpoints: () => Promise<readonly AdvertisedEndpoint[]>; | ||
| 173 | pickFolder: (options?: PickFolderOptions) => Promise<string | null>; | 236 | pickFolder: (options?: PickFolderOptions) => Promise<string | null>; |
| 174 | confirm: (message: string) => Promise<boolean>; | 237 | confirm: (message: string) => Promise<boolean>; |
| 175 | setTheme: (theme: DesktopTheme) => Promise<void>; | 238 | setTheme: (theme: DesktopTheme) => Promise<void>; |
packages/contracts/src/remoteAccess.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 4 | |||
| 5 | export const AdvertisedEndpointProviderKind = Schema.Literals([ | ||
| 6 | "core", | ||
| 7 | "private-network", | ||
| 8 | "tunnel", | ||
| 9 | "manual", | ||
| 10 | ]); | ||
| 11 | export type AdvertisedEndpointProviderKind = typeof AdvertisedEndpointProviderKind.Type; | ||
| 12 | |||
| 13 | export const AdvertisedEndpointReachability = Schema.Literals([ | ||
| 14 | "loopback", | ||
| 15 | "lan", | ||
| 16 | "private-network", | ||
| 17 | "public", | ||
| 18 | ]); | ||
| 19 | export type AdvertisedEndpointReachability = typeof AdvertisedEndpointReachability.Type; | ||
| 20 | |||
| 21 | export const AdvertisedEndpointHostedHttpsCompatibility = Schema.Literals([ | ||
| 22 | "compatible", | ||
| 23 | "mixed-content-blocked", | ||
| 24 | "requires-configuration", | ||
| 25 | "unknown", | ||
| 26 | ]); | ||
| 27 | export type AdvertisedEndpointHostedHttpsCompatibility = | ||
| 28 | typeof AdvertisedEndpointHostedHttpsCompatibility.Type; | ||
| 29 | |||
| 30 | export const AdvertisedEndpointStatus = Schema.Literals(["available", "unavailable", "unknown"]); | ||
| 31 | export type AdvertisedEndpointStatus = typeof AdvertisedEndpointStatus.Type; | ||
| 32 | |||
| 33 | export const AdvertisedEndpointSource = Schema.Literals([ | ||
| 34 | "desktop-core", | ||
| 35 | "desktop-addon", | ||
| 36 | "server", | ||
| 37 | "user", | ||
| 38 | ]); | ||
| 39 | export type AdvertisedEndpointSource = typeof AdvertisedEndpointSource.Type; | ||
| 40 | |||
| 41 | export const AdvertisedEndpointProvider = Schema.Struct({ | ||
| 42 | id: TrimmedNonEmptyString, | ||
| 43 | label: TrimmedNonEmptyString, | ||
| 44 | kind: AdvertisedEndpointProviderKind, | ||
| 45 | isAddon: Schema.Boolean, | ||
| 46 | }); | ||
| 47 | export type AdvertisedEndpointProvider = typeof AdvertisedEndpointProvider.Type; | ||
| 48 | |||
| 49 | export const AdvertisedEndpointCompatibility = Schema.Struct({ | ||
| 50 | hostedHttpsApp: AdvertisedEndpointHostedHttpsCompatibility, | ||
| 51 | desktopApp: Schema.Literals(["compatible", "unknown"]), | ||
| 52 | }); | ||
| 53 | export type AdvertisedEndpointCompatibility = typeof AdvertisedEndpointCompatibility.Type; | ||
| 54 | |||
| 55 | export const AdvertisedEndpoint = Schema.Struct({ | ||
| 56 | id: TrimmedNonEmptyString, | ||
| 57 | label: TrimmedNonEmptyString, | ||
| 58 | provider: AdvertisedEndpointProvider, | ||
| 59 | httpBaseUrl: TrimmedNonEmptyString, | ||
| 60 | wsBaseUrl: TrimmedNonEmptyString, | ||
| 61 | reachability: AdvertisedEndpointReachability, | ||
| 62 | compatibility: AdvertisedEndpointCompatibility, | ||
| 63 | source: AdvertisedEndpointSource, | ||
| 64 | status: AdvertisedEndpointStatus, | ||
| 65 | isDefault: Schema.optional(Schema.Boolean), | ||
| 66 | description: Schema.optional(TrimmedNonEmptyString), | ||
| 67 | }); | ||
| 68 | export type AdvertisedEndpoint = typeof AdvertisedEndpoint.Type; | ||
packages/contracts/src/server.ts+1| ... | @@ -198,6 +198,7 @@ export const ServerConfigUpdatedPayload = Schema.Struct({ | ... | @@ -198,6 +198,7 @@ export const ServerConfigUpdatedPayload = Schema.Struct({ |
| 198 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; | 198 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; |
| 199 | 199 | ||
| 200 | export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({ | 200 | export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({ |
| 201 | keybindings: ResolvedKeybindingsConfig, | ||
| 201 | issues: ServerConfigIssues, | 202 | issues: ServerConfigIssues, |
| 202 | }); | 203 | }); |
| 203 | export type ServerConfigKeybindingsUpdatedPayload = | 204 | export type ServerConfigKeybindingsUpdatedPayload = |
packages/shared/package.json+4| ... | @@ -75,6 +75,10 @@ | ... | @@ -75,6 +75,10 @@ |
| 75 | "./path": { | 75 | "./path": { |
| 76 | "types": "./src/path.ts", | 76 | "types": "./src/path.ts", |
| 77 | "import": "./src/path.ts" | 77 | "import": "./src/path.ts" |
| 78 | }, | ||
| 79 | "./keybindings": { | ||
| 80 | "types": "./src/keybindings.ts", | ||
| 81 | "import": "./src/keybindings.ts" | ||
| 78 | } | 82 | } |
| 79 | }, | 83 | }, |
| 80 | "scripts": { | 84 | "scripts": { |
packages/shared/src/keybindings.ts created+283| ... | @@ -0,0 +1,283 @@ | ||
| 1 | import { | ||
| 2 | type KeybindingRule, | ||
| 3 | type KeybindingShortcut, | ||
| 4 | type KeybindingWhenNode, | ||
| 5 | MAX_KEYBINDINGS_COUNT, | ||
| 6 | MAX_WHEN_EXPRESSION_DEPTH, | ||
| 7 | MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | ||
| 8 | type ResolvedKeybindingRule, | ||
| 9 | type ResolvedKeybindingsConfig, | ||
| 10 | THREAD_JUMP_KEYBINDING_COMMANDS, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | |||
| 13 | type WhenToken = | ||
| 14 | | { type: "identifier"; value: string } | ||
| 15 | | { type: "not" } | ||
| 16 | | { type: "and" } | ||
| 17 | | { type: "or" } | ||
| 18 | | { type: "lparen" } | ||
| 19 | | { type: "rparen" }; | ||
| 20 | |||
| 21 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ||
| 22 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 23 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 24 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | ||
| 25 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | ||
| 26 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, | ||
| 27 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, | ||
| 28 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | ||
| 29 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | ||
| 30 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | ||
| 31 | { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, | ||
| 32 | { key: "mod+o", command: "editor.openFavorite" }, | ||
| 33 | { key: "mod+shift+[", command: "thread.previous" }, | ||
| 34 | { key: "mod+shift+]", command: "thread.next" }, | ||
| 35 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | ||
| 36 | key: `mod+${index + 1}`, | ||
| 37 | command, | ||
| 38 | })), | ||
| 39 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | ||
| 40 | key: `mod+${index + 1}`, | ||
| 41 | command, | ||
| 42 | when: "modelPickerOpen", | ||
| 43 | })), | ||
| 44 | ]; | ||
| 45 | |||
| 46 | function normalizeKeyToken(token: string): string { | ||
| 47 | if (token === "space") return " "; | ||
| 48 | if (token === "esc") return "escape"; | ||
| 49 | return token; | ||
| 50 | } | ||
| 51 | |||
| 52 | export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { | ||
| 53 | const rawTokens = value | ||
| 54 | .toLowerCase() | ||
| 55 | .split("+") | ||
| 56 | .map((token) => token.trim()); | ||
| 57 | const tokens = [...rawTokens]; | ||
| 58 | let trailingEmptyCount = 0; | ||
| 59 | while (tokens[tokens.length - 1] === "") { | ||
| 60 | trailingEmptyCount += 1; | ||
| 61 | tokens.pop(); | ||
| 62 | } | ||
| 63 | if (trailingEmptyCount > 0) { | ||
| 64 | tokens.push("+"); | ||
| 65 | } | ||
| 66 | if (tokens.some((token) => token.length === 0)) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | if (tokens.length === 0) return null; | ||
| 70 | |||
| 71 | let key: string | null = null; | ||
| 72 | let metaKey = false; | ||
| 73 | let ctrlKey = false; | ||
| 74 | let shiftKey = false; | ||
| 75 | let altKey = false; | ||
| 76 | let modKey = false; | ||
| 77 | |||
| 78 | for (const token of tokens) { | ||
| 79 | switch (token) { | ||
| 80 | case "cmd": | ||
| 81 | case "meta": | ||
| 82 | metaKey = true; | ||
| 83 | break; | ||
| 84 | case "ctrl": | ||
| 85 | case "control": | ||
| 86 | ctrlKey = true; | ||
| 87 | break; | ||
| 88 | case "shift": | ||
| 89 | shiftKey = true; | ||
| 90 | break; | ||
| 91 | case "alt": | ||
| 92 | case "option": | ||
| 93 | altKey = true; | ||
| 94 | break; | ||
| 95 | case "mod": | ||
| 96 | modKey = true; | ||
| 97 | break; | ||
| 98 | default: { | ||
| 99 | if (key !== null) return null; | ||
| 100 | key = normalizeKeyToken(token); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | if (key === null) return null; | ||
| 106 | return { | ||
| 107 | key, | ||
| 108 | metaKey, | ||
| 109 | ctrlKey, | ||
| 110 | shiftKey, | ||
| 111 | altKey, | ||
| 112 | modKey, | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | ||
| 117 | const tokens: WhenToken[] = []; | ||
| 118 | let index = 0; | ||
| 119 | |||
| 120 | while (index < expression.length) { | ||
| 121 | const current = expression[index]; | ||
| 122 | if (!current) break; | ||
| 123 | |||
| 124 | if (/\s/.test(current)) { | ||
| 125 | index += 1; | ||
| 126 | continue; | ||
| 127 | } | ||
| 128 | if (expression.startsWith("&&", index)) { | ||
| 129 | tokens.push({ type: "and" }); | ||
| 130 | index += 2; | ||
| 131 | continue; | ||
| 132 | } | ||
| 133 | if (expression.startsWith("||", index)) { | ||
| 134 | tokens.push({ type: "or" }); | ||
| 135 | index += 2; | ||
| 136 | continue; | ||
| 137 | } | ||
| 138 | if (current === "!") { | ||
| 139 | tokens.push({ type: "not" }); | ||
| 140 | index += 1; | ||
| 141 | continue; | ||
| 142 | } | ||
| 143 | if (current === "(") { | ||
| 144 | tokens.push({ type: "lparen" }); | ||
| 145 | index += 1; | ||
| 146 | continue; | ||
| 147 | } | ||
| 148 | if (current === ")") { | ||
| 149 | tokens.push({ type: "rparen" }); | ||
| 150 | index += 1; | ||
| 151 | continue; | ||
| 152 | } | ||
| 153 | |||
| 154 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | ||
| 155 | if (!identifier) { | ||
| 156 | return null; | ||
| 157 | } | ||
| 158 | tokens.push({ type: "identifier", value: identifier[0] }); | ||
| 159 | index += identifier[0].length; | ||
| 160 | } | ||
| 161 | |||
| 162 | return tokens; | ||
| 163 | } | ||
| 164 | |||
| 165 | function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { | ||
| 166 | const tokens = tokenizeWhenExpression(expression); | ||
| 167 | if (!tokens || tokens.length === 0) return null; | ||
| 168 | let index = 0; | ||
| 169 | |||
| 170 | const parsePrimary = (depth: number): KeybindingWhenNode | null => { | ||
| 171 | if (depth > MAX_WHEN_EXPRESSION_DEPTH) { | ||
| 172 | return null; | ||
| 173 | } | ||
| 174 | const token = tokens[index]; | ||
| 175 | if (!token) return null; | ||
| 176 | |||
| 177 | if (token.type === "identifier") { | ||
| 178 | index += 1; | ||
| 179 | return { type: "identifier", name: token.value }; | ||
| 180 | } | ||
| 181 | |||
| 182 | if (token.type === "lparen") { | ||
| 183 | index += 1; | ||
| 184 | const expressionNode = parseOr(depth + 1); | ||
| 185 | const closeToken = tokens[index]; | ||
| 186 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | ||
| 187 | return null; | ||
| 188 | } | ||
| 189 | index += 1; | ||
| 190 | return expressionNode; | ||
| 191 | } | ||
| 192 | |||
| 193 | return null; | ||
| 194 | }; | ||
| 195 | |||
| 196 | const parseUnary = (depth: number): KeybindingWhenNode | null => { | ||
| 197 | let notCount = 0; | ||
| 198 | while (tokens[index]?.type === "not") { | ||
| 199 | index += 1; | ||
| 200 | notCount += 1; | ||
| 201 | if (notCount > MAX_WHEN_EXPRESSION_DEPTH) { | ||
| 202 | return null; | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | let node = parsePrimary(depth); | ||
| 207 | if (!node) return null; | ||
| 208 | |||
| 209 | while (notCount > 0) { | ||
| 210 | node = { type: "not", node }; | ||
| 211 | notCount -= 1; | ||
| 212 | } | ||
| 213 | |||
| 214 | return node; | ||
| 215 | }; | ||
| 216 | |||
| 217 | const parseAnd = (depth: number): KeybindingWhenNode | null => { | ||
| 218 | let left = parseUnary(depth); | ||
| 219 | if (!left) return null; | ||
| 220 | |||
| 221 | while (tokens[index]?.type === "and") { | ||
| 222 | index += 1; | ||
| 223 | const right = parseUnary(depth); | ||
| 224 | if (!right) return null; | ||
| 225 | left = { type: "and", left, right }; | ||
| 226 | } | ||
| 227 | |||
| 228 | return left; | ||
| 229 | }; | ||
| 230 | |||
| 231 | const parseOr = (depth: number): KeybindingWhenNode | null => { | ||
| 232 | let left = parseAnd(depth); | ||
| 233 | if (!left) return null; | ||
| 234 | |||
| 235 | while (tokens[index]?.type === "or") { | ||
| 236 | index += 1; | ||
| 237 | const right = parseAnd(depth); | ||
| 238 | if (!right) return null; | ||
| 239 | left = { type: "or", left, right }; | ||
| 240 | } | ||
| 241 | |||
| 242 | return left; | ||
| 243 | }; | ||
| 244 | |||
| 245 | const ast = parseOr(0); | ||
| 246 | if (!ast || index !== tokens.length) return null; | ||
| 247 | return ast; | ||
| 248 | } | ||
| 249 | |||
| 250 | export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { | ||
| 251 | const shortcut = parseKeybindingShortcut(rule.key); | ||
| 252 | if (!shortcut) return null; | ||
| 253 | |||
| 254 | if (rule.when !== undefined) { | ||
| 255 | const whenAst = parseKeybindingWhenExpression(rule.when); | ||
| 256 | if (!whenAst) return null; | ||
| 257 | return { | ||
| 258 | command: rule.command, | ||
| 259 | shortcut, | ||
| 260 | whenAst, | ||
| 261 | }; | ||
| 262 | } | ||
| 263 | |||
| 264 | return { | ||
| 265 | command: rule.command, | ||
| 266 | shortcut, | ||
| 267 | }; | ||
| 268 | } | ||
| 269 | |||
| 270 | export function compileResolvedKeybindingsConfig( | ||
| 271 | config: ReadonlyArray<KeybindingRule>, | ||
| 272 | ): ResolvedKeybindingsConfig { | ||
| 273 | const compiled: ResolvedKeybindingRule[] = []; | ||
| 274 | for (const rule of config) { | ||
| 275 | const result = compileResolvedKeybindingRule(rule); | ||
| 276 | if (result) { | ||
| 277 | compiled.push(result); | ||
| 278 | } | ||
| 279 | } | ||
| 280 | return compiled.slice(-MAX_KEYBINDINGS_COUNT); | ||
| 281 | } | ||
| 282 | |||
| 283 | export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); | ||
packages/ssh/package.json created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/ssh", | ||
| 3 | "version": "0.0.0-alpha.1", | ||
| 4 | "private": true, | ||
| 5 | "type": "module", | ||
| 6 | "exports": { | ||
| 7 | "./auth": { | ||
| 8 | "types": "./src/auth.ts", | ||
| 9 | "import": "./src/auth.ts" | ||
| 10 | }, | ||
| 11 | "./command": { | ||
| 12 | "types": "./src/command.ts", | ||
| 13 | "import": "./src/command.ts" | ||
| 14 | }, | ||
| 15 | "./config": { | ||
| 16 | "types": "./src/config.ts", | ||
| 17 | "import": "./src/config.ts" | ||
| 18 | }, | ||
| 19 | "./errors": { | ||
| 20 | "types": "./src/errors.ts", | ||
| 21 | "import": "./src/errors.ts" | ||
| 22 | }, | ||
| 23 | "./tunnel": { | ||
| 24 | "types": "./src/tunnel.ts", | ||
| 25 | "import": "./src/tunnel.ts" | ||
| 26 | } | ||
| 27 | }, | ||
| 28 | "scripts": { | ||
| 29 | "prepare": "effect-language-service patch", | ||
| 30 | "typecheck": "tsc --noEmit", | ||
| 31 | "test": "vitest run" | ||
| 32 | }, | ||
| 33 | "dependencies": { | ||
| 34 | "@t3tools/contracts": "workspace:*", | ||
| 35 | "@t3tools/shared": "workspace:*", | ||
| 36 | "effect": "catalog:" | ||
| 37 | }, | ||
| 38 | "devDependencies": { | ||
| 39 | "@effect/language-service": "catalog:", | ||
| 40 | "@effect/platform-node": "catalog:", | ||
| 41 | "@effect/vitest": "catalog:", | ||
| 42 | "@types/node": "catalog:", | ||
| 43 | "typescript": "catalog:", | ||
| 44 | "vitest": "catalog:" | ||
| 45 | } | ||
| 46 | } | ||
packages/ssh/src/auth.test.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Path } from "effect"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | buildSshAskpassHelperDescriptor, | ||
| 7 | buildSshChildEnvironment, | ||
| 8 | isSshAuthFailure, | ||
| 9 | } from "./auth.ts"; | ||
| 10 | |||
| 11 | describe("ssh auth", () => { | ||
| 12 | it.effect("detects ssh auth failures from common permission denied messages", () => | ||
| 13 | Effect.sync(() => { | ||
| 14 | assert.equal( | ||
| 15 | isSshAuthFailure( | ||
| 16 | new Error( | ||
| 17 | "julius@100.65.180.100: Permission denied (publickey,password,keyboard-interactive).", | ||
| 18 | ), | ||
| 19 | ), | ||
| 20 | true, | ||
| 21 | ); | ||
| 22 | assert.equal(isSshAuthFailure(new Error("Permission denied (publickey).")), true); | ||
| 23 | assert.equal(isSshAuthFailure(new Error("Connection timed out")), false); | ||
| 24 | assert.equal(isSshAuthFailure(new Error("mkdir: Permission denied")), false); | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | |||
| 28 | it.effect("creates askpass env for cached password prompts", () => | ||
| 29 | Effect.gen(function* () { | ||
| 30 | const fs = yield* FileSystem.FileSystem; | ||
| 31 | const path = yield* Path.Path; | ||
| 32 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-askpass-test-" }); | ||
| 33 | const env = yield* buildSshChildEnvironment({ | ||
| 34 | authSecret: "super-secret", | ||
| 35 | interactiveAuth: true, | ||
| 36 | askpassDirectory: directory, | ||
| 37 | platform: "linux", | ||
| 38 | baseEnv: {}, | ||
| 39 | }); | ||
| 40 | |||
| 41 | const askpassPath = path.join(directory, "ssh-askpass.sh"); | ||
| 42 | assert.equal(env.SSH_ASKPASS, askpassPath); | ||
| 43 | assert.equal(env.SSH_ASKPASS_REQUIRE, "force"); | ||
| 44 | assert.equal(env.T3_SSH_AUTH_SECRET, "super-secret"); | ||
| 45 | assert.equal(env.DISPLAY, "t3code"); | ||
| 46 | assert.equal(yield* fs.exists(askpassPath), true); | ||
| 47 | assert.include(yield* fs.readFileString(askpassPath), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"'); | ||
| 48 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 49 | ); | ||
| 50 | |||
| 51 | it.effect("builds a windows askpass launcher pair", () => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const descriptor = yield* buildSshAskpassHelperDescriptor({ | ||
| 54 | directory: "C:\\temp\\t3code-ssh-askpass", | ||
| 55 | platform: "win32", | ||
| 56 | }).pipe(Effect.provide(NodeServices.layer)); | ||
| 57 | |||
| 58 | assert.equal(descriptor.launcherPath, "C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd"); | ||
| 59 | assert.deepEqual( | ||
| 60 | descriptor.files.map((file) => file.path.split("\\").at(-1)), | ||
| 61 | ["ssh-askpass.cmd", "ssh-askpass.ps1"], | ||
| 62 | ); | ||
| 63 | }), | ||
| 64 | ); | ||
| 65 | }); | ||
packages/ssh/src/auth.ts created+205| ... | @@ -0,0 +1,205 @@ | ||
| 1 | import { Context, Effect, FileSystem, Layer, Path, PlatformError } from "effect"; | ||
| 2 | |||
| 3 | import { SshPasswordPromptError } from "./errors.ts"; | ||
| 4 | |||
| 5 | export interface SshPasswordRequest { | ||
| 6 | readonly destination: string; | ||
| 7 | readonly username: string | null; | ||
| 8 | readonly prompt: string; | ||
| 9 | readonly attempt: number; | ||
| 10 | } | ||
| 11 | |||
| 12 | export interface SshAskpassFile { | ||
| 13 | readonly path: string; | ||
| 14 | readonly contents: string; | ||
| 15 | readonly mode?: number; | ||
| 16 | } | ||
| 17 | |||
| 18 | export interface SshAskpassHelperDescriptor { | ||
| 19 | readonly launcherPath: string; | ||
| 20 | readonly files: ReadonlyArray<SshAskpassFile>; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface SshAuthOptions { | ||
| 24 | readonly authSecret?: string | null; | ||
| 25 | readonly batchMode?: "yes" | "no"; | ||
| 26 | readonly interactiveAuth?: boolean; | ||
| 27 | } | ||
| 28 | |||
| 29 | export interface SshPasswordPromptShape { | ||
| 30 | readonly isAvailable: boolean; | ||
| 31 | readonly request: ( | ||
| 32 | request: SshPasswordRequest, | ||
| 33 | ) => Effect.Effect<string | null, SshPasswordPromptError>; | ||
| 34 | } | ||
| 35 | |||
| 36 | export class SshPasswordPrompt extends Context.Service<SshPasswordPrompt, SshPasswordPromptShape>()( | ||
| 37 | "@t3tools/ssh/SshPasswordPrompt", | ||
| 38 | ) { | ||
| 39 | static readonly disabledLayer = Layer.succeed( | ||
| 40 | SshPasswordPrompt, | ||
| 41 | SshPasswordPrompt.of({ | ||
| 42 | isAvailable: false, | ||
| 43 | request: () => Effect.succeed(null), | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface SshChildEnvironmentOptions { | ||
| 49 | readonly interactiveAuth?: boolean; | ||
| 50 | readonly baseEnv?: NodeJS.ProcessEnv; | ||
| 51 | readonly askpassDirectory?: string; | ||
| 52 | readonly authSecret?: string | null; | ||
| 53 | readonly platform?: NodeJS.Platform; | ||
| 54 | } | ||
| 55 | |||
| 56 | const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; | ||
| 57 | |||
| 58 | function joinSshAskpassPath( | ||
| 59 | directory: string, | ||
| 60 | fileName: string, | ||
| 61 | platform: NodeJS.Platform, | ||
| 62 | ): string { | ||
| 63 | const trimmed = directory.replace(/[\\/]+$/u, ""); | ||
| 64 | return platform === "win32" ? `${trimmed}\\${fileName}` : `${trimmed}/${fileName}`; | ||
| 65 | } | ||
| 66 | |||
| 67 | export const ASKPASS_POSIX_SCRIPT = `#!/bin/sh | ||
| 68 | # Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password | ||
| 69 | # from the renderer's in-app prompt. We never expose a native dialog here - if | ||
| 70 | # T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly. | ||
| 71 | if [ "\${T3_SSH_AUTH_SECRET+x}" = "x" ]; then | ||
| 72 | printf "%s\\n" "$T3_SSH_AUTH_SECRET" | ||
| 73 | exit 0 | ||
| 74 | fi | ||
| 75 | printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\\n' >&2 | ||
| 76 | exit 1 | ||
| 77 | `; | ||
| 78 | |||
| 79 | export const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r | ||
| 80 | powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*\r | ||
| 81 | `; | ||
| 82 | |||
| 83 | export const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r | ||
| 84 | # ssh with a cached password from the renderer's in-app prompt. We never expose\r | ||
| 85 | # a native dialog here - if T3_SSH_AUTH_SECRET is missing, that's a caller bug\r | ||
| 86 | # and we fail loudly.\r | ||
| 87 | if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r | ||
| 88 | [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET)\r | ||
| 89 | exit 0\r | ||
| 90 | }\r | ||
| 91 | [Console]::Error.WriteLine("T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.")\r | ||
| 92 | exit 1\r | ||
| 93 | `; | ||
| 94 | |||
| 95 | export const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( | ||
| 96 | function* () { | ||
| 97 | const fs = yield* FileSystem.FileSystem; | ||
| 98 | const path = yield* Path.Path; | ||
| 99 | const parentDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }); | ||
| 100 | return path.join(parentDirectory, SSH_ASKPASS_DIR_NAME); | ||
| 101 | }, | ||
| 102 | ); | ||
| 103 | |||
| 104 | export const buildSshAskpassHelperDescriptor = Effect.fn( | ||
| 105 | "ssh/auth.buildSshAskpassHelperDescriptor", | ||
| 106 | )(function* (input: { | ||
| 107 | readonly directory: string; | ||
| 108 | readonly platform?: NodeJS.Platform; | ||
| 109 | }): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> { | ||
| 110 | const platform = input.platform ?? process.platform; | ||
| 111 | const path = yield* Path.Path; | ||
| 112 | const directory = input.directory; | ||
| 113 | |||
| 114 | if (platform === "win32") { | ||
| 115 | const powershellPath = joinSshAskpassPath(directory, "ssh-askpass.ps1", platform); | ||
| 116 | return { | ||
| 117 | launcherPath: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform), | ||
| 118 | files: [ | ||
| 119 | { | ||
| 120 | path: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform), | ||
| 121 | contents: ASKPASS_WINDOWS_LAUNCHER_SCRIPT, | ||
| 122 | }, | ||
| 123 | { | ||
| 124 | path: powershellPath, | ||
| 125 | contents: ASKPASS_WINDOWS_SCRIPT, | ||
| 126 | }, | ||
| 127 | ], | ||
| 128 | }; | ||
| 129 | } | ||
| 130 | |||
| 131 | return { | ||
| 132 | launcherPath: path.join(directory, "ssh-askpass.sh"), | ||
| 133 | files: [ | ||
| 134 | { | ||
| 135 | path: path.join(directory, "ssh-askpass.sh"), | ||
| 136 | contents: ASKPASS_POSIX_SCRIPT, | ||
| 137 | mode: 0o700, | ||
| 138 | }, | ||
| 139 | ], | ||
| 140 | }; | ||
| 141 | }); | ||
| 142 | |||
| 143 | export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( | ||
| 144 | function* (input: { | ||
| 145 | readonly directory: string; | ||
| 146 | readonly platform?: NodeJS.Platform; | ||
| 147 | }): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { | ||
| 148 | const fs = yield* FileSystem.FileSystem; | ||
| 149 | const path = yield* Path.Path; | ||
| 150 | const descriptor = yield* buildSshAskpassHelperDescriptor(input); | ||
| 151 | const platform = input.platform ?? process.platform; | ||
| 152 | |||
| 153 | yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); | ||
| 154 | |||
| 155 | for (const file of descriptor.files) { | ||
| 156 | const existing = yield* fs.exists(file.path); | ||
| 157 | const current = existing ? yield* fs.readFileString(file.path) : null; | ||
| 158 | if (current !== file.contents) { | ||
| 159 | yield* fs.writeFileString(file.path, file.contents); | ||
| 160 | } | ||
| 161 | if (file.mode !== undefined && platform !== "win32") { | ||
| 162 | yield* fs.chmod(file.path, file.mode); | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | return descriptor.launcherPath; | ||
| 167 | }, | ||
| 168 | ); | ||
| 169 | |||
| 170 | export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* ( | ||
| 171 | input: SshChildEnvironmentOptions = {}, | ||
| 172 | ): Effect.fn.Return< | ||
| 173 | NodeJS.ProcessEnv, | ||
| 174 | PlatformError.PlatformError, | ||
| 175 | FileSystem.FileSystem | Path.Path | ||
| 176 | > { | ||
| 177 | const baseEnv = { ...(input.baseEnv ?? process.env) }; | ||
| 178 | if (!input.interactiveAuth) { | ||
| 179 | return baseEnv; | ||
| 180 | } | ||
| 181 | |||
| 182 | const platform = input.platform ?? process.platform; | ||
| 183 | const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); | ||
| 184 | const sshAskpass = yield* ensureSshAskpassHelpers({ directory, platform }); | ||
| 185 | |||
| 186 | return { | ||
| 187 | ...baseEnv, | ||
| 188 | SSH_ASKPASS: sshAskpass, | ||
| 189 | SSH_ASKPASS_REQUIRE: "force", | ||
| 190 | ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }), | ||
| 191 | ...(platform === "win32" || baseEnv.DISPLAY ? {} : { DISPLAY: "t3code" }), | ||
| 192 | }; | ||
| 193 | }); | ||
| 194 | |||
| 195 | export function isSshAuthFailure(error: unknown): boolean { | ||
| 196 | const message = error instanceof Error ? error.message : String(error); | ||
| 197 | const normalized = message.toLowerCase(); | ||
| 198 | return ( | ||
| 199 | /permission denied \((?:publickey|password|keyboard-interactive|hostbased|gssapi-with-mic)[^)]*\)/u.test( | ||
| 200 | normalized, | ||
| 201 | ) || | ||
| 202 | /authentication failed/u.test(normalized) || | ||
| 203 | /too many authentication failures/u.test(normalized) | ||
| 204 | ); | ||
| 205 | } | ||
packages/ssh/src/command.test.ts created+151| ... | @@ -0,0 +1,151 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; | ||
| 4 | import { TestClock } from "effect/testing"; | ||
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | baseSshArgs, | ||
| 9 | getLastNonEmptyOutputLine, | ||
| 10 | parseSshResolveOutput, | ||
| 11 | resolveRemoteT3CliPackageSpec, | ||
| 12 | runSshCommand, | ||
| 13 | } from "./command.ts"; | ||
| 14 | |||
| 15 | const makeNeverFinishingProcess = () => { | ||
| 16 | let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; | ||
| 17 | return ChildProcessSpawner.makeHandle({ | ||
| 18 | pid: ChildProcessSpawner.ProcessId(123), | ||
| 19 | stdout: Stream.empty, | ||
| 20 | stderr: Stream.empty, | ||
| 21 | all: Stream.empty, | ||
| 22 | exitCode: Effect.callback<ChildProcessSpawner.ExitCode>((resume) => { | ||
| 23 | finish = (exitCode) => resume(Effect.succeed(exitCode)); | ||
| 24 | return Effect.sync(() => { | ||
| 25 | finish = null; | ||
| 26 | }); | ||
| 27 | }), | ||
| 28 | isRunning: Effect.succeed(true), | ||
| 29 | kill: () => | ||
| 30 | Effect.sync(() => { | ||
| 31 | finish?.(ChildProcessSpawner.ExitCode(143)); | ||
| 32 | }), | ||
| 33 | stdin: Sink.drain, | ||
| 34 | getInputFd: () => Sink.drain, | ||
| 35 | getOutputFd: () => Stream.empty, | ||
| 36 | unref: Effect.succeed(Effect.void), | ||
| 37 | }); | ||
| 38 | }; | ||
| 39 | |||
| 40 | describe("ssh command", () => { | ||
| 41 | it.effect("parses resolved ssh config output into a target", () => | ||
| 42 | Effect.sync(() => { | ||
| 43 | assert.deepEqual( | ||
| 44 | parseSshResolveOutput( | ||
| 45 | "devbox", | ||
| 46 | ["hostname devbox.example.com", "user julius", "port 2222", ""].join("\n"), | ||
| 47 | ), | ||
| 48 | { | ||
| 49 | alias: "devbox", | ||
| 50 | hostname: "devbox.example.com", | ||
| 51 | username: "julius", | ||
| 52 | port: 2222, | ||
| 53 | }, | ||
| 54 | ); | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | |||
| 58 | it.effect("builds interactive ssh args without forcing batch mode", () => | ||
| 59 | Effect.sync(() => { | ||
| 60 | assert.deepEqual( | ||
| 61 | baseSshArgs( | ||
| 62 | { | ||
| 63 | alias: "devbox", | ||
| 64 | hostname: "devbox.example.com", | ||
| 65 | username: "julius", | ||
| 66 | port: 2222, | ||
| 67 | }, | ||
| 68 | { batchMode: "no" }, | ||
| 69 | ), | ||
| 70 | ["-o", "BatchMode=no", "-o", "ConnectTimeout=10", "-p", "2222"], | ||
| 71 | ); | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | |||
| 75 | it.effect("resolves the remote t3 package spec from the desktop release channel", () => | ||
| 76 | Effect.sync(() => { | ||
| 77 | assert.equal( | ||
| 78 | resolveRemoteT3CliPackageSpec({ | ||
| 79 | appVersion: "0.0.17", | ||
| 80 | updateChannel: "latest", | ||
| 81 | }), | ||
| 82 | "t3@0.0.17", | ||
| 83 | ); | ||
| 84 | assert.equal( | ||
| 85 | resolveRemoteT3CliPackageSpec({ | ||
| 86 | appVersion: "0.0.17-nightly.20260415.44", | ||
| 87 | updateChannel: "nightly", | ||
| 88 | }), | ||
| 89 | "t3@0.0.17-nightly.20260415.44", | ||
| 90 | ); | ||
| 91 | assert.equal( | ||
| 92 | resolveRemoteT3CliPackageSpec({ | ||
| 93 | appVersion: "0.0.0-dev", | ||
| 94 | updateChannel: "nightly", | ||
| 95 | isDevelopment: true, | ||
| 96 | }), | ||
| 97 | "t3@nightly", | ||
| 98 | ); | ||
| 99 | assert.equal( | ||
| 100 | resolveRemoteT3CliPackageSpec({ | ||
| 101 | appVersion: "0.0.0-dev", | ||
| 102 | updateChannel: "latest", | ||
| 103 | isDevelopment: true, | ||
| 104 | }), | ||
| 105 | "t3@nightly", | ||
| 106 | ); | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | |||
| 110 | it.effect("reads the last non-empty ssh output line", () => | ||
| 111 | Effect.sync(() => { | ||
| 112 | assert.equal( | ||
| 113 | getLastNonEmptyOutputLine( | ||
| 114 | ["Welcome to the host", "", '{"credential":"pairing-token"}', ""].join("\n"), | ||
| 115 | ), | ||
| 116 | '{"credential":"pairing-token"}', | ||
| 117 | ); | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("fails commands that never finish", () => { | ||
| 122 | const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingProcess())); | ||
| 123 | const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); | ||
| 124 | const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); | ||
| 125 | |||
| 126 | return Effect.gen(function* () { | ||
| 127 | const fiber = yield* Effect.forkChild( | ||
| 128 | Effect.result( | ||
| 129 | runSshCommand( | ||
| 130 | { | ||
| 131 | alias: "devbox", | ||
| 132 | hostname: "devbox.example.com", | ||
| 133 | username: "julius", | ||
| 134 | port: 2222, | ||
| 135 | }, | ||
| 136 | { timeoutMs: 1 }, | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | yield* Effect.yieldNow; | ||
| 141 | yield* TestClock.adjust(Duration.millis(1)); | ||
| 142 | |||
| 143 | const result = yield* Fiber.join(fiber); | ||
| 144 | |||
| 145 | assert.isTrue(Result.isFailure(result)); | ||
| 146 | if (Result.isFailure(result)) { | ||
| 147 | assert.include(result.failure.message, "SSH command timed out after 1ms."); | ||
| 148 | } | ||
| 149 | }).pipe(Effect.provide(processLayer)); | ||
| 150 | }); | ||
| 151 | }); | ||
packages/ssh/src/command.ts created+337| ... | @@ -0,0 +1,337 @@ | ||
| 1 | import * as Crypto from "node:crypto"; | ||
| 2 | |||
| 3 | import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; | ||
| 4 | import { Duration, Effect, FileSystem, Option, Path, Scope, Stream } from "effect"; | ||
| 5 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 6 | |||
| 7 | import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; | ||
| 8 | import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; | ||
| 9 | |||
| 10 | const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; | ||
| 11 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; | ||
| 12 | |||
| 13 | const encoder = new TextEncoder(); | ||
| 14 | |||
| 15 | export interface SshCommandResult { | ||
| 16 | readonly stdout: string; | ||
| 17 | readonly stderr: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface RunSshCommandOptions extends SshAuthOptions { | ||
| 21 | readonly preHostArgs?: ReadonlyArray<string>; | ||
| 22 | readonly remoteCommandArgs?: ReadonlyArray<string>; | ||
| 23 | readonly stdin?: string; | ||
| 24 | readonly timeoutMs?: number; | ||
| 25 | } | ||
| 26 | |||
| 27 | export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget { | ||
| 28 | const values = new Map<string, string>(); | ||
| 29 | for (const line of stdout.split(/\r?\n/u)) { | ||
| 30 | const trimmed = line.trim(); | ||
| 31 | if (trimmed.length === 0) { | ||
| 32 | continue; | ||
| 33 | } | ||
| 34 | const [key, ...rest] = trimmed.split(/\s+/u); | ||
| 35 | if (!key || rest.length === 0 || values.has(key)) { | ||
| 36 | continue; | ||
| 37 | } | ||
| 38 | values.set(key, rest.join(" ").trim()); | ||
| 39 | } | ||
| 40 | |||
| 41 | const hostname = values.get("hostname")?.trim() || alias; | ||
| 42 | const username = values.get("user")?.trim() || null; | ||
| 43 | const rawPort = values.get("port")?.trim() ?? ""; | ||
| 44 | const parsedPort = Number.parseInt(rawPort, 10); | ||
| 45 | |||
| 46 | return { | ||
| 47 | alias, | ||
| 48 | hostname, | ||
| 49 | username, | ||
| 50 | port: Number.isInteger(parsedPort) ? parsedPort : null, | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string { | ||
| 55 | return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`; | ||
| 56 | } | ||
| 57 | |||
| 58 | export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { | ||
| 59 | return Crypto.createHash("sha256").update(targetConnectionKey(target)).digest("hex").slice(0, 16); | ||
| 60 | } | ||
| 61 | |||
| 62 | export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { | ||
| 63 | const destination = target.alias.trim() || target.hostname.trim(); | ||
| 64 | if (destination.length === 0) { | ||
| 65 | throw new Error("SSH target is missing its alias/hostname."); | ||
| 66 | } | ||
| 67 | return target.username ? `${target.username}@${destination}` : destination; | ||
| 68 | } | ||
| 69 | |||
| 70 | export const buildSshHostSpecEffect = ( | ||
| 71 | target: DesktopSshEnvironmentTarget, | ||
| 72 | ): Effect.Effect<string, SshInvalidTargetError> => | ||
| 73 | Effect.try({ | ||
| 74 | try: () => buildSshHostSpec(target), | ||
| 75 | catch: (cause) => | ||
| 76 | new SshInvalidTargetError({ | ||
| 77 | message: cause instanceof Error ? cause.message : "SSH target is invalid.", | ||
| 78 | }), | ||
| 79 | }); | ||
| 80 | |||
| 81 | export function baseSshArgs( | ||
| 82 | target: DesktopSshEnvironmentTarget, | ||
| 83 | input?: { readonly batchMode?: "yes" | "no" }, | ||
| 84 | ): string[] { | ||
| 85 | return [ | ||
| 86 | "-o", | ||
| 87 | `BatchMode=${input?.batchMode ?? "no"}`, | ||
| 88 | "-o", | ||
| 89 | "ConnectTimeout=10", | ||
| 90 | ...(target.port !== null ? ["-p", String(target.port)] : []), | ||
| 91 | ]; | ||
| 92 | } | ||
| 93 | |||
| 94 | export function getLastNonEmptyOutputLine(stdout: string): string | null { | ||
| 95 | return ( | ||
| 96 | stdout | ||
| 97 | .trim() | ||
| 98 | .split(/\r?\n/u) | ||
| 99 | .map((entry) => entry.trim()) | ||
| 100 | .findLast((entry) => entry.length > 0) ?? null | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | |||
| 104 | export const collectProcessOutput = <E>( | ||
| 105 | stream: Stream.Stream<Uint8Array, E>, | ||
| 106 | ): Effect.Effect<string, E> => | ||
| 107 | stream.pipe( | ||
| 108 | Stream.decodeText(), | ||
| 109 | Stream.runFold( | ||
| 110 | () => "", | ||
| 111 | (acc, chunk) => acc + chunk, | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | |||
| 115 | function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { | ||
| 116 | const cleaned = stderr.trim(); | ||
| 117 | return cleaned.length > 0 ? cleaned : fallbackMessage; | ||
| 118 | } | ||
| 119 | |||
| 120 | function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { | ||
| 121 | return { | ||
| 122 | alias: target.alias, | ||
| 123 | hostname: target.hostname, | ||
| 124 | username: target.username, | ||
| 125 | port: target.port, | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | function stdinStream(input: string | undefined) { | ||
| 130 | return input === undefined ? Stream.empty : Stream.make(encoder.encode(input)); | ||
| 131 | } | ||
| 132 | |||
| 133 | const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* ( | ||
| 134 | target: DesktopSshEnvironmentTarget, | ||
| 135 | input: RunSshCommandOptions, | ||
| 136 | commandScope: Scope.Scope, | ||
| 137 | ): Effect.fn.Return< | ||
| 138 | SshCommandResult, | ||
| 139 | SshCommandError | SshInvalidTargetError, | ||
| 140 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 141 | > { | ||
| 142 | const hostSpec = yield* buildSshHostSpecEffect(target); | ||
| 143 | const environment = yield* buildSshChildEnvironment({ | ||
| 144 | ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | ||
| 145 | ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }), | ||
| 146 | }).pipe( | ||
| 147 | Effect.mapError( | ||
| 148 | (cause) => | ||
| 149 | new SshCommandError({ | ||
| 150 | command: ["ssh"], | ||
| 151 | exitCode: null, | ||
| 152 | stderr: "", | ||
| 153 | message: "Failed to prepare SSH authentication helpers.", | ||
| 154 | cause, | ||
| 155 | }), | ||
| 156 | ), | ||
| 157 | ); | ||
| 158 | const args = [ | ||
| 159 | ...baseSshArgs(target, { | ||
| 160 | batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"), | ||
| 161 | }), | ||
| 162 | ...(input.preHostArgs ?? []), | ||
| 163 | hostSpec, | ||
| 164 | ...(input.remoteCommandArgs ?? []), | ||
| 165 | ]; | ||
| 166 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 167 | yield* Effect.logDebug("ssh.command.start", { | ||
| 168 | ...sshTargetLogFields(target), | ||
| 169 | command: ["ssh", ...args], | ||
| 170 | hasStdin: input.stdin !== undefined, | ||
| 171 | timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, | ||
| 172 | }); | ||
| 173 | const child = yield* spawner | ||
| 174 | .spawn( | ||
| 175 | ChildProcess.make("ssh", args, { | ||
| 176 | env: environment, | ||
| 177 | shell: process.platform === "win32", | ||
| 178 | stdin: { | ||
| 179 | stream: stdinStream(input.stdin), | ||
| 180 | endOnDone: true, | ||
| 181 | }, | ||
| 182 | }), | ||
| 183 | ) | ||
| 184 | .pipe( | ||
| 185 | Effect.provideService(Scope.Scope, commandScope), | ||
| 186 | Effect.mapError( | ||
| 187 | (cause) => | ||
| 188 | new SshCommandError({ | ||
| 189 | command: ["ssh", ...args], | ||
| 190 | exitCode: null, | ||
| 191 | stderr: "", | ||
| 192 | message: | ||
| 193 | cause instanceof Error | ||
| 194 | ? cause.message | ||
| 195 | : `Failed to spawn SSH command for ${hostSpec}.`, | ||
| 196 | cause, | ||
| 197 | }), | ||
| 198 | ), | ||
| 199 | ); | ||
| 200 | |||
| 201 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 202 | [ | ||
| 203 | collectProcessOutput(child.stdout), | ||
| 204 | collectProcessOutput(child.stderr), | ||
| 205 | child.exitCode.pipe(Effect.map(Number)), | ||
| 206 | ], | ||
| 207 | { concurrency: "unbounded" }, | ||
| 208 | ).pipe( | ||
| 209 | Effect.mapError( | ||
| 210 | (cause) => | ||
| 211 | new SshCommandError({ | ||
| 212 | command: ["ssh", ...args], | ||
| 213 | exitCode: null, | ||
| 214 | stderr: "", | ||
| 215 | message: | ||
| 216 | cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`, | ||
| 217 | cause, | ||
| 218 | }), | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | |||
| 222 | if (exitCode !== 0) { | ||
| 223 | yield* Effect.logWarning("ssh.command.failed", { | ||
| 224 | ...sshTargetLogFields(target), | ||
| 225 | command: ["ssh", ...args], | ||
| 226 | exitCode, | ||
| 227 | stderr, | ||
| 228 | }); | ||
| 229 | return yield* new SshCommandError({ | ||
| 230 | command: ["ssh", ...args], | ||
| 231 | exitCode, | ||
| 232 | stderr, | ||
| 233 | message: normalizeSshErrorMessage( | ||
| 234 | stderr, | ||
| 235 | `SSH command failed for ${hostSpec} (exit ${exitCode}).`, | ||
| 236 | ), | ||
| 237 | }); | ||
| 238 | } | ||
| 239 | |||
| 240 | yield* Effect.logDebug("ssh.command.succeeded", { | ||
| 241 | ...sshTargetLogFields(target), | ||
| 242 | command: ["ssh", ...args], | ||
| 243 | }); | ||
| 244 | return { stdout, stderr }; | ||
| 245 | }); | ||
| 246 | |||
| 247 | export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( | ||
| 248 | target: DesktopSshEnvironmentTarget, | ||
| 249 | input: RunSshCommandOptions = {}, | ||
| 250 | ): Effect.fn.Return< | ||
| 251 | SshCommandResult, | ||
| 252 | SshCommandError | SshInvalidTargetError, | ||
| 253 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 254 | > { | ||
| 255 | return yield* Effect.scopedWith((commandScope) => | ||
| 256 | runSshCommandInScope(target, input, commandScope), | ||
| 257 | ).pipe( | ||
| 258 | Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)), | ||
| 259 | Effect.flatMap((result) => | ||
| 260 | Option.match(result, { | ||
| 261 | onSome: Effect.succeed, | ||
| 262 | onNone: () => | ||
| 263 | Effect.gen(function* () { | ||
| 264 | yield* Effect.logWarning("ssh.command.timedOut", { | ||
| 265 | ...sshTargetLogFields(target), | ||
| 266 | timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, | ||
| 267 | remoteCommandArgs: input.remoteCommandArgs ?? [], | ||
| 268 | preHostArgs: input.preHostArgs ?? [], | ||
| 269 | hasStdin: input.stdin !== undefined, | ||
| 270 | }); | ||
| 271 | return yield* new SshCommandError({ | ||
| 272 | command: ["ssh"], | ||
| 273 | exitCode: null, | ||
| 274 | stderr: "", | ||
| 275 | message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`, | ||
| 276 | }); | ||
| 277 | }), | ||
| 278 | }), | ||
| 279 | ), | ||
| 280 | ); | ||
| 281 | }); | ||
| 282 | |||
| 283 | export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* ( | ||
| 284 | alias: string, | ||
| 285 | ): Effect.fn.Return< | ||
| 286 | DesktopSshEnvironmentTarget, | ||
| 287 | SshCommandError | SshInvalidTargetError, | ||
| 288 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 289 | > { | ||
| 290 | const trimmedAlias = alias.trim(); | ||
| 291 | if (trimmedAlias.length === 0) { | ||
| 292 | return yield* new SshInvalidTargetError({ message: "SSH host alias is required." }); | ||
| 293 | } | ||
| 294 | |||
| 295 | yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias }); | ||
| 296 | return yield* runSshCommand( | ||
| 297 | { | ||
| 298 | alias: trimmedAlias, | ||
| 299 | hostname: trimmedAlias, | ||
| 300 | username: null, | ||
| 301 | port: null, | ||
| 302 | }, | ||
| 303 | { preHostArgs: ["-G"] }, | ||
| 304 | ).pipe( | ||
| 305 | Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)), | ||
| 306 | Effect.tap((target) => | ||
| 307 | Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)), | ||
| 308 | ), | ||
| 309 | Effect.catch((cause) => | ||
| 310 | Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe( | ||
| 311 | Effect.as({ | ||
| 312 | alias: trimmedAlias, | ||
| 313 | hostname: trimmedAlias, | ||
| 314 | username: null, | ||
| 315 | port: null, | ||
| 316 | }), | ||
| 317 | ), | ||
| 318 | ), | ||
| 319 | ); | ||
| 320 | }); | ||
| 321 | |||
| 322 | export function resolveRemoteT3CliPackageSpec(input: { | ||
| 323 | readonly appVersion: string; | ||
| 324 | readonly updateChannel: DesktopUpdateChannel; | ||
| 325 | readonly isDevelopment?: boolean; | ||
| 326 | }): string { | ||
| 327 | const appVersion = input.appVersion.trim(); | ||
| 328 | if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { | ||
| 329 | return `t3@${appVersion}`; | ||
| 330 | } | ||
| 331 | |||
| 332 | if (input.isDevelopment) { | ||
| 333 | return "t3@nightly"; | ||
| 334 | } | ||
| 335 | |||
| 336 | return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; | ||
| 337 | } | ||
packages/ssh/src/config.test.ts created+136| ... | @@ -0,0 +1,136 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Path } from "effect"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | discoverSshHosts, | ||
| 7 | parseKnownHostsHostnames, | ||
| 8 | resolveSshConfigIncludePattern, | ||
| 9 | } from "./config.ts"; | ||
| 10 | |||
| 11 | function makeTempHomeDir() { | ||
| 12 | return Effect.gen(function* () { | ||
| 13 | const fs = yield* FileSystem.FileSystem; | ||
| 14 | return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-test-" }); | ||
| 15 | }); | ||
| 16 | } | ||
| 17 | |||
| 18 | describe("ssh config", () => { | ||
| 19 | it.effect("discovers ssh config hosts across included files", () => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const fs = yield* FileSystem.FileSystem; | ||
| 22 | const path = yield* Path.Path; | ||
| 23 | const homeDir = yield* makeTempHomeDir(); | ||
| 24 | const sshDir = path.join(homeDir, ".ssh"); | ||
| 25 | yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); | ||
| 26 | yield* fs.writeFileString( | ||
| 27 | path.join(sshDir, "config"), | ||
| 28 | [ | ||
| 29 | "Host devbox", | ||
| 30 | " HostName devbox.example.com", | ||
| 31 | "Host=equalsbox", | ||
| 32 | "Include=config.d/*.conf", | ||
| 33 | "", | ||
| 34 | ].join("\n"), | ||
| 35 | ); | ||
| 36 | yield* fs.writeFileString( | ||
| 37 | path.join(sshDir, "config.d", "team.conf"), | ||
| 38 | [ | ||
| 39 | "Host staging", | ||
| 40 | " HostName staging.example.com", | ||
| 41 | "Host *", | ||
| 42 | " ServerAliveInterval 30", | ||
| 43 | "", | ||
| 44 | ].join("\n"), | ||
| 45 | ); | ||
| 46 | yield* fs.writeFileString( | ||
| 47 | path.join(sshDir, "known_hosts"), | ||
| 48 | [ | ||
| 49 | "known.example.com ssh-ed25519 AAAA", | ||
| 50 | "|1|hashed|entry ssh-ed25519 AAAA", | ||
| 51 | "[bastion.example.com]:2222 ssh-ed25519 AAAA", | ||
| 52 | "", | ||
| 53 | ].join("\n"), | ||
| 54 | ); | ||
| 55 | |||
| 56 | const hosts = yield* discoverSshHosts({ homeDir }); | ||
| 57 | assert.deepEqual(hosts, [ | ||
| 58 | { | ||
| 59 | alias: "bastion.example.com", | ||
| 60 | hostname: "bastion.example.com", | ||
| 61 | username: null, | ||
| 62 | port: null, | ||
| 63 | source: "known-hosts", | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | alias: "devbox", | ||
| 67 | hostname: "devbox", | ||
| 68 | username: null, | ||
| 69 | port: null, | ||
| 70 | source: "ssh-config", | ||
| 71 | }, | ||
| 72 | { | ||
| 73 | alias: "equalsbox", | ||
| 74 | hostname: "equalsbox", | ||
| 75 | username: null, | ||
| 76 | port: null, | ||
| 77 | source: "ssh-config", | ||
| 78 | }, | ||
| 79 | { | ||
| 80 | alias: "known.example.com", | ||
| 81 | hostname: "known.example.com", | ||
| 82 | username: null, | ||
| 83 | port: null, | ||
| 84 | source: "known-hosts", | ||
| 85 | }, | ||
| 86 | { | ||
| 87 | alias: "staging", | ||
| 88 | hostname: "staging", | ||
| 89 | username: null, | ||
| 90 | port: null, | ||
| 91 | source: "ssh-config", | ||
| 92 | }, | ||
| 93 | ]); | ||
| 94 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 95 | ); | ||
| 96 | |||
| 97 | it.effect("parses known_hosts entries without returning hashed hosts", () => | ||
| 98 | Effect.sync(() => { | ||
| 99 | assert.deepEqual( | ||
| 100 | parseKnownHostsHostnames( | ||
| 101 | [ | ||
| 102 | "github.com ssh-ed25519 AAAA", | ||
| 103 | "gitlab.com,gitlab-alias ssh-ed25519 BBBB", | ||
| 104 | "|1|hashed|entry ssh-ed25519 CCCC", | ||
| 105 | "@cert-authority *.example.com ssh-ed25519 DDDD", | ||
| 106 | "[ssh.example.com]:2200 ssh-ed25519 EEEE", | ||
| 107 | "port.example.com:22 ssh-ed25519 HHHH", | ||
| 108 | "::1 ssh-ed25519 FFFF", | ||
| 109 | "2001:db8::1 ssh-ed25519 GGGG", | ||
| 110 | "", | ||
| 111 | ].join("\n"), | ||
| 112 | ), | ||
| 113 | [ | ||
| 114 | "::1", | ||
| 115 | "2001:db8::1", | ||
| 116 | "github.com", | ||
| 117 | "gitlab-alias", | ||
| 118 | "gitlab.com", | ||
| 119 | "port.example.com", | ||
| 120 | "ssh.example.com", | ||
| 121 | ], | ||
| 122 | ); | ||
| 123 | }), | ||
| 124 | ); | ||
| 125 | |||
| 126 | it.effect("expands tilde-prefixed ssh config include patterns", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const pattern = yield* resolveSshConfigIncludePattern( | ||
| 129 | "~/.ssh/config.d/*.conf", | ||
| 130 | "/tmp/project", | ||
| 131 | "/tmp/home", | ||
| 132 | ); | ||
| 133 | assert.equal(pattern, "/tmp/home/.ssh/config.d/*.conf"); | ||
| 134 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 135 | ); | ||
| 136 | }); | ||
packages/ssh/src/config.ts created+251| ... | @@ -0,0 +1,251 @@ | ||
| 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { Effect, FileSystem, Path, PlatformError } from "effect"; | ||
| 4 | |||
| 5 | import { SshHostDiscoveryError } from "./errors.ts"; | ||
| 6 | |||
| 7 | const NO_HOSTS: ReadonlyArray<string> = [] as const; | ||
| 8 | |||
| 9 | function stripInlineComment(line: string): string { | ||
| 10 | const hashIndex = line.indexOf("#"); | ||
| 11 | return (hashIndex >= 0 ? line.slice(0, hashIndex) : line).trim(); | ||
| 12 | } | ||
| 13 | |||
| 14 | function splitDirectiveArgs(value: string): ReadonlyArray<string> { | ||
| 15 | return value | ||
| 16 | .replace(/=(?!=)/gu, " ") | ||
| 17 | .trim() | ||
| 18 | .split(/\s+/u) | ||
| 19 | .map((entry) => entry.trim()) | ||
| 20 | .filter((entry) => entry.length > 0); | ||
| 21 | } | ||
| 22 | |||
| 23 | function expandHomePath(input: string, homeDir: string): string { | ||
| 24 | return input.replace(/^~(?=$|\/|\\)/u, homeDir); | ||
| 25 | } | ||
| 26 | |||
| 27 | export const resolveSshConfigIncludePattern = Effect.fnUntraced(function* ( | ||
| 28 | includePattern: string, | ||
| 29 | _directory: string, | ||
| 30 | homeDir: string, | ||
| 31 | ) { | ||
| 32 | const path = yield* Path.Path; | ||
| 33 | const expandedPattern = expandHomePath(includePattern, homeDir); | ||
| 34 | return path.isAbsolute(expandedPattern) | ||
| 35 | ? expandedPattern | ||
| 36 | : path.resolve(path.join(homeDir, ".ssh"), expandedPattern); | ||
| 37 | }); | ||
| 38 | |||
| 39 | function hasSshPattern(value: string): boolean { | ||
| 40 | return value.includes("*") || value.includes("?") || value.startsWith("!"); | ||
| 41 | } | ||
| 42 | |||
| 43 | function escapeRegex(value: string): string { | ||
| 44 | return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); | ||
| 45 | } | ||
| 46 | |||
| 47 | function globToRegExp(pattern: string): RegExp { | ||
| 48 | return new RegExp( | ||
| 49 | `^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`, | ||
| 50 | "u", | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | |||
| 54 | const expandGlob = Effect.fnUntraced(function* (pattern: string) { | ||
| 55 | const fs = yield* FileSystem.FileSystem; | ||
| 56 | const path = yield* Path.Path; | ||
| 57 | if (!pattern.includes("*") && !pattern.includes("?")) { | ||
| 58 | return (yield* fs.exists(pattern)) ? [pattern] : NO_HOSTS; | ||
| 59 | } | ||
| 60 | |||
| 61 | const directory = path.dirname(pattern); | ||
| 62 | const basePattern = path.basename(pattern); | ||
| 63 | if (!(yield* fs.exists(directory))) { | ||
| 64 | return NO_HOSTS; | ||
| 65 | } | ||
| 66 | |||
| 67 | const matcher = globToRegExp(basePattern); | ||
| 68 | const entries = yield* fs.readDirectory(directory); | ||
| 69 | const matchedPaths: string[] = []; | ||
| 70 | for (const entry of entries) { | ||
| 71 | if (!matcher.test(entry)) { | ||
| 72 | continue; | ||
| 73 | } | ||
| 74 | const entryPath = path.join(directory, entry); | ||
| 75 | if (yield* fs.exists(entryPath)) { | ||
| 76 | matchedPaths.push(entryPath); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | return matchedPaths.toSorted((left, right) => left.localeCompare(right)); | ||
| 80 | }); | ||
| 81 | |||
| 82 | export const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( | ||
| 83 | filePath: string, | ||
| 84 | visited = new Set<string>(), | ||
| 85 | homeDir: string, | ||
| 86 | ): Effect.fn.Return< | ||
| 87 | ReadonlyArray<string>, | ||
| 88 | PlatformError.PlatformError, | ||
| 89 | FileSystem.FileSystem | Path.Path | ||
| 90 | > { | ||
| 91 | const fs = yield* FileSystem.FileSystem; | ||
| 92 | const path = yield* Path.Path; | ||
| 93 | const resolvedPath = path.resolve(filePath); | ||
| 94 | if (visited.has(resolvedPath) || !(yield* fs.exists(resolvedPath))) { | ||
| 95 | return NO_HOSTS; | ||
| 96 | } | ||
| 97 | visited.add(resolvedPath); | ||
| 98 | |||
| 99 | const aliases = new Set<string>(); | ||
| 100 | const directory = path.dirname(resolvedPath); | ||
| 101 | const raw = yield* fs.readFileString(resolvedPath); | ||
| 102 | |||
| 103 | for (const line of raw.split(/\r?\n/u)) { | ||
| 104 | const stripped = stripInlineComment(line); | ||
| 105 | if (stripped.length === 0) { | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | |||
| 109 | const [directive = "", ...rawArgs] = splitDirectiveArgs(stripped); | ||
| 110 | const normalizedDirective = directive.toLowerCase(); | ||
| 111 | if (normalizedDirective === "include") { | ||
| 112 | for (const includePattern of rawArgs) { | ||
| 113 | const resolvedPattern = yield* resolveSshConfigIncludePattern( | ||
| 114 | includePattern, | ||
| 115 | directory, | ||
| 116 | homeDir, | ||
| 117 | ); | ||
| 118 | const includedPaths = yield* expandGlob(resolvedPattern); | ||
| 119 | for (const includedPath of includedPaths) { | ||
| 120 | const includedAliases = yield* collectSshConfigAliasesFromFile( | ||
| 121 | includedPath, | ||
| 122 | visited, | ||
| 123 | homeDir, | ||
| 124 | ); | ||
| 125 | for (const alias of includedAliases) { | ||
| 126 | aliases.add(alias); | ||
| 127 | } | ||
| 128 | } | ||
| 129 | } | ||
| 130 | continue; | ||
| 131 | } | ||
| 132 | |||
| 133 | if (normalizedDirective !== "host") { | ||
| 134 | continue; | ||
| 135 | } | ||
| 136 | |||
| 137 | for (const alias of rawArgs) { | ||
| 138 | if (alias.length === 0 || hasSshPattern(alias)) { | ||
| 139 | continue; | ||
| 140 | } | ||
| 141 | aliases.add(alias); | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | return [...aliases].toSorted((left, right) => left.localeCompare(right)); | ||
| 146 | }); | ||
| 147 | |||
| 148 | function normalizeKnownHostsHostname(rawHost: string): string { | ||
| 149 | const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost); | ||
| 150 | if (bracketMatch?.[1]) { | ||
| 151 | return bracketMatch[1]; | ||
| 152 | } | ||
| 153 | |||
| 154 | if (!rawHost.includes(":")) { | ||
| 155 | return rawHost; | ||
| 156 | } | ||
| 157 | |||
| 158 | const firstColonIndex = rawHost.indexOf(":"); | ||
| 159 | const lastColonIndex = rawHost.lastIndexOf(":"); | ||
| 160 | return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost; | ||
| 161 | } | ||
| 162 | |||
| 163 | export function parseKnownHostsHostnames(raw: string): ReadonlyArray<string> { | ||
| 164 | const hostnames = new Set<string>(); | ||
| 165 | |||
| 166 | for (const line of raw.split(/\r?\n/u)) { | ||
| 167 | const trimmed = line.trim(); | ||
| 168 | if (trimmed.length === 0 || trimmed.startsWith("#")) { | ||
| 169 | continue; | ||
| 170 | } | ||
| 171 | |||
| 172 | const withoutMarker = trimmed.startsWith("@") | ||
| 173 | ? trimmed.split(/\s+/u).slice(1).join(" ") | ||
| 174 | : trimmed; | ||
| 175 | const [hostField = ""] = withoutMarker.split(/\s+/u); | ||
| 176 | if (hostField.length === 0 || hostField.startsWith("|")) { | ||
| 177 | continue; | ||
| 178 | } | ||
| 179 | |||
| 180 | for (const rawHost of hostField.split(",")) { | ||
| 181 | const host = normalizeKnownHostsHostname(rawHost).trim(); | ||
| 182 | if (host.length === 0 || hasSshPattern(host)) { | ||
| 183 | continue; | ||
| 184 | } | ||
| 185 | hostnames.add(host); | ||
| 186 | } | ||
| 187 | } | ||
| 188 | |||
| 189 | return [...hostnames].toSorted((left, right) => left.localeCompare(right)); | ||
| 190 | } | ||
| 191 | |||
| 192 | const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { | ||
| 193 | const fs = yield* FileSystem.FileSystem; | ||
| 194 | if (!(yield* fs.exists(filePath))) { | ||
| 195 | return NO_HOSTS; | ||
| 196 | } | ||
| 197 | return parseKnownHostsHostnames(yield* fs.readFileString(filePath)); | ||
| 198 | }); | ||
| 199 | |||
| 200 | export const discoverSshHosts = Effect.fnUntraced( | ||
| 201 | function* (input: { readonly homeDir?: string }) { | ||
| 202 | const path = yield* Path.Path; | ||
| 203 | const homeDir = input?.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? ""; | ||
| 204 | if (homeDir.trim().length === 0) { | ||
| 205 | return []; | ||
| 206 | } | ||
| 207 | |||
| 208 | const sshDirectory = path.join(homeDir, ".ssh"); | ||
| 209 | const configAliases = yield* collectSshConfigAliasesFromFile( | ||
| 210 | path.join(sshDirectory, "config"), | ||
| 211 | new Set<string>(), | ||
| 212 | homeDir, | ||
| 213 | ); | ||
| 214 | const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts")); | ||
| 215 | const discovered = new Map<string, DesktopDiscoveredSshHost>(); | ||
| 216 | |||
| 217 | for (const alias of configAliases) { | ||
| 218 | discovered.set(alias, { | ||
| 219 | alias, | ||
| 220 | hostname: alias, | ||
| 221 | username: null, | ||
| 222 | port: null, | ||
| 223 | source: "ssh-config", | ||
| 224 | }); | ||
| 225 | } | ||
| 226 | |||
| 227 | for (const hostname of knownHosts) { | ||
| 228 | if (discovered.has(hostname)) { | ||
| 229 | continue; | ||
| 230 | } | ||
| 231 | discovered.set(hostname, { | ||
| 232 | alias: hostname, | ||
| 233 | hostname, | ||
| 234 | username: null, | ||
| 235 | port: null, | ||
| 236 | source: "known-hosts", | ||
| 237 | }); | ||
| 238 | } | ||
| 239 | |||
| 240 | return [...discovered.values()].toSorted((left, right) => | ||
| 241 | left.alias.localeCompare(right.alias), | ||
| 242 | ); | ||
| 243 | }, | ||
| 244 | Effect.mapError( | ||
| 245 | (cause) => | ||
| 246 | new SshHostDiscoveryError({ | ||
| 247 | message: "Failed to discover SSH hosts.", | ||
| 248 | cause, | ||
| 249 | }), | ||
| 250 | ), | ||
| 251 | ); | ||
packages/ssh/src/errors.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { Data } from "effect"; | ||
| 2 | |||
| 3 | export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{ | ||
| 4 | readonly message: string; | ||
| 5 | readonly cause: unknown; | ||
| 6 | }> {} | ||
| 7 | |||
| 8 | export class SshInvalidTargetError extends Data.TaggedError("SshInvalidTargetError")<{ | ||
| 9 | readonly message: string; | ||
| 10 | }> {} | ||
| 11 | |||
| 12 | export class SshCommandError extends Data.TaggedError("SshCommandError")<{ | ||
| 13 | readonly message: string; | ||
| 14 | readonly command: readonly string[]; | ||
| 15 | readonly exitCode: number | null; | ||
| 16 | readonly stderr: string; | ||
| 17 | readonly cause?: unknown; | ||
| 18 | }> {} | ||
| 19 | |||
| 20 | export class SshLaunchError extends Data.TaggedError("SshLaunchError")<{ | ||
| 21 | readonly message: string; | ||
| 22 | readonly stdout: string; | ||
| 23 | readonly cause?: unknown; | ||
| 24 | }> {} | ||
| 25 | |||
| 26 | export class SshPairingError extends Data.TaggedError("SshPairingError")<{ | ||
| 27 | readonly message: string; | ||
| 28 | readonly stdout: string; | ||
| 29 | readonly cause?: unknown; | ||
| 30 | }> {} | ||
| 31 | |||
| 32 | export class SshHttpBridgeError extends Data.TaggedError("SshHttpBridgeError")<{ | ||
| 33 | readonly message: string; | ||
| 34 | readonly status?: number; | ||
| 35 | readonly cause?: unknown; | ||
| 36 | }> {} | ||
| 37 | |||
| 38 | export class SshReadinessError extends Data.TaggedError("SshReadinessError")<{ | ||
| 39 | readonly message: string; | ||
| 40 | readonly cause?: unknown; | ||
| 41 | }> {} | ||
| 42 | |||
| 43 | export class SshPasswordPromptError extends Data.TaggedError("SshPasswordPromptError")<{ | ||
| 44 | readonly message: string; | ||
| 45 | readonly cause?: unknown; | ||
| 46 | }> {} | ||
packages/ssh/src/tunnel.test.ts created+294| ... | @@ -0,0 +1,294 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { NetService } from "@t3tools/shared/Net"; | ||
| 4 | import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; | ||
| 5 | import { TestClock } from "effect/testing"; | ||
| 6 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 7 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 8 | |||
| 9 | import { SshPasswordPrompt } from "./auth.ts"; | ||
| 10 | import { | ||
| 11 | buildRemoteLaunchScript, | ||
| 12 | buildRemotePairingScript, | ||
| 13 | buildRemoteStopScript, | ||
| 14 | buildRemoteT3RunnerScript, | ||
| 15 | describeReadinessCause, | ||
| 16 | issueRemotePairingToken, | ||
| 17 | REMOTE_PICK_PORT_SCRIPT, | ||
| 18 | SshEnvironmentManager, | ||
| 19 | waitForHttpReady, | ||
| 20 | } from "./tunnel.ts"; | ||
| 21 | |||
| 22 | const makeSuccessfulProcess = (stdout: string) => { | ||
| 23 | const stdoutStream = Stream.make(new TextEncoder().encode(stdout)); | ||
| 24 | return ChildProcessSpawner.makeHandle({ | ||
| 25 | pid: ChildProcessSpawner.ProcessId(123), | ||
| 26 | stdout: stdoutStream, | ||
| 27 | stderr: Stream.empty, | ||
| 28 | all: stdoutStream, | ||
| 29 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), | ||
| 30 | isRunning: Effect.succeed(false), | ||
| 31 | kill: () => Effect.void, | ||
| 32 | stdin: Sink.drain, | ||
| 33 | getInputFd: () => Sink.drain, | ||
| 34 | getOutputFd: () => Stream.empty, | ||
| 35 | unref: Effect.succeed(Effect.void), | ||
| 36 | }); | ||
| 37 | }; | ||
| 38 | |||
| 39 | const makeRunningProcess = (onKill: () => void) => { | ||
| 40 | let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; | ||
| 41 | return ChildProcessSpawner.makeHandle({ | ||
| 42 | pid: ChildProcessSpawner.ProcessId(123), | ||
| 43 | stdout: Stream.empty, | ||
| 44 | stderr: Stream.empty, | ||
| 45 | all: Stream.empty, | ||
| 46 | exitCode: Effect.callback<ChildProcessSpawner.ExitCode>((resume) => { | ||
| 47 | finish = (exitCode) => resume(Effect.succeed(exitCode)); | ||
| 48 | return Effect.sync(() => { | ||
| 49 | finish = null; | ||
| 50 | }); | ||
| 51 | }), | ||
| 52 | isRunning: Effect.succeed(true), | ||
| 53 | kill: () => | ||
| 54 | Effect.sync(() => { | ||
| 55 | onKill(); | ||
| 56 | finish?.(ChildProcessSpawner.ExitCode(143)); | ||
| 57 | }), | ||
| 58 | stdin: Sink.drain, | ||
| 59 | getInputFd: () => Sink.drain, | ||
| 60 | getOutputFd: () => Stream.empty, | ||
| 61 | unref: Effect.succeed(Effect.void), | ||
| 62 | }); | ||
| 63 | }; | ||
| 64 | |||
| 65 | const testHttpClient = HttpClient.make((request) => | ||
| 66 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))), | ||
| 67 | ); | ||
| 68 | |||
| 69 | const hangingHttpClient = HttpClient.make(() => Effect.never); | ||
| 70 | |||
| 71 | const testNetService = NetService.of({ | ||
| 72 | canListenOnHost: () => Effect.succeed(true), | ||
| 73 | isPortAvailableOnLoopback: () => Effect.succeed(true), | ||
| 74 | reserveLoopbackPort: () => Effect.succeed(41_773), | ||
| 75 | findAvailablePort: (preferred) => Effect.succeed(preferred), | ||
| 76 | }); | ||
| 77 | |||
| 78 | function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> { | ||
| 79 | return command._tag === "StandardCommand" ? command.args : []; | ||
| 80 | } | ||
| 81 | |||
| 82 | describe("ssh tunnel scripts", () => { | ||
| 83 | it("builds the remote t3 runner with npx and npm fallbacks", () => { | ||
| 84 | const script = buildRemoteT3RunnerScript(); | ||
| 85 | |||
| 86 | assert.include(script, "T3_NODE_SCRIPT_PATH=''"); | ||
| 87 | assert.include(script, 'exec t3 "$@"'); | ||
| 88 | assert.include(script, "exec npx --yes 't3@latest' \"$@\""); | ||
| 89 | assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\""); | ||
| 90 | assert.include(script, "could not install 't3@latest'"); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("shell-quotes package specs in the remote t3 runner", () => { | ||
| 94 | const script = buildRemoteT3RunnerScript({ | ||
| 95 | packageSpec: "t3@nightly; touch /tmp/t3-owned", | ||
| 96 | }); | ||
| 97 | |||
| 98 | assert.include(script, "exec npx --yes 't3@nightly; touch /tmp/t3-owned' \"$@\""); | ||
| 99 | assert.include(script, "exec npm exec --yes 't3@nightly; touch /tmp/t3-owned' -- \"$@\""); | ||
| 100 | assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned"); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("builds the remote t3 runner with a node script override", () => { | ||
| 104 | const script = buildRemoteT3RunnerScript({ | ||
| 105 | nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", | ||
| 106 | }); | ||
| 107 | |||
| 108 | assert.include( | ||
| 109 | script, | ||
| 110 | "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'", | ||
| 111 | ); | ||
| 112 | assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"'); | ||
| 113 | }); | ||
| 114 | |||
| 115 | it("uses the remote t3 runner for launch and pairing scripts", () => { | ||
| 116 | const target = { | ||
| 117 | alias: "devbox", | ||
| 118 | hostname: "devbox.example.com", | ||
| 119 | username: "julius", | ||
| 120 | port: 2222, | ||
| 121 | } as const; | ||
| 122 | |||
| 123 | assert.include( | ||
| 124 | buildRemoteLaunchScript(), | ||
| 125 | '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', | ||
| 126 | ); | ||
| 127 | assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1"); | ||
| 128 | assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); | ||
| 129 | assert.include(buildRemoteLaunchScript(), "wait_ready"); | ||
| 130 | assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); | ||
| 131 | assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); | ||
| 132 | assert.notInclude(buildRemoteLaunchScript(), "server-home"); | ||
| 133 | assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); | ||
| 134 | assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); | ||
| 135 | assert.include( | ||
| 136 | buildRemotePairingScript(target), | ||
| 137 | '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', | ||
| 138 | ); | ||
| 139 | assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"'); | ||
| 140 | assert.notInclude(buildRemotePairingScript(target), "server-home"); | ||
| 141 | assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); | ||
| 142 | assert.include( | ||
| 143 | buildRemoteStopScript(target), | ||
| 144 | 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', | ||
| 145 | ); | ||
| 146 | assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); | ||
| 147 | assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); | ||
| 148 | assert.include( | ||
| 149 | buildRemoteLaunchScript(), | ||
| 150 | 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', | ||
| 151 | ); | ||
| 152 | assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()"); | ||
| 153 | assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); | ||
| 154 | assert.isBelow( | ||
| 155 | buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), | ||
| 156 | buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), | ||
| 157 | ); | ||
| 158 | assert.isBelow( | ||
| 159 | buildRemoteLaunchScript().indexOf('DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port'), | ||
| 160 | buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), | ||
| 161 | ); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("allows the remote port picker to run without a state file path", () => { | ||
| 165 | assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); | ||
| 166 | }); | ||
| 167 | |||
| 168 | it.effect("bounds each HTTP readiness probe so retries cannot hang on one request", () => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const fiber = yield* Effect.forkChild( | ||
| 171 | Effect.result( | ||
| 172 | waitForHttpReady({ | ||
| 173 | baseUrl: "http://127.0.0.1:41773/", | ||
| 174 | timeoutMs: 1_000, | ||
| 175 | intervalMs: 100, | ||
| 176 | probeTimeoutMs: 250, | ||
| 177 | }), | ||
| 178 | ), | ||
| 179 | ); | ||
| 180 | yield* Effect.yieldNow; | ||
| 181 | yield* TestClock.adjust(Duration.millis(1_000)); | ||
| 182 | |||
| 183 | const result = yield* Fiber.join(fiber); | ||
| 184 | |||
| 185 | assert.isTrue(Result.isFailure(result)); | ||
| 186 | if (Result.isFailure(result)) { | ||
| 187 | assert.include(result.failure.message, "Timed out waiting 1000ms"); | ||
| 188 | } | ||
| 189 | }).pipe( | ||
| 190 | Effect.provide( | ||
| 191 | Layer.merge(TestClock.layer(), Layer.succeed(HttpClient.HttpClient, hangingHttpClient)), | ||
| 192 | ), | ||
| 193 | ), | ||
| 194 | ); | ||
| 195 | |||
| 196 | it("preserves primitive readiness reason values in diagnostic output", () => { | ||
| 197 | assert.deepEqual( | ||
| 198 | describeReadinessCause({ | ||
| 199 | _tag: "HttpClientError", | ||
| 200 | message: "Backend readiness probe failed.", | ||
| 201 | reason: "authentication failed", | ||
| 202 | cause: "upstream closed", | ||
| 203 | }), | ||
| 204 | { | ||
| 205 | _tag: "HttpClientError", | ||
| 206 | message: "Backend readiness probe failed.", | ||
| 207 | reason: "authentication failed", | ||
| 208 | cause: "upstream closed", | ||
| 209 | }, | ||
| 210 | ); | ||
| 211 | }); | ||
| 212 | |||
| 213 | it.effect("accepts pretty-printed pairing JSON from the remote CLI", () => { | ||
| 214 | const target = { | ||
| 215 | alias: "devbox", | ||
| 216 | hostname: "devbox.example.com", | ||
| 217 | username: "julius", | ||
| 218 | port: 2222, | ||
| 219 | } as const; | ||
| 220 | const spawner = ChildProcessSpawner.make(() => | ||
| 221 | Effect.succeed( | ||
| 222 | makeSuccessfulProcess(`{ | ||
| 223 | "id": "88941235-6ed5-4184-a2ff-5339e2075958", | ||
| 224 | "credential": "LCL4R2TPHDKQ", | ||
| 225 | "role": "client", | ||
| 226 | "expiresAt": "2026-04-29T01:01:20.994Z" | ||
| 227 | } | ||
| 228 | |||
| 229 | `), | ||
| 230 | ), | ||
| 231 | ); | ||
| 232 | const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); | ||
| 233 | const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); | ||
| 234 | return Effect.gen(function* () { | ||
| 235 | const result = yield* issueRemotePairingToken(target); | ||
| 236 | assert.equal(result.credential, "LCL4R2TPHDKQ"); | ||
| 237 | }).pipe(Effect.provide(processLayer)); | ||
| 238 | }); | ||
| 239 | |||
| 240 | it.effect("closes the tunnel scope and starts fresh after disconnect", () => { | ||
| 241 | const spawnedCommands: Array<ReadonlyArray<string>> = []; | ||
| 242 | let tunnelKillCount = 0; | ||
| 243 | let stopCommandCount = 0; | ||
| 244 | const spawner = ChildProcessSpawner.make((command) => | ||
| 245 | Effect.sync(() => { | ||
| 246 | const args = commandArgs(command); | ||
| 247 | spawnedCommands.push(args); | ||
| 248 | if (args.includes("-N")) { | ||
| 249 | return makeRunningProcess(() => { | ||
| 250 | tunnelKillCount += 1; | ||
| 251 | }); | ||
| 252 | } | ||
| 253 | if (args.includes("sh") && args.includes("--")) { | ||
| 254 | return makeSuccessfulProcess('{"remotePort":3773}\n'); | ||
| 255 | } | ||
| 256 | if (args.includes("sh")) { | ||
| 257 | stopCommandCount += 1; | ||
| 258 | return makeSuccessfulProcess('{"stopped":true}\n'); | ||
| 259 | } | ||
| 260 | return makeSuccessfulProcess("\n"); | ||
| 261 | }), | ||
| 262 | ); | ||
| 263 | const layer = Layer.mergeAll( | ||
| 264 | NodeServices.layer, | ||
| 265 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 266 | Layer.succeed(HttpClient.HttpClient, testHttpClient), | ||
| 267 | Layer.succeed(NetService, testNetService), | ||
| 268 | SshPasswordPrompt.disabledLayer, | ||
| 269 | SshEnvironmentManager.layer(), | ||
| 270 | ); | ||
| 271 | const target = { | ||
| 272 | alias: "devbox", | ||
| 273 | hostname: "devbox.example.com", | ||
| 274 | username: "julius", | ||
| 275 | port: 2222, | ||
| 276 | } as const; | ||
| 277 | |||
| 278 | return Effect.gen(function* () { | ||
| 279 | const manager = yield* SshEnvironmentManager; | ||
| 280 | |||
| 281 | const first = yield* manager.ensureEnvironment(target); | ||
| 282 | assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/"); | ||
| 283 | |||
| 284 | yield* manager.disconnectEnvironment(target); | ||
| 285 | assert.equal(tunnelKillCount, 1); | ||
| 286 | assert.equal(stopCommandCount, 1); | ||
| 287 | |||
| 288 | yield* manager.ensureEnvironment(target); | ||
| 289 | |||
| 290 | assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2); | ||
| 291 | assert.equal(tunnelKillCount, 1); | ||
| 292 | }).pipe(Effect.provide(layer), Effect.scoped); | ||
| 293 | }); | ||
| 294 | }); | ||
packages/ssh/src/tunnel.ts created+1584| ... | @@ -0,0 +1,1584 @@ | ||
| 1 | import type { | ||
| 2 | DesktopSshEnvironmentBootstrap, | ||
| 3 | DesktopSshEnvironmentTarget, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 5 | import { type NetError, NetService } from "@t3tools/shared/Net"; | ||
| 6 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | ||
| 7 | import { | ||
| 8 | Deferred, | ||
| 9 | Context, | ||
| 10 | Duration, | ||
| 11 | Effect, | ||
| 12 | Exit, | ||
| 13 | FileSystem, | ||
| 14 | Layer, | ||
| 15 | Option, | ||
| 16 | Path, | ||
| 17 | Ref, | ||
| 18 | Schema, | ||
| 19 | Scope, | ||
| 20 | Schedule, | ||
| 21 | Stream, | ||
| 22 | } from "effect"; | ||
| 23 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 24 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 25 | |||
| 26 | import { | ||
| 27 | buildSshChildEnvironment, | ||
| 28 | type SshAuthOptions, | ||
| 29 | SshPasswordPrompt, | ||
| 30 | isSshAuthFailure, | ||
| 31 | } from "./auth.ts"; | ||
| 32 | import { | ||
| 33 | baseSshArgs, | ||
| 34 | buildSshHostSpecEffect, | ||
| 35 | collectProcessOutput, | ||
| 36 | getLastNonEmptyOutputLine, | ||
| 37 | remoteStateKey, | ||
| 38 | resolveSshTarget, | ||
| 39 | runSshCommand, | ||
| 40 | targetConnectionKey, | ||
| 41 | } from "./command.ts"; | ||
| 42 | import { | ||
| 43 | SshCommandError, | ||
| 44 | SshHttpBridgeError, | ||
| 45 | SshInvalidTargetError, | ||
| 46 | SshLaunchError, | ||
| 47 | SshPairingError, | ||
| 48 | SshPasswordPromptError, | ||
| 49 | SshReadinessError, | ||
| 50 | } from "./errors.ts"; | ||
| 51 | |||
| 52 | export const DEFAULT_REMOTE_PORT = 3773; | ||
| 53 | const REMOTE_PORT_SCAN_WINDOW = 200; | ||
| 54 | const SSH_READY_TIMEOUT_MS = 20_000; | ||
| 55 | const SSH_READY_PROBE_TIMEOUT_MS = 1_000; | ||
| 56 | const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; | ||
| 57 | const REMOTE_READY_TIMEOUT_MS = 15_000; | ||
| 58 | const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; | ||
| 59 | |||
| 60 | export interface RemoteT3RunnerOptions { | ||
| 61 | readonly packageSpec?: string; | ||
| 62 | readonly nodeScriptPath?: string | null; | ||
| 63 | } | ||
| 64 | |||
| 65 | export interface SshEnvironmentManagerOptions { | ||
| 66 | readonly resolveCliPackageSpec?: () => string; | ||
| 67 | readonly resolveCliRunner?: () => RemoteT3RunnerOptions; | ||
| 68 | } | ||
| 69 | |||
| 70 | interface SshTunnelEntry { | ||
| 71 | readonly key: string; | ||
| 72 | readonly target: DesktopSshEnvironmentTarget; | ||
| 73 | readonly remotePort: number; | ||
| 74 | readonly remoteServerKind: "external" | "managed" | null; | ||
| 75 | readonly localPort: number; | ||
| 76 | readonly httpBaseUrl: string; | ||
| 77 | readonly wsBaseUrl: string; | ||
| 78 | readonly process: ChildProcessSpawner.ChildProcessHandle; | ||
| 79 | readonly scope: Scope.Scope; | ||
| 80 | } | ||
| 81 | |||
| 82 | type SshEnvironmentEffectContext = | ||
| 83 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 84 | | FileSystem.FileSystem | ||
| 85 | | Path.Path | ||
| 86 | | HttpClient.HttpClient | ||
| 87 | | NetService | ||
| 88 | | SshPasswordPrompt; | ||
| 89 | |||
| 90 | type SshEnvironmentEffectError = | ||
| 91 | | SshCommandError | ||
| 92 | | SshInvalidTargetError | ||
| 93 | | SshLaunchError | ||
| 94 | | SshPairingError | ||
| 95 | | SshReadinessError | ||
| 96 | | SshPasswordPromptError | ||
| 97 | | NetError; | ||
| 98 | |||
| 99 | function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError { | ||
| 100 | return new SshCommandError({ | ||
| 101 | command: ["ssh"], | ||
| 102 | exitCode: null, | ||
| 103 | stderr: "", | ||
| 104 | message: `SSH environment connection was cancelled for ${target.alias || target.hostname}.`, | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | |||
| 108 | function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { | ||
| 109 | return { | ||
| 110 | alias: target.alias, | ||
| 111 | hostname: target.hostname, | ||
| 112 | username: target.username, | ||
| 113 | port: target.port, | ||
| 114 | }; | ||
| 115 | } | ||
| 116 | |||
| 117 | function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { | ||
| 118 | if (runner?.nodeScriptPath?.trim()) { | ||
| 119 | return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; | ||
| 120 | } | ||
| 121 | if (runner?.packageSpec?.trim()) { | ||
| 122 | return { runner: "package", packageSpec: runner.packageSpec.trim() }; | ||
| 123 | } | ||
| 124 | return { runner: "default" }; | ||
| 125 | } | ||
| 126 | |||
| 127 | interface SshAuthOperationInput<T> { | ||
| 128 | readonly key: string; | ||
| 129 | readonly target: DesktopSshEnvironmentTarget; | ||
| 130 | readonly operation: ( | ||
| 131 | authOptions: SshAuthOptions, | ||
| 132 | ) => Effect.Effect<T, SshEnvironmentEffectError, SshEnvironmentEffectContext>; | ||
| 133 | } | ||
| 134 | |||
| 135 | interface SshAuthAttemptInput<T> extends SshAuthOperationInput<T> { | ||
| 136 | readonly promptCount: number; | ||
| 137 | readonly authSecret: string | null; | ||
| 138 | } | ||
| 139 | |||
| 140 | export interface SshEnvironmentManagerShape { | ||
| 141 | readonly ensureEnvironment: ( | ||
| 142 | target: DesktopSshEnvironmentTarget, | ||
| 143 | options?: { readonly issuePairingToken?: boolean }, | ||
| 144 | ) => Effect.Effect< | ||
| 145 | DesktopSshEnvironmentBootstrap, | ||
| 146 | SshEnvironmentEffectError, | ||
| 147 | SshEnvironmentEffectContext | ||
| 148 | >; | ||
| 149 | readonly disconnectEnvironment: ( | ||
| 150 | target: DesktopSshEnvironmentTarget, | ||
| 151 | ) => Effect.Effect<void, SshEnvironmentEffectError, SshEnvironmentEffectContext>; | ||
| 152 | } | ||
| 153 | |||
| 154 | const RemoteLaunchResult = Schema.Struct({ | ||
| 155 | remotePort: Schema.Number, | ||
| 156 | serverKind: Schema.optional(Schema.Literals(["external", "managed"])), | ||
| 157 | }); | ||
| 158 | |||
| 159 | const RemotePairingResult = Schema.Struct({ | ||
| 160 | credential: Schema.String, | ||
| 161 | }); | ||
| 162 | |||
| 163 | const RemoteHttpError = Schema.Struct({ | ||
| 164 | error: Schema.optional(Schema.String), | ||
| 165 | }); | ||
| 166 | |||
| 167 | const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult)); | ||
| 168 | const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult)); | ||
| 169 | const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError)); | ||
| 170 | |||
| 171 | export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { | ||
| 172 | const cleaned = stderr.trim(); | ||
| 173 | return cleaned.length > 0 ? cleaned : fallbackMessage; | ||
| 174 | } | ||
| 175 | |||
| 176 | function stripTrailingNewlines(value: string): string { | ||
| 177 | return value.replace(/\n+$/u, ""); | ||
| 178 | } | ||
| 179 | |||
| 180 | function shellSingleQuote(value: string): string { | ||
| 181 | return `'${value.replaceAll("'", "'\\''")}'`; | ||
| 182 | } | ||
| 183 | |||
| 184 | function applyScriptPlaceholders( | ||
| 185 | template: string, | ||
| 186 | replacements: Readonly<Record<string, string>>, | ||
| 187 | ): string { | ||
| 188 | let result = template; | ||
| 189 | for (const [token, value] of Object.entries(replacements)) { | ||
| 190 | result = result.replaceAll(`@@${token}@@`, value); | ||
| 191 | } | ||
| 192 | return result; | ||
| 193 | } | ||
| 194 | |||
| 195 | export function describeReadinessCause(cause: unknown): unknown { | ||
| 196 | if (cause instanceof SshReadinessError) { | ||
| 197 | return { | ||
| 198 | _tag: cause._tag, | ||
| 199 | message: cause.message, | ||
| 200 | ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), | ||
| 201 | }; | ||
| 202 | } | ||
| 203 | if (cause instanceof Error) { | ||
| 204 | return { | ||
| 205 | name: cause.name, | ||
| 206 | message: cause.message, | ||
| 207 | ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), | ||
| 208 | }; | ||
| 209 | } | ||
| 210 | if (typeof cause !== "object" || cause === null) { | ||
| 211 | return cause; | ||
| 212 | } | ||
| 213 | |||
| 214 | const record = cause as Readonly<Record<string, unknown>>; | ||
| 215 | return { | ||
| 216 | ...(typeof record._tag === "string" ? { _tag: record._tag } : {}), | ||
| 217 | ...(typeof record.message === "string" ? { message: record.message } : {}), | ||
| 218 | ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }), | ||
| 219 | ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }), | ||
| 220 | }; | ||
| 221 | } | ||
| 222 | |||
| 223 | export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs"); | ||
| 224 | const net = require("node:net"); | ||
| 225 | const filePath = process.argv[2] ?? ""; | ||
| 226 | const defaultPort = Number.parseInt(process.argv[3] ?? "", 10); | ||
| 227 | const scanWindow = Number.parseInt(process.argv[4] ?? "", 10); | ||
| 228 | const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : ""; | ||
| 229 | const preferred = Number.parseInt(raw, 10); | ||
| 230 | const start = Number.isInteger(preferred) ? preferred : defaultPort; | ||
| 231 | const end = start + scanWindow; | ||
| 232 | |||
| 233 | function tryPort(port) { | ||
| 234 | return new Promise((resolve) => { | ||
| 235 | const server = net.createServer(); | ||
| 236 | server.unref(); | ||
| 237 | server.once("error", () => resolve(false)); | ||
| 238 | server.listen(port, "127.0.0.1", () => { | ||
| 239 | server.close((error) => resolve(error ? false : port)); | ||
| 240 | }); | ||
| 241 | }); | ||
| 242 | } | ||
| 243 | |||
| 244 | (async () => { | ||
| 245 | for (let port = start; port < end; port += 1) { | ||
| 246 | const available = await tryPort(port); | ||
| 247 | if (available) { | ||
| 248 | process.stdout.write(String(port)); | ||
| 249 | return; | ||
| 250 | } | ||
| 251 | } | ||
| 252 | process.exit(1); | ||
| 253 | })().catch(() => process.exit(1)); | ||
| 254 | `; | ||
| 255 | |||
| 256 | export const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http"); | ||
| 257 | const port = Number.parseInt(process.argv[2] ?? "", 10); | ||
| 258 | const timeoutMs = Number.parseInt(process.argv[3] ?? "", 10); | ||
| 259 | const probeTimeoutMs = Number.parseInt(process.argv[4] ?? "", 10); | ||
| 260 | if (!Number.isInteger(port) || !Number.isInteger(timeoutMs) || !Number.isInteger(probeTimeoutMs)) { | ||
| 261 | process.exit(1); | ||
| 262 | } | ||
| 263 | const deadline = Date.now() + timeoutMs; | ||
| 264 | |||
| 265 | function sleep(ms) { | ||
| 266 | return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 267 | } | ||
| 268 | |||
| 269 | function probe() { | ||
| 270 | return new Promise((resolve) => { | ||
| 271 | const request = http.get( | ||
| 272 | { | ||
| 273 | hostname: "127.0.0.1", | ||
| 274 | port, | ||
| 275 | path: "/", | ||
| 276 | timeout: probeTimeoutMs, | ||
| 277 | }, | ||
| 278 | (response) => { | ||
| 279 | response.resume(); | ||
| 280 | response.once("end", () => { | ||
| 281 | resolve(response.statusCode >= 200 && response.statusCode < 300); | ||
| 282 | }); | ||
| 283 | }, | ||
| 284 | ); | ||
| 285 | request.once("timeout", () => { | ||
| 286 | request.destroy(); | ||
| 287 | resolve(false); | ||
| 288 | }); | ||
| 289 | request.once("error", () => resolve(false)); | ||
| 290 | }); | ||
| 291 | } | ||
| 292 | |||
| 293 | (async () => { | ||
| 294 | while (Date.now() < deadline) { | ||
| 295 | if (await probe()) { | ||
| 296 | process.exit(0); | ||
| 297 | } | ||
| 298 | await sleep(100); | ||
| 299 | } | ||
| 300 | process.exit(1); | ||
| 301 | })().catch(() => process.exit(1)); | ||
| 302 | `; | ||
| 303 | |||
| 304 | export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh | ||
| 305 | set -eu | ||
| 306 | T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ | ||
| 307 | if [ -n "$T3_NODE_SCRIPT_PATH" ]; then | ||
| 308 | exec node "$T3_NODE_SCRIPT_PATH" "$@" | ||
| 309 | fi | ||
| 310 | if command -v t3 >/dev/null 2>&1; then | ||
| 311 | exec t3 "$@" | ||
| 312 | fi | ||
| 313 | if command -v npx >/dev/null 2>&1; then | ||
| 314 | exec npx --yes @@T3_PACKAGE_SPEC@@ "$@" | ||
| 315 | fi | ||
| 316 | if command -v npm >/dev/null 2>&1; then | ||
| 317 | exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" | ||
| 318 | fi | ||
| 319 | printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because npx and npm are unavailable on PATH.\\n' >&2 | ||
| 320 | exit 1 | ||
| 321 | `; | ||
| 322 | |||
| 323 | export const REMOTE_LAUNCH_SCRIPT = `set -eu | ||
| 324 | STATE_KEY="$1" | ||
| 325 | STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" | ||
| 326 | DEFAULT_SERVER_HOME="$HOME/.t3" | ||
| 327 | DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json" | ||
| 328 | PORT_FILE="$STATE_DIR/port" | ||
| 329 | PID_FILE="$STATE_DIR/pid" | ||
| 330 | MANAGED_FILE="$STATE_DIR/managed" | ||
| 331 | LOG_FILE="$STATE_DIR/server.log" | ||
| 332 | RUNNER_FILE="$STATE_DIR/run-t3.sh" | ||
| 333 | RUNNER_NEXT="$STATE_DIR/run-t3.next.$$" | ||
| 334 | mkdir -p "$STATE_DIR" | ||
| 335 | cleanup_runner_next() { | ||
| 336 | rm -f "$RUNNER_NEXT" | ||
| 337 | } | ||
| 338 | trap cleanup_runner_next EXIT | ||
| 339 | cat >"$RUNNER_NEXT" <<'SH' | ||
| 340 | @@T3_RUNNER_SCRIPT@@ | ||
| 341 | SH | ||
| 342 | RUNNER_CHANGED=0 | ||
| 343 | if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then | ||
| 344 | RUNNER_CHANGED=1 | ||
| 345 | fi | ||
| 346 | mv "$RUNNER_NEXT" "$RUNNER_FILE" | ||
| 347 | chmod 700 "$RUNNER_FILE" | ||
| 348 | pick_port() { | ||
| 349 | node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' | ||
| 350 | @@T3_PICK_PORT_SCRIPT@@ | ||
| 351 | NODE | ||
| 352 | } | ||
| 353 | wait_ready() { | ||
| 354 | node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE' | ||
| 355 | @@T3_WAIT_READY_SCRIPT@@ | ||
| 356 | NODE | ||
| 357 | } | ||
| 358 | wait_for_pid_exit() { | ||
| 359 | PID_TO_WAIT="$1" | ||
| 360 | WAIT_COUNT=0 | ||
| 361 | while kill -0 "$PID_TO_WAIT" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do | ||
| 362 | WAIT_COUNT=$((WAIT_COUNT + 1)) | ||
| 363 | sleep 0.1 | ||
| 364 | done | ||
| 365 | } | ||
| 366 | resolve_default_runtime_port() { | ||
| 367 | node - "$DEFAULT_RUNTIME_FILE" <<'NODE' | ||
| 368 | const fs = require("node:fs"); | ||
| 369 | const runtimePath = process.argv[2] ?? ""; | ||
| 370 | try { | ||
| 371 | const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8")); | ||
| 372 | const pid = Number(runtime.pid); | ||
| 373 | const port = Number(runtime.port); | ||
| 374 | if (!Number.isInteger(pid) || !Number.isInteger(port)) { | ||
| 375 | process.exit(1); | ||
| 376 | } | ||
| 377 | const origin = new URL(String(runtime.origin ?? "")); | ||
| 378 | if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { | ||
| 379 | process.exit(1); | ||
| 380 | } | ||
| 381 | process.kill(pid, 0); | ||
| 382 | process.stdout.write(String(port)); | ||
| 383 | } catch { | ||
| 384 | process.exit(1); | ||
| 385 | } | ||
| 386 | NODE | ||
| 387 | } | ||
| 388 | REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" | ||
| 389 | REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" | ||
| 390 | REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" | ||
| 391 | DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port 2>/dev/null || true)" | ||
| 392 | if [ -n "$DEFAULT_REMOTE_PORT" ]; then | ||
| 393 | REMOTE_PORT="$DEFAULT_REMOTE_PORT" | ||
| 394 | if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then | ||
| 395 | if [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then | ||
| 396 | kill "$REMOTE_PID" 2>/dev/null || true | ||
| 397 | wait_for_pid_exit "$REMOTE_PID" | ||
| 398 | fi | ||
| 399 | printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" | ||
| 400 | printf 'external\\n' >"$MANAGED_FILE" | ||
| 401 | REMOTE_PID="" | ||
| 402 | REMOTE_MANAGED="external" | ||
| 403 | else | ||
| 404 | REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" | ||
| 405 | REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" | ||
| 406 | REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" | ||
| 407 | fi | ||
| 408 | fi | ||
| 409 | if [ "$REMOTE_MANAGED" = "external" ]; then | ||
| 410 | if [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then | ||
| 411 | REMOTE_PID="" | ||
| 412 | REMOTE_PORT="" | ||
| 413 | REMOTE_MANAGED="" | ||
| 414 | fi | ||
| 415 | elif [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then | ||
| 416 | if [ "$RUNNER_CHANGED" -eq 1 ]; then | ||
| 417 | kill "$REMOTE_PID" 2>/dev/null || true | ||
| 418 | wait_for_pid_exit "$REMOTE_PID" | ||
| 419 | REMOTE_PID="" | ||
| 420 | REMOTE_PORT="" | ||
| 421 | REMOTE_MANAGED="" | ||
| 422 | elif ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then | ||
| 423 | kill "$REMOTE_PID" 2>/dev/null || true | ||
| 424 | wait_for_pid_exit "$REMOTE_PID" | ||
| 425 | REMOTE_PID="" | ||
| 426 | REMOTE_PORT="" | ||
| 427 | REMOTE_MANAGED="" | ||
| 428 | fi | ||
| 429 | else | ||
| 430 | REMOTE_PID="" | ||
| 431 | REMOTE_PORT="" | ||
| 432 | REMOTE_MANAGED="" | ||
| 433 | fi | ||
| 434 | if [ -z "$REMOTE_PID" ] || [ -z "$REMOTE_PORT" ]; then | ||
| 435 | REMOTE_PORT="$(pick_port)" || true | ||
| 436 | if [ -z "$REMOTE_PORT" ]; then | ||
| 437 | printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 | ||
| 438 | exit 1 | ||
| 439 | fi | ||
| 440 | nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & | ||
| 441 | REMOTE_PID="$!" | ||
| 442 | printf '%s\\n' "$REMOTE_PID" >"$PID_FILE" | ||
| 443 | printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" | ||
| 444 | printf 'managed\\n' >"$MANAGED_FILE" | ||
| 445 | if ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then | ||
| 446 | printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2 | ||
| 447 | tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true | ||
| 448 | kill "$REMOTE_PID" 2>/dev/null || true | ||
| 449 | wait_for_pid_exit "$REMOTE_PID" | ||
| 450 | rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" | ||
| 451 | exit 1 | ||
| 452 | fi | ||
| 453 | fi | ||
| 454 | printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}" | ||
| 455 | `; | ||
| 456 | |||
| 457 | export const REMOTE_PAIRING_SCRIPT = `set -eu | ||
| 458 | STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" | ||
| 459 | DEFAULT_SERVER_HOME="$HOME/.t3" | ||
| 460 | RUNNER_FILE="$STATE_DIR/run-t3.sh" | ||
| 461 | mkdir -p "$STATE_DIR" | ||
| 462 | cat >"$RUNNER_FILE" <<'SH' | ||
| 463 | @@T3_RUNNER_SCRIPT@@ | ||
| 464 | SH | ||
| 465 | chmod 700 "$RUNNER_FILE" | ||
| 466 | PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME" | ||
| 467 | "$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json | ||
| 468 | `; | ||
| 469 | |||
| 470 | export const REMOTE_STOP_SCRIPT = `set -eu | ||
| 471 | STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" | ||
| 472 | PID_FILE="$STATE_DIR/pid" | ||
| 473 | PORT_FILE="$STATE_DIR/port" | ||
| 474 | MANAGED_FILE="$STATE_DIR/managed" | ||
| 475 | REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" | ||
| 476 | REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" | ||
| 477 | if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then | ||
| 478 | kill "$REMOTE_PID" 2>/dev/null || true | ||
| 479 | WAIT_COUNT=0 | ||
| 480 | while kill -0 "$REMOTE_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do | ||
| 481 | WAIT_COUNT=$((WAIT_COUNT + 1)) | ||
| 482 | sleep 0.1 | ||
| 483 | done | ||
| 484 | fi | ||
| 485 | rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" | ||
| 486 | printf '{"stopped":true}\\n' | ||
| 487 | `; | ||
| 488 | |||
| 489 | const REMOTE_LOG_TAIL_SCRIPT = `set -eu | ||
| 490 | STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" | ||
| 491 | LOG_FILE="$STATE_DIR/server.log" | ||
| 492 | if [ -f "$LOG_FILE" ]; then | ||
| 493 | tail -n 80 "$LOG_FILE" 2>/dev/null || true | ||
| 494 | fi | ||
| 495 | `; | ||
| 496 | |||
| 497 | export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { | ||
| 498 | const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); | ||
| 499 | const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; | ||
| 500 | return stripTrailingNewlines( | ||
| 501 | applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { | ||
| 502 | T3_PACKAGE_SPEC: packageSpec, | ||
| 503 | T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), | ||
| 504 | }), | ||
| 505 | ); | ||
| 506 | } | ||
| 507 | |||
| 508 | export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { | ||
| 509 | return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { | ||
| 510 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), | ||
| 511 | T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), | ||
| 512 | T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT), | ||
| 513 | T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT), | ||
| 514 | T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW), | ||
| 515 | T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS), | ||
| 516 | T3_REUSE_READY_TIMEOUT_MS: String(REMOTE_REUSE_READY_TIMEOUT_MS), | ||
| 517 | T3_READY_PROBE_TIMEOUT_MS: String(SSH_READY_PROBE_TIMEOUT_MS), | ||
| 518 | }); | ||
| 519 | } | ||
| 520 | |||
| 521 | export function buildRemotePairingScript( | ||
| 522 | target: DesktopSshEnvironmentTarget, | ||
| 523 | input?: RemoteT3RunnerOptions, | ||
| 524 | ): string { | ||
| 525 | return applyScriptPlaceholders(REMOTE_PAIRING_SCRIPT, { | ||
| 526 | T3_STATE_KEY: remoteStateKey(target), | ||
| 527 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), | ||
| 528 | }); | ||
| 529 | } | ||
| 530 | |||
| 531 | export function buildRemoteStopScript(target: DesktopSshEnvironmentTarget): string { | ||
| 532 | return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, { | ||
| 533 | T3_STATE_KEY: remoteStateKey(target), | ||
| 534 | }); | ||
| 535 | } | ||
| 536 | |||
| 537 | function buildRemoteLogTailScript(target: DesktopSshEnvironmentTarget): string { | ||
| 538 | return applyScriptPlaceholders(REMOTE_LOG_TAIL_SCRIPT, { | ||
| 539 | T3_STATE_KEY: remoteStateKey(target), | ||
| 540 | }); | ||
| 541 | } | ||
| 542 | |||
| 543 | export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemoteServer")( | ||
| 544 | function* ( | ||
| 545 | target: DesktopSshEnvironmentTarget, | ||
| 546 | input?: SshAuthOptions, | ||
| 547 | runner?: RemoteT3RunnerOptions, | ||
| 548 | ): Effect.fn.Return< | ||
| 549 | { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null }, | ||
| 550 | SshCommandError | SshInvalidTargetError | SshLaunchError, | ||
| 551 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 552 | > { | ||
| 553 | yield* Effect.logInfo("ssh.remoteServer.launch.start", { | ||
| 554 | ...sshTargetLogFields(target), | ||
| 555 | ...sshRunnerLogFields(runner), | ||
| 556 | stateKey: remoteStateKey(target), | ||
| 557 | }); | ||
| 558 | const result = yield* runSshCommand(target, { | ||
| 559 | remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], | ||
| 560 | stdin: buildRemoteLaunchScript(runner), | ||
| 561 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | ||
| 562 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | ||
| 563 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | ||
| 564 | }); | ||
| 565 | if (!getLastNonEmptyOutputLine(result.stdout)) { | ||
| 566 | return yield* new SshLaunchError({ | ||
| 567 | message: "SSH launch did not return a remote port.", | ||
| 568 | stdout: result.stdout, | ||
| 569 | }); | ||
| 570 | } | ||
| 571 | const parsed = yield* decodeRemoteLaunchResult(result.stdout).pipe( | ||
| 572 | Effect.mapError( | ||
| 573 | (cause) => | ||
| 574 | new SshLaunchError({ | ||
| 575 | message: "SSH launch returned unparseable output.", | ||
| 576 | stdout: result.stdout, | ||
| 577 | cause, | ||
| 578 | }), | ||
| 579 | ), | ||
| 580 | ); | ||
| 581 | if (!Number.isInteger(parsed.remotePort)) { | ||
| 582 | return yield* new SshLaunchError({ | ||
| 583 | message: `SSH launch returned an invalid remote port: ${String(parsed.remotePort)}.`, | ||
| 584 | stdout: result.stdout, | ||
| 585 | }); | ||
| 586 | } | ||
| 587 | yield* Effect.logInfo("ssh.remoteServer.launch.ready", { | ||
| 588 | ...sshTargetLogFields(target), | ||
| 589 | remotePort: parsed.remotePort, | ||
| 590 | remoteServerKind: parsed.serverKind ?? null, | ||
| 591 | stateKey: remoteStateKey(target), | ||
| 592 | }); | ||
| 593 | return { | ||
| 594 | remotePort: parsed.remotePort, | ||
| 595 | remoteServerKind: parsed.serverKind ?? null, | ||
| 596 | }; | ||
| 597 | }, | ||
| 598 | ); | ||
| 599 | |||
| 600 | export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingToken")(function* ( | ||
| 601 | target: DesktopSshEnvironmentTarget, | ||
| 602 | input?: SshAuthOptions, | ||
| 603 | runner?: RemoteT3RunnerOptions, | ||
| 604 | ): Effect.fn.Return< | ||
| 605 | { | ||
| 606 | readonly credential: string; | ||
| 607 | }, | ||
| 608 | SshCommandError | SshInvalidTargetError | SshPairingError, | ||
| 609 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 610 | > { | ||
| 611 | yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", { | ||
| 612 | ...sshTargetLogFields(target), | ||
| 613 | stateKey: remoteStateKey(target), | ||
| 614 | }); | ||
| 615 | const result = yield* runSshCommand(target, { | ||
| 616 | remoteCommandArgs: ["sh", "-s"], | ||
| 617 | stdin: buildRemotePairingScript(target, runner), | ||
| 618 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | ||
| 619 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | ||
| 620 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | ||
| 621 | }); | ||
| 622 | if (!getLastNonEmptyOutputLine(result.stdout)) { | ||
| 623 | return yield* new SshPairingError({ | ||
| 624 | message: "SSH pairing did not return a credential.", | ||
| 625 | stdout: result.stdout, | ||
| 626 | }); | ||
| 627 | } | ||
| 628 | const parsed = yield* decodeRemotePairingResult(result.stdout).pipe( | ||
| 629 | Effect.mapError( | ||
| 630 | (cause) => | ||
| 631 | new SshPairingError({ | ||
| 632 | message: "SSH pairing returned unparseable output.", | ||
| 633 | stdout: result.stdout, | ||
| 634 | cause, | ||
| 635 | }), | ||
| 636 | ), | ||
| 637 | ); | ||
| 638 | if (parsed.credential.trim().length === 0) { | ||
| 639 | return yield* new SshPairingError({ | ||
| 640 | message: "SSH pairing command returned an invalid credential.", | ||
| 641 | stdout: result.stdout, | ||
| 642 | }); | ||
| 643 | } | ||
| 644 | yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", { | ||
| 645 | ...sshTargetLogFields(target), | ||
| 646 | stateKey: remoteStateKey(target), | ||
| 647 | }); | ||
| 648 | return { | ||
| 649 | credential: parsed.credential, | ||
| 650 | }; | ||
| 651 | }); | ||
| 652 | |||
| 653 | export const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( | ||
| 654 | target: DesktopSshEnvironmentTarget, | ||
| 655 | input?: SshAuthOptions, | ||
| 656 | ): Effect.fn.Return< | ||
| 657 | void, | ||
| 658 | SshCommandError | SshInvalidTargetError, | ||
| 659 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 660 | > { | ||
| 661 | yield* Effect.logInfo("ssh.remoteServer.stop.start", { | ||
| 662 | ...sshTargetLogFields(target), | ||
| 663 | stateKey: remoteStateKey(target), | ||
| 664 | }); | ||
| 665 | yield* runSshCommand(target, { | ||
| 666 | remoteCommandArgs: ["sh", "-s"], | ||
| 667 | stdin: buildRemoteStopScript(target), | ||
| 668 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | ||
| 669 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | ||
| 670 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | ||
| 671 | }); | ||
| 672 | yield* Effect.logInfo("ssh.remoteServer.stop.succeeded", { | ||
| 673 | ...sshTargetLogFields(target), | ||
| 674 | stateKey: remoteStateKey(target), | ||
| 675 | }); | ||
| 676 | }); | ||
| 677 | |||
| 678 | const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(function* ( | ||
| 679 | target: DesktopSshEnvironmentTarget, | ||
| 680 | input?: SshAuthOptions, | ||
| 681 | ): Effect.fn.Return< | ||
| 682 | string, | ||
| 683 | SshCommandError | SshInvalidTargetError, | ||
| 684 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 685 | > { | ||
| 686 | const result = yield* runSshCommand(target, { | ||
| 687 | remoteCommandArgs: ["sh", "-s"], | ||
| 688 | stdin: buildRemoteLogTailScript(target), | ||
| 689 | timeoutMs: 10_000, | ||
| 690 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | ||
| 691 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | ||
| 692 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | ||
| 693 | }); | ||
| 694 | return result.stdout.trim(); | ||
| 695 | }); | ||
| 696 | |||
| 697 | export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: { | ||
| 698 | readonly baseUrl: string; | ||
| 699 | readonly timeoutMs?: number; | ||
| 700 | readonly intervalMs?: number; | ||
| 701 | readonly probeTimeoutMs?: number; | ||
| 702 | readonly path?: string; | ||
| 703 | }): Effect.fn.Return<void, SshReadinessError, HttpClient.HttpClient> { | ||
| 704 | const timeoutMs = input.timeoutMs ?? 30_000; | ||
| 705 | const intervalMs = input.intervalMs ?? 100; | ||
| 706 | const probeTimeoutMs = input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS; | ||
| 707 | const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe( | ||
| 708 | Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))), | ||
| 709 | ); | ||
| 710 | const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); | ||
| 711 | const client = yield* HttpClient.HttpClient; | ||
| 712 | const lastProbeFailure = yield* Ref.make<unknown>(null); | ||
| 713 | let attempt = 0; | ||
| 714 | |||
| 715 | yield* Effect.logDebug("ssh.tunnel.httpReady.start", { | ||
| 716 | baseUrl: input.baseUrl, | ||
| 717 | requestUrl, | ||
| 718 | timeoutMs, | ||
| 719 | intervalMs, | ||
| 720 | probeTimeoutMs, | ||
| 721 | }); | ||
| 722 | |||
| 723 | const readinessClient = client.pipe( | ||
| 724 | HttpClient.filterStatusOk, | ||
| 725 | HttpClient.transform((effect) => | ||
| 726 | Effect.gen(function* () { | ||
| 727 | attempt += 1; | ||
| 728 | const responseOption = yield* effect.pipe( | ||
| 729 | Effect.timeoutOption(Duration.millis(probeTimeoutMs)), | ||
| 730 | Effect.mapError( | ||
| 731 | (cause) => | ||
| 732 | new SshReadinessError({ | ||
| 733 | message: `Backend readiness probe failed at ${requestUrl}.`, | ||
| 734 | cause, | ||
| 735 | }), | ||
| 736 | ), | ||
| 737 | ); | ||
| 738 | return yield* Option.match(responseOption, { | ||
| 739 | onSome: Effect.succeed, | ||
| 740 | onNone: () => | ||
| 741 | Effect.fail( | ||
| 742 | new SshReadinessError({ | ||
| 743 | message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`, | ||
| 744 | cause: { | ||
| 745 | kind: "probe-timeout", | ||
| 746 | attempt, | ||
| 747 | probeTimeoutMs, | ||
| 748 | }, | ||
| 749 | }), | ||
| 750 | ), | ||
| 751 | }); | ||
| 752 | }).pipe( | ||
| 753 | Effect.mapError((cause) => | ||
| 754 | cause instanceof SshReadinessError | ||
| 755 | ? cause | ||
| 756 | : new SshReadinessError({ | ||
| 757 | message: `Backend readiness probe failed at ${requestUrl}.`, | ||
| 758 | cause, | ||
| 759 | }), | ||
| 760 | ), | ||
| 761 | Effect.tapError((cause) => | ||
| 762 | Ref.set(lastProbeFailure, { | ||
| 763 | attempt, | ||
| 764 | cause: describeReadinessCause(cause), | ||
| 765 | }), | ||
| 766 | ), | ||
| 767 | ), | ||
| 768 | ), | ||
| 769 | HttpClient.tap((response) => response.text.pipe(Effect.ignore)), | ||
| 770 | HttpClient.retry(retryPolicy), | ||
| 771 | ); | ||
| 772 | |||
| 773 | const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe( | ||
| 774 | Effect.mapError((cause) => | ||
| 775 | cause instanceof SshReadinessError | ||
| 776 | ? cause | ||
| 777 | : new SshReadinessError({ | ||
| 778 | message: `Backend readiness probe failed at ${requestUrl}.`, | ||
| 779 | cause, | ||
| 780 | }), | ||
| 781 | ), | ||
| 782 | Effect.timeoutOption(Duration.millis(timeoutMs)), | ||
| 783 | ); | ||
| 784 | |||
| 785 | return yield* Option.match(result, { | ||
| 786 | onSome: () => | ||
| 787 | Effect.logDebug("ssh.tunnel.httpReady.succeeded", { | ||
| 788 | baseUrl: input.baseUrl, | ||
| 789 | requestUrl, | ||
| 790 | attempts: attempt, | ||
| 791 | }), | ||
| 792 | onNone: () => | ||
| 793 | Effect.gen(function* () { | ||
| 794 | const lastFailure = yield* Ref.get(lastProbeFailure); | ||
| 795 | yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", { | ||
| 796 | baseUrl: input.baseUrl, | ||
| 797 | requestUrl, | ||
| 798 | timeoutMs, | ||
| 799 | intervalMs, | ||
| 800 | probeTimeoutMs, | ||
| 801 | attempts: attempt, | ||
| 802 | lastFailure, | ||
| 803 | }); | ||
| 804 | return yield* new SshReadinessError({ | ||
| 805 | message: `Timed out waiting ${timeoutMs}ms for backend readiness at ${input.baseUrl}.`, | ||
| 806 | cause: lastFailure, | ||
| 807 | }); | ||
| 808 | }), | ||
| 809 | }); | ||
| 810 | }); | ||
| 811 | |||
| 812 | function isLoopbackHostname(hostname: string): boolean { | ||
| 813 | const normalized = hostname | ||
| 814 | .trim() | ||
| 815 | .toLowerCase() | ||
| 816 | .replace(/^\[(.*)\]$/, "$1"); | ||
| 817 | return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; | ||
| 818 | } | ||
| 819 | |||
| 820 | function resolveLoopbackSshHttpUrl( | ||
| 821 | rawHttpBaseUrl: unknown, | ||
| 822 | pathname: string, | ||
| 823 | ): Effect.Effect<URL, SshHttpBridgeError> { | ||
| 824 | return Effect.try({ | ||
| 825 | try: () => { | ||
| 826 | if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { | ||
| 827 | throw new Error("Invalid SSH forwarded http base URL."); | ||
| 828 | } | ||
| 829 | const baseUrl = new URL(rawHttpBaseUrl); | ||
| 830 | if (!isLoopbackHostname(baseUrl.hostname)) { | ||
| 831 | throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); | ||
| 832 | } | ||
| 833 | const url = new URL(baseUrl.toString()); | ||
| 834 | url.pathname = pathname; | ||
| 835 | url.search = ""; | ||
| 836 | url.hash = ""; | ||
| 837 | return url; | ||
| 838 | }, | ||
| 839 | catch: (cause) => | ||
| 840 | new SshHttpBridgeError({ | ||
| 841 | message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.", | ||
| 842 | cause, | ||
| 843 | }), | ||
| 844 | }); | ||
| 845 | } | ||
| 846 | |||
| 847 | export const fetchLoopbackSshJson = Effect.fn("ssh/tunnel.fetchLoopbackSshJson")(function* < | ||
| 848 | T, | ||
| 849 | >(input: { | ||
| 850 | readonly httpBaseUrl: unknown; | ||
| 851 | readonly pathname: string; | ||
| 852 | readonly method?: "GET" | "POST"; | ||
| 853 | readonly bearerToken?: unknown; | ||
| 854 | readonly body?: unknown; | ||
| 855 | }): Effect.fn.Return<T, SshHttpBridgeError, HttpClient.HttpClient> { | ||
| 856 | const requestUrl = yield* resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname); | ||
| 857 | const bearerToken = | ||
| 858 | typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0 | ||
| 859 | ? input.bearerToken | ||
| 860 | : null; | ||
| 861 | |||
| 862 | const request = ( | ||
| 863 | input.method === "POST" | ||
| 864 | ? HttpClientRequest.post(requestUrl.toString()) | ||
| 865 | : HttpClientRequest.get(requestUrl.toString()) | ||
| 866 | ).pipe( | ||
| 867 | input.body === undefined ? (req) => req : HttpClientRequest.bodyJsonUnsafe(input.body), | ||
| 868 | bearerToken | ||
| 869 | ? HttpClientRequest.setHeader("authorization", `Bearer ${bearerToken}`) | ||
| 870 | : (req) => req, | ||
| 871 | ); | ||
| 872 | const client = yield* HttpClient.HttpClient; | ||
| 873 | const response = yield* client.execute(request).pipe( | ||
| 874 | Effect.mapError( | ||
| 875 | (cause) => | ||
| 876 | new SshHttpBridgeError({ | ||
| 877 | message: `Failed to reach SSH forwarded endpoint ${requestUrl.toString()}.`, | ||
| 878 | cause, | ||
| 879 | }), | ||
| 880 | ), | ||
| 881 | ); | ||
| 882 | if (response.status < 200 || response.status >= 300) { | ||
| 883 | const text = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); | ||
| 884 | const parsedError = yield* decodeRemoteHttpError(text).pipe( | ||
| 885 | Effect.catch(() => Effect.succeed(null)), | ||
| 886 | ); | ||
| 887 | const message = | ||
| 888 | parsedError?.error && parsedError.error.trim().length > 0 | ||
| 889 | ? parsedError.error | ||
| 890 | : text || `SSH forwarded request failed (${response.status}).`; | ||
| 891 | return yield* new SshHttpBridgeError({ | ||
| 892 | status: response.status, | ||
| 893 | message: `[ssh_http:${response.status}] ${message} (${input.method ?? "GET"} ${requestUrl.toString()})`, | ||
| 894 | }); | ||
| 895 | } | ||
| 896 | return (yield* response.json.pipe( | ||
| 897 | Effect.mapError( | ||
| 898 | (cause) => | ||
| 899 | new SshHttpBridgeError({ | ||
| 900 | message: `SSH forwarded endpoint ${requestUrl.toString()} returned invalid JSON.`, | ||
| 901 | cause, | ||
| 902 | }), | ||
| 903 | ), | ||
| 904 | )) as T; | ||
| 905 | }); | ||
| 906 | |||
| 907 | const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () { | ||
| 908 | const net = yield* NetService; | ||
| 909 | return yield* net.reserveLoopbackPort(); | ||
| 910 | }); | ||
| 911 | |||
| 912 | const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: { | ||
| 913 | readonly key: string; | ||
| 914 | readonly resolvedTarget: DesktopSshEnvironmentTarget; | ||
| 915 | readonly remotePort: number; | ||
| 916 | readonly localPort: number; | ||
| 917 | readonly httpBaseUrl: string; | ||
| 918 | readonly wsBaseUrl: string; | ||
| 919 | readonly authOptions: SshAuthOptions; | ||
| 920 | readonly remoteServerKind: "external" | "managed" | null; | ||
| 921 | }): Effect.fn.Return< | ||
| 922 | SshTunnelEntry, | ||
| 923 | SshCommandError | SshInvalidTargetError | SshReadinessError, | ||
| 924 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 925 | | FileSystem.FileSystem | ||
| 926 | | Path.Path | ||
| 927 | | HttpClient.HttpClient | ||
| 928 | | NetService | ||
| 929 | | Scope.Scope | ||
| 930 | > { | ||
| 931 | const hostSpec = yield* buildSshHostSpecEffect(input.resolvedTarget); | ||
| 932 | const childEnvironment = yield* buildSshChildEnvironment({ | ||
| 933 | ...(input.authOptions.authSecret === undefined | ||
| 934 | ? {} | ||
| 935 | : { authSecret: input.authOptions.authSecret }), | ||
| 936 | ...(input.authOptions.interactiveAuth === undefined | ||
| 937 | ? {} | ||
| 938 | : { interactiveAuth: input.authOptions.interactiveAuth }), | ||
| 939 | }).pipe( | ||
| 940 | Effect.mapError( | ||
| 941 | (cause) => | ||
| 942 | new SshCommandError({ | ||
| 943 | command: ["ssh"], | ||
| 944 | exitCode: null, | ||
| 945 | stderr: "", | ||
| 946 | message: "Failed to prepare SSH authentication helpers.", | ||
| 947 | cause, | ||
| 948 | }), | ||
| 949 | ), | ||
| 950 | ); | ||
| 951 | const args = [ | ||
| 952 | ...baseSshArgs(input.resolvedTarget, { | ||
| 953 | batchMode: input.authOptions.batchMode ?? "no", | ||
| 954 | }), | ||
| 955 | "-o", | ||
| 956 | "ExitOnForwardFailure=yes", | ||
| 957 | "-o", | ||
| 958 | "ServerAliveInterval=15", | ||
| 959 | "-o", | ||
| 960 | "ServerAliveCountMax=3", | ||
| 961 | "-n", | ||
| 962 | "-N", | ||
| 963 | "-L", | ||
| 964 | `${input.localPort}:127.0.0.1:${input.remotePort}`, | ||
| 965 | hostSpec, | ||
| 966 | ]; | ||
| 967 | const tunnelCommand = ["ssh", ...args]; | ||
| 968 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 969 | const scope = yield* Scope.Scope; | ||
| 970 | yield* Effect.logDebug("ssh.tunnel.spawn.start", { | ||
| 971 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 972 | command: tunnelCommand, | ||
| 973 | localPort: input.localPort, | ||
| 974 | remotePort: input.remotePort, | ||
| 975 | remoteServerKind: input.remoteServerKind, | ||
| 976 | httpBaseUrl: input.httpBaseUrl, | ||
| 977 | }); | ||
| 978 | const child = yield* spawner | ||
| 979 | .spawn( | ||
| 980 | ChildProcess.make("ssh", args, { | ||
| 981 | env: childEnvironment, | ||
| 982 | shell: process.platform === "win32", | ||
| 983 | stdin: { | ||
| 984 | stream: Stream.empty, | ||
| 985 | endOnDone: true, | ||
| 986 | }, | ||
| 987 | }), | ||
| 988 | ) | ||
| 989 | .pipe( | ||
| 990 | Effect.mapError( | ||
| 991 | (cause) => | ||
| 992 | new SshCommandError({ | ||
| 993 | command: tunnelCommand, | ||
| 994 | exitCode: null, | ||
| 995 | stderr: "", | ||
| 996 | message: | ||
| 997 | cause instanceof Error | ||
| 998 | ? cause.message | ||
| 999 | : `Failed to spawn SSH tunnel for ${input.resolvedTarget.alias}.`, | ||
| 1000 | cause, | ||
| 1001 | }), | ||
| 1002 | ), | ||
| 1003 | ); | ||
| 1004 | yield* Effect.logDebug("ssh.tunnel.spawn.succeeded", { | ||
| 1005 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1006 | command: tunnelCommand, | ||
| 1007 | pid: child.pid, | ||
| 1008 | localPort: input.localPort, | ||
| 1009 | remotePort: input.remotePort, | ||
| 1010 | httpBaseUrl: input.httpBaseUrl, | ||
| 1011 | }); | ||
| 1012 | const tunnelEntry: SshTunnelEntry = { | ||
| 1013 | key: input.key, | ||
| 1014 | target: input.resolvedTarget, | ||
| 1015 | remotePort: input.remotePort, | ||
| 1016 | remoteServerKind: input.remoteServerKind, | ||
| 1017 | localPort: input.localPort, | ||
| 1018 | httpBaseUrl: input.httpBaseUrl, | ||
| 1019 | wsBaseUrl: input.wsBaseUrl, | ||
| 1020 | process: child, | ||
| 1021 | scope, | ||
| 1022 | }; | ||
| 1023 | const exitFailure = Effect.all( | ||
| 1024 | [collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number))], | ||
| 1025 | { concurrency: "unbounded" }, | ||
| 1026 | ).pipe( | ||
| 1027 | Effect.mapError( | ||
| 1028 | (cause) => | ||
| 1029 | new SshCommandError({ | ||
| 1030 | command: tunnelCommand, | ||
| 1031 | exitCode: null, | ||
| 1032 | stderr: "", | ||
| 1033 | message: | ||
| 1034 | cause instanceof Error | ||
| 1035 | ? cause.message | ||
| 1036 | : `Failed to monitor SSH tunnel for ${input.resolvedTarget.alias}.`, | ||
| 1037 | cause, | ||
| 1038 | }), | ||
| 1039 | ), | ||
| 1040 | Effect.flatMap(([stderr, exitCode]) => { | ||
| 1041 | const error = new SshCommandError({ | ||
| 1042 | command: tunnelCommand, | ||
| 1043 | exitCode, | ||
| 1044 | stderr, | ||
| 1045 | message: normalizeSshErrorMessage( | ||
| 1046 | stderr, | ||
| 1047 | `SSH tunnel exited unexpectedly for ${input.resolvedTarget.alias} (exit ${exitCode}).`, | ||
| 1048 | ), | ||
| 1049 | }); | ||
| 1050 | return Effect.logWarning("ssh.tunnel.process.exited", { | ||
| 1051 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1052 | command: tunnelCommand, | ||
| 1053 | pid: child.pid, | ||
| 1054 | localPort: input.localPort, | ||
| 1055 | remotePort: input.remotePort, | ||
| 1056 | httpBaseUrl: input.httpBaseUrl, | ||
| 1057 | exitCode, | ||
| 1058 | stderr, | ||
| 1059 | }).pipe(Effect.andThen(Effect.fail(error))); | ||
| 1060 | }), | ||
| 1061 | ); | ||
| 1062 | yield* Effect.raceFirst( | ||
| 1063 | waitForHttpReady({ | ||
| 1064 | baseUrl: input.httpBaseUrl, | ||
| 1065 | timeoutMs: SSH_READY_TIMEOUT_MS, | ||
| 1066 | }), | ||
| 1067 | exitFailure, | ||
| 1068 | ).pipe( | ||
| 1069 | Effect.tap(() => | ||
| 1070 | Effect.logInfo("ssh.tunnel.ready", { | ||
| 1071 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1072 | command: tunnelCommand, | ||
| 1073 | pid: child.pid, | ||
| 1074 | localPort: input.localPort, | ||
| 1075 | remotePort: input.remotePort, | ||
| 1076 | httpBaseUrl: input.httpBaseUrl, | ||
| 1077 | }), | ||
| 1078 | ), | ||
| 1079 | Effect.tapError((cause) => | ||
| 1080 | Effect.gen(function* () { | ||
| 1081 | const net = yield* NetService; | ||
| 1082 | const processRunningExit = yield* Effect.exit(child.isRunning); | ||
| 1083 | const localPortAvailableExit = yield* Effect.exit( | ||
| 1084 | net.canListenOnHost(input.localPort, "127.0.0.1"), | ||
| 1085 | ); | ||
| 1086 | const remoteLogTailExit = yield* Effect.exit( | ||
| 1087 | readRemoteServerLogTail(input.resolvedTarget, input.authOptions), | ||
| 1088 | ); | ||
| 1089 | const processRunning = Exit.isSuccess(processRunningExit) ? processRunningExit.value : null; | ||
| 1090 | const localPortAvailable = Exit.isSuccess(localPortAvailableExit) | ||
| 1091 | ? localPortAvailableExit.value | ||
| 1092 | : null; | ||
| 1093 | const remoteLogTail = Exit.isSuccess(remoteLogTailExit) | ||
| 1094 | ? remoteLogTailExit.value || null | ||
| 1095 | : null; | ||
| 1096 | yield* Effect.logWarning("ssh.tunnel.ready.failed", { | ||
| 1097 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1098 | command: tunnelCommand, | ||
| 1099 | pid: child.pid, | ||
| 1100 | processRunning, | ||
| 1101 | ...(Exit.isSuccess(processRunningExit) | ||
| 1102 | ? {} | ||
| 1103 | : { processRunningError: processRunningExit.cause }), | ||
| 1104 | localPort: input.localPort, | ||
| 1105 | localPortListening: localPortAvailable === null ? null : !localPortAvailable, | ||
| 1106 | remotePort: input.remotePort, | ||
| 1107 | httpBaseUrl: input.httpBaseUrl, | ||
| 1108 | ...(Exit.isSuccess(localPortAvailableExit) | ||
| 1109 | ? {} | ||
| 1110 | : { localPortProbeError: localPortAvailableExit.cause }), | ||
| 1111 | ...(remoteLogTail === null ? {} : { remoteLogTail }), | ||
| 1112 | ...(Exit.isSuccess(remoteLogTailExit) | ||
| 1113 | ? {} | ||
| 1114 | : { remoteLogTailError: remoteLogTailExit.cause }), | ||
| 1115 | cause, | ||
| 1116 | }); | ||
| 1117 | }), | ||
| 1118 | ), | ||
| 1119 | Effect.onExit((exit) => | ||
| 1120 | Exit.isSuccess(exit) | ||
| 1121 | ? Effect.void | ||
| 1122 | : child | ||
| 1123 | .kill({ | ||
| 1124 | killSignal: "SIGTERM", | ||
| 1125 | forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, | ||
| 1126 | }) | ||
| 1127 | .pipe(Effect.ignore), | ||
| 1128 | ), | ||
| 1129 | ); | ||
| 1130 | return tunnelEntry; | ||
| 1131 | }); | ||
| 1132 | |||
| 1133 | const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* ( | ||
| 1134 | options: SshEnvironmentManagerOptions = {}, | ||
| 1135 | ): Effect.fn.Return<SshEnvironmentManagerShape, never, Scope.Scope> { | ||
| 1136 | const managerScope = yield* Scope.Scope; | ||
| 1137 | const tunnels = new Map<string, SshTunnelEntry>(); | ||
| 1138 | const pendingTunnelEntries = new Map< | ||
| 1139 | string, | ||
| 1140 | Deferred.Deferred<SshTunnelEntry, SshEnvironmentEffectError> | ||
| 1141 | >(); | ||
| 1142 | const authSecrets = new Map<string, string>(); | ||
| 1143 | |||
| 1144 | const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* ( | ||
| 1145 | entry: SshTunnelEntry, | ||
| 1146 | ) { | ||
| 1147 | yield* Effect.logDebug("ssh.tunnel.close.start", { | ||
| 1148 | ...sshTargetLogFields(entry.target), | ||
| 1149 | key: entry.key, | ||
| 1150 | localPort: entry.localPort, | ||
| 1151 | remotePort: entry.remotePort, | ||
| 1152 | }); | ||
| 1153 | yield* Scope.close(entry.scope, Exit.void).pipe(Effect.ignore); | ||
| 1154 | yield* Effect.logInfo("ssh.tunnel.close.succeeded", { | ||
| 1155 | ...sshTargetLogFields(entry.target), | ||
| 1156 | key: entry.key, | ||
| 1157 | localPort: entry.localPort, | ||
| 1158 | remotePort: entry.remotePort, | ||
| 1159 | }); | ||
| 1160 | }); | ||
| 1161 | |||
| 1162 | const cancelPendingTunnelEntry = Effect.fn("ssh/tunnel.cancelPendingTunnelEntry")(function* ( | ||
| 1163 | key: string, | ||
| 1164 | target: DesktopSshEnvironmentTarget, | ||
| 1165 | ) { | ||
| 1166 | const pending = pendingTunnelEntries.get(key); | ||
| 1167 | if (!pending) { | ||
| 1168 | return; | ||
| 1169 | } | ||
| 1170 | pendingTunnelEntries.delete(key); | ||
| 1171 | yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore); | ||
| 1172 | }); | ||
| 1173 | |||
| 1174 | yield* Scope.addFinalizer( | ||
| 1175 | managerScope, | ||
| 1176 | Effect.sync(() => [...tunnels.values()]).pipe( | ||
| 1177 | Effect.flatMap((entries) => | ||
| 1178 | Effect.forEach(entries, closeTunnelEntry, { concurrency: "unbounded" }), | ||
| 1179 | ), | ||
| 1180 | Effect.ignore, | ||
| 1181 | ), | ||
| 1182 | ); | ||
| 1183 | |||
| 1184 | const promptForPassword = Effect.fn("ssh/tunnel.promptForPassword")(function* ( | ||
| 1185 | target: DesktopSshEnvironmentTarget, | ||
| 1186 | attempt: number, | ||
| 1187 | ): Effect.fn.Return<string, SshInvalidTargetError | SshPasswordPromptError, SshPasswordPrompt> { | ||
| 1188 | const promptService = yield* SshPasswordPrompt; | ||
| 1189 | const hostSpec = yield* buildSshHostSpecEffect(target); | ||
| 1190 | if (!promptService.isAvailable) { | ||
| 1191 | yield* Effect.logWarning("ssh.auth.passwordPrompt.unavailable", { | ||
| 1192 | ...sshTargetLogFields(target), | ||
| 1193 | attempt, | ||
| 1194 | }); | ||
| 1195 | return yield* new SshPasswordPromptError({ | ||
| 1196 | message: `SSH authentication failed for ${hostSpec}.`, | ||
| 1197 | }); | ||
| 1198 | } | ||
| 1199 | |||
| 1200 | yield* Effect.logInfo("ssh.auth.passwordPrompt.request", { | ||
| 1201 | ...sshTargetLogFields(target), | ||
| 1202 | attempt, | ||
| 1203 | }); | ||
| 1204 | const password = yield* promptService.request({ | ||
| 1205 | attempt, | ||
| 1206 | destination: target.alias.trim() || target.hostname.trim(), | ||
| 1207 | username: target.username, | ||
| 1208 | prompt: `Enter the SSH password for ${hostSpec}.`, | ||
| 1209 | }); | ||
| 1210 | if (password === null) { | ||
| 1211 | yield* Effect.logWarning("ssh.auth.passwordPrompt.cancelled", { | ||
| 1212 | ...sshTargetLogFields(target), | ||
| 1213 | attempt, | ||
| 1214 | }); | ||
| 1215 | return yield* new SshPasswordPromptError({ | ||
| 1216 | message: `SSH authentication cancelled for ${hostSpec}.`, | ||
| 1217 | }); | ||
| 1218 | } | ||
| 1219 | yield* Effect.logInfo("ssh.auth.passwordPrompt.received", { | ||
| 1220 | ...sshTargetLogFields(target), | ||
| 1221 | attempt, | ||
| 1222 | }); | ||
| 1223 | return password; | ||
| 1224 | }); | ||
| 1225 | |||
| 1226 | const handleSshAuthFailure = Effect.fn("ssh/tunnel.runWithSshAuthAttempt.handleFailure")( | ||
| 1227 | function* <T>( | ||
| 1228 | input: SshAuthAttemptInput<T> & { | ||
| 1229 | readonly error: SshEnvironmentEffectError; | ||
| 1230 | }, | ||
| 1231 | ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> { | ||
| 1232 | if (!isSshAuthFailure(input.error)) { | ||
| 1233 | return yield* input.error; | ||
| 1234 | } | ||
| 1235 | |||
| 1236 | yield* Effect.logWarning("ssh.auth.failed", { | ||
| 1237 | ...sshTargetLogFields(input.target), | ||
| 1238 | key: input.key, | ||
| 1239 | promptCount: input.promptCount, | ||
| 1240 | cause: input.error, | ||
| 1241 | }); | ||
| 1242 | const promptService = yield* SshPasswordPrompt; | ||
| 1243 | if (!promptService.isAvailable) { | ||
| 1244 | return yield* input.error; | ||
| 1245 | } | ||
| 1246 | if (input.authSecret !== null) { | ||
| 1247 | authSecrets.delete(input.key); | ||
| 1248 | } | ||
| 1249 | if (input.promptCount >= 2) { | ||
| 1250 | return yield* input.error; | ||
| 1251 | } | ||
| 1252 | |||
| 1253 | const nextPromptCount = input.promptCount + 1; | ||
| 1254 | const nextAuthSecret = yield* promptForPassword(input.target, nextPromptCount); | ||
| 1255 | authSecrets.set(input.key, nextAuthSecret); | ||
| 1256 | return yield* runWithSshAuthAttempt({ | ||
| 1257 | ...input, | ||
| 1258 | promptCount: nextPromptCount, | ||
| 1259 | authSecret: nextAuthSecret, | ||
| 1260 | }); | ||
| 1261 | }, | ||
| 1262 | ); | ||
| 1263 | |||
| 1264 | const runWithSshAuthAttempt = Effect.fn("ssh/tunnel.runWithSshAuthAttempt")(function* <T>( | ||
| 1265 | input: SshAuthAttemptInput<T>, | ||
| 1266 | ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> { | ||
| 1267 | const promptService = yield* SshPasswordPrompt; | ||
| 1268 | const authOptions = | ||
| 1269 | input.authSecret === null | ||
| 1270 | ? { | ||
| 1271 | batchMode: promptService.isAvailable ? ("yes" as const) : ("no" as const), | ||
| 1272 | interactiveAuth: !promptService.isAvailable, | ||
| 1273 | } | ||
| 1274 | : { | ||
| 1275 | authSecret: input.authSecret, | ||
| 1276 | batchMode: "no" as const, | ||
| 1277 | interactiveAuth: true, | ||
| 1278 | }; | ||
| 1279 | |||
| 1280 | return yield* input | ||
| 1281 | .operation(authOptions) | ||
| 1282 | .pipe(Effect.catch((error) => handleSshAuthFailure({ ...input, error }))); | ||
| 1283 | }); | ||
| 1284 | |||
| 1285 | const runWithSshAuth = Effect.fn("ssh/tunnel.runWithSshAuth")(function* <T>( | ||
| 1286 | input: SshAuthOperationInput<T>, | ||
| 1287 | ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> { | ||
| 1288 | return yield* runWithSshAuthAttempt({ | ||
| 1289 | ...input, | ||
| 1290 | promptCount: 0, | ||
| 1291 | authSecret: authSecrets.get(input.key) ?? null, | ||
| 1292 | }); | ||
| 1293 | }); | ||
| 1294 | |||
| 1295 | const createTunnelEntry = Effect.fn("ssh/tunnel.ensureTunnelEntry.create")(function* (input: { | ||
| 1296 | readonly key: string; | ||
| 1297 | readonly resolvedTarget: DesktopSshEnvironmentTarget; | ||
| 1298 | readonly runner?: RemoteT3RunnerOptions; | ||
| 1299 | }): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> { | ||
| 1300 | yield* Effect.logDebug("ssh.environment.tunnel.create.start", { | ||
| 1301 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1302 | ...sshRunnerLogFields(input.runner), | ||
| 1303 | key: input.key, | ||
| 1304 | }); | ||
| 1305 | const remoteLaunch = yield* runWithSshAuth({ | ||
| 1306 | key: input.key, | ||
| 1307 | target: input.resolvedTarget, | ||
| 1308 | operation: (authOptions) => | ||
| 1309 | launchOrReuseRemoteServer(input.resolvedTarget, authOptions, input.runner), | ||
| 1310 | }); | ||
| 1311 | const remotePort = remoteLaunch.remotePort; | ||
| 1312 | yield* Effect.logDebug("ssh.environment.remotePort.ready", { | ||
| 1313 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1314 | key: input.key, | ||
| 1315 | remotePort, | ||
| 1316 | remoteServerKind: remoteLaunch.remoteServerKind, | ||
| 1317 | }); | ||
| 1318 | const localPort = yield* reserveLocalTunnelPort(); | ||
| 1319 | const httpBaseUrl = `http://127.0.0.1:${localPort}/`; | ||
| 1320 | const wsBaseUrl = `ws://127.0.0.1:${localPort}/`; | ||
| 1321 | yield* Effect.logDebug("ssh.environment.localPort.reserved", { | ||
| 1322 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1323 | key: input.key, | ||
| 1324 | localPort, | ||
| 1325 | remotePort, | ||
| 1326 | }); | ||
| 1327 | const entryScope = yield* Scope.make("sequential"); | ||
| 1328 | const tunnelEntry = yield* runWithSshAuth({ | ||
| 1329 | key: input.key, | ||
| 1330 | target: input.resolvedTarget, | ||
| 1331 | operation: (authOptions) => | ||
| 1332 | startSshTunnel({ | ||
| 1333 | key: input.key, | ||
| 1334 | resolvedTarget: input.resolvedTarget, | ||
| 1335 | remotePort, | ||
| 1336 | localPort, | ||
| 1337 | httpBaseUrl, | ||
| 1338 | wsBaseUrl, | ||
| 1339 | authOptions, | ||
| 1340 | remoteServerKind: remoteLaunch.remoteServerKind, | ||
| 1341 | }).pipe(Effect.provideService(Scope.Scope, entryScope)), | ||
| 1342 | }).pipe( | ||
| 1343 | Effect.onExit((exit) => | ||
| 1344 | Exit.isSuccess(exit) ? Effect.void : Scope.close(entryScope, Exit.void).pipe(Effect.ignore), | ||
| 1345 | ), | ||
| 1346 | ); | ||
| 1347 | tunnels.set(input.key, tunnelEntry); | ||
| 1348 | const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1349 | const fileSystemService = yield* FileSystem.FileSystem; | ||
| 1350 | const pathService = yield* Path.Path; | ||
| 1351 | yield* Scope.addFinalizer( | ||
| 1352 | entryScope, | ||
| 1353 | Effect.gen(function* () { | ||
| 1354 | if (tunnels.get(tunnelEntry.key) !== tunnelEntry) { | ||
| 1355 | return; | ||
| 1356 | } | ||
| 1357 | yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", { | ||
| 1358 | ...sshTargetLogFields(tunnelEntry.target), | ||
| 1359 | key: tunnelEntry.key, | ||
| 1360 | localPort: tunnelEntry.localPort, | ||
| 1361 | remotePort: tunnelEntry.remotePort, | ||
| 1362 | }); | ||
| 1363 | tunnels.delete(tunnelEntry.key); | ||
| 1364 | const authSecret = authSecrets.get(tunnelEntry.key) ?? null; | ||
| 1365 | yield* Effect.all( | ||
| 1366 | [ | ||
| 1367 | tunnelEntry.process.kill({ | ||
| 1368 | killSignal: "SIGTERM", | ||
| 1369 | forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, | ||
| 1370 | }), | ||
| 1371 | stopRemoteServer( | ||
| 1372 | tunnelEntry.target, | ||
| 1373 | authSecret === null | ||
| 1374 | ? { | ||
| 1375 | batchMode: "yes", | ||
| 1376 | interactiveAuth: false, | ||
| 1377 | } | ||
| 1378 | : { | ||
| 1379 | authSecret, | ||
| 1380 | batchMode: "no", | ||
| 1381 | interactiveAuth: true, | ||
| 1382 | }, | ||
| 1383 | ).pipe( | ||
| 1384 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService), | ||
| 1385 | Effect.provideService(FileSystem.FileSystem, fileSystemService), | ||
| 1386 | Effect.provideService(Path.Path, pathService), | ||
| 1387 | ), | ||
| 1388 | ], | ||
| 1389 | { concurrency: "unbounded" }, | ||
| 1390 | ).pipe(Effect.ignore); | ||
| 1391 | yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", { | ||
| 1392 | ...sshTargetLogFields(tunnelEntry.target), | ||
| 1393 | key: tunnelEntry.key, | ||
| 1394 | localPort: tunnelEntry.localPort, | ||
| 1395 | remotePort: tunnelEntry.remotePort, | ||
| 1396 | }); | ||
| 1397 | }).pipe(Effect.ignore), | ||
| 1398 | ); | ||
| 1399 | yield* Effect.logDebug("ssh.environment.tunnel.create.succeeded", { | ||
| 1400 | ...sshTargetLogFields(input.resolvedTarget), | ||
| 1401 | key: input.key, | ||
| 1402 | localPort, | ||
| 1403 | remotePort, | ||
| 1404 | }); | ||
| 1405 | return tunnelEntry; | ||
| 1406 | }); | ||
| 1407 | |||
| 1408 | const ensureTunnelEntry = Effect.fn("ssh/tunnel.ensureTunnelEntry")(function* ( | ||
| 1409 | key: string, | ||
| 1410 | resolvedTarget: DesktopSshEnvironmentTarget, | ||
| 1411 | runner?: RemoteT3RunnerOptions, | ||
| 1412 | ): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> { | ||
| 1413 | let entry = tunnels.get(key) ?? null; | ||
| 1414 | |||
| 1415 | if (entry !== null) { | ||
| 1416 | yield* Effect.logDebug("ssh.environment.tunnel.existing.check", { | ||
| 1417 | ...sshTargetLogFields(resolvedTarget), | ||
| 1418 | key, | ||
| 1419 | localPort: entry.localPort, | ||
| 1420 | remotePort: entry.remotePort, | ||
| 1421 | }); | ||
| 1422 | const readinessExit = yield* Effect.exit( | ||
| 1423 | waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }), | ||
| 1424 | ); | ||
| 1425 | if (Exit.isSuccess(readinessExit)) { | ||
| 1426 | yield* Effect.logDebug("ssh.environment.tunnel.reused", { | ||
| 1427 | ...sshTargetLogFields(resolvedTarget), | ||
| 1428 | key, | ||
| 1429 | localPort: entry.localPort, | ||
| 1430 | remotePort: entry.remotePort, | ||
| 1431 | }); | ||
| 1432 | return entry; | ||
| 1433 | } | ||
| 1434 | yield* Effect.logWarning("ssh.environment.tunnel.existing.stale", { | ||
| 1435 | ...sshTargetLogFields(resolvedTarget), | ||
| 1436 | key, | ||
| 1437 | localPort: entry.localPort, | ||
| 1438 | remotePort: entry.remotePort, | ||
| 1439 | cause: readinessExit.cause, | ||
| 1440 | }); | ||
| 1441 | yield* closeTunnelEntry(entry); | ||
| 1442 | yield* cancelPendingTunnelEntry(key, resolvedTarget); | ||
| 1443 | entry = null; | ||
| 1444 | } | ||
| 1445 | |||
| 1446 | const pending = pendingTunnelEntries.get(key); | ||
| 1447 | if (pending) { | ||
| 1448 | yield* Effect.logDebug("ssh.environment.tunnel.pending.await", { | ||
| 1449 | ...sshTargetLogFields(resolvedTarget), | ||
| 1450 | key, | ||
| 1451 | }); | ||
| 1452 | return yield* Deferred.await(pending); | ||
| 1453 | } | ||
| 1454 | |||
| 1455 | const deferred = yield* Deferred.make<SshTunnelEntry, SshEnvironmentEffectError>(); | ||
| 1456 | pendingTunnelEntries.set(key, deferred); | ||
| 1457 | |||
| 1458 | return yield* createTunnelEntry({ | ||
| 1459 | key, | ||
| 1460 | resolvedTarget, | ||
| 1461 | ...(runner === undefined ? {} : { runner }), | ||
| 1462 | }).pipe( | ||
| 1463 | Effect.tapError((cause) => | ||
| 1464 | Effect.logWarning("ssh.environment.tunnel.create.failed", { | ||
| 1465 | ...sshTargetLogFields(resolvedTarget), | ||
| 1466 | key, | ||
| 1467 | cause, | ||
| 1468 | }), | ||
| 1469 | ), | ||
| 1470 | Effect.onExit((exit) => | ||
| 1471 | Effect.sync(() => { | ||
| 1472 | if (pendingTunnelEntries.get(key) === deferred) { | ||
| 1473 | pendingTunnelEntries.delete(key); | ||
| 1474 | } | ||
| 1475 | }).pipe(Effect.andThen(Deferred.done(deferred, exit))), | ||
| 1476 | ), | ||
| 1477 | ); | ||
| 1478 | }); | ||
| 1479 | |||
| 1480 | const ensureEnvironment = Effect.fn("ssh/tunnel.ensureEnvironment")(function* ( | ||
| 1481 | target: DesktopSshEnvironmentTarget, | ||
| 1482 | requestOptions?: { readonly issuePairingToken?: boolean }, | ||
| 1483 | ): Effect.fn.Return< | ||
| 1484 | DesktopSshEnvironmentBootstrap, | ||
| 1485 | SshEnvironmentEffectError, | ||
| 1486 | SshEnvironmentEffectContext | ||
| 1487 | > { | ||
| 1488 | yield* Effect.logInfo("ssh.environment.ensure.start", { | ||
| 1489 | ...sshTargetLogFields(target), | ||
| 1490 | issuePairingToken: requestOptions?.issuePairingToken === true, | ||
| 1491 | }); | ||
| 1492 | const baseResolved = yield* resolveSshTarget(target.alias || target.hostname); | ||
| 1493 | const resolvedTarget: DesktopSshEnvironmentTarget = { | ||
| 1494 | ...baseResolved, | ||
| 1495 | ...(target.username !== null ? { username: target.username } : {}), | ||
| 1496 | ...(target.port !== null ? { port: target.port } : {}), | ||
| 1497 | }; | ||
| 1498 | const key = targetConnectionKey(resolvedTarget); | ||
| 1499 | yield* Effect.logDebug("ssh.environment.target.resolved", { | ||
| 1500 | ...sshTargetLogFields(resolvedTarget), | ||
| 1501 | key, | ||
| 1502 | }); | ||
| 1503 | const packageSpec = options.resolveCliPackageSpec?.(); | ||
| 1504 | const runner = | ||
| 1505 | options.resolveCliRunner?.() ?? (packageSpec === undefined ? undefined : { packageSpec }); | ||
| 1506 | yield* Effect.logDebug("ssh.environment.runner.resolved", { | ||
| 1507 | ...sshTargetLogFields(resolvedTarget), | ||
| 1508 | ...sshRunnerLogFields(runner), | ||
| 1509 | key, | ||
| 1510 | }); | ||
| 1511 | const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner); | ||
| 1512 | |||
| 1513 | const pairingResult = requestOptions?.issuePairingToken | ||
| 1514 | ? yield* runWithSshAuth({ | ||
| 1515 | key, | ||
| 1516 | target: entry.target, | ||
| 1517 | operation: (authOptions) => issueRemotePairingToken(entry.target, authOptions, runner), | ||
| 1518 | }) | ||
| 1519 | : null; | ||
| 1520 | const pairingToken = pairingResult?.credential ?? null; | ||
| 1521 | |||
| 1522 | yield* Effect.logInfo("ssh.environment.ensure.succeeded", { | ||
| 1523 | ...sshTargetLogFields(entry.target), | ||
| 1524 | key, | ||
| 1525 | localPort: entry.localPort, | ||
| 1526 | remotePort: entry.remotePort, | ||
| 1527 | remoteServerKind: entry.remoteServerKind, | ||
| 1528 | issuedPairingToken: pairingToken !== null, | ||
| 1529 | }); | ||
| 1530 | return { | ||
| 1531 | target: entry.target, | ||
| 1532 | httpBaseUrl: entry.httpBaseUrl, | ||
| 1533 | wsBaseUrl: entry.wsBaseUrl, | ||
| 1534 | pairingToken, | ||
| 1535 | remotePort: entry.remotePort, | ||
| 1536 | ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}), | ||
| 1537 | }; | ||
| 1538 | }); | ||
| 1539 | |||
| 1540 | const disconnectEnvironment = Effect.fn("ssh/tunnel.disconnectEnvironment")(function* ( | ||
| 1541 | target: DesktopSshEnvironmentTarget, | ||
| 1542 | ): Effect.fn.Return<void, SshEnvironmentEffectError, SshEnvironmentEffectContext> { | ||
| 1543 | yield* Effect.logInfo("ssh.environment.disconnect.start", sshTargetLogFields(target)); | ||
| 1544 | const baseResolved = yield* resolveSshTarget(target.alias || target.hostname); | ||
| 1545 | const resolvedTarget: DesktopSshEnvironmentTarget = { | ||
| 1546 | ...baseResolved, | ||
| 1547 | ...(target.username !== null ? { username: target.username } : {}), | ||
| 1548 | ...(target.port !== null ? { port: target.port } : {}), | ||
| 1549 | }; | ||
| 1550 | const key = targetConnectionKey(resolvedTarget); | ||
| 1551 | const entry = tunnels.get(key) ?? null; | ||
| 1552 | yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", { | ||
| 1553 | ...sshTargetLogFields(resolvedTarget), | ||
| 1554 | key, | ||
| 1555 | hasTunnel: entry !== null, | ||
| 1556 | hasPendingTunnel: pendingTunnelEntries.has(key), | ||
| 1557 | }); | ||
| 1558 | if (entry !== null) { | ||
| 1559 | yield* closeTunnelEntry(entry); | ||
| 1560 | } | ||
| 1561 | yield* cancelPendingTunnelEntry(key, resolvedTarget); | ||
| 1562 | if (entry === null) { | ||
| 1563 | yield* runWithSshAuth({ | ||
| 1564 | key, | ||
| 1565 | target: resolvedTarget, | ||
| 1566 | operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions), | ||
| 1567 | }); | ||
| 1568 | } | ||
| 1569 | yield* Effect.logInfo("ssh.environment.disconnect.succeeded", { | ||
| 1570 | ...sshTargetLogFields(resolvedTarget), | ||
| 1571 | key, | ||
| 1572 | }); | ||
| 1573 | }); | ||
| 1574 | |||
| 1575 | return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment }); | ||
| 1576 | }); | ||
| 1577 | |||
| 1578 | export class SshEnvironmentManager extends Context.Service< | ||
| 1579 | SshEnvironmentManager, | ||
| 1580 | SshEnvironmentManagerShape | ||
| 1581 | >()("@t3tools/ssh/SshEnvironmentManager") { | ||
| 1582 | static readonly layer = (options: SshEnvironmentManagerOptions = {}) => | ||
| 1583 | Layer.effect(SshEnvironmentManager, makeSshEnvironmentManager(options)); | ||
| 1584 | } | ||
packages/ssh/tsconfig.json created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "plugins": [ | ||
| 5 | { | ||
| 6 | "name": "@effect/language-service", | ||
| 7 | "diagnosticSeverity": { | ||
| 8 | "importFromBarrel": "error", | ||
| 9 | "anyUnknownInErrorContext": "warning", | ||
| 10 | "instanceOfSchema": "warning", | ||
| 11 | "deterministicKeys": "warning" | ||
| 12 | } | ||
| 13 | } | ||
| 14 | ] | ||
| 15 | }, | ||
| 16 | "include": ["src"] | ||
| 17 | } | ||
packages/tailscale/package.json created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/tailscale", | ||
| 3 | "version": "0.0.0-alpha.1", | ||
| 4 | "private": true, | ||
| 5 | "type": "module", | ||
| 6 | "exports": { | ||
| 7 | ".": { | ||
| 8 | "types": "./src/index.ts", | ||
| 9 | "import": "./src/index.ts" | ||
| 10 | } | ||
| 11 | }, | ||
| 12 | "scripts": { | ||
| 13 | "prepare": "effect-language-service patch", | ||
| 14 | "typecheck": "tsc --noEmit", | ||
| 15 | "test": "vitest run" | ||
| 16 | }, | ||
| 17 | "dependencies": { | ||
| 18 | "@effect/platform-node": "catalog:", | ||
| 19 | "effect": "catalog:" | ||
| 20 | }, | ||
| 21 | "devDependencies": { | ||
| 22 | "@effect/language-service": "catalog:", | ||
| 23 | "@effect/vitest": "catalog:", | ||
| 24 | "@types/node": "catalog:", | ||
| 25 | "typescript": "catalog:", | ||
| 26 | "vitest": "catalog:" | ||
| 27 | } | ||
| 28 | } | ||
packages/tailscale/src/index.ts created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | export * from "./tailscale.ts"; | ||
packages/tailscale/src/tailscale.test.ts created+141| ... | @@ -0,0 +1,141 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer, Sink, Stream } from "effect"; | ||
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | buildTailscaleHttpsBaseUrl, | ||
| 7 | disableTailscaleServe, | ||
| 8 | ensureTailscaleServe, | ||
| 9 | isTailscaleIpv4Address, | ||
| 10 | parseTailscaleMagicDnsName, | ||
| 11 | parseTailscaleStatus, | ||
| 12 | readTailscaleStatus, | ||
| 13 | } from "./tailscale.ts"; | ||
| 14 | |||
| 15 | const encoder = new TextEncoder(); | ||
| 16 | const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`; | ||
| 17 | const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`; | ||
| 18 | |||
| 19 | function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { | ||
| 20 | return ChildProcessSpawner.makeHandle({ | ||
| 21 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 22 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), | ||
| 23 | isRunning: Effect.succeed(false), | ||
| 24 | kill: () => Effect.void, | ||
| 25 | unref: Effect.succeed(Effect.void), | ||
| 26 | stdin: Sink.drain, | ||
| 27 | stdout: Stream.make(encoder.encode(result.stdout ?? "")), | ||
| 28 | stderr: Stream.make(encoder.encode(result.stderr ?? "")), | ||
| 29 | all: Stream.empty, | ||
| 30 | getInputFd: () => Sink.drain, | ||
| 31 | getOutputFd: () => Stream.empty, | ||
| 32 | }); | ||
| 33 | } | ||
| 34 | |||
| 35 | function mockSpawnerLayer( | ||
| 36 | handler: ( | ||
| 37 | command: string, | ||
| 38 | args: ReadonlyArray<string>, | ||
| 39 | ) => { stdout?: string; stderr?: string; code?: number }, | ||
| 40 | ) { | ||
| 41 | return Layer.succeed( | ||
| 42 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 43 | ChildProcessSpawner.make((command) => { | ||
| 44 | const childProcess = command as unknown as { | ||
| 45 | readonly command: string; | ||
| 46 | readonly args: ReadonlyArray<string>; | ||
| 47 | }; | ||
| 48 | return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | } | ||
| 52 | |||
| 53 | describe("tailscale", () => { | ||
| 54 | it.effect("detects Tailnet IPv4 addresses", () => | ||
| 55 | Effect.sync(() => { | ||
| 56 | assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); | ||
| 57 | assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); | ||
| 58 | assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); | ||
| 59 | assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | |||
| 63 | it.effect("parses MagicDNS names from tailscale status", () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson); | ||
| 66 | assert.equal(dnsName, "desktop.tail.ts.net"); | ||
| 67 | assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | it.effect("parses status facts", () => | ||
| 72 | Effect.gen(function* () { | ||
| 73 | const status = yield* parseTailscaleStatus(tailscaleStatusJson); | ||
| 74 | assert.deepEqual(status, { | ||
| 75 | magicDnsName: "desktop.tail.ts.net", | ||
| 76 | tailnetIpv4Addresses: ["100.100.100.100"], | ||
| 77 | }); | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | it.effect("builds clean HTTPS base URLs", () => | ||
| 82 | Effect.sync(() => { | ||
| 83 | assert.equal( | ||
| 84 | buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net" }), | ||
| 85 | "https://desktop.tail.ts.net/", | ||
| 86 | ); | ||
| 87 | assert.equal( | ||
| 88 | buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net", servePort: 8443 }), | ||
| 89 | "https://desktop.tail.ts.net:8443/", | ||
| 90 | ); | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | it.effect("reads tailscale status through the process spawner service", () => { | ||
| 95 | const layer = mockSpawnerLayer((command, args) => { | ||
| 96 | assert.equal(command, "tailscale"); | ||
| 97 | assert.deepEqual(args, ["status", "--json"]); | ||
| 98 | return { | ||
| 99 | stdout: tailscaleStatusWithSingleIpJson, | ||
| 100 | }; | ||
| 101 | }); | ||
| 102 | |||
| 103 | return Effect.gen(function* () { | ||
| 104 | const status = yield* readTailscaleStatus.pipe(Effect.provide(layer)); | ||
| 105 | assert.deepEqual(status, { | ||
| 106 | magicDnsName: "desktop.tail.ts.net", | ||
| 107 | tailnetIpv4Addresses: ["100.90.1.2"], | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it.effect("configures tailscale serve through the process spawner service", () => { | ||
| 113 | const layer = mockSpawnerLayer((command, args) => { | ||
| 114 | assert.equal(command, "tailscale"); | ||
| 115 | assert.deepEqual(args, ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"]); | ||
| 116 | return {}; | ||
| 117 | }); | ||
| 118 | |||
| 119 | return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer)); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it.effect("disables tailscale serve through the process spawner service", () => { | ||
| 123 | const commands: { | ||
| 124 | readonly command: string; | ||
| 125 | readonly args: ReadonlyArray<string>; | ||
| 126 | }[] = []; | ||
| 127 | const layer = mockSpawnerLayer((command, args) => { | ||
| 128 | commands.push({ command, args }); | ||
| 129 | assert.equal(command, "tailscale"); | ||
| 130 | assert.deepEqual(args, ["serve", "--https=8443", "off"]); | ||
| 131 | return {}; | ||
| 132 | }); | ||
| 133 | |||
| 134 | return Effect.gen(function* () { | ||
| 135 | yield* disableTailscaleServe({ servePort: 8443 }).pipe(Effect.provide(layer)); | ||
| 136 | assert.deepEqual(commands, [ | ||
| 137 | { command: "tailscale", args: ["serve", "--https=8443", "off"] }, | ||
| 138 | ]); | ||
| 139 | }); | ||
| 140 | }); | ||
| 141 | }); | ||
packages/tailscale/src/tailscale.ts created+320| ... | @@ -0,0 +1,320 @@ | ||
| 1 | import { Data, Effect, Option, Schema, Stream } from "effect"; | ||
| 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 3 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | |||
| 5 | export const DEFAULT_TAILSCALE_SERVE_PORT = 443; | ||
| 6 | export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; | ||
| 7 | export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; | ||
| 8 | export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; | ||
| 9 | |||
| 10 | export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ | ||
| 11 | readonly command: readonly string[]; | ||
| 12 | readonly message: string; | ||
| 13 | readonly exitCode: number | null; | ||
| 14 | readonly stderr: string; | ||
| 15 | }> {} | ||
| 16 | |||
| 17 | export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ | ||
| 18 | readonly cause: unknown; | ||
| 19 | }> {} | ||
| 20 | |||
| 21 | export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ | ||
| 22 | readonly reason: string; | ||
| 23 | }> {} | ||
| 24 | |||
| 25 | const TailscaleStatusSelf = Schema.Struct({ | ||
| 26 | DNSName: Schema.optional(Schema.Unknown), | ||
| 27 | TailscaleIPs: Schema.optional(Schema.Unknown), | ||
| 28 | }); | ||
| 29 | |||
| 30 | const TailscaleStatusJson = Schema.Struct({ | ||
| 31 | Self: Schema.optional(TailscaleStatusSelf), | ||
| 32 | }); | ||
| 33 | |||
| 34 | export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; | ||
| 35 | export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; | ||
| 36 | |||
| 37 | export interface TailscaleStatus { | ||
| 38 | readonly magicDnsName: string | null; | ||
| 39 | readonly tailnetIpv4Addresses: readonly string[]; | ||
| 40 | } | ||
| 41 | |||
| 42 | const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => | ||
| 43 | stream.pipe( | ||
| 44 | Stream.decodeText(), | ||
| 45 | Stream.runFold( | ||
| 46 | () => "", | ||
| 47 | (acc, chunk) => acc + chunk, | ||
| 48 | ), | ||
| 49 | ); | ||
| 50 | |||
| 51 | const collectStderr = collectStdout; | ||
| 52 | |||
| 53 | const tailscaleCommandError = ( | ||
| 54 | args: readonly string[], | ||
| 55 | message: string, | ||
| 56 | exitCode: number | null, | ||
| 57 | stderr = "", | ||
| 58 | ): TailscaleCommandError => | ||
| 59 | new TailscaleCommandError({ | ||
| 60 | command: ["tailscale", ...args], | ||
| 61 | message, | ||
| 62 | exitCode, | ||
| 63 | stderr, | ||
| 64 | }); | ||
| 65 | |||
| 66 | const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); | ||
| 67 | |||
| 68 | function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { | ||
| 69 | const dnsName = status.Self?.DNSName; | ||
| 70 | if (typeof dnsName !== "string") { | ||
| 71 | return null; | ||
| 72 | } | ||
| 73 | |||
| 74 | const normalized = dnsName.trim().replace(/\.$/u, ""); | ||
| 75 | return normalized.length > 0 ? normalized : null; | ||
| 76 | } | ||
| 77 | |||
| 78 | export const parseTailscaleMagicDnsName = ( | ||
| 79 | rawStatusJson: string, | ||
| 80 | ): Effect.Effect<string | null, TailscaleStatusParseError> => | ||
| 81 | decodeTailscaleStatusJson(rawStatusJson).pipe( | ||
| 82 | Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), | ||
| 83 | Effect.map(normalizeMagicDnsName), | ||
| 84 | ); | ||
| 85 | |||
| 86 | export function isTailscaleIpv4Address(address: string): boolean { | ||
| 87 | const parts = address.split("."); | ||
| 88 | if (parts.length !== 4) { | ||
| 89 | return false; | ||
| 90 | } | ||
| 91 | const [first, second, third, fourth] = parts.map((part) => Number.parseInt(part, 10)); | ||
| 92 | if ( | ||
| 93 | first === undefined || | ||
| 94 | second === undefined || | ||
| 95 | third === undefined || | ||
| 96 | fourth === undefined || | ||
| 97 | [first, second, third, fourth].some((part) => !Number.isInteger(part) || part < 0 || part > 255) | ||
| 98 | ) { | ||
| 99 | return false; | ||
| 100 | } | ||
| 101 | return first === 100 && second >= 64 && second <= 127; | ||
| 102 | } | ||
| 103 | |||
| 104 | export const parseTailscaleStatus = ( | ||
| 105 | rawStatusJson: string, | ||
| 106 | ): Effect.Effect<TailscaleStatus, TailscaleStatusParseError> => | ||
| 107 | decodeTailscaleStatusJson(rawStatusJson).pipe( | ||
| 108 | Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), | ||
| 109 | Effect.map((parsed) => { | ||
| 110 | const rawIps = parsed.Self?.TailscaleIPs; | ||
| 111 | const tailnetIpv4Addresses = Array.isArray(rawIps) | ||
| 112 | ? rawIps | ||
| 113 | .filter((address): address is string => typeof address === "string") | ||
| 114 | .filter(isTailscaleIpv4Address) | ||
| 115 | : []; | ||
| 116 | |||
| 117 | return { | ||
| 118 | magicDnsName: normalizeMagicDnsName(parsed), | ||
| 119 | tailnetIpv4Addresses, | ||
| 120 | }; | ||
| 121 | }), | ||
| 122 | ); | ||
| 123 | |||
| 124 | export const readTailscaleStatus: Effect.Effect< | ||
| 125 | TailscaleStatus, | ||
| 126 | TailscaleCommandError | TailscaleStatusParseError, | ||
| 127 | ChildProcessSpawner.ChildProcessSpawner | ||
| 128 | > = Effect.gen(function* () { | ||
| 129 | const args = ["status", "--json"]; | ||
| 130 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 131 | const child = yield* spawner | ||
| 132 | .spawn( | ||
| 133 | ChildProcess.make("tailscale", args, { | ||
| 134 | shell: process.platform === "win32", | ||
| 135 | }), | ||
| 136 | ) | ||
| 137 | .pipe( | ||
| 138 | Effect.mapError((cause) => | ||
| 139 | tailscaleCommandError( | ||
| 140 | args, | ||
| 141 | cause instanceof Error ? cause.message : "Failed to spawn tailscale status.", | ||
| 142 | null, | ||
| 143 | ), | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 147 | [ | ||
| 148 | collectStdout(child.stdout), | ||
| 149 | collectStderr(child.stderr), | ||
| 150 | child.exitCode.pipe(Effect.map(Number)), | ||
| 151 | ], | ||
| 152 | { concurrency: "unbounded" }, | ||
| 153 | ).pipe( | ||
| 154 | Effect.mapError((cause) => | ||
| 155 | tailscaleCommandError( | ||
| 156 | args, | ||
| 157 | cause instanceof Error ? cause.message : "Failed to run tailscale status.", | ||
| 158 | null, | ||
| 159 | ), | ||
| 160 | ), | ||
| 161 | ); | ||
| 162 | if (exitCode !== 0) { | ||
| 163 | return yield* tailscaleCommandError( | ||
| 164 | args, | ||
| 165 | `Tailscale status exited with code ${exitCode}.`, | ||
| 166 | exitCode, | ||
| 167 | stderr, | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | return yield* parseTailscaleStatus(stdout); | ||
| 171 | }).pipe( | ||
| 172 | Effect.scoped, | ||
| 173 | Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS), | ||
| 174 | Effect.flatMap((result) => | ||
| 175 | Option.match(result, { | ||
| 176 | onNone: () => | ||
| 177 | Effect.fail( | ||
| 178 | tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null), | ||
| 179 | ), | ||
| 180 | onSome: Effect.succeed, | ||
| 181 | }), | ||
| 182 | ), | ||
| 183 | ); | ||
| 184 | |||
| 185 | export function buildTailscaleHttpsBaseUrl(input: { | ||
| 186 | readonly magicDnsName: string; | ||
| 187 | readonly servePort?: number; | ||
| 188 | }): string { | ||
| 189 | const url = new URL(`https://${input.magicDnsName}`); | ||
| 190 | const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; | ||
| 191 | if (servePort !== DEFAULT_TAILSCALE_SERVE_PORT) { | ||
| 192 | url.port = String(servePort); | ||
| 193 | } | ||
| 194 | url.pathname = "/"; | ||
| 195 | return url.toString(); | ||
| 196 | } | ||
| 197 | |||
| 198 | const runTailscaleCommand = ( | ||
| 199 | args: readonly string[], | ||
| 200 | input: { | ||
| 201 | readonly spawnMessage: string; | ||
| 202 | readonly runMessage: string; | ||
| 203 | readonly exitMessage: (exitCode: number) => string; | ||
| 204 | readonly timeoutMessage: string; | ||
| 205 | readonly timeoutMs: number; | ||
| 206 | }, | ||
| 207 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => | ||
| 208 | Effect.gen(function* () { | ||
| 209 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 210 | const child = yield* spawner | ||
| 211 | .spawn( | ||
| 212 | ChildProcess.make("tailscale", args, { | ||
| 213 | shell: process.platform === "win32", | ||
| 214 | }), | ||
| 215 | ) | ||
| 216 | .pipe( | ||
| 217 | Effect.mapError((cause) => | ||
| 218 | tailscaleCommandError( | ||
| 219 | args, | ||
| 220 | cause instanceof Error ? cause.message : input.spawnMessage, | ||
| 221 | null, | ||
| 222 | ), | ||
| 223 | ), | ||
| 224 | ); | ||
| 225 | const [stderr, exitCode] = yield* Effect.all( | ||
| 226 | [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], | ||
| 227 | { concurrency: "unbounded" }, | ||
| 228 | ).pipe( | ||
| 229 | Effect.mapError((cause) => | ||
| 230 | tailscaleCommandError( | ||
| 231 | args, | ||
| 232 | cause instanceof Error ? cause.message : input.runMessage, | ||
| 233 | null, | ||
| 234 | ), | ||
| 235 | ), | ||
| 236 | ); | ||
| 237 | if (exitCode !== 0) { | ||
| 238 | return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr); | ||
| 239 | } | ||
| 240 | }).pipe( | ||
| 241 | Effect.scoped, | ||
| 242 | Effect.timeoutOption(input.timeoutMs), | ||
| 243 | Effect.flatMap((result) => | ||
| 244 | Option.match(result, { | ||
| 245 | onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), | ||
| 246 | onSome: Effect.succeed, | ||
| 247 | }), | ||
| 248 | ), | ||
| 249 | ); | ||
| 250 | |||
| 251 | export const ensureTailscaleServe = (input: { | ||
| 252 | readonly localPort: number; | ||
| 253 | readonly servePort?: number; | ||
| 254 | readonly localHost?: string; | ||
| 255 | }): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => { | ||
| 256 | const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; | ||
| 257 | const localHost = input.localHost ?? "127.0.0.1"; | ||
| 258 | const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; | ||
| 259 | return runTailscaleCommand(args, { | ||
| 260 | spawnMessage: "Failed to spawn tailscale serve.", | ||
| 261 | runMessage: "Failed to run tailscale serve.", | ||
| 262 | exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, | ||
| 263 | timeoutMessage: "Tailscale serve timed out.", | ||
| 264 | timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, | ||
| 265 | }); | ||
| 266 | }; | ||
| 267 | |||
| 268 | export const disableTailscaleServe = ( | ||
| 269 | input: { | ||
| 270 | readonly servePort?: number; | ||
| 271 | } = {}, | ||
| 272 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => | ||
| 273 | Effect.gen(function* () { | ||
| 274 | const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; | ||
| 275 | return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], { | ||
| 276 | spawnMessage: "Failed to spawn tailscale serve off.", | ||
| 277 | runMessage: "Failed to run tailscale serve off.", | ||
| 278 | exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, | ||
| 279 | timeoutMessage: "Tailscale serve off timed out.", | ||
| 280 | timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, | ||
| 281 | }); | ||
| 282 | }); | ||
| 283 | |||
| 284 | export const probeTailscaleHttpsEndpoint = (input: { | ||
| 285 | readonly baseUrl: string; | ||
| 286 | readonly timeoutMs?: number; | ||
| 287 | }): Effect.Effect<boolean, never, HttpClient.HttpClient> => | ||
| 288 | Effect.gen(function* () { | ||
| 289 | const client = yield* HttpClient.HttpClient; | ||
| 290 | const response = yield* Effect.gen(function* () { | ||
| 291 | const url = new URL("/.well-known/t3/environment", input.baseUrl); | ||
| 292 | const request = HttpClientRequest.get(url.toString()); | ||
| 293 | return yield* client.execute(request); | ||
| 294 | }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS)); | ||
| 295 | |||
| 296 | return Option.match(response, { | ||
| 297 | onNone: () => false, | ||
| 298 | onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300, | ||
| 299 | }); | ||
| 300 | }).pipe(Effect.catch(() => Effect.succeed(false))); | ||
| 301 | |||
| 302 | export const resolveTailscaleHttpsBaseUrl = ( | ||
| 303 | input: { | ||
| 304 | readonly servePort?: number; | ||
| 305 | } = {}, | ||
| 306 | ): Effect.Effect< | ||
| 307 | string | null, | ||
| 308 | TailscaleCommandError | TailscaleStatusParseError, | ||
| 309 | ChildProcessSpawner.ChildProcessSpawner | ||
| 310 | > => | ||
| 311 | readTailscaleStatus.pipe( | ||
| 312 | Effect.map((status) => | ||
| 313 | status.magicDnsName | ||
| 314 | ? buildTailscaleHttpsBaseUrl({ | ||
| 315 | magicDnsName: status.magicDnsName, | ||
| 316 | ...(input.servePort === undefined ? {} : { servePort: input.servePort }), | ||
| 317 | }) | ||
| 318 | : null, | ||
| 319 | ), | ||
| 320 | ); | ||
packages/tailscale/tsconfig.json created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "plugins": [ | ||
| 5 | { | ||
| 6 | "name": "@effect/language-service", | ||
| 7 | "diagnosticSeverity": { | ||
| 8 | "importFromBarrel": "error", | ||
| 9 | "anyUnknownInErrorContext": "warning", | ||
| 10 | "instanceOfSchema": "warning", | ||
| 11 | "deterministicKeys": "warning" | ||
| 12 | } | ||
| 13 | } | ||
| 14 | ] | ||
| 15 | }, | ||
| 16 | "include": ["src"] | ||
| 17 | } | ||
patches/effect@4.0.0-beta.59.patch created+277| ... | @@ -0,0 +1,277 @@ | ||
| 1 | diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts | ||
| 2 | index 7c5970042df67a25b4145df017960b3e6febed9c..7af3374632d53ad59f24e80835aa6741853b1f2b 100644 | ||
| 3 | --- a/dist/unstable/rpc/RpcClient.d.ts | ||
| 4 | +++ b/dist/unstable/rpc/RpcClient.d.ts | ||
| 5 | @@ -2,6 +2,7 @@ import * as Cause from "../../Cause.ts"; | ||
| 6 | import * as Context from "../../Context.ts"; | ||
| 7 | import type * as Duration from "../../Duration.ts"; | ||
| 8 | import * as Effect from "../../Effect.ts"; | ||
| 9 | +import * as Exit from "../../Exit.ts"; | ||
| 10 | import * as Layer from "../../Layer.ts"; | ||
| 11 | import * as Queue from "../../Queue.ts"; | ||
| 12 | import * as Schedule from "../../Schedule.ts"; | ||
| 13 | @@ -192,9 +193,40 @@ export declare const layerProtocolWorker: (options: { | ||
| 14 | readonly targetUtilization?: number | undefined; | ||
| 15 | readonly timeToLive: Duration.Input; | ||
| 16 | }) => Layer.Layer<Protocol, WorkerError, Worker.WorkerPlatform | Worker.Spawner>; | ||
| 17 | +declare const RequestHooks_base: Context.ServiceClass<RequestHooks, "effect/rpc/RpcClient/RequestHooks", { | ||
| 18 | + readonly onRequestStart?: ((info: { | ||
| 19 | + readonly id: RequestId; | ||
| 20 | + readonly tag: string; | ||
| 21 | + readonly stream: boolean; | ||
| 22 | + }) => Effect.Effect<void>) | undefined; | ||
| 23 | + readonly onRequestChunk?: ((info: { | ||
| 24 | + readonly id: RequestId; | ||
| 25 | + readonly tag: string; | ||
| 26 | + readonly chunkCount: number; | ||
| 27 | + }) => Effect.Effect<void>) | undefined; | ||
| 28 | + readonly onRequestExit?: ((info: { | ||
| 29 | + readonly id: RequestId; | ||
| 30 | + readonly tag: string; | ||
| 31 | + readonly stream: boolean; | ||
| 32 | + readonly exit: Exit.Exit<unknown, unknown>; | ||
| 33 | + }) => Effect.Effect<void>) | undefined; | ||
| 34 | + readonly onRequestInterrupt?: ((info: { | ||
| 35 | + readonly id: RequestId; | ||
| 36 | + readonly tag?: string | undefined; | ||
| 37 | + }) => Effect.Effect<void>) | undefined; | ||
| 38 | +}>; | ||
| 39 | +/** | ||
| 40 | + * @since 4.0.0 | ||
| 41 | + * @category RequestHooks | ||
| 42 | + */ | ||
| 43 | +export declare class RequestHooks extends RequestHooks_base { | ||
| 44 | +} | ||
| 45 | declare const ConnectionHooks_base: Context.ServiceClass<ConnectionHooks, "effect/rpc/RpcClient/ConnectionHooks", { | ||
| 46 | readonly onConnect: Effect.Effect<void>; | ||
| 47 | readonly onDisconnect: Effect.Effect<void>; | ||
| 48 | + readonly onPing?: Effect.Effect<void> | undefined; | ||
| 49 | + readonly onPong?: Effect.Effect<void> | undefined; | ||
| 50 | + readonly onPingTimeout?: Effect.Effect<void> | undefined; | ||
| 51 | }>; | ||
| 52 | /** | ||
| 53 | * @since 4.0.0 | ||
| 54 | diff --git a/dist/unstable/rpc/RpcClient.js b/dist/unstable/rpc/RpcClient.js | ||
| 55 | index 951dfc2d667a9136cfa714dcd3195e6575917fde..599c0345873bedd72bc93afe80eab0c676319421 100644 | ||
| 56 | --- a/dist/unstable/rpc/RpcClient.js | ||
| 57 | +++ b/dist/unstable/rpc/RpcClient.js | ||
| 58 | @@ -42,6 +42,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 59 | const services = yield* Effect.context(); | ||
| 60 | const scope = Context.get(services, Scope.Scope); | ||
| 61 | const entries = new Map(); | ||
| 62 | + const hooks = yield* Effect.serviceOption(RequestHooks); | ||
| 63 | let isShutdown = false; | ||
| 64 | yield* Scope.addFinalizer(scope, Effect.withFiber(parent => { | ||
| 65 | isShutdown = true; | ||
| 66 | @@ -79,6 +80,11 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 67 | return Effect.interrupt; | ||
| 68 | } | ||
| 69 | const id = generateRequestId(); | ||
| 70 | + const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ | ||
| 71 | + id, | ||
| 72 | + tag: rpc._tag, | ||
| 73 | + stream: false | ||
| 74 | + }) : Effect.void; | ||
| 75 | const send = middleware(message => options.onFromClient({ | ||
| 76 | message, | ||
| 77 | context, | ||
| 78 | @@ -96,7 +102,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 79 | headers: Headers.merge(parentFiber.getRef(CurrentHeaders), headers) | ||
| 80 | }); | ||
| 81 | if (discard) { | ||
| 82 | - return send; | ||
| 83 | + return onStart.pipe(Effect.andThen(send)); | ||
| 84 | } | ||
| 85 | let fiber; | ||
| 86 | return Effect.onInterrupt(Effect.callback(resume => { | ||
| 87 | @@ -114,7 +120,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 88 | } | ||
| 89 | }; | ||
| 90 | entries.set(id, entry); | ||
| 91 | - fiber = send.pipe(span ? Effect.withParentSpan(span, { | ||
| 92 | + fiber = onStart.pipe(Effect.andThen(send), span ? Effect.withParentSpan(span, { | ||
| 93 | captureStackTrace: false | ||
| 94 | }) : identity, Effect.runForkWith(parentFiber.context)); | ||
| 95 | fiber.addObserver(exit => { | ||
| 96 | @@ -123,8 +129,9 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 97 | } | ||
| 98 | }); | ||
| 99 | }), interruptors => { | ||
| 100 | + const entry = entries.get(id); | ||
| 101 | entries.delete(id); | ||
| 102 | - return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context)); | ||
| 103 | + return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context, entry?.rpc._tag)); | ||
| 104 | }); | ||
| 105 | }); | ||
| 106 | const onStreamRequest = Effect.fnUntraced(function* (rpc, middleware, payload, headers, streamBufferSize, context) { | ||
| 107 | @@ -136,11 +143,17 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 108 | }); | ||
| 109 | const fiber = Fiber.getCurrent(); | ||
| 110 | const id = generateRequestId(); | ||
| 111 | + const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ | ||
| 112 | + id, | ||
| 113 | + tag: rpc._tag, | ||
| 114 | + stream: true | ||
| 115 | + }) : Effect.void; | ||
| 116 | const scope = Context.getUnsafe(fiber.context, Scope.Scope); | ||
| 117 | yield* Scope.addFinalizerExit(scope, exit => { | ||
| 118 | - if (!entries.has(id)) return Effect.void; | ||
| 119 | + const entry = entries.get(id); | ||
| 120 | + if (!entry) return Effect.void; | ||
| 121 | entries.delete(id); | ||
| 122 | - return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context); | ||
| 123 | + return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context, entry.rpc._tag); | ||
| 124 | }); | ||
| 125 | const queue = yield* Queue.bounded(streamBufferSize); | ||
| 126 | entries.set(id, { | ||
| 127 | @@ -148,9 +161,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 128 | rpc, | ||
| 129 | queue, | ||
| 130 | scope, | ||
| 131 | - context | ||
| 132 | + context, | ||
| 133 | + chunkCount: 0 | ||
| 134 | }); | ||
| 135 | - yield* middleware(message => options.onFromClient({ | ||
| 136 | + yield* onStart.pipe(Effect.andThen(middleware(message => options.onFromClient({ | ||
| 137 | message, | ||
| 138 | context, | ||
| 139 | discard: false | ||
| 140 | @@ -165,7 +179,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 141 | sampled: span.sampled | ||
| 142 | } : {}), | ||
| 143 | headers: Headers.merge(fiber.getRef(CurrentHeaders), headers) | ||
| 144 | - }).pipe(span ? Effect.withParentSpan(span, { | ||
| 145 | + }))).pipe(span ? Effect.withParentSpan(span, { | ||
| 146 | captureStackTrace: false | ||
| 147 | }) : identity, Effect.catchCause(error => Queue.failCause(queue, error)), Effect.interruptible, Effect.forkIn(scope, { | ||
| 148 | startImmediately: true | ||
| 149 | @@ -195,7 +209,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 150 | }); | ||
| 151 | }; | ||
| 152 | }; | ||
| 153 | - const sendInterrupt = (requestId, interruptors, context) => Effect.callback(resume => { | ||
| 154 | + const sendInterrupt = (requestId, interruptors, context, tag) => (Option.isSome(hooks) && hooks.value.onRequestInterrupt ? hooks.value.onRequestInterrupt({ | ||
| 155 | + id: requestId, | ||
| 156 | + tag | ||
| 157 | + }) : Effect.void).pipe(Effect.andThen(Effect.callback(resume => { | ||
| 158 | const parentFiber = Fiber.getCurrent(); | ||
| 159 | const fiber = options.onFromClient({ | ||
| 160 | message: { | ||
| 161 | @@ -209,7 +226,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 162 | fiber.addObserver(() => { | ||
| 163 | resume(Effect.void); | ||
| 164 | }); | ||
| 165 | - }); | ||
| 166 | + }))); | ||
| 167 | const write = message => { | ||
| 168 | switch (message._tag) { | ||
| 169 | case "Chunk": | ||
| 170 | @@ -217,7 +234,13 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 171 | const requestId = message.requestId; | ||
| 172 | const entry = entries.get(requestId); | ||
| 173 | if (!entry || entry._tag !== "Queue") return Effect.void; | ||
| 174 | - return Queue.offerAll(entry.queue, message.values).pipe(supportsAck ? Effect.flatMap(() => options.onFromClient({ | ||
| 175 | + const chunkCount = entry.chunkCount += message.values.length; | ||
| 176 | + const onChunk = Option.isSome(hooks) && hooks.value.onRequestChunk ? hooks.value.onRequestChunk({ | ||
| 177 | + id: requestId, | ||
| 178 | + tag: entry.rpc._tag, | ||
| 179 | + chunkCount | ||
| 180 | + }) : Effect.void; | ||
| 181 | + return Queue.offerAll(entry.queue, message.values).pipe(Effect.andThen(onChunk), supportsAck ? Effect.flatMap(() => options.onFromClient({ | ||
| 182 | message: { | ||
| 183 | _tag: "Ack", | ||
| 184 | requestId: message.requestId | ||
| 185 | @@ -232,11 +255,18 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 186 | const entry = entries.get(requestId); | ||
| 187 | if (!entry) return Effect.void; | ||
| 188 | entries.delete(requestId); | ||
| 189 | + const onExit = Option.isSome(hooks) && hooks.value.onRequestExit ? hooks.value.onRequestExit({ | ||
| 190 | + id: requestId, | ||
| 191 | + tag: entry.rpc._tag, | ||
| 192 | + stream: entry._tag === "Queue", | ||
| 193 | + exit: message.exit | ||
| 194 | + }) : Effect.void; | ||
| 195 | if (entry._tag === "Effect") { | ||
| 196 | - entry.resume(message.exit); | ||
| 197 | - return Effect.void; | ||
| 198 | + return onExit.pipe(Effect.andThen(Effect.sync(() => { | ||
| 199 | + entry.resume(message.exit); | ||
| 200 | + }))); | ||
| 201 | } | ||
| 202 | - return message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause); | ||
| 203 | + return onExit.pipe(Effect.andThen(message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause))); | ||
| 204 | } | ||
| 205 | case "Defect": | ||
| 206 | { | ||
| 207 | @@ -530,7 +560,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 208 | const requestClientMap = new Map(); | ||
| 209 | const write = yield* socket.writer; | ||
| 210 | let parser = serialization.makeUnsafe(); | ||
| 211 | - const pinger = yield* makePinger(write(parser.encode(constPing))); | ||
| 212 | + const pinger = yield* makePinger(write(parser.encode(constPing)), Option.isSome(hooks) ? hooks.value : undefined); | ||
| 213 | let currentError; | ||
| 214 | const onOpen = Effect.suspend(() => { | ||
| 215 | currentError = undefined; | ||
| 216 | @@ -550,8 +580,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 217 | body: () => { | ||
| 218 | const response = responses[i++]; | ||
| 219 | if (response._tag === "Pong") { | ||
| 220 | - pinger.onPong(); | ||
| 221 | - return Effect.void; | ||
| 222 | + return pinger.onPong; | ||
| 223 | } | ||
| 224 | if ("requestId" in response) { | ||
| 225 | const clientId = requestClientMap.get(response.requestId); | ||
| 226 | @@ -579,12 +608,12 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 227 | } | ||
| 228 | }, { | ||
| 229 | onOpen | ||
| 230 | - }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => Effect.fail(new Socket.SocketError({ | ||
| 231 | + }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => (Option.isSome(hooks) && hooks.value.onPingTimeout ? hooks.value.onPingTimeout : Effect.void).pipe(Effect.andThen(Effect.fail(new Socket.SocketError({ | ||
| 232 | reason: new Socket.SocketOpenError({ | ||
| 233 | kind: "Timeout", | ||
| 234 | cause: new Error("ping timeout") | ||
| 235 | }) | ||
| 236 | - }))))); | ||
| 237 | + }))))))); | ||
| 238 | }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ | ||
| 239 | reason: new Socket.SocketCloseError({ | ||
| 240 | code: 1000 | ||
| 241 | @@ -626,20 +655,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 242 | }; | ||
| 243 | })); | ||
| 244 | const defaultRetryPolicy = /*#__PURE__*/Schedule.exponential(500, 1.5).pipe(/*#__PURE__*/Schedule.either(/*#__PURE__*/Schedule.spaced(5000))); | ||
| 245 | -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { | ||
| 246 | +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { | ||
| 247 | let recievedPong = true; | ||
| 248 | const latch = Latch.makeUnsafe(); | ||
| 249 | const reset = () => { | ||
| 250 | recievedPong = true; | ||
| 251 | latch.closeUnsafe(); | ||
| 252 | }; | ||
| 253 | - const onPong = () => { | ||
| 254 | + const onPong = Effect.sync(() => { | ||
| 255 | recievedPong = true; | ||
| 256 | - }; | ||
| 257 | + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); | ||
| 258 | yield* Effect.suspend(() => { | ||
| 259 | if (!recievedPong) return latch.open; | ||
| 260 | recievedPong = false; | ||
| 261 | - return writePing; | ||
| 262 | + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); | ||
| 263 | }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); | ||
| 264 | return { | ||
| 265 | timeout: latch.await, | ||
| 266 | @@ -773,6 +802,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun | ||
| 267 | * @category protocol | ||
| 268 | */ | ||
| 269 | export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); | ||
| 270 | +/** | ||
| 271 | + * @since 4.0.0 | ||
| 272 | + * @category RequestHooks | ||
| 273 | + */ | ||
| 274 | +export class RequestHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/RequestHooks") {} | ||
| 275 | /** | ||
| 276 | * @since 4.0.0 | ||
| 277 | * @category ConnectionHooks | ||
scripts/apply-web-brand-assets.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { Effect, FileSystem, Option, Path } from "effect"; | ||
| 6 | import { Argument, Command } from "effect/unstable/cli"; | ||
| 7 | import { resolveWebIconOverrides, type WebAssetBrand } from "./lib/brand-assets.ts"; | ||
| 8 | |||
| 9 | const WEB_ASSET_BRANDS = [ | ||
| 10 | "development", | ||
| 11 | "production", | ||
| 12 | ] as const satisfies ReadonlyArray<WebAssetBrand>; | ||
| 13 | |||
| 14 | export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* ( | ||
| 15 | brand: WebAssetBrand, | ||
| 16 | targetDirectory: string, | ||
| 17 | ) { | ||
| 18 | const fs = yield* FileSystem.FileSystem; | ||
| 19 | const path = yield* Path.Path; | ||
| 20 | const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); | ||
| 21 | |||
| 22 | yield* Effect.forEach( | ||
| 23 | resolveWebIconOverrides(brand, targetDirectory), | ||
| 24 | (override) => | ||
| 25 | fs.copyFile( | ||
| 26 | path.join(repoRoot, override.sourceRelativePath), | ||
| 27 | path.join(repoRoot, override.targetRelativePath), | ||
| 28 | ), | ||
| 29 | { concurrency: "unbounded" }, | ||
| 30 | ); | ||
| 31 | }); | ||
| 32 | |||
| 33 | export const applyWebBrandAssetsCommand = Command.make( | ||
| 34 | "apply-web-brand-assets", | ||
| 35 | { | ||
| 36 | brand: Argument.choice("brand", WEB_ASSET_BRANDS).pipe( | ||
| 37 | Argument.withDescription("Asset brand to copy into the hosted web output directory."), | ||
| 38 | ), | ||
| 39 | targetDirectory: Argument.string("target-directory").pipe( | ||
| 40 | Argument.withDescription("Output directory that contains the hosted web build assets."), | ||
| 41 | Argument.optional, | ||
| 42 | ), | ||
| 43 | }, | ||
| 44 | ({ brand, targetDirectory }) => | ||
| 45 | applyWebBrandAssets( | ||
| 46 | brand, | ||
| 47 | Option.getOrElse(targetDirectory, () => "apps/web/dist"), | ||
| 48 | ), | ||
| 49 | ).pipe(Command.withDescription("Copy web brand assets into a built hosted web app.")); | ||
| 50 | |||
| 51 | if (import.meta.main) { | ||
| 52 | Command.run(applyWebBrandAssetsCommand, { version: "0.0.0" }).pipe( | ||
| 53 | Effect.provide(NodeServices.layer), | ||
| 54 | NodeRuntime.runMain, | ||
| 55 | ); | ||
| 56 | } | ||
scripts/lib/brand-assets.test.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | BRAND_ASSET_PATHS, | ||
| 5 | DEVELOPMENT_ICON_OVERRIDES, | ||
| 6 | PUBLISH_ICON_OVERRIDES, | ||
| 7 | resolveWebIconOverrides, | ||
| 8 | } from "./brand-assets.ts"; | ||
| 9 | |||
| 10 | describe("brand-assets", () => { | ||
| 11 | it("maps server publish web assets to production icons", () => { | ||
| 12 | expect(PUBLISH_ICON_OVERRIDES).toEqual([ | ||
| 13 | { | ||
| 14 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebFaviconIco, | ||
| 15 | targetRelativePath: "dist/client/favicon.ico", | ||
| 16 | }, | ||
| 17 | { | ||
| 18 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon16Png, | ||
| 19 | targetRelativePath: "dist/client/favicon-16x16.png", | ||
| 20 | }, | ||
| 21 | { | ||
| 22 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon32Png, | ||
| 23 | targetRelativePath: "dist/client/favicon-32x32.png", | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, | ||
| 27 | targetRelativePath: "dist/client/apple-touch-icon.png", | ||
| 28 | }, | ||
| 29 | ]); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("maps server build web assets to development icons", () => { | ||
| 33 | expect(DEVELOPMENT_ICON_OVERRIDES[0]).toEqual({ | ||
| 34 | sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFaviconIco, | ||
| 35 | targetRelativePath: "dist/client/favicon.ico", | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("can target hosted web dist directly", () => { | ||
| 40 | expect(resolveWebIconOverrides("production", "apps/web/dist")).toContainEqual({ | ||
| 41 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, | ||
| 42 | targetRelativePath: "apps/web/dist/apple-touch-icon.png", | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | }); | ||
scripts/lib/brand-assets.ts+49-34| ... | @@ -19,45 +19,60 @@ export const BRAND_ASSET_PATHS = { | ... | @@ -19,45 +19,60 @@ export const BRAND_ASSET_PATHS = { |
| 19 | developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png", | 19 | developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png", |
| 20 | } as const; | 20 | } as const; |
| 21 | 21 | ||
| 22 | export type WebAssetBrand = "development" | "production"; | ||
| 23 | |||
| 22 | export interface IconOverride { | 24 | export interface IconOverride { |
| 23 | readonly sourceRelativePath: string; | 25 | readonly sourceRelativePath: string; |
| 24 | readonly targetRelativePath: string; | 26 | readonly targetRelativePath: string; |
| 25 | } | 27 | } |
| 26 | 28 | ||
| 27 | export const DEVELOPMENT_ICON_OVERRIDES: ReadonlyArray<IconOverride> = [ | 29 | const WEB_ICON_TARGET_FILENAMES = { |
| 28 | { | 30 | faviconIco: "favicon.ico", |
| 29 | sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFaviconIco, | 31 | favicon16Png: "favicon-16x16.png", |
| 30 | targetRelativePath: "dist/client/favicon.ico", | 32 | favicon32Png: "favicon-32x32.png", |
| 31 | }, | 33 | appleTouchIconPng: "apple-touch-icon.png", |
| 32 | { | 34 | } as const; |
| 33 | sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFavicon16Png, | ||
| 34 | targetRelativePath: "dist/client/favicon-16x16.png", | ||
| 35 | }, | ||
| 36 | { | ||
| 37 | sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFavicon32Png, | ||
| 38 | targetRelativePath: "dist/client/favicon-32x32.png", | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | sourceRelativePath: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, | ||
| 42 | targetRelativePath: "dist/client/apple-touch-icon.png", | ||
| 43 | }, | ||
| 44 | ]; | ||
| 45 | 35 | ||
| 46 | export const PUBLISH_ICON_OVERRIDES: ReadonlyArray<IconOverride> = [ | 36 | const WEB_ICON_SOURCE_PATHS_BY_BRAND = { |
| 47 | { | 37 | development: { |
| 48 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebFaviconIco, | 38 | faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco, |
| 49 | targetRelativePath: "dist/client/favicon.ico", | 39 | favicon16Png: BRAND_ASSET_PATHS.developmentWebFavicon16Png, |
| 40 | favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png, | ||
| 41 | appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, | ||
| 50 | }, | 42 | }, |
| 51 | { | 43 | production: { |
| 52 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon16Png, | 44 | faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, |
| 53 | targetRelativePath: "dist/client/favicon-16x16.png", | 45 | favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png, |
| 46 | favicon32Png: BRAND_ASSET_PATHS.productionWebFavicon32Png, | ||
| 47 | appleTouchIconPng: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, | ||
| 54 | }, | 48 | }, |
| 55 | { | 49 | } as const satisfies Record<WebAssetBrand, Record<keyof typeof WEB_ICON_TARGET_FILENAMES, string>>; |
| 56 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon32Png, | 50 | |
| 57 | targetRelativePath: "dist/client/favicon-32x32.png", | 51 | export function resolveWebIconOverrides( |
| 58 | }, | 52 | brand: WebAssetBrand, |
| 59 | { | 53 | targetDirectory: string, |
| 60 | sourceRelativePath: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, | 54 | ): ReadonlyArray<IconOverride> { |
| 61 | targetRelativePath: "dist/client/apple-touch-icon.png", | 55 | const sourcePaths = WEB_ICON_SOURCE_PATHS_BY_BRAND[brand]; |
| 62 | }, | 56 | return [ |
| 63 | ]; | 57 | { |
| 58 | sourceRelativePath: sourcePaths.faviconIco, | ||
| 59 | targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.faviconIco}`, | ||
| 60 | }, | ||
| 61 | { | ||
| 62 | sourceRelativePath: sourcePaths.favicon16Png, | ||
| 63 | targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.favicon16Png}`, | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | sourceRelativePath: sourcePaths.favicon32Png, | ||
| 67 | targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.favicon32Png}`, | ||
| 68 | }, | ||
| 69 | { | ||
| 70 | sourceRelativePath: sourcePaths.appleTouchIconPng, | ||
| 71 | targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.appleTouchIconPng}`, | ||
| 72 | }, | ||
| 73 | ]; | ||
| 74 | } | ||
| 75 | |||
| 76 | export const DEVELOPMENT_ICON_OVERRIDES = resolveWebIconOverrides("development", "dist/client"); | ||
| 77 | |||
| 78 | export const PUBLISH_ICON_OVERRIDES = resolveWebIconOverrides("production", "dist/client"); |
scripts/release-smoke.ts+13| ... | @@ -24,6 +24,8 @@ const workspaceFiles = [ | ... | @@ -24,6 +24,8 @@ const workspaceFiles = [ |
| 24 | "packages/client-runtime/package.json", | 24 | "packages/client-runtime/package.json", |
| 25 | "packages/contracts/package.json", | 25 | "packages/contracts/package.json", |
| 26 | "packages/shared/package.json", | 26 | "packages/shared/package.json", |
| 27 | "packages/ssh/package.json", | ||
| 28 | "packages/tailscale/package.json", | ||
| 27 | "packages/effect-acp/package.json", | 29 | "packages/effect-acp/package.json", |
| 28 | "packages/effect-codex-app-server/package.json", | 30 | "packages/effect-codex-app-server/package.json", |
| 29 | "scripts/package.json", | 31 | "scripts/package.json", |
| ... | @@ -36,6 +38,17 @@ function copyWorkspaceManifestFixture(targetRoot: string): void { | ... | @@ -36,6 +38,17 @@ function copyWorkspaceManifestFixture(targetRoot: string): void { |
| 36 | mkdirSync(dirname(destinationPath), { recursive: true }); | 38 | mkdirSync(dirname(destinationPath), { recursive: true }); |
| 37 | cpSync(sourcePath, destinationPath); | 39 | cpSync(sourcePath, destinationPath); |
| 38 | } | 40 | } |
| 41 | |||
| 42 | const packageJson = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as { | ||
| 43 | readonly patchedDependencies?: Record<string, string>; | ||
| 44 | }; | ||
| 45 | |||
| 46 | for (const relativePath of Object.values(packageJson.patchedDependencies ?? {})) { | ||
| 47 | const sourcePath = resolve(repoRoot, relativePath); | ||
| 48 | const destinationPath = resolve(targetRoot, relativePath); | ||
| 49 | mkdirSync(dirname(destinationPath), { recursive: true }); | ||
| 50 | cpSync(sourcePath, destinationPath); | ||
| 51 | } | ||
| 39 | } | 52 | } |
| 40 | 53 | ||
| 41 | function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { | 54 | function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { |
turbo.json+2| ... | @@ -11,6 +11,8 @@ | ... | @@ -11,6 +11,8 @@ |
| 11 | "T3CODE_NO_BROWSER", | 11 | "T3CODE_NO_BROWSER", |
| 12 | "T3CODE_HOME", | 12 | "T3CODE_HOME", |
| 13 | "T3CODE_AUTH_TOKEN", | 13 | "T3CODE_AUTH_TOKEN", |
| 14 | "T3CODE_TAILSCALE_SERVE", | ||
| 15 | "T3CODE_TAILSCALE_SERVE_PORT", | ||
| 14 | "T3CODE_DESKTOP_WS_URL", | 16 | "T3CODE_DESKTOP_WS_URL", |
| 15 | "T3CODE_TRACE_MIN_LEVEL", | 17 | "T3CODE_TRACE_MIN_LEVEL", |
| 16 | "T3CODE_TRACE_TIMING_ENABLED", | 18 | "T3CODE_TRACE_TIMING_ENABLED", |