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.
This commit is contained in:
@@ -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<std::path::PathBuf> {
|
||||
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<Vec<Cookie>, 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())
|
||||
}
|
||||
@@ -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<i64>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<Browser> {
|
||||
let mut out: Vec<Browser> = 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<Vec<Cookie>, 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<Cookie>, scopes: &[String]) -> Vec<Cookie> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user