From db841243043876835aeb06acbd259dc6a3637d45 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Fri, 11 Sep 2026 16:35:13 -0700 Subject: [PATCH 01/14] feat: add opt-in PostHog active-installation analytics --- README.md | 1 + electron.vite.config.ts | 5 + okf-bundle/log.md | 5 + okf-bundle/systems/hotkeys.md | 6 +- okf-bundle/systems/index.md | 1 + okf-bundle/systems/opt-in-usage-analytics.md | 26 ++++ okf-bundle/systems/secure-storage.md | 12 +- package-lock.json | 4 +- package.json | 2 +- src/main/analytics/README.md | 72 ++++++++++ src/main/analytics/client.test.ts | 134 ++++++++++++++++++ src/main/analytics/client.ts | 104 ++++++++++++++ src/main/analytics/index.ts | 35 +++++ src/main/app/index.ts | 2 + src/main/global.d.ts | 2 + src/main/hotkeys/actions.ts | 2 + src/preload/index.d.ts | 2 + src/preload/index.ts | 3 + .../settings/general/Analytics.test.tsx | 29 ++++ .../components/settings/general/Analytics.tsx | 56 ++++++++ .../settings/general/Application.tsx | 2 + src/renderer/src/test-setup.ts | 4 + 22 files changed, 499 insertions(+), 10 deletions(-) create mode 100644 okf-bundle/systems/opt-in-usage-analytics.md create mode 100644 src/main/analytics/README.md create mode 100644 src/main/analytics/client.test.ts create mode 100644 src/main/analytics/client.ts create mode 100644 src/main/analytics/index.ts create mode 100644 src/renderer/src/components/settings/general/Analytics.test.tsx create mode 100644 src/renderer/src/components/settings/general/Analytics.tsx diff --git a/README.md b/README.md index 4c30e2d5..83498838 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ snippets and open them in your documentation. - **Add tools** — Settings → Quick Clips → Tools - **Set hotkeys** — Settings → Hotkeys - **Adjust preferences** — Settings → General +- **Optional usage counts** — Settings → General → Application → Share usage counts (off by default). Sends active days and a random installation ID to PostHog; no clipboard contents, configured tools, session recordings, or error reports. PostHog sees the connection's IP address. [What is collected and how to opt out](src/main/analytics/README.md). - **Auto start with system** — Settings → General (Windows & macOS) - **Start minimized** — Settings → General (start hidden in the tray) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 883249f1..646d46cd 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -9,6 +9,11 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], define: { __APP_VERSION__: JSON.stringify(packageJson.version), + // Public ingestion token (not a personal API key). Empty overrides disable reporting. + __POSTHOG_PROJECT_TOKEN__: JSON.stringify( + process.env.CLIPLESS_POSTHOG_TOKEN ?? 'phc_zTkMbFNXm7QYuJRVfwGKnM6niFSb5VyDU4teX4Zdm8YD' + ), + __POSTHOG_REGION__: JSON.stringify(process.env.CLIPLESS_POSTHOG_REGION ?? 'us'), }, }, preload: { diff --git a/okf-bundle/log.md b/okf-bundle/log.md index 016728a4..dd62b424 100644 --- a/okf-bundle/log.md +++ b/okf-bundle/log.md @@ -1,5 +1,10 @@ # Update Log +## 2026-09-11 +* **Update**: Updated [Global Hotkeys](/systems/hotkeys.md). +* **Update**: Updated [Secure Storage](/systems/secure-storage.md). +* **Creation**: Created [Opt-in Usage Analytics](/systems/opt-in-usage-analytics.md). + ## 2026-08-24 * Record the prose-as-python false positive and the showLanguageLabel mitigation diff --git a/okf-bundle/systems/hotkeys.md b/okf-bundle/systems/hotkeys.md index d459fc30..ff50d35f 100644 --- a/okf-bundle/systems/hotkeys.md +++ b/okf-bundle/systems/hotkeys.md @@ -5,8 +5,8 @@ tags: - hotkeys - main-process generated: - by: human:dantheuber - at: 2026-08-23T05:47:02.151Z + by: okf-mcp/2.0.0 + at: 2026-09-11T23:05:12.522Z --- Global hotkeys (`src/main/hotkeys/`) use Electron's `globalShortcut` and work even when Clipless is minimized. The module uses a registry/actions/manager pattern: @@ -29,3 +29,5 @@ Defaults (verified in `src/main/storage/defaults.ts`): the hotkey system is **gl The registry/actions/manager split is the house pattern for main-process modules: separate low-level OS binding from action logic from coordination, exposing a singleton with a stable public API. `hotkeyManager.cleanup()` runs on `before-quit`. The OS-reserved combinations the recorder warns about (spec 15.6) are a hand-maintained per-platform list in `src/shared/osReservedShortcuts.ts`. + +After a successful quick-clip copy, `HotkeyActions` calls the no-argument `recordAppActivity()` hook for [opt-in usage counts](opt-in-usage-analytics.md). It sends no clip, index, hotkey binding or tool information; disabled analytics makes the hook a no-op. diff --git a/okf-bundle/systems/index.md b/okf-bundle/systems/index.md index 44e05125..8dfea4d2 100644 --- a/okf-bundle/systems/index.md +++ b/okf-bundle/systems/index.md @@ -5,6 +5,7 @@ * [Clipboard Monitoring](clipboard-monitoring.md) * [Clips Provider (Renderer State)](clips-provider.md) * [Global Hotkeys](hotkeys.md) +* [Opt-in Usage Analytics](opt-in-usage-analytics.md) * [Quick Clips Pattern Scanning](quick-clips.md) * [Secure Storage](secure-storage.md) * [Templates](templates.md) diff --git a/okf-bundle/systems/opt-in-usage-analytics.md b/okf-bundle/systems/opt-in-usage-analytics.md new file mode 100644 index 00000000..ada4a25e --- /dev/null +++ b/okf-bundle/systems/opt-in-usage-analytics.md @@ -0,0 +1,26 @@ +--- +type: system +title: Opt-in Usage Analytics +tags: + - main-process + - settings +status: stable +generated: + by: okf-mcp/2.0.0 + at: 2026-09-11T23:04:56.418Z +sources: + - id: implementation + resource: src/main/analytics/client.ts + - id: wiring + resource: src/main/analytics/index.ts + - id: contract + resource: src/main/analytics/README.md +--- + +Clipless reports optional active-installation counts through PostHog's capture API from the main process. The only event is `app_active`, with a random UUIDv4 and fixed properties disabling person profiles and GeoIP and overriding `$ip`. No SDK, session replay, remote configuration, error reporting, clipboard data or configured-tool data is captured. The no-argument activity interface never accepts application data.[^implementation] + +Consent defaults off. Settings → General → Application → Share usage counts controls `/usage-analytics.json`, a separate local file excluded from [Secure Storage](secure-storage.md) backup/import. Opt-out aborts requests and removes the ID; re-enabling creates a new ID. Development builds never send. The US public project token is compiled into main only; environment overrides can disable or change the project.[^contract] + +Window focus/input and successful quick-clip hotkey copies count as activity. Input arguments are ignored. Attempts are limited to once per UTC day per process, with no background clipboard heartbeat, retries or offline queues. Use PostHog unique users for daily/weekly/monthly opted-in active installations, not event totals or exact people. Direct requests expose the network IP to PostHog regardless of event properties; discard client IP data in project settings.[^wiring] + +See the [privacy contract](../../src/main/analytics/README.md) for the exact payload, failure semantics and release verification. Privacy tests use mocked fetch and send nothing to production. diff --git a/okf-bundle/systems/secure-storage.md b/okf-bundle/systems/secure-storage.md index 52aefaf5..de68827f 100644 --- a/okf-bundle/systems/secure-storage.md +++ b/okf-bundle/systems/secure-storage.md @@ -7,14 +7,14 @@ tags: - main-process status: stable generated: - by: claude-code/fable-5 - at: 2026-08-23T16:44:37.570Z + by: okf-mcp/2.0.0 + at: 2026-09-11T23:05:12.504Z verified: - by: claude-code/fable-5 - at: 2026-08-23T14:00:00Z + - by: claude-code/fable-5 + at: 2026-08-23T14:00:00Z --- -`SecureStorage` (singleton in `src/main/storage/index.ts`) persists all app data encrypted with Electron's `safeStorage` API -- DPAPI on Windows, Keychain on macOS, Secret Service/libsecret on Linux. No key management in app code; keys belong to the OS user account. +`SecureStorage` (singleton in `src/main/storage/index.ts`) persists clipboard content and configuration encrypted with Electron's `safeStorage` API -- DPAPI on Windows, Keychain on macOS, Secret Service/libsecret on Linux. No key management in app code; keys belong to the OS user account. Data lives in `/clipless-data/`, split into domain-specific files (see [Domain-split storage decision](../decisions/domain-split-storage.md)): @@ -32,3 +32,5 @@ Behaviors verified in source (`file-operations.ts`, `index.ts`): - Internal JSON is compact (no pretty-printing) to minimize encrypted payload; user-facing export IS pretty-printed. - If `safeStorage.isEncryptionAvailable()` is false, background load stops and the app keeps default in-memory data -- nothing persists and the only signal is a console warning; **the user is not notified**. The test-only `CLIPLESS_PLAINTEXT_STORAGE=1` switch (Linux only) bypasses encryption for the e2e suites, see [E2E on Linux](../gotchas/e2e-on-linux-playwright-forces-the-basic-password-store.md). - Export/import round-trips all domains as unencrypted JSON for backup. + +[Usage analytics](opt-in-usage-analytics.md) stores consent and a random installation ID separately in `/usage-analytics.json`. That file is not managed by SecureStorage and is excluded from backups/imports; it contains no clipboard content or tool settings. diff --git a/package-lock.json b/package-lock.json index 8445f0b0..ddbc7258 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "2.3.2", + "version": "2.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "2.3.2", + "version": "2.3.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6dc24ddd..3505ec48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "2.3.2", + "version": "2.3.3", "description": "A Clipboard manager for busy people", "main": "./out/main/index.js", "author": "Daniel Essig", diff --git a/src/main/analytics/README.md b/src/main/analytics/README.md new file mode 100644 index 00000000..c59bf93c --- /dev/null +++ b/src/main/analytics/README.md @@ -0,0 +1,72 @@ +# Usage analytics privacy contract + +Clipless uses PostHog's [capture API](https://posthog.com/docs/api/capture) directly from +the Electron main process. There is no PostHog SDK, remote configuration, renderer +tracker, session replay, autocapture, exception capture, survey or feature flag client. +Changing a PostHog dashboard setting cannot add collection capabilities to the app. + +## What leaves the app + +The sole event is `app_active`. Its complete payload is constructed in `client.ts`: + +- Public project token and fixed event name. +- A locally generated random UUIDv4 as `distinct_id`. +- `$process_person_profile: false`, `$geoip_disable: true`, `$ip: '0.0.0.0'`. + +There are no user-supplied properties or event names. No clipboard contents, hashes, +lengths, types, titles, URLs, tool identities, commands, configuration, searches, +filenames, OS usernames, screen data, keystrokes or error messages are collected. +Input event arguments are ignored; the analytics method takes no arguments. +Node's fetch adds standard transport headers, and PostHog records receipt time. + +**This is pseudonymous measurement, not a promise of zero personal data.** A stable +installation ID links days of activity. PostHog sees the originating network IP on +a direct connection even though the payload overrides `$ip` and disables GeoIP. +In PostHog, set **Settings → Project → General → IP data capture** to discard client +IP data as an additional server-side control. See +[PostHog's collection controls](https://posthog.com/docs/privacy/data-collection). +Preventing PostHog from seeing the originating IP would require a separately operated relay. + +## Consent and lifecycle + +Settings → General → Application → **Share usage counts** is off by default for new +and existing installations. Consent is saved with the random ID in +`/usage-analytics.json`, separate from encrypted clipboard storage and +excluded from all backup export/import paths. Neither importing a backup nor +clearing clipboard history changes consent. Unreadable/invalid state fails closed. + +Opt-out aborts any in-flight request and discards the local ID. An already transmitted +request cannot be recalled; opt-out does not delete previous events in PostHog. +Re-enabling generates a new ID. Disk failures are shown in the consent UI; a failed +opt-out stops sending in that process, but must be saved successfully to survive restart. +Development/unpackaged builds and builds with invalid/missing configuration never send. + +The main process records activity when a window gains focus, on window keyboard/mouse +input, or after a successful quick-clip hotkey copy. The initial focused window and +opting in also count. There is no clipboard-monitor heartbeat: a hidden app that merely +monitors the clipboard does not count as active. Only one request is attempted per UTC +day per process. Restarting can send another event with the same ID on the same day. +Network failures are dropped without retries, queues, logging, or blocking clipboard work. + +## Build and dashboard setup + +The public US project token is compiled into **main only** by `electron.vite.config.ts`. +It is meant to be shipped in the app and does not grant access to project data. Never +substitute a personal API key. Build-time environment overrides are +`CLIPLESS_POSTHOG_TOKEN` and `CLIPLESS_POSTHOG_REGION` (`us` or `eu`). Set the token to +an empty string to make a build without reporting. + +For a release smoke test, use a packaged build and a fresh app profile. Check that no +PostHog request occurs before consent. Opt in and inspect the first event's complete +properties in PostHog; then opt out and verify no further activity requests occur. +Automated tests mock fetch and never send test data to the production project. + +In PostHog create a Trends insight for `app_active`, aggregated as **unique users**: +daily intervals for DAU, weekly for WAU, monthly for MAU. Set the project's reporting +timezone to UTC to match the client's day boundary. Use the same event for retention. +These are **opted-in active installations**, not exact people or all Clipless users. +Multiple devices, resets, opt-in selection, offline days and dropped requests affect +the counts. Do not use total event counts as active-user counts. + +Future analytics changes must preserve the fixed payload boundary and extend the +privacy tests. Do not run the automatic PostHog wizard over this integration. diff --git a/src/main/analytics/client.test.ts b/src/main/analytics/client.test.ts new file mode 100644 index 00000000..89e6ffdb --- /dev/null +++ b/src/main/analytics/client.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { UsageAnalytics } from './client'; + +let directory: string; +let path: string; +const token = 'phc_test'; +const send = vi.fn().mockResolvedValue({ ok: true }); + +beforeEach(async () => { + directory = await fs.mkdtemp(join(tmpdir(), 'clipless-analytics-')); + path = join(directory, 'usage-analytics.json'); + vi.stubGlobal('fetch', send); + send.mockClear(); +}); + +afterEach(async () => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + await fs.rm(directory, { recursive: true, force: true }); +}); + +describe('usage analytics privacy boundary', () => { + it('sends nothing and creates no ID until explicit opt-in', async () => { + const client = new UsageAnalytics(path, token, 'us', true); + await client.recordActivity(); + expect(await client.preference()).toEqual({ enabled: false, available: true }); + expect(send).not.toHaveBeenCalled(); + await expect(fs.readFile(path)).rejects.toThrow(); + }); + + it('sends only the fixed schema, ignores extra arguments, and deduplicates activity', async () => { + const client = new UsageAnalytics(path, token, 'us', true); + await client.setEnabled(true); + // Runtime callers cannot inject content even if they bypass the TypeScript signature. + const privateData = { content: 'secret clipboard', tools: ['private company tool'] }; + await Reflect.apply(client.recordActivity, client, [privateData]); + await Promise.all([client.recordActivity(), client.recordActivity()]); + expect(send).toHaveBeenCalledTimes(1); + const [url, options] = send.mock.calls[0]; + expect(url).toBe('https://us.i.posthog.com/i/v0/e/'); + expect(options).toMatchObject({ credentials: 'omit', redirect: 'error' }); + expect(JSON.parse(options.body)).toEqual({ + api_key: token, + event: 'app_active', + distinct_id: expect.stringMatching(/^[0-9a-f-]{36}$/), + properties: { $process_person_profile: false, $geoip_disable: true, $ip: '0.0.0.0' }, + }); + expect(options.body).not.toContain('secret'); + const id = JSON.parse(options.body).distinct_id; + const restarted = new UsageAnalytics(path, token, 'us', true); + await restarted.recordActivity(); + expect(JSON.parse(send.mock.calls[1][1].body).distinct_id).toBe(id); + }); + + it('reports activity on a subsequent UTC day, but has no background heartbeat', async () => { + const client = new UsageAnalytics(path, token, 'us', true); + await client.setEnabled(true); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-11T23:59:00Z')); + await client.recordActivity(); + vi.setSystemTime(new Date('2026-09-12T00:01:00Z')); + expect(send).toHaveBeenCalledTimes(1); + await client.recordActivity(); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('opts out immediately, aborts an in-flight request and rotates identity on re-opt-in', async () => { + let finish!: () => void; + send.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); + const client = new UsageAnalytics(path, token, 'us', true); + await client.setEnabled(true); + const pending = client.recordActivity(); + await vi.waitFor(() => expect(send).toHaveBeenCalled()); + const originalId = JSON.parse(send.mock.calls[0][1].body).distinct_id; + await client.setEnabled(false); + expect(send.mock.calls[0][1].signal.aborted).toBe(true); + finish(); + await pending; + await client.recordActivity(); + expect(send).toHaveBeenCalledTimes(1); + expect(JSON.parse(await fs.readFile(path, 'utf8'))).toEqual({ enabled: false }); + await client.setEnabled(true); + await client.recordActivity(); + expect(JSON.parse(send.mock.calls[1][1].body).distinct_id).not.toBe(originalId); + }); + + it.each([ + ['', 'us', true], + [token, 'invalid', true], + [token, 'us', false], + ])( + 'does not send with missing configuration or in development: %s %s %s', + async (key, region, packaged) => { + const client = new UsageAnalytics(path, key as string, region as string, packaged as boolean); + await client.setEnabled(true); + await client.recordActivity(); + expect(send).not.toHaveBeenCalled(); + } + ); + + it.each(['broken json', '{"enabled":true,"id":"clipboard content"}', '{"enabled":"true"}'])( + 'fails closed for invalid consent/identity: %s', + async (state) => { + await fs.writeFile(path, state); + const client = new UsageAnalytics(path, token, 'us', true); + await client.recordActivity(); + expect(send).not.toHaveBeenCalled(); + } + ); + + it('does not enable reporting if consent cannot be saved', async () => { + const client = new UsageAnalytics(join(directory, 'missing', 'state.json'), token, 'us', true); + await expect(client.setEnabled(true)).rejects.toThrow(); + await client.recordActivity(); + expect(send).not.toHaveBeenCalled(); + }); + + it('swallows network failures without retrying and stops at shutdown', async () => { + const client = new UsageAnalytics(path, token, 'eu', true); + await client.setEnabled(true); + send.mockRejectedValueOnce(new Error('network unavailable')); + await expect(client.recordActivity()).resolves.toBeUndefined(); + await client.recordActivity(); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0]).toBe('https://eu.i.posthog.com/i/v0/e/'); + const restarted = new UsageAnalytics(path, token, 'us', true); + restarted.stop(); + await restarted.recordActivity(); + expect(send).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/analytics/client.ts b/src/main/analytics/client.ts new file mode 100644 index 00000000..595ead53 --- /dev/null +++ b/src/main/analytics/client.ts @@ -0,0 +1,104 @@ +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** No event/property arguments: application data must never enter this boundary. */ +export class UsageAnalytics { + private id: string | undefined; + private ready: Promise; + private writes: Promise = Promise.resolve(); + private lastDay = ''; + private request: AbortController | undefined; + private stopped = false; + private endpoint: string | undefined; + + constructor( + private statePath: string, + private token: string, + region: string, + packaged: boolean + ) { + if (packaged && /^phc_[a-zA-Z0-9]+$/.test(token) && ['us', 'eu'].includes(region)) { + this.endpoint = `https://${region}.i.posthog.com/i/v0/e/`; + } + this.ready = this.load(); + } + + private async load(): Promise { + try { + const state = JSON.parse(await fs.readFile(this.statePath, 'utf8')); + if (state.enabled === true && typeof state.id === 'string' && UUID.test(state.id)) { + this.id = state.id; + } + } catch { + // Missing, corrupt or unreadable consent always means off. + } + } + + async preference(): Promise<{ enabled: boolean; available: boolean }> { + await this.ready; + return { enabled: !!this.id, available: !!this.endpoint }; + } + + setEnabled(enabled: boolean): Promise { + if (typeof enabled !== 'boolean') return Promise.reject(new Error('Invalid preference')); + const change = this.writes.then(async () => { + await this.ready; + this.request?.abort(); + const nextId = enabled ? (this.id ?? randomUUID()) : undefined; + // Disable in memory before disk I/O; failed opt-outs must not keep sending. + this.id = undefined; + this.lastDay = ''; + const temporaryPath = `${this.statePath}.tmp`; + await fs.writeFile(temporaryPath, JSON.stringify({ enabled, id: nextId }), { mode: 0o600 }); + await fs.rename(temporaryPath, this.statePath); + this.id = nextId; + }); + this.writes = change.catch(() => {}); + return change; + } + + async recordActivity(): Promise { + await this.ready; + await this.writes; + const day = new Date().toISOString().slice(0, 10); + if (this.stopped || !this.endpoint || !this.id || this.lastDay === day) return; + // At most one attempt per UTC day per process. No offline queue or retries. + this.lastDay = day; + this.request?.abort(); + const controller = new AbortController(); + this.request = controller; + const timeout = setTimeout(() => controller.abort(), 5000); + timeout.unref(); + try { + await fetch(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'omit', + redirect: 'error', + signal: controller.signal, + body: JSON.stringify({ + api_key: this.token, + event: 'app_active', + distinct_id: this.id, + properties: { + $process_person_profile: false, + $geoip_disable: true, + $ip: '0.0.0.0', + }, + }), + }); + } catch { + // Analytics never interrupts clipboard operations or uploads error details. + } finally { + clearTimeout(timeout); + if (this.request === controller) this.request = undefined; + } + } + + stop(): void { + this.stopped = true; + this.request?.abort(); + } +} diff --git a/src/main/analytics/index.ts b/src/main/analytics/index.ts new file mode 100644 index 00000000..8b611e4d --- /dev/null +++ b/src/main/analytics/index.ts @@ -0,0 +1,35 @@ +import { app, BrowserWindow, ipcMain } from 'electron'; +import { join } from 'node:path'; +import { UsageAnalytics } from './client'; + +let analytics: UsageAnalytics | undefined; + +export function recordAppActivity(): void { + void analytics?.recordActivity(); +} + +export function initializeAnalytics(): void { + analytics = new UsageAnalytics( + join(app.getPath('userData'), 'usage-analytics.json'), + __POSTHOG_PROJECT_TOKEN__, + __POSTHOG_REGION__, + app.isPackaged + ); + ipcMain.handle('analytics-preference', () => analytics!.preference()); + ipcMain.handle('analytics-set-enabled', async (_event, enabled: boolean) => { + await analytics!.setEnabled(enabled); + // Choosing to participate is itself an interaction with Clipless. + if (enabled) recordAppActivity(); + return analytics!.preference(); + }); + const watch = (window: BrowserWindow): void => { + window.on('focus', recordAppActivity); + // Deliberately ignore every argument, including key text and mouse coordinates. + window.webContents.on('before-input-event', recordAppActivity); + window.webContents.on('before-mouse-event', recordAppActivity); + }; + BrowserWindow.getAllWindows().forEach(watch); + app.on('browser-window-created', (_event, window) => watch(window)); + if (BrowserWindow.getFocusedWindow()) recordAppActivity(); + app.on('before-quit', () => analytics?.stop()); +} diff --git a/src/main/app/index.ts b/src/main/app/index.ts index f03af459..9c408147 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -12,6 +12,7 @@ import { watchSystemThemeForWindowBackground, } from '../window/background'; import { applyAutoStart } from '../autoStart'; +import { initializeAnalytics } from '../analytics'; export async function initializeApp(): Promise { // Playwright launches Electron with --password-store=basic, and with that store Linux @@ -131,6 +132,7 @@ export function setupAppEvents(): void { } export function initializeServices(): void { + initializeAnalytics(); // Setup IPC handlers setupMainIPC(); diff --git a/src/main/global.d.ts b/src/main/global.d.ts index 41fad5b5..07f48f03 100644 --- a/src/main/global.d.ts +++ b/src/main/global.d.ts @@ -1 +1,3 @@ declare const __APP_VERSION__: string; +declare const __POSTHOG_PROJECT_TOKEN__: string; +declare const __POSTHOG_REGION__: string; diff --git a/src/main/hotkeys/actions.ts b/src/main/hotkeys/actions.ts index 6953e2d5..827f5a87 100644 --- a/src/main/hotkeys/actions.ts +++ b/src/main/hotkeys/actions.ts @@ -5,6 +5,7 @@ import { showNotification } from '../notifications'; import { loadImage } from '../storage/image-store'; import { checkClipboardNow, setSkipNextImageChange } from '../clipboard/monitoring'; import type { ClipItem, StoredClip } from '../../shared/types'; +import { recordAppActivity } from '../analytics'; /** * What the OS notification for a hotkey copy says: the clip's first line, since the window @@ -92,6 +93,7 @@ export class HotkeyActions { // Copy the clip content with the appropriate format based on its type await this.copyClipToClipboard(clipToCopy); + recordAppActivity(); console.log(`Hotkey: Copied clip ${index + 1} to clipboard`); showNotification('Clip copied', clipSummary(clipToCopy.clip)); diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 216165f1..834738be 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -52,6 +52,8 @@ declare global { storageGetClipsSnapshot: () => Promise; storageSaveClips: (clips: any[], lockedIndices: Record) => Promise; storageGetSettings: () => Promise; + analyticsPreference: () => Promise<{ enabled: boolean; available: boolean }>; + analyticsSetEnabled: (enabled: boolean) => Promise<{ enabled: boolean; available: boolean }>; storageSaveSettings: (settings: Partial) => Promise; storageGetStats: () => Promise; storageExportData: () => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4e6f258d..c8799846 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -91,6 +91,9 @@ const api = { storageSaveClips: (clips: StoredClip[], lockedIndices: Record) => electronAPI.ipcRenderer.invoke('storage-save-clips', clips, lockedIndices), storageGetSettings: () => electronAPI.ipcRenderer.invoke('storage-get-settings'), + analyticsPreference: () => electronAPI.ipcRenderer.invoke('analytics-preference'), + analyticsSetEnabled: (enabled: boolean) => + electronAPI.ipcRenderer.invoke('analytics-set-enabled', enabled), storageSaveSettings: (settings: UserSettings) => electronAPI.ipcRenderer.invoke('storage-save-settings', settings), storageGetStats: () => electronAPI.ipcRenderer.invoke('storage-get-stats'), diff --git a/src/renderer/src/components/settings/general/Analytics.test.tsx b/src/renderer/src/components/settings/general/Analytics.test.tsx new file mode 100644 index 00000000..69f68aed --- /dev/null +++ b/src/renderer/src/components/settings/general/Analytics.test.tsx @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Analytics } from './Analytics'; + +afterEach(cleanup); + +describe('analytics consent', () => { + it('explains the data sent and requires an explicit toggle to participate', async () => { + render(); + const toggle = screen.getByRole('switch', { name: 'Share usage counts' }); + await waitFor(() => expect(toggle).toBeEnabled()); + expect(toggle).not.toBeChecked(); + expect(screen.getByText(/PostHog sees your connection/)).toBeVisible(); + expect(window.api.analyticsSetEnabled).not.toHaveBeenCalled(); + fireEvent.click(toggle); + await waitFor(() => expect(toggle).toBeChecked()); + expect(window.api.analyticsSetEnabled).toHaveBeenCalledWith(true); + fireEvent.click(toggle); + await waitFor(() => expect(toggle).not.toBeChecked()); + expect(window.api.analyticsSetEnabled).toHaveBeenLastCalledWith(false); + }); + + it('keeps the control disabled if reading consent fails', async () => { + vi.mocked(window.api.analyticsPreference).mockRejectedValueOnce(new Error('unreadable')); + render(); + expect(await screen.findByRole('alert')).toBeVisible(); + expect(screen.getByRole('switch')).toBeDisabled(); + }); +}); diff --git a/src/renderer/src/components/settings/general/Analytics.tsx b/src/renderer/src/components/settings/general/Analytics.tsx new file mode 100644 index 00000000..ad409f0d --- /dev/null +++ b/src/renderer/src/components/settings/general/Analytics.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { ToggleSwitch } from '../usersettings/ToggleSwitch'; +import { Row } from './Row'; +import styles from './General.module.css'; + +export function Analytics() { + const [preference, setPreference] = useState<{ enabled: boolean; available: boolean }>(); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + window.api + .analyticsPreference() + .then(setPreference) + .catch(() => setFailed(true)); + }, []); + + const change = async (enabled: boolean) => { + setBusy(true); + setFailed(false); + try { + setPreference(await window.api.analyticsSetEnabled(enabled)); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + }; + + return ( +
+ + void change(enabled)} + disabled={busy || !preference || (!preference.available && !preference.enabled)} + label="Share usage counts" + testId="toggle-usageAnalytics" + /> + +

