Skip to content

Commit ba77a6a

Browse files
authored
Merge branch 'main' into fix/security-and-repo-hygiene
2 parents d4e902f + 2228fbc commit ba77a6a

12 files changed

Lines changed: 208 additions & 23 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ jobs:
6868
releaseBody: 'See the assets to download this version and install.'
6969
releaseDraft: false
7070
prerelease: false
71+
updaterJsonPreferNsis: true
7172

7273
update-homebrew:
7374
needs: [create-tag, build-and-release]

AUDIT_LOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ This log tracks all significant changes, updates, and versions in the PaperCache
1111
3. **Rust Config Gating**: Gated `FOCUS_LOSS_DEBOUNCE_MS` in `src-tauri/src/lib.rs` with `#[cfg(not(target_os = "macos"))]` so it is cleanly excluded on macOS where it is not used, eliminating dead-code warnings without blanket suppressions.
1212

1313
**Files changed:** `.github/workflows/release.yml`, `.gitignore`, `src-tauri/src/lib.rs`, `CHANGELOG.md`, `AUDIT_LOG.md`.
14+
## 2026-06-29 (Security & Auto-Update Overhaul)
15+
**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
16+
17+
**Details/Why:**
18+
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.
19+
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.
20+
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.
21+
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`.
22+
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.
23+
24+
**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`.
1425

1526
---
1627

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable, user-facing changes to PaperCache will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
- **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.
12+
13+
### Fixed
14+
- **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.
15+
816
## [v0.5.6] - 2026-06-28
917

1018
### Added

src-tauri/src/commands/system.rs

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,25 +109,99 @@ pub fn set_launch_at_startup(app: AppHandle, enabled: bool) -> Result<(), String
109109
Ok(())
110110
}
111111

