fix: remove Update channel from Settings, keep only Release channel

Update channel (Stable/Canary) was redundant since builds are flat.
Removed the UI dropdown, canary update logic in useUpdater, and
update_channel field from Settings type (TS + Rust). Release channel
(Alpha/Beta/Stable) remains as the sole selector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-04-04 12:11:26 +02:00
parent b6517e2794
commit 0be1060d81
15 changed files with 33 additions and 213 deletions

View File

@@ -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_<name>`), 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.

View File

@@ -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<br/>latest.json"]
canary["canary branch"] -->|push| canaryBuild["Canary build<br/>latest-canary.json"]
stable --> ghPages["GitHub Pages"]
canaryBuild --> ghPages
ghPages -->|"update_channel = stable"| stableUsers["Stable users<br/>(auto-update via plugin)"]
ghPages -->|"update_channel = canary"| canaryUsers["Canary users<br/>(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)

View File

@@ -13,7 +13,6 @@ pub struct Settings {
pub crash_reporting_enabled: Option<bool>,
pub analytics_enabled: Option<bool>,
pub anonymous_id: Option<String>,
pub update_channel: Option<String>,
pub release_channel: Option<String>,
}
@@ -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]

View File

@@ -69,7 +69,7 @@ const mockCommandResults: Record<string, unknown> = {
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,

View File

@@ -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') {

View File

@@ -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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByTestId('settings-update-channel')).toBeInTheDocument()
expect(screen.getByText('Updates')).toBeInTheDocument()
})
it('defaults to stable when update_channel is null', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={canarySettings} onSave={onSave} onClose={onClose} />
)
const select = screen.getByTestId('settings-update-channel') as HTMLSelectElement
expect(select.value).toBe('canary')
})
it('saves update_channel when changed to canary', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(

View File

@@ -125,7 +125,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
const [githubToken, setGithubToken] = useState(settings.github_token)
const [githubUsername, setGithubUsername] = useState(settings.github_username)
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
const [updateChannel, setUpdateChannel] = useState(settings.update_channel ?? 'stable')
const [releaseChannel, setReleaseChannel] = useState(settings.release_channel ?? 'stable')
const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
const [analytics, setAnalytics] = useState(settings.analytics_enabled ?? false)
@@ -150,9 +149,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
crash_reporting_enabled: crashReporting,
analytics_enabled: analytics,
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
update_channel: updateChannel === 'stable' ? null : updateChannel,
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
const handleSave = () => {
const prevAnalytics = settings.analytics_enabled ?? false
@@ -206,7 +204,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
pullInterval={pullInterval} setPullInterval={setPullInterval}
updateChannel={updateChannel} setUpdateChannel={setUpdateChannel}
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
analytics={analytics} setAnalytics={setAnalytics}
@@ -242,7 +239,6 @@ interface SettingsBodyProps {
onGitHubConnected: (token: string, username: string) => 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) {
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Updates</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Release Channel</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
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.
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Update channel</label>
<select
value={props.updateChannel}
onChange={(e) => props.setUpdateChannel(e.target.value)}
className="border border-border bg-transparent text-foreground rounded"
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
data-testid="settings-update-channel"
>
<option value="stable">Stable</option>
<option value="canary">Canary (pre-release)</option>
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Release channel</label>
<select
@@ -340,9 +322,6 @@ function SettingsBody(props: SettingsBodyProps) {
<option value="beta">Beta</option>
<option value="alpha">Alpha (bleeding edge)</option>
</select>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.4 }}>
Alpha users see all features. Beta/Stable see features as they are promoted.
</div>
</div>
<div style={{ height: 1, background: 'var(--border)' }} />

View File

@@ -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 () => {

View File

@@ -17,7 +17,6 @@ const EMPTY_SETTINGS: Settings = {
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
update_channel: null,
release_channel: null,
}

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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<string, { url: string; signature: string }>
}
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<UpdateStatus>({ state: 'idle' })
const updateRef = useRef<unknown>(null)
const canaryUrlRef = useRef<string | null>(null)
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
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<void>

View File

@@ -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<string, (args: any) => 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

View File

@@ -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
}

View File

@@ -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')
})
})