diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 597db572e..53d8b5902 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,7 +1,7 @@ name: Build macOS App on: push: - branches: [ main ] + branches: [ main, develop ] tags: - "v*" pull_request: @@ -99,14 +99,16 @@ jobs: # before `bun run desktop:build`, which caused DMG bundling failures on tags. - name: Import Apple Developer certificate if: (steps.release-version.outputs.mode == 'tag' || - steps.release-version.outputs.mode == 'release') && + steps.release-version.outputs.mode == 'release' || + steps.release-version.outputs.mode == 'develop') && steps.check-secrets.outputs.has_certificate == 'true' shell: "sops exec-env ops/secrets/secrets.yaml \"bash -e {0}\"" run: bash ops/scripts/release/import-certificate.sh # Sign the app bundle (required for notarization) - name: Sign app bundle if: (steps.release-version.outputs.mode == 'tag' || - steps.release-version.outputs.mode == 'release') && + steps.release-version.outputs.mode == 'release' || + steps.release-version.outputs.mode == 'develop') && steps.check-secrets.outputs.has_certificate == 'true' run: bash ops/scripts/release/sign-app.sh # Replace unsigned app inside the Tauri-built DMG with the signed one. @@ -117,13 +119,15 @@ jobs: # shipped a bare DMG without the drag-to-Applications affordance. - name: Swap signed app into Tauri DMG if: (steps.release-version.outputs.mode == 'tag' || - steps.release-version.outputs.mode == 'release') && + steps.release-version.outputs.mode == 'release' || + steps.release-version.outputs.mode == 'develop') && steps.check-secrets.outputs.has_certificate == 'true' run: bash ops/scripts/release/swap-signed-dmg.sh # Notarize the app (requires Apple Developer account) - name: Notarize app if: (steps.release-version.outputs.mode == 'tag' || - steps.release-version.outputs.mode == 'release') && + steps.release-version.outputs.mode == 'release' || + steps.release-version.outputs.mode == 'develop') && steps.check-secrets.outputs.has_notarization == 'true' shell: "sops exec-env ops/secrets/secrets.yaml \"bash -e {0}\"" run: bash ops/scripts/release/notarize.sh @@ -191,7 +195,8 @@ jobs: # overwriting latest.json on the updater CDN. - name: Upload to R2 if: (steps.release-version.outputs.mode == 'tag' || - steps.release-version.outputs.mode == 'release') && + steps.release-version.outputs.mode == 'release' || + steps.release-version.outputs.mode == 'develop') && !contains(steps.release-version.outputs.tag, '-test.') shell: "sops exec-env ops/secrets/secrets.yaml \"bash -e {0}\"" env: @@ -199,6 +204,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.release-version.outputs.version }} RELEASE_TAG: ${{ steps.release-version.outputs.tag }} + UPDATE_CHANNEL: ${{ steps.release-version.outputs.mode == 'develop' && 'develop' || 'stable' }} run: bash ops/scripts/release/upload-r2.sh # Sync the shipped release to Linear so issues referenced in commits since # the previous release get linked. Runs only after R2 upload succeeds, so diff --git a/.oxlintrc.json b/.oxlintrc.json index a334639c9..69baff647 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -8,12 +8,6 @@ "react-perf", "vitest" ], - "rules": { - "correctness": "error", - "perf": "error", - "style": "warn", - "suspicious": "error" - }, "overrides": [ { "files": [ @@ -26,4 +20,4 @@ } } ] -} \ No newline at end of file +} diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index 7b4f741a7..38fca8f9c 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -65,6 +65,8 @@ fn main() { .register::() .register::() .register::() + .register::() + .register::() .register::() .register::() .register::() diff --git a/apps/native/src-tauri/src/commands/ui_prefs.rs b/apps/native/src-tauri/src/commands/ui_prefs.rs index 9162c62a8..abcb2bf42 100644 --- a/apps/native/src-tauri/src/commands/ui_prefs.rs +++ b/apps/native/src-tauri/src/commands/ui_prefs.rs @@ -54,6 +54,12 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result Result &'static str { + match channel { + shared_types::UpdateChannel::Stable => STABLE_MANIFEST_URL, + shared_types::UpdateChannel::Develop => DEVELOP_MANIFEST_URL, + } +} + +#[cfg(not(debug_assertions))] +fn update_info( + channel: shared_types::UpdateChannel, + update: tauri_plugin_updater::Update, +) -> shared_types::UpdateInfo { + shared_types::UpdateInfo { + channel, + version: update.version, + notes: update.body, + } +} + +#[cfg(not(debug_assertions))] +fn selected_update_channel(app: &AppHandle) -> Result { + crate::storage::store::get_json_pref_or( + app, + crate::storage::store::UPDATE_CHANNEL_KEY, + shared_types::UpdateChannel::default(), + ) + .map_err(|e| format!("[updater] failed to read update channel preference: {e}")) +} + +#[cfg(not(debug_assertions))] +fn channel_updater( + app: &AppHandle, + channel: shared_types::UpdateChannel, +) -> Result { + use tauri_plugin_updater::UpdaterExt; + + let manifest_url: url::Url = update_manifest_url(channel) + .parse() + .map_err(|e: url::ParseError| format!("[updater] invalid channel manifest URL: {e}"))?; + + app.updater_builder() + .endpoints(vec![manifest_url]) + .map_err(|e| format!("[updater] endpoints rejected: {e}"))? + .build() + .map_err(|e| format!("[updater] build failed: {e}")) +} + +/// Check the selected auto-update channel for an available release. +#[tauri::command] +#[cfg(not(debug_assertions))] +pub async fn check_update(app: AppHandle) -> Result, String> { + let channel = selected_update_channel(&app)?; + let updater = channel_updater(&app, channel)?; + let update = updater + .check() + .await + .map_err(|e| format!("[updater] check failed: {e}"))?; + + Ok(update.map(|update| update_info(channel, update))) +} + +/// Download and install the latest release from the selected auto-update channel. +#[tauri::command] +#[cfg(not(debug_assertions))] +pub async fn install_update(app: AppHandle) -> Result<(), String> { + let channel = selected_update_channel(&app)?; + let updater = channel_updater(&app, channel)?; + let update = updater + .check() + .await + .map_err(|e| format!("[updater] check failed: {e}"))? + .ok_or_else(|| "[updater] no update available".to_string())?; + + update + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|e| format!("[updater] download_and_install failed: {e}"))?; + + Ok(()) +} + +#[tauri::command] +#[cfg(debug_assertions)] +pub async fn check_update(_app: AppHandle) -> Result, String> { + Ok(None) +} + +#[tauri::command] +#[cfg(debug_assertions)] +pub async fn install_update(_app: AppHandle) -> Result<(), String> { + Err("Auto-update install requires a release build (the updater plugin is not registered in dev mode).".to_string()) +} + /// Safely relaunch the app after the Tauri updater has installed a new bundle. /// /// On macOS, the updater atomically swaps the `.app` bundle on disk by moving @@ -51,3 +151,24 @@ pub fn relaunch_after_update(app: AppHandle) -> Result<(), String> { // (it does not call std::process::exit directly), so we must return Ok here. Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_channel_keeps_legacy_manifest_url() { + assert_eq!( + update_manifest_url(shared_types::UpdateChannel::Stable), + "https://releases.nixmac.com/latest.json" + ); + } + + #[test] + fn develop_channel_uses_isolated_manifest_url() { + assert_eq!( + update_manifest_url(shared_types::UpdateChannel::Develop), + "https://releases.nixmac.com/channels/develop/latest.json" + ); + } +} diff --git a/apps/native/src-tauri/src/e2e_runtime.rs b/apps/native/src-tauri/src/e2e_runtime.rs index 2040fe1eb..df2d1ad8c 100644 --- a/apps/native/src-tauri/src/e2e_runtime.rs +++ b/apps/native/src-tauri/src/e2e_runtime.rs @@ -6,15 +6,23 @@ //! debug builds can still receive deterministic test controls. Release builds //! ignore the file. +#[cfg(debug_assertions)] use serde::Deserialize; +#[cfg(debug_assertions)] use std::collections::HashMap; +#[cfg(debug_assertions)] use std::fs; +#[cfg(debug_assertions)] use std::path::PathBuf; +#[cfg(debug_assertions)] use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(debug_assertions)] const RUNTIME_FILE_NAME: &str = "e2e-runtime.json"; +#[cfg(debug_assertions)] const BUNDLE_ID: &str = "com.darkmatter.nixmac"; +#[cfg(debug_assertions)] #[derive(Debug, Deserialize)] struct E2eRuntimeFile { #[serde(rename = "schemaVersion")] @@ -26,6 +34,7 @@ struct E2eRuntimeFile { values: HashMap, } +#[cfg(debug_assertions)] fn runtime_file_path() -> Option { let home = dirs::home_dir()?; Some( @@ -36,6 +45,7 @@ fn runtime_file_path() -> Option { ) } +#[cfg(debug_assertions)] fn now_unix() -> Option { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 77d588ff9..7c5fc8cee 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -557,6 +557,8 @@ fn run_gui_mode( commands::cli_tool::check_cli_tools, commands::cli_tool::list_cli_models, // Updater + commands::updater::check_update, + commands::updater::install_update, commands::updater::relaunch_after_update, updater_pin::install_version, updater_pin::clear_pinned_version, diff --git a/apps/native/src-tauri/src/shared_types/prefs.rs b/apps/native/src-tauri/src/shared_types/prefs.rs index f7261e1f1..57a08db14 100644 --- a/apps/native/src-tauri/src/shared_types/prefs.rs +++ b/apps/native/src-tauri/src/shared_types/prefs.rs @@ -1,6 +1,20 @@ use serde::{Deserialize, Serialize}; use specta::Type; +/// Auto-update channel selected for release-mode builds. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Type)] +#[serde(rename_all = "camelCase")] +pub enum UpdateChannel { + Stable, + Develop, +} + +impl Default for UpdateChannel { + fn default() -> Self { + Self::Stable + } +} + /// User interface preferences (synced to settings.json via tauri-plugin-store). #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -43,6 +57,8 @@ pub struct UiPrefs { pub developer_mode: bool, /// Version pinned by the user, when update pinning is active. pub pinned_version: Option, + /// Auto-update channel used when no explicit version pin is active. + pub update_channel: UpdateChannel, } /// Partial update to UI preferences — every field is optional so the caller @@ -93,6 +109,20 @@ pub struct UiPrefsUpdate { with = "double_option" )] pub pinned_version: Option>, + /// Auto-update channel preference update. + pub update_channel: Option, +} + +/// Lightweight update metadata returned by the channel-aware updater command. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + /// Channel whose manifest produced this update. + pub channel: UpdateChannel, + /// Version advertised by the channel manifest. + pub version: String, + /// Release notes from the channel manifest, when available. + pub notes: Option, } #[allow(dead_code)] diff --git a/apps/native/src-tauri/src/storage/store.rs b/apps/native/src-tauri/src/storage/store.rs index 4d05ac4b4..8536ea319 100644 --- a/apps/native/src-tauri/src/storage/store.rs +++ b/apps/native/src-tauri/src/storage/store.rs @@ -10,6 +10,7 @@ use crate::storage::credential_store::{ }; use anyhow::Result; +use serde::{de::DeserializeOwned, Serialize}; use std::path::PathBuf; use std::sync::Arc; use tauri::{AppHandle, Runtime}; @@ -37,6 +38,7 @@ pub const SCAN_HOMEBREW_ON_STARTUP_KEY: &str = "scanHomebrewOnStartup"; // Developer-mode preference keys pub const DEVELOPER_MODE_KEY: &str = "developerMode"; pub const PINNED_VERSION_KEY: &str = "pinnedVersion"; +pub const UPDATE_CHANNEL_KEY: &str = "updateChannel"; pub const DEFAULT_MAX_ITERATIONS: usize = 25; const KEYCHAIN_SERVICE: &str = "com.darkmatter.nixmac"; @@ -351,6 +353,25 @@ fn get_string_pref(app: &AppHandle, key: &str) -> Result(app: &AppHandle, key: &str) -> Result> +where + R: Runtime, + T: DeserializeOwned, +{ + let store = get_store(app)?; + Ok(store + .get(key) + .and_then(|value| serde_json::from_value(value.clone()).ok())) +} + +pub fn get_json_pref_or(app: &AppHandle, key: &str, default: T) -> Result +where + R: Runtime, + T: DeserializeOwned, +{ + Ok(get_json_pref(app, key)?.unwrap_or(default)) +} + fn get_string_pref_raw(app: &AppHandle, key: &str) -> Result> { let store = get_store(app)?; if let Some(val) = store.get(key) { @@ -374,13 +395,21 @@ pub fn get_string_pref_public(app: &AppHandle, key: &str) -> Resu get_string_pref_raw(app, key) } -pub fn set_string_pref(app: &AppHandle, key: &str, value: &str) -> Result<()> { +pub fn set_json_pref(app: &AppHandle, key: &str, value: &T) -> Result<()> +where + R: Runtime, + T: Serialize + ?Sized, +{ let store = get_store(app)?; - store.set(key, serde_json::json!(value)); + store.set(key, serde_json::to_value(value)?); store.save()?; Ok(()) } +pub fn set_string_pref(app: &AppHandle, key: &str, value: &str) -> Result<()> { + set_json_pref(app, key, value) +} + pub fn delete_pref(app: &AppHandle, key: &str) -> Result<()> { delete_pref_raw(app, key) } @@ -444,30 +473,15 @@ fn set_secret_pref(app: &AppHandle, key: &'static str, value: &st } fn get_usize_pref(app: &AppHandle, key: &str) -> Result> { - let store = get_store(app)?; - if let Some(val) = store.get(key) { - if let Some(n) = val.as_u64() { - return Ok(Some(n as usize)); - } - } - Ok(None) + get_json_pref(app, key) } pub fn get_bool_pref(app: &AppHandle, key: &str, default: bool) -> Result { - let store = get_store(app)?; - if let Some(val) = store.get(key) { - if let Some(b) = val.as_bool() { - return Ok(b); - } - } - Ok(default) + get_json_pref_or(app, key, default) } pub fn set_bool_pref(app: &AppHandle, key: &str, value: bool) -> Result<()> { - let store = get_store(app)?; - store.set(key, serde_json::json!(value)); - store.save()?; - Ok(()) + set_json_pref(app, key, &value) } // ============================================================================= diff --git a/apps/native/src/components/widget/controls/directory-picker.test.tsx b/apps/native/src/components/widget/controls/directory-picker.test.tsx index 75c7f9b04..d5ddf604e 100644 --- a/apps/native/src/components/widget/controls/directory-picker.test.tsx +++ b/apps/native/src/components/widget/controls/directory-picker.test.tsx @@ -234,6 +234,7 @@ describe("", () => { evolveState: {} as never, hosts: ["mbp"], }); + mockListHosts.mockResolvedValue(["mbp"]); render(); typeAndBlur(screen.getByLabelText("Config directory"), "/Users/me/.darwin"); diff --git a/apps/native/src/components/widget/promptinput/prompt-input.tsx b/apps/native/src/components/widget/promptinput/prompt-input.tsx index 195018d1e..383aa85b2 100644 --- a/apps/native/src/components/widget/promptinput/prompt-input.tsx +++ b/apps/native/src/components/widget/promptinput/prompt-input.tsx @@ -17,9 +17,8 @@ import { useEvolve } from "@/hooks/use-evolve"; import { getProviderConfigInvalidReason } from "@/lib/ai-provider-validation"; import { useWidgetStore } from "@/stores/widget-store"; import { darwinAPI } from "@/tauri-api"; -import { ArrowUpIcon, Plus } from "lucide-react"; +import { ArrowUpIcon } from "lucide-react"; import { useEffect, useState } from "react"; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; const MAX_CONTEXT_LENGTH = 1000; diff --git a/apps/native/src/components/widget/settings/developer-tab.stories.tsx b/apps/native/src/components/widget/settings/developer-tab.stories.tsx index 25438f4ae..178242564 100644 --- a/apps/native/src/components/widget/settings/developer-tab.stories.tsx +++ b/apps/native/src/components/widget/settings/developer-tab.stories.tsx @@ -29,7 +29,7 @@ export const Unpinned = meta.story({ decorators: [ (Story: React.ComponentType) => { useEffect(() => { - useWidgetStore.setState({ developerMode: true, pinnedVersion: null }); + useWidgetStore.setState({ developerMode: true, pinnedVersion: null, updateChannel: "stable" }); }, []); return ; }, @@ -41,7 +41,7 @@ export const PinnedToPastVersion = meta.story({ decorators: [ (Story: React.ComponentType) => { useEffect(() => { - useWidgetStore.setState({ developerMode: true, pinnedVersion: "0.21.0" }); + useWidgetStore.setState({ developerMode: true, pinnedVersion: "0.21.0", updateChannel: "develop" }); }, []); return ; }, diff --git a/apps/native/src/components/widget/settings/developer-tab.tsx b/apps/native/src/components/widget/settings/developer-tab.tsx index eb7672ef2..87672f8a7 100644 --- a/apps/native/src/components/widget/settings/developer-tab.tsx +++ b/apps/native/src/components/widget/settings/developer-tab.tsx @@ -1,13 +1,14 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useWidgetStore } from "@/stores/widget-store"; -import { darwinAPI } from "@/tauri-api"; +import { darwinAPI, type UpdateChannel } from "@/tauri-api"; import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import { AlertTriangle, DatabaseZap, Download, + GitBranch, Eraser, History, Pin, @@ -20,6 +21,8 @@ const VERSION_PATTERN = /^[0-9]+(?:\.[0-9]+){0,2}(?:-[a-zA-Z0-9.-]+)?$/; export function DeveloperTab() { const pinnedVersion = useWidgetStore((s) => s.pinnedVersion); const setPinnedVersion = useWidgetStore((s) => s.setPinnedVersion); + const updateChannel = useWidgetStore((s) => s.updateChannel); + const setUpdateChannel = useWidgetStore((s) => s.setUpdateChannel); const [currentVersion, setCurrentVersion] = useState(null); const [versionInput, setVersionInput] = useState(""); const [installing, setInstalling] = useState(false); @@ -70,6 +73,23 @@ export function DeveloperTab() { } }; + const handleSetChannel = async (channel: UpdateChannel) => { + const previous = updateChannel; + setErrorMessage(null); + setUpdateChannel(channel); + try { + await darwinAPI.ui.setPrefs({ updateChannel: channel }); + setStatusMessage( + channel === "stable" + ? "Using stable updates from main." + : "Using develop updates. The next auto-update check will read the develop channel." + ); + } catch (err) { + setUpdateChannel(previous); + setErrorMessage(err instanceof Error ? err.message : String(err)); + } + }; + const handleClearTauriState = async () => { if ( !window.confirm("Clear Tauri stores? This resets saved settings, routing state, build state, and caches.") @@ -142,6 +162,40 @@ export function DeveloperTab() { + {/* Update channel */} +
+
+ + Update channel +
+
+

