symbiot 0.37.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 +26 -4
  2. package/index.mjs +73 -11
  3. package/package.json +1 -1
  4. package/screens.mjs +298 -29
  5. package/ui.mjs +33 -10
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,18 +418,33 @@ 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
417
- `cliclick` on macOS (`brew install cliclick`), and on Linux with `xdotool` on X11 or
418
- `ydotool` on Wayland (which needs access to `/dev/uinput`, and `ydotoold` running for
419
- ydotool 1.x). Windows can't click yet. Nothing types yet.
436
+ `cliclick` on macOS (`brew install cliclick`), with PowerShell on Windows (nothing to
437
+ install), and on Linux with `xdotool` on X11 or `ydotool` on Wayland (which needs
438
+ access to `/dev/uinput`, and `ydotoold` running for ydotool 1.x). Nothing types yet.
420
439
 
421
440
  Capture uses the screenshot tool your system has: `screencapture` on macOS,
422
441
  PowerShell on Windows, and on Linux `gnome-screenshot`, `spectacle`,
423
442
  `cosmic-screenshot`, `grim` or `xfce4-screenshooter` (plus `scrot` / ImageMagick
424
443
  `import` on X11). Why it's experimental: Wayland desktops restrict screenshots, so
425
- capture may fail or ask for permission (use **Load image** then). And on a scaled
444
+ capture may fail or ask for permission (use **Load image** then). If your desktop
445
+ has screenshots turned off for the app Symbiot was started from (say, the COSMIC
446
+ dock), Symbiot says so and offers **Allow screenshots**, which turns them on for
447
+ apps started that way. And on a scaled
426
448
  (HiDPI) display, screenshot pixels can differ from the coordinates a click tool
427
449
  expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, but
428
450
  it can't read the scale on Wayland, and ydotool's moves follow your pointer
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, 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));
@@ -105,6 +105,10 @@ function addTask(text, repo) {
105
105
  text = String(text || "").trim().slice(0, 300);
106
106
  if (!text) return { error: "empty" };
107
107
  const t = loadTasks();
108
+ // The same text already open in the same repo (in review counts): that task, not a duplicate.
109
+ const same = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
110
+ const dup = t.find((x) => !x.done && !x.archived && (x.repo || "") === (repo || "") && same(x.text) === same(text));
111
+ if (dup) return { ...dup, duplicate: true };
108
112
  const item = { id: randomBytes(6).toString("hex"), text, repo: repo || "", done: false, ts: Date.now() };
109
113
  t.unshift(item); saveTasks(t); return item;
110
114
  }
@@ -163,6 +167,58 @@ function workingDiff(path, cap = 400000) {
163
167
  }
164
168
  return d.length > cap ? d.slice(0, cap) + "\n… (diff truncated)" : d;
165
169
  }
170
+ // Merged work that isn't released yet: how many commits the default branch (or
171
+ // origin's copy, where approved PRs merge) is past its last v* tag, or null when
172
+ // it isn't, or the repo doesn't release with v* tags. bump: the version these
173
+ // uncommitted changes set in package.json, if they change it.
174
+ function unreleased(path) {
175
+ const base = gitDefaultBranch(path); let best = null;
176
+ for (const ref of ["refs/heads/" + base, "refs/remotes/origin/" + base]) {
177
+ const tag = git(path, ["describe", "--tags", "--abbrev=0", "--match", "v*", ref]).out; if (!tag) continue;
178
+ const ahead = +git(path, ["rev-list", "--count", tag + ".." + ref]).out || 0;
179
+ if (!best || ahead > best.ahead) best = { base, tag, ahead };
180
+ }
181
+ if (!best || !best.ahead) return null;
182
+ const ver = (s) => { try { return String(JSON.parse(s).version || ""); } catch { return ""; } };
183
+ let now = ""; try { now = ver(readFileSync(join(path, "package.json"), "utf8")); } catch {}
184
+ const was = ver(git(path, ["show", "HEAD:package.json"]).out);
185
+ return { ...best, ...(now && was && now !== was ? { bump: now } : {}) };
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
+ }
166
222
  // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