112+
#[derive(serde::Serialize, Clone)]
113+
struct UpdatePayload {
114+
status: String,
115+
#[serde(skip_serializing_if = "Option::is_none")]
116+
version: Option<String>,
117+
#[serde(skip_serializing_if = "Option::is_none")]
118+
error: Option<String>,
119+
}
120+
112121
#[tauri::command]
113122
pub async fn check_for_updates(app: tauri::AppHandle) -> Result<(), String> {
114123
use tauri_plugin_updater::UpdaterExt;
115-
let updater = app.updater().map_err(|e| e.to_string())?;
116-
117-
if let Some(update) = updater.check().await.map_err(|e| e.to_string())? {
118-
// Run the download + install + restart in the background so the command
119-
// returns immediately. The "update-ready" event gives the frontend 3 seconds
120-
// to show a toast before the process restarts.
121-
tokio::spawn(async move {
122-
let _ = update.download_and_install(|_, _| {}, || {}).await;
123-
let _ = app.emit("update-ready", ());
124-
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
125-
app.restart();
126-
});
124+
let _ = app.emit("update-status", UpdatePayload {
125+
status: "checking".into(),
126+
version: None,
127+
error: None,
128+
});
129+
130+
let updater = match app.updater() {
131+
Ok(u) => u,
132+
Err(e) => {
133+
let err_str = e.to_string();
134+
let _ = app.emit("update-status", UpdatePayload {
135+
status: "error".into(),
136+
version: None,
137+
error: Some(err_str.clone()),
138+
});
139+
return Err(err_str);
140+
}
141+
};
142+
143+
let update_res = updater.check().await;
144+
match update_res {
145+
Ok(Some(update)) => {
146+
let version = update.version.clone();
147+
let _ = app.emit("update-status", UpdatePayload {
148+
status: "available".into(),
149+
version: Some(version),
150+
error: None,
151+
});
152+
153+
let _ = app.emit("update-status", UpdatePayload {
154+
status: "downloading".into(),
155+
version: None,
156+
error: None,
157+
});
158+
159+
let app_clone = app.clone();
160+
tokio::spawn(async move {
161+
match update.download_and_install(|_, _| {}, || {}).await {
162+
Ok(_) => {
163+
let _ = app_clone.emit("update-status", UpdatePayload {
164+
status: "ready".into(),
165+
version: None,
166+
error: None,
167+
});
168+
let _ = app_clone.emit("update-ready", ());
169+
}
170+
Err(e) => {
171+
let _ = app_clone.emit("update-status", UpdatePayload {
172+
status: "error".into(),
173+
version: None,
174+
error: Some(e.to_string()),
175+
});
176+
}
177+
}
178+
});
179+
}
180+
Ok(None) => {
181+
let _ = app.emit("update-status", UpdatePayload {
182+
status: "up-to-date".into(),
183+
version: None,
184+
error: None,
185+
});
186+
}
187+
Err(e) => {
188+
let err_str = e.to_string();
189+
let _ = app.emit("update-status", UpdatePayload {
190+
status: "error".into(),
191+
version: None,
192+
error: Some(err_str.clone()),
193+
});
194+
return Err(err_str);
195+
}
127196
}
128197
Ok(())
129198
}
130199

200+
#[tauri::command]
201+
pub fn restart_app(app: AppHandle) {
202+
app.restart();
203+
}
204+
131205
#[tauri::command]
132206
pub fn is_hyprland() -> Result<bool, String> {
133207
Ok(std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() || std::env::var("HYPRLAND_CMD").is_ok())

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ pub fn run() {
168168
commands::system::get_launch_at_startup,
169169
commands::system::set_launch_at_startup,
170170
commands::system::check_for_updates,
171+
commands::system::restart_app,
171172
commands::system::is_hyprland,
172173
commands::keychain::set_api_key,
173174
commands::keychain::get_api_key_status,

src-tauri/tauri.conf.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"bundle": {
3030
"active": true,
31+
"createUpdaterArtifacts": "v1Compatible",
3132
"targets": ["nsis", "msi", "appimage", "deb", "app"],
3233
"icon": [
3334
"icons/32x32.png",

src/App.tsx

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,27 @@ function App() {
6464
useAppStore.getState().setIsHyprland(isHyp)
6565
})
6666

67+
const disposeUpdateStatus = window.electronAPI.onUpdateStatus((payload) => {
68+
if (payload.status === 'ready') {
69+
useAppStore.getState().addToast({
70+
message: '✨ A new update is ready to install.',
71+
type: 'info',
72+
actionLabel: 'Restart Now',
73+
onAction: () => {
74+
window.electronAPI.restartApp()
75+
},
76+
})
77+
}
78+
})
79+
6780
const disposeUpdateReady = window.electronAPI.onUpdateReady(() => {
6881
useAppStore.getState().addToast({
69-
message: '✨ PaperCache updated — restarting in 3 seconds…',
82+
message: '✨ A new update is ready to install.',
7083
type: 'info',
84+
actionLabel: 'Restart Now',
85+
onAction: () => {
86+
window.electronAPI.restartApp()
87+
},
7188
})
7289
})
7390

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

90107
return () => {
91108
isUnmounted = true
109+
disposeUpdateStatus()
92110
disposeUpdateReady()
93111
unlistenTimer?.()
94112
}
@@ -108,10 +126,12 @@ function App() {
108126

109127
for (const toast of toasts) {
110128
if (!timers.has(toast.id)) {
111-
timers.set(
112-
toast.id,
113-
setTimeout(() => removeToast(toast.id), TOAST_TIMEOUT_MS)
114-
)
129+
if (!toast.actionLabel) {
130+
timers.set(
131+
toast.id,
132+
setTimeout(() => removeToast(toast.id), TOAST_TIMEOUT_MS)
133+
)
134+
}
115135
}
116136
}
117137
}, [toasts, removeToast])
@@ -287,7 +307,9 @@ function App() {
287307
{toasts.map((toast) => (
288308
<div
289309
key={toast.id}
290-
onClick={() => removeToast(toast.id)}
310+
onClick={() => {
311+
if (!toast.actionLabel) removeToast(toast.id)
312+
}}
291313
style={{
292314
padding: '10px 16px',
293315
borderRadius: 8,
@@ -306,9 +328,35 @@ function App() {
306328
cursor: 'pointer',
307329
maxWidth: 320,
308330
animation: 'toast-in 0.25s ease',
331+
display: 'flex',
332+
alignItems: 'center',
333+
justifyContent: 'space-between',
334+
gap: 12,
309335
}}
310336
>
311-
{toast.message}
337+
<span>{toast.message}</span>
338+
{toast.actionLabel && (
339+
<button
340+
onClick={(e) => {
341+
e.stopPropagation()
342+
toast.onAction?.()
343+
removeToast(toast.id)
344+
}}
345+
style={{
346+
background: '#fff',
347+
color: '#000',
348+
border: 'none',
349+
borderRadius: 4,
350+
padding: '4px 8px',
351+
fontSize: 12,
352+
fontWeight: 600,
353+
cursor: 'pointer',
354+
whiteSpace: 'nowrap',
355+
}}
356+
>
357+
{toast.actionLabel}
358+
</button>
359+
)}
312360
</div>
313361
))}
314362
</div>

src/Settings.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import './Settings.css'
99
export default function Settings({ onClose }: { onClose?: () => void }) {
1010
const [apiKey, setApiKey] = useState('')
1111
const [isApiKeySet, setIsApiKeySet] = useState(false)
12+
const [updateChecking, setUpdateChecking] = useState(false)
1213

1314
useEffect(() => {
1415
window.electronAPI.getApiKeyStatus().then((status) => {
@@ -58,6 +59,27 @@ export default function Settings({ onClose }: { onClose?: () => void }) {
5859
setLaunchAtStartup(enabled)
5960
localStorage.setItem(SETTINGS_KEYS.LAUNCH_STARTUP, enabled.toString())
6061
})
62+
63+
const disposeUpdateStatus = window.electronAPI.onUpdateStatus((payload) => {
64+
if (payload.status === 'checking') {
65+
setUpdateChecking(true)
66+
} else {
67+
setUpdateChecking(false)
68+
if (payload.status === 'up-to-date') {
69+
useAppStore.getState().addToast({ message: '✨ PaperCache is up to date.', type: 'info' })
70+
} else if (payload.status === 'error') {
71+
useAppStore.getState().addToast({
72+
message: `Update failed: ${payload.error || 'Unknown error'}`,
73+
type: 'error',
74+
})
75+
} else if (payload.status === 'available') {
76+
useAppStore
77+
.getState()
78+
.addToast({ message: `Downloading update v${payload.version || ''}…`, type: 'info' })
79+
}
80+
}
81+
})
82+
return () => disposeUpdateStatus()
6183
}, [])
6284

6385
const [appVersion, setAppVersion] = useState('0.5.6')
@@ -448,18 +470,23 @@ export default function Settings({ onClose }: { onClose?: () => void }) {
448470
style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}
449471
>
450472
<button
451-
onClick={() => window.electronAPI.checkForUpdates()}
473+
onClick={() => {
474+
setUpdateChecking(true)
475+
window.electronAPI.checkForUpdates()
476+
}}
477+
disabled={updateChecking}
452478
style={{
453479
padding: '6px 14px',
454-
background: 'rgba(128,128,128,0.1)',
480+
background: updateChecking ? 'rgba(128,128,128,0.2)' : 'rgba(128,128,128,0.1)',
455481
border: '1px solid rgba(128,128,128,0.2)',
456482
borderRadius: '6px',
457-
cursor: 'pointer',
483+
cursor: updateChecking ? 'wait' : 'pointer',
458484
color: 'inherit',
459485
fontFamily: 'inherit',
486+
opacity: updateChecking ? 0.7 : 1,
460487
}}
461488
>
462-
Check for Updates
489+
{updateChecking ? 'Checking…' : 'Check for Updates'}
463490
</button>
464491
<button
465492
onClick={() => window.electronAPI.openExternal('https://ko-fi.com/thevariable')}

src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,6 @@ export const tauriApi: ElectronAPI = {
6464
pauseShortcuts: () => invoke('pause_shortcuts'),
6565
resumeShortcuts: () => invoke('resume_shortcuts'),
6666
onUpdateReady: (callback) => onEvent('update-ready', callback),
67+
restartApp: () => invoke('restart_app'),
68+
onUpdateStatus: (callback) => onEvent('update-status', callback),
6769
}

src/setupTests.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ if (typeof window !== 'undefined') {
8383
restoreWindowState: vi.fn().mockResolvedValue(undefined),
8484
getLaunchAtStartup: vi.fn().mockResolvedValue(false),
8585
removeOnboardingFiles: vi.fn().mockResolvedValue(undefined),
86+
restartApp: vi.fn().mockResolvedValue(undefined),
87+
onUpdateStatus: vi.fn().mockReturnValue(() => {}),
8688
} as ElectronAPI
8789
}
8890

0 commit comments

Comments
 (0)