feat(vscodex): add remote Codex collaboration module

This commit is contained in:
fawney
2026-09-01 20:25:35 +08:00
parent 5a69cfe40d
commit 30a75832f8
102 changed files with 38569 additions and 11 deletions
+559
View File
@@ -0,0 +1,559 @@
<template>
<div class="mx-auto flex min-h-[calc(100vh-9rem)] w-full max-w-[1800px] flex-col gap-4 pb-2">
<header class="flex flex-col gap-3 border-b border-border/60 pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h1 class="flex items-center gap-2 text-lg font-semibold text-foreground">
<SquareTerminal class="h-5 w-5 text-primary" />
{{ t('vscodex.title') }}
</h1>
</div>
<div class="flex min-w-0 items-center gap-2">
<label
v-if="devices.length > 0"
class="sr-only"
for="vscodex-device-select"
>{{ t('vscodex.devices.label') }}</label>
<select
v-if="devices.length > 0"
id="vscodex-device-select"
v-model="selectedDeviceId"
data-testid="vscodex-device-select"
class="h-9 min-w-0 max-w-64 rounded-md border border-border/70 bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20"
>
<option
v-for="device in devices"
:key="device.id"
:value="device.id"
>
{{ device.name }} · {{ statusLabel(device.status) }}
</option>
</select>
<button
v-if="selectedDevice"
type="button"
data-testid="vscodex-revoke-device"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive disabled:pointer-events-none disabled:opacity-50"
:disabled="revokingDevice"
:aria-label="t('vscodex.devices.revoke')"
:title="t('vscodex.devices.revoke')"
@click="revokeSelectedDevice"
>
<Loader2
v-if="revokingDevice"
class="h-4 w-4 animate-spin"
/>
<Trash2
v-else
class="h-4 w-4"
/>
</button>
<button
type="button"
data-testid="vscodex-refresh"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50"
:disabled="loadingDevices"
:aria-label="t('vscodex.devices.refresh')"
:title="t('vscodex.devices.refresh')"
@click="refreshDevices()"
>
<RefreshCcw
class="h-4 w-4"
:class="{ 'animate-spin': loadingDevices }"
/>
</button>
</div>
</header>
<div
v-if="loadError"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
role="alert"
>
<span class="flex items-center gap-2">
<AlertCircle class="h-4 w-4 shrink-0" />
{{ t('vscodex.devices.loadFailed') }}
</span>
<Button
variant="outline"
size="sm"
@click="refreshDevices()"
>
{{ t('vscodex.connection.retry') }}
</Button>
</div>
<div
v-if="revokeError"
class="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
role="alert"
>
<AlertCircle class="h-4 w-4 shrink-0" />
{{ t('vscodex.devices.revokeFailed') }}
</div>
<LoadingState
v-if="loadingDevices && devices.length === 0 && !pairing"
class="flex-1"
:message="t('vscodex.devices.loading')"
full-height
/>
<section
v-else-if="!selectedDevice"
data-testid="vscodex-pairing-state"
class="flex flex-1 items-center justify-center py-8"
>
<div class="w-full max-w-xl rounded-lg border border-dashed border-border bg-card/40 px-5 py-8 text-center sm:px-8">
<div class="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Link2 class="h-5 w-5" />
</div>
<h2 class="mt-4 text-base font-semibold text-foreground">
{{ pairing ? t('vscodex.pairing.title') : t('vscodex.devices.emptyTitle') }}
</h2>
<p class="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
{{ pairing ? t('vscodex.pairing.description') : t('vscodex.devices.emptyDescription') }}
</p>
<div
v-if="pairing"
class="mt-6"
>
<div class="text-xs font-medium uppercase text-muted-foreground">
{{ t('vscodex.pairing.code') }}
</div>
<div class="mt-2 flex items-center justify-center gap-2">
<code class="select-all rounded-md border border-border bg-background px-4 py-2 font-mono text-xl font-semibold text-foreground">
{{ pairing.code }}
</code>
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:aria-label="codeCopied ? t('vscodex.pairing.copied') : t('vscodex.pairing.copy')"
:title="codeCopied ? t('vscodex.pairing.copied') : t('vscodex.pairing.copy')"
@click="copyPairingCode"
>
<Check
v-if="codeCopied"
class="h-4 w-4 text-emerald-600"
/>
<Copy
v-else
class="h-4 w-4"
/>
</button>
</div>
<p
v-if="pairingExpiryLabel"
class="mt-3 text-xs text-muted-foreground"
>
{{ t('vscodex.pairing.expires', { time: pairingExpiryLabel }) }}
</p>
<div class="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="creatingPairing"
@click="createPairing"
>
<RefreshCcw class="mr-2 h-4 w-4" />
{{ t('vscodex.pairing.newCode') }}
</Button>
<Button
size="sm"
:disabled="loadingDevices"
@click="refreshDevices()"
>
{{ t('vscodex.devices.checkConnection') }}
</Button>
</div>
</div>
<Button
v-else
data-testid="vscodex-create-pairing"
class="mt-6"
:disabled="creatingPairing"
@click="createPairing"
>
<Loader2
v-if="creatingPairing"
class="mr-2 h-4 w-4 animate-spin"
/>
<Link2
v-else
class="mr-2 h-4 w-4"
/>
{{ creatingPairing ? t('vscodex.pairing.creating') : t('vscodex.pairing.create') }}
</Button>
<p
v-if="pairingError"
class="mt-4 text-sm text-destructive"
role="alert"
>
{{ t('vscodex.pairing.failed') }}
</p>
</div>
</section>
<section
v-else
class="flex min-h-[520px] flex-1 flex-col gap-3"
>
<div class="flex min-h-6 flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span class="flex items-center gap-2">
<span
class="h-2 w-2 rounded-full"
:class="statusDotClass(selectedDevice.status)"
/>
{{ selectedDevice.name }} · {{ statusLabel(selectedDevice.status) }}
</span>
<span
v-if="ticketLoading"
class="flex items-center gap-1.5"
>
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t('vscodex.connection.connecting') }}
</span>
</div>
<div
v-if="connectionError"
class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-2.5 text-sm text-destructive"
role="alert"
>
<span>{{ t('vscodex.connection.ticketFailed') }}</span>
<Button
variant="outline"
size="sm"
:disabled="ticketLoading || !frameReady"
@click="requestTicket"
>
{{ t('vscodex.connection.retry') }}
</Button>
</div>
<div class="relative min-h-[480px] flex-1 overflow-hidden rounded-lg border border-border bg-background">
<iframe
:key="frameKey"
ref="frameRef"
data-testid="vscodex-frame"
class="h-full min-h-[480px] w-full border-0 bg-background"
:src="childFrameUrl"
:title="t('vscodex.frame.title')"
sandbox="allow-scripts allow-same-origin allow-forms allow-downloads"
allow="clipboard-read; clipboard-write"
@load="frameLoaded = true"
/>
<div
v-if="!frameLoaded"
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-background"
>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t('vscodex.connection.loading') }}
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
AlertCircle,
Check,
Copy,
Link2,
Loader2,
RefreshCcw,
SquareTerminal,
Trash2,
} from 'lucide-vue-next'
import { vscodexApi, type VscodexDevice, type VscodexDeviceStatus, type VscodexPairing } from '@/api/vscodex'
import { LoadingState } from '@/components/common'
import { Button } from '@/components/ui'
import { useDarkMode } from '@/composables/useDarkMode'
import { useI18n } from '@/i18n'
const PROTOCOL_VERSION = 1
const PAIRING_POLL_INTERVAL_MS = 4_000
const childFrameUrl = `${import.meta.env.BASE_URL}aether-vscodex/index.html?embed=aether`
const { locale, t } = useI18n()
const { isDark } = useDarkMode()
const devices = ref<VscodexDevice[]>([])
const selectedDeviceId = ref('')
const loadingDevices = ref(true)
const loadError = ref(false)
const pairing = ref<VscodexPairing | null>(null)
const pairingCreatedAt = ref(0)
const creatingPairing = ref(false)
const pairingError = ref(false)
const codeCopied = ref(false)
const frameRef = ref<HTMLIFrameElement | null>(null)
const frameKey = ref(0)
const frameLoaded = ref(false)
const frameReady = ref(false)
const ticketLoading = ref(false)
const connectionError = ref(false)
const revokingDevice = ref(false)
const revokeError = ref(false)
let disposed = false
let deviceRequestVersion = 0
let connectionVersion = 0
let ticketRequest: Promise<void> | null = null
let pairingPollTimer: ReturnType<typeof setInterval> | null = null
const selectedDevice = computed(() => (
devices.value.find(device => device.id === selectedDeviceId.value) ?? null
))
const pairingExpiryLabel = computed(() => {
if (!pairing.value) return ''
if (pairing.value.expires_at) {
const expiry = new Date(pairing.value.expires_at)
if (!Number.isNaN(expiry.getTime())) return expiry.toLocaleString(locale.value)
}
if (pairing.value.expires_in_seconds) {
const expiry = new Date(pairingCreatedAt.value + pairing.value.expires_in_seconds * 1_000)
return expiry.toLocaleString(locale.value)
}
return ''
})
function statusLabel(status: VscodexDeviceStatus): string {
return t(`vscodex.devices.${status}`)
}
function statusDotClass(status: VscodexDeviceStatus): string {
if (status === 'online') return 'bg-emerald-500'
if (status === 'connecting') return 'bg-amber-500'
return 'bg-muted-foreground/50'
}
function stopPairingPoll(): void {
if (pairingPollTimer) {
clearInterval(pairingPollTimer)
pairingPollTimer = null
}
}
function startPairingPoll(): void {
stopPairingPoll()
pairingPollTimer = setInterval(() => {
if (!loadingDevices.value && !disposed) void refreshDevices({ silent: true })
}, PAIRING_POLL_INTERVAL_MS)
}
function invalidateConnection(reloadFrame = true): void {
postToFrame({ type: 'aether-vscodex/disconnect' })
connectionVersion += 1
ticketRequest = null
ticketLoading.value = false
connectionError.value = false
frameReady.value = false
frameLoaded.value = false
if (reloadFrame) frameKey.value += 1
}
async function refreshDevices(options: { silent?: boolean } = {}): Promise<void> {
const requestVersion = ++deviceRequestVersion
if (!options.silent) {
loadingDevices.value = true
loadError.value = false
}
try {
const nextDevices = await vscodexApi.listDevices()
if (disposed || requestVersion !== deviceRequestVersion) return
devices.value = nextDevices
const currentStillExists = nextDevices.some(device => device.id === selectedDeviceId.value)
if (!currentStillExists) {
selectedDeviceId.value = nextDevices.find(device => device.status === 'online')?.id
?? nextDevices[0]?.id
?? ''
}
if (nextDevices.length > 0) {
pairing.value = null
pairingError.value = false
stopPairingPoll()
}
} catch {
if (!disposed && requestVersion === deviceRequestVersion && !options.silent) {
loadError.value = true
}
} finally {
if (!disposed && requestVersion === deviceRequestVersion && !options.silent) {
loadingDevices.value = false
}
}
}
async function createPairing(): Promise<void> {
if (creatingPairing.value) return
creatingPairing.value = true
pairingError.value = false
codeCopied.value = false
try {
pairing.value = await vscodexApi.createPairing()
pairingCreatedAt.value = Date.now()
startPairingPoll()
} catch {
pairingError.value = true
} finally {
creatingPairing.value = false
}
}
async function revokeSelectedDevice(): Promise<void> {
const device = selectedDevice.value
if (!device || revokingDevice.value) return
if (!window.confirm(t('vscodex.devices.revokeConfirm', { name: device.name }))) return
revokingDevice.value = true
revokeError.value = false
try {
await vscodexApi.deleteDevice(device.id)
invalidateConnection()
devices.value = devices.value.filter(item => item.id !== device.id)
selectedDeviceId.value = devices.value.find(item => item.status === 'online')?.id
?? devices.value[0]?.id
?? ''
await refreshDevices({ silent: true })
} catch {
revokeError.value = true
} finally {
revokingDevice.value = false
}
}
async function copyPairingCode(): Promise<void> {
if (!pairing.value || !navigator.clipboard) return
try {
await navigator.clipboard.writeText(pairing.value.code)
codeCopied.value = true
window.setTimeout(() => {
codeCopied.value = false
}, 1_500)
} catch {
codeCopied.value = false
}
}
function postToFrame(message: Record<string, unknown>, target = frameRef.value?.contentWindow): void {
if (!target) return
target.postMessage({ v: PROTOCOL_VERSION, ...message }, window.location.origin)
}
function postContext(): void {
postToFrame({
type: 'aether-vscodex/context',
locale: locale.value,
theme: isDark.value ? 'dark' : 'light',
})
}
async function requestTicket(): Promise<void> {
if (!selectedDevice.value || !frameReady.value) return
if (ticketRequest) return ticketRequest
const requestVersion = connectionVersion
const deviceId = selectedDevice.value.id
const target = frameRef.value?.contentWindow
if (!target) return
ticketLoading.value = true
connectionError.value = false
const request = (async () => {
try {
const result = await vscodexApi.createWsTicket(deviceId)
if (
disposed
|| requestVersion !== connectionVersion
|| selectedDeviceId.value !== deviceId
|| frameRef.value?.contentWindow !== target
) return
postToFrame({
type: 'aether-vscodex/connect',
ticket: result.ticket,
wsUrl: result.ws_url,
deviceId,
locale: locale.value,
theme: isDark.value ? 'dark' : 'light',
}, target)
} catch {
if (disposed || requestVersion !== connectionVersion) return
connectionError.value = true
postToFrame({
type: 'aether-vscodex/error',
code: 'ticket_unavailable',
}, target)
} finally {
if (!disposed && requestVersion === connectionVersion) ticketLoading.value = false
}
})()
ticketRequest = request
try {
await request
} finally {
if (ticketRequest === request) ticketRequest = null
}
}
function isFrameMessage(value: unknown): value is { v: number; type: string } {
return typeof value === 'object'
&& value !== null
&& (value as Record<string, unknown>).v === PROTOCOL_VERSION
&& typeof (value as Record<string, unknown>).type === 'string'
}
function handleFrameMessage(event: MessageEvent): void {
const target = frameRef.value?.contentWindow
if (!target || event.origin !== window.location.origin || event.source !== target) return
if (!isFrameMessage(event.data)) return
if (event.data.type === 'aether-vscodex/ready') {
frameReady.value = true
postContext()
void requestTicket()
} else if (event.data.type === 'aether-vscodex/request-ticket') {
frameReady.value = true
void requestTicket()
}
}
watch(selectedDeviceId, async (next, previous) => {
if (next === previous) return
invalidateConnection()
await nextTick()
})
watch([locale, isDark], () => {
if (frameReady.value) postContext()
})
onMounted(() => {
window.addEventListener('message', handleFrameMessage)
void refreshDevices()
})
onBeforeUnmount(() => {
postToFrame({ type: 'aether-vscodex/disconnect' })
disposed = true
connectionVersion += 1
deviceRequestVersion += 1
stopPairingPoll()
window.removeEventListener('message', handleFrameMessage)
})
</script>
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
const vscodexApiMock = vi.hoisted(() => ({
listDevices: vi.fn(),
createPairing: vi.fn(),
createWsTicket: vi.fn(),
deleteDevice: vi.fn(),
}))
vi.mock('@/api/vscodex', () => ({
vscodexApi: vscodexApiMock,
}))
vi.mock('@/i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/i18n')>()
const { ref } = await import('vue')
const locale = ref('zh-CN')
return {
...actual,
useI18n: () => ({
locale,
t: (key: string) => key,
legacyT: (value: string) => value,
}),
}
})
vi.mock('@/composables/useDarkMode', async () => {
const { ref } = await import('vue')
const isDark = ref(false)
return {
useDarkMode: () => ({ isDark }),
}
})
vi.mock('@/components/common', () => ({
LoadingState: defineComponent({
name: 'LoadingStateStub',
setup: () => () => h('div', 'loading'),
}),
}))
vi.mock('@/components/ui', () => ({
Button: defineComponent({
name: 'ButtonStub',
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('button', attrs, slots.default?.())
},
}),
}))
import VscodeControl from '../VscodeControl.vue'
const mountedApps: Array<{ app: App; root: HTMLElement }> = []
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
async function settle() {
for (let index = 0; index < 6; index += 1) {
await Promise.resolve()
await nextTick()
}
}
async function mountControl() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(VscodeControl)
app.mount(root)
mountedApps.push({ app, root })
await settle()
const frame = root.querySelector<HTMLIFrameElement>('[data-testid="vscodex-frame"]')
if (!frame?.contentWindow) throw new Error('Expected VS Code control iframe to be mounted')
return { app, root, frame, target: frame.contentWindow }
}
function dispatchFrameMessage(options: {
source: MessageEventSource | null
origin?: string
type: string
version?: number
}) {
window.dispatchEvent(new MessageEvent('message', {
data: { v: options.version ?? 1, type: options.type },
origin: options.origin ?? window.location.origin,
source: options.source,
}))
}
beforeEach(() => {
vi.clearAllMocks()
vscodexApiMock.listDevices.mockResolvedValue([
{
id: 'device-1',
name: 'Studio Mac',
status: 'online',
last_seen_at: null,
created_at: null,
},
])
vscodexApiMock.createPairing.mockResolvedValue({
code: 'PAIR-1234',
expires_at: null,
expires_in_seconds: 300,
})
vscodexApiMock.createWsTicket.mockResolvedValue({
ticket: 'ticket-1',
ws_url: 'wss://aether.example/api/vscodex/ws',
expires_at: null,
})
vscodexApiMock.deleteDevice.mockResolvedValue(undefined)
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('VscodeControl iframe bridge', () => {
it('confirms and revokes a device credential from the control header', async () => {
vscodexApiMock.listDevices
.mockResolvedValueOnce([{
id: 'device-1',
name: 'Studio Mac',
status: 'online',
last_seen_at: null,
created_at: null,
}])
.mockResolvedValueOnce([])
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
const { root } = await mountControl()
const revoke = root.querySelector<HTMLButtonElement>('[data-testid="vscodex-revoke-device"]')
revoke?.click()
await settle()
expect(confirm).toHaveBeenCalledOnce()
expect(vscodexApiMock.deleteDevice).toHaveBeenCalledWith('device-1')
expect(root.querySelector('[data-testid="vscodex-pairing-state"]')).not.toBeNull()
confirm.mockRestore()
})
it('accepts ready messages only from the exact same-origin iframe window', async () => {
const { frame, target } = await mountControl()
const postMessage = vi.spyOn(target, 'postMessage')
dispatchFrameMessage({
source: target,
origin: 'https://untrusted.example',
type: 'aether-vscodex/ready',
})
dispatchFrameMessage({
source: window,
type: 'aether-vscodex/ready',
})
dispatchFrameMessage({
source: target,
type: 'aether-vscodex/ready',
version: 2,
})
await settle()
expect(frame.src).toContain('/aether-vscodex/index.html?embed=aether')
expect(vscodexApiMock.createWsTicket).not.toHaveBeenCalled()
dispatchFrameMessage({
source: target,
type: 'aether-vscodex/ready',
})
await settle()
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledOnce()
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledWith('device-1')
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
v: 1,
type: 'aether-vscodex/context',
locale: 'zh-CN',
theme: 'light',
}),
window.location.origin,
)
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
v: 1,
type: 'aether-vscodex/connect',
deviceId: 'device-1',
ticket: 'ticket-1',
wsUrl: 'wss://aether.example/api/vscodex/ws',
}),
window.location.origin,
)
})
it('deduplicates an in-flight ticket request and allows renewal after it settles', async () => {
const firstTicket = deferred<{
ticket: string
ws_url: string
expires_at: null
}>()
vscodexApiMock.createWsTicket
.mockReturnValueOnce(firstTicket.promise)
.mockResolvedValueOnce({
ticket: 'ticket-2',
ws_url: 'wss://aether.example/api/vscodex/ws',
expires_at: null,
})
const { target } = await mountControl()
dispatchFrameMessage({ source: target, type: 'aether-vscodex/ready' })
dispatchFrameMessage({ source: target, type: 'aether-vscodex/request-ticket' })
await settle()
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledTimes(1)
firstTicket.resolve({
ticket: 'ticket-1',
ws_url: 'wss://aether.example/api/vscodex/ws',
expires_at: null,
})
await settle()
dispatchFrameMessage({ source: target, type: 'aether-vscodex/request-ticket' })
await settle()
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledTimes(2)
})
it('disconnects on unmount and ignores a ticket that resolves afterward', async () => {
const pendingTicket = deferred<{
ticket: string
ws_url: string
expires_at: null
}>()
vscodexApiMock.createWsTicket.mockReturnValueOnce(pendingTicket.promise)
const { app, root, target } = await mountControl()
const postMessage = vi.spyOn(target, 'postMessage')
dispatchFrameMessage({ source: target, type: 'aether-vscodex/ready' })
await settle()
app.unmount()
root.remove()
mountedApps.splice(0, 1)
expect(postMessage).toHaveBeenCalledWith(
{ v: 1, type: 'aether-vscodex/disconnect' },
window.location.origin,
)
postMessage.mockClear()
pendingTicket.resolve({
ticket: 'stale-ticket',
ws_url: 'wss://aether.example/api/vscodex/ws',
expires_at: null,
})
await settle()
expect(postMessage).not.toHaveBeenCalled()
})
})