/** * Build and open the browser URL for a container's published service port. * * Centralizes the host, protocol, and path logic that the container card, the * stack "Open App" menu, and the stack anatomy footer all need, so a published * port renders as a real, reliable link instead of an ad hoc * `window.open('http://host:port')`. */ // Some multi-port apps serve their UI at a sub-path, so a bare host:port does // not land on the working page. Keyed by the app's container (private) port. const KNOWN_SERVICE_PATHS: Record = { 32400: '/web', // Plex }; function isValidPort(port: number): boolean { return Number.isInteger(port) && port >= 1 && port <= 65535; } // Accepts a bare host or a full URL (a future configured public host may be // pasted either way) and returns just the hostname, or '' when unusable. function normalizeHost(value: string): string { const trimmed = value.trim(); if (!trimmed) return ''; if (trimmed.includes('://')) { try { return new URL(trimmed).hostname; } catch { return ''; } } return trimmed; } export interface ServiceUrlOptions { /** Active node; a remote node resolves to its own host, never the browser host. */ node?: { type?: 'local' | 'remote'; api_url?: string } | null; /** Published host port (the browser-reachable one). */ publicPort: number; /** Container port, used for protocol/path inference only. */ privatePort?: number; /** Configured public/service host that overrides the node host. Reserved for future use. */ publicHost?: string | null; /** Browser hostname; defaults to window.location.hostname. Injectable for tests. */ browserHost?: string; /** Explicit protocol override. Reserved for future use. */ protocol?: 'http' | 'https'; } /** * Returns the browser URL for the published port, or null when no * browser-reachable host can be resolved (e.g. a remote node with no API URL, * such as a pilot-agent node) or the port is out of range. Callers render a * link only when this is non-null. */ export function buildServiceUrl(opts: ServiceUrlOptions): string | null { const { node, publicPort, privatePort, publicHost, browserHost, protocol } = opts; if (!isValidPort(publicPort)) return null; const host = resolveHost(node, publicHost, browserHost); if (!host) return null; const scheme = protocol ?? (publicPort === 443 || privatePort === 443 ? 'https' : 'http'); // When the container port is known, only it decides the app path. The // published-port lookup is a fallback for callers that do not know it (the // "Open App" menu and anatomy footer), where the host port is the best signal. const path = privatePort !== undefined ? (KNOWN_SERVICE_PATHS[privatePort] ?? '') : (KNOWN_SERVICE_PATHS[publicPort] ?? ''); return `${scheme}://${host}:${publicPort}${path}`; } function resolveHost( node: ServiceUrlOptions['node'], publicHost: string | null | undefined, browserHost: string | undefined, ): string | null { if (publicHost) { const normalized = normalizeHost(publicHost); if (normalized) return normalized; } if (node?.type === 'remote') { // A remote node must resolve to its own reachable host. Never fall back to // the browser host: that points at the control instance, not the remote. if (node.api_url) { try { return new URL(node.api_url).hostname || null; } catch { return null; } } return null; } // Local or no node: the browser is talking to the instance that runs the // stacks, so its hostname is the right target. if (browserHost) return browserHost; return typeof window !== 'undefined' ? window.location.hostname : null; } /** * Open a URL in a new tab via a transient anchor click. More reliable than * window.open on mobile browsers, which may block programmatic popups. */ export function openServiceUrl(url: string): void { const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); }