diff --git a/CLAUDE.md b/CLAUDE.md index 9f91141..d8991cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,11 +52,13 @@ PIXELVAULT_API_BASE=https://api-staging.pixelvault.dev npm run tauri dev - `lib.rs` — Tauri builder + `setup`: Accessory activation policy (no Dock icon), loads the cached session, manages `AppState`, builds the tray, spawns the watcher, registers the Mode B hotkey + the sign-in commands (`sign_in_start` / - `sign_in_complete` / `auth_status` / `sign_out`). Also the **shared `run_upload` + `sign_in_complete` / `auth_status` / `sign_out`) and the settings commands + (`get_settings` / `set_settings`). Also the **shared `run_upload` pipeline** (`upload_and_notify`): it branches on the cached session — signed-in - → keyed ephemeral upload; signed-out → anonymous trial with an atomic - reservation + hard gate. Plus `refresh_counter` / `refresh_recent` / - `refresh_account` / `set_busy` (tray updates, all on the main thread). + → keyed ephemeral upload (honouring the **private-uploads** preference → signed + URL); signed-out → anonymous trial with an atomic reservation + hard gate. Plus + `refresh_counter` / `refresh_recent` / `refresh_account` / `set_busy` (tray + updates, all on the main thread). - `watcher.rs` — **Mode A (passive):** background thread polls the clipboard (`arboard`) every 1.2s, hashes to dedupe, runs the shared pipeline, writes the URL back. Tracks a separate `gated_hash` so a gated image is retried once the @@ -67,12 +69,16 @@ PIXELVAULT_API_BASE=https://api-staging.pixelvault.dev npm run tauri dev api_key}` is stored in the OS keychain (`keyring`); `load_session()` **fails closed** (a real keychain error propagates rather than flattening to "signed out"). `config.rs` holds the shared API base. -- `upload.rs` — RGBA→PNG encode + multipart `POST /v1/images` (keyed = Bearer + - `expires_in`; else anonymous). Returns `UploadError::Unauthorized` on 401/403 so - the caller clears the session + re-prompts. Base from `PIXELVAULT_API_BASE`. -- `state.rs` — `TrialState`: free-upload counter (limit 5) + last 5 URLs in - `/state.json`. Anonymous admission is atomic: `try_reserve` - (compare-exchange) → `commit_reserved` / `release`. +- `upload.rs` — RGBA→PNG encode + multipart `POST /v1/images`. Keyed = Bearer + + `KeyedOptions` (`expires_in`; `visibility=private` + `sign_expires_in` when + private — the server returns the ready-to-paste **signed** URL); else anonymous + (always public). Returns `UploadError::Unauthorized` on 401/403 so the caller + clears the session + re-prompts; a `402` maps to a "private image limit reached" + message. Base from `PIXELVAULT_API_BASE`. +- `state.rs` — `TrialState`: free-upload counter (limit 5) + last 5 URLs + the + **private-uploads** preference (`private_uploads` + `sign_expires_secs`, clamped + to 60s..30d, default 7d) in `/state.json`. Anonymous admission + is atomic: `try_reserve` (compare-exchange) → `commit_reserved` / `release`. - `tray.rs` — menu-bar icon (transparent template glyph) + menu: status, account ("Signed in as …" / "Not signed in"), free-uploads counter, "Recent uploads" click-to-copy, pause/resume, Account & Settings…, quit. diff --git a/index.html b/index.html index 9ed1987..7c84d00 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,10 @@

PixelVault

+ +

Two ways to grab an image — both give you a hosted URL to paste:

diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 99bd0bf..a6dc9e4 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -61,6 +61,15 @@ pub fn load_session() -> Result, String> { } fn store(api_key: &str, email: &str) -> Result<(), String> { + // Clear any prior entry first. On macOS `set_password` can fail with + // errSecDuplicateItem ("item already exists") when an item is already + // present — e.g. a partial earlier sign-in, or a keychain ACL that no longer + // matches an unsigned dev rebuild (each rebuild has a different signature). + // Deleting first means we always create a fresh item owned by the current + // binary, avoiding the duplicate error and an extra access prompt. Best + // effort: a delete failure shouldn't block the set that follows. + let _ = delete("api_key"); + let _ = delete("email"); entry("api_key")? .set_password(api_key) .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8be4f29..a1e9554 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -70,6 +70,10 @@ fn handle_unauthorized(app: &AppHandle) { if let Ok(mut g) = app.state::().session.lock() { *g = None; } + // Drop any signed (private) URLs from the tray — the session that could + // mint them is gone. + app.state::().trial.forget_private_recent(); + refresh_recent(app); refresh_account(app); notify( app, @@ -90,6 +94,21 @@ pub fn notify(app: &AppHandle, title: &str, body: &str) { .show(); } +/// Render a duration in whole days/hours for a human-facing message. The signed +/// URL lifetimes we use are all whole days or hours (see the UI picker). +fn human_duration(secs: u64) -> String { + let plural = |n: u64, unit: &str| format!("{n} {unit}{}", if n == 1 { "" } else { "s" }); + if secs % 86_400 == 0 { + plural(secs / 86_400, "day") + } else if secs % 3_600 == 0 { + plural(secs / 3_600, "hour") + } else if secs % 60 == 0 { + plural(secs / 60, "minute") + } else { + plural(secs, "second") + } +} + /// Ephemeral TTL applied to signed-in (keyed) uploads: 30 days. const EPHEMERAL_SECS: u64 = 30 * 24 * 60 * 60; @@ -106,20 +125,44 @@ pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec) -> Result) -> Result, String> { match current_key(app) { // Signed in → keyed, ephemeral (30d) upload; not part of the free trial. - 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) + // Honour the private-uploads preference (signed URLs) from settings. + Some(key) => { + let (private, sign_secs) = { + let trial = &app.state::().trial; + (trial.private_uploads(), trial.sign_expires_secs()) + }; + let opts = upload::KeyedOptions { + expires_in: Some(EPHEMERAL_SECS), + private, + sign_expires_in: private.then_some(sign_secs), + }; + match upload::upload_png(png_bytes, Some(&key), opts) { + Ok(url) => { + app.state::().trial.push_recent(&url, private); + refresh_recent(app); + // The signed URL is a bearer capability; keep it off the + // notification (Notification Center history / lock screen). + // It's already on the clipboard + in the tray for this + // session. + if private { + notify( + app, + "Private link copied", + &format!("Paste to share · link expires in {}", human_duration(sign_secs)), + ); + } else { + 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()), } - 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). @@ -133,7 +176,7 @@ fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result, Str open_settings(app); return Ok(None); } - match upload::upload_png(png_bytes, None, None) { + match upload::upload_png(png_bytes, None, upload::KeyedOptions::default()) { Ok(url) => { app.state::().trial.commit_reserved(&url); refresh_counter(app); @@ -227,7 +270,7 @@ pub fn refresh_account(app: &AppHandle) { /// show the image filename and are clickable (copy the URL); empty slots show /// "—" and are disabled. pub fn refresh_recent(app: &AppHandle) { - let recent = app.state::().trial.recent(); + let recent = app.state::().trial.recent_entries(); let items = app .state::() .recent_items @@ -238,7 +281,7 @@ pub fn refresh_recent(app: &AppHandle) { let updates: Vec<(String, bool)> = (0..items.len()) .map(|i| match recent.get(i) { - Some(url) => (short_label(url), true), + Some((url, private)) => (short_label(url, *private), true), None => ("—".to_string(), false), }) .collect(); @@ -251,9 +294,17 @@ pub fn refresh_recent(app: &AppHandle) { }); } -/// Label a URL by its final path segment, e.g. `anon_l4f8nipug8ic.png`. -fn short_label(url: &str) -> String { - url.rsplit('/').next().unwrap_or(url).to_string() +/// Label a recent upload for the tray by its final path segment, e.g. +/// `anon_l4f8nipug8ic.png`. The query string is dropped so a signed URL's token +/// never appears in the menu; private links get a lock marker. +fn short_label(url: &str, private: bool) -> String { + let last = url.rsplit('/').next().unwrap_or(url); + let name = last.split('?').next().unwrap_or(last); + if private { + format!("🔒 {name}") + } else { + name.to_string() + } } /// Toggle the passive watcher on/off and update the tray label. @@ -316,10 +367,42 @@ fn sign_out(app: AppHandle) -> Result<(), String> { if let Ok(mut g) = app.state::().session.lock() { *g = None; } + // Forget signed (private) URLs so a capability link doesn't linger post-sign-out. + app.state::().trial.forget_private_recent(); + refresh_recent(&app); refresh_account(&app); Ok(()) } +#[derive(serde::Serialize)] +struct Settings { + private_uploads: bool, + sign_expires_secs: u64, +} + +fn read_settings(app: &AppHandle) -> Settings { + let trial = &app.state::().trial; + Settings { + private_uploads: trial.private_uploads(), + sign_expires_secs: trial.sign_expires_secs(), + } +} + +#[tauri::command] +fn get_settings(app: AppHandle) -> Settings { + read_settings(&app) +} + +/// Persist the upload settings. Returns the stored (clamped) values so the UI +/// reflects exactly what was saved. +#[tauri::command] +fn set_settings(app: AppHandle, private_uploads: bool, sign_expires_secs: u64) -> Settings { + app.state::() + .trial + .set_upload_prefs(private_uploads, sign_expires_secs); + read_settings(&app) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -330,7 +413,9 @@ pub fn run() { sign_in_start, sign_in_complete, auth_status, - sign_out + sign_out, + get_settings, + set_settings ]) .setup(|app| { // Menubar-first: no dock icon on macOS. @@ -383,3 +468,32 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::{human_duration, short_label}; + + #[test] + fn short_label_strips_the_signing_token() { + // A signed (private) URL's token must never reach the tray label. + let signed = "https://img.pixelvault.dev/proj/cp/i/img_abc.png?token=SECRET&expires=123"; + assert_eq!(short_label(signed, true), "🔒 img_abc.png"); + assert!(!short_label(signed, true).contains("SECRET")); + } + + #[test] + fn short_label_public_is_plain_filename() { + assert_eq!( + short_label("https://img.pixelvault.dev/proj/anon_xyz.png", false), + "anon_xyz.png" + ); + } + + #[test] + fn human_duration_reads_naturally() { + assert_eq!(human_duration(7 * 24 * 60 * 60), "7 days"); + assert_eq!(human_duration(24 * 60 * 60), "1 day"); + assert_eq!(human_duration(3600), "1 hour"); + assert_eq!(human_duration(60), "1 minute"); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7e66790..638e5b3 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,11 +1,17 @@ -//! Persisted app state: the trial upload counter + the last few upload URLs. +//! Persisted app state: the trial upload counter, the last few upload URLs, and +//! the private-uploads preference. //! -//! In v0 there is no sign-in yet, so crossing the free limit only *nudges* -//! (see `watcher`) — the real gate arrives with device login (slice 3). +//! In v0 there is no sign-in yet for the trial gate, so crossing the free limit +//! only *nudges* (see `watcher`). +//! +//! Private (signed) upload URLs are time-bounded **bearer capabilities**, so +//! they are kept in memory for the tray's click-to-copy but are NEVER written to +//! `state.json` (see `persist`) and are dropped from memory on sign-out. Public +//! URLs are harmless to persist and behave as before. use std::fs; use std::path::PathBuf; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Mutex; use serde::{Deserialize, Serialize}; @@ -16,18 +22,67 @@ pub const FREE_UPLOAD_LIMIT: u32 = 5; /// How many recent upload URLs to remember (and show in the tray). pub const RECENT_LIMIT: usize = 5; -#[derive(Serialize, Deserialize, Default)] +/// Default signed-URL lifetime for private uploads: 7 days. +pub const DEFAULT_SIGN_EXPIRES_SECS: u64 = 7 * 24 * 60 * 60; +/// Bounds the API enforces on `sign_expires_in` (60s .. 30d). We clamp locally +/// so a stale/hand-edited state file can't push an out-of-range value. +pub const MIN_SIGN_EXPIRES_SECS: u64 = 60; +pub const MAX_SIGN_EXPIRES_SECS: u64 = 30 * 24 * 60 * 60; + +fn default_sign_expires_secs() -> u64 { + DEFAULT_SIGN_EXPIRES_SECS +} + +#[derive(Serialize, Deserialize)] struct Persisted { uploads: u32, + /// PUBLIC upload URLs only — private signed URLs are deliberately excluded. #[serde(default)] recent: Vec, + /// Upload signed-in images privately (signed URLs). Off by default. + #[serde(default)] + private_uploads: bool, + /// Signed-URL lifetime, in seconds, for private uploads. + #[serde(default = "default_sign_expires_secs")] + sign_expires_secs: u64, +} + +// A manual `Default` (not derived) so the no-state-file case matches the serde +// field defaults — in particular `sign_expires_secs` must default to 7 days, not +// 0. `#[serde(default = …)]` only governs deserialization, so a derived `Default` +// would silently yield a 0-second (→ clamped 60s) link TTL on a fresh install. +impl Default for Persisted { + fn default() -> Self { + Self { + uploads: 0, + recent: Vec::new(), + private_uploads: false, + sign_expires_secs: DEFAULT_SIGN_EXPIRES_SECS, + } + } +} + +/// An entry in the in-memory recent list. `private` marks a signed (capability) +/// URL that must not be written to disk. +#[derive(Clone)] +struct RecentEntry { + url: String, + private: bool, } pub struct TrialState { path: PathBuf, uploads: AtomicU32, /// Most-recent-first, capped at `RECENT_LIMIT`. - recent: Mutex>, + recent: Mutex>, + /// Whether signed-in uploads are made private (signed URLs). + private_uploads: AtomicBool, + /// Signed-URL lifetime (seconds) for private uploads, clamped to bounds. + sign_expires_secs: AtomicU64, + /// Serializes the whole snapshot-and-write in `persist` so concurrent + /// writers (watcher/hotkey uploads vs. a settings save) can't tear the file + /// or lose a field. + save_lock: Mutex<()>, } impl TrialState { @@ -38,10 +93,22 @@ impl TrialState { .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .unwrap_or_default(); + // Everything on disk is public (private URLs are never persisted). + let recent = persisted + .recent + .into_iter() + .map(|url| RecentEntry { + url, + private: false, + }) + .collect(); Self { path, uploads: AtomicU32::new(persisted.uploads), - recent: Mutex::new(persisted.recent), + recent: Mutex::new(recent), + private_uploads: AtomicBool::new(persisted.private_uploads), + sign_expires_secs: AtomicU64::new(clamp_sign_expires(persisted.sign_expires_secs)), + save_lock: Mutex::new(()), } } @@ -53,8 +120,41 @@ impl TrialState { FREE_UPLOAD_LIMIT.saturating_sub(self.uploads()) } + /// URLs for the tray (most-recent-first), including in-session private ones + /// so click-to-copy still works before they're forgotten on sign-out. pub fn recent(&self) -> Vec { - self.recent.lock().map(|r| r.clone()).unwrap_or_default() + self.recent + .lock() + .map(|r| r.iter().map(|e| e.url.clone()).collect()) + .unwrap_or_default() + } + + /// Recent entries as `(url, is_private)`, for callers that must treat a + /// private (signed, bearer) URL differently — e.g. keeping it out of a + /// notification body or the menu label. + pub fn recent_entries(&self) -> Vec<(String, bool)> { + self.recent + .lock() + .map(|r| r.iter().map(|e| (e.url.clone(), e.private)).collect()) + .unwrap_or_default() + } + + /// Whether signed-in uploads should be made private (signed URLs). + pub fn private_uploads(&self) -> bool { + self.private_uploads.load(Ordering::Relaxed) + } + + /// Signed-URL lifetime (seconds) for private uploads. + pub fn sign_expires_secs(&self) -> u64 { + self.sign_expires_secs.load(Ordering::Relaxed) + } + + /// Update both upload preferences (clamping the lifetime) and persist once. + pub fn set_upload_prefs(&self, private_uploads: bool, sign_expires_secs: u64) { + self.private_uploads.store(private_uploads, Ordering::Relaxed); + self.sign_expires_secs + .store(clamp_sign_expires(sign_expires_secs), Ordering::Relaxed); + self.persist(); } /// Atomically reserve one anonymous-trial slot. Returns `true` if a slot was @@ -95,34 +195,174 @@ impl TrialState { } } - /// Commit a reserved anonymous upload: push the URL to recent and persist. - /// (The counter was already incremented by `try_reserve`.) + /// Commit a reserved anonymous (always-public) upload: record the URL 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(self.uploads(), recent); + self.push_recent_entry(url, false); + self.persist(); + } + + /// Record a keyed (signed-in) upload URL. Private signed URLs are kept in + /// memory for the tray but excluded from `state.json` by `persist`. + pub fn push_recent(&self, url: &str, private: bool) { + self.push_recent_entry(url, private); + self.persist(); } - /// Push a URL to the recent list WITHOUT touching the trial counter — used - /// for keyed (signed-in) uploads, which aren't part of the free trial. - pub fn push_recent(&self, url: &str) { - let recent = self.push_recent_inner(url); - self.persist(self.uploads(), recent); + /// Drop private (signed) URLs from the in-memory recent list — called on + /// sign-out / session loss so a capability URL doesn't linger in the tray. + pub fn forget_private_recent(&self) { + if let Ok(mut r) = self.recent.lock() { + r.retain(|e| !e.private); + } + self.persist(); } - fn push_recent_inner(&self, url: &str) -> Vec { - let mut r = self.recent.lock().unwrap(); - r.retain(|u| u != url); // de-dupe if the same URL recurs - r.insert(0, url.to_string()); - r.truncate(RECENT_LIMIT); - r.clone() + fn push_recent_entry(&self, url: &str, private: bool) { + if let Ok(mut r) = self.recent.lock() { + r.retain(|e| e.url != url); // de-dupe if the same URL recurs + r.insert( + 0, + RecentEntry { + url: url.to_string(), + private, + }, + ); + r.truncate(RECENT_LIMIT); + } } - fn persist(&self, uploads: u32, recent: Vec) { + /// Snapshot all state and write it atomically. `save_lock` serializes the + /// whole read-and-write so concurrent callers can't tear the file or lose a + /// field; private URLs are filtered out so signed capabilities never touch + /// the disk. + fn persist(&self) { + let _guard = self.save_lock.lock().unwrap_or_else(|e| e.into_inner()); + let recent = self + .recent + .lock() + .map(|r| { + r.iter() + .filter(|e| !e.private) + .map(|e| e.url.clone()) + .collect() + }) + .unwrap_or_default(); + let snapshot = Persisted { + uploads: self.uploads(), + recent, + private_uploads: self.private_uploads(), + sign_expires_secs: self.sign_expires_secs(), + }; if let Some(parent) = self.path.parent() { let _ = fs::create_dir_all(parent); } - if let Ok(json) = serde_json::to_string(&Persisted { uploads, recent }) { - let _ = fs::write(&self.path, json); + let Ok(json) = serde_json::to_string(&snapshot) else { + return; + }; + // Write to a temp file then rename, so a concurrent or partial write + // can't leave a truncated state.json (which `load` would silently reset + // to defaults). `rename` is atomic on the same filesystem. + let tmp = self.path.with_extension("json.tmp"); + if fs::write(&tmp, json).is_ok() { + // Owner-only: state.json holds upload history + prefs, so keep it out + // of other local users' reach. Set on the temp file before the + // rename so the final file is never briefly world-readable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)); + } + let _ = fs::rename(&tmp, &self.path); } } } + +/// Clamp a signed-URL lifetime to the range the API accepts, so a stale or +/// hand-edited value can never produce a request the server would reject. +fn clamp_sign_expires(secs: u64) -> u64 { + secs.clamp(MIN_SIGN_EXPIRES_SECS, MAX_SIGN_EXPIRES_SECS) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A unique temp directory per call (no external deps). + fn temp_dir() -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pv-desktop-test-{}-{}", std::process::id(), n)); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn clamp_covers_bounds_inclusive() { + assert_eq!(clamp_sign_expires(0), MIN_SIGN_EXPIRES_SECS); + assert_eq!(clamp_sign_expires(59), MIN_SIGN_EXPIRES_SECS); + assert_eq!(clamp_sign_expires(60), 60); + assert_eq!(clamp_sign_expires(604_800), 604_800); + assert_eq!(clamp_sign_expires(MAX_SIGN_EXPIRES_SECS), MAX_SIGN_EXPIRES_SECS); + assert_eq!(clamp_sign_expires(u64::MAX), MAX_SIGN_EXPIRES_SECS); + } + + #[test] + fn defaults_when_state_absent() { + let s = TrialState::load(temp_dir()); + assert!(!s.private_uploads()); + assert_eq!(s.sign_expires_secs(), DEFAULT_SIGN_EXPIRES_SECS); + assert!(s.recent().is_empty()); + } + + #[test] + fn prefs_round_trip_and_clamp_on_set() { + let dir = temp_dir(); + { + let s = TrialState::load(dir.clone()); + s.set_upload_prefs(true, 5); // below MIN → clamps to 60 + } + let s = TrialState::load(dir); + assert!(s.private_uploads()); + assert_eq!(s.sign_expires_secs(), MIN_SIGN_EXPIRES_SECS); + } + + #[test] + fn private_urls_are_never_persisted_but_public_are() { + let dir = temp_dir(); + { + let s = TrialState::load(dir.clone()); + s.commit_reserved("https://img/pub.png"); // public + s.push_recent("https://img/cp/i/secret.png?sig=abc", true); // private + assert_eq!(s.recent().len(), 2, "both are visible in-memory for the tray"); + } + // Reloaded from disk: the signed (private) URL must be gone. + let s = TrialState::load(dir); + assert_eq!(s.recent(), vec!["https://img/pub.png".to_string()]); + } + + #[test] + fn forget_private_recent_drops_only_private() { + let s = TrialState::load(temp_dir()); + s.push_recent("https://img/pub.png", false); + s.push_recent("https://img/priv.png?sig=x", true); + assert_eq!(s.recent().len(), 2); + s.forget_private_recent(); + assert_eq!(s.recent(), vec!["https://img/pub.png".to_string()]); + } + + #[test] + fn loads_old_state_without_new_fields() { + let dir = temp_dir(); + fs::write( + dir.join("state.json"), + r#"{"uploads":3,"recent":["https://img/a.png"]}"#, + ) + .unwrap(); + let s = TrialState::load(dir); + assert_eq!(s.uploads(), 3); + assert_eq!(s.recent(), vec!["https://img/a.png".to_string()]); + assert!(!s.private_uploads()); + assert_eq!(s.sign_expires_secs(), DEFAULT_SIGN_EXPIRES_SECS); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index e1631c8..e5c9bc0 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -105,11 +105,18 @@ fn handle_menu(app: &AppHandle, id: &str) { } fn copy_recent(app: &AppHandle, idx: usize) { - let recent = app.state::().trial.recent(); - if let Some(url) = recent.get(idx) { + let recent = app.state::().trial.recent_entries(); + if let Some((url, private)) = recent.get(idx) { if let Ok(mut clipboard) = arboard::Clipboard::new() { if clipboard.set_text(url.clone()).is_ok() { - crate::notify(app, "URL copied", url); + if *private { + // The signed URL is a bearer capability — keep it off the + // notification body (Notification Center history / lock + // screen), same as the upload path. It's on the clipboard. + crate::notify(app, "Private link copied", "Paste to share"); + } else { + crate::notify(app, "URL copied", url); + } } } } diff --git a/src-tauri/src/upload.rs b/src-tauri/src/upload.rs index cd0998c..a1879a8 100644 --- a/src-tauri/src/upload.rs +++ b/src-tauri/src/upload.rs @@ -49,15 +49,28 @@ pub fn encode_png(width: u32, height: u32, rgba: Vec) -> Result, Str Ok(out.into_inner()) } +/// Options for a keyed (signed-in) upload. +#[derive(Default)] +pub struct KeyedOptions { + /// Ephemeral image TTL in seconds (when the image itself auto-archives). + pub expires_in: Option, + /// Upload privately (mint a signed URL). Requires a secret key. + pub private: bool, + /// Signed-URL lifetime in seconds when `private` (the pasted link's TTL). + pub sign_expires_in: Option, +} + /// Upload a PNG and return the hosted URL. /// -/// - `api_key`: `Some` → keyed (permanent unless `expires_in` set); `None` → -/// anonymous temporary. -/// - `expires_in`: keyed ephemeral TTL in seconds (ignored when anonymous). +/// - `api_key`: `Some` → keyed; `None` → anonymous temporary (always public). +/// - `opts`: keyed-only knobs (image TTL, private/signed URL). Ignored when +/// anonymous — the server ignores `visibility` without a secret key anyway. +/// +/// For a private upload the returned URL is the **signed** URL, ready to paste. pub fn upload_png( png: Vec, api_key: Option<&str>, - expires_in: Option, + opts: KeyedOptions, ) -> Result { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(30)) @@ -70,9 +83,15 @@ pub fn upload_png( .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 { + if let Some(secs) = opts.expires_in { form = form.text("expires_in", secs.to_string()); } + if opts.private { + form = form.text("visibility", "private"); + if let Some(secs) = opts.sign_expires_in { + form = form.text("sign_expires_in", secs.to_string()); + } + } } let mut req = client.post(format!("{}/v1/images", api_base())).multipart(form); @@ -88,6 +107,9 @@ pub fn upload_png( } // Friendly, body-free messages for the common cases. let msg = match status.as_u16() { + 402 => { + "Private image limit reached — upgrade for unlimited private images.".to_string() + } 413 => "Image is too large.".to_string(), 429 => "Rate limit reached — try again shortly.".to_string(), s => format!("Upload failed ({s})."), diff --git a/src/main.ts b/src/main.ts index 2f2693d..e25f1ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,32 @@ interface AuthStatus { remaining: number; } +interface Settings { + private_uploads: boolean; + sign_expires_secs: number; +} + const account = document.getElementById("account")!; +const settings = document.getElementById("settings")!; + +/** Signed-URL lifetime choices (seconds), shown in the expiry picker. */ +const EXPIRY_OPTIONS: { label: string; secs: number }[] = [ + { label: "1 hour", secs: 3600 }, + { label: "1 day", secs: 86400 }, + { label: "7 days", secs: 604800 }, + { label: "30 days", secs: 2592000 }, +]; + +/** Human label for an arbitrary (e.g. hand-edited) lifetime in seconds. */ +function expiryLabel(secs: number): string { + const known = EXPIRY_OPTIONS.find((o) => o.secs === secs); + if (known) return known.label; + const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? "" : "s"}`; + if (secs % 86400 === 0) return plural(secs / 86400, "day"); + if (secs % 3600 === 0) return plural(secs / 3600, "hour"); + if (secs % 60 === 0) return plural(secs / 60, "minute"); + return plural(secs, "second"); +} function esc(s: string): string { return s.replace( @@ -26,13 +51,108 @@ function setMsg(el: HTMLElement, text: string, isError: boolean): void { el.classList.toggle("error", isError); } +/** Whether the account section currently shows the signed-in view (null = not + * yet rendered). Used to avoid re-rendering — and wiping an in-progress sign-in + * form — when the window merely regains focus without an auth-state change. */ +let signedInView: boolean | null = null; + async function refresh(): Promise { const status = await invoke("auth_status"); - if (status.signed_in && status.email) { - renderSignedIn(status.email); + signedInView = status.signed_in && !!status.email; + if (signedInView) { + renderSignedIn(status.email!); + await renderSettings(); } else { renderSignedOut(status.remaining); + // Private uploads require a signed-in secret key — hide the controls. + settings.hidden = true; + settings.innerHTML = ""; + } +} + +/** Focus-regain handler: only re-render when the signed-in/out state actually + * changed (e.g. the session ended elsewhere). Otherwise leave the DOM alone so + * a half-typed email or verification code survives an app switch. */ +async function refreshOnFocus(): Promise { + // Defer to the initial render if it hasn't set the view yet, so an early + // focus event doesn't fire a redundant re-render. + if (signedInView === null) return; + const status = await invoke("auth_status"); + const nowSignedIn = status.signed_in && !!status.email; + if (nowSignedIn !== signedInView) { + await refresh(); + } +} + +/** Render the private-uploads controls (only shown while signed in). */ +async function renderSettings(): Promise { + const current = await invoke("get_settings"); + // Always include the stored value so an off-grid (e.g. hand-edited) lifetime + // is represented and selected — otherwise the + + Private uploads + Paste a signed URL only you can share, instead of a public link. + + +
+ + +
+

+ `; + settings.hidden = false; + + const toggle = settings.querySelector("#private-toggle")!; + const expiryRow = settings.querySelector(".set-expiry")!; + const expiry = settings.querySelector("#expiry")!; + const msg = settings.querySelector("#set-msg")!; + + async function save(): Promise { + try { + await invoke("set_settings", { + privateUploads: toggle.checked, + signExpiresSecs: Number(expiry.value), + }); + setMsg( + msg, + toggle.checked + ? "New uploads will be private." + : "New uploads will be public.", + false + ); + } catch (e) { + setMsg(msg, String(e), true); + } } + + toggle.addEventListener("change", () => { + expiryRow.hidden = !toggle.checked; + void save(); + }); + expiry.addEventListener("change", () => void save()); } function renderSignedOut(remaining: number): void { @@ -119,11 +239,11 @@ 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. + // so DOMContentLoaded fires only once. On refocus, reconcile auth state — but + // only re-render if it changed, so we never wipe an in-progress sign-in form. getCurrentWindow() .onFocusChanged(({ payload: focused }) => { - if (focused) refresh().catch((e) => console.error(e)); + if (focused) refreshOnFocus().catch((e) => console.error(e)); }) .catch((e) => console.error(e)); }); diff --git a/src/styles.css b/src/styles.css index ad7b8dd..80ce6a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -181,3 +181,67 @@ button.secondary:hover { .acct-msg.error { color: var(--red); } + +/* Settings card (private uploads) — only shown when signed in. */ +.settings { + margin: 0 0 16px; + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + text-align: left; +} +.set-row { + display: flex; + gap: 10px; + align-items: flex-start; +} +.set-row + .set-row { + margin-top: 12px; +} +.set-row input[type="checkbox"] { + margin-top: 3px; + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} +.set-title { + display: block; + font-weight: 600; + color: var(--text-primary); +} +.set-sub { + display: block; + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary); +} +.set-expiry { + align-items: center; + gap: 8px; +} +.set-expiry .set-title { + font-weight: 500; + color: var(--text-secondary); +} +.set-expiry select { + margin-left: auto; + padding: 7px 10px; + color: var(--text-primary); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; + cursor: pointer; +} +.set-expiry select:focus { + outline: none; + border-color: var(--accent); +} +.settings .acct-msg { + min-height: 0; +} +.settings .acct-msg:empty { + margin: 0; +}