mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix(session-sharing): use the linked server for desktop meetings and links (#1507)
This commit is contained in:
1 parent
9801cc7fc7
commit
ec7c2c2835
15 files changed
+621
-210
No files matched your search
@@ -118,12 +118,14 @@ function RemoteDesktopStandalone({ hostId, view }: StandaloneViewProps) {
|
||||
/** What collab rooms and shared-session links draw a stream with. */
|
||||
function RemoteDisplay({
|
||||
token,
|
||||
connectionOrigin,
|
||||
protocol,
|
||||
isVisible,
|
||||
onConnect,
|
||||
onError,
|
||||
}: {
|
||||
token: string;
|
||||
connectionOrigin?: "local" | "remote";
|
||||
protocol: Protocol;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
@@ -131,7 +133,7 @@ function RemoteDisplay({
|
||||
}) {
|
||||
return (
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{ token, protocol, type: protocol }}
|
||||
connectionConfig={{ token, protocol, type: protocol, connectionOrigin }}
|
||||
isVisible={isVisible}
|
||||
onConnect={onConnect}
|
||||
onError={onError}
|
||||
|
||||
@@ -132,7 +132,10 @@
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting"
|
||||
"roomTab": "Meeting",
|
||||
"serverChanged": "The meeting server changed. Reopen this meeting from the Meetings panel.",
|
||||
"hostNotSynced": "Sync this host to the meeting server before presenting.",
|
||||
"linkRequiresServer": "Link this desktop to a Termix server and create the meeting there to share a web link."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings"
|
||||
|
||||
@@ -520,6 +520,17 @@ export function createDirectory(
|
||||
return !!rows[0]?.isAdmin;
|
||||
},
|
||||
|
||||
async hostIdsBySyncId(syncId: string): Promise<number[]> {
|
||||
const { hosts } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.syncId, syncId));
|
||||
return rows.map((row: { id: number }) => row.id);
|
||||
},
|
||||
|
||||
async hostExists(hostId: number): Promise<boolean> {
|
||||
const { hosts } = await refs();
|
||||
const rows = await (
|
||||
|
||||
@@ -200,6 +200,32 @@ export function registerRoomRoutes(
|
||||
} = actions;
|
||||
const requireUse = ctx.rbac.require("use") as never;
|
||||
|
||||
router.get(
|
||||
"/meeting-host/:syncId",
|
||||
requireUse,
|
||||
async (req: Request, res: Response) => {
|
||||
if (!ctx.currentActor())
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
try {
|
||||
for (const id of await directory.hostIdsBySyncId(
|
||||
String(req.params.syncId),
|
||||
)) {
|
||||
if ((await ctx.hosts.checkAccess(id, "connect")).hasAccess) {
|
||||
return res.json({ id });
|
||||
}
|
||||
}
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"The host must be synced to the meeting server before presenting",
|
||||
});
|
||||
} catch {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to resolve meeting host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const audit = (
|
||||
req: Request,
|
||||
action: string,
|
||||
|
||||
@@ -12,15 +12,19 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { createCollabRoom, listCollabRooms, type CollabRoom } from "./api";
|
||||
import type { CollabRoom } from "./api";
|
||||
import type { MeetingBackend } from "./meeting-backend";
|
||||
import { getErrorMessage } from "./shared";
|
||||
|
||||
export function CollabPanel({
|
||||
onOpenRoom,
|
||||
backend,
|
||||
}: {
|
||||
onOpenRoom: (room: CollabRoom) => void;
|
||||
backend: MeetingBackend;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { createCollabRoom, listCollabRooms } = backend.api;
|
||||
const [rooms, setRooms] = useState<CollabRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -37,7 +41,7 @@ export function CollabPanel({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [backend]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
||||
@@ -34,36 +34,16 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
Input,
|
||||
PluginComponent,
|
||||
resolveConnectionOrigin,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { Input, PluginComponent } from "@termix/plugin-sdk/ui";
|
||||
import { CollabMembersSidebar } from "./CollabMembersSidebar";
|
||||
import {
|
||||
RemoteDisplay,
|
||||
createRemoteSessionToken,
|
||||
getErrorMessage,
|
||||
wsUrlForPath,
|
||||
wsTargetForPath,
|
||||
} from "./shared";
|
||||
import {
|
||||
deleteCollabRoom,
|
||||
endCollabRoom,
|
||||
dismissCollabControlRequest,
|
||||
getCollabRoom,
|
||||
getCollabStage,
|
||||
inviteCollabMembers,
|
||||
presentCollabStage,
|
||||
requestCollabStageControl,
|
||||
removeCollabMember,
|
||||
setCollabGuestLink,
|
||||
setCollabStageControl,
|
||||
stopCollabStage,
|
||||
getDirectory,
|
||||
type CollabRoomDetail,
|
||||
type CollabStage,
|
||||
type DirectoryRole,
|
||||
} from "./api";
|
||||
import type { CollabRoomDetail, CollabStage, DirectoryRole } from "./api";
|
||||
import { meetingGuestUrl, type MeetingBackend } from "./meeting-backend";
|
||||
|
||||
type SharedProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
@@ -84,9 +64,10 @@ const POLL_FALLBACK_MS = 15000;
|
||||
|
||||
// The terminal socket the ssh-terminal plugin serves. Authentication rides on
|
||||
// the jwt cookie the way every terminal WS connection does.
|
||||
async function roomEventsWsUrl(roomId: string): Promise<string | null> {
|
||||
const { eventsWsPath } = await getCollabRoom(roomId);
|
||||
return wsUrlForPath(eventsWsPath);
|
||||
async function roomEventsWsUrl(roomId: string, backend: MeetingBackend) {
|
||||
const { eventsWsPath } = await backend.api.getCollabRoom(roomId);
|
||||
await backend.assertCurrent();
|
||||
return wsTargetForPath(eventsWsPath, backend.origin);
|
||||
}
|
||||
|
||||
type PresentDraft =
|
||||
@@ -105,11 +86,28 @@ type PresentChoice = {
|
||||
export function CollabRoomTab({
|
||||
roomId,
|
||||
isVisible,
|
||||
backend,
|
||||
}: {
|
||||
backend: MeetingBackend;
|
||||
roomId?: string;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
deleteCollabRoom,
|
||||
endCollabRoom,
|
||||
dismissCollabControlRequest,
|
||||
getCollabRoom,
|
||||
getCollabStage,
|
||||
inviteCollabMembers,
|
||||
presentCollabStage,
|
||||
requestCollabStageControl,
|
||||
removeCollabMember,
|
||||
setCollabGuestLink,
|
||||
setCollabStageControl,
|
||||
stopCollabStage,
|
||||
getDirectory,
|
||||
} = backend.api;
|
||||
const [detail, setDetail] = useState<CollabRoomDetail | null>(null);
|
||||
const [stage, setStage] = useState<CollabStage | null>(null);
|
||||
const [ended, setEnded] = useState(false);
|
||||
@@ -190,7 +188,7 @@ export function CollabRoomTab({
|
||||
setLoadError(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
}, [roomId]);
|
||||
}, [roomId, backend]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -216,10 +214,10 @@ export function CollabRoomTab({
|
||||
const connect = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const url = await roomEventsWsUrl(roomId);
|
||||
const url = await roomEventsWsUrl(roomId, backend);
|
||||
if (cancelled) return;
|
||||
if (!url) throw new Error("No terminal endpoint is available");
|
||||
ws = new WebSocket(url);
|
||||
ws = new WebSocket(url.url, url.protocols);
|
||||
} catch {
|
||||
startPolling();
|
||||
reconnectTimer = setTimeout(() => void connect(), 5000);
|
||||
@@ -359,19 +357,21 @@ export function CollabRoomTab({
|
||||
if (!roomId) return;
|
||||
setPresentOpen(false);
|
||||
try {
|
||||
await backend.assertCurrent();
|
||||
if (backend.origin === "remote") {
|
||||
if (!host.syncId) throw new Error(t("collab.hostNotSynced"));
|
||||
const resolved = await backend.api.resolveHost(host.syncId);
|
||||
host = { ...host, id: String(resolved.id), connectionOrigin: "remote" };
|
||||
} else {
|
||||
host = { ...host, connectionOrigin: "local" };
|
||||
}
|
||||
if (protocol === "ssh") {
|
||||
setDraft({ protocol, host });
|
||||
return;
|
||||
}
|
||||
const response = await createRemoteSessionToken(
|
||||
Number(host.id),
|
||||
await resolveConnectionOrigin(
|
||||
{
|
||||
connectionOrigin: host.connectionOrigin as
|
||||
"local" | "remote" | undefined,
|
||||
},
|
||||
{ defaultRemote: true },
|
||||
),
|
||||
backend.origin,
|
||||
protocol,
|
||||
);
|
||||
if (!response?.connectionId) {
|
||||
@@ -465,8 +465,16 @@ export function CollabRoomTab({
|
||||
}
|
||||
}
|
||||
|
||||
function guestLinkUrl(token: string) {
|
||||
return `${window.location.origin}${window.location.pathname}?view=collab-guest&token=${token}`;
|
||||
async function copyGuestLink(token: string) {
|
||||
try {
|
||||
await backend.assertCurrent();
|
||||
const url = meetingGuestUrl(backend.publicUrl, token);
|
||||
if (!url) throw new Error(t("collab.linkRequiresServer"));
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success(t("collab.linkCopied"));
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
@@ -648,10 +656,7 @@ export function CollabRoomTab({
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(guestLinkUrl(guestLinkToken))
|
||||
.then(() => toast.success(t("collab.linkCopied")))
|
||||
.catch((error) => toast.error(getErrorMessage(error)));
|
||||
void copyGuestLink(guestLinkToken);
|
||||
}}
|
||||
>
|
||||
{t("collab.copyLink")}
|
||||
@@ -706,6 +711,7 @@ export function CollabRoomTab({
|
||||
/>
|
||||
) : (
|
||||
<RemoteDisplay
|
||||
connectionOrigin={backend.origin}
|
||||
token={draft.token}
|
||||
protocol={draft.protocol}
|
||||
isVisible={isVisible}
|
||||
@@ -734,6 +740,7 @@ export function CollabRoomTab({
|
||||
id="terminal.view"
|
||||
hostConfig={{
|
||||
id: stage.hostId ?? undefined,
|
||||
connectionOrigin: backend.origin,
|
||||
name: detail?.room.name ?? "stage",
|
||||
ip: "",
|
||||
port: 0,
|
||||
@@ -748,6 +755,7 @@ export function CollabRoomTab({
|
||||
/>
|
||||
) : stage.connectParams?.token ? (
|
||||
<RemoteDisplay
|
||||
connectionOrigin={backend.origin}
|
||||
key={stage.connectParams.token}
|
||||
token={stage.connectParams.token}
|
||||
protocol={stage.protocol}
|
||||
|
||||
@@ -249,148 +249,6 @@ export interface CollabRoomDetail {
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useEffect, type ComponentType } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Presentation, Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -11,7 +17,7 @@ import {
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { bindApi, listCollabRooms } from "./api";
|
||||
import { bindApi } from "./api";
|
||||
import { SharedWithMeSection } from "./SharedWithMeSection";
|
||||
import CollabGuestView from "./CollabGuestView";
|
||||
import { CollabPanel } from "./CollabPanel";
|
||||
@@ -26,6 +32,73 @@ import {
|
||||
} from "./share-dialog";
|
||||
import { REMOTE_DISPLAY_SLOT } from "./shared";
|
||||
|
||||
import { connectMeetings, type MeetingBackend } from "./meeting-backend";
|
||||
|
||||
let meetingApp: TermixApp;
|
||||
|
||||
function MeetingScope({
|
||||
children,
|
||||
serverKey,
|
||||
pin = false,
|
||||
}: {
|
||||
children: (backend: MeetingBackend) => ReactNode;
|
||||
serverKey?: string;
|
||||
pin?: boolean;
|
||||
}) {
|
||||
const [backend, setBackend] = useState<MeetingBackend | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pinned = useRef(serverKey);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let sequence = 0;
|
||||
const refresh = async () => {
|
||||
const request = ++sequence;
|
||||
try {
|
||||
const next = await connectMeetings(meetingApp);
|
||||
if (cancelled || request !== sequence) return;
|
||||
if (
|
||||
pin &&
|
||||
pinned.current !== undefined &&
|
||||
pinned.current !== next.key
|
||||
) {
|
||||
setBackend(null);
|
||||
setError(meetingApp.t("collab.serverChanged"));
|
||||
return;
|
||||
}
|
||||
if (pin) pinned.current ??= next.key;
|
||||
setError(null);
|
||||
setBackend((previous) =>
|
||||
previous?.key === next.key ? previous : next,
|
||||
);
|
||||
} catch (failure) {
|
||||
if (!cancelled) {
|
||||
setBackend(null);
|
||||
setError(String(failure));
|
||||
}
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const dispose = meetingApp.desktop.onRemoteServerChange(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
dispose();
|
||||
};
|
||||
}, [serverKey, pin]);
|
||||
if (error)
|
||||
return (
|
||||
<p role="alert" className="p-3 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
return backend ? (
|
||||
<div key={backend.key} className="contents">
|
||||
{children(backend)}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const COLLAB = "collab";
|
||||
const SHARE_ACTION = "session-sharing.share";
|
||||
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
|
||||
@@ -38,25 +111,39 @@ function GuestView({ view }: StandaloneViewProps) {
|
||||
|
||||
function CollabRoomTabView({ tab, isVisible }: TabProps) {
|
||||
const roomId = tab.data?.roomId;
|
||||
const serverKey =
|
||||
typeof tab.data?.meetingServerKey === "string"
|
||||
? tab.data.meetingServerKey
|
||||
: undefined;
|
||||
return (
|
||||
<CollabRoomTab
|
||||
roomId={typeof roomId === "string" ? roomId : undefined}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
<MeetingScope serverKey={serverKey} pin>
|
||||
{(backend) => (
|
||||
<CollabRoomTab
|
||||
roomId={typeof roomId === "string" ? roomId : undefined}
|
||||
isVisible={isVisible}
|
||||
backend={backend}
|
||||
/>
|
||||
)}
|
||||
</MeetingScope>
|
||||
);
|
||||
}
|
||||
|
||||
function CollabPanelView({ shell }: PanelProps) {
|
||||
return (
|
||||
<CollabPanel
|
||||
onOpenRoom={(room) =>
|
||||
shell.openTab(null, COLLAB, {
|
||||
label: room.name,
|
||||
forceNewTab: true,
|
||||
data: { roomId: room.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<MeetingScope>
|
||||
{(backend) => (
|
||||
<CollabPanel
|
||||
backend={backend}
|
||||
onOpenRoom={(room) =>
|
||||
shell.openTab(null, COLLAB, {
|
||||
label: room.name,
|
||||
forceNewTab: true,
|
||||
data: { roomId: room.id, meetingServerKey: backend.key },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MeetingScope>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +163,9 @@ function createInviteWatcher(app: TermixApp): ComponentType {
|
||||
let cancelled = false;
|
||||
const check = async () => {
|
||||
try {
|
||||
const { rooms = [] } = await listCollabRooms();
|
||||
const { rooms = [] } = await (
|
||||
await connectMeetings(app)
|
||||
).api.listCollabRooms();
|
||||
if (cancelled) return;
|
||||
let seen: string[] = [];
|
||||
try {
|
||||
@@ -142,6 +231,7 @@ function isRemoteDesktopContext(
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
meetingApp = app;
|
||||
bindApi(app.api);
|
||||
app.onDispose(() => {
|
||||
closeShareDialog();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
CollabRoom,
|
||||
CollabRoomDetail,
|
||||
CollabStage,
|
||||
CollabControlRequest,
|
||||
DirectoryUser,
|
||||
DirectoryRole,
|
||||
} from "./api";
|
||||
|
||||
export function createMeetingApi(
|
||||
client: PluginApiClient,
|
||||
assertCurrent: () => Promise<void>,
|
||||
) {
|
||||
const api = () => client;
|
||||
async function call<T>(
|
||||
request: () => Promise<{ data: T }>,
|
||||
fallback: string,
|
||||
): Promise<T> {
|
||||
await assertCurrent();
|
||||
try {
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
const response = error as { response?: { data?: { error?: string } } };
|
||||
throw new Error(response.response?.data?.error || fallback);
|
||||
}
|
||||
}
|
||||
const room = (roomId: string, suffix = "") =>
|
||||
`/rooms/${encodeURIComponent(roomId)}${suffix}`;
|
||||
|
||||
function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
|
||||
return call(
|
||||
() => api().get<{ rooms: CollabRoom[] }>("/rooms"),
|
||||
"Failed to list rooms",
|
||||
);
|
||||
}
|
||||
|
||||
function createCollabRoom(
|
||||
name: string,
|
||||
persistent: boolean,
|
||||
): Promise<{ room: CollabRoom }> {
|
||||
return call(
|
||||
() => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }),
|
||||
"Failed to create room",
|
||||
);
|
||||
}
|
||||
|
||||
function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
|
||||
return call(
|
||||
() => api().get<CollabRoomDetail>(room(roomId)),
|
||||
"Failed to get room",
|
||||
);
|
||||
}
|
||||
|
||||
async function inviteCollabMembers(
|
||||
roomId: string,
|
||||
targets: { userIds?: string[]; roleIds?: number[] },
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() => api().post(room(roomId, "/members"), targets),
|
||||
"Failed to invite members",
|
||||
);
|
||||
}
|
||||
|
||||
async function removeCollabMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() =>
|
||||
api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)),
|
||||
"Failed to remove member",
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
async function stopCollabStage(roomId: string): Promise<void> {
|
||||
await call(
|
||||
() => api().post(room(roomId, "/stop")),
|
||||
"Failed to stop presenting",
|
||||
);
|
||||
}
|
||||
|
||||
function getCollabStage(
|
||||
roomId: string,
|
||||
): Promise<{ stage: CollabStage | null }> {
|
||||
return call(
|
||||
() => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")),
|
||||
"Failed to resolve stage",
|
||||
);
|
||||
}
|
||||
|
||||
async function setCollabStageControl(
|
||||
roomId: string,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() => api().post(room(roomId, "/control"), { userId }),
|
||||
"Failed to change stage control",
|
||||
);
|
||||
}
|
||||
|
||||
function requestCollabStageControl(
|
||||
roomId: string,
|
||||
): Promise<{ request: CollabControlRequest }> {
|
||||
return call(
|
||||
() =>
|
||||
api().post<{ request: CollabControlRequest }>(
|
||||
room(roomId, "/control/request"),
|
||||
),
|
||||
"Failed to request control",
|
||||
);
|
||||
}
|
||||
|
||||
function listCollabControlRequests(
|
||||
roomId: string,
|
||||
): Promise<{ requests: CollabControlRequest[] }> {
|
||||
return call(
|
||||
() =>
|
||||
api().get<{ requests: CollabControlRequest[] }>(
|
||||
room(roomId, "/control/requests"),
|
||||
),
|
||||
"Failed to list control requests",
|
||||
);
|
||||
}
|
||||
|
||||
async function dismissCollabControlRequest(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await call(
|
||||
() =>
|
||||
api().delete(
|
||||
room(roomId, `/control/requests/${encodeURIComponent(userId)}`),
|
||||
),
|
||||
"Failed to dismiss control request",
|
||||
);
|
||||
}
|
||||
|
||||
async function endCollabRoom(roomId: string): Promise<void> {
|
||||
await call(() => api().post(room(roomId, "/end")), "Failed to end room");
|
||||
}
|
||||
|
||||
async function deleteCollabRoom(roomId: string): Promise<void> {
|
||||
await call(() => api().delete(room(roomId)), "Failed to delete room");
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
function getDirectory() {
|
||||
return call(
|
||||
() =>
|
||||
client.get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
|
||||
"/directory",
|
||||
),
|
||||
"Failed to list users and roles",
|
||||
);
|
||||
}
|
||||
function resolveHost(syncId: string) {
|
||||
return call(
|
||||
() =>
|
||||
client.get<{ id: number }>(
|
||||
`/meeting-host/${encodeURIComponent(syncId)}`,
|
||||
),
|
||||
"The host must be synced to the meeting server before presenting",
|
||||
);
|
||||
}
|
||||
return {
|
||||
listCollabRooms,
|
||||
createCollabRoom,
|
||||
getCollabRoom,
|
||||
inviteCollabMembers,
|
||||
removeCollabMember,
|
||||
presentCollabStage,
|
||||
stopCollabStage,
|
||||
getCollabStage,
|
||||
setCollabStageControl,
|
||||
requestCollabStageControl,
|
||||
listCollabControlRequests,
|
||||
dismissCollabControlRequest,
|
||||
endCollabRoom,
|
||||
deleteCollabRoom,
|
||||
setCollabGuestLink,
|
||||
getDirectory,
|
||||
resolveHost,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { createMeetingApi } from "./meeting-api";
|
||||
|
||||
export type MeetingBackend = Awaited<ReturnType<typeof connectMeetings>>;
|
||||
|
||||
/** A room, its hosts and its sockets must all belong to the same backend. */
|
||||
export async function connectMeetings(app: TermixApp) {
|
||||
const remoteUrl = app.desktop.available
|
||||
? await app.desktop.remoteServerUrl()
|
||||
: null;
|
||||
const key = remoteUrl?.replace(/\/+$/, "") || "local";
|
||||
const origin = remoteUrl ? ("remote" as const) : ("local" as const);
|
||||
const publicUrl =
|
||||
remoteUrl || (!app.desktop.available ? window.location.href : null);
|
||||
const assertCurrent = async () => {
|
||||
const current = app.desktop.available
|
||||
? await app.desktop.remoteServerUrl()
|
||||
: null;
|
||||
if ((current?.replace(/\/+$/, "") || "local") !== key) {
|
||||
throw new Error(app.t("collab.serverChanged"));
|
||||
}
|
||||
};
|
||||
return {
|
||||
key,
|
||||
origin,
|
||||
publicUrl,
|
||||
assertCurrent,
|
||||
api: createMeetingApi(app.apiFor(origin), assertCurrent),
|
||||
};
|
||||
}
|
||||
|
||||
export function meetingGuestUrl(
|
||||
publicUrl: string | null,
|
||||
token: string,
|
||||
): string | null {
|
||||
if (!publicUrl) return null;
|
||||
const url = new URL(publicUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
url.searchParams.set("view", "collab-guest");
|
||||
url.searchParams.set("token", token);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
|
||||
|
||||
export interface RemoteDisplayProps {
|
||||
token: string;
|
||||
connectionOrigin?: "local" | "remote";
|
||||
protocol: string;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
@@ -65,14 +66,25 @@ export async function createRemoteSessionToken(
|
||||
}
|
||||
|
||||
/** A socket URL for a /plugin-ws/<id>/<path>?query path the server handed out. */
|
||||
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
|
||||
export async function wsTargetForPath(
|
||||
wsPath: string,
|
||||
origin: "local" | "remote" = "local",
|
||||
) {
|
||||
const [pathname, query] = wsPath.split("?", 2);
|
||||
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
const target = await pluginWsUrl(match[1], match[2]);
|
||||
const target = await pluginWsUrl(match[1], match[2], { origin });
|
||||
if (!target) return null;
|
||||
if (!query) return target.url;
|
||||
return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`;
|
||||
return {
|
||||
...target,
|
||||
url: query
|
||||
? `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`
|
||||
: target.url,
|
||||
};
|
||||
}
|
||||
|
||||
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
|
||||
return (await wsTargetForPath(wsPath))?.url ?? null;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import http from "node:http";
|
||||
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
@@ -85,7 +86,20 @@ export async function startServer(
|
||||
settings?: Record<string, unknown>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sync_id TEXT");
|
||||
},
|
||||
});
|
||||
const refs = await db.database.refs();
|
||||
db.database.refs = async () =>
|
||||
({
|
||||
...refs,
|
||||
hosts: sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey(),
|
||||
syncId: text("sync_id"),
|
||||
}),
|
||||
}) as Awaited<ReturnType<typeof db.database.refs>>;
|
||||
for (const user of options.users ?? ["alice", "bob", "carol"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, ?)")
|
||||
|
||||
@@ -28,6 +28,28 @@ async function present(s: TestServer, roomId: string, user = "alice") {
|
||||
}
|
||||
|
||||
describe("collab rooms", () => {
|
||||
it("resolves a synced host to the server ID only with connect access", async () => {
|
||||
server = await startServer({ hostIds: [41, 42], accessibleHostIds: [42] });
|
||||
server.db.sqlite.exec("UPDATE ssh_data SET sync_id = 'shared-host'");
|
||||
expect(await server.request("GET", "/meeting-host/shared-host")).toEqual({
|
||||
status: 200,
|
||||
body: { id: 42 },
|
||||
});
|
||||
server.db.sqlite.exec(
|
||||
"UPDATE ssh_data SET sync_id = 'private-host' WHERE id = 41",
|
||||
);
|
||||
expect(
|
||||
(await server.request("GET", "/meeting-host/private-host")).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/meeting-host/missing-host")).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/meeting-host/shared-host", { user: null }))
|
||||
.status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("creates a room with the creator as host and hides the guest token", async () => {
|
||||
server = await startServer();
|
||||
const res = await server.request("POST", "/rooms", {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
connectMeetings,
|
||||
meetingGuestUrl,
|
||||
} from "../../src/frontend/meeting-backend";
|
||||
|
||||
function client(): PluginApiClient {
|
||||
return Object.fromEntries(
|
||||
["get", "post", "put", "patch", "delete"].map((method) => [
|
||||
method,
|
||||
vi.fn(async () => ({ data: { rooms: [] } })),
|
||||
]),
|
||||
) as unknown as PluginApiClient;
|
||||
}
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("meeting backend", () => {
|
||||
it("routes rooms, invites and host resolution to the linked server and refuses a later switch", async () => {
|
||||
const local = client();
|
||||
const remote = client();
|
||||
let url: string | null = "https://termix.example/base/";
|
||||
const app = {
|
||||
desktop: { available: true, remoteServerUrl: async () => url },
|
||||
apiFor: (origin: string) => (origin === "remote" ? remote : local),
|
||||
t: (key: string) => key,
|
||||
} as unknown as TermixApp;
|
||||
const backend = await connectMeetings(app);
|
||||
expect(backend.origin).toBe("remote");
|
||||
await backend.api.listCollabRooms();
|
||||
await backend.api.createCollabRoom("Standup", true);
|
||||
await backend.api.getDirectory();
|
||||
await backend.api.resolveHost("stable-host-id");
|
||||
await backend.api.presentCollabStage("room", {
|
||||
hostId: 99,
|
||||
sessionId: "remote-session",
|
||||
protocol: "ssh",
|
||||
});
|
||||
expect(local.get).not.toHaveBeenCalled();
|
||||
expect(local.post).not.toHaveBeenCalled();
|
||||
expect(remote.get).toHaveBeenCalledWith("/meeting-host/stable-host-id");
|
||||
expect(remote.post).toHaveBeenCalledWith("/rooms/room/present", {
|
||||
hostId: 99,
|
||||
sessionId: "remote-session",
|
||||
protocol: "ssh",
|
||||
});
|
||||
expect(meetingGuestUrl(backend.publicUrl, "a+b&c")).toBe(
|
||||
"https://termix.example/base/?view=collab-guest&token=a%2Bb%26c",
|
||||
);
|
||||
url = "https://other.example";
|
||||
await expect(backend.api.deleteCollabRoom("room")).rejects.toThrow(
|
||||
"collab.serverChanged",
|
||||
);
|
||||
expect(remote.delete).not.toHaveBeenCalled();
|
||||
url = null;
|
||||
await expect(backend.api.getCollabRoom("room")).rejects.toThrow(
|
||||
"collab.serverChanged",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps standalone rooms local without generating a file URL", async () => {
|
||||
const local = client();
|
||||
const backend = await connectMeetings({
|
||||
desktop: { available: true, remoteServerUrl: async () => null },
|
||||
apiFor: () => local,
|
||||
t: (key: string) => key,
|
||||
} as unknown as TermixApp);
|
||||
expect(backend.origin).toBe("local");
|
||||
await backend.api.listCollabRooms();
|
||||
expect(local.get).toHaveBeenCalledWith("/rooms");
|
||||
expect(meetingGuestUrl(backend.publicUrl, "token")).toBeNull();
|
||||
expect(meetingGuestUrl("file:///app/index.html", "token")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves web deployment paths while replacing old query and fragment values", () => {
|
||||
expect(
|
||||
meetingGuestUrl("https://termix.example/app/?view=other#old", "token"),
|
||||
).toBe("https://termix.example/app/?view=collab-guest&token=token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { pluginWsUrl } = vi.hoisted(() => ({ pluginWsUrl: vi.fn() }));
|
||||
vi.mock("@termix/plugin-sdk/ui", () => ({ pluginWsUrl }));
|
||||
import { wsTargetForPath } from "../../src/frontend/shared";
|
||||
|
||||
describe("meeting event transport", () => {
|
||||
it("preserves remote authentication protocols and event query parameters", async () => {
|
||||
pluginWsUrl.mockResolvedValue({
|
||||
url: "wss://server/base/plugin-ws/ssh-terminal/terminal",
|
||||
protocols: ["jwt", "session-token"],
|
||||
});
|
||||
expect(
|
||||
await wsTargetForPath(
|
||||
"/plugin-ws/ssh-terminal/terminal?room=abc",
|
||||
"remote",
|
||||
),
|
||||
).toEqual({
|
||||
url: "wss://server/base/plugin-ws/ssh-terminal/terminal?room=abc",
|
||||
protocols: ["jwt", "session-token"],
|
||||
});
|
||||
expect(pluginWsUrl).toHaveBeenCalledWith("ssh-terminal", "/terminal", {
|
||||
origin: "remote",
|
||||
});
|
||||
expect(
|
||||
await wsTargetForPath("https://another-server/ws", "remote"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user