+ Sends active days and a random installation ID to PostHog. No clipboard content, configured + tools, screen recordings or error reports. PostHog sees your connection’s IP address. + Turning this off stops future reports and resets the ID. +

+ {preference && !preference.available && ( +

Usage reporting is unavailable in this build.

+ )} + {failed &&

Could not read or save your usage reporting preference.

} +
+ ); +} diff --git a/src/renderer/src/components/settings/general/Application.tsx b/src/renderer/src/components/settings/general/Application.tsx index dd64c544..9aeb64b5 100644 --- a/src/renderer/src/components/settings/general/Application.tsx +++ b/src/renderer/src/components/settings/general/Application.tsx @@ -1,4 +1,5 @@ import { ClipsToKeep } from './ClipsToKeep'; +import { Analytics } from './Analytics'; import { Panel, Row, ToggleRow } from './Row'; import { useSetting } from './useSetting'; import w from '../shell/widgets.module.css'; @@ -59,6 +60,7 @@ export function Application() { description="Show the detected language tag at the left of a code clip's row." dimmed={codeDetection.value !== true} /> + ); } diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts index 6b8f5843..8857f0cf 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -20,6 +20,10 @@ Object.defineProperty(window, 'matchMedia', { const createMockApi = () => ({ platform: 'win32' as NodeJS.Platform, arch: 'x64', + analyticsPreference: vi.fn().mockResolvedValue({ enabled: false, available: true }), + analyticsSetEnabled: vi + .fn() + .mockImplementation(async (enabled: boolean) => ({ enabled, available: true })), storageGetSettings: vi.fn().mockResolvedValue({ maxClips: 100, startMinimized: false, From 269f05b683212887b6cd1c566e4f6676708ebf92 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:43:01 +0000 Subject: [PATCH 02/14] fix(analytics): report opt-out failures in the row status and correct the docs privacy page The docs site still claimed no analytics of any kind. Scope the local-only bullet to clipboard data and describe the opt-in usage ping in the same terms as the README, linking to the analytics README. The settings row now uses the shared RowStatus slot instead of a generic alert. A failed opt-out re-reads the stored preference so the switch shows what is on disk, and the error message says reporting is paused now but may resume next launch. The initial read failure keeps its own message. Name the preference payload AnalyticsPreference in shared/types and use it in preload, the main client and the settings component. Add the analytics row to the Application panel docstring. --- site/docs/index.html | 16 +++++++- src/main/analytics/client.ts | 3 +- src/preload/index.d.ts | 5 ++- .../settings/general/Analytics.test.tsx | 14 +++++++ .../components/settings/general/Analytics.tsx | 39 +++++++++++++------ .../settings/general/Application.tsx | 3 +- src/shared/types.ts | 6 +++ 7 files changed, 68 insertions(+), 18 deletions(-) diff --git a/site/docs/index.html b/site/docs/index.html index a77816d6..b12182c6 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -606,8 +606,20 @@

Code Detection

Security and Privacy