/** * desktop/src/deep-link.ts — B2: terminalapp:// deep-link parsing. * * Maps the OS custom-protocol URL `terminalapp://join/` onto the existing * frontend's `/?join=` route (see DESKTOP_PLAN §4.3). Pure + hand-validated * (no URL-parsing surprises): the URL class does the heavy lifting inside a * try/catch so a malformed link yields null instead of throwing at the boundary. */ import type { DeepLink } from './types.js' /** Custom protocol registered with the OS (electron app.setAsDefaultProtocolClient). */ export const DEEP_LINK_PROTOCOL = 'terminalapp' /** The single legal host segment: terminalapp://join/. */ const JOIN_HOST = 'join' /** URL.protocol includes the trailing colon, so compare against that form. */ const PROTOCOL_WITH_COLON = `${DEEP_LINK_PROTOCOL}:` /** * Parse `terminalapp://join/` into a DeepLink, or null if it is not a * well-formed join link. Rejects: wrong scheme, wrong host, missing/empty id, * ids with '/' or control chars, and anything the URL parser chokes on. */ export function parseDeepLink(url: string): DeepLink | null { let parsed: URL try { parsed = new URL(url) } catch { return null } if (parsed.protocol !== PROTOCOL_WITH_COLON) return null if (parsed.host !== JOIN_HOST) return null // pathname is '/' for a single segment; strip the leading slash and require // exactly one non-empty segment (no nested paths like join/a/b). const rawPath = parsed.pathname.replace(/^\/+/, '') if (rawPath === '' || rawPath.includes('/')) return null const id = safeDecode(rawPath) if (id === null) return null if (!isValidId(id)) return null return { join: id } } /** Render a DeepLink as the frontend query-route the window navigates to. */ export function deepLinkToPath(link: DeepLink): string { return `/?join=${encodeURIComponent(link.join)}` } /** decodeURIComponent can throw on malformed percent-encoding — narrow to null. */ function safeDecode(value: string): string | null { try { return decodeURIComponent(value) } catch { return null } } /** An id is valid if it is non-empty, not whitespace-only, and control-char free. */ function isValidId(id: string): boolean { if (id.trim() === '') return false if (id.includes('/')) return false // Reject C0/C1 control characters (they can't be part of a real session id). for (const ch of id) { const code = ch.charCodeAt(0) if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return false } return true }