Skip to content
Merged
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
Binary file modified src-tauri/icons/tray-update.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/tray-update@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 50 additions & 6 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,20 @@ pub fn run() {
show_settings_window(app);
}
"update" => {
show_settings_window(app);
// Trigger Install & restart directly from the tray.
// The Settings banner button calls the same shared
// routine through the `install_update` Tauri
// command. Spawn rather than block: tray click
// handlers run synchronously, but the install
// performs network IO and signature verification.
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) =
crate::updater::commands::install_update_inner(app_handle).await
{
eprintln!("tray-triggered install failed: {e}");
}
});
}
"quit" => {
app.state::<crate::commands::GenerationState>().cancel();
Expand Down Expand Up @@ -1084,12 +1097,43 @@ pub fn run() {
// ── Updater state + optional background poller ────────────
{
let updater_state = updater::UpdaterState::default();
let running_version = app.package_info().version.to_string();

let sidecar_path = app
.path()
.app_config_dir()
.ok()
.map(|d| d.join(crate::config::defaults::DEFAULT_UPDATER_STATE_FILENAME));

let mut sidecar = updater::SnoozeSidecar::default();
if let Some(path) = sidecar_path.as_ref() {
if let Ok(loaded) = updater::SnoozeSidecar::load(path) {
sidecar = loaded;
}
}

// Detect a fresh upgrade and clear the stale TCC grants
// macOS keeps for the previous binary's code signature.
// Without this, System Settings shows the toggle on but
// the new binary cannot actually use the permission.
if updater::tcc_reset::should_reset_for_upgrade(
sidecar.last_launched_version.as_deref(),
&running_version,
) {
updater::tcc_reset::tccutil_reset(&app.config().identifier);
}

if let Ok(dir) = app.path().app_config_dir() {
let path = dir.join(crate::config::defaults::DEFAULT_UPDATER_STATE_FILENAME);
if let Ok(s) = updater::SnoozeSidecar::load(&path) {
updater_state.set_settings_snooze(s.settings_snoozed_until);
updater_state.set_chat_snooze(s.chat_snoozed_until);
// Restore persisted snooze flags into the live state.
updater_state.set_settings_snooze(sidecar.settings_snoozed_until);
updater_state.set_chat_snooze(sidecar.chat_snoozed_until);

// Record the running version so the next launch can
// detect another upgrade. Best-effort; failure to write
// the sidecar is logged inside SnoozeSidecar::save.
sidecar.last_launched_version = Some(running_version);
if let Some(path) = sidecar_path.as_ref() {
if let Err(e) = sidecar.save(path) {
eprintln!("thuki: [updater] failed to persist sidecar: {e}");
}
}

Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/updater/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ pub async fn check_for_update(app: AppHandle) -> Result<UpdaterSnapshot, String>
#[cfg_attr(coverage_nightly, coverage(off))]
#[tauri::command]
pub async fn install_update(app: AppHandle) -> Result<(), String> {
install_update_inner(app).await
}

/// Shared install-and-restart routine. Re-checks the manifest (rather than
/// trusting the in-memory `UpdaterState`), downloads the signed payload,
/// verifies the ed25519 signature against the public key compiled into the
/// app, swaps the running `.app`, and relaunches.
///
/// Exposed to the tray click handler so clicking "Update Thuki to vX.Y.Z"
/// triggers the install directly without forcing the user to detour through
/// the Settings banner. The Settings banner button calls the
/// `install_update` Tauri command, which delegates here.
#[cfg_attr(coverage_nightly, coverage(off))]
pub async fn install_update_inner(app: AppHandle) -> Result<(), String> {
let updater = app.updater().map_err(|e| e.to_string())?;
let update = updater.check().await.map_err(|e| e.to_string())?;
let Some(update) = update else {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/updater/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
pub mod commands;
pub mod poller;
pub mod state;
pub mod tcc_reset;

pub use state::{AvailableUpdate, SnoozeSidecar, UpdaterSnapshot, UpdaterState};
9 changes: 9 additions & 0 deletions src-tauri/src/updater/poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ pub async fn check_once(app: AppHandle) {
Ok(u) => u,
Err(e) => {
eprintln!("updater builder failed: {e}");
// Even when the updater client cannot be built, the user clicked
// Check now and deserves to see "Last checked just now" instead
// of "Never". Mark the attempt without touching `update`.
state.mark_check_attempted();
return;
}
};
Expand All @@ -52,6 +56,11 @@ pub async fn check_once(app: AppHandle) {
}
Err(e) => {
eprintln!("updater check failed: {e}");
// Network/HTTP/manifest errors are transient. Record that we
// tried so the UI shows "Last checked X seconds ago" instead of
// "Never". Do not clear `update`: a previously known available
// version should survive a flaky check.
state.mark_check_attempted();
}
}
}
Expand Down
106 changes: 100 additions & 6 deletions src-tauri/src/updater/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,28 @@ use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

/// Snoozes that survive across app restarts. Stored as a JSON sidecar
/// (not in the user-editable TOML) because they are state-machine flags,
/// not preferences.
/// Updater state that survives across app restarts. Stored as a JSON
/// sidecar (not in the user-editable TOML) because these are state-machine
/// flags, not preferences. Holds: per-surface snooze deadlines (so "Later"
/// persists across launches) and the last-launched binary version (so the
/// startup sequence can detect a fresh upgrade and reset stale TCC grants).
///
/// Kept named `SnoozeSidecar` for back-compat with existing sidecar files
/// on user disks. Renaming would orphan their snooze state.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SnoozeSidecar {
/// Unix seconds. `None` means not snoozed.
#[serde(default)]
pub settings_snoozed_until: Option<u64>,
#[serde(default)]
pub chat_snoozed_until: Option<u64>,
/// SemVer string of the binary that wrote this sidecar last. Used to
/// detect upgrades on startup so we can reset the stale TCC grants
/// macOS keeps for the previous code signature. Absent on first ever
/// launch and on sidecars written by pre-0.8.2 builds; both cases are
/// treated as "no upgrade detected, do nothing."
#[serde(default)]
pub last_launched_version: Option<String>,
}

impl SnoozeSidecar {
Expand All @@ -23,9 +37,10 @@ impl SnoozeSidecar {
}

pub fn save(&self, path: &Path) -> std::io::Result<()> {
// SnoozeSidecar holds two Option<u64> fields, so serde_json::to_string
// is provably infallible here. expect() documents the invariant; if a
// future field ever changes that, the panic surface is loud and local.
// The struct holds plain Option<u64>/Option<String> fields, so
// serde_json::to_string is provably infallible here. expect()
// documents the invariant; if a future field ever changes that,
// the panic surface is loud and local.
let s = serde_json::to_string(self).expect("SnoozeSidecar serializes");
std::fs::write(path, s)
}
Expand Down Expand Up @@ -67,6 +82,16 @@ impl UpdaterState {
inner.last_check_at = Some(SystemTime::now());
}

/// Records that a check was attempted at the current wall clock without
/// touching `update`. Use this on transient failures (network errors,
/// 4xx/5xx, malformed manifest) so the UI can show "Last checked X
/// seconds ago" instead of "Never". Preserves any previously known
/// available update so a flaky network does not erase real signal.
pub fn mark_check_attempted(&self) {
let mut inner = self.inner.lock().expect("updater state mutex");
inner.last_check_at = Some(SystemTime::now());
}

pub fn set_chat_snooze(&self, until_unix: Option<u64>) {
let mut inner = self.inner.lock().expect("updater state mutex");
inner.snooze.chat_snoozed_until = until_unix;
Expand Down Expand Up @@ -119,6 +144,7 @@ mod tests {
let original = SnoozeSidecar {
settings_snoozed_until: Some(1_700_000_000),
chat_snoozed_until: Some(1_700_001_000),
last_launched_version: None,
};
original.save(&path).unwrap();

Expand All @@ -135,6 +161,41 @@ mod tests {
assert_eq!(loaded, SnoozeSidecar::default());
}

#[test]
fn snooze_sidecar_round_trips_last_launched_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("updater_state.json");

let original = SnoozeSidecar {
settings_snoozed_until: None,
chat_snoozed_until: None,
last_launched_version: Some("0.8.1".to_string()),
};
original.save(&path).unwrap();

let loaded = SnoozeSidecar::load(&path).unwrap();
assert_eq!(loaded, original);
}

#[test]
fn snooze_sidecar_back_compat_old_file_without_version_field() {
// Old (pre-0.8.2) sidecar files were written without the
// `last_launched_version` field. Loading must default it to None
// rather than fail, otherwise existing snooze state would be lost.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("updater_state.json");
std::fs::write(
&path,
r#"{"settings_snoozed_until":1700000000,"chat_snoozed_until":null}"#,
)
.unwrap();

let loaded = SnoozeSidecar::load(&path).unwrap();
assert_eq!(loaded.settings_snoozed_until, Some(1_700_000_000));
assert!(loaded.chat_snoozed_until.is_none());
assert!(loaded.last_launched_version.is_none());
}

#[test]
fn snooze_sidecar_load_corrupt_file_returns_default() {
let dir = tempfile::tempdir().unwrap();
Expand All @@ -157,6 +218,39 @@ mod tests {
assert_eq!(snap.update.as_ref().unwrap().version, "0.8.0");
}

#[test]
fn mark_check_attempted_updates_timestamp_without_touching_update() {
let state = UpdaterState::default();
// No update yet, no last_check_at.
assert!(state.snapshot().last_check_at_unix.is_none());

state.mark_check_attempted();
let snap = state.snapshot();
assert!(snap.last_check_at_unix.is_some());
assert!(snap.update.is_none());
}

#[test]
fn mark_check_attempted_preserves_existing_update() {
let state = UpdaterState::default();
state.set_update(Some(AvailableUpdate {
version: "0.9.0".to_string(),
notes_url: None,
}));
let before = state.snapshot();
let prior_ts = before.last_check_at_unix.unwrap();

// Sleep a tick so the new timestamp differs.
std::thread::sleep(std::time::Duration::from_millis(1100));
state.mark_check_attempted();
let after = state.snapshot();

// Update info preserved across the failed attempt.
assert_eq!(after.update.as_ref().unwrap().version, "0.9.0");
// Timestamp moved forward.
assert!(after.last_check_at_unix.unwrap() > prior_ts);
}

#[test]
fn set_chat_snooze_persists_in_snapshot() {
let state = UpdaterState::default();
Expand Down
101 changes: 101 additions & 0 deletions src-tauri/src/updater/tcc_reset.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! macOS TCC grant reset on app upgrade.
//!
//! Background. Thuki is ad-hoc signed (no Apple Developer ID). macOS keys
//! TCC (Transparency, Consent, Control) grants by code requirement, not
//! bundle ID. When the auto-updater swaps the binary, the new code
//! requirement does not match the stored grant, so System Settings shows
//! "Thuki: granted" but `AXIsProcessTrusted` returns false. The toggle is a
//! visual lie.
//!
//! `tccutil reset <service> <bundle-id>` removes the entry for that bundle
//! ID under that service. On the next permission request, macOS adds a
//! fresh entry tied to the current binary's code requirement, which then
//! actually grants the running app when the user toggles it on.
//!
//! This module:
//!
//! 1. Defines which TCC services Thuki uses.
//! 2. Provides a pure helper, `should_reset_for_upgrade`, that decides
//! whether the running version differs from what the sidecar last
//! recorded.
//! 3. Provides `tccutil_reset`, a thin wrapper around `/usr/bin/tccutil`
//! that fails open: any error is logged and ignored. A failed reset
//! leaves the user with the existing manual toggle-off / toggle-on
//! workaround, which is no worse than today's behavior.

use std::process::Command;

/// TCC services Thuki actively uses and whose stale grants need clearing
/// on an upgrade. `Accessibility` powers the global Control hotkey;
/// `ScreenCapture` powers the `/screen` command.
const SERVICES: &[&str] = &["Accessibility", "ScreenCapture"];

/// Pure decision function. Returns `true` when the recorded version
/// differs from the running version, indicating an upgrade just happened.
/// Returns `false` when:
/// - The sidecar has no recorded version (first ever launch; nothing to
/// reset because no prior binary ever held grants).
/// - The recorded version equals the running version (normal launch).
pub fn should_reset_for_upgrade(recorded: Option<&str>, running: &str) -> bool {
match recorded {
Some(prev) => prev != running,
None => false,
}
}

/// Shells out to `/usr/bin/tccutil reset <service> <bundle_id>` for each
/// TCC service Thuki uses. Logs failures but never propagates them: TCC
/// reset is a UX nicety, not a correctness requirement.
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn tccutil_reset(bundle_id: &str) {
for service in SERVICES {
let result = Command::new("/usr/bin/tccutil")
.args(["reset", service, bundle_id])
.status();
match result {
Ok(status) if status.success() => {
eprintln!("thuki: [updater] cleared stale TCC grant for {service} ({bundle_id})");
}
Ok(status) => {
eprintln!(
"thuki: [updater] tccutil reset {service} exited with {status}; \
leaving any existing grant in place"
);
}
Err(e) => {
eprintln!(
"thuki: [updater] tccutil invocation failed: {e}; \
leaving any existing grant in place"
);
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn no_reset_when_recorded_version_matches() {
assert!(!should_reset_for_upgrade(Some("0.8.1"), "0.8.1"));
}

#[test]
fn reset_when_recorded_version_differs() {
assert!(should_reset_for_upgrade(Some("0.8.0"), "0.8.1"));
}

#[test]
fn no_reset_on_first_ever_launch_when_recorded_is_absent() {
// First launch: nothing recorded, nothing to invalidate.
assert!(!should_reset_for_upgrade(None, "0.8.1"));
}

#[test]
fn reset_when_recorded_version_is_higher_than_running() {
// Downgrade still counts as a binary swap — the csreq differs in
// either direction, so the stale grant must be cleared.
assert!(should_reset_for_upgrade(Some("0.9.0"), "0.8.1"));
}
}
Loading