@hover-dev/core 0.2.0 → 0.2.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"cdpStatus.d.ts","sourceRoot":"","sources":["../../src/playwright/cdpStatus.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,QAAQ,GAAG,aAAa,GAAG,cAAc,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,eAAe,CAAC,CA4B1B;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAiC3E"}
1
+ {"version":3,"file":"cdpStatus.d.ts","sourceRoot":"","sources":["../../src/playwright/cdpStatus.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,QAAQ,GAAG,aAAa,GAAG,cAAc,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,eAAe,CAAC,CA4B1B;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA8C3E"}
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { chromium } from 'playwright-core';
15
15
  import { preflightCDP } from './preflight.js';
16
+ import { findCdpPid, raiseChromeWindow } from './raiseWindow.js';
16
17
  /**
17
18
  * Parse a page URL down to its origin (protocol + host + port). We compare
18
19
  * by origin, not full URL — the user might be on /login while the debug
@@ -72,25 +73,47 @@ export async function focusDebugTab(cdpUrl, pageUrl) {
72
73
  const msg = err instanceof Error ? err.message : String(err);
73
74
  return { ok: false, reason: `couldn't connect to CDP at ${cdpUrl}: ${msg}` };
74
75
  }
76
+ let focusedUrl;
75
77
  try {
76
78
  const pages = browser.contexts().flatMap(c => c.pages());
77
79
  const match = pages.find(p => originOf(p.url()) === wantOrigin);
78
80
  if (match) {
79
81
  await match.bringToFront();
80
- return { ok: true, focusedUrl: match.url() };
82
+ focusedUrl = match.url();
83
+ }
84
+ else {
85
+ // No tab on the dev origin yet — open one so the widget appears.
86
+ const context = browser.contexts()[0] ?? (await browser.newContext());
87
+ const page = await context.newPage();
88
+ await page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
89
+ await page.bringToFront();
90
+ focusedUrl = page.url();
81
91
  }
82
- // No tab on the dev origin yet — open one so the widget appears.
83
- const context = browser.contexts()[0] ?? (await browser.newContext());
84
- const page = await context.newPage();
85
- await page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
86
- await page.bringToFront();
87
- return { ok: true, focusedUrl: page.url() };
88
92
  }
89
93
  catch (err) {
94
+ await browser.close().catch(() => { });
90
95
  const msg = err instanceof Error ? err.message : String(err);
91
96
  return { ok: false, reason: `bringToFront failed: ${msg}` };
92
97
  }
93
- finally {
94
- await browser.close().catch(() => { });
98
+ await browser.close().catch(() => { });
99
+ // CDP-level bringToFront only activates the tab inside the Chrome process;
100
+ // on macOS in particular the Chrome *window* stays buried if it wasn't
101
+ // foreground already. Raise the OS window too. Best-effort, never fatal.
102
+ const port = portFromCdpUrl(cdpUrl);
103
+ if (port !== null) {
104
+ const pid = await findCdpPid(port);
105
+ if (pid !== null)
106
+ await raiseChromeWindow(pid);
107
+ }
108
+ return { ok: true, focusedUrl };
109
+ }
110
+ function portFromCdpUrl(cdpUrl) {
111
+ try {
112
+ const u = new URL(cdpUrl);
113
+ const port = Number.parseInt(u.port, 10);
114
+ return Number.isInteger(port) && port > 0 ? port : null;
115
+ }
116
+ catch {
117
+ return null;
95
118
  }
96
119
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Find the OS PID of the process listening on the given TCP port.
3
+ * Returns null if nothing is listening or the lookup tool isn't available.
4
+ */
5
+ export declare function findCdpPid(port: number): Promise<number | null>;
6
+ /**
7
+ * Raise the Chrome window owned by `pid` to the OS foreground. Best-effort.
8
+ */
9
+ export declare function raiseChromeWindow(pid: number): Promise<void>;
10
+ //# sourceMappingURL=raiseWindow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"raiseWindow.d.ts","sourceRoot":"","sources":["../../src/playwright/raiseWindow.ts"],"names":[],"mappings":"AAyBA;;;GAGG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA8BrE;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAwClE"}
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Raise the OS-level Chrome window to the foreground.
3
+ *
4
+ * Why this exists: CDP's `Page.bringToFront()` and `Target.activateTarget`
5
+ * only reorder tabs *inside* the Chrome process — they do not raise the
6
+ * Chrome application window in the OS's window stack. When the user clicks
7
+ * "Switch me to it" from a widget hosted in a different window, the tab
8
+ * activates correctly inside the (possibly background) debug Chrome, but
9
+ * the window stays buried. The user then has to manually click the Chrome
10
+ * Dock icon / Alt-Tab to it, which defeats the point of the button.
11
+ *
12
+ * Fix: after `bringToFront()`, run an OS-specific command that raises the
13
+ * specific Chrome *process* (matched by PID, found from the CDP port via
14
+ * `lsof` / `netstat`). PID-matching is critical — the user's own primary
15
+ * Chrome and Hover's debug Chrome are both "Google Chrome" to AppleScript,
16
+ * so raising by app name would risk activating the wrong window.
17
+ *
18
+ * Best-effort and non-blocking — if the helper fails, we still leave the
19
+ * tab correctly focused inside the debug Chrome and the user can click
20
+ * over manually like before. Logging is to stderr only; this never throws
21
+ * back to the caller.
22
+ */
23
+ import { spawn } from 'node:child_process';
24
+ import { platform } from 'node:os';
25
+ /**
26
+ * Find the OS PID of the process listening on the given TCP port.
27
+ * Returns null if nothing is listening or the lookup tool isn't available.
28
+ */
29
+ export async function findCdpPid(port) {
30
+ const os = platform();
31
+ if (os === 'darwin' || os === 'linux') {
32
+ // -t prints just PIDs, -sTCP:LISTEN filters to listeners (otherwise
33
+ // every client connection's PID would show up too).
34
+ const out = await runCapture('lsof', ['-tiTCP:' + port, '-sTCP:LISTEN']);
35
+ if (!out)
36
+ return null;
37
+ // lsof may print multiple lines if forked workers also hold the port;
38
+ // take the first numeric one.
39
+ for (const line of out.split('\n')) {
40
+ const pid = Number.parseInt(line.trim(), 10);
41
+ if (Number.isInteger(pid) && pid > 0)
42
+ return pid;
43
+ }
44
+ return null;
45
+ }
46
+ if (os === 'win32') {
47
+ // `netstat -ano` columns: Proto Local Foreign State PID
48
+ const out = await runCapture('netstat', ['-ano']);
49
+ if (!out)
50
+ return null;
51
+ for (const line of out.split('\n')) {
52
+ // Match `TCP 127.0.0.1:9222 0.0.0.0:0 LISTENING 1234`
53
+ const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i);
54
+ if (m && Number(m[1]) === port)
55
+ return Number(m[2]);
56
+ }
57
+ return null;
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Raise the Chrome window owned by `pid` to the OS foreground. Best-effort.
63
+ */
64
+ export async function raiseChromeWindow(pid) {
65
+ const os = platform();
66
+ try {
67
+ if (os === 'darwin') {
68
+ // System Events can frontmost any process by its unix PID, regardless
69
+ // of app bundle. This works even when several "Google Chrome"
70
+ // processes coexist (user's primary + Hover's debug).
71
+ await runDetached('osascript', [
72
+ '-e',
73
+ `tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true`,
74
+ ]);
75
+ return;
76
+ }
77
+ if (os === 'linux') {
78
+ // wmctrl is the most common helper for X11; not always installed,
79
+ // but the alternative (xdotool) needs the same dependency story.
80
+ // We try wmctrl with the PID match; if it isn't installed the
81
+ // outer try/catch swallows the ENOENT and we degrade gracefully.
82
+ await runDetached('wmctrl', ['-ia', String(pid)]);
83
+ return;
84
+ }
85
+ if (os === 'win32') {
86
+ // PowerShell is bundled with Windows 10+. AppActivate is best-effort:
87
+ // it requires the target to have a visible main window, which a
88
+ // headless-less Chrome with a tab open satisfies.
89
+ const ps = `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; ` +
90
+ `if ($p) { ` +
91
+ ` Add-Type -AssemblyName Microsoft.VisualBasic; ` +
92
+ ` [Microsoft.VisualBasic.Interaction]::AppActivate($p.Id) ` +
93
+ `}`;
94
+ await runDetached('powershell', ['-NoProfile', '-Command', ps]);
95
+ return;
96
+ }
97
+ }
98
+ catch {
99
+ // Best-effort. CDP-level bringToFront already ran; user can still
100
+ // click the Chrome window manually.
101
+ }
102
+ }
103
+ function runCapture(cmd, args) {
104
+ return new Promise(resolve => {
105
+ let out = '';
106
+ const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
107
+ child.stdout.on('data', chunk => {
108
+ out += chunk.toString();
109
+ });
110
+ child.on('error', () => resolve(null));
111
+ child.on('close', code => resolve(code === 0 ? out : null));
112
+ });
113
+ }
114
+ function runDetached(cmd, args) {
115
+ return new Promise((resolve, reject) => {
116
+ const child = spawn(cmd, args, { stdio: 'ignore' });
117
+ child.on('error', reject);
118
+ child.on('close', code => {
119
+ // Don't treat non-zero as fatal — caller already wraps in try/catch.
120
+ resolve();
121
+ void code;
122
+ });
123
+ });
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hover-dev/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Hover's local Node service: agent invocation, Playwright CDP preflight, WebSocket bridge.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hyperyond",