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 */
11import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
12import {
13 REMOTE_CAPABLE_EDITOR_IDS,
14 type EditorId,
15 type EnvironmentId,
16 type RemoteOpenTarget,
17} from "@t3tools/contracts";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20import { useEffect, useMemo, useState } from "react";
21
22import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
23import { isLoopbackHostname } from "~/environments/primary/target";
24import { useLocalStorage } from "~/hooks/useLocalStorage";
25import { useEnvironmentPresentation } from "~/state/presentation";
26
27export interface RemoteOpenHost {
28 readonly kind: "ssh-alias" | RemoteOpenTarget["kind"];
29 readonly host: string;
30}
31
32export type RemoteOpenState =
33 | { readonly mode: "local-exec" }
34 | { readonly mode: "remote-links"; readonly host: RemoteOpenHost }
35 | { readonly mode: "remote-unavailable" };
36
37export type RemoteOpenMode = RemoteOpenState["mode"];
38
39export interface RemoteOpenResolution {
40 readonly state: RemoteOpenState;
41 readonly isResolved: boolean;
42}
43
44const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" };
45const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" };
46const UNRESOLVED_REMOTE_OPEN: RemoteOpenResolution = {
47 state: LOCAL_EXEC,
48 isResolved: false,
49};
50
51function parseHostname(url: string): string | null {
52 try {
53 return new URL(url).hostname;
54 } catch {
55 return null;
56 }
57}
58
59export function resolveRemoteOpenState(input: {
60 readonly target: ConnectionTarget | null;
61 /** Real ssh alias for desktop-SSH environments; null elsewhere. */
62 readonly sshAlias: string | null;
63 /** Server-advertised hosts; undefined on servers that predate the feature. */
64 readonly remoteOpenTargets: ReadonlyArray<RemoteOpenTarget> | undefined;
65 /** True when running inside the desktop app's renderer. */
66 readonly isDesktopRenderer: boolean;
67}): RemoteOpenState {
68 const { target } = input;
69 // No catalog entry: keep today's exec behavior rather than guessing.
70 if (target === null) {
71 return LOCAL_EXEC;
72 }
73 if (target._tag === "PrimaryConnectionTarget") {
74 // The desktop app manages its own primary backend, so it is always on
75 // this machine even when its URL is not loopback (wsl-only mode binds
76 // the WSL2 NAT address). In a browser, a loopback primary means the
77 // browser runs on the serving machine; a tailnet/LAN URL means remote.
78 if (input.isDesktopRenderer) {
79 return LOCAL_EXEC;
80 }
81 const hostname = parseHostname(target.httpBaseUrl);
82 if (hostname !== null && isLoopbackHostname(hostname)) {
83 return LOCAL_EXEC;
84 }
85 } else if (isDesktopLocalConnectionTarget(target)) {
86 return LOCAL_EXEC;
87 }
88
89 if (input.sshAlias !== null && input.sshAlias.length > 0) {
90 return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } };
91 }
92 const advertised = input.remoteOpenTargets?.[0];
93 if (advertised !== undefined) {
94 return { mode: "remote-links", host: advertised };
95 }
96 return REMOTE_UNAVAILABLE;
97}
98
99export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution {
100 const { presentation } = useEnvironmentPresentation(environmentId);
101
102 return useMemo(() => {
103 if (presentation === null) {
104 return UNRESOLVED_REMOTE_OPEN;
105 }
106 const profile = Option.getOrNull(presentation.entry.profile);
107 const sshAlias =
108 profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null;
109 return {
110 state: resolveRemoteOpenState({
111 target: presentation.entry.target,
112 sshAlias,
113 remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,
114 isDesktopRenderer: window.desktopBridge !== undefined,
115 }),
116 isResolved: true,
117 };
118 }, [presentation]);
119}
120
121export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
122 return useRemoteOpenResolution(environmentId).state;
123}
124
125/**
126 * Editors offered in remote-link mode. The desktop app probes the machine the
127 * renderer runs on; a browser cannot, so it offers VS Code only.
128 */
129const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode"];
130
131let cachedProbedEditors: ReadonlyArray<EditorId> | null = null;
132
133export function useRemoteCapableEditors(): ReadonlyArray<EditorId> {
134 const [editors, setEditors] = useState<ReadonlyArray<EditorId>>(
135 () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS,
136 );
137
138 useEffect(() => {
139 if (cachedProbedEditors !== null) {
140 return;
141 }
142 const probe = window.desktopBridge?.probeRemoteEditors;
143 if (probe === undefined) {
144 cachedProbedEditors = REMOTE_FALLBACK_EDITORS;
145 return;
146 }
147 let cancelled = false;
148 probe().then(
149 (ids) => {
150 const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id));
151 cachedProbedEditors = remoteCapable.length > 0 ? remoteCapable : REMOTE_FALLBACK_EDITORS;
152 if (!cancelled) {
153 setEditors(cachedProbedEditors);
154 }
155 },
156 () => {
157 cachedProbedEditors = REMOTE_FALLBACK_EDITORS;
158 },
159 );
160 return () => {
161 cancelled = true;
162 };
163 }, []);
164
165 return editors;
166}
167
168/**
169 * Fire a remote editor deep link. In desktop, route through the Electron
170 * shell so the OS handler opens without navigating the renderer; in a
171 * browser, assign the location — unlike window.open this does not leave a
172 * blank tab behind.
173 *
174 * Resolves false when the desktop shell refused the URL (e.g. an older
175 * build whose protocol allowlist predates editor schemes) so callers do not
176 * record a successful open that never happened.
177 */
178export async function openRemoteEditorUrl(url: string): Promise<boolean> {
179 const bridge = window.desktopBridge;
180 if (bridge !== undefined) {
181 try {
182 return await bridge.openExternal(url);
183 } catch {
184 return false;
185 }
186 }
187 window.location.assign(url);
188 return true;
189}
190
191/**
192 * One-time "you need SSH keys on that machine" hint, shown in the picker menu
193 * until the first remote open fires (we cannot observe SSH success from here,
194 * so first click is the dismiss signal).
195 */
196const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen";
197
198export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] {
199 const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean);
200 return [seen, () => setSeen(true)] as const;
201}