dsh-browser-playwright-codex 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +17 -7
  3. package/SECURITY.md +15 -10
  4. package/lib/browser-lifecycle.d.ts +12 -0
  5. package/lib/browser-lifecycle.js +186 -0
  6. package/lib/challenge.d.ts +20 -0
  7. package/lib/challenge.js +40 -0
  8. package/lib/config.d.ts +81 -0
  9. package/lib/config.js +61 -0
  10. package/lib/extract.d.ts +13 -0
  11. package/lib/extract.js +34 -0
  12. package/lib/index.d.ts +4 -2
  13. package/lib/index.js +3 -1
  14. package/lib/login-state.d.ts +10 -0
  15. package/lib/login-state.js +83 -0
  16. package/lib/page-actions.d.ts +134 -0
  17. package/lib/page-actions.js +725 -0
  18. package/lib/page-diagnostics.d.ts +65 -0
  19. package/lib/page-diagnostics.js +181 -0
  20. package/lib/page-probes.d.ts +78 -0
  21. package/lib/page-probes.js +281 -0
  22. package/lib/page-snapshot.d.ts +96 -0
  23. package/lib/page-snapshot.js +280 -0
  24. package/lib/playwright.d.ts +5 -644
  25. package/lib/playwright.js +28 -2602
  26. package/lib/provider.d.ts +157 -0
  27. package/lib/provider.js +352 -0
  28. package/lib/session.d.ts +410 -0
  29. package/lib/session.js +633 -0
  30. package/lib/tool-render.d.ts +20 -0
  31. package/lib/tool-render.js +137 -0
  32. package/lib/tool-schemas.d.ts +76 -0
  33. package/lib/tool-schemas.js +46 -0
  34. package/lib/tool-types.d.ts +82 -0
  35. package/lib/tool-types.js +1 -0
  36. package/lib/tool.d.ts +2 -26
  37. package/lib/tool.js +5 -252
  38. package/lib/url-policy.d.ts +2 -0
  39. package/lib/url-policy.js +26 -0
  40. package/lib/window-focus.d.ts +27 -0
  41. package/lib/window-focus.js +54 -0
  42. package/package.json +1 -1
  43. package/scripts/dsh-browser-verify.mjs +2 -1
  44. package/scripts/prompt-cost.mjs +1 -1
