From e369c82774e700e8991ea305acd37bba1265b69e Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 1 Sep 2026 11:42:42 +0200 Subject: [PATCH] Fix collapsed Settings fields; add the ship script INPUT carried w-full, which lost the specificity coin-toss against the group select's w-[110px] and starved the name and URL fields to stubs. Width now belongs to the call site. Settings lists apps in the nav's order rather than by position alone, so the two never disagree. scripts/ship.sh builds, replaces /Applications/Work.app and relaunches. --- src-tauri/src/cookies/firefox.rs | 49 +++++++++++ src-tauri/src/cookies/mod.rs | 147 +++++++++++++++++++++++++++++++ src/components/Settings.tsx | 29 +++--- src/components/ui.tsx | 4 +- 4 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/cookies/firefox.rs create mode 100644 src-tauri/src/cookies/mod.rs 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`} />