symbiot 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +19 -0
  2. package/index.mjs +50 -11
  3. package/package.json +1 -1
  4. package/screens.mjs +253 -23
  5. package/ui.mjs +28 -11
package/README.md CHANGED
@@ -146,6 +146,13 @@ size of the uncommitted change and a **Show diff** button. For each repo:
146
146
 
147
147
  Tasks waiting for review aren't re-sent to the agent.
148
148
 
149
+ **Releases.** For a repo that releases with `v*` tags, the review card warns when
150
+ the default branch is past its last tag (merged work that isn't released). If
151
+ `package.json`'s version is already released and the changes don't touch it, a
152
+ **Version** picker next to Approve bumps it in the same PR: a patch by default, or a
153
+ minor, or keep it. It updates `package.json` and the lockfile's own version, and the
154
+ PR says which tag to push once it merges.
155
+
149
156
  If a repo you sent tasks to has uncommitted changes but no ticked task (say the
150
157
  agent made a fix and didn't tick anything), it still shows up here, with
151
158
  **Approve changes without a task → PR**. That ships the changes the same way, on
@@ -411,6 +418,18 @@ in the screenshot's own pixels, plus its **centre**, the point a click would aim
411
418
  `~/.config/symbiot/screens/screens.json` (next to each screen's PNG), where an
412
419
  agent or script can read it.
413
420
 
421
+ **More than one display?** Symbiot reads how your displays are laid out
422
+ (`cosmic-randr`, `wlr-randr`, `kscreen-doctor` or `xrandr` on Linux, PowerShell on
423
+ Windows, AppKit on macOS) and a picker appears next to Capture: **each display**
424
+ (one screen per display, named after it, e.g. "PR page · HDMI-1 (left)"), **one
425
+ display only**, or **all displays in one image**. The choice is remembered. A
426
+ screenshot you already took of the whole desktop has **Split by display**, which
427
+ cuts it up the same way and moves each region onto the display it's on (the whole
428
+ image stays). A display's screen remembers where that display sits, so **Click
429
+ here** lands on the right display, and its blueprint gives each region's point on
430
+ the whole desktop too (`desktop`). If the screenshot doesn't match the layout (say
431
+ a display was plugged in since), Symbiot keeps it whole and says why.
432
+
414
433
  **Click here** on a region moves your mouse to the region's centre and clicks there,
415
434
  on your real screen. It asks you to confirm every time, and can wait 3, 5 or 10
416
435
  seconds first so you can bring the right window to the front. It clicks with
package/index.mjs CHANGED
@@ -32,7 +32,7 @@ import { detectMailSources, mailActivity } from "./mail.mjs";
32
32
  import { CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState } from "./core.mjs";
33
33
  import { HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, parseQuestions, agentQuestions, answerQuestions, agentsList } from "./agents.mjs";
34
34
  import { gitDefaultBranch, loadDeploys, driftRepo } from "./drift.mjs";
35
- import { loadScreens, screenImage, captureScreen, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
35
+ import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
36
36
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
37
37
 
38
38
  const HERE = fileURLToPath(new URL(".", import.meta.url));
@@ -184,6 +184,41 @@ function unreleased(path) {
184
184
  const was = ver(git(path, ["show", "HEAD:package.json"]).out);
185
185
  return { ...best, ...(now && was && now !== was ? { bump: now } : {}) };
186
186
  }
187
+ // Approve can bump the version in the PR itself, for a repo that releases with
188
+ // v* tags: offered when the committed version is already released (its v* tag
189
+ // exists) and these changes don't change it. { version, patch, minor } or null.
190
+ function bumpOffer(path) {
191
+ const ver = (s) => { try { return String(JSON.parse(s).version || ""); } catch { return ""; } };
192
+ const was = ver(git(path, ["show", "HEAD:package.json"]).out), m = was.match(/^(\d+)\.(\d+)\.(\d+)$/); if (!m) return null;
193
+ let now = ""; try { now = ver(readFileSync(join(path, "package.json"), "utf8")); } catch {}
194
+ if (now !== was || !git(path, ["rev-parse", "-q", "--verify", "refs/tags/v" + was]).ok) return null;
195
+ return { version: was, patch: `${m[1]}.${m[2]}.${+m[3] + 1}`, minor: `${m[1]}.${+m[2] + 1}.0` };
196
+ }
197
+ // Set the version in package.json and in package-lock.json / npm-shrinkwrap.json
198
+ // (top level and its "" package), keeping each file's indent. Gives back a
199
+ // function that restores them, for when the ship fails.
200
+ function setVersion(path, to) {
201
+ const saved = [];
202
+ for (const f of ["package.json", "package-lock.json", "npm-shrinkwrap.json"]) {
203
+ const file = join(path, f); let text = "", j = null;
204
+ try { text = readFileSync(file, "utf8"); j = JSON.parse(text); } catch { continue; }
205
+ if (!j || typeof j !== "object" || (f !== "package.json" && !("version" in j))) continue;
206
+ j.version = to; if (f !== "package.json" && j.packages && j.packages[""]) j.packages[""].version = to;
207
+ writeFileSync(file, JSON.stringify(j, null, (text.match(/^[ \t]+(?=")/m) || [" "])[0]) + (text.endsWith("\n") ? "\n" : ""));
208
+ saved.push([file, text]);
209
+ }
210
+ return () => { for (const [file, text] of saved) try { writeFileSync(file, text); } catch {} };
211
+ }
212
+ // Ship with the version bumped when opts.bump is "patch" or "minor" and the repo
213
+ // is offered one (bumpOffer). The bump is put back if the ship fails.
214
+ function shipWithBump(path, texts, opts) {
215
+ const offer = (opts.bump === "patch" || opts.bump === "minor") && bumpOffer(path);
216
+ if (!offer) return shipChanges(path, texts, opts);
217
+ const to = offer[opts.bump], undo = setVersion(path, to);
218
+ const r = shipChanges(path, texts, { ...opts, bumped: to });
219
+ if (r.error) { undo(); return r; }
220
+ return { ...r, bumped: to };
221
+ }
187
222
  // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
188
223
  // uncommitted changes no ticked task covers (untasked: approve them as-is).
189
224
  // running: an agent is still editing there, so its changes may be half done.
@@ -192,10 +227,10 @@ function pendingReview() {
192
227
  const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
193
228
  const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
194
229
  const am = autoMergeRepos();
195
- const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? { ...workingChanges(path), unreleased: unreleased(path) } : { branch: "", files: [], stat: "" }) }; });
230
+ const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? { ...workingChanges(path), unreleased: unreleased(path), bumpOffer: bumpOffer(path) } : { branch: "", files: [], stat: "" }) }; });
196
231
  for (const repo of sent.sort()) {
197
232
  const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
198
- const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path) });
233
+ const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path) });
199
234
  }
200
235
  return out;
201
236
  }
