feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

@@ -0,0 +1,51 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { LiveSessions } from "./live.js";
import type { RateLimiter } from "./rate-limit.js";
import type {
Directory,
RoomRepository,
ShareRepository,
} from "./repositories.js";
import type { CollabRoomHub } from "./room-hub.js";
import type { CollabRuntimeStore } from "./runtime-store.js";
/** Everything the routes and services share, built once in activate. */
export interface SharingDeps {
ctx: PluginContext;
shares: ShareRepository;
rooms: RoomRepository;
directory: Directory;
live: LiveSessions;
hub: CollabRoomHub;
store: CollabRuntimeStore;
/** /resolve/:linkToken, 30 a minute per IP. */
resolveLimiter: RateLimiter;
/** Room guest links, 60 a minute per IP. */
guestLimiter: RateLimiter;
isSharingEnabledForHost: (hostId: number) => Promise<boolean>;
}
export const PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const;
/** Where SSH shares are joined: the terminal's own socket. */
export const TERMINAL_WS_PATH = "/plugin-ws/ssh-terminal/terminal";
/** Where remote desktop viewers connect. */
export const DISPLAY_WS_PATH = "/plugin-ws/remote-desktop/display";
export function clientIp(req: {
ip?: string;
socket?: { remoteAddress?: string };
}): string {
return req.ip || req.socket?.remoteAddress || "unknown";
}
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/** The acting user, which core set from the authenticated request. */
export function actorOf(ctx: PluginContext): string {
const userId = ctx.currentActor();
if (!userId) throw new Error("No acting user");
return userId;
}
@@ -0,0 +1,80 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SharingDeps } from "./deps.js";
import { createLiveSessions, isSharingEnabledForHost } from "./live.js";
import { createRateLimiter } from "./rate-limit.js";
import {
createDirectory,
createRoomRepository,
createShareRepository,
type CoreRefs,
type Tables,
} from "./repositories.js";
import { CollabRoomHub } from "./room-hub.js";
import { createRoomActions, registerRoomRoutes } from "./room-routes.js";
import { CollabRuntimeStore } from "./runtime-store.js";
import {
SESSION_GUESTS_KEY,
createSessionGuests,
createSessionSharingService,
} from "./services.js";
import { registerShareRoutes } from "./share-routes.js";
import { roomMembers, rooms, shareParticipants, shares } from "./tables.js";
export async function activate(ctx: PluginContext) {
const tables: Tables = {
shares: await ctx.db.define(shares),
participants: await ctx.db.define(shareParticipants),
rooms: await ctx.db.define(rooms),
members: await ctx.db.define(roomMembers),
};
const refs = () => ctx.db.refs<CoreRefs>();
const directory = createDirectory(ctx.db, refs);
const store = new CollabRuntimeStore(ctx.log);
const hub = new CollabRoomHub(store);
const resolveLimiter = createRateLimiter({
windowMs: 60_000,
maxAttempts: 30,
});
const guestLimiter = createRateLimiter({ windowMs: 60_000, maxAttempts: 60 });
ctx.disposables.add(() => {
hub.dispose();
resolveLimiter.dispose();
guestLimiter.dispose();
return store.close();
});
const deps: SharingDeps = {
ctx,
shares: createShareRepository(ctx.db, tables),
rooms: createRoomRepository(ctx.db, tables, refs),
directory,
live: createLiveSessions(ctx),
hub,
store,
resolveLimiter,
guestLimiter,
isSharingEnabledForHost: (hostId) =>
isSharingEnabledForHost(ctx, directory.hostExists, hostId),
};
const actions = createRoomActions(deps);
store.onEvent(actions.onRuntimeEvent);
// Guest links carry their own token, checked in the handlers.
const router = ctx.http.router<Router>({
public: ["/resolve/:linkToken", "/guest/:token"],
});
registerShareRoutes(router, deps);
registerRoomRoutes(router, deps, actions);
ctx.services.provide("sessions.sharing", createSessionSharingService(deps));
ctx.registry.provide(SESSION_GUESTS_KEY, createSessionGuests(deps));
ctx.log.info("Session sharing mounted at /plugin-api/session-sharing");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+183
View File
@@ -0,0 +1,183 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { LiveProtocol } from "./repositories.js";
export const LIVE_SESSIONS_SERVICE = "sessions.live";
/** What every sessions.live provider reports about one session. */
export interface LiveSessionInfo {
id: string;
userId: string;
hostId: number;
hostName: string;
isConnected: boolean;
createdAt: number;
tabInstanceId: string | null;
}
/**
* sessions.live v1, keyed by session type. ssh-terminal provides "ssh";
* remote-desktop provides "rdp", "vnc" and "telnet". Through a service handle
* every method answers with a promise. The control methods are the terminal's
* (a remote desktop viewer cannot be kicked or handed control), and
* createViewerToken is remote desktop's: a join token for a viewer.
*/
export interface LiveSessionProvider {
getSession: (sessionId: string) => LiveSessionInfo | null;
ownerEndSession?: (sessionId: string, reason: string) => void;
disconnectParticipants?: (
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
) => number;
setRoomShareControl?: (
sessionId: string,
shareId: string,
controllerUserId: string | null,
) => void;
createViewerToken?: (sessionId: string, readOnly: boolean) => string;
}
type Async<T> = {
[K in keyof T]-?: T[K] extends ((...args: infer A) => infer R) | undefined
? (...args: A) => Promise<R>
: never;
};
export type LiveSessions = ReturnType<typeof createLiveSessions>;
/**
* Reaches live sessions through sessions.live as the acting user. A caller
* with no actor (a public guest route) passes the share owner, who owns the
* session being looked at.
*/
export function createLiveSessions(ctx: PluginContext) {
async function run<T>(
asUser: string | undefined,
fn: () => Promise<T>,
): Promise<T> {
if (asUser && ctx.currentActor() !== asUser) {
return ctx.asUser(asUser, fn);
}
return fn();
}
function provider(protocol: string): Async<LiveSessionProvider> | null {
const handle = ctx.services.get<Async<LiveSessionProvider>>(
LIVE_SESSIONS_SERVICE,
{ provider: protocol },
);
return "getSession" in handle ? handle : null;
}
async function getSession(
protocol: string,
sessionId: string,
asUser?: string,
): Promise<LiveSessionInfo | null> {
const live = provider(protocol);
if (!live) return null;
try {
return (await run(asUser, () => live.getSession(sessionId))) ?? null;
} catch {
return null;
}
}
return {
getSession,
async isLive(
protocol: string,
sessionId: string,
asUser?: string,
): Promise<boolean> {
const session = await getSession(protocol, sessionId, asUser);
return !!session?.isConnected;
},
async isOwnedBy(
protocol: LiveProtocol,
sessionId: string,
userId: string,
): Promise<boolean> {
const session = await getSession(protocol, sessionId);
return !!session && session.isConnected && session.userId === userId;
},
/** Ends the session for every guest. Best effort, like before. */
async ownerEndSession(
protocol: string,
sessionId: string,
reason: string,
asUser?: string,
): Promise<void> {
const live = provider(protocol);
if (!live || !("ownerEndSession" in live)) return;
await run(asUser, () => live.ownerEndSession(sessionId, reason)).catch(
() => {},
);
},
async disconnectParticipants(
protocol: string,
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
asUser?: string,
): Promise<void> {
const live = provider(protocol);
if (!live || !("disconnectParticipants" in live)) return;
await run(asUser, () =>
live.disconnectParticipants(sessionId, shareId, options),
).catch(() => {});
},
async setRoomShareControl(
protocol: string,
sessionId: string,
shareId: string,
controllerUserId: string | null,
asUser?: string,
): Promise<void> {
const live = provider(protocol);
if (!live || !("setRoomShareControl" in live)) return;
await run(asUser, () =>
live.setRoomShareControl(sessionId, shareId, controllerUserId),
).catch(() => {});
},
/** A viewer token for a remote desktop session. Throws while unavailable. */
async createViewerToken(
protocol: string,
sessionId: string,
readOnly: boolean,
asUser?: string,
): Promise<string> {
const live = provider(protocol);
if (!live || !("createViewerToken" in live)) {
throw new Error(`No ${protocol} session provider is running`);
}
return run(asUser, () => live.createViewerToken(sessionId, readOnly));
},
};
}
/**
* Whether sessions on a host may be shared: the admin switch first, then the
* host's own setting. Both default to on. A host that does not exist cannot
* be shared.
*/
export async function isSharingEnabledForHost(
ctx: PluginContext,
hostExists: (hostId: number) => Promise<boolean>,
hostId: number,
): Promise<boolean> {
if ((await ctx.settings.get<boolean>("globallyEnabled")) === false) {
return false;
}
if (!(await hostExists(hostId))) return false;
return (
(await ctx.settings.getHost<boolean>(hostId, "allowSessionSharing")) !==
false
);
}
@@ -0,0 +1,37 @@
/**
* A per-IP fixed window, for the public guest endpoints. `dispose` stops the
* sweep that forgets old windows.
*/
export function createRateLimiter(options: {
windowMs: number;
maxAttempts: number;
}) {
const attempts = new Map<string, { count: number; windowStart: number }>();
const sweep = setInterval(() => {
const now = Date.now();
for (const [ip, entry] of attempts) {
if (now - entry.windowStart > options.windowMs) attempts.delete(ip);
}
}, 5 * options.windowMs);
sweep.unref();
return {
/** Counts an attempt and says whether this IP is over the limit. */
isLimited(ip: string): boolean {
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || now - entry.windowStart > options.windowMs) {
attempts.set(ip, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > options.maxAttempts;
},
dispose(): void {
clearInterval(sweep);
attempts.clear();
},
};
}
export type RateLimiter = ReturnType<typeof createRateLimiter>;
@@ -0,0 +1,580 @@
import { and, desc, eq, gt, inArray, isNull } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// Tables come from ctx.db.define, which the SDK hands back untyped, and the
// drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type ShareType = "link" | "user" | "room";
export type PermissionLevel = "read-only" | "read-write";
export type LiveProtocol = "ssh" | "rdp" | "vnc" | "telnet";
export interface ShareRecord {
id: string;
hostId: number;
ownerUserId: string;
protocol: string;
sessionId: string;
tabInstanceId: string | null;
shareType: string;
targetUserId: string | null;
linkToken: string | null;
permissionLevel: string;
createdAt: string;
expiresAt: string;
revokedAt: string | null;
lastJoinedAt: string | null;
joinCount: number;
}
export interface ShareCreateInput {
id: string;
hostId: number;
ownerUserId: string;
protocol: string;
sessionId: string;
tabInstanceId?: string | null;
shareType: ShareType;
targetUserId?: string | null;
linkToken?: string | null;
permissionLevel: PermissionLevel;
expiresAt: string;
}
export interface RoomRecord {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
guestLinkToken: string | null;
createdAt: string;
endedAt: string | null;
}
export interface RoomMemberRecord {
id: number;
roomId: string;
userId: string;
roomRole: string;
addedBy: string | null;
createdAt: string;
}
export interface RoomMemberWithUser {
userId: string;
username: string;
roomRole: string;
createdAt: string;
}
export interface RoomStage {
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
}
export interface Tables {
shares: Table;
participants: Table;
rooms: Table;
members: Table;
}
export interface CoreRefs {
users: Table;
hosts: Table;
roles: Table;
userRoles: Table;
}
const nowIso = () => new Date().toISOString();
/** Not revoked and not expired. Expiry is compared as ISO text, as before. */
function activeShare(shares: Table, now: string) {
return and(isNull(shares.revokedAt), gt(shares.expiresAt, now));
}
export type ShareRepository = ReturnType<typeof createShareRepository>;
export function createShareRepository(db: PluginDatabase, tables: Tables) {
const client = () => db.client<Drizzle>();
const { shares, participants } = tables;
async function findById(id: string): Promise<ShareRecord | null> {
const rows = await (
await client()
)
.select()
.from(shares)
.where(eq(shares.id, id))
.limit(1);
return rows[0] ?? null;
}
return {
findById,
async create(input: ShareCreateInput): Promise<ShareRecord> {
await (await client()).insert(shares).values({
id: input.id,
hostId: input.hostId,
ownerUserId: input.ownerUserId,
protocol: input.protocol,
sessionId: input.sessionId,
tabInstanceId: input.tabInstanceId ?? null,
shareType: input.shareType,
targetUserId: input.targetUserId ?? null,
linkToken: input.linkToken ?? null,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
});
await db.persist();
return (await findById(input.id))!;
},
async findActiveById(
id: string,
now = nowIso(),
): Promise<ShareRecord | null> {
const rows = await (
await client()
)
.select()
.from(shares)
.where(and(eq(shares.id, id), activeShare(shares, now)))
.limit(1);
return rows[0] ?? null;
},
async findByLinkToken(
linkToken: string,
now = nowIso(),
): Promise<ShareRecord | null> {
const rows = await (
await client()
)
.select()
.from(shares)
.where(and(eq(shares.linkToken, linkToken), activeShare(shares, now)))
.limit(1);
return rows[0] ?? null;
},
async findActiveSharesForHost(
hostId: number,
ownerUserId: string,
now = nowIso(),
): Promise<ShareRecord[]> {
return (await client())
.select()
.from(shares)
.where(
and(
eq(shares.hostId, hostId),
eq(shares.ownerUserId, ownerUserId),
activeShare(shares, now),
),
);
},
/** Active user shares aimed at this user. */
async findSharesTargetingUser(
userId: string,
now = nowIso(),
): Promise<ShareRecord[]> {
return (await client())
.select()
.from(shares)
.where(
and(
eq(shares.shareType, "user"),
eq(shares.targetUserId, userId),
activeShare(shares, now),
),
);
},
/** Revokes a share the given user owns. False when it is not theirs. */
async revoke(shareId: string, ownerUserId: string): Promise<boolean> {
const share = await findById(shareId);
if (!share || share.ownerUserId !== ownerUserId) return false;
await (
await client()
)
.update(shares)
.set({ revokedAt: nowIso() })
.where(eq(shares.id, shareId));
await db.persist();
return true;
},
async revokeAny(shareId: string): Promise<boolean> {
if (!(await findById(shareId))) return false;
await (
await client()
)
.update(shares)
.set({ revokedAt: nowIso() })
.where(eq(shares.id, shareId));
await db.persist();
return true;
},
async touchUsage(shareId: string): Promise<void> {
const current = await findById(shareId);
if (!current) return;
await (
await client()
)
.update(shares)
.set({
lastJoinedAt: nowIso(),
joinCount: (current.joinCount ?? 0) + 1,
})
.where(eq(shares.id, shareId));
await db.persist();
},
async recordParticipantJoin(
shareId: string,
userId: string | null,
guestLabel: string | null,
): Promise<void> {
await (
await client()
)
.insert(participants)
.values({ shareId, userId, guestLabel });
await db.persist();
},
};
}
export type RoomRepository = ReturnType<typeof createRoomRepository>;
export function createRoomRepository(
db: PluginDatabase,
tables: Tables,
refs: () => Promise<CoreRefs>,
) {
const client = () => db.client<Drizzle>();
const { rooms, members } = tables;
async function findById(id: string): Promise<RoomRecord | null> {
const rows = await (
await client()
)
.select()
.from(rooms)
.where(eq(rooms.id, id))
.limit(1);
return rows[0] ?? null;
}
async function findMember(
roomId: string,
userId: string,
): Promise<RoomMemberRecord | null> {
const rows = await (
await client()
)
.select()
.from(members)
.where(and(eq(members.roomId, roomId), eq(members.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async function updateStage(roomId: string, stage: RoomStage): Promise<void> {
await (await client()).update(rooms).set(stage).where(eq(rooms.id, roomId));
await db.persist();
}
return {
findById,
findMember,
updateStage,
async createRoom(input: {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
}): Promise<RoomRecord> {
await (await client()).insert(rooms).values(input);
await db.persist();
return (await findById(input.id))!;
},
/** The live room whose stage points at this share, if any. */
async findByStageShareId(shareId: string): Promise<RoomRecord | null> {
const rows = await (
await client()
)
.select()
.from(rooms)
.where(and(eq(rooms.stageShareId, shareId), isNull(rooms.endedAt)))
.limit(1);
return rows[0] ?? null;
},
async findByGuestToken(token: string): Promise<RoomRecord | null> {
const rows = await (
await client()
)
.select()
.from(rooms)
.where(and(eq(rooms.guestLinkToken, token), isNull(rooms.endedAt)))
.limit(1);
return rows[0] ?? null;
},
async setGuestToken(roomId: string, token: string | null): Promise<void> {
await (
await client()
)
.update(rooms)
.set({ guestLinkToken: token })
.where(eq(rooms.id, roomId));
await db.persist();
},
async listForUser(userId: string): Promise<RoomRecord[]> {
const rows = await (
await client()
)
.select({ room: rooms })
.from(members)
.innerJoin(rooms, eq(members.roomId, rooms.id))
.where(and(eq(members.userId, userId), isNull(rooms.endedAt)))
.orderBy(desc(rooms.createdAt));
return rows.map((row: { room: RoomRecord }) => row.room);
},
async addMember(input: {
roomId: string;
userId: string;
roomRole: "host" | "member";
addedBy: string | null;
}): Promise<boolean> {
if (await findMember(input.roomId, input.userId)) return false;
await (await client()).insert(members).values(input);
await db.persist();
return true;
},
async removeMember(roomId: string, userId: string): Promise<void> {
await (
await client()
)
.delete(members)
.where(and(eq(members.roomId, roomId), eq(members.userId, userId)));
await db.persist();
},
async listMembers(roomId: string): Promise<RoomMemberWithUser[]> {
const rows: RoomMemberRecord[] = await (
await client()
)
.select()
.from(members)
.where(eq(members.roomId, roomId));
const names = await usernames(rows.map((row) => row.userId));
return rows
.filter((row) => names.has(row.userId))
.map((row) => ({
userId: row.userId,
username: names.get(row.userId)!,
roomRole: row.roomRole,
createdAt: row.createdAt,
}))
.sort((a, b) => a.username.localeCompare(b.username));
},
/**
* Sets the stage only if it still points at `expectedShareId`, so two
* members taking the stage at once cannot both win.
*/
async replaceStage(
roomId: string,
expectedShareId: string | null,
stage: RoomStage,
): Promise<boolean> {
await (
await client()
)
.update(rooms)
.set(stage)
.where(
and(
eq(rooms.id, roomId),
expectedShareId
? eq(rooms.stageShareId, expectedShareId)
: isNull(rooms.stageShareId),
),
);
const after = await findById(roomId);
const changed = !!after && after.stageShareId === stage.stageShareId;
if (changed) await db.persist();
return changed;
},
async clearStage(roomId: string): Promise<void> {
await updateStage(roomId, {
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
});
},
async endRoom(roomId: string): Promise<void> {
await (
await client()
)
.update(rooms)
.set({
endedAt: nowIso(),
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
})
.where(eq(rooms.id, roomId));
await db.persist();
},
async deleteRoom(roomId: string): Promise<void> {
const drizzle = await client();
// Not left to the foreign key: SQLite only cascades with foreign_keys on.
await drizzle.delete(members).where(eq(members.roomId, roomId));
await drizzle.delete(rooms).where(eq(rooms.id, roomId));
await db.persist();
},
};
async function usernames(ids: string[]): Promise<Map<string, string>> {
if (ids.length === 0) return new Map();
const { users } = await refs();
const rows: Array<{ id: string; username: string }> = await (
await client()
)
.select({ id: users.id, username: users.username })
.from(users)
.where(inArray(users.id, ids));
return new Map(rows.map((row) => [row.id, row.username]));
}
}
export type Directory = ReturnType<typeof createDirectory>;
/** Users and roles, read through ctx.db.refs, for names and invites. */
export function createDirectory(
db: PluginDatabase,
refs: () => Promise<CoreRefs>,
) {
const client = () => db.client<Drizzle>();
return {
async username(userId: string): Promise<string> {
const { users } = await refs();
const rows = await (
await client()
)
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return rows[0]?.username ?? userId;
},
async userExists(userId: string): Promise<boolean> {
const { users } = await refs();
const rows = await (
await client()
)
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return rows.length > 0;
},
async isAdmin(userId: string): Promise<boolean> {
const { users } = await refs();
const rows = await (
await client()
)
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return !!rows[0]?.isAdmin;
},
async hostExists(hostId: number): Promise<boolean> {
const { hosts } = await refs();
const rows = await (
await client()
)
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.id, hostId))
.limit(1);
return rows.length > 0;
},
async roleExists(roleId: number): Promise<boolean> {
const { roles } = await refs();
const rows = await (
await client()
)
.select({ id: roles.id })
.from(roles)
.where(eq(roles.id, roleId))
.limit(1);
return rows.length > 0;
},
async roleUserIds(roleId: number): Promise<string[]> {
const { userRoles } = await refs();
const rows: Array<{ userId: string }> = await (
await client()
)
.select({ userId: userRoles.userId })
.from(userRoles)
.where(eq(userRoles.roleId, roleId));
return rows.map((row) => row.userId);
},
async listUsers(): Promise<Array<{ id: string; username: string }>> {
const { users } = await refs();
return (await client())
.select({ id: users.id, username: users.username })
.from(users)
.orderBy(users.username);
},
async listRoles(): Promise<
Array<{ id: number; name: string; displayName: string | null }>
> {
const { roles } = await refs();
return (await client())
.select({
id: roles.id,
name: roles.name,
displayName: roles.displayName,
})
.from(roles)
.orderBy(roles.name);
},
};
}
@@ -0,0 +1,118 @@
import type { WebSocket } from "ws";
import type { CollabRuntimeStore } from "./runtime-store.js";
const PRESENCE_HEARTBEAT_MS = 15_000;
export interface CollabRoomClient {
ws: WebSocket;
userId: string;
username: string;
}
/**
* Local WebSocket fan-out plus the runtime store's pub/sub for room events and
* presence across instances. The terminal socket feeds subscribe and
* unsubscribe through the sessions.sharing service.
*/
export class CollabRoomHub {
private rooms = new Map<string, Set<CollabRoomClient>>();
private readonly heartbeat: NodeJS.Timeout;
constructor(private readonly store: CollabRuntimeStore) {
store.onEvent((roomId, message) => {
if (
"type" in message &&
typeof message.type === "string" &&
message.type.startsWith("collab_internal_")
) {
return;
}
this.broadcastLocal(roomId, message);
});
this.heartbeat = setInterval(() => {
for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId);
}, PRESENCE_HEARTBEAT_MS);
this.heartbeat.unref();
}
subscribe(roomId: string, client: CollabRoomClient): void {
let clients = this.rooms.get(roomId);
if (!clients) {
clients = new Set();
this.rooms.set(roomId, clients);
}
for (const existing of clients) {
if (existing.ws === client.ws) return;
}
clients.add(client);
void this.refreshPresence(roomId);
}
/** Drops the socket from one room, or from every room when roomId is omitted. */
unsubscribe(ws: WebSocket, roomId?: string): void {
for (const [id, clients] of this.rooms) {
if (roomId && id !== roomId) continue;
let removed = false;
for (const client of clients) {
if (client.ws === ws) {
clients.delete(client);
removed = true;
}
}
if (clients.size === 0) this.rooms.delete(id);
if (removed) void this.refreshPresence(id);
}
}
broadcast(roomId: string, message: object): void {
this.broadcastLocal(roomId, message);
void this.store.publish(roomId, message);
}
async onlineUsers(
roomId: string,
): Promise<Array<{ userId: string; username: string }>> {
return this.store.onlineUsers(roomId, this.localOnlineUsers(roomId));
}
dispose(): void {
clearInterval(this.heartbeat);
this.rooms.clear();
}
private broadcastLocal(roomId: string, message: object): void {
const clients = this.rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(message);
for (const client of clients) {
if (client.ws.readyState !== client.ws.OPEN) continue;
try {
client.ws.send(payload);
} catch {
/* keep broadcasting to the rest */
}
}
}
private localOnlineUsers(
roomId: string,
): Array<{ userId: string; username: string }> {
const clients = this.rooms.get(roomId);
if (!clients) return [];
const seen = new Map<string, string>();
for (const client of clients) {
seen.set(client.userId, client.username);
}
return Array.from(seen, ([userId, username]) => ({ userId, username }));
}
private async refreshPresence(roomId: string): Promise<void> {
const localUsers = this.localOnlineUsers(roomId);
await this.store.updatePresence(roomId, localUsers);
this.broadcast(roomId, {
type: "collab_online",
roomId,
users: await this.onlineUsers(roomId),
});
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,289 @@
import crypto from "node:crypto";
import { createClient } from "redis";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export interface CollabControlRequest {
userId: string;
username: string;
requestedAt: string;
}
interface PresenceEntry {
instanceId: string;
userId: string;
username: string;
}
interface EventEnvelope {
source: string;
roomId: string;
message: object;
}
const KEY_PREFIX = process.env.TERMIX_REDIS_PREFIX?.trim() || "termix:collab";
const EVENT_CHANNEL = `${KEY_PREFIX}:events`;
const PRESENCE_TTL_MS = 45_000;
const STATE_TTL_SECONDS = 12 * 60 * 60;
const CONNECT_RETRY_MS = 15_000;
/**
* Room state that has to be shared between Termix instances: who controls a
* stage, pending control requests, presence and room events. Redis-backed when
* REDIS_URL is set, with local state for a single instance.
*/
export class CollabRuntimeStore {
constructor(private readonly log: PluginLogger) {}
private readonly instanceId = crypto.randomUUID();
private publisher: ReturnType<typeof createClient> | null = null;
private subscriber: ReturnType<typeof createClient> | null = null;
private connecting: Promise<boolean> | null = null;
private nextConnectAttempt = 0;
private eventListeners = new Set<(roomId: string, message: object) => void>();
private localControllers = new Map<string, string>();
private localRequests = new Map<string, Map<string, CollabControlRequest>>();
private presenceMembers = new Map<string, Set<string>>();
onEvent(listener: (roomId: string, message: object) => void): void {
this.eventListeners.add(listener);
void this.ensureConnected();
}
async publish(roomId: string, message: object): Promise<void> {
if (!(await this.ensureConnected()) || !this.publisher) return;
await this.publisher
.publish(
EVENT_CHANNEL,
JSON.stringify({ source: this.instanceId, roomId, message }),
)
.catch((error) => this.logRedisFailure("publish", error));
}
async getController(roomId: string): Promise<string | null> {
if (await this.ensureConnected()) {
const value = await this.publisher
?.get(`${KEY_PREFIX}:controller:${roomId}`)
.catch((error) => {
this.logRedisFailure("get_controller", error);
return undefined;
});
if (value !== undefined) return value;
}
return this.localControllers.get(roomId) ?? null;
}
async setController(roomId: string, userId: string | null): Promise<void> {
if (userId) this.localControllers.set(roomId, userId);
else this.localControllers.delete(roomId);
if (!(await this.ensureConnected()) || !this.publisher) return;
const key = `${KEY_PREFIX}:controller:${roomId}`;
const operation = userId
? this.publisher.set(key, userId, { EX: STATE_TTL_SECONDS })
: this.publisher.del(key);
await operation.catch((error) =>
this.logRedisFailure("set_controller", error),
);
}
async listRequests(roomId: string): Promise<CollabControlRequest[]> {
if (await this.ensureConnected()) {
const values = await this.publisher
?.hVals(`${KEY_PREFIX}:requests:${roomId}`)
.catch((error) => {
this.logRedisFailure("list_requests", error);
return null;
});
if (values) {
return values
.flatMap((value) => this.parse<CollabControlRequest>(value) ?? [])
.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
}
}
return Array.from(this.localRequests.get(roomId)?.values() ?? []).sort(
(a, b) => a.requestedAt.localeCompare(b.requestedAt),
);
}
async upsertRequest(
roomId: string,
request: CollabControlRequest,
): Promise<void> {
let requests = this.localRequests.get(roomId);
if (!requests) {
requests = new Map();
this.localRequests.set(roomId, requests);
}
requests.set(request.userId, request);
if (!(await this.ensureConnected()) || !this.publisher) return;
const key = `${KEY_PREFIX}:requests:${roomId}`;
await this.publisher
.multi()
.hSet(key, request.userId, JSON.stringify(request))
.expire(key, STATE_TTL_SECONDS)
.exec()
.catch((error) => this.logRedisFailure("upsert_request", error));
}
async removeRequest(roomId: string, userId: string): Promise<void> {
const requests = this.localRequests.get(roomId);
requests?.delete(userId);
if (requests?.size === 0) this.localRequests.delete(roomId);
if (!(await this.ensureConnected()) || !this.publisher) return;
await this.publisher
.hDel(`${KEY_PREFIX}:requests:${roomId}`, userId)
.catch((error) => this.logRedisFailure("remove_request", error));
}
async clearRequests(roomId: string): Promise<void> {
this.localRequests.delete(roomId);
if (!(await this.ensureConnected()) || !this.publisher) return;
await this.publisher
.del(`${KEY_PREFIX}:requests:${roomId}`)
.catch((error) => this.logRedisFailure("clear_requests", error));
}
async updatePresence(
roomId: string,
users: Array<{ userId: string; username: string }>,
): Promise<void> {
if (!(await this.ensureConnected()) || !this.publisher) return;
const key = `${KEY_PREFIX}:presence:${roomId}`;
const previous = this.presenceMembers.get(roomId) ?? new Set<string>();
const expiresAt = Date.now() + PRESENCE_TTL_MS;
const current = new Set(
users.map((user) =>
JSON.stringify({
instanceId: this.instanceId,
...user,
} satisfies PresenceEntry),
),
);
const transaction = this.publisher.multi();
if (previous.size > 0) transaction.zRem(key, Array.from(previous));
for (const member of current) {
transaction.zAdd(key, { score: expiresAt, value: member });
}
transaction.expire(key, Math.ceil(PRESENCE_TTL_MS / 1000) * 2);
await transaction
.exec()
.then(() => {
if (current.size > 0) this.presenceMembers.set(roomId, current);
else this.presenceMembers.delete(roomId);
})
.catch((error) => this.logRedisFailure("update_presence", error));
}
async onlineUsers(
roomId: string,
localUsers: Array<{ userId: string; username: string }>,
): Promise<Array<{ userId: string; username: string }>> {
if (!(await this.ensureConnected()) || !this.publisher) return localUsers;
const key = `${KEY_PREFIX}:presence:${roomId}`;
const now = Date.now();
const transaction = this.publisher
.multi()
.zRemRangeByScore(key, 0, now)
.zRangeByScore(key, now + 1, "+inf");
const result = await transaction.exec().catch((error) => {
this.logRedisFailure("online_users", error);
return null;
});
const members = result?.[1] as string[] | undefined;
if (!members) return localUsers;
const seen = new Map<string, string>();
for (const member of members) {
const entry = this.parse<PresenceEntry>(member);
if (entry) seen.set(entry.userId, entry.username);
}
return Array.from(seen, ([userId, username]) => ({ userId, username }));
}
async close(): Promise<void> {
this.eventListeners.clear();
this.nextConnectAttempt = Number.POSITIVE_INFINITY;
await Promise.allSettled([
this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(),
this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(),
]);
this.publisher = null;
this.subscriber = null;
}
private async ensureConnected(): Promise<boolean> {
const url = process.env.REDIS_URL?.trim();
if (!url) return false;
if (this.publisher?.isReady && this.subscriber?.isReady) return true;
if (Date.now() < this.nextConnectAttempt) return false;
if (this.connecting) return this.connecting;
this.connecting = this.connect(url).finally(() => {
this.connecting = null;
});
return this.connecting;
}
private async connect(url: string): Promise<boolean> {
try {
this.publisher = createClient({
url,
socket: { connectTimeout: 1500, reconnectStrategy: false },
});
this.subscriber = this.publisher.duplicate();
this.publisher.on("error", (error) =>
this.logRedisFailure("client", error),
);
this.subscriber.on("error", (error) =>
this.logRedisFailure("subscriber", error),
);
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
await this.subscriber.subscribe(EVENT_CHANNEL, (raw) => {
const envelope = this.parse<EventEnvelope>(raw);
if (
!envelope ||
typeof envelope.source !== "string" ||
typeof envelope.roomId !== "string" ||
!envelope.message ||
typeof envelope.message !== "object" ||
envelope.source === this.instanceId
) {
return;
}
for (const listener of this.eventListeners) {
try {
listener(envelope.roomId, envelope.message);
} catch (error) {
this.logRedisFailure("event_listener", error);
}
}
});
this.log.info("Collaboration Redis runtime connected");
this.nextConnectAttempt = 0;
return true;
} catch (error) {
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
this.logRedisFailure("connect", error);
await Promise.allSettled([
this.publisher?.disconnect(),
this.subscriber?.disconnect(),
]);
this.publisher = null;
this.subscriber = null;
return false;
}
}
private parse<T>(value: string): T | null {
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
private logRedisFailure(operation: string, error: unknown): void {
this.log.warn(
`Collaboration Redis runtime unavailable (${operation}), using local state: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
@@ -0,0 +1,158 @@
import type { WebSocket } from "ws";
import type { SharingDeps } from "./deps.js";
import type { ShareRecord } from "./repositories.js";
/**
* What the terminal consumes. The shapes match
* plugins/ssh-terminal/src/backend/services.ts, which is the consumer's copy.
*/
export interface SharedSessionRef {
id: string;
sessionId: string;
permissionLevel: "read-write" | "read-only";
}
/** sessions.sharing v1: signed-in joins. Every call runs as the acting user. */
export interface SessionSharingV1 {
authorizeJoin: (
shareId: string,
) => Promise<{ share: SharedSessionRef; displayName: string } | null>;
recordJoin: (shareId: string) => Promise<void>;
subscribeRoom: (roomId: string, socket: unknown) => Promise<boolean>;
unsubscribeRoom: (socket: unknown, roomId?: string) => Promise<void>;
}
/**
* Guest joins through a share link or a room guest link, on ctx.registry as
* SESSION_GUESTS_KEY. A guest has no user, so a service's per-call
* permission check cannot apply: the token is the authority.
*/
export interface SessionGuestsV1 {
resolve: (request: {
shareToken?: string;
roomGuestToken?: string;
clientIp: string;
}) => Promise<
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
>;
recordJoin: (shareId: string) => Promise<void>;
}
export const SESSION_GUESTS_KEY = "sessions.sharing.guests";
function toRef(share: ShareRecord): SharedSessionRef {
return {
id: share.id,
sessionId: share.sessionId,
permissionLevel:
share.permissionLevel === "read-write" ? "read-write" : "read-only",
};
}
export function createSessionSharingService(
deps: SharingDeps,
): SessionSharingV1 {
const { ctx, shares, rooms, directory, hub } = deps;
return {
async authorizeJoin(shareId) {
const userId = ctx.currentActor();
if (!userId) return null;
const share = await shares.findActiveById(shareId);
if (!share || share.protocol !== "ssh") return null;
if (share.shareType === "user") {
// A user share needs its target, who can still reach the host.
if (share.targetUserId !== userId) return null;
const access = await ctx.hosts.checkAccess(share.hostId, "connect");
if (!access.hasAccess) return null;
} else if (share.shareType === "room") {
// Room membership is the authorization for a room stage: it is
// read-only and never exposes host credentials or config.
const room = await rooms.findByStageShareId(share.id);
if (!room || !(await rooms.findMember(room.id, userId))) return null;
} else {
return null;
}
return {
share: toRef(share),
displayName: await directory.username(userId),
};
},
async recordJoin(shareId) {
const userId = ctx.currentActor() ?? null;
await shares.touchUsage(shareId);
await shares.recordParticipantJoin(shareId, userId, null);
},
async subscribeRoom(roomId, socket) {
const userId = ctx.currentActor();
if (!userId) return false;
const room = await rooms.findById(roomId);
if (!room || room.endedAt) return false;
if (!(await rooms.findMember(roomId, userId))) return false;
hub.subscribe(roomId, {
ws: socket as WebSocket,
userId,
username: await directory.username(userId),
});
return true;
},
async unsubscribeRoom(socket, roomId) {
hub.unsubscribe(socket as WebSocket, roomId);
},
};
}
export function createSessionGuests(deps: SharingDeps): SessionGuestsV1 {
const { shares, rooms, guestLimiter } = deps;
/** Sharing still allowed, and an SSH share: the only kind the socket joins. */
async function joinable(
share: ShareRecord,
): Promise<
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
> {
if (share.protocol !== "ssh") {
return { ok: false, reason: "Unsupported share protocol" };
}
if (!(await deps.isSharingEnabledForHost(share.hostId))) {
return { ok: false, reason: "Session sharing is disabled for this host" };
}
return { ok: true, share: toRef(share) };
}
return {
async resolve({ shareToken, roomGuestToken, clientIp }) {
if (shareToken) {
const share = await shares.findByLinkToken(shareToken);
if (!share || share.shareType !== "link") {
return { ok: false, reason: "Invalid or expired share link" };
}
return joinable(share);
}
if (roomGuestToken) {
if (guestLimiter.isLimited(clientIp)) {
return { ok: false, reason: "Too many requests" };
}
const room = await rooms.findByGuestToken(roomGuestToken);
const share = room?.stageShareId
? await shares.findActiveById(room.stageShareId)
: null;
if (!share) return { ok: false, reason: "Nothing is being presented" };
return joinable(share);
}
return { ok: false, reason: "Authentication required" };
},
async recordJoin(shareId) {
await shares.touchUsage(shareId);
await shares.recordParticipantJoin(shareId, null, "Guest");
},
};
}
@@ -0,0 +1,523 @@
import crypto from "node:crypto";
import type { Request, Response, Router } from "express";
import {
DISPLAY_WS_PATH,
PROTOCOLS,
TERMINAL_WS_PATH,
actorOf,
clientIp,
type SharingDeps,
} from "./deps.js";
import type { LiveProtocol, PermissionLevel } from "./repositories.js";
const DEFAULT_EXPIRY_HOURS = 24;
const MAX_EXPIRY_HOURS = 24 * 30;
function computeExpiresAt(expiryHours: number | undefined): string {
const hours = Math.min(
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
MAX_EXPIRY_HOURS,
);
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}
/**
* The share routes. `/resolve/:linkToken` is public (an anonymous guest link);
* the rest run behind core auth and the plugin's `use` permission, which the
* caller applies to the router first.
*/
export function registerShareRoutes(router: Router, deps: SharingDeps): void {
const { ctx, shares, directory, live, resolveLimiter } = deps;
const requireUse = ctx.rbac.require("use") as never;
/**
* @openapi
* /plugin-api/session-sharing/resolve/{linkToken}:
* get:
* summary: Resolve a guest share link
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
* tags:
* - Session Sharing
* parameters:
* - in: path
* name: linkToken
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Resolved share connection info
* 404:
* description: Link not found, expired, revoked, or sharing disabled
* 429:
* description: Too many requests
* 500:
* description: Server error
*/
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
try {
if (resolveLimiter.isLimited(clientIp(req))) {
return res.status(429).json({ error: "Too many requests" });
}
const linkToken = String(req.params.linkToken);
const share = await shares.findByLinkToken(linkToken);
if (!share || share.shareType !== "link") {
return res.status(404).json({ error: "Link not found or expired" });
}
if (!(await deps.isSharingEnabledForHost(share.hostId))) {
return res.status(404).json({ error: "Link not found or expired" });
}
const protocol = share.protocol as LiveProtocol;
if (!(await live.isLive(protocol, share.sessionId, share.ownerUserId))) {
return res.status(404).json({ error: "Session is no longer active" });
}
// Field by field on purpose: anonymous guests never see the host's
// name, address, username or id.
const response: {
protocol: LiveProtocol;
permissionLevel: PermissionLevel;
wsPath: string;
connectParams?: Record<string, string>;
} = {
protocol,
permissionLevel: share.permissionLevel as PermissionLevel,
// Advisory only: the client builds its own socket URL per deployment.
wsPath:
protocol === "ssh"
? `${TERMINAL_WS_PATH}?shareToken=${encodeURIComponent(linkToken)}`
: DISPLAY_WS_PATH,
};
if (protocol !== "ssh") {
response.connectParams = {
token: await live.createViewerToken(
protocol,
share.sessionId,
share.permissionLevel === "read-only",
share.ownerUserId,
),
};
}
try {
await shares.touchUsage(share.id);
await shares.recordParticipantJoin(share.id, null, "Guest");
} catch {
// Bookkeeping never fails the resolve.
}
res.json(response);
} catch (error) {
ctx.log.error(
"Failed to resolve session share link",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to resolve share link" });
}
});
/**
* @openapi
* /plugin-api/session-sharing/create:
* post:
* summary: Create a session share (link or targeted user)
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - hostId
* - sessionId
* - protocol
* - shareType
* - permissionLevel
* properties:
* hostId:
* type: integer
* sessionId:
* type: string
* tabInstanceId:
* type: string
* protocol:
* type: string
* enum: [ssh, rdp, vnc, telnet]
* shareType:
* type: string
* enum: [link, user]
* targetUserId:
* type: string
* permissionLevel:
* type: string
* enum: [read-only, read-write]
* expiryHours:
* type: number
* responses:
* 200:
* description: Share created
* 400:
* description: Invalid request
* 403:
* description: Sharing disabled, or caller does not own the session
* 500:
* description: Server error
*/
router.post("/create", requireUse, async (req: Request, res: Response) => {
try {
const userId = actorOf(ctx);
const {
hostId,
sessionId,
tabInstanceId,
protocol,
shareType,
targetUserId,
permissionLevel,
expiryHours,
} = req.body ?? {};
if (
!hostId ||
!sessionId ||
!protocol ||
!shareType ||
!permissionLevel
) {
return res.status(400).json({ error: "Missing required fields" });
}
if (!PROTOCOLS.includes(protocol)) {
return res.status(400).json({ error: "Invalid protocol" });
}
if (!["link", "user"].includes(shareType)) {
return res.status(400).json({ error: "Invalid shareType" });
}
if (!["read-only", "read-write"].includes(permissionLevel)) {
return res.status(400).json({ error: "Invalid permissionLevel" });
}
if (shareType === "user" && !targetUserId) {
return res
.status(400)
.json({ error: "targetUserId is required for user shares" });
}
const numericHostId = Number(hostId);
if (!(await deps.isSharingEnabledForHost(numericHostId))) {
return res
.status(403)
.json({ error: "Session sharing is disabled for this host" });
}
if (!(await live.isOwnedBy(protocol, String(sessionId), userId))) {
return res
.status(403)
.json({ error: "You do not own this live session" });
}
if (shareType === "user") {
const target = String(targetUserId);
const access = (await directory.userExists(target))
? await ctx.asUser(target, () =>
ctx.hosts.checkAccess(numericHostId, "connect"),
)
: { hasAccess: false };
if (!access.hasAccess) {
return res.status(403).json({
error: "Target user does not have access to this host",
});
}
}
const created = await shares.create({
id: crypto.randomUUID(),
hostId: numericHostId,
ownerUserId: userId,
protocol,
sessionId: String(sessionId),
tabInstanceId: tabInstanceId ?? null,
shareType,
targetUserId: shareType === "user" ? String(targetUserId) : null,
linkToken:
shareType === "link"
? crypto.randomBytes(24).toString("base64url")
: null,
permissionLevel,
expiresAt: computeExpiresAt(expiryHours),
});
res.json({
shareId: created.id,
linkToken: created.linkToken,
expiresAt: created.expiresAt,
});
} catch (error) {
ctx.log.error(
"Failed to create session share",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to create session share" });
}
});
/**
* @openapi
* /plugin-api/session-sharing/host/{hostId}/active:
* get:
* summary: List active session shares for a host
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: List of active shares
* 400:
* description: Invalid host id
* 500:
* description: Server error
*/
router.get(
"/host/:hostId/active",
requireUse,
async (req: Request, res: Response) => {
try {
const userId = actorOf(ctx);
const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || Number.isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
res.json({
shares: await shares.findActiveSharesForHost(hostId, userId),
});
} catch (error) {
ctx.log.error(
"Failed to list session shares",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to list session shares" });
}
},
);
/**
* @openapi
* /plugin-api/session-sharing/shared-with-me:
* get:
* summary: List live SSH sessions other users shared with the caller
* description: Active user shares aimed at the caller whose session is still live, in the shape of /open-tabs/active-sessions.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Shared sessions
* 500:
* description: Server error
*/
router.get(
"/shared-with-me",
requireUse,
async (_req: Request, res: Response) => {
try {
const userId = actorOf(ctx);
const result = [];
for (const share of await shares.findSharesTargetingUser(userId)) {
if (share.protocol !== "ssh") continue;
const session = await live.getSession("ssh", share.sessionId);
if (!session?.isConnected) continue;
result.push({
sessionId: session.id,
hostId: session.hostId,
hostName: session.hostName,
tabInstanceId: session.tabInstanceId,
isConnected: session.isConnected,
createdAt: session.createdAt,
isOwnSession: false,
sharedByUsername: await directory.username(share.ownerUserId),
permissionLevel: share.permissionLevel,
shareId: share.id,
});
}
res.json(result);
} catch (error) {
ctx.log.error(
"Failed to list shared sessions",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to list shared sessions" });
}
},
);
/**
* @openapi
* /plugin-api/session-sharing/directory:
* get:
* summary: Users and roles to share with or invite
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Users (id, username) and roles (id, name, displayName)
* 500:
* description: Server error
*/
router.get("/directory", requireUse, async (_req: Request, res: Response) => {
try {
res.json({
users: await directory.listUsers(),
roles: await directory.listRoles(),
});
} catch (error) {
ctx.log.error(
"Failed to list users and roles",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to list users and roles" });
}
});
/**
* @openapi
* /plugin-api/session-sharing/{shareId}:
* delete:
* summary: Revoke a session share
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; remote desktop viewers are not force-disconnected.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: shareId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Share revoked
* 403:
* description: Not authorized to revoke this share
* 404:
* description: Share not found
* 500:
* description: Server error
*/
router.delete(
"/:shareId",
requireUse,
async (req: Request, res: Response) => {
try {
const userId = actorOf(ctx);
const shareId = String(req.params.shareId);
const share = await shares.findById(shareId);
if (!share) {
return res.status(404).json({ error: "Share not found" });
}
let revoked = await shares.revoke(shareId, userId);
if (!revoked && (await directory.isAdmin(userId))) {
revoked = await shares.revokeAny(shareId);
}
if (!revoked) {
return res
.status(403)
.json({ error: "Not authorized to revoke this share" });
}
// guacamole-lite has no kick, so a revoked remote desktop link only
// blocks new resolves until the viewer's own socket ends.
if (share.protocol === "ssh") {
await live.ownerEndSession(
"ssh",
share.sessionId,
"Session share revoked by owner",
share.ownerUserId,
);
}
res.json({ success: true });
} catch (error) {
ctx.log.error(
"Failed to revoke session share",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to revoke session share" });
}
},
);
/**
* @openapi
* /plugin-api/session-sharing/{shareId}/end:
* post:
* summary: End a shared session for all participants
* description: Owner-only. Terminates the underlying session and notifies joined participants. Remote desktop viewers are not force-disconnected.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: shareId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Session ended
* 403:
* description: Not the owner of this share
* 404:
* description: Share not found
* 500:
* description: Server error
*/
router.post(
"/:shareId/end",
requireUse,
async (req: Request, res: Response) => {
try {
const userId = actorOf(ctx);
const share = await shares.findById(String(req.params.shareId));
if (!share) {
return res.status(404).json({ error: "Share not found" });
}
if (share.ownerUserId !== userId) {
return res.status(403).json({ error: "Not the owner of this share" });
}
if (share.protocol === "ssh") {
await live.ownerEndSession(
"ssh",
share.sessionId,
"Session ended by owner",
);
}
res.json({ success: true });
} catch (error) {
ctx.log.error(
"Failed to end shared session",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to end shared session" });
}
},
);
}
@@ -0,0 +1,128 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refHost,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/*
* Adopted from core's session_shares, session_share_participants,
* collab_rooms and collab_room_members, so column and index names are the
* legacy ones and the rename carries every row across.
*
* Columns that point at a user or host but may be null or set null on delete,
* and the links between these four tables, are plain columns here: the SDK
* only references users and ssh_data, cascading. The adoption migrations keep
* the legacy FOREIGN KEY clauses by hand, as fleets does.
*/
/**
* A grant to join one live session: a link for anonymous guests, a user, or
* a collab room's stage ("room").
*/
export const shares = adoptLegacyTable(
"session_shares",
defineTable(
"shares",
{
id: varchar().primaryKey(),
hostId: refHost(),
ownerUserId: refUser(),
protocol: text().notNull(),
// The terminal's session id for SSH, guacd's connection id otherwise.
// Neither is a row, so there is no foreign key.
sessionId: varchar().notNull(),
tabInstanceId: text(),
shareType: text().notNull(),
targetUserId: varchar(),
linkToken: varchar().unique(),
permissionLevel: text().notNull().default("read-only"),
createdAt: timestamp().notNull().defaultNow(),
expiresAt: text().notNull(),
revokedAt: text(),
lastJoinedAt: text(),
joinCount: integer().notNull().default(0),
},
{
indexes: [
{ name: "idx_session_shares_session_id", columns: ["sessionId"] },
{ name: "idx_session_shares_host_id", columns: ["hostId"] },
],
},
),
);
/** Who joined through a share, for the owner's record. */
export const shareParticipants = adoptLegacyTable(
"session_share_participants",
defineTable("share_participants", {
id: id(),
shareId: varchar().notNull(),
userId: varchar(),
guestLabel: text(),
joinedAt: timestamp().notNull().defaultNow(),
leftAt: text(),
}),
);
/**
* A collaboration room: members watching one stage, the live session the
* current presenter shows through a "room" share.
*/
export const rooms = adoptLegacyTable(
"collab_rooms",
defineTable(
"rooms",
{
id: varchar().primaryKey(),
name: text().notNull(),
ownerUserId: refUser(),
persistent: boolean().notNull().default(false),
presenterUserId: varchar(),
stageProtocol: text(),
stageHostId: integer(),
stageShareId: varchar(),
guestLinkToken: varchar(),
createdAt: timestamp().notNull().defaultNow(),
endedAt: text(),
},
{
indexes: [{ name: "idx_collab_rooms_owner", columns: ["ownerUserId"] }],
uniques: [
{ name: "idx_collab_rooms_guest_token", columns: ["guestLinkToken"] },
],
},
),
);
export const roomMembers = adoptLegacyTable(
"collab_room_members",
defineTable(
"room_members",
{
id: id(),
roomId: varchar().notNull(),
userId: refUser(),
roomRole: text().notNull().default("member"),
addedBy: varchar(),
createdAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_collab_room_members_room_user",
columns: ["roomId", "userId"],
},
],
indexes: [{ name: "idx_collab_room_members_user", columns: ["userId"] }],
},
),
);
export const tables = [shares, shareParticipants, rooms, roomMembers];
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
import { RemoteDisplay } from "./shared";
import {
GuestTerminalView,
type SessionParticipantInfo,
} from "./SharedSessionView";
import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
const POLL_MS = 5000;
/**
* Anonymous guest page for a collab room (?view=collab-guest&token=...).
* Guests have no account and no event channel, so they poll the public
* resolve endpoint and remount the viewer whenever the stage share changes.
*/
export default function CollabGuestView() {
const { t } = useTranslation();
const token = new URLSearchParams(window.location.search).get("token");
const [roomName, setRoomName] = useState<string | null>(null);
const [stage, setStage] = useState<CollabGuestStage | null>(null);
const [error, setError] = useState<string | null>(null);
const [participantCount, setParticipantCount] = useState(0);
const stageShareIdRef = useRef<string | null>(null);
useEffect(() => {
if (!token) {
setError(t("collab.guest.linkInvalid"));
return;
}
let cancelled = false;
const poll = async () => {
try {
const result = await resolveCollabGuestStage(token);
if (cancelled) return;
setRoomName(result.roomName);
setError(null);
const nextShareId = result.stage?.shareId ?? null;
// Tokens are minted per resolve; only swap the viewer on a real change.
if (nextShareId !== stageShareIdRef.current) {
stageShareIdRef.current = nextShareId;
setStage(result.stage);
setParticipantCount(0);
}
} catch {
if (!cancelled) setError(t("collab.guest.linkInvalid"));
}
};
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [token, t]);
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--bg-base)", color: "var(--foreground)" }}
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border text-sm shrink-0">
<Presentation className="size-4 text-muted-foreground shrink-0" />
<span className="font-semibold truncate min-w-0">
{roomName ?? t("collab.guest.title")}
</span>
<div className="flex items-center gap-3 shrink-0 ml-auto text-xs text-muted-foreground">
{participantCount >= 2 && (
<span className="flex items-center gap-1">
<Users className="size-3.5" />
{participantCount}
</span>
)}
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{t("sessionSharing.guestView.readOnlyBadge")}
</span>
</div>
</div>
<div className="relative flex-1 min-h-0">
{error ? (
<Note icon={<AlertCircle className="size-8" />} text={error} />
) : !stage ? (
<Note
icon={<Presentation className="size-8" />}
text={t("collab.guest.waiting")}
/>
) : stage.protocol === "ssh" ? (
<GuestTerminalView
key={stage.shareId}
share={{ permissionLevel: "read-only" }}
wsPath={stage.wsPath ?? ""}
hideBadges
onParticipantsChange={(participants: SessionParticipantInfo[]) =>
setParticipantCount(participants.length)
}
/>
) : stage.connectParams?.token ? (
<RemoteDisplay
key={stage.shareId}
token={stage.connectParams.token}
protocol={stage.protocol}
isVisible
/>
) : null}
</div>
</div>
);
}
function Note({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
{icon}
<p className="text-sm">{text}</p>
</div>
);
}
@@ -0,0 +1,287 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Check,
Crown,
Hand,
MonitorUp,
MousePointerClick,
UserMinus,
Users,
X,
} from "lucide-react";
import { Badge } from "@termix/plugin-sdk/ui";
import { Button } from "@termix/plugin-sdk/ui";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@termix/plugin-sdk/ui";
import type { CollabControlRequest, CollabRoomDetail } from "./api";
interface CollabMembersSidebarProps {
detail: CollabRoomDetail;
onClose: () => void;
onInvite: () => void;
onControl: (userId: string | null) => Promise<void>;
onDismissRequest: (userId: string) => Promise<void>;
onRemoveMember: (userId: string) => Promise<void>;
}
export function CollabMembersSidebar({
detail,
onClose,
onInvite,
onControl,
onDismissRequest,
onRemoveMember,
}: CollabMembersSidebarProps) {
const { t } = useTranslation();
const [removing, setRemoving] = useState<{
userId: string;
username: string;
} | null>(null);
const [busyUserId, setBusyUserId] = useState<string | null>(null);
const onlineIds = new Set(detail.online.map((user) => user.userId));
const presenterUserId = detail.stage.presenterUserId;
const canManageControl = detail.isHost || presenterUserId === detail.me;
const canControl =
canManageControl &&
detail.stage.protocol === "ssh" &&
!!detail.stage.shareId;
async function run(userId: string, action: () => Promise<void>) {
setBusyUserId(userId);
try {
await action();
} finally {
setBusyUserId(null);
}
}
async function grant(request: CollabControlRequest) {
await run(request.userId, async () => {
await onControl(request.userId);
});
}
return (
<aside
id="collab-members-sidebar"
aria-label={t("collab.membersPanel")}
className="absolute inset-y-0 right-0 z-30 flex h-full w-[min(20rem,100%)] shrink-0 flex-col border-l border-border bg-background shadow-xl lg:static lg:z-auto lg:shadow-none"
>
<header className="flex h-11 items-center gap-2 border-b border-border px-3">
<Users className="size-4 text-muted-foreground" />
<h2 className="flex-1 text-sm font-semibold">
{t("collab.membersWithCount", { count: detail.members.length })}
</h2>
{detail.isHost && (
<Button size="sm" variant="outline" onClick={onInvite}>
{t("collab.invite")}
</Button>
)}
<Button
size="icon-sm"
variant="ghost"
aria-label={t("common.close")}
onClick={onClose}
>
<X className="size-4" />
</Button>
</header>
{canManageControl && (
<section className="border-b border-border p-3" aria-live="polite">
<div className="mb-2 flex items-center gap-2">
<Hand className="size-3.5 text-muted-foreground" />
<h3 className="text-xs font-semibold uppercase tracking-wide">
{t("collab.controlQueue")}
</h3>
{detail.controlRequests.length > 0 && (
<Badge variant="default" className="ml-auto text-xs">
{detail.controlRequests.length}
</Badge>
)}
</div>
{detail.controlRequests.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("collab.noControlRequests")}
</p>
) : (
<div className="flex flex-col gap-2">
{detail.controlRequests.map((request) => (
<div
key={request.userId}
className="flex items-center gap-2 border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{request.username}
</p>
<p className="text-xs text-muted-foreground">
{new Date(request.requestedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<Button
size="icon-sm"
aria-label={t("collab.grantControlTo", {
name: request.username,
})}
disabled={busyUserId === request.userId}
onClick={() => void grant(request)}
>
<Check className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="outline"
aria-label={t("collab.dismissControlRequest", {
name: request.username,
})}
disabled={busyUserId === request.userId}
onClick={() =>
void run(request.userId, () =>
onDismissRequest(request.userId),
)
}
>
<X className="size-3.5" />
</Button>
</div>
))}
</div>
)}
</section>
)}
<section className="min-h-0 flex-1 overflow-y-auto p-2">
<h3 className="sr-only">{t("collab.members")}</h3>
<div className="flex flex-col gap-1">
{detail.members.map((member) => {
const isOnline = onlineIds.has(member.userId);
const isPresenter = member.userId === presenterUserId;
const hasControl = member.userId === detail.controllerUserId;
return (
<div
key={member.userId}
className="group flex min-h-11 items-center gap-2 border border-transparent px-2 py-1.5 hover:border-border hover:bg-muted/40"
>
<span
className={`size-2 shrink-0 rounded-full ${
isOnline ? "bg-green-500" : "bg-muted-foreground/30"
}`}
aria-label={t(isOnline ? "collab.online" : "collab.offline")}
role="img"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{member.username}
{member.userId === detail.me && (
<span className="ml-1 text-muted-foreground">
{t("collab.you")}
</span>
)}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{member.roomRole === "host" && (
<span className="flex items-center gap-1">
<Crown className="size-3" /> {t("collab.hostBadge")}
</span>
)}
{isPresenter && (
<span className="flex items-center gap-1">
<MonitorUp className="size-3" />
{t("collab.presenterBadge")}
</span>
)}
{hasControl && (
<span className="flex items-center gap-1">
<MousePointerClick className="size-3" />
{t("collab.controlBadge")}
</span>
)}
</div>
</div>
{canControl && !isPresenter && (
<Button
size="icon-sm"
variant={hasControl ? "default" : "ghost"}
aria-label={t(
hasControl
? "collab.revokeControl"
: "collab.grantControlTo",
{ name: member.username },
)}
disabled={busyUserId === member.userId}
onClick={() =>
void run(member.userId, () =>
onControl(hasControl ? null : member.userId),
)
}
>
<MousePointerClick className="size-3.5" />
</Button>
)}
{detail.isHost && member.userId !== detail.room.ownerUserId && (
<Button
size="icon-sm"
variant="ghost"
aria-label={t("collab.removeMember", {
name: member.username,
})}
onClick={() =>
setRemoving({
userId: member.userId,
username: member.username,
})
}
>
<UserMinus className="size-3.5" />
</Button>
)}
</div>
);
})}
</div>
</section>
<AlertDialog
open={Boolean(removing)}
onOpenChange={(open) => {
if (!open) setRemoving(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("collab.removeMemberTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("collab.removeMemberDescription", {
name: removing?.username,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (removing) void onRemoveMember(removing.userId);
}}
>
{t("collab.deleteMember")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</aside>
);
}
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@termix/plugin-sdk/ui";
import { Badge } from "@termix/plugin-sdk/ui";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@termix/plugin-sdk/ui";
import { createCollabRoom, listCollabRooms, type CollabRoom } from "./api";
import { getErrorMessage } from "./shared";
export function CollabPanel({
onOpenRoom,
}: {
onOpenRoom: (room: CollabRoom) => void;
}) {
const { t } = useTranslation();
const [rooms, setRooms] = useState<CollabRoom[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [persistent, setPersistent] = useState(false);
const [creating, setCreating] = useState(false);
const refresh = useCallback(async () => {
try {
const result = await listCollabRooms();
setRooms(result.rooms ?? []);
} catch {
/* the list stays as-is */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = window.setInterval(() => void refresh(), 30000);
return () => window.clearInterval(timer);
}, [refresh]);
async function handleCreate() {
if (!name.trim()) return;
setCreating(true);
try {
const { room } = await createCollabRoom(name.trim(), persistent);
toast.success(t("collab.created"));
setCreateOpen(false);
setName("");
setPersistent(false);
await refresh();
onOpenRoom(room);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreating(false);
}
}
return (
<div className="flex flex-col gap-2 p-2">
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-8 text-xs flex-1 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand font-semibold hover:bg-accent-brand/20"
onClick={() => setCreateOpen(true)}
>
<Plus className="size-3.5 mr-1" />
{t("collab.createRoom")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => void refresh()}
aria-label={t("common.refresh")}
title={t("common.refresh")}
>
<RefreshCw className="size-3.5" />
</Button>
</div>
{loading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : rooms.length === 0 ? (
<p className="text-xs text-muted-foreground px-1 py-4 text-center">
{t("collab.noRooms")}
</p>
) : (
<div className="flex flex-col gap-1">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => onOpenRoom(room)}
className="flex items-center gap-2 px-2 py-1.5 text-left border border-border hover:bg-muted/50"
>
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 text-xs truncate">{room.name}</span>
{room.presenterUserId && (
<span
className="size-1.5 rounded-full bg-red-500 shrink-0"
role="img"
aria-label={t("collab.presenterBadge")}
title={t("collab.presenterBadge")}
/>
)}
{room.persistent && (
<Badge variant="outline" className="text-[9px] px-1 py-0">
{t("collab.persistentRoom")}
</Badge>
)}
</button>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label htmlFor="collab-room-name" className="text-xs font-medium">
{t("collab.roomName")}
</label>
<Input
id="collab-room-name"
placeholder={t("collab.roomName")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
/>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={persistent}
onChange={(e) => setPersistent(e.target.checked)}
/>
<span>
<span className="font-medium">
{t("collab.persistentRoom")}
</span>
<br />
<span className="text-muted-foreground">
{t("collab.persistentRoomHint")}
</span>
</span>
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateOpen(false)}
disabled={creating}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleCreate()}
disabled={creating || !name.trim()}
>
{creating && <Loader2 className="size-3.5 mr-1 animate-spin" />}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,429 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@termix/plugin-sdk/ui";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@termix/plugin-sdk/ui";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import {
createSessionShare,
getActiveSessionShares,
revokeSessionShare,
type SessionShareProtocol,
type SessionSharePermissionLevel,
type SessionShareRecord,
getDirectory,
} from "./api";
const EXPIRY_PRESETS = [
{ key: "oneHour", hours: 1 },
{ key: "oneDay", hours: 24 },
{ key: "sevenDays", hours: 24 * 7 },
{ key: "thirtyDays", hours: 24 * 30 },
{ key: "custom", hours: undefined },
] as const;
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
export function ShareSessionModal({
open,
onClose,
hostId,
sessionId,
protocol,
tabInstanceId,
}: {
open: boolean;
onClose: () => void;
hostId: number;
sessionId: string | null;
protocol: SessionShareProtocol;
tabInstanceId?: string;
}) {
const { t } = useTranslation();
const [mode, setMode] = useState<"link" | "user">("link");
const [permissionLevel, setPermissionLevel] =
useState<SessionSharePermissionLevel>("read-only");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("oneDay");
const [customHours, setCustomHours] = useState("");
const [search, setSearch] = useState("");
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [createdLink, setCreatedLink] = useState<string | null>(null);
const [shares, setShares] = useState<SessionShareRecord[]>([]);
const [sharesLoaded, setSharesLoaded] = useState(false);
useEffect(() => {
if (!open) return;
setMode("link");
setPermissionLevel("read-only");
setExpiryPreset("oneDay");
setCustomHours("");
setSearch("");
setSelectedUserId(null);
setCreatedLink(null);
setSharesLoaded(false);
setShares([]);
}, [open, sessionId]);
useEffect(() => {
if (!open || sharesLoaded) return;
setSharesLoaded(true);
Promise.all([
getDirectory().catch(() => ({ users: [] })),
getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
]).then(([usersRes, sharesRes]) => {
setUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.id),
username: u.username,
})),
);
setShares(sharesRes.shares ?? []);
});
}, [open, hostId, sharesLoaded]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? users.filter((u) => u.username.toLowerCase().includes(q))
: users;
}, [users, search]);
const expiryHours = (() => {
if (expiryPreset === "custom") {
const hours = Number(customHours);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
}
return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours;
})();
async function refreshShares() {
try {
const res = await getActiveSessionShares(hostId);
setShares(res.shares ?? []);
} catch {
// silently ignore
}
}
async function handleCreate() {
if (!sessionId) return;
if (mode === "user" && !selectedUserId) return;
if (expiryPreset === "custom" && !expiryHours) return;
setSubmitting(true);
try {
const result = await createSessionShare({
hostId,
sessionId,
tabInstanceId,
protocol,
shareType: mode,
targetUserId:
mode === "user" ? (selectedUserId ?? undefined) : undefined,
permissionLevel,
expiryHours,
});
if (mode === "link" && result.linkToken) {
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
setCreatedLink(url);
toast.success(t("sessionSharing.linkCreated"));
} else {
toast.success(t("sessionSharing.shareCreated"));
setSelectedUserId(null);
}
await refreshShares();
} catch (error) {
const status = (error as { status?: number })?.status;
if (mode === "user" && status === 403) {
toast.error(t("sessionSharing.userLacksHostAccess"));
} else {
toast.error(t("sessionSharing.shareFailed"));
}
} finally {
setSubmitting(false);
}
}
async function handleCopyLink() {
if (!createdLink) return;
try {
await navigator.clipboard.writeText(createdLink);
toast.success(t("sessionSharing.linkCopied"));
} catch {
// clipboard API unavailable, ignore
}
}
async function handleRevoke(shareId: string) {
try {
await revokeSessionShare(shareId);
setShares((prev) => prev.filter((s) => s.id !== shareId));
toast.success(t("sessionSharing.revoked"));
} catch {
toast.error(t("sessionSharing.revokeFailed"));
}
}
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("sessionSharing.modalTitle")}</DialogTitle>
<DialogDescription>
{mode === "link"
? t("sessionSharing.linkModeDescription")
: t("sessionSharing.userModeDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex gap-1.5">
{(["link", "user"] as const).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
setCreatedLink(null);
}}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "link" ? (
<Link2 className="size-3 shrink-0" />
) : (
<User className="size-3 shrink-0" />
)}
{m === "link"
? t("sessionSharing.modeTab.link")
: t("sessionSharing.modeTab.user")}
</button>
))}
</div>
{mode === "user" && (
<>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("sessionSharing.searchUsersPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noUsersFound")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserId === user.id;
return (
<button
key={user.id}
onClick={() => setSelectedUserId(user.id)}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
)}
</div>
</>
)}
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("sessionSharing.permissionLevel.label")}
</span>
<select
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(
e.target.value as SessionSharePermissionLevel,
)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
<option value="read-only">
{t("sessionSharing.permissionLevel.readOnly")}
</option>
<option value="read-write">
{t("sessionSharing.permissionLevel.readWrite")}
</option>
</select>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("sessionSharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("sessionSharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
<p className="text-[11px] text-muted-foreground leading-snug">
{permissionLevel === "read-only"
? t("sessionSharing.permissionLevel.readOnlyDescription")
: t("sessionSharing.permissionLevel.readWriteDescription")}
</p>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
!sessionId ||
submitting ||
(mode === "user" && !selectedUserId) ||
(expiryPreset === "custom" && !expiryHours)
}
onClick={handleCreate}
>
{mode === "link"
? t("sessionSharing.createLinkButton")
: t("sessionSharing.createShareButton")}
</Button>
{createdLink && (
<div className="flex items-center gap-1.5">
<Input readOnly value={createdLink} className="text-xs" />
<Button
variant="outline"
size="icon-sm"
onClick={handleCopyLink}
title={t("sessionSharing.copyLink")}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="flex flex-col gap-0 border-t border-border pt-2">
<div className="flex items-center gap-1.5 pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("sessionSharing.activeShares")}
{shares.length > 0 && (
<span className="text-muted-foreground/40">
({shares.length})
</span>
)}
</div>
<div className="flex flex-col max-h-40 overflow-y-auto">
{shares.length === 0 && (
<div className="px-1 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noActiveShares")}
</div>
)}
{shares.map((share) => {
const targetUser = users.find(
(u) => u.id === share.targetUserId,
);
return (
<div
key={share.id}
className="flex items-center justify-between gap-2 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center gap-1.5 min-w-0">
{share.shareType === "link" ? (
<Link2 className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="truncate font-semibold">
{share.shareType === "link"
? t("sessionSharing.linkShareBadge")
: t("sessionSharing.userShareBadge", {
username:
targetUser?.username ??
share.targetUserId ??
"?",
})}
</span>
<span className="text-[10px] text-muted-foreground/60">
{share.permissionLevel === "read-write"
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
{" · "}
{t("sessionSharing.expiresAt", {
date: new Date(share.expiresAt).toLocaleString(),
})}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
onClick={() => handleRevoke(share.id)}
>
{t("sessionSharing.revoke")}
</Button>
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,355 @@
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { AlertCircle, Eye, Users } from "lucide-react";
import {
resolveShareLink,
type ResolvedShareLink,
type ShareLinkErrorKind,
} from "./api";
import { Loader, RemoteDisplay, wsUrlForPath } from "./shared";
const PING_INTERVAL_MS = 30000;
interface TerminalWsMessage {
type: string;
data?: string;
[key: string]: unknown;
}
// A shared session link always resolves against the desktop app's embedded
// local backend: joining a session on someone else's remote server is not
// supported from the desktop app.
async function resolveTerminalWsUrl(wsPath: string): Promise<string> {
const url = await wsUrlForPath(wsPath);
if (!url) throw new Error("No terminal endpoint is available");
return url;
}
export interface SessionParticipantInfo {
isOwner: boolean;
permissionLevel: "read-write" | "read-only";
label: string | null;
}
function ParticipantsBadge({
participants,
ownerLabel,
}: {
participants: SessionParticipantInfo[];
ownerLabel: string;
}) {
if (participants.length < 2) return null;
const names = participants
.map((participant) =>
participant.isOwner ? ownerLabel : (participant.label ?? "?"),
)
.join(", ");
return (
<div
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
title={names}
>
<Users className="size-3.5" />
{participants.length}
</div>
);
}
function ReadOnlyBadge({ label }: { label: string }) {
return (
<div
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
>
<Eye className="size-3.5" />
{label}
</div>
);
}
function CenteredMessage({
icon,
message,
}: {
icon: React.ReactNode;
message: string;
}) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4 w-full"
style={{ backgroundColor: "var(--bg-base)" }}
>
{icon}
<p
className="text-sm font-semibold text-center max-w-xs"
style={{ color: "var(--foreground)" }}
>
{message}
</p>
</div>
);
}
export function GuestTerminalView({
share,
wsPath,
hideBadges = false,
onParticipantsChange,
}: {
share: Pick<ResolvedShareLink, "permissionLevel">;
/** The socket path the backend handed out, query string included. */
wsPath: string;
/** Suppress the built-in overlay badges when the host page renders its own. */
hideBadges?: boolean;
onParticipantsChange?: (participants: SessionParticipantInfo[]) => void;
}) {
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const [ended, setEnded] = useState<string | null>(null);
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
[],
);
const wsRef = useRef<WebSocket | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onParticipantsChangeRef = useRef(onParticipantsChange);
onParticipantsChangeRef.current = onParticipantsChange;
useEffect(() => {
if (!terminal || !xtermRef.current) return;
terminal.options.theme = { background: "#0c0d0b" };
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(xtermRef.current);
fitAddon.fit();
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(xtermRef.current);
let cancelled = false;
let ws: WebSocket | null = null;
resolveTerminalWsUrl(wsPath).then((url) => {
if (cancelled) return;
ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
pingIntervalRef.current = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, PING_INTERVAL_MS);
};
ws.onmessage = (event) => {
let msg: TerminalWsMessage;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
switch (msg.type) {
case "data":
if (typeof msg.data === "string") terminal.write(msg.data);
break;
case "participants":
if (Array.isArray(msg.participants)) {
const next = msg.participants as SessionParticipantInfo[];
setParticipants(next);
onParticipantsChangeRef.current?.(next);
}
break;
case "sessionExpired":
case "sessionTerminatedByOwner":
case "session_ended":
setEnded(t("sessionSharing.guestView.sessionEnded"));
break;
default:
break;
}
};
ws.onclose = () => {
setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded"));
};
if (share.permissionLevel === "read-write") {
terminal.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
}
});
return () => {
cancelled = true;
resizeObserver.disconnect();
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
ws?.close();
wsRef.current = null;
};
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, wsPath]);
return (
<div className="relative w-full h-full">
{!hideBadges && (
<>
<ParticipantsBadge
participants={participants}
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
/>
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge
label={t("sessionSharing.guestView.readOnlyBadge")}
/>
)}
</>
)}
{ended && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={ended}
/>
</div>
)}
<div ref={xtermRef} className="w-full h-full" />
</div>
);
}
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
const { t } = useTranslation();
const [connectionError, setConnectionError] = useState<string | null>(null);
if (!share.connectParams?.token) {
return (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={t("sessionSharing.guestView.linkInvalid")}
/>
);
}
return (
<div className="relative w-full h-full">
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
)}
{connectionError && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={connectionError}
/>
</div>
)}
<RemoteDisplay
token={share.connectParams.token}
protocol={share.protocol}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
</div>
);
}
export default function SharedSessionView() {
const { t } = useTranslation();
const [share, setShare] = useState<ResolvedShareLink | null>(null);
const [linkToken, setLinkToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (!token) {
setError(t("sessionSharing.guestView.linkInvalid"));
setLoading(false);
return;
}
setLinkToken(token);
resolveShareLink(token)
.then((resolved) => setShare(resolved))
.catch((err) => {
const kind = (err as { kind?: ShareLinkErrorKind })?.kind;
if (kind === "rate-limited") {
setError(t("sessionSharing.guestView.rateLimited"));
} else {
setError(t("sessionSharing.guestView.linkInvalid"));
}
})
.finally(() => setLoading(false));
}, [t]);
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div className="relative flex-1 min-h-0">
{loading && <Loader message={t("sessionSharing.guestView.loading")} />}
{!loading && error && (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={error}
/>
)}
{!loading &&
!error &&
share &&
linkToken &&
(share.protocol === "ssh" ? (
<GuestTerminalView share={share} wsPath={share.wsPath} />
) : (
<GuestGuacamoleView share={share} />
))}
</div>
</div>
);
}
+410
View File
@@ -0,0 +1,410 @@
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;
export function bindApi(api: PluginApiClient | null): void {
client = api;
}
function api(): PluginApiClient {
if (!client) throw new Error("Session sharing is not running");
return client;
}
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<T>(
request: () => Promise<{ data: T }>,
fallback: string,
): Promise<T> {
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<ResolvedShareLink> {
try {
const response = await api().get<ResolvedShareLink>(
`/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,
): Promise<CreateSessionShareResponse> {
return call(
() => api().post<CreateSessionShareResponse>("/create", request),
"Failed to create session share",
);
}
export function getActiveSessionShares(
hostId: number,
): Promise<{ shares: SessionShareRecord[] }> {
return call(
() => api().get<{ shares: SessionShareRecord[] }>(`/host/${hostId}/active`),
"Failed to fetch active session shares",
);
}
export function revokeSessionShare(shareId: string): Promise<unknown> {
return call(
() => api().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(): Promise<{
users: DirectoryUser[];
roles: DirectoryRole[];
}> {
return call(
() =>
api().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<unknown[]> {
return call(
() => api().get<unknown[]>("/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/<id>/<path>. */
eventsWsPath: string;
}
const room = (roomId: string, suffix = "") =>
`/rooms/${encodeURIComponent(roomId)}${suffix}`;
export function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
return call(
() => api().get<{ rooms: CollabRoom[] }>("/rooms"),
"Failed to list rooms",
);
}
export function createCollabRoom(
name: string,
persistent: boolean,
): Promise<{ room: CollabRoom }> {
return call(
() => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }),
"Failed to create room",
);
}
export function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
return call(
() => api().get<CollabRoomDetail>(room(roomId)),
"Failed to get room",
);
}
export async function inviteCollabMembers(
roomId: string,
targets: { userIds?: string[]; roleIds?: number[] },
): Promise<void> {
await call(
() => api().post(room(roomId, "/members"), targets),
"Failed to invite members",
);
}
export async function removeCollabMember(
roomId: string,
userId: string,
): Promise<void> {
await call(
() => api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)),
"Failed to remove member",
);
}
export function presentCollabStage(
roomId: string,
input: { protocol: string; sessionId: string; hostId: number },
): Promise<{ stage: CollabStage }> {
return call(
() => api().post<{ stage: CollabStage }>(room(roomId, "/present"), input),
"Failed to start presenting",
);
}
export async function stopCollabStage(roomId: string): Promise<void> {
await call(
() => api().post(room(roomId, "/stop")),
"Failed to stop presenting",
);
}
export function getCollabStage(
roomId: string,
): Promise<{ stage: CollabStage | null }> {
return call(
() => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")),
"Failed to resolve stage",
);
}
export async function setCollabStageControl(
roomId: string,
userId: string | null,
): Promise<void> {
await call(
() => api().post(room(roomId, "/control"), { userId }),
"Failed to change stage control",
);
}
export function requestCollabStageControl(
roomId: string,
): Promise<{ request: CollabControlRequest }> {
return call(
() =>
api().post<{ request: CollabControlRequest }>(
room(roomId, "/control/request"),
),
"Failed to request control",
);
}
export function listCollabControlRequests(
roomId: string,
): Promise<{ requests: CollabControlRequest[] }> {
return call(
() =>
api().get<{ requests: CollabControlRequest[] }>(
room(roomId, "/control/requests"),
),
"Failed to list control requests",
);
}
export async function dismissCollabControlRequest(
roomId: string,
userId: string,
): Promise<void> {
await call(
() =>
api().delete(
room(roomId, `/control/requests/${encodeURIComponent(userId)}`),
),
"Failed to dismiss control request",
);
}
export async function endCollabRoom(roomId: string): Promise<void> {
await call(() => api().post(room(roomId, "/end")), "Failed to end room");
}
export async function deleteCollabRoom(roomId: string): Promise<void> {
await call(() => api().delete(room(roomId)), "Failed to delete room");
}
export function setCollabGuestLink(
roomId: string,
enabled: boolean,
): Promise<{ guestLinkToken: string | null }> {
return call(
() =>
api().post<{ guestLinkToken: string | null }>(
room(roomId, "/guest-link"),
{ enabled },
),
"Failed to update guest link",
);
}
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;
}
@@ -0,0 +1,233 @@
import { useEffect, type ComponentType } from "react";
import { Presentation, Share2 } from "lucide-react";
import { toast } from "sonner";
import {
useCurrentUser,
usePermission,
type PanelProps,
type StandaloneViewProps,
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { bindApi, getSharedWithMe, listCollabRooms } from "./api";
import CollabGuestView from "./CollabGuestView";
import { CollabPanel } from "./CollabPanel";
import { CollabRoomTab } from "./CollabRoomTab";
import SharedSessionView from "./SharedSessionView";
import {
ShareDialogHost,
closeShareDialog,
openShareDialog,
type ShareTarget,
} from "./share-dialog";
import { REMOTE_DISPLAY_SLOT } from "./shared";
const COLLAB = "collab";
const SHARE_ACTION = "session-sharing.share";
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
const INVITE_POLL_MS = 60_000;
/** `?view=shared` and `?view=collab-guest`, the two anonymous guest links. */
function GuestView({ view }: StandaloneViewProps) {
return view === "shared" ? <SharedSessionView /> : <CollabGuestView />;
}
function CollabRoomTabView({ tab, isVisible }: TabProps) {
const roomId = tab.data?.roomId;
return (
<CollabRoomTab
roomId={typeof roomId === "string" ? roomId : undefined}
isVisible={isVisible}
/>
);
}
function CollabPanelView({ shell }: PanelProps) {
return (
<CollabPanel
onOpenRoom={(room) =>
shell.openTab(null, COLLAB, {
label: room.name,
forceNewTab: true,
data: { roomId: room.id },
})
}
/>
);
}
/**
* Rooms are discovered by polling, so a room this browser has never shown
* gets one toast with an Open action. The first poll after login only
* records what already exists.
*/
function createInviteWatcher(app: TermixApp): ComponentType {
return function InviteWatcher() {
const user = useCurrentUser();
const allowed = usePermission("use");
const userId = user?.userId;
useEffect(() => {
if (!userId || !allowed) return;
let cancelled = false;
const check = async () => {
try {
const { rooms = [] } = await listCollabRooms();
if (cancelled) return;
let seen: string[] = [];
try {
seen = JSON.parse(localStorage.getItem(SEEN_ROOMS_KEY) ?? "[]");
} catch {
seen = [];
}
const seenSet = new Set(seen);
const fresh = rooms.filter((room) => !seenSet.has(room.id));
if (fresh.length === 0) return;
localStorage.setItem(
SEEN_ROOMS_KEY,
JSON.stringify([...seenSet, ...fresh.map((room) => room.id)]),
);
if (seen.length === 0) return;
for (const room of fresh) {
if (room.ownerUserId === userId) continue;
toast(app.t("collab.invitedTo", { name: room.name }), {
action: {
label: app.t("collab.openRoom"),
onClick: () => app.tabs.openRailView(COLLAB),
},
});
}
} catch {
/* next poll */
}
};
void check();
const timer = setInterval(() => void check(), INVITE_POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [userId, allowed]);
return null;
};
}
/** A terminal toolbar action is invoked with the terminal's slot API. */
interface TerminalSlotApi {
getShareTarget?: () => ShareTarget | null;
}
/** A remote desktop toolbar action is invoked with the session it shows. */
interface RemoteDesktopToolbarContext {
hostId: number;
sessionId: string | null;
protocol: "rdp" | "vnc" | "telnet";
tabInstanceId?: string;
}
function isRemoteDesktopContext(
value: unknown,
): value is RemoteDesktopToolbarContext {
return (
!!value &&
typeof value === "object" &&
"protocol" in value &&
"hostId" in value
);
}
export function activate(app: TermixApp): void {
bindApi(app.api);
app.onDispose(() => {
closeShareDialog();
bindApi(null);
});
// Rooms and share links draw remote desktop streams through this slot.
app.declareActionSlot({ id: REMOTE_DISPLAY_SLOT, accepts: ["component"] });
// Guest pages open the tab type's standalone view by ?view= name.
const guestViews = {
standalone: GuestView,
standaloneViews: ["shared", "collab-guest"],
};
if (app.guest) {
app.registerTab(COLLAB, () => null, { hostless: true, ...guestViews });
return;
}
app.registerRailItem({
id: COLLAB,
icon: Presentation,
titleKey: "nav.collab",
kind: "panel",
after: "connections",
separatorAfter: true,
permission: "use",
});
app.registerPanel(COLLAB, CollabPanelView);
app.registerTab(COLLAB, CollabRoomTabView, {
icon: Presentation,
titleKey: "nav.collab",
hostless: true,
multiInstance: true,
...guestViews,
});
app.registerAction(
SHARE_ACTION,
(context: unknown) => {
let target: ShareTarget | null = null;
if (isRemoteDesktopContext(context)) {
target = context.sessionId
? {
hostId: context.hostId,
sessionId: context.sessionId,
protocol: context.protocol,
tabInstanceId: context.tabInstanceId,
}
: null;
} else {
target = (context as TerminalSlotApi)?.getShareTarget?.() ?? null;
}
if (target) openShareDialog(target);
else toast.error(app.t("sessionSharing.notReadyToShare"));
},
{ permission: "use" },
);
for (const slot of ["terminal.toolbar", "remote-desktop.toolbar"]) {
app.registerSlotContribution(slot, {
actionId: SHARE_ACTION,
titleKey: "sessionSharing.shareButton",
icon: Share2,
kind: "button",
});
}
app.registerSlotContribution("shell.overlay", {
actionId: "session-sharing.shareDialog",
titleKey: "sessionSharing.shareButton",
kind: "component",
component: ShareDialogHost as ComponentType<Record<string, unknown>>,
});
app.registerSlotContribution("shell.overlay", {
actionId: "session-sharing.inviteWatcher",
titleKey: "nav.collab",
kind: "component",
component: createInviteWatcher(app) as ComponentType<
Record<string, unknown>
>,
});
// Core's active connections list asks for sessions shared with the user.
app.registerAction(SHARED_WITH_ME_ACTION, () => getSharedWithMe(), {
permission: "use",
});
}
export function deactivate(): void {
closeShareDialog();
}
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from "react";
import { ShareSessionModal } from "./ShareSessionModal";
import type { SessionShareProtocol } from "./api";
/** What the share dialog needs to share one live session. */
export interface ShareTarget {
hostId: number;
sessionId: string;
protocol: SessionShareProtocol;
tabInstanceId?: string;
}
let current: ShareTarget | null = null;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
/** Opens the share dialog, which the shell overlay slot keeps mounted. */
export function openShareDialog(target: ShareTarget): void {
current = target;
emit();
}
export function closeShareDialog(): void {
current = null;
emit();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Rendered once in "shell.overlay"; shows the dialog when a toolbar asks. */
export function ShareDialogHost() {
const target = useSyncExternalStore(subscribe, () => current);
if (!target) return null;
return (
<ShareSessionModal
open
onClose={closeShareDialog}
hostId={target.hostId}
sessionId={target.sessionId}
protocol={target.protocol}
tabInstanceId={target.tabInstanceId}
/>
);
}
@@ -0,0 +1,90 @@
import { useMemo } from "react";
import {
invokeAction,
useSlotContributions,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { pluginWsUrl } from "@termix/plugin-sdk/ui";
/**
* A remote desktop stream drawn by whichever plugin provides one. Rooms and
* share links show RDP, VNC and Telnet without knowing how they are drawn:
* the provider contributes a component to this slot and answers the token
* action below.
*/
export const REMOTE_DISPLAY_SLOT = "session.remoteDisplay";
export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
export interface RemoteDisplayProps {
token: string;
protocol: string;
isVisible: boolean;
onConnect?: () => void;
onError?: (error: string) => void;
}
export function RemoteDisplay(props: RemoteDisplayProps) {
const { t } = useTranslation();
const contributions = useSlotContributions(REMOTE_DISPLAY_SLOT);
const provider = useMemo(
() =>
contributions.find(
(contribution) =>
contribution.kind === "component" && contribution.component,
),
[contributions],
);
if (!provider?.component) {
return (
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
{t("sessionSharing.remoteDisplayUnavailable")}
</div>
);
}
const Display = provider.component;
return <Display {...(props as unknown as Record<string, unknown>)} />;
}
export interface RemoteSessionToken {
token: string;
connectionId: string;
}
/** Mints a session token for presenting a host, through the provider. */
export async function createRemoteSessionToken(
hostId: number,
origin: unknown,
protocol: string,
): Promise<RemoteSessionToken | null> {
return ((await invokeAction(
REMOTE_SESSION_TOKEN_ACTION,
hostId,
origin,
protocol,
)) ?? null) as RemoteSessionToken | null;
}
/** A socket URL for a /plugin-ws/<id>/<path>?query path the server handed out. */
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
const [pathname, query] = wsPath.split("?", 2);
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
if (!match) return null;
const target = await pluginWsUrl(match[1], match[2]);
if (!target) return null;
if (!query) return target.url;
return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return typeof error === "string" ? error : String(error);
}
export function Loader({ message }: { message: string }) {
return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-background">
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}