Skip to content

Commit 2a9b9a8

Browse files
authored
Merge branch 'main' into refactor/code-quality-and-tests
2 parents be9f97e + 2228fbc commit 2a9b9a8

12 files changed

Lines changed: 216 additions & 31 deletions

File tree

.github/workflows/release.yml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
new_tag: ${{ steps.get_version.outputs.new_tag }}
1414
new_version: ${{ steps.get_version.outputs.new_version }}
1515
steps:
16-
- uses: actions/checkout@v4
16+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1717
- name: Get version from package.json
1818
id: get_version
1919
run: |
@@ -35,25 +35,29 @@ jobs:
3535
os: [macos-latest, ubuntu-latest, windows-latest]
3636
runs-on: ${{ matrix.os }}
3737
steps:
38-
- uses: actions/checkout@v4
38+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
39+
with:
40+
persist-credentials: false
3941

4042
- name: Install Rust
41-
uses: dtolnay/rust-toolchain@stable
43+
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
44+
with:
45+
toolchain: stable
4246

4347
- name: Install dependencies (Ubuntu only)
4448
if: matrix.os == 'ubuntu-latest'
4549
run: |
4650
sudo apt-get update
4751
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
4852
49-
- uses: actions/setup-node@v4
53+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
5054
with:
5155
node-version: '22'
5256

5357
- run: npm ci
5458

5559
- name: Build and Upload Tauri App
56-
uses: tauri-apps/tauri-action@v0
60+
uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4 # v0
5761
env:
5862
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5963
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -64,13 +68,14 @@ jobs:
6468
releaseBody: 'See the assets to download this version and install.'
6569
releaseDraft: false
6670
prerelease: false
71+
updaterJsonPreferNsis: true
6772

6873
update-homebrew:
6974
needs: [create-tag, build-and-release]
7075
runs-on: macos-latest
7176
steps:
7277
- name: Checkout Homebrew Tap
73-
uses: actions/checkout@v4
78+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
7479
with:
7580
repository: VariableThe/homebrew-tap
7681
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}

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. **VariableScope Unit Tests**: Created `src/lib/editor/VariableScope.test.ts` testing global/note scope merging and debounced regex mathematical expression parsing (`/var x = ...`) using fake timers.
1212

1313
**Files changed:** `src-tauri/src/commands/shortcuts.rs`, `src/store/useTimerStore.ts`, `src/lib/editor/VariableScope.test.ts`, `AUDIT_LOG.md`, `CHANGELOG.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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111
- **Code Quality & Test Reliability**: Refactored global shortcut registration to remove duplicate event handling logic in the backend. Improved countdown timer cleanup reliability by properly tracking and clearing async timeouts when timers complete or are removed. Added comprehensive unit tests for inline DSL variable evaluation (`VariableScope`).
12+
### Added
13+
- **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.
14+
15+
### Fixed
16+
- **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.
1217

1318
## [v0.5.6] - 2026-06-28
1419

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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ mod commands;
77
mod macos;
88
mod tray;
99

10-
#[allow(dead_code)]
10+
#[cfg(not(target_os = "macos"))]
1111
const FOCUS_LOSS_DEBOUNCE_MS: u64 = 200;
12-
#[allow(dead_code)]
1312
const WINDOW_STATE_RESTORE_DELAY_MS: u64 = 300;
1413

1514

@@ -169,6 +168,7 @@ pub fn run() {
169168
commands::system::get_launch_at_startup,
170169
commands::system::set_launch_at_startup,
171170
commands::system::check_for_updates,
171+
commands::system::restart_app,
172172
commands::system::is_hyprland,
173173
commands::keychain::set_api_key,
174174
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')}

0 commit comments

Comments
 (0)