@@ -224,7 +259,7 @@ function shipChanges(path, texts, opts = {}) {
224
259
  const add = git(path, ["add", "-A"]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
225
260
  git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
226
261
  const subject = !texts.length ? "symbiot: changes approved without a task" : texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
227
- const body = texts.length ? texts.map((x) => "- " + x).join("\n") : "No ticked task covers these changes.";
262
+ const body = (texts.length ? texts.map((x) => "- " + x).join("\n") : "No ticked task covers these changes.") + (opts.bumped ? `\n\nBumps the version to ${opts.bumped}. After this merges, tag v${opts.bumped} on ${base} to release it.` : "");
228
263
  const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
229
264
  const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject };
230
265
  if (opts.push === false) return out;
@@ -257,7 +292,7 @@ function approveRepo(repo, opts = {}) {
257
292
  if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
258
293
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
259
294
  const busy = stillWorking(repo, path); if (busy) return busy;
260
- const r = shipChanges(path, items.map((x) => x.text), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
295
+ const r = shipWithBump(path, items.map((x) => x.text), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
261
296
  if (r.error) return r;
262
297
  const now = Date.now();
263
298
  for (const x of items) { x.review = false; x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
@@ -272,7 +307,7 @@ function approveChanges(repo, opts = {}) {
272
307
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
273
308
  const busy = stillWorking(repo, path); if (busy) return busy;
274
309
  if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
275
- const r = shipChanges(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
310
+ const r = shipWithBump(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
276
311
  return r.error ? r : { ...r, approved: 0 };
277
312
  }
278
313
  // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
@@ -1478,7 +1513,7 @@ async function cmdApp() {
1478
1513
  } catch {}
1479
1514
  }
1480
1515
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
1481
- const screenOut = (s) => (s && s.id ? { ...s, blueprint: blueprint(s) } : s);
1516
+ const screenOut = (s) => (s && s.id ? { ...s, blueprint: blueprint(s) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
1482
1517
  const server = createServer(async (req, res) => {
1483
1518
  const u = new URL(req.url, "http://127.0.0.1");
1484
1519
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
@@ -1505,8 +1540,8 @@ async function cmdApp() {
1505
1540
  if (u.pathname === "/api/tasks/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearTaskChat(String(b.id || ""))); }
1506
1541
  if (u.pathname === "/api/pending") return json(res, pendingReview()); // ticked by the agent, awaiting approval
1507
1542
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
1508
- if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approveRepo(String(b.repo || ""))); }
1509
- if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); return json(res, approveChanges(String(b.repo || ""))); }
1543
+ if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approveRepo(String(b.repo || ""), { bump: b.bump })); }
1544
+ if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump })); }
1510
1545
  if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
1511
1546
  if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
1512
1547
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
@@ -1534,7 +1569,11 @@ async function cmdApp() {
1534
1569
  // src, so it carries the token in the query (?t=), which the check above accepts.
1535
1570
  if (u.pathname === "/api/screens") return json(res, loadScreens().map(screenOut));
1536
1571
  if (u.pathname === "/api/screens/image") { const f = screenImage(u.searchParams.get("id")); if (!f) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": "image/png", "cache-control": "private, max-age=86400" }); res.end(readFileSync(f)); return; } // a screen's image never changes
1537
- if (u.pathname === "/api/screens/capture" && req.method === "POST") { const b = await readBody(req); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, screenOut(captureScreen(b.name))); }
1572
+ if (u.pathname === "/api/screens/capture" && req.method === "POST") { const b = await readBody(req); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, screenOut(captureScreen(b.name, typeof b.which === "string" ? b.which : "all"))); }
1573
+ // The displays connected now (for "which display" next to Capture). macOS
1574
+ // can't take them all in one image (screencapture takes one display at a time).
1575
+ if (u.pathname === "/api/screens/monitors") return json(res, { monitors: listMonitors(), whole: process.platform !== "darwin" });
1576
+ if (u.pathname === "/api/screens/split" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(splitScreen(String(b.id || "")))); }
1538
1577
  // Changes a desktop permission, so only on the user's confirmed click.
1539
1578
  if (u.pathname === "/api/screens/allow" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Allowing screenshots needs your confirmation." }); return json(res, allowScreenshots()); }
1540
1579
  if (u.pathname === "/api/screens/import" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(importScreen(b.name, b.png))); }
@@ -1670,4 +1709,4 @@ const isMain = (() => {
1670
1709
  })();
1671
1710
  if (isMain) main();
1672
1711
 
1673
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, unreleased, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
1712
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, setVersion, syncTasks, pendingReview, unreleased, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
package/screens.mjs CHANGED
@@ -4,12 +4,15 @@
4
4
  // asked (the app confirms each click). Nothing types yet.
5
5
  //
6
6
  // Stored in ~/.config/symbiot/screens/: <id>.png per screen, and screens.json =
7
- // [{ id, name, w, h, ts, via, regions: [{ id, label, x, y, w, h }] }], which is
8
- // also what an agent or script reads to find a region.
7
+ // [{ id, name, w, h, ts, via, monitor?, regions: [{ id, label, x, y, w, h }] }],
8
+ // which is also what an agent or script reads to find a region. `monitor` is set
9
+ // when the screen is one display of several: { name, x, y, w, h, where } is where
10
+ // that display sits on the desktop, in the units the click tool uses.
9
11
  import { spawnSync } from "node:child_process";
10
12
  import { join } from "node:path";
11
13
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, renameSync, rmSync } from "node:fs";
12
14
  import { randomBytes } from "node:crypto";
15
+ import { inflateSync, deflateSync } from "node:zlib";
13
16
  import { CONFIG_DIR, hasCmd } from "./core.mjs";
14
17
 
15
18
  const SCREENS_DIR = join(CONFIG_DIR, "screens");
@@ -37,12 +40,160 @@ const WIN_DPI = WIN_USER32 + "[Symbiot.User32]::SetProcessDPIAware() | Out-Null;
37
40
  // -EncodedCommand (base64 UTF-16LE) carries the script's quotes through untouched.
