feat: add multiplayer/shared sessions for terminal and guacd

This commit is contained in:
LukeGus
2026-07-20 22:18:54 -05:00
parent 36ab7e4872
commit f44d09eef7
50 changed files with 5089 additions and 102 deletions
@@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR =
path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = {
guacamoleConnectionId?: string;
connectionSettings?: {
connection?: { type?: string };
connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata;
termixMeta?: {
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: string;
};
};
};
export interface GuacSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
// primary connection's guacd handshake completes.
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
const pendingConnectResolvers = new Map<
string,
(info: GuacSessionInfo | null) => void
>();
export function waitForGuacdOpen(
termixConnectId: string,
timeoutMs = 10000,
): Promise<GuacSessionInfo | null> {
const existing = guacSessionByConnectId.get(termixConnectId);
if (existing) return Promise.resolve(existing);
return new Promise((resolve) => {
let settled = false;
const finish = (info: GuacSessionInfo | null) => {
if (settled) return;
settled = true;
pendingConnectResolvers.delete(termixConnectId);
resolve(info);
};
pendingConnectResolvers.set(termixConnectId, finish);
setTimeout(() => finish(null), timeoutMs);
});
}
export function getGuacSessionInfo(
guacamoleConnectionId: string,
): GuacSessionInfo | null {
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
}
async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection,
): Promise<void> {
@@ -149,6 +201,25 @@ function createGuacServer(): GuacamoleLite {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
if (!isJoin && termixMeta && guacamoleConnectionId) {
const info: GuacSessionInfo = {
guacamoleConnectionId,
hostId: termixMeta.hostId,
ownerUserId: termixMeta.ownerUserId,
protocol: termixMeta.protocol,
openedAt: Date.now(),
};
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
const resolver = pendingConnectResolvers.get(termixMeta.termixConnectId);
if (resolver) resolver(info);
}
});
server.on("close", (clientConnection: GuacamoleClientConnection) => {
@@ -156,6 +227,15 @@ function createGuacServer(): GuacamoleLite {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type,
});
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
if (!isJoin && termixMeta && guacamoleConnectionId) {
guacSessionByConnectId.delete(termixMeta.termixConnectId);
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
}
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",