mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Add revocable runtime share access (#2236)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type RuntimeAccessGrant = {
|
||||
deviceId: string
|
||||
name: string
|
||||
createdAt: number
|
||||
lastSeenAt: number | null
|
||||
}
|
||||
Reference in New Issue
Block a user