dashboard (#1034)
* dashboard * fix: re-apply download behavior on recording context (#1019) * fix: re-apply download behavior on recording context record start creates a new browser context via Target.createBrowserContext. Browser.setDownloadBehavior called at launch only applies to the default context, so downloads in the recording context are silently dropped. Fix: 1. Store download_path on BrowserManager (from LaunchOptions) 2. After creating the recording context, call Browser.setDownloadBehavior with the new browserContextId This ensures downloads work during recording. Fixes #1018 * fix: add download_path to third BrowserManager constructor (auto_connect_cdp) * fix: reap zombie Chrome process and fast-detect crash for auto-restart (#1023) When Chrome crashes (e.g. SIGTRAP from CHECK() assertion), the daemon now: 1. Reaps the zombie immediately via a SIGCHLD handler in the event loop that calls waitpid(-1, WNOHANG) 2. Detects the crash instantly on the next command via a non-blocking try_wait() check (has_process_exited), avoiding the 3-second CDP timeout that is_connection_alive() would incur 3. Auto-relaunches Chrome transparently for the caller Fixes #1017 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * fix: route keyboard type through text input (#1014) * fix: handle --clear flag in console command (#1015) The console and errors commands parsed --clear from CLI args but the action handlers silently ignored the flag. The handlers did not accept the cmd parameter so they had no way to read the clear field. Changes: - Add clear_console() method to EventTracker in network.rs - Update handle_console to accept cmd, read the clear field, and clear the buffer when --clear is passed (returns {cleared: true}) - Update call site in execute_command to pass cmd Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com> * chore: patch release - ### Bug Fixes - **Re-apply download behavior on r... (#1025) * Add runtime stream enable/disable/status commands (#951) * Add runtime stream management commands * Run rustfmt and satisfy clippy * Fix stream disable cleanup semantics * Format stream disable regression tests * fix: retain radio/checkbox elements in compact snapshot tree (#1008) compact_tree() checked for "[ref=" to identify lines worth keeping, but radio and checkbox elements render as e.g. [checked=false, ref=e1] where the "[" opens before "checked=", not "ref=". Dropping the leading bracket so the check is just "ref=" fixes the match for all elements with refs. Fixes #1006 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * chore: version packages (#1027) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fixes * dashboard * fixes * remove observe * fmt * fixes * fixes * jotai * fmt * upload dashboard --------- Co-authored-by: Stefan Smiljkovic <stefan@vanila.io> Co-authored-by: ctate <366502+ctate@users.noreply.github.com> Co-authored-by: zhanba <c5e1856@gmail.com> Co-authored-by: xuyongliang <478439790@qq.com> Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com> Co-authored-by: Thomas Kosiewski <thoma471@googlemail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ctate
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Stefan Smiljkovic
zhanba
xuyongliang
xuyongliang
Thomas Kosiewski
parent
63f03b8e06
commit
f9174513c2
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import type { ActivityEvent } from "@/types";
|
||||
import { streamEventsAtom } from "@/store/stream";
|
||||
import { activeSessionNameAtom } from "@/store/sessions";
|
||||
|
||||
const PERSIST_KEY = "ab-persist-activity";
|
||||
const MAX_PERSISTED = 500;
|
||||
|
||||
function activityStorageKey(session: string) {
|
||||
return `ab-activity-${session}`;
|
||||
}
|
||||
|
||||
function loadPersistedEvents(session: string): ActivityEvent[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(activityStorageKey(session));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function savePersistedEvents(session: string, events: ActivityEvent[]) {
|
||||
try {
|
||||
const capped = events.slice(-MAX_PERSISTED);
|
||||
localStorage.setItem(activityStorageKey(session), JSON.stringify(capped));
|
||||
} catch {
|
||||
// Storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function clearPersistedEvents(session: string) {
|
||||
try {
|
||||
localStorage.removeItem(activityStorageKey(session));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const persistActivityAtom = atom(
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem(PERSIST_KEY) === "true"
|
||||
: false,
|
||||
);
|
||||
|
||||
export const restoredEventsAtom = atom<ActivityEvent[]>([]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const combinedEventsAtom = atom((get) => {
|
||||
const persist = get(persistActivityAtom);
|
||||
const restored = get(restoredEventsAtom);
|
||||
const streamEvents = get(streamEventsAtom);
|
||||
|
||||
if (persist && restored.length > 0) {
|
||||
return [...restored, ...streamEvents].slice(-MAX_PERSISTED);
|
||||
}
|
||||
return streamEvents;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const togglePersistAtom = atom(null, (get, set) => {
|
||||
const next = !get(persistActivityAtom);
|
||||
set(persistActivityAtom, next);
|
||||
localStorage.setItem(PERSIST_KEY, String(next));
|
||||
|
||||
if (!next) {
|
||||
const session = get(activeSessionNameAtom);
|
||||
if (session) clearPersistedEvents(session);
|
||||
set(restoredEventsAtom, []);
|
||||
}
|
||||
});
|
||||
|
||||
export const clearActivityAtom = atom(null, (get, set) => {
|
||||
set(streamEventsAtom, []);
|
||||
set(restoredEventsAtom, []);
|
||||
const session = get(activeSessionNameAtom);
|
||||
if (session) clearPersistedEvents(session);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync hook -- call once to keep localStorage in sync with atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useActivitySync() {
|
||||
const persist = useAtomValue(persistActivityAtom);
|
||||
const session = useAtomValue(activeSessionNameAtom);
|
||||
const combinedEvents = useAtomValue(combinedEventsAtom);
|
||||
const setRestored = useSetAtom(restoredEventsAtom);
|
||||
|
||||
// Load persisted events when session changes
|
||||
useEffect(() => {
|
||||
if (persist && session) {
|
||||
setRestored(loadPersistedEvents(session));
|
||||
} else {
|
||||
setRestored([]);
|
||||
}
|
||||
}, [persist, session, setRestored]);
|
||||
|
||||
// Save combined events to localStorage when persist is on
|
||||
useEffect(() => {
|
||||
if (persist && session && combinedEvents.length > 0) {
|
||||
savePersistedEvents(session, combinedEvents);
|
||||
}
|
||||
}, [persist, session, combinedEvents]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Provider } from "jotai";
|
||||
|
||||
export function JotaiProvider({ children }: { children: React.ReactNode }) {
|
||||
return <Provider>{children}</Provider>;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import type { SessionInfo } from "@/types";
|
||||
import { execCommand, killSession, sessionArgs } from "@/lib/exec";
|
||||
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
|
||||
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
|
||||
|
||||
function getPort(): number {
|
||||
if (typeof window === "undefined") return 9223;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const p = params.get("port");
|
||||
return p ? parseInt(p, 10) || 9223 : 9223;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT = 4848;
|
||||
|
||||
function getSessionsUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const origin = window.location.origin;
|
||||
if (origin.includes(`:${DASHBOARD_PORT}`)) {
|
||||
return "/api/sessions";
|
||||
}
|
||||
}
|
||||
return `http://localhost:${DASHBOARD_PORT}/api/sessions`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const activePortAtom = atom(getPort());
|
||||
|
||||
export const polledSessionsAtom = atom<SessionInfo[]>([]);
|
||||
|
||||
export const pendingSessionsAtom = atom<{ session: string; engine: string }[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
export const closingSessionsAtom = atom<Set<string>>(new Set<string>());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const sessionsAtom = atom((get) => {
|
||||
const polled = get(polledSessionsAtom);
|
||||
const pending = get(pendingSessionsAtom);
|
||||
const closing = get(closingSessionsAtom);
|
||||
|
||||
const polledNames = new Set(polled.map((s) => s.session));
|
||||
const pendingEntries = pending
|
||||
.filter((p) => !polledNames.has(p.session))
|
||||
.map((p) => ({
|
||||
session: p.session,
|
||||
port: 0,
|
||||
engine: p.engine,
|
||||
pending: true as const,
|
||||
}));
|
||||
const merged = polled.map((s) =>
|
||||
closing.has(s.session) ? { ...s, closing: true as const } : s,
|
||||
);
|
||||
return [...merged, ...pendingEntries];
|
||||
});
|
||||
|
||||
export const activeSessionInfoAtom = atom((get) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const port = get(activePortAtom);
|
||||
return sessions.find((s) => s.port === port);
|
||||
});
|
||||
|
||||
export const activeSessionNameAtom = atom(
|
||||
(get) => get(activeSessionInfoAtom)?.session ?? "",
|
||||
);
|
||||
|
||||
export const activeExtensionsAtom = atom((get) => {
|
||||
const info = get(activeSessionInfoAtom);
|
||||
return (
|
||||
(info && "extensions" in info ? info.extensions : undefined) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createSessionAtom = atom(
|
||||
null,
|
||||
(
|
||||
_get,
|
||||
set,
|
||||
{ name, engine }: { name: string; engine: string },
|
||||
) => {
|
||||
set(pendingSessionsAtom, (prev) => [...prev, { session: name, engine }]);
|
||||
execCommand(["--session", name, "--engine", engine, "open", "about:blank"]);
|
||||
},
|
||||
);
|
||||
|
||||
export const closeSessionAtom = atom(null, (get, set, port: number) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
||||
execCommand(sessionArgs(s, "close"));
|
||||
}
|
||||
});
|
||||
|
||||
export const killSessionAtom = atom(null, (get, set, port: number) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
||||
killSession(s);
|
||||
}
|
||||
});
|
||||
|
||||
export const closeAllSessionsAtom = atom(null, (get, set) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
for (const s of sessions) {
|
||||
if (!s.pending && !s.closing) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s.session));
|
||||
execCommand(sessionArgs(s.session, "close"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const closeTabAtom = atom(
|
||||
null,
|
||||
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) execCommand(sessionArgs(s, "tab", "close", String(tabIndex)));
|
||||
},
|
||||
);
|
||||
|
||||
export const addTabAtom = atom(null, (get, _set, port: number) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) execCommand(sessionArgs(s, "tab", "new"));
|
||||
});
|
||||
|
||||
export const switchTabAtom = atom(
|
||||
null,
|
||||
(get, _set, { port, tabIndex }: { port: number; tabIndex: number }) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) execCommand(sessionArgs(s, "tab", String(tabIndex)));
|
||||
},
|
||||
);
|
||||
|
||||
/** Prune pending/closing once the polled list confirms them */
|
||||
const reconcileSessionsAtom = atom(
|
||||
null,
|
||||
(get, set) => {
|
||||
const polled = get(polledSessionsAtom);
|
||||
const polledNames = new Set(polled.map((s) => s.session));
|
||||
|
||||
set(pendingSessionsAtom, (prev) => {
|
||||
const next = prev.filter((p) => !polledNames.has(p.session));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
|
||||
set(closingSessionsAtom, (prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const name of prev) {
|
||||
if (!polledNames.has(name)) next.delete(name);
|
||||
}
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useSessionsSync(pollInterval = 5000) {
|
||||
const failCountRef = useRef(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const reconcile = useAtomCallback(
|
||||
useCallback((_get, set) => {
|
||||
set(reconcileSessionsAtom);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const fetchSessions = useAtomCallback(
|
||||
useCallback(
|
||||
async (get, set) => {
|
||||
try {
|
||||
const resp = await fetch(getSessionsUrl());
|
||||
if (resp.ok) {
|
||||
failCountRef.current = 0;
|
||||
const data: SessionInfo[] = await resp.json();
|
||||
data.sort((a, b) => a.session.localeCompare(b.session));
|
||||
set(polledSessionsAtom, data);
|
||||
|
||||
// Reconcile pending/closing
|
||||
reconcile();
|
||||
|
||||
// Seed engine cache from session list
|
||||
const engineCache = get(engineCacheAtom);
|
||||
const nextEngine = { ...engineCache };
|
||||
let engineChanged = false;
|
||||
for (const s of data) {
|
||||
if (s.engine && !nextEngine[s.port]) {
|
||||
nextEngine[s.port] = s.engine;
|
||||
engineChanged = true;
|
||||
}
|
||||
}
|
||||
if (engineChanged) set(engineCacheAtom, nextEngine);
|
||||
|
||||
// Auto-select first session if current port is not in list
|
||||
const activePort = get(activePortAtom);
|
||||
const sessions = get(sessionsAtom);
|
||||
if (sessions.length > 0 && !sessions.some((s) => s.port === activePort)) {
|
||||
set(activePortAtom, sessions[0].port);
|
||||
}
|
||||
|
||||
// Poll tabs for all sessions
|
||||
for (const s of data) {
|
||||
try {
|
||||
const tabsResp = await fetch(
|
||||
`http://localhost:${s.port}/api/tabs`,
|
||||
).catch(() => null);
|
||||
if (tabsResp?.ok) {
|
||||
const tabs = await tabsResp.json();
|
||||
if (tabs.length > 0) {
|
||||
set(tabCacheAtom, (prev) => ({ ...prev, [s.port]: tabs }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Session unreachable
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Server unreachable
|
||||
}
|
||||
failCountRef.current++;
|
||||
if (failCountRef.current >= 2) set(polledSessionsAtom, []);
|
||||
},
|
||||
[reconcile],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
timerRef.current = setInterval(fetchSessions, pollInterval);
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [fetchSessions, pollInterval]);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSetAtom } from "jotai/react";
|
||||
import type {
|
||||
ActivityEvent,
|
||||
ConsoleEntry,
|
||||
StreamMessage,
|
||||
TabInfo,
|
||||
} from "@/types";
|
||||
import { activePortAtom } from "@/store/sessions";
|
||||
import { tabCacheAtom, engineCacheAtom } from "@/store/tabs";
|
||||
|
||||
const MAX_EVENTS = 500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const streamConnectedAtom = atom(false);
|
||||
export const browserConnectedAtom = atom(false);
|
||||
export const screencastingAtom = atom(false);
|
||||
export const recordingAtom = atom(false);
|
||||
export const viewportWidthAtom = atom(1280);
|
||||
export const viewportHeightAtom = atom(720);
|
||||
export const currentFrameAtom = atom<string | null>(null);
|
||||
export const streamEventsAtom = atom<ActivityEvent[]>([]);
|
||||
export const consoleLogsAtom = atom<ConsoleEntry[]>([]);
|
||||
export const streamTabsAtom = atom<TabInfo[]>([]);
|
||||
export const streamEngineAtom = atom("");
|
||||
export const wsRefAtom = atom<WebSocket | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const activeUrlAtom = atom(
|
||||
(get) => get(streamTabsAtom).find((t) => t.active)?.url ?? "",
|
||||
);
|
||||
|
||||
export const hasConsoleErrorsAtom = atom((get) =>
|
||||
get(consoleLogsAtom).some(
|
||||
(e) =>
|
||||
e.type === "page_error" ||
|
||||
(e.type === "console" && e.level === "error"),
|
||||
),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const sendInputAtom = atom(
|
||||
null,
|
||||
(get, _set, msg: Record<string, unknown>) => {
|
||||
const ws = get(wsRefAtom);
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const clearEventsAtom = atom(null, (_get, set) => {
|
||||
set(streamEventsAtom, []);
|
||||
});
|
||||
|
||||
export const clearConsoleLogsAtom = atom(null, (_get, set) => {
|
||||
set(consoleLogsAtom, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useStreamSync(port: number) {
|
||||
const setConnected = useSetAtom(streamConnectedAtom);
|
||||
const setBrowserConnected = useSetAtom(browserConnectedAtom);
|
||||
const setScreencasting = useSetAtom(screencastingAtom);
|
||||
const setRecording = useSetAtom(recordingAtom);
|
||||
const setVpWidth = useSetAtom(viewportWidthAtom);
|
||||
const setVpHeight = useSetAtom(viewportHeightAtom);
|
||||
const setFrame = useSetAtom(currentFrameAtom);
|
||||
const setEvents = useSetAtom(streamEventsAtom);
|
||||
const setConsoleLogs = useSetAtom(consoleLogsAtom);
|
||||
const setTabs = useSetAtom(streamTabsAtom);
|
||||
const setEngine = useSetAtom(streamEngineAtom);
|
||||
const setWsRef = useSetAtom(wsRefAtom);
|
||||
const setTabCache = useSetAtom(tabCacheAtom);
|
||||
const setEngineCache = useSetAtom(engineCacheAtom);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const eventsRef = useRef<ActivityEvent[]>([]);
|
||||
const consoleRef = useRef<ConsoleEntry[]>([]);
|
||||
const portRef = useRef(port);
|
||||
|
||||
// Reset all stream state when port changes
|
||||
useEffect(() => {
|
||||
if (portRef.current !== port) {
|
||||
portRef.current = port;
|
||||
eventsRef.current = [];
|
||||
consoleRef.current = [];
|
||||
setConnected(false);
|
||||
setBrowserConnected(false);
|
||||
setScreencasting(false);
|
||||
setRecording(false);
|
||||
setVpWidth(1280);
|
||||
setVpHeight(720);
|
||||
setFrame(null);
|
||||
setEvents([]);
|
||||
setConsoleLogs([]);
|
||||
setTabs([]);
|
||||
setEngine("");
|
||||
}
|
||||
}, [port, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${port}`);
|
||||
wsRef.current = ws;
|
||||
setWsRef(ws);
|
||||
|
||||
ws.onopen = () => {
|
||||
retryCountRef.current = 0;
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
const delay = Math.min(2000 * 2 ** retryCountRef.current, 30000);
|
||||
retryCountRef.current++;
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg: StreamMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "frame":
|
||||
setFrame(msg.data);
|
||||
break;
|
||||
|
||||
case "status":
|
||||
setBrowserConnected(msg.connected);
|
||||
setScreencasting(msg.screencasting);
|
||||
if (msg.recording != null) setRecording(msg.recording);
|
||||
setVpWidth(msg.viewportWidth);
|
||||
setVpHeight(msg.viewportHeight);
|
||||
if (msg.engine) {
|
||||
setEngine(msg.engine);
|
||||
setEngineCache((prev) => ({ ...prev, [port]: msg.engine! }));
|
||||
}
|
||||
break;
|
||||
|
||||
case "command": {
|
||||
const updated = [...eventsRef.current, msg].slice(-MAX_EVENTS);
|
||||
eventsRef.current = updated;
|
||||
setEvents(updated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "console": {
|
||||
const conUpdated = [...consoleRef.current, msg].slice(-MAX_EVENTS);
|
||||
consoleRef.current = conUpdated;
|
||||
setConsoleLogs(conUpdated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "page_error": {
|
||||
const conUpdated = [...consoleRef.current, msg].slice(-MAX_EVENTS);
|
||||
consoleRef.current = conUpdated;
|
||||
setConsoleLogs(conUpdated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "result": {
|
||||
const cmdIdx = eventsRef.current.findIndex(
|
||||
(e) => e.type === "command" && e.id === msg.id,
|
||||
);
|
||||
const base =
|
||||
cmdIdx >= 0
|
||||
? [
|
||||
...eventsRef.current.slice(0, cmdIdx),
|
||||
...eventsRef.current.slice(cmdIdx + 1),
|
||||
]
|
||||
: eventsRef.current;
|
||||
const updated = [...base, msg].slice(-MAX_EVENTS);
|
||||
eventsRef.current = updated;
|
||||
setEvents(updated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tabs":
|
||||
setTabs(msg.tabs);
|
||||
setTabCache((prev) => ({ ...prev, [port]: msg.tabs }));
|
||||
break;
|
||||
|
||||
case "url":
|
||||
setTabs((prev) =>
|
||||
prev.map((t) => (t.active ? { ...t, url: msg.url } : t)),
|
||||
);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
break;
|
||||
}
|
||||
};
|
||||
}, [port, setWsRef, setConnected, setBrowserConnected, setScreencasting, setRecording, setVpWidth, setVpHeight, setFrame, setEvents, setConsoleLogs, setTabs, setEngine, setTabCache, setEngineCache]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
wsRef.current?.close();
|
||||
setWsRef(null);
|
||||
};
|
||||
}, [connect, setWsRef]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { atom } from "jotai";
|
||||
import type { TabInfo } from "@/types";
|
||||
import { activePortAtom } from "@/store/sessions";
|
||||
import { streamTabsAtom, streamEngineAtom } from "@/store/stream";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const tabCacheAtom = atom<Record<number, TabInfo[]>>({});
|
||||
export const engineCacheAtom = atom<Record<number, string>>({});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived atoms (used by SessionTree to get tabs/engine for any port)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const tabsForPortAtom = atom((get) => {
|
||||
const activePort = get(activePortAtom);
|
||||
const streamTabs = get(streamTabsAtom);
|
||||
const cache = get(tabCacheAtom);
|
||||
|
||||
return (port: number): TabInfo[] => {
|
||||
if (port === activePort && streamTabs.length > 0) return streamTabs;
|
||||
return cache[port] ?? [];
|
||||
};
|
||||
});
|
||||
|
||||
export const engineForPortAtom = atom((get) => {
|
||||
const activePort = get(activePortAtom);
|
||||
const streamEngine = get(streamEngineAtom);
|
||||
const cache = get(engineCacheAtom);
|
||||
|
||||
return (port: number): string => {
|
||||
if (cache[port]) return cache[port];
|
||||
if (port === activePort && streamEngine) return streamEngine;
|
||||
return "";
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user