Open History to see the last commit
1import * as DateTime from "effect/DateTime";
2
3import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
4import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "@t3tools/shared/remote";
5import { readHostedPairingRequest } from "../../hostedPairing";
6import {
7 type ServerClientSessionRecord,
8 type ServerPairingLinkRecord,
9} from "~/environments/primary";
10import {
11 type AdvertisedEndpoint,
12 type AuthClientMetadata,
13 type AuthClientSession,
14 type AuthGrantScope,
15 type AuthPairingLink,
16 AuthTerminalOperateScope,
17 AuthTerminalReadScope,
18 type DesktopBridge,
19 type DesktopServerExposureState,
20 type DesktopSshEnvironmentTarget,
21 type DesktopUpdateState,
22 type DesktopWslState,
23 type EnvironmentId,
24 type ServerAuthPolicy,
25 type ServerDirectEndpoint,
26} from "@t3tools/contracts";
27import {
28 getDesktopUpdateDownloadedVersion,
29 resolveDesktopUpdateButtonAction,
30} from "../desktopUpdate.logic";
31
32/** Keep terminal:read with terminal:operate to avoid creating unseen shells on every script run. */
33export function togglePairingScopeSelection(
34 current: ReadonlyArray<AuthGrantScope>,
35 scope: AuthGrantScope,
36 checked: boolean,
37): ReadonlyArray<AuthGrantScope> {
38 const without = (scopes: ReadonlyArray<AuthGrantScope>) =>
39 scopes.filter((currentScope) => currentScope !== scope);
40 if (!checked) {
41 return scope === AuthTerminalReadScope
42 ? without(current).filter((currentScope) => currentScope !== AuthTerminalOperateScope)
43 : without(current);
44 }
45 const next = [...without(current), scope];
46 return scope === AuthTerminalOperateScope && !next.includes(AuthTerminalReadScope)
47 ? [...next, AuthTerminalReadScope]
48 : next;
49}
50
51export function canRevokeOtherClients(
52 clientSessions: ReadonlyArray<{ readonly current: boolean }> | null,
53): boolean {
54 return clientSessions === null || clientSessions.some((session) => !session.current);
55}
56
57type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;
58
59/** Never offer loopback URLs as QR targets; scanning devices would connect to themselves. */
60export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean {
61 return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback";
62}
63
64/** Desktop skips Tailscale probing until network/Serve is enabled to avoid macOS prompts; absence then
65 * means unchecked. */
66export function missingTailscaleEndpointDescription(
67 state: Pick<DesktopServerExposureState, "mode" | "tailscaleServeEnabled"> | null,
68): string {
69 return state && state.mode !== "network-accessible" && !state.tailscaleServeEnabled
70 ? "Turn on Network access to detect Tailscale."
71 : "Start Tailscale to set up HTTPS access through MagicDNS.";
72}
73
74export function resolveTailscaleServeBaseUrl(
75 endpoint: Pick<AdvertisedEndpoint, "httpBaseUrl"> | null,
76 port: number,
77 isPortValid: boolean,
78): string | null {
79 if (!endpoint) return null;
80 if (!isPortValid) return endpoint.httpBaseUrl;
81 try {
82 const url = new URL(endpoint.httpBaseUrl);
83 url.port = String(port);
84 return url.toString().replace(/\/$/u, "");
85 } catch {
86 return endpoint.httpBaseUrl;
87 }
88}
89
90export function isWslSettingsRowVisible(input: {
91 readonly state: DesktopWslState | null;
92 readonly error: string | null;
93}): boolean {
94 const { state, error } = input;
95 return state ? state.available || state.enabled || state.wslOnly : error !== null;
96}
97
98/**
99 * Once client/server versions match, this passive Version label follows the sidebar update action,
100 * including retryable failures. Rechecks retain downloaded-update labels; `state` is null outside desktop.
101 */
102export function resolveLocalVersionStatusLabel(state: DesktopUpdateState | null): string {
103 if (!state) return "Up to date";
104 if (state.status === "downloading") {
105 return state.availableVersion
106 ? `Downloading update ${state.availableVersion}`
107 : "Downloading update";
108 }
109 const action = resolveDesktopUpdateButtonAction(state);
110 if (
111 action === "install" ||
112 state.status === "downloaded" ||
113 (state.status === "checking" && state.downloadedVersion !== null)
114 ) {
115 const version = getDesktopUpdateDownloadedVersion(state);
116 return version ? `Update ${version} downloaded` : "Update downloaded";
117 }
118 if (action === "download") {
119 return state.availableVersion
120 ? `Update ${state.availableVersion} available`
121 : "Update available";
122 }
123 return "Up to date";
124}
125
126export type QrEndpointOption = {
127 readonly id: string;
128 /** Stable per endpoint type, not unique; used only to match the saved default. */
129 readonly preferenceKey: string;
130 readonly qrShareable: boolean;
131};
132
133export function selectQrEndpointOption<T extends QrEndpointOption>(
134 options: ReadonlyArray<T>,
135 selectedId: string | null,
136 defaultPreferenceKey: string | null,
137): T | null {
138 return (
139 (selectedId !== null ? options.find((option) => option.id === selectedId) : undefined) ??
140 (defaultPreferenceKey !== null
141 ? options.find((option) => option.preferenceKey === defaultPreferenceKey)
142 : undefined) ??
143 options.find((option) => option.qrShareable) ??
144 options[0] ??
145 null
146 );
147}
148
149export async function applyWslEnableSelection(input: {
150 readonly bridge: WslEnableBridge;
151 readonly mode: "both" | "wsl-only";
152 readonly nextDistro: string | null;
153 readonly persistedDistro: string | null;
154}): Promise<DesktopWslState> {
155 const { bridge, mode, nextDistro, persistedDistro } = input;
156
157 // Stage preferences before enabling; only the final call should relaunch WSL with the complete selection.
158 await bridge.setWslOnly(mode === "wsl-only");
159 if (persistedDistro !== nextDistro) {
160 await bridge.setWslDistro(nextDistro);
161 }
162 return await bridge.setWslBackendEnabled(true);
163}
164
165const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
166 dateStyle: "medium",
167 timeStyle: "short",
168});
169
170export function formatAccessTimestamp(value: string): string {
171 const parsed = new Date(value);
172 if (Number.isNaN(parsed.getTime())) {
173 return value;
174 }
175 return accessTimestampFormatter.format(parsed);
176}
177
178export function parseManualDesktopSshTarget(input: {
179 readonly host: string;
180 readonly username: string;
181 readonly port: string;
182}): DesktopSshEnvironmentTarget {
183 const rawHost = input.host.trim();
184 if (rawHost.length === 0) {
185 throw new Error("SSH host or alias is required.");
186 }
187
188 let hostname = rawHost;
189 let username = input.username.trim() || null;
190 let port: number | null = null;
191
192 const atIndex = hostname.lastIndexOf("@");
193 if (atIndex > 0) {
194 const inlineUsername = hostname.slice(0, atIndex).trim();
195 hostname = hostname.slice(atIndex + 1).trim();
196 if (!username && inlineUsername.length > 0) {
197 username = inlineUsername;
198 }
199 }
200
201 const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname);
202 if (bracketedHostMatch) {
203 hostname = bracketedHostMatch[1]!.trim();
204 if (bracketedHostMatch[2]) {
205 port = Number.parseInt(bracketedHostMatch[2], 10);
206 }
207 } else {
208 const colonSegments = hostname.split(":");
209 if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) {
210 hostname = colonSegments[0]!.trim();
211 port = Number.parseInt(colonSegments[1]!, 10);
212 }
213 }
214
215 const rawPort = input.port.trim();
216 if (rawPort.length > 0) {
217 port = /^\d+$/u.test(rawPort) ? Number.parseInt(rawPort, 10) : Number.NaN;
218 }
219
220 if (hostname.length === 0) {
221 throw new Error("SSH host or alias is required.");
222 }
223
224 if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) {
225 throw new Error("SSH port must be between 1 and 65535.");
226 }
227
228 return {
229 alias: hostname,
230 hostname,
231 username,
232 port,
233 };
234}
235
236export function parsePairingUrlFields(
237 input: string,
238): { readonly host: string; readonly pairingCode: string } | null {
239 const trimmed = input.trim();
240 if (!trimmed) return null;
241
242 try {
243 const urlLikeInput =
244 /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//")
245 ? trimmed
246 : `https://${trimmed}`;
247 const url = new URL(urlLikeInput, window.location.origin);
248 const hostedPairingRequest = readHostedPairingRequest(url);
249 if (hostedPairingRequest) {
250 return {
251 host: hostedPairingRequest.host,
252 pairingCode: hostedPairingRequest.token,
253 };
254 }
255
256 const pairingCode = getPairingTokenFromUrl(url);
257 if (!pairingCode) return null;
258 return {
259 host: url.origin,
260 pairingCode,
261 };
262 } catch {
263 return null;
264 }
265}
266
267export function parseRemotePairingFields(input: {
268 readonly host: string;
269 readonly pairingCode: string;
270}): {
271 readonly host: string;
272 readonly pairingCode: string;
273} {
274 const parsedPairingUrl = parsePairingUrlFields(input.host);
275 if (parsedPairingUrl) return parsedPairingUrl;
276
277 const host = input.host.trim();
278 const pairingCode = input.pairingCode.trim();
279 if (!host) {
280 throw new Error("Enter a backend host.");
281 }
282 if (!pairingCode) {
283 throw new Error("Enter a pairing code.");
284 }
285 return { host, pairingCode };
286}
287
288export function formatDesktopSshConnectionError(error: unknown): string {
289 const fallback = "Failed to connect SSH host.";
290 const rawMessage = error instanceof Error ? error.message : fallback;
291 const withoutIpcPrefix = rawMessage.replace(
292 /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u,
293 "",
294 );
295 const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, "");
296 return withoutTaggedErrorPrefix.trim() || fallback;
297}
298
299export function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) {
300 return [...links].toSorted(
301 (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
302 );
303}
304
305export function summarizeAuthorizedClients(
306 sessions: ReadonlyArray<ServerClientSessionRecord>,
307 links: ReadonlyArray<ServerPairingLinkRecord>,
308): string {
309 const parts = [
310 `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`,
311 links.length > 0
312 ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}`
313 : null,
314 ];
315 return parts.filter((part): part is string => part !== null).join(" · ");
316}
317
318export function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) {
319 return [...sessions].toSorted((left, right) => {
320 if (left.current !== right.current) {
321 return left.current ? -1 : 1;
322 }
323 if (left.connected !== right.connected) {
324 return left.connected ? -1 : 1;
325 }
326 return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime();
327 });
328}
329
330export function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord {
331 return {
332 ...pairingLink,
333 scopes: pairingLink.permissions ?? pairingLink.scopes,
334 createdAt: DateTime.formatIso(pairingLink.createdAt),
335 expiresAt: DateTime.formatIso(pairingLink.expiresAt),
336 };
337}
338
339export function toDesktopClientSessionRecord(
340 clientSession: AuthClientSession,
341): ServerClientSessionRecord {
342 return {
343 ...clientSession,
344 scopes: clientSession.permissions ?? clientSession.scopes,
345 issuedAt: DateTime.formatIso(clientSession.issuedAt),
346 expiresAt: DateTime.formatIso(clientSession.expiresAt),
347 lastConnectedAt:
348 clientSession.lastConnectedAt === null
349 ? null
350 : DateTime.formatIso(clientSession.lastConnectedAt),
351 };
352}
353
354export function selectPairingEndpoint(
355 endpoints: ReadonlyArray<AdvertisedEndpoint>,
356 defaultEndpointKey?: string | null,
357): AdvertisedEndpoint | null {
358 const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable");
359 if (defaultEndpointKey) {
360 const selectedEndpoint = availableEndpoints.find(
361 (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey,
362 );
363 if (selectedEndpoint) {
364 return selectedEndpoint;
365 }
366 }
367 return (
368 availableEndpoints.find((endpoint) => endpoint.isDefault) ??
369 availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ??
370 availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ??
371 null
372 );
373}
374
375/** A loopback server advertises a direct endpoint only once its Tailscale Serve URL answers. */
376export function isServerNetworkAccessible(input: {
377 readonly authPolicy: ServerAuthPolicy | null;
378 readonly directEndpoints: ReadonlyArray<ServerDirectEndpoint> | undefined;
379}): boolean {
380 if (input.authPolicy === "remote-reachable") return true;
381 return input.authPolicy === "loopback-browser" && (input.directEndpoints?.length ?? 0) > 0;
382}
383
384export function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean {
385 return endpoint.id.startsWith("tailscale-magicdns:");
386}
387
388export function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string {
389 if (endpoint.id.startsWith("desktop-loopback:")) {
390 return "desktop-core:loopback:http";
391 }
392 if (endpoint.id.startsWith("desktop-lan:")) {
393 return "desktop-core:lan:http";
394 }
395 if (endpoint.id.startsWith("tailscale-ip:")) {
396 return "tailscale:ip:http";
397 }
398 if (isTailscaleHttpsEndpoint(endpoint)) {
399 return "tailscale:magicdns:https";
400 }
401
402 let scheme = "unknown";
403 try {
404 scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, "");
405 } catch {}
406
407 return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`;
408}
409
410export function resolveAdvertisedEndpointPairingUrl(
411 endpoint: AdvertisedEndpoint,
412 credential: string,
413): string {
414 if (endpoint.compatibility.hostedHttpsApp === "compatible") {
415 return (
416 resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ??
417 resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential)
418 );
419 }
420 return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential);
421}
422
423export function resolveCurrentOriginPairingUrl(credential: string): string {
424 const url = new URL("/pair", window.location.href);
425 return setPairingTokenOnUrl(url, credential).toString();
426}
427
428export function isHostedAppPairingUrl(value: string): boolean {
429 try {
430 const url = new URL(value);
431 return url.pathname === "/pair" && url.searchParams.has("host");
432 } catch {
433 return false;
434 }
435}
436
437export function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {
438 if (isHostedAppPairingUrl(url)) {
439 return "Opens the hosted app, no install needed";
440 }
441 switch (endpoint.reachability) {
442 case "lan":
443 return "Devices on the same network";
444 case "private-network":
445 return "Devices on your private network";
446 case "public":
447 return "Reachable from anywhere";
448 case "loopback":
449 return "Clients on this machine";
450 }
451}
452
453/** An explicit choice while it is listed, else the primary, else a connected environment. */
454export function resolveAccessEnvironmentId(input: {
455 readonly primaryEnvironmentId: EnvironmentId | null;
456 readonly environments: ReadonlyArray<{
457 readonly environmentId: EnvironmentId;
458 readonly connected: boolean;
459 }>;
460 readonly selected: EnvironmentId | null;
461}): EnvironmentId | null {
462 const isListed = (environmentId: EnvironmentId | null) =>
463 input.environments.some((environment) => environment.environmentId === environmentId);
464 if (isListed(input.selected)) return input.selected;
465 if (isListed(input.primaryEnvironmentId)) return input.primaryEnvironmentId;
466 return (
467 (input.environments.find((environment) => environment.connected) ?? input.environments[0])
468 ?.environmentId ?? null
469 );
470}
471
472/** A short name for a T3 Connect device: its label, or what it runs on. */
473export function connectDeviceName({
474 client,
475}: {
476 readonly client?: AuthClientMetadata | undefined;
477}): string {
478 return client?.label ?? client?.os ?? client?.browser ?? "this device";
479}
480
481/** How a T3 Connect device describes itself. It can say anything, so the code is what to trust. */
482export function describeConnectDeviceClient({
483 client,
484}: {
485 readonly client?: AuthClientMetadata | undefined;
486}): string {
487 const bits = [client?.label, client?.os, client?.browser, client?.ipAddress].filter(
488 (value): value is string => typeof value === "string" && value.length > 0,
489 );
490 return bits.length > 0 ? [...new Set(bits)].join(" · ") : "Unknown device";
491}