browserzap 0.4.0 → 0.5.5

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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.5 (2026-10-30)
4
+
5
+ Comment mode, debugging tools, files, Edge and WSL, a much better doctor, and the Chrome Web Store build. Known items to review are listed in docs/REVIEW_LATER.md (upload approval visibility, a file the person picks themselves, a download re-test, wider Comment screenshots, a calmer side panel).
6
+
7
+ - No more stuck file dialogs: when the agent clicks a page's "Choose file" control, no Windows file picker opens; the result says the page asked for a file and to use `file_upload`. Your own clicks open the picker as usual. If Chrome is set to ask where to save each download, `browserzap doctor` and `browser_downloads` say so and how to turn it off (an extension cannot skip that dialog).
8
+ - Messages for people who installed from npm: the side panel, the extension and the agent no longer point to source-only commands (`npm run health`, `npm run bridge:restart`, …) but to `browserzap doctor`, `browserzap restart-bridge`, `browserzap --print-token`. When a bridge from another install holds the port, the side panel now says so and shows the fix instead of staying grey.
9
+ - Agents are told about Comment mode, console/network, downloads and uploads when they connect.
10
+ - The helper accepts the Chrome Web Store build of the extension (id bbnmhaonicbmljhbahimpgfafbjoijpe) as well as the unpacked one; setup and the README point to the store listing first (in review).
11
+ - Fixed: with `BROWSERZAP_PROFILE` set to a profile name such as "Work", auto-launch asked Chrome for a profile folder of that name, which made Chrome create a new, empty profile. It now opens the matching profile (looked up in Chrome's profile list), or Chrome's last profile when there is no match.
12
+ - **Downloads**: a click that starts a download now says so ("It started a download: invoice.pdf"), and the new `browser_downloads` tool lists what was downloaded during the session — name, size, full path, still downloading or complete — and can wait for a download to finish. No new extension permission; set `BROWSERZAP_DOWNLOAD_DIR` if Chrome saves somewhere other than your Downloads folder.
13
+ - **`file_upload`**: your agent can attach a file from your computer to a site's "Choose file" input, including the common kind hidden behind a styled button. You approve that exact file (name and size) in the side panel every time; `BROWSERZAP_UPLOAD_DIR` can limit uploads to one folder.
14
+ - A screenshot of an element with no visible box now says so instead of failing with an internal error.
15
+ - **WSL**: use BrowserZap from Claude Code running inside WSL. The agent starts the Windows helper through interop (claude mcp add browserzap -- cmd.exe /c browserzap); setup prints the line when the machine has WSL, and starting BrowserZap inside WSL explains what to do instead (docs/WSL.md).
16
+ - **Microsoft Edge**: `browserzap setup` registers the helper for Edge as well as Chrome (Windows registry; the Edge folders on macOS and Linux), and `browserzap doctor` checks it. Edge installs Chrome Web Store extensions and loads the same unpacked folder with the same id. Not yet tested in a real Edge; auto-launch still opens Chrome.
17
+ - **Comment mode** (free): in the side panel press **Comment on this page**, click elements, write what should change, add images, and send every note to your agent at once. The agent reads them with the new `feedback_get` tool: the element, its component and source file on development builds (React, Vue, Svelte, Angular) and a screenshot with password and card fields covered. Saved only on your computer (docs/COMMENT_MODE.md).
18
+ - `browserzap doctor` now explains three silent ways the side panel stays grey: the helper launcher points at a Node that is gone (after switching Node with fnm, Volta or nvm), a Chrome enterprise policy blocks the helper or the extension, or a bridge with a different pairing secret holds the port. New `browserzap restart-bridge` replaces that bridge.
19
+ - **`page_console` and `page_network`** (free): your agent can read the page's console messages (logs, warnings, errors, uncaught exceptions, failed loads) and its requests (status, timing, size, failures) while it debugs your app. Headers, cookies and bodies are never read; other sites' URLs lose their query string.
20
+ - The extension zip on each GitHub release is ready for the Chrome Web Store: it leaves out the manifest `key`, which the store refuses (`npm run package:store` builds it locally).
21
+ - The helper can let in a second extension id, for the Chrome Web Store build, next to the Load-unpacked one; `browserzap doctor` checks both once it is set.
22
+
23
+ ## 0.5.0 (2026-10-29)
24
+
25
+ - Chrome's "BrowserZap started debugging this browser" bar now goes away: when the agent ends, when you take the tab (Stop or a hand-off), and after the agent has not used the tab for a minute. It comes back only while the agent works (D-35).
26
+ - No more piles of tabs. An agent works in one main tab and closes the helper tabs it opens for side trips (new tool `browser_close_tab`); tabs a site opens from the agent's tab count as helpers. At most 5 tabs while it works, 3 once it is idle, and **none** after the agent ends or its connection is lost for 30 s. A tab you are looking at, or drag out of the BrowserZap group, is never closed (D-36, D-38).
27
+
3
28
  ## 0.4.0 — first public release (2026-10-28)
4
29
 
5
30
  The first version published on npm (`npm install -g browserzap`, then `browserzap setup`).
package/README.md CHANGED
@@ -2,23 +2,27 @@
2
2
 
3
3
  **Let the AI agent on your computer use your own Chrome — your tab, your logins — while you watch and stay in control.**
4
4
 
5
- Website: <https://browserzap.com> · Contact: <hello@browserzap.com> · Security: <security@browserzap.com>
5
+ Website and docs: <https://browserzap.com/docs> · Contact: <hello@browserzap.com> · Security: <security@browserzap.com>
6
6
 
7
7
  BrowserZap connects an MCP agent (Claude Code, Codex CLI, Cursor, …) to the Chrome you already use. There is no cloud
8
8
  browser, no copied cookies and no new login: the agent works in your real tab, and you can see every step.
9
9
 
10
10
  - **Exact, not approximate.** Every click and fill is resolved to exactly one element by its role and name. If two
11
11
  elements match, nothing happens and the agent is told which ones.
12
- - **You approve what matters.** Submits, payments, deletions and posts wait for **Approve once** in the side panel.
12
+ - **You approve what matters.** Submits, payments, deletions, posts and file uploads wait for **Approve once** in the
13
+ side panel.
13
14
  - **Your passwords stay yours.** The agent never types a password, one-time code or card number. The page asks you
14
15
  to (**Your turn** → **Done**), and what you type is never logged.
15
16
  - **Stop at any time.** A pill on the page and a switch in the side panel take the tab back at once.
17
+ - **Comment mode.** Click what is wrong on any page, write a note, and send every note to your coding agent at once —
18
+ with the component, source file and a screenshot of each element.
19
+ - **Debug with your agent.** It reads the page's console errors and failed network requests itself.
16
20
  - **Record once, replay many.** Save a task as a workflow and run it again with new values, step by step and checked.
17
21
 
18
22
  ## Requirements
19
23
 
20
24
  - Windows 10 or 11 (macOS and Linux are not supported yet)
21
- - Google Chrome
25
+ - Google Chrome (Microsoft Edge works too)
22
26
  - Node.js 22 or newer; 24 recommended (<https://nodejs.org>)
23
27
  - An MCP client: Claude Code, Codex CLI, Cursor or another
24
28
 
@@ -29,64 +33,82 @@ npm install -g browserzap
29
33
  browserzap setup
30
34
  ```
31
35
 
32
- `browserzap setup` does three things:
36
+ `browserzap setup` does three things, and is safe to run again:
33
37
 
34
- 1. Registers a small helper with Chrome for your Windows user only (no administrator rights), so the extension
35
- connects by itself. There is no token to copy.
38
+ 1. Registers a small helper with Chrome and Edge for your Windows user only (no administrator rights), so the
39
+ extension connects by itself. There is no token to copy.
36
40
  2. Copies the BrowserZap extension to `%USERPROFILE%\BrowserZap\extension` and prints that path.
37
41
  3. Finds Claude Code, Codex CLI and Cursor, shows the exact entry it would add for BrowserZap, and adds it only after
38
42
  you answer `y`.
39
43
 
40
- Then load the extension (Chrome does not allow this step to be automated):
44
+ Then add the extension to Chrome:
41
45
 
42
- 1. Open `chrome://extensions` and turn on **Developer mode**.
43
- 2. Click **Load unpacked** and choose the folder `setup` printed.
44
- 3. Click the BrowserZap icon to open the side panel. It turns green when it is connected.
46
+ - **From the Chrome Web Store** (in review; this link works once it is published):
47
+ <https://chromewebstore.google.com/detail/bbnmhaonicbmljhbahimpgfafbjoijpe>. Chrome keeps it updated.
48
+ - **Until then, load it unpacked** (Chrome does not allow this step to be automated):
49
+ 1. Open `chrome://extensions` and turn on **Developer mode**.
50
+ 2. Click **Load unpacked** and choose the folder `setup` printed.
45
51
 
46
- A Chrome Web Store listing is on its way; until then the extension is loaded from the package as above.
47
-
48
- Check everything with:
52
+ Click the BrowserZap icon to open the side panel. It turns green when it is connected. Check everything with:
49
53
 
50
54
  ```cmd
51
55
  browserzap doctor
52
56
  ```
53
57
 
58
+ Using Claude Code inside WSL? BrowserZap stays on Windows; in WSL run
59
+ `claude mcp add browserzap -- cmd.exe /c browserzap` (details: <https://browserzap.com/docs/platforms>).
60
+
54
61
  ## Use it
55
62
 
56
63
  Restart your MCP client and ask for something in the browser, for example: *"Open example.com and tell me what the
57
64
  main heading says."* If Chrome is closed, the first browser request opens it.
58
65
 
59
- While the agent works, a small cursor shows where it is acting, and a pill at the top of the page says the agent is
60
- working. Press **Stop** on the pill, or switch control in the side panel, to take over.
66
+ While the agent works, a small cursor shows where it is acting, a pill at the top of the page says the agent is
67
+ working, and its tabs sit in a labelled **BrowserZap** tab group that it cleans up when it is done. Press **Stop** on
68
+ the pill, or switch control in the side panel, to take over.
61
69
 
62
70
  Things to try:
63
71
 
64
72
  - *"Search Wikipedia for 'Model Context Protocol' and summarise the first paragraph."*
65
73
  - *"In my GitHub notifications, list the unread ones from this week."* (uses your existing login)
66
74
  - *"Fill in the contact form on this page with my details, but let me approve before sending."*
67
- - *"Record a workflow called `export-report` while I show you how to download last month's report."*
75
+ - *"Open my app on localhost:3000, click Save, and tell me why it fails — check the console and the network."*
76
+ - In the side panel: **Comments → Comment on this page**, click a few elements, write notes, **Send**, then tell your
77
+ agent *"Apply my BrowserZap feedback"*.
78
+
79
+ Every tool and option: <https://browserzap.com/docs/tools>.
68
80
 
69
81
  ## Troubleshooting
70
82
 
83
+ Start with `browserzap doctor`: every failed line says what to do.
84
+
71
85
  | You see | Do this |
72
86
  |---|---|
73
- | The side panel stays grey or says *Not paired yet* | Run `browserzap setup`, then press the reload icon on the BrowserZap card in `chrome://extensions`. `browserzap doctor` says which part is missing |
74
- | Chrome says *Disable developer mode extensions* on start-up | Expected while the extension is loaded unpacked. Choose **Keep**; the Chrome Web Store version will not show it |
75
- | A bar says *"BrowserZap" started debugging this browser* | Expected: Chrome shows it whenever an agent can act. Press **Stop** in the page to end control |
87
+ | The side panel stays grey or says *Not paired yet* | Run `browserzap setup`, then reload the extension in `chrome://extensions` |
88
+ | Doctor: *a bridge with a different pairing secret holds 127.0.0.1:9334* | `browserzap restart-bridge`, then **Reconnect** in the side panel |
89
+ | Doctor: *Launcher finds Node and the helper* fails | Node moved (a version switch with fnm, Volta or nvm): run `browserzap setup` again |
90
+ | Doctor: *Not blocked by Chrome policy* fails | Your computer is managed; ask IT to allow the helper `com.browserzap.bridge` and the extension |
91
+ | Chrome says *Disable developer mode extensions* on start-up | Expected while the extension is loaded unpacked. Choose **Keep**; the Chrome Web Store version does not show it |
92
+ | A bar says *"BrowserZap" started debugging this browser* | Expected while an agent can act; it goes away when the agent ends or after a minute idle |
76
93
  | Your agent does not list BrowserZap's tools | Restart the agent app after `browserzap setup`. To add it by hand, `browserzap --help` prints the exact MCP configuration |
77
- | A step stops with *"needs you"* | The page asked for a password, a code or an approval. Do it in the page, press **Done**, and the agent continues |
78
- | Anything else | Run `browserzap doctor` and email <hello@browserzap.com> with its output (it contains no passwords or page content) |
94
+ | A step stops with *"needs you"* | The page asked for a password, a code or an approval. Do it, press **Done**, and the agent continues |
95
+ | Anything else | Email <hello@browserzap.com> with the output of `browserzap doctor` (it contains no passwords or page content) |
96
+
97
+ More: <https://browserzap.com/docs/troubleshooting>.
79
98
 
80
99
  ## Commands
81
100
 
82
101
  | Command | What it does |
83
102
  |---|---|
84
103
  | `browserzap setup` | One-time setup (above). Safe to run again |
85
- | `browserzap doctor` | Checks the helper registration, the extension id and the connection |
104
+ | `browserzap doctor` | Checks the helper registration, the launcher, Chrome policy, the extension id and the connection |
105
+ | `browserzap restart-bridge` | Replaces a bridge that will not pair (another install's secret or an old version) |
86
106
  | `browserzap run <workflow> --params rows.csv` | Runs a saved workflow without an agent (one row on the free plan) |
87
107
  | `browserzap uninstall` | Removes the helper registration and the extension copy; keeps your data |
88
108
  | `browserzap uninstall --purge` | Also removes your BrowserZap data folder (cannot be undone) |
89
109
 
110
+ Environment variables (allowed sites, profile, upload folder, retention, …): <https://browserzap.com/docs/configuration>.
111
+
90
112
  ## Update
91
113
 
92
114
  ```cmd
@@ -94,25 +116,26 @@ npm install -g browserzap@latest
94
116
  browserzap setup
95
117
  ```
96
118
 
97
- Then press the reload icon on the BrowserZap card in `chrome://extensions`. Your workflows, run reports and settings
98
- are kept.
119
+ A Web Store install updates itself; an unpacked one needs the reload icon on its card in `chrome://extensions`. Your
120
+ workflows, run reports and settings are kept.
99
121
 
100
122
  ## Free and Pro
101
123
 
102
- The free plan includes every browser tool and every safety control, plus up to 10 saved workflows. BrowserZap Pro
103
- (running a workflow over many rows, workflow history, run reports) is coming soon. Safety controls are never paid
104
- features.
124
+ Every live browser tool, Comment mode, console and network reading, and every safety control are free; today up to
125
+ 10 saved workflows are free too. BrowserZap Pro — automation at scale: workflows over many rows, background runs,
126
+ history and run reports — is coming, as a monthly, yearly or lifetime licence. Any change to plans is announced on
127
+ <https://browserzap.com> first. Safety controls are never paid features.
105
128
 
106
129
  ## Privacy
107
130
 
108
131
  BrowserZap runs on your machine. It has no cloud service, no account, no telemetry and no analytics. The bridge
109
132
  listens only on `127.0.0.1` and accepts only authenticated connections. Form values, passwords and cookies are never
110
- logged. What the agent reads on a page goes to the AI client you connected, under that client's terms. Full policy:
111
- <https://browserzap.com/privacy>.
133
+ logged; Comment mode screenshots cover password and card fields and stay on your computer. What the agent reads on a
134
+ page goes to the AI client you connected, under that client's terms. Full policy: <https://browserzap.com/privacy>.
112
135
 
113
136
  ## Known limitations
114
137
 
115
- - Windows only so far; Chrome only.
138
+ - Windows only so far (Chrome or Edge). macOS and Linux are planned.
116
139
  - `chrome://` pages and the Chrome Web Store cannot be automated.
117
140
  - Canvas apps, some custom widgets and cross-origin frames may need you to do the step.
118
141
  - In a background tab, clicks are made inside the page; a site that requires a real user click needs the tab in front.
@@ -20,6 +20,7 @@
20
20
 
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
+ const os = require('os');
23
24
  const { execFileSync, spawn } = require('child_process');
24
25
 
25
26
  /** Set to 0 (or false/off/no) to stop BrowserZap opening Chrome itself. */
@@ -131,6 +132,41 @@ function cleanProfileDirectory(value) {
131
132
  return cleaned || null;
132
133
  }
133
134
 
135
+ /** Chrome's User Data folder, where `Local State` lists the profiles. */
136
+ function userDataDir({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
137
+ if (platform === 'win32') return path.win32.join(env.LOCALAPPDATA || path.win32.join(home, 'AppData', 'Local'), 'Google', 'Chrome', 'User Data');
138
+ if (platform === 'darwin') return path.posix.join(home, 'Library', 'Application Support', 'Google', 'Chrome');
139
+ return path.posix.join(home, '.config', 'google-chrome');
140
+ }
141
+
142
+ /** Directory names Chrome itself uses, safe to pass when Local State cannot be read. */
143
+ const PROFILE_DIR_RE = /^(Default|Profile \d+)$/;
144
+
145
+ /**
146
+ * Turn BROWSERZAP_PROFILE into a profile directory Chrome can open, or null.
147
+ *
148
+ * The variable holds the name a person gave the profile ("Work": the same name
149
+ * the MCP server selects by), while Chrome's --profile-directory wants the folder
150
+ * ("Profile 1"). Passing "Work" made Chrome create a new, empty profile. So:
151
+ * a folder listed in Local State is used as is; otherwise a profile whose Chrome
152
+ * name matches (any case) gives its folder; otherwise null, and Chrome opens the
153
+ * last profile used — the session then picks its profile by name once connected.
154
+ */
155
+ function resolveProfileDirectory(value, { readFile = (f) => fs.readFileSync(f, 'utf8'), dataDir = userDataDir() } = {}) {
156
+ const wanted = cleanProfileDirectory(value);
157
+ if (!wanted) return null;
158
+ let cache = null;
159
+ try {
160
+ cache = JSON.parse(readFile(path.join(dataDir, 'Local State')))?.profile?.info_cache ?? null;
161
+ } catch {
162
+ cache = null;
163
+ }
164
+ if (!cache || typeof cache !== 'object') return PROFILE_DIR_RE.test(wanted) ? wanted : null;
165
+ if (Object.hasOwn(cache, wanted)) return wanted;
166
+ const byName = Object.entries(cache).find(([, info]) => typeof info?.name === 'string' && info.name.trim().toLowerCase() === wanted.toLowerCase());
167
+ return byName ? byName[0] : null;
168
+ }
169
+
134
170
  /**
135
171
  * The arguments Chrome is started with. One optional flag and nothing else:
136
172
  * a profile directory tells Chrome which of the user's own profiles to open.
@@ -177,7 +213,10 @@ function ensureChromeRunning(options = {}) {
177
213
  if (isChromeRunning(options)) return { status: 'already_running', launched: false, path: null };
178
214
  const found = findChrome(options);
179
215
  if (!found) return { status: 'not_found', launched: false, path: null };
180
- const profileDirectory = options.profileDirectory ?? env[PROFILE_ENV] ?? null;
216
+ const requested = options.profileDirectory ?? env[PROFILE_ENV] ?? null;
217
+ const profileDirectory = requested
218
+ ? resolveProfileDirectory(requested, { readFile: options.readFile, dataDir: options.userDataDir ?? userDataDir({ platform: options.platform, env }) })
219
+ : null;
181
220
  const result = launchChrome({ chromePath: found.path, profileDirectory, spawnChild: options.spawnChild });
182
221
  if (!result.launched) {
183
222
  return { status: 'launch_failed', launched: false, path: found.path, error: result.error ?? 'Chrome did not start.' };
@@ -198,4 +237,6 @@ module.exports = {
198
237
  isChromeRunning,
199
238
  launchChrome,
200
239
  readRegistryDefault,
240
+ resolveProfileDirectory,
241
+ userDataDir,
201
242
  };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * BrowserZap — what the browser downloaded during a session (ROADMAP 0.5.4).
3
+ *
4
+ * Chrome writes downloads to the user's Downloads folder, first as
5
+ * `<name>.crdownload` (or `Unconfirmed <n>.crdownload`), then renamed to the
6
+ * final name. The MCP server watches that folder, so a click that starts a
7
+ * download can say so and `browser_downloads` can list and wait for files —
8
+ * with no new extension permission (a `downloads` permission would mean a new
9
+ * Chrome Web Store review).
10
+ *
11
+ * - Folder: BROWSERZAP_DOWNLOAD_DIR, else the Windows "Downloads" known
12
+ * folder (User Shell Folders), else ~/Downloads.
13
+ * - Only files that arrived after a given time are reported: names, sizes,
14
+ * paths and state. File contents are never read.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ const DOWNLOADS_GUID = '{374DE290-123F-4565-9164-39C4925E467B}';
25
+ const SHELL_FOLDERS = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders';
26
+ const PARTIAL_RE = /\.(crdownload|part|download|tmp)$/i;
27
+ const POLL_MS = 250;
28
+ const MAX_WAIT_MS = 120_000;
29
+ const MAX_LISTED = 50;
30
+
31
+ function defaultRun(argv) {
32
+ const result = spawnSync(process.env.BROWSERZAP_REG_BIN || 'reg', argv, { encoding: 'utf8', windowsHide: true });
33
+ return { status: result.status ?? 1, stdout: result.stdout ?? '' };
34
+ }
35
+
36
+ /** Expand %VAR% the way Windows does for REG_EXPAND_SZ values. */
37
+ function expandWindowsVars(value, env) {
38
+ return value.replace(/%([^%]+)%/g, (whole, name) => {
39
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
40
+ return key ? env[key] : whole;
41
+ });
42
+ }
43
+
44
+ /** Where the browser saves downloads. */
45
+ function downloadDir({ env = process.env, platform = process.platform, home = os.homedir(), run = defaultRun } = {}) {
46
+ if (env.BROWSERZAP_DOWNLOAD_DIR) return path.resolve(env.BROWSERZAP_DOWNLOAD_DIR);
47
+ if (platform === 'win32') {
48
+ const result = run(['query', SHELL_FOLDERS, '/v', DOWNLOADS_GUID]);
49
+ const match = result.status === 0 && /REG_(?:EXPAND_)?SZ\s+(.+?)\s*$/m.exec(result.stdout ?? '');
50
+ if (match) {
51
+ const dir = expandWindowsVars(match[1], env);
52
+ if (!/%/.test(dir)) return dir;
53
+ }
54
+ }
55
+ return path.join(home, 'Downloads');
56
+ }
57
+
58
+ /** name → { size, mtimeMs } for the folder's files (top level only). Never throws. */
59
+ function snapshot(dir) {
60
+ const out = new Map();
61
+ let names = [];
62
+ try { names = fs.readdirSync(dir); } catch { return out; }
63
+ for (const name of names) {
64
+ try {
65
+ const st = fs.statSync(path.join(dir, name));
66
+ if (st.isFile()) out.set(name, { size: st.size, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs || st.mtimeMs });
67
+ } catch { /* gone meanwhile */ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function finalName(name) {
73
+ return name.replace(PARTIAL_RE, '');
74
+ }
75
+
76
+ /** The folder's file names: one directory read, cheap enough to take before every click. Never throws. */
77
+ function names(dir) {
78
+ try {
79
+ return new Set(fs.readdirSync(dir));
80
+ } catch {
81
+ return new Set();
82
+ }
83
+ }
84
+
85
+ /** Downloads that appeared since `before` (a Set from names()): only the new names are looked at. */
86
+ function arrived(before, dir) {
87
+ const entries = [];
88
+ for (const name of names(dir)) {
89
+ if (before.has(name)) continue;
90
+ try {
91
+ const st = fs.statSync(path.join(dir, name));
92
+ if (st.isFile()) entries.push(entry(dir, name, { size: st.size, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs || st.mtimeMs }));
93
+ } catch { /* gone meanwhile (a .crdownload renamed) */ }
94
+ }
95
+ return collapse(entries);
96
+ }
97
+
98
+ function entry(dir, name, info) {
99
+ const partial = PARTIAL_RE.test(name);
100
+ return {
101
+ fileName: partial ? (/^Unconfirmed \d+\./i.test(name) ? null : finalName(name)) : name,
102
+ path: path.join(dir, name),
103
+ sizeBytes: info.size,
104
+ state: partial ? 'in_progress' : 'complete',
105
+ at: new Date(info.birthtimeMs || info.mtimeMs).toISOString(),
106
+ };
107
+ }
108
+
109
+ /** A finished file replaces its own partial entry. */
110
+ function collapse(entries) {
111
+ const done = new Set(entries.filter((e) => e.state === 'complete').map((e) => e.fileName));
112
+ return entries
113
+ .filter((e) => e.state === 'complete' || !e.fileName || !done.has(e.fileName))
114
+ .sort((a, b) => a.at.localeCompare(b.at));
115
+ }
116
+
117
+ /** Downloads that arrived at or after `sinceMs` (new files only; touched old files are not downloads). */
118
+ function since(dir, sinceMs) {
119
+ const entries = [];
120
+ for (const [name, info] of snapshot(dir)) {
121
+ const born = Math.max(info.birthtimeMs || 0, 0);
122
+ // A rename keeps the birth time of the .crdownload, which is after sinceMs.
123
+ if ((born || info.mtimeMs) + 1000 < sinceMs) continue;
124
+ entries.push(entry(dir, name, info));
125
+ }
126
+ return collapse(entries).slice(-MAX_LISTED);
127
+ }
128
+
129
+ /**
130
+ * Wait (bounded poll) until no download that arrived since `sinceMs` is still
131
+ * in progress and at least one exists, or `waitMs` passes. Returns the entries
132
+ * and whether it timed out.
133
+ */
134
+ async function waitFor(dir, sinceMs, waitMs, { sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now } = {}) {
135
+ const deadline = now() + Math.min(Math.max(waitMs, 0), MAX_WAIT_MS);
136
+ let entries = since(dir, sinceMs);
137
+ while (now() < deadline && (entries.length === 0 || entries.some((e) => e.state === 'in_progress'))) {
138
+ await sleep(POLL_MS);
139
+ entries = since(dir, sinceMs);
140
+ }
141
+ const settled = entries.length > 0 && entries.every((e) => e.state === 'complete');
142
+ return { entries, timedOut: !settled && waitMs > 0 };
143
+ }
144
+
145
+ /**
146
+ * Whether Chrome asks where to save each download (Settings → Downloads), which
147
+ * opens a "Save as" dialog BrowserZap cannot answer. Read from the Preferences of
148
+ * the profile Chrome used last (Local State), or the one named. Null when unknown.
149
+ */
150
+ function asksWhereToSave({ dataDir, profileDir = null, readFile = (f) => fs.readFileSync(f, 'utf8') } = {}) {
151
+ if (!dataDir) return null;
152
+ try {
153
+ let dir = profileDir;
154
+ if (!dir) {
155
+ const state = JSON.parse(readFile(path.join(dataDir, 'Local State')));
156
+ dir = state?.profile?.last_used || 'Default';
157
+ }
158
+ const prefs = JSON.parse(readFile(path.join(dataDir, dir, 'Preferences')));
159
+ return prefs?.download?.prompt_for_download === true;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ const ASK_HINT = 'Chrome is set to ask where to save each download, so every download opens a "Save as" dialog that ' +
166
+ 'BrowserZap cannot answer. Turn off "Ask where to save each file before downloading" in chrome://settings/downloads.';
167
+
168
+ function describe(e) {
169
+ const kb = e.sizeBytes < 1024 ? `${e.sizeBytes} B` : e.sizeBytes < 1048576 ? `${Math.round(e.sizeBytes / 1024)} KB` : `${(e.sizeBytes / 1048576).toFixed(1)} MB`;
170
+ return `${e.fileName ?? 'a file'} (${e.state === 'complete' ? kb : 'still downloading'})`;
171
+ }
172
+
173
+ module.exports = { asksWhereToSave, ASK_HINT, downloadDir, snapshot, names, arrived, since, waitFor, describe, finalName, expandWindowsVars, PARTIAL_RE };