@tiens.nguyen/gu-cli 1.0.834 → 1.0.836
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 +1 -1
- package/browser-bridge.mjs +72 -11
- package/browser-command.mjs +214 -20
- package/browser-extension/README.md +67 -27
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +100 -322
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/manifest.json +3 -1
- package/browser-extension/popup.html +4 -0
- package/browser-extension/popup.js +23 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +424 -59
- package/gu-cli.mjs +45 -15
- package/gu-repl.mjs +185 -26
- package/gu_browser.py +92 -20
- package/gu_powershell.py +675 -0
- package/install-path.mjs +19 -2
- package/package.json +5 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +95 -26
- package/terminal-layout.mjs +1 -0
- package/win-path.mjs +185 -0
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
// click is a real mouse press and release at a point, and typing is real key input, the way a
|
|
6
6
|
// person would do it, not el.click().
|
|
7
7
|
//
|
|
8
|
-
// gu only works in ITS OWN tabs: the ones it opened (kept in a tab group named "gu")
|
|
9
|
-
//
|
|
8
|
+
// gu only works in ITS OWN tabs: the ones it opened (kept in a tab group named "gu"), the popups
|
|
9
|
+
// those open, and a tab the user shares from the popup ("Let gu use this tab", #150). The user's
|
|
10
|
+
// other tabs are never read, captured or controlled. What each command does is in actions.js,
|
|
11
|
+
// shared with gu's own Chrome window (browser-chrome.mjs).
|
|
12
|
+
|
|
13
|
+
import { enableDomains, forgetTab, onCdpEvent, runAction } from "./actions.js";
|
|
10
14
|
|
|
11
15
|
const DEFAULT_PORT = 4127;
|
|
12
16
|
const VERSION = chrome.runtime.getManifest().version;
|
|
@@ -15,9 +19,6 @@ let ws = null;
|
|
|
15
19
|
let connecting = false; // set before the first await: startup fires connect() several times at once
|
|
16
20
|
let pingTimer = null;
|
|
17
21
|
const attached = new Set();
|
|
18
|
-
const consoleBuf = new Map(); // tabId → [{level, text}] since the last "console" read
|
|
19
|
-
const dialogs = new Map(); // tabId → [text] of alerts/confirms gu accepted
|
|
20
|
-
const freshErrors = new Map(); // tabId → errors not yet reported after a command
|
|
21
22
|
|
|
22
23
|
// ── connection ──────────────────────────────────────────────────────────────────────────────
|
|
23
24
|
async function bridgePort() {
|
|
@@ -41,24 +42,36 @@ async function connect() {
|
|
|
41
42
|
connecting = false;
|
|
42
43
|
}
|
|
43
44
|
ws = sock;
|
|
44
|
-
sock.onopen = () => {
|
|
45
|
-
sock.send(JSON.stringify({ type: "hello", version: VERSION }));
|
|
45
|
+
sock.onopen = async () => {
|
|
46
|
+
sock.send(JSON.stringify({ type: "hello", version: VERSION, installType: await installType() }));
|
|
46
47
|
setBadge(true);
|
|
47
48
|
clearInterval(pingTimer);
|
|
48
49
|
// An open socket that carries a message at least every 30 s keeps the service worker awake.
|
|
49
50
|
pingTimer = setInterval(() => sock.readyState === WebSocket.OPEN && sock.send('{"type":"ping"}'), 20000);
|
|
50
51
|
};
|
|
51
52
|
sock.onmessage = (ev) => onCommand(sock, ev.data);
|
|
52
|
-
sock.onclose = () => {
|
|
53
|
+
sock.onclose = (ev) => {
|
|
53
54
|
if (ws !== sock) return; // an older socket the bridge replaced
|
|
54
55
|
ws = null;
|
|
55
56
|
clearInterval(pingTimer);
|
|
56
57
|
setBadge(false);
|
|
57
|
-
|
|
58
|
+
// 4001: another gu extension (the store copy next to a developer one) has the bridge.
|
|
59
|
+
// Retry slowly instead of every 3 s; the alarm keeps trying too (#148).
|
|
60
|
+
setTimeout(connect, ev.code === 4001 ? 30000 : 3000);
|
|
58
61
|
};
|
|
59
62
|
sock.onerror = () => {};
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
// "development" = loaded unpacked (Developer mode); "normal" = from the Chrome Web Store (#146).
|
|
66
|
+
// getSelf needs no "management" permission.
|
|
67
|
+
async function installType() {
|
|
68
|
+
try {
|
|
69
|
+
return (await chrome.management.getSelf()).installType;
|
|
70
|
+
} catch {
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
function setBadge(on) {
|
|
63
76
|
chrome.action.setBadgeText({ text: on ? "on" : "" }).catch(() => {});
|
|
64
77
|
chrome.action.setBadgeBackgroundColor({ color: "#6d5bd0" }).catch(() => {});
|
|
@@ -75,6 +88,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
|
|
75
88
|
} else if (msg?.type === "reconnect") {
|
|
76
89
|
connect().then(() => reply({ ok: true }));
|
|
77
90
|
return true;
|
|
91
|
+
} else if (msg?.type === "tabState") {
|
|
92
|
+
state().then((s) => reply({ shared: s.tabs.includes(msg.tabId) }));
|
|
93
|
+
return true;
|
|
94
|
+
} else if (msg?.type === "share") {
|
|
95
|
+
// "Let gu use this tab" (#150): the user hands gu a tab of their own.
|
|
96
|
+
addToGroup(msg.tabId)
|
|
97
|
+
.then(() => saveState({ current: msg.tabId }))
|
|
98
|
+
.then(() => reply({ ok: true }), (e) => reply({ ok: false, error: String(e?.message || e) }));
|
|
99
|
+
return true;
|
|
100
|
+
} else if (msg?.type === "unshare") {
|
|
101
|
+
unshare(msg.tabId).then(() => reply({ ok: true }));
|
|
102
|
+
return true;
|
|
78
103
|
}
|
|
79
104
|
return false;
|
|
80
105
|
});
|
|
@@ -134,6 +159,16 @@ async function addToGroup(tabId) {
|
|
|
134
159
|
await saveState({ tabs, group });
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
// "Stop sharing": the tab leaves the group, gu lets go of it, and never touches it again.
|
|
163
|
+
async function unshare(tabId) {
|
|
164
|
+
const s = await state();
|
|
165
|
+
await saveState({ tabs: s.tabs.filter((t) => t !== tabId), current: s.current === tabId ? null : s.current });
|
|
166
|
+
await chrome.tabs.ungroup(tabId).catch(() => {});
|
|
167
|
+
if (attached.has(tabId)) await chrome.debugger.detach({ tabId }).catch(() => {});
|
|
168
|
+
attached.delete(tabId);
|
|
169
|
+
forgetTab(tabId);
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
// A page gu opened that opens another (target=_blank, window.open) — that one is gu's too.
|
|
138
173
|
chrome.tabs.onCreated.addListener(async (tab) => {
|
|
139
174
|
const s = await state();
|
|
@@ -143,240 +178,37 @@ chrome.tabs.onCreated.addListener(async (tab) => {
|
|
|
143
178
|
}
|
|
144
179
|
});
|
|
145
180
|
|
|
146
|
-
async function currentTab() {
|
|
147
|
-
const tabs = await liveTabs();
|
|
148
|
-
if (!tabs.length) throw new Error('gu has no tab open yet — use action "open" with a url first');
|
|
149
|
-
const s = await state();
|
|
150
|
-
return tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1];
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function waitLoaded(tabId, ms = 20000) {
|
|
154
|
-
const end = Date.now() + ms;
|
|
155
|
-
await sleep(150);
|
|
156
|
-
while (Date.now() < end) {
|
|
157
|
-
const t = await chrome.tabs.get(tabId).catch(() => null);
|
|
158
|
-
if (!t) throw new Error("the tab was closed");
|
|
159
|
-
if (t.status === "complete") return t;
|
|
160
|
-
await sleep(150);
|
|
161
|
-
}
|
|
162
|
-
return chrome.tabs.get(tabId);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
181
|
// ── DevTools protocol ───────────────────────────────────────────────────────────────────────
|
|
166
|
-
async function attach(tabId) {
|
|
167
|
-
if (attached.has(tabId)) return;
|
|
168
|
-
try {
|
|
169
|
-
await chrome.debugger.attach({ tabId }, "1.3");
|
|
170
|
-
} catch (e) {
|
|
171
|
-
if (!/already attached/i.test(String(e?.message))) {
|
|
172
|
-
throw new Error(`cannot control this tab (${e?.message || e}) — Chrome pages (chrome://, the Web Store) cannot be automated`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
attached.add(tabId);
|
|
176
|
-
await cdp(tabId, "Runtime.enable");
|
|
177
|
-
await cdp(tabId, "Page.enable");
|
|
178
|
-
await cdp(tabId, "Log.enable").catch(() => {});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
182
|
function cdp(tabId, method, params = {}) {
|
|
182
183
|
return chrome.debugger.sendCommand({ tabId }, method, params);
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
chrome.debugger.onDetach.addListener((src) => attached.delete(src.tabId));
|
|
187
|
+
chrome.debugger.onEvent.addListener((src, method, params) => onCdpEvent(src.tabId, method, params, cdp));
|
|
186
188
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
187
189
|
attached.delete(tabId);
|
|
188
|
-
|
|
189
|
-
freshErrors.delete(tabId);
|
|
190
|
-
dialogs.delete(tabId);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
function pushConsole(tabId, level, text) {
|
|
194
|
-
const buf = consoleBuf.get(tabId) || [];
|
|
195
|
-
buf.push({ level, text: String(text).slice(0, 500) });
|
|
196
|
-
if (buf.length > 100) buf.splice(0, buf.length - 100);
|
|
197
|
-
consoleBuf.set(tabId, buf);
|
|
198
|
-
if (level === "error") freshErrors.set(tabId, [...(freshErrors.get(tabId) || []), String(text).slice(0, 300)].slice(-5));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
chrome.debugger.onEvent.addListener((src, method, params) => {
|
|
202
|
-
const tabId = src.tabId;
|
|
203
|
-
if (method === "Runtime.consoleAPICalled") {
|
|
204
|
-
const text = (params.args || []).map((a) => (a.value !== undefined ? String(a.value) : a.description || a.type)).join(" ");
|
|
205
|
-
pushConsole(tabId, params.type === "warning" ? "warn" : params.type, text);
|
|
206
|
-
} else if (method === "Runtime.exceptionThrown") {
|
|
207
|
-
const d = params.exceptionDetails || {};
|
|
208
|
-
pushConsole(tabId, "error", `Uncaught ${d.exception?.description || d.text || "exception"}`);
|
|
209
|
-
} else if (method === "Log.entryAdded") {
|
|
210
|
-
const e = params.entry || {};
|
|
211
|
-
if (e.level === "error" || e.level === "warning") pushConsole(tabId, e.level === "warning" ? "warn" : "error", `${e.text}${e.url ? ` (${e.url})` : ""}`);
|
|
212
|
-
} else if (method === "Page.javascriptDialogOpening") {
|
|
213
|
-
// An alert/confirm would freeze the page and every later command; accept it and report it.
|
|
214
|
-
const list = dialogs.get(tabId) || [];
|
|
215
|
-
list.push(`${params.type}: ${params.message}`);
|
|
216
|
-
dialogs.set(tabId, list);
|
|
217
|
-
cdp(tabId, "Page.handleJavaScriptDialog", { accept: true, promptText: params.defaultPrompt || "" }).catch(() => {});
|
|
218
|
-
}
|
|
190
|
+
forgetTab(tabId);
|
|
219
191
|
});
|
|
220
192
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// "click 7"; the numbers are good until the next read.
|
|
233
|
-
function readPage(opts) {
|
|
234
|
-
const sel = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch],[role=combobox],[role=textbox],[contenteditable=""],[contenteditable=true],[onclick],[tabindex]:not([tabindex="-1"])';
|
|
235
|
-
const vw = innerWidth;
|
|
236
|
-
const vh = innerHeight;
|
|
237
|
-
for (const e of document.querySelectorAll("[data-gu-ref]")) e.removeAttribute("data-gu-ref");
|
|
238
|
-
const clean = (s) => String(s || "").replace(/\s+/g, " ").trim();
|
|
239
|
-
const lines = [];
|
|
240
|
-
let n = 0;
|
|
241
|
-
let below = 0;
|
|
242
|
-
for (const el of document.querySelectorAll(sel)) {
|
|
243
|
-
const r = el.getBoundingClientRect();
|
|
244
|
-
if (r.width < 1 || r.height < 1) continue;
|
|
245
|
-
const cs = getComputedStyle(el);
|
|
246
|
-
if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) === 0) continue;
|
|
247
|
-
if (!(r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw)) {
|
|
248
|
-
below++;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (lines.length >= (opts.max || 150)) {
|
|
252
|
-
below++;
|
|
253
|
-
continue;
|
|
193
|
+
// The host actions.js runs on: chrome.tabs + chrome.debugger, in the user's own Chrome.
|
|
194
|
+
const host = {
|
|
195
|
+
cdp,
|
|
196
|
+
async attach(tabId) {
|
|
197
|
+
if (attached.has(tabId)) return;
|
|
198
|
+
try {
|
|
199
|
+
await chrome.debugger.attach({ tabId }, "1.3");
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (!/already attached/i.test(String(e?.message))) {
|
|
202
|
+
throw new Error(`cannot control this tab (${e?.message || e}) — Chrome pages (chrome://, the Web Store) cannot be automated`);
|
|
203
|
+
}
|
|
254
204
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "input" ? (["checkbox", "radio", "submit", "button"].includes(type) ? type : `textbox(${type})`) : tag);
|
|
260
|
-
const label = el.labels && el.labels[0] ? el.labels[0].innerText : "";
|
|
261
|
-
const name = clean(el.getAttribute("aria-label") || label || (tag === "input" || tag === "textarea" || tag === "select" ? "" : el.innerText) || el.getAttribute("placeholder") || el.getAttribute("title") || el.getAttribute("alt") || el.getAttribute("name") || "").slice(0, 80);
|
|
262
|
-
const bits = [];
|
|
263
|
-
if ((tag === "input" && !["checkbox", "radio", "submit", "button"].includes(type)) || tag === "textarea") bits.push(`value=${JSON.stringify(String(el.value).slice(0, 60))}`);
|
|
264
|
-
if (tag === "select") bits.push(`selected=${JSON.stringify(clean(el.options[el.selectedIndex]?.text))}`);
|
|
265
|
-
if (type === "checkbox" || type === "radio") bits.push(el.checked ? "checked" : "unchecked");
|
|
266
|
-
if (el.disabled || el.getAttribute("aria-disabled") === "true") bits.push("disabled");
|
|
267
|
-
if (tag === "a") bits.push(`→ ${clean(el.getAttribute("href")).slice(0, 60)}`);
|
|
268
|
-
if (el === document.activeElement) bits.push("focused");
|
|
269
|
-
lines.push(`[${ref}] ${role} ${JSON.stringify(name)}${bits.length ? ` ${bits.join(" ")}` : ""} @${Math.round(r.left + r.width / 2)},${Math.round(r.top + r.height / 2)}`);
|
|
270
|
-
}
|
|
271
|
-
const de = document.documentElement;
|
|
272
|
-
return {
|
|
273
|
-
url: location.href,
|
|
274
|
-
title: document.title,
|
|
275
|
-
viewport: `${vw}x${vh}`,
|
|
276
|
-
scroll: `${Math.round(scrollY)} of ${Math.max(0, de.scrollHeight - vh)}`,
|
|
277
|
-
elements: lines,
|
|
278
|
-
offscreen: below,
|
|
279
|
-
text: String((document.body && document.body.innerText) || "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, opts.maxText || 6000),
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
async function refPoint(tabId, ref) {
|
|
284
|
-
const p = await evaluate(tabId, `(() => {
|
|
285
|
-
const el = document.querySelector('[data-gu-ref="${Number(ref)}"]');
|
|
286
|
-
if (!el) return null;
|
|
287
|
-
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
288
|
-
const r = el.getBoundingClientRect();
|
|
289
|
-
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
290
|
-
})()`);
|
|
291
|
-
if (!p) throw new Error(`no element [${ref}] on the page now — read the page again for fresh numbers`);
|
|
292
|
-
return p;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// What is really under the point — an overlay or a cookie banner takes the click, not the button.
|
|
296
|
-
async function hitAt(tabId, x, y) {
|
|
297
|
-
return evaluate(tabId, `(() => {
|
|
298
|
-
const el = document.elementFromPoint(${x}, ${y});
|
|
299
|
-
if (!el) return "nothing";
|
|
300
|
-
const t = (el.innerText || el.value || el.getAttribute("aria-label") || "").replace(/\\s+/g, " ").trim().slice(0, 50);
|
|
301
|
-
return el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (t ? ' "' + t + '"' : "");
|
|
302
|
-
})()`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function point(tabId, cmd) {
|
|
306
|
-
if (cmd.ref !== undefined && cmd.ref !== null && cmd.ref !== "") return refPoint(tabId, cmd.ref);
|
|
307
|
-
if (Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) return { x: cmd.x, y: cmd.y };
|
|
308
|
-
throw new Error("say where: ref (a number from read) or x and y");
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async function mouse(tabId, x, y, button = "left", clicks = 1) {
|
|
312
|
-
await cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
313
|
-
for (let i = 1; i <= clicks; i++) {
|
|
314
|
-
await cdp(tabId, "Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount: i });
|
|
315
|
-
await cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount: i });
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ── keys ────────────────────────────────────────────────────────────────────────────────────
|
|
320
|
-
const KEYS = {
|
|
321
|
-
enter: ["Enter", "Enter", 13, "\r"], tab: ["Tab", "Tab", 9], escape: ["Escape", "Escape", 27], esc: ["Escape", "Escape", 27],
|
|
322
|
-
backspace: ["Backspace", "Backspace", 8], delete: ["Delete", "Delete", 46], space: [" ", "Space", 32, " "],
|
|
323
|
-
arrowup: ["ArrowUp", "ArrowUp", 38], arrowdown: ["ArrowDown", "ArrowDown", 40], arrowleft: ["ArrowLeft", "ArrowLeft", 37],
|
|
324
|
-
arrowright: ["ArrowRight", "ArrowRight", 39], up: ["ArrowUp", "ArrowUp", 38], down: ["ArrowDown", "ArrowDown", 40],
|
|
325
|
-
left: ["ArrowLeft", "ArrowLeft", 37], right: ["ArrowRight", "ArrowRight", 39], home: ["Home", "Home", 36], end: ["End", "End", 35],
|
|
326
|
-
pageup: ["PageUp", "PageUp", 33], pagedown: ["PageDown", "PageDown", 34],
|
|
327
|
-
};
|
|
328
|
-
const MODS = { alt: 1, option: 1, ctrl: 2, control: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
|
|
329
|
-
|
|
330
|
-
async function pressKey(tabId, combo) {
|
|
331
|
-
const parts = String(combo).split("+").map((s) => s.trim()).filter(Boolean);
|
|
332
|
-
let mods = 0;
|
|
333
|
-
for (const p of parts.slice(0, -1)) mods |= MODS[p.toLowerCase()] || 0;
|
|
334
|
-
const k = parts[parts.length - 1] || "";
|
|
335
|
-
// Select-all and friends are editor COMMANDS in Chrome: a bare ctrl+a does not select on a Mac.
|
|
336
|
-
const cmdName = (mods & 6) && /^[acvxz]$/i.test(k) ? { a: "selectAll", c: "copy", v: "paste", x: "cut", z: "undo" }[k.toLowerCase()] : null;
|
|
337
|
-
let key, code, keyCode, text;
|
|
338
|
-
if (KEYS[k.toLowerCase()]) [key, code, keyCode, text] = KEYS[k.toLowerCase()];
|
|
339
|
-
else if (k.length === 1) {
|
|
340
|
-
key = mods & 8 ? k.toUpperCase() : k;
|
|
341
|
-
code = /[a-z]/i.test(k) ? `Key${k.toUpperCase()}` : /\d/.test(k) ? `Digit${k}` : "";
|
|
342
|
-
keyCode = k.toUpperCase().charCodeAt(0);
|
|
343
|
-
text = key;
|
|
344
|
-
} else throw new Error(`unknown key "${k}" — use Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, Space or one character, with ctrl+ / shift+ / alt+ / meta+`);
|
|
345
|
-
if (mods & 7) text = undefined; // a shortcut types nothing
|
|
346
|
-
const base = { key, code, windowsVirtualKeyCode: keyCode, modifiers: mods };
|
|
347
|
-
await cdp(tabId, "Input.dispatchKeyEvent", { ...base, type: text ? "keyDown" : "rawKeyDown", text, ...(cmdName ? { commands: [cmdName] } : {}) });
|
|
348
|
-
await cdp(tabId, "Input.dispatchKeyEvent", { ...base, type: "keyUp" });
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// ── the commands ────────────────────────────────────────────────────────────────────────────
|
|
352
|
-
function sleep(ms) {
|
|
353
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async function settle(tabId) {
|
|
357
|
-
await sleep(350);
|
|
358
|
-
const t = await chrome.tabs.get(tabId).catch(() => null);
|
|
359
|
-
if (t && t.status === "loading") await waitLoaded(tabId, 15000);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
async function after(tabId, extra = {}) {
|
|
363
|
-
const t = await chrome.tabs.get(tabId).catch(() => null);
|
|
364
|
-
const errs = freshErrors.get(tabId) || [];
|
|
365
|
-
const dl = dialogs.get(tabId) || [];
|
|
366
|
-
freshErrors.delete(tabId);
|
|
367
|
-
dialogs.delete(tabId);
|
|
368
|
-
return { tab: tabId, url: t?.url || "", title: t?.title || "", errors: errs, dialogs: dl, ...extra };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
async function run(cmd) {
|
|
372
|
-
const a = String(cmd.action || "").toLowerCase();
|
|
373
|
-
if (a === "status") return { version: VERSION };
|
|
374
|
-
if (a === "open") {
|
|
375
|
-
const url = String(cmd.url || "");
|
|
376
|
-
if (!/^(https?|file):\/\//i.test(url) && !/^about:blank$/i.test(url)) throw new Error("open needs a full http(s):// or file:// url");
|
|
205
|
+
attached.add(tabId);
|
|
206
|
+
await enableDomains(cdp, tabId);
|
|
207
|
+
},
|
|
208
|
+
async open(url, newTab) {
|
|
377
209
|
const tabs = await liveTabs();
|
|
378
210
|
const s = await state();
|
|
379
|
-
let tab =
|
|
211
|
+
let tab = newTab ? null : tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1];
|
|
380
212
|
if (tab) tab = await chrome.tabs.update(tab.id, { url, active: true });
|
|
381
213
|
else {
|
|
382
214
|
tab = await chrome.tabs.create({ url, active: true });
|
|
@@ -384,100 +216,46 @@ async function run(cmd) {
|
|
|
384
216
|
}
|
|
385
217
|
await saveState({ current: tab.id });
|
|
386
218
|
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
if (a === "tabs") {
|
|
219
|
+
return tab.id;
|
|
220
|
+
},
|
|
221
|
+
async tabs() {
|
|
392
222
|
const s = await state();
|
|
393
|
-
return
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const id = Number(
|
|
397
|
-
|
|
398
|
-
if (!tab) throw new Error(`tab ${cmd.tab} is not one of gu's tabs — see action "tabs"`);
|
|
223
|
+
return (await liveTabs()).map((t) => ({ id: t.id, title: t.title, url: t.url, current: t.id === s.current }));
|
|
224
|
+
},
|
|
225
|
+
async switchTo(tabId) {
|
|
226
|
+
const id = Number(tabId);
|
|
227
|
+
if (!(await liveTabs()).some((t) => t.id === id)) return false;
|
|
399
228
|
await chrome.tabs.update(id, { active: true });
|
|
400
229
|
await saveState({ current: id });
|
|
401
|
-
return
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
await
|
|
407
|
-
return
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
return after(id, { at: `${Math.round(p.x)},${Math.round(p.y)}`, hit });
|
|
431
|
-
}
|
|
432
|
-
case "type": {
|
|
433
|
-
if (cmd.ref !== undefined && cmd.ref !== null && cmd.ref !== "") {
|
|
434
|
-
const p = await refPoint(id, cmd.ref);
|
|
435
|
-
await mouse(id, p.x, p.y);
|
|
436
|
-
if (cmd.clear) await pressKey(id, "ctrl+a");
|
|
437
|
-
}
|
|
438
|
-
await cdp(id, "Input.insertText", { text: String(cmd.text ?? "") });
|
|
439
|
-
if (cmd.submit) await pressKey(id, "Enter");
|
|
440
|
-
await settle(id);
|
|
441
|
-
return after(id);
|
|
442
|
-
}
|
|
443
|
-
case "key":
|
|
444
|
-
for (const k of String(cmd.text || "").split(/\s+/).filter(Boolean)) await pressKey(id, k);
|
|
445
|
-
await settle(id);
|
|
446
|
-
return after(id);
|
|
447
|
-
case "scroll": {
|
|
448
|
-
const m = await cdp(id, "Page.getLayoutMetrics");
|
|
449
|
-
const vp = m.cssVisualViewport || {};
|
|
450
|
-
const x = Number.isFinite(cmd.x) ? cmd.x : vp.clientWidth / 2;
|
|
451
|
-
const y = Number.isFinite(cmd.y) ? cmd.y : vp.clientHeight / 2;
|
|
452
|
-
const amount = Number(cmd.amount) || Math.round((vp.clientHeight || 800) * 0.8);
|
|
453
|
-
const dir = String(cmd.direction || "down").toLowerCase();
|
|
454
|
-
const dx = dir === "left" ? -amount : dir === "right" ? amount : 0;
|
|
455
|
-
const dy = dir === "up" ? -amount : dir === "down" ? amount : 0;
|
|
456
|
-
await cdp(id, "Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: dx, deltaY: dy });
|
|
457
|
-
await sleep(300);
|
|
458
|
-
return after(id, { scroll: await evaluate(id, "Math.round(scrollY) + ' of ' + Math.max(0, document.documentElement.scrollHeight - innerHeight)") });
|
|
459
|
-
}
|
|
460
|
-
case "back":
|
|
461
|
-
await evaluate(id, "history.back()");
|
|
462
|
-
await settle(id);
|
|
463
|
-
return after(id);
|
|
464
|
-
case "reload":
|
|
465
|
-
await cdp(id, "Page.reload", { ignoreCache: true });
|
|
466
|
-
await settle(id);
|
|
467
|
-
return after(id);
|
|
468
|
-
case "wait":
|
|
469
|
-
await sleep(Math.max(0, Math.min(Number(cmd.seconds) || 1, 30)) * 1000);
|
|
470
|
-
return after(id);
|
|
471
|
-
case "js": {
|
|
472
|
-
const value = await evaluate(id, String(cmd.text || ""));
|
|
473
|
-
return after(id, { value: value === undefined ? "undefined" : JSON.stringify(value)?.slice(0, 8000) });
|
|
474
|
-
}
|
|
475
|
-
case "console": {
|
|
476
|
-
const all = consoleBuf.get(id) || [];
|
|
477
|
-
consoleBuf.set(id, []);
|
|
478
|
-
return after(id, { console: all });
|
|
479
|
-
}
|
|
480
|
-
default:
|
|
481
|
-
throw new Error(`unknown action "${cmd.action}"`);
|
|
230
|
+
return true;
|
|
231
|
+
},
|
|
232
|
+
async current() {
|
|
233
|
+
const tabs = await liveTabs();
|
|
234
|
+
if (!tabs.length) throw new Error('gu has no tab open yet — use action "open" with a url first');
|
|
235
|
+
const s = await state();
|
|
236
|
+
return (tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1]).id;
|
|
237
|
+
},
|
|
238
|
+
async info(tabId) {
|
|
239
|
+
const t = await chrome.tabs.get(tabId).catch(() => null);
|
|
240
|
+
return t ? { url: t.url, title: t.title, status: t.status } : null;
|
|
241
|
+
},
|
|
242
|
+
async activate(tabId) {
|
|
243
|
+
await chrome.tabs.update(tabId, { active: true });
|
|
244
|
+
},
|
|
245
|
+
async close(tabId) {
|
|
246
|
+
await chrome.tabs.remove(tabId);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
async function run(cmd) {
|
|
251
|
+
const a = String(cmd.action || "").toLowerCase();
|
|
252
|
+
if (a === "status") return { version: VERSION, installType: await installType() };
|
|
253
|
+
if (a === "uninstall_self") {
|
|
254
|
+
// /browser cleanup (#147): a developer copy removes itself. Never the store copy — that one
|
|
255
|
+
// the user removes, or keeps.
|
|
256
|
+
if ((await installType()) !== "development") throw new Error("this is not a developer (unpacked) install — not removed");
|
|
257
|
+
setTimeout(() => chrome.management.uninstallSelf({ showConfirmDialog: false }).catch(() => {}), 200);
|
|
258
|
+
return { uninstalling: chrome.runtime.id };
|
|
482
259
|
}
|
|
260
|
+
return runAction(cmd, host);
|
|
483
261
|
}
|
|
Binary file
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "gu browser",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"description": "Lets the gu coding agent open, see and operate web pages in its own tabs (tab group \"gu\") with a real mouse and keyboard.",
|
|
6
6
|
"minimum_chrome_version": "116",
|
|
7
7
|
"icons": {
|
|
8
8
|
"16": "icons/icon16.png",
|
|
9
|
+
"32": "icons/icon32.png",
|
|
9
10
|
"48": "icons/icon48.png",
|
|
10
11
|
"128": "icons/icon128.png"
|
|
11
12
|
},
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
"default_popup": "popup.html",
|
|
29
30
|
"default_icon": {
|
|
30
31
|
"16": "icons/icon16.png",
|
|
32
|
+
"32": "icons/icon32.png",
|
|
31
33
|
"48": "icons/icon48.png",
|
|
32
34
|
"128": "icons/icon128.png"
|
|
33
35
|
}
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
<h1>gu browser</h1>
|
|
17
17
|
<div id="status"><span class="dot"></span><span id="label">checking…</span></div>
|
|
18
18
|
<p id="help"></p>
|
|
19
|
+
<div id="tab" style="margin-top:12px;display:none">
|
|
20
|
+
<div id="tabLabel" style="color:inherit"></div>
|
|
21
|
+
<button id="share">Let gu use this tab</button>
|
|
22
|
+
</div>
|
|
19
23
|
<button id="retry">Connect now</button>
|
|
20
24
|
<script src="popup.js"></script>
|
|
21
25
|
</body>
|
|
@@ -20,3 +20,26 @@ document.getElementById("retry").onclick = () => {
|
|
|
20
20
|
chrome.runtime.sendMessage({ type: "reconnect" }, () => setTimeout(check, 600));
|
|
21
21
|
};
|
|
22
22
|
check();
|
|
23
|
+
|
|
24
|
+
// "Let gu use this tab" (#150): hand gu one of your own tabs — it joins the "gu" group and the
|
|
25
|
+
// agent can use it; "Stop sharing" takes it back. Chrome's own pages cannot be shared.
|
|
26
|
+
const tabBox = document.getElementById("tab");
|
|
27
|
+
const tabLabel = document.getElementById("tabLabel");
|
|
28
|
+
const shareBtn = document.getElementById("share");
|
|
29
|
+
|
|
30
|
+
async function showTab() {
|
|
31
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
32
|
+
if (!tab || !/^(https?|file):/i.test(tab.url || "")) return;
|
|
33
|
+
const { shared } = await chrome.runtime.sendMessage({ type: "tabState", tabId: tab.id });
|
|
34
|
+
tabBox.style.display = "block";
|
|
35
|
+
tabLabel.textContent = shared ? "gu can use this tab." : "This tab is yours — gu cannot see it.";
|
|
36
|
+
shareBtn.textContent = shared ? "Stop sharing this tab" : "Let gu use this tab";
|
|
37
|
+
shareBtn.onclick = async () => {
|
|
38
|
+
shareBtn.disabled = true;
|
|
39
|
+
const r = await chrome.runtime.sendMessage({ type: shared ? "unshare" : "share", tabId: tab.id });
|
|
40
|
+
shareBtn.disabled = false;
|
|
41
|
+
if (r && r.ok === false) tabLabel.textContent = r.error;
|
|
42
|
+
else showTab();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
showTab();
|
package/client-expert-api.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
21
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
22
22
|
import { spawnSurviving, pidOnPort } from "./platform-tools.mjs";
|
|
23
|
+
import { procCommand, procStart, sameProcess } from "./proc-identity.mjs";
|
|
23
24
|
import { DEFAULT_API_BASE as HOSTED_API_BASE } from "./device-login.mjs";
|
|
24
25
|
import { spawn } from "node:child_process";
|
|
25
26
|
import { writeFile, mkdir, readFile, access } from "node:fs/promises";
|
|
@@ -113,6 +114,46 @@ export function localMongoUri({ env = process.env, home = homedir() } = {}) {
|
|
|
113
114
|
|
|
114
115
|
const GONEXT_DIR = join(homedir(), ".gonext");
|
|
115
116
|
export const API_PID_FILE = join(GONEXT_DIR, "local-api.pid");
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* THE PID FILE CARRIES THE PROCESS'S START TIME (#373, GitHub #154): "<pid>\n<start epoch s>\n".
|
|
120
|
+
* The file outlives the API (a crash, a reboot), and its id may belong to another program by the
|
|
121
|
+
* time `gu api stop`, restart, upgrade or reset reads it. The start time, read from ps when the
|
|
122
|
+
* file is written, is what tells them apart. Older files hold only the pid.
|
|
123
|
+
*/
|
|
124
|
+
export async function writeApiPid(pid, { path = API_PID_FILE } = {}) {
|
|
125
|
+
if (!pid) return;
|
|
126
|
+
const t = procStart(pid);
|
|
127
|
+
await writeFile(path, `${pid}\n${Number.isFinite(t) ? t : ""}\n`).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** { pid, started } from the pid file; pid null when there is none. */
|
|
131
|
+
export async function readApiPid({ path = API_PID_FILE } = {}) {
|
|
132
|
+
const [a, b] = String(await readFile(path, "utf8").catch(() => "")).split(/\s+/);
|
|
133
|
+
const pid = Number(a);
|
|
134
|
+
const started = b ? Number(b) : NaN;
|
|
135
|
+
return { pid: Number.isInteger(pid) && pid > 0 ? pid : null, started: Number.isFinite(started) ? started : null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The pid to stop — only while it is still the local API. With a start time: the same process
|
|
140
|
+
* started then. An older file without one: the process must look like the API (node … index.js).
|
|
141
|
+
* { pid } to signal, or { pid: null, why } — never a guess.
|
|
142
|
+
*/
|
|
143
|
+
export async function apiPidToStop({ path = API_PID_FILE, platform = process.platform, startOf = procStart, commandOf = procCommand } = {}) {
|
|
144
|
+
const { pid, started } = await readApiPid({ path });
|
|
145
|
+
if (!pid) return { pid: null, why: "no local API pid on record" };
|
|
146
|
+
if (platform === "win32") return { pid };
|
|
147
|
+
if (started != null) {
|
|
148
|
+
const same = sameProcess(pid, started, { before: 2, after: 2, startOf });
|
|
149
|
+
if (same === "same") return { pid };
|
|
150
|
+
if (same === "gone") return { pid: null, why: "not running" };
|
|
151
|
+
if (same === "other") return { pid: null, why: `pid ${pid} is now another program, not the local API — left alone` };
|
|
152
|
+
}
|
|
153
|
+
const cmd = commandOf(pid, { platform });
|
|
154
|
+
if (!cmd) return { pid: null, why: "not running" };
|
|
155
|
+
return /\bnode\b.*\bindex\.js\b/.test(cmd) ? { pid } : { pid: null, why: `pid ${pid} is now another program, not the local API — left alone` };
|
|
156
|
+
}
|
|
116
157
|
export const API_LOG_FILE = join(GONEXT_DIR, "local-api.log");
|
|
117
158
|
|
|
118
159
|
/** The API authenticates a worker by sha256 of the key — same function it uses server-side. */
|
|
@@ -528,14 +569,14 @@ export async function startLocalApi({
|
|
|
528
569
|
// serves — which is the better question anyway: what matters is who is answering, not who
|
|
529
570
|
// we spawned. Recorded after the server is up, by the caller's waitForApi.
|
|
530
571
|
const pid = res.pid ?? null;
|
|
531
|
-
|
|
572
|
+
await writeApiPid(pid);
|
|
532
573
|
return { pid, how: res.how, port };
|
|
533
574
|
}
|
|
534
575
|
|
|
535
576
|
/** Record whoever is serving the port, so `api stop` has something to stop. */
|
|
536
577
|
export async function recordApiPid(port = LOCAL_API_PORT) {
|
|
537
578
|
const pid = await pidOnPort(port);
|
|
538
|
-
|
|
579
|
+
await writeApiPid(pid);
|
|
539
580
|
return pid;
|
|
540
581
|
}
|
|
541
582
|
|