symbiot 0.37.0 → 0.38.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 CHANGED
@@ -414,15 +414,18 @@ agent or script can read it.
414
414
  **Click here** on a region moves your mouse to the region's centre and clicks there,
415
415
  on your real screen. It asks you to confirm every time, and can wait 3, 5 or 10
416
416
  seconds first so you can bring the right window to the front. It clicks with
417
- `cliclick` on macOS (`brew install cliclick`), and on Linux with `xdotool` on X11 or
418
- `ydotool` on Wayland (which needs access to `/dev/uinput`, and `ydotoold` running for
419
- ydotool 1.x). Windows can't click yet. Nothing types yet.
417
+ `cliclick` on macOS (`brew install cliclick`), with PowerShell on Windows (nothing to
418
+ install), and on Linux with `xdotool` on X11 or `ydotool` on Wayland (which needs
419
+ access to `/dev/uinput`, and `ydotoold` running for ydotool 1.x). Nothing types yet.
420
420
 
421
421
  Capture uses the screenshot tool your system has: `screencapture` on macOS,
422
422
  PowerShell on Windows, and on Linux `gnome-screenshot`, `spectacle`,
423
423
  `cosmic-screenshot`, `grim` or `xfce4-screenshooter` (plus `scrot` / ImageMagick
424
424
  `import` on X11). Why it's experimental: Wayland desktops restrict screenshots, so
425
- capture may fail or ask for permission (use **Load image** then). And on a scaled
425
+ capture may fail or ask for permission (use **Load image** then). If your desktop
426
+ has screenshots turned off for the app Symbiot was started from (say, the COSMIC
427
+ dock), Symbiot says so and offers **Allow screenshots**, which turns them on for
428
+ apps started that way. And on a scaled
426
429
  (HiDPI) display, screenshot pixels can differ from the coordinates a click tool
427
430
  expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, but
428
431
  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, 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,23 @@ 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
+ }
166
187
  // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
167
188
  // uncommitted changes no ticked task covers (untasked: approve them as-is).
168
189
  // running: an agent is still editing there, so its changes may be half done.
@@ -171,10 +192,10 @@ function pendingReview() {
171
192
  const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
172
193
  const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
173
194
  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: "" }) }; });
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: "" }) }; });
175
196
  for (const repo of sent.sort()) {
176
197
  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 });
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) });
178
199
  }
179
200
  return out;
180
201
  }
@@ -1514,6 +1535,8 @@ async function cmdApp() {
1514
1535
  if (u.pathname === "/api/screens") return json(res, loadScreens().map(screenOut));
1515
1536
  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
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))); }
1538
+ // Changes a desktop permission, so only on the user's confirmed click.
1539
+ 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
1540
  if (u.pathname === "/api/screens/import" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(importScreen(b.name, b.png))); }
1518
1541
  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
1542
  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 +1670,4 @@ const isMain = (() => {
1647
1670
  })();
1648
1671
  if (isMain) main();
1649
1672
 
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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.37.0",
3
+ "version": "0.38.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
@@ -30,6 +30,13 @@ function pngSize(buf) {
30
30
  return w && h ? { w, h } : null;
31
31
  }
32
32
 