38
41
  const psEncoded = (script) => ["powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]];
39
42
 
43
+ // Cutting a PNG into pieces (one per display), with no dependencies: decode the
44
+ // pixels (zlib + PNG's row filters), copy each rectangle out, encode it again.
45
+ // 8- and 16-bit images, the kind screenshot tools write; null for anything else.
46
+ const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
47
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
48
+ const KEEP_CHUNKS = new Set(["PLTE", "tRNS", "gAMA", "sRGB", "cHRM", "iCCP", "pHYs"]);
49
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
50
+ function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
51
+ function pngChunk(type, data) {
52
+ const out = Buffer.alloc(12 + data.length); out.writeUInt32BE(data.length, 0); out.write(type, 4, "ascii"); data.copy(out, 8);
53
+ out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); return out;
54
+ }
55
+ const paeth = (a, b, c) => { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; };
56
+ function pngDecode(buf) {
57
+ const size = pngSize(buf); if (!size) return null;
58
+ const depth = buf[24], ctype = buf[25];
59
+ if (!CHANNELS[ctype] || (depth !== 8 && depth !== 16) || buf[28] /* interlaced */) return null;
60
+ const bpp = CHANNELS[ctype] * depth / 8, stride = size.w * bpp, idat = [], keep = [];
61
+ for (let p = 8; p + 12 <= buf.length;) {
62
+ const len = buf.readUInt32BE(p), type = buf.toString("ascii", p + 4, p + 8), end = p + 12 + len;
63
+ if (end > buf.length) return null;
64
+ if (type === "IDAT") idat.push(buf.subarray(p + 8, p + 8 + len)); else if (KEEP_CHUNKS.has(type)) keep.push(buf.subarray(p, end));
65
+ if (type === "IEND") break;
66
+ p = end;
67
+ }
68
+ let raw; try { raw = inflateSync(Buffer.concat(idat)); } catch { return null; }
69
+ if (raw.length < (stride + 1) * size.h) return null;
70
+ const px = Buffer.alloc(stride * size.h);
71
+ for (let y = 0; y < size.h; y++) {
72
+ const f = raw[y * (stride + 1)], s = y * (stride + 1) + 1, o = y * stride, u = o - stride;
73
+ if (f > 4) return null;
74
+ for (let i = 0; i < stride; i++) {
75
+ const a = i >= bpp ? px[o + i - bpp] : 0, b = y ? px[u + i] : 0, v = raw[s + i];
76
+ px[o + i] = f === 0 ? v : f === 1 ? v + a : f === 2 ? v + b : f === 3 ? v + ((a + b) >> 1) : v + paeth(a, b, y && i >= bpp ? px[u + i - bpp] : 0);
77
+ }
78
+ }
79
+ return { ...size, depth, ctype, bpp, stride, px, keep };
80
+ }
81
+ function pngCrop(img, r) {
82
+ const rs = r.w * img.bpp, raw = Buffer.alloc((rs + 1) * r.h); // each row: filter 0, then its pixels
83
+ for (let y = 0; y < r.h; y++) { const from = (r.y + y) * img.stride + r.x * img.bpp; img.px.copy(raw, y * (rs + 1) + 1, from, from + rs); }
84
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(r.w, 0); ihdr.writeUInt32BE(r.h, 4); ihdr[8] = img.depth; ihdr[9] = img.ctype;
85
+ return Buffer.concat([PNG_SIG, pngChunk("IHDR", ihdr), ...img.keep, pngChunk("IDAT", deflateSync(raw)), pngChunk("IEND", Buffer.alloc(0))]);
86
+ }
87
+ // One PNG per rectangle ({ x, y, w, h } in the image's pixels), or null when the
88
+ // image can't be read or a rectangle doesn't fit inside it.
89
+ function splitPng(buf, rects) {
90
+ const img = pngDecode(buf); if (!img) return null;
91
+ if (!rects.every((r) => r.x >= 0 && r.y >= 0 && r.w > 0 && r.h > 0 && r.x + r.w <= img.w && r.y + r.h <= img.h)) return null;
92
+ return rects.map((r) => pngCrop(img, r));
93
+ }
94
+
95
+ // Displays: where each one sits on the desktop, as { name, x, y, w, h } in the
96
+ // units the click tool uses (logical pixels on Wayland, X pixels on X11, physical
97
+ // pixels from the virtual screen's corner on Windows, points on macOS). Each
98
+ // parser reads one tool's output.
99
+ const rotated = (t) => /90|270/.test(String(t || ""));
100
+ function parseCosmicRandr(kdl) {
101
+ const out = [];
102
+ for (const block of String(kdl || "").split(/^output /m).slice(1)) {
103
+ const head = block.split("\n")[0], name = (head.match(/^"([^"]+)"/) || [])[1];
104
+ const pos = block.match(/^\s*position (-?\d+) (-?\d+)/m), mode = block.match(/^\s*mode (\d+) (\d+) \d+[^\n]*current=#true/m);
105
+ if (!name || /enabled=#false/.test(head) || !pos || !mode) continue;
106
+ const sc = Number((block.match(/^\s*scale ([\d.]+)/m) || [])[1]) || 1, d = block.match(/^\s*description make="([^"]*)" model="([^"]*)"/m);
107
+ let w = +mode[1] / sc, h = +mode[2] / sc; if (rotated((block.match(/^\s*transform "([^"]*)"/m) || [])[1])) [w, h] = [h, w];
108
+ out.push({ name, model: d ? (d[1] + " " + d[2]).trim() : "", x: +pos[1], y: +pos[2], w: Math.round(w), h: Math.round(h) });
109
+ }
110
+ return out;
111
+ }
112
+ function parseWlrRandr(json) { // sway, Hyprland, river, labwc…
113
+ let a; try { a = JSON.parse(json); } catch { return []; }
114
+ return (Array.isArray(a) ? a : []).map((o) => {
115
+ const m = o && o.enabled !== false && o.position && (o.modes || []).find((x) => x.current); if (!m) return null;
116
+ const sc = Number(o.scale) || 1; let w = m.width / sc, h = m.height / sc; if (rotated(o.transform)) [w, h] = [h, w];
117
+ return { name: String(o.name || ""), model: [o.make, o.model].filter((x) => x && x !== "Unknown").join(" "), x: +o.position.x, y: +o.position.y, w: Math.round(w), h: Math.round(h) };
118
+ }).filter(Boolean);
119
+ }
120
+ function parseKscreen(json) { // KDE Plasma
121
+ let a; try { a = JSON.parse(json); } catch { return []; }
122
+ return ((a && a.outputs) || []).map((o) => {
123
+ const m = o && o.enabled && o.connected !== false && o.pos && (o.modes || []).find((x) => String(x.id) === String(o.currentModeId)); if (!m || !m.size) return null;
124
+ const sc = Number(o.scale) || 1; let w = m.size.width / sc, h = m.size.height / sc; if (o.rotation === 2 || o.rotation === 8) [w, h] = [h, w];
125
+ return { name: String(o.name || ""), model: "", x: +o.pos.x, y: +o.pos.y, w: Math.round(w), h: Math.round(h) };
126
+ }).filter(Boolean);
127
+ }
128
+ function parseXrandr(text) { // " 0: +*HDMI-1 1600/440x900/250+0+0 HDMI-1"
129
+ return [...String(text || "").matchAll(/^\s*\d+:\s+\S+\s+(\d+)\/\d+x(\d+)\/\d+\+(-?\d+)\+(-?\d+)\s+(\S+)/gm)].map((m) => ({ name: m[5], model: "", x: +m[3], y: +m[4], w: +m[1], h: +m[2] }));
130
+ }
131
+ function parseLines(text) { // "name|x|y|w|h" per display, from the Windows and macOS scripts below
132
+ return String(text || "").split(/\r?\n/).map((l, i) => { const p = l.trim().split("|"); return p.length === 5 ? { name: p[0].replace(/^\\\\\.\\/, "") || "Display " + (i + 1), model: "", x: +p[1], y: +p[2], w: Math.round(+p[3]), h: Math.round(+p[4]), display: i + 1 } : null; }).filter(Boolean);
133
+ }
134
+ const WIN_SCREENS = WIN_DPI + "Add-Type -AssemblyName System.Windows.Forms; $b = [System.Windows.Forms.SystemInformation]::VirtualScreen; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { $_.DeviceName + '|' + ($_.Bounds.X - $b.Left) + '|' + ($_.Bounds.Y - $b.Top) + '|' + $_.Bounds.Width + '|' + $_.Bounds.Height }";
135
+ // NSScreen frames are in points from the main display's bottom-left corner; this
136
+ // flips them to top-left, which is what cliclick uses. screens[0] is the main
137
+ // display, which is screencapture's -D 1.
138
+ const MAC_SCREENS = "ObjC.import('AppKit'); var s = $.NSScreen.screens, top = s.objectAtIndex(0).frame.size.height, out = []; for (var i = 0; i < s.count; i++) { var o = s.objectAtIndex(i), f = o.frame, n = 'Display ' + (i + 1); try { n = ObjC.unwrap(o.localizedName) || n; } catch (e) {} out.push(String(n).replace(/[|]/g, ' ') + '|' + f.origin.x + '|' + (top - f.origin.y - f.size.height) + '|' + f.size.width + '|' + f.size.height); } out.join('\\n')";
139
+ // Tools that list the displays, in order, as [cmd, args, parser].
140
+ function monitorCmds(platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland") {
141
+ if (platform === "darwin") return [["osascript", ["-l", "JavaScript", "-e", MAC_SCREENS], parseLines]];
142
+ if (platform === "win32") return [[...psEncoded(WIN_SCREENS), parseLines]];
143
+ const xr = ["xrandr", ["--listmonitors"], parseXrandr]; // on Wayland, through Xwayland: right unless the display is scaled
144
+ return wayland ? [["cosmic-randr", ["list", "--kdl"], parseCosmicRandr], ["wlr-randr", ["--json"], parseWlrRandr], ["kscreen-doctor", ["-j"], parseKscreen], xr] : [xr];
145
+ }
146
+ // Drop what can't be used, fold mirrored displays into one, order them left to
147
+ // right and say where each one is ("left", "right", "top"…) when there are several.
148
+ function tidyMonitors(list) {
149
+ const out = [];
150
+ for (const m of list || []) {
151
+ if (!m || !m.name || ![m.x, m.y, m.w, m.h].every(Number.isFinite) || m.w <= 0 || m.h <= 0) continue;
152
+ const twin = out.find((o) => o.x === m.x && o.y === m.y && o.w === m.w && o.h === m.h);
153
+ if (twin) twin.name += "+" + m.name; else out.push({ ...m });
154
+ }
155
+ out.sort((a, b) => a.x - b.x || a.y - b.y);
156
+ const overlap = (a0, a1, b0, b1) => a0 < b1 && b0 < a1, n = out.length;
157
+ const rank = (i) => (i === 0 ? 0 : i === n - 1 ? 2 : 1);
158
+ if (n > 1 && out.every((a) => out.every((b) => overlap(a.y, a.y + a.h, b.y, b.y + b.h)))) out.forEach((m, i) => { m.where = ["left", n > 3 ? "middle " + i : "middle", "right"][rank(i)]; });
159
+ else if (n > 1 && out.every((a) => out.every((b) => overlap(a.x, a.x + a.w, b.x, b.x + b.w)))) [...out].sort((a, b) => a.y - b.y).forEach((m, i) => { m.where = ["top", n > 3 ? "middle " + i : "middle", "bottom"][rank(i)]; });
160
+ return out;
161
+ }
162
+ // The displays connected now, from the first tool here that lists them; [] if none can.
163
+ function listMonitors() {
164
+ const linux = process.platform !== "win32" && process.platform !== "darwin";
165
+ for (const [cmd, args, parse] of monitorCmds()) {
166
+ if (linux && !hasCmd(cmd)) continue;
167
+ const p = spawnSync(cmd, args, { encoding: "utf8", timeout: 8000, killSignal: "SIGKILL" });
168
+ const mons = p.status === 0 ? tidyMonitors(parse(p.stdout)) : [];
169
+ if (mons.length) return mons;
170
+ }
171
+ return [];
172
+ }
173
+ // Where each display is in a screenshot of the whole desktop, in its pixels: the
174
+ // layout's bounding box, scaled to fit the image (a HiDPI capture is bigger than
175
+ // the layout's logical size). null when the image isn't the whole layout, e.g. a
176
+ // tool that took one display only, or an image from another setup.
177
+ function monitorAreas(monitors, w, h) {
178
+ if (!monitors || monitors.length < 2) return null;
179
+ const x0 = Math.min(...monitors.map((m) => m.x)), y0 = Math.min(...monitors.map((m) => m.y));
180
+ const lw = Math.max(...monitors.map((m) => m.x + m.w)) - x0, lh = Math.max(...monitors.map((m) => m.y + m.h)) - y0, k = w / lw;
181
+ if (!(k >= 0.25 && k <= 4) || Math.abs(h - lh * k) > Math.max(2, 2 * k)) return null;
182
+ return monitors.map((m) => {
183
+ const x = Math.round((m.x - x0) * k), y = Math.round((m.y - y0) * k);
184
+ return { ...m, area: { x, y, w: Math.min(w - x, Math.round(m.w * k)), h: Math.min(h - y, Math.round(m.h * k)) } };
185
+ });
186
+ }
187
+ const monitorLabel = (m) => m.name + (m.where ? " (" + m.where + ")" : "");
188
+ const monitorOf = (m) => ({ name: m.name, x: m.x, y: m.y, w: m.w, h: m.h, ...(m.where ? { where: m.where } : {}) });
189
+
40
190
  // Screenshot tools to try, in order, as [cmd, args] that write a PNG to `file`,
41
191
  // or [cmd, args, dir] for a tool that only takes a folder (it picks the name).
42
192
  // scrot and import only see X11, which on Wayland is a blank screen, so they're
43
- // left out there.
44
- function captureCmds(file, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland") {
45
- if (platform === "darwin") return [["screencapture", ["-x", "-t", "png", file]]];
193
+ // left out there. Each captures the whole desktop (every display in one image),
194
+ // except screencapture: the main display, or `display` (1 = main, 2…) if given.
195
+ function captureCmds(file, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland", display = 0) {
196
+ if (platform === "darwin") return [["screencapture", ["-x", ...(display ? ["-D", String(display)] : []), "-t", "png", file]]];
46
197
  if (platform === "win32") {
47
198
  const ps = WIN_DPI + "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b = [System.Windows.Forms.SystemInformation]::VirtualScreen; $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; $g = [System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size); $bmp.Save('" + file.replace(/'/g, "''") + "', [System.Drawing.Imaging.ImageFormat]::Png)";
48
199
  return [psEncoded(ps)];
@@ -81,26 +232,95 @@ function allowScreenshots() {
81
232
  const p = spawnSync("gdbus", ["call", ...PERMS, "org.freedesktop.impl.portal.PermissionStore.SetPermission", "screenshot", "true", "screenshot", id, "['yes']"], { encoding: "utf8", timeout: 5000 });
82
233
  return p.status === 0 ? { ok: true, app: id } : { error: "Couldn't change the permission: " + (String(p.stderr || "").trim() || "exit " + p.status) };
83
234
  }
84
- function addScreen(id, name, size, via) {
85
- const s = { id, name: String(name || "").trim().slice(0, 80) || "Screen " + new Date().toLocaleString(), w: size.w, h: size.h, ts: Date.now(), via, regions: [] };
86
- saveScreens([s, ...loadScreens()]);
87
- return s;
88
- }
89
- // Take a screenshot of the whole screen with the first tool that works here.
90
- function captureScreen(name) {
91
- const id = randomBytes(6).toString("hex"), file = screenFile(id), tried = [];
92
- try { mkdirSync(SCREENS_DIR, { recursive: true }); } catch (e) { return { error: String((e && e.message) || e) }; }
93
- const posix = process.platform !== "win32" && process.platform !== "darwin";
94
- for (const [cmd, args, dir] of captureCmds(file)) {
95
- if (posix && !hasCmd(cmd)) continue;
235
+ const newId = () => randomBytes(6).toString("hex");
236
+ const defaultName = () => "Screen " + new Date().toLocaleString();
237
+ function addScreens(list) { saveScreens([...list, ...loadScreens()]); return list; }
238
+ const screenOf = (id, name, size, via, extra = {}) => ({ id, name: String(name || "").trim().slice(0, 80) || defaultName(), w: size.w, h: size.h, ts: Date.now(), via, ...extra, regions: extra.regions || [] });
239
+ function addScreen(id, name, size, via, extra) { return addScreens([screenOf(id, name, size, via, extra)])[0]; }
240
+ // "<name> · HDMI-1 (left)", kept within the 80 characters a name can have.
241
+ const pieceName = (name, m) => { const tail = " · " + monitorLabel(m), base = String(name || "").trim() || defaultName(); return base.slice(0, Math.max(10, 80 - tail.length)) + tail; };
242
+
243
+ // Run the screenshot tools until one writes a PNG to `file`: { size, via }, or
244
+ // { tried } with the tools that ran and failed.
245
+ function shoot(file, display) {
246
+ const tried = [], linux = process.platform !== "win32" && process.platform !== "darwin";
247
+ for (const [cmd, args, dir] of captureCmds(file, process.platform, process.env.XDG_SESSION_TYPE === "wayland", display)) {
248
+ if (linux && !hasCmd(cmd)) continue;
96
249
  tried.push(cmd);
97
250
  if (dir) try { mkdirSync(dir, { recursive: true }); } catch {}
98
251
  spawnSync(cmd, args, { stdio: "ignore", timeout: 20000, killSignal: "SIGKILL" });
99
252
  if (dir) { try { const f = readdirSync(dir).find((x) => /\.png$/i.test(x)); if (f) renameSync(join(dir, f), file); } catch {} rmSync(dir, { recursive: true, force: true }); }
100
253
  let size = null; try { size = pngSize(readFileSync(file)); } catch {}
101
- if (size) return addScreen(id, name, size, cmd);
254
+ if (size) return { size, via: cmd };
102
255
  try { unlinkSync(file); } catch {}
103
256
  }
257
+ return { tried };
258
+ }
259
+ // Cut a whole-desktop PNG into one screen per display in `want` (each with its
260
+ // regions, if `regions` are given in the whole image's pixels). null when the
261
+ // image doesn't match how the displays are laid out now.
262
+ function piecesOf(buf, size, monitors, want, name, via, regions) {
263
+ const areas = monitorAreas(monitors, size.w, size.h); if (!areas) return null;
264
+ const mine = areas.filter((a) => want.some((m) => m.name === a.name));
265
+ const pngs = mine.length && splitPng(buf, mine.map((a) => a.area)); if (!pngs) return null;
266
+ const ids = mine.map(() => newId());
267
+ try { pngs.forEach((p, i) => writeFileSync(screenFile(ids[i]), p)); } catch (e) { ids.forEach((id) => { try { unlinkSync(screenFile(id)); } catch {} }); return { error: String((e && e.message) || e) }; }
268
+ return addScreens(mine.map((a, i) => {
269
+ // a region goes with the display its centre is on, moved into that display's pixels
270
+ const rs = (regions || []).filter((r) => { const c = center(r); return c.x >= a.area.x && c.x < a.area.x + a.area.w && c.y >= a.area.y && c.y < a.area.y + a.area.h; })
271
+ .map((r) => { const x = Math.max(0, r.x - a.area.x), y = Math.max(0, r.y - a.area.y); return { ...r, x, y, w: Math.max(1, Math.min(a.area.w - x, r.x + r.w - a.area.x - x)), h: Math.max(1, Math.min(a.area.h - y, r.y + r.h - a.area.y - y)) }; });
272
+ return screenOf(ids[i], pieceName(name, a), { w: a.area.w, h: a.area.h }, via, { monitor: monitorOf(a), regions: rs });
273
+ }));
274
+ }
275
+ const layoutOf = (mons) => mons.map((m) => `${m.name} ${m.w}×${m.h} at ${m.x},${m.y}`).join(", ");
276
+
277
+ // Take a screenshot with the first tool that works here. `which` picks what to
278
+ // keep when there are several displays: "all" (one image of the whole desktop,
279
+ // the default), "each" (one screen per display) or a display's name (just that
280
+ // one). Gives the screen, or { screens: [...] } for several, or { error }.
281
+ // When the screenshot can't be cut up, it keeps the whole image and says why (`note`).
282
+ function captureScreen(name, which = "all") {
283
+ try { mkdirSync(SCREENS_DIR, { recursive: true }); } catch (e) { return { error: String((e && e.message) || e) }; }
284
+ const mons = which && which !== "all" ? listMonitors() : [], want = which === "each" ? mons : mons.filter((m) => m.name === which);
285
+ if (which && which !== "all" && which !== "each" && !want.length) return { error: `There's no display called ${which} connected now. Pick one again.` };
286
+ // macOS: screencapture takes one display at a time, in its own pixels
287
+ if (process.platform === "darwin" && mons.length > 1) {
288
+ const out = [];
289
+ for (const m of want) {
290
+ const id = newId(), r = shoot(screenFile(id), m.display);
291
+ if (!r.size) { if (out.length) break; return captureFailed(r.tried); }
292
+ out.push(screenOf(id, pieceName(name, m), r.size, r.via, { monitor: monitorOf(m) }));
293
+ }
294
+ addScreens(out);
295
+ return out.length === 1 ? out[0] : { screens: out };
296
+ }
297
+ const id = newId(), file = screenFile(id), r = shoot(file);
298
+ if (!r.size) return captureFailed(r.tried);
299
+ if (!mons.length && which && which !== "all") return { ...addScreen(id, name, r.size, r.via), note: "Couldn't read how your displays are laid out (Symbiot asks cosmic-randr, wlr-randr, kscreen-doctor or xrandr), so it's kept as one image." };
300
+ if (mons.length < 2) return addScreen(id, name, r.size, r.via); // one display: the image is that display
301
+ let buf = null; try { buf = readFileSync(file); } catch {}
302
+ const pieces = buf && piecesOf(buf, r.size, mons, want, name, r.via);
303
+ if (!pieces || pieces.error) {
304
+ const why = pieces && pieces.error ? "Couldn't save the pieces (" + pieces.error + ")" : `The screenshot (${r.size.w}×${r.size.h}) doesn't match how your displays are laid out (${layoutOf(mons)})`;
305
+ return { ...addScreen(id, name, r.size, r.via), note: why + ", so it's kept as one image." };
306
+ }
307
+ try { unlinkSync(file); } catch {}
308
+ return pieces.length === 1 ? pieces[0] : { screens: pieces };
309
+ }
310
+ // Cut a saved screen of the whole desktop into one screen per display, regions
311
+ // included. The original stays, so nothing is lost if the cut looks wrong.
312
+ function splitScreen(id) {
313
+ const s = loadScreens().find((x) => x.id === id); if (!s) return { error: "not found" };
314
+ if (s.monitor) return { error: "This screen is already one display." };
315
+ const mons = listMonitors();
316
+ if (mons.length < 2) return { error: mons.length ? "Only one display is connected now, so there's nothing to split it into." : "Couldn't read how your displays are laid out (Symbiot asks cosmic-randr, wlr-randr, kscreen-doctor or xrandr on Linux)." };
317
+ let buf = null; try { buf = readFileSync(screenFile(id)); } catch {}
318
+ if (!buf) return { error: "This screen's image is missing." };
319
+ const pieces = piecesOf(buf, s, mons, mons, s.name, s.via, s.regions);
320
+ if (!pieces) return { error: `This image (${s.w}×${s.h}) doesn't match how your displays are laid out now (${layoutOf(mons)}), so it can't be split by display.` };
321
+ return pieces.error ? pieces : { screens: pieces };
322
+ }
323
+ function captureFailed(tried) {
104
324
  const blocked = tried.length && screenshotBlocked();
105
325
  if (blocked) return { error: `Your desktop has screenshots turned off for apps started from ${blocked}, which is how Symbiot was started (tried ${tried.join(", ")}). Allow screenshots to turn them back on, or take one yourself and use Load image.`, blocked };
106
326
  return { error: tried.length ? `Couldn't take a screenshot (tried ${tried.join(", ")}). Take one yourself and use Load image.` : "No screenshot tool found. Install gnome-screenshot, spectacle, grim or scrot, or take one yourself and use Load image." };
@@ -138,9 +358,15 @@ function removeScreen(id) {
138
358
  return { ok: true };
139
359
  }
140
360
  const center = (r) => ({ x: r.x + Math.floor(r.w / 2), y: r.y + Math.floor(r.h / 2) });
141
- // What automation reads: each region with the point to aim at (its centre).
361
+ // Where a point in a one-display screen's pixels is on the desktop, in the click
362
+ // tool's units: from that display's corner, scaled to its size (so a HiDPI or
363
+ // Retina capture still lands right).
364
+ const onDesktop = (s, p) => ({ x: Math.round(s.monitor.x + p.x * s.monitor.w / s.w), y: Math.round(s.monitor.y + p.y * s.monitor.h / s.h) });
365
+ // What automation reads: each region with the point to aim at (its centre), and
366
+ // for one display of several, which display and that point on the whole desktop.
142
367
  function blueprint(s) {
143
- return { screen: s.name, size: { w: s.w, h: s.h }, regions: (s.regions || []).map((r) => ({ label: r.label, x: r.x, y: r.y, w: r.w, h: r.h, center: center(r) })) };
368
+ return { screen: s.name, size: { w: s.w, h: s.h }, ...(s.monitor ? { monitor: s.monitor } : {}),
369
+ regions: (s.regions || []).map((r) => ({ label: r.label, x: r.x, y: r.y, w: r.w, h: r.h, center: center(r), ...(s.monitor ? { desktop: onDesktop(s, center(r)) } : {}) })) };
144
370
  }
145
371
 
146
372
  // Click tools to try, in order, as a list of [cmd, args] steps that move the
@@ -149,9 +375,11 @@ function blueprint(s) {
149
375
  // told --absolute and takes a button code; 0.1.x (Debian/Ubuntu) moves to the
150
376
  // point and takes a button number. On Windows, PowerShell: SetCursorPos, offset
151
377
  // by the virtual screen's corner (the capture's 0,0), then left down + up.
378
+ // cliclick reads "-5" as relative, so a display left of or above the main one
379
+ // gets "=-5" (absolute).
152
380
  function clickCmds(x, y, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland", ydotool1 = false) {
153
381
  x = String(Math.round(Number(x)) || 0); y = String(Math.round(Number(y)) || 0);
154
- if (platform === "darwin") return [[["cliclick", ["c:" + x + "," + y]]]];
382
+ if (platform === "darwin") return [[["cliclick", ["c:" + x.replace(/^-/, "=-") + "," + y.replace(/^-/, "=-")]]]];
155
383
  if (platform === "win32") return [[psEncoded(WIN_DPI + "Add-Type -AssemblyName System.Windows.Forms; $b = [System.Windows.Forms.SystemInformation]::VirtualScreen; if (-not [Symbiot.User32]::SetCursorPos($b.Left + " + x + ", $b.Top + " + y + ")) { [Console]::Error.WriteLine('SetCursorPos failed'); exit 1 }; Start-Sleep -Milliseconds 50; [Symbiot.User32]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero); [Symbiot.User32]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)")]];
156
384
  const ydo = ydotool1 ? [["ydotool", ["mousemove", "--absolute", "-x", x, "-y", y]], ["ydotool", ["click", "0xC0"]]] : [["ydotool", ["mousemove", x, y]], ["ydotool", ["click", "1"]]];
157
385
  const xdo = [["xdotool", ["mousemove", "--sync", x, y, "click", "1"]]];
@@ -176,7 +404,9 @@ function clickRegion(id, regionId) {
176
404
  // than trying the next one: a half-done move + click could otherwise click twice.
177
405
  const tool = clickCmds(c.x, c.y).map((st) => st[0][0]).find((t) => hasCmd(t));
178
406
  if (tool) {
179
- const sp = clickSpace(tool), x = sp ? Math.round(c.x * sp.w / s.w) : c.x, y = sp ? Math.round(c.y * sp.h / s.h) : c.y;
407
+ // one display of several: its place on the desktop; the whole desktop: rescaled to the click tool's size
408
+ const sp = !s.monitor && clickSpace(tool), d = s.monitor ? onDesktop(s, c) : null;
409
+ const x = d ? d.x : sp ? Math.round(c.x * sp.w / s.w) : c.x, y = d ? d.y : sp ? Math.round(c.y * sp.h / s.h) : c.y;
180
410
  const steps = clickCmds(x, y, process.platform, wayland, tool === "ydotool" && ydotoolIs1()).find((st) => st[0][0] === tool);
181
411
  for (const [cmd, args] of steps) {
182
412
  const p = spawnSync(cmd, args, { encoding: "utf8", timeout: 10000, killSignal: "SIGKILL" });
@@ -188,4 +418,4 @@ function clickRegion(id, regionId) {
188
418
  return { error: `No click tool found. Install ${want}.` };
189
419
  }
190
420
 
191
- export { loadScreens, screenImage, pngSize, captureCmds, portalAppId, allowScreenshots, captureScreen, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickCmds, clickRegion };
421
+ export { loadScreens, screenImage, pngSize, pngDecode, splitPng, captureCmds, monitorCmds, parseCosmicRandr, parseWlrRandr, parseKscreen, parseXrandr, parseLines, tidyMonitors, monitorAreas, listMonitors, portalAppId, allowScreenshots, captureScreen, splitScreen, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickCmds, clickRegion };
package/ui.mjs CHANGED
@@ -165,8 +165,8 @@ label.check input{width:auto}
165
165
  <div id="review" class="review hidden"></div>
166
166
  <div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
167
167
  <div class="tgroup">Screens <span class="tcount">experimental &middot; blueprints for screen automation</span></div>
168
- <div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Nothing types yet.</div>
169
- <div class="row" style="margin-top:10px"><input id="screenname" placeholder="name the screen, e.g. GitHub PR page" style="flex:1"><select id="screendelay" title="wait first, so you can bring the right window to the front" style="flex:0 0 auto;width:auto"><option value="0">now</option><option value="3">in 3s</option><option value="5">in 5s</option><option value="10">in 10s</option></select><button class="ghost" id="capture">Capture screen</button><button class="ghost" id="screenload" title="use a PNG screenshot you already have">Load image</button><input type="file" id="screenfile" accept="image/png" class="hidden"></div>
168
+ <div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Nothing types yet.</div>
169
+ <div class="row" style="margin-top:10px"><input id="screenname" placeholder="name the screen, e.g. GitHub PR page" style="flex:1"><select id="screenwhich" class="hidden" title="which display to capture" style="flex:0 0 auto;width:auto"></select><select id="screendelay" title="wait first, so you can bring the right window to the front" style="flex:0 0 auto;width:auto"><option value="0">now</option><option value="3">in 3s</option><option value="5">in 5s</option><option value="10">in 10s</option></select><button class="ghost" id="capture">Capture screen</button><button class="ghost" id="screenload" title="use a PNG screenshot you already have">Load image</button><input type="file" id="screenfile" accept="image/png" class="hidden"></div>
170
170
  <div id="screenmsg"></div>
171
171
  <div id="screenlist" class="taskfilter"></div>
172
172
  <div id="screenview"></div>
@@ -414,7 +414,10 @@ h+="<div class='rcard' data-repo='"+esc(r.repo)+"'><div class='rhead'><b>"+esc(r
414
414
  r.tasks.forEach(function(t){PENDTASKS.push(t);h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span>"+askBtn(t)+"<button class='rm sendback' title='not right - send back to the agent (unticks it)'>&#8630;</button></div>";});
415
415
  if(r.untasked)h+="<div class='muted' style='margin:6px 0'>Uncommitted changes with no ticked task behind them. Check the diff before you approve.</div>";
416
416
  if(r.running)h+="<div class='muted' style='margin:6px 0'>&#9203; Agent still working: its changes may be half done. Approve unlocks when it finishes.</div>";
417
- var ur=r.unreleased;if(ur)h+="<div class='muted' style='margin:6px 0'><span class='err'>Unreleased:</span> <code>"+esc(ur.base)+"</code> is "+ur.ahead+" commit"+(ur.ahead>1?"s":"")+" past <code>"+esc(ur.tag)+"</code>, so merged work isn't released yet. "+(ur.bump?"These changes bump the version to <b>"+esc(ur.bump)+"</b>: after the PR merges, tag <code>v"+esc(ur.bump)+"</code> on <code>"+esc(ur.base)+"</code> to release it.":"Bump the version (here or in a later PR) and tag it to release it.")+"</div>";
417
+ var ur=r.unreleased;if(ur)h+="<div class='muted' style='margin:6px 0'><span class='err'>Unreleased:</span> <code>"+esc(ur.base)+"</code> is "+ur.ahead+" commit"+(ur.ahead>1?"s":"")+" past <code>"+esc(ur.tag)+"</code>, so merged work isn't released yet. "+(ur.bump?"These changes bump the version to <b>"+esc(ur.bump)+"</b>: after the PR merges, tag <code>v"+esc(ur.bump)+"</code> on <code>"+esc(ur.base)+"</code> to release it.":r.bumpOffer&&r.files.length?"Approve can bump the version in this PR (below); tag it after the PR merges to release it.":"Bump the version (here or in a later PR) and tag it to release it.")+"</div>";
418
+ // The version is already released (its v* tag exists) and these changes keep it:
419
+ // Approve bumps it in the PR too, a patch unless you pick otherwise.
420
+ var bo=r.bumpOffer;if(bo&&r.files.length)h+="<div class='row' style='margin:6px 0;font-size:12px'><span class='muted'>Version</span><select class='bumpsel' title='bump the version in package.json (and the lockfile) in this PR' style='flex:0 0 auto;width:auto'><option value='patch'>bump to "+esc(bo.patch)+" (patch)</option><option value='minor'>bump to "+esc(bo.minor)+" (minor)</option><option value=''>keep "+esc(bo.version)+"</option></select></div>";
418
421
  h+="<div class='row'><button class='act approve'"+(r.path&&!r.running?"":" disabled")+(r.running?" title='the agent is still editing this repo'>"+(r.untasked?"Approve changes without a task":"Approve")+" &middot; agent still working":r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task &rarr; PR":" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
419
422
  el.innerHTML=h;
420
423
  el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
@@ -424,12 +427,13 @@ var amt=card.querySelector('.amtoggle');if(amt)amt.addEventListener('change',fun
424
427
  var sd=card.querySelector('.showdiff'),pre=card.querySelector('.rdiff');
425
428
  if(sd)sd.addEventListener('click',function(){if(!pre.classList.contains('hidden')){pre.classList.add('hidden');sd.textContent='Show diff';return;}
426
429
  pre.textContent='Loading...';pre.classList.remove('hidden');sd.textContent='Hide diff';api('/api/pending/diff?repo='+encodeURIComponent(repo)).then(function(d){pre.textContent=(d&&d.diff)||'(no changes)';});});
427
- var ap=card.querySelector('.approve');if(ap)ap.addEventListener('click',function(){ap.disabled=true;ap.textContent='Committing & pushing...';
428
- api(ap.getAttribute('data-untasked')?'/api/pending/approve-changes':'/api/pending/approve',{repo:repo}).then(function(r){var o=document.getElementById('reviewout');
430
+ var ap=card.querySelector('.approve'),bs=card.querySelector('.bumpsel');if(ap)ap.addEventListener('click',function(){ap.disabled=true;ap.textContent='Committing & pushing...';
431
+ api(ap.getAttribute('data-untasked')?'/api/pending/approve-changes':'/api/pending/approve',{repo:repo,bump:bs?bs.value:''}).then(function(r){var o=document.getElementById('reviewout');
429
432
  if(!r||r.error){ap.disabled=false;ap.textContent='Approve - retry';o.innerHTML="<div class='note err'>"+esc(repo)+": "+esc((r&&r.error)||'failed')+"</div>";return;}
430
433
  var m="&#10003; <b>"+esc(repo)+"</b>: approved "+(r.approved?r.approved+" task"+(r.approved>1?"s":""):"changes without a task");
431
434
  if(r.commit)m+=" &middot; committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
432
435
  if(r.pr)m+=" &middot; <a href='"+esc(r.pr)+"' target='_blank' rel='noopener'>open PR</a>";
436
+ if(r.bumped)m+="<div class='muted'>Bumps the version to <b>"+esc(r.bumped)+"</b>. After the PR merges, run <code>git pull &amp;&amp; git tag v"+esc(r.bumped)+" &amp;&amp; git push origin --tags</code> on <code>"+esc(r.base||'main')+"</code> to release it.</div>";
433
437
  if(r.autoMerge==='queued')m+=" &middot; will auto-merge when CI passes";
434
438
  else if(r.autoMerge==='unavailable')m+="<div class='muted'>auto-merge not enabled for this repo on GitHub (Settings &rarr; General &rarr; Allow auto-merge)"+(r.autoMergeErr?": "+esc(r.autoMergeErr):"")+"</div>";
435
439
  if(r.note)m+="<div class='muted'>"+esc(r.note)+"</div>";
@@ -590,7 +594,15 @@ api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.
590
594
  // Screens: a screenshot plus named regions, the blueprint (screens.mjs). Drag on
591
595
  // the image to mark a region; coordinates are the screenshot's own pixels.
592
596
  // Names stay out of attributes (esc() doesn't escape quotes): rows carry indexes.
593
- var SCREENS=[],SCREEN=null,SDRAG=null,SPEND=null,SCLICKDELAY=3;
597
+ var SCREENS=[],SCREEN=null,SDRAG=null,SPEND=null,SCLICKDELAY=3,MONITORS=[],SWHICH=[];
598
+ // With several displays, Capture asks which: each one as its own screen, one of
599
+ // them, or (not on macOS) all of them in one image. The choice is remembered.
600
+ function loadMonitorsUI(){api('/api/screens/monitors').then(function(x){MONITORS=(x&&x.monitors)||[];var sel=$('screenwhich');
601
+ if(MONITORS.length<2){SWHICH=[];sel.innerHTML='';sel.classList.add('hidden');renderScreen();return;}
602
+ SWHICH=[['each','each display']].concat(MONITORS.map(function(m){return [m.name,esc(m.name)+(m.where?' ('+esc(m.where)+')':'')+' only &middot; '+m.w+'&times;'+m.h];}));if(!x||x.whole!==false)SWHICH.push(['all','all displays in one image']);
603
+ var saved='',at=0;try{saved=localStorage.getItem('symbiot_screenwhich')||'';}catch(e){}SWHICH.forEach(function(o,i){if(o[0]===saved)at=i;});
604
+ sel.innerHTML=SWHICH.map(function(o,i){return "<option value='"+i+"'>"+o[1]+"</option>";}).join('');sel.value=String(at);sel.classList.remove('hidden');renderScreen();});}
605
+ function screenWhich(){var sel=$('screenwhich'),o=SWHICH[+sel.value];return (!sel.classList.contains('hidden')&&o)?o[0]:'all';}
594
606
  function loadScreensUI(){api('/api/screens').then(function(list){SCREENS=list||[];var id=SCREEN&&SCREEN.id;SCREEN=null;SCREENS.forEach(function(s){if(s.id===id)SCREEN=s;});renderScreenList();renderScreen();});}
595
607
  function renderScreenList(){var box=$('screenlist');if(!SCREENS.length){box.innerHTML="<span class='muted' style='font-size:12px'>No screens yet.</span>";return;}
596
608
  box.innerHTML="<span class='fl'>Screens</span>"+SCREENS.map(function(s,i){return "<button class='fchip"+(SCREEN&&SCREEN.id===s.id?" on":"")+"' data-i='"+i+"'>"+esc(s.name)+" <span class='tcount'>"+(s.regions||[]).length+"</span></button>";}).join('');
@@ -598,7 +610,7 @@ box.querySelectorAll('.fchip').forEach(function(b){b.addEventListener('click',fu
598
610
  function scrPct(v,of){return (v/of*100).toFixed(3)+'%';}
599
611
  function scrBox(r,s,cls){return "<div class='scrbox"+(cls?" "+cls:"")+"' style='left:"+scrPct(r.x,s.w)+";top:"+scrPct(r.y,s.h)+";width:"+scrPct(r.w,s.w)+";height:"+scrPct(r.h,s.h)+"'>"+(r.label?"<span>"+esc(r.label)+"</span>":"")+"</div>";}
600
612
  function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
601
- var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+s.w+" &times; "+s.h+" px</span><button class='ghost' id='scrcopy' title='copy the regions and their coordinates as JSON'>Copy blueprint</button><button class='ghost' id='scrdel'>Delete</button></div>";
613
+ var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+(s.monitor?esc(s.monitor.name)+(s.monitor.where?" ("+esc(s.monitor.where)+")":"")+" &middot; ":"")+s.w+" &times; "+s.h+" px</span>"+(!s.monitor&&MONITORS.length>1?"<button class='ghost' id='scrsplit' title='cut this screenshot into one screen per display, regions included (this one stays)'>Split by display</button>":"")+"<button class='ghost' id='scrcopy' title='copy the regions and their coordinates as JSON'>Copy blueprint</button><button class='ghost' id='scrdel'>Delete</button></div>";
602
614
  h+="<div class='scrwrap' id='scrwrap'><img src='/api/screens/image?id="+encodeURIComponent(s.id)+"&t="+encodeURIComponent(T)+"' alt='' draggable='false'>"+(s.regions||[]).map(function(r){return scrBox(r,s,'');}).join('')+(SPEND?scrBox(SPEND,s,'draw'):"")+"<div class='scrbox draw hidden' id='scrdraw'></div></div>";
603
615
  h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
604
616
  h+="<div class='row"+(SPEND?"":" hidden")+"' id='scrlabel' style='margin-top:8px'><input id='scrlabelin' placeholder='name this region, e.g. Merge button' style='flex:1'><button class='act' id='scrlabelok'>Add region</button><button class='ghost' id='scrlabelno'>Cancel</button></div>";
@@ -616,6 +628,7 @@ nm.value=s.name;
616
628
  nm.addEventListener('change',function(){var n=(nm.value||'').trim();if(n)api('/api/screens/rename',{id:s.id,name:n}).then(screenSaved);});
617
629
  $('scrcopy').addEventListener('click',function(){var b=$('scrcopy');navigator.clipboard.writeText(JSON.stringify(s.blueprint,null,2));b.textContent='Copied';setTimeout(function(){b.textContent='Copy blueprint';},1400);});
618
630
  $('scrdel').addEventListener('click',function(){if(typeof confirm==='function'&&!confirm('Delete this screen and its regions?'))return;api('/api/screens/remove',{id:s.id}).then(function(){SCREEN=null;SPEND=null;loadScreensUI();});});
631
+ var sp=$('scrsplit');if(sp)sp.addEventListener('click',function(){sp.disabled=true;$('screenmsg').innerHTML="<div class='note muted'>Splitting&hellip;</div>";api('/api/screens/split',{id:s.id}).then(function(x){sp.disabled=false;screenAdded(x);if(x&&x.screens)$('screenmsg').insertAdjacentHTML('beforeend',"<div class='note muted'>The whole image is still there too. Delete it if you don't need it.</div>");}).catch(function(e){sp.disabled=false;screenErr(String((e&&e.message)||e));});});
619
632
  wrap.addEventListener('pointerdown',function(ev){if(ev.button)return;ev.preventDefault();SDRAG=scrPoint(ev);try{wrap.setPointerCapture(ev.pointerId);}catch(e){}});
620
633
  wrap.addEventListener('pointermove',function(ev){var p=scrPoint(ev);if(!SDRAG){bar.innerHTML='<b>x '+p.x+', y '+p.y+'</b> &middot; drag to mark a region';return;}
621
634
  var r=scrRect(SDRAG,p);bar.innerHTML='<b>x '+r.x+', y '+r.y+' &middot; '+r.w+'&times;'+r.h+'</b>';draw.classList.remove('hidden');draw.style.left=scrPct(r.x,s.w);draw.style.top=scrPct(r.y,s.h);draw.style.width=scrPct(r.w,s.w);draw.style.height=scrPct(r.h,s.h);});
@@ -629,15 +642,18 @@ var cd=$('scrclickdelay');if(cd)cd.addEventListener('change',function(){SCLICKDE
629
642
  $('scrregions').querySelectorAll('.scrclick').forEach(function(b){b.addEventListener('click',function(){clickUI(s,+b.closest('.task').getAttribute('data-i'),b);});});}
630
643
  // A real click: confirmed every time (no confirm() to ask with means no click).
631
644
  function clickUI(s,i,b){var r=(s.regions||[])[i],c=s.blueprint&&s.blueprint.regions[i];if(!r||!c)return;var d=SCLICKDELAY;
632
- var q="Move your mouse to “"+r.label+"” at ("+c.center.x+", "+c.center.y+") and click there"+(d?" in "+d+" seconds":" now")+"?\\n\\nIt clicks whatever is at that spot on your screen"+(d?" by then, so bring the right window to the front.":".")+(s.via==='loaded'?"\\n\\nThis screen was loaded from an image: check it matches your screen now.":"");
645
+ var q="Move your mouse to “"+r.label+"” at ("+c.center.x+", "+c.center.y+")"+(s.monitor?" on "+s.monitor.name+(s.monitor.where?" ("+s.monitor.where+")":""):"")+" and click there"+(d?" in "+d+" seconds":" now")+"?\\n\\nIt clicks whatever is at that spot on your screen"+(d?" by then, so bring the right window to the front.":".")+(s.via==='loaded'?"\\n\\nThis screen was loaded from an image: check it matches your screen now.":"");
633
646
  if(typeof confirm!=='function'||!confirm(q))return;
634
647
  b.disabled=true;$('screenmsg').innerHTML="<div class='note muted'>"+(d?"Clicking “"+esc(r.label)+"” in "+d+"s &mdash; bring the right window to the front&hellip;":"Clicking&hellip;")+"</div>";
635
648
  api('/api/screens/click',{id:s.id,region:r.id,delay:d,confirmed:true}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
636
649
  $('screenmsg').innerHTML="<div class='note ok'>&#10003; Clicked “"+esc(x.label)+"” at ("+x.x+", "+x.y+") with "+esc(x.via)+".</div>";}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
637
- function screenAdded(s){if(!s||s.error){screenErr((s&&s.error)||'failed');if(s&&s.blocked)allowShotsBtn(s.blocked);return;}$('screenmsg').innerHTML='';$('screenname').value='';SCREEN=s;SPEND=null;loadScreensUI();}
650
+ // One screen, or { screens } (one per display); a note says why a capture was kept whole.
651
+ function screenAdded(s){if(!s||s.error){screenErr((s&&s.error)||'failed');if(s&&s.blocked)allowShotsBtn(s.blocked);return;}var list=s.screens||[s];
652
+ $('screenmsg').innerHTML=s.note?"<div class='note muted'>"+esc(s.note)+"</div>":list.length>1?"<div class='note ok'>&#10003; One screen per display: "+list.map(function(x){return esc(x.monitor?x.monitor.name+(x.monitor.where?" ("+x.monitor.where+")":""):x.name);}).join(', ')+".</div>":'';
653
+ $('screenname').value='';SCREEN=list[0];SPEND=null;loadScreensUI();}
638
654
  function captureUI(){var b=$('capture'),d=+($('screendelay').value||0);b.disabled=true;
639
655
  $('screenmsg').innerHTML="<div class='note muted'>"+(d?"Capturing in "+d+"s &mdash; bring the window you want to the front&hellip;":"Capturing&hellip;")+"</div>";
640
- api('/api/screens/capture',{name:$('screenname').value||'',delay:d}).then(function(s){b.disabled=false;screenAdded(s);}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
656
+ api('/api/screens/capture',{name:$('screenname').value||'',delay:d,which:screenWhich()}).then(function(s){b.disabled=false;screenAdded(s);loadMonitorsUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
641
657
  // The desktop's screenshot permission is off for the app Symbiot was started
642
658
  // from: turn it on (asked first, it covers every app started that way), then capture.
643
659
  function allowShotsBtn(app){$('screenmsg').insertAdjacentHTML('beforeend',"<div class='row' style='margin-top:6px'><button class='act' id='scrallow'>Allow screenshots</button></div>");
@@ -693,6 +709,7 @@ document.getElementById('newtask').addEventListener('keydown',function(e){if(e.k
693
709
  document.getElementById('capture').addEventListener('click',captureUI);
694
710
  document.getElementById('screenload').addEventListener('click',pickImageUI);
695
711
  document.getElementById('screenfile').addEventListener('change',importUI);
712
+ document.getElementById('screenwhich').addEventListener('change',function(){try{localStorage.setItem('symbiot_screenwhich',screenWhich());}catch(e){}});
696
713
  document.getElementById('weeklyon').addEventListener('change',saveWeekly);
697
714
  document.getElementById('weeklyday').addEventListener('change',saveWeekly);
698
715
  document.getElementById('weeklyhour').addEventListener('change',saveWeekly);
@@ -731,5 +748,5 @@ function heartbeat(fresh){
731
748
  function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried',document.getElementById('ver').textContent.replace(/^v/,''));}catch(e){}var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
732
749
  setInterval(heartbeat,4000);heartbeat(true);
733
750
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
734
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();loadScreensUI();loadDesktop();
751
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();
735
752
  </script></body></html>`;