167
223
  // uncommitted changes no ticked task covers (untasked: approve them as-is).
168
224
  // running: an agent is still editing there, so its changes may be half done.
@@ -171,10 +227,10 @@ function pendingReview() {
171
227
  const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
172
228
  const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
173
229
  const am = autoMergeRepos();
174
- 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) : { 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: "" }) }; });
175
231
  for (const repo of sent.sort()) {
176
232
  const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
177
- const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc });
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) });
178
234
  }
179
235
  return out;
180
236
  }
@@ -203,7 +259,7 @@ function shipChanges(path, texts, opts = {}) {
203
259
  const add = git(path, ["add", "-A"]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
204
260
  git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
205
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`;
206
- 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.` : "");
207
263
  const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
208
264
  const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject };
209
265
  if (opts.push === false) return out;
@@ -236,7 +292,7 @@ function approveRepo(repo, opts = {}) {
236
292
  if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
237
293
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
238
294
  const busy = stillWorking(repo, path); if (busy) return busy;
239
- 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) });
240
296
  if (r.error) return r;
241
297
  const now = Date.now();
242
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]; }
@@ -251,7 +307,7 @@ function approveChanges(repo, opts = {}) {
251
307
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
252
308
  const busy = stillWorking(repo, path); if (busy) return busy;
253
309
  if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
254
- 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) });
255
311
  return r.error ? r : { ...r, approved: 0 };
256
312
  }
257
313
  // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
@@ -1457,7 +1513,7 @@ async function cmdApp() {
1457
1513
  } catch {}
1458
1514
  }
1459
1515
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
1460
- 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);
1461
1517
  const server = createServer(async (req, res) => {
1462
1518
  const u = new URL(req.url, "http://127.0.0.1");
1463
1519
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
@@ -1484,8 +1540,8 @@ async function cmdApp() {
1484
1540
  if (u.pathname === "/api/tasks/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearTaskChat(String(b.id || ""))); }
1485
1541
  if (u.pathname === "/api/pending") return json(res, pendingReview()); // ticked by the agent, awaiting approval
1486
1542
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
1487
- if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approveRepo(String(b.repo || ""))); }
1488
- 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 })); }
1489
1545
  if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
1490
1546
  if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
1491
1547
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
@@ -1513,7 +1569,13 @@ async function cmdApp() {
1513
1569
  // src, so it carries the token in the query (?t=), which the check above accepts.
1514
1570
  if (u.pathname === "/api/screens") return json(res, loadScreens().map(screenOut));
1515
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
1516
- 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 || "")))); }
1577
+ // Changes a desktop permission, so only on the user's confirmed click.
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()); }
1517
1579
  if (u.pathname === "/api/screens/import" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(importScreen(b.name, b.png))); }
1518
1580
  if (u.pathname === "/api/screens/regions" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(setRegions(String(b.id || ""), b.regions))); }
1519
1581
  if (u.pathname === "/api/screens/rename" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(renameScreen(String(b.id || ""), b.name))); }
@@ -1647,4 +1709,4 @@ const isMain = (() => {
1647
1709
  })();
1648
1710
  if (isMain) main();
1649
1711
 
1650
- 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, 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.37.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");
@@ -30,40 +33,296 @@ function pngSize(buf) {
30
33
  return w && h ? { w, h } : null;
31
34
  }
32
35
 
