symbiot 0.63.6 → 0.63.8
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 +41 -0
- package/README.md +39 -5
- package/agents.mjs +52 -11
- package/browser.html +21 -3
- package/capture.mjs +761 -0
- package/demo.mjs +171 -0
- package/guard.mjs +23 -5
- package/handback.mjs +22 -8
- package/handover.mjs +6 -2
- package/headless.mjs +281 -80
- package/home.mjs +23 -41
- package/index.mjs +65 -11
- package/lanes.mjs +99 -9
- package/links.mjs +52 -2
- package/marketing.mjs +40 -7
- package/mdns.mjs +6 -4
- package/package.json +6 -3
- package/phone.mjs +79 -12
- package/plain.mjs +48 -0
- package/reports.mjs +15 -1
- package/server.mjs +89 -42
- package/tray.mjs +44 -4
- package/ui.mjs +317 -47
package/headless.mjs
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// signed in. It's separate from your everyday browser profile.
|
|
22
22
|
import { spawn, spawnSync } from "node:child_process";
|
|
23
23
|
import { join, resolve as resolvePath } from "node:path";
|
|
24
|
-
import { mkdirSync, existsSync, statSync, readlinkSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { mkdirSync, existsSync, statSync, readlinkSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
25
26
|
import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
|
|
26
27
|
import { loadScreens, addPageScreen, center, stitchPng, stitchListPng } from "./screens.mjs";
|
|
27
28
|
|
|
@@ -150,30 +151,56 @@ async function closeOthers(c, keep) {
|
|
|
150
151
|
let queue = Promise.resolve();
|
|
151
152
|
const oneAtATime = (fn) => { const run = queue.then(fn, fn); queue = run.catch(() => {}); return run; };
|
|
152
153
|
|
|
153
|
-
// The hidden browser, while it's open: { proc, c,
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
154
|
+
// The hidden browser, while it's open: { proc, c, tabs, timer }. Each agent has a tab
|
|
155
|
+
// of its own in it (tabs: who → { page, shown, used }), so two lanes driving it at once
|
|
156
|
+
// (LinkedIn and the Twilio console, 2026-10-12) don't land on each other's page, and a
|
|
157
|
+
// post half-written in one keeps its text and picture between that agent's steps.
|
|
158
|
+
// shown is the id of the screen last mapped from that tab, which is what it shows now.
|
|
159
|
+
// keepBrowserOpen(ms) keeps it open that long after each action (the app does); with 0,
|
|
160
|
+
// a one-off `symbiot screens` command, it closes after each one.
|
|
157
161
|
let live = null, KEEP = 0;
|
|
162
|
+
const MAIN = "symbiot", MAX_TABS = 6;
|
|
163
|
+
// Whose tab: the agent's run folder (SYMBIOT_AGENT, else where the command ran), or Symbiot's own.
|
|
164
|
+
const tabKey = (who) => String(who || "").trim().slice(0, 300) || MAIN;
|
|
158
165
|
function keepBrowserOpen(ms) { KEEP = Math.max(0, Number(ms) || 0); if (!KEEP && live && !live.busy) closeBrowser(); }
|
|
159
166
|
const browserOpen = () => !!(live && !live.c.closed());
|
|
160
167
|
// Don't leave it running when Symbiot exits (a restart, Ctrl+C).
|
|
161
168
|
process.once("exit", () => { if (live && live.proc.exitCode === null) try { live.proc.kill("SIGKILL"); } catch {} });
|
|
162
169
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
170
|
+
// The Chrome holding Symbiot's profile: its SingletonLock names the process ("host-1234",
|
|
171
|
+
// Linux and Mac), and its own args say whether it's hidden. { pid, headless }, or null
|
|
172
|
+
// when none is (no lock, or a leftover one whose process has gone).
|
|
165
173
|
const OWN = new Set(); // every hidden Chrome this Symbiot started
|
|
166
|
-
function
|
|
167
|
-
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
} catch { return
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
function argsOf(pid) {
|
|
175
|
+
try { return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").join(" "); } catch {}
|
|
176
|
+
try { return spawnSync("ps", ["-ww", "-o", "args=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 }).stdout || ""; } catch { return ""; }
|
|
177
|
+
}
|
|
178
|
+
function lockHolder() {
|
|
179
|
+
let pid = 0; try { pid = Number((/-(\d+)$/.exec(readlinkSync(join(PROFILE, "SingletonLock"))) || [])[1]) || 0; } catch {}
|
|
180
|
+
if (!pid) return null;
|
|
181
|
+
try { process.kill(pid, 0); } catch { return null; }
|
|
182
|
+
return { pid, headless: /--headless/.test(argsOf(pid)) };
|
|
183
|
+
}
|
|
184
|
+
// Is a visible Chrome (the Symbiot Browser) using Symbiot's profile? Not our own hidden
|
|
185
|
+
// browser (running, or still closing), and not a hidden one at all: one that isn't ours
|
|
186
|
+
// is a stray (strayGone). Taking any hidden Chrome for a window blocked every map and
|
|
187
|
+
// press while the app ran, with "click Done" in a window that didn't exist (2026-10-12).
|
|
188
|
+
function windowOpen() { const h = lockHolder(); return !!h && !h.headless && !OWN.has(h.pid); }
|
|
189
|
+
// Another Symbiot's hidden browser on the profile (a one-off `symbiot screens`, or an app
|
|
190
|
+
// from before an update that hasn't gone): it closes after its action, so it's given a
|
|
191
|
+
// moment; one still there after that is a stray, and is closed. Gives once it's gone.
|
|
192
|
+
const STRAY_WAIT = Number(process.env.SYMBIOT_STRAY_WAIT) || 10000;
|
|
193
|
+
async function strayGone(wait = STRAY_WAIT) {
|
|
194
|
+
const stray = () => { const h = lockHolder(); return h && h.headless && !OWN.has(h.pid) ? h.pid : 0; };
|
|
195
|
+
for (const end = Date.now() + wait; stray() && Date.now() < end;) await sleep(250);
|
|
196
|
+
const pid = stray(); if (!pid) return;
|
|
197
|
+
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
198
|
+
for (let i = 0; i < 20 && stray() === pid; i++) await sleep(250);
|
|
199
|
+
if (stray() === pid) try { process.kill(pid, "SIGKILL"); } catch {}
|
|
200
|
+
for (let i = 0; i < 8 && stray() === pid; i++) await sleep(250);
|
|
201
|
+
}
|
|
202
|
+
// Start the hidden browser, with a tab for who: { proc, c, tabs }.
|
|
203
|
+
async function launch(who = MAIN) {
|
|
177
204
|
const chrome = chromeBinary();
|
|
178
205
|
if (!chrome) throw new Error(process.platform === "android" || process.env.SYMBIOT_ANDROID_APP === "1"
|
|
179
206
|
? "Mapping a page needs a desktop browser (Chrome, Chromium, Edge or Brave), which a phone doesn't have. Map pages from Symbiot on your computer."
|
|
@@ -182,11 +209,12 @@ async function launch() {
|
|
|
182
209
|
// the Symbiot Browser (or a sign-in window) has the profile: a second Chrome would hand
|
|
183
210
|
// over to it and pop an empty window into it, so wait for Done instead
|
|
184
211
|
if (windowOpen()) throw new Error("The Symbiot Browser is open: Symbiot reads your sites once you click Done in it.");
|
|
212
|
+
await strayGone();
|
|
185
213
|
const proc = spawn(chrome, browserArgs(true), { stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] });
|
|
186
214
|
if (proc.pid) OWN.add(proc.pid);
|
|
187
215
|
let err = ""; proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
|
|
188
216
|
const failed = new Promise((resolve, reject) => { proc.on("error", reject); }); failed.catch(() => {});
|
|
189
|
-
const c = connect(proc), b = { proc, c,
|
|
217
|
+
const c = connect(proc), b = { proc, c, tabs: new Map(), timer: null };
|
|
190
218
|
const kill = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, START);
|
|
191
219
|
try {
|
|
192
220
|
const run = (async () => {
|
|
@@ -198,41 +226,57 @@ async function launch() {
|
|
|
198
226
|
throw new Error("The browser didn't start: " + ((err.trim().split("\n").pop()) || e.message));
|
|
199
227
|
}
|
|
200
228
|
await closeOthers(c, targetId); await keepSessions(c);
|
|
201
|
-
|
|
202
|
-
const send = (method, params) => c.send(method, params, sessionId);
|
|
203
|
-
const until = (method, ms) => c.until((m) => m.sessionId === sessionId && m.method === method, ms);
|
|
204
|
-
// Requests in flight, so "loaded" can wait for a page that fills itself in.
|
|
205
|
-
const inflight = new Set();
|
|
206
|
-
c.on((m) => {
|
|
207
|
-
if (!m || m.sessionId !== sessionId) return;
|
|
208
|
-
if (m.method === "Network.requestWillBeSent") inflight.add(m.params.requestId);
|
|
209
|
-
else if (m.method === "Network.loadingFinished" || m.method === "Network.loadingFailed") inflight.delete(m.params.requestId);
|
|
210
|
-
});
|
|
211
|
-
// Quiet: no more than one request (a long poll, say) open for 600 ms, or 8 s at most.
|
|
212
|
-
const idle = async () => {
|
|
213
|
-
const end = Date.now() + 8000; let calm = 0;
|
|
214
|
-
await sleep(400);
|
|
215
|
-
while (Date.now() < end && calm < 600) { await sleep(100); calm = inflight.size <= 1 ? calm + 100 : 0; }
|
|
216
|
-
};
|
|
217
|
-
await send("Page.enable"); await send("Network.enable");
|
|
218
|
-
const ss = loadSessions(); if (Object.keys(ss).length) try { await send("Page.addScriptToEvaluateOnNewDocument", { source: sessionFill(ss), worldName: "symbiot-sessions" }); } catch {}
|
|
219
|
-
await send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 1, mobile: false });
|
|
220
|
-
// Sites serve "HeadlessChrome" something else (or a block page): look like the browser it is.
|
|
221
|
-
const { userAgent } = await c.send("Browser.getVersion");
|
|
222
|
-
if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
|
|
223
|
-
// every `method` event on this tab, until the function it gives is called
|
|
224
|
-
const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
|
|
225
|
-
b.page = { send, until, idle, on };
|
|
229
|
+
await openTab(b, who, targetId);
|
|
226
230
|
return b;
|
|
227
231
|
})();
|
|
228
232
|
return await Promise.race([run, failed]);
|
|
229
233
|
} catch (e) { await shut(b); throw e; }
|
|
230
234
|
finally { clearTimeout(kill); }
|
|
231
235
|
}
|
|
236
|
+
// A tab of its own for who (a new one, or targetId's): { who, targetId, page: { send, until,
|
|
237
|
+
// idle, on }, shown, used }. Past MAX_TABS, the one used longest ago is closed first.
|
|
238
|
+
async function openTab(b, who, targetId) {
|
|
239
|
+
const c = b.c;
|
|
240
|
+
if (b.tabs.size >= MAX_TABS) { const old = [...b.tabs.values()].sort((x, y) => x.used - y.used)[0]; if (old) await closeTab(b, old); }
|
|
241
|
+
if (!targetId) ({ targetId } = await c.send("Target.createTarget", { url: "about:blank" }));
|
|
242
|
+
const { sessionId } = await c.send("Target.attachToTarget", { targetId, flatten: true });
|
|
243
|
+
const send = (method, params) => c.send(method, params, sessionId);
|
|
244
|
+
const until = (method, ms) => c.until((m) => m.sessionId === sessionId && m.method === method, ms);
|
|
245
|
+
// Requests in flight, so "loaded" can wait for a page that fills itself in.
|
|
246
|
+
const inflight = new Set();
|
|
247
|
+
const unhook = c.on((m) => {
|
|
248
|
+
if (!m || m.sessionId !== sessionId) return;
|
|
249
|
+
if (m.method === "Network.requestWillBeSent") inflight.add(m.params.requestId);
|
|
250
|
+
else if (m.method === "Network.loadingFinished" || m.method === "Network.loadingFailed") inflight.delete(m.params.requestId);
|
|
251
|
+
});
|
|
252
|
+
// Quiet: no more than one request (a long poll, say) open for 600 ms, or 8 s at most.
|
|
253
|
+
const idle = async () => {
|
|
254
|
+
const end = Date.now() + 8000; let calm = 0;
|
|
255
|
+
await sleep(400);
|
|
256
|
+
while (Date.now() < end && calm < 600) { await sleep(100); calm = inflight.size <= 1 ? calm + 100 : 0; }
|
|
257
|
+
};
|
|
258
|
+
await send("Page.enable"); await send("Network.enable");
|
|
259
|
+
const ss = loadSessions(); if (Object.keys(ss).length) try { await send("Page.addScriptToEvaluateOnNewDocument", { source: sessionFill(ss), worldName: "symbiot-sessions" }); } catch {}
|
|
260
|
+
await send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 1, mobile: false });
|
|
261
|
+
// Sites serve "HeadlessChrome" something else (or a block page): look like the browser it is.
|
|
262
|
+
const { userAgent } = await c.send("Browser.getVersion");
|
|
263
|
+
if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
|
|
264
|
+
// every `method` event on this tab, until the function it gives is called
|
|
265
|
+
const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
|
|
266
|
+
const tab = { who, targetId, page: { send, until, idle, on }, shown: "", used: Date.now(), unhook };
|
|
267
|
+
b.tabs.set(who, tab);
|
|
268
|
+
return tab;
|
|
269
|
+
}
|
|
270
|
+
async function closeTab(b, tab) {
|
|
271
|
+
if (b.tabs.get(tab.who) === tab) b.tabs.delete(tab.who);
|
|
272
|
+
if (tab.unhook) tab.unhook();
|
|
273
|
+
try { await Promise.race([b.c.send("Target.closeTarget", { targetId: tab.targetId }), sleep(3000)]); } catch {}
|
|
274
|
+
}
|
|
232
275
|
async function shut(b) {
|
|
233
276
|
if (!b) return;
|
|
234
277
|
clearTimeout(b.timer); if (live === b) live = null;
|
|
235
|
-
|
|
278
|
+
const any = b.tabs && [...b.tabs.values()][0];
|
|
279
|
+
if (!b.c.closed()) { try { if (any) { const own = await Promise.race([readSession(any.page.send), sleep(2500)]); if (own) saveSessions([own]); } await Promise.race([keepSessions(b.c), sleep(3000)]); await Promise.race([b.c.send("Browser.close"), sleep(3000)]); } catch {} }
|
|
236
280
|
// Chrome writes its cookies to disk as it shuts down: killed before that, a sign-in made
|
|
237
281
|
// or kept since it started is lost. Give it a few seconds to finish.
|
|
238
282
|
if (b.proc.exitCode === null) await Promise.race([new Promise((r) => b.proc.once("exit", r)), sleep(5000)]);
|
|
@@ -241,21 +285,27 @@ async function shut(b) {
|
|
|
241
285
|
// Close the hidden browser now (the Sign in window needs its profile).
|
|
242
286
|
function closeBrowser() { return shut(live); }
|
|
243
287
|
|
|
244
|
-
// Give fn the hidden browser
|
|
245
|
-
// shows (
|
|
246
|
-
// so
|
|
247
|
-
|
|
288
|
+
// Give fn who's tab in the hidden browser (the open one, else a new one), and what it
|
|
289
|
+
// shows (tab.shown). After: kept open for KEEP ms, or closed. On an error that tab is
|
|
290
|
+
// closed, so that agent's next action starts afresh, and the other agents' tabs stay
|
|
291
|
+
// as they are; the whole browser only when it's gone.
|
|
292
|
+
async function withPage(fn, keep = true, who = MAIN) {
|
|
248
293
|
if (live && live.c.closed()) await shut(live);
|
|
294
|
+
who = tabKey(who);
|
|
249
295
|
let b = live;
|
|
250
|
-
if (b) clearTimeout(b.timer); else b = live = await launch();
|
|
296
|
+
if (b) clearTimeout(b.timer); else b = live = await launch(who);
|
|
251
297
|
b.busy = true;
|
|
252
298
|
const kill = setTimeout(() => { try { b.proc.kill("SIGKILL"); } catch {} }, TIMEOUT);
|
|
299
|
+
let tab = null;
|
|
253
300
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
301
|
+
tab = b.tabs.get(who) || await openTab(b, who);
|
|
302
|
+
tab.used = Date.now();
|
|
303
|
+
try { await tab.page.send("Page.bringToFront"); } catch {} // a tab behind another doesn't paint
|
|
304
|
+
const r = await fn(tab.page, tab);
|
|
305
|
+
// stay: nothing changed on the page, so it shows what fn left in tab.shown
|
|
306
|
+
if (r && r.stay) delete r.stay; else tab.shown = r && r.id && !r.error ? r.id : "";
|
|
257
307
|
return r;
|
|
258
|
-
} catch (e) { await shut(b); throw e; }
|
|
308
|
+
} catch (e) { if (!tab || b.c.closed()) await shut(b); else await closeTab(b, tab); throw e; }
|
|
259
309
|
finally {
|
|
260
310
|
clearTimeout(kill); b.busy = false;
|
|
261
311
|
if (live === b && KEEP && keep && !b.c.closed()) b.timer = setTimeout(() => oneAtATime(() => live === b && !b.busy ? shut(b) : null), KEEP);
|
|
@@ -523,7 +573,7 @@ async function snapshotList(page, name) {
|
|
|
523
573
|
// Map a site: open it in the hidden browser and save what's on it as a screen.
|
|
524
574
|
// whole: all of the page in one tall screen (snapshotWhole); a page whose list
|
|
525
575
|
// scrolls inside it gets the usual map, with a note that says why.
|
|
526
|
-
function mapPage(input, name, { whole = false } = {}) {
|
|
576
|
+
function mapPage(input, name, { whole = false, who = "" } = {}) {
|
|
527
577
|
const url = siteUrl(input);
|
|
528
578
|
if (!url) return Promise.resolve({ error: "Give a site to map: a name (gmail), a host (github.com/pulls) or a web address." });
|
|
529
579
|
return oneAtATime(() => withPage(async (page) => {
|
|
@@ -533,12 +583,12 @@ function mapPage(input, name, { whole = false } = {}) {
|
|
|
533
583
|
if (!s.inside) return s;
|
|
534
584
|
const one = await snapshot(page, name);
|
|
535
585
|
return one.error || one.note ? one : { ...one, note: s.error };
|
|
536
|
-
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
586
|
+
}, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
537
587
|
}
|
|
538
588
|
|
|
539
589
|
// The whole of a page you've mapped, as one tall screen (snapshotWhole). Only
|
|
540
590
|
// looks, so it never asks first.
|
|
541
|
-
function wholePage(id) {
|
|
591
|
+
function wholePage(id, { who = "" } = {}) {
|
|
542
592
|
const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
|
|
543
593
|
if (!s.page || !s.page.url) return Promise.resolve({ error: "Whole page works on a mapped page." });
|
|
544
594
|
return oneAtATime(() => withPage(async (page, b) => {
|
|
@@ -546,7 +596,7 @@ function wholePage(id) {
|
|
|
546
596
|
const next = await snapshotWhole(page, s.name.replace(/ ↓ \d+%$/, "").replace(/ \(whole page\)$/, ""));
|
|
547
597
|
if (next.error) { delete next.inside; return next; }
|
|
548
598
|
return { ...next, kept: here };
|
|
549
|
-
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
599
|
+
}, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
550
600
|
}
|
|
551
601
|
|
|
552
602
|
// Read a site as it is now, without saving a screen: { url, title, items, login }.
|
|
@@ -556,7 +606,9 @@ function wholePage(id) {
|
|
|
556
606
|
// browser open after.
|
|
557
607
|
// password: a page asking for a password counts as a sign-in page too (a site's own login
|
|
558
608
|
// form at an address that doesn't say so), for a check of whether you're signed in.
|
|
559
|
-
|
|
609
|
+
// So does a page whose own button or link is just "Log in" or "Sign in": signed out, Reddit's
|
|
610
|
+
// home page has no password box, only its Log In button, and read as signed in.
|
|
611
|
+
const ASKS_PASSWORD = `!![...document.querySelectorAll('input[type=password], a, button, [role=button]')].find((e) => { const b = e.getBoundingClientRect(); if (!(b.width > 0 && b.height > 0)) return false; return e.matches('input') || /^(log ?in|sign ?in)$/i.test(String(e.innerText || e.textContent || '').replace(/\\s+/g, ' ').trim()); })`;
|
|
560
612
|
function readPage(input, { password = false } = {}) {
|
|
561
613
|
const url = siteUrl(input);
|
|
562
614
|
if (!url) return Promise.resolve({ error: "Give a site to read: a name (gmail), a host or a web address." });
|
|
@@ -670,6 +722,47 @@ function pageClip(input, seconds = 8) {
|
|
|
670
722
|
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
671
723
|
}
|
|
672
724
|
|
|
725
|
+
// A browser of its own for a capture (capture.mjs): a fresh profile in a temp folder, so
|
|
726
|
+
// nothing you're signed in to in Symbiot's browser shows in a product's pictures, and it
|
|
727
|
+
// never waits on the Symbiot Browser. fn gets its page ({ send, until, idle, on }); the
|
|
728
|
+
// browser and its profile go after. width × height: the page's size.
|
|
729
|
+
async function scratchPage(fn, { width = 1440, height = 900 } = {}) {
|
|
730
|
+
const chrome = chromeBinary();
|
|
731
|
+
if (!chrome) throw new Error("Capturing a product needs Chrome, Chromium, Edge or Brave, and none was found.");
|
|
732
|
+
const dir = mkdtempSync(join(tmpdir(), "symbiot-capture-"));
|
|
733
|
+
const proc = spawn(chrome, ["--headless=new", "--hide-scrollbars", "--mute-audio", "--remote-debugging-pipe", `--window-size=${width},${height}`,
|
|
734
|
+
"--user-data-dir=" + dir, "--no-first-run", "--no-default-browser-check", ...(process.platform === "linux" ? ["--password-store=basic"] : process.platform === "darwin" ? ["--use-mock-keychain"] : []), "about:blank"],
|
|
735
|
+
{ stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] });
|
|
736
|
+
let err = ""; proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
|
|
737
|
+
const failed = new Promise((resolve, reject) => { proc.on("error", reject); }); failed.catch(() => {});
|
|
738
|
+
const c = connect(proc), kill = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, START);
|
|
739
|
+
try {
|
|
740
|
+
const page = await Promise.race([failed, (async () => {
|
|
741
|
+
let targetId; try { ({ targetId } = await c.send("Target.createTarget", { url: "about:blank" })); } catch (e) { throw new Error("The browser didn't start: " + ((err.trim().split("\n").pop()) || e.message)); }
|
|
742
|
+
const { sessionId } = await c.send("Target.attachToTarget", { targetId, flatten: true });
|
|
743
|
+
const send = (method, params) => c.send(method, params, sessionId);
|
|
744
|
+
const until = (method, ms) => c.until((m) => m.sessionId === sessionId && m.method === method, ms);
|
|
745
|
+
const inflight = new Set();
|
|
746
|
+
c.on((m) => { if (!m || m.sessionId !== sessionId) return; if (m.method === "Network.requestWillBeSent") inflight.add(m.params.requestId); else if (m.method === "Network.loadingFinished" || m.method === "Network.loadingFailed") inflight.delete(m.params.requestId); });
|
|
747
|
+
const idle = async () => { const end = Date.now() + 8000; let calm = 0; await sleep(400); while (Date.now() < end && calm < 600) { await sleep(100); calm = inflight.size <= 1 ? calm + 100 : 0; } };
|
|
748
|
+
const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
|
|
749
|
+
await send("Page.enable"); await send("Network.enable");
|
|
750
|
+
await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false });
|
|
751
|
+
const { userAgent } = await c.send("Browser.getVersion");
|
|
752
|
+
if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
|
|
753
|
+
return { send, until, idle, on };
|
|
754
|
+
})()]);
|
|
755
|
+
clearTimeout(kill);
|
|
756
|
+
return await fn(page);
|
|
757
|
+
} finally {
|
|
758
|
+
clearTimeout(kill);
|
|
759
|
+
try { await Promise.race([c.send("Browser.close"), sleep(3000)]); } catch {}
|
|
760
|
+
if (proc.exitCode === null) await Promise.race([new Promise((r) => proc.once("exit", r)), sleep(3000)]);
|
|
761
|
+
if (proc.exitCode === null) try { proc.kill("SIGKILL"); } catch {}
|
|
762
|
+
try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
673
766
|
// Trusted sites (Settings → Screens): on a page from one of these, press and type
|
|
674
767
|
// go ahead without asking, for you and for agents. Anywhere else each one asks.
|
|
675
768
|
// A host covers its subdomains (google.com covers mail.google.com). Only the app's
|
|
@@ -719,7 +812,7 @@ async function showScreen(page, b, s) {
|
|
|
719
812
|
// is taller than its window, and a map only has what the window shows. Only looks,
|
|
720
813
|
// so it never asks first. At the end already, it says so and maps nothing.
|
|
721
814
|
const SCROLLS = ["down", "up", "top", "bottom"];
|
|
722
|
-
function scrollPage(id, to = "down") {
|
|
815
|
+
function scrollPage(id, to = "down", { who = "" } = {}) {
|
|
723
816
|
to = String(to || "down").toLowerCase();
|
|
724
817
|
if (!SCROLLS.includes(to)) return Promise.resolve({ error: `Scroll ${SCROLLS.join(", ")}: not "${to}".` });
|
|
725
818
|
const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
|
|
@@ -736,7 +829,7 @@ function scrollPage(id, to = "down") {
|
|
|
736
829
|
const pct = m.max > 0 ? Math.round(m.y / m.max * 100) : 0;
|
|
737
830
|
const next = await snapshot(page, s.name.replace(/ ↓ \d+%$/, "") + (pct ? ` ↓ ${pct}%` : ""));
|
|
738
831
|
return next.error ? next : { ...next, scrolled: to, kept: here };
|
|
739
|
-
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
832
|
+
}, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
740
833
|
}
|
|
741
834
|
|
|
742
835
|
// Act on a mapped region: find it (by its selector, else at the same spot) and give
|
|
@@ -745,13 +838,13 @@ function scrollPage(id, to = "down") {
|
|
|
745
838
|
// was typed is still there); on any other, its page is opened from its address
|
|
746
839
|
// again. It acts on the real site, signed in as you, so it needs the caller's
|
|
747
840
|
// confirmation unless the page's site is trusted.
|
|
748
|
-
function actOnRegion(id, regionId, verb, confirmed, check, act) {
|
|
841
|
+
function actOnRegion(id, regionId, verb, confirmed, check, act, who = "") {
|
|
749
842
|
const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
|
|
750
843
|
if (!s.page || !s.page.url) return Promise.resolve({ error: `${verb} works on a mapped page. Use Click here for a screenshot of your screen.` });
|
|
751
844
|
const r = (s.regions || []).find((x) => x.id === regionId); if (!r) return Promise.resolve({ error: "That region is gone. Reload the screen." });
|
|
752
845
|
const bad = check(r); if (bad) return Promise.resolve({ error: bad });
|
|
753
846
|
const host = hostOf(s.page.url);
|
|
754
|
-
if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : verb === "Upload" ? "Attaching a file with" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
|
|
847
|
+
if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : verb === "Upload" ? "Attaching a file with" : verb === "Choose" ? "Choosing in" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
|
|
755
848
|
return oneAtATime(() => withPage(async (page, b) => {
|
|
756
849
|
const here = await showScreen(page, b, s);
|
|
757
850
|
// A new tab would leave this one where it was, so a link opens here instead.
|
|
@@ -772,11 +865,13 @@ function actOnRegion(id, regionId, verb, confirmed, check, act) {
|
|
|
772
865
|
}
|
|
773
866
|
const navigating = page.until("Page.frameStartedLoading", 1500);
|
|
774
867
|
const extra = await act(page, at, r);
|
|
868
|
+
// refused on the page, nothing changed: the page stays as it was, so what was typed before is kept
|
|
869
|
+
if (extra && extra.refused) return { error: extra.refused, stay: true };
|
|
775
870
|
if (await navigating) await page.until("Page.loadEventFired", 30000);
|
|
776
871
|
await page.idle();
|
|
777
872
|
const next = await snapshot(page, ""); // named after the page it landed on
|
|
778
873
|
return next.error ? next : { ...next, ...extra, found, kept: here };
|
|
779
|
-
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
874
|
+
}, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
780
875
|
}
|
|
781
876
|
const clickAt = async (page, at) => { for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) await page.send("Input.dispatchMouseEvent", { type, x: at.x, y: at.y, button: "left", clickCount: type === "mouseMoved" ? 0 : 1 }); };
|
|
782
877
|
|
|
@@ -789,10 +884,10 @@ const clickAt = async (page, at) => { for (const type of ["mouseMoved", "mousePr
|
|
|
789
884
|
const MAIL_HOSTS = /^(mail\.google\.com|outlook\.(live|office|office365)\.com)$/;
|
|
790
885
|
const POSTS = /\b(post|reply|comment|submit|publish|share|repost|tweet)\b/i;
|
|
791
886
|
const isSend = (r, host = "") => /^(button|menu item)$/.test(r.kind || "button") && (/\bsend\b/i.test(r.label || "") || (!MAIL_HOSTS.test(host) && POSTS.test(r.label || "")));
|
|
792
|
-
function pressRegion(id, regionId, { confirmed = false, noSend = false } = {}) {
|
|
887
|
+
function pressRegion(id, regionId, { confirmed = false, noSend = false, who = "" } = {}) {
|
|
793
888
|
const s = noSend ? loadScreens().find((x) => x.id === id) : null, host = s && s.page ? hostOf(s.page.url) : "";
|
|
794
889
|
return actOnRegion(id, regionId, "Press", confirmed, (r) => (noSend && isSend(r, host) ? `"${r.label}" sends or posts. This run only drafts: it never presses Send, Post, Comment or Reply. The draft stays for the user to send or post.` : ""),
|
|
795
|
-
async (page, at, r) => { await clickAt(page, at); return { pressed: r.label }; });
|
|
890
|
+
async (page, at, r) => { await clickAt(page, at); return { pressed: r.label }; }, who);
|
|
796
891
|
}
|
|
797
892
|
|
|
798
893
|
// Type: click into a field, replace what's in it with text, and press Enter if
|
|
@@ -801,25 +896,106 @@ function pressRegion(id, regionId, { confirmed = false, noSend = false } = {}) {
|
|
|
801
896
|
// open (the app's): what's typed is still there. Otherwise it's gone by then.
|
|
802
897
|
// noSend (a draft reply's run) refuses Enter, which sends in a chat, and in a
|
|
803
898
|
// WhatsApp chat types line breaks as spaces, in case a new line sends there too.
|
|
899
|
+
// replace: only that part of what's in it is typed over (selected, then typed, as a
|
|
900
|
+
// person would), so the rest stays as it is: retyping all of a scheduled LinkedIn
|
|
901
|
+
// post to change one line dropped its picture (2026-10-12). It has to be there just
|
|
902
|
+
// once; none, or more than once, changes nothing and says so.
|
|
804
903
|
const MAX_TEXT = 2000;
|
|
805
904
|
const CHAT_HOSTS = new Set(["web.whatsapp.com"]);
|
|
806
|
-
|
|
905
|
+
const SELECT_PART = (old) => `(() => {
|
|
906
|
+
const e = document.activeElement, w = ${JSON.stringify(String(old || ""))};
|
|
907
|
+
if (!e) return { problem: 'none' };
|
|
908
|
+
const count = (t) => { let n = 0, i = -1; while ((i = t.indexOf(w, i + 1)) !== -1) n++; return n; };
|
|
909
|
+
if (typeof e.setSelectionRange === 'function' && typeof e.value === 'string') {
|
|
910
|
+
const n = count(e.value); if (n !== 1) return { problem: n ? 'more' : 'none', count: n };
|
|
911
|
+
const i = e.value.indexOf(w); e.setSelectionRange(i, i + w.length); return { ok: true };
|
|
912
|
+
}
|
|
913
|
+
if (!e.isContentEditable) return { problem: 'none' };
|
|
914
|
+
// a rich editor (LinkedIn's): its text nodes, as one string, and the match mapped back onto them
|
|
915
|
+
const nodes = [], walk = document.createTreeWalker(e, NodeFilter.SHOW_TEXT); let all = '';
|
|
916
|
+
for (let x = walk.nextNode(); x; x = walk.nextNode()) { nodes.push([x, all.length]); all += x.data; }
|
|
917
|
+
const n = count(all); if (n !== 1) return { problem: n ? 'more' : 'none', count: n };
|
|
918
|
+
const from = all.indexOf(w), to = from + w.length, at = (k, end) => { for (const [x, o] of nodes) if (k < o + x.data.length || (end && k === o + x.data.length)) return [x, k - o]; return null; };
|
|
919
|
+
const a = at(from, false), b = at(to, true); if (!a || !b) return { problem: 'none' };
|
|
920
|
+
const r = document.createRange(); r.setStart(a[0], a[1]); r.setEnd(b[0], b[1]);
|
|
921
|
+
const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); return { ok: true };
|
|
922
|
+
})()`;
|
|
923
|
+
function typeRegion(id, regionId, text, { enter = false, confirmed = false, noSend = false, replace = null, who = "" } = {}) {
|
|
924
|
+
const part = replace == null ? null : String(replace);
|
|
807
925
|
text = String(text == null ? "" : text);
|
|
808
926
|
if (noSend) { const s = loadScreens().find((x) => x.id === id); if (s && s.page && CHAT_HOSTS.has(hostOf(s.page.url))) text = text.replace(/\s*[\r\n]+\s*/g, " ").trim(); }
|
|
809
927
|
return actOnRegion(id, regionId, "Type", confirmed, (r) => {
|
|
810
928
|
if (noSend && enter) return "Enter sends in a chat, and this run only drafts: type without --enter, and the reply stays unsent for the user to send.";
|
|
929
|
+
if (r.kind === "menu") return `"${r.label}" is a menu, not a field: pick its option with Choose (symbiot screens choose <screen> <menu> "option").`;
|
|
811
930
|
if (r.kind !== "field") return `"${r.label}" isn't a field (it's a ${r.kind || "region"}). Type works on a field; use Press for the rest.`;
|
|
812
|
-
if (
|
|
931
|
+
if (part === "") return "Give the words to replace: some of what's in the field now.";
|
|
932
|
+
if (part == null && !text && !enter) return "Give the text to type.";
|
|
813
933
|
if (text.length > MAX_TEXT) return `That's more than ${MAX_TEXT} characters.`;
|
|
814
934
|
return "";
|
|
815
935
|
}, async (page, at, r) => {
|
|
816
936
|
await clickAt(page, at);
|
|
817
|
-
|
|
937
|
+
if (part != null) {
|
|
938
|
+
const sel = await evaluate(page, SELECT_PART(part));
|
|
939
|
+
if (!sel || !sel.ok) return { refused: sel && sel.problem === "more" ? `"${part}" is in "${r.label}" ${sel.count} times: give more of it, so it's there just once. Nothing was changed.` : `"${r.label}" doesn't have "${part}" in it (it has to be within one line or paragraph, as it is now). Nothing was changed.` };
|
|
940
|
+
if (text) await page.send("Input.insertText", { text });
|
|
941
|
+
else for (const type of ["keyDown", "keyUp"]) await page.send("Input.dispatchKeyEvent", { type, key: "Delete", code: "Delete", windowsVirtualKeyCode: 46, nativeVirtualKeyCode: 46 });
|
|
942
|
+
} else {
|
|
943
|
+
await page.send("Runtime.evaluate", { expression: `(() => { const e = document.activeElement; if (!e) return;
|
|
818
944
|
if (typeof e.select === 'function') e.select(); else if (e.isContentEditable) getSelection().selectAllChildren(e); })()` });
|
|
819
|
-
|
|
945
|
+
if (text) await page.send("Input.insertText", { text });
|
|
946
|
+
}
|
|
820
947
|
if (enter) for (const type of ["keyDown", "keyUp"]) await page.send("Input.dispatchKeyEvent", { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, ...(type === "keyDown" ? { text: "\r" } : {}) });
|
|
821
|
-
return { typed: r.label, entered: !!enter };
|
|
822
|
-
});
|
|
948
|
+
return { typed: r.label, entered: !!enter, ...(part != null ? { replaced: part } : {}) };
|
|
949
|
+
}, who);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Choose: pick an option in a menu, as choosing it would. A native <select> (a press only
|
|
953
|
+
// opens it, and typing isn't allowed in one: domains.co.za's DNS record type, 2026-10-09)
|
|
954
|
+
// gets the option set and the page told (input and change), so what depends on it updates
|
|
955
|
+
// (MX turns its Priority box on). A menu of the site's own making (role=combobox) is
|
|
956
|
+
// opened, and its option (role=option) clicked. The option: its label or value, exactly,
|
|
957
|
+
// else the one label that has it in it; none, or more than one, says which there are.
|
|
958
|
+
const CHOOSE_SELECT = (sel, at, want) => `(() => {
|
|
959
|
+
let e = null; try { e = ${JSON.stringify(sel || "")} && document.querySelector(${JSON.stringify(sel || "")}); } catch (x) {}
|
|
960
|
+
e = e || document.elementFromPoint(${Number(at.x) || 0}, ${Number(at.y) || 0});
|
|
961
|
+
const lab = e && e.closest && e.closest('label');
|
|
962
|
+
const s = !e ? null : e.tagName === 'SELECT' ? e : (e.querySelector && e.querySelector('select')) || (lab && lab.control && lab.control.tagName === 'SELECT' ? lab.control : null);
|
|
963
|
+
if (!s) return { native: false };
|
|
964
|
+
const norm = (t) => String(t || '').replace(/\\s+/g, ' ').trim().toLowerCase(), w = norm(${JSON.stringify(String(want || ""))});
|
|
965
|
+
const opts = [...s.options].map((o) => ({ o, label: norm(o.label || o.text), value: norm(o.value) })), all = opts.map((x) => x.o.label || x.o.text);
|
|
966
|
+
let hit = opts.filter((x) => x.label === w || x.value === w); if (!hit.length) hit = opts.filter((x) => w && x.label.includes(w));
|
|
967
|
+
if (hit.length !== 1) return { native: true, problem: hit.length ? 'more' : 'none', options: all };
|
|
968
|
+
const o = hit[0].o; if (s.disabled || o.disabled) return { native: true, problem: 'off', options: all };
|
|
969
|
+
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
|
|
970
|
+
s.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true }));
|
|
971
|
+
return { native: true, chose: o.label || o.text, value: s.value };
|
|
972
|
+
})()`;
|
|
973
|
+
const CHOOSE_OPTION = (want) => `(() => {
|
|
974
|
+
const norm = (t) => String(t || '').replace(/\\s+/g, ' ').trim().toLowerCase(), w = norm(${JSON.stringify(String(want || ""))});
|
|
975
|
+
const seen = [...document.querySelectorAll('[role=option]')].filter((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; });
|
|
976
|
+
const all = seen.map((e) => (e.getAttribute('aria-label') || e.textContent || '').replace(/\\s+/g, ' ').trim());
|
|
977
|
+
let hit = seen.filter((e, i) => norm(all[i]) === w); if (!hit.length) hit = seen.filter((e, i) => w && norm(all[i]).includes(w));
|
|
978
|
+
if (hit.length !== 1) return { problem: hit.length ? 'more' : 'none', options: all };
|
|
979
|
+
hit[0].scrollIntoView({ block: 'center' }); const b = hit[0].getBoundingClientRect();
|
|
980
|
+
return { at: { x: b.left + b.width / 2, y: b.top + b.height / 2 }, chose: all[seen.indexOf(hit[0])] };
|
|
981
|
+
})()`;
|
|
982
|
+
const chooseSaid = (r, want, x) => x.problem === "off" ? `"${r.label}" is switched off on the page, or that option is: nothing was chosen.`
|
|
983
|
+
: `${x.problem === "more" ? `More than one option in "${r.label}" matches "${want}"` : `"${r.label}" has no option "${want}"`}. Its options: ${(x.options || []).slice(0, 40).map((o) => `"${o}"`).join(", ") || "none showing"}.`;
|
|
984
|
+
function chooseRegion(id, regionId, option, { confirmed = false, who = "" } = {}) {
|
|
985
|
+
const want = String(option == null ? "" : option).trim();
|
|
986
|
+
return actOnRegion(id, regionId, "Choose", confirmed, (r) => {
|
|
987
|
+
if (!want) return "Give the option to choose: its label, as the menu shows it.";
|
|
988
|
+
if (r.kind !== "menu") return `"${r.label}" isn't a menu (it's a ${r.kind || "region"}). Choose works on a menu; use Press or Type for the rest.`;
|
|
989
|
+
return "";
|
|
990
|
+
}, async (page, at, r) => {
|
|
991
|
+
const sel = await evaluate(page, CHOOSE_SELECT(r.selector, at, want));
|
|
992
|
+
if (sel && sel.native) { if (sel.problem) throw new Error(chooseSaid(r, want, sel)); return { chose: sel.chose, value: sel.value }; }
|
|
993
|
+
await clickAt(page, at); await sleep(400); // the site's own menu: open it, then its option
|
|
994
|
+
const o = await evaluate(page, CHOOSE_OPTION(want));
|
|
995
|
+
if (!o || o.problem) { await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }).catch(() => {}); throw new Error(chooseSaid(r, want, o || {})); }
|
|
996
|
+
await clickAt(page, o.at);
|
|
997
|
+
return { chose: o.chose };
|
|
998
|
+
}, who);
|
|
823
999
|
}
|
|
824
1000
|
|
|
825
1001
|
// Upload: put a file (or a few) in a page's file box, as picking it in the file
|
|
@@ -878,10 +1054,10 @@ function uploadFiles(files) {
|
|
|
878
1054
|
const bad = list.find((f) => { try { return !statSync(f).isFile(); } catch { return true; } });
|
|
879
1055
|
return bad ? { error: `No file at ${bad}.` } : { files: list };
|
|
880
1056
|
}
|
|
881
|
-
function uploadRegion(id, regionId, files, { confirmed = false } = {}) {
|
|
1057
|
+
function uploadRegion(id, regionId, files, { confirmed = false, who = "" } = {}) {
|
|
882
1058
|
const f = uploadFiles(files);
|
|
883
1059
|
return actOnRegion(id, regionId, "Upload", confirmed, () => f.error || "",
|
|
884
|
-
async (page, at, r) => ({ uploaded: r.label, files: f.files, ...(await attachFiles(page, at, r, f.files)) }));
|
|
1060
|
+
async (page, at, r) => ({ uploaded: r.label, files: f.files, ...(await attachFiles(page, at, r, f.files)) }), who);
|
|
885
1061
|
}
|
|
886
1062
|
|
|
887
1063
|
// Open the site in Symbiot's browser profile as a normal window, to sign in (or
|
|
@@ -907,7 +1083,9 @@ const browserHub = () => HUB.replace(/t=[0-9a-f]+/, "t=…"); // where it opens
|
|
|
907
1083
|
function profilePids({ headless } = {}) {
|
|
908
1084
|
if (process.platform === "win32") return [];
|
|
909
1085
|
try {
|
|
910
|
-
|
|
1086
|
+
// -ww: the whole line, however wide. With COLUMNS set (an app started from a terminal) ps cut
|
|
1087
|
+
// each at 80, the profile's flag went, and Done found no window to close (2026-10-12).
|
|
1088
|
+
const out = spawnSync("ps", ["-ww", "-eo", "pid=,args="], { encoding: "utf8", timeout: 5000 }).stdout || "";
|
|
911
1089
|
return out.split("\n").map((l) => l.trim().match(/^(\d+)\s+(.*)$/)).filter(Boolean)
|
|
912
1090
|
.filter(([, , a]) => a.includes("--user-data-dir=" + PROFILE) && !/--type=/.test(a) && (headless === undefined || /--headless/.test(a) === headless))
|
|
913
1091
|
.map(([, pid]) => Number(pid)).filter((p) => p && p !== process.pid);
|
|
@@ -931,14 +1109,37 @@ async function openSymbiotBrowser(url = "") {
|
|
|
931
1109
|
if (url) setTimeout(() => { try { spawn(chrome, [...base, "--new-window", url], { detached: true, stdio: "ignore" }).unref(); } catch {} }, page.length ? 1500 : 0);
|
|
932
1110
|
if (!page.length && !url) spawn(chrome, [...base, "--new-window", "about:blank"], { detached: true, stdio: "ignore" }).unref();
|
|
933
1111
|
} catch (e) { return { error: String((e && e.message) || e) }; }
|
|
1112
|
+
watchWindow();
|
|
934
1113
|
return { ok: true, ...(url ? { url } : {}), browser: true };
|
|
935
1114
|
}
|
|
936
1115
|
// Done: the Symbiot Browser closes (its sign-ins are saved), so the hidden browser can read with them.
|
|
937
|
-
|
|
938
|
-
|
|
1116
|
+
// Its windows are the visible Chromes on the profile, and the one holding the profile's lock if
|
|
1117
|
+
// it's visible (found even where ps can't list it). One still open after a few seconds (a page
|
|
1118
|
+
// holding it up) is closed outright: Done always closes it.
|
|
1119
|
+
const windows = () => { const h = lockHolder(); return [...new Set([...profilePids({ headless: false }), ...(h && !h.headless && !OWN.has(h.pid) ? [h.pid] : [])])]; };
|
|
1120
|
+
async function closeSymbiotBrowser({ wait = 8000 } = {}) {
|
|
1121
|
+
const open = windows();
|
|
939
1122
|
kill(open);
|
|
940
|
-
for (
|
|
941
|
-
|
|
1123
|
+
for (const end = Date.now() + wait; Date.now() < end && windows().length;) await sleep(250);
|
|
1124
|
+
const left = windows(); for (const p of left) try { process.kill(p, "SIGKILL"); } catch {}
|
|
1125
|
+
for (let i = 0; i < 8 && windows().length; i++) await sleep(250);
|
|
1126
|
+
return { closed: open.length, ...(left.length ? { forced: left.length } : {}) };
|
|
1127
|
+
}
|
|
1128
|
+
// Closed by its X (the last of its windows), or by Done: once no window has the profile any
|
|
1129
|
+
// more, the app is told (onBrowserClosed), so it checks your sites just as Done does. Watched
|
|
1130
|
+
// from when it opens; a window that never came up (in `start`) tells nothing.
|
|
1131
|
+
let CLOSED = null, WATCH = null;
|
|
1132
|
+
function onBrowserClosed(f) { CLOSED = typeof f === "function" ? f : null; }
|
|
1133
|
+
function watchWindow({ every = 2000, start = 30000 } = {}) {
|
|
1134
|
+
clearInterval(WATCH); if (!CLOSED) return;
|
|
1135
|
+
let seen = false; const t0 = Date.now();
|
|
1136
|
+
WATCH = setInterval(() => {
|
|
1137
|
+
if (windowOpen()) { seen = true; return; }
|
|
1138
|
+
if (!seen && Date.now() - t0 < start) return;
|
|
1139
|
+
clearInterval(WATCH); WATCH = null;
|
|
1140
|
+
if (seen && CLOSED) try { CLOSED(); } catch {}
|
|
1141
|
+
}, every);
|
|
1142
|
+
if (WATCH.unref) WATCH.unref();
|
|
942
1143
|
}
|
|
943
1144
|
|
|
944
|
-
export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, keepSessions, SESSION_KEEP, SESSIONS, loadSessions, trustedSites, isTrusted, trustSite, untrustSite, PROFILE , openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub , browserHub };
|
|
1145
|
+
export { tabKey, siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, chooseRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, keepSessions, SESSION_KEEP, SESSIONS, loadSessions, trustedSites, isTrusted, trustSite, untrustSite, PROFILE , openSymbiotBrowser, closeSymbiotBrowser, onBrowserClosed, scratchPage, open as openPage, evaluate, SCROLLER, watchWindow, windowOpen, lockHolder, setBrowserHub , browserHub };
|