2026-06-03 02:42:00 +03:00
|
|
|
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
2026-02-05 01:59:49 +02:00
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
2026-02-05 01:59:49 +02:00
|
|
|
|
|
|
|
|
export type DesktopHost = {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
2026-06-02 00:43:05 +03:00
|
|
|
/** Legacy/UI URL. During migration this may equal apiUrl. */
|
2026-02-05 01:59:49 +02:00
|
|
|
url: string;
|
2026-06-02 00:43:05 +03:00
|
|
|
/** API endpoint used by packaged Electron UI for this instance. */
|
|
|
|
|
apiUrl?: string;
|
|
|
|
|
/** Remote client bearer token for packaged-client API access. */
|
|
|
|
|
clientToken?: string;
|
2026-02-05 01:59:49 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type DesktopHostsConfig = {
|
|
|
|
|
hosts: DesktopHost[];
|
|
|
|
|
defaultHostId: string | null;
|
2026-04-15 01:32:59 +08:00
|
|
|
initialHostChoiceCompleted: boolean;
|
2026-06-02 00:43:05 +03:00
|
|
|
localOrigin?: string | null;
|
2026-04-15 01:32:59 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Backward-compatible input type — callers may omit `initialHostChoiceCompleted`. */
|
|
|
|
|
export type DesktopHostsConfigInput = {
|
|
|
|
|
hosts: DesktopHost[];
|
|
|
|
|
defaultHostId: string | null;
|
|
|
|
|
initialHostChoiceCompleted?: boolean;
|
2026-06-02 00:43:05 +03:00
|
|
|
localClientToken?: string | null;
|
2026-02-05 01:59:49 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type HostProbeResult = {
|
2026-06-02 00:43:05 +03:00
|
|
|
status: 'ok' | 'auth' | 'update-recommended' | 'incompatible' | 'wrong-service' | 'unreachable';
|
2026-02-05 01:59:49 +02:00
|
|
|
latencyMs: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
export type DesktopHostUrlResolution = {
|
|
|
|
|
persistedUrl: string;
|
|
|
|
|
redeemUrl: string | null;
|
|
|
|
|
kind: 'normal-host' | 'tunnel-connect-link';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SENSITIVE_QUERY_KEY = /^(t|.*(?:token|auth|secret|api).*)$/i;
|
2026-02-22 03:20:11 +02:00
|
|
|
|
|
|
|
|
export const normalizeHostUrl = (raw: string): string | null => {
|
|
|
|
|
const trimmed = raw.trim();
|
|
|
|
|
if (!trimmed) return null;
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(trimmed);
|
|
|
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return trimmed.split('#')[0] || null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
export const resolveDesktopHostUrl = (raw: string): DesktopHostUrlResolution | null => {
|
|
|
|
|
const normalized = normalizeHostUrl(raw);
|
|
|
|
|
if (!normalized) return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(normalized);
|
|
|
|
|
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
|
|
|
if (pathname === '/connect' && url.searchParams.has('t')) {
|
|
|
|
|
return {
|
|
|
|
|
persistedUrl: url.origin,
|
|
|
|
|
redeemUrl: url.toString(),
|
|
|
|
|
kind: 'tunnel-connect-link',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
persistedUrl: normalized,
|
|
|
|
|
redeemUrl: null,
|
|
|
|
|
kind: 'normal-host',
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-22 03:20:11 +02:00
|
|
|
export const redactSensitiveUrl = (raw: string): string => {
|
|
|
|
|
const normalized = normalizeHostUrl(raw);
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(normalized);
|
2026-04-15 01:32:59 +08:00
|
|
|
// Redact embedded credentials (userinfo) to prevent leaking user:pass
|
|
|
|
|
if (url.username || url.password) {
|
|
|
|
|
url.username = '';
|
|
|
|
|
url.password = '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 03:20:11 +02:00
|
|
|
const keys = Array.from(new Set(Array.from(url.searchParams.keys())));
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
if (SENSITIVE_QUERY_KEY.test(key)) {
|
|
|
|
|
url.searchParams.set(key, '[REDACTED]');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return url.toString();
|
|
|
|
|
} catch {
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const locationMatchesHost = (locationHref: string, hostUrl: string): boolean => {
|
|
|
|
|
const normalizedCurrent = normalizeHostUrl(locationHref);
|
|
|
|
|
const normalizedHost = normalizeHostUrl(hostUrl);
|
|
|
|
|
if (!normalizedCurrent || !normalizedHost) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const current = new URL(normalizedCurrent);
|
|
|
|
|
const host = new URL(normalizedHost);
|
|
|
|
|
if (current.origin !== host.origin) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (host.search && current.search !== host.search) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hostPath = host.pathname.length > 1 ? host.pathname.replace(/\/+$/, '') : host.pathname;
|
|
|
|
|
const currentPath = current.pathname.length > 1 ? current.pathname.replace(/\/+$/, '') : current.pathname;
|
|
|
|
|
if (hostPath === '/') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return currentPath === hostPath || currentPath.startsWith(`${hostPath}/`);
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
|
|
|
return typeof value === 'object' && value !== null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readString = (obj: Record<string, unknown>, key: string): string | null => {
|
|
|
|
|
const val = obj[key];
|
|
|
|
|
return typeof val === 'string' ? val : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readNumber = (obj: Record<string, unknown>, key: string): number | null => {
|
|
|
|
|
const val = obj[key];
|
|
|
|
|
return typeof val === 'number' && Number.isFinite(val) ? val : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseHost = (value: unknown): DesktopHost | null => {
|
|
|
|
|
if (!isRecord(value)) return null;
|
|
|
|
|
const id = readString(value, 'id');
|
|
|
|
|
const label = readString(value, 'label');
|
|
|
|
|
const url = readString(value, 'url');
|
2026-06-02 00:43:05 +03:00
|
|
|
const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url');
|
|
|
|
|
const clientToken = readString(value, 'clientToken') || readString(value, 'client_token');
|
2026-02-05 01:59:49 +02:00
|
|
|
if (!id || !label || !url) return null;
|
2026-06-02 00:43:05 +03:00
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
label,
|
|
|
|
|
url,
|
|
|
|
|
...(apiUrl ? { apiUrl } : {}),
|
|
|
|
|
...(clientToken ? { clientToken } : {}),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getDesktopHostApiUrl = (host: DesktopHost): string => {
|
|
|
|
|
return normalizeHostUrl(host.apiUrl || host.url) || host.apiUrl || host.url;
|
2026-02-05 01:59:49 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
const getInvoke = (): DesktopInvoke | null => {
|
|
|
|
|
if (!hasDesktopInvoke()) return null;
|
|
|
|
|
return (command, args) => invokeDesktop(command, args) as Promise<unknown>;
|
2026-02-05 01:59:49 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const desktopHostsGet = async (): Promise<DesktopHostsConfig> => {
|
|
|
|
|
const invoke = getInvoke();
|
|
|
|
|
if (!invoke) {
|
2026-04-15 01:32:59 +08:00
|
|
|
return { hosts: [], defaultHostId: 'local', initialHostChoiceCompleted: false };
|
2026-02-05 01:59:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const raw = await invoke('desktop_hosts_get');
|
|
|
|
|
if (!isRecord(raw)) {
|
2026-04-15 01:32:59 +08:00
|
|
|
return { hosts: [], defaultHostId: null, initialHostChoiceCompleted: false };
|
2026-02-05 01:59:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hostsRaw = raw.hosts;
|
|
|
|
|
const hosts = Array.isArray(hostsRaw)
|
|
|
|
|
? hostsRaw.map(parseHost).filter((h): h is DesktopHost => Boolean(h))
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const defaultHostId =
|
|
|
|
|
readString(raw, 'defaultHostId') ||
|
|
|
|
|
readString(raw, 'default_host_id') ||
|
|
|
|
|
readString(raw, 'defaultHostID');
|
|
|
|
|
|
2026-04-15 01:32:59 +08:00
|
|
|
const initialHostChoiceCompleted =
|
|
|
|
|
raw.initialHostChoiceCompleted === true || raw.initial_host_choice_completed === true;
|
2026-06-02 00:43:05 +03:00
|
|
|
const localOrigin = readString(raw, 'localOrigin') || readString(raw, 'local_origin');
|
2026-04-15 01:32:59 +08:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
return { hosts, defaultHostId, initialHostChoiceCompleted, localOrigin };
|
2026-02-05 01:59:49 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-15 01:32:59 +08:00
|
|
|
export const desktopHostsSet = async (config: DesktopHostsConfigInput): Promise<void> => {
|
2026-02-05 01:59:49 +02:00
|
|
|
const invoke = getInvoke();
|
|
|
|
|
if (!invoke) return;
|
2026-06-02 00:43:05 +03:00
|
|
|
const input: Record<string, unknown> = {
|
|
|
|
|
hosts: config.hosts,
|
|
|
|
|
defaultHostId: config.defaultHostId,
|
|
|
|
|
initialHostChoiceCompleted: config.initialHostChoiceCompleted,
|
|
|
|
|
};
|
|
|
|
|
if (config.localClientToken !== undefined) {
|
|
|
|
|
input.localClientToken = config.localClientToken;
|
|
|
|
|
}
|
2026-02-05 01:59:49 +02:00
|
|
|
await invoke('desktop_hosts_set', {
|
2026-06-02 00:43:05 +03:00
|
|
|
input,
|
2026-02-05 01:59:49 +02:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
export const desktopLocalClientTokenGet = async (): Promise<string> => {
|
|
|
|
|
const invoke = getInvoke();
|
|
|
|
|
if (!invoke) return '';
|
|
|
|
|
const raw = await invoke('desktop_local_client_token_get').catch(() => null);
|
|
|
|
|
return typeof raw === 'string' ? raw.trim() : '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise<HostProbeResult> => {
|
2026-02-05 01:59:49 +02:00
|
|
|
const invoke = getInvoke();
|
|
|
|
|
if (!invoke) {
|
|
|
|
|
return { status: 'unreachable', latencyMs: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined });
|
2026-02-05 01:59:49 +02:00
|
|
|
if (!isRecord(raw)) {
|
|
|
|
|
return { status: 'unreachable', latencyMs: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawStatus = raw.status;
|
|
|
|
|
const status: HostProbeResult['status'] =
|
2026-06-02 00:43:05 +03:00
|
|
|
rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'update-recommended' || rawStatus === 'incompatible' || rawStatus === 'wrong-service' || rawStatus === 'unreachable'
|
2026-02-05 01:59:49 +02:00
|
|
|
? rawStatus
|
|
|
|
|
: 'unreachable';
|
|
|
|
|
|
|
|
|
|
const latencyMs = readNumber(raw, 'latencyMs') ?? readNumber(raw, 'latency_ms') ?? 0;
|
|
|
|
|
return { status, latencyMs };
|
|
|
|
|
};
|
2026-02-10 02:07:04 +02:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise<void> => {
|
2026-02-10 02:07:04 +02:00
|
|
|
const invoke = getInvoke();
|
|
|
|
|
if (!invoke) return;
|
2026-06-02 00:43:05 +03:00
|
|
|
await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined });
|
2026-02-10 02:07:04 +02:00
|
|
|
};
|