+ Stable follows releases from main. Develop follows signed + release-mode builds from develop. Version pins override the + selected channel until you resume auto-update. +

+
+ {(["stable", "develop"] as const).map((channel) => { + const selected = updateChannel === channel; + return ( + + ); + })} +
+
+ Current channel: {updateChannel} +
+
+
+ {/* Pinned-version status */}
diff --git a/apps/native/src/hooks/use-prefs.ts b/apps/native/src/hooks/use-prefs.ts index c6ec112bf..1ee581509 100644 --- a/apps/native/src/hooks/use-prefs.ts +++ b/apps/native/src/hooks/use-prefs.ts @@ -12,6 +12,7 @@ export function usePrefs() { .setBoolPref("scanHomebrewOnStartup", prefs.scanHomebrewOnStartup ?? true); useWidgetStore.getState().setDeveloperMode(prefs.developerMode ?? false); useWidgetStore.getState().setPinnedVersion(prefs.pinnedVersion ?? null); + useWidgetStore.getState().setUpdateChannel(prefs.updateChannel ?? "stable"); } useWidgetStore.getState().setPrefsLoaded(true); }; diff --git a/apps/native/src/hooks/use-updater.ts b/apps/native/src/hooks/use-updater.ts index 9ec137a9b..a50311090 100644 --- a/apps/native/src/hooks/use-updater.ts +++ b/apps/native/src/hooks/use-updater.ts @@ -1,14 +1,14 @@ -import { useState, useEffect, useRef } from "react"; -import type { Update } from "@tauri-apps/plugin-updater"; +import { useState, useEffect, useCallback, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { darwinAPI } from "@/tauri-api"; +import type { UpdateInfo } from "@/tauri-api"; import { useWidgetStore } from "@/stores/widget-store"; interface UpdateState { /** Whether we're currently checking for updates */ checking: boolean; /** Available update (null if none) */ - available: Update | null; + available: UpdateInfo | null; /** Version string of the available update */ version: string | null; /** Release notes / changelog */ @@ -39,21 +39,19 @@ export function useUpdater() { const checkedRef = useRef(false); const isDevMode = import.meta.env.DEV; const pinnedVersion = useWidgetStore((s) => s.pinnedVersion); + const updateChannel = useWidgetStore((s) => s.updateChannel); - const checkForUpdates = async () => { + const checkForUpdates = useCallback(async () => { setState((s) => ({ ...s, checking: true, error: null })); try { - // Dynamic import: if the updater plugin isn't registered (e.g. NIXMAC_DISABLE_UPDATER=1), - // the import will succeed but check() will throw — which we catch below. - const { check } = await import("@tauri-apps/plugin-updater"); - const update = await check(); + const update = await invoke("check_update"); if (update) { setState((s) => ({ ...s, checking: false, available: update, version: update.version, - notes: update.body ?? null, + notes: update.notes ?? null, })); } else { setState((s) => ({ ...s, checking: false })); @@ -86,7 +84,7 @@ export function useUpdater() { errorSource: "check", })); } - }; + }, [isDevMode]); const installUpdate = async () => { const update = state.available; @@ -95,26 +93,8 @@ export function useUpdater() { setState((s) => ({ ...s, downloading: true, progress: 0, error: null })); try { - let totalBytes = 0; - let downloadedBytes = 0; - - await update.downloadAndInstall((event) => { - switch (event.event) { - case "Started": - totalBytes = event.data.contentLength ?? 0; - break; - case "Progress": - downloadedBytes += event.data.chunkLength; - if (totalBytes > 0) { - const pct = Math.round((downloadedBytes / totalBytes) * 100); - setState((s) => ({ ...s, progress: pct })); - } - break; - case "Finished": - setState((s) => ({ ...s, progress: 100 })); - break; - } - }); + await invoke("install_update"); + setState((s) => ({ ...s, progress: 100 })); // On macOS the updater swaps the .app bundle on disk; using the // custom relaunch_after_update command opens the newly-installed @@ -183,7 +163,7 @@ export function useUpdater() { return () => { cancelled = true; }; - }, [pinnedVersion]); + }, [checkForUpdates, pinnedVersion, updateChannel]); return { ...state, diff --git a/apps/native/src/lib/env.ts b/apps/native/src/lib/env.ts index 384339863..f3c1f0ac6 100644 --- a/apps/native/src/lib/env.ts +++ b/apps/native/src/lib/env.ts @@ -1,15 +1,35 @@ -import * as Schema from "effect/Schema"; +export interface SettingsType { + VITE_SERVER_URL?: string; + NIX_INSTALLED_OVERRIDE?: boolean; +} -const Settings = Schema.Struct({ - VITE_SERVER_URL: Schema.optional(Schema.String), - NIX_INSTALLED_OVERRIDE: Schema.optional(Schema.BooleanFromString), -}); +function booleanFromEnv(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return undefined; -export type SettingsType = Schema.Schema.Type; + switch (value.trim().toLowerCase()) { + case "1": + case "true": + case "yes": + case "on": + return true; + case "0": + case "false": + case "no": + case "off": + return false; + default: + return undefined; + } +} -export const settings: SettingsType = Schema.decodeUnknownSync(Settings)( - import.meta.env, -); +export const settings: SettingsType = { + VITE_SERVER_URL: + typeof import.meta.env.VITE_SERVER_URL === "string" + ? import.meta.env.VITE_SERVER_URL + : undefined, + NIX_INSTALLED_OVERRIDE: booleanFromEnv(import.meta.env.NIX_INSTALLED_OVERRIDE), +}; // Helper to resolve the public website URL used by the native/web apps. // Prefers the Vite env var `VITE_SERVER_URL` when available, otherwise diff --git a/apps/native/src/stores/__mocks__/widget-store.ts b/apps/native/src/stores/__mocks__/widget-store.ts index bb6fea536..28ad5c1cb 100644 --- a/apps/native/src/stores/__mocks__/widget-store.ts +++ b/apps/native/src/stores/__mocks__/widget-store.ts @@ -20,6 +20,7 @@ export type { GitFileStatus, GitStatus, PermissionsState, + UpdateChannel, } from "@/stores/widget-store.impl"; export type { diff --git a/apps/native/src/stores/widget-store.impl.ts b/apps/native/src/stores/widget-store.impl.ts index a4b583fc0..27793f0e1 100644 --- a/apps/native/src/stores/widget-store.impl.ts +++ b/apps/native/src/stores/widget-store.impl.ts @@ -6,6 +6,7 @@ import type { HistoryItem, PermissionsState, RecommendedPrompt, + UpdateChannel, } from "@/tauri-api"; import { FeedbackType } from "@/types/feedback"; import type { SemanticChangeMap } from "@/types/shared"; @@ -18,6 +19,7 @@ export type { GitFileStatus, GitStatus, PermissionsState, + UpdateChannel, } from "@/tauri-api"; // ============================================================================= @@ -158,6 +160,7 @@ export interface WidgetState { // Developer mode (hidden settings panel for bisecting / pinning to a past release) developerMode: boolean; pinnedVersion: string | null; + updateChannel: UpdateChannel; // Editor editingFile: string | null; @@ -220,6 +223,7 @@ interface WidgetActions { // Developer mode setDeveloperMode: (value: boolean) => void; setPinnedVersion: (value: string | null) => void; + setUpdateChannel: (value: UpdateChannel) => void; // Client-side state (NOT from server) setSummarizing: (summarizing: boolean) => void; @@ -350,6 +354,7 @@ const initialWidgetState: WidgetState = { // Developer mode developerMode: false, pinnedVersion: null, + updateChannel: "stable", // Editor editingFile: null, @@ -399,6 +404,7 @@ export function createWidgetStore(initialState?: Partial) { setAutoSummarizeOnFocus: (value) => set({ autoSummarizeOnFocus: value }), setDeveloperMode: (value) => set({ developerMode: value }), setPinnedVersion: (value) => set({ pinnedVersion: value }), + setUpdateChannel: (value) => set({ updateChannel: value }), setHistory: (history) => set({ history }), setHistoryLoading: (historyLoading) => set({ historyLoading }), addAnalyzingHistoryHash: (hash) => diff --git a/apps/native/src/tauri-api.ts b/apps/native/src/tauri-api.ts index 86aa1707f..dd90a6c20 100644 --- a/apps/native/src/tauri-api.ts +++ b/apps/native/src/tauri-api.ts @@ -91,6 +91,8 @@ export type { RustPanicEvent, SystemDefault, SystemDefaultsScan, + UpdateChannel, + UpdateInfo, UiPrefs as DarwinPrefs, UiPrefsUpdate as DarwinPrefsUpdate, WatcherEvent, diff --git a/apps/native/src/types/shared.ts b/apps/native/src/types/shared.ts index 2a207f224..cd0bab988 100644 --- a/apps/native/src/types/shared.ts +++ b/apps/native/src/types/shared.ts @@ -406,7 +406,7 @@ export type EvolveEventType = /** * Agent is reading a file. */ -"reading" | +"reading" | "searchPackages" | /** * Agent is editing a file. */ @@ -1374,7 +1374,11 @@ developerMode: boolean; /** * Version pinned by the user, when update pinning is active. */ -pinnedVersion: string | null } +pinnedVersion: string | null; +/** + * Auto-update channel used when no explicit version pin is active. + */ +updateChannel: UpdateChannel } /** * Partial update to UI preferences — every field is optional so the caller @@ -1456,7 +1460,33 @@ developerMode: boolean | null; /** * `None` -> field not sent; `Some(None)` -> clear the pinned version. */ -pinnedVersion?: string | null } +pinnedVersion?: string | null; +/** + * Auto-update channel preference update. + */ +updateChannel: UpdateChannel | null } + +/** + * Auto-update channel selected for release-mode builds. + */ +export type UpdateChannel = "stable" | "develop" + +/** + * Lightweight update metadata returned by the channel-aware updater command. + */ +export type UpdateInfo = { +/** + * Channel whose manifest produced this update. + */ +channel: UpdateChannel; +/** + * Version advertised by the channel manifest. + */ +version: string; +/** + * Release notes from the channel manifest, when available. + */ +notes: string | null } /** * Event payload emitted by the git status watcher. @@ -1482,4 +1512,3 @@ error: string | null; * True when a build outside nixmac was detected. */ externalBuildDetected: boolean } - diff --git a/ops/scripts/release/compute-version.sh b/ops/scripts/release/compute-version.sh index 1d17ae240..1b6e199b0 100755 --- a/ops/scripts/release/compute-version.sh +++ b/ops/scripts/release/compute-version.sh @@ -9,7 +9,7 @@ set -euo pipefail # GITHUB_EVENT_NAME - push, pull_request, workflow_dispatch # # Outputs (via GITHUB_OUTPUT if set): -# mode - "tag" | "release" | "branch" +# mode - "tag" | "release" | "develop" | "branch" # version - computed version string (empty for branch mode) # tag - computed tag string (empty for branch mode) @@ -32,6 +32,16 @@ elif [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" ]]; PAT=$((PAT + 1)) VERSION="${MAJ}.${MIN}.${PAT}" TAG="v${VERSION}" +elif [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/develop" ]]; then + MODE="develop" + BASE=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "") + if [[ -z "$BASE" ]]; then + BASE=$(node -p "require('./package.json').version") + echo "No tags found — using package.json version $BASE as develop base" + fi + IFS='.' read -r MAJ MIN PAT <<<"$BASE" + PAT=$((PAT + 1)) + VERSION="${MAJ}.${MIN}.${PAT}-develop.${GITHUB_RUN_NUMBER:-0}" fi echo "Computed: mode=$MODE version=$VERSION tag=$TAG" diff --git a/ops/scripts/release/sync-version.sh b/ops/scripts/release/sync-version.sh index fc5f2381a..9f9d80da5 100755 --- a/ops/scripts/release/sync-version.sh +++ b/ops/scripts/release/sync-version.sh @@ -4,8 +4,8 @@ set -euo pipefail # Sync versions across native app files, with stale-package.json guard for branch builds. # # Required env vars: -# RELEASE_MODE - "tag" | "release" | "branch" (from compute-version.sh) -# RELEASE_VERSION - computed version (from compute-version.sh, for tag/release) +# RELEASE_MODE - "tag" | "release" | "develop" | "branch" (from compute-version.sh) +# RELEASE_VERSION - computed version (from compute-version.sh, for tag/release/develop) # GITHUB_SHA - commit SHA (for branch build suffix) # # Optional env vars: @@ -16,7 +16,7 @@ set -euo pipefail # VERSION env var - build version (may include -SHORT_SHA suffix for branch) # build_version output - same, via GITHUB_OUTPUT -if [[ "$RELEASE_MODE" == "tag" || "$RELEASE_MODE" == "release" ]]; then +if [[ "$RELEASE_MODE" == "tag" || "$RELEASE_MODE" == "release" || "$RELEASE_MODE" == "develop" ]]; then VERSION="$RELEASE_VERSION" echo "$RELEASE_MODE build — syncing to version: $VERSION" node scripts/sync-versions.mjs "$VERSION" diff --git a/ops/scripts/release/upload-r2.sh b/ops/scripts/release/upload-r2.sh index d65f74377..01ceae4f0 100755 --- a/ops/scripts/release/upload-r2.sh +++ b/ops/scripts/release/upload-r2.sh @@ -9,6 +9,8 @@ set -euo pipefail # R2_ENDPOINT - R2 endpoint URL # Required env vars: # RELEASE_VERSION - Version being released +# Optional env vars: +# UPDATE_CHANNEL - "stable" (default) or "develop" # RELEASE_TAG - Tag being released # Optional env vars: # GITHUB_TOKEN - For fetching release notes via gh CLI @@ -19,6 +21,24 @@ export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY" export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-auto}" VERSION="$RELEASE_VERSION" +UPDATE_CHANNEL="${UPDATE_CHANNEL:-stable}" + +case "$UPDATE_CHANNEL" in + stable) + ARTIFACT_PREFIX="${VERSION}" + MANIFEST_KEY="latest.json" + BUNDLE_URL="https://releases.nixmac.com/${VERSION}/nixmac.app.tar.gz" + ;; + develop) + ARTIFACT_PREFIX="channels/develop/${VERSION}" + MANIFEST_KEY="channels/develop/latest.json" + BUNDLE_URL="https://releases.nixmac.com/channels/develop/${VERSION}/nixmac.app.tar.gz" + ;; + *) + echo "ERROR: unsupported UPDATE_CHANNEL: $UPDATE_CHANNEL" + exit 1 + ;; +esac TAR_GZ=$(find target/release/bundle -name "*.app.tar.gz" -not -name "*.sig" | head -1) SIG_FILE=$(find target/release/bundle -name "*.app.tar.gz.sig" | head -1) @@ -35,18 +55,20 @@ echo "Found sig: $SIG_FILE" SIGNATURE=$(cat "$SIG_FILE" | jq -Rs .) -aws s3 cp "$TAR_GZ" "s3://nixmac-releases/${VERSION}/nixmac.app.tar.gz" \ +aws s3 cp "$TAR_GZ" "s3://nixmac-releases/${ARTIFACT_PREFIX}/nixmac.app.tar.gz" \ --endpoint-url "$R2_ENDPOINT" \ --cache-control "max-age=31536000" -aws s3 cp "$SIG_FILE" "s3://nixmac-releases/${VERSION}/nixmac.app.tar.gz.sig" \ +aws s3 cp "$SIG_FILE" "s3://nixmac-releases/${ARTIFACT_PREFIX}/nixmac.app.tar.gz.sig" \ --endpoint-url "$R2_ENDPOINT" \ --cache-control "max-age=31536000" PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") NOTES="" -if command -v gh &>/dev/null; then +if [[ "$UPDATE_CHANNEL" == "develop" ]]; then + NOTES="Develop build ${GITHUB_SHA:-unknown}" +elif command -v gh &>/dev/null && [[ -n "${RELEASE_TAG:-}" ]]; then NOTES=$(gh release view "$RELEASE_TAG" --json body -q .body 2>/dev/null || echo "") fi @@ -58,7 +80,7 @@ cat >/tmp/latest.json </tmp/latest-formatted.json -aws s3 cp /tmp/latest-formatted.json "s3://nixmac-releases/latest.json" \ +aws s3 cp /tmp/latest-formatted.json "s3://nixmac-releases/${MANIFEST_KEY}" \ --endpoint-url "$R2_ENDPOINT" \ --cache-control "max-age=300" \ --content-type "application/json" -echo "✅ Uploaded to R2: v${VERSION}" +echo "✅ Uploaded to R2 (${UPDATE_CHANNEL}): v${VERSION}"