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.
- package/README.md +26 -4
- package/index.mjs +73 -11
- package/package.json +1 -1
- package/screens.mjs +298 -29
- 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`),
|
|
418
|
-
|
|
419
|
-
ydotool 1.x).
|
|
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).
|
|
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 =
|
|
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 =
|
|
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.
|
|
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 }] }],
|
|
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
|
-
|
|
38
|
-
|
|
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 [
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
//
|
|
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 },
|
|
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
|
-
|
|
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 · 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)'>↶</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.":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")+" · 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>";});
|
|
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="✓ <b>"+esc(repo)+"</b>: approved "+(r.approved?r.approved+" task"+(r.approved>1?"s":""):"changes without a task");
|
|
430
434
|
if(r.commit)m+=" · committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
|
|
431
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>";
|
|
432
437
|
if(r.autoMerge==='queued')m+=" · 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 → General → 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 · '+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';}
|
|
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+" × "+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>";
|
|
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 · 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…</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> · drag to mark a region';return;}
|
|
620
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);});
|
|
@@ -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 — bring the right window to the front…":"Clicking…")+"</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'>✓ 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
|
-
|
|
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();}
|
|
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 — bring the window you want to the front…":"Capturing…")+"</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…</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>`;
|