fix: build desktop session share links on the linked server (#1350)

This commit is contained in:
LukeGus committed 2026-09-30 12:04:25 -05:00
1 parent d30875483d
commit ab43563247
11 files changed
+153 -29

No files matched your search

@@ -639,6 +639,8 @@ const GuacamoleAppInner = React.forwardRef<
sessionId: guacamoleConnectionId, sessionId: guacamoleConnectionId,
protocol: resolvedProtocol, protocol: resolvedProtocol,
tabInstanceId: tabId, tabInstanceId: tabId,
origin:
sessionLookup?.origin === "remote" ? "remote" : "local",
} }
: undefined : undefined
} }
@@ -64,6 +64,8 @@ export interface RemoteDesktopToolbarContext {
sessionId: string | null; sessionId: string | null;
protocol: "rdp" | "vnc" | "telnet"; protocol: "rdp" | "vnc" | "telnet";
tabInstanceId?: string; tabInstanceId?: string;
/** The backend the session runs on. */
origin?: "local" | "remote";
} }
const MODIFIER_KEYSYMS = { const MODIFIER_KEYSYMS = {
+1
View File
@@ -30,6 +30,7 @@
"searchUsersPlaceholder": "Search users...", "searchUsersPlaceholder": "Search users...",
"noUsersFound": "No users found", "noUsersFound": "No users found",
"linkCreated": "Share link created", "linkCreated": "Share link created",
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
"linkCopied": "Link copied to clipboard", "linkCopied": "Link copied to clipboard",
"copyLink": "Copy link", "copyLink": "Copy link",
"shareCreated": "Session shared. It will appear in their Connections tab.", "shareCreated": "Session shared. It will appear in their Connections tab.",
@@ -25,7 +25,9 @@ import {
type SessionSharePermissionLevel, type SessionSharePermissionLevel,
type SessionShareRecord, type SessionShareRecord,
getDirectory, getDirectory,
type SessionOrigin,
} from "./api"; } from "./api";
import { guestViewUrl } from "./meeting-backend";
const EXPIRY_PRESETS = [ const EXPIRY_PRESETS = [
{ key: "oneHour", hours: 1 }, { key: "oneHour", hours: 1 },
@@ -44,6 +46,8 @@ export function ShareSessionModal({
sessionId, sessionId,
protocol, protocol,
tabInstanceId, tabInstanceId,
origin,
resolvePublicUrl,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -51,6 +55,8 @@ export function ShareSessionModal({
sessionId: string | null; sessionId: string | null;
protocol: SessionShareProtocol; protocol: SessionShareProtocol;
tabInstanceId?: string; tabInstanceId?: string;
origin?: SessionOrigin;
resolvePublicUrl?: (origin?: SessionOrigin) => Promise<string | null>;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [mode, setMode] = useState<"link" | "user">("link"); const [mode, setMode] = useState<"link" | "user">("link");
@@ -83,8 +89,8 @@ export function ShareSessionModal({
if (!open || sharesLoaded) return; if (!open || sharesLoaded) return;
setSharesLoaded(true); setSharesLoaded(true);
Promise.all([ Promise.all([
getDirectory().catch(() => ({ users: [] })), getDirectory(origin).catch(() => ({ users: [] })),
getActiveSessionShares(hostId).catch(() => ({ shares: [] })), getActiveSessionShares(hostId, origin).catch(() => ({ shares: [] })),
]).then(([usersRes, sharesRes]) => { ]).then(([usersRes, sharesRes]) => {
setUsers( setUsers(
(usersRes.users ?? []).map((u) => ({ (usersRes.users ?? []).map((u) => ({
@@ -94,7 +100,7 @@ export function ShareSessionModal({
); );
setShares(sharesRes.shares ?? []); setShares(sharesRes.shares ?? []);
}); });
}, [open, hostId, sharesLoaded]); }, [open, hostId, origin, sharesLoaded]);
const filteredUsers = useMemo(() => { const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -113,7 +119,7 @@ export function ShareSessionModal({
async function refreshShares() { async function refreshShares() {
try { try {
const res = await getActiveSessionShares(hostId); const res = await getActiveSessionShares(hostId, origin);
setShares(res.shares ?? []); setShares(res.shares ?? []);
} catch { } catch {
// silently ignore // silently ignore
@@ -127,21 +133,33 @@ export function ShareSessionModal({
setSubmitting(true); setSubmitting(true);
try { try {
const result = await createSessionShare({ const base =
hostId, mode === "link"
sessionId, ? resolvePublicUrl
tabInstanceId, ? await resolvePublicUrl(origin)
protocol, : window.location.href
shareType: mode, : null;
targetUserId: if (mode === "link" && !guestViewUrl(base, "shared", "check")) {
mode === "user" ? (selectedUserId ?? undefined) : undefined, toast.error(t("sessionSharing.linkNeedsServer"));
permissionLevel, return;
expiryHours, }
}); const result = await createSessionShare(
{
hostId,
sessionId,
tabInstanceId,
protocol,
shareType: mode,
targetUserId:
mode === "user" ? (selectedUserId ?? undefined) : undefined,
permissionLevel,
expiryHours,
},
origin,
);
if (mode === "link" && result.linkToken) { if (mode === "link" && result.linkToken) {
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`; setCreatedLink(guestViewUrl(base, "shared", result.linkToken));
setCreatedLink(url);
toast.success(t("sessionSharing.linkCreated")); toast.success(t("sessionSharing.linkCreated"));
} else { } else {
toast.success(t("sessionSharing.shareCreated")); toast.success(t("sessionSharing.shareCreated"));
@@ -172,7 +190,7 @@ export function ShareSessionModal({
async function handleRevoke(shareId: string) { async function handleRevoke(shareId: string) {
try { try {
await revokeSessionShare(shareId); await revokeSessionShare(shareId, origin);
setShares((prev) => prev.filter((s) => s.id !== shareId)); setShares((prev) => prev.filter((s) => s.id !== shareId));
toast.success(t("sessionSharing.revoked")); toast.success(t("sessionSharing.revoked"));
} catch { } catch {
+25 -9
View File
@@ -5,16 +5,24 @@ import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
* Guest pages use it too: the two guest routes are public. * Guest pages use it too: the two guest routes are public.
*/ */
let client: PluginApiClient | null = null; let client: PluginApiClient | null = null;
let clientFor: ((origin?: unknown) => PluginApiClient) | null = null;
export function bindApi(api: PluginApiClient | null): void { export function bindApi(
api: PluginApiClient | null,
apiFor?: (origin?: unknown) => PluginApiClient,
): void {
client = api; client = api;
clientFor = api ? (apiFor ?? null) : null;
} }
function api(): PluginApiClient { /** The session lives on the backend its tab connected through. */
function api(origin?: SessionOrigin): PluginApiClient {
if (!client) throw new Error("Session sharing is not running"); if (!client) throw new Error("Session sharing is not running");
return client; return origin && clientFor ? clientFor(origin) : client;
} }
export type SessionOrigin = "local" | "remote";
interface HttpError { interface HttpError {
response?: { status?: number; data?: { error?: string } }; response?: { status?: number; data?: { error?: string } };
} }
@@ -134,25 +142,33 @@ export interface CreateSessionShareResponse {
export function createSessionShare( export function createSessionShare(
request: CreateSessionShareRequest, request: CreateSessionShareRequest,
origin?: SessionOrigin,
): Promise<CreateSessionShareResponse> { ): Promise<CreateSessionShareResponse> {
return call( return call(
() => api().post<CreateSessionShareResponse>("/create", request), () => api(origin).post<CreateSessionShareResponse>("/create", request),
"Failed to create session share", "Failed to create session share",
); );
} }
export function getActiveSessionShares( export function getActiveSessionShares(
hostId: number, hostId: number,
origin?: SessionOrigin,
): Promise<{ shares: SessionShareRecord[] }> { ): Promise<{ shares: SessionShareRecord[] }> {
return call( return call(
() => api().get<{ shares: SessionShareRecord[] }>(`/host/${hostId}/active`), () =>
api(origin).get<{ shares: SessionShareRecord[] }>(
`/host/${hostId}/active`,
),
"Failed to fetch active session shares", "Failed to fetch active session shares",
); );
} }
export function revokeSessionShare(shareId: string): Promise<unknown> { export function revokeSessionShare(
shareId: string,
origin?: SessionOrigin,
): Promise<unknown> {
return call( return call(
() => api().delete(`/${encodeURIComponent(shareId)}`), () => api(origin).delete(`/${encodeURIComponent(shareId)}`),
"Failed to revoke session share", "Failed to revoke session share",
); );
} }
@@ -169,13 +185,13 @@ export interface DirectoryRole {
} }
/** Users and roles to share with or invite. */ /** Users and roles to share with or invite. */
export function getDirectory(): Promise<{ export function getDirectory(origin?: SessionOrigin): Promise<{
users: DirectoryUser[]; users: DirectoryUser[];
roles: DirectoryRole[]; roles: DirectoryRole[];
}> { }> {
return call( return call(
() => () =>
api().get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>( api(origin).get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
"/directory", "/directory",
), ),
"Failed to list users and roles", "Failed to list users and roles",
@@ -26,6 +26,7 @@ import SharedSessionView from "./SharedSessionView";
import { ParticipantsOverlay } from "./ParticipantsOverlay"; import { ParticipantsOverlay } from "./ParticipantsOverlay";
import { import {
ShareDialogHost, ShareDialogHost,
bindShareApp,
closeShareDialog, closeShareDialog,
openShareDialog, openShareDialog,
type ShareTarget, type ShareTarget,
@@ -217,6 +218,7 @@ interface RemoteDesktopToolbarContext {
sessionId: string | null; sessionId: string | null;
protocol: "rdp" | "vnc" | "telnet"; protocol: "rdp" | "vnc" | "telnet";
tabInstanceId?: string; tabInstanceId?: string;
origin?: "local" | "remote";
} }
function isRemoteDesktopContext( function isRemoteDesktopContext(
@@ -232,10 +234,12 @@ function isRemoteDesktopContext(
export function activate(app: TermixApp): void { export function activate(app: TermixApp): void {
meetingApp = app; meetingApp = app;
bindApi(app.api); bindApi(app.api, app.apiFor);
bindShareApp(app);
app.onDispose(() => { app.onDispose(() => {
closeShareDialog(); closeShareDialog();
bindApi(null); bindApi(null);
bindShareApp(null);
}); });
// Rooms and share links draw remote desktop streams through this slot. // Rooms and share links draw remote desktop streams through this slot.
@@ -281,6 +285,7 @@ export function activate(app: TermixApp): void {
sessionId: context.sessionId, sessionId: context.sessionId,
protocol: context.protocol, protocol: context.protocol,
tabInstanceId: context.tabInstanceId, tabInstanceId: context.tabInstanceId,
origin: context.origin,
} }
: null; : null;
} else { } else {
@@ -32,13 +32,32 @@ export async function connectMeetings(app: TermixApp) {
export function meetingGuestUrl( export function meetingGuestUrl(
publicUrl: string | null, publicUrl: string | null,
token: string, token: string,
): string | null {
return guestViewUrl(publicUrl, "collab-guest", token);
}
/** A guest link on a server others can open, never a file:// desktop page. */
export function guestViewUrl(
publicUrl: string | null,
view: string,
token: string,
): string | null { ): string | null {
if (!publicUrl) return null; if (!publicUrl) return null;
const url = new URL(publicUrl); const url = new URL(publicUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (url.protocol !== "http:" && url.protocol !== "https:") return null;
url.search = ""; url.search = "";
url.hash = ""; url.hash = "";
url.searchParams.set("view", "collab-guest"); url.searchParams.set("view", view);
url.searchParams.set("token", token); url.searchParams.set("token", token);
return url.toString(); return url.toString();
} }
/** Where guests reach a session that runs on the given backend. */
export async function sessionPublicUrl(
app: TermixApp,
origin: "local" | "remote" | undefined,
): Promise<string | null> {
if (!app.desktop.available) return window.location.href;
if (origin !== "remote") return null;
return (await app.desktop.remoteServerUrl()) || null;
}
@@ -1,6 +1,21 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { ShareSessionModal } from "./ShareSessionModal"; import { ShareSessionModal } from "./ShareSessionModal";
import type { SessionShareProtocol } from "./api"; import type { SessionShareProtocol } from "./api";
import { sessionPublicUrl } from "./meeting-backend";
let shareApp: TermixApp | null = null;
export function bindShareApp(app: TermixApp | null): void {
shareApp = app;
}
function resolveShareBase(
origin: "local" | "remote" | undefined,
): Promise<string | null> {
if (!shareApp) return Promise.resolve(window.location.href);
return sessionPublicUrl(shareApp, origin);
}
/** What the share dialog needs to share one live session. */ /** What the share dialog needs to share one live session. */
export interface ShareTarget { export interface ShareTarget {
@@ -8,6 +23,8 @@ export interface ShareTarget {
sessionId: string; sessionId: string;
protocol: SessionShareProtocol; protocol: SessionShareProtocol;
tabInstanceId?: string; tabInstanceId?: string;
/** The backend the session runs on; a desktop can be on either. */
origin?: "local" | "remote";
} }
let current: ShareTarget | null = null; let current: ShareTarget | null = null;
@@ -45,6 +62,8 @@ export function ShareDialogHost() {
sessionId={target.sessionId} sessionId={target.sessionId}
protocol={target.protocol} protocol={target.protocol}
tabInstanceId={target.tabInstanceId} tabInstanceId={target.tabInstanceId}
origin={target.origin}
resolvePublicUrl={resolveShareBase}
/> />
); );
} }
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import {
guestViewUrl,
sessionPublicUrl,
} from "../../src/frontend/meeting-backend";
function desktop(url: string | null): TermixApp {
return {
desktop: { available: true, remoteServerUrl: async () => url },
} as unknown as TermixApp;
}
describe("session share links", () => {
it("builds the link on the linked server for a remote session", async () => {
const base = await sessionPublicUrl(
desktop("https://termix.example/"),
"remote",
);
expect(guestViewUrl(base, "shared", "tok")).toBe(
"https://termix.example/?view=shared&token=tok",
);
});
it("gives no link for a session on the desktop's own backend", async () => {
expect(
await sessionPublicUrl(desktop("https://termix.example/"), "local"),
).toBeNull();
expect(
guestViewUrl("file:///C:/app/index.html", "shared", "tok"),
).toBeNull();
});
it("uses the page address in a browser", async () => {
const app = { desktop: { available: false } } as unknown as TermixApp;
expect(await sessionPublicUrl(app, undefined)).toBe(window.location.href);
});
});
@@ -281,6 +281,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const webSocketRef = useRef<WebSocket | null>(null); const webSocketRef = useRef<WebSocket | null>(null);
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null); const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef<TerminalLocalEcho | null>(null); const localEchoRef = useRef<TerminalLocalEcho | null>(null);
const sessionOriginRef = useRef<"local" | "remote">("local");
const customKeybindingsRef = useRef<CustomKeybinding[]>([]); const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null); const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false); const wasDisconnectedBySSH = useRef(false);
@@ -785,6 +786,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
sessionId: sessionIdRef.current, sessionId: sessionIdRef.current,
protocol: "ssh" as const, protocol: "ssh" as const,
tabInstanceId: hostConfig.instanceId, tabInstanceId: hostConfig.instanceId,
origin: sessionOriginRef.current,
} }
: null, : null,
}), }),
@@ -1642,6 +1644,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
"local" | "remote" | null | undefined, "local" | "remote" | null | undefined,
}) })
: "local"; : "local";
sessionOriginRef.current = origin === "remote" ? "remote" : "local";
const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", { const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", {
origin, origin,
}); });
@@ -38,6 +38,7 @@ export interface TerminalSlotApi {
sessionId: string; sessionId: string;
protocol: "ssh"; protocol: "ssh";
tabInstanceId?: string; tabInstanceId?: string;
origin?: "local" | "remote";
} | null; } | null;
} }