diff --git a/src-tauri/src/cookies/firefox.rs b/src-tauri/src/cookies/firefox.rs new file mode 100644 index 0000000..fd822a1 --- /dev/null +++ b/src-tauri/src/cookies/firefox.rs @@ -0,0 +1,49 @@ +//! Firefox keeps `cookies.sqlite` unencrypted, which makes it the one browser +//! here that needs no Keychain access at all. + +use rusqlite::{Connection, OpenFlags}; + +use super::Cookie; + +pub fn find_cookie_db() -> Option { + let root = super::home().join("Library/Application Support/Firefox/Profiles"); + let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None; + for entry in std::fs::read_dir(root).ok()?.flatten() { + let db = entry.path().join("cookies.sqlite"); + if !db.exists() { + continue; + } + let modified = db.metadata().and_then(|m| m.modified()).ok()?; + if best.as_ref().is_none_or(|(t, _)| modified > *t) { + best = Some((modified, db)); + } + } + best.map(|(_, p)| p) +} + +pub fn read() -> Result, String> { + let db = find_cookie_db().ok_or("no Firefox profile with cookies")?; + let copy = super::chrome::copy_locked(&db)?; + let conn = Connection::open_with_flags(©, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + + let mut stmt = conn + .prepare("SELECT host, name, value, path, expiry, isSecure, isHttpOnly FROM moz_cookies") + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map([], |r| { + Ok(Cookie { + domain: r.get::<_, String>(0)?, + name: r.get::<_, String>(1)?, + value: r.get::<_, String>(2)?, + path: r.get::<_, String>(3)?, + expires: r.get::<_, i64>(4).ok().filter(|v| *v > 0), + secure: r.get::<_, i64>(5).unwrap_or(0) != 0, + http_only: r.get::<_, i64>(6).unwrap_or(0) != 0, + }) + }) + .map_err(|e| e.to_string())?; + + Ok(rows.filter_map(Result::ok).collect()) +} diff --git a/src-tauri/src/cookies/mod.rs b/src-tauri/src/cookies/mod.rs new file mode 100644 index 0000000..b84ade5 --- /dev/null +++ b/src-tauri/src/cookies/mod.rs @@ -0,0 +1,147 @@ +//! Importing a browser's cookies, so a tool you are already signed into in +//! Chrome does not ask again here. +//! +//! This is an accelerator, not the foundation. Each webview keeps its own +//! persistent jar regardless; the import only saves first logins, and it is +//! expected to fail against services that bind a session to the browser that +//! created it. + +pub mod chrome; +pub mod firefox; +#[cfg(target_os = "macos")] +pub mod inject; + +use serde::{Deserialize, Serialize}; + +/// One cookie, in the only shape the rest of the app cares about. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cookie { + pub domain: String, + pub name: String, + pub value: String, + pub path: String, + /// Unix seconds. `None` is a session cookie. + pub expires: Option, + pub secure: bool, + pub http_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Browser { + pub id: String, + pub label: String, + /// Whether a readable cookie store was actually found on disk. + pub available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairResult { + pub imported: usize, + pub domains: usize, + /// Non-fatal problems worth showing: a profile that would not open, a + /// browser whose format is not supported. + pub warnings: Vec, +} + +fn home() -> std::path::PathBuf { + std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()) +} + +/// Chromium-family browsers, by profile directory and Keychain service name. +/// +/// The Keychain entry is per-browser: Brave's key does not open Chrome's. +pub fn chromium_browsers() -> Vec<(&'static str, &'static str, std::path::PathBuf, &'static str)> { + let h = home(); + vec![ + ("chrome", "Google Chrome", h.join("Library/Application Support/Google/Chrome"), "Chrome"), + ("brave", "Brave", h.join("Library/Application Support/BraveSoftware/Brave-Browser"), "Brave"), + ("edge", "Microsoft Edge", h.join("Library/Application Support/Microsoft Edge"), "Microsoft Edge"), + ("vivaldi", "Vivaldi", h.join("Library/Application Support/Vivaldi"), "Vivaldi"), + ("arc", "Arc", h.join("Library/Application Support/Arc/User Data"), "Arc"), + ("chromium", "Chromium", h.join("Library/Application Support/Chromium"), "Chromium"), + ] +} + +/// The browsers this machine actually has, for the Settings picker. +pub fn list() -> Vec { + let mut out: Vec = chromium_browsers() + .into_iter() + .map(|(id, label, dir, _)| Browser { + id: id.to_string(), + label: label.to_string(), + available: chrome::find_cookie_db(&dir).is_some(), + }) + .collect(); + + out.push(Browser { + id: "firefox".into(), + label: "Firefox".into(), + available: firefox::find_cookie_db().is_some(), + }); + + out.retain(|b| b.available); + out +} + +/// Reads every cookie the named browser holds. +pub fn read_all(browser: &str) -> Result, String> { + if browser == "firefox" { + return firefox::read(); + } + let (_, label, dir, service) = chromium_browsers() + .into_iter() + .find(|(id, ..)| *id == browser) + .ok_or_else(|| format!("{browser} is not a browser this can read"))?; + chrome::read(&dir, service).map_err(|e| format!("{label}: {e}")) +} + +/// Keeps only what the configured apps and their sign-in hosts need. +/// +/// The filter is the whole point: this reaches into a browser's cookie store, +/// and it should come back with the session for the tools on the list and +/// nothing else at all. +pub fn filter_to_scopes(cookies: Vec, scopes: &[String]) -> Vec { + cookies + .into_iter() + .filter(|c| { + let host = c.domain.trim_start_matches('.'); + scopes.iter().any(|s| crate::routing::host_matches(host, s)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c(domain: &str) -> Cookie { + Cookie { + domain: domain.into(), + name: "s".into(), + value: "1".into(), + path: "/".into(), + expires: None, + secure: true, + http_only: true, + } + } + + #[test] + fn filter_keeps_scoped_hosts_and_their_subdomains() { + let got = filter_to_scopes( + vec![c("github.com"), c(".github.com"), c("gist.github.com"), c("example.net")], + &["github.com".to_string()], + ); + assert_eq!(got.len(), 3); + } + + #[test] + fn filter_drops_everything_unscoped() { + // A browser's cookie store holds the user's whole life. Only the apps + // on the list may come across. + let got = filter_to_scopes(vec![c("bank.example"), c("evilgithub.com")], &["github.com".into()]); + assert!(got.is_empty()); + } +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a916be1..8ef078c 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -46,6 +46,13 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }: const groups = [...config.groups].sort((a, b) => a.order - b.order); + // Same order the nav shows, so the two lists never disagree about position. + const groupRank = (id: string | null) => + id === null ? -1 : (groups.find((g) => g.id === id)?.order ?? Number.MAX_SAFE_INTEGER); + const orderedApps = [...config.apps].sort( + (a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order, + ); + const run = async (fn: () => Promise) => { try { onConfig(await fn()); @@ -88,9 +95,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }: {config.apps.length === 0 && (

Nothing yet. Add the first one below.

)} - {[...config.apps] - .sort((a, b) => a.order - b.order) - .map((app) => ( + {orderedApps.map((app) => (
patch(app, { name: e.target.value })} - className={`${INPUT} h-[26px] flex-1`} + className={`${INPUT} h-[26px] w-full flex-1`} /> patch(app, { url: e.target.value })} title="Changing this rebuilds the app's view" - className={`${INPUT} h-[26px] flex-[1.4] font-mono text-[11px]`} + className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`} /> setGroupId(e.target.value)} - className={`${INPUT} cursor-pointer`} + className={`${INPUT} w-full cursor-pointer`} > {groups.map((g) => ( @@ -190,7 +195,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }: onChange={(e) => run(() => api.updateGroup({ ...g, name: e.target.value })) } - className={`${INPUT} h-[26px] flex-1`} + className={`${INPUT} h-[26px] w-full flex-1`} /> {config.apps.filter((a) => a.groupId === g.id).length} apps @@ -213,7 +218,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }: onChange={(e) => setGroupName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addGroup()} placeholder="Finance" - className={INPUT} + className={`${INPUT} w-full`} />