From 2d0175ef804156611eb3914a87dbb0454a90b6f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 09:55:48 -0500 Subject: [PATCH] B116: automatic active count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a day while NODE is in use, installed copies send one request with NODE's version, the OS and whether it is the first count this week and this month. No id, pages, addresses or keywords. On by default, said on the welcome screen and in the 0.11.0 notes, off in Settings → Help. The share Worker stores one empty object per count (no address), deletes them after 400 days, and serves the counts with a token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011V7Bv7WZ9Q92MZoNWYwevQ --- .github/workflows/ci.yml | 4 +- .github/workflows/deploy-share.yml | 10 ++++ README.md | 3 +- core/active-count.js | 35 +++++++++++++ docs/02-roadmap-backlog.md | 1 + docs/03-architecture.md | 6 +++ docs/05-changelog.md | 1 + docs/release-notes/v0.11.0.md | 7 +++ main.js | 45 ++++++++++++++++ package.json | 3 +- renderer/app.js | 2 + renderer/index.html | 5 +- test/active-count-demo.js | 32 ++++++++++++ test/e2e/55-active-count.js | 84 ++++++++++++++++++++++++++++++ test/e2e/README.md | 1 + test/share-worker-demo.js | 41 ++++++++++++++- worker/README.md | 10 +++- worker/src/index.js | 72 +++++++++++++++++++++++-- worker/wrangler.toml | 6 +++ 19 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 core/active-count.js create mode 100644 test/active-count-demo.js create mode 100644 test/e2e/55-active-count.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 682e5f1..9a7af5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: (cd test/e2e/site && python3 -m http.server 8765 >/dev/null 2>&1 &) sleep 1 mkdir -p e2e-out - for t in 01-basics 03-rules-and-fast-results 04-find-zoom-history 05-research-session 09-playback-limit 12-pip-bookmarks-adblock 13-ask-ai 15-agents-mcp 16-research-agent 17-talk 18-redteam 19-browser-basics 20-first-run-and-help 21-rules-editor 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments 53-saved-copies 54-share-check; do + for t in 01-basics 03-rules-and-fast-results 04-find-zoom-history 05-research-session 09-playback-limit 12-pip-bookmarks-adblock 13-ask-ai 15-agents-mcp 16-research-agent 17-talk 18-redteam 19-browser-basics 20-first-run-and-help 21-rules-editor 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments 53-saved-copies 54-share-check 55-active-count; do echo "::group::$t" xvfb-run -a node test/e2e/$t.js "$PWD/e2e-out" 2>&1 | tee e2e-out/$t.log status=${PIPESTATUS[0]} @@ -71,7 +71,7 @@ jobs: (cd test/e2e/site && python -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &) sleep 2 mkdir -p e2e-out - for t in 01-basics 05-research-session 19-browser-basics 20-first-run-and-help 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments 53-saved-copies 54-share-check; do + for t in 01-basics 05-research-session 19-browser-basics 20-first-run-and-help 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments 53-saved-copies 54-share-check 55-active-count; do echo "::group::$t" node test/e2e/$t.js "$PWD/e2e-out" 2>&1 | tee e2e-out/$t.log status=${PIPESTATUS[0]} diff --git a/.github/workflows/deploy-share.yml b/.github/workflows/deploy-share.yml index 88b057f..d22100f 100644 --- a/.github/workflows/deploy-share.yml +++ b/.github/workflows/deploy-share.yml @@ -17,6 +17,7 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + NODE_STATS_TOKEN: ${{ secrets.NODE_STATS_TOKEN }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -36,6 +37,15 @@ jobs: - name: Delete trails after 30 days working-directory: worker run: npx --yes wrangler@4 r2 bucket lifecycle add node-share-trails expire-after-30-days t/ --expire-days 30 --force || echo "::warning::Could not set the 30-day rule. The Worker still refuses trails older than 30 days; set the rule in the Cloudflare dashboard (R2 → node-share-trails → Settings → Object lifecycle rules)." + - name: Delete active counts after 400 days (B116) + working-directory: worker + run: npx --yes wrangler@4 r2 bucket lifecycle add node-share-trails expire-counts-after-400-days a/ --expire-days 400 --force || echo "::warning::Could not set the 400-day rule for active counts." - name: Deploy working-directory: worker run: npx --yes wrangler@4 deploy + # Reading the counts (GET /api/active) needs this token. Optional: add a + # repository secret NODE_STATS_TOKEN (any long random text) to turn it on. + - name: Set the token for reading counts + if: env.NODE_STATS_TOKEN != '' + working-directory: worker + run: printf '%s' "$NODE_STATS_TOKEN" | npx --yes wrangler@4 secret put STATS_TOKEN diff --git a/README.md b/README.md index 69f3aac..ab435a1 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ The old Tauri setup (`src-tauri/`, `renderer/tauri-bridge.js`) does not compile ## What NODE sends -NODE has no account and sends no usage data or analytics. The usage summary in Help stays on your computer until you copy it. Diagnostics are only copied when you click Copy Diagnostics. +NODE has no account. The only usage data it sends is one anonymous count a day (below), which you can turn off in Settings → Help. The usage summary in Help stays on your computer until you copy it. Diagnostics are only copied when you click Copy Diagnostics. What leaves your computer, and when: @@ -139,6 +139,7 @@ What leaves your computer, and when: | The text, titles and addresses of the tabs in Mosaic, or of the 8 pages you read most recently in the research session | Your local model, or xAI (Grok) | Only when you ask about Mosaic tabs or the session in Ask AI. Session pages that are not open are read again, logged out. Sign-in, account and payment pages are never sent | | The address of the page you're reading | Hacker News search (Algolia) and Bluesky search | Only while the Talk tab is open | | A check for a new version | GitHub | Every 6 hours in installed copies, if "Check for updates" is on | +| One anonymous count: NODE's version, your operating system (Mac, Windows or Linux), and whether it is the first count this week and this month. No id, pages, addresses or keywords | NODE's share server (a Cloudflare Worker), which keeps a count per day and nothing else | Once a day while you use NODE, in installed copies. On by default; turn it off in Settings → Help ("Send one anonymous count a day") | | A download of the ad and tracker block lists (uBlock Origin's default lists; with cookie notices on, also the cookie-notice lists) | GitHub | About once a week | | Google sign-in and Google apps | A separate Chrome-based browser on your computer, with its own NODE profile | When you open a Google account page (Settings → Google window) | | A shared trail, encrypted on your computer (the key stays in the link, so the server cannot read it) | NODE's share server (a Cloudflare Worker), which deletes it after 30 days | Only when you click Create Link in Share Trail. Stop Sharing deletes it sooner | diff --git a/core/active-count.js b/core/active-count.js new file mode 100644 index 0000000..f378c92 --- /dev/null +++ b/core/active-count.js @@ -0,0 +1,35 @@ +// Automatic active count (B116). Once a day while NODE is in use, one +// anonymous request with only the version and the operating system, so the +// number of daily, weekly and monthly users can be counted. No id of any +// kind: weekly and monthly users are counted from a "first this week" and a +// "first this month" mark on the request. Turn it off in Settings → Help. +// No platform imports: main.js sends the request and keeps the state. + +const pad = (n) => String(n).padStart(2, "0"); +const dayOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const monthOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; +// ISO week, like "2026-W46". +function weekOf(d) { + const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = t.getUTCDay() || 7; + t.setUTCDate(t.getUTCDate() + 4 - day); + const year = t.getUTCFullYear(); + const week = Math.ceil(((t - Date.UTC(year, 0, 1)) / 86400000 + 1) / 7); + return `${year}-W${pad(week)}`; +} +const OS = { darwin: "mac", win32: "windows", linux: "linux" }; +const osName = (platform) => OS[platform] || "other"; +const VERSION = /^\d{1,3}\.\d{1,3}\.\d{1,4}(?:-[0-9A-Za-z.]{1,20})?$/; + +// What to send now, or null. state: { day, week, month } of the last count sent. +function nextCount(state, { now = new Date(), version, platform }) { + const day = dayOf(now); + if (state?.day === day || !VERSION.test(String(version))) return null; + const week = weekOf(now), month = monthOf(now); + return { + body: { v: String(version), os: osName(platform), w: state?.week !== week, m: state?.month !== month }, + state: { day, week, month }, + }; +} + +module.exports = { nextCount, weekOf, dayOf, osName, VERSION }; diff --git a/docs/02-roadmap-backlog.md b/docs/02-roadmap-backlog.md index a1a1926..7fd06b9 100644 --- a/docs/02-roadmap-backlog.md +++ b/docs/02-roadmap-backlog.md @@ -137,6 +137,7 @@ Effort: XS under 1 hour, S half a day, M 1-2 days, L 3+ days. Finding IDs (C1, K | B113 | 4.T | Canvas depth: the dot grid moves faster than the cards when panning (1.25 times), with reduced motion it moves with them | P2 | XS | Built 2026-11-05 (PR #61). e2e `42-trail-canvas` | | B114 | 4.T | Library media on the canvas: images, screenshots, videos and audio from the Library (Put on canvas, drag) or dropped from Finder become media cards; images show, video and audio play on the card, double-click opens a viewer; link them like any card. In a shared file a media card links to where the media came from, with a large preview for the reader | P1 | M | Built 2026-11-05 (PR #62). Tests `test:trail`, `test:replay`, e2e `48-canvas-media` | | B115 | 4.T | Rename and delete trails straight from the switcher: a "…" button on each trail with Rename and Delete…; double-click the switcher's name or press F2 to rename; Delete asks first, closes the trail's tabs and offers Undo; the files go at the next start. Opened files can be deleted, not renamed | P1 | S | Built 2026-11-06 (PR #64). Tests `test:trail`, e2e `49-trail-rename-delete` | +| B116 | 4 | Automatic active count: once a day while NODE is in use, send one anonymous request with only the version and OS (no ID, pages, addresses or keywords), so we can count daily and weekly active users. On by default, no prompt (Sven, 2026-11-05). Said plainly on the welcome screen and in the update notes for the release that adds it; switch in Settings → Help and in the privacy page (B74); listed in "What NODE sends" (B73) and the README. Receiver: a small Cloudflare Worker that adds to a per-day count by version and OS (the B82 share-link account), with no IP or request logging. First added 2026-11-05 as B113 and lost when another chat saved the backlog in the project | P1 | S | Built 2026-11-09 (PR #75), in 0.11.0: `core/active-count.js`; the request also says whether it is the first this week and this month, so weekly and monthly users can be counted without an id; installed copies only; the share Worker stores one empty R2 object per count (`a/////`, deleted after 400 days) and `GET /api/active` reads the counts with the `NODE_STATS_TOKEN` secret. Tests `test:active`, `share-worker-demo`, e2e `55-active-count` | | B60 | 5 | NODE for Chromium extension | — | — | Dropped 2026-10-14: missing must-have features | | B59 | 6 | Research agent v3: gated actions with confirmation (forms, downloads) | P2 | L | Not started | | B52 | 4 | Google window: Google sign-in and Google apps open in a separate window of a Chromium-based browser on the computer, with its own NODE profile and a NODE extension (history, center on click, autoplay, no duplicate tabs). The extension is also the first step of B51 | P0 | M | Done; to test sign-in on the Mac | diff --git a/docs/03-architecture.md b/docs/03-architecture.md index f9c3b58..975f976 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -274,6 +274,12 @@ The same core modules run in the built-in extension of the Chromium build (`brow - **Rename:** `rename(id, name)` records a `renamed` event; `applySession` now does the same when a session's name changes, so renames through research:\* are events too. - **Channels:** `trail:rename-id` (id, name; refused for read-only trails; the current trail goes through research:\*), `trail:delete` (closes the trail's tabs first, then `changeTrail` if it was current; returns `{deleted, name, tabs, current}`), `trail:restore` (via `changeTrail`). - **Switcher** (`renderer/app.js`): each trail row is `.sb-menu-row` with the item and a "…" button; the actions row (`.sb-menu-acts`) holds Rename and Delete…, then the question with Delete trail and Keep. `#sb-undo` under the menu shows Undo for 10 s. Keys on a trail in the menu: F2 renames, Delete or Backspace asks to delete, → reaches "…". Over a page the field is typed in the sidebar's layer (`chrome-overlay.html`); the real field does not save on losing focus there, and Enter or Esc come back through `overlayEvent`. +## Automatic active count (B116) + +- `core/active-count.js` (no platform imports): `nextCount(state, { now, version, platform })` gives the body `{ v, os, w, m }` (w and m: first count this ISO week and this month, local time) and the new state, or null when today's count was sent or the version is not a release number. +- **Main:** `sendActiveCount` runs a minute after start, every 15 minutes and 5 s after the window gets focus; it sends only when the setting `activeCount` is on (default), NODE is in use (`nodeInUse`), and the app is packaged (or `NODE_ACTIVE_API` is set, for tests). It uses Node's `fetch` (no cookies or session) to `/api/active` and writes `userData/active-count.json` only after a 2xx. +- **Worker** (`worker/src/index.js`): `POST /api/active` validates and writes an empty R2 object `a/////`; `COUNT_LIMIT` rate limiter (5 a minute per address, in memory). `GET /api/active` lists `a/` and sums per day, ISO week (from w) and month (from m), only with `STATS_TOKEN`. The deploy workflow adds a 400-day lifecycle rule on `a/`. + ## The check before sharing (B86) - `core/share-check.js` (no platform imports): `findContacts(texts)` finds email addresses and phone numbers (10 to 15 digits in groups, or starting with +; not dates, times, year ranges or plain runs of digits), one finding per detail with `where`, `count` and other spellings (`also`). `snapshotTexts(snap, { note })` lists what a trail file shares; `sessionTexts(trail)` what a share link (format 1) shares. `redact(obj, leaveOut)` replaces each left-out detail in every string of a copy. diff --git a/docs/05-changelog.md b/docs/05-changelog.md index 44ccaa9..3dfd32a 100644 --- a/docs/05-changelog.md +++ b/docs/05-changelog.md @@ -4,6 +4,7 @@ Newest first. Commit hashes refer to the git history inside the delivered zip fi | Date | Commit | Change | | --- | --- | --- | +| 2026-11-09 | (PR #75) | **B116: automatic active count.** Once a day while NODE is in use (window focused, input in the last two minutes), installed copies send one request to the share Worker with NODE's version, the operating system (mac, windows, linux) and two marks: first count this week, first this month. No id, pages, addresses or keywords; Node's fetch, no cookies. Weekly and monthly users are counted from the marks, so no id is needed. On by default (Sven, 2026-11-05); the welcome screen says so, and Settings → Help has **Send one anonymous count a day** with what it sends. NODE keeps only the day, week and month of the last count it sent (`userData/active-count.json`); a failed send is tried again later. The Worker (`POST /api/active`) checks the shape, stores one empty R2 object named `a/////` (no address; the address is used only for a 5-a-minute limit in memory), and an R2 rule deletes counts after 400 days. `GET /api/active?days=30` returns counts per day (by OS and version), ISO week and month, with the `NODE_STATS_TOKEN` repository secret (set as the Worker secret `STATS_TOKEN` on deploy). README "What NODE sends" and the 0.11.0 release notes updated. Tests: `test/active-count-demo.js` (`npm run test:active`, in `test:unit`), 4 more checks in `test/share-worker-demo.js`, new `test/e2e/55-active-count.js` (11 checks, in CI). | | 2026-11-08 | (PR #74) | **0.11.0 prepared.** Version 0.11.0, release notes `release-notes/v0.11.0.md`: search past trails (B89), media moments (B88), saved copies (B87), how each page was reached (B83), rename and delete trails (B115), the check before sharing (B86), Library media on the canvas (B114), canvas depth (B113). Desktop (Electron) only. Full end-to-end suite run on the release commit. Not tagged until Sven has tried it. | | 2026-11-08 | (PR #73) | **B86: a check before sharing.** The share sheet now lists the contact details in what you share, for the trail file and for share links: email addresses and phone numbers in card titles and text, notes, questions and replies, group names, timeline titles and the note typed for the reader (Sven chose contact details only, not names or social posts). Each is ticked, with where it first appears ("Email jo@mill.example · Note: Miller: …, and 1 more place"). Unticking one leaves it out of what is shared: it shows as "[email left out]" or "[phone number left out]" everywhere it appears, in the preview and the saved file or link. The trail itself is not changed. Dates, times, years, ids and quantities are not taken for phone numbers. Trails stay private: nothing is shared unless you share it. Agents (MCP `get_research_session` and the session resource) now get an opened trail file with a first line saying it came from someone else and is untrusted data, not instructions. The red-team set (B58) gains `site/redteam/injected.trail.md` and part C of `18-redteam`: the file opens read only, agents get it marked untrusted, and Ask about this trail with a compromised model shows its answer as text with no attacker image or request. New `core/share-check.js` (no platform imports), channel `trail:share-check`, share options `leaveOut`. Tests: `test/share-check-demo.js` (`npm run test:share-check`, in `test:unit`, 6 checks), new `test/e2e/54-share-check.js` (10 checks, in CI), `18-redteam` (4 more checks). | | 2026-11-08 | (PR #71) | **B87: saved copies of trail pages.** Offered, never forced. **Save a Copy of This Page** (File menu, or right-click a page) and **Save a copy** on a selected page card (with no tab open, the page loads out of sight) save the page as it looks now as one MHTML file, named by its SHA-256, with its readable text beside it, on this computer (userData/copies). The page becomes a card if it wasn't. Saving again when the page's text hasn't changed keeps the one copy ("This page hasn't changed since its copy of 8 Nov 10:05."). **Keep copies** in a trail's … menu (off by default) saves a copy of each page that becomes a card in that trail. The card shows "Copy · 8 Nov 10:05" (and "· 2 copies" when there are more); clicking it opens the copy in a tab after checking its SHA-256, and a copy that changed on disk is not opened. Sign-in, account and payment pages (B66), private tabs and opened (read-only) trails get no copies. Sharing: trail files list each copy ("Copy: 8 Nov 2026 10:05 · SHA-256 …"); copies are sent only when **Saved copies of pages** is ticked in the share sheet, as files in a folder " copies" beside the trail file, linked from the file (doc 26). Opening such a file keeps each copy whose SHA-256 matches and marks one that doesn't as changed. Copy files no trail names are deleted at start-up, after deleted trails are purged. New `TrailStore.addCopy`, `copyHashes`, `setKeepCopies` (event `copied`), channels `trail:copy-save`, `trail:copy-open`, `trail:keep-copies`. Electron build only. Tests: `test/copies-demo.js` (`npm run test:copies`, in `test:unit`, 4 checks), new `test/e2e/53-saved-copies.js` (20 checks, in CI). | diff --git a/docs/release-notes/v0.11.0.md b/docs/release-notes/v0.11.0.md index ba04236..862066d 100644 --- a/docs/release-notes/v0.11.0.md +++ b/docs/release-notes/v0.11.0.md @@ -1,5 +1,7 @@ NODE 0.11.0 is about finding things again and keeping a record of them. Search all your trails by what you remember, save moments from videos, keep copies of pages, and see how you reached each page. Thank you for testing. +**New in this version: NODE sends one anonymous count a day: its version and your operating system, nothing else. Turn it off in Settings → Help.** It lets us count how many people use NODE each day, week and month. Until now we had no way to know. + NODE updates itself on Windows, and on a Mac from 0.9.6 on. Copies of 0.9.5 or earlier on a Mac need this version downloaded by hand once. ## What's new @@ -24,6 +26,11 @@ NODE updates itself on Windows, and on a Mac from 0.9.6 on. Copies of 0.9.5 or e - **Library media on the canvas.** Images, screenshots, videos and audio from your Library can go on the canvas: choose **Put on canvas**, or drag them out of the Library or from your computer. Videos and audio play on the card; double-click for a larger view. - **Canvas depth.** The dot grid moves faster than the cards when you pan, for a sense of depth. With Reduce Motion on, it moves with the cards. +## What NODE sends + +- **New:** once a day while you use NODE, one request to NODE's server with NODE's version, your operating system (Mac, Windows or Linux), and whether it is the first count this week and this month. There is no id of any kind, and no pages, addresses or keywords. The server keeps a count per day and does not store your network address. It is on by default; turn it off in Settings → Help → **Send one anonymous count a day**. +- Everything else NODE sends is listed in the README under "What NODE sends". + ## Sharing - When you share a trail file, saved copies of pages go only if you tick **Saved copies of pages**. They go in a folder named " copies" next to the file; send the file and the folder together. When someone opens the file, NODE checks each copy's fingerprint and marks any that changed. diff --git a/main.js b/main.js index 11ee748..26857d1 100644 --- a/main.js +++ b/main.js @@ -1273,6 +1273,45 @@ function systemName() { if (process.platform === "win32") return `Windows ${os.release()} (${process.arch})`; return `Linux ${os.release()} (${process.arch})`; } +// ---- Automatic active count (B116, core/active-count.js) ---- +// Once a day while NODE is in use (window focused, input in the last two +// minutes): one request with the version and OS, and whether it is the first +// this week and this month. No id, no pages, addresses or keywords. On by +// default; Settings → Help turns it off. Only installed copies send it (or a +// test that sets NODE_ACTIVE_API). State in userData/active-count.json. +const ActiveCount = require("./core/active-count"); +const ACTIVE_API = process.env.NODE_ACTIVE_API || `${ShareLink.API_BASE}/api/active`; +let activeSending = false; +async function sendActiveCount({ inUse = nodeInUse() } = {}) { + if (activeSending || loadSettings().activeCount === false || !inUse) return null; + if (!app.isPackaged && !process.env.NODE_ACTIVE_API) return null; + const file = path.join(app.getPath("userData"), "active-count.json"); + let state = null; + try { + state = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (_) {} + const next = ActiveCount.nextCount(state, { version: app.getVersion(), platform: process.platform }); + if (!next) return null; + activeSending = true; + try { + // Node's fetch: no cookies, no browser session. + const res = await fetch(ACTIVE_API, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(next.body), signal: AbortSignal.timeout(10000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + fs.writeFileSync(file, JSON.stringify(next.state)); + return next.body; + } catch (err) { + log().warn(`active count: ${err.message}`); // tried again later + return null; + } finally { + activeSending = false; + } +} +function startActiveCount() { + setTimeout(sendActiveCount, 60 * 1000); + setInterval(sendActiveCount, 15 * 60 * 1000); + app.on("browser-window-focus", () => setTimeout(sendActiveCount, 5000)); +} + function usageSummary() { return usage().summary({ version: app.getVersion(), os: systemName() }); } @@ -2307,6 +2346,7 @@ function loadSettings() { base.adblockCookies = raw.adblockCookies === true; // B77: also hide cookie notices (off by default, as in uBlock Origin) base.restoreTabs = raw.restoreTabs !== false; base.autoUpdate = raw.autoUpdate !== false; + base.activeCount = raw.activeCount !== false; // B116: on by default base.passkeys = raw.passkeys === true; base.mosaicMax = MOSAIC_MAX_CHOICES.includes(raw.mosaicMax) ? raw.mosaicMax : 4; base.tabSleep = SLEEP_CHOICES.includes(raw.tabSleep) ? raw.tabSleep : 30; @@ -2348,6 +2388,7 @@ function saveSettings(next) { } if (next && "restoreTabs" in next) current.restoreTabs = Boolean(next.restoreTabs); if (next && "autoUpdate" in next) current.autoUpdate = Boolean(next.autoUpdate); + if (next && "activeCount" in next) current.activeCount = Boolean(next.activeCount); if (next && "passkeys" in next) current.passkeys = Boolean(next.passkeys); if (next && next.firstRunDone) current.firstRunDone = true; if (next && "passwordsOffer" in next) current.passwordsOffer = Boolean(next.passwordsOffer); @@ -2386,6 +2427,7 @@ function saveSettings(next) { google: { ...current.google }, restoreTabs: current.restoreTabs !== false, autoUpdate: current.autoUpdate !== false, + activeCount: current.activeCount !== false, passkeys: current.passkeys === true, mosaicMax: current.mosaicMax, tabSleep: current.tabSleep, @@ -2435,6 +2477,7 @@ function publicSettings() { google: { ...s.google }, restoreTabs: s.restoreTabs !== false, autoUpdate: s.autoUpdate !== false, + activeCount: s.activeCount !== false, passkeys: s.passkeys === true, passkeysActive: PASSKEYS_ON, update: updater ? updater.state() : null, @@ -2949,6 +2992,7 @@ app.commandLine.appendSwitch("disable-features", "PreloadMediaEngagementData"); app.whenReady().then(() => { usage().touch(); + startActiveCount(); if (process.env.NODE_TEST_PLAIN_PASSWORDS === "1" && app.commandLine.hasSwitch("user-data-dir")) safeStorage.setUsePlainTextEncryption?.(true); protocol.handle("scoutlib", (request) => { try { @@ -3009,6 +3053,7 @@ app.whenReady().then(() => { global.nodeCompanion = companion; // for the e2e tests global.nodeTest = { claudeDesktopEntry, sleepIdleTabs, + sendActiveCount: () => sendActiveCount({ inUse: true }), // B116 setTrailSavePath: (p) => (trailSavePath = p), openSharedTrail, openFromOutside, openShareLink, setPasswordAsk: (answer) => (passwordAsk = async (opts) => ((global.nodeTest.lastPasswordAsk = opts), answer)), diff --git a/package.json b/package.json index ce8a4ae..1dae2c6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:moment": "node test/moment-demo.js", "test:copies": "node test/copies-demo.js", "test:share-check": "node test/share-check-demo.js", + "test:active": "node test/active-count-demo.js", "test:trailfile": "node test/trail-file-v2-demo.js", "try:canvas": "node test/try-canvas.js", "test:keywords": "node test/keywords-demo.js", @@ -40,7 +41,7 @@ "dist:mac": "electron-builder --mac", "dist:win": "electron-builder --win", "test:redteam": "node test/e2e/18-redteam.js", - "test:unit": "node test/net-demo.js && node test/trail-demo.js && node test/canvas-demo.js && node test/placement-demo.js && node test/ladder-demo.js && node test/replay-demo.js && node test/provenance-demo.js && node test/recall-demo.js && node test/moment-demo.js && node test/copies-demo.js && node test/share-check-demo.js && node test/share-link-demo.js && node test/share-worker-demo.js && node test/trail-file-demo.js && node test/trail-file-v2-demo.js && node test/threads-demo.js && node test/trail-open-demo.js && node test/passwords-demo.js && node test/chat-demo.js && node test/usage-demo.js && node test/research-demo.js && node test/keywords-demo.js && node test/keywords-eval.js && node test/providers-demo.js && node test/rules-demo.js && node test/ai-demo.js && node test/controller-demo.js && node test/playback-demo.js && node test/bookmarks-demo.js && node test/google-demo.js && node test/agents-demo.js && node test/agent-demo.js && node test/talk-demo.js", + "test:unit": "node test/net-demo.js && node test/trail-demo.js && node test/canvas-demo.js && node test/placement-demo.js && node test/ladder-demo.js && node test/replay-demo.js && node test/provenance-demo.js && node test/recall-demo.js && node test/moment-demo.js && node test/copies-demo.js && node test/share-check-demo.js && node test/active-count-demo.js && node test/share-link-demo.js && node test/share-worker-demo.js && node test/trail-file-demo.js && node test/trail-file-v2-demo.js && node test/threads-demo.js && node test/trail-open-demo.js && node test/passwords-demo.js && node test/chat-demo.js && node test/usage-demo.js && node test/research-demo.js && node test/keywords-demo.js && node test/keywords-eval.js && node test/providers-demo.js && node test/rules-demo.js && node test/ai-demo.js && node test/controller-demo.js && node test/playback-demo.js && node test/bookmarks-demo.js && node test/google-demo.js && node test/agents-demo.js && node test/agent-demo.js && node test/talk-demo.js", "release": "electron-builder --publish always", "build:browser-extension": "node browser/extension/build.js", "check:browser-patches": "python3 browser/check_patches.py", diff --git a/renderer/app.js b/renderer/app.js index 0d4b65a..f883a0b 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1572,6 +1572,7 @@ async function renderSources() { document.getElementById("mosaic-max").value = String(settings.mosaicMax ?? 4); document.getElementById("tab-sleep").value = String(settings.tabSleep ?? 30); document.getElementById("auto-update").checked = settings.autoUpdate !== false; + document.getElementById("active-count").checked = settings.activeCount !== false; // B116 document.getElementById("passkeys").checked = settings.passkeys === true; document.getElementById("passkeys").closest(".top").hidden = document.getElementById("passkeys-help").hidden = !/Mac/.test(navigator.platform); fillUpdate(settings.update); @@ -2019,6 +2020,7 @@ async function saveSources(rerender = true) { tabSleep: Number(document.getElementById("tab-sleep").value), passwordsOffer: document.getElementById("passwords-offer").checked, autoUpdate: document.getElementById("auto-update").checked, + activeCount: document.getElementById("active-count").checked, passkeys: document.getElementById("passkeys").checked, }); if (rerender) await renderSources(); diff --git a/renderer/index.html b/renderer/index.html index 0e4f426..6c78776 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -570,12 +570,14 @@

Problems

Copy diagnostics puts NODE's version, your system, which features are on and recent errors on the clipboard, without page text, addresses you typed or keys. Paste it into a bug report.

-

Beta survey: about 4 minutes, anonymous. Copy usage summary puts counts on the clipboard to paste into the survey: days used, suggestions shown and opened, pins, hides, trail pages and a few others. No pages, addresses or keywords, and nothing is sent by NODE.

+

Beta survey: about 4 minutes, anonymous. Copy usage summary puts counts on the clipboard to paste into the survey: days used, suggestions shown and opened, pins, hides, trail pages and a few others. No pages, addresses or keywords; the summary is only sent if you paste it.

Gestures and shortcuts: pinch on the trackpad to move between one tab, Mosaic and all your tabs. The tutorial takes about 2 minutes, on practice tabs.

+
+

Once a day while you use NODE, it sends NODE's version and your operating system (Mac, Windows or Linux), and nothing else: no id, pages, addresses or keywords. It lets us count how many people use NODE each day, week and month.

On a Mac, passkeys need a signed NODE that Apple has approved as a browser. Until then they are off, so sites such as X offer password sign-in instead of waiting for a passkey. Takes effect after restarting NODE.

@@ -656,6 +658,7 @@

Browsing

NODE blocks ads and trackers on every page with the filter lists uBlock Origin uses by default, built in. The shield next to the address shows how many it blocked on the page you're reading.

+

NODE sends one anonymous count a day: its version and your operating system, nothing else. Turn it off in Settings → Help.

diff --git a/test/active-count-demo.js b/test/active-count-demo.js new file mode 100644 index 0000000..c3f9fe6 --- /dev/null +++ b/test/active-count-demo.js @@ -0,0 +1,32 @@ +// Automatic active count (B116): core/active-count.js decides when to send +// and what. Run: npm run test:active +const assert = require("assert"); +const A = require("../core/active-count"); +let n = 0; +const ok = (name) => console.log(`ok ${++n} - ${name}`); + +const at = (y, m, d, h = 10) => new Date(y, m - 1, d, h); +{ + const first = A.nextCount(null, { now: at(2026, 11, 9), version: "0.11.0", platform: "darwin" }); + assert.deepStrictEqual(first.body, { v: "0.11.0", os: "mac", w: true, m: true }); + assert.deepStrictEqual(Object.keys(first.body).sort(), ["m", "os", "v", "w"], "nothing else is sent"); + assert.strictEqual(A.nextCount(first.state, { now: at(2026, 11, 9, 23), version: "0.11.0", platform: "darwin" }), null, "once a day"); + const tue = A.nextCount(first.state, { now: at(2026, 11, 10), version: "0.11.0", platform: "darwin" }); + assert.deepStrictEqual(tue.body, { v: "0.11.0", os: "mac", w: false, m: false }, "the same week and month"); + const nextWeek = A.nextCount(tue.state, { now: at(2026, 11, 16), version: "0.11.1", platform: "darwin" }); + assert.deepStrictEqual([nextWeek.body.w, nextWeek.body.m, nextWeek.body.v], [true, false, "0.11.1"]); + const dec = A.nextCount(nextWeek.state, { now: at(2026, 12, 1), version: "0.11.1", platform: "darwin" }); + assert.deepStrictEqual([dec.body.w, dec.body.m], [true, true]); + ok("one count a day, marked first this week and first this month"); +} +{ + assert.strictEqual(A.osName("win32"), "windows"); + assert.strictEqual(A.osName("linux"), "linux"); + assert.strictEqual(A.osName("freebsd"), "other"); + assert.strictEqual(A.nextCount(null, { version: "dev build", platform: "linux" }), null, "only real version numbers"); + assert.strictEqual(A.weekOf(at(2027, 1, 1)), "2026-W53"); + assert.strictEqual(A.weekOf(at(2026, 1, 1)), "2026-W01"); + assert.strictEqual(A.weekOf(at(2026, 12, 28)), "2026-W53"); + ok("OS names, version check and ISO weeks"); +} +console.log(`\n${n} checks passed`); diff --git a/test/e2e/55-active-count.js b/test/e2e/55-active-count.js new file mode 100644 index 0000000..a9754eb --- /dev/null +++ b/test/e2e/55-active-count.js @@ -0,0 +1,84 @@ +// Automatic active count (B116): once a day, one request with the version +// and OS (and first-this-week, first-this-month marks), nothing else. On by +// default, said on the welcome screen, off in Settings → Help. +const { _electron: electron } = require(process.env.PLAYWRIGHT_PATH || "playwright"); +const path = require("path"), fs = require("fs"), os = require("os"), http = require("http"); +const OUT = process.argv[2] || fs.mkdtempSync(path.join(os.tmpdir(), "node-e2e-")); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failed = 0; +const check = (name, ok, detail = "") => { + if (!ok) failed++; + console.log(ok ? "✓" : "✗", name, detail ? `(${detail})` : ""); +}; +const userData = fs.mkdtempSync(path.join(OUT, "ud-")); + +(async () => { + // A stand-in for the count server that keeps what it receives. + const got = []; + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + got.push({ method: req.method, url: req.url, headers: req.headers, body }); + res.writeHead(204).end(); + }); + }); + await new Promise((r) => server.listen(8791, "127.0.0.1", r)); + + const app = await electron.launch({ executablePath: require("electron"), env: { ...process.env, NODE_FIRST_RUN: "1", NODE_ACTIVE_API: "http://127.0.0.1:8791/api/active" }, args: ["--no-sandbox", `--user-data-dir=${userData}`, path.join(__dirname, "..", "..")] }); + const ui = await app.firstWindow(); + await sleep(1800); + + // First run: the welcome screen says it. + const welcome = await ui.evaluate(() => ({ shown: !document.getElementById("firstrun").hidden, text: document.getElementById("fr-active")?.textContent || "" })); + check("the welcome screen says NODE sends one anonymous count a day", welcome.shown && welcome.text === "NODE sends one anonymous count a day: its version and your operating system, nothing else. Turn it off in Settings → Help.", JSON.stringify(welcome)); + await ui.click("#fr-done"); + await sleep(800); + check("on by default", (await ui.evaluate(() => window.scout.getSettings())).activeCount === true); + + // One count. + const sent = await app.evaluate(() => global.nodeTest.sendActiveCount()); + await sleep(300); + const v = await app.evaluate(({ app }) => app.getVersion()); + const osName = { darwin: "mac", win32: "windows", linux: "linux" }[process.platform] || "other"; + check("one request", got.length === 1 && got[0].method === "POST" && got[0].url === "/api/active", JSON.stringify(got.map((g) => g.url))); + check("with only the version, the OS and the week and month marks", got[0] && got[0].body === JSON.stringify({ v, os: osName, w: true, m: true }), got[0]?.body); + check("no cookies, no referrer, no id in the headers", got[0] && !got[0].headers.cookie && !got[0].headers.referer && !Object.keys(got[0].headers).some((h) => /id|token|auth/i.test(h)), JSON.stringify(got[0]?.headers)); + check("NODE keeps only the day, week and month it last sent", JSON.stringify(Object.keys(JSON.parse(fs.readFileSync(path.join(userData, "active-count.json"), "utf8")))) === '["day","week","month"]' && sent?.v === v); + + // Once a day. + await app.evaluate(() => global.nodeTest.sendActiveCount()); + await sleep(300); + check("no second count the same day", got.length === 1, String(got.length)); + + // Settings → Help: the switch and what it sends. + await ui.evaluate(() => openMenu?.("settings") ?? document.getElementById("settings-open")?.click()); + await sleep(800); + const help = await ui.evaluate(() => { + const cb = document.getElementById("active-count"); + return { checked: cb?.checked, label: cb?.closest("label")?.textContent.trim(), help: cb?.closest(".top")?.nextElementSibling?.textContent || "" }; + }); + check("Settings → Help has the switch, on, with what it sends", help.checked === true && help.label === "Send one anonymous count a day" && /version and your operating system \(Mac, Windows or Linux\), and nothing else: no id, pages, addresses or keywords/.test(help.help), JSON.stringify(help)); + + // Off: nothing is sent, even on a new day. + await ui.evaluate(() => window.scout.setSettings({ activeCount: false })); + fs.rmSync(path.join(userData, "active-count.json")); + await app.evaluate(() => global.nodeTest.sendActiveCount()); + await sleep(300); + check("turned off, nothing is sent", got.length === 1, String(got.length)); + check("and it stays off", (await ui.evaluate(() => window.scout.getSettings())).activeCount === false); + + // The server down: no error for the user, tried again later. + await ui.evaluate(() => window.scout.setSettings({ activeCount: true })); + server.close(); + await sleep(200); + const r = await app.evaluate(() => global.nodeTest.sendActiveCount()); + check("server down: nothing breaks, and it isn't marked as sent", r === null && !fs.existsSync(path.join(userData, "active-count.json"))); + + await app.close(); + console.log(failed ? `\n${failed} FAILED` : "\nall passed"); + process.exit(failed ? 1 : 0); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/test/e2e/README.md b/test/e2e/README.md index c960734..701caee 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -35,6 +35,7 @@ On Linux without a display, run each script under `xvfb-run -a`. | 52-media-moments | Media moments (B88): ⌘⇧K start then end saves a moment card with captions and pauses; ⌘⇧J saves the last 30 seconds with the caption cues; with a loop set one press saves the loop; the card (Moment, times, captions, Play, Ask for ID); the timeline says Moment; changing the times; Ask for ID makes a "raises" question; Play seeks, plays and loops the range; no video, nothing saved | | 53-saved-copies | Saved copies (B87): File → Save a Copy of This Page makes the page a card with an MHTML copy named by its SHA-256 and its text; an unchanged page keeps one copy; sign-in pages never; the card line opens the copy, and a copy changed on disk is not opened; Save a copy on a card with no tab; Keep copies in the trail's … menu; sharing lists copies, and sends them in “ copies” only when ticked; opening the file brings matching copies in and marks a changed one | | 54-share-check | The check before sharing (B86): the share sheet lists email addresses and phone numbers in the file, each ticked with where it is; the note typed in the sheet is checked; unticking leaves a detail out of the preview and the saved file ("[email left out]") but not the trail; share links are checked; nothing found, no list | +| 55-active-count | Automatic active count (B116): the welcome screen line; on by default; one request with only version, OS and the week and month marks, no cookies or id; once a day; the switch and its text in Settings → Help; off sends nothing; the server down breaks nothing | | 21-rules-editor | Site rules editor: list with plain summaries, Rule for this site, saving and the rule taking effect (autoplay off after reload), invalid pattern refused with the file unchanged, hand-written fields kept, move and delete | | 20-first-run-and-help | Welcome screen on first start (page views hidden), choices saved with keys encrypted, not shown again; new tab page with most visited pages and its search box; a custom new tab page; Copy diagnostics without keys; Help menu | | 19-browser-basics | Error page for a failed load, page-stopped page after a crash, reopen closed tab, Tab 1 and Next Tab, right-click menu items for links, images, text and the page, two downloads (second renamed), Library → Downloads, Save as PDF, sleeping idle tabs and waking one, tabs restored after a restart (asleep except the active one) | diff --git a/test/share-worker-demo.js b/test/share-worker-demo.js index d854414..af9d0ac 100644 --- a/test/share-worker-demo.js +++ b/test/share-worker-demo.js @@ -7,13 +7,20 @@ function memoryBucket() { const items = new Map(); return { items, - async put(key, body, opts) { items.set(key, { bytes: new Uint8Array(body), customMetadata: opts.customMetadata }); }, + async put(key, body, opts) { items.set(key, { bytes: new Uint8Array(body), customMetadata: opts?.customMetadata }); }, async get(key) { const it = items.get(key); if (!it) return null; return { customMetadata: it.customMetadata, body: new Blob([it.bytes]).stream(), arrayBuffer: async () => it.bytes.buffer.slice(0) }; }, async delete(key) { items.delete(key); }, + // R2 list(): keys in order, after startAfter, 2 a page to test paging. + async list({ prefix = "", cursor, startAfter } = {}) { + const keys = [...items.keys()].filter((k) => k.startsWith(prefix) && (!startAfter || k > startAfter)).sort(); + const at = cursor ? Number(cursor) : 0; + const page = keys.slice(at, at + 2); + return { objects: page.map((key) => ({ key })), truncated: at + 2 < keys.length, cursor: String(at + 2) }; + }, }; } @@ -71,6 +78,38 @@ function memoryBucket() { env.UPLOAD_LIMIT = { limit: async () => ({ success: false }) }; assert.strictEqual((await call("/api/trails", { method: "POST", body: sealed })).status, 429); ok("bad ids, robots.txt, and the upload limit"); + + // ---- Daily active count (B116) ---- + const countReq = (body, headers = {}) => call("/api/active", { method: "POST", headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.9", ...headers }, body: JSON.stringify(body) }); + assert.strictEqual((await countReq({ v: "0.11.0", os: "mac", w: true, m: true })).status, 204); + assert.strictEqual((await countReq({ v: "0.11.0", os: "windows", w: false, m: false })).status, 204); + assert.strictEqual((await countReq({ v: "0.10.0", os: "windows", w: true, m: false })).status, 204); + const keys = [...env.TRAILS.items.keys()].filter((k) => k.startsWith("a/")); + const today = new Date().toISOString().slice(0, 10); + assert.strictEqual(keys.length, 3); + assert.ok(keys.every((k) => new RegExp(`^a/${today}/(mac|windows)/0\\.1[01]\\.0/[w-][m-]/[A-Za-z0-9]{12}$`).test(k)), keys.join()); + assert.ok(keys.every((k) => env.TRAILS.items.get(k).bytes.length === 0 && !env.TRAILS.items.get(k).customMetadata), "nothing in the object"); + assert.ok(!JSON.stringify([...env.TRAILS.items]).includes("203.0.113.9"), "the address is not stored"); + ok("a count is one empty object named by day, OS, version and the week and month marks; no address"); + + for (const bad of [{ v: "0.11.0", os: "mac", w: true }, { v: "