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
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
APPLE_SIGNING_IDENTITY: ${{ steps.apple-cert.outputs.identity }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
with:
projectPath: apps/desktop
# Upload to the existing draft release
Expand Down
7 changes: 0 additions & 7 deletions apps/desktop/.env.example

This file was deleted.

1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@tauri-apps/plugin-updater": "^2",
"immer": "^11.0.1",
"lucide-react": "^0.561.0",
"posthog-js": "^1.351.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zustand": "^5.0.9"
Expand Down
35 changes: 34 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import { ServerInstallModal } from '@/components/ServerInstallModal';
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
import { ConnectIDEs } from '@/components/ConnectIDEs';
import { useDataSync } from '@/hooks/useDataSync';
import { useAppStore, useActiveSpace, useViewSpace, useTheme } from '@/stores';
import { useAnalytics } from '@/hooks/useAnalytics';
import { initAnalytics, capture, optIn, optOut } from '@/lib/analytics';
import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled } from '@/stores';
import { RegistryPage } from '@/features/registry';
import { FeatureSetsPage } from '@/features/featuresets';
import { ClientsPage } from '@/features/clients';
Expand Down Expand Up @@ -111,6 +113,7 @@ function AppContent() {
const setTheme = useAppStore((state) => state.setTheme);
const activeSpace = useActiveSpace();
const viewSpace = useViewSpace();
const analyticsEnabled = useAnalyticsEnabled();

// App version from Rust backend
const [appVersion, setAppVersion] = useState('');
Expand All @@ -120,6 +123,36 @@ function AppContent() {
.catch((err) => console.error('Failed to get version:', err));
}, []);

// Initialize analytics once we have the app version
useEffect(() => {
if (!appVersion) return;
initAnalytics(appVersion);
if (analyticsEnabled) {
optIn();
capture('app_opened');
} else {
optOut();
}
}, [appVersion]); // eslint-disable-line react-hooks/exhaustive-deps

// Sync opt-in/out when user toggles analytics
useEffect(() => {
if (!appVersion) return;
if (analyticsEnabled) {
optIn();
} else {
optOut();
}
}, [analyticsEnabled, appVersion]);

// Track domain events (server install/uninstall)
useAnalytics();

// Track page navigation
useEffect(() => {
capture('page_viewed', { page: activeNav });
}, [activeNav]);

// Gateway status for sidebar footer
const [gatewayUrl, setGatewayUrl] = useState<string | null>(null);
const loadGatewayUrl = useCallback(async () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/features/registry/RegistryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useRegistryStore } from '../../stores/registryStore';
import { ServerCard } from './ServerCard';
import { ServerDetailModal } from './ServerDetailModal';
import { useViewSpace } from '@/stores';
import { capture } from '@/lib/analytics';

