234 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
}
|