Add revocable runtime share access (#2236)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-18 00:41:54 -07:00
committed by GitHub
co-authored by Orca
parent 95577ea9a0
commit 8b5b5a8e00
11 changed files with 542 additions and 82 deletions
+64
View File
@@ -132,4 +132,68 @@ describe('registerMobileHandlers', () => {
scope: 'runtime'
})
})
it('lists runtime access grants including unused generated links', () => {
const rpcServer = {
getDeviceRegistry: () => ({
listDevices: () => [
{
deviceId: 'mobile-1',
name: 'Phone',
scope: 'mobile',
pairedAt: 1,
lastSeenAt: 2
},
{
deviceId: 'runtime-1',
name: 'Browser',
scope: 'runtime',
pairedAt: 3,
lastSeenAt: 4
},
{
deviceId: 'pending-runtime',
name: 'Copied link',
scope: 'runtime',
pairedAt: 5,
lastSeenAt: 0
}
]
})
}
registerMobileHandlers(rpcServer as never)
expect(handlers.get('mobile:listRuntimeAccessGrants')?.()).toEqual({
grants: [
{
deviceId: 'pending-runtime',
name: 'Copied link',
createdAt: 5,
lastSeenAt: null
},
{
deviceId: 'runtime-1',
name: 'Browser',
createdAt: 3,
lastSeenAt: 4
}
]
})
})
it('revokes runtime access through the runtime server', () => {
const revokeRuntimeAccess = vi.fn().mockReturnValue(true)
const rpcServer = {
getDeviceRegistry: () => ({}),
revokeRuntimeAccess
}
registerMobileHandlers(rpcServer as never)
expect(handlers.get('mobile:revokeRuntimeAccess')?.(null, { deviceId: 'runtime-1' })).toEqual({
revoked: true
})
expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1')
})
})
+35
View File
@@ -1,6 +1,8 @@
import { ipcMain } from 'electron'
import { networkInterfaces } from 'os'
import QRCode from 'qrcode'
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
import type { DeviceEntry } from '../runtime/device-registry'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
export type NetworkInterface = {
@@ -33,6 +35,15 @@ function getLanAddress(): string | null {
return ifaces.length > 0 ? ifaces[0]!.address : null
}
function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
return {
deviceId: device.deviceId,
name: device.name,
createdAt: device.pairedAt,
lastSeenAt: device.lastSeenAt > 0 ? device.lastSeenAt : null
}
}
// Why: the mobile IPC handlers provide the renderer with QR code pairing data,
// device management, and WebSocket readiness status. They depend on the
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
@@ -137,6 +148,22 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
}
})
ipcMain.handle('mobile:listRuntimeAccessGrants', () => {
const registry = rpcServer.getDeviceRegistry()
if (!registry) {
return { grants: [] }
}
// Why: generated web/runtime links are bearer credentials even before a
// client first connects, so pending runtime grants must stay revocable.
return {
grants: registry
.listDevices()
.filter((d) => d.scope === 'runtime')
.sort((a, b) => b.pairedAt - a.pairedAt)
.map(toRuntimeAccessGrant)
}
})
ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => {
const registry = rpcServer.getDeviceRegistry()
if (!registry) {
@@ -145,6 +172,14 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
return { revoked: rpcServer.revokeMobileDevice(args.deviceId) }
})
ipcMain.handle('mobile:revokeRuntimeAccess', (_event, args: { deviceId: string }) => {
const registry = rpcServer.getDeviceRegistry()
if (!registry) {
return { revoked: false }
}
return { revoked: rpcServer.revokeRuntimeAccess(args.deviceId) }
})
ipcMain.handle('mobile:isWebSocketReady', () => {
return {
ready: rpcServer.getWebSocketEndpoint() !== null,
+91
View File
@@ -519,6 +519,97 @@ describe('OrcaRuntimeRpcServer', () => {
}
})
it('terminates active WebSockets for a revoked runtime access grant', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const offer = server.createPairingOffer({
address: '127.0.0.1',
name: 'runtime-test',
scope: 'runtime'
})
expect(offer.available).toBe(true)
if (!offer.available) {
throw new Error('WebSocket pairing unavailable')
}
const first = await authenticateMobileWs(offer.pairingUrl)
const second = await authenticateMobileWs(offer.pairingUrl)
expect(server.revokeRuntimeAccess(offer.deviceId)).toBe(true)
await Promise.all([waitForWsClose(first), waitForWsClose(second)])
await waitFor(() => server['e2eeChannels'].size === 0 && server['wsConnectionIds'].size === 0)
expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)).toBeNull()
} finally {
await server.stop()
}
})
it('rotates unused runtime pairing links without revoking already-used grants', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const first = server.createPairingOffer({
address: '127.0.0.1',
name: 'runtime-test',
rotate: true,
scope: 'runtime'
})
const second = server.createPairingOffer({
address: '127.0.0.1',
name: 'runtime-test',
rotate: true,
scope: 'runtime'
})
expect(first.available).toBe(true)
expect(second.available).toBe(true)
if (!first.available || !second.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(first.deviceId).not.toBe(second.deviceId)
expect(parsePairingCode(first.pairingUrl)?.deviceToken).not.toBe(
parsePairingCode(second.pairingUrl)?.deviceToken
)
expect(server.getDeviceRegistry()?.getDevice(first.deviceId)).toBeNull()
server.getDeviceRegistry()?.updateLastSeen(second.deviceId)
const third = server.createPairingOffer({
address: '127.0.0.1',
name: 'runtime-test',
rotate: true,
scope: 'runtime'
})
expect(third.available).toBe(true)
if (!third.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(server.getDeviceRegistry()?.getDevice(second.deviceId)).not.toBeNull()
expect(server.getDeviceRegistry()?.getDevice(third.deviceId)).not.toBeNull()
} finally {
await server.stop()
}
})
it('caps WebSocket long-polls and aborts them when the socket closes', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
+9
View File
@@ -298,6 +298,15 @@ export class OrcaRuntimeRpcServer {
return true
}
revokeRuntimeAccess(deviceId: string): boolean {
const device = this.deviceRegistry?.getDevice(deviceId)
if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) {
return false
}
this.wsTransport?.terminateClientConnections(device.token)
return true
}
getWebSocketEndpoint(): string | null {
const ws = this.transports.find((t) => t.kind === 'websocket')
return ws?.endpoint ?? null
+3
View File
@@ -101,6 +101,7 @@ import type {
} from '../shared/types'
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
import type { RuntimeAccessGrant } from '../shared/runtime-access-grants'
import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope'
import type {
AddIssueCommentBySlugArgs,
@@ -1893,6 +1894,8 @@ export type PreloadApi = {
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
}>
revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }>
listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }>
revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }>
isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }>
}
speech: {
+5
View File
@@ -2958,6 +2958,11 @@ const api = {
revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> =>
ipcRenderer.invoke('mobile:revokeDevice', args),
listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'),
revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> =>
ipcRenderer.invoke('mobile:revokeRuntimeAccess', args),
isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> =>
ipcRenderer.invoke('mobile:isWebSocketReady')
},
@@ -0,0 +1,118 @@
import { Loader2, RefreshCw, Trash2 } from 'lucide-react'
import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
function formatAccessTimestamp(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(timestamp))
}
type RuntimeAccessGrantListProps = {
className?: string
grants: RuntimeAccessGrant[]
currentGrantId: string | null
isLoading: boolean
revokingGrantId: string | null
onRefresh: () => void
onRevoke: (grant: RuntimeAccessGrant) => void
}
export function RuntimeAccessGrantList({
className,
grants,
currentGrantId,
isLoading,
revokingGrantId,
onRefresh,
onRevoke
}: RuntimeAccessGrantListProps): React.JSX.Element {
return (
<div className={className}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">Shared Server Access</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onRefresh}
disabled={isLoading}
aria-label="Refresh shared access"
>
<RefreshCw className={isLoading ? 'animate-spin' : undefined} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Refresh shared access
</TooltipContent>
</Tooltip>
</div>
{grants.length === 0 ? (
<p className="text-muted-foreground text-sm">No shared server access yet.</p>
) : (
<div className="space-y-2">
{grants.map((grant) => {
const isCurrent = currentGrantId === grant.deviceId
const isRevoking = revokingGrantId === grant.deviceId
return (
<div
key={grant.deviceId}
className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2"
>
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{grant.name}</span>
{isCurrent ? (
<span className="text-muted-foreground shrink-0 text-xs">Current link</span>
) : null}
</div>
<div className="text-muted-foreground text-xs">
Created {formatAccessTimestamp(grant.createdAt)} ·{' '}
{grant.lastSeenAt
? `Last used ${formatAccessTimestamp(grant.lastSeenAt)}`
: 'Not used yet'}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => onRevoke(grant)}
disabled={isRevoking}
aria-label={`Revoke ${grant.name}`}
>
{isRevoking ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Revoke access
</TooltipContent>
</Tooltip>
</div>
)
})}
</div>
)}
{grants.length > 0 ? (
<p className="text-muted-foreground mt-3 text-xs">
Anyone with an active grant can connect until you revoke it. Revoking shared access
disconnects active clients immediately.
</p>
) : null}
</div>
)
}
@@ -387,9 +387,9 @@ export function RuntimeEnvironmentsPane({
<div className="overflow-hidden rounded-lg border border-border/50">
<div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">Share this desktop</div>
<div className="text-sm font-medium">Share this Orca server</div>
<p className="text-xs text-muted-foreground">
Generate a pairing URL for the web client or another Orca client.
Create a revocable access grant so a browser or another Orca client can connect.
</p>
</div>
<Button
@@ -400,14 +400,16 @@ export function RuntimeEnvironmentsPane({
onClick={() => setShareServerFormOpen((open) => !open)}
>
<Share2 />
{shareServerFormOpen ? 'Hide' : 'Generate Link'}
{shareServerFormOpen ? 'Hide Form' : 'New Link'}
</Button>
</div>
{shareServerFormOpen ? (
<div className="border-t border-border/40 px-3 py-3">
<RuntimePairingUrlGenerator framed={false} showHeader={false} />
</div>
) : null}
<div className="border-t border-border/40 px-3 py-3">
<RuntimePairingUrlGenerator
framed={false}
showHeader={false}
showGeneratorForm={shareServerFormOpen}
/>
</div>
</div>
) : null}
@@ -1,10 +1,12 @@
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
const LOOPBACK_ADDRESS = '127.0.0.1'
@@ -15,20 +17,24 @@ const runtimePairingUrlCache: {
customAddress: string
runtimePairingUrl: string | null
webClientUrl: string | null
runtimePairingDeviceId: string | null
} = {
selectedAddress: LOOPBACK_ADDRESS,
customAddress: '',
runtimePairingUrl: null,
webClientUrl: null
webClientUrl: null,
runtimePairingDeviceId: null
}
function GeneratedUrlRow({
label,
description,
value,
copied,
onCopy
}: {
label: string
description?: string
value: string
copied: boolean
onCopy: () => void
@@ -36,6 +42,7 @@ function GeneratedUrlRow({
return (
<div className="space-y-1">
<Label>{label}</Label>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-background/70 px-2 py-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{value}
@@ -54,14 +61,33 @@ function GeneratedUrlRow({
)
}
function UnavailableUrlRow({
label,
description
}: {
label: string
description: string
}): React.JSX.Element {
return (
<div className="space-y-1">
<Label>{label}</Label>
<div className="rounded-md border border-border/60 px-2 py-1.5">
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
)
}
type RuntimePairingUrlGeneratorProps = {
framed?: boolean
showHeader?: boolean
showGeneratorForm?: boolean
}
export function RuntimePairingUrlGenerator({
framed = true,
showHeader = true
showHeader = true,
showGeneratorForm = true
}: RuntimePairingUrlGeneratorProps): React.JSX.Element {
const [networkInterfaces, setNetworkInterfaces] = useState<{ name: string; address: string }[]>(
[]
@@ -74,8 +100,40 @@ export function RuntimePairingUrlGenerator({
const [webClientUrl, setWebClientUrl] = useState<string | null>(
runtimePairingUrlCache.webClientUrl
)
const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState<string | null>(
runtimePairingUrlCache.runtimePairingDeviceId
)
const [runtimeAccessGrants, setRuntimeAccessGrants] = useState<RuntimeAccessGrant[]>([])
const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false)
const [revokingGrantId, setRevokingGrantId] = useState<string | null>(null)
const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null)
const [isGeneratingPairing, setIsGeneratingPairing] = useState(false)
const accessGrantLoadIdRef = useRef(0)
const loadRuntimeAccessGrants = useCallback(
async (options: { showToastOnError?: boolean } = {}): Promise<void> => {
const loadId = accessGrantLoadIdRef.current + 1
accessGrantLoadIdRef.current = loadId
setIsLoadingAccessGrants(true)
try {
const result = await window.api.mobile.listRuntimeAccessGrants()
if (loadId === accessGrantLoadIdRef.current) {
setRuntimeAccessGrants(result.grants)
}
} catch (error) {
if (loadId === accessGrantLoadIdRef.current && options.showToastOnError) {
toast.error(
error instanceof Error ? error.message : 'Failed to load shared access grants.'
)
}
} finally {
if (loadId === accessGrantLoadIdRef.current) {
setIsLoadingAccessGrants(false)
}
}
},
[]
)
useEffect(() => {
let stale = false
@@ -98,6 +156,22 @@ export function RuntimePairingUrlGenerator({
}
}, [])
useEffect(() => {
void loadRuntimeAccessGrants()
return () => {
accessGrantLoadIdRef.current += 1
}
}, [loadRuntimeAccessGrants])
const clearGeneratedUrls = (): void => {
runtimePairingUrlCache.runtimePairingUrl = null
runtimePairingUrlCache.webClientUrl = null
runtimePairingUrlCache.runtimePairingDeviceId = null
setRuntimePairingUrl(null)
setWebClientUrl(null)
setRuntimePairingDeviceId(null)
}
const generateRuntimePairingUrl = async (): Promise<void> => {
setIsGeneratingPairing(true)
try {
@@ -107,17 +181,17 @@ export function RuntimePairingUrlGenerator({
rotate: true
})
if (!result.available) {
runtimePairingUrlCache.runtimePairingUrl = null
runtimePairingUrlCache.webClientUrl = null
setRuntimePairingUrl(null)
setWebClientUrl(null)
clearGeneratedUrls()
toast.error('Runtime pairing is unavailable.')
return
}
runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl
runtimePairingUrlCache.webClientUrl = result.webClientUrl
runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId
setRuntimePairingUrl(result.pairingUrl)
setWebClientUrl(result.webClientUrl)
setRuntimePairingDeviceId(result.deviceId)
await loadRuntimeAccessGrants()
toast.success(result.webClientUrl ? 'Generated web client URL.' : 'Generated pairing URL.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.')
@@ -126,6 +200,29 @@ export function RuntimePairingUrlGenerator({
}
}
const revokeRuntimeAccess = async (grant: RuntimeAccessGrant): Promise<void> => {
setRevokingGrantId(grant.deviceId)
try {
const result = await window.api.mobile.revokeRuntimeAccess({ deviceId: grant.deviceId })
if (!result.revoked) {
toast.error('Shared access was already revoked.')
await loadRuntimeAccessGrants()
return
}
setRuntimeAccessGrants((current) =>
current.filter((entry) => entry.deviceId !== grant.deviceId)
)
if (runtimePairingDeviceId === grant.deviceId) {
clearGeneratedUrls()
}
toast.success('Shared access revoked.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to revoke shared access.')
} finally {
setRevokingGrantId(null)
}
}
const copyGeneratedUrl = async (target: 'web' | 'pairing', value: string): Promise<void> => {
try {
await window.api.ui.writeClipboardText(value)
@@ -142,6 +239,7 @@ export function RuntimePairingUrlGenerator({
const containerClassName = framed
? 'space-y-3 rounded-lg border border-border/50 bg-muted/25 p-3'
: 'space-y-4'
const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : ''
const updateSelectedAddress = (address: string): void => {
runtimePairingUrlCache.selectedAddress = address
@@ -157,83 +255,110 @@ export function RuntimePairingUrlGenerator({
<div className={containerClassName}>
{showHeader ? (
<div className="space-y-1">
<Label id="runtime-share-server-label">Share this desktop</Label>
<Label id="runtime-share-server-label">Share this Orca server</Label>
<p className="text-xs text-muted-foreground">
Generate a runtime pairing URL for the web client or another Orca client.
Create a revocable access grant for browser or desktop clients.
</p>
</div>
) : null}
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-[minmax(0,220px)_minmax(0,1fr)]">
<div className="space-y-1">
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
Advertise address
</Label>
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
<SelectTrigger
id="runtime-pairing-address"
size="sm"
className="min-w-[220px]"
aria-labelledby="runtime-pairing-address-label"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LOOPBACK_ADDRESS}>This computer ({LOOPBACK_ADDRESS})</SelectItem>
{networkInterfaces.map((networkInterface, index) => (
<SelectItem
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
value={networkInterface.address}
{showGeneratorForm ? (
<>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-[minmax(0,220px)_minmax(0,1fr)]">
<div className="space-y-1">
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
Connection address
</Label>
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
<SelectTrigger
id="runtime-pairing-address"
size="sm"
className="min-w-[220px]"
aria-labelledby="runtime-pairing-address-label"
>
{networkInterface.name} ({networkInterface.address})
</SelectItem>
))}
</SelectContent>
</Select>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LOOPBACK_ADDRESS}>
This computer ({LOOPBACK_ADDRESS})
</SelectItem>
{networkInterfaces.map((networkInterface, index) => (
<SelectItem
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
value={networkInterface.address}
>
{networkInterface.name} ({networkInterface.address})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-0 space-y-1">
<Label htmlFor="runtime-pairing-custom-address">Custom address</Label>
<Input
id="runtime-pairing-custom-address"
value={customAddress}
onChange={(event) => updateCustomAddress(event.target.value)}
placeholder="host, host:port, or wss://host/path"
className="h-8 font-mono text-xs"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for
another device.
</p>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void generateRuntimePairingUrl()}
disabled={isGeneratingPairing}
>
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Generate Access Link
</Button>
</div>
</div>
<div className="min-w-0 space-y-1">
<Label htmlFor="runtime-pairing-custom-address">Custom address</Label>
<Input
id="runtime-pairing-custom-address"
value={customAddress}
onChange={(event) => updateCustomAddress(event.target.value)}
placeholder="host, host:port, or wss://host/path"
className="h-8 font-mono text-xs"
{webClientUrl ? (
<GeneratedUrlRow
label="Open in browser"
description="Use this URL from a browser that can reach the selected address."
value={webClientUrl}
copied={copiedTarget === 'web'}
onCopy={() => void copyGeneratedUrl('web', webClientUrl)}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void generateRuntimePairingUrl()}
disabled={isGeneratingPairing}
>
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Generate
</Button>
</div>
</div>
) : runtimePairingUrl ? (
<UnavailableUrlRow
label="Open in browser"
description="Browser link unavailable in this build. The pairing URL still works for Orca clients."
/>
) : null}
{webClientUrl ? (
<GeneratedUrlRow
label="Web client URL"
value={webClientUrl}
copied={copiedTarget === 'web'}
onCopy={() => void copyGeneratedUrl('web', webClientUrl)}
/>
{runtimePairingUrl ? (
<GeneratedUrlRow
label="Pair another Orca client"
description="Paste this pairing URL into another Orca client."
value={runtimePairingUrl}
copied={copiedTarget === 'pairing'}
onCopy={() => void copyGeneratedUrl('pairing', runtimePairingUrl)}
/>
) : null}
</>
) : null}
{runtimePairingUrl ? (
<GeneratedUrlRow
label="Pairing URL"
value={runtimePairingUrl}
copied={copiedTarget === 'pairing'}
onCopy={() => void copyGeneratedUrl('pairing', runtimePairingUrl)}
/>
) : null}
<RuntimeAccessGrantList
className={sharedAccessClassName}
grants={runtimeAccessGrants}
currentGrantId={runtimePairingDeviceId}
isLoading={isLoadingAccessGrants}
revokingGrantId={revokingGrantId}
onRefresh={() => void loadRuntimeAccessGrants({ showToastOnError: true })}
onRevoke={(grant) => void revokeRuntimeAccess(grant)}
/>
</div>
)
}
+2
View File
@@ -201,6 +201,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
getRuntimePairingUrl: () => Promise.resolve({ available: false }),
listDevices: () => Promise.resolve({ devices: [] }),
revokeDevice: () => Promise.resolve({ revoked: false }),
listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }),
revokeRuntimeAccess: () => Promise.resolve({ revoked: false }),
isWebSocketReady: () => Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null })
},
telemetryTrack: () => Promise.resolve(),
+6
View File
@@ -0,0 +1,6 @@
export type RuntimeAccessGrant = {
deviceId: string
name: string
createdAt: number
lastSeenAt: number | null
}