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) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-25 17:51:33 +01:00
parent f7f669774e
commit cf2bc61ce5
18 changed files with 628 additions and 6 deletions

286
.github/workflows/release-canary.yml vendored Normal file
View File

@@ -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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laputa — Release History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
h1 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.5rem; }
.subtitle { color: #787774; margin-bottom: 2rem; }
.release { background: #fff; border: 1px solid #E9E9E7; border-radius: 8px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; }
.release h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; }
.release .meta { font-size: 0.8125rem; color: #787774; margin-bottom: 0.75rem; }
.release .body { font-size: 0.875rem; white-space: pre-wrap; }
.release .downloads { margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.release .downloads a { display: inline-block; padding: 0.375rem 0.75rem; background: #155DFF; color: #fff; border-radius: 6px; text-decoration: none; font-size: 0.8125rem; font-weight: 500; }
.release .downloads a:hover { background: #1248CC; }
.canary { border-left: 3px solid #f59e0b; }
.empty { color: #787774; text-align: center; padding: 3rem; }
</style>
</head>
<body>
<h1>Laputa Release History</h1>
<p class="subtitle">Auto-updated on every release</p>
<div id="releases"></div>
<script>
fetch('releases.json').then(r=>r.json()).then(releases=>{
const el=document.getElementById('releases');
if(!releases.length){el.innerHTML='<p class="empty">No releases yet.</p>';return;}
releases.forEach(r=>{
const date=new Date(r.published_at).toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'});
const dmgs=(r.assets||[]).filter(a=>a.name.endsWith('.dmg'));
const links=dmgs.map(a=>'<a href="'+a.browser_download_url+'">'+a.name+'</a>').join('');
const body=(r.body||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const div=document.createElement('div');
div.className='release'+(r.prerelease?' canary':'');
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Canary</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
el.appendChild(div);
});
});
</script>
</body>
</html>
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 }}"

View File

@@ -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'
<!DOCTYPE html>
<html lang="en">

View File

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

@@ -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<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`
### 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_<name>` 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

View File

@@ -14,6 +14,7 @@ pub struct Settings {
pub crash_reporting_enabled: Option<bool>,
pub analytics_enabled: Option<bool>,
pub anonymous_id: Option<String>,
pub update_channel: Option<String>,
}
fn settings_path() -> Result<PathBuf, String> {
@@ -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]

View File

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

View File

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

View File

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

@@ -124,6 +124,7 @@ 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 [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
const [analytics, setAnalytics] = useState(settings.analytics_enabled ?? false)
const panelRef = useRef<HTMLDivElement>(null)
@@ -148,7 +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,
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
update_channel: updateChannel === 'stable' ? null : updateChannel,
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
const handleSave = () => {
onSave(buildSettings())
@@ -198,6 +200,7 @@ 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}
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
analytics={analytics} setAnalytics={setAnalytics}
/>
@@ -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) {
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Updates</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.
</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={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Privacy &amp; Telemetry</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>

View File

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

View File

@@ -0,0 +1,26 @@
/**
* Local feature flag hook (V1 — no remote fetching).
*
* Flags are resolved in order:
* 1. localStorage override (`ff_<name>`) — 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<FeatureFlagName, boolean> = {
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
}

View File

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

View File

@@ -18,6 +18,7 @@ const EMPTY_SETTINGS: Settings = {
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
update_channel: null,
}
export function useSettings() {

View File

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

View File

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

View File

@@ -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<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 } {
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'
@@ -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<void>

View File

@@ -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<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,
}
return null
},

View File

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