Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
`<app_config_dir>/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 `<app_config_dir>/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.
Expand Down
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ <h1>PixelVault</h1>
<!-- Rendered by main.ts: sign-in form or signed-in status. -->
</section>

<section class="settings" id="settings" hidden>
<!-- Rendered by main.ts when signed in: private-uploads toggle. -->
</section>

<p class="status">
Two ways to grab an image — both give you a hosted URL to paste:
</p>
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ pub fn load_session() -> Result<Option<Session>, 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())?;
Expand Down
154 changes: 134 additions & 20 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ fn handle_unauthorized(app: &AppHandle) {
if let Ok(mut g) = app.state::<AppState>().session.lock() {
*g = None;
}
// Drop any signed (private) URLs from the tray — the session that could
// mint them is gone.
app.state::<AppState>().trial.forget_private_recent();
refresh_recent(app);
refresh_account(app);
notify(
app,
Expand All @@ -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;

Expand All @@ -106,20 +125,44 @@ pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec<u8>) -> Result<Option<S
fn run_upload(app: &AppHandle, png_bytes: Vec<u8>) -> Result<Option<String>, 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::<AppState>().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::<AppState>().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::<AppState>().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).
Expand All @@ -133,7 +176,7 @@ fn run_upload(app: &AppHandle, png_bytes: Vec<u8>) -> Result<Option<String>, 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::<AppState>().trial.commit_reserved(&url);
refresh_counter(app);
Expand Down Expand Up @@ -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::<AppState>().trial.recent();
let recent = app.state::<AppState>().trial.recent_entries();
let items = app
.state::<AppState>()
.recent_items
Expand All @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -316,10 +367,42 @@ fn sign_out(app: AppHandle) -> Result<(), String> {
if let Ok(mut g) = app.state::<AppState>().session.lock() {
*g = None;
}
// Forget signed (private) URLs so a capability link doesn't linger post-sign-out.
app.state::<AppState>().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::<AppState>().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::<AppState>()
.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()
Expand All @@ -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.
Expand Down Expand Up @@ -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");
}
}
Loading
Loading