Skip to content

Commit b3292de

Browse files
authored
feat: persist auth session to disk across restarts (#5)
* feat: persist auth session to disk across restarts The GitHub OAuth token was only stored in-memory (AppState.token), so every app restart required re-authenticating. Now the token is saved to/loaded from a file in the app data directory. Changes: - auth.rs: added save_token_to_disk(), load_token_from_disk(), delete_token_from_disk() helpers using app data dir - auth.rs: poll_for_token_cmd and poll_for_token now persist the token after successful OAuth - auth.rs: logout_cmd deletes the persisted token - lib.rs: setup closure loads saved token before checking auth state * fix: use SHA-256 for Stronghold key derivation (32 bytes) DefaultHasher only produced 8 bytes. Stronghold expects a 32-byte key. Switch to sha2::Sha256 for proper key derivation. * fix: restrict token file permissions + validate on startup Addresses review feedback: - P1: Token file now written with mode 0o600 on Unix (owner-only). Uses platform-specific write_token_file helper (std::fs::write fallback on Windows). - P2: On startup, an async task validates the restored token via GET /user. If the token is confirmed invalid (401/403), it is cleared from memory and disk, and the tray falls back to sign-in. Network errors are tolerated (keeps the session for offline use). * fix: only invalidate token on 401/403, not transient errors validate_token now returns None (keep token) for 429/5xx responses instead of Some(false), avoiding false logouts from rate limits or GitHub outages. * fix: guard token clear against race with fresh login Before clearing an invalid token, re-check that it still matches the one we validated. If the user re-authenticated while the async validation was in-flight, keep the new token. * docs: add Codex review comment workflow to AGENTS.md Documents the requirement that all Codex review comments must be resolved before merging, and the poll-fix-resolve loop. * fix: emit auth-cleared event to frontend + poller detects revoked tokens - lib.rs: emits 'auth-cleared' event when startup validation clears an invalid token, so the frontend switches to the login screen - App.svelte: listens for 'auth-cleared' and resets auth state - poller.rs: on fetch error, validates the token and clears session if confirmed invalid (401/403), preventing a stuck auth loop * fix: poller guards token clear with identity check + clears PR caches - Guards token clear with as_deref() == Some(&token) check, same as the startup validator, to avoid wiping a fresh login. - Clears prs and previous_prs caches when invalidating, preventing stale data and incorrect notifications on re-login. * fix: add libayatana-appindicator3 as Linux package dependency - tauri.conf.json: added deb.depends and rpm.depends so the tray library installs automatically via .deb/.rpm packages - AGENTS.md: added Linux Prerequisites section with install commands for Arch/Manjaro, Ubuntu/Debian, and Fedora * fix: enable Wayland clipboard support on Linux arboard's wayland-data-control feature is needed for clipboard access on Wayland compositors (KDE/GNOME/Sway). Falls back to X11 automatically when Wayland is unavailable. * fix: register auth-cleared listener before init + only 401 invalidates token - App.svelte: moved event listener registration before init() to avoid missing auth-cleared from fast startup validation - github.rs: validate_token now only returns Some(false) for 401, not 403 (GitHub uses 403 for rate limits/abuse, not just bad creds) * fix: await listener registration before init in App.svelte listen() is async — fire-and-forget .then() still allowed init() to race ahead. Now both listeners are awaited in a setup() function before init() runs, closing the event-drop window.
1 parent 72acdd6 commit b3292de

8 files changed

Lines changed: 235 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ All commands are registered in `lib.rs` via `invoke_handler`. Frontend calls the
103103
|-------|-----------|---------|
104104
| `prs-updated` | Rust → Frontend | `PullRequest[]` |
105105

106+
### Linux Prerequisites
107+
108+
The system tray requires `libayatana-appindicator3`. Install it before running `make dev` or the release binary:
109+
110+
| Distro | Command |
111+
|--------|---------|
112+
| Arch / Manjaro | `sudo pacman -S libayatana-appindicator` |
113+
| Ubuntu / Debian | `sudo apt install libayatana-appindicator3-dev` |
114+
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
115+
116+
The `.deb` and `.rpm` bundles declare this as a package dependency so it installs automatically when users install through those package formats. Arch/pacman users must install it manually.
117+
106118
## Essential Commands
107119

108120
```bash
@@ -284,3 +296,14 @@ Common prefixes: `feat`, `fix`, `chore`, `refactor`, `docs`.
284296
- Summarise what changed and why.
285297
- List files modified by category (Rust backend / Svelte frontend / config).
286298
- Note any build verification results (`npm run check`, `npm run vite:build`, `cargo check`).
299+
300+
### Codex Review Comments
301+
302+
Every PR with Codex reviews enabled must have **zero unresolved review comments** before merging. Follow this workflow:
303+
304+
1. After pushing, poll for new Codex review comments (`gh api repos/{owner}/{repo}/pulls/{number}/comments`).
305+
2. For each new comment, fix the issue in code, commit, and push.
306+
3. Reply to each comment explaining the fix, then resolve the review thread via the GraphQL `resolveReviewThread` mutation.
307+
4. Repeat: each push may trigger a new Codex review. Keep polling (~2 min intervals) until no new comments appear after the latest push.
308+
5. Only consider the PR ready to merge when all review threads are resolved and no new comments arrive.
309+

src-tauri/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ serde = { version = "1", features = ["derive"] }
1616
serde_json = "1"
1717
tokio = { version = "1", features = ["full"] }
1818
chrono = { version = "0.4", features = ["serde"] }
19-
arboard = "3"
19+
arboard = { version = "3", features = ["wayland-data-control"] }
20+
sha2 = "0.10"

src-tauri/src/auth.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use reqwest::Client;
22
use serde::{Deserialize, Serialize};
3+
use tauri::Manager;
34
use tauri::State;
45

56
use crate::state::AppState;
67

78
const GITHUB_CLIENT_ID: &str = "Ov23liCVKFN3jOo9R7HS";
9+
const TOKEN_FILE: &str = "auth_token";
810

911
#[derive(Debug, Serialize, Deserialize)]
1012
pub struct DeviceCodeResponse {
@@ -39,6 +41,65 @@ fn get_client_id() -> String {
3941
std::env::var("GITHUB_CLIENT_ID").unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string())
4042
}
4143

44+
// --- Token persistence (app data dir) ---
45+
46+
fn save_token_to_disk(app: &tauri::AppHandle, token: &str) {
47+
match app.path().app_data_dir() {
48+
Ok(dir) => {
49+
if let Err(e) = std::fs::create_dir_all(&dir) {
50+
eprintln!("[auth] Failed to create app data dir: {}", e);
51+
return;
52+
}
53+
let path = dir.join(TOKEN_FILE);
54+
// Write with restrictive permissions (owner-only on Unix)
55+
if let Err(e) = write_token_file(&path, token) {
56+
eprintln!("[auth] Failed to save token to disk: {}", e);
57+
} else {
58+
eprintln!("[auth] Token persisted to disk");
59+
}
60+
}
61+
Err(e) => eprintln!("[auth] Failed to resolve app data dir: {}", e),
62+
}
63+
}
64+
65+
#[cfg(unix)]
66+
fn write_token_file(path: &std::path::Path, token: &str) -> std::io::Result<()> {
67+
use std::io::Write;
68+
use std::os::unix::fs::OpenOptionsExt;
69+
let mut file = std::fs::OpenOptions::new()
70+
.write(true)
71+
.create(true)
72+
.truncate(true)
73+
.mode(0o600)
74+
.open(path)?;
75+
file.write_all(token.as_bytes())
76+
}
77+
78+
#[cfg(not(unix))]
79+
fn write_token_file(path: &std::path::Path, token: &str) -> std::io::Result<()> {
80+
std::fs::write(path, token)
81+
}
82+
83+
pub(crate) fn load_token_from_disk(app: &tauri::AppHandle) -> Option<String> {
84+
let dir = app.path().app_data_dir().ok()?;
85+
let path = dir.join(TOKEN_FILE);
86+
match std::fs::read_to_string(&path) {
87+
Ok(token) if !token.is_empty() => {
88+
eprintln!("[auth] Loaded saved token from disk");
89+
Some(token)
90+
}
91+
_ => None,
92+
}
93+
}
94+
95+
pub(crate) fn delete_token_from_disk(app: &tauri::AppHandle) {
96+
if let Ok(dir) = app.path().app_data_dir() {
97+
let path = dir.join(TOKEN_FILE);
98+
let _ = std::fs::remove_file(&path);
99+
eprintln!("[auth] Token deleted from disk");
100+
}
101+
}
102+
42103
#[tauri::command]
43104
pub async fn start_device_flow_cmd() -> Result<DeviceCodeResponse, AuthError> {
44105
let client = Client::new();
@@ -78,6 +139,7 @@ pub async fn start_device_flow_cmd() -> Result<DeviceCodeResponse, AuthError> {
78139
pub async fn poll_for_token_cmd(
79140
device_code: String,
80141
state: State<'_, AppState>,
142+
app: tauri::AppHandle,
81143
) -> Result<bool, AuthError> {
82144
let client = Client::new();
83145
let client_id = get_client_id();
@@ -119,6 +181,7 @@ pub async fn poll_for_token_cmd(
119181
eprintln!("[auth] ✅ Token received ({}...)", &token[..8.min(token.len())]);
120182
let mut stored_token = state.token.lock().unwrap();
121183
*stored_token = Some(token.clone());
184+
save_token_to_disk(&app, token);
122185
Ok(true)
123186
} else if let Some(error) = token_response.error {
124187
eprintln!("[auth] GitHub response: error={}, desc={:?}", error, token_response.error_description);
@@ -138,7 +201,7 @@ pub async fn poll_for_token_cmd(
138201
}
139202

140203
#[tauri::command]
141-
pub async fn logout_cmd(state: State<'_, AppState>) -> Result<(), AuthError> {
204+
pub async fn logout_cmd(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), AuthError> {
142205
let mut token = state.token.lock().unwrap();
143206
*token = None;
144207
let mut user = state.user.lock().unwrap();
@@ -147,6 +210,7 @@ pub async fn logout_cmd(state: State<'_, AppState>) -> Result<(), AuthError> {
147210
prs.clear();
148211
let mut previous_prs = state.previous_prs.lock().unwrap();
149212
previous_prs.clear();
213+
delete_token_from_disk(&app);
150214
Ok(())
151215
}
152216

@@ -192,7 +256,7 @@ pub async fn start_device_flow() -> Result<DeviceCodeResponse, AuthError> {
192256
}
193257

194258
/// Non-command version: takes &AppState directly instead of State<'_, AppState>
195-
pub async fn poll_for_token(device_code: &str, state: &AppState) -> Result<bool, AuthError> {
259+
pub async fn poll_for_token(device_code: &str, state: &AppState, app: &tauri::AppHandle) -> Result<bool, AuthError> {
196260
let client = Client::new();
197261
let client_id = get_client_id();
198262
eprintln!("[auth] Polling for token (device_code={}...)", &device_code[..8.min(device_code.len())]);
@@ -233,6 +297,7 @@ pub async fn poll_for_token(device_code: &str, state: &AppState) -> Result<bool,
233297
eprintln!("[auth] ✅ Token received ({}...)", &token[..8.min(token.len())]);
234298
let mut stored_token = state.token.lock().unwrap();
235299
*stored_token = Some(token.clone());
300+
save_token_to_disk(app, token);
236301
Ok(true)
237302
} else if let Some(error) = token_response.error {
238303
eprintln!("[auth] GitHub response: error={}, desc={:?}", error, token_response.error_description);

src-tauri/src/github.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,32 @@ pub async fn fetch_user_info(token: &str) -> Result<GitHubUser, AuthError> {
186186
})
187187
}
188188

189+
/// Validate a token by making a lightweight API call.
190+
/// Returns true only if the token is confirmed valid (200 OK).
191+
/// Returns false only for a definitive 401 Unauthorized.
192+
/// Returns None for network errors or any other status (403 can
193+
/// mean rate-limiting/abuse, not just bad credentials).
194+
pub async fn validate_token(token: &str) -> Option<bool> {
195+
let client = Client::new();
196+
let response = client
197+
.get("https://api.github.com/user")
198+
.header("Authorization", format!("Bearer {}", token))
199+
.header("User-Agent", "PR-Buddy")
200+
.send()
201+
.await
202+
.ok()?; // Network error → None (don't clear token)
203+
let status = response.status();
204+
if status.is_success() {
205+
Some(true)
206+
} else if status.as_u16() == 401 {
207+
Some(false)
208+
} else {
209+
// 403 (rate limit/abuse), 429, 5xx, etc. — don't invalidate
210+
eprintln!("[auth] Token validation got HTTP {}, treating as transient", status);
211+
None
212+
}
213+
}
214+
189215
#[tauri::command]
190216
pub async fn get_pull_requests_cmd(
191217
state: State<'_, AppState>,

src-tauri/src/lib.rs

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ mod poller;
77
mod state;
88

99
use tauri::tray::TrayIconBuilder;
10-
use tauri::Manager;
10+
use tauri::{Emitter, Manager};
1111

1212
#[cfg_attr(mobile, tauri::mobile_entry_point)]
1313
pub fn run() {
@@ -16,11 +16,9 @@ pub fn run() {
1616
.plugin(tauri_plugin_notification::init())
1717
.plugin(
1818
tauri_plugin_stronghold::Builder::new(|password| {
19-
use std::hash::{DefaultHasher, Hasher};
20-
let mut hasher = DefaultHasher::new();
21-
hasher.write(password.as_bytes());
22-
let hash = hasher.finish();
23-
hash.to_le_bytes().to_vec()
19+
use sha2::{Sha256, Digest};
20+
let hash = Sha256::digest(password.as_bytes());
21+
hash.to_vec()
2422
})
2523
.build(),
2624
)
@@ -35,6 +33,13 @@ pub fn run() {
3533
github::refresh_prs_cmd
3634
])
3735
.setup(|app| {
36+
// Restore saved auth token from disk (if any)
37+
if let Some(saved_token) = auth::load_token_from_disk(app.handle()) {
38+
let state = app.state::<state::AppState>();
39+
*state.token.lock().unwrap() = Some(saved_token);
40+
eprintln!("[setup] Restored auth session from disk");
41+
}
42+
3843
// Build initial menu based on auth state
3944
let state = app.state::<state::AppState>();
4045
let is_authed = state.token.lock().unwrap().is_some();
@@ -81,6 +86,55 @@ pub fn run() {
8186

8287
poller::start_polling(app.handle().clone());
8388

89+
// Validate restored token asynchronously (don't block startup).
90+
// If the token is revoked/invalid, clear it and fall back to sign-in.
91+
if is_authed {
92+
let app_handle = app.handle().clone();
93+
tauri::async_runtime::spawn(async move {
94+
let token = {
95+
let state = app_handle.state::<state::AppState>();
96+
let val = state.token.lock().unwrap().clone();
97+
val
98+
};
99+
if let Some(token) = token {
100+
match github::validate_token(&token).await {
101+
Some(false) => {
102+
// Token is confirmed invalid (401/403) — clear it,
103+
// but only if the token hasn't been replaced by a
104+
// fresh login while we were validating.
105+
let state = app_handle.state::<state::AppState>();
106+
let mut current = state.token.lock().unwrap();
107+
if current.as_deref() == Some(&token) {
108+
eprintln!("[setup] Saved token is invalid, clearing session");
109+
*current = None;
110+
drop(current);
111+
auth::delete_token_from_disk(&app_handle);
112+
{
113+
let tray_guard = state.tray.lock().unwrap();
114+
if let Some(tray) = tray_guard.as_ref() {
115+
if let Ok(m) = menu::build_auth_menu(&app_handle) {
116+
let _ = tray.set_menu(Some(m));
117+
}
118+
}
119+
}
120+
// Notify the frontend so it switches to the login screen
121+
let _ = app_handle.emit("auth-cleared", ());
122+
} else {
123+
eprintln!("[setup] Token changed during validation, keeping new session");
124+
}
125+
}
126+
Some(true) => {
127+
eprintln!("[setup] Saved token validated successfully");
128+
}
129+
None => {
130+
// Network error — keep the token, poller will retry
131+
eprintln!("[setup] Could not validate token (offline?), keeping session");
132+
}
133+
}
134+
}
135+
});
136+
}
137+
84138
Ok(())
85139
})
86140
.run(tauri::generate_context!())
@@ -163,7 +217,7 @@ fn handle_menu_event(app: &tauri::AppHandle, id: &str) {
163217
}
164218

165219
let state = app.state::<state::AppState>();
166-
match auth::poll_for_token(&resp.device_code, &state).await {
220+
match auth::poll_for_token(&resp.device_code, &state, &app).await {
167221
Ok(true) => {
168222
eprintln!("[auth] ✅ Authenticated via menu");
169223
let token = {

src-tauri/src/poller.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use std::time::Duration;
33

44
use tauri::{AppHandle, Emitter, Manager};
55

6+
use crate::auth;
7+
68
use crate::github::fetch_pull_requests;
79
use crate::models::{CheckStatus, PullRequest};
810
use crate::notifications::{diff_pr_states, send_notification};
@@ -79,6 +81,38 @@ pub fn start_polling(app_handle: AppHandle) {
7981
tokio::time::sleep(Duration::from_secs(interval)).await;
8082
}
8183
Err(_) => {
84+
// Check if this is an auth failure (revoked token)
85+
match crate::github::validate_token(&token).await {
86+
Some(false) => {
87+
// Token is confirmed invalid — clear session,
88+
// but only if it hasn't been replaced by a fresh login.
89+
let state = app_handle.state::<AppState>();
90+
let mut current = state.token.lock().unwrap();
91+
if current.as_deref() == Some(token.as_str()) {
92+
eprintln!("[poller] Token is invalid, clearing session");
93+
*current = None;
94+
drop(current);
95+
auth::delete_token_from_disk(&app_handle);
96+
// Clear cached PR state to avoid stale data on re-login
97+
state.prs.lock().unwrap().clear();
98+
state.previous_prs.lock().unwrap().clear();
99+
{
100+
let tray_guard = state.tray.lock().unwrap();
101+
if let Some(tray) = tray_guard.as_ref() {
102+
if let Ok(m) = crate::menu::build_auth_menu(&app_handle) {
103+
let _ = tray.set_menu(Some(m));
104+
}
105+
}
106+
}
107+
let _ = app_handle.emit("auth-cleared", ());
108+
} else {
109+
eprintln!("[poller] Token changed during validation, keeping new session");
110+
}
111+
}
112+
_ => {
113+
// Network/transient error — keep token, retry later
114+
}
115+
}
82116
tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_IDLE)).await;
83117
}
84118
}

src-tauri/tauri.conf.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@
3838
"icons/icon.ico"
3939
],
4040
"linux": {
41+
"deb": {
42+
"depends": ["libayatana-appindicator3-1"]
43+
},
4144
"rpm": {
4245
"epoch": 0,
43-
"release": "1"
46+
"release": "1",
47+
"depends": ["libayatana-appindicator-gtk3"]
4448
}
4549
}
4650
},

0 commit comments

Comments
 (0)