From 2a150bbbbd964f5f25dc714ffc2915274697160d Mon Sep 17 00:00:00 2001 From: Aditya Date: Mon, 29 Jun 2026 13:56:15 +0530 Subject: [PATCH 1/2] fix(updater): overhaul auto-update flow with granular events and contextual UI --- .github/workflows/release.yml | 13 +++-- AUDIT_LOG.md | 14 +++++ CHANGELOG.md | 8 +++ src-tauri/src/commands/system.rs | 98 ++++++++++++++++++++++++++++---- src-tauri/src/lib.rs | 4 +- src-tauri/tauri.conf.json | 1 + src/App.tsx | 62 +++++++++++++++++--- src/Settings.tsx | 35 ++++++++++-- src/api.ts | 2 + src/setupTests.ts | 2 + src/store/useAppStore.ts | 2 + src/types.d.ts | 8 +++ 12 files changed, 218 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9e02ec..5cbd66d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: new_tag: ${{ steps.get_version.outputs.new_tag }} new_version: ${{ steps.get_version.outputs.new_version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Get version from package.json id: get_version run: | @@ -35,10 +35,10 @@ jobs: os: [macos-latest, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - name: Install dependencies (Ubuntu only) if: matrix.os == 'ubuntu-latest' @@ -46,14 +46,14 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '22' - run: npm ci - name: Build and Upload Tauri App - uses: tauri-apps/tauri-action@v0 + uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4 # v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -64,13 +64,14 @@ jobs: releaseBody: 'See the assets to download this version and install.' releaseDraft: false prerelease: false + updaterJsonPreferNsis: true update-homebrew: needs: [create-tag, build-and-release] runs-on: macos-latest steps: - name: Checkout Homebrew Tap - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: VariableThe/homebrew-tap token: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/AUDIT_LOG.md b/AUDIT_LOG.md index 350a68d..a1931eb 100644 --- a/AUDIT_LOG.md +++ b/AUDIT_LOG.md @@ -2,6 +2,20 @@ This log tracks all significant changes, updates, and versions in the PaperCache project. +## 2026-06-29 (Security & Auto-Update Overhaul) +**Change:** fix(security): pin third-party GitHub Action references in release workflow to immutable SHA-1 digests; fix(updater): overhaul Tauri auto-update mechanism to emit granular status events and require user-triggered restarts + +**Details/Why:** +1. **Supply-Chain Security**: Pinned `actions/checkout`, `dtolnay/rust-toolchain`, `actions/setup-node`, and `tauri-apps/tauri-action` to immutable SHA-1 commit hashes in `.github/workflows/release.yml` to prevent supply-chain attacks. +2. **Updater Artifact Configuration**: Enabled `"createUpdaterArtifacts": "v1Compatible"` in `tauri.conf.json` and added `updaterJsonPreferNsis: true` to `release.yml` to ensure manifest generation (`latest.json`) functions properly for both v1 and v2 clients. +3. **Event-Driven Update Flow**: Refactored `check_for_updates` in `system.rs` to emit `update-status` events (`checking`, `available`, `downloading`, `ready`, `error`, `up-to-date`) instead of executing opaque silent updates. Added a user-triggered `restart_app` command. +4. **Contextual UI Feedback**: Updated `Settings.tsx` button to display "Checking…" visual state with disabled interaction during update checks. Updated `App.tsx` to display a persistent toast notification when an update is downloaded and ready, featuring a prominent "Restart Now" button that calls `restart_app`. +5. **Dead Code Gate**: Gated `FOCUS_LOSS_DEBOUNCE_MS` constant in `lib.rs` with `#[cfg(not(target_os = "macos"))]` to prevent unused code warnings on non-macOS targets. + +**Files changed:** `.github/workflows/release.yml`, `src-tauri/tauri.conf.json`, `src-tauri/src/lib.rs`, `src-tauri/src/commands/system.rs`, `src/types.d.ts`, `src/api.ts`, `src/store/useAppStore.ts`, `src/App.tsx`, `src/Settings.tsx`, `src/setupTests.ts`, `AUDIT_LOG.md`, `CHANGELOG.md`. + +--- + ## 2026-06-29 (Code Quality Cleanup) **Change:** refactor: code quality cleanup — dead code, boilerplate, types, constants, AI comments; fix: address PR review findings — listener leak, type contracts, dead ref, stale guard, cfg scope, shortcut loop, timer constant diff --git a/CHANGELOG.md b/CHANGELOG.md index 52cff4a..b95252f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable, user-facing changes to PaperCache will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Contextual Auto-Update UI**: When checking for updates in Settings, visual feedback is now displayed ("Checking…"). When an update is downloaded and ready, a persistent toast notification appears with a prominent "Restart Now" button so users can restart when convenient rather than experiencing unexpected application restarts. + +### Fixed +- **Updater Artifact Manifest Generation**: Fixed an issue where auto-updates failed due to missing or improperly configured updater manifests (`latest.json`) in GitHub release assets. + ## [v0.5.6] - 2026-06-28 ### Added diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index b0afa4e..cf1db42 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -109,25 +109,99 @@ pub fn set_launch_at_startup(app: AppHandle, enabled: bool) -> Result<(), String Ok(()) } +#[derive(serde::Serialize, Clone)] +struct UpdatePayload { + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + #[tauri::command] pub async fn check_for_updates(app: tauri::AppHandle) -> Result<(), String> { use tauri_plugin_updater::UpdaterExt; - let updater = app.updater().map_err(|e| e.to_string())?; - - if let Some(update) = updater.check().await.map_err(|e| e.to_string())? { - // Run the download + install + restart in the background so the command - // returns immediately. The "update-ready" event gives the frontend 3 seconds - // to show a toast before the process restarts. - tokio::spawn(async move { - let _ = update.download_and_install(|_, _| {}, || {}).await; - let _ = app.emit("update-ready", ()); - tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; - app.restart(); - }); + let _ = app.emit("update-status", UpdatePayload { + status: "checking".into(), + version: None, + error: None, + }); + + let updater = match app.updater() { + Ok(u) => u, + Err(e) => { + let err_str = e.to_string(); + let _ = app.emit("update-status", UpdatePayload { + status: "error".into(), + version: None, + error: Some(err_str.clone()), + }); + return Err(err_str); + } + }; + + let update_res = updater.check().await; + match update_res { + Ok(Some(update)) => { + let version = update.version.clone(); + let _ = app.emit("update-status", UpdatePayload { + status: "available".into(), + version: Some(version), + error: None, + }); + + let _ = app.emit("update-status", UpdatePayload { + status: "downloading".into(), + version: None, + error: None, + }); + + let app_clone = app.clone(); + tokio::spawn(async move { + match update.download_and_install(|_, _| {}, || {}).await { + Ok(_) => { + let _ = app_clone.emit("update-status", UpdatePayload { + status: "ready".into(), + version: None, + error: None, + }); + let _ = app_clone.emit("update-ready", ()); + } + Err(e) => { + let _ = app_clone.emit("update-status", UpdatePayload { + status: "error".into(), + version: None, + error: Some(e.to_string()), + }); + } + } + }); + } + Ok(None) => { + let _ = app.emit("update-status", UpdatePayload { + status: "up-to-date".into(), + version: None, + error: None, + }); + } + Err(e) => { + let err_str = e.to_string(); + let _ = app.emit("update-status", UpdatePayload { + status: "error".into(), + version: None, + error: Some(err_str.clone()), + }); + return Err(err_str); + } } Ok(()) } +#[tauri::command] +pub fn restart_app(app: AppHandle) { + app.restart(); +} + #[tauri::command] pub fn is_hyprland() -> Result { Ok(std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() || std::env::var("HYPRLAND_CMD").is_ok()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1fc9fad..258dd18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,9 +7,8 @@ mod commands; mod macos; mod tray; -#[allow(dead_code)] +#[cfg(not(target_os = "macos"))] const FOCUS_LOSS_DEBOUNCE_MS: u64 = 200; -#[allow(dead_code)] const WINDOW_STATE_RESTORE_DELAY_MS: u64 = 300; @@ -169,6 +168,7 @@ pub fn run() { commands::system::get_launch_at_startup, commands::system::set_launch_at_startup, commands::system::check_for_updates, + commands::system::restart_app, commands::system::is_hyprland, commands::keychain::set_api_key, commands::keychain::get_api_key_status, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2071f9d..68e9306 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,6 +28,7 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": "v1Compatible", "targets": ["nsis", "msi", "appimage", "deb", "app"], "icon": [ "icons/32x32.png", diff --git a/src/App.tsx b/src/App.tsx index f788959..5f0e9df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,10 +64,27 @@ function App() { useAppStore.getState().setIsHyprland(isHyp) }) + const disposeUpdateStatus = window.electronAPI.onUpdateStatus((payload) => { + if (payload.status === 'ready') { + useAppStore.getState().addToast({ + message: '✨ A new update is ready to install.', + type: 'info', + actionLabel: 'Restart Now', + onAction: () => { + window.electronAPI.restartApp() + }, + }) + } + }) + const disposeUpdateReady = window.electronAPI.onUpdateReady(() => { useAppStore.getState().addToast({ - message: '✨ PaperCache updated — restarting in 3 seconds…', + message: '✨ A new update is ready to install.', type: 'info', + actionLabel: 'Restart Now', + onAction: () => { + window.electronAPI.restartApp() + }, }) }) @@ -89,6 +106,7 @@ function App() { return () => { isUnmounted = true + disposeUpdateStatus() disposeUpdateReady() unlistenTimer?.() } @@ -108,10 +126,12 @@ function App() { for (const toast of toasts) { if (!timers.has(toast.id)) { - timers.set( - toast.id, - setTimeout(() => removeToast(toast.id), TOAST_TIMEOUT_MS) - ) + if (!toast.actionLabel) { + timers.set( + toast.id, + setTimeout(() => removeToast(toast.id), TOAST_TIMEOUT_MS) + ) + } } } }, [toasts, removeToast]) @@ -287,7 +307,9 @@ function App() { {toasts.map((toast) => (
removeToast(toast.id)} + onClick={() => { + if (!toast.actionLabel) removeToast(toast.id) + }} style={{ padding: '10px 16px', borderRadius: 8, @@ -306,9 +328,35 @@ function App() { cursor: 'pointer', maxWidth: 320, animation: 'toast-in 0.25s ease', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, }} > - {toast.message} + {toast.message} + {toast.actionLabel && ( + + )}
))} diff --git a/src/Settings.tsx b/src/Settings.tsx index ef8078b..509ec02 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -9,6 +9,7 @@ import './Settings.css' export default function Settings({ onClose }: { onClose?: () => void }) { const [apiKey, setApiKey] = useState('') const [isApiKeySet, setIsApiKeySet] = useState(false) + const [updateChecking, setUpdateChecking] = useState(false) useEffect(() => { window.electronAPI.getApiKeyStatus().then((status) => { @@ -58,6 +59,27 @@ export default function Settings({ onClose }: { onClose?: () => void }) { setLaunchAtStartup(enabled) localStorage.setItem(SETTINGS_KEYS.LAUNCH_STARTUP, enabled.toString()) }) + + const disposeUpdateStatus = window.electronAPI.onUpdateStatus((payload) => { + if (payload.status === 'checking') { + setUpdateChecking(true) + } else { + setUpdateChecking(false) + if (payload.status === 'up-to-date') { + useAppStore.getState().addToast({ message: '✨ PaperCache is up to date.', type: 'info' }) + } else if (payload.status === 'error') { + useAppStore.getState().addToast({ + message: `Update failed: ${payload.error || 'Unknown error'}`, + type: 'error', + }) + } else if (payload.status === 'available') { + useAppStore + .getState() + .addToast({ message: `Downloading update v${payload.version || ''}…`, type: 'info' }) + } + } + }) + return () => disposeUpdateStatus() }, []) const [appVersion, setAppVersion] = useState('0.5.6') @@ -448,18 +470,23 @@ export default function Settings({ onClose }: { onClose?: () => void }) { style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }} >