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
This commit is contained in:
Bohdan Triapitsyn
2026-03-22 23:02:53 +02:00
committed by GitHub
parent 53c2a0d919
commit 64b55025e1
9 changed files with 116 additions and 25 deletions
@@ -110,6 +110,7 @@ const VisualSectionContent: React.FC = () => {
'spacing',
'inputBarOffset',
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
'reportUsage',
]} />;
};
@@ -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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
</div>
)}
{/* --- Privacy & Data --- */}
{shouldShow('reportUsage') && (
<div className="space-y-3">
<section className="px-2 pb-2 pt-0">
<h4 className="typography-ui-header font-medium text-foreground mb-2">Privacy</h4>
<div className="flex items-start gap-2 py-1.5">
<Checkbox
checked={reportUsage}
onChange={handleReportUsageChange}
ariaLabel="Send anonymous usage reports"
/>
<div className="flex min-w-0 flex-col gap-0.5">
<div
className="group flex cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={reportUsage}
onClick={() => handleReportUsageChange(!reportUsage)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleReportUsageChange(!reportUsage);
}
}}
>
<span className="typography-ui-label text-foreground">Send anonymous usage reports</span>
</div>
<span className="typography-meta text-muted-foreground pointer-events-none">
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.
</span>
</div>
</div>
</section>
</div>
)}
</div>
);
};
+2
View File
@@ -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 = {
+7
View File
@@ -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;
};
+7 -3
View File
@@ -558,10 +558,8 @@ interface UIStore {
stickyUserHeader: boolean;
showMobileSessionStatusBar: boolean;
isMobileSessionStatusBarCollapsed: boolean;
viewPagerPage: 'left' | 'center' | 'right';
isExpandedInput: boolean;
reportUsage: boolean;
shortcutOverrides: Record<string, ShortcutCombo>;
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<UIStore>()(
showMobileSessionStatusBar: true,
isMobileSessionStatusBarCollapsed: false,
isExpandedInput: false,
reportUsage: true,
shortcutOverrides: {},
setTheme: (theme) => {
@@ -1690,6 +1691,9 @@ export const useUIStore = create<UIStore>()(
setIsMobileSessionStatusBarCollapsed: (value) => {
set({ isMobileSessionStatusBarCollapsed: value });
},
setReportUsage: (value) => {
set({ reportUsage: value });
},
viewPagerPage: 'center',
setViewPagerPage: (page: 'left' | 'center' | 'right') => {
set({ viewPagerPage: page });
+5 -1
View File
@@ -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());
+40 -20
View File
@@ -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<string> => {
const state = ctx?.context?.globalState;
if (state) {
const existing = state.get<string>(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,
+4
View File
@@ -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' } });
+5
View File
@@ -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;
};