From cf2bc61ce562a766abaf632b3afd1313b64e5779 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 25 Mar 2026 17:51:33 +0100 Subject: [PATCH] feat: add canary release channel and local feature flags Add update_channel setting (stable/canary) to Settings with UI toggle. Stable channel uses Tauri updater plugin; canary fetches latest-canary.json and opens GitHub release page for manual download. Add useFeatureFlag() hook with localStorage overrides and compile-time defaults (no remote dependencies). Add release-canary.yml CI workflow for canary branch builds. Update stable workflow to preserve latest-canary.json on GH Pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release-canary.yml | 286 ++++++++++++++++++++++++++ .github/workflows/release.yml | 5 + docs/ABSTRACTIONS.md | 15 ++ docs/ARCHITECTURE.md | 49 +++++ src-tauri/src/settings.rs | 6 + src/App.test.tsx | 2 +- src/App.tsx | 2 +- src/components/SettingsPanel.test.tsx | 52 +++++ src/components/SettingsPanel.tsx | 29 ++- src/hooks/useFeatureFlag.test.ts | 70 +++++++ src/hooks/useFeatureFlag.ts | 26 +++ src/hooks/useSettings.test.ts | 3 + src/hooks/useSettings.ts | 1 + src/hooks/useTelemetry.test.ts | 2 +- src/hooks/useUpdater.test.ts | 41 ++++ src/hooks/useUpdater.ts | 42 +++- src/mock-tauri/mock-handlers.ts | 2 + src/types.ts | 1 + 18 files changed, 628 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release-canary.yml create mode 100644 src/hooks/useFeatureFlag.test.ts create mode 100644 src/hooks/useFeatureFlag.ts diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml new file mode 100644 index 00000000..2714021f --- /dev/null +++ b/.github/workflows/release-canary.yml @@ -0,0 +1,286 @@ +name: Release (Canary) + +on: + push: + branches: + - canary + +concurrency: + group: release-canary-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ───────────────────────────────────────────────────────────── + # Phase 1: Compute the canary version string + # ───────────────────────────────────────────────────────────── + version: + name: Compute version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + tag: ${{ steps.ver.outputs.tag }} + steps: + - id: ver + run: | + VERSION="0.$(date -u +%Y%m%d).${GITHUB_RUN_NUMBER}-canary" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + echo "### Canary version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" + + # ───────────────────────────────────────────────────────────── + # Phase 2: Build each architecture in parallel + # ───────────────────────────────────────────────────────────── + build: + name: Build (${{ matrix.arch }}) + needs: version + runs-on: macos-15 + strategy: + fail-fast: true + matrix: + include: + - arch: aarch64 + target: aarch64-apple-darwin + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Setup Bun (required for bundle-qmd.sh) + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-release-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-release-cargo- + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Set version + run: | + VERSION="${{ needs.version.outputs.version }}" + jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json + sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml + + - name: Import Apple Developer certificate into keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + CERT_PATH="$RUNNER_TEMP/apple_cert.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db" + KEYCHAIN_PASSWORD="$(uuidgen)" + echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Build Tauri app (with signing + notarization) + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + pnpm tauri build --target ${{ matrix.target }} + + - name: Upload .dmg + uses: actions/upload-artifact@v4 + with: + name: dmg-${{ matrix.arch }} + path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg + retention-days: 1 + + - name: Upload updater artifacts (.tar.gz + .sig) + uses: actions/upload-artifact@v4 + with: + name: updater-${{ matrix.arch }} + path: | + src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz + src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig + retention-days: 1 + + # ───────────────────────────────────────────────────────────── + # Phase 3: Publish GitHub Release (prerelease) + # ───────────────────────────────────────────────────────────── + release: + name: GitHub Release (canary) + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Generate release notes + run: | + PREV_TAG=$(git tag --sort=-version:refname | grep canary | head -n 1 || echo "") + if [ -z "$PREV_TAG" ]; then + NOTES=$(git log --oneline --no-merges -20) + else + NOTES=$(git log --oneline --no-merges "${PREV_TAG}..HEAD") + fi + { + echo "## What's Changed (Canary)" + echo "" + echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done + echo "" + echo "---" + echo "**Canary build — pre-release, may be unstable**" + echo "" + echo "**Requires Apple Silicon (M1/M2/M3)**" + echo "" + echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*" + } > release_notes.md + + - name: Build latest-canary.json + run: | + VERSION="${{ needs.version.outputs.version }}" + TAG="${{ needs.version.outputs.tag }}" + REPO="refactoringhq/laputa-app" + + ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig) + ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename) + + cat > latest-canary.json << EOF + { + "version": "${VERSION}", + "notes": "Canary build. See https://refactoringhq.github.io/laputa-app/ for release notes.", + "pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "platforms": { + "darwin-aarch64": { + "signature": "${ARM_SIG}", + "url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}" + } + } + } + EOF + echo "latest-canary.json:"; cat latest-canary.json + + - name: Publish GitHub Release (prerelease) + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.version.outputs.tag }} + name: Laputa ${{ needs.version.outputs.version }} (Canary) + body_path: release_notes.md + draft: false + prerelease: true + files: | + dmg-aarch64/*.dmg + updater-aarch64/*.app.tar.gz + updater-aarch64/*.app.tar.gz.sig + latest-canary.json + + # ───────────────────────────────────────────────────────────── + # Phase 4: Update GitHub Pages (preserve stable latest.json) + # ───────────────────────────────────────────────────────────── + pages: + name: Update release history page + needs: [version, release] + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: github-pages + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - name: Build release history page + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p _site + gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json + # Download stable latest.json from existing GH Pages (preserve it) + curl -fsSL "https://refactoringhq.github.io/laputa-app/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json + # Copy canary latest.json from this release + gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "latest-canary.json" --output _site/latest-canary.json || true + cat > _site/index.html << 'HTMLEOF' + + + + + + Laputa — Release History + + + +

