aboutsummaryrefslogtreecommitdiff
path: root/src/client.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client.ts')
-rw-r--r--src/client.ts138
1 files changed, 91 insertions, 47 deletions
diff --git a/src/client.ts b/src/client.ts
index 9a97369..1049212 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,12 +1,15 @@
import { type Result, err, ok } from "./util/result";
import { safeJs } from "./util/js-template";
import { type Mutex, createMutex } from "./util/mutex";
-import { discoverWsUrl } from "./cdp/discovery";
+import { discoverEndpoint } from "./cdp/discovery";
import { type CdpError, cdpError } from "./cdp/errors";
import type { CdpTransport } from "./cdp/transport";
import { createCdpTransport } from "./cdp/transport";
import { type CdpSession, createCdpSession } from "./cdp/session";
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
+import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
+import { seedProfileWindow } from "./profile/bind";
+import type { ProfilePin } from "./profile/store";
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
export type BrowserClientOptions = {
@@ -18,6 +21,8 @@ export type BrowserClientOptions = {
readonly harnessWindowTargetId?: string;
readonly harnessWindowId?: number;
};
+ /** Profile to pin this session to, loaded from the durable pin store. */
+ readonly profilePin?: ProfilePin | null;
readonly onOwnershipChange?: (snapshot: {
readonly ownedTargetIds: ReadonlyArray<string>;
readonly harnessWindowTargetId: string | undefined;
@@ -49,6 +54,19 @@ export type BrowserClient = {
current(): { readonly sessionId: string; readonly targetId: string } | null;
session(): CdpSession;
transport(): CdpTransport;
+ /** User-data-dir of the connected browser, when discovery could determine it.
+ * Undefined for BU_CDP_WS / bare-port connections, where profile selection
+ * is unavailable. */
+ userDataDir(): string | undefined;
+ /** The profile this session is pinned to, or null when unpinned. */
+ profilePin(): ProfilePin | null;
+ setProfilePin(pin: ProfilePin | null): void;
+ /** browserContextId of the pinned profile, once a window has identified it.
+ * Session-scoped: Chrome re-mints context ids on every browser run. */
+ profileContextId(): string | undefined;
+ setProfileContextId(contextId: string | undefined): void;
+ /** Open and adopt a window inside the pinned profile. Returns its seed tab. */
+ seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
/** Returns the shared async mutex that serialized browser tools must acquire
* before performing mutations. Observation tools should not use this. */
mutationMutex(): Mutex;
@@ -108,11 +126,33 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
});
});
}
- const session = createCdpSession(transport, ownership);
+ // The seed provider routes attachFirstPage's fallback through the target
+ // factory, so a pinned profile is honoured on every path that can create the
+ // harness window.
+ const session = createCdpSession(transport, ownership, async () => {
+ const window = await ensureHarnessWindow(api);
+ return window.success ? ok(window.data.targetId) : window;
+ });
const mutationMutex = createMutex();
let lastHealth = 0;
let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
+ let userDataDir: string | undefined;
+ let profilePin: ProfilePin | null = opts.profilePin ?? null;
+ let profileContextId: string | undefined;
+
+ /**
+ * With a profile pinned, the harness window must exist BEFORE anything
+ * attaches: session.attachFirstPage() would otherwise fall back to a bare
+ * Target.createTarget, which lands in the focus-derived default profile.
+ * Seeding first means attachFirstPage always finds an owned tab to attach to.
+ */
+ const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
+ if (!profilePin) return ok(undefined);
+ const window = await ensureHarnessWindow(api);
+ if (!window.success) return window;
+ return ok(undefined);
+ };
const start = async (): Promise<Result<void, CdpError>> => {
if (transport.state() === "open" && session.current()) return ok(undefined);
@@ -123,15 +163,23 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
} else if (envUrl) {
wsUrl = envUrl;
} else {
- const discovered = await discoverWsUrl();
+ const discovered = await discoverEndpoint();
if (!discovered.success) return discovered;
- wsUrl = discovered.data;
+ wsUrl = discovered.data.wsUrl;
+ userDataDir = discovered.data.userDataDir;
}
const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
if (!connected.success) return connected;
if (!remote) {
remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
}
+ // A fresh browser run invalidates any context id resolved earlier.
+ profileContextId = undefined;
+ const seeded = await seedIfPinned();
+ if (!seeded.success) {
+ await transport.close();
+ return seeded;
+ }
const attached = await session.attachFirstPage();
if (!attached.success) {
await transport.close();
@@ -166,6 +214,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
expression: "1", returnByValue: true,
}, { timeoutMs: 2_000 });
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
+ // Re-seed first when pinned: if the user closed every harness tab,
+ // attachFirstPage would mint a window in the focus-derived profile.
+ const seeded = await seedIfPinned();
+ if (!seeded.success) return seeded;
const reattached = await session.attachFirstPage();
if (reattached.success) {
pageCaches.clear();
@@ -265,60 +317,34 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return ok(undefined);
};
- // Ask Chrome for the real windowId hosting a target. Undefined on failure —
- // callers treat the window binding as best-effort.
- const getWindowId = async (targetId: string): Promise<number | undefined> => {
- const r = await session.callBrowser("Browser.getWindowForTarget", { targetId });
- if (!r.success) return undefined;
- const wid = (r.data as { windowId?: number }).windowId;
- return typeof wid === "number" ? wid : undefined;
- };
-
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
- // Verify the recorded harness window still exists. Querying the live
- // target list also gives listTabs's reconciliation a chance to run.
+ // Reconcile ownership against live targets before deciding where to open.
const tabsResult = await listTabs(true);
if (!tabsResult.success) return tabsResult;
- const live = new Set(tabsResult.data.map((t) => t.targetId));
- const hw = ownership.harnessWindow();
- // Pick an opener that keeps us inside the harness window. Prefer the seed
- // tab; if the user closed it but other owned tabs survive, reuse one of
- // those instead of spawning a second window (avoids window scatter).
- const opener = hw && live.has(hw)
- ? hw
- : ownership.list().find((id) => live.has(id));
- const params: Record<string, unknown> = { url: "about:blank" };
- if (opener) {
- // Open as a child of an owned tab — Chrome places it in the same window,
- // giving us visual grouping for free and keeping the session single-window.
- params["openerId"] = opener;
+ // ensureHarnessWindow reuses the seed tab, falls back to any surviving
+ // owned tab, and only then creates a window — which, when a profile is
+ // pinned, means launching one inside that profile.
+ const window = await ensureHarnessWindow(api);
+ if (!window.success) return window;
+ // A freshly created window IS the new tab; opening another would leave a
+ // stray blank tab behind.
+ let tabId: string;
+ if (window.data.freshlyCreated) {
+ tabId = window.data.targetId;
} else {
- // Fresh session, or the whole harness window was closed by the user.
- // Spawn a new dedicated window and rebind to it.
- params["newWindow"] = true;
+ const opened = await openHarnessTab(api, window.data.targetId);
+ if (!opened.success) return opened;
+ tabId = opened.data;
}
- const created = await session.callBrowser("Target.createTarget", params);
- if (!created.success) return created;
- const c = created.data as { targetId: string };
- if (params["newWindow"]) {
- ownership.setHarnessWindow(c.targetId);
- const wid = await getWindowId(c.targetId);
- if (wid !== undefined) ownership.setHarnessWindowId(wid);
- } else if (opener && opener !== hw) {
- // Re-seeded onto a surviving owned tab; keep the seed pointer live.
- ownership.setHarnessWindow(opener);
- }
- ownership.add(c.targetId);
-
- const switched = await switchTab(c.targetId);
+ const switched = await switchTab(tabId);
if (!switched.success) return switched;
if (url && url !== "about:blank") {
const nav = await session.call("Page.navigate", { url });
if (!nav.success) return nav;
}
- return ok(c.targetId);
+ return ok(tabId);
};
const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
@@ -357,7 +383,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
};
- return {
+ const api: BrowserClient = {
namespace: opts.namespace,
ensureAlive, status, start, stop, detach, closeOwnedTabs,
evaluateJs, pageInfo,
@@ -368,6 +394,24 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
current: () => session.current(),
session: () => session,
transport: () => transport,
+ userDataDir: () => userDataDir,
+ profilePin: () => profilePin,
+ setProfilePin: (pin) => {
+ profilePin = pin;
+ // The old profile's context no longer describes where tabs should go.
+ profileContextId = undefined;
+ },
+ profileContextId: () => profileContextId,
+ setProfileContextId: (contextId) => {
+ profileContextId = contextId;
+ },
+ seedPinnedProfileWindow: async () => {
+ if (!profilePin) {
+ return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile"));
+ }
+ return seedProfileWindow(api, profilePin);
+ },
mutationMutex: () => mutationMutex,
};
+ return api;
};