Skip to content
Source
Sign inSign up
Open History to see the last commit

ConnectionsSettings.logic.ts

491 lines16.0 KB

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;
50​
51export function canRevokeOtherClients(
52 clientSessions: ReadonlyArray<{ readonly current: boolean }> | null,
53): boolean {
54 return clientSessions === null || clientSessions.some((session) => !session.current);
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";
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.";
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 }
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;
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";
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 );
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);
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);
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 };
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 }
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 };
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;
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 );
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(" · ");
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 });
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 };
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 };
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 );
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;
383​
384export function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean {
385 return endpoint.id.startsWith("tailscale-magicdns:");
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}`;
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);
422​
423export function resolveCurrentOriginPairingUrl(credential: string): string {
424 const url = new URL("/pair", window.location.href);
425 return setPairingTokenOnUrl(url, credential).toString();
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 }
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 }
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 );
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";
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";