import type { PluginApiClient } from "@termix/plugin-sdk/frontend"; /** * The plugin's own /plugin-api/session-sharing client, bound in activate. * Guest pages use it too: the two guest routes are public. */ let client: PluginApiClient | null = null; let clientFor: ((origin?: unknown) => PluginApiClient) | null = null; export function bindApi( api: PluginApiClient | null, apiFor?: (origin?: unknown) => PluginApiClient, ): void { client = api; clientFor = api ? (apiFor ?? null) : null; } /** The session lives on the backend its tab connected through. */ function api(origin?: SessionOrigin): PluginApiClient { if (!client) throw new Error("Session sharing is not running"); return origin && clientFor ? clientFor(origin) : client; } export type SessionOrigin = "local" | "remote"; interface HttpError { response?: { status?: number; data?: { error?: string } }; } function statusOf(error: unknown): number | undefined { return (error as HttpError)?.response?.status; } /** The server's own message, like core's handleApiError. */ function apiError(error: unknown, fallback: string): Error { const message = (error as HttpError)?.response?.data?.error; return new Error(message || fallback); } async function call( request: () => Promise<{ data: T }>, fallback: string, ): Promise { try { return (await request()).data; } catch (error) { throw apiError(error, fallback); } } // --------------------------------------------------------------------------- // Guest share links // --------------------------------------------------------------------------- export interface ResolvedShareLink { protocol: "ssh" | "rdp" | "vnc" | "telnet"; permissionLevel: "read-only" | "read-write"; wsPath: string; connectParams?: { token: string }; } export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown"; export class ShareLinkError extends Error { constructor( message: string, public readonly kind: ShareLinkErrorKind, ) { super(message); this.name = "ShareLinkError"; } } export async function resolveShareLink( linkToken: string, ): Promise { try { const response = await api().get( `/resolve/${encodeURIComponent(linkToken)}`, ); return response.data; } catch (error) { const status = statusOf(error); if (status === 404) { throw new ShareLinkError( "Share link is invalid, expired, or revoked", "not-found", ); } if (status === 429) { throw new ShareLinkError( "Too many attempts, please try again shortly", "rate-limited", ); } throw new ShareLinkError("Failed to resolve share link", "unknown"); } } // --------------------------------------------------------------------------- // Owner-side shares // --------------------------------------------------------------------------- export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet"; export type SessionShareType = "link" | "user"; export type SessionSharePermissionLevel = "read-only" | "read-write"; export interface SessionShareRecord { id: string; hostId: number; ownerUserId: string; protocol: SessionShareProtocol; sessionId: string; tabInstanceId: string | null; shareType: SessionShareType; targetUserId: string | null; linkToken: string | null; permissionLevel: SessionSharePermissionLevel; createdAt: string; expiresAt: string; revokedAt: string | null; lastJoinedAt: string | null; joinCount: number; } export interface CreateSessionShareRequest { hostId: number; sessionId: string; tabInstanceId?: string; protocol: SessionShareProtocol; shareType: SessionShareType; targetUserId?: string; permissionLevel: SessionSharePermissionLevel; expiryHours?: number; } export interface CreateSessionShareResponse { shareId: string; linkToken: string | null; expiresAt: string; } export function createSessionShare( request: CreateSessionShareRequest, origin?: SessionOrigin, ): Promise { return call( () => api(origin).post("/create", request), "Failed to create session share", ); } export function getActiveSessionShares( hostId: number, origin?: SessionOrigin, ): Promise<{ shares: SessionShareRecord[] }> { return call( () => api(origin).get<{ shares: SessionShareRecord[] }>( `/host/${hostId}/active`, ), "Failed to fetch active session shares", ); } export function revokeSessionShare( shareId: string, origin?: SessionOrigin, ): Promise { return call( () => api(origin).delete(`/${encodeURIComponent(shareId)}`), "Failed to revoke session share", ); } export interface DirectoryUser { id: string; username: string; } export interface DirectoryRole { id: number; name: string; displayName: string | null; } /** Users and roles to share with or invite. */ export function getDirectory(origin?: SessionOrigin): Promise<{ users: DirectoryUser[]; roles: DirectoryRole[]; }> { return call( () => api(origin).get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>( "/directory", ), "Failed to list users and roles", ); } /** Live sessions other users shared with the caller, as active-session rows. */ export function getSharedWithMe(): Promise { return call( () => api().get("/shared-with-me"), "Failed to list shared sessions", ); } // --------------------------------------------------------------------------- // Collab rooms // --------------------------------------------------------------------------- export interface CollabRoom { id: string; name: string; ownerUserId: string; persistent: boolean; presenterUserId: string | null; stageProtocol: string | null; stageHostId: number | null; stageShareId: string | null; guestLinkEnabled: boolean; createdAt: string; endedAt: string | null; } export interface CollabRoomMember { userId: string; username: string; roomRole: string; createdAt: string; } export interface CollabOnlineUser { userId: string; username: string; } export interface CollabStage { presenterUserId: string | null; protocol: "ssh" | "rdp" | "vnc" | "telnet" | null; hostId: number | null; shareId: string | null; sessionId?: string; controllerUserId?: string | null; connectParams?: { token: string }; } export interface CollabControlRequest { userId: string; username: string; requestedAt: string; } export interface CollabRoomDetail { room: CollabRoom; me: string; isHost: boolean; members: CollabRoomMember[]; online: CollabOnlineUser[]; stage: CollabStage; controllerUserId: string | null; controlRequests: CollabControlRequest[]; /** Where live room events are served, as /plugin-ws//. */ eventsWsPath: string; } export interface CollabGuestStage { protocol: "ssh" | "rdp" | "vnc" | "telnet"; shareId: string; wsPath?: string; connectParams?: { token: string }; } /** Anonymous: guests poll this to follow the presenter. Throws on 404/429. */ export async function resolveCollabGuestStage( token: string, ): Promise<{ roomName: string; stage: CollabGuestStage | null }> { const response = await api().get<{ roomName: string; stage: CollabGuestStage | null; }>(`/guest/${encodeURIComponent(token)}`); return response.data; }