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
17 changes: 11 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -35,25 +35,29 @@ jobs:
os: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable

- name: Install dependencies (Ubuntu only)
if: matrix.os == 'ubuntu-latest'
run: |
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 }}
Expand All @@ -64,13 +68,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 }}
Expand Down
14 changes: 14 additions & 0 deletions AUDIT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 86 additions & 12 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}

#[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<bool, String> {
Ok(std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() || std::env::var("HYPRLAND_CMD").is_ok())
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;


Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": "v1Compatible",
"targets": ["nsis", "msi", "appimage", "deb", "app"],
"icon": [
"icons/32x32.png",
Expand Down
62 changes: 55 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
})
})

Expand All @@ -89,6 +106,7 @@ function App() {

return () => {
isUnmounted = true
disposeUpdateStatus()
disposeUpdateReady()
unlistenTimer?.()
}
Expand All @@ -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])
Expand Down Expand Up @@ -287,7 +307,9 @@ function App() {
{toasts.map((toast) => (
<div
key={toast.id}
onClick={() => removeToast(toast.id)}
onClick={() => {
if (!toast.actionLabel) removeToast(toast.id)
}}
style={{
padding: '10px 16px',
borderRadius: 8,
Expand All @@ -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}
<span>{toast.message}</span>
{toast.actionLabel && (
<button
onClick={(e) => {
e.stopPropagation()
toast.onAction?.()
removeToast(toast.id)
}}
style={{
background: '#fff',
color: '#000',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{toast.actionLabel}
</button>
)}
</div>
))}
</div>
Expand Down
35 changes: 31 additions & 4 deletions src/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -448,18 +470,23 @@ export default function Settings({ onClose }: { onClose?: () => void }) {
style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}
>
<button
onClick={() => window.electronAPI.checkForUpdates()}
onClick={() => {
setUpdateChecking(true)
window.electronAPI.checkForUpdates()
}}
disabled={updateChecking}
style={{
padding: '6px 14px',
background: 'rgba(128,128,128,0.1)',
background: updateChecking ? 'rgba(128,128,128,0.2)' : 'rgba(128,128,128,0.1)',
border: '1px solid rgba(128,128,128,0.2)',
borderRadius: '6px',
cursor: 'pointer',
cursor: updateChecking ? 'wait' : 'pointer',
color: 'inherit',
fontFamily: 'inherit',
opacity: updateChecking ? 0.7 : 1,
}}
>
Check for Updates
{updateChecking ? 'Checking…' : 'Check for Updates'}
</button>
<button
onClick={() => window.electronAPI.openExternal('https://ko-fi.com/thevariable')}
Expand Down
Loading
Loading