diff --git a/README.md b/README.md index 4c30e2d5..3f81efb1 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 → Privacy → Send analytics (off by default). Prompts once on first launch. Sends active days, feature-use counts 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/e2e/analytics-prompt.spec.ts b/e2e/analytics-prompt.spec.ts new file mode 100644 index 00000000..662d07cd --- /dev/null +++ b/e2e/analytics-prompt.spec.ts @@ -0,0 +1,53 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import { resolve } from 'path'; + +test.describe('Analytics consent', () => { + for (const choice of ['No thanks', 'Send analytics']) { + test(`first-launch metrics prompt remembers ${choice} on renderer reload`, async () => { + const app = await electron.launch({ args: [resolve(__dirname, '../out/main/index.js')] }); + try { + const main = await app.firstWindow(); + await main.waitForSelector('#root > *'); + // Exercise the packaged-build UI without enabling production analytics or altering consent. + await app.evaluate(({ ipcMain }) => { + let decided = false; + let enabled = false; + ipcMain.removeHandler('analytics-preference'); + ipcMain.removeHandler('analytics-set-enabled'); + ipcMain.handle('analytics-preference', () => ({ + enabled, + available: true, + needsPrompt: !decided, + })); + ipcMain.handle('analytics-set-enabled', (_event, value: boolean) => { + enabled = value; + decided = true; + return { enabled, available: true, needsPrompt: false }; + }); + }); + await main.reload(); + const prompt = main.getByRole('dialog', { name: 'Help improve Clipless?' }); + await expect(prompt).toBeVisible(); + await expect(prompt).toContainText('100% opt-in'); + await expect(prompt.getByRole('button', { name: 'No thanks' })).toBeFocused(); + const recordingDetails = prompt.getByText(/We send only usage metrics/); + await expect(recordingDetails).toBeHidden(); + await prompt.getByText('What is sent?', { exact: true }).click(); + await expect(recordingDetails).toBeVisible(); + await prompt.getByRole('button', { name: choice }).click(); + await expect(prompt).toHaveCount(0); + await main.reload(); + await main.waitForSelector('#root > *'); + const preference = await main.evaluate(() => window.api.analyticsPreference()); + expect(preference).toEqual({ + enabled: choice === 'Send analytics', + available: true, + needsPrompt: false, + }); + await expect(prompt).toHaveCount(0); + } finally { + await app.close(); + } + }); + } +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 6da4e9ad..9563a04a 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -80,7 +80,7 @@ test.describe('Settings window', () => { await expect(settings.locator('text=Versions')).toHaveCount(0); }); - test('General shows all five panels without scrolling at 900 x 600', async () => { + test('General shows all six panels and the privacy disclosure without scrolling at 900 x 600', async () => { const size = await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes('settings.html')); return { min: win?.getMinimumSize(), resizable: win?.isResizable() }; @@ -91,9 +91,11 @@ test.describe('Settings window', () => { // window at its minimum, so the default size is set here rather than trusted await setSettingsSize(app, 900, 600); await expect.poll(() => settings.evaluate(() => window.innerWidth)).toBe(900); - for (const name of ['application', 'window', 'storage', 'updates', 'about']) { + for (const name of ['application', 'window', 'privacy', 'storage', 'updates', 'about']) { await expect(settings.getByTestId(`panel-${name}`)).toBeVisible(); } + await expect(settings.getByTestId('toggle-usageAnalytics')).toBeInViewport(); + await expect(settings.getByText(/Help improve Clipless/)).toBeInViewport(); const scrolls = await settings.getByTestId('general-grid').evaluate((grid) => { const pane = grid.parentElement as HTMLElement; return pane.scrollHeight > pane.clientHeight; 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..b1de20d2 100644 --- a/okf-bundle/log.md +++ b/okf-bundle/log.md @@ -1,5 +1,18 @@ # Update Log +## 2026-09-12 +* **Update**: Updated [Opt-in Usage Analytics](/systems/opt-in-usage-analytics.md). +* **Update**: Updated [Opt-in Usage Analytics](/systems/opt-in-usage-analytics.md). +* **Update**: Updated [Global Hotkeys](/systems/hotkeys.md). +* **Update**: Updated [Opt-in Usage Analytics](/systems/opt-in-usage-analytics.md). +* **Update**: Updated [Opt-in Usage Analytics](/systems/opt-in-usage-analytics.md). +* **Update**: Updated [Opt-in Usage Analytics](/systems/opt-in-usage-analytics.md). + +## 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..d34778fd 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 `recordFeatureUsage('quick_clip_hotkey')` 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..f6b0bfb2 --- /dev/null +++ b/okf-bundle/systems/opt-in-usage-analytics.md @@ -0,0 +1,34 @@ +--- +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. Events are `app_active` and `feature_used`, 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 activity interface has no arguments; feature capture accepts only runtime-validated categories from `src/shared/analytics.ts`, with no custom properties.[^implementation] + +Consent defaults off. Settings → General → Privacy → Send analytics 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. Activity is limited to once per UTC day per process; each feature action sends one event, 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. + +The Privacy panel sits beneath Window in General's right column so its short usage explanation stays visible without scrolling at 900 × 600. Keep paragraph spacing compact and retain the responsive E2E assertions when changing explanatory copy. + +The first main-window load of a configured packaged build prompts for opt-in. Accept, decline or Escape saves the decision; existing saved consent suppresses the prompt. Failed writes keep reporting off and allow retry or continuing without analytics. The native dialog keeps background controls inert and initially focuses No thanks. + +Feature categories are quick_look (open action), history_search (first query input per search-bar opening), clip_copy (successful window copy), quick_clip_hotkey (successful hotkey copy), template_copy (successful output copy) and tool_launch (one successful launch action, not one per tab). No names, queries, URLs, clip data or configuration are sent. Use feature_used broken down by feature with unique users for adoption and total events for frequency. Feature usage also contributes to the daily activity count. + +The main-window consent prompt keeps the opt-in explanation visible. Recording specifics sit inside the collapsed What is sent? disclosure. Send analytics uses the shared primary/info confirmation styling; No thanks remains available and initially focused. 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/site/docs/index.html b/site/docs/index.html index a77816d6..50a9f8d5 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -606,8 +606,21 @@

Code Detection

Security and Privacy