Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
53 changes: 53 additions & 0 deletions e2e/analytics-prompt.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
}
});
6 changes: 4 additions & 2 deletions e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
13 changes: 13 additions & 0 deletions okf-bundle/log.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 4 additions & 2 deletions okf-bundle/systems/hotkeys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
1 change: 1 addition & 0 deletions okf-bundle/systems/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions okf-bundle/systems/opt-in-usage-analytics.md
Original file line number Diff line number Diff line change
@@ -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 `<userData>/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.
12 changes: 7 additions & 5 deletions okf-bundle/systems/secure-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<userData>/clipless-data/`, split into domain-specific files (see [Domain-split storage decision](../decisions/domain-split-storage.md)):

Expand All @@ -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 `<userData>/usage-analytics.json`. That file is not managed by SecureStorage and is excluded from backups/imports; it contains no clipboard content or tool settings.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
17 changes: 15 additions & 2 deletions site/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -606,8 +606,21 @@ <h2>Code Detection</h2>
<h2>Security and Privacy</h2>
<ul>
<li>All clipboard data is encrypted at rest using OS-native encryption</li>
<li>Data is stored locally only — no cloud sync, no remote servers</li>
<li>No telemetry, analytics, or tracking of any kind</li>
<li>Clipboard data is stored locally only — no cloud sync, no remote servers</li>
<li>
Send analytics (Settings &gt; General &gt; Privacy) is off by default. Clipless
asks once on first launch whether to participate and remembers either choice.
When enabled, it sends active-day and feature-use events with a random installation
ID to PostHog; it never sends clipboard contents, tool configurations, search term
details, session recordings or error reports. PostHog sees the connection's IP
address. See
<a
href="https://github.com/dantheuber/clipless/blob/main/src/main/analytics/README.md"
target="_blank"
rel="noopener"
>what is collected and how to opt out</a
>
</li>
<li>
The application is open source:
<a href="https://github.com/dantheuber/clipless" target="_blank" rel="noopener"
Expand Down
Loading
Loading