diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index 85280d9..ffda87c 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -314,6 +314,20 @@ on a blocking worker rather than the calling thread — its completion handler r main thread, and waiting for it *there* deadlocks until the timeout and returns nothing every time. +## Passwords + +Submitting a form that contains a password offers to remember it, the way a browser does. + +It goes into the **macOS Keychain** — encrypted at rest, unlocked with the login session, +and the one place on this machine actually built to hold a password. Never `apps.json`, +never a log. + +The value also travels as little as possible: the injected script hands it straight to +Rust, which holds it in memory and tells the shell only *which host* and *which username*, +because that is all the shell needs to ask the question. It is written on Save and dropped +on anything else. Autofill is not built — reading a password back out and injecting it into +a page is a larger surface than offering to store one, and worth deciding on separately. + ## Zoom Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aa9677f..dc9c4f0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3025,6 +3025,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -4896,6 +4919,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "objc2-web-kit", + "security-framework", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 85ce816..61ef287 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,3 +32,4 @@ block2 = "0.6" # Notifications are raised here rather than through the plugin, which offers no # way to learn that one was clicked. mac-notification-sys = "0.6" +security-framework = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 332c815..ca3ce51 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,6 +20,9 @@ pub struct AppState { /// Title bar height: how far a child webview's origin sits above the /// content the shell measures from. pub chrome: Mutex, + /// A login waiting on an answer: host, account, password. Held only until + /// it is saved or declined, and never written anywhere but the Keychain. + pub pending_password: Mutex>, pub config: Mutex, pub active: Mutex>, pub stage: Mutex, @@ -77,6 +80,7 @@ pub fn build_state(handle: &AppHandle) -> Result { dir, radius: Mutex::new(0.0), chrome: Mutex::new(0.0), + pending_password: Mutex::new(None), config: Mutex::new(config), active: Mutex::new(None), stage: Mutex::new((240.0, 38.0, 800.0, 600.0)), @@ -763,6 +767,43 @@ pub fn app_reports(state: State<'_, AppState>) -> Vec<(String, String)> { .collect() } +// ------------------------------------------------------------ passwords + +/// Writes the offered login to the macOS Keychain. +/// +/// The Keychain, not `apps.json`: it is encrypted at rest, unlocked with the +/// login session, and the one place on this machine that is actually built to +/// hold a password. The value never touches the config file, the logs, or the +/// shell. +#[tauri::command] +pub fn save_password(state: State<'_, AppState>) -> Result { + let offer = state.pending_password.lock().unwrap().take(); + let Some((host, account, password)) = offer else { + return Err("nothing waiting to be saved".into()); + }; + + #[cfg(target_os = "macos")] + { + let service = format!("Work — {host}"); + security_framework::passwords::set_generic_password( + &service, + &account, + password.as_bytes(), + ) + .map_err(|e| format!("the Keychain refused it: {e}"))?; + } + #[cfg(not(target_os = "macos"))] + let _ = password; + + Ok(host) +} + +/// Drops the offered login without saving it. +#[tauri::command] +pub fn discard_password(state: State<'_, AppState>) { + *state.pending_password.lock().unwrap() = None; +} + // -------------------------------------------------- notification clicks /// Runs the page's own click handler for a notification it raised. diff --git a/src-tauri/src/inject.js b/src-tauri/src/inject.js index 542ed7d..a4f3118 100644 --- a/src-tauri/src/inject.js +++ b/src-tauri/src/inject.js @@ -481,6 +481,42 @@ }; } catch (e) {} + /* -------------------------------------------------------- passwords */ + + /* Offers to remember a login you just typed, the way a browser does. + + The value goes straight to Rust and into the macOS Keychain; it is never + written to this app's config, never logged, and never handed to the shell + — the shell is only told which host and which username, which is all it + needs to ask you the question. */ + function credentialsIn(form) { + var pw = form.querySelector('input[type="password"]'); + if (!pw || !pw.value) return null; + + /* The username is whatever text-like field comes before the password — + which is how every login form on the web is built, whatever it calls + its fields. */ + var fields = form.querySelectorAll('input'); + var user = ''; + for (var i = 0; i < fields.length; i++) { + if (fields[i] === pw) break; + var t = (fields[i].type || '').toLowerCase(); + if (t === 'text' || t === 'email' || t === 'tel') user = fields[i].value || user; + } + return { user: user, pass: pw.value }; + } + + document.addEventListener('submit', function (e) { + var form = e.target; + if (!form || form.tagName !== 'FORM') return; + try { + var found = credentialsIn(form); + if (found) { + send('savepw', { u: found.user, p: found.pass, h: location.hostname }); + } + } catch (err) {} + }, true); + /* ---------------------------------------------------------- the icon */ /* The site's own icon, read off the page it is on. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 275f464..eb154df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,8 @@ pub fn run() { commands::set_theme, commands::unread_counts, commands::focus_window, + commands::save_password, + commands::discard_password, commands::notification_click, commands::set_zoom, commands::set_hidden, diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 5aa0235..fda5108 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -52,6 +52,16 @@ pub struct NotificationClick { pub notification_id: String, } +/// A login worth offering to remember. Carries no password: the value stays in +/// Rust until it is either written to the Keychain or dropped. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordOffer { + pub app_id: String, + pub host: String, + pub account: String, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct IconEvent { @@ -247,6 +257,27 @@ fn handle_sentinel( } } + // A login was just submitted. The password is held here and offered; + // the shell is told only the host and the username, because that is + // all it needs to ask the question and the less that value travels + // the better. + "savepw" => { + let (Some(host), Some(pass)) = (params.get("h"), params.get("p")) else { + return; + }; + if pass.is_empty() { + return; + } + let account = params.get("u").cloned().unwrap_or_default(); + let state = handle.state::(); + *state.pending_password.lock().unwrap() = + Some((host.clone(), account.clone(), pass.clone())); + let _ = handle.emit( + "password-offer", + PasswordOffer { app_id: from.clone(), host: host.clone(), account }, + ); + } + "emptycache" => { empty_cache(&handle, &from); } diff --git a/src/App.tsx b/src/App.tsx index d77f1f5..cf89d80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,9 +4,16 @@ import { listen } from "@tauri-apps/api/event"; import * as api from "./api"; import Nav from "./components/Nav"; import Settings from "./components/Settings"; -import { BTN_PRIMARY } from "./components/ui"; +import { BTN_PRIMARY, Dialog } from "./components/ui"; import { useAppearance, type Theme } from "./hooks/useAppearance"; -import type { Config, Group, HiddenEvent, NotificationClick, SwitchEvent } from "./types"; +import type { + Config, + Group, + HiddenEvent, + NotificationClick, + PasswordOffer, + SwitchEvent, +} from "./types"; export default function App() { const [config, setConfig] = useState(null); @@ -16,6 +23,8 @@ export default function App() { const [theme, setTheme] = useAppearance("system"); const [unread, setUnread] = useState>({}); const [backdrop, setBackdrop] = useState(null); + const [offer, setOffer] = useState(null); + const [saved, setSaved] = useState(null); const stageRef = useRef(null); const booted = useRef(false); @@ -110,6 +119,8 @@ export default function App() { listen<[string, number][]>("unread-changed", (e) => { setUnread(Object.fromEntries(e.payload)); }), + // A login was submitted. The password is held in Rust; this only asks. + listen("password-offer", (e) => setOffer(e.payload)), ]; return () => { unlisten.forEach((p) => p.then((f) => f())); @@ -204,6 +215,47 @@ export default function App() { )} + {offer && ( + { + void api.discardPassword(); + setOffer(null); + }} + confirmLabel="Save" + onConfirm={async () => { + try { + setSaved(await api.savePassword()); + } catch { + setSaved(null); + } + setOffer(null); + }} + > + {offer.account ? ( + <> + {offer.account} at{" "} + {offer.host}. + + ) : ( + <> + The login you just entered at{" "} + {offer.host}. + + )}{" "} + It goes into your macOS Keychain — not into this app's settings file, and + nowhere else on disk. + + )} + + {saved && ( + setSaved(null)}> + The password for {saved} is in + your Keychain. You can see and remove it in Keychain Access, under{" "} + Work — {saved}. + + )} + {settingsOpen && ( invoke<[string, string][]>("app_reports"); export const unreadCounts = () => invoke<[string, number][]>("unread_counts"); export const stageSnapshot = () => invoke("stage_snapshot"); +export const savePassword = () => invoke("save_password"); +export const discardPassword = () => invoke("discard_password"); diff --git a/src/types.ts b/src/types.ts index 21dbbfa..4ad8313 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,3 +57,10 @@ export interface NotificationClick { appId: string; notificationId: string; } + +/** A login just submitted, offered for saving. Carries no password. */ +export interface PasswordOffer { + appId: string; + host: string; + account: string; +}