Files
CodeNomad/packages/ui/src/components/remote-access-overlay.tsx
2025-12-03 21:57:27 +00:00

234 lines
9.6 KiB
TypeScript

import { Dialog } from "@kobalte/core/dialog"
import { Switch } from "@kobalte/core/switch"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { toDataURL } from "qrcode"
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli"
import { preferences, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts"
interface RemoteAccessOverlayProps {
open: boolean
onClose: () => void
}
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [loading, setLoading] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const refreshMeta = async () => {
setLoading(true)
setError(null)
try {
const result = await serverApi.fetchServerMeta()
setMeta(result)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
createEffect(() => {
if (props.open) {
void refreshMeta()
}
})
const toggleExpanded = async (url: string) => {
if (expandedUrl() === url) {
setExpandedUrl(null)
return
}
setExpandedUrl(url)
if (!qrCodes()[url]) {
try {
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
} catch (err) {
console.error("Failed to generate QR code", err)
}
}
}
const handleAllowConnectionsChange = async (checked: boolean) => {
const allow = Boolean(checked)
const targetMode: "local" | "all" = allow ? "all" : "local"
if (targetMode === currentMode()) {
return
}
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", {
title: allow ? "Open to other devices" : "Limit to this device",
variant: "warning",
confirmLabel: "Restart now",
cancelLabel: "Cancel",
})
if (!confirmed) {
// Switch will revert automatically since `checked` is derived from store state
return
}
setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError("Unable to restart automatically. Please restart the app to apply the change.")
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
void refreshMeta()
}
const handleOpenUrl = (url: string) => {
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (err) {
console.error("Failed to open URL", err)
}
}
return (
<Dialog
open={props.open}
modal
onOpenChange={(nextOpen) => {
if (!nextOpen) {
props.onClose()
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay remote-overlay-backdrop" />
<div class="remote-overlay">
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
<header class="remote-header">
<div>
<p class="remote-eyebrow">Remote handover</p>
<h2 class="remote-title">Connect to this session remotely</h2>
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p>
</div>
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access">
Close
</button>
</header>
<div class="remote-body">
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">Listening mode</p>
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p>
</div>
</div>
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
Refresh
</button>
</div>
<Switch
class="remote-toggle"
checked={allowExternalConnections()}
onChange={(nextChecked) => {
void handleAllowConnectionsChange(nextChecked)
}}
>
<Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
<span class="remote-toggle-state">{allowExternalConnections() ? "On" : "Off"}</span>
<Switch.Thumb class="remote-toggle-thumb" />
</Switch.Control>
<div class="remote-toggle-copy">
<span class="remote-toggle-title">Allow connections from other IPs</span>
<span class="remote-toggle-caption">
{allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"}
</span>
</div>
</Switch>
<p class="remote-toggle-note">
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the
server restarts.
</p>
</section>
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Wifi class="remote-icon" />
<div>
<p class="remote-label">Reachable addresses</p>
<p class="remote-help">Launch or scan from another machine to hand over control.</p>
</div>
</div>
</div>
<Show when={!loading()} fallback={<div class="remote-card">Loading addresses</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={addresses().length > 0} fallback={<div class="remote-card">No addresses available yet.</div>}>
<div class="remote-address-list">
<For each={addresses()}>
{(address) => {
const expandedState = () => expandedUrl() === address.url
const qr = () => qrCodes()[address.url]
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{address.url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
<ExternalLink class="remote-icon" />
Open
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(address.url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? "Hide QR" : "Show QR"}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => <img src={dataUrl()} alt={`QR for ${address.url}`} class="remote-qr-img" />}
</Show>
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</Show>
</Show>
</section>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}