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,
protocol: resolvedProtocol,
tabInstanceId: tabId,
origin:
sessionLookup?.origin === "remote" ? "remote" : "local",
}
: undefined
}
@@ -64,6 +64,8 @@ export interface RemoteDesktopToolbarContext {
sessionId: string | null;
protocol: "rdp" | "vnc" | "telnet";
tabInstanceId?: string;
/** The backend the session runs on. */
origin?: "local" | "remote";
}
const MODIFIER_KEYSYMS = {
+1
View File
@@ -30,6 +30,7 @@
"searchUsersPlaceholder": "Search users...",
"noUsersFound": "No users found",
"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",
"copyLink": "Copy link",
"shareCreated": "Session shared. It will appear in their Connections tab.",
@@ -25,7 +25,9 @@ import {
type SessionSharePermissionLevel,
type SessionShareRecord,
getDirectory,
type SessionOrigin,
} from "./api";
import { guestViewUrl } from "./meeting-backend";
const EXPIRY_PRESETS = [
{ key: "oneHour", hours: 1 },
@@ -44,6 +46,8 @@ export function ShareSessionModal({
sessionId,
protocol,
tabInstanceId,
origin,
resolvePublicUrl,
}: {
open: boolean;
onClose: () => void;
@@ -51,6 +55,8 @@ export function ShareSessionModal({
sessionId: string | null;
protocol: SessionShareProtocol;
tabInstanceId?: string;
origin?: SessionOrigin;
resolvePublicUrl?: (origin?: SessionOrigin) => Promise<string | null>;
}) {
const { t } = useTranslation();
const [mode, setMode] = useState<"link" | "user">("link");
@@ -83,8 +89,8 @@ export function ShareSessionModal({
if (!open || sharesLoaded) return;
setSharesLoaded(true);
Promise.all([
getDirectory().catch(() => ({ users: [] })),
getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
getDirectory(origin).catch(() => ({ users: [] })),
getActiveSessionShares(hostId, origin).catch(() => ({ shares: [] })),
]).then(([usersRes, sharesRes]) => {
setUsers(
(usersRes.users ?? []).map((u) => ({
@@ -94,7 +100,7 @@ export function ShareSessionModal({
);
setShares(sharesRes.shares ?? []);
});
}, [open, hostId, sharesLoaded]);
}, [open, hostId, origin, sharesLoaded]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -113,7 +119,7 @@ export function ShareSessionModal({
async function refreshShares() {
try {
const res = await getActiveSessionShares(hostId);
const res = await getActiveSessionShares(hostId, origin);
setShares(res.shares ?? []);
} catch {
// silently ignore
@@ -127,21 +133,33 @@ export function ShareSessionModal({
setSubmitting(true);
try {
const result = await createSessionShare({
hostId,
sessionId,
tabInstanceId,
protocol,
shareType: mode,
targetUserId:
mode === "user" ? (selectedUserId ?? undefined) : undefined,
permissionLevel,
expiryHours,
});
const base =
mode === "link"
? resolvePublicUrl
? await resolvePublicUrl(origin)
: window.location.href
: null;
if (mode === "link" && !guestViewUrl(base, "shared", "check")) {
toast.error(t("sessionSharing.linkNeedsServer"));
return;
}
const result = await createSessionShare(
{
hostId,
sessionId,
tabInstanceId,
protocol,
shareType: mode,
targetUserId:
mode === "user" ? (selectedUserId ?? undefined) : undefined,
permissionLevel,
expiryHours,
},
origin,
);
if (mode === "link" && result.linkToken) {
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
setCreatedLink(url);
setCreatedLink(guestViewUrl(base, "shared", result.linkToken));
toast.success(t("sessionSharing.linkCreated"));
} else {
toast.success(t("sessionSharing.shareCreated"));
@@ -172,7 +190,7 @@ export function ShareSessionModal({
async function handleRevoke(shareId: string) {
try {
await revokeSessionShare(shareId);
await revokeSessionShare(shareId, origin);
setShares((prev) => prev.filter((s) => s.id !== shareId));
toast.success(t("sessionSharing.revoked"));
} 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.
*/
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;
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");
return client;
return origin && clientFor ? clientFor(origin) : client;
}
export type SessionOrigin = "local" | "remote";
interface HttpError {
response?: { status?: number; data?: { error?: string } };
}
@@ -134,25 +142,33 @@ export interface CreateSessionShareResponse {
export function createSessionShare(
request: CreateSessionShareRequest,
origin?: SessionOrigin,
): Promise<CreateSessionShareResponse> {
return call(
() => api().post<CreateSessionShareResponse>("/create", request),
() => api(origin).post<CreateSessionShareResponse>("/create", request),
"Failed to create session share",
);
}
export function getActiveSessionShares(
hostId: number,
origin?: SessionOrigin,
): Promise<{ shares: SessionShareRecord[] }> {
return call(
() => api().get<{ shares: SessionShareRecord[] }>(`/host/${hostId}/active`),
() =>
api(origin).get<{ shares: SessionShareRecord[] }>(
`/host/${hostId}/active`,
),
"Failed to fetch active session shares",
);
}
export function revokeSessionShare(shareId: string): Promise<unknown> {
export function revokeSessionShare(
shareId: string,
origin?: SessionOrigin,
): Promise<unknown> {
return call(
() => api().delete(`/${encodeURIComponent(shareId)}`),
() => api(origin).delete(`/${encodeURIComponent(shareId)}`),
"Failed to revoke session share",
);
}
@@ -169,13 +185,13 @@ export interface DirectoryRole {
}
/** Users and roles to share with or invite. */
export function getDirectory(): Promise<{
export function getDirectory(origin?: SessionOrigin): Promise<{
users: DirectoryUser[];
roles: DirectoryRole[];
}> {
return call(
() =>
api().get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
api(origin).get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
"/directory",
),
"Failed to list users and roles",
@@ -26,6 +26,7 @@ import SharedSessionView from "./SharedSessionView";
import { ParticipantsOverlay } from "./ParticipantsOverlay";
import {
ShareDialogHost,
bindShareApp,
closeShareDialog,
openShareDialog,
type ShareTarget,
@@ -217,6 +218,7 @@ interface RemoteDesktopToolbarContext {
sessionId: string | null;
protocol: "rdp" | "vnc" | "telnet";
tabInstanceId?: string;
origin?: "local" | "remote";
}
function isRemoteDesktopContext(
@@ -232,10 +234,12 @@ function isRemoteDesktopContext(
export function activate(app: TermixApp): void {
meetingApp = app;
bindApi(app.api);
bindApi(app.api, app.apiFor);
bindShareApp(app);
app.onDispose(() => {
closeShareDialog();
bindApi(null);
bindShareApp(null);
});
// Rooms and share links draw remote desktop streams through this slot.
@@ -281,6 +285,7 @@ export function activate(app: TermixApp): void {
sessionId: context.sessionId,
protocol: context.protocol,
tabInstanceId: context.tabInstanceId,
origin: context.origin,
}
: null;
} else {
@@ -32,13 +32,32 @@ export async function connectMeetings(app: TermixApp) {
export function meetingGuestUrl(
publicUrl: string | null,
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 {
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("view", view);
url.searchParams.set("token", token);
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 type { TermixApp } from "@termix/plugin-sdk/frontend";
import { ShareSessionModal } from "./ShareSessionModal";
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. */
export interface ShareTarget {
@@ -8,6 +23,8 @@ export interface ShareTarget {
sessionId: string;
protocol: SessionShareProtocol;
tabInstanceId?: string;
/** The backend the session runs on; a desktop can be on either. */
origin?: "local" | "remote";
}
let current: ShareTarget | null = null;
@@ -45,6 +62,8 @@ export function ShareDialogHost() {
sessionId={target.sessionId}
protocol={target.protocol}
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 terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
const sessionOriginRef = useRef<"local" | "remote">("local");
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
@@ -785,6 +786,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
sessionId: sessionIdRef.current,
protocol: "ssh" as const,
tabInstanceId: hostConfig.instanceId,
origin: sessionOriginRef.current,
}
: null,
}),
@@ -1642,6 +1644,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
"local" | "remote" | null | undefined,
})
: "local";
sessionOriginRef.current = origin === "remote" ? "remote" : "local";
const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", {
origin,
});
@@ -38,6 +38,7 @@ export interface TerminalSlotApi {
sessionId: string;
protocol: "ssh";
tabInstanceId?: string;
origin?: "local" | "remote";
} | null;
}