Laputa Release History

+

Auto-updated on every release

+
+ + + + HTMLEOF + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./_site + commit_message: "Update release history for canary ${{ needs.version.outputs.tag }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70515410..c87a8433 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,6 +216,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + concurrency: + group: github-pages + cancel-in-progress: false steps: - uses: actions/checkout@v4 @@ -227,6 +230,8 @@ jobs: gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json # Copy latest.json to GitHub Pages for auto-updater endpoint gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true + # Preserve canary latest.json from existing GH Pages + curl -fsSL "https://refactoringhq.github.io/laputa-app/latest-canary.json" -o _site/latest-canary.json || true cat > _site/index.html << 'HTMLEOF' diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index ed0a49c8..6b271770 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -605,3 +605,18 @@ Managed by `useSettings` hook and `SettingsPanel` component. ### Tauri Commands - **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting. + +--- + +## Update Channels & Feature Flags + +### Settings +- **`update_channel`** — `"stable"` (default/null) or `"canary"`. Stored in `Settings` struct, configurable in Settings panel under "Updates" section. + +### Hooks +- **`useUpdater(channel?)`** — Checks for updates. For stable: uses Tauri updater plugin. For canary: fetches `latest-canary.json` and opens release page for download. +- **`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 30374c0f..6c6b1af6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -849,3 +849,52 @@ sequenceDiagram - **JS:** `@sentry/browser` + `posthog-js` initialized lazily by `useTelemetry` hook - **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct - **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null` + +### Update Channels (Stable / Canary) + +Laputa supports two release channels: + +- **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` + +### Feature Flags (Local V1) + +Feature flags use a local-only system with no external dependencies: + +```typescript +import { useFeatureFlag } from './hooks/useFeatureFlag' + +const enabled = useFeatureFlag('example_flag') // boolean +``` + +**Resolution order:** +1. `localStorage` override: key `ff_` with value `"true"` or `"false"` +2. Compile-time default in `FLAG_DEFAULTS` map + +**How to add a new flag:** +1. Add the flag name to the `FeatureFlagName` union type in `src/hooks/useFeatureFlag.ts` +2. Set its default in the `FLAG_DEFAULTS` record +3. Use `useFeatureFlag('your_flag')` in components + +**Design decisions:** +- No remote fetching, no PostHog dependency — zero privacy concerns +- `localStorage` overrides allow dev/QA testing without rebuilding +- Type-safe flag names via TypeScript union type +- API surface is compatible with future migration to remote flags diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4ec1294a..3e59598a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -14,6 +14,7 @@ pub struct Settings { pub crash_reporting_enabled: Option, pub analytics_enabled: Option, pub anonymous_id: Option, + pub update_channel: Option, } fn settings_path() -> Result { @@ -67,6 +68,10 @@ 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()), }; let json = serde_json::to_string_pretty(&cleaned) @@ -137,6 +142,7 @@ 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 41521798..80770340 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -67,7 +67,7 @@ const mockCommandResults: Record = { get_modified_files: [], get_note_content: mockAllContent['/vault/project/test.md'] || '', get_file_history: [], - get_settings: { anthropic_key: null, 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 }, + get_settings: { anthropic_key: null, 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 }, 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 32db242c..37e1eab9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -411,7 +411,7 @@ function App() { const zoom = useZoom() const buildNumber = useBuildNumber() - const { status: updateStatus, actions: updateActions } = useUpdater() + const { status: updateStatus, actions: updateActions } = useUpdater(settings.update_channel) const handleCheckForUpdates = useCallback(async () => { if (updateStatus.state === 'downloading') { diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 0e8ec6e1..fce7d82b 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -28,6 +28,7 @@ const emptySettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, + update_channel: null, } const populatedSettings: Settings = { @@ -41,6 +42,7 @@ const populatedSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, + update_channel: null, } describe('SettingsPanel', () => { @@ -404,6 +406,56 @@ 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 69a950ae..9645d151 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -124,6 +124,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit(null) @@ -148,7 +149,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit { onSave(buildSettings()) @@ -198,6 +200,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit @@ -232,6 +235,7 @@ interface SettingsBodyProps { onGitHubConnected: (token: string, username: string) => void onGitHubDisconnect: () => void pullInterval: number; setPullInterval: (v: number) => void + updateChannel: string; setUpdateChannel: (v: string) => void crashReporting: boolean; setCrashReporting: (v: boolean) => void analytics: boolean; setAnalytics: (v: boolean) => void } @@ -294,6 +298,29 @@ function SettingsBody(props: SettingsBodyProps) {
+
+
Updates
+
+ Canary builds include the latest features but may be less stable. Restart required after changing. +
+
+ +
+ + +
+ +
+
Privacy & Telemetry
diff --git a/src/hooks/useFeatureFlag.test.ts b/src/hooks/useFeatureFlag.test.ts new file mode 100644 index 00000000..4800f4cb --- /dev/null +++ b/src/hooks/useFeatureFlag.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useFeatureFlag } from './useFeatureFlag' + +describe('useFeatureFlag', () => { + it('returns false for example_flag by default', () => { + vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({ + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }) + const { result } = renderHook(() => useFeatureFlag('example_flag')) + expect(result.current).toBe(false) + vi.restoreAllMocks() + }) + + it('returns true when localStorage override is set to "true"', () => { + vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({ + getItem: (key: string) => key === 'ff_example_flag' ? 'true' : null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }) + const { result } = renderHook(() => useFeatureFlag('example_flag')) + expect(result.current).toBe(true) + vi.restoreAllMocks() + }) + + it('returns false when localStorage override is set to "false"', () => { + vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({ + getItem: (key: string) => key === 'ff_example_flag' ? 'false' : null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }) + const { result } = renderHook(() => useFeatureFlag('example_flag')) + expect(result.current).toBe(false) + vi.restoreAllMocks() + }) + + it('ignores non-boolean localStorage values (treats as false)', () => { + vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({ + getItem: (key: string) => key === 'ff_example_flag' ? 'maybe' : null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }) + const { result } = renderHook(() => useFeatureFlag('example_flag')) + expect(result.current).toBe(false) + vi.restoreAllMocks() + }) + + it('falls back to default when localStorage throws', () => { + vi.spyOn(globalThis, 'localStorage', 'get').mockImplementation(() => { + throw new Error('localStorage disabled') + }) + const { result } = renderHook(() => useFeatureFlag('example_flag')) + expect(result.current).toBe(false) + vi.restoreAllMocks() + }) +}) diff --git a/src/hooks/useFeatureFlag.ts b/src/hooks/useFeatureFlag.ts new file mode 100644 index 00000000..b1da55db --- /dev/null +++ b/src/hooks/useFeatureFlag.ts @@ -0,0 +1,26 @@ +/** + * Local feature flag hook (V1 — no remote fetching). + * + * Flags are resolved in order: + * 1. localStorage override (`ff_`) — for dev/QA testing + * 2. Compile-time defaults in FLAG_DEFAULTS + * + * To add a new flag: add its name to the FeatureFlagName union and + * set its default in FLAG_DEFAULTS. + */ + +export type FeatureFlagName = 'example_flag' + +const FLAG_DEFAULTS: Record = { + example_flag: false, +} + +export function useFeatureFlag(flag: FeatureFlagName): boolean { + try { + const override = localStorage.getItem(`ff_${flag}`) + if (override !== null) return override === 'true' + } catch { + // localStorage may be unavailable in some contexts + } + return FLAG_DEFAULTS[flag] ?? false +} diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 06c7d5e6..d87da8a6 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -14,6 +14,7 @@ const defaultSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, + update_channel: null, } const savedSettings: Settings = { @@ -27,6 +28,7 @@ const savedSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, + update_channel: null, } let mockSettingsStore: Settings = { ...defaultSettings } @@ -92,6 +94,7 @@ describe('useSettings', () => { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, + update_channel: null, } await act(async () => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 3f1c95e4..b9ac5e89 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -18,6 +18,7 @@ const EMPTY_SETTINGS: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, + update_channel: null, } export function useSettings() { diff --git a/src/hooks/useTelemetry.test.ts b/src/hooks/useTelemetry.test.ts index 5af723c6..e676e32c 100644 --- a/src/hooks/useTelemetry.test.ts +++ b/src/hooks/useTelemetry.test.ts @@ -19,7 +19,7 @@ const baseSettings: Settings = { anthropic_key: null, 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, + analytics_enabled: null, anonymous_id: null, update_channel: null, } describe('useTelemetry', () => { diff --git a/src/hooks/useUpdater.test.ts b/src/hooks/useUpdater.test.ts index d50f599c..01310dfe 100644 --- a/src/hooks/useUpdater.test.ts +++ b/src/hooks/useUpdater.test.ts @@ -25,6 +25,11 @@ 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', () => { @@ -200,6 +205,42 @@ 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 d8632063..d239e276 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -3,6 +3,7 @@ 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' } @@ -20,14 +21,45 @@ export interface UpdateActions { dismiss: () => void } -export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } { +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 } { 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' @@ -43,7 +75,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } { console.warn('[updater] Failed to check for updates') return 'error' } - }, []) + }, [channel]) useEffect(() => { if (!isTauri()) return @@ -52,6 +84,12 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } { }, [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 111dd11e..e7151724 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -85,6 +85,7 @@ let mockSettings: Settings = { crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, + update_channel: null, } let mockLastVaultPath: string | null = null @@ -206,6 +207,7 @@ 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, } return null }, diff --git a/src/types.ts b/src/types.ts index a1f77117..7bb6b5fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,6 +81,7 @@ export interface Settings { crash_reporting_enabled: boolean | null analytics_enabled: boolean | null anonymous_id: string | null + update_channel: string | null } export interface GitPullResult {