From c02465f3dd1f205d12017d950778078399c2117d Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 30 Aug 2026 00:35:04 +0800 Subject: [PATCH] feat(desktop): approve guest Turn requests Complete the Desktop collaboration loop with Guest Turn requests, durable Owner decisions, and fail-closed read-only behavior when authority is absent. Generated-by: OpenAI Codex --- ...untime-host-collaboration-ipc-main.test.ts | 16 +- apps/desktop/src/main/runtime-host-client.ts | 16 +- .../runtime-host-collaboration-ipc-main.ts | 61 ++++- .../main/runtime-host-desktop-candidate.ts | 14 +- apps/desktop/src/preload/bridge-contract.d.ts | 24 ++ apps/desktop/src/preload/preload.ts | 48 +++- apps/desktop/src/renderer/app-shell.tsx | 38 ++- .../locales/session-collaboration-copy.ts | 47 ++++ .../renderer/session-collaboration-dialog.tsx | 235 ++++++++++++++++-- .../session-turn-request-composer.tsx | 227 +++++++++++++++++ .../renderer/styles/settings/runtime-host.css | 20 ++ .../src/renderer/styles/shell-layout.css | 48 ++++ .../use-session-collaboration-dialog.ts | 1 + docs/astryx-surface-file-inventory.md | 5 +- docs/astryx-surface-file-inventory.paths | 1 + packages/runtime-host/src/protocol/index.ts | 2 +- .../src/protocol/session-collaboration.ts | 14 +- .../src/server/access-authority.ts | 4 + 18 files changed, 769 insertions(+), 52 deletions(-) create mode 100644 apps/desktop/src/renderer/session-turn-request-composer.tsx diff --git a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts index 98061fddd2..2863fec8e2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts @@ -75,12 +75,20 @@ test('requires Owner confirmation before issuing a plaintext collaboration invit const prepare = handlers.get('session-collaboration:prepare'); assert.ok(prepare); - assert.deepEqual(await prepare({} as Parameters[0], 'session-1', false), { - kind: 'insecure_confirmation_required', - }); + assert.deepEqual( + await prepare({} as Parameters[0], 'session-1', 'observe', false), + { + kind: 'insecure_confirmation_required', + }, + ); assert.equal(prepareCalls, 0); - const result = await prepare({} as Parameters[0], 'session-1', true); + const result = await prepare( + {} as Parameters[0], + 'session-1', + 'observe', + true, + ); assert.equal(prepareCalls, 1); assert.equal((result as { kind?: unknown }).kind, 'prepared'); const invitation = (result as { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 235e4fe2bc..e772116784 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -106,8 +106,10 @@ import { type SessionCatalogProjection, type SharedSessionCatalogProjection, type CollaborationAccessQueryResult, + type CollaborationGrantRevokeResult, type CollaborationInvitationPrepareResult, type CollaborationPrincipalRevokeResult, + type CollaborationTurnRequestAcknowledgeResult, type CollaborationTurnRequestDecideResult, type CollaborationTurnRequestQueryResult, type SessionCollaborationGrantKind, @@ -294,13 +296,19 @@ export class DesktopRuntimeHostClient { ); } + revokeCollaborationGrant(grantId: string): Promise { + return this.request('collaboration.grant.revoke', { grantId }); + } + revokeCollaborationPrincipal( principalId: string, ): Promise { return this.request('collaboration.principal.revoke', { principalId }); } - createCollaborationTurnRequest(intent: SessionTurnRequestIntent): Promise { + createCollaborationTurnRequest( + intent: SessionTurnRequestIntent, + ): Promise { return this.request('collaboration.turn-request.create', { intent }); } @@ -308,6 +316,12 @@ export class DesktopRuntimeHostClient { return this.request('collaboration.turn-request.query', { sessionId }); } + acknowledgeCollaborationTurnRequest( + requestId: string, + ): Promise { + return this.request('collaboration.turn-request.acknowledge', { requestId }); + } + decideCollaborationTurnRequest( requestId: string, decision: 'approve' | 'reject', diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts index 0377498090..622cb75c7a 100644 --- a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts @@ -31,7 +31,12 @@ export function registerRuntimeHostCollaborationIpc( client: Pick< DesktopRuntimeHostClient, | 'prepareCollaborationInvitation' + | 'acknowledgeCollaborationTurnRequest' + | 'createCollaborationTurnRequest' + | 'decideCollaborationTurnRequest' + | 'queryCollaborationTurnRequests' | 'queryCollaborationAccess' + | 'revokeCollaborationGrant' | 'revokeCollaborationPrincipal' >, ipcMain: ReconnectableReadIpcMain, @@ -41,14 +46,21 @@ export function registerRuntimeHostCollaborationIpc( ): void { ipcMain.handle( 'session-collaboration:prepare', - async (_event, sessionId: unknown, allowInsecure: unknown) => { + async ( + _event, + sessionId: unknown, + preset: unknown, + allowInsecure: unknown, + ) => { const target = await resolveConnectionTarget(); if (target.transport.kind === 'plaintext' && allowInsecure !== true) { return { kind: 'insecure_confirmation_required' } as const; } const prepared = await client.prepareCollaborationInvitation( requiredId(sessionId, 'Session'), - ['session_observation'], + requiredPreset(preset) === 'request_turn' + ? ['session_observation', 'session_turn_request'] + : ['session_observation'], ); return { kind: 'prepared', @@ -62,12 +74,43 @@ export function registerRuntimeHostCollaborationIpc( }; }, ); + ipcMain.handle( + 'session-collaboration:turn-request:create', + (_event, intent: unknown) => + client.createCollaborationTurnRequest(intent as Parameters< + DesktopRuntimeHostClient['createCollaborationTurnRequest'] + >[0]), + ); + handleReconnectableRead( + ipcMain, + 'session-collaboration:turn-request:query', + (_event, sessionId: unknown) => + client.queryCollaborationTurnRequests(requiredId(sessionId, 'Session')), + ); + ipcMain.handle( + 'session-collaboration:turn-request:acknowledge', + (_event, requestId: unknown) => + client.acknowledgeCollaborationTurnRequest(requiredId(requestId, 'Turn request')), + ); + ipcMain.handle( + 'session-collaboration:turn-request:decide', + (_event, requestId: unknown, decision: unknown) => + client.decideCollaborationTurnRequest( + requiredId(requestId, 'Turn request'), + requiredDecision(decision), + ), + ); handleReconnectableRead( ipcMain, 'session-collaboration:getAccess', (_event, sessionId: unknown) => client.queryCollaborationAccess(requiredId(sessionId, 'Session')), ); + ipcMain.handle( + 'session-collaboration:revokeGrant', + (_event, grantId: unknown) => + client.revokeCollaborationGrant(requiredId(grantId, 'Grant')), + ); ipcMain.handle( 'session-collaboration:revokePrincipal', (_event, principalId: unknown) => @@ -86,3 +129,17 @@ function requiredId(value: unknown, label: string): string { } return value; } + +function requiredPreset(value: unknown): 'observe' | 'request_turn' { + if (value !== 'observe' && value !== 'request_turn') { + throw new Error('Invalid collaboration invitation preset'); + } + return value; +} + +function requiredDecision(value: unknown): 'approve' | 'reject' { + if (value !== 'approve' && value !== 'reject') { + throw new Error('Invalid collaboration Turn request decision'); + } + return value; +} diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 51ff440b9f..53faa70a3b 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -811,14 +811,14 @@ export async function createDesktopRuntimeHostCandidate( ipc, ); } + registerRuntimeHostCollaborationIpc(client, ipc, async () => { + if (collaborationConnectionTarget) return collaborationConnectionTarget; + if (target.kind === 'local' && deps.resolveLocalCollaborationConnectionTarget) { + return deps.resolveLocalCollaborationConnectionTarget(); + } + throw new Error('This Runtime Host does not have a shareable connection target'); + }); if (target.access === 'owner') { - registerRuntimeHostCollaborationIpc(client, ipc, async () => { - if (collaborationConnectionTarget) return collaborationConnectionTarget; - if (target.kind === 'local' && deps.resolveLocalCollaborationConnectionTarget) { - return deps.resolveLocalCollaborationConnectionTarget(); - } - throw new Error('This Runtime Host does not have a shareable connection target'); - }); registerRuntimeHostWorkHubIpc(client, ipc, { resolveCreateProject: () => deps.resolveSessionCreateProject({}, target), emitSessionsChanged, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 917aec24ba..df55b7d7aa 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -111,8 +111,13 @@ import type { } from '@maka/runtime-host/protocol'; import type { CollaborationAccessQueryResult, + CollaborationGrantRevokeResult, CollaborationInvitationPrepareResult, CollaborationPrincipalRevokeResult, + CollaborationTurnRequestAcknowledgeResult, + CollaborationTurnRequestDecideResult, + CollaborationTurnRequestQueryResult, + SessionTurnAccessRequest, } from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import type { @@ -676,9 +681,14 @@ export interface MakaBridge { sessionCollaboration: { prepareInvitation( sessionId: string, + preset: 'observe' | 'request_turn', allowInsecure?: boolean, ): Promise; getAccess(sessionId: string): Promise; + revokeGrant( + sessionId: string, + grantId: string, + ): Promise; revokePrincipal( sessionId: string, principalId: string, @@ -687,6 +697,20 @@ export interface MakaBridge { readonly code: string; readonly allowInsecure?: boolean; }): Promise; + requestTurn( + sessionId: string, + input: { readonly turnId: string; readonly text: string }, + ): Promise; + getTurnRequests(sessionId: string): Promise; + acknowledgeTurnRequest( + sessionId: string, + requestId: string, + ): Promise; + decideTurnRequest( + sessionId: string, + requestId: string, + decision: 'approve' | 'reject', + ): Promise; }; runtimeHost: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 158a8e1008..c3f7025879 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1180,12 +1180,13 @@ const browserSelection = createBrowserSelectionCoordinator(runtimeHostSessionRef const makaBridge = { runtimeHost, sessionCollaboration: { - async prepareInvitation(sessionId, allowInsecure = false) { + async prepareInvitation(sessionId, preset, allowInsecure = false) { const session = await runtimeHostSessionRef(sessionId); return ipcRenderer.invoke( 'session-collaboration:prepare', session.scope, session.sessionId, + preset, allowInsecure, ); }, @@ -1205,9 +1206,54 @@ const makaBridge = { principalId, ); }, + async revokeGrant(sessionId, grantId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:revokeGrant', + session.scope, + grantId, + ); + }, importInvitation({ code, allowInsecure = false }) { return ipcRenderer.invoke('session-collaboration:import', code, allowInsecure); }, + async requestTurn(sessionId, input) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:create', + session.scope, + { + sessionId: session.sessionId, + turnId: input.turnId, + content: { text: input.text }, + }, + ); + }, + async getTurnRequests(sessionId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:query', + session.scope, + session.sessionId, + ); + }, + async acknowledgeTurnRequest(sessionId, requestId) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:acknowledge', + session.scope, + requestId, + ); + }, + async decideTurnRequest(sessionId, requestId, decision) { + const session = await runtimeHostSessionRef(sessionId); + return ipcRenderer.invoke( + 'session-collaboration:turn-request:decide', + session.scope, + requestId, + decision, + ); + }, }, runtimeHostProfiles: { getSnapshot() { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index edb6e038fa..f6bded74bc 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -108,6 +108,7 @@ import { } from './features/task-entry'; import { useNewTaskChoice } from './use-new-task-choice'; import { SessionCollaborationDialog } from './session-collaboration-dialog'; +import { SessionTurnRequestComposer } from './session-turn-request-composer.js'; import { getSessionCollaborationCopy } from './locales/session-collaboration-copy'; import { useSessionCollaborationDialog } from './use-session-collaboration-dialog'; import { NEW_TASK_PENDING_KEY } from './pending-items'; @@ -835,6 +836,13 @@ function AppShellContent({ const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeMessageSubmitting = transientMessages.length > 0; const activeDesktopSession = activeSession; + function openSessionSharing(session: DesktopSessionSummary): void { + sharedSessionDialog.open({ + sessionId: session.id, + sessionName: session.name, + requiresRemoteAccess: session.profileKind === 'local', + }); + } // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the // semantic snapshot rather than the projection (#1985). @@ -2779,13 +2787,16 @@ function AppShellContent({ key={activeSessionForView.id} sessionName={activeSessionForView.name} readOnly={sharedSessionActive} - action={sharedSessionActive ? undefined : { - label: getSessionCollaborationCopy(uiLocale).shareAction, - onClick: () => sharedSessionDialog.open({ - sessionId: activeSessionForView.id, - sessionName: activeSessionForView.name, - }), - }} + action={ + sharedSessionActive || + !activeDesktopSession || + activeDesktopSession.profileKind === 'environment' + ? undefined + : { + label: getSessionCollaborationCopy(uiLocale).shareAction, + onClick: () => void openSessionSharing(activeDesktopSession), + } + } onRenameSession={(name) => { void sessionNavigationCommandsRef.current?.renameSession(activeSessionForView.id, name); }} @@ -2918,10 +2929,8 @@ function AppShellContent({ onClick={openWorkHub} /> ) : null} - {sharedSessionActive ? ( -
- {getSessionCollaborationCopy(uiLocale).observeHelp} -
+ {sharedSessionActive && activeId ? ( + ) : ( { + const copy = getSessionCollaborationCopy(uiLocale); + sharedSessionDialog.close(); + toastApi.info(copy.enableRemoteAccessTitle, copy.enableRemoteAccessBody); + openSettingsSection('projects'); + }} onClose={sharedSessionDialog.close} /> ) : null} diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts index 0a7779bb20..9e9a71b851 100644 --- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts +++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts @@ -23,11 +23,17 @@ const ZH = { shareAction: '分享此任务', shareTitle: '分享任务', shareDescription: '创建一次性邀请,让另一台 Maka 访问这个任务。', + enableRemoteAccessTitle: '先开启本机远程访问', + enableRemoteAccessBody: '已为你打开 Runtime Host 设置;开启远程访问后即可分享此任务。', disclosureTitle: '分享前请确认', disclosureBody: '对方将看到这个任务已有和之后产生的全部可见内容,包括其中已经出现的文件路径、凭据或其他秘密。撤销访问只能阻止后续读取,无法收回对方已经复制的内容。', + accessLabel: '访问权限', observe: '只读', observeHelp: '查看完整历史和实时更新', + requestTurn: '可请求新轮次', + revokeTurnRequests: '撤销轮次请求权限', + requestTurnHelp: '查看完整内容,并提交需要你逐次批准的新轮次请求', createInvitation: '创建邀请', invitationCode: '一次性邀请码', invitationHelp: '邀请码包含连接地址和访客凭据,不包含所有者凭据。', @@ -35,6 +41,8 @@ const ZH = { copied: '邀请码已复制', close: '完成', activeAccess: '当前访问', + accessUnavailable: '共享管理暂时不可用;此窗口会自动重试。', + guest: '访客', noAccess: '尚未分享给任何人', pending: '待领取', active: '已连接', @@ -51,6 +59,21 @@ const ZH = { shareInsecure: '接受风险并创建', joinInsecure: '接受风险并加入', sharedBadge: '共享', + turnRequests: '轮次请求', + noTurnRequests: '暂无轮次请求', + approve: '批准', + reject: '拒绝', + turnRequestPlaceholder: '描述你希望发起的新轮次', + submitTurnRequest: '请求新轮次', + turnRequestSent: '请求已提交,等待所有者批准', + turnRequestReconciling: '正在确认请求是否已被 Host 接收…', + turnRequestPending: '等待批准', + turnRequestApproved: '已批准', + turnRequestRejected: '已拒绝', + turnRequestStarted: '已开始', + turnRequestBlocked: '未能开始', + turnRequestFailed: '准入失败', + dismissTurnRequest: '关闭', }; type SessionCollaborationCopy = { @@ -61,11 +84,18 @@ const EN = { shareAction: 'Share this task', shareTitle: 'Share task', shareDescription: 'Create a one-time invitation for another Maka installation.', + enableRemoteAccessTitle: 'Enable remote access first', + enableRemoteAccessBody: + 'Runtime Host settings are open. Enable remote access there, then share this task.', disclosureTitle: 'Confirm what will be shared', disclosureBody: 'The Guest can read all existing and future visible content in this task, including paths, credentials, or other secrets already present. Revoking access stops future reads but cannot retract copied content.', + accessLabel: 'Access', observe: 'Read only', observeHelp: 'Read the full history and live updates', + requestTurn: 'Can request Turns', + revokeTurnRequests: 'Revoke Turn requests', + requestTurnHelp: 'Read the task and propose new Turns for your individual approval', createInvitation: 'Create invitation', invitationCode: 'One-time invitation code', invitationHelp: 'The code contains the connection address and Guest credential, never the Owner credential.', @@ -73,6 +103,8 @@ const EN = { copied: 'Invitation copied', close: 'Done', activeAccess: 'Current access', + accessUnavailable: 'Sharing controls are temporarily unavailable. This window will retry.', + guest: 'Guest', noAccess: 'No one has access yet', pending: 'Pending', active: 'Connected', @@ -89,6 +121,21 @@ const EN = { shareInsecure: 'Accept risk and create', joinInsecure: 'Accept risk and join', sharedBadge: 'Shared', + turnRequests: 'Turn requests', + noTurnRequests: 'No Turn requests yet', + approve: 'Approve', + reject: 'Reject', + turnRequestPlaceholder: 'Describe the new Turn you want to start', + submitTurnRequest: 'Request new Turn', + turnRequestSent: 'Request sent for Owner approval', + turnRequestReconciling: 'Checking whether the Host received this request…', + turnRequestPending: 'Awaiting approval', + turnRequestApproved: 'Approved', + turnRequestRejected: 'Rejected', + turnRequestStarted: 'Started', + turnRequestBlocked: 'Could not start', + turnRequestFailed: 'Admission failed', + dismissTurnRequest: 'Dismiss', } satisfies SessionCollaborationCopy; const COPY = { zh: ZH, en: EN } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/session-collaboration-dialog.tsx index f077e944bd..eb7e2b06fd 100644 --- a/apps/desktop/src/renderer/session-collaboration-dialog.tsx +++ b/apps/desktop/src/renderer/session-collaboration-dialog.tsx @@ -17,9 +17,10 @@ * under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core'; import { Button, FormLayout, @@ -31,14 +32,19 @@ import { import type { CollaborationAccessQueryResult, CollaborationInvitationPrepareResult, + SessionCollaborationGrant, + SessionTurnAccessRequest, } from '@maka/runtime-host/protocol'; import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js'; +import { turnRequestStateLabel } from './session-turn-request-composer.js'; type Props = | { readonly mode: 'share'; readonly sessionId: string; readonly sessionName: string; + readonly requiresRemoteAccess: boolean; + readonly onEnableRemoteAccess: () => void; readonly onClose: () => void; } | { @@ -47,6 +53,12 @@ type Props = readonly onClose: () => void; }; +type CollaborationAuthorityState = + | 'loading' + | 'available' + | 'remote_access_off' + | 'unavailable'; + export function SessionCollaborationDialog(props: Props) { return props.mode === 'share' ? @@ -56,37 +68,95 @@ export function SessionCollaborationDialog(props: Props) { function ShareSessionDialog(props: Extract) { const copy = getSessionCollaborationCopy(useUiLocale()); const toast = useToast(); + const [preset, setPreset] = useState<'observe' | 'request_turn'>('observe'); const [access, setAccess] = useState(); const [invitation, setInvitation] = useState(); + const [turnRequests, setTurnRequests] = useState(); + const [authorityState, setAuthorityState] = useState('loading'); const [working, setWorking] = useState(false); - async function refresh(): Promise { - const nextAccess = await window.maka.sessionCollaboration.getAccess(props.sessionId); - setAccess(nextAccess); + async function readProjection() { + if (props.requiresRemoteAccess) { + const remoteAccess = await window.maka.localRuntimeHostRemoteAccess.getSnapshot(); + if (remoteAccess.state !== 'on') return { kind: 'remote_access_off' } as const; + } + const [nextAccess, nextRequests] = await Promise.all([ + window.maka.sessionCollaboration.getAccess(props.sessionId), + window.maka.sessionCollaboration.getTurnRequests(props.sessionId), + ]); + return { + kind: 'available', + access: nextAccess, + turnRequests: nextRequests.requests, + } as const; + } + + function applyProjection(projection: Awaited>): void { + if (projection.kind === 'remote_access_off') { + setAuthorityState('remote_access_off'); + return; + } + setAccess(projection.access); + setTurnRequests(projection.turnRequests); + setAuthorityState('available'); setInvitation((current) => { if (!current || Date.parse(current.expiresAt) <= Date.now()) return undefined; - const principal = nextAccess.principals.find( + const principal = projection.access.principals.find( (candidate) => candidate.principalId === current.principalId, ); return principal?.status === 'pending' ? current : undefined; }); } + async function refresh(): Promise { + try { + applyProjection(await readProjection()); + } catch { + setAuthorityState('unavailable'); + } + } + useEffect(() => { - const poll = () => void refresh().catch(() => undefined); - void refresh().catch((error) => toast.error(copy.shareTitle, errorMessage(error))); - const timer = window.setInterval(poll, 2_000); - return () => window.clearInterval(timer); - }, [props.sessionId]); + let disposed = false; + let timer: number | undefined; + const poll = async () => { + try { + const projection = await readProjection(); + if (!disposed) applyProjection(projection); + } catch { + if (!disposed) setAuthorityState('unavailable'); + } finally { + if (!disposed) timer = window.setTimeout(() => void poll(), 2_000); + } + }; + void poll(); + return () => { + disposed = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [props.sessionId, props.requiresRemoteAccess]); async function createInvitation(allowInsecure = false): Promise { setWorking(true); try { - const result = await window.maka.sessionCollaboration.prepareInvitation( + if (authorityState === 'remote_access_off') { + props.onEnableRemoteAccess(); + return; + } + if (authorityState !== 'available') return; + if (props.requiresRemoteAccess) { + const access = await window.maka.localRuntimeHostRemoteAccess.getSnapshot(); + if (access.state !== 'on') { + props.onEnableRemoteAccess(); + return; + } + } + const created = await window.maka.sessionCollaboration.prepareInvitation( props.sessionId, + preset, allowInsecure, ); - if (result.kind === 'insecure_confirmation_required') { + if (created.kind === 'insecure_confirmation_required') { const confirmed = await toast.confirm({ title: copy.insecureTitle, description: copy.insecureBody, @@ -96,7 +166,7 @@ function ShareSessionDialog(props: Extract) { if (confirmed) await createInvitation(true); return; } - setInvitation(result.invitation); + setInvitation(created.invitation); await refresh(); } catch (error) { toast.error(copy.shareTitle, errorMessage(error)); @@ -127,6 +197,49 @@ function ShareSessionDialog(props: Extract) { } } + async function revokeGrant(grant: SessionCollaborationGrant): Promise { + setWorking(true); + try { + await window.maka.sessionCollaboration.revokeGrant( + props.sessionId, + grant.grantId, + ); + await refresh(); + } catch (error) { + toast.error(copy.shareTitle, errorMessage(error)); + } finally { + setWorking(false); + } + } + + async function decideTurnRequest( + request: SessionTurnAccessRequest, + decision: 'approve' | 'reject', + ): Promise { + setWorking(true); + try { + await window.maka.sessionCollaboration.decideTurnRequest( + props.sessionId, + request.requestId, + decision, + ); + await refresh(); + } catch (error) { + toast.error(copy.turnRequests, errorMessage(error)); + } finally { + setWorking(false); + } + } + + const grantsByPrincipal = useMemo(() => { + const groups = new Map(); + for (const grant of access?.grants ?? []) { + const grants = groups.get(grant.principalId) ?? []; + grants.push(grant); + groups.set(grant.principalId, grants); + } + return groups; + }, [access]); return ( !open && !working && props.onClose()} purpose="form" width={620}> ) { {copy.disclosureTitle} {copy.disclosureBody} - {copy.observeHelp} + setPreset(value as typeof preset)} + > + + + + + {preset === 'observe' ? copy.observeHelp : copy.requestTurnHelp} + {invitation ? (