1import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
2import {
3 REMOTE_CAPABLE_EDITOR_IDS,
4 type EditorId,
5 type EnvironmentId,
6 type RemoteOpenTarget,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import { Atom } from "effect/reactivity";
11import { useAtomValue } from "@effect/atom-react";
12
13import { browserClientOs, type BrowserIdentity } from "~/connection/clientMetadata";
14import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
15import { isLoopbackHostname } from "~/environments/primary/target";
16import { useLocalStorage } from "~/hooks/useLocalStorage";
17import { environmentPresentations } from "~/state/presentation";
18import { hostEnvironmentIdAtom } from "~/state/primaryEnvironment";
19
20export interface RemoteOpenHost {
21 readonly kind: "ssh-alias" | RemoteOpenTarget["kind"];
22 readonly host: string;
23 readonly username?: string;
24}
25
26export type RemoteOpenState =
27 | { readonly mode: "local-exec" }
28 | { readonly mode: "remote-links"; readonly host: RemoteOpenHost }
29 | { readonly mode: "remote-unavailable" };
30
31export type RemoteOpenMode = RemoteOpenState["mode"];
32
33export interface RemoteOpenResolution {
34 readonly state: RemoteOpenState;
35 readonly isResolved: boolean;
36}
37
38const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" };
39const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" };
40const UNRESOLVED_REMOTE_OPEN: RemoteOpenResolution = {
41 state: LOCAL_EXEC,
42 isResolved: false,
43};
44
45function parseHostname(url: string): string | null {
46 try {
47 return new URL(url).hostname;
48 } catch {
49 return null;
50 }
51}
52
53export function resolveRemoteOpenState(input: {
54 readonly target: ConnectionTarget | null;
55 readonly sshTarget: { readonly alias: string; readonly username: string | null } | null;
56 readonly remoteOpenTargets: ReadonlyArray<RemoteOpenTarget> | undefined;
57 readonly isDesktopRenderer: boolean;
58 readonly hostEnvironmentId: string | null;
59}): RemoteOpenState {
60 const { target } = input;
61 if (target === null || target.environmentId === input.hostEnvironmentId) {
62 return LOCAL_EXEC;
63 }
64 if (target._tag === "PrimaryConnectionTarget") {
65 // Desktop owns its primary even at a non-loopback WSL2 NAT address.
66 if (input.isDesktopRenderer) {
67 return LOCAL_EXEC;
68 }
69 const hostname = parseHostname(target.httpBaseUrl);
70 if (hostname !== null && isLoopbackHostname(hostname)) {
71 return LOCAL_EXEC;
72 }
73 } else if (isDesktopLocalConnectionTarget(target)) {
74 return LOCAL_EXEC;
75 }
76
77 if (input.sshTarget !== null && input.sshTarget.alias.length > 0) {
78 const { alias } = input.sshTarget;
79 const userSeparator = alias.lastIndexOf("@");
80 const host = userSeparator === -1 ? alias : alias.slice(userSeparator + 1);
81 const username =
82 input.sshTarget.username?.trim() ||
83 (userSeparator === -1 ? null : alias.slice(0, userSeparator));
84 return {
85 mode: "remote-links",
86 host: {
87 kind: "ssh-alias",
88 host,
89 ...(username === null ? {} : { username }),
90 },
91 };
92 }
93 const advertised = input.remoteOpenTargets?.[0];
94 if (advertised !== undefined) {
95 return { mode: "remote-links", host: advertised };
96 }
97 return REMOTE_UNAVAILABLE;
98}
99
100function remoteOpenStatesEqual(previous: RemoteOpenState, next: RemoteOpenState): boolean {
101 if (previous.mode !== next.mode) return false;
102 if (previous.mode !== "remote-links" || next.mode !== "remote-links") return true;
103 return (
104 previous.host.kind === next.host.kind &&
105 previous.host.host === next.host.host &&
106 previous.host.username === next.host.username
107 );
108}
109
110// Config pushes recreate presentations; preserve equal resolutions to avoid re-rendering file links.
111const remoteOpenResolutionAtom = Atom.family((environmentId: EnvironmentId) =>
112 Atom.make((get): RemoteOpenResolution => {
113 const presentation = get(environmentPresentations.presentationAtom(environmentId));
114 const entry = presentation?.entry ?? null;
115 if (entry === null) {
116 return UNRESOLVED_REMOTE_OPEN;
117 }
118 const profile = Option.getOrNull(entry.profile);
119 const next: RemoteOpenResolution = {
120 state: resolveRemoteOpenState({
121 target: entry.target,
122 sshTarget:
123 profile !== null && profile._tag === "SshConnectionProfile" ? profile.target : null,
124 remoteOpenTargets: presentation?.serverConfig?.remoteOpenTargets,
125 isDesktopRenderer: window.desktopBridge !== undefined,
126 hostEnvironmentId: get(hostEnvironmentIdAtom),
127 }),
128 isResolved: true,
129 };
130 const previous = Option.getOrNull(get.self<RemoteOpenResolution>());
131 return previous !== null &&
132 previous.isResolved &&
133 remoteOpenStatesEqual(previous.state, next.state)
134 ? previous
135 : next;
136 }).pipe(Atom.withLabel(`remote-open-resolution:${environmentId}`)),
137);
138
139const UNRESOLVED_REMOTE_OPEN_ATOM = Atom.make(UNRESOLVED_REMOTE_OPEN);
140
141export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution {
142 return useAtomValue(
143 environmentId === null ? UNRESOLVED_REMOTE_OPEN_ATOM : remoteOpenResolutionAtom(environmentId),
144 );
145}
146
147export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
148 return useRemoteOpenResolution(environmentId).state;
149}
150
151const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode"];
152
153// Phone and tablet browsers have no desktop editor to receive a vscode:// remote link.
154export function remoteFallbackEditors(identity: BrowserIdentity): ReadonlyArray<EditorId> {
155 const os = browserClientOs(identity);
156 return os === "iOS" || os === "Android" ? [] : REMOTE_FALLBACK_EDITORS;
157}
158
159// Probed once per page: installed editors rarely change, and the probe spawns processes.
160const remoteCapableEditorsAtom = Atom.make((get): ReadonlyArray<EditorId> => {
161 const probe = window.desktopBridge?.probeRemoteEditors;
162 probe?.().then(
163 (ids) => {
164 const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id));
165 if (remoteCapable.length > 0) get.setSelf(remoteCapable);
166 },
167 () => undefined,
168 );
169 return remoteFallbackEditors(navigator);
170}).pipe(Atom.keepAlive, Atom.withLabel("remote-capable-editors"));
171
172export function useRemoteCapableEditors(): ReadonlyArray<EditorId> {
173 return useAtomValue(remoteCapableEditorsAtom);
174}
175
176/** Resolves false when the desktop refuses the URL, including unsupported editor schemes. */
177export async function openRemoteEditorUrl(url: string): Promise<boolean> {
178 const bridge = window.desktopBridge;
179 if (bridge !== undefined) {
180 try {
181 return await bridge.openExternal(url);
182 } catch {
183 return false;
184 }
185 }
186 window.location.assign(url);
187 return true;
188}
189
190const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen";
191
192export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] {
193 const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean);
194 return [seen, () => setSeen(true)] as const;
195}