36
+ // Windows: user32 calls for PowerShell. DPI-aware first, so with display scaling
37
+ // the capture is the whole screen in real pixels and a click lands in the same ones.
38
+ const WIN_USER32 = "Add-Type -Namespace Symbiot -Name User32 -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x, int y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, System.UIntPtr e);'; ";
39
+ const WIN_DPI = WIN_USER32 + "[Symbiot.User32]::SetProcessDPIAware() | Out-Null; ";
40
+ // -EncodedCommand (base64 UTF-16LE) carries the script's quotes through untouched.
41
+ const psEncoded = (script) => ["powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]];
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
+
33
190
  // Screenshot tools to try, in order, as [cmd, args] that write a PNG to `file`,
34
191
  // or [cmd, args, dir] for a tool that only takes a folder (it picks the name).
35
192
  // scrot and import only see X11, which on Wayland is a blank screen, so they're
36
- // left out there.
37
- function captureCmds(file, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland") {
38
- 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]]];
39
197
  if (platform === "win32") {
40
- const ps = "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)";
41
- return [["powershell", ["-NoProfile", "-Command", ps]]];
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)";
199
+ return [psEncoded(ps)];
42
200
  }
43
201
  const dir = file.replace(/\.png$/, ".d");
44
202
  return [["gnome-screenshot", ["-f", file]], ["spectacle", ["-b", "-n", "-f", "-o", file]], ["cosmic-screenshot", ["--interactive=false", "--modal=false", "--notify=false", "--save-dir", dir], dir], ["grim", [file]],
45
203
  ["xfce4-screenshooter", ["-f", "-s", file]], ...(wayland ? [] : [["scrot", [file]], ["import", ["-window", "root", file]]])];
46
204
  }
47
- function addScreen(id, name, size, via) {
48
- 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: [] };
49
- saveScreens([s, ...loadScreens()]);
50
- return s;
205
+ // On Wayland, cosmic-screenshot (and other portal tools) ask xdg-desktop-portal,
206
+ // which keys its screenshot permission on the app that started us: the systemd
207
+ // unit in our cgroup, app[-<launcher>]-<AppID>-<random>.scope or
208
+ // app[-<launcher>]-<AppID>[@<random>].service. Started from the COSMIC dock,
209
+ // that's com.system76.CosmicAppList; from a terminal, "" (no app id).
210
+ function portalAppId(cgroup) {
211
+ const unit = String(cgroup || "").trim().split("\n").pop().split("/").pop();
212
+ const m = unit.match(/^app-(?:[A-Za-z0-9]+-)?(.+?)(?:-[^-]*)?\.scope$/) || unit.match(/^app-(?:[A-Za-z0-9]+-)?(.+?)(?:@.*)?\.service$/);
213
+ const id = m ? m[1].replace(/\\x2d/g, "-") : "";
214
+ return /^[A-Za-z_][\w-]*(\.[A-Za-z_][\w-]*)+$/.test(id) ? id : "";
51
215
  }
