Add remote access controls

This commit is contained in:
Shantur Rathore
2025-12-03 21:52:42 +00:00
parent 976430d61c
commit 94cb741c7f
23 changed files with 1165 additions and 29 deletions

View File

@@ -7,6 +7,7 @@ import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { initMarkdown } from "./lib/markdown"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
@@ -57,6 +58,7 @@ const App: Component = () => {
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
createEffect(() => {
void initMarkdown(isDark()).catch(console.error)
@@ -284,6 +286,7 @@ const App: Component = () => {
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
<Show when={activeInstance()} keyed>
@@ -338,10 +341,13 @@ const App: Component = () => {
</div>
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog />
<Toaster
position="top-right"
gutter={16}
toastOptions={{

View File

@@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus } from "lucide-solid"
import { Plus, Share2 } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
interface InstanceTabsProps {
@@ -11,6 +11,7 @@ interface InstanceTabsProps {
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
onOpenRemoteAccess?: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
@@ -37,6 +38,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
>
<Plus class="w-4 h-4" />
</button>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button"
onClick={() => props.onOpenRemoteAccess?.()}
title="Remote access"
aria-label="Remote access"
>
<Share2 class="w-4 h-4" />
</button>
</Show>
</div>
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="flex-shrink-0 ml-auto pl-4">

View File

@@ -0,0 +1,236 @@
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 [expanded, setExpanded] = createSignal<Set<string>>(new Set())
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) => {
const next = new Set(expanded())
if (next.has(url)) {
next.delete(url)
setExpanded(next)
return
}
next.add(url)
setExpanded(next)
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 access</p>
<h2 class="remote-title">Share this CodeNomad server</h2>
<p class="remote-subtitle">Choose who can connect and share ready-to-open links or QR codes.</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">Toggle whether other devices on your network can reach this server.</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">Use these URLs to connect from this or other devices.</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 = () => expanded().has(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>
)
}

View File

@@ -0,0 +1,28 @@
import { runtimeEnv } from "../runtime-env"
export async function restartCli(): Promise<boolean> {
try {
if (runtimeEnv.host === "electron") {
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
if (api?.restartCli) {
await api.restartCli()
return true
}
return false
}
if (runtimeEnv.host === "tauri") {
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
if (tauri?.invoke) {
await tauri.invoke("cli_restart")
return true
}
return false
}
} catch (error) {
console.error("Failed to restart CLI", error)
return false
}
return false
}

View File

@@ -4,8 +4,8 @@ import { serverApi } from "./api-client"
let cachedMeta: ServerMeta | null = null
let pendingMeta: Promise<ServerMeta> | null = null
export async function getServerMeta(): Promise<ServerMeta> {
if (cachedMeta) {
export async function getServerMeta(forceRefresh = false): Promise<ServerMeta> {
if (cachedMeta && !forceRefresh) {
return cachedMeta
}
if (pendingMeta) {

View File

@@ -27,6 +27,8 @@ export interface AgentModelSelections {
export type DiffViewMode = "split" | "unified"
export type ExpansionPreference = "expanded" | "collapsed"
export type ListeningMode = "local" | "all"
export interface Preferences {
showThinkingBlocks: boolean
thinkingBlocksExpansion: ExpansionPreference
@@ -37,10 +39,13 @@ export interface Preferences {
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
showUsageMetrics: boolean
autoCleanupBlankSessions?: boolean
autoCleanupBlankSessions: boolean
listeningMode: ListeningMode
}
export interface OpenCodeBinary {
path: string
version?: string
lastUsed: number
@@ -66,8 +71,10 @@ const defaultPreferences: Preferences = {
diagnosticsExpansion: "expanded",
showUsageMetrics: true,
autoCleanupBlankSessions: true,
listeningMode: "local",
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
@@ -101,10 +108,12 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
}
}
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
const preferences = createMemo<Preferences>(() => internalConfig().preferences)
@@ -260,6 +269,11 @@ function updatePreferences(updates: Partial<Preferences>): void {
})
}
function setListeningMode(mode: ListeningMode): void {
if (preferences().listeningMode === mode) return
updatePreferences({ listeningMode: mode })
}
function setDiffViewMode(mode: DiffViewMode): void {
if (preferences().diffViewMode === mode) return
updatePreferences({ diffViewMode: mode })
@@ -399,6 +413,7 @@ interface ConfigContextValue {
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion
setListeningMode: typeof setListeningMode
addRecentFolder: typeof addRecentFolder
removeRecentFolder: typeof removeRecentFolder
addOpenCodeBinary: typeof addOpenCodeBinary
@@ -432,6 +447,7 @@ const configContextValue: ConfigContextValue = {
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setListeningMode,
addRecentFolder,
removeRecentFolder,
addOpenCodeBinary,
@@ -502,8 +518,11 @@ export {
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setListeningMode,
themePreference,
setThemePreference,
recordWorkspaceLaunch,
}

View File

@@ -0,0 +1,289 @@
.remote-overlay {
position: fixed;
inset: 0;
z-index: 41;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal-overlay.remote-overlay-backdrop {
background: var(--overlay-scrim);
backdrop-filter: blur(6px);
z-index: 40;
}
.remote-panel {
width: min(960px, 100%);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.remote-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid var(--border-base);
}
.remote-eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
color: var(--text-subtle);
margin: 0 0 4px;
}
.remote-title {
margin: 0;
font-size: 20px;
color: var(--text-primary);
}
.remote-subtitle {
margin: 4px 0 0;
color: var(--text-secondary);
font-size: 14px;
}
.remote-close {
border: 1px solid var(--border-base);
background: var(--surface-secondary);
color: var(--text-primary);
border-radius: 10px;
padding: 8px 12px;
cursor: pointer;
}
.remote-body {
padding: 16px 24px 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.remote-section {
border: 1px solid var(--border-base);
border-radius: 12px;
background: var(--surface-secondary);
padding: 16px;
}
.remote-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.remote-section-title {
display: flex;
gap: 10px;
align-items: center;
}
.remote-icon {
width: 18px;
height: 18px;
}
.remote-label {
margin: 0;
color: var(--text-primary);
font-weight: 600;
}
.remote-help {
margin: 2px 0 0;
color: var(--text-secondary);
font-size: 13px;
}
.remote-refresh {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border-base);
background: var(--surface-primary);
color: var(--text-primary);
cursor: pointer;
}
.remote-toggle {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border-base);
background: var(--surface-primary);
cursor: pointer;
}
.remote-toggle-switch {
width: 58px;
height: 28px;
border-radius: 999px;
background: var(--surface-secondary);
border: 1px solid var(--border-base);
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 6px;
transition: background 0.2s ease, border-color 0.2s ease;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.remote-toggle-state {
pointer-events: none;
}
.remote-toggle-thumb {
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--surface-primary);
transition: transform 0.2s ease;
transform: translateX(0);
}
.remote-toggle-switch[data-checked="true"] {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--surface-primary);
}
.remote-toggle-switch[data-checked="true"] .remote-toggle-thumb {
transform: translateX(20px);
}
.remote-toggle-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.remote-toggle-title {
font-weight: 600;
color: var(--text-primary);
}
.remote-toggle-caption {
font-size: 13px;
color: var(--text-secondary);
}
.remote-toggle-note {
margin: 12px 0 0;
font-size: 13px;
color: var(--text-secondary);
}
.remote-address-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.remote-address {
border: 1px solid var(--border-base);
border-radius: 12px;
padding: 12px;
background: var(--surface-primary);
}
.remote-address-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.remote-address-url {
margin: 0;
font-weight: 600;
color: var(--text-primary);
}
.remote-address-meta {
margin: 4px 0 0;
color: var(--text-secondary);
font-size: 12px;
}
.remote-actions {
display: flex;
gap: 8px;
}
.remote-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 999px;
border: 1px solid var(--border-base);
background: var(--surface-secondary);
color: var(--text-primary);
cursor: pointer;
}
.remote-qr {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
border: 1px dashed var(--border-base);
border-radius: 10px;
background: var(--surface-secondary);
}
.remote-qr-img {
width: 160px;
height: 160px;
image-rendering: pixelated;
}
.remote-card {
border: 1px dashed var(--border-base);
border-radius: 10px;
padding: 12px;
color: var(--text-secondary);
}
.remote-error {
border: 1px solid var(--border-critical, #e65c5c);
background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent);
border-radius: 10px;
padding: 12px;
color: var(--text-primary);
}
.remote-spin {
animation: remote-spin 1s linear infinite;
}
@keyframes remote-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -5,3 +5,4 @@
@import "./components/selector.css";
@import "./components/env-vars.css";
@import "./components/directory-browser.css";
@import "./components/remote-access.css";

3
packages/ui/src/types/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module "qrcode" {
export function toDataURL(text: string, opts?: Record<string, unknown>): Promise<string>
}