From 88121c3840303c079100adda4bda73a10bd470d3 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Sat, 11 Jul 2026 11:08:36 +0200 Subject: [PATCH 1/6] feat: hard-gate anonymous uploads after the free trial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trial that never blocks converts no one, so once the 5 free anonymous uploads are used up we now STOP uploading and prompt sign-in (open the Account window) instead of nudging-and-continuing. - upload_and_notify returns Ok(None) when gated so callers don't show a false "Upload failed"; Ok(Some(url)) on a real upload. - Watcher keeps the clipboard image in place when gated (retry after sign-in); the per-image dedup avoids notification spam. - Signed-in users are unaffected (keyed uploads, no trial). Still client-side bypassable by design — it presents the wall, the server rate limiter is the real ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/capture.rs | 3 ++- src-tauri/src/lib.rs | 30 +++++++++++++++++++++--------- src-tauri/src/watcher.rs | 5 ++++- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index da473a4..3eee37a 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -50,11 +50,12 @@ pub fn trigger(app: AppHandle) { let _ = std::fs::remove_file(&path); match crate::upload_and_notify(&app, bytes) { - Ok(url) => { + Ok(Some(url)) => { if let Ok(mut clipboard) = arboard::Clipboard::new() { let _ = clipboard.set_text(url); } } + Ok(None) => {} // gated (free trial used up) — sign-in was prompted Err(e) => crate::notify(&app, "Upload failed", &e), } }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5618757..2bb1641 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,15 +57,16 @@ pub fn notify(app: &AppHandle, title: &str, body: &str) { const EPHEMERAL_SECS: u64 = 30 * 24 * 60 * 60; /// Shared Mode A/B pipeline: upload PNG bytes → record + refresh tray → notify. -/// Returns the hosted URL. Does NOT touch the clipboard — the caller places it. -pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec) -> Result { +/// Returns `Some(url)` on upload (caller places it on the clipboard), or `None` +/// when the free trial is exhausted and the upload was gated (sign-in prompted). +pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec) -> Result, String> { set_busy(app, true); let result = run_upload(app, png_bytes); set_busy(app, false); result } -fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result { +fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result, String> { match auth::stored_key() { // Signed in → keyed, ephemeral (30d) upload; not part of the free trial. Some(key) => { @@ -73,17 +74,20 @@ fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result { app.state::().trial.push_recent(&url); refresh_recent(app); notify(app, "Image URL copied", &url); - Ok(url) + Ok(Some(url)) } - // Signed out → anonymous trial upload. Crossing the free limit only - // nudges (the real ceiling is the server-side anonymous rate limiter). + // Signed out → anonymous trial. Once the free limit is hit, HARD-gate: + // stop uploading and prompt sign-in (a trial that never blocks converts + // no one). Bypassable by design (client-side), but it presents the wall. None => { if app.state::().trial.remaining() == 0 { notify( app, - "Free uploads used up", - "You've used your 5 free uploads. Sign in (Settings) to keep going — still uploading for now.", + "Sign in to keep uploading", + "You've used your 5 free uploads. Sign in (Account & Settings) for unlimited uploads + history.", ); + open_settings(app); + return Ok(None); } let url = upload::upload_png(png_bytes, None, None)?; app.state::().trial.record_upload(&url); @@ -95,11 +99,19 @@ fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result { "Image URL copied", &format!("{url}\n{remaining} of {} free uploads left", state::FREE_UPLOAD_LIMIT), ); - Ok(url) + Ok(Some(url)) } } } +/// Show + focus the settings/account window. +fn open_settings(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } +} + /// Flash the tray title while an upload is in flight (an always-visible "busy" /// cue in the menu bar). Menu/tray mutations run on the main thread (AppKit). fn set_busy(app: &AppHandle, busy: bool) { diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index 26c853e..c358f66 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -62,12 +62,15 @@ pub fn spawn(app: AppHandle) { }; match crate::upload_and_notify(&app, png) { - Ok(url) => { + Ok(Some(url)) => { // Swap the URL onto the clipboard (replaces the image). if let Err(e) = clipboard.set_text(url) { eprintln!("[pixelvault] failed to set clipboard text: {e}"); } } + // Gated (free trial used up) — sign-in was prompted; leave the + // clipboard image in place so the user can retry after signing in. + Ok(None) => {} Err(e) => { eprintln!("[pixelvault] upload error: {e}"); crate::notify(&app, "Upload failed", &e); From a429038946f2dc9fbd7b70c4adbc1214dd9f9e77 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Sat, 11 Jul 2026 11:15:37 +0200 Subject: [PATCH 2/6] style: brand the settings window with the PixelVault dark + gold palette Replaces the generic light/blue Tauri styling with PixelVault's design tokens (mirrors apps/web global.css): dark bg (#08090c), gold accent (#e4a44c), branded card/inputs/buttons. Window is now dark-only to match pixelvault.dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/styles.css | 134 ++++++++++++++++++++++++++----------------------- 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/src/styles.css b/src/styles.css index bd2be0b..c720496 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,41 +1,68 @@ +/* PixelVault brand palette (mirrors apps/web design tokens). Dark, gold accent. */ :root { + --bg-deep: #08090c; + --bg-surface: #0f1117; + --bg-elevated: #161820; + --bg-card: #1a1c26; + --border: #252730; + --border-hover: #3a3d4a; + --text-primary: #e8e9ed; + --text-secondary: #8e919e; + --text-muted: #5c5f6e; + --accent: #e4a44c; + --accent-hover: #f0b866; + --btn-primary-text: #0a0a0a; + --red: #f87171; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 15px; line-height: 1.5; - color: #111; - background-color: #f6f6f6; + color: var(--text-primary); + background-color: var(--bg-deep); -webkit-font-smoothing: antialiased; + color-scheme: dark; +} + +html, +body { + margin: 0; + background-color: var(--bg-deep); } .wrap { margin: 0 auto; - max-width: 360px; - padding: 32px 24px; + max-width: 380px; + padding: 28px 24px; text-align: center; } h1 { margin: 0 0 4px; font-size: 22px; + color: var(--text-primary); } .tag { margin: 0 0 20px; font-weight: 600; - color: #396cd8; + color: var(--accent); } .status { margin: 0 0 16px; text-align: left; + color: var(--text-secondary); +} +.status strong { + color: var(--text-primary); } .modes { margin: 0 0 16px; padding-left: 20px; text-align: left; + color: var(--text-secondary); } - .modes li { margin-bottom: 6px; } @@ -43,52 +70,47 @@ h1 { kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85em; - background: #fff; - border: 1px solid #d0d0d0; + color: var(--text-primary); + background: var(--bg-elevated); + border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08); -} - -.muted { - margin: 0 0 20px; - font-size: 13px; - color: #666; - text-align: left; } .link { font-weight: 500; - color: #396cd8; + color: var(--accent); text-decoration: none; } .link:hover { + color: var(--accent-hover); text-decoration: underline; } .foss { margin: 24px 0 0; font-size: 12px; - color: #888; + color: var(--text-muted); } /* Account / sign-in card */ .account { margin: 0 0 20px; padding: 16px; - background: #fff; - border: 1px solid #e5e7eb; - border-radius: 10px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; text-align: left; } .acct-title { margin: 0 0 4px; font-weight: 600; + color: var(--text-primary); } .acct-sub { margin: 0 0 12px; font-size: 12px; - color: #666; + color: var(--text-secondary); } .acct-row { display: flex; @@ -98,67 +120,53 @@ kbd { .acct-row input { flex: 1; min-width: 0; - padding: 8px 10px; - border: 1px solid #d0d0d0; + padding: 9px 11px; + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border); border-radius: 8px; font: inherit; } +.acct-row input::placeholder { + color: var(--text-muted); +} +.acct-row input:focus { + outline: none; + border-color: var(--accent); +} .acct-row button, button.secondary { - padding: 8px 14px; + padding: 9px 15px; border: 1px solid transparent; border-radius: 8px; - background: #396cd8; - color: #fff; + background: var(--accent); + color: var(--btn-primary-text); font: inherit; font-weight: 600; cursor: pointer; + transition: background 0.15s; +} +.acct-row button:hover { + background: var(--accent-hover); } .acct-row button:disabled { - opacity: 0.6; + opacity: 0.55; cursor: default; } button.secondary { - background: #eee; - color: #333; + background: var(--bg-elevated); + color: var(--text-primary); + border-color: var(--border); +} +button.secondary:hover { + border-color: var(--border-hover); } .acct-msg { margin: 8px 0 0; font-size: 12px; - color: #666; + color: var(--text-secondary); min-height: 1em; } .acct-msg.error { - color: #c0392b; -} - -@media (prefers-color-scheme: dark) { - :root { - color: #f6f6f6; - background-color: #2f2f2f; - } - kbd { - background: #1c1c1c; - border-color: #444; - } - .muted { - color: #aaa; - } - .account { - background: #1c1c1c; - border-color: #444; - } - .acct-sub, - .acct-msg { - color: #aaa; - } - .acct-row input { - background: #111; - border-color: #444; - color: #f6f6f6; - } - button.secondary { - background: #333; - color: #eee; - } + color: var(--red); } From bd06e61c2809dba74b5dcae94a151bc4d8e3d672 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Sat, 11 Jul 2026 11:36:41 +0200 Subject: [PATCH 3/6] fix(ui): fit the settings window without scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump window height 540→640 and tighten vertical spacing so the account card + how-to + footer fit without a scrollbar. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/tauri.conf.json | 2 +- src/styles.css | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5cc9213..c4dd2ad 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "label": "main", "title": "PixelVault", "width": 440, - "height": 540, + "height": 640, "resizable": false, "visible": false } diff --git a/src/styles.css b/src/styles.css index c720496..177c370 100644 --- a/src/styles.css +++ b/src/styles.css @@ -32,7 +32,7 @@ body { .wrap { margin: 0 auto; max-width: 380px; - padding: 28px 24px; + padding: 20px 24px; text-align: center; } @@ -43,13 +43,13 @@ h1 { } .tag { - margin: 0 0 20px; + margin: 0 0 16px; font-weight: 600; color: var(--accent); } .status { - margin: 0 0 16px; + margin: 0 0 12px; text-align: left; color: var(--text-secondary); } @@ -58,7 +58,7 @@ h1 { } .modes { - margin: 0 0 16px; + margin: 0 0 12px; padding-left: 20px; text-align: left; color: var(--text-secondary); @@ -88,14 +88,14 @@ kbd { } .foss { - margin: 24px 0 0; + margin: 16px 0 0; font-size: 12px; color: var(--text-muted); } /* Account / sign-in card */ .account { - margin: 0 0 20px; + margin: 0 0 16px; padding: 16px; background: var(--bg-card); border: 1px solid var(--border); From bb7a1ccbf70bf484452889a490d6632be385cbdc Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Sat, 11 Jul 2026 11:38:33 +0200 Subject: [PATCH 4/6] feat(ui): add the PixelVault mark to the settings window header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline the transparent gold logo SVG above the title; bump height 640→700 and trim top padding so it still fits without scrolling. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html | 29 +++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/styles.css | 13 ++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index a464349..e18a5c8 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,35 @@
+

PixelVault

Copy an image — paste a hosted URL.

diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c4dd2ad..010d43f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "label": "main", "title": "PixelVault", "width": 440, - "height": 640, + "height": 700, "resizable": false, "visible": false } diff --git a/src/styles.css b/src/styles.css index 177c370..ad7b8dd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -32,10 +32,21 @@ body { .wrap { margin: 0 auto; max-width: 380px; - padding: 20px 24px; + padding: 18px 24px; text-align: center; } +.logo { + margin: 0 auto 8px; + width: 52px; + height: 52px; +} +.logo svg { + display: block; + width: 100%; + height: 100%; +} + h1 { margin: 0 0 4px; font-size: 22px; From d56da97228bf44bf8d75a42160de0d5cd8dcbdd2 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Sat, 11 Jul 2026 11:48:20 +0200 Subject: [PATCH 5/6] ui: drop redundant pixelvault.dev link; tighten window height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Free & open source · by PixelVault' line already links out, so the separate pixelvault.dev link was redundant. Height 700→650 to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html | 3 --- src-tauri/tauri.conf.json | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/index.html b/index.html index e18a5c8..9ed1987 100644 --- a/index.html +++ b/index.html @@ -57,9 +57,6 @@

PixelVault

⌘V to paste it into your coding agent.

- - pixelvault.dev -

Free & open source · by PixelVault diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 010d43f..6adfc07 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "label": "main", "title": "PixelVault", "width": 440, - "height": 700, + "height": 650, "resizable": false, "visible": false } From 19b879599e8555f94cbf8eb800036cf97cb7de76 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Sat, 11 Jul 2026 12:06:32 +0200 Subject: [PATCH 6/6] fix: address review-council findings (auth robustness, gate, thread-safety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-model review council (Claude + Codex) on the v0.2.0 diff. Fixes the 5 important findings: 1. Auth fail-open/split → unified cached Session (loaded once at startup, fail-CLOSED on real keychain errors vs. 'not found'); uploads read the cache, never the keychain. sign_out() now surfaces delete errors. A 401/403 on a keyed upload clears the session + prompts re-sign-in (UploadError::Unauthorized) instead of spamming raw error bodies. 2. Stale settings window → re-render on window focus (onFocusChanged), not just DOMContentLoaded. 3. Background-thread window mutation → open_settings() now runs show()/set_focus() via run_on_main_thread. 4. Non-atomic trial admission → try_reserve()/release()/commit_reserved() with a compare-exchange loop so concurrent watcher+capture uploads can't exceed 5. 5. Gated image never retried after sign-in → watcher tracks a separate gated_hash and lets the exact gated image through once signed in (no re-copy needed). cargo check + tsc/vite build green, no warnings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/auth.rs | 69 +++++++++++++----- src-tauri/src/lib.rs | 149 ++++++++++++++++++++++++++++++--------- src-tauri/src/state.rs | 47 ++++++++++-- src-tauri/src/upload.rs | 41 +++++++++-- src-tauri/src/watcher.rs | 24 +++++-- src/main.ts | 10 +++ 6 files changed, 275 insertions(+), 65 deletions(-) diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index adb17ef..99bd0bf 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -2,6 +2,10 @@ //! //! Flow: `device_start(email)` emails a 6-digit code; `device_complete(email, //! code)` exchanges it for an app-scoped API key, stored in the OS keychain. +//! +//! Keychain access **fails closed**: a real access error propagates as `Err` +//! rather than being flattened to "signed out", so a transient keychain failure +//! can't silently downgrade a signed-in user to anonymous uploads. use std::time::Duration; @@ -11,18 +15,49 @@ use crate::config::api_base; const KEYRING_SERVICE: &str = "dev.pixelvault.desktop"; +/// A signed-in session. Both fields are always present together. +#[derive(Clone)] +pub struct Session { + pub email: String, + pub api_key: String, +} + fn entry(user: &str) -> Result { keyring::Entry::new(KEYRING_SERVICE, user).map_err(|e| e.to_string()) } -/// The stored API key, if signed in. -pub fn stored_key() -> Option { - entry("api_key").ok().and_then(|e| e.get_password().ok()) +/// Read one keychain item, distinguishing "absent" (`Ok(None)`) from a real +/// access error (`Err`). +fn read(user: &str) -> Result, String> { + match entry(user)?.get_password() { + Ok(v) => Ok(Some(v)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.to_string()), + } } -/// The signed-in email, if any. -pub fn stored_email() -> Option { - entry("email").ok().and_then(|e| e.get_password().ok()) +/// Delete one keychain item. "Not found" counts as success. +fn delete(user: &str) -> Result<(), String> { + match entry(user)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +/// Load the persisted session. Requires BOTH the key and the email; a real +/// access error propagates (fail closed). A half-written state (only one +/// present) is cleared and treated as signed out. +pub fn load_session() -> Result, String> { + let api_key = read("api_key")?; + let email = read("email")?; + match (api_key, email) { + (Some(api_key), Some(email)) => Ok(Some(Session { email, api_key })), + (None, None) => Ok(None), + _ => { + let _ = sign_out(); + Ok(None) + } + } } fn store(api_key: &str, email: &str) -> Result<(), String> { @@ -35,14 +70,12 @@ fn store(api_key: &str, email: &str) -> Result<(), String> { Ok(()) } +/// Delete the stored session. Surfaces any real delete error so we never claim +/// a sign-out that actually left the API key behind. Attempts both regardless. pub fn sign_out() -> Result<(), String> { - if let Ok(e) = entry("api_key") { - let _ = e.delete_credential(); - } - if let Ok(e) = entry("email") { - let _ = e.delete_credential(); - } - Ok(()) + let a = delete("api_key"); + let b = delete("email"); + a.and(b) } fn client() -> Result { @@ -93,8 +126,9 @@ struct CompleteData { email: String, } -/// Exchange `code` for an API key and store it. Returns the signed-in email. -pub fn device_complete(email: &str, code: &str) -> Result { +/// Exchange `code` for an API key, store it in the keychain, and return the +/// resulting session so the caller can cache it in memory. +pub fn device_complete(email: &str, code: &str) -> Result { let resp = client()? .post(format!("{}/v1/auth/device/complete", api_base())) .json(&serde_json::json!({ "email": email, "code": code })) @@ -107,5 +141,8 @@ pub fn device_complete(email: &str, code: &str) -> Result { } let env: CompleteEnvelope = resp.json().map_err(|e| format!("bad response: {e}"))?; store(&env.data.api_key, &env.data.email)?; - Ok(env.data.email) + Ok(Session { + email: env.data.email, + api_key: env.data.api_key, + }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2bb1641..8be4f29 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,10 +38,47 @@ pub struct AppState { pub account_item: Mutex>>, /// The tray icon, so we can flash a busy title while uploading. pub tray_icon: Mutex>>, + /// Cached signed-in session. Loaded once from the keychain at startup and + /// updated on sign-in/out, so uploads never hit the keychain (which could + /// transiently fail and silently downgrade a signed-in user to anonymous). + pub session: Mutex>, /// Whether the passive clipboard watcher is active. pub watching: AtomicBool, } +/// The cached signed-in API key, if any. +fn current_key(app: &AppHandle) -> Option { + app.state::() + .session + .lock() + .ok() + .and_then(|g| g.as_ref().map(|s| s.api_key.clone())) +} + +/// Whether a session is currently cached. +pub fn is_signed_in(app: &AppHandle) -> bool { + app.state::() + .session + .lock() + .map(|g| g.is_some()) + .unwrap_or(false) +} + +/// Clear the session (revoked/expired key) and prompt re-sign-in. +fn handle_unauthorized(app: &AppHandle) { + let _ = auth::sign_out(); + if let Ok(mut g) = app.state::().session.lock() { + *g = None; + } + refresh_account(app); + notify( + app, + "Session expired", + "Please sign in again (Account & Settings).", + ); + open_settings(app); +} + /// Show a native notification. Safe to call from any thread. pub fn notify(app: &AppHandle, title: &str, body: &str) { use tauri_plugin_notification::NotificationExt; @@ -67,20 +104,27 @@ pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec) -> Result) -> Result, String> { - match auth::stored_key() { + match current_key(app) { // Signed in → keyed, ephemeral (30d) upload; not part of the free trial. - Some(key) => { - let url = upload::upload_png(png_bytes, Some(&key), Some(EPHEMERAL_SECS))?; - app.state::().trial.push_recent(&url); - refresh_recent(app); - notify(app, "Image URL copied", &url); - Ok(Some(url)) - } - // Signed out → anonymous trial. Once the free limit is hit, HARD-gate: - // stop uploading and prompt sign-in (a trial that never blocks converts - // no one). Bypassable by design (client-side), but it presents the wall. + Some(key) => match upload::upload_png(png_bytes, Some(&key), Some(EPHEMERAL_SECS)) { + Ok(url) => { + app.state::().trial.push_recent(&url); + refresh_recent(app); + notify(app, "Image URL copied", &url); + Ok(Some(url)) + } + // Revoked/expired key — clear the session and prompt re-sign-in. + Err(upload::UploadError::Unauthorized) => { + handle_unauthorized(app); + Ok(None) + } + Err(e) => Err(e.message()), + }, + // Signed out → anonymous trial. Reserve a slot atomically; if the free + // limit is reached, HARD-gate: stop and prompt sign-in (a trial that + // never blocks converts no one). Bypassable by design (client-side). None => { - if app.state::().trial.remaining() == 0 { + if !app.state::().trial.try_reserve() { notify( app, "Sign in to keep uploading", @@ -89,27 +133,39 @@ fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result, Str open_settings(app); return Ok(None); } - let url = upload::upload_png(png_bytes, None, None)?; - app.state::().trial.record_upload(&url); - refresh_counter(app); - refresh_recent(app); - let remaining = app.state::().trial.remaining(); - notify( - app, - "Image URL copied", - &format!("{url}\n{remaining} of {} free uploads left", state::FREE_UPLOAD_LIMIT), - ); - Ok(Some(url)) + match upload::upload_png(png_bytes, None, None) { + Ok(url) => { + app.state::().trial.commit_reserved(&url); + refresh_counter(app); + refresh_recent(app); + let remaining = app.state::().trial.remaining(); + notify( + app, + "Image URL copied", + &format!("{url}\n{remaining} of {} free uploads left", state::FREE_UPLOAD_LIMIT), + ); + Ok(Some(url)) + } + Err(e) => { + // Upload failed — release the reserved slot so it isn't burned. + app.state::().trial.release(); + Err(e.message()) + } + } } } } -/// Show + focus the settings/account window. +/// Show + focus the settings/account window. Window ops run on the main thread +/// (this is called from the background watcher/capture threads on the gate path). fn open_settings(app: &AppHandle) { - if let Some(w) = app.get_webview_window("main") { - let _ = w.show(); - let _ = w.set_focus(); - } + let app = app.clone(); + let _ = app.clone().run_on_main_thread(move || { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + }); } /// Flash the tray title while an upload is in flight (an always-visible "busy" @@ -142,8 +198,14 @@ pub fn refresh_counter(app: &AppHandle) { } } -/// Refresh the tray account status item from the keychain. +/// Refresh the tray account status item from the cached session. pub fn refresh_account(app: &AppHandle) { + let email = app + .state::() + .session + .lock() + .ok() + .and_then(|g| g.as_ref().map(|s| s.email.clone())); let item = app .state::() .account_item @@ -151,7 +213,7 @@ pub fn refresh_account(app: &AppHandle) { .ok() .and_then(|g| g.clone()); if let Some(item) = item { - let text = match auth::stored_email() { + let text = match email { Some(email) => format!("Signed in as {email}"), None => "Not signed in".to_string(), }; @@ -217,9 +279,13 @@ fn sign_in_start(email: String) -> Result<(), String> { #[tauri::command] fn sign_in_complete(app: AppHandle, email: String, code: String) -> Result { - let signed_in = auth::device_complete(email.trim(), code.trim())?; + let session = auth::device_complete(email.trim(), code.trim())?; + let email = session.email.clone(); + if let Ok(mut g) = app.state::().session.lock() { + *g = Some(session); + } refresh_account(&app); - Ok(signed_in) + Ok(email) } #[derive(serde::Serialize)] @@ -231,7 +297,12 @@ struct AuthStatus { #[tauri::command] fn auth_status(app: AppHandle) -> AuthStatus { - let email = auth::stored_email(); + let email = app + .state::() + .session + .lock() + .ok() + .and_then(|g| g.as_ref().map(|s| s.email.clone())); AuthStatus { signed_in: email.is_some(), email, @@ -242,6 +313,9 @@ fn auth_status(app: AppHandle) -> AuthStatus { #[tauri::command] fn sign_out(app: AppHandle) -> Result<(), String> { auth::sign_out()?; + if let Ok(mut g) = app.state::().session.lock() { + *g = None; + } refresh_account(&app); Ok(()) } @@ -268,6 +342,14 @@ pub fn run() { .app_config_dir() .unwrap_or_else(|_| std::path::PathBuf::from(".")); + // Load the session once at startup. A keychain access error → treat + // as signed out (log it) rather than crashing; uploads then use the + // anonymous trial instead of a broken key. + let session = auth::load_session().unwrap_or_else(|e| { + eprintln!("[pixelvault] keychain read failed at startup: {e}"); + None + }); + app.manage(AppState { trial: state::TrialState::load(config_dir), counter_item: Mutex::new(None), @@ -275,6 +357,7 @@ pub fn run() { recent_items: Mutex::new(Vec::new()), account_item: Mutex::new(None), tray_icon: Mutex::new(None), + session: Mutex::new(session), watching: AtomicBool::new(true), }); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 891f0ac..7e66790 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -57,12 +57,49 @@ impl TrialState { self.recent.lock().map(|r| r.clone()).unwrap_or_default() } - /// Record an anonymous upload: bump the trial counter and push the URL to - /// the recent list. Persists both. - pub fn record_upload(&self, url: &str) { - let uploads = self.uploads.fetch_add(1, Ordering::Relaxed) + 1; + /// Atomically reserve one anonymous-trial slot. Returns `true` if a slot was + /// available (counter incremented), `false` if the free limit is reached. + /// A compare-and-swap loop makes admission atomic across the watcher and + /// hotkey-capture threads, so two concurrent uploads can't both slip past + /// the last free slot. + pub fn try_reserve(&self) -> bool { + let mut current = self.uploads.load(Ordering::Relaxed); + loop { + if current >= FREE_UPLOAD_LIMIT { + return false; + } + match self.uploads.compare_exchange_weak( + current, + current + 1, + Ordering::SeqCst, + Ordering::Relaxed, + ) { + Ok(_) => return true, + Err(actual) => current = actual, + } + } + } + + /// Release a reserved slot (upload failed after `try_reserve`). + pub fn release(&self) { + loop { + let current = self.uploads.load(Ordering::Relaxed); + let next = current.saturating_sub(1); + if self + .uploads + .compare_exchange_weak(current, next, Ordering::SeqCst, Ordering::Relaxed) + .is_ok() + { + return; + } + } + } + + /// Commit a reserved anonymous upload: push the URL to recent and persist. + /// (The counter was already incremented by `try_reserve`.) + pub fn commit_reserved(&self, url: &str) { let recent = self.push_recent_inner(url); - self.persist(uploads, recent); + self.persist(self.uploads(), recent); } /// Push a URL to the recent list WITHOUT touching the trial counter — used diff --git a/src-tauri/src/upload.rs b/src-tauri/src/upload.rs index 74a71da..cd0998c 100644 --- a/src-tauri/src/upload.rs +++ b/src-tauri/src/upload.rs @@ -11,6 +11,23 @@ use serde::Deserialize; use crate::config::api_base; +/// Upload failure. `Unauthorized` (401/403) is separated so the caller can clear +/// a revoked/expired key and prompt re-sign-in. Messages are user-safe (never +/// the raw response body). +pub enum UploadError { + Unauthorized, + Failed(String), +} + +impl UploadError { + pub fn message(&self) -> String { + match self { + UploadError::Unauthorized => "Your session has expired.".to_string(), + UploadError::Failed(m) => m.clone(), + } + } +} + #[derive(Deserialize)] struct UploadEnvelope { data: UploadData, @@ -41,16 +58,16 @@ pub fn upload_png( png: Vec, api_key: Option<&str>, expires_in: Option, -) -> Result { +) -> Result { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(30)) .build() - .map_err(|e| e.to_string())?; + .map_err(|e| UploadError::Failed(e.to_string()))?; let part = reqwest::blocking::multipart::Part::bytes(png) .file_name("clipboard.png") .mime_str("image/png") - .map_err(|e| e.to_string())?; + .map_err(|e| UploadError::Failed(e.to_string()))?; let mut form = reqwest::blocking::multipart::Form::new().part("file", part); if api_key.is_some() { if let Some(secs) = expires_in { @@ -63,13 +80,23 @@ pub fn upload_png( req = req.bearer_auth(key); } - let resp = req.send().map_err(|e| e.to_string())?; + let resp = req.send().map_err(|e| UploadError::Failed(e.to_string()))?; let status = resp.status(); if !status.is_success() { - let body = resp.text().unwrap_or_default(); - return Err(format!("upload failed ({status}): {body}")); + if status.as_u16() == 401 || status.as_u16() == 403 { + return Err(UploadError::Unauthorized); + } + // Friendly, body-free messages for the common cases. + let msg = match status.as_u16() { + 413 => "Image is too large.".to_string(), + 429 => "Rate limit reached — try again shortly.".to_string(), + s => format!("Upload failed ({s})."), + }; + return Err(UploadError::Failed(msg)); } - let env: UploadEnvelope = resp.json().map_err(|e| format!("bad response: {e}"))?; + let env: UploadEnvelope = resp + .json() + .map_err(|e| UploadError::Failed(format!("bad response: {e}")))?; Ok(env.data.url) } diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index c358f66..ab37e9f 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -29,6 +29,11 @@ pub fn spawn(app: AppHandle) { // content on every poll. (A deliberate re-copy of the *same* image // within a session won't re-upload — acceptable for v0.) let mut last_hash: Option = None; + // Hash of an image that was gated (free trial used up). We keep it in the + // clipboard and skip re-prompting for it while signed out — but once the + // user signs in, we let it through so the exact image that triggered the + // sign-in gets uploaded without needing a re-copy. + let mut gated_hash: Option = None; loop { std::thread::sleep(POLL_INTERVAL); @@ -47,7 +52,11 @@ pub fn spawn(app: AppHandle) { if Some(hash) == last_hash { continue; } - last_hash = Some(hash); + // Previously gated this exact image and still signed out → don't + // re-prompt every poll. (Once signed in, fall through and upload it.) + if Some(hash) == gated_hash && !crate::is_signed_in(&app) { + continue; + } let width = img.width as u32; let height = img.height as u32; @@ -57,21 +66,28 @@ pub fn spawn(app: AppHandle) { Ok(p) => p, Err(e) => { eprintln!("[pixelvault] encode error: {e}"); + last_hash = Some(hash); continue; } }; match crate::upload_and_notify(&app, png) { Ok(Some(url)) => { + last_hash = Some(hash); + gated_hash = None; // Swap the URL onto the clipboard (replaces the image). if let Err(e) = clipboard.set_text(url) { eprintln!("[pixelvault] failed to set clipboard text: {e}"); } } - // Gated (free trial used up) — sign-in was prompted; leave the - // clipboard image in place so the user can retry after signing in. - Ok(None) => {} + // Gated (free trial used up) — sign-in was prompted. Remember the + // hash (don't advance last_hash) so a subsequent sign-in retries + // this exact image. + Ok(None) => { + gated_hash = Some(hash); + } Err(e) => { + last_hash = Some(hash); eprintln!("[pixelvault] upload error: {e}"); crate::notify(&app, "Upload failed", &e); } diff --git a/src/main.ts b/src/main.ts index 08b2c4a..2f2693d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ // The menubar tray (built in Rust) is the primary UI. This window is a // status/settings surface — its main job is the sign-in flow. import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; interface AuthStatus { signed_in: boolean; @@ -116,4 +117,13 @@ function renderSignedIn(email: string): void { window.addEventListener("DOMContentLoaded", () => { refresh().catch((e) => console.error(e)); + + // The window is hidden/shown from the tray (and the hard gate), not reloaded, + // so DOMContentLoaded fires only once. Re-render whenever it regains focus so + // the counter + auth state are never stale. + getCurrentWindow() + .onFocusChanged(({ payload: focused }) => { + if (focused) refresh().catch((e) => console.error(e)); + }) + .catch((e) => console.error(e)); });