From 64b55025e1ba26da44037c6a08840dac467760c0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 22 Mar 2026 23:02:53 +0200 Subject: [PATCH] feat: Add opt-out setting for anonymous usage reporting (#743) * refactor: align VS Code update checks with web runtime parity - VS Code now uses file-based installId (shared with web server) - Accepts platform/arch from webview for consistent behavior - Usage data collection now matches web implementation * feat: Add opt-out setting for anonymous usage reporting in Appearance - Add privacy control in Appearance settings to opt-out of anonymous usage reports - Usage data includes only app version, platform, and runtime - no personal data or code collected - Setting persists across all runtimes and controls the reportUsage parameter in update checks --- .../sections/openchamber/OpenChamberPage.tsx | 1 + .../openchamber/OpenChamberVisualSettings.tsx | 46 +++++++++++++- packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/persistence.ts | 7 +++ packages/ui/src/stores/useUIStore.ts | 10 +++- packages/ui/src/stores/useUpdateStore.ts | 6 +- packages/vscode/src/bridge.ts | 60 ++++++++++++------- packages/vscode/webview/main.tsx | 4 ++ packages/web/server/index.js | 5 ++ 9 files changed, 116 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 799f5243..dbdce9a4 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -110,6 +110,7 @@ const VisualSectionContent: React.FC = () => { 'spacing', 'inputBarOffset', ...(!isVSCode ? ['terminalQuickKeys' as const] : []), + 'reportUsage', ]} />; }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 4215eedc..cdc43f29 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -143,7 +143,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -210,6 +210,14 @@ export const OpenChamberVisualSettings: React.FC const [themesReloading, setThemesReloading] = React.useState(false); const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0); + const reportUsage = useUIStore(state => state.reportUsage); + const setReportUsage = useUIStore(state => state.setReportUsage); + + // Sync reportUsage changes to server settings + const handleReportUsageChange = React.useCallback((enabled: boolean) => { + setReportUsage(enabled); + void updateDesktopSettings({ reportUsage: enabled }); + }, [setReportUsage]); const shouldAnimateChatPreview = isSettingsDialogOpen && (visibleSettings ? visibleSettings.includes('chatRenderMode') : true); @@ -1244,6 +1252,42 @@ export const OpenChamberVisualSettings: React.FC )} + {/* --- Privacy & Data --- */} + {shouldShow('reportUsage') && ( +
+
+

Privacy

+
+ +
+
handleReportUsageChange(!reportUsage)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleReportUsageChange(!reportUsage); + } + }} + > + Send anonymous usage reports +
+ + Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code. + +
+
+
+
+ )} + ); }; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 811a92ed..662b821b 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -144,6 +144,8 @@ export type DesktopSettings = { // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) skillCatalogs?: SkillCatalogConfig[]; + // Opt-in to send anonymous usage reports for update checks (default: true) + reportUsage?: boolean; }; type TauriGlobal = { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 5b6ad19d..81975005 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -393,6 +393,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) { store.setStickyUserHeader(settings.stickyUserHeader); } + if (typeof settings.reportUsage === 'boolean' && settings.reportUsage !== store.reportUsage) { + store.setReportUsage(settings.reportUsage); + } if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) { store.setFontSize(settings.fontSize); } @@ -840,6 +843,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { result.skillCatalogs = skillCatalogs; } + if (typeof candidate.reportUsage === 'boolean') { + result.reportUsage = candidate.reportUsage; + } + return result; }; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 0c6bc7f3..934ed070 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -558,10 +558,8 @@ interface UIStore { stickyUserHeader: boolean; showMobileSessionStatusBar: boolean; isMobileSessionStatusBarCollapsed: boolean; - viewPagerPage: 'left' | 'center' | 'right'; - isExpandedInput: boolean; - + reportUsage: boolean; shortcutOverrides: Record; setTheme: (theme: 'light' | 'dark' | 'system') => void; @@ -669,11 +667,13 @@ interface UIStore { setStickyUserHeader: (value: boolean) => void; setShowMobileSessionStatusBar: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; + viewPagerPage: 'left' | 'center' | 'right'; setViewPagerPage: (page: 'left' | 'center' | 'right') => void; toggleExpandedInput: () => void; setExpandedInput: (value: boolean) => void; openMultiRunLauncher: () => void; openMultiRunLauncherWithPrompt: (prompt: string) => void; + setReportUsage: (value: boolean) => void; setShortcutOverride: (actionId: string, combo: ShortcutCombo) => void; clearShortcutOverride: (actionId: string) => void; resetAllShortcutOverrides: () => void; @@ -782,6 +782,7 @@ export const useUIStore = create()( showMobileSessionStatusBar: true, isMobileSessionStatusBarCollapsed: false, isExpandedInput: false, + reportUsage: true, shortcutOverrides: {}, setTheme: (theme) => { @@ -1690,6 +1691,9 @@ export const useUIStore = create()( setIsMobileSessionStatusBarCollapsed: (value) => { set({ isMobileSessionStatusBarCollapsed: value }); }, + setReportUsage: (value) => { + set({ reportUsage: value }); + }, viewPagerPage: 'center', setViewPagerPage: (page: 'left' | 'center' | 'right') => { set({ viewPagerPage: page }); diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index 877e6bd2..8f3c167b 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { UpdateInfo, UpdateProgress } from '@/lib/desktop'; import { getDeviceInfo } from '@/lib/device'; +import { useUIStore } from './useUIStore'; import { checkForDesktopUpdates, downloadDesktopUpdate, @@ -66,7 +67,10 @@ function detectPlatform(): 'macos' | 'windows' | 'linux' | 'web' { } function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams { - const params = new URLSearchParams({ reportUsage: 'true' }); + // Check if user has opted out of usage reporting (default: true/enabled from UI store) + const shouldReportUsage = useUIStore.getState().reportUsage; + + const params = new URLSearchParams({ reportUsage: shouldReportUsage ? 'true' : 'false' }); params.set('deviceClass', detectDeviceClass()); params.set('arch', detectArch()); params.set('platform', detectPlatform()); diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index d7d06cc8..96ec40e9 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -113,9 +113,39 @@ const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); -const VSCODE_INSTALL_ID_KEY = 'openchamber.installId.vscode'; const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check'; +const getOpenChamberConfigDir = (): string => { + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + if (appData) return path.join(appData, 'openchamber'); + } + return path.join(os.homedir(), '.config', 'openchamber'); +}; + +const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => { + if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; + return 'web'; +}; + +const getOrCreateInstallId = (scope: string): string => { + const configDir = getOpenChamberConfigDir(); + const normalizedScope = sanitizeInstallScope(scope); + const idPath = path.join(configDir, `install-id-${normalizedScope}`); + + try { + const existing = fs.readFileSync(idPath, 'utf8').trim(); + if (existing) return existing; + } catch { + // Generate new id. + } + + const installId = randomUUID(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 }); + return installId; +}; + const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => { if (value === 'darwin') return 'macos'; if (value === 'win32') return 'windows'; @@ -129,22 +159,6 @@ const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => { return 'unknown'; }; -const getOrCreateVSCodeInstallId = async (ctx?: BridgeContext): Promise => { - const state = ctx?.context?.globalState; - if (state) { - const existing = state.get(VSCODE_INSTALL_ID_KEY); - if (typeof existing === 'string' && existing.trim().length > 0) { - return existing.trim(); - } - } - - const generated = randomUUID(); - if (state) { - await state.update(VSCODE_INSTALL_ID_KEY, generated); - } - return generated; -}; - const guessMimeTypeFromExtension = (ext: string) => { switch (ext) { case '.png': @@ -2935,14 +2949,20 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0 ? body.deviceClass.trim() : 'desktop'; + const platformRaw = typeof body.platform === 'string' && body.platform.trim().length > 0 + ? body.platform.trim() + : os.platform(); + const archRaw = typeof body.arch === 'string' && body.arch.trim().length > 0 + ? body.arch.trim() + : os.arch(); const reportUsage = body.reportUsage !== false; - const installId = await getOrCreateVSCodeInstallId(ctx); + const installId = getOrCreateInstallId('vscode'); const requestBody = { appType: 'vscode', deviceClass, - platform: mapNodePlatformToApiPlatform(os.platform()), - arch: mapNodeArchToApiArch(os.arch()), + platform: mapNodePlatformToApiPlatform(platformRaw), + arch: mapNodeArchToApiArch(archRaw), channel: 'stable', currentVersion, installId, diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index b810f443..48b2a4d1 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -754,12 +754,16 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const currentVersion = url.searchParams.get('currentVersion') || undefined; const instanceMode = url.searchParams.get('instanceMode') || 'local'; const deviceClass = url.searchParams.get('deviceClass') || 'desktop'; + const platform = url.searchParams.get('platform') || undefined; + const arch = url.searchParams.get('arch') || undefined; const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase(); const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no'); const data = await sendBridgeMessage('api:openchamber:update-check', { currentVersion, instanceMode, deviceClass, + platform, + arch, reportUsage, }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 12ece679..c891e0f8 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -2448,6 +2448,11 @@ const sanitizeSettingsUpdate = (payload) => { } } + // Usage reporting opt-out (default: true/enabled) + if (typeof candidate.reportUsage === 'boolean') { + result.reportUsage = candidate.reportUsage; + } + return result; };