diff --git a/src-tauri/icons/tray-update.png b/src-tauri/icons/tray-update.png index 0fdcb89..f5f7bb5 100644 Binary files a/src-tauri/icons/tray-update.png and b/src-tauri/icons/tray-update.png differ diff --git a/src-tauri/icons/tray-update@2x.png b/src-tauri/icons/tray-update@2x.png index 6376a30..0155cab 100644 Binary files a/src-tauri/icons/tray-update@2x.png and b/src-tauri/icons/tray-update@2x.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5085877..1b2831b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::().cancel(); @@ -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}"); } } diff --git a/src-tauri/src/updater/commands.rs b/src-tauri/src/updater/commands.rs index 79aaa27..f33dbff 100644 --- a/src-tauri/src/updater/commands.rs +++ b/src-tauri/src/updater/commands.rs @@ -22,6 +22,20 @@ pub async fn check_for_update(app: AppHandle) -> Result #[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 { diff --git a/src-tauri/src/updater/mod.rs b/src-tauri/src/updater/mod.rs index 658740b..5b0e7c2 100644 --- a/src-tauri/src/updater/mod.rs +++ b/src-tauri/src/updater/mod.rs @@ -7,5 +7,6 @@ pub mod commands; pub mod poller; pub mod state; +pub mod tcc_reset; pub use state::{AvailableUpdate, SnoozeSidecar, UpdaterSnapshot, UpdaterState}; diff --git a/src-tauri/src/updater/poller.rs b/src-tauri/src/updater/poller.rs index 0023d0c..ed1a15a 100644 --- a/src-tauri/src/updater/poller.rs +++ b/src-tauri/src/updater/poller.rs @@ -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; } }; @@ -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(); } } } diff --git a/src-tauri/src/updater/state.rs b/src-tauri/src/updater/state.rs index ce1bd77..ea17587 100644 --- a/src-tauri/src/updater/state.rs +++ b/src-tauri/src/updater/state.rs @@ -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, + #[serde(default)] pub chat_snoozed_until: Option, + /// 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, } impl SnoozeSidecar { @@ -23,9 +37,10 @@ impl SnoozeSidecar { } pub fn save(&self, path: &Path) -> std::io::Result<()> { - // SnoozeSidecar holds two Option 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/Option 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) } @@ -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) { let mut inner = self.inner.lock().expect("updater state mutex"); inner.snooze.chat_snoozed_until = until_unix; @@ -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(); @@ -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(); @@ -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(); diff --git a/src-tauri/src/updater/tcc_reset.rs b/src-tauri/src/updater/tcc_reset.rs new file mode 100644 index 0000000..1f3fe25 --- /dev/null +++ b/src-tauri/src/updater/tcc_reset.rs @@ -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 ` 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 ` 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")); + } +} diff --git a/src/App.tsx b/src/App.tsx index ff60777..eb2140b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1918,34 +1918,57 @@ function App() { maxImages={config.window.maxImages} onFirstKeystroke={() => void invoke('warm_up_model')} /> - {/* Tip bar: ask-bar mode only. The !isChatMode gate lives - OUTSIDE AnimatePresence for the same reason as the history - panel above: prevents two simultaneous ResizeObserver - setSize() calls (jitter) when isChatMode transitions. */} - {!isChatMode && ( + {/* Footer slot. + * + * UpdateFooterBar takes priority and renders in BOTH modes + * (ask bar + chat) so a pending update is visible no matter + * what the user is doing. The TipBar stays scoped to + * ask-bar mode: tips are noise mid-conversation, but an + * update is signal worth showing. + * + * The mode gate lives OUTSIDE AnimatePresence (same reason + * as the history panel above): prevents two simultaneous + * ResizeObserver setSize() calls (jitter) when isChatMode + * transitions. + */} + {showUpdate ? ( - {isTipVisible && ( - - {showUpdate ? ( - void updater.install()} - onLater={() => void updater.snoozeChat(24)} - /> - ) : ( - - )} - - )} + + void updater.install()} + onLater={() => void updater.snoozeChat(24)} + /> + + ) : ( + !isChatMode && ( + + {isTipVisible && ( + + + + )} + + ) )} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index a7652d9..d4d8e62 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -5472,6 +5472,54 @@ describe('App', () => { expect(screen.queryByTestId('tip-bar')).not.toBeInTheDocument(); }); + it('keeps the UpdateFooterBar visible after entering chat mode', async () => { + (useTips as ReturnType).mockReturnValue({ + tip: 'test tip', + tipKey: 0, + isVisible: true, + }); + enableChannelCaptureWithResponses({ + get_updater_state: { + last_check_at_unix: 100, + update: { version: '0.8.1', notes_url: null }, + settings_snoozed_until: null, + chat_snoozed_until: null, + }, + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + }); + + render(); + await act(async () => {}); + await showOverlay(); + // Visible in ask-bar mode first. + await waitFor(() => + expect(screen.getByTestId('update-footer-bar')).toBeInTheDocument(), + ); + + // Send a message to flip into chat mode. + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'hi' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'hello' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Critical: the update footer must still render in chat mode. + expect(screen.getByTestId('update-footer-bar')).toBeInTheDocument(); + expect(screen.queryByTestId('tip-bar')).not.toBeInTheDocument(); + }); + it('shows TipBar normally when no update is available', async () => { (useTips as ReturnType).mockReturnValue({ tip: 'test tip',