@vashp2029/glovebox 0.0.0-stage → 0.3.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/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +238 -2
- package/SECURITY.md +81 -0
- package/bin/glovebox.mjs +319 -0
- package/docs/agents.md +124 -0
- package/docs/api.md +146 -0
- package/docs/backend-protocol.md +118 -0
- package/docs/cli.md +150 -0
- package/docs/design.md +212 -0
- package/docs/recipes.md +162 -0
- package/docs/roadmap.md +167 -0
- package/docs/testing.md +79 -0
- package/native/macos/GloveboxHelper.swift +1654 -0
- package/package.json +28 -4
- package/skills/glovebox/SKILL.md +49 -0
- package/src/api.mjs +552 -0
- package/src/axformat.mjs +158 -0
- package/src/backends/index.mjs +27 -0
- package/src/backends/macos.mjs +106 -0
- package/src/config.mjs +92 -0
- package/src/daemon.mjs +127 -0
- package/src/geometry.mjs +38 -0
- package/src/kernel.mjs +217 -0
- package/src/keys.mjs +96 -0
- package/src/macos-helper.mjs +164 -0
- package/src/mcp.mjs +126 -0
- package/src/session.mjs +110 -0
- package/src/smoke.mjs +160 -0
- package/src/transform.mjs +131 -0
package/src/axformat.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Turns a backend's accessibility node list into compact text, and diffs it
|
|
2
|
+
// against the previous read so repeat reads cost a few lines, not a tree.
|
|
3
|
+
//
|
|
4
|
+
// Node shape from a backend: {i, d, role, subrole?, title?, value?, desc?,
|
|
5
|
+
// help?, placeholder?, id?, frame?: [x,y,w,h] screen points, actions?,
|
|
6
|
+
// focused?, selected?, disabled?, settable?}
|
|
7
|
+
|
|
8
|
+
const ROLE_NAMES = {
|
|
9
|
+
AXStaticText: "text",
|
|
10
|
+
AXTextField: "text field",
|
|
11
|
+
AXTextArea: "text area",
|
|
12
|
+
AXSecureTextField: "secure text field",
|
|
13
|
+
AXButton: "button",
|
|
14
|
+
AXCheckBox: "checkbox",
|
|
15
|
+
AXRadioButton: "radio button",
|
|
16
|
+
AXPopUpButton: "pop up button",
|
|
17
|
+
AXMenuButton: "menu button",
|
|
18
|
+
AXComboBox: "combo box",
|
|
19
|
+
AXScrollArea: "scroll area",
|
|
20
|
+
AXScrollBar: "scroll bar",
|
|
21
|
+
AXSplitGroup: "split group",
|
|
22
|
+
AXSplitter: "splitter",
|
|
23
|
+
AXGroup: "group",
|
|
24
|
+
AXWindow: "window",
|
|
25
|
+
AXSheet: "sheet",
|
|
26
|
+
AXToolbar: "toolbar",
|
|
27
|
+
AXTabGroup: "tab group",
|
|
28
|
+
AXTable: "table",
|
|
29
|
+
AXOutline: "outline",
|
|
30
|
+
AXRow: "row",
|
|
31
|
+
AXColumn: "column",
|
|
32
|
+
AXCell: "cell",
|
|
33
|
+
AXList: "list",
|
|
34
|
+
AXImage: "image",
|
|
35
|
+
AXLink: "link",
|
|
36
|
+
AXSlider: "slider",
|
|
37
|
+
AXMenuBar: "menu bar",
|
|
38
|
+
AXMenuBarItem: "menu bar item",
|
|
39
|
+
AXMenu: "menu",
|
|
40
|
+
AXMenuItem: "menu item",
|
|
41
|
+
AXWebArea: "web area",
|
|
42
|
+
AXHeading: "heading",
|
|
43
|
+
AXProgressIndicator: "progress indicator",
|
|
44
|
+
AXValueIndicator: "value indicator",
|
|
45
|
+
AXDisclosureTriangle: "disclosure triangle",
|
|
46
|
+
AXIncrementor: "stepper",
|
|
47
|
+
AXLayoutArea: "layout area",
|
|
48
|
+
AXUnknown: "element",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const SUBROLE_NAMES = {
|
|
52
|
+
AXCloseButton: "close button",
|
|
53
|
+
AXMinimizeButton: "minimize button",
|
|
54
|
+
AXZoomButton: "zoom button",
|
|
55
|
+
AXFullScreenButton: "full screen button",
|
|
56
|
+
AXSearchField: "search field",
|
|
57
|
+
AXStandardWindow: "window",
|
|
58
|
+
AXDialog: "dialog",
|
|
59
|
+
AXToggle: "toggle",
|
|
60
|
+
AXSwitch: "switch",
|
|
61
|
+
AXTabButton: "tab",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function roleName(node) {
|
|
65
|
+
if (node.subrole && SUBROLE_NAMES[node.subrole]) return SUBROLE_NAMES[node.subrole];
|
|
66
|
+
if (ROLE_NAMES[node.role]) return ROLE_NAMES[node.role];
|
|
67
|
+
return node.role
|
|
68
|
+
.replace(/^AX/, "")
|
|
69
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function quote(text) {
|
|
74
|
+
// Scroll positions and sliders report floats like 0.1851851791143417.
|
|
75
|
+
if (/^-?\d+\.\d{4,}$/.test(text)) text = String(Number(Number(text).toFixed(3)));
|
|
76
|
+
return JSON.stringify(text.replace(/\s+/g, " ").trim());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Actions that are implied by the role or covered by click()/scroll().
|
|
80
|
+
const QUIET_ACTIONS = new Set([
|
|
81
|
+
"AXPress",
|
|
82
|
+
"AXRaise",
|
|
83
|
+
"AXCancel",
|
|
84
|
+
"AXConfirm",
|
|
85
|
+
"AXScrollLeftByPage",
|
|
86
|
+
"AXScrollRightByPage",
|
|
87
|
+
"AXScrollUpByPage",
|
|
88
|
+
"AXScrollDownByPage",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
function actionName(action) {
|
|
92
|
+
return action.replace(/^AX/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// One node as one line, without indentation. `toImage` maps a screen-point
|
|
96
|
+
// frame to image pixels when frames are requested.
|
|
97
|
+
export function nodeLine(node, { toImage } = {}) {
|
|
98
|
+
const parts = [String(node.i), roleName(node)];
|
|
99
|
+
const label = node.title ?? node.desc;
|
|
100
|
+
if (label) parts.push(quote(label));
|
|
101
|
+
if (node.title && node.desc && node.desc !== node.title) parts.push(`desc=${quote(node.desc)}`);
|
|
102
|
+
if (node.value != null && node.value !== label) parts.push(`value=${quote(node.value)}`);
|
|
103
|
+
if (node.placeholder) parts.push(`placeholder=${quote(node.placeholder)}`);
|
|
104
|
+
if (node.id) parts.push(`id=${node.id}`);
|
|
105
|
+
if (node.help && node.help !== label) parts.push(`help=${quote(node.help)}`);
|
|
106
|
+
const flags = [];
|
|
107
|
+
if (node.focused) flags.push("focused");
|
|
108
|
+
if (node.selected) flags.push("selected");
|
|
109
|
+
if (node.disabled) flags.push("disabled");
|
|
110
|
+
if (node.settable && /text|combo|slider|search/.test(roleName(node))) flags.push("settable");
|
|
111
|
+
if (flags.length) parts.push(`(${flags.join(", ")})`);
|
|
112
|
+
const extra = (node.actions ?? []).filter((a) => !QUIET_ACTIONS.has(a) && !a.startsWith("Name:"));
|
|
113
|
+
if (extra.length) parts.push(`actions: ${extra.map(actionName).join(", ")}`);
|
|
114
|
+
if (toImage && node.frame) {
|
|
115
|
+
const [x, y, w, h] = toImage(node.frame).map((v) => Math.round(v));
|
|
116
|
+
parts.push(`@${x},${y} ${w}x${h}`);
|
|
117
|
+
}
|
|
118
|
+
return parts.join(" ");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formatTree(nodes, options = {}) {
|
|
122
|
+
return nodes.map((node) => `${"\t".repeat(node.d)}${nodeLine(node, options)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Diffs two formatted trees keyed by element index. Lines are compared
|
|
126
|
+
// without indentation, so a change to any attribute shows as "~" but an
|
|
127
|
+
// element that only moved deeper (wrapped in a new container) does not.
|
|
128
|
+
export function diffTrees(previous, next) {
|
|
129
|
+
const key = (line) => line.trimStart().split(" ", 1)[0];
|
|
130
|
+
const before = new Map(previous.map((line) => [key(line), line.trimStart()]));
|
|
131
|
+
const nextKeys = new Set();
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const line of next) {
|
|
134
|
+
const k = key(line);
|
|
135
|
+
nextKeys.add(k);
|
|
136
|
+
const old = before.get(k);
|
|
137
|
+
if (old === undefined) out.push(`+${line}`);
|
|
138
|
+
else if (old !== line.trimStart()) out.push(`~${line}`);
|
|
139
|
+
}
|
|
140
|
+
const removed = [...before.keys()].filter((k) => !nextKeys.has(k)).map(Number).sort((a, b) => a - b);
|
|
141
|
+
if (removed.length) out.push(`- removed: ${ranges(removed)}`);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function ranges(numbers) {
|
|
146
|
+
const parts = [];
|
|
147
|
+
let start = numbers[0];
|
|
148
|
+
let prev = start;
|
|
149
|
+
for (const n of numbers.slice(1).concat([NaN])) {
|
|
150
|
+
if (n === prev + 1) {
|
|
151
|
+
prev = n;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
parts.push(start === prev ? `${start}` : `${start}-${prev}`);
|
|
155
|
+
start = prev = n;
|
|
156
|
+
}
|
|
157
|
+
return parts.join(", ");
|
|
158
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Picks the backend for this OS. Each backend exposes request(method, params)
|
|
2
|
+
// with the same methods and emits "escape" when the user presses Escape.
|
|
3
|
+
// See docs/backend-protocol.md for the contract a new OS backend implements.
|
|
4
|
+
import { MacBackend } from "./macos.mjs";
|
|
5
|
+
|
|
6
|
+
export function createBackend(options) {
|
|
7
|
+
switch (process.platform) {
|
|
8
|
+
case "darwin":
|
|
9
|
+
return new MacBackend(options);
|
|
10
|
+
default:
|
|
11
|
+
return new UnsupportedBackend(process.platform);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class UnsupportedBackend {
|
|
16
|
+
constructor(platform) {
|
|
17
|
+
this.platform = platform;
|
|
18
|
+
}
|
|
19
|
+
on() {
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
async request() {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`glovebox has no ${this.platform} backend yet. macOS is supported today; Windows and Linux are planned (see docs/roadmap.md).`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Client for Glovebox Helper.app over its Unix socket.
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import * as helper from "../macos-helper.mjs";
|
|
6
|
+
|
|
7
|
+
function connectOnce(path) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const socket = net.connect(path);
|
|
10
|
+
socket.once("connect", () => resolve(socket));
|
|
11
|
+
socket.once("error", reject);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class MacBackend extends EventEmitter {
|
|
16
|
+
platform = "macos";
|
|
17
|
+
|
|
18
|
+
constructor({ client = "An agent" } = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this.client = client;
|
|
21
|
+
this.socket = null;
|
|
22
|
+
this.pending = new Map();
|
|
23
|
+
this.nextId = 1;
|
|
24
|
+
this.connecting = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async connect() {
|
|
28
|
+
if (this.socket) return;
|
|
29
|
+
this.connecting ??= this.#connect().finally(() => {
|
|
30
|
+
this.connecting = null;
|
|
31
|
+
});
|
|
32
|
+
await this.connecting;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async #connect() {
|
|
36
|
+
if (!fs.existsSync(helper.appPath()) || !helper.isBuilt()) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Glovebox Helper is missing, or was built from different source than this glovebox. " +
|
|
39
|
+
"Run: glovebox setup. That rebuilds the helper; unless you ran `glovebox signing --create`, " +
|
|
40
|
+
"macOS will ask for its permissions again. If you have more than one install, check which `glovebox` this client runs.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const path = helper.socketPath();
|
|
44
|
+
let socket;
|
|
45
|
+
try {
|
|
46
|
+
socket = await connectOnce(path);
|
|
47
|
+
} catch {
|
|
48
|
+
await helper.launch();
|
|
49
|
+
for (let attempt = 0; attempt < 50 && !socket; attempt++) {
|
|
50
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
51
|
+
socket = await connectOnce(path).catch(() => null);
|
|
52
|
+
}
|
|
53
|
+
if (!socket) throw new Error(`Glovebox Helper did not start (no socket at ${path}). Run: glovebox doctor`);
|
|
54
|
+
}
|
|
55
|
+
this.socket = socket;
|
|
56
|
+
let buffer = "";
|
|
57
|
+
socket.setEncoding("utf8");
|
|
58
|
+
socket.on("data", (chunk) => {
|
|
59
|
+
buffer += chunk;
|
|
60
|
+
let newline;
|
|
61
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
62
|
+
const line = buffer.slice(0, newline);
|
|
63
|
+
buffer = buffer.slice(newline + 1);
|
|
64
|
+
if (line) this.#onMessage(JSON.parse(line));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
socket.on("close", () => {
|
|
68
|
+
if (this.socket === socket) this.socket = null;
|
|
69
|
+
for (const { reject } of this.pending.values()) reject(new Error("Glovebox Helper disconnected"));
|
|
70
|
+
this.pending.clear();
|
|
71
|
+
});
|
|
72
|
+
socket.on("error", () => {});
|
|
73
|
+
await this.request("hello", { client: this.client });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#onMessage(message) {
|
|
77
|
+
if (message.event) {
|
|
78
|
+
this.emit(message.event, message);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const waiter = this.pending.get(message.id);
|
|
82
|
+
if (!waiter) return;
|
|
83
|
+
this.pending.delete(message.id);
|
|
84
|
+
if (message.error) {
|
|
85
|
+
const error = new Error(message.error.message);
|
|
86
|
+
error.code = message.error.code;
|
|
87
|
+
waiter.reject(error);
|
|
88
|
+
} else {
|
|
89
|
+
waiter.resolve(message.result);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async request(method, params = {}) {
|
|
94
|
+
if (!this.socket) await this.connect();
|
|
95
|
+
const id = this.nextId++;
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
this.pending.set(id, { resolve, reject });
|
|
98
|
+
this.socket.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close() {
|
|
103
|
+
this.socket?.destroy();
|
|
104
|
+
this.socket = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Paths and user configuration. Everything lives under one directory so a user
|
|
2
|
+
// can inspect or delete it: GLOVEBOX_HOME, else ~/.glovebox.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export const VERSION = "0.3.0";
|
|
8
|
+
|
|
9
|
+
export function homeDir() {
|
|
10
|
+
return process.env.GLOVEBOX_HOME || path.join(os.homedir(), ".glovebox");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const paths = {
|
|
14
|
+
get home() {
|
|
15
|
+
return homeDir();
|
|
16
|
+
},
|
|
17
|
+
get config() {
|
|
18
|
+
return path.join(homeDir(), "config.json");
|
|
19
|
+
},
|
|
20
|
+
get log() {
|
|
21
|
+
return path.join(homeDir(), "actions.log");
|
|
22
|
+
},
|
|
23
|
+
get bin() {
|
|
24
|
+
return path.join(homeDir(), "bin");
|
|
25
|
+
},
|
|
26
|
+
get shots() {
|
|
27
|
+
return path.join(homeDir(), "screenshots");
|
|
28
|
+
},
|
|
29
|
+
get socket() {
|
|
30
|
+
return process.platform === "win32"
|
|
31
|
+
? `\\\\.\\pipe\\glovebox-${os.userInfo().username}`
|
|
32
|
+
: path.join(homeDir(), "daemon.sock");
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const DEFAULTS = {
|
|
37
|
+
// ask: native dialog on first use of each app. allowlist: only apps in `allow`.
|
|
38
|
+
// allow-all: no prompts (explicit opt-in; blocked apps still need `allow`).
|
|
39
|
+
approval: "ask",
|
|
40
|
+
allow: [],
|
|
41
|
+
deny: [],
|
|
42
|
+
// background: input reaches apps without focusing them (falls back to
|
|
43
|
+
// foreground when the app or macOS can't take it). foreground: always front.
|
|
44
|
+
input: "background",
|
|
45
|
+
// Apps that need foreground input, by name or bundle id.
|
|
46
|
+
foregroundApps: [],
|
|
47
|
+
// Draw the agent cursor where actions land.
|
|
48
|
+
cursor: true,
|
|
49
|
+
// Pause after each action so the app can update before the next one.
|
|
50
|
+
settleMs: 80,
|
|
51
|
+
screenshot: { maxLongSide: 1568, maxPixels: 1_150_000, format: "png", quality: 80 },
|
|
52
|
+
ax: { maxNodes: 800, maxDepth: 40, maxTextLength: 160 },
|
|
53
|
+
log: { enabled: true, text: false },
|
|
54
|
+
daemon: { idleMinutes: 60 },
|
|
55
|
+
timeoutMs: 120_000,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function merge(base, extra) {
|
|
59
|
+
const out = { ...base };
|
|
60
|
+
for (const [key, value] of Object.entries(extra ?? {})) {
|
|
61
|
+
out[key] =
|
|
62
|
+
value && typeof value === "object" && !Array.isArray(value) && base[key] && typeof base[key] === "object"
|
|
63
|
+
? merge(base[key], value)
|
|
64
|
+
: value;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readUserConfig() {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(fs.readFileSync(paths.config, "utf8"));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error.code === "ENOENT") return {};
|
|
74
|
+
throw new Error(`Cannot read ${paths.config}: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function loadConfig() {
|
|
79
|
+
const config = merge(DEFAULTS, readUserConfig());
|
|
80
|
+
if (process.env.GLOVEBOX_APPROVAL) config.approval = process.env.GLOVEBOX_APPROVAL;
|
|
81
|
+
if (!["ask", "allowlist", "allow-all"].includes(config.approval)) {
|
|
82
|
+
throw new Error(`approval must be ask, allowlist, or allow-all (got ${config.approval})`);
|
|
83
|
+
}
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeUserConfig(update) {
|
|
88
|
+
fs.mkdirSync(homeDir(), { recursive: true, mode: 0o700 });
|
|
89
|
+
const next = { ...readUserConfig(), ...update };
|
|
90
|
+
fs.writeFileSync(paths.config, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
91
|
+
return next;
|
|
92
|
+
}
|
package/src/daemon.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Background process that keeps CLI sessions alive between `glovebox run` calls,
|
|
2
|
+
// so shell-only agents get the same persistent bindings as MCP clients.
|
|
3
|
+
// One JSON request per connection, one JSON reply.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { loadConfig, paths } from "./config.mjs";
|
|
10
|
+
import { Session } from "./session.mjs";
|
|
11
|
+
|
|
12
|
+
export function runDaemon() {
|
|
13
|
+
const sessions = new Map();
|
|
14
|
+
const config = loadConfig();
|
|
15
|
+
let lastActivity = Date.now();
|
|
16
|
+
|
|
17
|
+
const sessionFor = (name, client) => {
|
|
18
|
+
let session = sessions.get(name);
|
|
19
|
+
if (!session) {
|
|
20
|
+
session = new Session({ client: client || `glovebox CLI (${name})`, imageMode: "file" });
|
|
21
|
+
sessions.set(name, session);
|
|
22
|
+
}
|
|
23
|
+
return session;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function handle(request) {
|
|
27
|
+
lastActivity = Date.now();
|
|
28
|
+
const name = request.session || "default";
|
|
29
|
+
switch (request.op) {
|
|
30
|
+
case "run":
|
|
31
|
+
return sessionFor(name, request.client).run(request.code, {
|
|
32
|
+
title: request.title,
|
|
33
|
+
timeoutMs: request.timeoutMs || config.timeoutMs,
|
|
34
|
+
});
|
|
35
|
+
case "reset":
|
|
36
|
+
sessions.get(name)?.reset();
|
|
37
|
+
sessions.delete(name);
|
|
38
|
+
return { ok: true, content: [{ type: "text", text: `Session "${name}" reset.` }] };
|
|
39
|
+
case "list":
|
|
40
|
+
return { ok: true, content: [{ type: "text", text: [...sessions.keys()].join("\n") || "(no sessions)" }] };
|
|
41
|
+
case "stop":
|
|
42
|
+
setTimeout(shutdown, 50);
|
|
43
|
+
return { ok: true, content: [{ type: "text", text: "Daemon stopped." }] };
|
|
44
|
+
default:
|
|
45
|
+
return { ok: false, content: [{ type: "text", text: `Unknown op ${request.op}` }] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (process.platform !== "win32") {
|
|
50
|
+
fs.mkdirSync(paths.home, { recursive: true, mode: 0o700 });
|
|
51
|
+
try {
|
|
52
|
+
fs.unlinkSync(paths.socket);
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
const server = net.createServer((socket) => {
|
|
56
|
+
let buffer = "";
|
|
57
|
+
socket.setEncoding("utf8");
|
|
58
|
+
socket.on("data", async (chunk) => {
|
|
59
|
+
buffer += chunk;
|
|
60
|
+
const newline = buffer.indexOf("\n");
|
|
61
|
+
if (newline < 0) return;
|
|
62
|
+
let reply;
|
|
63
|
+
try {
|
|
64
|
+
reply = await handle(JSON.parse(buffer.slice(0, newline)));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
reply = { ok: false, content: [{ type: "text", text: error.message }] };
|
|
67
|
+
}
|
|
68
|
+
socket.end(`${JSON.stringify(reply)}\n`);
|
|
69
|
+
});
|
|
70
|
+
socket.on("error", () => {});
|
|
71
|
+
});
|
|
72
|
+
server.listen(paths.socket, () => {
|
|
73
|
+
if (process.platform !== "win32") fs.chmodSync(paths.socket, 0o600);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function shutdown() {
|
|
77
|
+
for (const session of sessions.values()) session.close();
|
|
78
|
+
server.close();
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
process.on("SIGTERM", shutdown);
|
|
82
|
+
process.on("SIGINT", shutdown);
|
|
83
|
+
|
|
84
|
+
const idleMs = (config.daemon.idleMinutes ?? 60) * 60_000;
|
|
85
|
+
if (idleMs > 0) {
|
|
86
|
+
setInterval(() => {
|
|
87
|
+
if (Date.now() - lastActivity > idleMs) shutdown();
|
|
88
|
+
}, 30_000).unref();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function send(request) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const socket = net.connect(paths.socket);
|
|
95
|
+
let buffer = "";
|
|
96
|
+
socket.setEncoding("utf8");
|
|
97
|
+
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
|
98
|
+
socket.on("data", (chunk) => (buffer += chunk));
|
|
99
|
+
socket.on("end", () => {
|
|
100
|
+
try {
|
|
101
|
+
resolve(JSON.parse(buffer));
|
|
102
|
+
} catch {
|
|
103
|
+
reject(new Error("Bad reply from glovebox daemon"));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
socket.on("error", reject);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sends a request, starting the daemon first if nothing is listening.
|
|
111
|
+
export async function daemonRequest(request, { autostart = true } = {}) {
|
|
112
|
+
try {
|
|
113
|
+
return await send(request);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!autostart || !["ENOENT", "ECONNREFUSED"].includes(error.code)) throw error;
|
|
116
|
+
}
|
|
117
|
+
const cli = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "glovebox.mjs");
|
|
118
|
+
const child = spawn(process.execPath, [cli, "daemon"], { detached: true, stdio: "ignore", env: process.env });
|
|
119
|
+
child.unref();
|
|
120
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
121
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
122
|
+
try {
|
|
123
|
+
return await send(request);
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`glovebox daemon did not start (socket ${paths.socket})`);
|
|
127
|
+
}
|
package/src/geometry.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The image space a model sees for a window, and the mapping back to screen points.
|
|
2
|
+
//
|
|
3
|
+
// Image pixels are window-local: (0, 0) is the window's top-left corner. One
|
|
4
|
+
// view's scale `s` is image pixels per screen point. It depends only on the
|
|
5
|
+
// window size, the display's backing scale, and the configured limits, so the
|
|
6
|
+
// AX tree, screenshots, and clicks all agree even before any screenshot exists.
|
|
7
|
+
|
|
8
|
+
export function viewFor(window, limits) {
|
|
9
|
+
const [x, y, w, h] = window.frame;
|
|
10
|
+
const backing = window.scale || 1;
|
|
11
|
+
const s = Math.min(
|
|
12
|
+
backing,
|
|
13
|
+
limits.maxLongSide / Math.max(w, h),
|
|
14
|
+
Math.sqrt(limits.maxPixels / Math.max(1, w * h)),
|
|
15
|
+
);
|
|
16
|
+
return {
|
|
17
|
+
window: window.index,
|
|
18
|
+
windowId: window.windowId,
|
|
19
|
+
title: window.title,
|
|
20
|
+
origin: [x, y],
|
|
21
|
+
size: [w, h],
|
|
22
|
+
scale: s,
|
|
23
|
+
width: Math.max(1, Math.round(w * s)),
|
|
24
|
+
height: Math.max(1, Math.round(h * s)),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toScreen(view, [ix, iy]) {
|
|
29
|
+
return [view.origin[0] + ix / view.scale, view.origin[1] + iy / view.scale];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function toImage(view, [sx, sy, sw, sh]) {
|
|
33
|
+
return [(sx - view.origin[0]) * view.scale, (sy - view.origin[1]) * view.scale, sw * view.scale, sh * view.scale];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function center([x, y, w, h]) {
|
|
37
|
+
return [x + w / 2, y + h / 2];
|
|
38
|
+
}
|