@bivy/bivy 0.17.3-staging.30 → 0.17.3-staging.31
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/bin/bivy.mjs +39 -2
- package/dist/apps/display-viewer.js +74 -4
- package/dist/apps/gateway.js +55 -1
- package/dist/apps/macos-display.js +3 -2
- package/dist/apps/macos-display.swift +160 -13
- package/dist/apps/menu.js +76 -0
- package/dist/apps/service.js +24 -2
- package/dist/controllers/app-commands.js +1 -0
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +1 -0
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -2440,6 +2440,7 @@ async function cmdApp(args = []) {
|
|
|
2440
2440
|
bivy app scroll <x> <y> [--up|--down|--left|--right] [--steps <n>] [--app <app>]
|
|
2441
2441
|
bivy app drag <x> <y> <to-x> <to-y> [--app <app>]
|
|
2442
2442
|
bivy app move <x> <y> [--app <app>]
|
|
2443
|
+
bivy app menu ["File > Save"] [--app <app>] macOS: list the menu bar, or choose an item
|
|
2443
2444
|
|
|
2444
2445
|
Manifest: {"version":1,"name":"My app","views":[
|
|
2445
2446
|
{"kind":"web","name":"Website","source":{"kind":"service","port":3000}},
|
|
@@ -2469,6 +2470,8 @@ app (default: the one opened last). Keys: a character or enter, tab, escape,
|
|
|
2469
2470
|
backspace, delete, arrows (up…), home, end, pageup, pagedown, f1–f12, with
|
|
2470
2471
|
modifiers shift, ctrl, alt/option, cmd. On a Mac, the pointer and keyboard
|
|
2471
2472
|
focus move to the app while it's used.
|
|
2473
|
+
"menu" (macOS) lists the app's menu bar with shortcuts; "menu 'File > Save'"
|
|
2474
|
+
chooses an item — more reliable than clicking, and prints a screenshot after.
|
|
2472
2475
|
"present" says a change is ready for the user to look at: they get a card in
|
|
2473
2476
|
the chat with the app at phone width (and before/after), and it opens the live
|
|
2474
2477
|
preview. Use it when you finish a visible change, or when the user asks to see
|
|
@@ -2483,6 +2486,7 @@ Web previews require operator setup; see docs/apps.md.`);
|
|
|
2483
2486
|
return;
|
|
2484
2487
|
}
|
|
2485
2488
|
if (INPUT_ACTIONS[action]) return appInput(action, rest);
|
|
2489
|
+
if (action === "menu") return appMenu(rest);
|
|
2486
2490
|
if (!["publish", "list", "remove", "shot", "run", "present", "share"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
|
|
2487
2491
|
// `run -- <command> [args…]`: publish a one-window desktop app without a manifest file.
|
|
2488
2492
|
let runManifest;
|
|
@@ -2546,7 +2550,39 @@ Web previews require operator setup; see docs/apps.md.`);
|
|
|
2546
2550
|
await appRequest(action === "run" ? "publish" : action, body);
|
|
2547
2551
|
}
|
|
2548
2552
|
|
|
2549
|
-
async function
|
|
2553
|
+
async function appMenu(rest) {
|
|
2554
|
+
const options = {};
|
|
2555
|
+
const positional = [];
|
|
2556
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2557
|
+
if (rest[i] === "--app" || rest[i] === "--session") {
|
|
2558
|
+
const value = rest[++i];
|
|
2559
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`${rest[i - 1]} requires a value.`);
|
|
2560
|
+
options[rest[i - 1]] = value;
|
|
2561
|
+
} else if (rest[i].startsWith("--")) throw new Error(`Unknown option ${rest[i]}. Run bivy app --help.`);
|
|
2562
|
+
else positional.push(rest[i]);
|
|
2563
|
+
}
|
|
2564
|
+
if (positional.length > 1) throw new Error('Usage: bivy app menu ["File > Save"] [--app <app>]');
|
|
2565
|
+
const sessionId = resolveAttachSessionId({ sessionFlag: options["--session"], env: process.env });
|
|
2566
|
+
if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
|
|
2567
|
+
const body = { sessionId, ...(options["--app"] ? { target: options["--app"] } : {}) };
|
|
2568
|
+
if (positional[0]) body.path = positional[0].split(">").map((part) => part.trim());
|
|
2569
|
+
const result = await appRequest("menu", body, { print: false });
|
|
2570
|
+
if (!result.menus) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
2571
|
+
// A tree an agent can read: one item per line, indented by level.
|
|
2572
|
+
const lines = [];
|
|
2573
|
+
const walk = (items, depth) => {
|
|
2574
|
+
for (const item of items) {
|
|
2575
|
+
if (item.separator) continue;
|
|
2576
|
+
const note = [item.shortcut, item.checked && "checked", item.enabled === false && "disabled"].filter(Boolean).join(", ");
|
|
2577
|
+
lines.push(`${" ".repeat(depth)}${item.title}${note ? ` (${note})` : ""}`);
|
|
2578
|
+
if (item.items) walk(item.items, depth + 1);
|
|
2579
|
+
}
|
|
2580
|
+
};
|
|
2581
|
+
for (const menu of result.menus) { lines.push(menu.title); walk(menu.items, 1); }
|
|
2582
|
+
console.log(lines.length ? `${lines.join("\n")}\n\nChoose one with: bivy app menu "<Menu> > <Item>"` : "This app has no menus.");
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
async function appRequest(action, body, { print = true } = {}) {
|
|
2550
2586
|
const config = loadConfig();
|
|
2551
2587
|
if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
|
|
2552
2588
|
const token = await localDeviceToken(config);
|
|
@@ -2561,7 +2597,8 @@ async function appRequest(action, body) {
|
|
|
2561
2597
|
console.error("Anyone with this link can use the app, including its live backend, until it expires or you revoke it (Apps → Revoke access).");
|
|
2562
2598
|
return;
|
|
2563
2599
|
}
|
|
2564
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2600
|
+
if (print) console.log(JSON.stringify(result, null, 2));
|
|
2601
|
+
return result;
|
|
2565
2602
|
}
|
|
2566
2603
|
|
|
2567
2604
|
// Computer use in desktop apps: each command's positional arguments, and the
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
export const DISPLAY_SOCKET_PATH = "/__bivy/display";
|
|
4
4
|
export const DISPLAY_STATS_PATH = "/__bivy/display-stats";
|
|
5
|
+
export const DISPLAY_MENU_PATH = "/__bivy/display-menu";
|
|
5
6
|
export const NOVNC_PATH = "/__bivy/novnc/";
|
|
6
7
|
/** The modifier apps paste with, as a keysym and noVNC key code: ⌘ on a Mac. */
|
|
7
8
|
const PASTE_MODIFIER = { darwin: [0xffeb, "MetaLeft"] };
|
|
@@ -9,7 +10,8 @@ const PASTE_MODIFIER = { darwin: [0xffeb, "MetaLeft"] };
|
|
|
9
10
|
* the app's display. It asks the display to match its size in device pixels
|
|
10
11
|
* (`scale` is what the display was started at), scales down when the display
|
|
11
12
|
* is larger, reconnects while the app starts, carries the clipboard both ways
|
|
12
|
-
* on a tap, gives phones a keyboard,
|
|
13
|
+
* on a tap, gives phones a keyboard, offers the app's menu bar where the
|
|
14
|
+
* display can read it (macOS), and reports stream latency to Bivy. */
|
|
13
15
|
export function displayViewer(nonce, name, scale, platform = process.platform) {
|
|
14
16
|
const [modifier, modifierCode] = PASTE_MODIFIER[platform] ?? [0xffe3, "ControlLeft"];
|
|
15
17
|
const title = name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
@@ -20,11 +22,24 @@ export function displayViewer(nonce, name, scale, platform = process.platform) {
|
|
|
20
22
|
button,#paste{font:inherit;font-size:14px;min-height:44px;padding:0 14px;border-radius:22px;border:1px solid GrayText;background:Canvas;color:CanvasText}
|
|
21
23
|
#keys{display:none;min-width:44px;padding:0}@media (pointer:coarse){#keys{display:block}}
|
|
22
24
|
#paste{display:flex;gap:8px;align-items:center;padding:4px 4px 4px 14px}#paste input{font:inherit;min-height:36px;border:0;background:transparent;color:inherit;width:12em}
|
|
23
|
-
#typing{position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0}
|
|
25
|
+
#typing{position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0}
|
|
26
|
+
#menu{position:fixed;left:12px;bottom:72px;width:min(22rem,calc(100vw - 24px));max-height:min(70vh,36rem);display:flex;flex-direction:column;background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:16px;box-shadow:0 8px 24px color-mix(in srgb,CanvasText 25%,transparent);overflow:hidden}
|
|
27
|
+
#menu header{display:flex;align-items:center;gap:4px;padding:4px;border-bottom:1px solid GrayText}
|
|
28
|
+
#menu header button{border:0;min-width:44px;padding:0;font-size:18px}
|
|
29
|
+
#menu h2{flex:1;margin:0;padding:0 8px;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
30
|
+
#menu-status{margin:0;padding:12px 16px;color:GrayText;font-size:14px}#menu-status:empty{display:none}
|
|
31
|
+
#menu ul{list-style:none;margin:0;padding:4px 0;overflow:auto;overscroll-behavior:contain}
|
|
32
|
+
#menu li[role=separator]{border-top:1px solid GrayText;margin:4px 16px;opacity:.5}
|
|
33
|
+
#menu li button{display:flex;align-items:center;gap:8px;width:100%;border:0;border-radius:0;padding:0 16px;text-align:start}
|
|
34
|
+
#menu li button:hover:not(:disabled),#menu li button:focus-visible{background:Highlight;color:HighlightText;outline:none}
|
|
35
|
+
#menu li button:disabled{color:GrayText}
|
|
36
|
+
#menu .mark{width:1em;flex:none}#menu .label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#menu .keys{flex:none;opacity:.7;font-variant-numeric:tabular-nums}</style>
|
|
24
37
|
<div id="screen"></div><p id="status" role="status">Starting the app…</p>
|
|
25
|
-
<div id="tools"><button id="copy" type="button" hidden>Copy from app</button>
|
|
38
|
+
<div id="tools"><button id="menu-open" type="button" aria-haspopup="menu" aria-controls="menu" aria-expanded="false" hidden>Menu</button><button id="copy" type="button" hidden>Copy from app</button>
|
|
26
39
|
<form id="paste" hidden><input id="paste-text" aria-label="Text to paste into the app" placeholder="Paste here"><button type="submit">Send</button></form>
|
|
27
40
|
<button id="paste-open" type="button">Paste</button><button id="keys" type="button" aria-label="Show keyboard">⌨</button></div>
|
|
41
|
+
<div id="menu" role="dialog" aria-labelledby="menu-title" hidden><header><button id="menu-back" type="button" aria-label="Back">‹</button><h2 id="menu-title">Menu</h2><button id="menu-close" type="button" aria-label="Close menu">✕</button></header>
|
|
42
|
+
<p id="menu-status" role="status"></p><ul id="menu-items" role="menu" aria-labelledby="menu-title"></ul></div>
|
|
28
43
|
<input id="typing" aria-label="Type into the app" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false">
|
|
29
44
|
<script type="module" nonce="${nonce}">import RFB from "${NOVNC_PATH}core/rfb.js";
|
|
30
45
|
const $=id=>document.getElementById(id),screen=$("screen"),status=$("status"),typing=$("typing"),SCALE=${scale === 2 ? 2 : 1};
|
|
@@ -57,7 +72,7 @@ function connect(){
|
|
|
57
72
|
rfb._screenSize=()=>{const s=measure();return{w:Math.round(s.w*ask),h:Math.round(s.h*ask)};};
|
|
58
73
|
rfb._requestRemoteResize=()=>{ask=SCALE;try{request();}finally{ask=1;}};
|
|
59
74
|
rfb.resizeSession=true;rfb.scaleViewport=true;rfb.focusOnClick=true;
|
|
60
|
-
rfb.addEventListener("connect",()=>{wait=500;say("");});
|
|
75
|
+
rfb.addEventListener("connect",()=>{wait=500;say("");hasMenu();});
|
|
61
76
|
rfb.addEventListener("disconnect",()=>{say("Starting the app…");setTimeout(connect,wait);wait=Math.min(wait*2,5000);});
|
|
62
77
|
// The app copied text: offer it; the browser only lets a tap write the clipboard.
|
|
63
78
|
rfb.addEventListener("clipboard",e=>{remote=e.detail.text;$("copy").hidden=!remote;$("copy").textContent="Copy from app";});
|
|
@@ -75,6 +90,61 @@ $("paste-open").onclick=async()=>{
|
|
|
75
90
|
$("paste").onsubmit=e=>{e.preventDefault();paste($("paste-text").value);$("paste-text").value="";$("paste").hidden=true;};
|
|
76
91
|
$("paste-text").addEventListener("keydown",e=>{if(e.key==="Escape"){$("paste").hidden=true;rfb.focus();}});
|
|
77
92
|
|
|
93
|
+
// The app's menu bar, read when opened so enabled and checked states are
|
|
94
|
+
// current. Drill down through submenus; choosing an item closes the menu.
|
|
95
|
+
// Titles come from the app: shown as text only.
|
|
96
|
+
const MENU="${DISPLAY_MENU_PATH}",menu=$("menu"),items=$("menu-items"),menuStatus=$("menu-status");
|
|
97
|
+
let stack=[];
|
|
98
|
+
const hasMenu=()=>fetch(MENU,{method:"HEAD"}).then(r=>{$("menu-open").hidden=!r.ok;}).catch(()=>{});
|
|
99
|
+
const setOpen=open=>{menu.hidden=!open;$("tools").hidden=open;$("menu-open").setAttribute("aria-expanded",String(open));};
|
|
100
|
+
const closeMenu=()=>{setOpen(false);stack=[];rfb.focus();};
|
|
101
|
+
const cell=(cls,text)=>{const span=document.createElement("span");span.className=cls;span.textContent=text;return span;};
|
|
102
|
+
function show(){
|
|
103
|
+
const level=stack[stack.length-1];
|
|
104
|
+
$("menu-title").textContent=level.title;$("menu-back").hidden=stack.length<2;
|
|
105
|
+
items.replaceChildren(...level.items.map(item=>{
|
|
106
|
+
const li=document.createElement("li");
|
|
107
|
+
if(item.separator){li.setAttribute("role","separator");return li;}
|
|
108
|
+
li.setAttribute("role","none");
|
|
109
|
+
const button=document.createElement("button");
|
|
110
|
+
button.type="button";button.setAttribute("role",item.checked?"menuitemcheckbox":"menuitem");
|
|
111
|
+
if(item.checked)button.setAttribute("aria-checked","true");
|
|
112
|
+
button.disabled=item.enabled===false;
|
|
113
|
+
button.append(cell("mark",item.checked?"✓":""),cell("label",item.title));
|
|
114
|
+
if(item.items){button.setAttribute("aria-haspopup","menu");button.append(cell("keys","›"));button.onclick=()=>{stack.push({title:item.title,items:item.items,path:[...level.path,item.title]});show();};}
|
|
115
|
+
else{if(item.shortcut){button.append(cell("keys",item.shortcut));button.setAttribute("aria-keyshortcuts",item.shortcut);}button.onclick=()=>choose([...level.path,item.title]);}
|
|
116
|
+
li.append(button);return li;
|
|
117
|
+
}));
|
|
118
|
+
items.querySelector("button:not(:disabled)")?.focus();
|
|
119
|
+
}
|
|
120
|
+
async function choose(path){
|
|
121
|
+
menuStatus.textContent="Choosing "+path.join(" › ")+"…";
|
|
122
|
+
try{
|
|
123
|
+
const r=await fetch(MENU,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path})});
|
|
124
|
+
if(!r.ok)throw new Error((await r.json().catch(()=>({}))).error||"The app didn’t take it.");
|
|
125
|
+
menuStatus.textContent="";closeMenu();
|
|
126
|
+
}catch(e){menuStatus.textContent=e.message;}
|
|
127
|
+
}
|
|
128
|
+
$("menu-open").onclick=async()=>{
|
|
129
|
+
stack=[];items.replaceChildren();setOpen(true);$("menu-back").hidden=true;$("menu-title").textContent="Menu";
|
|
130
|
+
menuStatus.textContent="Reading the app’s menus…";$("menu-close").focus();
|
|
131
|
+
try{
|
|
132
|
+
const r=await fetch(MENU);const body=await r.json();
|
|
133
|
+
if(!r.ok)throw new Error(body.error||"Couldn’t read the app’s menus.");
|
|
134
|
+
menuStatus.textContent=body.menus.length?"":"This app has no menus.";
|
|
135
|
+
stack=[{title:"Menu",items:body.menus.map(m=>({title:m.title,enabled:true,items:m.items})),path:[]}];show();
|
|
136
|
+
}catch(e){menuStatus.textContent=e.message;}
|
|
137
|
+
};
|
|
138
|
+
$("menu-close").onclick=closeMenu;
|
|
139
|
+
$("menu-back").onclick=()=>{stack.pop();show();};
|
|
140
|
+
menu.addEventListener("keydown",e=>{
|
|
141
|
+
if(e.key==="Escape"){e.preventDefault();if(stack.length>1){stack.pop();show();}else closeMenu();return;}
|
|
142
|
+
if(e.key!=="ArrowDown"&&e.key!=="ArrowUp")return;
|
|
143
|
+
const all=[...items.querySelectorAll("button:not(:disabled)")];if(!all.length)return;
|
|
144
|
+
e.preventDefault();const at=all.indexOf(document.activeElement);
|
|
145
|
+
all[(at+(e.key==="ArrowDown"?1:-1)+all.length)%all.length].focus();
|
|
146
|
+
});
|
|
147
|
+
|
|
78
148
|
// Phones: a soft keyboard needs a text field; forward what's typed as keys.
|
|
79
149
|
const SPECIAL={Backspace:0xff08,Enter:0xff0d,Tab:0xff09,Escape:0xff1b,ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54};
|
|
80
150
|
$("keys").onclick=()=>typing.focus();
|
package/dist/apps/gateway.js
CHANGED
|
@@ -9,7 +9,8 @@ import { readFileSync } from "node:fs";
|
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
import { WebSocketServer } from "ws";
|
|
11
11
|
import { previewShell } from "./preview-shell.js";
|
|
12
|
-
import { displayViewer, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
|
|
12
|
+
import { displayViewer, DISPLAY_MENU_PATH, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
|
|
13
|
+
import { pressMenu, readMenuPath, readMenus } from "./menu.js";
|
|
13
14
|
import { inspectorScript } from "./inspector.js";
|
|
14
15
|
const MIME = {
|
|
15
16
|
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
|
|
@@ -601,6 +602,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
601
602
|
res.end();
|
|
602
603
|
return;
|
|
603
604
|
}
|
|
605
|
+
if (req.url === DISPLAY_MENU_PATH) {
|
|
606
|
+
await this.displayMenu(req, res, entry);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
604
609
|
if (!["GET", "HEAD"].includes(req.method ?? "")) {
|
|
605
610
|
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
606
611
|
res.end();
|
|
@@ -629,6 +634,55 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
629
634
|
res.writeHead(200, { ...headers, "content-type": "text/html; charset=utf-8", "content-length": body.length });
|
|
630
635
|
res.end(req.method === "HEAD" ? undefined : body);
|
|
631
636
|
}
|
|
637
|
+
/** The app's menu bar for the viewer: HEAD says whether it has one, GET
|
|
638
|
+
* reads it, POST {path} chooses an item (same-origin, checked above). */
|
|
639
|
+
async displayMenu(req, res, entry) {
|
|
640
|
+
const control = entry.displayControl;
|
|
641
|
+
const json = (status, body) => { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(body)); };
|
|
642
|
+
if (req.method === "HEAD") {
|
|
643
|
+
res.writeHead(control ? 204 : 404);
|
|
644
|
+
res.end();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (!control) {
|
|
648
|
+
json(404, { error: "This app's menus aren't available." });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
if (req.method === "GET") {
|
|
653
|
+
json(200, { menus: await readMenus(control) });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (req.method !== "POST") {
|
|
657
|
+
res.writeHead(405, { Allow: "GET, HEAD, POST" });
|
|
658
|
+
res.end();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
let body = "";
|
|
662
|
+
for await (const chunk of req) {
|
|
663
|
+
body += chunk.toString();
|
|
664
|
+
if (body.length > 4096) {
|
|
665
|
+
res.writeHead(413);
|
|
666
|
+
res.end();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
let path;
|
|
671
|
+
try {
|
|
672
|
+
path = readMenuPath(JSON.parse(body).path);
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
json(400, { error: error.message });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
await pressMenu(control, path);
|
|
679
|
+
res.writeHead(204);
|
|
680
|
+
res.end();
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
json(409, { error: error.message });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
632
686
|
/** Relays a viewer's WebSocket to the display's private VNC socket. */
|
|
633
687
|
displaySocket(req, socket, head, entry) {
|
|
634
688
|
this.displays.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -119,8 +119,9 @@ export class MacDisplayHost {
|
|
|
119
119
|
throw new Error(missing);
|
|
120
120
|
}
|
|
121
121
|
const socket = path.join(state.dir, "vnc.sock");
|
|
122
|
+
const control = path.join(state.dir, "control.sock");
|
|
122
123
|
const token = randomBytes(16).toString("hex");
|
|
123
|
-
const child = spawn(binary, ["serve", socket, token, String(scale)], { stdio: ["ignore", "pipe", "pipe"] });
|
|
124
|
+
const child = spawn(binary, ["serve", socket, control, token, String(scale)], { stdio: ["ignore", "pipe", "pipe"] });
|
|
124
125
|
state.child = child;
|
|
125
126
|
const wm = { count: 0 };
|
|
126
127
|
let errors = "";
|
|
@@ -141,7 +142,7 @@ export class MacDisplayHost {
|
|
|
141
142
|
});
|
|
142
143
|
if (!up)
|
|
143
144
|
throw new Error(`Couldn't start a display for this app.${errors ? ` ${errors.trim().split("\n").at(-1)}` : ""}`);
|
|
144
|
-
return { socket, wm, scale, env: { BIVY_MAC_DISPLAY: token }, launch: [binary, "run", "--"] };
|
|
145
|
+
return { socket, control, wm, scale, env: { BIVY_MAC_DISPLAY: token }, launch: [binary, "run", "--"] };
|
|
145
146
|
}
|
|
146
147
|
stop(id) {
|
|
147
148
|
const state = this.displays.get(id);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// screenshots work unchanged.
|
|
8
8
|
//
|
|
9
9
|
// macos-display check [--prompt] permissions, as JSON
|
|
10
|
-
// macos-display serve <socket> <token> <scale>
|
|
10
|
+
// macos-display serve <socket> <control> <token> <scale>
|
|
11
11
|
// macos-display run -- <command> [args…] starts the app (see below)
|
|
12
12
|
//
|
|
13
13
|
// The app is found by its environment: Bivy starts it through `run` with
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
// ScreenCaptureKit (Screen Recording permission); clicks, keys and window
|
|
19
19
|
// sizing go through Accessibility. Keys are sent to the app only; clicks land
|
|
20
20
|
// only when the app's window is the one under the pointer.
|
|
21
|
+
//
|
|
22
|
+
// The control socket answers one JSON line per connection: {"menu":true}
|
|
23
|
+
// returns the app's menu bar, read through Accessibility; {"press":["File",
|
|
24
|
+
// "Save"]} presses the item at that path. The app's own full screen is
|
|
25
|
+
// switched off: the preview already sizes it to the viewer.
|
|
21
26
|
import AppKit
|
|
22
27
|
import ApplicationServices
|
|
23
28
|
import CoreMedia
|
|
@@ -142,6 +147,103 @@ func axWindow(_ win: Win) -> AXUIElement? {
|
|
|
142
147
|
} ?? axValue(app, kAXMainWindowAttribute)
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
/// A listening Unix socket only this user can connect to.
|
|
151
|
+
func listening(_ path: String) -> Int32 {
|
|
152
|
+
unlink(path)
|
|
153
|
+
let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
|
|
154
|
+
var addr = sockaddr_un()
|
|
155
|
+
addr.sun_family = sa_family_t(AF_UNIX)
|
|
156
|
+
withUnsafeMutableBytes(of: &addr.sun_path) { buf in _ = path.withCString { strncpy(buf.baseAddress!.assumingMemoryBound(to: CChar.self), $0, buf.count - 1) } }
|
|
157
|
+
let old = umask(0o177)
|
|
158
|
+
let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) } }
|
|
159
|
+
umask(old)
|
|
160
|
+
guard bound == 0, chmod(path, 0o600) == 0, Darwin.listen(fd, 16) == 0 else { warn("Couldn't listen on \(path)"); exit(1) }
|
|
161
|
+
return fd
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// MARK: - Menus
|
|
165
|
+
|
|
166
|
+
/// Menus deeper than this, or past this many items, are left out.
|
|
167
|
+
let MENU_DEPTH = 4, MENU_ITEMS = 3000
|
|
168
|
+
/// How a menu item's shortcut modifiers are written (AXMenuItemCmdModifiers
|
|
169
|
+
/// bits: 1 Shift, 2 Option, 4 Control, 8 no Command).
|
|
170
|
+
let SHORTCUT_MODIFIERS: [(bit: Int, symbol: String)] = [(4, "⌃"), (2, "⌥"), (1, "⇧")]
|
|
171
|
+
|
|
172
|
+
func shortcut(_ char: String?, _ modifiers: Int?) -> String? {
|
|
173
|
+
guard let char, !char.isEmpty, let modifiers else { return nil }
|
|
174
|
+
let symbols = SHORTCUT_MODIFIERS.filter { modifiers & $0.bit != 0 }.map(\.symbol).joined()
|
|
175
|
+
return symbols + (modifiers & 8 == 0 ? "⌘" : "") + char.uppercased()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// The app's menu bar as data: titles, enabled, checked, shortcut, submenus.
|
|
179
|
+
/// The Apple menu is left out; the app menu (Settings, Quit) stays.
|
|
180
|
+
func readMenus(_ pid: pid_t) -> [[String: Any]] {
|
|
181
|
+
let app = AXUIElementCreateApplication(pid)
|
|
182
|
+
AXUIElementSetMessagingTimeout(app, 1)
|
|
183
|
+
guard let bar: AXUIElement = axValue(app, kAXMenuBarAttribute) else { return [] }
|
|
184
|
+
var budget = MENU_ITEMS
|
|
185
|
+
let keys = [kAXTitleAttribute, kAXEnabledAttribute, "AXMenuItemCmdChar", "AXMenuItemCmdModifiers", "AXMenuItemMarkChar", kAXChildrenAttribute] as CFArray
|
|
186
|
+
func items(_ menu: AXUIElement, depth: Int) -> [[String: Any]] {
|
|
187
|
+
var out: [[String: Any]] = []
|
|
188
|
+
for item in (axValue(menu, kAXChildrenAttribute) as [AXUIElement]?) ?? [] {
|
|
189
|
+
guard budget > 0 else { break }
|
|
190
|
+
budget -= 1
|
|
191
|
+
var values: CFArray?
|
|
192
|
+
guard AXUIElementCopyMultipleAttributeValues(item, keys, AXCopyMultipleAttributeOptions(), &values) == .success, let v = values as? [Any] else { continue }
|
|
193
|
+
let title = (v[0] as? String) ?? ""
|
|
194
|
+
if title.isEmpty {
|
|
195
|
+
if let last = out.last, last["separator"] == nil { out.append(["separator": true]) }
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
var entry: [String: Any] = ["title": title, "enabled": (v[1] as? Bool) ?? false]
|
|
199
|
+
if let keys = shortcut(v[2] as? String, v[3] as? Int) { entry["shortcut"] = keys }
|
|
200
|
+
if let mark = v[4] as? String, !mark.isEmpty { entry["checked"] = true }
|
|
201
|
+
if depth < MENU_DEPTH, let submenu = (v[5] as? [AXUIElement])?.first {
|
|
202
|
+
let children = items(submenu, depth: depth + 1)
|
|
203
|
+
if !children.isEmpty { entry["items"] = children }
|
|
204
|
+
}
|
|
205
|
+
out.append(entry)
|
|
206
|
+
}
|
|
207
|
+
if out.last?["separator"] != nil { out.removeLast() }
|
|
208
|
+
return out
|
|
209
|
+
}
|
|
210
|
+
let tops: [AXUIElement] = axValue(bar, kAXChildrenAttribute) ?? []
|
|
211
|
+
return tops.dropFirst().compactMap { top in
|
|
212
|
+
guard let title: String = axValue(top, kAXTitleAttribute), !title.isEmpty, let menu = (axValue(top, kAXChildrenAttribute) as [AXUIElement]?)?.first else { return nil }
|
|
213
|
+
return ["title": title, "items": items(menu, depth: 1)]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// Presses the menu item at a path of titles, like choosing it from the menu bar.
|
|
218
|
+
func pressMenu(_ pid: pid_t, _ path: [String]) -> String? {
|
|
219
|
+
let app = AXUIElementCreateApplication(pid)
|
|
220
|
+
AXUIElementSetMessagingTimeout(app, 1)
|
|
221
|
+
guard var node: AXUIElement = axValue(app, kAXMenuBarAttribute) else { return "The app has no menu bar." }
|
|
222
|
+
for (depth, title) in path.enumerated() {
|
|
223
|
+
// Below the bar, each level's items sit in a menu element.
|
|
224
|
+
let parent = depth == 0 ? node : ((axValue(node, kAXChildrenAttribute) as [AXUIElement]?)?.first ?? node)
|
|
225
|
+
let children: [AXUIElement] = axValue(parent, kAXChildrenAttribute) ?? []
|
|
226
|
+
guard let next = children.dropFirst(depth == 0 ? 1 : 0).first(where: { (axValue($0, kAXTitleAttribute) as String?) == title }) else {
|
|
227
|
+
return "No menu item \"\(path[0...depth].joined(separator: " > "))\"."
|
|
228
|
+
}
|
|
229
|
+
node = next
|
|
230
|
+
}
|
|
231
|
+
if (axValue(node, kAXEnabledAttribute) as Bool?) == false { return "\"\(path.joined(separator: " > "))\" is disabled right now." }
|
|
232
|
+
AXUIElementSetAttributeValue(app, kAXFrontmostAttribute as CFString, kCFBooleanTrue)
|
|
233
|
+
return AXUIElementPerformAction(node, kAXPressAction as CFString) == .success ? nil : "The app didn't accept the menu item."
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// Switches the app's own full screen off, window by window. True if any was.
|
|
237
|
+
func leaveFullScreen(_ pid: pid_t) -> Bool {
|
|
238
|
+
let app = AXUIElementCreateApplication(pid)
|
|
239
|
+
AXUIElementSetMessagingTimeout(app, 1)
|
|
240
|
+
var left = false
|
|
241
|
+
for window in (axValue(app, kAXWindowsAttribute) as [AXUIElement]?) ?? [] where (axValue(window, "AXFullScreen") as Bool?) == true {
|
|
242
|
+
left = AXUIElementSetAttributeValue(window, "AXFullScreen" as CFString, kCFBooleanFalse) == .success || left
|
|
243
|
+
}
|
|
244
|
+
return left
|
|
245
|
+
}
|
|
246
|
+
|
|
145
247
|
// MARK: - Keys
|
|
146
248
|
|
|
147
249
|
/// US-layout key codes for the characters a keysym can name.
|
|
@@ -275,6 +377,13 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
|
|
|
275
377
|
var wanted: (ids: [CGWindowID], region: CGRect)?
|
|
276
378
|
var captured: (ids: [CGWindowID], region: CGRect)?
|
|
277
379
|
var acceptor: DispatchSourceRead?
|
|
380
|
+
/// Menus and full screen are Accessibility round trips to the app: off the
|
|
381
|
+
/// frame queue, one at a time.
|
|
382
|
+
let ax = DispatchQueue(label: "sh.bivy.macos-display.ax")
|
|
383
|
+
var control: DispatchSourceRead?
|
|
384
|
+
/// The app's processes, including ones whose windows aren't on screen now
|
|
385
|
+
/// (a full-screen window in another Space).
|
|
386
|
+
var apps = Set<pid_t>()
|
|
278
387
|
/// The last capture error, and when: retried after a pause, reported once.
|
|
279
388
|
var failure = (message: "", at: 0.0)
|
|
280
389
|
|
|
@@ -300,6 +409,7 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
|
|
|
300
409
|
let all = onScreenWindows()
|
|
301
410
|
known = known.filter { entry in all.contains { $0.pid == entry.key } }
|
|
302
411
|
ours = all.filter { !SYSTEM_LAYERS.contains($0.layer) && owns($0.pid) }
|
|
412
|
+
apps.formUnion(ours.map(\.pid))
|
|
303
413
|
let normal = ours.filter { $0.layer == 0 }
|
|
304
414
|
if normal.count != count { count = normal.count; say("windows \(count)") }
|
|
305
415
|
main = normal.max { $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height }
|
|
@@ -400,15 +510,7 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
|
|
|
400
510
|
// MARK: Serving viewers
|
|
401
511
|
|
|
402
512
|
func listen(path: String) {
|
|
403
|
-
|
|
404
|
-
let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
|
|
405
|
-
var addr = sockaddr_un()
|
|
406
|
-
addr.sun_family = sa_family_t(AF_UNIX)
|
|
407
|
-
withUnsafeMutableBytes(of: &addr.sun_path) { buf in _ = path.withCString { strncpy(buf.baseAddress!.assumingMemoryBound(to: CChar.self), $0, buf.count - 1) } }
|
|
408
|
-
let old = umask(0o177)
|
|
409
|
-
let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) } }
|
|
410
|
-
umask(old)
|
|
411
|
-
guard bound == 0, chmod(path, 0o600) == 0, Darwin.listen(fd, 16) == 0 else { warn("Couldn't listen on \(path)"); exit(1) }
|
|
513
|
+
let fd = listening(path)
|
|
412
514
|
let accept = DispatchSource.makeReadSource(fileDescriptor: fd, queue: q)
|
|
413
515
|
accept.setEventHandler { [self] in
|
|
414
516
|
let peer = Darwin.accept(fd, nil, nil)
|
|
@@ -429,6 +531,50 @@ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendabl
|
|
|
429
531
|
acceptor = accept
|
|
430
532
|
}
|
|
431
533
|
|
|
534
|
+
/// The control socket: one request line, one JSON answer, then closed.
|
|
535
|
+
func serveControl(path: String) {
|
|
536
|
+
let fd = listening(path)
|
|
537
|
+
let accept = DispatchSource.makeReadSource(fileDescriptor: fd, queue: ax)
|
|
538
|
+
accept.setEventHandler { [self] in
|
|
539
|
+
let peer = Darwin.accept(fd, nil, nil)
|
|
540
|
+
guard peer >= 0 else { return }
|
|
541
|
+
defer { Darwin.close(peer) }
|
|
542
|
+
var wait = timeval(tv_sec: 5, tv_usec: 0), on: Int32 = 1
|
|
543
|
+
setsockopt(peer, SOL_SOCKET, SO_RCVTIMEO, &wait, socklen_t(MemoryLayout<timeval>.size))
|
|
544
|
+
setsockopt(peer, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
|
|
545
|
+
var line: [UInt8] = []
|
|
546
|
+
var byte: UInt8 = 0
|
|
547
|
+
while line.count < 16384, Darwin.read(peer, &byte, 1) == 1, byte != 10 { line.append(byte) }
|
|
548
|
+
let answer = respond(to: (try? JSONSerialization.jsonObject(with: Data(line))) as? [String: Any] ?? [:])
|
|
549
|
+
let data = ((try? JSONSerialization.data(withJSONObject: answer)) ?? Data()) + Data([10])
|
|
550
|
+
_ = data.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, $0.count) }
|
|
551
|
+
}
|
|
552
|
+
accept.resume()
|
|
553
|
+
control = accept
|
|
554
|
+
// Full screen is checked here too, so it never holds up frames.
|
|
555
|
+
let timer = DispatchSource.makeTimerSource(queue: ax)
|
|
556
|
+
timer.schedule(deadline: .now() + 1, repeating: .seconds(1))
|
|
557
|
+
timer.setEventHandler { [self] in
|
|
558
|
+
let pids = q.sync { apps = apps.filter { kill($0, 0) == 0 }; return apps }
|
|
559
|
+
guard pids.contains(where: leaveFullScreen) else { return }
|
|
560
|
+
// Once it has animated back, fit it to the viewer again.
|
|
561
|
+
q.asyncAfter(deadline: .now() + 1) { self.fitted = nil }
|
|
562
|
+
}
|
|
563
|
+
timer.resume()
|
|
564
|
+
fullScreenTimer = timer
|
|
565
|
+
}
|
|
566
|
+
var fullScreenTimer: DispatchSourceTimer?
|
|
567
|
+
|
|
568
|
+
func respond(to request: [String: Any]) -> [String: Any] {
|
|
569
|
+
guard let pid = q.sync(execute: { main?.pid }) else { return ["error": "The app has no window yet."] }
|
|
570
|
+
if request["menu"] as? Bool == true { return ["menus": readMenus(pid)] }
|
|
571
|
+
if let path = request["press"] as? [String], (1...8).contains(path.count) {
|
|
572
|
+
if let error = pressMenu(pid, path) { return ["error": error] }
|
|
573
|
+
return ["ok": true]
|
|
574
|
+
}
|
|
575
|
+
return ["error": "Unknown request."]
|
|
576
|
+
}
|
|
577
|
+
|
|
432
578
|
func drop(_ client: Client) {
|
|
433
579
|
guard let index = clients.firstIndex(where: { $0 === client }) else { return }
|
|
434
580
|
clients.remove(at: index)
|
|
@@ -711,9 +857,10 @@ case "run" where args.count > 3 && args[2] == "--":
|
|
|
711
857
|
run(Array(args[3...]))
|
|
712
858
|
case "check":
|
|
713
859
|
say(permissions(prompt: args.contains("--prompt")))
|
|
714
|
-
case "serve" where args.count ==
|
|
715
|
-
let host = Host(token: args[
|
|
860
|
+
case "serve" where args.count == 6:
|
|
861
|
+
let host = Host(token: args[4], scale: CGFloat(Double(args[5]) == 2 ? 2 : 1))
|
|
716
862
|
host.q.sync { host.listen(path: args[2]) }
|
|
863
|
+
host.serveControl(path: args[3])
|
|
717
864
|
let timer = DispatchSource.makeTimerSource(queue: host.q)
|
|
718
865
|
timer.schedule(deadline: .now(), repeating: .milliseconds(200))
|
|
719
866
|
timer.setEventHandler { host.tick() }
|
|
@@ -721,6 +868,6 @@ case "serve" where args.count == 5:
|
|
|
721
868
|
say("ready")
|
|
722
869
|
RunLoop.main.run()
|
|
723
870
|
default:
|
|
724
|
-
warn("Usage: macos-display check [--prompt] | serve <socket> <token> <scale> | run -- <command> [args…]")
|
|
871
|
+
warn("Usage: macos-display check [--prompt] | serve <socket> <control> <token> <scale> | run -- <command> [args…]")
|
|
725
872
|
exit(2)
|
|
726
873
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
const MAX_ITEMS = 3000;
|
|
5
|
+
const MAX_DEPTH = 4;
|
|
6
|
+
const TIMEOUT_MS = 5_000;
|
|
7
|
+
/** A path of titles, top menu first: "File > Export…" or ["File", "Export…"]. */
|
|
8
|
+
export function readMenuPath(input) {
|
|
9
|
+
const path = typeof input === "string" ? input.split(">").map((part) => part.trim()) : input;
|
|
10
|
+
if (!Array.isArray(path) || !path.length || path.length > 8 || path.some((part) => typeof part !== "string" || !part || part.length > 200)) {
|
|
11
|
+
throw new Error("A menu item is a path of titles, like \"File > Save\".");
|
|
12
|
+
}
|
|
13
|
+
return path;
|
|
14
|
+
}
|
|
15
|
+
/** Keeps what the display says to known fields and bounded sizes: the menu
|
|
16
|
+
* comes from the app, so it is data to show, never markup. */
|
|
17
|
+
function clean(items, depth, budget) {
|
|
18
|
+
if (!Array.isArray(items))
|
|
19
|
+
return [];
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const raw of items) {
|
|
22
|
+
if (budget.left-- <= 0)
|
|
23
|
+
break;
|
|
24
|
+
const item = raw;
|
|
25
|
+
if (item?.separator === true) {
|
|
26
|
+
out.push({ separator: true });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (typeof item?.title !== "string" || !item.title)
|
|
30
|
+
continue;
|
|
31
|
+
const entry = { title: item.title.slice(0, 200), enabled: item.enabled === true };
|
|
32
|
+
if (typeof item.shortcut === "string" && item.shortcut.length <= 20)
|
|
33
|
+
entry.shortcut = item.shortcut;
|
|
34
|
+
if (item.checked === true)
|
|
35
|
+
entry.checked = true;
|
|
36
|
+
const children = depth < MAX_DEPTH ? clean(item.items, depth + 1, budget) : [];
|
|
37
|
+
if (children.length)
|
|
38
|
+
entry.items = children;
|
|
39
|
+
out.push(entry);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** One request to a display's control socket, one JSON answer. */
|
|
44
|
+
function ask(control, request) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const socket = net.connect(control);
|
|
47
|
+
let data = "";
|
|
48
|
+
socket.setTimeout(TIMEOUT_MS, () => socket.destroy(new Error("The app didn't answer in time.")));
|
|
49
|
+
socket.on("data", (chunk) => { data += chunk; if (data.length > 4 * 1024 * 1024)
|
|
50
|
+
socket.destroy(new Error("The app's menu is too large.")); });
|
|
51
|
+
socket.on("error", (error) => reject(error.code ? new Error("The app isn't running.") : error));
|
|
52
|
+
socket.on("end", () => {
|
|
53
|
+
try {
|
|
54
|
+
const answer = JSON.parse(data);
|
|
55
|
+
if (typeof answer.error === "string")
|
|
56
|
+
reject(new Error(answer.error.slice(0, 300)));
|
|
57
|
+
else
|
|
58
|
+
resolve(answer);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
reject(new Error("The app's display gave an unreadable answer."));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
socket.end(`${JSON.stringify(request)}\n`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** The app's menu bar, read now, so enabled and checked states are current. */
|
|
68
|
+
export async function readMenus(control) {
|
|
69
|
+
const answer = await ask(control, { menu: true });
|
|
70
|
+
const budget = { left: MAX_ITEMS };
|
|
71
|
+
return (Array.isArray(answer.menus) ? answer.menus : []).flatMap((menu) => typeof menu?.title === "string" && menu.title ? [{ title: menu.title.slice(0, 200), items: clean(menu.items, 1, budget) }] : []);
|
|
72
|
+
}
|
|
73
|
+
/** Chooses the menu item at `path`, as if picked from the menu bar. */
|
|
74
|
+
export async function pressMenu(control, path) {
|
|
75
|
+
await ask(control, { press: path });
|
|
76
|
+
}
|
package/dist/apps/service.js
CHANGED
|
@@ -8,6 +8,7 @@ import { takeShots } from "./screenshot.js";
|
|
|
8
8
|
import { approximate, composite, readStrokes } from "./annotate.js";
|
|
9
9
|
import { captureFrame, encodePng, sendInput } from "./rfb.js";
|
|
10
10
|
import { inputEvents, readAction } from "./input.js";
|
|
11
|
+
import { pressMenu, readMenuPath, readMenus } from "./menu.js";
|
|
11
12
|
import { pngSize } from "./review.js";
|
|
12
13
|
const NO_REVIEWS = { publish: (review) => review, expire: () => { } };
|
|
13
14
|
const NO_DISPLAYS = { unavailable: () => "Desktop app views aren't available on this machine.", ensure: () => Promise.reject(new Error("Desktop app views aren't available on this machine.")), stop: () => { } };
|
|
@@ -373,12 +374,31 @@ export class AppService {
|
|
|
373
374
|
const { width, height } = await sendInput(entry.display, inputEvents(action));
|
|
374
375
|
if (!this.screenshots.enabled())
|
|
375
376
|
return { width, height, message: "Done. Agent screenshots are off on this machine, so there's no picture of the result: bivy config set sessions.appScreenshots true" };
|
|
377
|
+
return { width, height, shot: await this.after(entry, sessionId), message: "Done. The shot is the app now." };
|
|
378
|
+
}
|
|
379
|
+
/** A desktop app's menu bar: listed, or one item chosen by its path of
|
|
380
|
+
* titles ("File > Save"). Choosing returns the app afterwards, like `act`. */
|
|
381
|
+
async menu(sessionId, target, path) {
|
|
382
|
+
const item = path === undefined ? undefined : readMenuPath(path);
|
|
383
|
+
const entry = this.pickView(sessionId, target, true);
|
|
384
|
+
if (!entry.display)
|
|
385
|
+
await this.windowShown(entry);
|
|
386
|
+
if (!entry.displayControl)
|
|
387
|
+
throw new Error("This machine's desktop apps don't expose their menus (macOS only). Use keyboard shortcuts: bivy app key.");
|
|
388
|
+
if (!item)
|
|
389
|
+
return { menus: await readMenus(entry.displayControl) };
|
|
390
|
+
await pressMenu(entry.displayControl, item);
|
|
391
|
+
if (!this.screenshots.enabled())
|
|
392
|
+
return { message: "Chosen. Agent screenshots are off on this machine, so there's no picture of the result." };
|
|
393
|
+
return { shot: await this.after(entry, sessionId), message: "Chosen. The shot is the app now." };
|
|
394
|
+
}
|
|
395
|
+
/** The app a moment after an action, as a screenshot. */
|
|
396
|
+
async after(entry, sessionId) {
|
|
376
397
|
await new Promise((r) => setTimeout(r, AFTER_INPUT_MS));
|
|
377
398
|
const outDir = path.join(os.tmpdir(), "bivy-shots", sessionId.replace(/[^A-Za-z0-9_-]/g, "_"), String(Date.now()));
|
|
378
399
|
const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [], themes: [], path: "/" }, outDir));
|
|
379
400
|
this.shooting = run;
|
|
380
|
-
|
|
381
|
-
return { width, height, shot, message: "Done. The shot is the app now." };
|
|
401
|
+
return (await run)[0];
|
|
382
402
|
}
|
|
383
403
|
/** Draw on the preview: the user's marks on a picture of what they saw.
|
|
384
404
|
* The picture is the Compare screenshot they drew on, a desktop app's
|
|
@@ -462,6 +482,7 @@ export class AppService {
|
|
|
462
482
|
throw new Error("This view has no program.");
|
|
463
483
|
return this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale).then((display) => {
|
|
464
484
|
entry.display = display.socket;
|
|
485
|
+
entry.displayControl = display.control;
|
|
465
486
|
const [command, ...args] = [...display.launch ?? [], target.command, ...target.args];
|
|
466
487
|
return { command: command, args, workspace: target.workspace, env: display.env, name: `${entry.app.name} · ${entry.view.name}` };
|
|
467
488
|
});
|
|
@@ -518,6 +539,7 @@ export class AppService {
|
|
|
518
539
|
const entry = this.registry.getView(id);
|
|
519
540
|
if (entry) {
|
|
520
541
|
entry.display = undefined;
|
|
542
|
+
entry.displayControl = undefined;
|
|
521
543
|
entry.displayScale = undefined;
|
|
522
544
|
}
|
|
523
545
|
const server = this.servers.get(id);
|
|
@@ -23,6 +23,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
|
|
|
23
23
|
"apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined, typeof msg.path === "string" ? msg.path : undefined),
|
|
24
24
|
"apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
|
|
25
25
|
"apps.input": (msg) => service.act(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.action),
|
|
26
|
+
"apps.menu": (msg) => service.menu(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.path),
|
|
26
27
|
"apps.present": (msg) => service.present(String(msg.sessionId), { target: typeof msg.target === "string" ? msg.target : undefined, path: typeof msg.path === "string" ? msg.path : undefined, note: typeof msg.note === "string" ? msg.note : undefined }),
|
|
27
28
|
"apps.showMe": (msg) => service.present(String(msg.sessionId), { target: typeof msg.appId === "string" ? msg.appId : undefined, trigger: "asked" }),
|
|
28
29
|
"apps.mute": (msg) => service.mute(String(msg.sessionId)),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Direct-mode adapters for operations whose canonical implementation is the
|
|
4
4
|
* client command registry. Relay and HTTP now differ only in framing. */
|
|
5
5
|
export const CLIENT_COMMAND_ROUTES = [
|
|
6
|
-
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
6
|
+
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
7
7
|
{ method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
|
|
8
8
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
9
9
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
@@ -16,6 +16,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
16
16
|
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
17
17
|
"apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
18
18
|
"apps.input": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), action: Type.Object({ kind: Type.String({ maxLength: 20 }) }) }),
|
|
19
|
+
"apps.menu": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 1, maxItems: 8 })) }),
|
|
19
20
|
"apps.present": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.String({ maxLength: 2048 })), note: Type.Optional(Type.String({ maxLength: 2000 })) }),
|
|
20
21
|
"apps.showMe": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()) }),
|
|
21
22
|
"apps.mute": Type.Object({ ...request, ...session }),
|
package/package.json
CHANGED