| author | |
| committer | |
| log | 57a299a7852b430613dfdd97ba76249ee9f374d5 |
| tree | 311db911020f881694e85d0657d82a851e4ba1d3 |
| parent | 74f7b434865c2d758c7b1cd5f52f4c96b76d03fb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>24 files changed, 806 insertions(+), 46 deletions(-)
apps/desktop/src/electron/ElectronShell.ts+11-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; | |
| 1 | 2 | import * as Context from "effect/Context"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -5,7 +6,16 @@ import * as Option from "effect/Option"; |
| 5 | 6 | |
| 6 | 7 | import * as Electron from "electron"; |
| 7 | 8 | |
| 8 | const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); | |
| 9 | // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) | |
| 10 | // must reach the OS handler; every other non-web scheme stays blocked. | |
| 11 | const SAFE_EXTERNAL_PROTOCOLS = new Set([ | |
| 12 | "http:", | |
| 13 | "https:", | |
| 14 | ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { | |
| 15 | const scheme = remoteSchemeForEditor(id); | |
| 16 | return scheme === undefined ? [] : [`${scheme}:`]; | |
| 17 | }), | |
| 18 | ]); | |
| 9 | 19 | |
| 10 | 20 | export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> { |
| 11 | 21 | if (typeof rawUrl !== "string") { |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | getLocalEnvironmentBearerToken, |
| 37 | 37 | getWindowFullscreenState, |
| 38 | 38 | openExternal, |
| 39 | probeRemoteEditors, | |
| 39 | 40 | pickFolder, |
| 40 | 41 | pickThemeFiles, |
| 41 | 42 | setTheme, |
| ... | ... | @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 83 | 84 | yield* ipc.handle(setTheme); |
| 84 | 85 | yield* ipc.handle(showContextMenu); |
| 85 | 86 | yield* ipc.handle(openExternal); |
| 87 | yield* ipc.handle(probeRemoteEditors); | |
| 86 | 88 | yield* ipc.handle(getUpdateState); |
| 87 | 89 | yield* ipc.handle(setUpdateChannel); |
| 88 | 90 | yield* ipc.handle(downloadUpdate); |
apps/desktop/src/ipc/channels.ts+1| ... | ... | @@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; |
| 3 | 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; | |
| 6 | 7 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 7 | 8 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; |
| 8 | 9 | export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; |
apps/desktop/src/ipc/methods/window.ts+28| ... | ... | @@ -3,12 +3,16 @@ import { |
| 3 | 3 | DesktopAppBrandingSchema, |
| 4 | 4 | DesktopEnvironmentBootstrapSchema, |
| 5 | 5 | DesktopThemeSchema, |
| 6 | EDITORS, | |
| 7 | EditorId, | |
| 6 | 8 | PickedThemeFileSchema, |
| 7 | 9 | PickFolderOptionsSchema, |
| 8 | 10 | PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 11 | REMOTE_CAPABLE_EDITOR_IDS, | |
| 9 | 12 | type DesktopEnvironmentBootstrap, |
| 10 | 13 | type PickedThemeFile, |
| 11 | 14 | } from "@t3tools/contracts"; |
| 15 | import { isCommandAvailable } from "@t3tools/shared/shell"; | |
| 12 | 16 | import * as NodeOS from "node:os"; |
| 13 | 17 | import * as FileSystem from "effect/FileSystem"; |
| 14 | 18 | import * as Path from "effect/Path"; |
| ... | ... | @@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({ |
| 261 | 265 | }), |
| 262 | 266 | }); |
| 263 | 267 | |
| 268 | export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ | |
| 269 | channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, | |
| 270 | payload: Schema.Undefined, | |
| 271 | result: Schema.Array(EditorId), | |
| 272 | // Probes THIS machine (where the renderer runs) for remote-capable editor | |
| 273 | // CLIs, unlike the server's probe which walks the environment host's PATH. | |
| 274 | // A Finder-launched app can miss PATH entries; an empty result makes the | |
| 275 | // renderer fall back to VS Code only, so that fails soft. | |
| 276 | handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { | |
| 277 | const available: Array<EditorId> = []; | |
| 278 | for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { | |
| 279 | const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; | |
| 280 | if (!commands) continue; | |
| 281 | for (const command of commands) { | |
| 282 | if (yield* isCommandAvailable(command, { env: process.env })) { | |
| 283 | available.push(editorId); | |
| 284 | break; | |
| 285 | } | |
| 286 | } | |
| 287 | } | |
| 288 | return available; | |
| 289 | }), | |
| 290 | }); | |
| 291 | ||
| 264 | 292 | /** Theme files are a few KB; anything larger returns empty text and lets the |
| 265 | 293 | * renderer reject it by size without the contents ever crossing the bridge. */ |
| 266 | 294 | const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; |
apps/desktop/src/preload.ts+1| ... | ... | @@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 105 | 105 | ...(position === undefined ? {} : { position }), |
| 106 | 106 | }), |
| 107 | 107 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), |
| 108 | probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), | |
| 108 | 109 | onMenuAction: (listener) => { |
| 109 | 110 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 110 | 111 | if (typeof action !== "string") return; |
apps/desktop/src/wsl/DesktopWslBackend.test.ts+1| ... | ... | @@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed( |
| 77 | 77 | const netLayer = Layer.succeed(NetService.NetService, { |
| 78 | 78 | canListenOnHost: () => Effect.succeed(true), |
| 79 | 79 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 80 | hasListenerOnHost: () => Effect.succeed(false), | |
| 80 | 81 | reserveLoopbackPort: () => Effect.succeed(41773), |
| 81 | 82 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 82 | 83 | } satisfies NetService.NetService["Service"]); |
apps/server/src/environment/RemoteOpenTargets.test.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | import { it } from "@effect/vitest"; | |
| 2 | import { HostProcessHostname } from "@t3tools/shared/hostProcess"; | |
| 3 | import * as NetService from "@t3tools/shared/Net"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Sink from "effect/Sink"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 9 | import { describe, expect } from "vite-plus/test"; | |
| 10 | ||
| 11 | import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; | |
| 12 | ||
| 13 | const encoder = new TextEncoder(); | |
| 14 | ||
| 15 | const TAILSCALE_STATUS_JSON = JSON.stringify({ | |
| 16 | Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, | |
| 17 | }); | |
| 18 | ||
| 19 | /** Spawner whose `tailscale status --json` exits with the given output. */ | |
| 20 | const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) => | |
| 21 | Layer.succeed( | |
| 22 | ChildProcessSpawner.ChildProcessSpawner, | |
| 23 | ChildProcessSpawner.make(() => | |
| 24 | Effect.succeed( | |
| 25 | ChildProcessSpawner.makeHandle({ | |
| 26 | pid: ChildProcessSpawner.ProcessId(1), | |
| 27 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), | |
| 28 | isRunning: Effect.succeed(false), | |
| 29 | kill: () => Effect.void, | |
| 30 | unref: Effect.succeed(Effect.void), | |
| 31 | stdin: Sink.drain, | |
| 32 | stdout: Stream.make(encoder.encode(input.stdout)), | |
| 33 | stderr: Stream.empty, | |
| 34 | all: Stream.empty, | |
| 35 | getInputFd: () => Sink.drain, | |
| 36 | getOutputFd: () => Stream.empty, | |
| 37 | }), | |
| 38 | ), | |
| 39 | ), | |
| 40 | ); | |
| 41 | ||
| 42 | const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => | |
| 43 | Layer.succeed(NetService.NetService, { | |
| 44 | canListenOnHost: () => Effect.succeed(true), | |
| 45 | isPortAvailableOnLoopback: () => Effect.succeed(true), | |
| 46 | hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4), | |
| 47 | reserveLoopbackPort: () => Effect.succeed(40_000), | |
| 48 | findAvailablePort: (preferred) => Effect.succeed(preferred), | |
| 49 | }); | |
| 50 | ||
| 51 | const resolveTargets = (input: { | |
| 52 | readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; | |
| 53 | readonly tailscale: { readonly exitCode: number; readonly stdout: string }; | |
| 54 | readonly hostname: string; | |
| 55 | }) => | |
| 56 | Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( | |
| 57 | Effect.provideService(HostProcessHostname, input.hostname), | |
| 58 | Effect.provide( | |
| 59 | RemoteOpenTargets.layer.pipe( | |
| 60 | Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), | |
| 61 | ), | |
| 62 | ), | |
| 63 | ); | |
| 64 | ||
| 65 | const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; | |
| 66 | const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; | |
| 67 | ||
| 68 | describe("RemoteOpenTargets", () => { | |
| 69 | it.effect("advertises nothing when no sshd accepts on either loopback", () => | |
| 70 | Effect.gen(function* () { | |
| 71 | const targets = yield* resolveTargets({ | |
| 72 | sshd: { ipv4: false, ipv6: false }, | |
| 73 | tailscale: TAILSCALE_UP, | |
| 74 | hostname: "bb-1", | |
| 75 | }); | |
| 76 | expect(targets).toEqual([]); | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | it.effect("orders the tailnet name before the mDNS name", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | const targets = yield* resolveTargets({ | |
| 83 | sshd: { ipv4: true, ipv6: true }, | |
| 84 | tailscale: TAILSCALE_UP, | |
| 85 | hostname: "bb-1", | |
| 86 | }); | |
| 87 | expect(targets).toEqual([ | |
| 88 | { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, | |
| 89 | { kind: "mdns", host: "bb-1.local" }, | |
| 90 | ]); | |
| 91 | }), | |
| 92 | ); | |
| 93 | ||
| 94 | it.effect("accepts an sshd bound to IPv6 loopback only", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | const targets = yield* resolveTargets({ | |
| 97 | sshd: { ipv4: false, ipv6: true }, | |
| 98 | tailscale: TAILSCALE_DOWN, | |
| 99 | hostname: "bb-1", | |
| 100 | }); | |
| 101 | expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); | |
| 102 | }), | |
| 103 | ); | |
| 104 | ||
| 105 | it.effect("falls back to mDNS alone when tailscale is unavailable", () => | |
| 106 | Effect.gen(function* () { | |
| 107 | const targets = yield* resolveTargets({ | |
| 108 | sshd: { ipv4: true, ipv6: false }, | |
| 109 | tailscale: TAILSCALE_DOWN, | |
| 110 | hostname: "bb-1", | |
| 111 | }); | |
| 112 | expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); | |
| 113 | }), | |
| 114 | ); | |
| 115 | ||
| 116 | it.effect("shortens an FQDN hostname to its first label for mDNS", () => | |
| 117 | Effect.gen(function* () { | |
| 118 | const targets = yield* resolveTargets({ | |
| 119 | sshd: { ipv4: true, ipv6: true }, | |
| 120 | tailscale: TAILSCALE_DOWN, | |
| 121 | hostname: "bb-1.example.com", | |
| 122 | }); | |
| 123 | expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); | |
| 124 | }), | |
| 125 | ); | |
| 126 | }); |
apps/server/src/environment/RemoteOpenTargets.ts created+72| ... | ... | @@ -0,0 +1,72 @@ |
| 1 | /** | |
| 2 | * RemoteOpenTargets - resolves the SSH hostnames this environment advertises | |
| 3 | * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). | |
| 4 | * | |
| 5 | * The server can only check itself: sshd listening locally, tailscaled | |
| 6 | * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a | |
| 7 | * given name resolves from the viewer's machine is inherently client-side. | |
| 8 | * Targets are ordered most-reachable first (tailnet name works from anywhere | |
| 9 | * on the tailnet; `<hostname>.local` only on the same LAN). | |
| 10 | */ | |
| 11 | import { type RemoteOpenTarget } from "@t3tools/contracts"; | |
| 12 | import { HostProcessHostname } from "@t3tools/shared/hostProcess"; | |
| 13 | import * as NetService from "@t3tools/shared/Net"; | |
| 14 | import { readTailscaleStatus } from "@t3tools/tailscale"; | |
| 15 | import * as Context from "effect/Context"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 19 | ||
| 20 | const SSH_PORT = 22; | |
| 21 | ||
| 22 | export class RemoteOpenTargets extends Context.Service< | |
| 23 | RemoteOpenTargets, | |
| 24 | { | |
| 25 | readonly resolveTargets: () => Effect.Effect<ReadonlyArray<RemoteOpenTarget>>; | |
| 26 | } | |
| 27 | >()("t3/environment/RemoteOpenTargets") {} | |
| 28 | ||
| 29 | export const make = Effect.gen(function* () { | |
| 30 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 31 | const net = yield* NetService.NetService; | |
| 32 | ||
| 33 | const resolveTargets = Effect.gen(function* () { | |
| 34 | // No local sshd means no name can work; advertise nothing so clients | |
| 35 | // render a clear "no SSH route" state instead of links that hang. | |
| 36 | // Check both loopback families: sshd can be bound IPv6-only. | |
| 37 | const sshdListening = yield* Effect.zipWith( | |
| 38 | net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), | |
| 39 | net.hasListenerOnHost(SSH_PORT, "::1"), | |
| 40 | (ipv4, ipv6) => ipv4 || ipv6, | |
| 41 | ); | |
| 42 | if (!sshdListening) { | |
| 43 | return []; | |
| 44 | } | |
| 45 | ||
| 46 | const targets: Array<RemoteOpenTarget> = []; | |
| 47 | ||
| 48 | // Tailscale absent or down is the common case, not an error. | |
| 49 | const magicDnsName = yield* readTailscaleStatus.pipe( | |
| 50 | Effect.map((status) => status.magicDnsName), | |
| 51 | Effect.orElseSucceed(() => null), | |
| 52 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 53 | ); | |
| 54 | if (magicDnsName !== null) { | |
| 55 | targets.push({ kind: "tailscale", host: magicDnsName }); | |
| 56 | } | |
| 57 | ||
| 58 | // os.hostname() may already be an FQDN (macOS often reports | |
| 59 | // "Name.local"); mDNS names are always `<first-label>.local`. | |
| 60 | const hostname = yield* HostProcessHostname; | |
| 61 | const shortHostname = hostname.split(".")[0]?.trim(); | |
| 62 | if (shortHostname !== undefined && shortHostname.length > 0) { | |
| 63 | targets.push({ kind: "mdns", host: `${shortHostname}.local` }); | |
| 64 | } | |
| 65 | ||
| 66 | return targets; | |
| 67 | }); | |
| 68 | ||
| 69 | return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); | |
| 70 | }); | |
| 71 | ||
| 72 | export const layer = Layer.effect(RemoteOpenTargets, make); |
apps/server/src/preview/PortScanner.test.ts+3| ... | ... | @@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null; |
| 47 | 47 | const TestIntegrationNet = Layer.succeed(Net.NetService, { |
| 48 | 48 | canListenOnHost: () => Effect.succeed(true), |
| 49 | 49 | isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), |
| 50 | hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), | |
| 50 | 51 | reserveLoopbackPort: () => Effect.succeed(40_000), |
| 51 | 52 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 52 | 53 | }); |
| ... | ... | @@ -62,6 +63,7 @@ const makeProbeFailureLayer = ( |
| 62 | 63 | Layer.succeed(Net.NetService, { |
| 63 | 64 | canListenOnHost: () => Effect.succeed(true), |
| 64 | 65 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 66 | hasListenerOnHost: () => Effect.succeed(false), | |
| 65 | 67 | reserveLoopbackPort: () => Effect.succeed(40_000), |
| 66 | 68 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 67 | 69 | }), |
| ... | ... | @@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: { |
| 107 | 109 | Layer.succeed(Net.NetService, { |
| 108 | 110 | canListenOnHost: () => Effect.succeed(true), |
| 109 | 111 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 112 | hasListenerOnHost: () => Effect.succeed(false), | |
| 110 | 113 | reserveLoopbackPort: () => Effect.succeed(40_000), |
| 111 | 114 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 112 | 115 | }), |
apps/server/src/server.test.ts+10-4| ... | ... | @@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 107 | 107 | import * as GitManager from "./git/GitManager.ts"; |
| 108 | 108 | import * as Keybindings from "./keybindings.ts"; |
| 109 | 109 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 110 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | |
| 110 | 111 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 111 | 112 | import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; |
| 112 | 113 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| ... | ... | @@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: { |
| 650 | 651 | }), |
| 651 | 652 | ), |
| 652 | 653 | Layer.provide( |
| 653 | Layer.mock(ExternalLauncher.ExternalLauncher)({ | |
| 654 | resolveAvailableEditors: () => Effect.succeed([]), | |
| 655 | ...options?.layers?.externalLauncher, | |
| 656 | }), | |
| 654 | Layer.mergeAll( | |
| 655 | Layer.mock(ExternalLauncher.ExternalLauncher)({ | |
| 656 | resolveAvailableEditors: () => Effect.succeed([]), | |
| 657 | ...options?.layers?.externalLauncher, | |
| 658 | }), | |
| 659 | Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ | |
| 660 | resolveTargets: () => Effect.succeed([]), | |
| 661 | }), | |
| 662 | ), | |
| 657 | 663 | ), |
| 658 | 664 | Layer.provide( |
| 659 | 665 | Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ |
apps/server/src/server.ts+2| ... | ... | @@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe |
| 81 | 81 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 82 | 82 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 83 | 83 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 84 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | |
| 84 | 85 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 85 | 86 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 86 | 87 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| ... | ... | @@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( |
| 420 | 421 | Layer.provideMerge(TraceDiagnostics.layer), |
| 421 | 422 | Layer.provideMerge(AnalyticsService.layer), |
| 422 | 423 | Layer.provideMerge(ExternalLauncher.layer), |
| 424 | Layer.provideMerge(RemoteOpenTargets.layer), | |
| 423 | 425 | Layer.provideMerge(ServerLifecycleEvents.layer), |
| 424 | 426 | Layer.provide(NetService.layer), |
| 425 | 427 | ); |
apps/server/src/ws.ts+7| ... | ... | @@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 99 | 99 | import * as ReviewService from "./review/ReviewService.ts"; |
| 100 | 100 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 101 | 101 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 102 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | |
| 102 | 103 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 103 | 104 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 104 | 105 | import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; |
| ... | ... | @@ -361,6 +362,7 @@ const makeWsRpcLayer = ( |
| 361 | 362 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 362 | 363 | const keybindings = yield* Keybindings.Keybindings; |
| 363 | 364 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 365 | const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; | |
| 364 | 366 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; |
| 365 | 367 | const review = yield* ReviewService.ReviewService; |
| 366 | 368 | const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; |
| ... | ... | @@ -1010,6 +1012,11 @@ const makeWsRpcLayer = ( |
| 1010 | 1012 | availableEditors: yield* resolveAvailableEditorsForConfig( |
| 1011 | 1013 | externalLauncher.resolveAvailableEditors(), |
| 1012 | 1014 | ), |
| 1015 | // Same discovery-with-timeout treatment as editors: a slow probe | |
| 1016 | // must not stall server.getConfig, so it degrades to no targets. | |
| 1017 | remoteOpenTargets: yield* resolveAvailableEditorsForConfig( | |
| 1018 | remoteOpenTargets.resolveTargets(), | |
| 1019 | ), | |
| 1013 | 1020 | observability: { |
| 1014 | 1021 | logsDirectoryPath: config.logsDir, |
| 1015 | 1022 | localTracingEnabled: true, |
apps/web/src/components/chat/ChatHeader.test.ts+18-3| ... | ... | @@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => { |
| 12 | 12 | activeProjectName: "codething-mvp", |
| 13 | 13 | activeThreadEnvironmentId: primaryEnvironmentId, |
| 14 | 14 | primaryEnvironmentId, |
| 15 | remoteOpenMode: "local-exec", | |
| 15 | 16 | }), |
| 16 | 17 | ).toBe(true); |
| 17 | 18 | }); |
| 18 | 19 | |
| 19 | it("hides the picker when hosted static mode has no primary environment", () => { | |
| 20 | it("shows the picker for remote environments in deep-link mode", () => { | |
| 21 | expect( | |
| 22 | shouldShowOpenInPicker({ | |
| 23 | activeProjectName: "codething-mvp", | |
| 24 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), | |
| 25 | primaryEnvironmentId, | |
| 26 | remoteOpenMode: "remote-links", | |
| 27 | }), | |
| 28 | ).toBe(true); | |
| 29 | }); | |
| 30 | ||
| 31 | it("shows the picker's unavailable state for remote environments without an SSH route", () => { | |
| 20 | 32 | expect( |
| 21 | 33 | shouldShowOpenInPicker({ |
| 22 | 34 | activeProjectName: "codething-mvp", |
| 23 | 35 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), |
| 24 | 36 | primaryEnvironmentId: null, |
| 37 | remoteOpenMode: "remote-unavailable", | |
| 25 | 38 | }), |
| 26 | ).toBe(false); | |
| 39 | ).toBe(true); | |
| 27 | 40 | }); |
| 28 | 41 | |
| 29 | it("hides the picker for remote environments", () => { | |
| 42 | it("hides the picker for non-primary local backends", () => { | |
| 30 | 43 | expect( |
| 31 | 44 | shouldShowOpenInPicker({ |
| 32 | 45 | activeProjectName: "codething-mvp", |
| 33 | 46 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), |
| 34 | 47 | primaryEnvironmentId, |
| 48 | remoteOpenMode: "local-exec", | |
| 35 | 49 | }), |
| 36 | 50 | ).toBe(false); |
| 37 | 51 | }); |
| ... | ... | @@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => { |
| 42 | 56 | activeProjectName: undefined, |
| 43 | 57 | activeThreadEnvironmentId: primaryEnvironmentId, |
| 44 | 58 | primaryEnvironmentId, |
| 59 | remoteOpenMode: "remote-links", | |
| 45 | 60 | }), |
| 46 | 61 | ).toBe(false); |
| 47 | 62 | }); |
apps/web/src/components/chat/ChatHeader.tsx+13-3| ... | ... | @@ -30,6 +30,7 @@ import ProjectScriptsControl, { |
| 30 | 30 | type ProjectScriptActionResult, |
| 31 | 31 | } from "../ProjectScriptsControl"; |
| 32 | 32 | import { OpenInPicker } from "./OpenInPicker"; |
| 33 | import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; | |
| 33 | 34 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 34 | 35 | import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; |
| 35 | 36 | import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; |
| ... | ... | @@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: { |
| 91 | 92 | readonly activeProjectName: string | undefined; |
| 92 | 93 | readonly activeThreadEnvironmentId: EnvironmentId; |
| 93 | 94 | readonly primaryEnvironmentId: EnvironmentId | null; |
| 95 | readonly remoteOpenMode: RemoteOpenMode; | |
| 94 | 96 | }): boolean { |
| 95 | return ( | |
| 96 | Boolean(input.activeProjectName) && | |
| 97 | if (!input.activeProjectName) return false; | |
| 98 | if ( | |
| 97 | 99 | input.primaryEnvironmentId !== null && |
| 98 | 100 | input.activeThreadEnvironmentId === input.primaryEnvironmentId |
| 99 | ); | |
| 101 | ) { | |
| 102 | return true; | |
| 103 | } | |
| 104 | // Remote environments get the picker in deep-link mode (or its explicit | |
| 105 | // "no SSH route" state). Non-primary local backends (e.g. WSL) keep it | |
| 106 | // hidden, matching pre-remote behavior. | |
| 107 | return input.remoteOpenMode !== "local-exec"; | |
| 100 | 108 | } |
| 101 | 109 | |
| 102 | 110 | export const ChatHeader = memo(function ChatHeader({ |
| ... | ... | @@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({ |
| 128 | 136 | activeThreadEnvironmentId, |
| 129 | 137 | activeProjectScripts ? activeProjectCwd : null, |
| 130 | 138 | ); |
| 139 | const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId); | |
| 131 | 140 | const showOpenInPicker = shouldShowOpenInPicker({ |
| 132 | 141 | activeProjectName, |
| 133 | 142 | activeThreadEnvironmentId, |
| 134 | 143 | primaryEnvironmentId, |
| 144 | remoteOpenMode: remoteOpenState.mode, | |
| 135 | 145 | }); |
| 136 | 146 | const activeThreadRef = useMemo( |
| 137 | 147 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), |
apps/web/src/components/chat/OpenInPicker.tsx+70-30| ... | ... | @@ -1,7 +1,19 @@ |
| 1 | import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | buildRemoteOpenUrl, | |
| 3 | EditorId, | |
| 4 | type EnvironmentId, | |
| 5 | type ResolvedKeybindingsConfig, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { memo, useCallback, useEffect, useMemo } from "react"; |
| 3 | 8 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; |
| 4 | 9 | import { usePreferredEditor } from "../../editorPreferences"; |
| 10 | import { | |
| 11 | openRemoteEditorUrl, | |
| 12 | useRemoteCapableEditors, | |
| 13 | useRemoteOpenHint, | |
| 14 | useRemoteOpenState, | |
| 15 | } from "../../remoteOpen"; | |
| 16 | import { useEnvironment } from "../../state/environments"; | |
| 5 | 17 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; |
| 6 | 18 | import { Button } from "../ui/button"; |
| 7 | 19 | import { Group, GroupSeparator } from "../ui/group"; |
| ... | ... | @@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 199 | 211 | enableShortcut?: boolean; |
| 200 | 212 | }) { |
| 201 | 213 | const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); |
| 202 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); | |
| 214 | const remote = useRemoteOpenState(environmentId); | |
| 215 | const remoteCapableEditors = useRemoteCapableEditors(); | |
| 216 | const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); | |
| 217 | const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; | |
| 218 | // Remote mode ignores the server's PATH probe: what matters is what runs on | |
| 219 | // the viewing machine, which only the desktop app can probe. | |
| 220 | const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; | |
| 221 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); | |
| 203 | 222 | const options = useMemo( |
| 204 | () => resolveOptions(navigator.platform, availableEditors), | |
| 205 | [availableEditors], | |
| 223 | () => resolveOptions(navigator.platform, effectiveEditors), | |
| 224 | [effectiveEditors], | |
| 206 | 225 | ); |
| 207 | 226 | const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; |
| 208 | 227 | |
| ... | ... | @@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 211 | 230 | if (!openInCwd) return; |
| 212 | 231 | const editor = editorId ?? preferredEditor; |
| 213 | 232 | if (!editor) return; |
| 233 | if (remote.mode === "remote-unavailable") return; | |
| 234 | if (remote.mode === "remote-links") { | |
| 235 | const url = buildRemoteOpenUrl({ | |
| 236 | editor, | |
| 237 | host: remote.host.host, | |
| 238 | absolutePath: openInCwd, | |
| 239 | }); | |
| 240 | if (url === undefined) return; | |
| 241 | // Only record hint-seen/preferred when the shell actually accepted | |
| 242 | // the URL (an older desktop build can refuse the editor scheme). | |
| 243 | void openRemoteEditorUrl(url).then((opened) => { | |
| 244 | if (!opened) return; | |
| 245 | markRemoteHintSeen(); | |
| 246 | setPreferredEditor(editor); | |
| 247 | }); | |
| 248 | return; | |
| 249 | } | |
| 214 | 250 | const result = openInEditorMutation({ |
| 215 | 251 | environmentId, |
| 216 | 252 | input: { |
| ... | ... | @@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 221 | 257 | setPreferredEditor(editor); |
| 222 | 258 | return result; |
| 223 | 259 | }, |
| 224 | [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], | |
| 260 | [ | |
| 261 | environmentId, | |
| 262 | markRemoteHintSeen, | |
| 263 | openInCwd, | |
| 264 | openInEditorMutation, | |
| 265 | preferredEditor, | |
| 266 | remote, | |
| 267 | setPreferredEditor, | |
| 268 | ], | |
| 225 | 269 | ); |
| 226 | 270 | |
| 227 | 271 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | ... | @@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 237 | 281 | if (!preferredEditor) return; |
| 238 | 282 | |
| 239 | 283 | e.preventDefault(); |
| 240 | void openInEditorMutation({ | |
| 241 | environmentId, | |
| 242 | input: { | |
| 243 | cwd: openInCwd, | |
| 244 | editor: preferredEditor, | |
| 245 | }, | |
| 246 | }); | |
| 284 | void openInEditor(preferredEditor); | |
| 247 | 285 | }; |
| 248 | 286 | window.addEventListener("keydown", handler); |
| 249 | 287 | return () => window.removeEventListener("keydown", handler); |
| 250 | }, [ | |
| 251 | enableShortcut, | |
| 252 | environmentId, | |
| 253 | keybindings, | |
| 254 | openInCwd, | |
| 255 | openInEditorMutation, | |
| 256 | preferredEditor, | |
| 257 | ]); | |
| 288 | }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); | |
| 258 | 289 | |
| 259 | 290 | return ( |
| 260 | 291 | <Group aria-label="Open in editor"> |
| ... | ... | @@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 263 | 294 | className="ps-[8.5px]" |
| 264 | 295 | size="xs" |
| 265 | 296 | variant="outline" |
| 266 | disabled={!preferredEditor || !openInCwd} | |
| 297 | disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} | |
| 267 | 298 | onClick={() => openInEditor(preferredEditor)} |
| 268 | 299 | > |
| 269 | 300 | {primaryOption?.Icon && ( |
| ... | ... | @@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 296 | 327 | <ChevronDownIcon aria-hidden="true" className="size-4" /> |
| 297 | 328 | </MenuTrigger> |
| 298 | 329 | <MenuPopup align="end"> |
| 299 | {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>} | |
| 300 | {options.map(({ label, Icon, value, kind }) => ( | |
| 301 | <MenuItem key={value} onClick={() => openInEditor(value)}> | |
| 302 | <Icon aria-hidden="true" className={getOpenInIconClass(kind)} /> | |
| 303 | {label} | |
| 304 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( | |
| 305 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 330 | {remote.mode === "remote-unavailable" ? ( | |
| 331 | <MenuItem disabled>No SSH route to {environmentLabel}</MenuItem> | |
| 332 | ) : ( | |
| 333 | <> | |
| 334 | {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>} | |
| 335 | {options.map(({ label, Icon, value, kind }) => ( | |
| 336 | <MenuItem key={value} onClick={() => openInEditor(value)}> | |
| 337 | <Icon aria-hidden="true" className={getOpenInIconClass(kind)} /> | |
| 338 | {label} | |
| 339 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( | |
| 340 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 341 | )} | |
| 342 | </MenuItem> | |
| 343 | ))} | |
| 344 | {remote.mode === "remote-links" && !remoteHintSeen && ( | |
| 345 | <MenuItem disabled>Opens over SSH. Needs your key on {environmentLabel}</MenuItem> | |
| 306 | 346 | )} |
| 307 | </MenuItem> | |
| 308 | ))} | |
| 347 | </> | |
| 348 | )} | |
| 309 | 349 | </MenuPopup> |
| 310 | 350 | </Menu> |
| 311 | 351 | </Group> |
apps/web/src/components/files/FilePreviewPanel.tsx+4-1| ... | ... | @@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre |
| 20 | 20 | import { useAssetUrlState } from "~/assets/assetUrls"; |
| 21 | 21 | import ChatMarkdown from "~/components/ChatMarkdown"; |
| 22 | 22 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 23 | import { useRemoteOpenState } from "~/remoteOpen"; | |
| 23 | 24 | import { useClientSettings } from "~/hooks/useSettings"; |
| 24 | 25 | import { useTheme } from "~/hooks/useTheme"; |
| 25 | 26 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; |
| ... | ... | @@ -771,6 +772,7 @@ export default function FilePreviewPanel({ |
| 771 | 772 | const { resolvedTheme } = useTheme(); |
| 772 | 773 | const wordWrap = useClientSettings((settings) => settings.wordWrap); |
| 773 | 774 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 775 | const remoteOpenState = useRemoteOpenState(environmentId); | |
| 774 | 776 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); |
| 775 | 777 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 776 | 778 | reportFailure: false, |
| ... | ... | @@ -893,7 +895,8 @@ export default function FilePreviewPanel({ |
| 893 | 895 | ))} |
| 894 | 896 | </div> |
| 895 | 897 | </ScrollArea> |
| 896 | {absolutePath && environmentId === primaryEnvironmentId ? ( | |
| 898 | {absolutePath && | |
| 899 | (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( | |
| 897 | 900 | <OpenInPicker |
| 898 | 901 | environmentId={environmentId} |
| 899 | 902 | keybindings={keybindings} |
apps/web/src/remoteOpen.test.ts created+149| ... | ... | @@ -0,0 +1,149 @@ |
| 1 | import { | |
| 2 | BearerConnectionTarget, | |
| 3 | PrimaryConnectionTarget, | |
| 4 | RelayConnectionTarget, | |
| 5 | SshConnectionTarget, | |
| 6 | } from "@t3tools/client-runtime/connection"; | |
| 7 | import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; | |
| 8 | import { describe, expect, it } from "vite-plus/test"; | |
| 9 | ||
| 10 | import { resolveRemoteOpenState } from "./remoteOpen"; | |
| 11 | ||
| 12 | const environmentId = EnvironmentId.make("environment-1"); | |
| 13 | ||
| 14 | const primaryTarget = (httpBaseUrl: string) => | |
| 15 | new PrimaryConnectionTarget({ | |
| 16 | environmentId, | |
| 17 | label: "sol", | |
| 18 | httpBaseUrl, | |
| 19 | wsBaseUrl: httpBaseUrl.replace("http", "ws"), | |
| 20 | }); | |
| 21 | ||
| 22 | const TAILSCALE_TARGETS = [ | |
| 23 | { kind: "tailscale", host: "sol.tail1234.ts.net" }, | |
| 24 | { kind: "mdns", host: "sol.local" }, | |
| 25 | ] as const; | |
| 26 | ||
| 27 | describe("resolveRemoteOpenState", () => { | |
| 28 | it("keeps exec behavior for a loopback primary target", () => { | |
| 29 | expect( | |
| 30 | resolveRemoteOpenState({ | |
| 31 | target: primaryTarget("http://127.0.0.1:8000"), | |
| 32 | sshAlias: null, | |
| 33 | isDesktopRenderer: false, | |
| 34 | remoteOpenTargets: TAILSCALE_TARGETS, | |
| 35 | }), | |
| 36 | ).toEqual({ mode: "local-exec" }); | |
| 37 | }); | |
| 38 | ||
| 39 | it("uses deep links for a primary target reached over the network", () => { | |
| 40 | expect( | |
| 41 | resolveRemoteOpenState({ | |
| 42 | target: primaryTarget("https://sol.tail1234.ts.net"), | |
| 43 | sshAlias: null, | |
| 44 | isDesktopRenderer: false, | |
| 45 | remoteOpenTargets: TAILSCALE_TARGETS, | |
| 46 | }), | |
| 47 | ).toEqual({ | |
| 48 | mode: "remote-links", | |
| 49 | host: { kind: "tailscale", host: "sol.tail1234.ts.net" }, | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | it("keeps exec behavior for the desktop app's own primary even on a NAT URL", () => { | |
| 54 | // wsl-only mode binds the primary to the WSL2 NAT address; it is still | |
| 55 | // this machine because the desktop app manages its own primary backend. | |
| 56 | expect( | |
| 57 | resolveRemoteOpenState({ | |
| 58 | target: primaryTarget("http://172.29.112.1:14369"), | |
| 59 | sshAlias: null, | |
| 60 | isDesktopRenderer: true, | |
| 61 | remoteOpenTargets: TAILSCALE_TARGETS, | |
| 62 | }), | |
| 63 | ).toEqual({ mode: "local-exec" }); | |
| 64 | }); | |
| 65 | ||
| 66 | it("keeps exec behavior for desktop-local secondary backends", () => { | |
| 67 | expect( | |
| 68 | resolveRemoteOpenState({ | |
| 69 | target: new BearerConnectionTarget({ | |
| 70 | environmentId, | |
| 71 | label: "WSL (Ubuntu)", | |
| 72 | connectionId: "local:wsl-1", | |
| 73 | }), | |
| 74 | sshAlias: null, | |
| 75 | isDesktopRenderer: false, | |
| 76 | remoteOpenTargets: TAILSCALE_TARGETS, | |
| 77 | }), | |
| 78 | ).toEqual({ mode: "local-exec" }); | |
| 79 | }); | |
| 80 | ||
| 81 | it("prefers the desktop SSH alias over server-advertised hosts", () => { | |
| 82 | expect( | |
| 83 | resolveRemoteOpenState({ | |
| 84 | target: new SshConnectionTarget({ | |
| 85 | environmentId, | |
| 86 | label: "sol", | |
| 87 | connectionId: "ssh-1", | |
| 88 | }), | |
| 89 | sshAlias: "sol", | |
| 90 | isDesktopRenderer: true, | |
| 91 | remoteOpenTargets: TAILSCALE_TARGETS, | |
| 92 | }), | |
| 93 | ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); | |
| 94 | }); | |
| 95 | ||
| 96 | it("reports unavailable when a remote environment advertises no hosts", () => { | |
| 97 | for (const remoteOpenTargets of [[], undefined] as const) { | |
| 98 | expect( | |
| 99 | resolveRemoteOpenState({ | |
| 100 | target: new RelayConnectionTarget({ environmentId, label: "sol" }), | |
| 101 | sshAlias: null, | |
| 102 | isDesktopRenderer: false, | |
| 103 | remoteOpenTargets, | |
| 104 | }), | |
| 105 | ).toEqual({ mode: "remote-unavailable" }); | |
| 106 | } | |
| 107 | }); | |
| 108 | ||
| 109 | it("falls back to exec when the environment has no catalog entry", () => { | |
| 110 | expect( | |
| 111 | resolveRemoteOpenState({ | |
| 112 | target: null, | |
| 113 | sshAlias: null, | |
| 114 | isDesktopRenderer: false, | |
| 115 | remoteOpenTargets: undefined, | |
| 116 | }), | |
| 117 | ).toEqual({ mode: "local-exec" }); | |
| 118 | }); | |
| 119 | }); | |
| 120 | ||
| 121 | describe("buildRemoteOpenUrl", () => { | |
| 122 | it("builds a vscode-remote deep link", () => { | |
| 123 | expect( | |
| 124 | buildRemoteOpenUrl({ | |
| 125 | editor: "vscode", | |
| 126 | host: "sol.tail1234.ts.net", | |
| 127 | absolutePath: "/home/theo/code/my repo", | |
| 128 | }), | |
| 129 | ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo"); | |
| 130 | }); | |
| 131 | ||
| 132 | it("uses the fork's scheme", () => { | |
| 133 | expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe( | |
| 134 | "cursor://vscode-remote/ssh-remote+sol/tmp/x", | |
| 135 | ); | |
| 136 | }); | |
| 137 | ||
| 138 | it("roots Windows paths", () => { | |
| 139 | expect( | |
| 140 | buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }), | |
| 141 | ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); | |
| 142 | }); | |
| 143 | ||
| 144 | it("returns undefined for editors without remote support", () => { | |
| 145 | expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( | |
| 146 | undefined, | |
| 147 | ); | |
| 148 | }); | |
| 149 | }); |
apps/web/src/remoteOpen.ts created+189| ... | ... | @@ -0,0 +1,189 @@ |
| 1 | /** | |
| 2 | * Remote open-in-editor: when this client is not on the environment's | |
| 3 | * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` | |
| 4 | * deep link (local editor connects over SSH) instead of exec'ing an editor | |
| 5 | * on the environment host. | |
| 6 | * | |
| 7 | * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias | |
| 8 | * beats server-advertised names; among advertised names the tailnet MagicDNS | |
| 9 | * name beats mDNS `<hostname>.local` (server sends them in that order). | |
| 10 | */ | |
| 11 | import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; | |
| 12 | import { | |
| 13 | REMOTE_CAPABLE_EDITOR_IDS, | |
| 14 | type EditorId, | |
| 15 | type EnvironmentId, | |
| 16 | type RemoteOpenTarget, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Schema from "effect/Schema"; | |
| 20 | import { useEffect, useMemo, useState } from "react"; | |
| 21 | ||
| 22 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | |
| 23 | import { isLoopbackHostname } from "~/environments/primary/target"; | |
| 24 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | |
| 25 | import { useEnvironmentPresentation } from "~/state/presentation"; | |
| 26 | ||
| 27 | export interface RemoteOpenHost { | |
| 28 | readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; | |
| 29 | readonly host: string; | |
| 30 | } | |
| 31 | ||
| 32 | export type RemoteOpenState = | |
| 33 | | { readonly mode: "local-exec" } | |
| 34 | | { readonly mode: "remote-links"; readonly host: RemoteOpenHost } | |
| 35 | | { readonly mode: "remote-unavailable" }; | |
| 36 | ||
| 37 | export type RemoteOpenMode = RemoteOpenState["mode"]; | |
| 38 | ||
| 39 | const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; | |
| 40 | const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; | |
| 41 | ||
| 42 | function parseHostname(url: string): string | null { | |
| 43 | try { | |
| 44 | return new URL(url).hostname; | |
| 45 | } catch { | |
| 46 | return null; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | export function resolveRemoteOpenState(input: { | |
| 51 | readonly target: ConnectionTarget | null; | |
| 52 | /** Real ssh alias for desktop-SSH environments; null elsewhere. */ | |
| 53 | readonly sshAlias: string | null; | |
| 54 | /** Server-advertised hosts; undefined on servers that predate the feature. */ | |
| 55 | readonly remoteOpenTargets: ReadonlyArray<RemoteOpenTarget> | undefined; | |
| 56 | /** True when running inside the desktop app's renderer. */ | |
| 57 | readonly isDesktopRenderer: boolean; | |
| 58 | }): RemoteOpenState { | |
| 59 | const { target } = input; | |
| 60 | // No catalog entry: keep today's exec behavior rather than guessing. | |
| 61 | if (target === null) { | |
| 62 | return LOCAL_EXEC; | |
| 63 | } | |
| 64 | if (target._tag === "PrimaryConnectionTarget") { | |
| 65 | // The desktop app manages its own primary backend, so it is always on | |
| 66 | // this machine even when its URL is not loopback (wsl-only mode binds | |
| 67 | // the WSL2 NAT address). In a browser, a loopback primary means the | |
| 68 | // browser runs on the serving machine; a tailnet/LAN URL means remote. | |
| 69 | if (input.isDesktopRenderer) { | |
| 70 | return LOCAL_EXEC; | |
| 71 | } | |
| 72 | const hostname = parseHostname(target.httpBaseUrl); | |
| 73 | if (hostname !== null && isLoopbackHostname(hostname)) { | |
| 74 | return LOCAL_EXEC; | |
| 75 | } | |
| 76 | } else if (isDesktopLocalConnectionTarget(target)) { | |
| 77 | return LOCAL_EXEC; | |
| 78 | } | |
| 79 | ||
| 80 | if (input.sshAlias !== null && input.sshAlias.length > 0) { | |
| 81 | return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } }; | |
| 82 | } | |
| 83 | const advertised = input.remoteOpenTargets?.[0]; | |
| 84 | if (advertised !== undefined) { | |
| 85 | return { mode: "remote-links", host: advertised }; | |
| 86 | } | |
| 87 | return REMOTE_UNAVAILABLE; | |
| 88 | } | |
| 89 | ||
| 90 | export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { | |
| 91 | const { presentation } = useEnvironmentPresentation(environmentId); | |
| 92 | ||
| 93 | return useMemo(() => { | |
| 94 | if (presentation === null) { | |
| 95 | return LOCAL_EXEC; | |
| 96 | } | |
| 97 | const profile = Option.getOrNull(presentation.entry.profile); | |
| 98 | const sshAlias = | |
| 99 | profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null; | |
| 100 | return resolveRemoteOpenState({ | |
| 101 | target: presentation.entry.target, | |
| 102 | sshAlias, | |
| 103 | remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, | |
| 104 | isDesktopRenderer: window.desktopBridge !== undefined, | |
| 105 | }); | |
| 106 | }, [presentation]); | |
| 107 | } | |
| 108 | ||
| 109 | /** | |
| 110 | * Editors offered in remote-link mode. The desktop app probes the machine the | |
| 111 | * renderer runs on; a browser cannot, so it offers VS Code only. | |
| 112 | */ | |
| 113 | const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode"]; | |
| 114 | ||
| 115 | let cachedProbedEditors: ReadonlyArray<EditorId> | null = null; | |
| 116 | ||
| 117 | export function __resetRemoteEditorProbeForTests(): void { | |
| 118 | cachedProbedEditors = null; | |
| 119 | } | |
| 120 | ||
| 121 | export function useRemoteCapableEditors(): ReadonlyArray<EditorId> { | |
| 122 | const [editors, setEditors] = useState<ReadonlyArray<EditorId>>( | |
| 123 | () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS, | |
| 124 | ); | |
| 125 | ||
| 126 | useEffect(() => { | |
| 127 | if (cachedProbedEditors !== null) { | |
| 128 | return; | |
| 129 | } | |
| 130 | const probe = window.desktopBridge?.probeRemoteEditors; | |
| 131 | if (probe === undefined) { | |
| 132 | cachedProbedEditors = REMOTE_FALLBACK_EDITORS; | |
| 133 | return; | |
| 134 | } | |
| 135 | let cancelled = false; | |
| 136 | probe().then( | |
| 137 | (ids) => { | |
| 138 | const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id)); | |
| 139 | cachedProbedEditors = remoteCapable.length > 0 ? remoteCapable : REMOTE_FALLBACK_EDITORS; | |
| 140 | if (!cancelled) { | |
| 141 | setEditors(cachedProbedEditors); | |
| 142 | } | |
| 143 | }, | |
| 144 | () => { | |
| 145 | cachedProbedEditors = REMOTE_FALLBACK_EDITORS; | |
| 146 | }, | |
| 147 | ); | |
| 148 | return () => { | |
| 149 | cancelled = true; | |
| 150 | }; | |
| 151 | }, []); | |
| 152 | ||
| 153 | return editors; | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Fire a remote editor deep link. In desktop, route through the Electron | |
| 158 | * shell so the OS handler opens without navigating the renderer; in a | |
| 159 | * browser, assign the location — unlike window.open this does not leave a | |
| 160 | * blank tab behind. | |
| 161 | * | |
| 162 | * Resolves false when the desktop shell refused the URL (e.g. an older | |
| 163 | * build whose protocol allowlist predates editor schemes) so callers do not | |
| 164 | * record a successful open that never happened. | |
| 165 | */ | |
| 166 | export async function openRemoteEditorUrl(url: string): Promise<boolean> { | |
| 167 | const bridge = window.desktopBridge; | |
| 168 | if (bridge !== undefined) { | |
| 169 | try { | |
| 170 | return await bridge.openExternal(url); | |
| 171 | } catch { | |
| 172 | return false; | |
| 173 | } | |
| 174 | } | |
| 175 | window.location.assign(url); | |
| 176 | return true; | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * One-time "you need SSH keys on that machine" hint, shown in the picker menu | |
| 181 | * until the first remote open fires (we cannot observe SSH success from here, | |
| 182 | * so first click is the dismiss signal). | |
| 183 | */ | |
| 184 | const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; | |
| 185 | ||
| 186 | export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { | |
| 187 | const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean); | |
| 188 | return [seen, () => setSeen(true)] as const; | |
| 189 | } |
packages/contracts/src/editor.ts+76-3| ... | ... | @@ -10,20 +10,45 @@ type EditorDefinition = { |
| 10 | 10 | readonly commands: readonly [string, ...string[]] | null; |
| 11 | 11 | readonly baseArgs?: readonly string[]; |
| 12 | 12 | readonly launchStyle: EditorLaunchStyle; |
| 13 | /** | |
| 14 | * URL scheme for editors that support VS Code's remote deep links | |
| 15 | * (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code | |
| 16 | * and forks that ship the Remote-SSH machinery. | |
| 17 | */ | |
| 18 | readonly remoteScheme?: string; | |
| 13 | 19 | }; |
| 14 | 20 | |
| 15 | 21 | export const EDITORS = [ |
| 16 | { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, | |
| 22 | { | |
| 23 | id: "cursor", | |
| 24 | label: "Cursor", | |
| 25 | commands: ["cursor"], | |
| 26 | launchStyle: "goto", | |
| 27 | remoteScheme: "cursor", | |
| 28 | }, | |
| 17 | 29 | { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, |
| 18 | 30 | { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, |
| 19 | { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, | |
| 31 | { | |
| 32 | id: "vscode", | |
| 33 | label: "VS Code", | |
| 34 | commands: ["code"], | |
| 35 | launchStyle: "goto", | |
| 36 | remoteScheme: "vscode", | |
| 37 | }, | |
| 20 | 38 | { |
| 21 | 39 | id: "vscode-insiders", |
| 22 | 40 | label: "VS Code Insiders", |
| 23 | 41 | commands: ["code-insiders"], |
| 24 | 42 | launchStyle: "goto", |
| 43 | remoteScheme: "vscode-insiders", | |
| 44 | }, | |
| 45 | { | |
| 46 | id: "vscodium", | |
| 47 | label: "VSCodium", | |
| 48 | commands: ["codium"], | |
| 49 | launchStyle: "goto", | |
| 50 | remoteScheme: "vscodium", | |
| 25 | 51 | }, |
| 26 | { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" }, | |
| 27 | 52 | { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, |
| 28 | 53 | { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, |
| 29 | 54 | { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, |
| ... | ... | @@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({ |
| 50 | 75 | }); |
| 51 | 76 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; |
| 52 | 77 | |
| 78 | const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; | |
| 79 | ||
| 80 | /** Editors that can open a remote workspace via `vscode-remote` deep links. */ | |
| 81 | export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) => | |
| 82 | remoteSchemeOf(editor) !== undefined ? [editor.id] : [], | |
| 83 | ); | |
| 84 | ||
| 85 | export const remoteSchemeForEditor = (id: EditorId): string | undefined => { | |
| 86 | const editor = EDITORS.find((candidate) => candidate.id === id); | |
| 87 | return editor === undefined ? undefined : remoteSchemeOf(editor); | |
| 88 | }; | |
| 89 | ||
| 90 | /** | |
| 91 | * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link that | |
| 92 | * opens `absolutePath` on `host` in the local editor over SSH. Returns | |
| 93 | * undefined for editors without remote deep-link support. | |
| 94 | */ | |
| 95 | export const buildRemoteOpenUrl = (input: { | |
| 96 | readonly editor: EditorId; | |
| 97 | readonly host: string; | |
| 98 | readonly absolutePath: string; | |
| 99 | }): string | undefined => { | |
| 100 | const scheme = remoteSchemeForEditor(input.editor); | |
| 101 | if (scheme === undefined) { | |
| 102 | return undefined; | |
| 103 | } | |
| 104 | // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. | |
| 105 | const posixPath = input.absolutePath.replaceAll("\\", "/"); | |
| 106 | const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; | |
| 107 | const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); | |
| 108 | return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; | |
| 109 | }; | |
| 110 | ||
| 111 | /** | |
| 112 | * SSH hostnames an environment advertises for remote open links. Reachability | |
| 113 | * is client-side; the server only advertises names that resolve to itself and | |
| 114 | * gates them on a local sshd listen check. Ordered most-reachable first | |
| 115 | * (tailnet MagicDNS name, then mDNS `<hostname>.local`). | |
| 116 | */ | |
| 117 | export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); | |
| 118 | export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; | |
| 119 | ||
| 120 | export const RemoteOpenTarget = Schema.Struct({ | |
| 121 | kind: RemoteOpenTargetKind, | |
| 122 | host: TrimmedNonEmptyString, | |
| 123 | }); | |
| 124 | export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; | |
| 125 | ||
| 53 | 126 | export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()( |
| 54 | 127 | "ExternalLauncherUnknownEditorError", |
| 55 | 128 | { |
packages/contracts/src/ipc.ts+7| ... | ... | @@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr |
| 92 | 92 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 93 | 93 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 94 | 94 | import type { ClientSettings } from "./settings.ts"; |
| 95 | import type { EditorId } from "./editor.ts"; | |
| 95 | 96 | import type { |
| 96 | 97 | SourceControlCloneRepositoryInput, |
| 97 | 98 | SourceControlCloneRepositoryResult, |
| ... | ... | @@ -1072,6 +1073,12 @@ export interface DesktopBridge { |
| 1072 | 1073 | position?: { x: number; y: number }, |
| 1073 | 1074 | ) => Promise<T | null>; |
| 1074 | 1075 | openExternal: (url: string) => Promise<boolean>; |
| 1076 | /** | |
| 1077 | * Probe this desktop machine for installed remote-capable editor CLIs | |
| 1078 | * (used for remote open-in-editor deep links). Optional: older desktop | |
| 1079 | * builds lack it; callers fall back to VS Code only. | |
| 1080 | */ | |
| 1081 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; | |
| 1075 | 1082 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1076 | 1083 | getWindowFullscreenState: () => boolean; |
| 1077 | 1084 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; |
packages/contracts/src/server.ts+7-1| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | KeybindingWhen, |
| 18 | 18 | ResolvedKeybindingsConfig, |
| 19 | 19 | } from "./keybindings.ts"; |
| 20 | import { EditorId } from "./editor.ts"; | |
| 20 | import { EditorId, RemoteOpenTarget } from "./editor.ts"; | |
| 21 | 21 | import { ModelCapabilities } from "./model.ts"; |
| 22 | 22 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 23 | 23 | import { ServerSettings } from "./settings.ts"; |
| ... | ... | @@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({ |
| 428 | 428 | // Editor ids grow over time; drop ones this build does not know rather than |
| 429 | 429 | // failing the whole config decode. |
| 430 | 430 | availableEditors: ForwardCompatibleArray(EditorId), |
| 431 | /** | |
| 432 | * SSH hosts this environment advertises for remote open-in-editor links. | |
| 433 | * Absent on servers that predate the feature; empty when the machine has no | |
| 434 | * sshd or no advertisable name. | |
| 435 | */ | |
| 436 | remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), | |
| 431 | 437 | observability: ServerObservability, |
| 432 | 438 | settings: ServerSettings, |
| 433 | 439 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ |
packages/shared/src/Net.ts+7| ... | ... | @@ -39,6 +39,12 @@ export interface NetServiceShape { |
| 39 | 39 | */ |
| 40 | 40 | readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>; |
| 41 | 41 | |
| 42 | /** | |
| 43 | * Returns true when something accepts TCP connections on {host, port}. | |
| 44 | * Unlike the bind-side checks this works for privileged ports (<1024). | |
| 45 | */ | |
| 46 | readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect<boolean>; | |
| 47 | ||
| 42 | 48 | /** |
| 43 | 49 | * Reserve an ephemeral loopback port and release it immediately. |
| 44 | 50 | */ |
| ... | ... | @@ -183,6 +189,7 @@ export const make = () => { |
| 183 | 189 | return { |
| 184 | 190 | canListenOnHost, |
| 185 | 191 | isPortAvailableOnLoopback, |
| 192 | hasListenerOnHost, | |
| 186 | 193 | reserveLoopbackPort, |
| 187 | 194 | findAvailablePort: (preferred) => |
| 188 | 195 | Effect.gen(function* () { |
packages/ssh/src/tunnel.test.ts+1| ... | ... | @@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never); |
| 80 | 80 | const testNetService = NetService.NetService.of({ |
| 81 | 81 | canListenOnHost: () => Effect.succeed(true), |
| 82 | 82 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 83 | hasListenerOnHost: () => Effect.succeed(false), | |
| 83 | 84 | reserveLoopbackPort: () => Effect.succeed(41_773), |
| 84 | 85 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 85 | 86 | }); |
scripts/dev-runner.test.ts+1| ... | ... | @@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} } |
| 35 | 35 | const netServiceLayer = Layer.succeed(NetService.NetService, { |
| 36 | 36 | canListenOnHost: () => Effect.succeed(true), |
| 37 | 37 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 38 | hasListenerOnHost: () => Effect.succeed(false), | |
| 38 | 39 | reserveLoopbackPort: () => Effect.succeed(49_152), |
| 39 | 40 | findAvailablePort: (port) => Effect.succeed(port), |
| 40 | 41 | }); |