diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx
index fd8335fa..464baab7 100644
--- a/src/components/SettingsPanel.test.tsx
+++ b/src/components/SettingsPanel.test.tsx
@@ -71,6 +71,20 @@ describe('SettingsPanel', () => {
expect(screen.queryByText(/Beta\/Stable/i)).not.toBeInTheDocument()
})
+ it('treats a legacy beta release channel as stable', () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId('settings-release-channel')).toHaveAttribute('data-value', 'stable')
+ expect(screen.queryByText('Beta')).not.toBeInTheDocument()
+ })
+
it('preserves alpha when alpha is already selected', () => {
const alphaSettings: Settings = {
...emptySettings,
diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts
index 3807eda2..da44322f 100644
--- a/src/hooks/useSettings.test.ts
+++ b/src/hooks/useSettings.test.ts
@@ -68,6 +68,21 @@ describe('useSettings', () => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_settings', {})
})
+ it('normalizes a legacy beta release channel back to stable on load', async () => {
+ mockSettingsStore = {
+ ...savedSettings,
+ release_channel: 'beta',
+ }
+
+ const { result } = renderHook(() => useSettings())
+
+ await waitFor(() => {
+ expect(result.current.loaded).toBe(true)
+ })
+
+ expect(result.current.settings.release_channel).toBeNull()
+ })
+
it('saves settings via backend', async () => {
const { result } = renderHook(() => useSettings())
diff --git a/src/hooks/useTelemetry.test.ts b/src/hooks/useTelemetry.test.ts
index 1a867bdb..f103ae25 100644
--- a/src/hooks/useTelemetry.test.ts
+++ b/src/hooks/useTelemetry.test.ts
@@ -7,14 +7,16 @@ const mockInitSentry = vi.fn()
const mockTeardownSentry = vi.fn()
const mockInitPostHog = vi.fn()
const mockTeardownPostHog = vi.fn()
+const mockUpdatePostHogIdentify = vi.fn()
+const mockSetReleaseChannel = vi.fn()
vi.mock('../lib/telemetry', () => ({
initSentry: (...args: unknown[]) => mockInitSentry(...args),
teardownSentry: () => mockTeardownSentry(),
initPostHog: (...args: unknown[]) => mockInitPostHog(...args),
teardownPostHog: () => mockTeardownPostHog(),
- updatePostHogIdentify: vi.fn(),
- setReleaseChannel: vi.fn(),
+ updatePostHogIdentify: (...args: unknown[]) => mockUpdatePostHogIdentify(...args),
+ setReleaseChannel: (...args: unknown[]) => mockSetReleaseChannel(...args),
}))
const baseSettings: Settings = {
@@ -23,35 +25,49 @@ const baseSettings: Settings = {
analytics_enabled: null, anonymous_id: null, release_channel: null,
}
+function renderTelemetry(settings: Settings, loaded = true) {
+ return renderHook(() => useTelemetry(settings, loaded))
+}
+
+function analyticsSettings(releaseChannel: string | null = null): Settings {
+ return {
+ ...baseSettings,
+ analytics_enabled: true,
+ anonymous_id: 'test-uuid',
+ release_channel: releaseChannel,
+ }
+}
+
describe('useTelemetry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does nothing when settings are not loaded', () => {
- renderHook(() => useTelemetry(baseSettings, false))
+ renderTelemetry(baseSettings, false)
expect(mockInitSentry).not.toHaveBeenCalled()
expect(mockInitPostHog).not.toHaveBeenCalled()
})
it('does nothing when consent is not granted', () => {
- renderHook(() => useTelemetry({ ...baseSettings, telemetry_consent: false }, true))
+ renderTelemetry({ ...baseSettings, telemetry_consent: false })
expect(mockInitSentry).not.toHaveBeenCalled()
expect(mockInitPostHog).not.toHaveBeenCalled()
})
it('initializes Sentry when crash reporting is enabled', () => {
- renderHook(() =>
- useTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: 'test-uuid' }, true)
- )
+ renderTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: 'test-uuid' })
expect(mockInitSentry).toHaveBeenCalledWith('test-uuid')
})
- it('initializes PostHog when analytics is enabled', () => {
- renderHook(() =>
- useTelemetry({ ...baseSettings, analytics_enabled: true, anonymous_id: 'test-uuid' }, true)
- )
- expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid', 'stable')
+ it.each([
+ { name: 'stable defaults', releaseChannel: null, expectedChannel: 'stable' },
+ { name: 'legacy beta release channels', releaseChannel: 'beta', expectedChannel: 'stable' },
+ ])('initializes PostHog for $name', ({ releaseChannel, expectedChannel }) => {
+ renderTelemetry(analyticsSettings(releaseChannel))
+
+ expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid', expectedChannel)
+ expect(mockSetReleaseChannel).toHaveBeenCalledWith(expectedChannel)
})
it('tears down Sentry when crash reporting is disabled after being enabled', () => {
@@ -68,9 +84,36 @@ describe('useTelemetry', () => {
})
it('does not initialize without anonymous_id', () => {
- renderHook(() =>
- useTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: null }, true)
- )
+ renderTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: null })
expect(mockInitSentry).not.toHaveBeenCalled()
})
+
+ it('updates the analytics identity when the normalized channel changes', () => {
+ const { rerender } = renderHook(
+ ({ settings, loaded }) => useTelemetry(settings, loaded),
+ {
+ initialProps: {
+ settings: {
+ ...baseSettings,
+ analytics_enabled: true,
+ anonymous_id: 'test-uuid',
+ release_channel: 'alpha',
+ },
+ loaded: true,
+ },
+ },
+ )
+
+ rerender({
+ settings: {
+ ...baseSettings,
+ analytics_enabled: true,
+ anonymous_id: 'test-uuid',
+ release_channel: 'beta',
+ },
+ loaded: true,
+ })
+
+ expect(mockUpdatePostHogIdentify).toHaveBeenCalledWith('stable')
+ })
})
diff --git a/src/hooks/useTelemetry.ts b/src/hooks/useTelemetry.ts
index f407ffd5..9267b65b 100644
--- a/src/hooks/useTelemetry.ts
+++ b/src/hooks/useTelemetry.ts
@@ -2,12 +2,54 @@ import { useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { initSentry, teardownSentry, initPostHog, teardownPostHog, updatePostHogIdentify, setReleaseChannel } from '../lib/telemetry'
+import { normalizeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import type { Settings } from '../types'
function tauriCall(command: string): Promise {
return isTauri() ? invoke(command) : mockInvoke(command)
}
+function resolveReleaseChannel(settings: Settings): ReleaseChannel {
+ return normalizeReleaseChannel(settings.release_channel)
+}
+
+function syncCrashReporting(
+ crashEnabled: boolean,
+ anonymousId: string | null,
+ wasEnabled: boolean,
+): void {
+ if (crashEnabled && anonymousId) {
+ if (!wasEnabled) initSentry(anonymousId)
+ return
+ }
+
+ if (!wasEnabled) return
+
+ teardownSentry()
+ tauriCall('reinit_telemetry').catch(() => {})
+}
+
+function syncAnalytics(
+ analyticsEnabled: boolean,
+ anonymousId: string | null,
+ releaseChannel: ReleaseChannel,
+ wasEnabled: boolean,
+): void {
+ if (!analyticsEnabled) {
+ if (wasEnabled) teardownPostHog()
+ return
+ }
+
+ if (!anonymousId) return
+
+ if (wasEnabled) {
+ updatePostHogIdentify(releaseChannel)
+ return
+ }
+
+ initPostHog(anonymousId, releaseChannel)
+}
+
/**
* Initializes / tears down Sentry and PostHog reactively based on user settings.
* Call once in App after settings are loaded.
@@ -20,25 +62,12 @@ export function useTelemetry(settings: Settings, loaded: boolean): void {
if (!loaded) return
const crashEnabled = settings.crash_reporting_enabled === true
const analyticsEnabled = settings.analytics_enabled === true
- const id = settings.anonymous_id
+ const anonymousId = settings.anonymous_id
+ const releaseChannel = resolveReleaseChannel(settings)
- if (crashEnabled && id && !prevCrash.current) {
- initSentry(id)
- } else if (!crashEnabled && prevCrash.current) {
- teardownSentry()
- tauriCall('reinit_telemetry').catch(() => {})
- }
-
- const channel = settings.release_channel ?? 'stable'
- setReleaseChannel(channel)
-
- if (analyticsEnabled && id && !prevAnalytics.current) {
- initPostHog(id, channel)
- } else if (!analyticsEnabled && prevAnalytics.current) {
- teardownPostHog()
- } else if (analyticsEnabled && id) {
- updatePostHogIdentify(channel)
- }
+ syncCrashReporting(crashEnabled, anonymousId, prevCrash.current)
+ setReleaseChannel(releaseChannel)
+ syncAnalytics(analyticsEnabled, anonymousId, releaseChannel, prevAnalytics.current)
prevCrash.current = crashEnabled
prevAnalytics.current = analyticsEnabled