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
32 changes: 29 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@
</head>
<body>
<main class="wrap">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<circle cx="100" cy="100" r="60" fill="none" stroke="#e4a44c" stroke-width="8" />
<g stroke="#e4a44c" stroke-width="5" stroke-linecap="butt">
<line x1="100" y1="15" x2="100" y2="36" />
<line x1="100" y1="44" x2="100" y2="100" />
<line x1="100" y1="100" x2="100" y2="156" />
<line x1="100" y1="164" x2="100" y2="185" />
<line x1="15" y1="100" x2="36" y2="100" />
<line x1="44" y1="100" x2="100" y2="100" />
<line x1="100" y1="100" x2="156" y2="100" />
<line x1="164" y1="100" x2="185" y2="100" />
</g>
<circle cx="100" cy="30" r="7" fill="#e4a44c" />
<g fill="#e4a44c">
<circle cx="100" cy="55" r="3.2" />
<circle cx="88.4" cy="56.2" r="3.2" />
<circle cx="111.6" cy="56.2" r="3.2" />
<circle cx="77.5" cy="61" r="3.2" />
<circle cx="122.5" cy="61" r="3.2" />
<circle cx="68.2" cy="68.2" r="3" />
<circle cx="131.8" cy="68.2" r="3" />
<circle cx="61.2" cy="76.2" r="2.8" />
<circle cx="138.8" cy="76.2" r="2.8" />
<circle cx="56.6" cy="84.6" r="2.6" />
<circle cx="143.4" cy="84.6" r="2.6" />
</g>
</svg>
</div>
<h1>PixelVault</h1>
<p class="tag">Copy an image — paste a hosted URL.</p>

Expand All @@ -28,9 +57,6 @@ <h1>PixelVault</h1>
<kbd>⌘V</kbd> to paste it into your coding agent.
</p>

<a class="link" href="https://pixelvault.dev" target="_blank" rel="noreferrer">
pixelvault.dev
</a>
<p class="foss">
Free &amp; open source · by
<a class="link" href="https://github.com/pixelvault-dev/desktop" target="_blank" rel="noreferrer">PixelVault</a>
Expand Down
69 changes: 53 additions & 16 deletions src-tauri/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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, String> {
keyring::Entry::new(KEYRING_SERVICE, user).map_err(|e| e.to_string())
}

/// The stored API key, if signed in.
pub fn stored_key() -> Option<String> {
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<Option<String>, 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<String> {
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<Option<Session>, 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> {
Expand All @@ -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<reqwest::blocking::Client, String> {
Expand Down Expand Up @@ -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<String, String> {
/// 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<Session, String> {
let resp = client()?
.post(format!("{}/v1/auth/device/complete", api_base()))
.json(&serde_json::json!({ "email": email, "code": code }))
Expand All @@ -107,5 +141,8 @@ pub fn device_complete(email: &str, code: &str) -> Result<String, String> {
}
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,
})
}
3 changes: 2 additions & 1 deletion src-tauri/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
});
Expand Down
Loading
Loading