52
- // Take a screenshot of the whole screen with the first tool that works here.
53
- function captureScreen(name) {
54
- const id = randomBytes(6).toString("hex"), file = screenFile(id), tried = [];
55
- try { mkdirSync(SCREENS_DIR, { recursive: true }); } catch (e) { return { error: String((e && e.message) || e) }; }
56
- const posix = process.platform !== "win32" && process.platform !== "darwin";
57
- for (const [cmd, args, dir] of captureCmds(file)) {
58
- if (posix && !hasCmd(cmd)) continue;
216
+ const PERMS = ["--session", "--dest", "org.freedesktop.impl.portal.PermissionStore", "--object-path", "/org/freedesktop/impl/portal/PermissionStore", "--method"];
217
+ // The app id the portal has screenshots turned off for (it answers "no" without
218
+ // asking again), or "" when they're allowed, unset or it can't be read.
219
+ function screenshotBlocked() {
220
+ if (process.platform !== "linux" || !hasCmd("gdbus")) return "";
221
+ let id = ""; try { id = portalAppId(readFileSync("/proc/self/cgroup", "utf8")); } catch {}
222
+ if (!id) return "";
223
+ const out = String(spawnSync("gdbus", ["call", ...PERMS, "org.freedesktop.impl.portal.PermissionStore.Lookup", "screenshot", "screenshot"], { encoding: "utf8", timeout: 5000 }).stdout || "");
224
+ const m = out.match(new RegExp("'" + id.replace(/[.\\-]/g, "\\$&") + "': \\['(\\w+)'"));
225
+ return m && m[1] === "no" ? id : "";
226
+ }
227
+ // Turn screenshots on for the app id that blocks them. Only when the user asks,
228
+ // in the app: it's their desktop's permission, and it covers every app started
229
+ // the same way (e.g. everything from the COSMIC dock).
230
+ function allowScreenshots() {
231
+ const id = screenshotBlocked(); if (!id) return { error: "Screenshots aren't turned off for Symbiot. Try Capture screen again." };
232
+ const p = spawnSync("gdbus", ["call", ...PERMS, "org.freedesktop.impl.portal.PermissionStore.SetPermission", "screenshot", "true", "screenshot", id, "['yes']"], { encoding: "utf8", timeout: 5000 });
233
+ return p.status === 0 ? { ok: true, app: id } : { error: "Couldn't change the permission: " + (String(p.stderr || "").trim() || "exit " + p.status) };
234
+ }
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;
59
249
  tried.push(cmd);
60
250
  if (dir) try { mkdirSync(dir, { recursive: true }); } catch {}
61
251
  spawnSync(cmd, args, { stdio: "ignore", timeout: 20000, killSignal: "SIGKILL" });
62
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 }); }
63
253
  let size = null; try { size = pngSize(readFileSync(file)); } catch {}
64
- if (size) return addScreen(id, name, size, cmd);
254
+ if (size) return { size, via: cmd };
65
255
  try { unlinkSync(file); } catch {}
66
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) {
324
+ const blocked = tried.length && screenshotBlocked();
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 };
67
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." };
68
327
  }
69
328
  // A PNG you already have (base64, or a data: URL), for when capture can't run.
@@ -99,20 +358,29 @@ function removeScreen(id) {
99
358
  return { ok: true };
100
359
  }
101
360
  const center = (r) => ({ x: r.x + Math.floor(r.w / 2), y: r.y + Math.floor(r.h / 2) });
102
- // 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.
103
367
  function blueprint(s) {
104
- 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)) } : {}) })) };
105
370
  }
106
371
 
107
372
  // Click tools to try, in order, as a list of [cmd, args] steps that move the
108
373
  // mouse to (x, y) and left-click there. xdotool only reaches X11 windows, so on
109
374
  // Wayland it's ydotool (it needs /dev/uinput). ydotool 1.x moves relative unless
110
375
  // told --absolute and takes a button code; 0.1.x (Debian/Ubuntu) moves to the
111
- // point and takes a button number.
376
+ // point and takes a button number. On Windows, PowerShell: SetCursorPos, offset
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).
112
380
  function clickCmds(x, y, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland", ydotool1 = false) {
113
- x = String(x); y = String(y);
114
- if (platform === "darwin") return [[["cliclick", ["c:" + x + "," + y]]]];
115
- if (platform === "win32") return [];
381
+ x = String(Math.round(Number(x)) || 0); y = String(Math.round(Number(y)) || 0);
382
+ if (platform === "darwin") return [[["cliclick", ["c:" + x.replace(/^-/, "=-") + "," + y.replace(/^-/, "=-")]]]];
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)")]];
116
384
  const ydo = ydotool1 ? [["ydotool", ["mousemove", "--absolute", "-x", x, "-y", y]], ["ydotool", ["click", "0xC0"]]] : [["ydotool", ["mousemove", x, y]], ["ydotool", ["click", "1"]]];
117
385
  const xdo = [["xdotool", ["mousemove", "--sync", x, y, "click", "1"]]];
118
386
  return wayland ? [ydo] : [xdo, ydo];
