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:
committed by
GitHub
parent
53c2a0d919
commit
64b55025e1
@@ -110,6 +110,7 @@ const VisualSectionContent: React.FC = () => {
|
|||||||
'spacing',
|
'spacing',
|
||||||
'inputBarOffset',
|
'inputBarOffset',
|
||||||
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
||||||
|
'reportUsage',
|
||||||
]} />;
|
]} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
|||||||
return mode === 'markdown' ? '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 {
|
interface OpenChamberVisualSettingsProps {
|
||||||
/** Which settings to show. If undefined, shows all. */
|
/** 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 [themesReloading, setThemesReloading] = React.useState(false);
|
||||||
const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
|
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
|
const shouldAnimateChatPreview = isSettingsDialogOpen
|
||||||
&& (visibleSettings ? visibleSettings.includes('chatRenderMode') : true);
|
&& (visibleSettings ? visibleSettings.includes('chatRenderMode') : true);
|
||||||
@@ -1244,6 +1252,42 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export type DesktopSettings = {
|
|||||||
|
|
||||||
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
||||||
skillCatalogs?: SkillCatalogConfig[];
|
skillCatalogs?: SkillCatalogConfig[];
|
||||||
|
// Opt-in to send anonymous usage reports for update checks (default: true)
|
||||||
|
reportUsage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TauriGlobal = {
|
type TauriGlobal = {
|
||||||
|
|||||||
@@ -393,6 +393,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) {
|
if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) {
|
||||||
store.setStickyUserHeader(settings.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) {
|
if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) {
|
||||||
store.setFontSize(settings.fontSize);
|
store.setFontSize(settings.fontSize);
|
||||||
}
|
}
|
||||||
@@ -840,6 +843,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
result.skillCatalogs = skillCatalogs;
|
result.skillCatalogs = skillCatalogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof candidate.reportUsage === 'boolean') {
|
||||||
|
result.reportUsage = candidate.reportUsage;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -558,10 +558,8 @@ interface UIStore {
|
|||||||
stickyUserHeader: boolean;
|
stickyUserHeader: boolean;
|
||||||
showMobileSessionStatusBar: boolean;
|
showMobileSessionStatusBar: boolean;
|
||||||
isMobileSessionStatusBarCollapsed: boolean;
|
isMobileSessionStatusBarCollapsed: boolean;
|
||||||
viewPagerPage: 'left' | 'center' | 'right';
|
|
||||||
|
|
||||||
isExpandedInput: boolean;
|
isExpandedInput: boolean;
|
||||||
|
reportUsage: boolean;
|
||||||
shortcutOverrides: Record<string, ShortcutCombo>;
|
shortcutOverrides: Record<string, ShortcutCombo>;
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||||
@@ -669,11 +667,13 @@ interface UIStore {
|
|||||||
setStickyUserHeader: (value: boolean) => void;
|
setStickyUserHeader: (value: boolean) => void;
|
||||||
setShowMobileSessionStatusBar: (value: boolean) => void;
|
setShowMobileSessionStatusBar: (value: boolean) => void;
|
||||||
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||||
|
viewPagerPage: 'left' | 'center' | 'right';
|
||||||
setViewPagerPage: (page: 'left' | 'center' | 'right') => void;
|
setViewPagerPage: (page: 'left' | 'center' | 'right') => void;
|
||||||
toggleExpandedInput: () => void;
|
toggleExpandedInput: () => void;
|
||||||
setExpandedInput: (value: boolean) => void;
|
setExpandedInput: (value: boolean) => void;
|
||||||
openMultiRunLauncher: () => void;
|
openMultiRunLauncher: () => void;
|
||||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||||
|
setReportUsage: (value: boolean) => void;
|
||||||
setShortcutOverride: (actionId: string, combo: ShortcutCombo) => void;
|
setShortcutOverride: (actionId: string, combo: ShortcutCombo) => void;
|
||||||
clearShortcutOverride: (actionId: string) => void;
|
clearShortcutOverride: (actionId: string) => void;
|
||||||
resetAllShortcutOverrides: () => void;
|
resetAllShortcutOverrides: () => void;
|
||||||
@@ -782,6 +782,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
showMobileSessionStatusBar: true,
|
showMobileSessionStatusBar: true,
|
||||||
isMobileSessionStatusBarCollapsed: false,
|
isMobileSessionStatusBarCollapsed: false,
|
||||||
isExpandedInput: false,
|
isExpandedInput: false,
|
||||||
|
reportUsage: true,
|
||||||
shortcutOverrides: {},
|
shortcutOverrides: {},
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
@@ -1690,6 +1691,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
setIsMobileSessionStatusBarCollapsed: (value) => {
|
setIsMobileSessionStatusBarCollapsed: (value) => {
|
||||||
set({ isMobileSessionStatusBarCollapsed: value });
|
set({ isMobileSessionStatusBarCollapsed: value });
|
||||||
},
|
},
|
||||||
|
setReportUsage: (value) => {
|
||||||
|
set({ reportUsage: value });
|
||||||
|
},
|
||||||
viewPagerPage: 'center',
|
viewPagerPage: 'center',
|
||||||
setViewPagerPage: (page: 'left' | 'center' | 'right') => {
|
setViewPagerPage: (page: 'left' | 'center' | 'right') => {
|
||||||
set({ viewPagerPage: page });
|
set({ viewPagerPage: page });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
||||||
import { getDeviceInfo } from '@/lib/device';
|
import { getDeviceInfo } from '@/lib/device';
|
||||||
|
import { useUIStore } from './useUIStore';
|
||||||
import {
|
import {
|
||||||
checkForDesktopUpdates,
|
checkForDesktopUpdates,
|
||||||
downloadDesktopUpdate,
|
downloadDesktopUpdate,
|
||||||
@@ -66,7 +67,10 @@ function detectPlatform(): 'macos' | 'windows' | 'linux' | 'web' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
|
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('deviceClass', detectDeviceClass());
|
||||||
params.set('arch', detectArch());
|
params.set('arch', detectArch());
|
||||||
params.set('platform', detectPlatform());
|
params.set('platform', detectPlatform());
|
||||||
|
|||||||
@@ -113,9 +113,39 @@ const execFileAsync = promisify(execFile);
|
|||||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
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 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 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' => {
|
const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => {
|
||||||
if (value === 'darwin') return 'macos';
|
if (value === 'darwin') return 'macos';
|
||||||
if (value === 'win32') return 'windows';
|
if (value === 'win32') return 'windows';
|
||||||
@@ -129,22 +159,6 @@ const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => {
|
|||||||
return '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) => {
|
const guessMimeTypeFromExtension = (ext: string) => {
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case '.png':
|
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
|
const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0
|
||||||
? body.deviceClass.trim()
|
? body.deviceClass.trim()
|
||||||
: 'desktop';
|
: '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 reportUsage = body.reportUsage !== false;
|
||||||
|
|
||||||
const installId = await getOrCreateVSCodeInstallId(ctx);
|
const installId = getOrCreateInstallId('vscode');
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
appType: 'vscode',
|
appType: 'vscode',
|
||||||
deviceClass,
|
deviceClass,
|
||||||
platform: mapNodePlatformToApiPlatform(os.platform()),
|
platform: mapNodePlatformToApiPlatform(platformRaw),
|
||||||
arch: mapNodeArchToApiArch(os.arch()),
|
arch: mapNodeArchToApiArch(archRaw),
|
||||||
channel: 'stable',
|
channel: 'stable',
|
||||||
currentVersion,
|
currentVersion,
|
||||||
installId,
|
installId,
|
||||||
|
|||||||
@@ -754,12 +754,16 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
const currentVersion = url.searchParams.get('currentVersion') || undefined;
|
const currentVersion = url.searchParams.get('currentVersion') || undefined;
|
||||||
const instanceMode = url.searchParams.get('instanceMode') || 'local';
|
const instanceMode = url.searchParams.get('instanceMode') || 'local';
|
||||||
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
|
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 reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
|
||||||
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
|
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
|
||||||
const data = await sendBridgeMessage('api:openchamber:update-check', {
|
const data = await sendBridgeMessage('api:openchamber:update-check', {
|
||||||
currentVersion,
|
currentVersion,
|
||||||
instanceMode,
|
instanceMode,
|
||||||
deviceClass,
|
deviceClass,
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
reportUsage,
|
reportUsage,
|
||||||
});
|
});
|
||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|||||||
@@ -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;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user