33
+ // Windows: user32 calls for PowerShell. DPI-aware first, so with display scaling
34
+ // the capture is the whole screen in real pixels and a click lands in the same ones.
35
+ 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);'; ";
36
+ const WIN_DPI = WIN_USER32 + "[Symbiot.User32]::SetProcessDPIAware() | Out-Null; ";
37
+ // -EncodedCommand (base64 UTF-16LE) carries the script's quotes through untouched.
38
+ const psEncoded = (script) => ["powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]];
39
+
33
40
  // Screenshot tools to try, in order, as [cmd, args] that write a PNG to `file`,
34
41
  // or [cmd, args, dir] for a tool that only takes a folder (it picks the name).
35
42
  // scrot and import only see X11, which on Wayland is a blank screen, so they're
@@ -37,13 +44,43 @@ function pngSize(buf) {
37
44
  function captureCmds(file, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland") {
38
45
  if (platform === "darwin") return [["screencapture", ["-x", "-t", "png", file]]];
39
46
  if (platform === "win32") {
40
- const ps = "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b = [System.Windows.Forms.SystemInformation]::VirtualScreen; $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; $g = [System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size); $bmp.Save('" + file.replace(/'/g, "''") + "', [System.Drawing.Imaging.ImageFormat]::Png)";
41
- return [["powershell", ["-NoProfile", "-Command", ps]]];
47
+ 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
+ return [psEncoded(ps)];
42
49
  }
43
50
  const dir = file.replace(/\.png$/, ".d");
44
51
  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
52
  ["xfce4-screenshooter", ["-f", "-s", file]], ...(wayland ? [] : [["scrot", [file]], ["import", ["-window", "root", file]]])];
46
53
  }
54
+ // On Wayland, cosmic-screenshot (and other portal tools) ask xdg-desktop-portal,
55
+ // which keys its screenshot permission on the app that started us: the systemd
56
+ // unit in our cgroup, app[-<launcher>]-<AppID>-<random>.scope or
57
+ // app[-<launcher>]-<AppID>[@<random>].service. Started from the COSMIC dock,
58
+ // that's com.system76.CosmicAppList; from a terminal, "" (no app id).
59
+ function portalAppId(cgroup) {
60
+ const unit = String(cgroup || "").trim().split("\n").pop().split("/").pop();
61
+ const m = unit.match(/^app-(?:[A-Za-z0-9]+-)?(.+?)(?:-[^-]*)?\.scope$/) || unit.match(/^app-(?:[A-Za-z0-9]+-)?(.+?)(?:@.*)?\.service$/);
62
+ const id = m ? m[1].replace(/\\x2d/g, "-") : "";
63
+ return /^[A-Za-z_][\w-]*(\.[A-Za-z_][\w-]*)+$/.test(id) ? id : "";
64
+ }
65
+ const PERMS = ["--session", "--dest", "org.freedesktop.impl.portal.PermissionStore", "--object-path", "/org/freedesktop/impl/portal/PermissionStore", "--method"];
66
+ // The app id the portal has screenshots turned off for (it answers "no" without
67
+ // asking again), or "" when they're allowed, unset or it can't be read.
68
+ function screenshotBlocked() {
69
+ if (process.platform !== "linux" || !hasCmd("gdbus")) return "";
70
+ let id = ""; try { id = portalAppId(readFileSync("/proc/self/cgroup", "utf8")); } catch {}
71
+ if (!id) return "";
72
+ const out = String(spawnSync("gdbus", ["call", ...PERMS, "org.freedesktop.impl.portal.PermissionStore.Lookup", "screenshot", "screenshot"], { encoding: "utf8", timeout: 5000 }).stdout || "");
73
+ const m = out.match(new RegExp("'" + id.replace(/[.\\-]/g, "\\$&") + "': \\['(\\w+)'"));
74
+ return m && m[1] === "no" ? id : "";
75
+ }
76
+ // Turn screenshots on for the app id that blocks them. Only when the user asks,
77
+ // in the app: it's their desktop's permission, and it covers every app started
78
+ // the same way (e.g. everything from the COSMIC dock).
79
+ function allowScreenshots() {
80
+ const id = screenshotBlocked(); if (!id) return { error: "Screenshots aren't turned off for Symbiot. Try Capture screen again." };
81
+ const p = spawnSync("gdbus", ["call", ...PERMS, "org.freedesktop.impl.portal.PermissionStore.SetPermission", "screenshot", "true", "screenshot", id, "['yes']"], { encoding: "utf8", timeout: 5000 });
82
+ return p.status === 0 ? { ok: true, app: id } : { error: "Couldn't change the permission: " + (String(p.stderr || "").trim() || "exit " + p.status) };
83
+ }
47
84
  function addScreen(id, name, size, via) {
48
85
  const s = { id, name: String(name || "").trim().slice(0, 80) || "Screen " + new Date().toLocaleString(), w: size.w, h: size.h, ts: Date.now(), via, regions: [] };
49
86
  saveScreens([s, ...loadScreens()]);
@@ -64,6 +101,8 @@ function captureScreen(name) {
64
101
  if (size) return addScreen(id, name, size, cmd);
65
102
  try { unlinkSync(file); } catch {}
66
103
  }
104
+ const blocked = tried.length && screenshotBlocked();
105
+ 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
106
  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
107
  }
69
108
  // A PNG you already have (base64, or a data: URL), for when capture can't run.
@@ -108,11 +147,12 @@ function blueprint(s) {
108
147
  // mouse to (x, y) and left-click there. xdotool only reaches X11 windows, so on
109
148
  // Wayland it's ydotool (it needs /dev/uinput). ydotool 1.x moves relative unless
110
149
  // told --absolute and takes a button code; 0.1.x (Debian/Ubuntu) moves to the
111
- // point and takes a button number.
150
+ // point and takes a button number. On Windows, PowerShell: SetCursorPos, offset
151
+ // by the virtual screen's corner (the capture's 0,0), then left down + up.
112
152
  function clickCmds(x, y, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland", ydotool1 = false) {
113
- x = String(x); y = String(y);
153
+ x = String(Math.round(Number(x)) || 0); y = String(Math.round(Number(y)) || 0);
114
154
  if (platform === "darwin") return [[["cliclick", ["c:" + x + "," + y]]]];
115
- if (platform === "win32") return [];
155
+ 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
156
  const ydo = ydotool1 ? [["ydotool", ["mousemove", "--absolute", "-x", x, "-y", y]], ["ydotool", ["click", "0xC0"]]] : [["ydotool", ["mousemove", x, y]], ["ydotool", ["click", "1"]]];
117
157
  const xdo = [["xdotool", ["mousemove", "--sync", x, y, "click", "1"]]];
118
158
  return wayland ? [ydo] : [xdo, ydo];
@@ -131,7 +171,6 @@ function clickSpace(tool) {
131
171
  function clickRegion(id, regionId) {
132
172
  const s = loadScreens().find((x) => x.id === id); if (!s) return { error: "not found" };
133
173
  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
174
  const c = center(r), wayland = process.env.XDG_SESSION_TYPE === "wayland";
136
175
  // The first tool that's installed does it. If that fails it says so, rather
137
176
  // than trying the next one: a half-done move + click could otherwise click twice.
@@ -145,8 +184,8 @@ function clickRegion(id, regionId) {
145
184
  }
146
185
  return { ok: true, label: r.label, x, y, via: tool };
147
186
  }
148
- const want = process.platform === "darwin" ? "cliclick (brew install cliclick)" : wayland ? "ydotool" : "xdotool or ydotool";
187
+ const want = process.platform === "darwin" ? "cliclick (brew install cliclick)" : process.platform === "win32" ? "Windows PowerShell" : wayland ? "ydotool" : "xdotool or ydotool";
149
188
  return { error: `No click tool found. Install ${want}.` };
150
189
  }
151
190
 
152
- export { loadScreens, screenImage, pngSize, captureCmds, captureScreen, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickCmds, clickRegion };
191
+ export { loadScreens, screenImage, pngSize, captureCmds, portalAppId, allowScreenshots, captureScreen, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickCmds, clickRegion };
package/ui.mjs CHANGED
@@ -414,6 +414,7 @@ h+="<div class='rcard' data-repo='"+esc(r.repo)+"'><div class='rhead'><b>"+esc(r
414
414
  r.tasks.forEach(function(t){PENDTASKS.push(t);h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span>"+askBtn(t)+"<button class='rm sendback' title='not right - send back to the agent (unticks it)'>&#8630;</button></div>";});
415
415
  if(r.untasked)h+="<div class='muted' style='margin:6px 0'>Uncommitted changes with no ticked task behind them. Check the diff before you approve.</div>";
416
416
  if(r.running)h+="<div class='muted' style='margin:6px 0'>&#9203; Agent still working: its changes may be half done. Approve unlocks when it finishes.</div>";
417
+ var ur=r.unreleased;if(ur)h+="<div class='muted' style='margin:6px 0'><span class='err'>Unreleased:</span> <code>"+esc(ur.base)+"</code> is "+ur.ahead+" commit"+(ur.ahead>1?"s":"")+" past <code>"+esc(ur.tag)+"</code>, so merged work isn't released yet. "+(ur.bump?"These changes bump the version to <b>"+esc(ur.bump)+"</b>: after the PR merges, tag <code>v"+esc(ur.bump)+"</code> on <code>"+esc(ur.base)+"</code> to release it.":"Bump the version (here or in a later PR) and tag it to release it.")+"</div>";
417
418
  h+="<div class='row'><button class='act approve'"+(r.path&&!r.running?"":" disabled")+(r.running?" title='the agent is still editing this repo'>"+(r.untasked?"Approve changes without a task":"Approve")+" &middot; agent still working":r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task &rarr; PR":" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
418
419
  el.innerHTML=h;
419
420
  el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
@@ -633,10 +634,15 @@ if(typeof confirm!=='function'||!confirm(q))return;
633
634
  b.disabled=true;$('screenmsg').innerHTML="<div class='note muted'>"+(d?"Clicking “"+esc(r.label)+"” in "+d+"s &mdash; bring the right window to the front&hellip;":"Clicking&hellip;")+"</div>";
634
635
  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
636
  $('screenmsg').innerHTML="<div class='note ok'>&#10003; Clicked “"+esc(x.label)+"” at ("+x.x+", "+x.y+") with "+esc(x.via)+".</div>";}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
636
- function screenAdded(s){if(!s||s.error){screenErr((s&&s.error)||'failed');return;}$('screenmsg').innerHTML='';$('screenname').value='';SCREEN=s;SPEND=null;loadScreensUI();}
637
+ function screenAdded(s){if(!s||s.error){screenErr((s&&s.error)||'failed');if(s&&s.blocked)allowShotsBtn(s.blocked);return;}$('screenmsg').innerHTML='';$('screenname').value='';SCREEN=s;SPEND=null;loadScreensUI();}
637
638
  function captureUI(){var b=$('capture'),d=+($('screendelay').value||0);b.disabled=true;
638
639
  $('screenmsg').innerHTML="<div class='note muted'>"+(d?"Capturing in "+d+"s &mdash; bring the window you want to the front&hellip;":"Capturing&hellip;")+"</div>";
639
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));});}
641
+ // The desktop's screenshot permission is off for the app Symbiot was started
642
+ // from: turn it on (asked first, it covers every app started that way), then capture.
643
+ function allowShotsBtn(app){$('screenmsg').insertAdjacentHTML('beforeend',"<div class='row' style='margin-top:6px'><button class='act' id='scrallow'>Allow screenshots</button></div>");
644
+ $('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;
645
+ $('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
646
  function pickImageUI(){var f=$('screenfile');if(f&&f.click)f.click();}
641
647
  function importUI(){var f=$('screenfile'),file=f&&f.files&&f.files[0];if(!file)return;$('screenmsg').innerHTML="<div class='note muted'>Loading&hellip;</div>";
642
648
  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);}