@@ -131,13 +399,14 @@ function clickSpace(tool) {
131
399
  function clickRegion(id, regionId) {
132
400
  const s = loadScreens().find((x) => x.id === id); if (!s) return { error: "not found" };
133
401
  const r = (s.regions || []).find((x) => x.id === regionId); if (!r) return { error: "That region is gone. Reload the screen." };
134
- if (process.platform === "win32") return { error: "Clicking isn't supported on Windows yet." };
135
402
  const c = center(r), wayland = process.env.XDG_SESSION_TYPE === "wayland";
136
403
  // The first tool that's installed does it. If that fails it says so, rather
137
404
  // than trying the next one: a half-done move + click could otherwise click twice.
138
405
  const tool = clickCmds(c.x, c.y).map((st) => st[0][0]).find((t) => hasCmd(t));
139
406
  if (tool) {
140
- 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;
141
410
  const steps = clickCmds(x, y, process.platform, wayland, tool === "ydotool" && ydotoolIs1()).find((st) => st[0][0] === tool);
142
411
  for (const [cmd, args] of steps) {
143
412
  const p = spawnSync(cmd, args, { encoding: "utf8", timeout: 10000, killSignal: "SIGKILL" });
@@ -145,8 +414,8 @@ function clickRegion(id, regionId) {
145
414
  }
146
415
  return { ok: true, label: r.label, x, y, via: tool };
147
416
  }
148
- const want = process.platform === "darwin" ? "cliclick (brew install cliclick)" : wayland ? "ydotool" : "xdotool or ydotool";
417
+ const want = process.platform === "darwin" ? "cliclick (brew install cliclick)" : process.platform === "win32" ? "Windows PowerShell" : wayland ? "ydotool" : "xdotool or ydotool";
149
418
  return { error: `No click tool found. Install ${want}.` };
150
419
  }
151
420
 
152
- export { loadScreens, screenImage, pngSize, captureCmds, 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,6 +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.":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>";
417
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>";});
418
422
  el.innerHTML=h;
419
423
  el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
@@ -423,12 +427,13 @@ var amt=card.querySelector('.amtoggle');if(amt)amt.addEventListener('change',fun
423
427
  var sd=card.querySelector('.showdiff'),pre=card.querySelector('.rdiff');
424
428
  if(sd)sd.addEventListener('click',function(){if(!pre.classList.contains('hidden')){pre.classList.add('hidden');sd.textContent='Show diff';return;}
425
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)';});});
426
- var ap=card.querySelector('.approve');if(ap)ap.addEventListener('click',function(){ap.disabled=true;ap.textContent='Committing & pushing...';
427
- 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');
428
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;}
429
433
  var m="&#10003; <b>"+esc(repo)+"</b>: approved "+(r.approved?r.approved+" task"+(r.approved>1?"s":""):"changes without a task");
430
434
  if(r.commit)m+=" &middot; committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
431
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>";
432
437
  if(r.autoMerge==='queued')m+=" &middot; will auto-merge when CI passes";
433
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>";
434
439
  if(r.note)m+="<div class='muted'>"+esc(r.note)+"</div>";
@@ -589,7 +594,15 @@ api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.
589
594
  // Screens: a screenshot plus named regions, the blueprint (screens.mjs). Drag on
590
595
  // the image to mark a region; coordinates are the screenshot's own pixels.
591
596
  // Names stay out of attributes (esc() doesn't escape quotes): rows carry indexes.
592
- 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';}
593
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();});}
594
607
  function renderScreenList(){var box=$('screenlist');if(!SCREENS.length){box.innerHTML="<span class='muted' style='font-size:12px'>No screens yet.</span>";return;}
595
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('');
@@ -597,7 +610,7 @@ box.querySelectorAll('.fchip').forEach(function(b){b.addEventListener('click',fu
597
610
  function scrPct(v,of){return (v/of*100).toFixed(3)+'%';}
598
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>";}
599
612
  function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
600
- 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>";
601
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>";
602
615
  h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
603
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>";
@@ -615,6 +628,7 @@ nm.value=s.name;
615
628
  nm.addEventListener('change',function(){var n=(nm.value||'').trim();if(n)api('/api/screens/rename',{id:s.id,name:n}).then(screenSaved);});
616
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);});
617
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));});});
618
632
  wrap.addEventListener('pointerdown',function(ev){if(ev.button)return;ev.preventDefault();SDRAG=scrPoint(ev);try{wrap.setPointerCapture(ev.pointerId);}catch(e){}});
