mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// `background.ts` is a WXT entrypoint that calls the global `defineBackground`
|
||||
// at module load. WXT injects that helper at build time via auto-imports;
|
||||
// vitest runs the file directly so we have to stub it before the import is
|
||||
// resolved. `vi.hoisted` runs before ESM imports, which is exactly what we need.
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { defineBackground: (cb: unknown) => unknown }).defineBackground = (
|
||||
cb: unknown,
|
||||
) => cb;
|
||||
});
|
||||
|
||||
import { handleOverlayInterrupt } from "@/entrypoints/background";
|
||||
|
||||
describe("handleOverlayInterrupt", () => {
|
||||
it("sends a session.user_interrupt event to the daemon and acks ok", async () => {
|
||||
const send = vi.fn().mockReturnValue(undefined);
|
||||
const transport = { send } as unknown as Parameters<typeof handleOverlayInterrupt>[0];
|
||||
const result = await handleOverlayInterrupt(transport, "sess-1");
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
event: "session.user_interrupt",
|
||||
payload: { session_id: "sess-1" },
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("returns ok=false when the transport send throws", async () => {
|
||||
const send = vi.fn(() => {
|
||||
throw new Error("ws closed");
|
||||
});
|
||||
const transport = { send } as unknown as Parameters<typeof handleOverlayInterrupt>[0];
|
||||
const result = await handleOverlayInterrupt(transport, "sess-1");
|
||||
expect(result).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import { ConnectionController } from "@/lib/connection-controller";
|
||||
import {
|
||||
getConnectionEnabled,
|
||||
setConnectionEnabled as persistConnectionEnabled,
|
||||
setLabel,
|
||||
} from "@/lib/instance-id";
|
||||
import { startKeepalive } from "@/lib/keepalive";
|
||||
import {
|
||||
OVERLAY_MSG_INTERRUPT,
|
||||
OVERLAY_MSG_WHO_AM_I,
|
||||
type OverlayInterruptRequest,
|
||||
type OverlayInterruptResponse,
|
||||
type OverlayMessage,
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
|
||||
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
|
||||
import { attachSessionEventHandler } from "@/session-manager/event-handler";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import {
|
||||
attachBorrowNotificationButtonHandler,
|
||||
attachBorrowNotificationClickHandler,
|
||||
type BorrowNotificationCopy,
|
||||
defaultBorrowChromeNotifications,
|
||||
defaultBorrowChromeWindows,
|
||||
requestBorrowConfirmation,
|
||||
} from "@/tools/borrow-confirmation";
|
||||
import { ToolDispatcher } from "@/tools/dispatcher";
|
||||
import { detectBrowserMeta } from "@/transport/handshake";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import { WSTransport } from "@/transport/ws-transport";
|
||||
|
||||
export const DEFAULT_DAEMON_PORT = 52800;
|
||||
|
||||
export default defineBackground(() => {
|
||||
const port = DEFAULT_DAEMON_PORT;
|
||||
const controller = new ConnectionController();
|
||||
const transport = new WSTransport({ url: `ws://127.0.0.1:${port}` });
|
||||
const sessions = new SessionManager();
|
||||
const cdp = new ChromiumCdp();
|
||||
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
||||
// Re-sync the storage.session flag on SW startup so a previous SW's
|
||||
// stale `true` does not keep waking us on every page load until the
|
||||
// first mutation (review M4/M5 round 3 m-R3-1).
|
||||
void sessionsLive.refresh();
|
||||
const dispatcher = new ToolDispatcher({
|
||||
transport,
|
||||
sessions,
|
||||
cdp,
|
||||
onSessionsChanged: () => {
|
||||
void sessionsLive.syncFromManager();
|
||||
},
|
||||
approveBorrow: (ctx) =>
|
||||
requestBorrowConfirmation(ctx.tabId, {
|
||||
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
||||
deps: {
|
||||
// Skip every Agent Window when choosing where to render the
|
||||
// overlay — Agent Windows boot on about:blank, which has no
|
||||
// content script, so sendMessage would fail-open and silently
|
||||
// allow the borrow without any UI shown.
|
||||
isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null,
|
||||
// Resolve i18n strings per-borrow so language switches take effect
|
||||
// without re-creating the dispatcher.
|
||||
notificationCopy: makeBorrowNotificationCopy(),
|
||||
},
|
||||
}),
|
||||
helpNotificationCopy: () => ({
|
||||
title: i18n.t("helpRequest.notificationTitle", { ns: "extension" }),
|
||||
body: "",
|
||||
}),
|
||||
});
|
||||
dispatcher.start();
|
||||
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
|
||||
attachBorrowNotificationClickHandler({
|
||||
onClicked: chrome.notifications.onClicked,
|
||||
windows: defaultBorrowChromeWindows,
|
||||
notifications: defaultBorrowChromeNotifications,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"[browser-skill] chrome.notifications unavailable; borrow notifications will be skipped",
|
||||
);
|
||||
}
|
||||
// The Allow / Deny buttons on the OS notification are the *explicit*
|
||||
// authorization fallback when every candidate user window's content
|
||||
// script was missing (extension just reloaded, page in BFCache, etc.).
|
||||
// Without this listener those button clicks would land nowhere and we'd
|
||||
// be back to fail-open-after-sendMessage-failure.
|
||||
if (typeof chrome.notifications?.onButtonClicked?.addListener === "function") {
|
||||
attachBorrowNotificationButtonHandler({
|
||||
onButtonClicked: chrome.notifications.onButtonClicked,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"[browser-skill] chrome.notifications.onButtonClicked unavailable; borrow Allow/Deny buttons will be inactive",
|
||||
);
|
||||
}
|
||||
attachSessionEventHandler({
|
||||
manager: sessions,
|
||||
transport,
|
||||
cdp,
|
||||
onSessionsChanged: () => {
|
||||
void sessionsLive.syncFromManager();
|
||||
},
|
||||
});
|
||||
|
||||
// MV3 service worker keepalive + reconnect supervisor (review M4/M5
|
||||
// C3 + C4). Every 30s the alarm wakes the SW; if the transport is
|
||||
// not connected we force a fresh connect attempt so we never sit on
|
||||
// a stale `disconnected` state when the setTimeout-based reconnect
|
||||
// dies with the SW. Reconnect is skipped when the user has disabled
|
||||
// the BrowserSkill connection.
|
||||
startKeepalive({
|
||||
transport,
|
||||
shouldConnect: () => controller.isConnectionEnabled,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const connectionEnabled = await getConnectionEnabled();
|
||||
await controller.attach(transport, detectBrowserMeta(), connectionEnabled);
|
||||
})().catch((err) => {
|
||||
console.error("[browser-skill] controller failed to attach", err);
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((rawMsg, sender, sendResponse) => {
|
||||
const msg = rawMsg as OverlayMessage | undefined;
|
||||
if (!msg || typeof msg !== "object" || !("kind" in msg)) return false;
|
||||
|
||||
if (msg.kind === OVERLAY_MSG_WHO_AM_I) {
|
||||
const windowId = sender.tab?.windowId;
|
||||
const ctx = typeof windowId === "number" ? sessions.findByWindowId(windowId) : null;
|
||||
sendResponse({ sessionId: ctx?.sessionId ?? null });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.kind === OVERLAY_MSG_INTERRUPT) {
|
||||
const req = msg as OverlayInterruptRequest;
|
||||
void handleOverlayInterrupt(transport, req.sessionId).then((reply) => {
|
||||
sendResponse(reply);
|
||||
});
|
||||
return true; // keep channel open
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
chrome.runtime.onConnect.addListener((connection) => {
|
||||
if (connection.name !== POPUP_PORT_NAME) return;
|
||||
const post = (msg: PopupInbound) => {
|
||||
try {
|
||||
connection.postMessage(msg);
|
||||
} catch (err) {
|
||||
console.debug("[browser-skill] popup post failed", err);
|
||||
}
|
||||
};
|
||||
const unsubscribe = controller.subscribe((snap) => {
|
||||
post({ kind: "snapshot", data: snap });
|
||||
});
|
||||
connection.onMessage.addListener((raw: unknown) => {
|
||||
const msg = raw as PopupOutbound;
|
||||
if (msg && typeof msg === "object" && "kind" in msg) {
|
||||
if (msg.kind === "set_label") {
|
||||
void setLabel(msg.value).then(() => controller.refreshLabel());
|
||||
} else if (msg.kind === "set_port") {
|
||||
// Placeholder for the future custom-port UI; warn loudly so
|
||||
// any reintroduced popup control is caught instead of
|
||||
// silently doing nothing (review M4/M5 C2).
|
||||
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
|
||||
} else if (msg.kind === "set_connection_enabled") {
|
||||
void controller.setConnectionEnabled(msg.value).then(() =>
|
||||
persistConnectionEnabled(msg.value),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
connection.onDisconnect.addListener(() => unsubscribe());
|
||||
});
|
||||
|
||||
// Stash on globalThis so the SW DevTools can poke at internals.
|
||||
// Dev-only to avoid leaking internals to inspectors in shipped builds
|
||||
// (review M4/M5 M4).
|
||||
if (import.meta.env.DEV) {
|
||||
const dbg = globalThis as unknown as {
|
||||
__bskController?: ConnectionController;
|
||||
__bhSessions?: SessionManager;
|
||||
__bhDispatcher?: ToolDispatcher;
|
||||
};
|
||||
dbg.__bskController = controller;
|
||||
dbg.__bhSessions = sessions;
|
||||
dbg.__bhDispatcher = dispatcher;
|
||||
}
|
||||
|
||||
console.info("[browser-skill] background worker initialised");
|
||||
});
|
||||
|
||||
/**
|
||||
* Push a `session.user_interrupt` event to the daemon for `sessionId`.
|
||||
* Returns `{ ok: true }` when `transport.send` accepts the frame and
|
||||
* `{ ok: false }` when it throws (sink closed, transport not yet
|
||||
* connected, etc.). The daemon-side cancellation is fire-and-forget
|
||||
* — a failure here just means the user will need to retry the
|
||||
* interrupt; no daemon state is left half-updated.
|
||||
*/
|
||||
export async function handleOverlayInterrupt(
|
||||
transport: Pick<Transport, "send">,
|
||||
sessionId: string,
|
||||
): Promise<OverlayInterruptResponse> {
|
||||
try {
|
||||
transport.send({
|
||||
event: "session.user_interrupt",
|
||||
payload: { session_id: sessionId },
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.warn("[browser-skill] failed to send session.user_interrupt", err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function makeBorrowNotificationCopy(): BorrowNotificationCopy {
|
||||
return {
|
||||
title: i18n.t("borrowConfirmation.notificationTitle", { ns: "extension" }),
|
||||
body: (tabTitle: string) =>
|
||||
i18n.t("borrowConfirmation.notificationBody", { ns: "extension", tabTitle }),
|
||||
iconUrl: "icon/logo.png",
|
||||
allowButton: i18n.t("borrowConfirmation.allow", { ns: "extension" }),
|
||||
denyButton: i18n.t("borrowConfirmation.deny", { ns: "extension" }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
|
||||
import { ControlOverlay } from "@/content/ControlOverlay";
|
||||
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
|
||||
import overlayCss from "@/content/overlay.css?inline";
|
||||
import { OverlayController } from "@/content/overlay-controller";
|
||||
import {
|
||||
HELP_RESPONSE,
|
||||
type HelpCancelMessage,
|
||||
type HelpRequestMessage,
|
||||
type HelpResponseMessage,
|
||||
isHelpCancelMessage,
|
||||
isHelpRequestMessage,
|
||||
} from "@/lib/help-bridge";
|
||||
import {
|
||||
OVERLAY_AUTOMATION_BYPASS,
|
||||
OVERLAY_MSG_WHO_AM_I,
|
||||
type OverlayAgentOverlayResetMessage,
|
||||
type OverlayAutomationBypassMessage,
|
||||
type OverlayWhoAmIResponse,
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
import { SESSIONS_LIVE_FLAG_KEY } from "@/lib/sessions-live-flag";
|
||||
import type {
|
||||
BorrowCancelMessage,
|
||||
BorrowRequestMessage,
|
||||
BorrowResponseMessage,
|
||||
} from "@/tools/borrow-confirmation";
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
// Run at document_end so the overlay does not block first paint. Only attach
|
||||
// in the top-level frame so iframes do not double-render overlays.
|
||||
export default defineContentScript({
|
||||
matches: ["<all_urls>"],
|
||||
runAt: "document_end",
|
||||
allFrames: false,
|
||||
cssInjectionMode: "ui",
|
||||
|
||||
async main(ctx) {
|
||||
if (window.top !== window) return;
|
||||
|
||||
const overlays = new OverlayController();
|
||||
let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null =
|
||||
null;
|
||||
let reactRoot: ReactDOM.Root | null = null;
|
||||
let overlayHost: HTMLElement | null = null;
|
||||
let hostLossReported = false;
|
||||
let remountInProgress = false;
|
||||
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: "browser-skill-overlay",
|
||||
position: "inline",
|
||||
anchor: "html",
|
||||
css: overlayCss,
|
||||
onMount(container, _shadow, shadowHost) {
|
||||
shadowHost.setAttribute("aria-hidden", "true");
|
||||
shadowHost.setAttribute("data-bsk-overlay", "");
|
||||
overlayHost = shadowHost;
|
||||
hostLossReported = false;
|
||||
const app = document.createElement("div");
|
||||
app.className = "bsk-overlay-root";
|
||||
container.append(app);
|
||||
reactRoot = ReactDOM.createRoot(app);
|
||||
renderOverlay();
|
||||
return reactRoot;
|
||||
},
|
||||
onRemove(root) {
|
||||
overlayHost = null;
|
||||
root?.unmount();
|
||||
reactRoot = null;
|
||||
},
|
||||
});
|
||||
|
||||
function renderOverlay() {
|
||||
const overlayState = overlays.snapshot();
|
||||
reactRoot?.render(
|
||||
React.createElement(
|
||||
I18nextProvider,
|
||||
{ i18n },
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(BorrowConfirmationOverlay, {
|
||||
requests: overlayState.borrowRequests,
|
||||
}),
|
||||
React.createElement(ControlOverlay, {
|
||||
visible: overlayState.controlVisible && overlayState.activeHelp === null,
|
||||
interrupting: overlayState.interrupting,
|
||||
automationBypass: overlayState.automationBypassCount > 0,
|
||||
onInterrupt: handleInterrupt,
|
||||
}),
|
||||
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resetAgentOverlayState(sessionId: string) {
|
||||
const previousHelp = overlays.resetAgentOverlays(sessionId);
|
||||
if (previousHelp) {
|
||||
activeHelpRespond?.("cancelled");
|
||||
activeHelpRespond = null;
|
||||
}
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function handleInterrupt() {
|
||||
const state = overlays.snapshot();
|
||||
if (state.interrupting) return;
|
||||
const sessionId = state.activeSessionId;
|
||||
if (!sessionId) {
|
||||
console.warn("[bsk overlay] interrupt requested with no active session id");
|
||||
return;
|
||||
}
|
||||
overlays.setInterrupting(true);
|
||||
renderOverlay();
|
||||
void sendInterrupt(
|
||||
(msg) => chrome.runtime.sendMessage(msg),
|
||||
sessionId,
|
||||
).then((reply) => {
|
||||
// Always retract the mask after the round trip resolves
|
||||
// (success, failure, or timeout). Cancellation is fire-and-
|
||||
// forget on the daemon side; the user must not be stuck
|
||||
// behind a transient issue. The Agent Window stays open.
|
||||
resetAgentOverlayState(sessionId);
|
||||
if (!reply.ok) {
|
||||
console.warn("[bsk overlay] interrupt did not get a clean ack from daemon");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const onMessage = (
|
||||
message:
|
||||
| BorrowRequestMessage
|
||||
| BorrowCancelMessage
|
||||
| HelpRequestMessage
|
||||
| HelpCancelMessage
|
||||
| OverlayAgentOverlayResetMessage
|
||||
| OverlayAutomationBypassMessage,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => void,
|
||||
) => {
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
"type" in message &&
|
||||
message.type === OVERLAY_AUTOMATION_BYPASS
|
||||
) {
|
||||
const bypassMsg = message as OverlayAutomationBypassMessage;
|
||||
overlays.setAutomationBypass(bypassMsg.enabled);
|
||||
renderOverlay();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isOverlayAgentOverlayResetMessage(message)) {
|
||||
resetAgentOverlayState(message.sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === "borrow-cancel") {
|
||||
overlays.removeBorrowRequest(message.requestId);
|
||||
renderOverlay();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isHelpCancelMessage(message)) {
|
||||
const state = overlays.snapshot();
|
||||
if (state.activeHelp && state.activeHelp.id === message.requestId) {
|
||||
activeHelpRespond?.("cancelled");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isHelpRequestMessage(message)) {
|
||||
const helpMsg = message as HelpRequestMessage;
|
||||
let responded = false;
|
||||
const respond = (outcome: "continued" | "cancelled", note?: string) => {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
const reply: HelpResponseMessage = {
|
||||
type: HELP_RESPONSE,
|
||||
outcome,
|
||||
...(note ? { note } : {}),
|
||||
};
|
||||
sendResponse(reply);
|
||||
activeHelpRespond = null;
|
||||
overlays.clearAgentHelpRequest(helpMsg.requestId);
|
||||
renderOverlay();
|
||||
};
|
||||
const previousHelp = overlays.setAgentHelpRequest({
|
||||
id: helpMsg.requestId,
|
||||
prompt: helpMsg.prompt,
|
||||
...(helpMsg.title ? { title: helpMsg.title } : {}),
|
||||
selectors: helpMsg.selectors,
|
||||
onContinue: (note: string) => respond("continued", note.trim() ? note : undefined),
|
||||
onCancel: () => respond("cancelled"),
|
||||
});
|
||||
if (previousHelp) {
|
||||
activeHelpRespond?.("cancelled");
|
||||
}
|
||||
activeHelpRespond = respond;
|
||||
renderOverlay();
|
||||
return true; // async sendResponse
|
||||
}
|
||||
|
||||
if (message.type === "borrow-request") {
|
||||
let responded = false;
|
||||
const respond = (allowed: boolean) => {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
sendResponse({ type: "borrow-response", allowed });
|
||||
overlays.removeBorrowRequest(message.requestId);
|
||||
renderOverlay();
|
||||
};
|
||||
|
||||
overlays.addBorrowRequest({
|
||||
id: message.requestId,
|
||||
isActiveTab: message.isActiveTab,
|
||||
tabTitle: message.tabTitle,
|
||||
timeoutMs: message.timeoutMs,
|
||||
onAllow: () => respond(true),
|
||||
onDeny: () => respond(false),
|
||||
});
|
||||
renderOverlay();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
async function mountOverlayIfAgent(): Promise<void> {
|
||||
if (!(await anySessionLive())) return;
|
||||
try {
|
||||
const reply = (await chrome.runtime.sendMessage({
|
||||
kind: OVERLAY_MSG_WHO_AM_I,
|
||||
})) as OverlayWhoAmIResponse | undefined;
|
||||
if (!reply?.sessionId) return;
|
||||
overlays.activateAgentSession(reply.sessionId);
|
||||
renderOverlay();
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] who_am_i failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
ui.mount();
|
||||
chrome.runtime.onMessage.addListener(onMessage);
|
||||
void mountOverlayIfAgent();
|
||||
|
||||
const hostObserver = new MutationObserver(() => {
|
||||
const connected = overlayHost?.isConnected ?? false;
|
||||
if (overlays.isControlVisible() && !connected && !hostLossReported) {
|
||||
hostLossReported = true;
|
||||
if (!remountInProgress) {
|
||||
remountInProgress = true;
|
||||
try {
|
||||
ui.mount();
|
||||
} finally {
|
||||
remountInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (connected) {
|
||||
hostLossReported = false;
|
||||
}
|
||||
});
|
||||
hostObserver.observe(document.documentElement, { childList: true, subtree: false });
|
||||
|
||||
ctx.onInvalidated(() => {
|
||||
hostObserver.disconnect();
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function anySessionLive(): Promise<boolean> {
|
||||
if (!chrome.storage?.session?.get) return true;
|
||||
try {
|
||||
const result = (await chrome.storage.session.get({
|
||||
[SESSIONS_LIVE_FLAG_KEY]: false,
|
||||
})) as Record<string, unknown> | undefined;
|
||||
return Boolean(result?.[SESSIONS_LIVE_FLAG_KEY]);
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] sessions-live flag read failed", err);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SnapshotInfo } from "@/lib/connection-controller";
|
||||
import { EXTENSION_VERSION } from "@/transport/handshake";
|
||||
import { App } from "./App";
|
||||
import { useConnectionState } from "./use-connection-state";
|
||||
|
||||
vi.mock("./use-connection-state", () => ({
|
||||
useConnectionState: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseConnectionState = vi.mocked(useConnectionState);
|
||||
|
||||
/** Arbitrary peer fixture — only used to distinguish daemon vs extension in the UI. */
|
||||
const mockDaemonVersion = "daemon-fixture";
|
||||
|
||||
const baseSnapshot: SnapshotInfo = {
|
||||
state: "disconnected",
|
||||
instanceId: "",
|
||||
label: "",
|
||||
extensionVersion: EXTENSION_VERSION,
|
||||
handshake: null,
|
||||
lastError: null,
|
||||
connectionEnabled: true,
|
||||
};
|
||||
|
||||
describe("App", () => {
|
||||
const setLabel = vi.fn();
|
||||
const setConnectionEnabled = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: baseSnapshot,
|
||||
statusState: "disconnected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows status label without helper subtitle", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("未连接")).toBeTruthy();
|
||||
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses flex gap for label field spacing", () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
const labelField = container.querySelector("[data-slot='popup-label-field']");
|
||||
expect(labelField?.className).toContain("flex");
|
||||
expect(labelField?.className).toContain("flex-col");
|
||||
expect(labelField?.className).toContain("gap-3");
|
||||
});
|
||||
|
||||
it("shows single-line compact metadata and copies the instance id", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "connected",
|
||||
instanceId: "03c3e47f",
|
||||
label: "个人 Chrome",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByText(/^扩展 v/)).toBeNull();
|
||||
expect(screen.queryByText(/^daemon v/)).toBeNull();
|
||||
expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
|
||||
expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
|
||||
expect(screen.getByText("03c3e47f")).toBeTruthy();
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
|
||||
expect(copyButton.textContent).toBe("");
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("03c3e47f");
|
||||
await waitFor(() => expect(copyButton.getAttribute("title")).toBe("已复制"));
|
||||
});
|
||||
|
||||
it("renders the connection toggle with switch semantics", () => {
|
||||
render(<App />);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls setConnectionEnabled(false) when the toggle is turned off", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" }));
|
||||
expect(setConnectionEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows disabled status when connection is turned off", () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: { ...baseSnapshot, connectionEnabled: false },
|
||||
statusState: "disabled",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("连接已关闭")).toBeTruthy();
|
||||
expect(screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { Badge, Button, Input, Label, cn } from "@browser-skill/ui";
|
||||
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
import { PROTOCOL_VERSION } from "@/transport/handshake";
|
||||
import { ConnectionStatusIndicator } from "./connection-status-indicator";
|
||||
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
|
||||
|
||||
const STATE_LABEL_KEYS = {
|
||||
disconnected: "popup.stateLabel.disconnected",
|
||||
connected: "popup.stateLabel.connected",
|
||||
version_skew: "popup.stateLabel.version_skew",
|
||||
disabled: "popup.stateLabel.disabled",
|
||||
} as const satisfies Record<PopupStatusState, string>;
|
||||
|
||||
const STATE_BADGE_KEYS = {
|
||||
disconnected: "popup.stateBadge.disconnected",
|
||||
connected: "popup.stateBadge.connected",
|
||||
version_skew: "popup.stateBadge.version_skew",
|
||||
disabled: "popup.stateBadge.disabled",
|
||||
} as const satisfies Record<PopupStatusState, string>;
|
||||
|
||||
function getLogoSrc() {
|
||||
if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {
|
||||
return chrome.runtime.getURL("icon/logo.png");
|
||||
}
|
||||
return "/icon/logo.png";
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { t } = useTranslation("extension");
|
||||
const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState();
|
||||
const [labelDraft, setLabelDraft] = useState(snapshot.label);
|
||||
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const applyTheme = (isDark: boolean) => {
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
};
|
||||
applyTheme(query.matches);
|
||||
const onChange = (event: MediaQueryListEvent) => applyTheme(event.matches);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLabelDraft(snapshot.label);
|
||||
}, [snapshot.label]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopiedInstanceId(false);
|
||||
}, [snapshot.instanceId]);
|
||||
|
||||
const onLabelBlur = () => {
|
||||
if (labelDraft !== snapshot.label) setLabel(labelDraft);
|
||||
};
|
||||
|
||||
const isSkewed = statusState === "version_skew";
|
||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||
const extensionVersion = snapshot.extensionVersion || "—";
|
||||
const instanceId = snapshot.instanceId || "—";
|
||||
|
||||
const copyInstanceId = async () => {
|
||||
if (!snapshot.instanceId || !navigator.clipboard?.writeText) return;
|
||||
await navigator.clipboard.writeText(snapshot.instanceId);
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
|
||||
data-slot="popup-root"
|
||||
data-version-skew={isSkewed ? "true" : undefined}
|
||||
>
|
||||
<header className="flex items-center gap-2" data-slot="popup-brand-header">
|
||||
<img
|
||||
src={getLogoSrc()}
|
||||
alt=""
|
||||
className="size-7 rounded-lg"
|
||||
data-slot="popup-brand-logo"
|
||||
/>
|
||||
<h1 className="text-sm font-semibold tracking-tight">{t("popup.brandName")}</h1>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
||||
data-slot="popup-connection-card"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ConnectionStatusIndicator state={statusState} />
|
||||
<span
|
||||
className="truncate text-sm font-medium"
|
||||
data-slot="popup-state-label"
|
||||
>
|
||||
{t(STATE_LABEL_KEYS[statusState])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 text-[10px] font-medium uppercase"
|
||||
data-slot="popup-state-badge"
|
||||
>
|
||||
{t(STATE_BADGE_KEYS[statusState])}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={snapshot.connectionEnabled}
|
||||
aria-label={t("popup.connectionToggleTitle")}
|
||||
data-slot="popup-connection-toggle"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
|
||||
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{snapshot.lastError && (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
|
||||
data-slot="popup-error"
|
||||
>
|
||||
{snapshot.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3" data-slot="popup-label-field">
|
||||
<Label htmlFor="bh-label" className="block text-xs text-muted-foreground">
|
||||
{t("popup.labelTitle")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-label"
|
||||
type="text"
|
||||
value={labelDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setLabelDraft(event.target.value)}
|
||||
onBlur={onLabelBlur}
|
||||
placeholder={t("popup.labelPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-label-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
|
||||
data-slot="popup-meta"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
|
||||
<span aria-hidden>/</span>
|
||||
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
||||
<code
|
||||
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
|
||||
data-slot="popup-instance-id"
|
||||
>
|
||||
{instanceId}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 rounded-md"
|
||||
disabled={!snapshot.instanceId}
|
||||
aria-label={t("popup.copyInstanceId")}
|
||||
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
|
||||
onClick={() => {
|
||||
void copyInstanceId();
|
||||
}}
|
||||
data-slot="popup-copy-instance-id"
|
||||
>
|
||||
{copiedInstanceId ? (
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
) : (
|
||||
<RiFileCopyLine className="size-3" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@browser-skill/ui";
|
||||
import type { PopupStatusState } from "./use-connection-state";
|
||||
|
||||
const DOT_STYLES: Record<PopupStatusState, string> = {
|
||||
disconnected: "bg-muted-foreground/40",
|
||||
connected: "bg-emerald-500",
|
||||
version_skew: "bg-amber-500",
|
||||
disabled: "bg-muted-foreground/25",
|
||||
};
|
||||
|
||||
interface ConnectionStatusIndicatorProps {
|
||||
state: PopupStatusState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Flat online-status dot (Slack / GitHub style). */
|
||||
export function ConnectionStatusIndicator({ state, className }: ConnectionStatusIndicatorProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 shrink-0 rounded-full ring-2 ring-background",
|
||||
DOT_STYLES[state],
|
||||
className,
|
||||
)}
|
||||
data-slot={`popup-state-dot-${state}`}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>browser-skill</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./style.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("[browser-skill] popup root element missing");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nextProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
@import "@browser-skill/ui/styles";
|
||||
@source "../../";
|
||||
@source "../../../../../packages/ui/src";
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePopupDisplayState, resolvePopupStatusState } from "./use-connection-state";
|
||||
|
||||
describe("resolvePopupDisplayState", () => {
|
||||
it("returns wire state when not connecting", () => {
|
||||
expect(resolvePopupDisplayState("connected", "disconnected")).toBe("connected");
|
||||
expect(resolvePopupDisplayState("disconnected", "connected")).toBe("disconnected");
|
||||
expect(resolvePopupDisplayState("version_skew", "connected")).toBe("version_skew");
|
||||
});
|
||||
|
||||
it("holds last stable state while connecting", () => {
|
||||
expect(resolvePopupDisplayState("connecting", "connected")).toBe("connected");
|
||||
expect(resolvePopupDisplayState("connecting", "disconnected")).toBe("disconnected");
|
||||
expect(resolvePopupDisplayState("connecting", "version_skew")).toBe("version_skew");
|
||||
});
|
||||
|
||||
it("falls back to disconnected when no stable state yet", () => {
|
||||
expect(resolvePopupDisplayState("connecting", "connecting")).toBe("disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePopupStatusState", () => {
|
||||
it("returns disabled when connection is turned off", () => {
|
||||
expect(
|
||||
resolvePopupStatusState({ connectionEnabled: false, state: "connected" }, "connected"),
|
||||
).toBe("disabled");
|
||||
});
|
||||
|
||||
it("returns version_skew when enabled and wire state is version_skew", () => {
|
||||
expect(
|
||||
resolvePopupStatusState({ connectionEnabled: true, state: "version_skew" }, "connected"),
|
||||
).toBe("version_skew");
|
||||
});
|
||||
|
||||
it("returns display state when enabled and not version_skew", () => {
|
||||
expect(
|
||||
resolvePopupStatusState({ connectionEnabled: true, state: "connected" }, "connected"),
|
||||
).toBe("connected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { SnapshotInfo } from "@/lib/connection-controller";
|
||||
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
|
||||
import type { ConnectionState } from "@/transport/types";
|
||||
|
||||
export type PopupDisplayState = Exclude<ConnectionState, "connecting">;
|
||||
|
||||
/** Popup status including user-disabled connection preference. */
|
||||
export type PopupStatusState = PopupDisplayState | "disabled";
|
||||
|
||||
/** Popup UI: omit transient `connecting` to avoid label/dot flicker on reconnect. */
|
||||
export function resolvePopupDisplayState(
|
||||
state: ConnectionState,
|
||||
lastStable: ConnectionState,
|
||||
): PopupDisplayState {
|
||||
if (state !== "connecting") return state;
|
||||
return lastStable === "connecting" ? "disconnected" : lastStable;
|
||||
}
|
||||
|
||||
/** When the user has disabled connection, always show the disabled status. */
|
||||
export function resolvePopupStatusState(
|
||||
snapshot: Pick<SnapshotInfo, "connectionEnabled" | "state">,
|
||||
displayState: PopupDisplayState,
|
||||
): PopupStatusState {
|
||||
if (!snapshot.connectionEnabled) return "disabled";
|
||||
if (snapshot.state === "version_skew") return "version_skew";
|
||||
return displayState;
|
||||
}
|
||||
|
||||
const FALLBACK_SNAPSHOT: SnapshotInfo = {
|
||||
state: "disconnected",
|
||||
instanceId: "",
|
||||
label: "",
|
||||
extensionVersion: "",
|
||||
handshake: null,
|
||||
lastError: null,
|
||||
connectionEnabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Live snapshot of the background's `ConnectionController` for popup UI.
|
||||
*
|
||||
* Posts `set_label` mutations back to the background; the incoming
|
||||
* snapshot stream reflects the canonical state. The `set_port` channel
|
||||
* is defined by the popup-bridge but is not currently routed to the
|
||||
* background — wiring it requires `ConnectionController` to swap its
|
||||
* Transport URL and persist the chosen port. Tracked for a follow-up
|
||||
* milestone; the popup intentionally does not expose a control until
|
||||
* the underlying support lands (review M4/M5 C2).
|
||||
*/
|
||||
export function useConnectionState(): {
|
||||
snapshot: SnapshotInfo;
|
||||
statusState: PopupStatusState;
|
||||
setLabel: (value: string) => void;
|
||||
setConnectionEnabled: (value: boolean) => void;
|
||||
} {
|
||||
const [snapshot, setSnapshot] = useState<SnapshotInfo>(FALLBACK_SNAPSHOT);
|
||||
const portRef = useRef<chrome.runtime.Port | null>(null);
|
||||
const lastStableRef = useRef<ConnectionState>("disconnected");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof chrome === "undefined" || !chrome.runtime?.connect) {
|
||||
return undefined;
|
||||
}
|
||||
const port = chrome.runtime.connect({ name: POPUP_PORT_NAME });
|
||||
portRef.current = port;
|
||||
const onMessage = (raw: unknown) => {
|
||||
const msg = raw as PopupInbound;
|
||||
if (msg?.kind === "snapshot") setSnapshot(msg.data);
|
||||
};
|
||||
port.onMessage.addListener(onMessage);
|
||||
port.onDisconnect.addListener(() => {
|
||||
portRef.current = null;
|
||||
});
|
||||
return () => {
|
||||
port.onMessage.removeListener(onMessage);
|
||||
port.disconnect();
|
||||
portRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const post = (msg: PopupOutbound) => {
|
||||
try {
|
||||
portRef.current?.postMessage(msg);
|
||||
} catch (err) {
|
||||
console.warn("[browser-skill] popup post failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const displayState = resolvePopupDisplayState(snapshot.state, lastStableRef.current);
|
||||
if (snapshot.state !== "connecting") {
|
||||
lastStableRef.current = snapshot.state;
|
||||
}
|
||||
|
||||
const statusState = resolvePopupStatusState(snapshot, displayState);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
statusState,
|
||||
setLabel: (value: string) => post({ kind: "set_label", value }),
|
||||
setConnectionEnabled: (value: boolean) => post({ kind: "set_connection_enabled", value }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user