symbiot 0.38.0 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/index.mjs +50 -11
- package/package.json +1 -1
- package/screens.mjs +253 -23
- 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 =
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "0.39.1",
|
|
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 }] }],
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
//
|
|
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 },
|
|
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
|
-
|
|
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 · 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)'>↶</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'>⏳ 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")+" · 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 → PR":" title='commit on a branch, push, open a PR, then archive'>Approve → "+(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="✓ <b>"+esc(repo)+"</b>: approved "+(r.approved?r.approved+" task"+(r.approved>1?"s":""):"changes without a task");
|
|
431
434
|
if(r.commit)m+=" · committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
|
|
432
435
|
if(r.pr)m+=" · <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 && git tag v"+esc(r.bumped)+" && git push origin --tags</code> on <code>"+esc(r.base||'main')+"</code> to release it.</div>";
|
|
433
437
|
if(r.autoMerge==='queued')m+=" · 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 → General → 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 · '+m.w+'×'+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+" × "+s.h+" px</span
|
|
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)+")":"")+" · ":"")+s.w+" × "+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 · 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…</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> · drag to mark a region';return;}
|
|
621
634
|
var r=scrRect(SDRAG,p);bar.innerHTML='<b>x '+r.x+', y '+r.y+' · '+r.w+'×'+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 — bring the right window to the front…":"Clicking…")+"</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'>✓ 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
|
-
|
|
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'>✓ 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 — bring the window you want to the front…":"Capturing…")+"</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>`;
|