Add remote access controls
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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">
|
||||
|
||||
236
packages/ui/src/components/remote-access-overlay.tsx
Normal file
236
packages/ui/src/components/remote-access-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
packages/ui/src/lib/native/cli.ts
Normal file
28
packages/ui/src/lib/native/cli.ts
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
289
packages/ui/src/styles/components/remote-access.css
Normal file
289
packages/ui/src/styles/components/remote-access.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
3
packages/ui/src/types/qrcode.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module "qrcode" {
|
||||
export function toDataURL(text: string, opts?: Record<string, unknown>): Promise<string>
|
||||
}
|
||||
Reference in New Issue
Block a user