mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15: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
+609
-198
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. */
|
/** What collab rooms and shared-session links draw a stream with. */
|
||||||
function RemoteDisplay({
|
function RemoteDisplay({
|
||||||
token,
|
token,
|
||||||
|
connectionOrigin,
|
||||||
protocol,
|
protocol,
|
||||||
isVisible,
|
isVisible,
|
||||||
onConnect,
|
onConnect,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
token: string;
|
token: string;
|
||||||
|
connectionOrigin?: "local" | "remote";
|
||||||
protocol: Protocol;
|
protocol: Protocol;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onConnect?: () => void;
|
onConnect?: () => void;
|
||||||
@@ -131,7 +133,7 @@ function RemoteDisplay({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<GuacamoleDisplay
|
<GuacamoleDisplay
|
||||||
connectionConfig={{ token, protocol, type: protocol }}
|
connectionConfig={{ token, protocol, type: protocol, connectionOrigin }}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
|
|||||||
@@ -132,7 +132,10 @@
|
|||||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||||
"created": "Room created",
|
"created": "Room created",
|
||||||
"invited": "Members invited",
|
"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": {
|
"nav": {
|
||||||
"collab": "Meetings"
|
"collab": "Meetings"
|
||||||
|
|||||||
@@ -520,6 +520,17 @@ export function createDirectory(
|
|||||||
return !!rows[0]?.isAdmin;
|
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> {
|
async hostExists(hostId: number): Promise<boolean> {
|
||||||
const { hosts } = await refs();
|
const { hosts } = await refs();
|
||||||
const rows = await (
|
const rows = await (
|
||||||
|
|||||||
@@ -200,6 +200,32 @@ export function registerRoomRoutes(
|
|||||||
} = actions;
|
} = actions;
|
||||||
const requireUse = ctx.rbac.require("use") as never;
|
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 = (
|
const audit = (
|
||||||
req: Request,
|
req: Request,
|
||||||
action: string,
|
action: string,
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} 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";
|
import { getErrorMessage } from "./shared";
|
||||||
|
|
||||||
export function CollabPanel({
|
export function CollabPanel({
|
||||||
onOpenRoom,
|
onOpenRoom,
|
||||||
|
backend,
|
||||||
}: {
|
}: {
|
||||||
onOpenRoom: (room: CollabRoom) => void;
|
onOpenRoom: (room: CollabRoom) => void;
|
||||||
|
backend: MeetingBackend;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { createCollabRoom, listCollabRooms } = backend.api;
|
||||||
const [rooms, setRooms] = useState<CollabRoom[]>([]);
|
const [rooms, setRooms] = useState<CollabRoom[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@@ -37,7 +41,7 @@ export function CollabPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [backend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|||||||
@@ -34,36 +34,16 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import {
|
import { Input, PluginComponent } from "@termix/plugin-sdk/ui";
|
||||||
Input,
|
|
||||||
PluginComponent,
|
|
||||||
resolveConnectionOrigin,
|
|
||||||
} from "@termix/plugin-sdk/ui";
|
|
||||||
import { CollabMembersSidebar } from "./CollabMembersSidebar";
|
import { CollabMembersSidebar } from "./CollabMembersSidebar";
|
||||||
import {
|
import {
|
||||||
RemoteDisplay,
|
RemoteDisplay,
|
||||||
createRemoteSessionToken,
|
createRemoteSessionToken,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
wsUrlForPath,
|
wsTargetForPath,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import {
|
import type { CollabRoomDetail, CollabStage, DirectoryRole } from "./api";
|
||||||
deleteCollabRoom,
|
import { meetingGuestUrl, type MeetingBackend } from "./meeting-backend";
|
||||||
endCollabRoom,
|
|
||||||
dismissCollabControlRequest,
|
|
||||||
getCollabRoom,
|
|
||||||
getCollabStage,
|
|
||||||
inviteCollabMembers,
|
|
||||||
presentCollabStage,
|
|
||||||
requestCollabStageControl,
|
|
||||||
removeCollabMember,
|
|
||||||
setCollabGuestLink,
|
|
||||||
setCollabStageControl,
|
|
||||||
stopCollabStage,
|
|
||||||
getDirectory,
|
|
||||||
type CollabRoomDetail,
|
|
||||||
type CollabStage,
|
|
||||||
type DirectoryRole,
|
|
||||||
} from "./api";
|
|
||||||
|
|
||||||
type SharedProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
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 terminal socket the ssh-terminal plugin serves. Authentication rides on
|
||||||
// the jwt cookie the way every terminal WS connection does.
|
// the jwt cookie the way every terminal WS connection does.
|
||||||
async function roomEventsWsUrl(roomId: string): Promise<string | null> {
|
async function roomEventsWsUrl(roomId: string, backend: MeetingBackend) {
|
||||||
const { eventsWsPath } = await getCollabRoom(roomId);
|
const { eventsWsPath } = await backend.api.getCollabRoom(roomId);
|
||||||
return wsUrlForPath(eventsWsPath);
|
await backend.assertCurrent();
|
||||||
|
return wsTargetForPath(eventsWsPath, backend.origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PresentDraft =
|
type PresentDraft =
|
||||||
@@ -105,11 +86,28 @@ type PresentChoice = {
|
|||||||
export function CollabRoomTab({
|
export function CollabRoomTab({
|
||||||
roomId,
|
roomId,
|
||||||
isVisible,
|
isVisible,
|
||||||
|
backend,
|
||||||
}: {
|
}: {
|
||||||
|
backend: MeetingBackend;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 [detail, setDetail] = useState<CollabRoomDetail | null>(null);
|
||||||
const [stage, setStage] = useState<CollabStage | null>(null);
|
const [stage, setStage] = useState<CollabStage | null>(null);
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
@@ -190,7 +188,7 @@ export function CollabRoomTab({
|
|||||||
setLoadError(getErrorMessage(error));
|
setLoadError(getErrorMessage(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [roomId]);
|
}, [roomId, backend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -216,10 +214,10 @@ export function CollabRoomTab({
|
|||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try {
|
try {
|
||||||
const url = await roomEventsWsUrl(roomId);
|
const url = await roomEventsWsUrl(roomId, backend);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!url) throw new Error("No terminal endpoint is available");
|
if (!url) throw new Error("No terminal endpoint is available");
|
||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url.url, url.protocols);
|
||||||
} catch {
|
} catch {
|
||||||
startPolling();
|
startPolling();
|
||||||
reconnectTimer = setTimeout(() => void connect(), 5000);
|
reconnectTimer = setTimeout(() => void connect(), 5000);
|
||||||
@@ -359,19 +357,21 @@ export function CollabRoomTab({
|
|||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
setPresentOpen(false);
|
setPresentOpen(false);
|
||||||
try {
|
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") {
|
if (protocol === "ssh") {
|
||||||
setDraft({ protocol, host });
|
setDraft({ protocol, host });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = await createRemoteSessionToken(
|
const response = await createRemoteSessionToken(
|
||||||
Number(host.id),
|
Number(host.id),
|
||||||
await resolveConnectionOrigin(
|
backend.origin,
|
||||||
{
|
|
||||||
connectionOrigin: host.connectionOrigin as
|
|
||||||
"local" | "remote" | undefined,
|
|
||||||
},
|
|
||||||
{ defaultRemote: true },
|
|
||||||
),
|
|
||||||
protocol,
|
protocol,
|
||||||
);
|
);
|
||||||
if (!response?.connectionId) {
|
if (!response?.connectionId) {
|
||||||
@@ -465,8 +465,16 @@ export function CollabRoomTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function guestLinkUrl(token: string) {
|
async function copyGuestLink(token: string) {
|
||||||
return `${window.location.origin}${window.location.pathname}?view=collab-guest&token=${token}`;
|
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() {
|
async function handleInvite() {
|
||||||
@@ -648,10 +656,7 @@ export function CollabRoomTab({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard
|
void copyGuestLink(guestLinkToken);
|
||||||
.writeText(guestLinkUrl(guestLinkToken))
|
|
||||||
.then(() => toast.success(t("collab.linkCopied")))
|
|
||||||
.catch((error) => toast.error(getErrorMessage(error)));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("collab.copyLink")}
|
{t("collab.copyLink")}
|
||||||
@@ -706,6 +711,7 @@ export function CollabRoomTab({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RemoteDisplay
|
<RemoteDisplay
|
||||||
|
connectionOrigin={backend.origin}
|
||||||
token={draft.token}
|
token={draft.token}
|
||||||
protocol={draft.protocol}
|
protocol={draft.protocol}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
@@ -734,6 +740,7 @@ export function CollabRoomTab({
|
|||||||
id="terminal.view"
|
id="terminal.view"
|
||||||
hostConfig={{
|
hostConfig={{
|
||||||
id: stage.hostId ?? undefined,
|
id: stage.hostId ?? undefined,
|
||||||
|
connectionOrigin: backend.origin,
|
||||||
name: detail?.room.name ?? "stage",
|
name: detail?.room.name ?? "stage",
|
||||||
ip: "",
|
ip: "",
|
||||||
port: 0,
|
port: 0,
|
||||||
@@ -748,6 +755,7 @@ export function CollabRoomTab({
|
|||||||
/>
|
/>
|
||||||
) : stage.connectParams?.token ? (
|
) : stage.connectParams?.token ? (
|
||||||
<RemoteDisplay
|
<RemoteDisplay
|
||||||
|
connectionOrigin={backend.origin}
|
||||||
key={stage.connectParams.token}
|
key={stage.connectParams.token}
|
||||||
token={stage.connectParams.token}
|
token={stage.connectParams.token}
|
||||||
protocol={stage.protocol}
|
protocol={stage.protocol}
|
||||||
|
|||||||
@@ -249,148 +249,6 @@ export interface CollabRoomDetail {
|
|||||||
eventsWsPath: string;
|
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 {
|
export interface CollabGuestStage {
|
||||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
shareId: string;
|
shareId: string;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// The terminal surfaces need xterm's own stylesheet.
|
// The terminal surfaces need xterm's own stylesheet.
|
||||||
import "@xterm/xterm/css/xterm.css";
|
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 { Presentation, Share2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +17,7 @@ import {
|
|||||||
type TabProps,
|
type TabProps,
|
||||||
type TermixApp,
|
type TermixApp,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import { bindApi, listCollabRooms } from "./api";
|
import { bindApi } from "./api";
|
||||||
import { SharedWithMeSection } from "./SharedWithMeSection";
|
import { SharedWithMeSection } from "./SharedWithMeSection";
|
||||||
import CollabGuestView from "./CollabGuestView";
|
import CollabGuestView from "./CollabGuestView";
|
||||||
import { CollabPanel } from "./CollabPanel";
|
import { CollabPanel } from "./CollabPanel";
|
||||||
@@ -26,6 +32,73 @@ import {
|
|||||||
} from "./share-dialog";
|
} from "./share-dialog";
|
||||||
import { REMOTE_DISPLAY_SLOT } from "./shared";
|
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 COLLAB = "collab";
|
||||||
const SHARE_ACTION = "session-sharing.share";
|
const SHARE_ACTION = "session-sharing.share";
|
||||||
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
|
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
|
||||||
@@ -38,25 +111,39 @@ function GuestView({ view }: StandaloneViewProps) {
|
|||||||
|
|
||||||
function CollabRoomTabView({ tab, isVisible }: TabProps) {
|
function CollabRoomTabView({ tab, isVisible }: TabProps) {
|
||||||
const roomId = tab.data?.roomId;
|
const roomId = tab.data?.roomId;
|
||||||
|
const serverKey =
|
||||||
|
typeof tab.data?.meetingServerKey === "string"
|
||||||
|
? tab.data.meetingServerKey
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
|
<MeetingScope serverKey={serverKey} pin>
|
||||||
|
{(backend) => (
|
||||||
<CollabRoomTab
|
<CollabRoomTab
|
||||||
roomId={typeof roomId === "string" ? roomId : undefined}
|
roomId={typeof roomId === "string" ? roomId : undefined}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
|
backend={backend}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</MeetingScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollabPanelView({ shell }: PanelProps) {
|
function CollabPanelView({ shell }: PanelProps) {
|
||||||
return (
|
return (
|
||||||
|
<MeetingScope>
|
||||||
|
{(backend) => (
|
||||||
<CollabPanel
|
<CollabPanel
|
||||||
|
backend={backend}
|
||||||
onOpenRoom={(room) =>
|
onOpenRoom={(room) =>
|
||||||
shell.openTab(null, COLLAB, {
|
shell.openTab(null, COLLAB, {
|
||||||
label: room.name,
|
label: room.name,
|
||||||
forceNewTab: true,
|
forceNewTab: true,
|
||||||
data: { roomId: room.id },
|
data: { roomId: room.id, meetingServerKey: backend.key },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</MeetingScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +163,9 @@ function createInviteWatcher(app: TermixApp): ComponentType {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
try {
|
try {
|
||||||
const { rooms = [] } = await listCollabRooms();
|
const { rooms = [] } = await (
|
||||||
|
await connectMeetings(app)
|
||||||
|
).api.listCollabRooms();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
let seen: string[] = [];
|
let seen: string[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -142,6 +231,7 @@ function isRemoteDesktopContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function activate(app: TermixApp): void {
|
export function activate(app: TermixApp): void {
|
||||||
|
meetingApp = app;
|
||||||
bindApi(app.api);
|
bindApi(app.api);
|
||||||
app.onDispose(() => {
|
app.onDispose(() => {
|
||||||
closeShareDialog();
|
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 {
|
export interface RemoteDisplayProps {
|
||||||
token: string;
|
token: string;
|
||||||
|
connectionOrigin?: "local" | "remote";
|
||||||
protocol: string;
|
protocol: string;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onConnect?: () => void;
|
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. */
|
/** 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 [pathname, query] = wsPath.split("?", 2);
|
||||||
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
||||||
if (!match) return null;
|
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 (!target) return null;
|
||||||
if (!query) return target.url;
|
return {
|
||||||
return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`;
|
...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 {
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
|
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import express, { type Router } from "express";
|
import express, { type Router } from "express";
|
||||||
@@ -85,7 +86,20 @@ export async function startServer(
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<TestServer> {
|
): 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"]) {
|
for (const user of options.users ?? ["alice", "bob", "carol"]) {
|
||||||
db.sqlite
|
db.sqlite
|
||||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, ?)")
|
.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", () => {
|
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 () => {
|
it("creates a room with the creator as host and hides the guest token", async () => {
|
||||||
server = await startServer();
|
server = await startServer();
|
||||||
const res = await server.request("POST", "/rooms", {
|
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