export function RegistryPage() {
const {
Expand Down Expand Up @@ -85,6 +86,9 @@ export function RegistryPage() {
const timer = setTimeout(() => {
if (localSearch !== searchQuery) {
search(localSearch);
if (localSearch.trim()) {
capture('registry_search', { query: localSearch.trim() });
}
}
}, 300);
return () => clearTimeout(timer);
Expand Down
37 changes: 36 additions & 1 deletion apps/desktop/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import {
Minimize2,
XCircle,
Trash2,
BarChart3,
} from 'lucide-react';
import { useAppStore, useTheme } from '@/stores';
import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores';
import { UpdateChecker } from './UpdateChecker';

interface StartupSettings {
Expand All @@ -35,6 +36,8 @@ interface StartupSettings {
export function SettingsPage() {
const theme = useTheme();
const setTheme = useAppStore((state) => state.setTheme);
const analyticsEnabled = useAnalyticsEnabled();
const setAnalyticsEnabled = useAppStore((state) => state.setAnalyticsEnabled);
const [logsPath, setLogsPath] = useState<string>('');
const [openingLogs, setOpeningLogs] = useState(false);
const { toasts, success, error } = useToast();
Expand Down Expand Up @@ -302,6 +305,38 @@ export function SettingsPage() {
</CardContent>
</Card>

{/* Analytics Section */}
<Card data-testid="settings-analytics-section">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Analytics
</CardTitle>
<CardDescription>
Help improve McpMux by sharing anonymous usage data. No personal information is collected.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<BarChart3 className="h-5 w-5 mt-0.5 text-[rgb(var(--muted))] flex-shrink-0" />
<div>
<label className="text-sm font-medium">Share Usage Data</label>
<p className="text-xs text-[rgb(var(--muted))] mt-1">
Sends anonymous data like app version, OS, and feature usage to help us prioritize improvements.
Location is approximated from IP by PostHog. No credentials or server configurations are shared.
</p>
</div>
</div>
<Switch
checked={analyticsEnabled}
onCheckedChange={setAnalyticsEnabled}
data-testid="analytics-switch"
/>
</div>
</CardContent>
</Card>

{/* Logs Section */}
<Card>
<CardHeader>
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/hooks/useAnalytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Hook that subscribes to domain events and forwards core metrics to PostHog.
*
* Tracked: server_installed, server_uninstalled
* (app_opened, registry_search, version, OS, location are handled elsewhere)
*/

import { useEffect } from 'react';
import { capture } from '@/lib/analytics';
import { useDomainEvents } from './useDomainEvents';
import type { ServerChangedPayload } from './useDomainEvents';

export function useAnalytics() {
const { subscribe } = useDomainEvents();

useEffect(() => {
return subscribe('server-changed', (payload: ServerChangedPayload) => {
if (payload.action === 'installed') {
capture('server_installed', {
server_id: payload.server_id,
server_name: payload.server_name,
});
} else if (payload.action === 'uninstalled') {
capture('server_uninstalled', {
server_id: payload.server_id,
});
}
});
}, [subscribe]);
}
70 changes: 70 additions & 0 deletions apps/desktop/src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* PostHog analytics for McpMux desktop app.
*
* - Anonymous by default (no PII collected)
* - Opt-out toggle available in Settings
* - PostHog auto-captures geolocation from IP
*/

import posthog from 'posthog-js';

const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY ?? '';
const POSTHOG_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com';

let initialized = false;

/** Initialize PostHog with app-level super properties. */
export function initAnalytics(appVersion: string) {
if (initialized || typeof window === 'undefined') return;
if (!POSTHOG_KEY) return;

posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
person_profiles: 'identified_only',
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
persistence: 'localStorage',
});

// Super properties sent with every event
posthog.register({
app_version: appVersion,
os: getOS(),
platform: 'desktop',
});

initialized = true;
}

/** Capture an analytics event (no-op if not initialized or opted out). */
export function capture(event: string, properties?: Record<string, unknown>) {
if (!initialized) return;
posthog.capture(event, properties);
}

/** Opt out of analytics. */
export function optOut() {
if (!initialized) return;
posthog.opt_out_capturing();
}

/** Opt back in to analytics. */
export function optIn() {
if (!initialized) return;
posthog.opt_in_capturing();
}

/** Check if user has opted out. */
export function hasOptedOut(): boolean {
if (!initialized) return false;
return posthog.has_opted_out_capturing();
}

function getOS(): string {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('win')) return 'windows';
if (ua.includes('mac')) return 'macos';
if (ua.includes('linux')) return 'linux';
return 'unknown';
}
7 changes: 7 additions & 0 deletions apps/desktop/src/stores/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const initialState: AppState = {
viewSpaceId: null,
sidebarCollapsed: false,
theme: 'system',
analyticsEnabled: true,
loading: {
spaces: false,
servers: false,
Expand Down Expand Up @@ -95,6 +96,11 @@ export const useAppStore = create<AppStore>()(
state.theme = theme;
}),

setAnalyticsEnabled: (enabled) =>
set((state) => {
state.analyticsEnabled = enabled;
}),

// Loading
setLoading: (key, value) =>
set((state) => {
Expand All @@ -110,6 +116,7 @@ export const useAppStore = create<AppStore>()(
activeSpaceId: state.activeSpaceId,
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
analyticsEnabled: state.analyticsEnabled,
}),
}
)
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/stores/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const useActiveSpaceId = () => useAppStore((state) => state.activeSpaceId
export const useViewSpaceId = () => useAppStore((state) => state.viewSpaceId);
export const useTheme = () => useAppStore((state) => state.theme);
export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCollapsed);
export const useAnalyticsEnabled = () => useAppStore((state) => state.analyticsEnabled);

// Computed selectors
export const useActiveSpace = (): Space | null => {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/stores/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface AppState {
// UI state
sidebarCollapsed: boolean;
theme: 'light' | 'dark' | 'system';
analyticsEnabled: boolean;

// Loading states
loading: {
Expand All @@ -29,6 +30,7 @@ export interface AppActions {
// UI
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
setAnalyticsEnabled: (enabled: boolean) => void;

// Loading
setLoading: (key: keyof AppState['loading'], value: boolean) => void;
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_POSTHOG_KEY: string;
readonly VITE_POSTHOG_HOST: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
Loading