- Inject the site account Cookie (manual + auto-login session, session wins)
into the camofox profile before the first tab opens; password accounts
without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
every 10 min of activity, and from refresh-login when the stored cookie is
invalid (verified before saving). user_info.cookieOrigin records
source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
logged in (logout link present), 5 cookies read back.
Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
216 lines
8.1 KiB
TypeScript
216 lines
8.1 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { SiteListItem } from "@umt/contracts";
|
|
import { Hand, Image as ImageIcon, LoaderCircle, Monitor, Power, RotateCw, Undo2 } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { useBrowserControl, useCloseBrowserSession, useSiteBrowserSession } from "~/api/browser.ts";
|
|
import { LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { apiData, errorMessage } from "~/lib/api.ts";
|
|
import { qk } from "~/lib/query-keys.ts";
|
|
import { CONTROLLER_LABEL, credentialBadge, holderName, waitingText } from "./coords.ts";
|
|
import { SiteOriginView } from "./site-origin-view.tsx";
|
|
|
|
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
|
|
// 画面就是 Agent 正在驱动的同一个浏览器窗口。键鼠权限由服务端按人工租约决定:未接管只能看,接管后可直接点和输入。
|
|
// 桌面不可用(旧镜像 / 未配置)或当前不是安全上下文(HTTP)时回退截图模式,并说明原因。
|
|
|
|
interface DesktopStatus {
|
|
available: boolean;
|
|
reason: string | null;
|
|
role: "controller" | "viewer";
|
|
leaseExpiresAt: string | null;
|
|
path: string;
|
|
requiresSecureContext: true;
|
|
}
|
|
|
|
function useDesktopStatus(sessionId: string | null) {
|
|
return useQuery({
|
|
queryKey: qk.sub("browser", "desktop", sessionId ?? ""),
|
|
queryFn: ({ signal }) =>
|
|
apiData<DesktopStatus>("/browser/desktop/{id}/_status", { pathParams: { id: sessionId ?? "" }, signal }),
|
|
enabled: !!sessionId,
|
|
refetchInterval: 10_000,
|
|
retry: false,
|
|
});
|
|
}
|
|
|
|
const CONTROLLER_TONE: Record<string, StatusTone> = {
|
|
automation: "neutral",
|
|
human: "success",
|
|
waiting: "warning",
|
|
closed: "neutral",
|
|
};
|
|
|
|
type Mode = "auto" | "screenshot";
|
|
|
|
/** 原站视图 Tab:能用桌面模式就用,否则截图模式 */
|
|
export function SiteOriginPanel({ site }: { site: SiteListItem }) {
|
|
const session = useSiteBrowserSession(site.id);
|
|
const s = session.data ?? null;
|
|
const status = useDesktopStatus(s && s.controller !== "closed" ? s.id : null);
|
|
const [mode, setMode] = useState<Mode>("auto");
|
|
const [secure, setSecure] = useState(true);
|
|
useEffect(() => setSecure(window.isSecureContext), []);
|
|
|
|
const desktop = status.data;
|
|
const canDesktop = !!desktop?.available && secure;
|
|
if (s && status.isPending && s.controller !== "closed") return <LoadingState rows={4} />;
|
|
if (!s || !canDesktop || mode === "screenshot") {
|
|
let note: string | null = null;
|
|
if (s && desktop && !desktop.available && desktop.reason) note = `桌面模式不可用:${desktop.reason}。`;
|
|
else if (s && desktop?.available && !secure) note = "桌面模式需要通过 HTTPS 访问控制台,当前使用截图模式。";
|
|
return (
|
|
<div className="space-y-3">
|
|
{note || (canDesktop && mode === "screenshot") ? (
|
|
<div className="flex flex-wrap items-center gap-2 text-caption text-muted-foreground">
|
|
{note ? <span>{note}</span> : null}
|
|
{canDesktop ? (
|
|
<Button variant="outline" size="xs" className="ml-auto" onClick={() => setMode("auto")}>
|
|
<Monitor />
|
|
桌面模式
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<SiteOriginView site={site} />
|
|
</div>
|
|
);
|
|
}
|
|
return <DesktopView site={site} status={desktop} onScreenshotMode={() => setMode("screenshot")} />;
|
|
}
|
|
|
|
function DesktopView({
|
|
site,
|
|
status,
|
|
onScreenshotMode,
|
|
}: {
|
|
site: SiteListItem;
|
|
status: DesktopStatus;
|
|
onScreenshotMode: () => void;
|
|
}) {
|
|
const session = useSiteBrowserSession(site.id);
|
|
const s = session.data ?? null;
|
|
const control = useBrowserControl(site.id);
|
|
const close = useCloseBrowserSession(site.id);
|
|
const qc = useQueryClient();
|
|
const refreshRole = () => void qc.invalidateQueries({ queryKey: qk.sub("browser", "desktop") });
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
const [loaded, setLoaded] = useState(false);
|
|
|
|
// 控制权变化(接管 / 交还 / 过期)后重连,Selkies 按新口令认证;服务端也会主动断开旧连接
|
|
const role = status.role;
|
|
const prevRole = useRef(role);
|
|
useEffect(() => {
|
|
if (prevRole.current !== role) {
|
|
prevRole.current = role;
|
|
setLoaded(false);
|
|
setReloadKey((k) => k + 1);
|
|
}
|
|
}, [role]);
|
|
|
|
if (!s) return <LoadingState rows={4} />;
|
|
const human = s.controller === "human";
|
|
const mine = role === "controller";
|
|
const busy = control.acquire.isPending || control.release.isPending;
|
|
const waiting = waitingText(s.waitingReason);
|
|
const cred = credentialBadge(s.credential);
|
|
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
|
|
const conn: { tone: StatusTone; text: string } = loaded
|
|
? mine
|
|
? { tone: "success", text: "已连接,可操作" }
|
|
: { tone: "success", text: "已连接,只读" }
|
|
: { tone: "neutral", text: "连接中" };
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
|
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
|
当前控制:{CONTROLLER_LABEL[s.controller]}
|
|
{human && leaseHolder ? `(${leaseHolder})` : ""}
|
|
</StatusBadge>
|
|
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
|
|
{cred ? (
|
|
<span title={cred.title}>
|
|
<StatusBadge tone={cred.tone}>{cred.text}</StatusBadge>
|
|
</span>
|
|
) : null}
|
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
|
{human && mine ? (
|
|
<Button
|
|
size="sm"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
control.release.mutate(s.id, {
|
|
onSuccess: () => {
|
|
refreshRole();
|
|
toast.success("已交还,Agent 会重新读取页面");
|
|
},
|
|
onError: (e) => toast.error(errorMessage(e)),
|
|
})
|
|
}
|
|
>
|
|
<Undo2 />
|
|
交还
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
disabled={busy || s.controller === "closed" || human}
|
|
onClick={() =>
|
|
control.acquire.mutate(s.id, { onSuccess: refreshRole, onError: (e) => toast.error(errorMessage(e)) })
|
|
}
|
|
>
|
|
{busy ? <LoaderCircle className="animate-spin" /> : <Hand />}
|
|
接管
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setLoaded(false);
|
|
setReloadKey((k) => k + 1);
|
|
}}
|
|
>
|
|
<RotateCw />
|
|
重新连接
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={onScreenshotMode}>
|
|
<ImageIcon />
|
|
截图模式
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={close.isPending}
|
|
onClick={() => close.mutate(s.id, { onError: (e) => toast.error(errorMessage(e)) })}
|
|
>
|
|
<Power />
|
|
<span className="sr-only sm:not-sr-only">关闭会话</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-caption text-muted-foreground">
|
|
{mine
|
|
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
|
|
: "只读观看,Agent 可能正在操作。需要自己点或输入时先接管。"}
|
|
</p>
|
|
|
|
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
|
|
<iframe
|
|
key={reloadKey}
|
|
title="浏览器桌面"
|
|
src={status.path}
|
|
onLoad={() => setLoaded(true)}
|
|
allow="clipboard-read; clipboard-write; fullscreen; keyboard-map"
|
|
className="block aspect-[1280/900] w-full border-0 bg-scrim"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|