@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.
@@ -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") and the
9
- // popups those open. The user's other tabs are never read, captured or controlled.
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
- setTimeout(connect, 3000);
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
- consoleBuf.delete(tabId);
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
- async function evaluate(tabId, expression) {
222
- const r = await cdp(tabId, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, userGesture: true });
223
- if (r.exceptionDetails) {
224
- const d = r.exceptionDetails;
225
- throw new Error(`page script error: ${d.exception?.description || d.text}`);
226
- }
227
- return r.result?.value;
228
- }
229
-
230
- // ── reading the page ────────────────────────────────────────────────────────────────────────
231
- // Runs IN the page. Numbers the visible interactive elements (data-gu-ref) so the agent can say
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
- const ref = ++n;
256
- el.setAttribute("data-gu-ref", String(ref));
257
- const tag = el.tagName.toLowerCase();
258
- const type = tag === "input" ? (el.getAttribute("type") || "text").toLowerCase() : "";
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 = cmd.new_tab ? null : tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1];
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
- await attach(tab.id);
388
- await waitLoaded(tab.id);
389
- return after(tab.id);
390
- }
391
- if (a === "tabs") {
219
+ return tab.id;
220
+ },
221
+ async tabs() {
392
222
  const s = await state();
393
- return { tabs: (await liveTabs()).map((t) => ({ id: t.id, title: t.title, url: t.url, current: t.id === s.current })) };
394
- }
395
- if (a === "switch") {
396
- const id = Number(cmd.tab);
397
- const tab = (await liveTabs()).find((t) => t.id === id);
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 after(id);
402
- }
403
- const tab = await currentTab();
404
- const id = tab.id;
405
- if (a === "close") {
406
- await chrome.tabs.remove(id);
407
- return { closed: id };
408
- }
409
- await attach(id);
410
- switch (a) {
411
- case "read":
412
- return { ...(await evaluate(id, `(${readPage})(${JSON.stringify({ maxText: cmd.max_text || 6000 })})`)), ...(await after(id)) };
413
- case "screenshot": {
414
- // Make it the visible tab: a background tab is not painted.
415
- await chrome.tabs.update(id, { active: true });
416
- const m = await cdp(id, "Page.getLayoutMetrics");
417
- const vp = m.cssVisualViewport || m.visualViewport || {};
418
- const shot = await cdp(id, "Page.captureScreenshot", { format: "jpeg", quality: 80 });
419
- return { ...(await after(id)), image: shot.data, mime: "image/jpeg", width: Math.round(vp.clientWidth), height: Math.round(vp.clientHeight) };
420
- }
421
- case "click":
422
- case "double_click":
423
- case "right_click":
424
- case "hover": {
425
- const p = await point(id, cmd);
426
- const hit = await hitAt(id, p.x, p.y);
427
- if (a === "hover") await cdp(id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y });
428
- else await mouse(id, p.x, p.y, a === "right_click" ? "right" : "left", a === "double_click" ? 2 : 1);
429
- await settle(id);
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
  }
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "gu browser",
4
- "version": "1.0.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();
@@ -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
- if (pid) await writeFile(API_PID_FILE, String(pid) + "\n").catch(() => {});
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
- if (pid) await writeFile(API_PID_FILE, String(pid) + "\n").catch(() => {});
579
+ await writeApiPid(pid);
539
580
  return pid;
540
581
  }
541
582