diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 0d14aac0..369ac876 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -551,15 +551,11 @@ Managed by `useSettings` hook and `SettingsPanel` component. --- -## Update Channels & Feature Flags - -### Settings -- **`update_channel`** — `"stable"` (default/null) or `"canary"`. Stored in `Settings` struct, configurable in Settings panel under "Updates" section. +## Updates & Feature Flags ### Hooks -- **`useUpdater(channel?)`** — Checks for updates. For stable: uses Tauri updater plugin. For canary: fetches `latest-canary.json` and opens release page for download. +- **`useUpdater()`** — Checks for updates using the Tauri updater plugin. Automatic download and install. - **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_`), then falls back to compile-time default. Type-safe via `FeatureFlagName` union. ### CI/CD - **`.github/workflows/release.yml`** — Stable builds from `main`. Produces `latest.json` on GitHub Pages. -- **`.github/workflows/release-canary.yml`** — Canary builds from `canary` branch. Produces `latest-canary.json` on GitHub Pages. Releases are marked as prerelease. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66284670..472ea01e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -802,29 +802,13 @@ sequenceDiagram - **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct - **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null` -### Update Channels (Stable / Canary) +### Updates -Laputa supports two release channels: +Laputa uses the Tauri updater plugin for automatic updates: -- **Stable** (default): builds from `main` branch, published as full GitHub Releases -- **Canary**: builds from `canary` branch, published as pre-release GitHub Releases - -```mermaid -flowchart LR - main["main branch"] -->|push| stable["Stable build
latest.json"] - canary["canary branch"] -->|push| canaryBuild["Canary build
latest-canary.json"] - stable --> ghPages["GitHub Pages"] - canaryBuild --> ghPages - ghPages -->|"update_channel = stable"| stableUsers["Stable users
(auto-update via plugin)"] - ghPages -->|"update_channel = canary"| canaryUsers["Canary users
(fetch + manual download)"] -``` - -**How it works:** -- Both channels publish to GitHub Pages: `latest.json` (stable) and `latest-canary.json` (canary) -- `update_channel` is stored in `Settings` (`settings.json`), configurable in Settings panel -- **Stable**: uses the Tauri updater plugin with automatic download and install -- **Canary**: `useUpdater` hook fetches `latest-canary.json` via HTTP, compares versions, and opens the GitHub release page for manual download -- Canary versions use semver prerelease: `0.YYYYMMDD.N-canary` +- Builds from `main` branch are published as GitHub Releases +- `latest.json` is published to GitHub Pages for the updater plugin +- `useUpdater()` hook checks for updates automatically and supports download + install ### Feature Flags (PostHog + Release Channels) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2b9f1311..7a335392 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -13,7 +13,6 @@ pub struct Settings { pub crash_reporting_enabled: Option, pub analytics_enabled: Option, pub anonymous_id: Option, - pub update_channel: Option, pub release_channel: Option, } @@ -64,10 +63,6 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> { .anonymous_id .map(|k| k.trim().to_string()) .filter(|k| !k.is_empty()), - update_channel: settings - .update_channel - .map(|k| k.trim().to_string()) - .filter(|k| !k.is_empty()), release_channel: settings .release_channel .map(|k| k.trim().to_string()) @@ -141,7 +136,6 @@ mod tests { assert!(s.crash_reporting_enabled.is_none()); assert!(s.analytics_enabled.is_none()); assert!(s.anonymous_id.is_none()); - assert!(s.update_channel.is_none()); } #[test] diff --git a/src/App.test.tsx b/src/App.test.tsx index 0d278a35..ed36a69f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -69,7 +69,7 @@ const mockCommandResults: Record = { get_modified_files: [], get_note_content: mockAllContent['/vault/project/test.md'] || '', get_file_history: [], - get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, update_channel: null }, + get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null }, git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }, save_settings: null, check_vault_exists: true, diff --git a/src/App.tsx b/src/App.tsx index 9f6a895f..20a18979 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -412,7 +412,7 @@ function App() { const zoom = useZoom() const buildNumber = useBuildNumber() - const { status: updateStatus, actions: updateActions } = useUpdater(settings.update_channel) + const { status: updateStatus, actions: updateActions } = useUpdater() const handleCheckForUpdates = useCallback(async () => { if (updateStatus.state === 'downloading') { diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 6a3aae67..46fa3bc7 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -28,7 +28,7 @@ const emptySettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, - update_channel: null, + release_channel: null, } const populatedSettings: Settings = { @@ -41,7 +41,7 @@ const populatedSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, - update_channel: null, + release_channel: null, } describe('SettingsPanel', () => { @@ -405,56 +405,6 @@ describe('SettingsPanel', () => { }) }) - describe('Update channel section', () => { - it('renders the update channel dropdown', () => { - render( - - ) - expect(screen.getByTestId('settings-update-channel')).toBeInTheDocument() - expect(screen.getByText('Updates')).toBeInTheDocument() - }) - - it('defaults to stable when update_channel is null', () => { - render( - - ) - const select = screen.getByTestId('settings-update-channel') as HTMLSelectElement - expect(select.value).toBe('stable') - }) - - it('reflects canary setting', () => { - const canarySettings: Settings = { ...emptySettings, update_channel: 'canary' } - render( - - ) - const select = screen.getByTestId('settings-update-channel') as HTMLSelectElement - expect(select.value).toBe('canary') - }) - - it('saves update_channel when changed to canary', () => { - render( - - ) - fireEvent.change(screen.getByTestId('settings-update-channel'), { target: { value: 'canary' } }) - fireEvent.click(screen.getByTestId('settings-save')) - - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - update_channel: 'canary', - })) - }) - - it('saves null when channel is stable (default)', () => { - render( - - ) - fireEvent.click(screen.getByTestId('settings-save')) - - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - update_channel: null, - })) - }) - }) - describe('Privacy & Telemetry section', () => { it('renders crash reporting and analytics toggles', () => { render( diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 60ae7c3b..5cea4ee3 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -125,7 +125,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit { const prevAnalytics = settings.analytics_enabled ?? false @@ -206,7 +204,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit void onGitHubDisconnect: () => void pullInterval: number; setPullInterval: (v: number) => void - updateChannel: string; setUpdateChannel: (v: string) => void releaseChannel: string; setReleaseChannel: (v: string) => void crashReporting: boolean; setCrashReporting: (v: boolean) => void analytics: boolean; setAnalytics: (v: boolean) => void @@ -307,26 +303,12 @@ function SettingsBody(props: SettingsBodyProps) {
-
Updates
+
Release Channel
- Canary builds include the latest features but may be less stable. Restart required after changing. + Controls which features are visible. Alpha users see all features. Beta/Stable see features as they are promoted.
-
- - -
-
-
- Alpha users see all features. Beta/Stable see features as they are promoted. -
diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index d02811cb..91d5c570 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -14,7 +14,6 @@ const defaultSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, - update_channel: null, release_channel: null, } @@ -28,7 +27,6 @@ const savedSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, - update_channel: null, release_channel: null, } @@ -93,7 +91,7 @@ describe('useSettings', () => { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, - update_channel: null, + release_channel: null, } await act(async () => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index fca7c1e4..165dee41 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -17,7 +17,6 @@ const EMPTY_SETTINGS: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, - update_channel: null, release_channel: null, } diff --git a/src/hooks/useTelemetry.test.ts b/src/hooks/useTelemetry.test.ts index f02122d5..38c0da6c 100644 --- a/src/hooks/useTelemetry.test.ts +++ b/src/hooks/useTelemetry.test.ts @@ -21,7 +21,7 @@ const baseSettings: Settings = { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: null, crash_reporting_enabled: null, - analytics_enabled: null, anonymous_id: null, update_channel: null, release_channel: null, + analytics_enabled: null, anonymous_id: null, release_channel: null, } describe('useTelemetry', () => { diff --git a/src/hooks/useUpdater.test.ts b/src/hooks/useUpdater.test.ts index 01310dfe..d50f599c 100644 --- a/src/hooks/useUpdater.test.ts +++ b/src/hooks/useUpdater.test.ts @@ -25,11 +25,6 @@ vi.mock('@tauri-apps/plugin-process', () => ({ relaunch: (...args: unknown[]) => mockRelaunch(...args), })) -const mockGetVersion = vi.fn().mockResolvedValue('0.20260101.1') -vi.mock('@tauri-apps/api/app', () => ({ - getVersion: () => mockGetVersion(), -})) - import { isTauri } from '../mock-tauri' describe('useUpdater', () => { @@ -205,42 +200,6 @@ describe('useUpdater', () => { expect(mockDownload).toHaveBeenCalled() }) - describe('canary channel', () => { - it('fetches latest-canary.json when channel is canary', async () => { - vi.mocked(isTauri).mockReturnValue(true) - mockGetVersion.mockResolvedValue('0.20260101.1') - - const mockFetch = vi.mocked(globalThis.fetch) - mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ - version: '0.20260325.99-canary', - notes: 'Canary build', - platforms: { - 'darwin-aarch64': { - url: 'https://github.com/refactoringhq/laputa-app/releases/download/v0.20260325.99-canary/laputa.app.tar.gz', - signature: 'sig123', - }, - }, - }), { status: 200 })) - - const { result } = renderHook(() => useUpdater('canary')) - - let checkResult: string | undefined - await act(async () => { - checkResult = await result.current.actions.checkForUpdates() - }) - - expect(checkResult).toBe('available') - expect(result.current.status).toEqual({ - state: 'available', - version: '0.20260325.99-canary', - notes: 'Canary build', - }) - expect(mockFetch).toHaveBeenCalledWith( - 'https://refactoringhq.github.io/laputa-app/latest-canary.json' - ) - }) - }) - describe('checkForUpdates (manual)', () => { it('returns up-to-date when no update is available', async () => { vi.mocked(isTauri).mockReturnValue(true) diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index d239e276..d8632063 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -3,7 +3,6 @@ import { isTauri } from '../mock-tauri' import { openExternalUrl } from '../utils/url' const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/' -const CANARY_ENDPOINT = 'https://refactoringhq.github.io/laputa-app/latest-canary.json' export type UpdateStatus = | { state: 'idle' } @@ -21,45 +20,14 @@ export interface UpdateActions { dismiss: () => void } -interface CanaryRelease { - version: string - notes: string - platforms: Record -} - -async function checkCanaryUpdate(): Promise<{ version: string; notes: string; downloadUrl: string } | null> { - const response = await fetch(CANARY_ENDPOINT) - if (!response.ok) return null - - const data = await response.json() as CanaryRelease - const { getVersion } = await import('@tauri-apps/api/app') - const currentVersion = await getVersion() - - if (data.version === currentVersion) return null - - const platform = data.platforms['darwin-aarch64'] - const downloadUrl = platform?.url?.replace(/\.tar\.gz$/, '').replace(/\.app$/, '') ?? '' - - return { version: data.version, notes: data.notes, downloadUrl } -} - -export function useUpdater(channel: string | null = null): { status: UpdateStatus; actions: UpdateActions } { +export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } { const [status, setStatus] = useState({ state: 'idle' }) const updateRef = useRef(null) - const canaryUrlRef = useRef(null) const checkForUpdates = useCallback(async (): Promise => { if (!isTauri()) return 'up-to-date' try { - if (channel === 'canary') { - const canary = await checkCanaryUpdate() - if (!canary) return 'up-to-date' - canaryUrlRef.current = canary.downloadUrl - setStatus({ state: 'available', version: canary.version, notes: canary.notes }) - return 'available' - } - const { check } = await import('@tauri-apps/plugin-updater') const update = await check() if (!update) return 'up-to-date' @@ -75,7 +43,7 @@ export function useUpdater(channel: string | null = null): { status: UpdateStatu console.warn('[updater] Failed to check for updates') return 'error' } - }, [channel]) + }, []) useEffect(() => { if (!isTauri()) return @@ -84,12 +52,6 @@ export function useUpdater(channel: string | null = null): { status: UpdateStatu }, [checkForUpdates]) const startDownload = useCallback(async () => { - // Canary: open the GitHub release page for manual download - if (canaryUrlRef.current) { - openExternalUrl(canaryUrlRef.current) - return - } - const update = updateRef.current as { version: string downloadAndInstall: (cb: (event: { event: string; data?: { contentLength?: number; chunkLength?: number } }) => void) => Promise diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index c6c5a02c..bf622977 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -84,7 +84,6 @@ let mockSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, - update_channel: null, release_channel: null, } @@ -210,7 +209,6 @@ export const mockHandlers: Record any> = { crash_reporting_enabled: s.crash_reporting_enabled, analytics_enabled: s.analytics_enabled, anonymous_id: s.anonymous_id, - update_channel: s.update_channel, release_channel: s.release_channel, } return null diff --git a/src/types.ts b/src/types.ts index c0e38414..7a87be2b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,7 +79,6 @@ export interface Settings { crash_reporting_enabled: boolean | null analytics_enabled: boolean | null anonymous_id: string | null - update_channel: string | null release_channel: string | null } diff --git a/tests/smoke/canary-release-feature-flags.spec.ts b/tests/smoke/canary-release-feature-flags.spec.ts index 2c4e449e..9ddea875 100644 --- a/tests/smoke/canary-release-feature-flags.spec.ts +++ b/tests/smoke/canary-release-feature-flags.spec.ts @@ -9,32 +9,34 @@ async function openSettings(page: import('@playwright/test').Page) { return panel } -test.describe('Canary release channel + feature flags', () => { +test.describe('Release channel settings', () => { test.beforeEach(async ({ page }) => { await page.goto('/') await page.waitForLoadState('networkidle') }) - test('Settings panel shows Update channel dropdown defaulting to Stable', async ({ page }) => { + test('Settings panel shows Release channel dropdown defaulting to Stable', async ({ page }) => { await openSettings(page) - // Check the Updates section exists - await expect(page.getByText('Updates')).toBeVisible() - await expect(page.getByText('Canary builds include')).toBeVisible() + // Check the Release Channel section exists + await expect(page.getByText('Release Channel')).toBeVisible() // Check the dropdown defaults to stable - const select = page.locator('[data-testid="settings-update-channel"]') + const select = page.locator('[data-testid="settings-release-channel"]') await expect(select).toBeVisible() await expect(select).toHaveValue('stable') + + // Update channel should NOT be present + await expect(page.locator('[data-testid="settings-update-channel"]')).not.toBeVisible() }) - test('Update channel can be changed to canary and saved', async ({ page }) => { + test('Release channel can be changed to alpha and saved', async ({ page }) => { await openSettings(page) - // Change to canary - const select = page.locator('[data-testid="settings-update-channel"]') - await select.selectOption('canary') - await expect(select).toHaveValue('canary') + // Change to alpha + const select = page.locator('[data-testid="settings-release-channel"]') + await select.selectOption('alpha') + await expect(select).toHaveValue('alpha') // Save (closes the panel) await page.click('[data-testid="settings-save"]') @@ -42,7 +44,7 @@ test.describe('Canary release channel + feature flags', () => { // Reopen settings and verify the value persisted await openSettings(page) - const reopenedSelect = page.locator('[data-testid="settings-update-channel"]') - await expect(reopenedSelect).toHaveValue('canary') + const reopenedSelect = page.locator('[data-testid="settings-release-channel"]') + await expect(reopenedSelect).toHaveValue('alpha') }) })