Drop cookie import; click-through notifications; per-app zoom
The cookie import is gone. It worked mechanically - 43 cookies decrypted from Arc and verifiably visible to the page - but Google, Microsoft and Odoo all refused the imported sessions, because each binds a session to the browser that created it. Signing in once inside the app is simpler and actually works, so the whole path is deleted rather than kept as a feature that mostly fails. That takes rusqlite, aes, cbc, pbkdf2, hmac, sha1 and sha2 out of the build with it. Notifications are now raised through mac-notification-sys rather than Tauri's notification plugin, because the plugin cannot report that one was clicked. A click switches to the app that raised it and then runs the page's own click handler - the only thing that knows which message the notification was about. Zoom is per app, on a fixed ladder so Cmd+0 returns to exactly 100%. The shortcuts are menu-bar accelerators rather than a key listener, since the keystroke has to work while a remote page has focus. The hidden-element count is off the nav rows.
This commit is contained in:
+109
-117
@@ -4,7 +4,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::{self, App, Config, Group};
|
||||
@@ -51,15 +51,6 @@ impl AppState {
|
||||
}
|
||||
self.persist()
|
||||
}
|
||||
|
||||
/// Every host the import is allowed to bring cookies across for: the
|
||||
/// configured apps, plus the sign-in hosts that vouch for them.
|
||||
fn importable_hosts(&self) -> Vec<String> {
|
||||
let cfg = self.config.lock().unwrap();
|
||||
let mut hosts: Vec<String> = cfg.apps.iter().flat_map(|a| a.scopes()).collect();
|
||||
hosts.extend(crate::routing::identity_providers().iter().map(|s| s.to_string()));
|
||||
hosts
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
@@ -243,6 +234,7 @@ pub fn add_app(
|
||||
group_id,
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
zoom: 1.0,
|
||||
order,
|
||||
};
|
||||
cfg.apps.push(new.clone());
|
||||
@@ -501,116 +493,106 @@ pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Asks the page which cookies it can actually see.
|
||||
// ----------------------------------------------------------------- zoom
|
||||
|
||||
/// The zoom ladder, so a keystroke lands on a sensible size rather than
|
||||
/// drifting by a multiplier that never returns to exactly 100%.
|
||||
const ZOOM_STEPS: [f64; 13] = [
|
||||
0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0,
|
||||
];
|
||||
|
||||
fn step_zoom(current: f64, direction: i32) -> f64 {
|
||||
// The nearest rung, so a hand-edited value still moves somewhere sane.
|
||||
let idx = ZOOM_STEPS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|a, b| {
|
||||
(a.1 - current).abs().partial_cmp(&(b.1 - current).abs()).unwrap()
|
||||
})
|
||||
.map(|(i, _)| i as i32)
|
||||
.unwrap_or(5);
|
||||
let next = (idx + direction).clamp(0, ZOOM_STEPS.len() as i32 - 1);
|
||||
ZOOM_STEPS[next as usize]
|
||||
}
|
||||
|
||||
/// Applies a zoom change to whichever app is showing, and remembers it.
|
||||
///
|
||||
/// `setCookie` is fire-and-forget, so the import's count is what was handed to
|
||||
/// WebKit, not what WebKit kept. This reads the other end. HttpOnly cookies are
|
||||
/// invisible to script by design, so the answer is a floor, not a total — but a
|
||||
/// zero here means the injection never landed at all.
|
||||
/// Per app, because a dense ERP and a mail client do not want the same size.
|
||||
pub fn adjust_zoom(app: &AppHandle, direction: Option<i32>) {
|
||||
let state = app.state::<AppState>();
|
||||
let Some(id) = state.active.lock().unwrap().clone() else { return };
|
||||
|
||||
let zoom = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let Some(target) = cfg.apps.iter_mut().find(|a| a.id == id) else { return };
|
||||
target.zoom = match direction {
|
||||
Some(d) => step_zoom(target.zoom, d),
|
||||
None => 1.0,
|
||||
};
|
||||
target.zoom
|
||||
};
|
||||
let _ = state.persist();
|
||||
|
||||
if let Some(wv) = app.get_webview(&webviews::label_for(&id)) {
|
||||
let _ = wv.set_zoom(zoom);
|
||||
}
|
||||
let _ = app.emit("zoom-changed", (id, zoom));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn probe_cookies(app_id: String, app: AppHandle) -> Result<(), String> {
|
||||
pub fn set_zoom(app_id: String, zoom: f64, app: AppHandle, state: State<'_, AppState>) -> Result<Config, String> {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
let target = cfg
|
||||
.apps
|
||||
.iter_mut()
|
||||
.find(|a| a.id == app_id)
|
||||
.ok_or_else(|| format!("no app {app_id}"))?;
|
||||
target.zoom = zoom.clamp(0.25, 5.0);
|
||||
}
|
||||
state.persist()?;
|
||||
if let Some(wv) = app.get_webview(&webviews::label_for(&app_id)) {
|
||||
let _ = wv.set_zoom(zoom);
|
||||
}
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
/// Reloads whichever app is showing, for the menu bar's Reload item.
|
||||
pub fn reload_active(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
let Some(id) = state.active.lock().unwrap().clone() else { return };
|
||||
if let Some(wv) = app.get_webview(&webviews::label_for(&id)) {
|
||||
let _ = wv.eval("location.reload()");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------- notification clicks
|
||||
|
||||
/// Runs the page's own click handler for a notification it raised.
|
||||
///
|
||||
/// This is what makes a click land on the message rather than merely on the
|
||||
/// app: Gmail's handler knows which thread it was about, and this app does not
|
||||
/// and should not.
|
||||
#[tauri::command]
|
||||
pub fn notification_click(app_id: String, notification_id: String, app: AppHandle) -> Result<(), String> {
|
||||
let wv = app
|
||||
.get_webview(&webviews::label_for(&app_id))
|
||||
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
||||
wv.eval(
|
||||
r#"(function () {
|
||||
var names = document.cookie
|
||||
? document.cookie.split(';').map(function (c) { return c.split('=')[0].trim(); })
|
||||
: [];
|
||||
if (window.__workAppSend) {
|
||||
window.__workAppSend('diag', {
|
||||
host: location.hostname,
|
||||
visible: String(names.length),
|
||||
names: names.slice(0, 12).join(',')
|
||||
});
|
||||
}
|
||||
})();"#,
|
||||
)
|
||||
let escaped = notification_id.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
wv.eval(&format!(
|
||||
"window.__workAppNotifyClick && window.__workAppNotifyClick('{escaped}')"
|
||||
))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What the last cookie probe saw.
|
||||
/// Brings the window forward when a notification is clicked.
|
||||
#[tauri::command]
|
||||
pub fn cookie_probe(app: AppHandle) -> String {
|
||||
let diag = app.state::<AppState>().diag.lock().unwrap().clone();
|
||||
if diag.is_empty() { "no answer from the page".into() } else { diag }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- browser pairing
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_browsers() -> Vec<crate::cookies::Browser> {
|
||||
crate::cookies::list()
|
||||
}
|
||||
|
||||
/// Imports the paired browser's cookies for the configured hosts.
|
||||
///
|
||||
/// Everything outside those hosts is dropped before anything is written: this
|
||||
/// reaches into a browser's whole cookie store, and it must come back with the
|
||||
/// sessions for the tools on the list and nothing else.
|
||||
#[tauri::command]
|
||||
pub fn pair_browser(
|
||||
browser: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::cookies::PairResult, String> {
|
||||
let all = crate::cookies::read_all(&browser)?;
|
||||
let scanned = all.len();
|
||||
let wanted = crate::cookies::filter_to_scopes(all, &state.importable_hosts());
|
||||
|
||||
let mut domains: Vec<String> = wanted.iter().map(|c| c.domain.clone()).collect();
|
||||
domains.sort();
|
||||
domains.dedup();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if wanted.is_empty() {
|
||||
warnings.push(format!(
|
||||
"Read {scanned} cookies, none for the apps on your list. \
|
||||
Sign in to them in that browser first."
|
||||
));
|
||||
pub fn focus_window(app: AppHandle) {
|
||||
if let Some(w) = app.get_window("main") {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
|
||||
let imported = crate::cookies::inject::install(&app, wanted)?;
|
||||
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.settings.paired_browser = Some(browser);
|
||||
cfg.settings.last_paired_at = Some(now_iso());
|
||||
}
|
||||
state.persist()?;
|
||||
|
||||
Ok(crate::cookies::PairResult {
|
||||
imported,
|
||||
domains: domains.len(),
|
||||
domain_names: domains,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// A timestamp for "last paired", without pulling in a date library for it.
|
||||
fn now_iso() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = secs / 86_400;
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
let (y, mo, d) = civil_from_days(days as i64);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
|
||||
/// Howard Hinnant's days-to-civil-date algorithm.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -618,12 +600,22 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_matches_known_dates() {
|
||||
// Cross-checked against Python:
|
||||
// date(1970,1,1) + timedelta(days=n)
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
||||
assert_eq!(civil_from_days(20_697), (2026, 9, 1));
|
||||
assert_eq!(civil_from_days(20_698), (2026, 9, 2));
|
||||
fn zoom_steps_up_and_down_the_ladder() {
|
||||
assert_eq!(step_zoom(1.0, 1), 1.1);
|
||||
assert_eq!(step_zoom(1.0, -1), 0.9);
|
||||
assert_eq!(step_zoom(1.25, 1), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_stops_at_the_ends_rather_than_wrapping() {
|
||||
assert_eq!(step_zoom(3.0, 1), 3.0);
|
||||
assert_eq!(step_zoom(0.5, -1), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_ladder_value_snaps_to_its_nearest_rung() {
|
||||
// A hand-edited apps.json should still zoom somewhere sensible.
|
||||
assert_eq!(step_zoom(1.04, 1), 1.1);
|
||||
assert_eq!(step_zoom(1.04, -1), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -33,10 +33,18 @@ pub struct App {
|
||||
/// the thing you never want to see again.
|
||||
#[serde(default)]
|
||||
pub hidden: Vec<String>,
|
||||
/// Page zoom, remembered per app: a dense ERP and a mail client do not
|
||||
/// want the same size.
|
||||
#[serde(default = "default_zoom")]
|
||||
pub zoom: f64,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
}
|
||||
|
||||
fn default_zoom() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn scopes(&self) -> Vec<String> {
|
||||
if self.scope.is_empty() {
|
||||
@@ -69,10 +77,6 @@ pub struct Settings {
|
||||
pub nav_collapsed: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
#[serde(default)]
|
||||
pub paired_browser: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_paired_at: Option<String>,
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
@@ -84,8 +88,6 @@ impl Default for Settings {
|
||||
Self {
|
||||
nav_collapsed: false,
|
||||
theme: default_theme(),
|
||||
paired_browser: None,
|
||||
last_paired_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +187,7 @@ pub fn seed() -> Config {
|
||||
group_id: Some(group.into()),
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
zoom: 1.0,
|
||||
order,
|
||||
};
|
||||
Config {
|
||||
@@ -291,6 +294,8 @@ mod tests {
|
||||
assert_eq!(cfg.version, 1);
|
||||
assert_eq!(cfg.settings.theme, "system");
|
||||
assert_eq!(cfg.apps[0].scopes(), vec!["x.com".to_string()]);
|
||||
// A file written before zoom existed must not open every app at 0%.
|
||||
assert_eq!(cfg.apps[0].zoom, 1.0);
|
||||
assert!(cfg.apps[0].ua().contains("Chrome/"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
//! Reading a Chromium browser's cookie jar on macOS.
|
||||
//!
|
||||
//! The values are AES-encrypted with a key that lives in the login Keychain, so
|
||||
//! the first import raises a Keychain prompt. That prompt is the point: it is
|
||||
//! macOS asking whether this app may read that key, and the honest answer has
|
||||
//! to come from the user.
|
||||
|
||||
use aes::Aes128;
|
||||
use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::Cookie;
|
||||
|
||||
type Decryptor = cbc::Decryptor<Aes128>;
|
||||
|
||||
/// Chromium's fixed KDF parameters on macOS. Not secrets — they are compiled
|
||||
/// into every Chromium build, and the actual secret is the Keychain entry.
|
||||
const SALT: &[u8] = b"saltysalt";
|
||||
const ROUNDS: u32 = 1003;
|
||||
const IV: [u8; 16] = [b' '; 16];
|
||||
|
||||
/// The profile most recently used, since that is the one you are signed into.
|
||||
pub fn find_cookie_db(user_data: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
if !user_data.exists() {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
|
||||
let entries = std::fs::read_dir(user_data).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
// Chromium keeps cookies at <Profile>/Cookies, and newer builds at
|
||||
// <Profile>/Network/Cookies.
|
||||
for candidate in [entry.path().join("Cookies"), entry.path().join("Network/Cookies")] {
|
||||
if !candidate.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = candidate.metadata().and_then(|m| m.modified()) else {
|
||||
continue;
|
||||
};
|
||||
if best.as_ref().is_none_or(|(t, _)| modified > *t) {
|
||||
best = Some((modified, candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// SQLite will not open a file the browser holds a lock on, so it is copied
|
||||
/// first. The `-wal` sidecar comes too, or recent writes are invisible.
|
||||
pub fn copy_locked(db: &std::path::Path) -> Result<std::path::PathBuf, String> {
|
||||
let tmp = std::env::temp_dir().join(format!("work-app-cookies-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
|
||||
let dest = tmp.join("Cookies");
|
||||
std::fs::copy(db, &dest).map_err(|e| format!("could not read the cookie store: {e}"))?;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let side = db.with_file_name(format!(
|
||||
"{}{suffix}",
|
||||
db.file_name().unwrap_or_default().to_string_lossy()
|
||||
));
|
||||
if side.exists() {
|
||||
let _ = std::fs::copy(&side, dest.with_file_name(format!("Cookies{suffix}")));
|
||||
}
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// The browser's encryption password, from the login Keychain.
|
||||
///
|
||||
/// Shelling out to `security` rather than binding the Security framework: it is
|
||||
/// the same prompt either way, and this keeps a C API with a long history of
|
||||
/// footguns out of the app.
|
||||
fn keychain_password(service: &str) -> Result<String, String> {
|
||||
let out = std::process::Command::new("/usr/bin/security")
|
||||
.args([
|
||||
"find-generic-password",
|
||||
"-w",
|
||||
"-s",
|
||||
&format!("{service} Safe Storage"),
|
||||
"-a",
|
||||
service,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("could not run `security`: {e}"))?;
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"no Keychain entry for \"{service} Safe Storage\". \
|
||||
If a prompt appeared, it needs Allow — otherwise open {service} once and try again."
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn derive_key(password: &str) -> [u8; 16] {
|
||||
let mut key = [0u8; 16];
|
||||
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password.as_bytes(), SALT, ROUNDS, &mut key);
|
||||
key
|
||||
}
|
||||
|
||||
/// Decrypts one `v10` value.
|
||||
///
|
||||
/// Chromium 130 and later prepend the SHA-256 of the cookie's host to the
|
||||
/// plaintext. It is stripped by comparing against the hash rather than by
|
||||
/// guessing at the bytes, so a value that merely looks binary is left alone.
|
||||
fn decrypt_value(encrypted: &[u8], key: &[u8; 16], host: &str) -> Option<String> {
|
||||
if encrypted.len() < 4 || &encrypted[..3] != b"v10" {
|
||||
return None;
|
||||
}
|
||||
let plain = Decryptor::new(key.into(), &IV.into())
|
||||
.decrypt_padded_vec_mut::<Pkcs7>(&encrypted[3..])
|
||||
.ok()?;
|
||||
|
||||
let expected: [u8; 32] = Sha256::digest(host.as_bytes()).into();
|
||||
let body = if plain.len() >= 32 && plain[..32] == expected {
|
||||
&plain[32..]
|
||||
} else {
|
||||
&plain[..]
|
||||
};
|
||||
String::from_utf8(body.to_vec()).ok()
|
||||
}
|
||||
|
||||
pub fn read(user_data: &std::path::Path, service: &str) -> Result<Vec<Cookie>, String> {
|
||||
let db = find_cookie_db(user_data).ok_or("no profile with a cookie store")?;
|
||||
let key = derive_key(&keychain_password(service)?);
|
||||
let copy = copy_locked(&db)?;
|
||||
|
||||
let conn = Connection::open_with_flags(©, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| format!("could not open the cookie store: {e}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT host_key, name, value, encrypted_value, path, expires_utc, \
|
||||
is_secure, is_httponly FROM cookies",
|
||||
)
|
||||
.map_err(|e| format!("unfamiliar cookie schema: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let host: String = r.get(0)?;
|
||||
let plain: String = r.get(2).unwrap_or_default();
|
||||
let enc: Vec<u8> = r.get(3).unwrap_or_default();
|
||||
let value = if plain.is_empty() {
|
||||
decrypt_value(&enc, &key, &host).unwrap_or_default()
|
||||
} else {
|
||||
plain
|
||||
};
|
||||
Ok(Cookie {
|
||||
domain: host,
|
||||
name: r.get(1)?,
|
||||
value,
|
||||
path: r.get(4).unwrap_or_else(|_| "/".into()),
|
||||
expires: chromium_time(r.get::<_, i64>(5).unwrap_or(0)),
|
||||
secure: r.get::<_, i64>(6).unwrap_or(0) != 0,
|
||||
http_only: r.get::<_, i64>(7).unwrap_or(0) != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let cookies: Vec<Cookie> = rows
|
||||
.filter_map(Result::ok)
|
||||
// A value that would not decrypt is worse than useless: injecting an
|
||||
// empty session cookie logs you out rather than in.
|
||||
.filter(|c| !c.value.is_empty())
|
||||
.collect();
|
||||
|
||||
let _ = std::fs::remove_dir_all(copy.parent().unwrap_or(©));
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Chromium counts microseconds from 1601-01-01; the rest of the world counts
|
||||
/// seconds from 1970. Zero means a session cookie.
|
||||
fn chromium_time(value: i64) -> Option<i64> {
|
||||
if value <= 0 {
|
||||
return None;
|
||||
}
|
||||
const EPOCH_DELTA: i64 = 11_644_473_600;
|
||||
Some(value / 1_000_000 - EPOCH_DELTA)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chromium_time_converts_to_unix_seconds() {
|
||||
// 13 Jan 2022 00:00:00 UTC in Chromium's epoch.
|
||||
assert_eq!(chromium_time(13_287_916_800_000_000), Some(1_643_443_200));
|
||||
assert_eq!(chromium_time(0), None);
|
||||
assert_eq!(chromium_time(-5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_derivation_matches_chromiums_published_parameters() {
|
||||
// Cross-checked against an independent implementation:
|
||||
// python3 -c "import hashlib; print(hashlib.pbkdf2_hmac(
|
||||
// 'sha1', b'peanuts', b'saltysalt', 1003, 16).hex())"
|
||||
// "peanuts" is Chromium's documented fallback password. If this ever
|
||||
// fails, the KDF is wrong and every imported cookie will be garbage.
|
||||
let key = derive_key("peanuts");
|
||||
assert_eq!(
|
||||
key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
|
||||
"d9a09d499b4e1b7461f28e67972c6dbd"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_trip_decrypts_and_strips_the_host_hash() {
|
||||
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
|
||||
let key = derive_key("peanuts");
|
||||
let host = "example.com";
|
||||
|
||||
let mut plain = Sha256::digest(host.as_bytes()).to_vec();
|
||||
plain.extend_from_slice(b"session=abc123");
|
||||
|
||||
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(&plain);
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&ct);
|
||||
|
||||
assert_eq!(decrypt_value(&stored, &key, host).unwrap(), "session=abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_without_the_hash_prefix_survives_intact() {
|
||||
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
|
||||
let key = derive_key("peanuts");
|
||||
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(b"plain=1");
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&ct);
|
||||
assert_eq!(decrypt_value(&stored, &key, "example.com").unwrap(), "plain=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unversioned_values_are_refused() {
|
||||
let key = derive_key("peanuts");
|
||||
assert!(decrypt_value(b"not encrypted", &key, "example.com").is_none());
|
||||
assert!(decrypt_value(b"", &key, "example.com").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! Putting imported cookies into WebKit's shared jar.
|
||||
//!
|
||||
//! Not `document.cookie`: the cookies that carry a session are almost always
|
||||
//! HttpOnly, which is exactly the set script cannot write. Only
|
||||
//! `WKHTTPCookieStore` can, so this drops to Objective-C.
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_foundation::{
|
||||
MainThreadMarker, NSDate, NSDictionary, NSHTTPCookie, NSString, NSHTTPCookieDomain,
|
||||
NSHTTPCookieExpires, NSHTTPCookieName, NSHTTPCookiePath, NSHTTPCookieSecure,
|
||||
NSHTTPCookieValue,
|
||||
};
|
||||
use objc2_web_kit::WKWebsiteDataStore;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::Cookie;
|
||||
|
||||
fn build(c: &Cookie) -> Option<Retained<NSHTTPCookie>> {
|
||||
let name = NSString::from_str(&c.name);
|
||||
let value = NSString::from_str(&c.value);
|
||||
let domain = NSString::from_str(&c.domain);
|
||||
let path = NSString::from_str(if c.path.is_empty() { "/" } else { &c.path });
|
||||
|
||||
let mut keys: Vec<&NSString> = Vec::with_capacity(6);
|
||||
let mut values: Vec<&AnyObject> = Vec::with_capacity(6);
|
||||
|
||||
unsafe {
|
||||
keys.push(NSHTTPCookieName);
|
||||
values.push(&*(Retained::as_ptr(&name) as *const AnyObject));
|
||||
keys.push(NSHTTPCookieValue);
|
||||
values.push(&*(Retained::as_ptr(&value) as *const AnyObject));
|
||||
keys.push(NSHTTPCookieDomain);
|
||||
values.push(&*(Retained::as_ptr(&domain) as *const AnyObject));
|
||||
keys.push(NSHTTPCookiePath);
|
||||
values.push(&*(Retained::as_ptr(&path) as *const AnyObject));
|
||||
}
|
||||
|
||||
// Any non-nil value means secure; the key's absence means it is not.
|
||||
let yes = NSString::from_str("TRUE");
|
||||
if c.secure {
|
||||
unsafe {
|
||||
keys.push(NSHTTPCookieSecure);
|
||||
values.push(&*(Retained::as_ptr(&yes) as *const AnyObject));
|
||||
}
|
||||
}
|
||||
|
||||
// Omitting the key is what makes a session cookie, which is the right
|
||||
// shape for the ones that matter most here.
|
||||
let expires = c.expires.map(|s| NSDate::dateWithTimeIntervalSince1970(s as f64));
|
||||
if let Some(d) = &expires {
|
||||
unsafe {
|
||||
keys.push(NSHTTPCookieExpires);
|
||||
values.push(&*(Retained::as_ptr(d) as *const AnyObject));
|
||||
}
|
||||
}
|
||||
|
||||
let props: Retained<NSDictionary<NSString, AnyObject>> =
|
||||
NSDictionary::from_slices(&keys, &values);
|
||||
unsafe { NSHTTPCookie::cookieWithProperties(std::mem::transmute(&*props)) }
|
||||
}
|
||||
|
||||
/// Writes every cookie into the shared store the app's webviews read from.
|
||||
///
|
||||
/// Returns how many were accepted. Runs on the main thread because WebKit
|
||||
/// insists, and blocks until done so the caller can report a real number.
|
||||
pub fn install(app: &AppHandle, cookies: Vec<Cookie>) -> Result<usize, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
app.run_on_main_thread(move || {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
let _ = tx.send(Err("not on the main thread".to_string()));
|
||||
return;
|
||||
};
|
||||
let store = unsafe { WKWebsiteDataStore::defaultDataStore(mtm).httpCookieStore() };
|
||||
let mut n = 0usize;
|
||||
for c in &cookies {
|
||||
if let Some(cookie) = build(c) {
|
||||
unsafe { store.setCookie_completionHandler(&cookie, None) };
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(Ok(n));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
rx.recv().map_err(|e| e.to_string())?
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
//! 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,
|
||||
/// The hosts a session actually came across for. Shown because "4 domains"
|
||||
/// does not tell you which tool is still going to ask you to sign in.
|
||||
pub domain_names: Vec<String>,
|
||||
/// 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"),
|
||||
]
|
||||
}
|
||||
|
||||
/// When a cookie store was last written, as a stand-in for "last used".
|
||||
fn last_used(db: Option<std::path::PathBuf>) -> Option<std::time::SystemTime> {
|
||||
db.and_then(|p| p.metadata().ok()).and_then(|m| m.modified().ok())
|
||||
}
|
||||
|
||||
/// The browsers this machine actually has, most recently used first.
|
||||
///
|
||||
/// Ordered rather than alphabetical because the first entry becomes the
|
||||
/// default choice, and someone with four Chromium browsers installed wants the
|
||||
/// one they actually browse in — not whichever happens to sort first.
|
||||
pub fn list() -> Vec<Browser> {
|
||||
let mut found: Vec<(Browser, Option<std::time::SystemTime>)> = chromium_browsers()
|
||||
.into_iter()
|
||||
.map(|(id, label, dir, _)| {
|
||||
let db = chrome::find_cookie_db(&dir);
|
||||
let used = last_used(db.clone());
|
||||
(
|
||||
Browser { id: id.to_string(), label: label.to_string(), available: db.is_some() },
|
||||
used,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
found.push((
|
||||
Browser { id: "firefox".into(), label: "Firefox".into(), available: firefox::find_cookie_db().is_some() },
|
||||
last_used(firefox::find_cookie_db()),
|
||||
));
|
||||
|
||||
found.retain(|(b, _)| b.available);
|
||||
found.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
found.into_iter().map(|(b, _)| b).collect()
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
+98
-23
@@ -2,7 +2,8 @@
|
||||
* Runs inside every app's webview, before the page does.
|
||||
*
|
||||
* Four jobs: route links by intent, hide elements the user has chosen to be
|
||||
* rid of, replace WKWebView's inert Notification API, and put a right-click
|
||||
* rid of, replace WKWebView's inert Notification API — carrying clicks back
|
||||
* to the page that raised them — and put a right-click
|
||||
* menu on the page. Configuration arrives as `window.__WORKAPP`, written
|
||||
* immediately above this by Rust.
|
||||
*
|
||||
@@ -323,30 +324,104 @@
|
||||
/* WKWebView *does* define Notification — it just does nothing. Constructing
|
||||
one throws no error and shows no banner, so Gmail believes it notified you
|
||||
and you never hear about it. The native one is therefore replaced outright
|
||||
rather than only filled in when missing. */
|
||||
{
|
||||
var WorkNotification = function (title, options) {
|
||||
options = options || {};
|
||||
this.title = title;
|
||||
this.body = options.body || '';
|
||||
send('notify', { t: String(title || ''), b: String(options.body || ''), a: CFG.name || '' });
|
||||
};
|
||||
WorkNotification.__work = true;
|
||||
WorkNotification.permission = 'granted';
|
||||
WorkNotification.requestPermission = function (cb) {
|
||||
if (cb) cb('granted');
|
||||
return Promise.resolve('granted');
|
||||
};
|
||||
WorkNotification.prototype.close = function () {};
|
||||
WorkNotification.prototype.addEventListener = function () {};
|
||||
WorkNotification.prototype.removeEventListener = function () {};
|
||||
try {
|
||||
Object.defineProperty(window, 'Notification', {
|
||||
value: WorkNotification, writable: true, configurable: true
|
||||
});
|
||||
} catch (e) { window.Notification = WorkNotification; }
|
||||
rather than only filled in when missing.
|
||||
|
||||
Each one is kept so that clicking the macOS banner can run the page's own
|
||||
click handler. That handler is the only thing that knows which message the
|
||||
notification was about — this app does not, and should not have to. */
|
||||
var notifications = {};
|
||||
var notifySeq = 0;
|
||||
|
||||
function WorkNotification(title, options) {
|
||||
options = options || {};
|
||||
var self = this;
|
||||
this.title = title;
|
||||
this.body = options.body || '';
|
||||
this.data = options.data;
|
||||
this.tag = options.tag || '';
|
||||
this.icon = options.icon || '';
|
||||
this.onclick = null;
|
||||
this.onclose = null;
|
||||
this.onerror = null;
|
||||
this.onshow = null;
|
||||
this._listeners = { click: [], close: [], show: [], error: [] };
|
||||
|
||||
this.id = 'n' + (++notifySeq);
|
||||
notifications[this.id] = this;
|
||||
|
||||
/* An app that raises hundreds in a session should not grow forever. */
|
||||
var ids = Object.keys(notifications);
|
||||
if (ids.length > 200) delete notifications[ids[0]];
|
||||
|
||||
send('notify', {
|
||||
t: String(title == null ? '' : title),
|
||||
b: String(this.body),
|
||||
a: CFG.name || '',
|
||||
id: this.id
|
||||
});
|
||||
|
||||
setTimeout(function () { self._fire('show'); }, 0);
|
||||
}
|
||||
|
||||
WorkNotification.prototype._fire = function (type) {
|
||||
var ev;
|
||||
try {
|
||||
ev = new Event(type);
|
||||
} catch (e) {
|
||||
ev = { type: type };
|
||||
}
|
||||
try { Object.defineProperty(ev, 'target', { value: this }); } catch (e) {}
|
||||
|
||||
var handler = this['on' + type];
|
||||
if (typeof handler === 'function') {
|
||||
try { handler.call(this, ev); } catch (e) {}
|
||||
}
|
||||
(this._listeners[type] || []).forEach(function (fn) {
|
||||
try { fn.call(this, ev); } catch (e) {}
|
||||
}, this);
|
||||
};
|
||||
|
||||
WorkNotification.prototype.close = function () {
|
||||
delete notifications[this.id];
|
||||
this._fire('close');
|
||||
};
|
||||
WorkNotification.prototype.addEventListener = function (type, fn) {
|
||||
if (!this._listeners[type]) this._listeners[type] = [];
|
||||
this._listeners[type].push(fn);
|
||||
};
|
||||
WorkNotification.prototype.removeEventListener = function (type, fn) {
|
||||
var list = this._listeners[type];
|
||||
if (!list) return;
|
||||
var i = list.indexOf(fn);
|
||||
if (i !== -1) list.splice(i, 1);
|
||||
};
|
||||
WorkNotification.prototype.dispatchEvent = function (ev) {
|
||||
this._fire(ev && ev.type ? ev.type : 'click');
|
||||
return true;
|
||||
};
|
||||
|
||||
WorkNotification.__work = true;
|
||||
WorkNotification.permission = 'granted';
|
||||
WorkNotification.maxActions = 0;
|
||||
WorkNotification.requestPermission = function (cb) {
|
||||
if (cb) cb('granted');
|
||||
return Promise.resolve('granted');
|
||||
};
|
||||
|
||||
/* Called from Rust when the macOS banner is clicked. */
|
||||
window.__workAppNotifyClick = function (id) {
|
||||
var n = notifications[id];
|
||||
if (!n) return false;
|
||||
n._fire('click');
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(window, 'Notification', {
|
||||
value: WorkNotification, writable: true, configurable: true
|
||||
});
|
||||
} catch (e) { window.Notification = WorkNotification; }
|
||||
|
||||
/* ------------------------------------------------------------- start */
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
+28
-7
@@ -1,10 +1,9 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod routing;
|
||||
pub mod cookies;
|
||||
pub mod webviews;
|
||||
|
||||
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
|
||||
use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use tauri::Manager;
|
||||
|
||||
/// The macOS menu bar. Edit has no entry of its own, but its items live under
|
||||
@@ -43,13 +42,36 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
||||
],
|
||||
)?;
|
||||
|
||||
Menu::with_items(app, &[&app_menu, &window_menu])
|
||||
// Zoom lives in the menu bar rather than a key listener, because the
|
||||
// keystroke has to work while an app's own webview has focus — and that
|
||||
// webview is a remote page this app deliberately cannot script for input.
|
||||
let view_menu = Submenu::with_items(
|
||||
app,
|
||||
"View",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(app, "zoom-in", "Zoom In", true, Some("CmdOrCtrl+="))?,
|
||||
&MenuItem::with_id(app, "zoom-out", "Zoom Out", true, Some("CmdOrCtrl+-"))?,
|
||||
&MenuItem::with_id(app, "zoom-reset", "Actual Size", true, Some("CmdOrCtrl+0"))?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?,
|
||||
],
|
||||
)?;
|
||||
|
||||
Menu::with_items(app, &[&app_menu, &view_menu, &window_menu])
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.menu(build_menu)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"zoom-in" => commands::adjust_zoom(app, Some(1)),
|
||||
"zoom-out" => commands::adjust_zoom(app, Some(-1)),
|
||||
"zoom-reset" => commands::adjust_zoom(app, None),
|
||||
"reload" => commands::reload_active(app),
|
||||
_ => {}
|
||||
})
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
@@ -81,14 +103,13 @@ pub fn run() {
|
||||
commands::delete_group,
|
||||
commands::set_nav_collapsed,
|
||||
commands::set_theme,
|
||||
commands::focus_window,
|
||||
commands::notification_click,
|
||||
commands::set_zoom,
|
||||
commands::set_hidden,
|
||||
commands::pick_hidden,
|
||||
commands::test_notification,
|
||||
commands::notification_status,
|
||||
commands::probe_cookies,
|
||||
commands::cookie_probe,
|
||||
commands::list_browsers,
|
||||
commands::pair_browser,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+68
-25
@@ -43,6 +43,14 @@ pub struct UrlEvent {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A notification the user clicked, and the page object that raised it.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationClick {
|
||||
pub app_id: String,
|
||||
pub notification_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HiddenEvent {
|
||||
@@ -129,34 +137,11 @@ fn handle_sentinel(
|
||||
}
|
||||
|
||||
"notify" => {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let title = params.get("t").cloned().unwrap_or_default();
|
||||
let body = params.get("b").cloned().unwrap_or_default();
|
||||
let app_name = params.get("a").cloned().unwrap_or_default();
|
||||
// The app's own name leads, or a notification from four tools
|
||||
// in one window says nothing about which one wants you.
|
||||
let heading = if app_name.is_empty() {
|
||||
title.clone()
|
||||
} else {
|
||||
format!("{app_name} — {title}")
|
||||
};
|
||||
let outcome = match handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title(heading.clone())
|
||||
.body(body)
|
||||
.show()
|
||||
{
|
||||
Ok(()) => "raised".to_string(),
|
||||
Err(e) => {
|
||||
eprintln!("could not raise a notification: {e}");
|
||||
format!("failed: {e}")
|
||||
}
|
||||
};
|
||||
// Recorded so the diagnostic can show that a page's notification
|
||||
// actually reached macOS, not merely that the shim ran.
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
*state.last_notification.lock().unwrap() = format!("{heading} → {outcome}");
|
||||
let notification_id = params.get("id").cloned().unwrap_or_default();
|
||||
notify(&handle, &from, &app_name, &title, &body, notification_id);
|
||||
}
|
||||
|
||||
// Reports what the page found, over the same channel a real
|
||||
@@ -180,6 +165,58 @@ fn handle_sentinel(
|
||||
});
|
||||
}
|
||||
|
||||
/// Raises a macOS notification and waits, on its own thread, to see it clicked.
|
||||
///
|
||||
/// Not the notification plugin: that has no way to report a click, and a
|
||||
/// notification you cannot click through to the message is barely a
|
||||
/// notification. `send_notification` blocks until the user acts or it is
|
||||
/// dismissed, which is why this gets a thread of its own.
|
||||
fn notify(
|
||||
handle: &AppHandle,
|
||||
app_id: &str,
|
||||
app_name: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
notification_id: String,
|
||||
) {
|
||||
// The app's own name leads, or a notification from four tools in one
|
||||
// window says nothing about which one wants you.
|
||||
let heading = if app_name.is_empty() { "Work".to_string() } else { app_name.to_string() };
|
||||
let subtitle = title.to_string();
|
||||
let message = body.to_string();
|
||||
let handle = handle.clone();
|
||||
let app_id = app_id.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
let response = mac_notification_sys::send_notification(
|
||||
&heading,
|
||||
if subtitle.is_empty() { None } else { Some(&subtitle) },
|
||||
&message,
|
||||
None,
|
||||
);
|
||||
|
||||
match response {
|
||||
Ok(mac_notification_sys::NotificationResponse::Click) => {
|
||||
*state.last_notification.lock().unwrap() =
|
||||
format!("{heading} / {subtitle} → clicked");
|
||||
let _ = handle.emit(
|
||||
"notification-clicked",
|
||||
NotificationClick { app_id, notification_id },
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
*state.last_notification.lock().unwrap() =
|
||||
format!("{heading} / {subtitle} → raised");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("could not raise a notification: {e}");
|
||||
*state.last_notification.lock().unwrap() = format!("failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Turns on WKWebView's two-finger back and forward swipes.
|
||||
///
|
||||
/// wry supports it but Tauri does not expose it, so it is set on the native
|
||||
@@ -280,6 +317,10 @@ pub fn create(
|
||||
|
||||
enable_swipe_navigation(handle, &id);
|
||||
|
||||
if let Some(wv) = handle.get_webview(&label_for(&id)) {
|
||||
let _ = wv.set_zoom(app.zoom);
|
||||
}
|
||||
|
||||
// Created hidden. `show_only` is what puts one on screen, so startup does
|
||||
// not flash every app in turn as they are built.
|
||||
if let Some(wv) = handle.get_webview(&label_for(&id)) {
|
||||
@@ -300,6 +341,7 @@ pub fn show_only(
|
||||
if Some(app.id.as_str()) == app_id {
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||
let _ = wv.set_zoom(app.zoom);
|
||||
let _ = wv.show();
|
||||
} else {
|
||||
let _ = wv.hide();
|
||||
@@ -398,6 +440,7 @@ mod tests {
|
||||
group_id: None,
|
||||
user_agent: None,
|
||||
hidden: vec![".ad".into()],
|
||||
zoom: 1.0,
|
||||
order: 0,
|
||||
};
|
||||
let s = script_for(&app);
|
||||
|
||||
Reference in New Issue
Block a user