Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Build macOS App
on:
push:
branches: [ main ]
branches: [ main, develop ]
tags:
- "v*"
pull_request:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -191,14 +195,16 @@ 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:
AWS_DEFAULT_REGION: auto
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
Expand Down
8 changes: 1 addition & 7 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@
"react-perf",
"vitest"
],
"rules": {
"correctness": "error",
"perf": "error",
"style": "warn",
"suspicious": "error"
},
"overrides": [
{
"files": [
Expand All @@ -26,4 +20,4 @@
}
}
]
}
}
2 changes: 2 additions & 0 deletions apps/native/src-tauri/examples/specta_gen_ts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ fn main() {
.register::<shared_types::EvolutionFailureResult>()
.register::<shared_types::RollbackResult>()
.register::<shared_types::SetDirResult>()
.register::<shared_types::UpdateChannel>()
.register::<shared_types::UpdateInfo>()
.register::<shared_types::UiPrefs>()
.register::<shared_types::UiPrefsUpdate>()
.register::<shared_types::OkResult>()
Expand Down
11 changes: 11 additions & 0 deletions apps/native/src-tauri/src/commands/ui_prefs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result<shared_types::UiPrefs, Strin
.map_err(|e| capture_err("ui_get_prefs", e))?;
let pinned_version = store::get_string_pref_public(&app, store::PINNED_VERSION_KEY)
.map_err(|e| capture_err("ui_get_prefs", e))?;
let update_channel = store::get_json_pref_or(
&app,
store::UPDATE_CHANNEL_KEY,
shared_types::UpdateChannel::default(),
)
.map_err(|e| capture_err("ui_get_prefs", e))?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest using wrap_result_and_capture_err which I added after tiring of seeing this same verbose/error-prone pattern dozens of times in this file.

log::debug!("ui_get_prefs completed");

Ok(shared_types::UiPrefs {
Expand All @@ -80,6 +86,7 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result<shared_types::UiPrefs, Strin
scan_homebrew_on_startup,
developer_mode,
pinned_version,
update_channel,
})
}

Expand Down Expand Up @@ -180,6 +187,10 @@ pub async fn ui_set_prefs(
}
}
}
if let Some(update_channel) = prefs.update_channel {
store::set_json_pref(&app, store::UPDATE_CHANNEL_KEY, &update_channel)
.map_err(|e| capture_err("ui_set_prefs", e))?;
}

Ok(shared_types::OkResult::yes())
}
Expand Down
121 changes: 121 additions & 0 deletions apps/native/src-tauri/src/commands/updater.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,105 @@
use crate::shared_types;
use tauri::AppHandle;

#[cfg(any(not(debug_assertions), test))]
const STABLE_MANIFEST_URL: &str = "https://releases.nixmac.com/latest.json";
#[cfg(any(not(debug_assertions), test))]
const DEVELOP_MANIFEST_URL: &str = "https://releases.nixmac.com/channels/develop/latest.json";

#[cfg(any(not(debug_assertions), test))]
fn update_manifest_url(channel: shared_types::UpdateChannel) -> &'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<shared_types::UpdateChannel, String> {
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<tauri_plugin_updater::Updater, String> {
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<Option<shared_types::UpdateInfo>, 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<Option<shared_types::UpdateInfo>, 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
Expand Down Expand Up @@ -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"
);
}
}
10 changes: 10 additions & 0 deletions apps/native/src-tauri/src/e2e_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -26,6 +34,7 @@ struct E2eRuntimeFile {
values: HashMap<String, String>,
}

#[cfg(debug_assertions)]
fn runtime_file_path() -> Option<PathBuf> {
let home = dirs::home_dir()?;
Some(
Expand All @@ -36,6 +45,7 @@ fn runtime_file_path() -> Option<PathBuf> {
)
}

#[cfg(debug_assertions)]
fn now_unix() -> Option<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down
2 changes: 2 additions & 0 deletions apps/native/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions apps/native/src-tauri/src/shared_types/prefs.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand Down Expand Up @@ -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<String>,
/// 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
Expand Down Expand Up @@ -93,6 +109,20 @@ pub struct UiPrefsUpdate {
with = "double_option"
)]
pub pinned_version: Option<Option<String>>,
/// Auto-update channel preference update.
pub update_channel: Option<UpdateChannel>,
}

/// 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<String>,
}

#[allow(dead_code)]
Expand Down
Loading
Loading