feat(vscodex): add remote Codex collaboration module

This commit is contained in:
fawney
2026-09-01 20:08:36 +08:00
parent 1aab31a148
commit 5059093d29
102 changed files with 38569 additions and 11 deletions
+4
View File
@@ -4,8 +4,12 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"sync:vscodex": "npm --prefix ../aether-vscodex/web run build && node scripts/sync-vscodex.mjs",
"predev": "npm run sync:vscodex",
"dev": "vite --host",
"prebuild": "npm run sync:vscodex",
"build": "vite build",
"prebuild:with-typecheck": "npm run sync:vscodex",
"build:with-typecheck": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs",
+16
View File
@@ -0,0 +1,16 @@
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const frontendRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const moduleRoot = resolve(frontendRoot, '..', 'aether-vscodex')
const vueBuild = resolve(moduleRoot, 'web', 'dist')
const destination = resolve(frontendRoot, 'public', 'aether-vscodex')
if (!existsSync(resolve(vueBuild, 'index.html'))) {
throw new Error(`aether-vscodex Vue build was not found at ${vueBuild}`)
}
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })
cpSync(vueBuild, destination, { recursive: true })
+116
View File
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { deleteMock, getMock, postMock } = vi.hoisted(() => ({
deleteMock: vi.fn(),
getMock: vi.fn(),
postMock: vi.fn(),
}))
vi.mock('@/api/client', () => ({
default: {
get: getMock,
post: postMock,
delete: deleteMock,
},
}))
import { vscodexApi } from '@/api/vscodex'
describe('vscodexApi', () => {
beforeEach(() => {
getMock.mockReset()
postMock.mockReset()
deleteMock.mockReset()
})
it('lists and normalizes the current user devices', async () => {
getMock.mockResolvedValue({
data: {
devices: [
{
device_id: 'device-online',
display_name: 'Studio Mac',
connected: true,
last_seen_at: '2026-08-31T10:00:00Z',
},
{
id: 'device-unknown',
name: 'Laptop',
status: 'unexpected-status',
},
{ name: 'missing id' },
],
},
})
await expect(vscodexApi.listDevices()).resolves.toEqual([
{
id: 'device-online',
name: 'Studio Mac',
status: 'online',
last_seen_at: '2026-08-31T10:00:00Z',
created_at: null,
},
{
id: 'device-unknown',
name: 'Laptop',
status: 'unknown',
last_seen_at: null,
created_at: null,
},
])
expect(getMock).toHaveBeenCalledWith('/api/users/me/vscodex/devices')
})
it('creates a pairing using an explicit empty body and normalizes its code', async () => {
postMock.mockResolvedValue({
data: {
pairing_code: 'PAIR-1234',
expires_in_seconds: 300,
},
})
await expect(vscodexApi.createPairing()).resolves.toEqual({
code: 'PAIR-1234',
expires_at: null,
expires_in_seconds: 300,
})
expect(postMock).toHaveBeenCalledWith('/api/users/me/vscodex/pairings', {})
})
it('requests a scoped WebSocket ticket for the selected device', async () => {
postMock.mockResolvedValue({
data: {
ticket: 'single-use-ticket',
wsUrl: 'wss://aether.example/api/vscodex/ws',
},
})
await expect(vscodexApi.createWsTicket('device-online')).resolves.toEqual({
ticket: 'single-use-ticket',
ws_url: 'wss://aether.example/api/vscodex/ws',
expires_at: null,
})
expect(postMock).toHaveBeenCalledWith('/api/users/me/vscodex/ws-tickets', {
device_id: 'device-online',
})
})
it('revokes the selected device using an encoded path segment', async () => {
deleteMock.mockResolvedValue({ status: 204 })
await expect(vscodexApi.deleteDevice('device/one')).resolves.toBeUndefined()
expect(deleteMock).toHaveBeenCalledWith('/api/users/me/vscodex/devices/device%2Fone')
})
it('rejects incomplete pairing and ticket responses', async () => {
postMock
.mockResolvedValueOnce({ data: {} })
.mockResolvedValueOnce({ data: { ticket: 'missing-url' } })
await expect(vscodexApi.createPairing()).rejects.toThrow('Pairing response did not include a code')
await expect(vscodexApi.createWsTicket('device-online')).rejects.toThrow(
'WebSocket ticket response was incomplete',
)
})
})
+111
View File
@@ -0,0 +1,111 @@
import apiClient from '@/api/client'
const BASE_PATH = '/api/users/me/vscodex'
export type VscodexDeviceStatus = 'online' | 'offline' | 'connecting' | 'unknown'
export interface VscodexDevice {
id: string
name: string
status: VscodexDeviceStatus
last_seen_at: string | null
created_at: string | null
}
export interface VscodexPairing {
code: string
expires_at: string | null
expires_in_seconds: number | null
}
export interface VscodexWsTicket {
ticket: string
ws_url: string
expires_at: string | null
}
type DevicePayload = Partial<VscodexDevice> & {
device_id?: string
display_name?: string
connected?: boolean
}
type DevicesPayload = DevicePayload[] | {
devices?: DevicePayload[]
items?: DevicePayload[]
}
type PairingPayload = Partial<VscodexPairing> & {
pairing_code?: string
}
type WsTicketPayload = Partial<VscodexWsTicket> & {
wsUrl?: string
}
function normalizeStatus(device: DevicePayload): VscodexDeviceStatus {
if (device.connected === true) return 'online'
if (device.connected === false) return 'offline'
switch (device.status) {
case 'online':
case 'offline':
case 'connecting':
return device.status
default:
return 'unknown'
}
}
function normalizeDevice(device: DevicePayload): VscodexDevice | null {
const id = device.id || device.device_id
if (!id) return null
return {
id,
name: device.name || device.display_name || id,
status: normalizeStatus(device),
last_seen_at: device.last_seen_at ?? null,
created_at: device.created_at ?? null,
}
}
export const vscodexApi = {
async listDevices(): Promise<VscodexDevice[]> {
const response = await apiClient.get<DevicesPayload>(`${BASE_PATH}/devices`)
const payload = response.data
const devices = Array.isArray(payload) ? payload : payload.devices ?? payload.items ?? []
return devices.map(normalizeDevice).filter((device): device is VscodexDevice => device !== null)
},
async createPairing(): Promise<VscodexPairing> {
const response = await apiClient.post<PairingPayload>(`${BASE_PATH}/pairings`, {})
const code = response.data.code || response.data.pairing_code
if (!code) throw new Error('Pairing response did not include a code')
return {
code,
expires_at: response.data.expires_at ?? null,
expires_in_seconds: response.data.expires_in_seconds ?? null,
}
},
async createWsTicket(deviceId: string): Promise<VscodexWsTicket> {
const response = await apiClient.post<WsTicketPayload>(`${BASE_PATH}/ws-tickets`, {
device_id: deviceId,
})
const { ticket } = response.data
const wsUrl = response.data.ws_url || response.data.wsUrl
if (!ticket || !wsUrl) throw new Error('WebSocket ticket response was incomplete')
return {
ticket,
ws_url: wsUrl,
expires_at: response.data.expires_at ?? null,
}
},
async deleteDevice(deviceId: string): Promise<void> {
await apiClient.delete(`${BASE_PATH}/devices/${encodeURIComponent(deviceId)}`)
},
}
+62
View File
@@ -275,6 +275,37 @@ export const messages = {
'nav.healthMonitor': '健康监控',
'nav.modelCatalog': '模型目录',
'nav.apiKeys': 'API 密钥',
'nav.vscodex': 'Codex 远程控制',
'vscodex.title': 'Codex 远程控制',
'vscodex.devices.label': '控制设备',
'vscodex.devices.refresh': '刷新设备',
'vscodex.devices.loading': '正在加载设备...',
'vscodex.devices.loadFailed': '无法加载设备,请检查网络后重试。',
'vscodex.devices.emptyTitle': '尚未连接设备',
'vscodex.devices.emptyDescription': '安装 aether-vscodex 插件后,在 VS Code 命令面板运行“Codex Remote: Pair with Aether”连接此账户。',
'vscodex.devices.checkConnection': '检查连接',
'vscodex.devices.online': '在线',
'vscodex.devices.offline': '离线',
'vscodex.devices.connecting': '连接中',
'vscodex.devices.unknown': '状态未知',
'vscodex.devices.revoke': '撤销此设备',
'vscodex.devices.revokeConfirm': '撤销设备“{name}”后,它会立即断开,并且必须重新配对才能连接。确认撤销吗?',
'vscodex.devices.revokeFailed': '无法撤销设备,请稍后重试。',
'vscodex.pairing.create': '生成配对码',
'vscodex.pairing.creating': '正在生成...',
'vscodex.pairing.title': '连接本地插件',
'vscodex.pairing.description': '在 VS Code 命令面板运行“Codex Remote: Pair with Aether”,并输入下方的一次性配对码。配对完成后本页会自动连接。',
'vscodex.pairing.code': '一次性配对码',
'vscodex.pairing.copy': '复制配对码',
'vscodex.pairing.copied': '配对码已复制',
'vscodex.pairing.expires': '有效期至 {time}',
'vscodex.pairing.newCode': '生成新配对码',
'vscodex.pairing.failed': '无法生成配对码,请稍后重试。',
'vscodex.connection.loading': '正在加载控制台...',
'vscodex.connection.connecting': '正在建立安全连接...',
'vscodex.connection.ticketFailed': '安全连接凭证获取失败。',
'vscodex.connection.retry': '重试',
'vscodex.frame.title': 'Codex 远程控制台',
'nav.walletCenter': '钱包中心',
'nav.billingCenter': '套餐中心',
'nav.myReferral': '我的邀请',
@@ -594,6 +625,37 @@ export const messages = {
'nav.healthMonitor': 'Health monitor',
'nav.modelCatalog': 'Model catalog',
'nav.apiKeys': 'API keys',
'nav.vscodex': 'Codex remote control',
'vscodex.title': 'Codex remote control',
'vscodex.devices.label': 'Control device',
'vscodex.devices.refresh': 'Refresh devices',
'vscodex.devices.loading': 'Loading devices...',
'vscodex.devices.loadFailed': 'Devices could not be loaded. Check your connection and retry.',
'vscodex.devices.emptyTitle': 'No connected devices',
'vscodex.devices.emptyDescription': 'After installing aether-vscodex, run “Codex Remote: Pair with Aether” from the VS Code Command Palette to connect this account.',
'vscodex.devices.checkConnection': 'Check connection',
'vscodex.devices.online': 'Online',
'vscodex.devices.offline': 'Offline',
'vscodex.devices.connecting': 'Connecting',
'vscodex.devices.unknown': 'Status unknown',
'vscodex.devices.revoke': 'Revoke this device',
'vscodex.devices.revokeConfirm': 'Revoking “{name}” disconnects it immediately. It must be paired again before reconnecting. Revoke it?',
'vscodex.devices.revokeFailed': 'The device could not be revoked. Try again later.',
'vscodex.pairing.create': 'Generate pairing code',
'vscodex.pairing.creating': 'Generating...',
'vscodex.pairing.title': 'Connect the local plugin',
'vscodex.pairing.description': 'Run “Codex Remote: Pair with Aether” from the VS Code Command Palette and enter this one-time code. This page connects automatically after pairing.',
'vscodex.pairing.code': 'One-time pairing code',
'vscodex.pairing.copy': 'Copy pairing code',
'vscodex.pairing.copied': 'Pairing code copied',
'vscodex.pairing.expires': 'Valid until {time}',
'vscodex.pairing.newCode': 'Generate a new code',
'vscodex.pairing.failed': 'A pairing code could not be generated. Try again later.',
'vscodex.connection.loading': 'Loading control surface...',
'vscodex.connection.connecting': 'Establishing a secure connection...',
'vscodex.connection.ticketFailed': 'A secure connection ticket could not be issued.',
'vscodex.connection.retry': 'Retry',
'vscodex.frame.title': 'Codex remote control surface',
'nav.walletCenter': 'Wallet',
'nav.billingCenter': 'Plans',
'nav.myReferral': 'Referrals',
@@ -37,6 +37,37 @@ describe('main layout navigation builder', () => {
expect(navigation.flatMap(group => group.items.map(item => item.name))).toContain('tx:nav.myReferral')
})
it('exposes the same VS Code control destination to users and administrators', () => {
const commonOptions = {
modules: {},
isModuleActive: () => false,
t: translate,
}
const userNavigation = buildNavigation({
...commonOptions,
canAccessAdmin: false,
})
const adminNavigation = buildNavigation({
...commonOptions,
canAccessAdmin: true,
})
const findVscodeControl = (navigation: ReturnType<typeof buildNavigation>) => (
navigation
.flatMap(group => group.items)
.find(item => item.href === '/dashboard/vscodex')
)
expect(findVscodeControl(userNavigation)).toMatchObject({
name: 'tx:nav.vscodex',
href: '/dashboard/vscodex',
})
expect(findVscodeControl(adminNavigation)).toMatchObject({
name: 'tx:nav.vscodex',
href: '/dashboard/vscodex',
})
})
it('builds admin navigation with dynamic module menu items sorted by menu order', () => {
const navigation = buildNavigation({
canAccessAdmin: true,
@@ -99,5 +130,16 @@ describe('main layout navigation builder', () => {
{ label: 'tx:nav.routing', href: '/admin/routing' },
{ label: 'tx:breadcrumb.routingCreate' },
])
expect(buildBreadcrumbs({
route: route('/dashboard/vscodex'),
navigation,
modules: {},
isNavActive: href => href === '/dashboard/vscodex',
t: translate,
})).toEqual([
expect.objectContaining({ label: expect.any(String) }),
{ label: 'tx:nav.vscodex' },
])
})
})
@@ -21,6 +21,7 @@ import {
Server,
Shield,
SlidersHorizontal,
SquareTerminal,
Users,
Wallet,
Zap,
@@ -88,6 +89,7 @@ export function buildNavigation(options: {
items: [
{ name: t('nav.modelCatalog'), href: '/dashboard/models', icon: Box },
{ name: t('nav.apiKeys'), href: '/dashboard/api-keys', icon: Key },
{ name: t('nav.vscodex'), href: '/dashboard/vscodex', icon: SquareTerminal },
]
},
{
@@ -115,6 +117,7 @@ export function buildNavigation(options: {
title: t('nav.group.overview'),
items: [
{ name: t('nav.dashboard'), href: '/admin/dashboard', icon: Home },
{ name: t('nav.vscodex'), href: '/dashboard/vscodex', icon: SquareTerminal },
{ name: t('nav.operations'), href: '/admin/operations', icon: Activity },
{ name: t('nav.healthMonitor'), href: '/admin/health-monitor', icon: Activity },
{ name: t('nav.userStats'), href: '/admin/user-stats', icon: BarChart3 },
+5
View File
@@ -63,6 +63,11 @@ export const dashboardRoutes: RouteRecordRaw[] = [
name: 'ModelCatalog',
component: view(() => import('@/views/user/ModelCatalog.vue'))
},
{
path: 'vscodex',
name: 'VscodeControl',
component: view(() => import('@/views/user/VscodeControl.vue'))
},
{
path: 'async-tasks',
name: 'UserAsyncTasks',
+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()
})
})
+1
View File
@@ -80,6 +80,7 @@ export default defineConfig(({ mode }) => {
target: gatewayTarget,
changeOrigin: true,
secure: false,
ws: true,
},
'/v1/': {
target: gatewayTarget,