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.
- package/CHANGELOG.md +55 -0
- package/README.md +17 -7
- package/SECURITY.md +15 -10
- package/lib/browser-lifecycle.d.ts +12 -0
- package/lib/browser-lifecycle.js +186 -0
- package/lib/challenge.d.ts +20 -0
- package/lib/challenge.js +40 -0
- package/lib/config.d.ts +81 -0
- package/lib/config.js +61 -0
- package/lib/extract.d.ts +13 -0
- package/lib/extract.js +34 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -1
- package/lib/login-state.d.ts +10 -0
- package/lib/login-state.js +83 -0
- package/lib/page-actions.d.ts +134 -0
- package/lib/page-actions.js +725 -0
- package/lib/page-diagnostics.d.ts +65 -0
- package/lib/page-diagnostics.js +181 -0
- package/lib/page-probes.d.ts +78 -0
- package/lib/page-probes.js +281 -0
- package/lib/page-snapshot.d.ts +96 -0
- package/lib/page-snapshot.js +280 -0
- package/lib/playwright.d.ts +5 -644
- package/lib/playwright.js +28 -2602
- package/lib/provider.d.ts +157 -0
- package/lib/provider.js +352 -0
- package/lib/session.d.ts +410 -0
- package/lib/session.js +633 -0
- package/lib/tool-render.d.ts +20 -0
- package/lib/tool-render.js +137 -0
- package/lib/tool-schemas.d.ts +76 -0
- package/lib/tool-schemas.js +46 -0
- package/lib/tool-types.d.ts +82 -0
- package/lib/tool-types.js +1 -0
- package/lib/tool.d.ts +2 -26
- package/lib/tool.js +5 -252
- package/lib/url-policy.d.ts +2 -0
- package/lib/url-policy.js +26 -0
- package/lib/window-focus.d.ts +27 -0
- package/lib/window-focus.js +54 -0
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +2 -1
- 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
|
+
}
|
package/lib/provider.js
ADDED
|
@@ -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
|
+
}
|