@@ -0,0 +1,157 @@
1
+ import { type Browser, type BrowserContext, type Dialog, type Page } from 'playwright-core';
2
+ import { PlaywrightSession } from './session.ts';
3
+ import type { PlaywrightConfig } from './config.ts';
4
+ /**
5
+ * Playwright-backed {@link BrowserProvider}: one shared browser, one context
6
+ * per owner key, LRU eviction, idle disposal, ref-based snapshot interaction.
7
+ */
8
+ /** One owner's live session handle inside the provider's map. */
9
+ export interface OwnerEntry {
10
+ context: BrowserContext;
11
+ session: PlaywrightSession;
12
+ lastUsed: number;
13
+ /** Operations in flight; idle disposal defers while this is non-zero. */
14
+ pending: number;
15
+ timer: NodeJS.Timeout | undefined;
16
+ }
17
+ /** One action's outcome: either a native dialog blocked it, or it produced a value. */
18
+ export interface ActionResult {
19
+ blocked: boolean;
20
+ pending?: PendingDialogRecord | undefined;
21
+ value?: unknown;
22
+ }
23
+ /** A native dialog parked on one page, waiting for an explicit answer. */
24
+ export interface PendingDialogRecord {
25
+ dialog: Dialog;
26
+ type: string;
27
+ message: string;
28
+ owner: string | undefined;
29
+ }
30
+ export declare class PlaywrightProvider {
31
+ config: PlaywrightConfig;
32
+ id: string;
33
+ /** Legacy (persistent:false) shared browser instance. */
34
+ browser: Browser | undefined;
35
+ /** Persistent-mode shared profile-backed context (the personal window). */
36
+ context: BrowserContext | undefined;
37
+ entries: Map<string, OwnerEntry>;
38
+ launchError: unknown;
39
+ /** Resolved profile directory (persistent mode only). */
40
+ profileDir: string;
41
+ /**
42
+ * Where cookies+localStorage are exported for the session-cookie fallback.
43
+ * Undefined outside persistent mode, and that is load-bearing: every writer
44
+ * and reader below guards on `undefined`, while an empty string would make
45
+ * `'' + '.tmp'` land in the process working directory.
46
+ */
47
+ stateFile: string | undefined;
48
+ /** Owner that raised the dialog currently parked on a page. */
49
+ lastActor: string | undefined;
50
+ lastStatePersist: number;
51
+ constructor(config: PlaywrightConfig);
52
+ available(): boolean;
53
+ /** Pages already carrying the dialog guard (WeakSet: pages stay collectable). */
54
+ armedDialogs: WeakSet<WeakKey>;
55
+ /** Pending native dialog per page: { dialog, type, message }. Never auto-resolved. */
56
+ pendingDialogs: Map<Page, PendingDialogRecord>;
57
+ /** Resolvers waiting for the next dialog on a page (the action/dialog race). */
58
+ dialogWaits: Map<any, any>;
59
+ /**
60
+ * Make a page report native dialogs instead of having them silently
61
+ * dismissed. The dialog is parked as a per-page pending state and left
62
+ * untouched: the page stays blocked until browser_dialog answers it, and
63
+ * whichever action raised it returns early through the dialog race.
64
+ * See DIALOG-POLICY.md for why this shape beats any parameter-level check.
65
+ */
66
+ armDialogGuard(page: Page): void;
67
+ /** The dialog currently blocking this page, or undefined. */
68
+ pendingDialogFor(page: Page): PendingDialogRecord | undefined;
69
+ /**
70
+ * Remove and return the pending dialog (atomic: one dialog, one answer).
71
+ * Only the owner that raised it may take it: in persistent mode several
72
+ * sessions share one page, and answering another session's dialog would
73
+ * execute its action on their behalf.
74
+ */
75
+ takePendingDialog(page: Page, owner: string): PendingDialogRecord | undefined;
76
+ /**
77
+ * Wake when a dialog appears on this page. cancel() unregisters the waiter
78
+ * so a finished action leaves nothing behind.
79
+ */
80
+ armDialogWait(page: Page): {
81
+ promise: Promise<true>;
82
+ cancel: () => void;
83
+ };
84
+ /** One line describing a parked dialog for the tool result. */
85
+ dialogNoteFor(pending: PendingDialogRecord): string;
86
+ acquire(owner: string): Promise<PlaywrightSession>;
87
+ disposeOwner(owner: string): Promise<void>;
88
+ dispose(): Promise<void>;
89
+ /** Touch the entry's last-used clock and re-arm its idle timer. */
90
+ touch(owner: string): void;
91
+ /** Mark one operation in flight so the idle timer cannot kill it mid-way. */
92
+ beginOp(owner: string): void;
93
+ /** Settle one operation and restart the idle countdown. */
94
+ endOp(owner: string): void;
95
+ /** Ensure the session entry for an owner still exists. */
96
+ isLive(owner: string): boolean;
97
+ /** Drop an owner's entry after a page-level crash without closing the window. */
98
+ invalidateOwner(owner: string): void;
99
+ armIdle(entry: OwnerEntry): void;
100
+ /**
101
+ * Ensure the shared persistent context is alive (persistent mode). A
102
+ * profile-backed window is launched once and reused until closed by the
103
+ * last owner, idle disposal, or an external close; login state lives in
104
+ * the profile plus the exported state file, so reopening is seamless.
105
+ */
106
+ ensureContext(): Promise<BrowserContext>;
107
+ /** Remove stale Chrome singleton lock files inside the profile directory. */
108
+ clearChromeLocks(): Promise<void>;
109
+ /**
110
+ * Collect the login-state fallback WITHOUT ever opening a page while the
111
+ * window is put away.
112
+ *
113
+ * `context.storageState()` is not a pure read. For every origin this context
114
+ * has visited whose page is since gone, it opens a TEMPORARY PAGE, navigates
115
+ * it to that origin, reads its storage, and closes it again
116
+ * (playwright-core 1.62.1 `coreBundle.js:51663-51683`). The origins set is
117
+ * accumulated by `addVisitedOrigin` on every navigation
118
+ * (`coreBundle.js:22352` → `51634`) and only reset by `setStorageState`, so a
119
+ * long-lived context always has leftovers. That temporary page is created
120
+ * through `Target.createTarget` without `background: true`
121
+ * (`coreBundle.js:38340-38342`) — and Chromium activates the window on every
122
+ * tab creation. Same root as Cause 2 in FOCUS-STEALING.md, different trigger:
123
+ * a bookkeeping step that ran after every call could drag a minimized window
124
+ * back onto the screen.
125
+ *
126
+ * Cookies alone never open a page (`coreBundle.js:51639`), so when the window
127
+ * is minimized — or its state cannot be read at all — export cookies only.
128
+ * Nothing durable is lost: this file's job is the session-cookie fallback, and
129
+ * localStorage already survives in the persistent profile. When the window is
130
+ * positively visible we take the full `storageState()`, because a temporary
131
+ * page cannot disturb someone who is already looking at the window.
132
+ */
133
+ collectLoginState(context: BrowserContext): Promise<{
134
+ cookies: unknown[];
135
+ origins: unknown[];
136
+ }>;
137
+ /** Export the login-state fallback to the state file (session-cookie fallback). */
138
+ persistState(context: BrowserContext): Promise<void>;
139
+ /**
140
+ * Throttled, fire-and-forget variant called after every operation.
141
+ *
142
+ * The "is the window minimized?" question moved inside `collectLoginState`,
143
+ * which answers it by choosing a safe API rather than by giving up: the export
144
+ * now runs in either window state, so the session-cookie fallback stops having
145
+ * a hole exactly where it matters most (a user who minimized the window is the
146
+ * one most likely to close it by hand).
147
+ */
148
+ persistStateSoon(context: BrowserContext): void;
149
+ /**
150
+ * Re-inject a previously exported state. Durable cookies already live in
151
+ * the profile; this covers session cookies the browser drops on close and
152
+ * localStorage entries, so logins survive window close/reopen.
153
+ */
154
+ restoreState(context: BrowserContext): Promise<void>;
155
+ /** Launch the browser once, probing the configured or auto-detected channel. */
156
+ ensureBrowser(): Promise<Browser>;
157
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * 浏览器提供方:profile/context 生命周期、会话注册、空闲回收、弹窗登记、标签记账。
3
+ * @module dsh-browser-playwright-codex/provider
4
+ */
5
+ import { persistState, restoreState } from "./login-state.js";
6
+ import { clearChromeLocks, ensureBrowser, ensureContext } from "./browser-lifecycle.js";
7
+ import {} from 'playwright-core';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { PlaywrightSession } from "./session.js";
11
+ import { getEnabled, publishConfiguredEngine } from "./runtime-state.js";
12
+ import { BrowserError } from "./errors.js";
13
+ import { SNAPSHOT_SCRIPT } from "./injected.js";
14
+ import { isWindowMinimized } from "./window-focus.js";
15
+ export class PlaywrightProvider {
16
+ config;
17
+ id = 'playwright';
18
+ /** Legacy (persistent:false) shared browser instance. */
19
+ browser;
20
+ /** Persistent-mode shared profile-backed context (the personal window). */
21
+ context;
22
+ entries = new Map();
23
+ launchError;
24
+ /** Resolved profile directory (persistent mode only). */
25
+ profileDir = '';
26
+ /**
27
+ * Where cookies+localStorage are exported for the session-cookie fallback.
28
+ * Undefined outside persistent mode, and that is load-bearing: every writer
29
+ * and reader below guards on `undefined`, while an empty string would make
30
+ * `'' + '.tmp'` land in the process working directory.
31
+ */
32
+ stateFile = undefined;
33
+ /** Owner that raised the dialog currently parked on a page. */
34
+ lastActor;
35
+ lastStatePersist = 0;
36
+ constructor(config) {
37
+ this.config = config;
38
+ // Tell the settings card which page-reading mode this deployment was
39
+ // configured with, so it can show "当前:X(默认)" truthfully instead of
40
+ // assuming the built-in default.
41
+ publishConfiguredEngine(config.snapshot?.engine ?? 'legacy');
42
+ if (config.launch.persistent) {
43
+ const dir = config.launch.profileDir !== undefined && config.launch.profileDir.trim() !== ''
44
+ ? config.launch.profileDir
45
+ : path.join(os.homedir(), '.dsh', 'browser-profiles', 'playwright');
46
+ this.profileDir = dir;
47
+ this.stateFile = path.join(dir, 'dsh-storage-state.json');
48
+ }
49
+ }
50
+ available() {
51
+ return true;
52
+ }
53
+ /** Pages already carrying the dialog guard (WeakSet: pages stay collectable). */
54
+ armedDialogs = new WeakSet();
55
+ /** Pending native dialog per page: { dialog, type, message }. Never auto-resolved. */
56
+ pendingDialogs = new Map();
57
+ /** Resolvers waiting for the next dialog on a page (the action/dialog race). */
58
+ dialogWaits = new Map();
59
+ /**
60
+ * Make a page report native dialogs instead of having them silently
61
+ * dismissed. The dialog is parked as a per-page pending state and left
62
+ * untouched: the page stays blocked until browser_dialog answers it, and
63
+ * whichever action raised it returns early through the dialog race.
64
+ * See DIALOG-POLICY.md for why this shape beats any parameter-level check.
65
+ */
66
+ armDialogGuard(page) {
67
+ if (this.armedDialogs.has(page))
68
+ return;
69
+ this.armedDialogs.add(page);
70
+ page.on('dialog', (dialog) => {
71
+ this.pendingDialogs.set(page, { dialog, type: dialog.type(), message: dialog.message(), owner: this.lastActor });
72
+ const waiters = this.dialogWaits.get(page);
73
+ this.dialogWaits.delete(page);
74
+ if (waiters !== undefined) {
75
+ for (const wake of waiters)
76
+ wake();
77
+ }
78
+ });
79
+ }
80
+ /** The dialog currently blocking this page, or undefined. */
81
+ pendingDialogFor(page) {
82
+ return this.pendingDialogs.get(page);
83
+ }
84
+ /**
85
+ * Remove and return the pending dialog (atomic: one dialog, one answer).
86
+ * Only the owner that raised it may take it: in persistent mode several
87
+ * sessions share one page, and answering another session's dialog would
88
+ * execute its action on their behalf.
89
+ */
90
+ takePendingDialog(page, owner) {
91
+ const pending = this.pendingDialogs.get(page);
92
+ if (pending === undefined)
93
+ return undefined;
94
+ if (pending.owner !== undefined && owner !== undefined && pending.owner !== owner)
95
+ return undefined;
96
+ this.pendingDialogs.delete(page);
97
+ return pending;
98
+ }
99
+ /**
100
+ * Wake when a dialog appears on this page. cancel() unregisters the waiter
101
+ * so a finished action leaves nothing behind.
102
+ */
103
+ armDialogWait(page) {
104
+ const waiters = this.dialogWaits.get(page) ?? new Set();
105
+ let wake = () => { };
106
+ const promise = new Promise((resolve) => {
107
+ wake = () => resolve(true);
108
+ });
109
+ waiters.add(wake);
110
+ this.dialogWaits.set(page, waiters);
111
+ return {
112
+ promise,
113
+ cancel: () => {
114
+ waiters.delete(wake);
115
+ },
116
+ };
117
+ }
118
+ /** One line describing a parked dialog for the tool result. */
119
+ dialogNoteFor(pending) {
120
+ return ('[dialog] ' +
121
+ pending.type +
122
+ ' "' +
123
+ pending.message.replace(/\s+/g, ' ').trim().slice(0, 300) +
124
+ '"' +
125
+ ' is PENDING and the page is blocked until it is answered. Nothing has been accepted or cancelled yet.' +
126
+ ' Answer it with browser_dialog({ accept }) — accept only if the user authorized exactly this action,' +
127
+ ' otherwise dismiss; no other tool can act until it is answered.');
128
+ }
129
+ async acquire(owner) {
130
+ if (!getEnabled()) {
131
+ throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
132
+ }
133
+ const existing = this.entries.get(owner);
134
+ if (existing) {
135
+ existing.lastUsed = Date.now();
136
+ this.armIdle(existing);
137
+ return existing.session;
138
+ }
139
+ if (this.config.launch.persistent) {
140
+ // Personal-browser mode: all owners drive one shared profile-backed
141
+ // window (Codex-style). Entries are lightweight session handles on
142
+ // the shared context; the last owner to leave closes the window.
143
+ const context = await this.ensureContext();
144
+ const session = new PlaywrightSession(this, owner, context);
145
+ const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
146
+ this.entries.set(owner, entry);
147
+ this.armIdle(entry);
148
+ return session;
149
+ }
150
+ const browser = await this.ensureBrowser();
151
+ while (this.entries.size >= this.config.maxSessions) {
152
+ const oldest = [...this.entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
153
+ if (oldest === undefined)
154
+ break;
155
+ await this.disposeOwner(oldest[0]);
156
+ }
157
+ const context = await browser.newContext({
158
+ // Headful: viewport: null lets each page track its real window
159
+ // (system DPI respected) exactly like a hand-opened Chrome, instead
160
+ // of Playwright forcing a fixed CSS viewport that overflows the
161
+ // window on scaled displays. Headless keeps the fixed viewport.
162
+ ...(this.config.launch.headless ? { viewport: this.config.launch.viewport } : { viewport: null }),
163
+ ignoreHTTPSErrors: this.config.launch.ignoreHTTPSErrors,
164
+ });
165
+ context.on('page', (page) => this.armDialogGuard(page));
166
+ await context.addInitScript(SNAPSHOT_SCRIPT);
167
+ const session = new PlaywrightSession(this, owner, context);
168
+ const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
169
+ this.entries.set(owner, entry);
170
+ this.armIdle(entry);
171
+ void context.on('close', () => {
172
+ if (this.entries.get(owner) === entry) {
173
+ if (entry.timer !== undefined)
174
+ clearTimeout(entry.timer);
175
+ this.entries.delete(owner);
176
+ }
177
+ });
178
+ return session;
179
+ }
180
+ async disposeOwner(owner) {
181
+ const entry = this.entries.get(owner);
182
+ if (entry === undefined)
183
+ return;
184
+ if (entry.timer !== undefined)
185
+ clearTimeout(entry.timer);
186
+ this.entries.delete(owner);
187
+ if (this.config.launch.persistent) {
188
+ // Close the shared window only when the last owner leaves; the
189
+ // profile and state file keep the login for the next launch.
190
+ if (this.entries.size === 0 && this.context !== undefined) {
191
+ const ctx = this.context;
192
+ this.context = undefined;
193
+ await this.persistState(ctx);
194
+ await ctx.close().catch(() => { });
195
+ }
196
+ return;
197
+ }
198
+ await entry.context.close().catch(() => { });
199
+ }
200
+ async dispose() {
201
+ const owners = [...this.entries.keys()];
202
+ for (const owner of owners)
203
+ await this.disposeOwner(owner);
204
+ if (this.config.launch.persistent) {
205
+ if (this.context !== undefined) {
206
+ const ctx = this.context;
207
+ this.context = undefined;
208
+ await this.persistState(ctx);
209
+ await ctx.close().catch(() => { });
210
+ }
211
+ return;
212
+ }
213
+ if (this.browser !== undefined) {
214
+ await this.browser.close().catch(() => { });
215
+ this.browser = undefined;
216
+ }
217
+ }
218
+ /** Touch the entry's last-used clock and re-arm its idle timer. */
219
+ touch(owner) {
220
+ const entry = this.entries.get(owner);
221
+ if (entry === undefined)
222
+ return;
223
+ entry.lastUsed = Date.now();
224
+ this.armIdle(entry);
225
+ }
226
+ /** Mark one operation in flight so the idle timer cannot kill it mid-way. */
227
+ beginOp(owner) {
228
+ // Remember who is driving: a dialog raised during this call belongs to
229
+ // that owner, and no other session may answer it.
230
+ this.lastActor = owner;
231
+ const entry = this.entries.get(owner);
232
+ if (entry !== undefined)
233
+ entry.pending += 1;
234
+ }
235
+ /** Settle one operation and restart the idle countdown. */
236
+ endOp(owner) {
237
+ const entry = this.entries.get(owner);
238
+ if (entry === undefined)
239
+ return;
240
+ entry.pending = Math.max(0, entry.pending - 1);
241
+ this.touch(owner);
242
+ // Opportunistically export login state after actions (throttled) so a
243
+ // manual window close by the user never loses session cookies.
244
+ this.persistStateSoon(entry.context);
245
+ }
246
+ /** Ensure the session entry for an owner still exists. */
247
+ isLive(owner) {
248
+ return this.entries.has(owner);
249
+ }
250
+ /** Drop an owner's entry after a page-level crash without closing the window. */
251
+ invalidateOwner(owner) {
252
+ const entry = this.entries.get(owner);
253
+ if (entry === undefined)
254
+ return;
255
+ if (entry.timer !== undefined)
256
+ clearTimeout(entry.timer);
257
+ this.entries.delete(owner);
258
+ }
259
+ armIdle(entry) {
260
+ if (entry.timer !== undefined)
261
+ clearTimeout(entry.timer);
262
+ entry.timer = undefined;
263
+ const idle = this.config.idleTimeoutMs;
264
+ if (idle <= 0)
265
+ return;
266
+ entry.timer = setTimeout(() => {
267
+ // A slow operation can outlive the idle window; defer disposal until
268
+ // it settles instead of killing its browser context mid-flight.
269
+ if (entry.pending > 0) {
270
+ this.armIdle(entry);
271
+ return;
272
+ }
273
+ void this.disposeOwner(entry.session.owner);
274
+ }, idle);
275
+ entry.timer.unref?.();
276
+ }
277
+ /**
278
+ * Ensure the shared persistent context is alive (persistent mode). A
279
+ * profile-backed window is launched once and reused until closed by the
280
+ * last owner, idle disposal, or an external close; login state lives in
281
+ * the profile plus the exported state file, so reopening is seamless.
282
+ */
283
+ async ensureContext() {
284
+ return ensureContext(this);
285
+ }
286
+ /** Remove stale Chrome singleton lock files inside the profile directory. */
287
+ async clearChromeLocks() {
288
+ return clearChromeLocks(this);
289
+ }
290
+ /**
291
+ * Collect the login-state fallback WITHOUT ever opening a page while the
292
+ * window is put away.
293
+ *
294
+ * `context.storageState()` is not a pure read. For every origin this context
295
+ * has visited whose page is since gone, it opens a TEMPORARY PAGE, navigates
296
+ * it to that origin, reads its storage, and closes it again
297
+ * (playwright-core 1.62.1 `coreBundle.js:51663-51683`). The origins set is
298
+ * accumulated by `addVisitedOrigin` on every navigation
299
+ * (`coreBundle.js:22352` → `51634`) and only reset by `setStorageState`, so a
300
+ * long-lived context always has leftovers. That temporary page is created
301
+ * through `Target.createTarget` without `background: true`
302
+ * (`coreBundle.js:38340-38342`) — and Chromium activates the window on every
303
+ * tab creation. Same root as Cause 2 in FOCUS-STEALING.md, different trigger:
304
+ * a bookkeeping step that ran after every call could drag a minimized window
305
+ * back onto the screen.
306
+ *
307
+ * Cookies alone never open a page (`coreBundle.js:51639`), so when the window
308
+ * is minimized — or its state cannot be read at all — export cookies only.
309
+ * Nothing durable is lost: this file's job is the session-cookie fallback, and
310
+ * localStorage already survives in the persistent profile. When the window is
311
+ * positively visible we take the full `storageState()`, because a temporary
312
+ * page cannot disturb someone who is already looking at the window.
313
+ */
314
+ async collectLoginState(context) {
315
+ const anchor = context.pages()[0];
316
+ if (anchor !== undefined && !(await isWindowMinimized(anchor)))
317
+ return await context.storageState();
318
+ return { cookies: await context.cookies(), origins: [] };
319
+ }
320
+ /** Export the login-state fallback to the state file (session-cookie fallback). */
321
+ async persistState(context) {
322
+ return persistState(this, context);
323
+ }
324
+ /**
325
+ * Throttled, fire-and-forget variant called after every operation.
326
+ *
327
+ * The "is the window minimized?" question moved inside `collectLoginState`,
328
+ * which answers it by choosing a safe API rather than by giving up: the export
329
+ * now runs in either window state, so the session-cookie fallback stops having
330
+ * a hole exactly where it matters most (a user who minimized the window is the
331
+ * one most likely to close it by hand).
332
+ */
333
+ persistStateSoon(context) {
334
+ if (this.stateFile === undefined || context === undefined)
335
+ return;
336
+ if (Date.now() - this.lastStatePersist < 1000)
337
+ return;
338
+ void this.persistState(context).catch(() => { });
339
+ }
340
+ /**
341
+ * Re-inject a previously exported state. Durable cookies already live in
342
+ * the profile; this covers session cookies the browser drops on close and
343
+ * localStorage entries, so logins survive window close/reopen.
344
+ */
345
+ async restoreState(context) {
346
+ return restoreState(this, context);
347
+ }
348
+ /** Launch the browser once, probing the configured or auto-detected channel. */
349
+ async ensureBrowser() {
350
+ return ensureBrowser(this);
351
+ }
352
+ }