diff --git a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx index cea630725..c95a5ef18 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx @@ -639,6 +639,8 @@ const GuacamoleAppInner = React.forwardRef< sessionId: guacamoleConnectionId, protocol: resolvedProtocol, tabInstanceId: tabId, + origin: + sessionLookup?.origin === "remote" ? "remote" : "local", } : undefined } diff --git a/plugins/remote-desktop/src/frontend/GuacamoleToolbar.tsx b/plugins/remote-desktop/src/frontend/GuacamoleToolbar.tsx index 3cd2ad7c8..c66bb05f4 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleToolbar.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleToolbar.tsx @@ -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 = { diff --git a/plugins/session-sharing/locales/en.json b/plugins/session-sharing/locales/en.json index 739ce3723..d1b826ee0 100644 --- a/plugins/session-sharing/locales/en.json +++ b/plugins/session-sharing/locales/en.json @@ -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.", diff --git a/plugins/session-sharing/src/frontend/ShareSessionModal.tsx b/plugins/session-sharing/src/frontend/ShareSessionModal.tsx index 9e0fb61dd..2f80e7f8b 100644 --- a/plugins/session-sharing/src/frontend/ShareSessionModal.tsx +++ b/plugins/session-sharing/src/frontend/ShareSessionModal.tsx @@ -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; }) { 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 { diff --git a/plugins/session-sharing/src/frontend/api.ts b/plugins/session-sharing/src/frontend/api.ts index 947228174..8f50ac92a 100644 --- a/plugins/session-sharing/src/frontend/api.ts +++ b/plugins/session-sharing/src/frontend/api.ts @@ -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 { return call( - () => api().post("/create", request), + () => api(origin).post("/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 { +export function revokeSessionShare( + shareId: string, + origin?: SessionOrigin, +): Promise { 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", diff --git a/plugins/session-sharing/src/frontend/index.tsx b/plugins/session-sharing/src/frontend/index.tsx index 2bfd69fc2..a794b5fbd 100644 --- a/plugins/session-sharing/src/frontend/index.tsx +++ b/plugins/session-sharing/src/frontend/index.tsx @@ -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 { diff --git a/plugins/session-sharing/src/frontend/meeting-backend.ts b/plugins/session-sharing/src/frontend/meeting-backend.ts index 6dfe04c9d..03c0c18de 100644 --- a/plugins/session-sharing/src/frontend/meeting-backend.ts +++ b/plugins/session-sharing/src/frontend/meeting-backend.ts @@ -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 { + if (!app.desktop.available) return window.location.href; + if (origin !== "remote") return null; + return (await app.desktop.remoteServerUrl()) || null; +} diff --git a/plugins/session-sharing/src/frontend/share-dialog.tsx b/plugins/session-sharing/src/frontend/share-dialog.tsx index 9f55a2905..487a8ea0b 100644 --- a/plugins/session-sharing/src/frontend/share-dialog.tsx +++ b/plugins/session-sharing/src/frontend/share-dialog.tsx @@ -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 { + 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} /> ); } diff --git a/plugins/session-sharing/tests/frontend/share-link-url.test.ts b/plugins/session-sharing/tests/frontend/share-link-url.test.ts new file mode 100644 index 000000000..d3716ebda --- /dev/null +++ b/plugins/session-sharing/tests/frontend/share-link-url.test.ts @@ -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); + }); +}); diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx index fbc0e7613..4da80fecc 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx @@ -281,6 +281,7 @@ const TerminalInner = forwardRef( const webSocketRef = useRef(null); const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null); const localEchoRef = useRef(null); + const sessionOriginRef = useRef<"local" | "remote">("local"); const customKeybindingsRef = useRef([]); const resizeTimeout = useRef(null); const wasDisconnectedBySSH = useRef(false); @@ -785,6 +786,7 @@ const TerminalInner = forwardRef( sessionId: sessionIdRef.current, protocol: "ssh" as const, tabInstanceId: hostConfig.instanceId, + origin: sessionOriginRef.current, } : null, }), @@ -1642,6 +1644,7 @@ const TerminalInner = forwardRef( "local" | "remote" | null | undefined, }) : "local"; + sessionOriginRef.current = origin === "remote" ? "remote" : "local"; const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", { origin, }); diff --git a/plugins/ssh-terminal/src/frontend/terminal/terminal-slots.ts b/plugins/ssh-terminal/src/frontend/terminal/terminal-slots.ts index a87d502be..f4653df96 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/terminal-slots.ts +++ b/plugins/ssh-terminal/src/frontend/terminal/terminal-slots.ts @@ -38,6 +38,7 @@ export interface TerminalSlotApi { sessionId: string; protocol: "ssh"; tabInstanceId?: string; + origin?: "local" | "remote"; } | null; }