feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
import { RemoteDisplay } from "./shared";
import {
GuestTerminalView,
type SessionParticipantInfo,
} from "./SharedSessionView";
import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
const POLL_MS = 5000;
/**
* Anonymous guest page for a collab room (?view=collab-guest&token=...).
* Guests have no account and no event channel, so they poll the public
* resolve endpoint and remount the viewer whenever the stage share changes.
*/
export default function CollabGuestView() {
const { t } = useTranslation();
const token = new URLSearchParams(window.location.search).get("token");
const [roomName, setRoomName] = useState<string | null>(null);
const [stage, setStage] = useState<CollabGuestStage | null>(null);
const [error, setError] = useState<string | null>(null);
const [participantCount, setParticipantCount] = useState(0);
const stageShareIdRef = useRef<string | null>(null);
useEffect(() => {
if (!token) {
setError(t("collab.guest.linkInvalid"));
return;
}
let cancelled = false;
const poll = async () => {
try {
const result = await resolveCollabGuestStage(token);
if (cancelled) return;
setRoomName(result.roomName);
setError(null);
const nextShareId = result.stage?.shareId ?? null;
// Tokens are minted per resolve; only swap the viewer on a real change.
if (nextShareId !== stageShareIdRef.current) {
stageShareIdRef.current = nextShareId;
setStage(result.stage);
setParticipantCount(0);
}
} catch {
if (!cancelled) setError(t("collab.guest.linkInvalid"));
}
};
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [token, t]);
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--bg-base)", color: "var(--foreground)" }}
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border text-sm shrink-0">
<Presentation className="size-4 text-muted-foreground shrink-0" />
<span className="font-semibold truncate min-w-0">
{roomName ?? t("collab.guest.title")}
</span>
<div className="flex items-center gap-3 shrink-0 ml-auto text-xs text-muted-foreground">
{participantCount >= 2 && (
<span className="flex items-center gap-1">
<Users className="size-3.5" />
{participantCount}
</span>
)}
<span className="flex items-center gap-1">
<Eye className="size-3.5" />
{t("sessionSharing.guestView.readOnlyBadge")}
</span>
</div>
</div>
<div className="relative flex-1 min-h-0">
{error ? (
<Note icon={<AlertCircle className="size-8" />} text={error} />
) : !stage ? (
<Note
icon={<Presentation className="size-8" />}
text={t("collab.guest.waiting")}
/>
) : stage.protocol === "ssh" ? (
<GuestTerminalView
key={stage.shareId}
share={{ permissionLevel: "read-only" }}
wsPath={stage.wsPath ?? ""}
hideBadges
onParticipantsChange={(participants: SessionParticipantInfo[]) =>
setParticipantCount(participants.length)
}
/>
) : stage.connectParams?.token ? (
<RemoteDisplay
key={stage.shareId}
token={stage.connectParams.token}
protocol={stage.protocol}
isVisible
/>
) : null}
</div>
</div>
);
}
function Note({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
{icon}
<p className="text-sm">{text}</p>
</div>
);
}
@@ -0,0 +1,287 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Check,
Crown,
Hand,
MonitorUp,
MousePointerClick,
UserMinus,
Users,
X,
} from "lucide-react";
import { Badge } from "@termix/plugin-sdk/ui";
import { Button } from "@termix/plugin-sdk/ui";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@termix/plugin-sdk/ui";
import type { CollabControlRequest, CollabRoomDetail } from "./api";
interface CollabMembersSidebarProps {
detail: CollabRoomDetail;
onClose: () => void;
onInvite: () => void;
onControl: (userId: string | null) => Promise<void>;
onDismissRequest: (userId: string) => Promise<void>;
onRemoveMember: (userId: string) => Promise<void>;
}
export function CollabMembersSidebar({
detail,
onClose,
onInvite,
onControl,
onDismissRequest,
onRemoveMember,
}: CollabMembersSidebarProps) {
const { t } = useTranslation();
const [removing, setRemoving] = useState<{
userId: string;
username: string;
} | null>(null);
const [busyUserId, setBusyUserId] = useState<string | null>(null);
const onlineIds = new Set(detail.online.map((user) => user.userId));
const presenterUserId = detail.stage.presenterUserId;
const canManageControl = detail.isHost || presenterUserId === detail.me;
const canControl =
canManageControl &&
detail.stage.protocol === "ssh" &&
!!detail.stage.shareId;
async function run(userId: string, action: () => Promise<void>) {
setBusyUserId(userId);
try {
await action();
} finally {
setBusyUserId(null);
}
}
async function grant(request: CollabControlRequest) {
await run(request.userId, async () => {
await onControl(request.userId);
});
}
return (
<aside
id="collab-members-sidebar"
aria-label={t("collab.membersPanel")}
className="absolute inset-y-0 right-0 z-30 flex h-full w-[min(20rem,100%)] shrink-0 flex-col border-l border-border bg-background shadow-xl lg:static lg:z-auto lg:shadow-none"
>
<header className="flex h-11 items-center gap-2 border-b border-border px-3">
<Users className="size-4 text-muted-foreground" />
<h2 className="flex-1 text-sm font-semibold">
{t("collab.membersWithCount", { count: detail.members.length })}
</h2>
{detail.isHost && (
<Button size="sm" variant="outline" onClick={onInvite}>
{t("collab.invite")}
</Button>
)}
<Button
size="icon-sm"
variant="ghost"
aria-label={t("common.close")}
onClick={onClose}
>
<X className="size-4" />
</Button>
</header>
{canManageControl && (
<section className="border-b border-border p-3" aria-live="polite">
<div className="mb-2 flex items-center gap-2">
<Hand className="size-3.5 text-muted-foreground" />
<h3 className="text-xs font-semibold uppercase tracking-wide">
{t("collab.controlQueue")}
</h3>
{detail.controlRequests.length > 0 && (
<Badge variant="default" className="ml-auto text-xs">
{detail.controlRequests.length}
</Badge>
)}
</div>
{detail.controlRequests.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("collab.noControlRequests")}
</p>
) : (
<div className="flex flex-col gap-2">
{detail.controlRequests.map((request) => (
<div
key={request.userId}
className="flex items-center gap-2 border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{request.username}
</p>
<p className="text-xs text-muted-foreground">
{new Date(request.requestedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<Button
size="icon-sm"
aria-label={t("collab.grantControlTo", {
name: request.username,
})}
disabled={busyUserId === request.userId}
onClick={() => void grant(request)}
>
<Check className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="outline"
aria-label={t("collab.dismissControlRequest", {
name: request.username,
})}
disabled={busyUserId === request.userId}
onClick={() =>
void run(request.userId, () =>
onDismissRequest(request.userId),
)
}
>
<X className="size-3.5" />
</Button>
</div>
))}
</div>
)}
</section>
)}
<section className="min-h-0 flex-1 overflow-y-auto p-2">
<h3 className="sr-only">{t("collab.members")}</h3>
<div className="flex flex-col gap-1">
{detail.members.map((member) => {
const isOnline = onlineIds.has(member.userId);
const isPresenter = member.userId === presenterUserId;
const hasControl = member.userId === detail.controllerUserId;
return (
<div
key={member.userId}
className="group flex min-h-11 items-center gap-2 border border-transparent px-2 py-1.5 hover:border-border hover:bg-muted/40"
>
<span
className={`size-2 shrink-0 rounded-full ${
isOnline ? "bg-green-500" : "bg-muted-foreground/30"
}`}
aria-label={t(isOnline ? "collab.online" : "collab.offline")}
role="img"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{member.username}
{member.userId === detail.me && (
<span className="ml-1 text-muted-foreground">
{t("collab.you")}
</span>
)}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{member.roomRole === "host" && (
<span className="flex items-center gap-1">
<Crown className="size-3" /> {t("collab.hostBadge")}
</span>
)}
{isPresenter && (
<span className="flex items-center gap-1">
<MonitorUp className="size-3" />
{t("collab.presenterBadge")}
</span>
)}
{hasControl && (
<span className="flex items-center gap-1">
<MousePointerClick className="size-3" />
{t("collab.controlBadge")}
</span>
)}
</div>
</div>
{canControl && !isPresenter && (
<Button
size="icon-sm"
variant={hasControl ? "default" : "ghost"}
aria-label={t(
hasControl
? "collab.revokeControl"
: "collab.grantControlTo",
{ name: member.username },
)}
disabled={busyUserId === member.userId}
onClick={() =>
void run(member.userId, () =>
onControl(hasControl ? null : member.userId),
)
}
>
<MousePointerClick className="size-3.5" />
</Button>
)}
{detail.isHost && member.userId !== detail.room.ownerUserId && (
<Button
size="icon-sm"
variant="ghost"
aria-label={t("collab.removeMember", {
name: member.username,
})}
onClick={() =>
setRemoving({
userId: member.userId,
username: member.username,
})
}
>
<UserMinus className="size-3.5" />
</Button>
)}
</div>
);
})}
</div>
</section>
<AlertDialog
open={Boolean(removing)}
onOpenChange={(open) => {
if (!open) setRemoving(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("collab.removeMemberTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("collab.removeMemberDescription", {
name: removing?.username,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (removing) void onRemoveMember(removing.userId);
}}
>
{t("collab.deleteMember")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</aside>
);
}
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@termix/plugin-sdk/ui";
import { Badge } from "@termix/plugin-sdk/ui";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@termix/plugin-sdk/ui";
import { createCollabRoom, listCollabRooms, type CollabRoom } from "./api";
import { getErrorMessage } from "./shared";
export function CollabPanel({
onOpenRoom,
}: {
onOpenRoom: (room: CollabRoom) => void;
}) {
const { t } = useTranslation();
const [rooms, setRooms] = useState<CollabRoom[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [persistent, setPersistent] = useState(false);
const [creating, setCreating] = useState(false);
const refresh = useCallback(async () => {
try {
const result = await listCollabRooms();
setRooms(result.rooms ?? []);
} catch {
/* the list stays as-is */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = window.setInterval(() => void refresh(), 30000);
return () => window.clearInterval(timer);
}, [refresh]);
async function handleCreate() {
if (!name.trim()) return;
setCreating(true);
try {
const { room } = await createCollabRoom(name.trim(), persistent);
toast.success(t("collab.created"));
setCreateOpen(false);
setName("");
setPersistent(false);
await refresh();
onOpenRoom(room);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreating(false);
}
}
return (
<div className="flex flex-col gap-2 p-2">
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-8 text-xs flex-1 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand font-semibold hover:bg-accent-brand/20"
onClick={() => setCreateOpen(true)}
>
<Plus className="size-3.5 mr-1" />
{t("collab.createRoom")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => void refresh()}
aria-label={t("common.refresh")}
title={t("common.refresh")}
>
<RefreshCw className="size-3.5" />
</Button>
</div>
{loading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : rooms.length === 0 ? (
<p className="text-xs text-muted-foreground px-1 py-4 text-center">
{t("collab.noRooms")}
</p>
) : (
<div className="flex flex-col gap-1">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => onOpenRoom(room)}
className="flex items-center gap-2 px-2 py-1.5 text-left border border-border hover:bg-muted/50"
>
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 text-xs truncate">{room.name}</span>
{room.presenterUserId && (
<span
className="size-1.5 rounded-full bg-red-500 shrink-0"
role="img"
aria-label={t("collab.presenterBadge")}
title={t("collab.presenterBadge")}
/>
)}
{room.persistent && (
<Badge variant="outline" className="text-[9px] px-1 py-0">
{t("collab.persistentRoom")}
</Badge>
)}
</button>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label htmlFor="collab-room-name" className="text-xs font-medium">
{t("collab.roomName")}
</label>
<Input
id="collab-room-name"
placeholder={t("collab.roomName")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
/>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={persistent}
onChange={(e) => setPersistent(e.target.checked)}
/>
<span>
<span className="font-medium">
{t("collab.persistentRoom")}
</span>
<br />
<span className="text-muted-foreground">
{t("collab.persistentRoomHint")}
</span>
</span>
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateOpen(false)}
disabled={creating}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleCreate()}
disabled={creating || !name.trim()}
>
{creating && <Loader2 className="size-3.5 mr-1 animate-spin" />}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,429 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@termix/plugin-sdk/ui";
import { Input } from "@termix/plugin-sdk/ui";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@termix/plugin-sdk/ui";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import {
createSessionShare,
getActiveSessionShares,
revokeSessionShare,
type SessionShareProtocol,
type SessionSharePermissionLevel,
type SessionShareRecord,
getDirectory,
} from "./api";
const EXPIRY_PRESETS = [
{ key: "oneHour", hours: 1 },
{ key: "oneDay", hours: 24 },
{ key: "sevenDays", hours: 24 * 7 },
{ key: "thirtyDays", hours: 24 * 30 },
{ key: "custom", hours: undefined },
] as const;
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
export function ShareSessionModal({
open,
onClose,
hostId,
sessionId,
protocol,
tabInstanceId,
}: {
open: boolean;
onClose: () => void;
hostId: number;
sessionId: string | null;
protocol: SessionShareProtocol;
tabInstanceId?: string;
}) {
const { t } = useTranslation();
const [mode, setMode] = useState<"link" | "user">("link");
const [permissionLevel, setPermissionLevel] =
useState<SessionSharePermissionLevel>("read-only");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("oneDay");
const [customHours, setCustomHours] = useState("");
const [search, setSearch] = useState("");
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [createdLink, setCreatedLink] = useState<string | null>(null);
const [shares, setShares] = useState<SessionShareRecord[]>([]);
const [sharesLoaded, setSharesLoaded] = useState(false);
useEffect(() => {
if (!open) return;
setMode("link");
setPermissionLevel("read-only");
setExpiryPreset("oneDay");
setCustomHours("");
setSearch("");
setSelectedUserId(null);
setCreatedLink(null);
setSharesLoaded(false);
setShares([]);
}, [open, sessionId]);
useEffect(() => {
if (!open || sharesLoaded) return;
setSharesLoaded(true);
Promise.all([
getDirectory().catch(() => ({ users: [] })),
getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
]).then(([usersRes, sharesRes]) => {
setUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.id),
username: u.username,
})),
);
setShares(sharesRes.shares ?? []);
});
}, [open, hostId, sharesLoaded]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? users.filter((u) => u.username.toLowerCase().includes(q))
: users;
}, [users, search]);
const expiryHours = (() => {
if (expiryPreset === "custom") {
const hours = Number(customHours);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
}
return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours;
})();
async function refreshShares() {
try {
const res = await getActiveSessionShares(hostId);
setShares(res.shares ?? []);
} catch {
// silently ignore
}
}
async function handleCreate() {
if (!sessionId) return;
if (mode === "user" && !selectedUserId) return;
if (expiryPreset === "custom" && !expiryHours) return;
setSubmitting(true);
try {
const result = await createSessionShare({
hostId,
sessionId,
tabInstanceId,
protocol,
shareType: mode,
targetUserId:
mode === "user" ? (selectedUserId ?? undefined) : undefined,
permissionLevel,
expiryHours,
});
if (mode === "link" && result.linkToken) {
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
setCreatedLink(url);
toast.success(t("sessionSharing.linkCreated"));
} else {
toast.success(t("sessionSharing.shareCreated"));
setSelectedUserId(null);
}
await refreshShares();
} catch (error) {
const status = (error as { status?: number })?.status;
if (mode === "user" && status === 403) {
toast.error(t("sessionSharing.userLacksHostAccess"));
} else {
toast.error(t("sessionSharing.shareFailed"));
}
} finally {
setSubmitting(false);
}
}
async function handleCopyLink() {
if (!createdLink) return;
try {
await navigator.clipboard.writeText(createdLink);
toast.success(t("sessionSharing.linkCopied"));
} catch {
// clipboard API unavailable, ignore
}
}
async function handleRevoke(shareId: string) {
try {
await revokeSessionShare(shareId);
setShares((prev) => prev.filter((s) => s.id !== shareId));
toast.success(t("sessionSharing.revoked"));
} catch {
toast.error(t("sessionSharing.revokeFailed"));
}
}
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("sessionSharing.modalTitle")}</DialogTitle>
<DialogDescription>
{mode === "link"
? t("sessionSharing.linkModeDescription")
: t("sessionSharing.userModeDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex gap-1.5">
{(["link", "user"] as const).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
setCreatedLink(null);
}}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "link" ? (
<Link2 className="size-3 shrink-0" />
) : (
<User className="size-3 shrink-0" />
)}
{m === "link"
? t("sessionSharing.modeTab.link")
: t("sessionSharing.modeTab.user")}
</button>
))}
</div>
{mode === "user" && (
<>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("sessionSharing.searchUsersPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noUsersFound")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserId === user.id;
return (
<button
key={user.id}
onClick={() => setSelectedUserId(user.id)}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
)}
</div>
</>
)}
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("sessionSharing.permissionLevel.label")}
</span>
<select
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(
e.target.value as SessionSharePermissionLevel,
)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
<option value="read-only">
{t("sessionSharing.permissionLevel.readOnly")}
</option>
<option value="read-write">
{t("sessionSharing.permissionLevel.readWrite")}
</option>
</select>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("sessionSharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("sessionSharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
<p className="text-[11px] text-muted-foreground leading-snug">
{permissionLevel === "read-only"
? t("sessionSharing.permissionLevel.readOnlyDescription")
: t("sessionSharing.permissionLevel.readWriteDescription")}
</p>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
!sessionId ||
submitting ||
(mode === "user" && !selectedUserId) ||
(expiryPreset === "custom" && !expiryHours)
}
onClick={handleCreate}
>
{mode === "link"
? t("sessionSharing.createLinkButton")
: t("sessionSharing.createShareButton")}
</Button>
{createdLink && (
<div className="flex items-center gap-1.5">
<Input readOnly value={createdLink} className="text-xs" />
<Button
variant="outline"
size="icon-sm"
onClick={handleCopyLink}
title={t("sessionSharing.copyLink")}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="flex flex-col gap-0 border-t border-border pt-2">
<div className="flex items-center gap-1.5 pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("sessionSharing.activeShares")}
{shares.length > 0 && (
<span className="text-muted-foreground/40">
({shares.length})
</span>
)}
</div>
<div className="flex flex-col max-h-40 overflow-y-auto">
{shares.length === 0 && (
<div className="px-1 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noActiveShares")}
</div>
)}
{shares.map((share) => {
const targetUser = users.find(
(u) => u.id === share.targetUserId,
);
return (
<div
key={share.id}
className="flex items-center justify-between gap-2 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center gap-1.5 min-w-0">
{share.shareType === "link" ? (
<Link2 className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="truncate font-semibold">
{share.shareType === "link"
? t("sessionSharing.linkShareBadge")
: t("sessionSharing.userShareBadge", {
username:
targetUser?.username ??
share.targetUserId ??
"?",
})}
</span>
<span className="text-[10px] text-muted-foreground/60">
{share.permissionLevel === "read-write"
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
{" · "}
{t("sessionSharing.expiresAt", {
date: new Date(share.expiresAt).toLocaleString(),
})}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
onClick={() => handleRevoke(share.id)}
>
{t("sessionSharing.revoke")}
</Button>
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,355 @@
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { AlertCircle, Eye, Users } from "lucide-react";
import {
resolveShareLink,
type ResolvedShareLink,
type ShareLinkErrorKind,
} from "./api";
import { Loader, RemoteDisplay, wsUrlForPath } from "./shared";
const PING_INTERVAL_MS = 30000;
interface TerminalWsMessage {
type: string;
data?: string;
[key: string]: unknown;
}
// A shared session link always resolves against the desktop app's embedded
// local backend: joining a session on someone else's remote server is not
// supported from the desktop app.
async function resolveTerminalWsUrl(wsPath: string): Promise<string> {
const url = await wsUrlForPath(wsPath);
if (!url) throw new Error("No terminal endpoint is available");
return url;
}
export interface SessionParticipantInfo {
isOwner: boolean;
permissionLevel: "read-write" | "read-only";
label: string | null;
}
function ParticipantsBadge({
participants,
ownerLabel,
}: {
participants: SessionParticipantInfo[];
ownerLabel: string;
}) {
if (participants.length < 2) return null;
const names = participants
.map((participant) =>
participant.isOwner ? ownerLabel : (participant.label ?? "?"),
)
.join(", ");
return (
<div
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
title={names}
>
<Users className="size-3.5" />
{participants.length}
</div>
);
}
function ReadOnlyBadge({ label }: { label: string }) {
return (
<div
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
>
<Eye className="size-3.5" />
{label}
</div>
);
}
function CenteredMessage({
icon,
message,
}: {
icon: React.ReactNode;
message: string;
}) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4 w-full"
style={{ backgroundColor: "var(--bg-base)" }}
>
{icon}
<p
className="text-sm font-semibold text-center max-w-xs"
style={{ color: "var(--foreground)" }}
>
{message}
</p>
</div>
);
}
export function GuestTerminalView({
share,
wsPath,
hideBadges = false,
onParticipantsChange,
}: {
share: Pick<ResolvedShareLink, "permissionLevel">;
/** The socket path the backend handed out, query string included. */
wsPath: string;
/** Suppress the built-in overlay badges when the host page renders its own. */
hideBadges?: boolean;
onParticipantsChange?: (participants: SessionParticipantInfo[]) => void;
}) {
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const [ended, setEnded] = useState<string | null>(null);
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
[],
);
const wsRef = useRef<WebSocket | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onParticipantsChangeRef = useRef(onParticipantsChange);
onParticipantsChangeRef.current = onParticipantsChange;
useEffect(() => {
if (!terminal || !xtermRef.current) return;
terminal.options.theme = { background: "#0c0d0b" };
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(xtermRef.current);
fitAddon.fit();
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(xtermRef.current);
let cancelled = false;
let ws: WebSocket | null = null;
resolveTerminalWsUrl(wsPath).then((url) => {
if (cancelled) return;
ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
pingIntervalRef.current = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, PING_INTERVAL_MS);
};
ws.onmessage = (event) => {
let msg: TerminalWsMessage;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
switch (msg.type) {
case "data":
if (typeof msg.data === "string") terminal.write(msg.data);
break;
case "participants":
if (Array.isArray(msg.participants)) {
const next = msg.participants as SessionParticipantInfo[];
setParticipants(next);
onParticipantsChangeRef.current?.(next);
}
break;
case "sessionExpired":
case "sessionTerminatedByOwner":
case "session_ended":
setEnded(t("sessionSharing.guestView.sessionEnded"));
break;
default:
break;
}
};
ws.onclose = () => {
setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded"));
};
if (share.permissionLevel === "read-write") {
terminal.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
}
});
return () => {
cancelled = true;
resizeObserver.disconnect();
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
ws?.close();
wsRef.current = null;
};
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, wsPath]);
return (
<div className="relative w-full h-full">
{!hideBadges && (
<>
<ParticipantsBadge
participants={participants}
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
/>
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge
label={t("sessionSharing.guestView.readOnlyBadge")}
/>
)}
</>
)}
{ended && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={ended}
/>
</div>
)}
<div ref={xtermRef} className="w-full h-full" />
</div>
);
}
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
const { t } = useTranslation();
const [connectionError, setConnectionError] = useState<string | null>(null);
if (!share.connectParams?.token) {
return (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={t("sessionSharing.guestView.linkInvalid")}
/>
);
}
return (
<div className="relative w-full h-full">
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
)}
{connectionError && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={connectionError}
/>
</div>
)}
<RemoteDisplay
token={share.connectParams.token}
protocol={share.protocol}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
</div>
);
}
export default function SharedSessionView() {
const { t } = useTranslation();
const [share, setShare] = useState<ResolvedShareLink | null>(null);
const [linkToken, setLinkToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (!token) {
setError(t("sessionSharing.guestView.linkInvalid"));
setLoading(false);
return;
}
setLinkToken(token);
resolveShareLink(token)
.then((resolved) => setShare(resolved))
.catch((err) => {
const kind = (err as { kind?: ShareLinkErrorKind })?.kind;
if (kind === "rate-limited") {
setError(t("sessionSharing.guestView.rateLimited"));
} else {
setError(t("sessionSharing.guestView.linkInvalid"));
}
})
.finally(() => setLoading(false));
}, [t]);
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div className="relative flex-1 min-h-0">
{loading && <Loader message={t("sessionSharing.guestView.loading")} />}
{!loading && error && (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={error}
/>
)}
{!loading &&
!error &&
share &&
linkToken &&
(share.protocol === "ssh" ? (
<GuestTerminalView share={share} wsPath={share.wsPath} />
) : (
<GuestGuacamoleView share={share} />
))}
</div>
</div>
);
}
+410
View File
@@ -0,0 +1,410 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
/**
* The plugin's own /plugin-api/session-sharing client, bound in activate.
* Guest pages use it too: the two guest routes are public.
*/
let client: PluginApiClient | null = null;
export function bindApi(api: PluginApiClient | null): void {
client = api;
}
function api(): PluginApiClient {
if (!client) throw new Error("Session sharing is not running");
return client;
}
interface HttpError {
response?: { status?: number; data?: { error?: string } };
}
function statusOf(error: unknown): number | undefined {
return (error as HttpError)?.response?.status;
}
/** The server's own message, like core's handleApiError. */
function apiError(error: unknown, fallback: string): Error {
const message = (error as HttpError)?.response?.data?.error;
return new Error(message || fallback);
}
async function call<T>(
request: () => Promise<{ data: T }>,
fallback: string,
): Promise<T> {
try {
return (await request()).data;
} catch (error) {
throw apiError(error, fallback);
}
}
// ---------------------------------------------------------------------------
// Guest share links
// ---------------------------------------------------------------------------
export interface ResolvedShareLink {
protocol: "ssh" | "rdp" | "vnc" | "telnet";
permissionLevel: "read-only" | "read-write";
wsPath: string;
connectParams?: { token: string };
}
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
export class ShareLinkError extends Error {
constructor(
message: string,
public readonly kind: ShareLinkErrorKind,
) {
super(message);
this.name = "ShareLinkError";
}
}
export async function resolveShareLink(
linkToken: string,
): Promise<ResolvedShareLink> {
try {
const response = await api().get<ResolvedShareLink>(
`/resolve/${encodeURIComponent(linkToken)}`,
);
return response.data;
} catch (error) {
const status = statusOf(error);
if (status === 404) {
throw new ShareLinkError(
"Share link is invalid, expired, or revoked",
"not-found",
);
}
if (status === 429) {
throw new ShareLinkError(
"Too many attempts, please try again shortly",
"rate-limited",
);
}
throw new ShareLinkError("Failed to resolve share link", "unknown");
}
}
// ---------------------------------------------------------------------------
// Owner-side shares
// ---------------------------------------------------------------------------
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
export type SessionShareType = "link" | "user";
export type SessionSharePermissionLevel = "read-only" | "read-write";
export interface SessionShareRecord {
id: string;
hostId: number;
ownerUserId: string;
protocol: SessionShareProtocol;
sessionId: string;
tabInstanceId: string | null;
shareType: SessionShareType;
targetUserId: string | null;
linkToken: string | null;
permissionLevel: SessionSharePermissionLevel;
createdAt: string;
expiresAt: string;
revokedAt: string | null;
lastJoinedAt: string | null;
joinCount: number;
}
export interface CreateSessionShareRequest {
hostId: number;
sessionId: string;
tabInstanceId?: string;
protocol: SessionShareProtocol;
shareType: SessionShareType;
targetUserId?: string;
permissionLevel: SessionSharePermissionLevel;
expiryHours?: number;
}
export interface CreateSessionShareResponse {
shareId: string;
linkToken: string | null;
expiresAt: string;
}
export function createSessionShare(
request: CreateSessionShareRequest,
): Promise<CreateSessionShareResponse> {
return call(
() => api().post<CreateSessionShareResponse>("/create", request),
"Failed to create session share",
);
}
export function getActiveSessionShares(
hostId: number,
): Promise<{ shares: SessionShareRecord[] }> {
return call(
() => api().get<{ shares: SessionShareRecord[] }>(`/host/${hostId}/active`),
"Failed to fetch active session shares",
);
}
export function revokeSessionShare(shareId: string): Promise<unknown> {
return call(
() => api().delete(`/${encodeURIComponent(shareId)}`),
"Failed to revoke session share",
);
}
export interface DirectoryUser {
id: string;
username: string;
}
export interface DirectoryRole {
id: number;
name: string;
displayName: string | null;
}
/** Users and roles to share with or invite. */
export function getDirectory(): Promise<{
users: DirectoryUser[];
roles: DirectoryRole[];
}> {
return call(
() =>
api().get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
"/directory",
),
"Failed to list users and roles",
);
}
/** Live sessions other users shared with the caller, as active-session rows. */
export function getSharedWithMe(): Promise<unknown[]> {
return call(
() => api().get<unknown[]>("/shared-with-me"),
"Failed to list shared sessions",
);
}
// ---------------------------------------------------------------------------
// Collab rooms
// ---------------------------------------------------------------------------
export interface CollabRoom {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
guestLinkEnabled: boolean;
createdAt: string;
endedAt: string | null;
}
export interface CollabRoomMember {
userId: string;
username: string;
roomRole: string;
createdAt: string;
}
export interface CollabOnlineUser {
userId: string;
username: string;
}
export interface CollabStage {
presenterUserId: string | null;
protocol: "ssh" | "rdp" | "vnc" | "telnet" | null;
hostId: number | null;
shareId: string | null;
sessionId?: string;
controllerUserId?: string | null;
connectParams?: { token: string };
}
export interface CollabControlRequest {
userId: string;
username: string;
requestedAt: string;
}
export interface CollabRoomDetail {
room: CollabRoom;
me: string;
isHost: boolean;
members: CollabRoomMember[];
online: CollabOnlineUser[];
stage: CollabStage;
controllerUserId: string | null;
controlRequests: CollabControlRequest[];
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
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;
wsPath?: string;
connectParams?: { token: string };
}
/** Anonymous: guests poll this to follow the presenter. Throws on 404/429. */
export async function resolveCollabGuestStage(
token: string,
): Promise<{ roomName: string; stage: CollabGuestStage | null }> {
const response = await api().get<{
roomName: string;
stage: CollabGuestStage | null;
}>(`/guest/${encodeURIComponent(token)}`);
return response.data;
}
@@ -0,0 +1,233 @@
import { useEffect, type ComponentType } from "react";
import { Presentation, Share2 } from "lucide-react";
import { toast } from "sonner";
import {
useCurrentUser,
usePermission,
type PanelProps,
type StandaloneViewProps,
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { bindApi, getSharedWithMe, listCollabRooms } from "./api";
import CollabGuestView from "./CollabGuestView";
import { CollabPanel } from "./CollabPanel";
import { CollabRoomTab } from "./CollabRoomTab";
import SharedSessionView from "./SharedSessionView";
import {
ShareDialogHost,
closeShareDialog,
openShareDialog,
type ShareTarget,
} from "./share-dialog";
import { REMOTE_DISPLAY_SLOT } from "./shared";
const COLLAB = "collab";
const SHARE_ACTION = "session-sharing.share";
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
const INVITE_POLL_MS = 60_000;
/** `?view=shared` and `?view=collab-guest`, the two anonymous guest links. */
function GuestView({ view }: StandaloneViewProps) {
return view === "shared" ? <SharedSessionView /> : <CollabGuestView />;
}
function CollabRoomTabView({ tab, isVisible }: TabProps) {
const roomId = tab.data?.roomId;
return (
<CollabRoomTab
roomId={typeof roomId === "string" ? roomId : undefined}
isVisible={isVisible}
/>
);
}
function CollabPanelView({ shell }: PanelProps) {
return (
<CollabPanel
onOpenRoom={(room) =>
shell.openTab(null, COLLAB, {
label: room.name,
forceNewTab: true,
data: { roomId: room.id },
})
}
/>
);
}
/**
* Rooms are discovered by polling, so a room this browser has never shown
* gets one toast with an Open action. The first poll after login only
* records what already exists.
*/
function createInviteWatcher(app: TermixApp): ComponentType {
return function InviteWatcher() {
const user = useCurrentUser();
const allowed = usePermission("use");
const userId = user?.userId;
useEffect(() => {
if (!userId || !allowed) return;
let cancelled = false;
const check = async () => {
try {
const { rooms = [] } = await listCollabRooms();
if (cancelled) return;
let seen: string[] = [];
try {
seen = JSON.parse(localStorage.getItem(SEEN_ROOMS_KEY) ?? "[]");
} catch {
seen = [];
}
const seenSet = new Set(seen);
const fresh = rooms.filter((room) => !seenSet.has(room.id));
if (fresh.length === 0) return;
localStorage.setItem(
SEEN_ROOMS_KEY,
JSON.stringify([...seenSet, ...fresh.map((room) => room.id)]),
);
if (seen.length === 0) return;
for (const room of fresh) {
if (room.ownerUserId === userId) continue;
toast(app.t("collab.invitedTo", { name: room.name }), {
action: {
label: app.t("collab.openRoom"),
onClick: () => app.tabs.openRailView(COLLAB),
},
});
}
} catch {
/* next poll */
}
};
void check();
const timer = setInterval(() => void check(), INVITE_POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [userId, allowed]);
return null;
};
}
/** A terminal toolbar action is invoked with the terminal's slot API. */
interface TerminalSlotApi {
getShareTarget?: () => ShareTarget | null;
}
/** A remote desktop toolbar action is invoked with the session it shows. */
interface RemoteDesktopToolbarContext {
hostId: number;
sessionId: string | null;
protocol: "rdp" | "vnc" | "telnet";
tabInstanceId?: string;
}
function isRemoteDesktopContext(
value: unknown,
): value is RemoteDesktopToolbarContext {
return (
!!value &&
typeof value === "object" &&
"protocol" in value &&
"hostId" in value
);
}
export function activate(app: TermixApp): void {
bindApi(app.api);
app.onDispose(() => {
closeShareDialog();
bindApi(null);
});
// Rooms and share links draw remote desktop streams through this slot.
app.declareActionSlot({ id: REMOTE_DISPLAY_SLOT, accepts: ["component"] });
// Guest pages open the tab type's standalone view by ?view= name.
const guestViews = {
standalone: GuestView,
standaloneViews: ["shared", "collab-guest"],
};
if (app.guest) {
app.registerTab(COLLAB, () => null, { hostless: true, ...guestViews });
return;
}
app.registerRailItem({
id: COLLAB,
icon: Presentation,
titleKey: "nav.collab",
kind: "panel",
after: "connections",
separatorAfter: true,
permission: "use",
});
app.registerPanel(COLLAB, CollabPanelView);
app.registerTab(COLLAB, CollabRoomTabView, {
icon: Presentation,
titleKey: "nav.collab",
hostless: true,
multiInstance: true,
...guestViews,
});
app.registerAction(
SHARE_ACTION,
(context: unknown) => {
let target: ShareTarget | null = null;
if (isRemoteDesktopContext(context)) {
target = context.sessionId
? {
hostId: context.hostId,
sessionId: context.sessionId,
protocol: context.protocol,
tabInstanceId: context.tabInstanceId,
}
: null;
} else {
target = (context as TerminalSlotApi)?.getShareTarget?.() ?? null;
}
if (target) openShareDialog(target);
else toast.error(app.t("sessionSharing.notReadyToShare"));
},
{ permission: "use" },
);
for (const slot of ["terminal.toolbar", "remote-desktop.toolbar"]) {
app.registerSlotContribution(slot, {
actionId: SHARE_ACTION,
titleKey: "sessionSharing.shareButton",
icon: Share2,
kind: "button",
});
}
app.registerSlotContribution("shell.overlay", {
actionId: "session-sharing.shareDialog",
titleKey: "sessionSharing.shareButton",
kind: "component",
component: ShareDialogHost as ComponentType<Record<string, unknown>>,
});
app.registerSlotContribution("shell.overlay", {
actionId: "session-sharing.inviteWatcher",
titleKey: "nav.collab",
kind: "component",
component: createInviteWatcher(app) as ComponentType<
Record<string, unknown>
>,
});
// Core's active connections list asks for sessions shared with the user.
app.registerAction(SHARED_WITH_ME_ACTION, () => getSharedWithMe(), {
permission: "use",
});
}
export function deactivate(): void {
closeShareDialog();
}
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from "react";
import { ShareSessionModal } from "./ShareSessionModal";
import type { SessionShareProtocol } from "./api";
/** What the share dialog needs to share one live session. */
export interface ShareTarget {
hostId: number;
sessionId: string;
protocol: SessionShareProtocol;
tabInstanceId?: string;
}
let current: ShareTarget | null = null;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
/** Opens the share dialog, which the shell overlay slot keeps mounted. */
export function openShareDialog(target: ShareTarget): void {
current = target;
emit();
}
export function closeShareDialog(): void {
current = null;
emit();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Rendered once in "shell.overlay"; shows the dialog when a toolbar asks. */
export function ShareDialogHost() {
const target = useSyncExternalStore(subscribe, () => current);
if (!target) return null;
return (
<ShareSessionModal
open
onClose={closeShareDialog}
hostId={target.hostId}
sessionId={target.sessionId}
protocol={target.protocol}
tabInstanceId={target.tabInstanceId}
/>
);
}
@@ -0,0 +1,90 @@
import { useMemo } from "react";
import {
invokeAction,
useSlotContributions,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { pluginWsUrl } from "@termix/plugin-sdk/ui";
/**
* A remote desktop stream drawn by whichever plugin provides one. Rooms and
* share links show RDP, VNC and Telnet without knowing how they are drawn:
* the provider contributes a component to this slot and answers the token
* action below.
*/
export const REMOTE_DISPLAY_SLOT = "session.remoteDisplay";
export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
export interface RemoteDisplayProps {
token: string;
protocol: string;
isVisible: boolean;
onConnect?: () => void;
onError?: (error: string) => void;
}
export function RemoteDisplay(props: RemoteDisplayProps) {
const { t } = useTranslation();
const contributions = useSlotContributions(REMOTE_DISPLAY_SLOT);
const provider = useMemo(
() =>
contributions.find(
(contribution) =>
contribution.kind === "component" && contribution.component,
),
[contributions],
);
if (!provider?.component) {
return (
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
{t("sessionSharing.remoteDisplayUnavailable")}
</div>
);
}
const Display = provider.component;
return <Display {...(props as unknown as Record<string, unknown>)} />;
}
export interface RemoteSessionToken {
token: string;
connectionId: string;
}
/** Mints a session token for presenting a host, through the provider. */
export async function createRemoteSessionToken(
hostId: number,
origin: unknown,
protocol: string,
): Promise<RemoteSessionToken | null> {
return ((await invokeAction(
REMOTE_SESSION_TOKEN_ACTION,
hostId,
origin,
protocol,
)) ?? null) as RemoteSessionToken | null;
}
/** A socket URL for a /plugin-ws/<id>/<path>?query path the server handed out. */
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
const [pathname, query] = wsPath.split("?", 2);
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
if (!match) return null;
const target = await pluginWsUrl(match[1], match[2]);
if (!target) return null;
if (!query) return target.url;
return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return typeof error === "string" ? error : String(error);
}
export function Loader({ message }: { message: string }) {
return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-background">
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}