farming-code 2.2.28 → 2.2.30
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/README.md +27 -90
- package/README.zh_cn.md +17 -90
- package/THIRD_PARTY_NOTICES.md +25 -6
- package/backend/acp/packaged-claude-acp.js +13 -0
- package/backend/acp/packaged-codex-acp.js +2 -2
- package/backend/acp-runtime.js +38 -17
- package/backend/acp-session-state.js +78 -88
- package/backend/acp-transcript.js +174 -9
- package/backend/agent-manager.js +27 -2
- package/backend/agent-session-history.js +45 -12
- package/backend/codex-session-history.js +1 -1
- package/backend/codex-transcript.js +0 -202
- package/backend/config-manager.js +44 -16
- package/backend/data/LICENSE.agent-browser +201 -0
- package/backend/data/runtime-dependency-manifest.json +199 -0
- package/backend/data/runtime-dependency-sources.json +5 -0
- package/backend/executable-discovery.js +7 -0
- package/backend/farming-agent-bootstrap.zh_cn.md +4 -2
- package/backend/farming-app-cli.js +161 -65
- package/backend/farming-cli.js +7 -59
- package/backend/index-html.js +31 -0
- package/backend/main-agent-skills.js +7 -28
- package/backend/npm-update-helper.js +43 -6
- package/backend/provider-adapters.js +10 -0
- package/backend/runtime-dependency-manager.js +759 -0
- package/backend/runtime-executable-invocation.js +20 -0
- package/backend/server-process-identity.js +47 -0
- package/backend/server.js +145 -204
- package/backend/slash-command-discovery.js +138 -1
- package/backend/storage-layout.js +30 -0
- package/backend/update-service.js +60 -914
- package/config/farming.deploy.env.example +1 -1
- package/config/farming.install.env.example +5 -1
- package/dist/acp/LICENSE.claude-agent-acp +191 -0
- package/dist/acp/LICENSE.claude-agent-sdk +1 -0
- package/dist/acp/claude-agent-acp-0.59.0.mjs +41606 -0
- package/dist/acp/{codex-acp-1.1.4.js → codex-acp-1.1.4.mjs} +387 -7
- package/dist/assets/{App-CM7Cy-l4.css → App-BKNUILuL.css} +1 -1
- package/dist/assets/App-Z1N7T0eM.js +695 -0
- package/dist/assets/{FileEditorMarkdownPreview-DYOMm-b1.js → FileEditorMarkdownPreview-Cs9cLv8a.js} +1 -1
- package/dist/assets/FileEditorPane-CNtuGjQ3.js +2 -0
- package/dist/assets/IconGlyphs-BClgag6E.js +1 -0
- package/dist/assets/ProjectFilesSection-K3GHuQ4U.js +12 -0
- package/dist/assets/ReviewPage-BRuxFaH5.js +3 -0
- package/dist/assets/code-dark-kLsQLvpf.css +1 -0
- package/dist/assets/{index-BzQO3pUr.js → index-D-zetSol.js} +2 -2
- package/dist/assets/main-DUTz-Rd2.css +1 -0
- package/dist/assets/{workspace-editor-monaco-tXA-udGx.js → workspace-editor-monaco-CJNwbpCy.js} +1 -1
- package/dist/assets/workspace-editor-monaco-CO9w8YV0.js +1 -0
- package/dist/assets/workspace-view-state-BjKPYp0E.js +7 -0
- package/dist/index.html +33 -3
- package/extensions/browser/backend/agent-browser-runtime.js +1041 -0
- package/extensions/browser/backend/browser-mcp-server.js +352 -0
- package/extensions/browser/backend/browser-resource-manager.js +921 -114
- package/extensions/browser/backend/browser-resource-store.js +47 -6
- package/extensions/browser/backend/browser-router.js +15 -2
- package/extensions/browser/backend/executable-discovery.js +164 -17
- package/extensions/browser/backend/managed-chromium-installer.js +593 -0
- package/extensions/browser/bin/farming-browser +384 -30
- package/extensions/browser/frontend/BrowserSidebarPortals.tsx +17 -19
- package/extensions/browser/frontend/BrowserViewer.tsx +217 -46
- package/extensions/browser/frontend/browser.css +68 -17
- package/extensions/browser/frontend/types.ts +13 -0
- package/extensions/browser/frontend/useBrowserResources.ts +18 -3
- package/index.html +32 -2
- package/package.json +10 -6
- package/shared/backend-connection-status.d.ts +12 -0
- package/shared/backend-connection-status.js +35 -4
- package/backend/agent-activity.js +0 -35
- package/backend/agent-memory-report.js +0 -583
- package/backend/bundle-update-helper.js +0 -113
- package/backend/codex-rollout-follower.js +0 -326
- package/dist/assets/App-CPG4WcjO.js +0 -658
- package/dist/assets/FileEditorPane-DXXq82aM.js +0 -2
- package/dist/assets/IconGlyphs-DrdPjYXr.js +0 -1
- package/dist/assets/ProjectFilesSection-CP66XVPl.js +0 -12
- package/dist/assets/ReviewPage-w21MEPD6.js +0 -3
- package/dist/assets/code-dark-BDjwj7Um.css +0 -1
- package/dist/assets/main-BlVUXfxa.css +0 -1
- package/dist/assets/workspace-editor-monaco-CFEfFNcA.js +0 -1
- package/dist/assets/workspace-view-state-BS_V8JVs.js +0 -7
- package/extensions/browser/backend/cdp-browser-runtime.js +0 -648
- package/extensions/browser/backend/cdp-client.js +0 -167
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const { EventEmitter } = require('events');
|
|
4
5
|
const storageLayout = require('../../../backend/storage-layout');
|
|
6
|
+
const {
|
|
7
|
+
matchingProcessIdentity,
|
|
8
|
+
readServerProcessIdentity,
|
|
9
|
+
} = require('../../../backend/server-process-identity');
|
|
5
10
|
const { BrowserResourceStore, RESOURCE_ID_RE } = require('./browser-resource-store');
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
11
|
+
const { AgentBrowserRuntime } = require('./agent-browser-runtime');
|
|
12
|
+
const {
|
|
13
|
+
discoverBrowserExecutables,
|
|
14
|
+
discoverBrowserRuntime,
|
|
15
|
+
} = require('./executable-discovery');
|
|
16
|
+
const { ManagedChromiumInstaller } = require('./managed-chromium-installer');
|
|
8
17
|
|
|
9
18
|
const MAX_VIEWER_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const VIEWER_RESIZE_SETTLE_MS = 80;
|
|
20
|
+
const BROWSER_RECOVERY_TIMEOUT_MS = 5_000;
|
|
21
|
+
const BROWSER_RECOVERY_POLL_MS = 100;
|
|
22
|
+
const MAX_UPLOAD_FILES = 20;
|
|
10
23
|
|
|
11
24
|
function publicResource(resource, collectionRevision) {
|
|
12
25
|
return {
|
|
@@ -34,11 +47,32 @@ function browserError(message, status = 400, code = 'BROWSER_INVALID_REQUEST') {
|
|
|
34
47
|
return error;
|
|
35
48
|
}
|
|
36
49
|
|
|
50
|
+
function externalBrowserFailure(action, cause) {
|
|
51
|
+
const error = browserError(
|
|
52
|
+
`${action}; verify the Browser plugin's external CDP address and the browser's /json/version endpoint`,
|
|
53
|
+
500,
|
|
54
|
+
'BROWSER_EXTERNAL_CDP_FAILED',
|
|
55
|
+
);
|
|
56
|
+
error.cause = cause;
|
|
57
|
+
return error;
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
function normalizeUrl(value) {
|
|
38
61
|
const input = String(value || '').trim();
|
|
39
62
|
if (!input) return 'about:blank';
|
|
40
63
|
if (input === 'about:blank') return input;
|
|
41
|
-
|
|
64
|
+
let url = input;
|
|
65
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input)) {
|
|
66
|
+
const authority = input.split(/[/?#]/, 1)[0];
|
|
67
|
+
const hostname = authority.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
68
|
+
const explicitPort = authority.match(/:(\d+)$/)?.[1] || '';
|
|
69
|
+
const isIpLiteral = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || authority.startsWith('[');
|
|
70
|
+
const localHost = hostname === 'localhost'
|
|
71
|
+
|| hostname.endsWith('.localhost')
|
|
72
|
+
|| !hostname.includes('.')
|
|
73
|
+
|| isIpLiteral;
|
|
74
|
+
url = `${localHost || (explicitPort && explicitPort !== '443') ? 'http' : 'https'}://${input}`;
|
|
75
|
+
}
|
|
42
76
|
try {
|
|
43
77
|
const parsed = new URL(url);
|
|
44
78
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
@@ -51,36 +85,281 @@ function normalizeUrl(value) {
|
|
|
51
85
|
}
|
|
52
86
|
}
|
|
53
87
|
|
|
88
|
+
function tabResourceName(tab) {
|
|
89
|
+
const title = String(tab?.title || '').trim();
|
|
90
|
+
if (title) return title.slice(0, 120);
|
|
91
|
+
try {
|
|
92
|
+
return new URL(String(tab?.url || '')).hostname.slice(0, 120) || 'Browser';
|
|
93
|
+
} catch {
|
|
94
|
+
return 'Browser';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function pathInside(root, candidate) {
|
|
99
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveWorkspaceInputFile(resource, value) {
|
|
103
|
+
const workspace = fs.realpathSync(resource.workspace);
|
|
104
|
+
const requested = path.resolve(resource.workspace, String(value || ''));
|
|
105
|
+
let resolved;
|
|
106
|
+
try {
|
|
107
|
+
resolved = fs.realpathSync(requested);
|
|
108
|
+
} catch {
|
|
109
|
+
throw browserError(`Upload file does not exist: ${value}`);
|
|
110
|
+
}
|
|
111
|
+
if (!pathInside(workspace, resolved)) {
|
|
112
|
+
throw browserError('Browser uploads must stay inside the Browser Project workspace');
|
|
113
|
+
}
|
|
114
|
+
if (!fs.statSync(resolved).isFile()) {
|
|
115
|
+
throw browserError(`Browser upload path is not a file: ${value}`);
|
|
116
|
+
}
|
|
117
|
+
return resolved;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveWorkspaceOutputFile(resource, value) {
|
|
121
|
+
const requestedValue = String(value || '').trim();
|
|
122
|
+
if (!requestedValue) throw browserError('Download output path is required');
|
|
123
|
+
const workspace = fs.realpathSync(resource.workspace);
|
|
124
|
+
const requested = path.resolve(resource.workspace, requestedValue);
|
|
125
|
+
if (!pathInside(path.resolve(resource.workspace), requested)) {
|
|
126
|
+
throw browserError('Browser downloads must stay inside the Browser Project workspace');
|
|
127
|
+
}
|
|
128
|
+
let parent;
|
|
129
|
+
try {
|
|
130
|
+
parent = fs.realpathSync(path.dirname(requested));
|
|
131
|
+
} catch {
|
|
132
|
+
throw browserError('Browser download parent directory does not exist');
|
|
133
|
+
}
|
|
134
|
+
if (!pathInside(workspace, parent)) {
|
|
135
|
+
throw browserError('Browser downloads must stay inside the Browser Project workspace');
|
|
136
|
+
}
|
|
137
|
+
if (fs.existsSync(requested)) {
|
|
138
|
+
throw browserError('Browser download target already exists');
|
|
139
|
+
}
|
|
140
|
+
return requested;
|
|
141
|
+
}
|
|
142
|
+
|
|
54
143
|
class BrowserResourceManager extends EventEmitter {
|
|
55
144
|
constructor(options) {
|
|
56
145
|
super();
|
|
57
146
|
this.configDir = options.configDir;
|
|
58
147
|
this.store = options.store || new BrowserResourceStore(options.configDir);
|
|
59
|
-
this.
|
|
60
|
-
|
|
148
|
+
this.chromiumInstaller = options.chromiumInstaller || new ManagedChromiumInstaller({
|
|
149
|
+
...options,
|
|
150
|
+
configDir: this.configDir,
|
|
151
|
+
});
|
|
152
|
+
this.discoverExecutable = options.discoverExecutable || (selection => discoverBrowserRuntime({
|
|
153
|
+
...options,
|
|
154
|
+
...selection,
|
|
155
|
+
configDir: this.configDir,
|
|
156
|
+
managedBrowserPath: this.chromiumInstaller.browserOption()?.path || '',
|
|
157
|
+
}));
|
|
158
|
+
this.discoverBrowserOptions = options.discoverBrowserOptions
|
|
159
|
+
|| (options.discoverExecutable
|
|
160
|
+
? () => []
|
|
161
|
+
: () => discoverBrowserExecutables({
|
|
162
|
+
...options,
|
|
163
|
+
managedBrowserPath: this.chromiumInstaller.browserOption()?.path || '',
|
|
164
|
+
}));
|
|
165
|
+
this.getBrowserSettings = typeof options.getBrowserSettings === 'function'
|
|
166
|
+
? options.getBrowserSettings
|
|
167
|
+
: () => ({ browserSource: 'system', browserExecutablePath: '', browserExternalCdpUrl: '' });
|
|
168
|
+
this.createRuntime = options.createRuntime || (input => new AgentBrowserRuntime(input));
|
|
169
|
+
this.recoverRuntime = options.recoverRuntime || (input => AgentBrowserRuntime.recover(input));
|
|
61
170
|
this.isEnabled = typeof options.isEnabled === 'function' ? options.isEnabled : () => true;
|
|
171
|
+
this.readProcessIdentity = options.readProcessIdentity || readServerProcessIdentity;
|
|
172
|
+
this.killProcessGroup = options.killProcessGroup || ((processGroupId, signal) => process.kill(-processGroupId, signal));
|
|
173
|
+
this.wait = options.wait || (durationMs => new Promise(resolve => setTimeout(resolve, durationMs)));
|
|
174
|
+
this.scheduleTimeout = options.scheduleTimeout || setTimeout;
|
|
175
|
+
this.cancelTimeout = options.cancelTimeout || clearTimeout;
|
|
62
176
|
this.runtimes = new Map();
|
|
177
|
+
this.sessions = new Map();
|
|
63
178
|
this.operations = new Map();
|
|
64
179
|
this.disposed = false;
|
|
180
|
+
this.runtimeCapability = null;
|
|
181
|
+
this.browserOptions = [];
|
|
65
182
|
}
|
|
66
183
|
|
|
67
|
-
init() {
|
|
184
|
+
async init() {
|
|
68
185
|
this.store.init();
|
|
186
|
+
await this.refreshCapability();
|
|
187
|
+
const interrupted = this.store.list().filter(resource =>
|
|
188
|
+
['running', 'starting', 'stopping'].includes(resource.status)
|
|
189
|
+
);
|
|
190
|
+
const groups = new Map();
|
|
191
|
+
for (const resource of interrupted) {
|
|
192
|
+
const key = resource.runtimeKind === 'agent-browser'
|
|
193
|
+
? (resource.sessionId || resource.id)
|
|
194
|
+
: resource.id;
|
|
195
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
196
|
+
groups.get(key).push(resource);
|
|
197
|
+
}
|
|
198
|
+
await Promise.all([...groups.values()].map(resources => {
|
|
199
|
+
const owner = resources.find(resource => resource.processIdentity) || resources[0];
|
|
200
|
+
return this.recoverInterruptedRuntime(owner, resources);
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async recoverInterruptedRuntime(resource, relatedResources = [resource]) {
|
|
205
|
+
if (resource.runtimeKind === 'agent-browser') {
|
|
206
|
+
const capability = this.runtimeCapability || await this.refreshCapability();
|
|
207
|
+
let runtimeError = null;
|
|
208
|
+
if (!capability || capability.error || !capability.agentBrowserPath) {
|
|
209
|
+
runtimeError = new Error(
|
|
210
|
+
capability?.error
|
|
211
|
+
|| 'The exact agent-browser runtime required to clean up this Browser is unavailable',
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
try {
|
|
215
|
+
await this.recoverRuntime({
|
|
216
|
+
id: resource.sessionId || resource.id,
|
|
217
|
+
generation: resource.sessionGeneration || resource.generation,
|
|
218
|
+
browserKind: resource.browserKind,
|
|
219
|
+
processIdentity: resource.processIdentity,
|
|
220
|
+
configDir: this.configDir,
|
|
221
|
+
profileDir: storageLayout.browserProfileDir(
|
|
222
|
+
this.configDir,
|
|
223
|
+
resource.sessionId || resource.id,
|
|
224
|
+
),
|
|
225
|
+
agentBrowserPath: capability.agentBrowserPath,
|
|
226
|
+
readProcessIdentity: this.readProcessIdentity,
|
|
227
|
+
wait: this.wait,
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
runtimeError = error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const related of relatedResources) {
|
|
234
|
+
this.store.update(related.id, {
|
|
235
|
+
status: 'failed',
|
|
236
|
+
error: runtimeError
|
|
237
|
+
? `agent-browser Session cleanup failed: ${runtimeError.message || runtimeError}`
|
|
238
|
+
: 'Farming restarted and cleaned up the previous Browser runtime',
|
|
239
|
+
...(!runtimeError ? { processIdentity: null } : {}),
|
|
240
|
+
tabId: '',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Migration cleanup for Browser rows created by Farming's former raw-CDP runtime.
|
|
247
|
+
const expected = resource.processIdentity;
|
|
248
|
+
if (!expected) {
|
|
249
|
+
this.store.update(resource.id, {
|
|
250
|
+
status: 'failed',
|
|
251
|
+
error: resource.browserKind === 'external-cdp'
|
|
252
|
+
? 'Farming restarted and disconnected from the external Browser'
|
|
253
|
+
: 'Farming restarted before the Browser runtime identity was committed',
|
|
254
|
+
processIdentity: null,
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const current = await this.readProcessIdentity(expected.pid);
|
|
259
|
+
if (!matchingProcessIdentity(expected, current)) {
|
|
260
|
+
this.store.update(resource.id, {
|
|
261
|
+
status: 'failed',
|
|
262
|
+
error: 'Farming restarted after the previous Browser runtime exited',
|
|
263
|
+
processIdentity: null,
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (expected.processGroupId !== expected.pid) {
|
|
268
|
+
this.store.update(resource.id, {
|
|
269
|
+
status: 'failed',
|
|
270
|
+
error: `Previous Browser process ${expected.pid} has an unsafe process-group identity; stop it manually`,
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
this.killProcessGroup(expected.processGroupId, 'SIGKILL');
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error?.code !== 'ESRCH') {
|
|
278
|
+
const permission = error?.code === 'EPERM' || error?.code === 'EACCES';
|
|
279
|
+
this.store.update(resource.id, {
|
|
280
|
+
status: 'failed',
|
|
281
|
+
error: permission
|
|
282
|
+
? `Farming cannot clean up previous Browser process ${expected.pid} because it lacks permission`
|
|
283
|
+
: `Farming could not clean up previous Browser process ${expected.pid}: ${error?.message || error}`,
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const startedAt = Date.now();
|
|
289
|
+
while (matchingProcessIdentity(expected, await this.readProcessIdentity(expected.pid))) {
|
|
290
|
+
if (Date.now() - startedAt >= BROWSER_RECOVERY_TIMEOUT_MS) {
|
|
291
|
+
this.store.update(resource.id, {
|
|
292
|
+
status: 'failed',
|
|
293
|
+
error: `Previous Browser process ${expected.pid} did not exit after SIGKILL`,
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await this.wait(BROWSER_RECOVERY_POLL_MS);
|
|
298
|
+
}
|
|
299
|
+
this.store.update(resource.id, {
|
|
300
|
+
status: 'failed',
|
|
301
|
+
error: 'Farming restarted and cleaned up the previous Browser runtime',
|
|
302
|
+
processIdentity: null,
|
|
303
|
+
});
|
|
69
304
|
}
|
|
70
305
|
|
|
71
306
|
capability() {
|
|
72
|
-
const executable = this.
|
|
307
|
+
const executable = this.runtimeCapability;
|
|
308
|
+
const runnable = executable && !executable.error;
|
|
73
309
|
const enabled = this.isEnabled() === true;
|
|
310
|
+
const selection = this.browserSelection();
|
|
74
311
|
return {
|
|
75
312
|
enabled,
|
|
76
|
-
available: enabled && Boolean(
|
|
77
|
-
browser:
|
|
313
|
+
available: enabled && Boolean(runnable),
|
|
314
|
+
browser: runnable ? { kind: executable.kind, path: executable.path } : null,
|
|
315
|
+
selection,
|
|
316
|
+
options: this.browserOptions.map(option => ({ kind: option.kind, path: option.path })),
|
|
317
|
+
installation: this.chromiumInstaller.status(),
|
|
78
318
|
message: !enabled
|
|
79
319
|
? 'Browser extension is disabled'
|
|
80
|
-
: (executable
|
|
320
|
+
: (executable?.error || (runnable
|
|
321
|
+
? ''
|
|
322
|
+
: 'Install Farming-managed Chromium, choose a system Chromium browser, or configure a loopback external CDP endpoint')),
|
|
81
323
|
};
|
|
82
324
|
}
|
|
83
325
|
|
|
326
|
+
browserSelection(settings = this.getBrowserSettings()) {
|
|
327
|
+
const source = settings?.browserSource;
|
|
328
|
+
return {
|
|
329
|
+
source: ['external-cdp', 'managed'].includes(source) ? source : 'system',
|
|
330
|
+
executablePath: String(settings?.browserExecutablePath || ''),
|
|
331
|
+
externalCdpUrl: String(settings?.browserExternalCdpUrl || 'http://127.0.0.1:9222'),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async probeCapability(selection = this.browserSelection()) {
|
|
336
|
+
const browserOptions = this.discoverBrowserOptions();
|
|
337
|
+
const selectedOption = browserOptions.find(option => option.path === selection.executablePath);
|
|
338
|
+
const runtimeCapability = await this.discoverExecutable({
|
|
339
|
+
source: selection.source,
|
|
340
|
+
executablePath: selection.executablePath,
|
|
341
|
+
executableKind: selectedOption?.kind,
|
|
342
|
+
externalCdpUrl: selection.externalCdpUrl,
|
|
343
|
+
});
|
|
344
|
+
return { browserOptions, runtimeCapability };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async refreshCapability(selection = this.browserSelection()) {
|
|
348
|
+
const probe = await this.probeCapability(selection);
|
|
349
|
+
this.browserOptions = probe.browserOptions;
|
|
350
|
+
this.runtimeCapability = probe.runtimeCapability;
|
|
351
|
+
return this.runtimeCapability;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async installManagedChromium() {
|
|
355
|
+
if (this.disposed) {
|
|
356
|
+
throw browserError('Browser manager is stopping', 503, 'BROWSER_MANAGER_STOPPING');
|
|
357
|
+
}
|
|
358
|
+
await this.chromiumInstaller.install();
|
|
359
|
+
await this.refreshCapability();
|
|
360
|
+
return this.capability();
|
|
361
|
+
}
|
|
362
|
+
|
|
84
363
|
list() {
|
|
85
364
|
this.requireEnabled();
|
|
86
365
|
return this.store.list().map(resource => publicResource(resource, this.store.revision));
|
|
@@ -116,18 +395,23 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
116
395
|
const title = String(name || '').trim();
|
|
117
396
|
if (!title) throw browserError('Browser name is required');
|
|
118
397
|
const resource = this.requireStored(id);
|
|
119
|
-
const next = this.store.update(resource.id, {
|
|
398
|
+
const next = this.store.update(resource.id, {
|
|
399
|
+
name: title.slice(0, 120),
|
|
400
|
+
autoName: false,
|
|
401
|
+
});
|
|
120
402
|
this.emitResource(next);
|
|
121
403
|
return publicResource(next, this.store.revision);
|
|
122
404
|
}
|
|
123
405
|
|
|
124
406
|
start(id) {
|
|
125
|
-
this.
|
|
407
|
+
this.requireEnabled();
|
|
126
408
|
return this.enqueue(id, async () => {
|
|
127
409
|
const resource = this.requireStored(id);
|
|
128
|
-
const
|
|
129
|
-
if (resource.status === 'running' &&
|
|
130
|
-
|
|
410
|
+
const existingBinding = this.runtimes.get(id);
|
|
411
|
+
if (resource.status === 'running' && existingBinding) {
|
|
412
|
+
return publicResource(resource, this.store.revision);
|
|
413
|
+
}
|
|
414
|
+
if (existingBinding) {
|
|
131
415
|
throw browserError(
|
|
132
416
|
'A previous Browser runtime still owns this resource; stop it before restarting',
|
|
133
417
|
409,
|
|
@@ -137,11 +421,20 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
137
421
|
if (resource.status === 'starting' || resource.status === 'stopping') {
|
|
138
422
|
throw browserError(`Browser is ${resource.status}`, 409, 'BROWSER_BUSY');
|
|
139
423
|
}
|
|
140
|
-
|
|
141
|
-
|
|
424
|
+
if (resource.processIdentity) {
|
|
425
|
+
const blockedIdentity = resource.processIdentity;
|
|
426
|
+
throw browserError(
|
|
427
|
+
`Previous Browser process ${blockedIdentity.pid} still requires cleanup`,
|
|
428
|
+
409,
|
|
429
|
+
'BROWSER_RECOVERY_CLEANUP_REQUIRED',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const executable = await this.refreshCapability();
|
|
433
|
+
if (!executable || executable.error) {
|
|
142
434
|
const failed = this.store.update(id, {
|
|
143
435
|
status: 'failed',
|
|
144
|
-
error:
|
|
436
|
+
error: executable?.error
|
|
437
|
+
|| 'Install Farming-managed Chromium, choose a system Chromium browser, or configure a loopback external CDP endpoint',
|
|
145
438
|
});
|
|
146
439
|
this.emitResource(failed);
|
|
147
440
|
throw browserError(failed.error, 503, 'BROWSER_EXECUTABLE_NOT_FOUND');
|
|
@@ -152,51 +445,146 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
152
445
|
status: 'starting',
|
|
153
446
|
generation,
|
|
154
447
|
browserKind: executable.kind,
|
|
448
|
+
runtimeKind: 'agent-browser',
|
|
155
449
|
error: '',
|
|
450
|
+
processIdentity: null,
|
|
156
451
|
});
|
|
157
452
|
this.emitResource(starting);
|
|
453
|
+
|
|
454
|
+
const reusableSession = [...this.sessions.values()].find(session => (
|
|
455
|
+
!session.closing
|
|
456
|
+
&& session.projectRootId === resource.projectRootId
|
|
457
|
+
&& session.browserKind === executable.kind
|
|
458
|
+
));
|
|
459
|
+
if (reusableSession) {
|
|
460
|
+
try {
|
|
461
|
+
let running;
|
|
462
|
+
let binding;
|
|
463
|
+
const operation = (reusableSession.actionChain || Promise.resolve())
|
|
464
|
+
.catch(() => {})
|
|
465
|
+
.then(async () => {
|
|
466
|
+
const tab = await reusableSession.runtime.createTab(
|
|
467
|
+
resource.url,
|
|
468
|
+
executable.kind === 'external-cdp'
|
|
469
|
+
? `farming-${resource.id}-g${generation}`
|
|
470
|
+
: '',
|
|
471
|
+
);
|
|
472
|
+
binding = this.createBinding(reusableSession, {
|
|
473
|
+
...starting,
|
|
474
|
+
tabId: tab.tabId,
|
|
475
|
+
});
|
|
476
|
+
reusableSession.bindings.set(id, binding);
|
|
477
|
+
reusableSession.activeResourceId = id;
|
|
478
|
+
this.runtimes.set(id, binding);
|
|
479
|
+
running = this.store.update(id, {
|
|
480
|
+
status: 'running',
|
|
481
|
+
sessionId: reusableSession.id,
|
|
482
|
+
sessionGeneration: reusableSession.generation,
|
|
483
|
+
tabId: tab.tabId,
|
|
484
|
+
url: tab.url || resource.url,
|
|
485
|
+
title: tab.title || '',
|
|
486
|
+
error: '',
|
|
487
|
+
processIdentity: null,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
reusableSession.actionChain = operation;
|
|
491
|
+
await operation;
|
|
492
|
+
this.emitResource(running);
|
|
493
|
+
this.broadcastRuntimeState(binding);
|
|
494
|
+
return publicResource(running, this.store.revision);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
const failed = this.store.update(id, {
|
|
497
|
+
status: 'failed',
|
|
498
|
+
error: error?.message || 'Failed to create Browser tab',
|
|
499
|
+
tabId: '',
|
|
500
|
+
});
|
|
501
|
+
this.emitResource(failed);
|
|
502
|
+
throw browserError(failed.error, 500, 'BROWSER_START_FAILED');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const sessionId = resource.sessionId || id;
|
|
507
|
+
const previousSessionGeneration = this.store.list()
|
|
508
|
+
.filter(candidate => candidate.sessionId === sessionId)
|
|
509
|
+
.reduce((maximum, candidate) => Math.max(maximum, candidate.sessionGeneration || 0), 0);
|
|
510
|
+
const sessionGeneration = previousSessionGeneration + 1;
|
|
158
511
|
const runtime = this.createRuntime({
|
|
159
|
-
id,
|
|
160
|
-
generation,
|
|
512
|
+
id: sessionId,
|
|
513
|
+
generation: sessionGeneration,
|
|
514
|
+
configDir: this.configDir,
|
|
515
|
+
agentBrowserPath: executable.agentBrowserPath,
|
|
161
516
|
executablePath: executable.path,
|
|
162
|
-
|
|
517
|
+
externalCdpUrl: executable.cdpUrl || '',
|
|
518
|
+
profileDir: storageLayout.browserProfileDir(this.configDir, sessionId),
|
|
163
519
|
});
|
|
164
|
-
|
|
165
|
-
|
|
520
|
+
const session = {
|
|
521
|
+
id: sessionId,
|
|
522
|
+
generation: sessionGeneration,
|
|
523
|
+
projectRootId: resource.projectRootId,
|
|
524
|
+
browserKind: executable.kind,
|
|
525
|
+
runtime,
|
|
526
|
+
bindings: new Map(),
|
|
527
|
+
activeResourceId: id,
|
|
528
|
+
processOwnerResourceId: id,
|
|
529
|
+
actionChain: Promise.resolve(),
|
|
530
|
+
reconcilingTabs: Promise.resolve(),
|
|
531
|
+
initializing: true,
|
|
532
|
+
closing: false,
|
|
533
|
+
};
|
|
534
|
+
const binding = this.createBinding(session, starting);
|
|
535
|
+
session.bindings.set(id, binding);
|
|
536
|
+
this.sessions.set(sessionId, session);
|
|
537
|
+
this.runtimes.set(id, binding);
|
|
538
|
+
this.bindSession(session);
|
|
166
539
|
try {
|
|
167
540
|
const metadata = await runtime.start(resource.url);
|
|
168
|
-
|
|
541
|
+
const tabs = await runtime.listTabs();
|
|
542
|
+
const tab = tabs.find(candidate => candidate.active) || tabs[0];
|
|
543
|
+
if (!tab) throw new Error('agent-browser did not report the Browser tab');
|
|
544
|
+
binding.tabId = tab.tabId;
|
|
545
|
+
if (this.runtimes.get(id) !== binding) {
|
|
169
546
|
throw browserError('Browser startup lost runtime ownership', 409, 'BROWSER_START_REPLACED');
|
|
170
547
|
}
|
|
171
548
|
const running = this.store.update(id, {
|
|
172
549
|
status: 'running',
|
|
550
|
+
sessionId,
|
|
551
|
+
sessionGeneration,
|
|
552
|
+
tabId: tab.tabId,
|
|
173
553
|
url: metadata.url || resource.url,
|
|
174
554
|
title: metadata.title || '',
|
|
175
555
|
error: '',
|
|
176
556
|
});
|
|
557
|
+
session.initializing = false;
|
|
177
558
|
this.emitResource(running);
|
|
178
|
-
this.broadcastRuntimeState(
|
|
559
|
+
this.broadcastRuntimeState(binding);
|
|
179
560
|
return publicResource(running, this.store.revision);
|
|
180
561
|
} catch (error) {
|
|
562
|
+
session.initializing = false;
|
|
181
563
|
let cleanupError = null;
|
|
182
564
|
try {
|
|
183
565
|
await runtime.close();
|
|
184
566
|
} catch (closeError) {
|
|
185
567
|
cleanupError = closeError;
|
|
186
568
|
}
|
|
187
|
-
if (!cleanupError && this.runtimes.get(id) ===
|
|
569
|
+
if (!cleanupError && this.runtimes.get(id) === binding) this.runtimes.delete(id);
|
|
570
|
+
if (!cleanupError && this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
|
|
188
571
|
const current = this.store.get(id);
|
|
572
|
+
const failureMessage = executable.kind === 'external-cdp'
|
|
573
|
+
? externalBrowserFailure('External Browser connection failed', error).message
|
|
574
|
+
: error?.message || 'Failed to start Browser';
|
|
189
575
|
const failed = current?.generation === generation
|
|
190
576
|
? this.store.update(id, {
|
|
191
577
|
status: 'failed',
|
|
192
578
|
error: cleanupError
|
|
193
|
-
? `${
|
|
194
|
-
:
|
|
579
|
+
? `${failureMessage}; cleanup failed`
|
|
580
|
+
: failureMessage,
|
|
581
|
+
tabId: '',
|
|
582
|
+
...(!cleanupError ? { processIdentity: null } : {}),
|
|
195
583
|
})
|
|
196
584
|
: null;
|
|
197
585
|
if (failed) this.emitResource(failed);
|
|
198
586
|
throw browserError(
|
|
199
|
-
failed?.error || error?.message || 'Failed to start
|
|
587
|
+
failed?.error || error?.message || 'Failed to start Browser',
|
|
200
588
|
500,
|
|
201
589
|
'BROWSER_START_FAILED',
|
|
202
590
|
);
|
|
@@ -207,22 +595,76 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
207
595
|
stop(id) {
|
|
208
596
|
this.requireEnabled();
|
|
209
597
|
return this.enqueue(id, async () => {
|
|
210
|
-
this.requireStored(id);
|
|
211
|
-
const
|
|
212
|
-
if (!
|
|
213
|
-
|
|
598
|
+
const resource = this.requireStored(id);
|
|
599
|
+
const binding = this.runtimes.get(id);
|
|
600
|
+
if (!binding) {
|
|
601
|
+
if (resource.processIdentity) {
|
|
602
|
+
await this.recoverInterruptedRuntime(resource);
|
|
603
|
+
const recovered = this.requireStored(id);
|
|
604
|
+
if (recovered.processIdentity) {
|
|
605
|
+
const blockedIdentity = recovered.processIdentity;
|
|
606
|
+
throw browserError(
|
|
607
|
+
recovered.error || `Previous Browser process ${blockedIdentity.pid} still requires cleanup`,
|
|
608
|
+
500,
|
|
609
|
+
'BROWSER_RECOVERY_CLEANUP_REQUIRED',
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const stopped = this.store.update(id, {
|
|
614
|
+
status: 'stopped',
|
|
615
|
+
error: '',
|
|
616
|
+
processIdentity: null,
|
|
617
|
+
});
|
|
214
618
|
this.emitResource(stopped);
|
|
215
619
|
return publicResource(stopped, this.store.revision);
|
|
216
620
|
}
|
|
217
621
|
const stopping = this.store.update(id, { status: 'stopping', error: '' });
|
|
218
622
|
this.emitResource(stopping);
|
|
219
|
-
this.broadcastRuntimeState(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
623
|
+
this.broadcastRuntimeState(binding);
|
|
624
|
+
const { session } = binding;
|
|
625
|
+
session.closing = session.bindings.size === 1;
|
|
626
|
+
try {
|
|
627
|
+
const closeOperation = (session.actionChain || Promise.resolve())
|
|
628
|
+
.catch(() => {})
|
|
629
|
+
.then(() => (
|
|
630
|
+
session.bindings.size > 1
|
|
631
|
+
? session.runtime.closeTab(binding.tabId)
|
|
632
|
+
: session.runtime.close()
|
|
633
|
+
));
|
|
634
|
+
session.actionChain = closeOperation;
|
|
635
|
+
await closeOperation;
|
|
636
|
+
} catch (error) {
|
|
637
|
+
session.closing = false;
|
|
638
|
+
if (resource.browserKind === 'external-cdp') {
|
|
639
|
+
throw externalBrowserFailure('External Browser targets could not be closed', error);
|
|
640
|
+
}
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
session.bindings.delete(id);
|
|
644
|
+
if (this.runtimes.get(id) === binding) this.runtimes.delete(id);
|
|
645
|
+
if (session.bindings.size === 0 && this.sessions.get(session.id) === session) {
|
|
646
|
+
this.sessions.delete(session.id);
|
|
647
|
+
}
|
|
648
|
+
session.closing = false;
|
|
649
|
+
|
|
650
|
+
if (session.processOwnerResourceId === id && session.bindings.size > 0) {
|
|
651
|
+
const nextOwner = session.bindings.values().next().value;
|
|
652
|
+
session.processOwnerResourceId = nextOwner.id;
|
|
653
|
+
const ownerResource = this.store.update(nextOwner.id, {
|
|
654
|
+
processIdentity: resource.processIdentity,
|
|
655
|
+
});
|
|
656
|
+
this.emitResource(ownerResource);
|
|
657
|
+
this.broadcastRuntimeState(nextOwner);
|
|
658
|
+
}
|
|
659
|
+
const stopped = this.store.update(id, {
|
|
660
|
+
status: 'stopped',
|
|
661
|
+
error: '',
|
|
662
|
+
processIdentity: null,
|
|
663
|
+
tabId: '',
|
|
664
|
+
});
|
|
223
665
|
this.emitResource(stopped);
|
|
224
|
-
this.broadcastRuntimeState(
|
|
225
|
-
|
|
666
|
+
this.broadcastRuntimeState(binding);
|
|
667
|
+
this.releaseViewerState(binding);
|
|
226
668
|
return publicResource(stopped, this.store.revision);
|
|
227
669
|
});
|
|
228
670
|
}
|
|
@@ -230,12 +672,18 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
230
672
|
async delete(id) {
|
|
231
673
|
this.requireEnabled();
|
|
232
674
|
await this.stop(id);
|
|
233
|
-
this.requireStored(id);
|
|
675
|
+
const resource = this.requireStored(id);
|
|
234
676
|
this.store.delete(id);
|
|
235
|
-
const
|
|
677
|
+
const sessionId = resource.sessionId || id;
|
|
678
|
+
const profileDir = storageLayout.browserProfileDir(this.configDir, sessionId);
|
|
236
679
|
const browsersDir = path.resolve(storageLayout.browserResourcesDir(this.configDir));
|
|
237
680
|
const resourceDir = path.resolve(profileDir, '..');
|
|
238
|
-
|
|
681
|
+
const sessionStillReferenced = this.store.list().some(candidate => candidate.sessionId === sessionId);
|
|
682
|
+
if (
|
|
683
|
+
!sessionStillReferenced
|
|
684
|
+
&& resourceDir.startsWith(`${browsersDir}${path.sep}`)
|
|
685
|
+
&& RESOURCE_ID_RE.test(sessionId)
|
|
686
|
+
) {
|
|
239
687
|
fs.rmSync(resourceDir, { recursive: true, force: true });
|
|
240
688
|
}
|
|
241
689
|
this.emit('deleted', { id, collectionRevision: this.store.revision });
|
|
@@ -245,33 +693,33 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
245
693
|
navigate(id, url) {
|
|
246
694
|
this.requireEnabled();
|
|
247
695
|
const normalized = normalizeUrl(url);
|
|
248
|
-
return this.withRuntime(id, async runtime => {
|
|
696
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
249
697
|
const metadata = await runtime.navigate(normalized);
|
|
250
|
-
this.updateMetadata(
|
|
698
|
+
this.updateMetadata(binding, metadata);
|
|
251
699
|
return this.get(id);
|
|
252
700
|
});
|
|
253
701
|
}
|
|
254
702
|
|
|
255
703
|
goBack(id) {
|
|
256
|
-
return this.withRuntime(id, async runtime => {
|
|
704
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
257
705
|
const metadata = await runtime.goBack();
|
|
258
|
-
this.updateMetadata(
|
|
706
|
+
this.updateMetadata(binding, metadata);
|
|
259
707
|
return this.get(id);
|
|
260
708
|
});
|
|
261
709
|
}
|
|
262
710
|
|
|
263
711
|
goForward(id) {
|
|
264
|
-
return this.withRuntime(id, async runtime => {
|
|
712
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
265
713
|
const metadata = await runtime.goForward();
|
|
266
|
-
this.updateMetadata(
|
|
714
|
+
this.updateMetadata(binding, metadata);
|
|
267
715
|
return this.get(id);
|
|
268
716
|
});
|
|
269
717
|
}
|
|
270
718
|
|
|
271
719
|
reload(id) {
|
|
272
|
-
return this.withRuntime(id, async runtime => {
|
|
720
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
273
721
|
const metadata = await runtime.reload();
|
|
274
|
-
this.updateMetadata(
|
|
722
|
+
this.updateMetadata(binding, metadata);
|
|
275
723
|
return this.get(id);
|
|
276
724
|
});
|
|
277
725
|
}
|
|
@@ -286,9 +734,74 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
286
734
|
if (kind === 'forward') return this.goForward(id);
|
|
287
735
|
if (kind === 'reload') return this.reload(id);
|
|
288
736
|
if (kind === 'click') return this.withRuntime(id, runtime => runtime.click(input));
|
|
737
|
+
if ([
|
|
738
|
+
'dblclick',
|
|
739
|
+
'hover',
|
|
740
|
+
'focus',
|
|
741
|
+
'check',
|
|
742
|
+
'uncheck',
|
|
743
|
+
'scrollintoview',
|
|
744
|
+
'highlight',
|
|
745
|
+
].includes(kind)) {
|
|
746
|
+
return this.withRuntime(id, runtime => runtime.elementAction(kind, input));
|
|
747
|
+
}
|
|
289
748
|
if (kind === 'type') return this.withRuntime(id, runtime => runtime.type(input, false));
|
|
290
749
|
if (kind === 'fill') return this.withRuntime(id, runtime => runtime.type(input, true));
|
|
750
|
+
if (kind === 'keyboard') return this.withRuntime(id, runtime => runtime.keyboard(input));
|
|
291
751
|
if (kind === 'press') return this.withRuntime(id, runtime => runtime.press(input));
|
|
752
|
+
if (kind === 'select') return this.withRuntime(id, runtime => runtime.select(input));
|
|
753
|
+
if (kind === 'drag') return this.withRuntime(id, runtime => runtime.drag(input));
|
|
754
|
+
if (kind === 'wait') return this.withRuntime(id, runtime => runtime.waitFor(input));
|
|
755
|
+
if (kind === 'get') return this.withRuntime(id, runtime => runtime.get(input));
|
|
756
|
+
if (kind === 'is') return this.withRuntime(id, runtime => runtime.is(input));
|
|
757
|
+
if (kind === 'find') return this.withRuntime(id, runtime => runtime.find(input));
|
|
758
|
+
if (kind === 'eval') return this.withRuntime(id, runtime => runtime.evaluate(input));
|
|
759
|
+
if (kind === 'console' || kind === 'errors') {
|
|
760
|
+
return this.withRuntime(id, runtime => runtime.debugLog(kind, input));
|
|
761
|
+
}
|
|
762
|
+
if (kind === 'network') return this.withRuntime(id, runtime => runtime.network(input));
|
|
763
|
+
if (kind === 'cookies') return this.withRuntime(id, runtime => runtime.cookies(input));
|
|
764
|
+
if (kind === 'storage') return this.withRuntime(id, runtime => runtime.storage(input));
|
|
765
|
+
if (kind === 'frame') return this.withRuntime(id, runtime => runtime.frame(input));
|
|
766
|
+
if (kind === 'dialog') return this.withRuntime(id, runtime => runtime.dialog(input));
|
|
767
|
+
if (kind === 'upload') {
|
|
768
|
+
const resource = this.requireStored(id);
|
|
769
|
+
const requestedFiles = Array.isArray(input?.files) ? input.files : [];
|
|
770
|
+
if (requestedFiles.length === 0 || requestedFiles.length > MAX_UPLOAD_FILES) {
|
|
771
|
+
throw browserError(`Browser upload requires between 1 and ${MAX_UPLOAD_FILES} files`);
|
|
772
|
+
}
|
|
773
|
+
const files = requestedFiles.map(file => resolveWorkspaceInputFile(resource, file));
|
|
774
|
+
return this.withRuntime(id, runtime => runtime.upload({ ...input, files }));
|
|
775
|
+
}
|
|
776
|
+
if (kind === 'download') {
|
|
777
|
+
const resource = this.requireStored(id);
|
|
778
|
+
const target = resolveWorkspaceOutputFile(resource, input?.path);
|
|
779
|
+
return this.withRuntime(id, async runtime => {
|
|
780
|
+
const resourceDir = path.dirname(storageLayout.browserProfileDir(
|
|
781
|
+
this.configDir,
|
|
782
|
+
resource.sessionId || id,
|
|
783
|
+
));
|
|
784
|
+
const downloadDir = path.join(resourceDir, 'downloads');
|
|
785
|
+
fs.mkdirSync(downloadDir, { recursive: true, mode: 0o700 });
|
|
786
|
+
const temporaryPath = path.join(
|
|
787
|
+
downloadDir,
|
|
788
|
+
`${crypto.randomUUID()}-${path.basename(target)}`,
|
|
789
|
+
);
|
|
790
|
+
try {
|
|
791
|
+
await runtime.download({ ...input, outputPath: temporaryPath });
|
|
792
|
+
const stat = fs.statSync(temporaryPath);
|
|
793
|
+
if (!stat.isFile()) throw browserError('Browser download did not produce a regular file');
|
|
794
|
+
fs.copyFileSync(temporaryPath, target, fs.constants.COPYFILE_EXCL);
|
|
795
|
+
return {
|
|
796
|
+
ok: true,
|
|
797
|
+
path: path.relative(resource.workspace, target) || path.basename(target),
|
|
798
|
+
size: stat.size,
|
|
799
|
+
};
|
|
800
|
+
} finally {
|
|
801
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
}
|
|
292
805
|
if (kind === 'scroll') return this.withRuntime(id, async runtime => {
|
|
293
806
|
await runtime.wheel(input);
|
|
294
807
|
return { ok: true };
|
|
@@ -299,15 +812,19 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
299
812
|
attachViewer(id, ws) {
|
|
300
813
|
this.requireEnabled();
|
|
301
814
|
const resource = this.requireStored(id);
|
|
302
|
-
const
|
|
815
|
+
const binding = this.runtimes.get(id);
|
|
303
816
|
ws.send(JSON.stringify({
|
|
304
817
|
type: 'browser-state',
|
|
305
818
|
resource: publicResource(resource, this.store.revision),
|
|
306
819
|
}));
|
|
307
|
-
if (!
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
820
|
+
if (!binding || resource.status !== 'running') return () => {};
|
|
821
|
+
binding.viewers.add(ws);
|
|
822
|
+
if (binding.latestFrame) ws.send(JSON.stringify(binding.latestFrame));
|
|
823
|
+
void this.withRuntime(id, () => {}).catch(error => {
|
|
824
|
+
if (ws.readyState === 1) {
|
|
825
|
+
ws.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser tab failed' }));
|
|
826
|
+
}
|
|
827
|
+
});
|
|
311
828
|
const onMessage = raw => {
|
|
312
829
|
let message;
|
|
313
830
|
try {
|
|
@@ -315,7 +832,10 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
315
832
|
} catch {
|
|
316
833
|
return;
|
|
317
834
|
}
|
|
318
|
-
|
|
835
|
+
const operation = message.type === 'resize'
|
|
836
|
+
? this.scheduleViewerResize(binding, ws, message)
|
|
837
|
+
: this.handleViewerMessage(binding, ws, message);
|
|
838
|
+
void Promise.resolve(operation).catch(error => {
|
|
319
839
|
if (ws.readyState === 1) {
|
|
320
840
|
ws.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser input failed' }));
|
|
321
841
|
}
|
|
@@ -323,29 +843,132 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
323
843
|
};
|
|
324
844
|
ws.on('message', onMessage);
|
|
325
845
|
const detach = () => {
|
|
326
|
-
|
|
846
|
+
binding.viewers.delete(ws);
|
|
847
|
+
binding.viewerGeometries.delete(ws);
|
|
848
|
+
if (binding.viewerViewportOwner === ws) {
|
|
849
|
+
binding.viewerViewportOwner = binding.viewers.values().next().value || null;
|
|
850
|
+
if (binding.viewerViewportOwner) {
|
|
851
|
+
const geometry = binding.viewerGeometries.get(binding.viewerViewportOwner);
|
|
852
|
+
if (geometry) {
|
|
853
|
+
void this.scheduleViewerResize(binding, binding.viewerViewportOwner, geometry).catch(() => {});
|
|
854
|
+
}
|
|
855
|
+
} else {
|
|
856
|
+
this.clearViewerResize(binding);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
327
859
|
ws.off('message', onMessage);
|
|
328
860
|
};
|
|
329
861
|
ws.once('close', detach);
|
|
330
862
|
return detach;
|
|
331
863
|
}
|
|
332
864
|
|
|
333
|
-
handleViewerMessage(
|
|
334
|
-
const
|
|
865
|
+
handleViewerMessage(binding, viewer, message) {
|
|
866
|
+
const resource = this.requireStored(binding.id);
|
|
867
|
+
if (
|
|
868
|
+
resource.status !== 'running'
|
|
869
|
+
|| message.generation !== binding.generation
|
|
870
|
+
|| this.runtimes.get(binding.id) !== binding
|
|
871
|
+
) {
|
|
872
|
+
return Promise.reject(browserError(
|
|
873
|
+
'Browser Viewer input is no longer admitted',
|
|
874
|
+
409,
|
|
875
|
+
'BROWSER_NOT_RUNNING',
|
|
876
|
+
));
|
|
877
|
+
}
|
|
878
|
+
const { session } = binding;
|
|
879
|
+
const next = (session.actionChain || Promise.resolve())
|
|
335
880
|
.catch(() => {})
|
|
336
|
-
.then(() =>
|
|
337
|
-
|
|
881
|
+
.then(async () => {
|
|
882
|
+
await this.activateBinding(binding);
|
|
883
|
+
return this.performViewerMessage(binding, viewer, message);
|
|
884
|
+
});
|
|
885
|
+
session.actionChain = next;
|
|
338
886
|
return next;
|
|
339
887
|
}
|
|
340
888
|
|
|
341
|
-
|
|
342
|
-
|
|
889
|
+
scheduleViewerResize(binding, viewer, message) {
|
|
890
|
+
const resource = this.requireStored(binding.id);
|
|
891
|
+
if (
|
|
892
|
+
resource.status !== 'running'
|
|
893
|
+
|| message.generation !== binding.generation
|
|
894
|
+
|| this.runtimes.get(binding.id) !== binding
|
|
895
|
+
) {
|
|
896
|
+
return Promise.reject(browserError(
|
|
897
|
+
'Browser Viewer resize is no longer admitted',
|
|
898
|
+
409,
|
|
899
|
+
'BROWSER_NOT_RUNNING',
|
|
900
|
+
));
|
|
901
|
+
}
|
|
902
|
+
const width = Math.round(Number(message.width));
|
|
903
|
+
const height = Math.round(Number(message.height));
|
|
904
|
+
const requestedDeviceScaleFactor = Number(message.deviceScaleFactor);
|
|
905
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
906
|
+
return Promise.reject(browserError('Browser Viewer size is invalid'));
|
|
907
|
+
}
|
|
908
|
+
const deviceScaleFactor = Number.isFinite(requestedDeviceScaleFactor)
|
|
909
|
+
? Math.max(1, Math.min(2, requestedDeviceScaleFactor))
|
|
910
|
+
: 1;
|
|
911
|
+
const geometry = {
|
|
912
|
+
type: 'resize',
|
|
913
|
+
generation: message.generation,
|
|
914
|
+
width,
|
|
915
|
+
height,
|
|
916
|
+
deviceScaleFactor,
|
|
917
|
+
};
|
|
918
|
+
binding.viewerGeometries.set(viewer, geometry);
|
|
919
|
+
if (
|
|
920
|
+
message.claim === true
|
|
921
|
+
|| !binding.viewerViewportOwner
|
|
922
|
+
|| !binding.viewers.has(binding.viewerViewportOwner)
|
|
923
|
+
) {
|
|
924
|
+
binding.viewerViewportOwner = viewer;
|
|
925
|
+
}
|
|
926
|
+
if (binding.viewerViewportOwner !== viewer) return Promise.resolve();
|
|
927
|
+
binding.pendingViewerResize = { viewer, geometry };
|
|
928
|
+
if (binding.viewerResizeTimer) this.cancelTimeout(binding.viewerResizeTimer);
|
|
929
|
+
binding.viewerResizeTimer = this.scheduleTimeout(() => {
|
|
930
|
+
binding.viewerResizeTimer = null;
|
|
931
|
+
const pending = binding.pendingViewerResize;
|
|
932
|
+
binding.pendingViewerResize = null;
|
|
933
|
+
if (!pending || binding.viewerViewportOwner !== pending.viewer) return;
|
|
934
|
+
void this.handleViewerMessage(binding, pending.viewer, pending.geometry).catch(error => {
|
|
935
|
+
if (pending.viewer.readyState === 1) {
|
|
936
|
+
pending.viewer.send(JSON.stringify({
|
|
937
|
+
type: 'browser-error',
|
|
938
|
+
message: error?.message || 'Browser resize failed',
|
|
939
|
+
}));
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}, VIEWER_RESIZE_SETTLE_MS);
|
|
943
|
+
binding.viewerResizeTimer.unref?.();
|
|
944
|
+
return Promise.resolve();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
clearViewerResize(runtime) {
|
|
948
|
+
if (runtime.viewerResizeTimer) this.cancelTimeout(runtime.viewerResizeTimer);
|
|
949
|
+
runtime.viewerResizeTimer = null;
|
|
950
|
+
runtime.pendingViewerResize = null;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
releaseViewerState(runtime) {
|
|
954
|
+
this.clearViewerResize(runtime);
|
|
955
|
+
runtime.viewerGeometries?.clear?.();
|
|
956
|
+
runtime.viewerViewportOwner = null;
|
|
957
|
+
runtime.viewers?.clear?.();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async performViewerMessage(binding, viewer, message) {
|
|
961
|
+
if (message.generation !== binding.generation || this.runtimes.get(binding.id) !== binding) {
|
|
343
962
|
throw browserError('Browser Viewer generation is stale', 409, 'BROWSER_STALE_GENERATION');
|
|
344
963
|
}
|
|
964
|
+
const runtime = binding.session.runtime;
|
|
345
965
|
if (message.type === 'resize') {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
966
|
+
if (binding.viewerViewportOwner !== viewer) return;
|
|
967
|
+
await runtime.resize({
|
|
968
|
+
width: message.width,
|
|
969
|
+
height: message.height,
|
|
970
|
+
deviceScaleFactor: message.deviceScaleFactor,
|
|
971
|
+
});
|
|
349
972
|
return;
|
|
350
973
|
}
|
|
351
974
|
if (message.type === 'pointer') {
|
|
@@ -367,20 +990,27 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
367
990
|
|
|
368
991
|
async dispose() {
|
|
369
992
|
this.disposed = true;
|
|
370
|
-
const
|
|
371
|
-
const results = await Promise.allSettled(
|
|
993
|
+
const sessions = [...this.sessions.values()];
|
|
994
|
+
const results = await Promise.allSettled(sessions.map(session => session.runtime.close()));
|
|
372
995
|
const failures = results
|
|
373
996
|
.filter(result => result.status === 'rejected')
|
|
374
997
|
.map(result => result.reason?.message || String(result.reason));
|
|
375
998
|
if (failures.length > 0) {
|
|
376
999
|
throw new Error(`Browser runtime cleanup failed: ${failures.join('; ')}`);
|
|
377
1000
|
}
|
|
1001
|
+
for (const binding of this.runtimes.values()) this.releaseViewerState(binding);
|
|
378
1002
|
this.runtimes.clear();
|
|
1003
|
+
this.sessions.clear();
|
|
379
1004
|
}
|
|
380
1005
|
|
|
381
1006
|
async stopAll() {
|
|
382
1007
|
const ids = [...this.runtimes.keys()];
|
|
383
|
-
const results =
|
|
1008
|
+
const results = [];
|
|
1009
|
+
for (const id of ids) {
|
|
1010
|
+
results.push(await Promise.resolve(this.stop(id))
|
|
1011
|
+
.then(value => ({ status: 'fulfilled', value }))
|
|
1012
|
+
.catch(reason => ({ status: 'rejected', reason })));
|
|
1013
|
+
}
|
|
384
1014
|
const failures = results
|
|
385
1015
|
.filter(result => result.status === 'rejected')
|
|
386
1016
|
.map(result => result.reason?.message || String(result.reason));
|
|
@@ -401,9 +1031,10 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
401
1031
|
|
|
402
1032
|
requireAvailable() {
|
|
403
1033
|
this.requireEnabled();
|
|
404
|
-
|
|
1034
|
+
const executable = this.runtimeCapability;
|
|
1035
|
+
if (!executable || executable.error) {
|
|
405
1036
|
throw browserError(
|
|
406
|
-
'Install a Chromium
|
|
1037
|
+
executable?.error || 'Install Farming-managed Chromium, choose a system Chromium browser, or configure a loopback external CDP endpoint',
|
|
407
1038
|
503,
|
|
408
1039
|
'BROWSER_EXECUTABLE_NOT_FOUND',
|
|
409
1040
|
);
|
|
@@ -428,91 +1059,266 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
428
1059
|
});
|
|
429
1060
|
}
|
|
430
1061
|
|
|
1062
|
+
createBinding(session, resource) {
|
|
1063
|
+
return {
|
|
1064
|
+
id: resource.id,
|
|
1065
|
+
generation: resource.generation,
|
|
1066
|
+
session,
|
|
1067
|
+
tabId: resource.tabId || '',
|
|
1068
|
+
viewers: new Set(),
|
|
1069
|
+
viewerGeometries: new Map(),
|
|
1070
|
+
viewerViewportOwner: null,
|
|
1071
|
+
viewerResizeTimer: null,
|
|
1072
|
+
pendingViewerResize: null,
|
|
1073
|
+
latestFrame: null,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async activateBinding(binding) {
|
|
1078
|
+
const { session } = binding;
|
|
1079
|
+
if (!binding.tabId) throw browserError('Browser tab is unavailable', 409, 'BROWSER_TAB_UNAVAILABLE');
|
|
1080
|
+
if (
|
|
1081
|
+
session.runtime.activeTabId !== binding.tabId
|
|
1082
|
+
|| session.runtime.streamTabId !== binding.tabId
|
|
1083
|
+
) {
|
|
1084
|
+
await session.runtime.switchTab(binding.tabId);
|
|
1085
|
+
}
|
|
1086
|
+
session.activeResourceId = binding.id;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
431
1089
|
withRuntime(id, operation) {
|
|
432
|
-
const
|
|
1090
|
+
const binding = this.runtimes.get(id);
|
|
433
1091
|
const resource = this.requireStored(id);
|
|
434
|
-
if (!
|
|
1092
|
+
if (!binding || resource.status !== 'running') {
|
|
435
1093
|
throw browserError('Browser is not running', 409, 'BROWSER_NOT_RUNNING');
|
|
436
1094
|
}
|
|
437
|
-
const
|
|
1095
|
+
const { session } = binding;
|
|
1096
|
+
const next = (session.actionChain || Promise.resolve())
|
|
438
1097
|
.catch(() => {})
|
|
439
|
-
.then(() =>
|
|
440
|
-
|
|
1098
|
+
.then(async () => {
|
|
1099
|
+
await this.activateBinding(binding);
|
|
1100
|
+
return operation(session.runtime, binding);
|
|
1101
|
+
});
|
|
1102
|
+
session.actionChain = next;
|
|
441
1103
|
return next;
|
|
442
1104
|
}
|
|
443
1105
|
|
|
444
|
-
|
|
1106
|
+
bindSession(session) {
|
|
1107
|
+
const { runtime } = session;
|
|
1108
|
+
runtime.on('process-identity', processIdentity => {
|
|
1109
|
+
const owner = this.store.get(session.processOwnerResourceId);
|
|
1110
|
+
if (
|
|
1111
|
+
this.sessions.get(session.id) !== session
|
|
1112
|
+
|| !owner
|
|
1113
|
+
|| owner.sessionId && owner.sessionId !== session.id
|
|
1114
|
+
) return;
|
|
1115
|
+
const next = this.store.update(owner.id, { processIdentity });
|
|
1116
|
+
this.emitResource(next);
|
|
1117
|
+
});
|
|
445
1118
|
runtime.on('frame', frame => {
|
|
446
|
-
|
|
447
|
-
|
|
1119
|
+
const binding = [...session.bindings.values()]
|
|
1120
|
+
.find(candidate => candidate.tabId === runtime.streamTabId)
|
|
1121
|
+
|| session.bindings.get(session.activeResourceId);
|
|
1122
|
+
if (!binding) return;
|
|
1123
|
+
const resourceFrame = {
|
|
1124
|
+
...frame,
|
|
1125
|
+
generation: binding.generation,
|
|
1126
|
+
};
|
|
1127
|
+
binding.latestFrame = resourceFrame;
|
|
1128
|
+
for (const viewer of binding.viewers) {
|
|
448
1129
|
if (viewer.readyState === 1 && (Number(viewer.bufferedAmount) || 0) <= MAX_VIEWER_BUFFER_BYTES) {
|
|
449
|
-
viewer.send(JSON.stringify(
|
|
1130
|
+
viewer.send(JSON.stringify(resourceFrame));
|
|
450
1131
|
}
|
|
451
1132
|
}
|
|
452
1133
|
});
|
|
453
|
-
runtime.on('metadata', metadata =>
|
|
1134
|
+
runtime.on('metadata', metadata => {
|
|
1135
|
+
const binding = [...session.bindings.values()]
|
|
1136
|
+
.find(candidate => candidate.tabId === runtime.activeTabId)
|
|
1137
|
+
|| session.bindings.get(session.activeResourceId);
|
|
1138
|
+
if (binding) this.updateMetadata(binding, metadata);
|
|
1139
|
+
});
|
|
1140
|
+
runtime.on('tabs', event => {
|
|
1141
|
+
if (session.initializing || session.closing) return;
|
|
1142
|
+
const next = (session.actionChain || Promise.resolve())
|
|
1143
|
+
.catch(() => {})
|
|
1144
|
+
.then(() => this.reconcileTabs(session, event));
|
|
1145
|
+
session.actionChain = next;
|
|
1146
|
+
});
|
|
454
1147
|
runtime.on('error', error => {
|
|
455
|
-
for (const
|
|
456
|
-
|
|
457
|
-
viewer.
|
|
1148
|
+
for (const binding of session.bindings.values()) {
|
|
1149
|
+
for (const viewer of binding.viewers) {
|
|
1150
|
+
if (viewer.readyState === 1) {
|
|
1151
|
+
viewer.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser runtime failed' }));
|
|
1152
|
+
}
|
|
458
1153
|
}
|
|
459
1154
|
}
|
|
460
1155
|
});
|
|
461
1156
|
runtime.once('exit', message => {
|
|
462
|
-
void this.handleRuntimeExit(
|
|
1157
|
+
void this.handleRuntimeExit(session, message);
|
|
463
1158
|
});
|
|
464
1159
|
}
|
|
465
1160
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
1161
|
+
async reconcileTabs(session, event) {
|
|
1162
|
+
if (this.sessions.get(session.id) !== session || session.closing) return;
|
|
1163
|
+
const tabs = Array.isArray(event?.tabs) ? event.tabs.filter(tab => tab.type === 'page') : [];
|
|
1164
|
+
const newlyObservedTabIds = new Set(
|
|
1165
|
+
Array.isArray(event?.newTabIds) ? event.newTabIds.map(String) : [],
|
|
1166
|
+
);
|
|
1167
|
+
const byTabId = new Map([...session.bindings.values()].map(binding => [binding.tabId, binding]));
|
|
1168
|
+
const opener = session.bindings.get(session.activeResourceId) || null;
|
|
1169
|
+
const opened = [];
|
|
1170
|
+
|
|
1171
|
+
for (const tab of tabs) {
|
|
1172
|
+
let binding = byTabId.get(tab.tabId);
|
|
1173
|
+
if (
|
|
1174
|
+
!binding
|
|
1175
|
+
&& newlyObservedTabIds.has(tab.tabId)
|
|
1176
|
+
&& (!session.runtime.externalCdpUrl || event.popupAdmitted)
|
|
1177
|
+
) {
|
|
1178
|
+
session.runtime.ownedTabIds.add(tab.tabId);
|
|
1179
|
+
const created = this.store.createRunningTab({
|
|
1180
|
+
projectRootId: opener?.session.projectRootId || session.projectRootId,
|
|
1181
|
+
workspace: this.store.get(opener?.id)?.workspace
|
|
1182
|
+
|| this.store.list().find(resource => resource.sessionId === session.id)?.workspace,
|
|
1183
|
+
name: tabResourceName(tab),
|
|
1184
|
+
url: tab.url,
|
|
1185
|
+
title: tab.title,
|
|
1186
|
+
browserKind: session.browserKind,
|
|
1187
|
+
sessionId: session.id,
|
|
1188
|
+
sessionGeneration: session.generation,
|
|
1189
|
+
tabId: tab.tabId,
|
|
1190
|
+
});
|
|
1191
|
+
binding = this.createBinding(session, created);
|
|
1192
|
+
session.bindings.set(created.id, binding);
|
|
1193
|
+
this.runtimes.set(created.id, binding);
|
|
1194
|
+
byTabId.set(tab.tabId, binding);
|
|
1195
|
+
this.emitResource(created);
|
|
1196
|
+
opened.push(publicResource(created, this.store.revision));
|
|
1197
|
+
}
|
|
1198
|
+
if (!binding) continue;
|
|
1199
|
+
const current = this.store.get(binding.id);
|
|
1200
|
+
if (!current || current.status !== 'running') continue;
|
|
1201
|
+
if (current.url !== tab.url || current.title !== tab.title) {
|
|
1202
|
+
const updated = this.store.update(binding.id, {
|
|
1203
|
+
url: tab.url || current.url,
|
|
1204
|
+
title: tab.title || '',
|
|
1205
|
+
...(current.autoName ? { name: tabResourceName(tab) } : {}),
|
|
1206
|
+
});
|
|
1207
|
+
this.emitResource(updated);
|
|
1208
|
+
this.broadcastRuntimeState(binding);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const liveTabIds = new Set(tabs.map(tab => tab.tabId));
|
|
1213
|
+
for (const binding of [...session.bindings.values()]) {
|
|
1214
|
+
if (liveTabIds.has(binding.tabId)) continue;
|
|
1215
|
+
const current = this.store.get(binding.id);
|
|
1216
|
+
if (!current || current.status === 'stopping') continue;
|
|
1217
|
+
session.bindings.delete(binding.id);
|
|
1218
|
+
this.runtimes.delete(binding.id);
|
|
1219
|
+
if (session.processOwnerResourceId === binding.id && session.bindings.size > 0) {
|
|
1220
|
+
const nextOwner = session.bindings.values().next().value;
|
|
1221
|
+
session.processOwnerResourceId = nextOwner.id;
|
|
1222
|
+
const transferred = this.store.update(nextOwner.id, {
|
|
1223
|
+
processIdentity: current.processIdentity,
|
|
1224
|
+
});
|
|
1225
|
+
this.emitResource(transferred);
|
|
1226
|
+
this.broadcastRuntimeState(nextOwner);
|
|
1227
|
+
}
|
|
1228
|
+
const stopped = this.store.update(binding.id, {
|
|
1229
|
+
status: 'stopped',
|
|
1230
|
+
tabId: '',
|
|
1231
|
+
processIdentity: null,
|
|
1232
|
+
error: '',
|
|
1233
|
+
});
|
|
1234
|
+
this.emitResource(stopped);
|
|
1235
|
+
this.broadcastRuntimeState(binding);
|
|
1236
|
+
this.releaseViewerState(binding);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const activeTab = tabs.find(tab => tab.active);
|
|
1240
|
+
const activeBinding = activeTab ? byTabId.get(activeTab.tabId) : null;
|
|
1241
|
+
if (activeBinding) {
|
|
1242
|
+
session.activeResourceId = activeBinding.id;
|
|
1243
|
+
if (session.runtime.streamTabId !== activeBinding.tabId) {
|
|
1244
|
+
await session.runtime.switchTab(activeBinding.tabId);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
if (opened.length > 0 && opener) {
|
|
1248
|
+
const message = JSON.stringify({
|
|
1249
|
+
type: 'browser-tab-opened',
|
|
1250
|
+
resource: opened.at(-1),
|
|
1251
|
+
});
|
|
1252
|
+
for (const viewer of opener.viewers) {
|
|
1253
|
+
if (viewer.readyState === 1) viewer.send(message);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
updateMetadata(binding, metadata) {
|
|
1259
|
+
const current = this.store.get(binding.id);
|
|
1260
|
+
if (!current || current.generation !== binding.generation || !metadata) return;
|
|
1261
|
+
const next = this.store.update(binding.id, {
|
|
470
1262
|
url: String(metadata.url || current.url),
|
|
471
1263
|
title: String(metadata.title || ''),
|
|
472
1264
|
});
|
|
473
1265
|
this.emitResource(next);
|
|
474
|
-
this.broadcastRuntimeState(
|
|
1266
|
+
this.broadcastRuntimeState(binding);
|
|
475
1267
|
}
|
|
476
1268
|
|
|
477
1269
|
emitResource(resource) {
|
|
478
1270
|
this.emit('resource', publicResource(resource, this.store.revision));
|
|
479
1271
|
}
|
|
480
1272
|
|
|
481
|
-
broadcastRuntimeState(
|
|
482
|
-
const resource = this.store.get(
|
|
1273
|
+
broadcastRuntimeState(binding) {
|
|
1274
|
+
const resource = this.store.get(binding.id);
|
|
483
1275
|
if (!resource) return;
|
|
484
1276
|
const message = JSON.stringify({
|
|
485
1277
|
type: 'browser-state',
|
|
486
1278
|
resource: publicResource(resource, this.store.revision),
|
|
487
1279
|
});
|
|
488
|
-
for (const viewer of
|
|
1280
|
+
for (const viewer of binding.viewers || []) {
|
|
489
1281
|
if (viewer.readyState === 1) viewer.send(message);
|
|
490
1282
|
}
|
|
491
1283
|
}
|
|
492
1284
|
|
|
493
|
-
async handleRuntimeExit(
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const failed = this.store.update(runtime.id, {
|
|
501
|
-
status: 'failed',
|
|
502
|
-
error: message || 'System browser exited',
|
|
503
|
-
});
|
|
504
|
-
this.emitResource(failed);
|
|
505
|
-
this.broadcastRuntimeState(runtime);
|
|
506
|
-
try {
|
|
507
|
-
await runtime.close();
|
|
508
|
-
if (this.runtimes.get(runtime.id) === runtime) this.runtimes.delete(runtime.id);
|
|
509
|
-
} catch (error) {
|
|
510
|
-
const cleanupFailed = this.store.update(runtime.id, {
|
|
1285
|
+
async handleRuntimeExit(session, message) {
|
|
1286
|
+
if (this.sessions.get(session.id) !== session) return;
|
|
1287
|
+
const failedBindings = [...session.bindings.values()];
|
|
1288
|
+
for (const binding of failedBindings) {
|
|
1289
|
+
const current = this.store.get(binding.id);
|
|
1290
|
+
if (!current) continue;
|
|
1291
|
+
const failed = this.store.update(binding.id, {
|
|
511
1292
|
status: 'failed',
|
|
512
|
-
error:
|
|
1293
|
+
error: current.browserKind === 'external-cdp'
|
|
1294
|
+
? 'External Browser connection exited'
|
|
1295
|
+
: message || 'Browser connection exited',
|
|
513
1296
|
});
|
|
514
|
-
this.emitResource(
|
|
515
|
-
this.broadcastRuntimeState(
|
|
1297
|
+
this.emitResource(failed);
|
|
1298
|
+
this.broadcastRuntimeState(binding);
|
|
1299
|
+
}
|
|
1300
|
+
try {
|
|
1301
|
+
await session.runtime.close();
|
|
1302
|
+
this.sessions.delete(session.id);
|
|
1303
|
+
for (const binding of failedBindings) {
|
|
1304
|
+
this.runtimes.delete(binding.id);
|
|
1305
|
+
this.releaseViewerState(binding);
|
|
1306
|
+
const cleaned = this.store.update(binding.id, { processIdentity: null });
|
|
1307
|
+
if (cleaned) this.emitResource(cleaned);
|
|
1308
|
+
}
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
for (const binding of failedBindings) {
|
|
1311
|
+
const current = this.store.get(binding.id);
|
|
1312
|
+
if (!current) continue;
|
|
1313
|
+
const cleanupFailed = this.store.update(binding.id, {
|
|
1314
|
+
status: 'failed',
|
|
1315
|
+
error: current.browserKind === 'external-cdp'
|
|
1316
|
+
? `${current.error}; target cleanup failed`
|
|
1317
|
+
: `${current.error}; cleanup failed: ${error?.message || error}`,
|
|
1318
|
+
});
|
|
1319
|
+
this.emitResource(cleanupFailed);
|
|
1320
|
+
this.broadcastRuntimeState(binding);
|
|
1321
|
+
}
|
|
516
1322
|
}
|
|
517
1323
|
}
|
|
518
1324
|
}
|
|
@@ -520,5 +1326,6 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
520
1326
|
module.exports = {
|
|
521
1327
|
BrowserResourceManager,
|
|
522
1328
|
browserError,
|
|
1329
|
+
externalBrowserFailure,
|
|
523
1330
|
normalizeUrl,
|
|
524
1331
|
};
|