619
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;}
620
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);});
@@ -628,15 +642,23 @@ var cd=$('scrclickdelay');if(cd)cd.addEventListener('change',function(){SCLICKDE
628
642
  $('scrregions').querySelectorAll('.scrclick').forEach(function(b){b.addEventListener('click',function(){clickUI(s,+b.closest('.task').getAttribute('data-i'),b);});});}
629
643
  // A real click: confirmed every time (no confirm() to ask with means no click).
630
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;
631
- 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.":"");
632
646
  if(typeof confirm!=='function'||!confirm(q))return;
633
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>";
634
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;}
635
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));});}
636
- function screenAdded(s){if(!s||s.error){screenErr((s&&s.error)||'failed');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();}
637
654
  function captureUI(){var b=$('capture'),d=+($('screendelay').value||0);b.disabled=true;
638
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>";
639
- 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));});}
657
+ // The desktop's screenshot permission is off for the app Symbiot was started
658
+ // from: turn it on (asked first, it covers every app started that way), then capture.
659
+ function allowShotsBtn(app){$('screenmsg').insertAdjacentHTML('beforeend',"<div class='row' style='margin-top:6px'><button class='act' id='scrallow'>Allow screenshots</button></div>");
660
+ $('scrallow').addEventListener('click',function(){if(typeof confirm!=='function'||!confirm("Turn screenshots on for apps started from "+app+"?\\n\\nYour desktop keeps this permission per app, and Symbiot shares it with "+app+", so it covers every app started that way, not only Symbiot."))return;
661
+ $('scrallow').disabled=true;api('/api/screens/allow',{confirmed:true}).then(function(x){if(!x||x.error){screenErr((x&&x.error)||'failed');return;}captureUI();}).catch(function(e){screenErr(String((e&&e.message)||e));});});}
640
662
  function pickImageUI(){var f=$('screenfile');if(f&&f.click)f.click();}
641
663
  function importUI(){var f=$('screenfile'),file=f&&f.files&&f.files[0];if(!file)return;$('screenmsg').innerHTML="<div class='note muted'>Loading&hellip;</div>";
642
664
  var rd=new FileReader();rd.onload=function(){api('/api/screens/import',{name:$('screenname').value||file.name.replace(/[.]png$/i,''),png:rd.result}).then(function(s){f.value='';screenAdded(s);});};rd.readAsDataURL(file);}
@@ -687,6 +709,7 @@ document.getElementById('newtask').addEventListener('keydown',function(e){if(e.k
687
709
  document.getElementById('capture').addEventListener('click',captureUI);
688
710
  document.getElementById('screenload').addEventListener('click',pickImageUI);
689
711
  document.getElementById('screenfile').addEventListener('change',importUI);
712
+ document.getElementById('screenwhich').addEventListener('change',function(){try{localStorage.setItem('symbiot_screenwhich',screenWhich());}catch(e){}});
690
713
  document.getElementById('weeklyon').addEventListener('change',saveWeekly);
691
714
  document.getElementById('weeklyday').addEventListener('change',saveWeekly);
692
715
  document.getElementById('weeklyhour').addEventListener('change',saveWeekly);
@@ -725,5 +748,5 @@ function heartbeat(fresh){
725
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',{});}
726
749
  setInterval(heartbeat,4000);heartbeat(true);
727
750
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
728
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();loadScreensUI();loadDesktop();
751
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();
729
752
  </script></body></html>`;