Browser pairing, notifications, element hiding; drop the top bar
Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1 against its Keychain key, then AES-128-CBC) and injects the result into WKHTTPCookieStore. Only the configured apps' hosts and their sign-in hosts survive the filter. Browsers are offered most-recently-used first, since the first entry becomes the default and someone with four Chromium browsers installed wants the one they actually browse in. WKWebView defines window.Notification but it does nothing: constructing one throws no error and shows no banner, so a page believes it notified you. Measured on the machine as `api=function shim=no` before the shim was made unconditional; `from page: Odoo - Test notification -> raised` after. Anything on a page can be right-clicked away. The rule is re-asserted on every navigation, because the injected script only carries a snapshot from when the view was built and a selector added since would otherwise come back on reload. The top bar is gone. Navigation lives beside the cog, the nav carries the traffic lights, and two-finger swipe goes back and forward. The seed is now the real app list, scoped to exact hosts so a Drive link inside Gmail switches rather than being swallowed.
This commit is contained in:
@@ -20,6 +20,10 @@ pub struct AppState {
|
||||
pub stage: Mutex<Stage>,
|
||||
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
|
||||
pub booted: Mutex<bool>,
|
||||
/// What the last page probe reported, for the notification diagnostic.
|
||||
pub diag: Mutex<String>,
|
||||
/// The last notification a page raised, and what macOS did with it.
|
||||
pub last_notification: Mutex<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -30,6 +34,32 @@ impl AppState {
|
||||
fn persist(&self) -> Result<(), String> {
|
||||
config::save(&self.dir, &self.config.lock().unwrap())
|
||||
}
|
||||
|
||||
/// Records a selector chosen by right-clicking it in the page.
|
||||
pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> {
|
||||
{
|
||||
let mut cfg = self.config.lock().unwrap();
|
||||
let app = cfg
|
||||
.apps
|
||||
.iter_mut()
|
||||
.find(|a| a.id == app_id)
|
||||
.ok_or_else(|| format!("no app {app_id}"))?;
|
||||
if app.hidden.iter().any(|s| s == selector) {
|
||||
return Ok(());
|
||||
}
|
||||
app.hidden.push(selector.to_string());
|
||||
}
|
||||
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> {
|
||||
@@ -44,6 +74,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
active: Mutex::new(None),
|
||||
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
||||
booted: Mutex::new(false),
|
||||
diag: Mutex::new(String::new()),
|
||||
last_notification: Mutex::new(String::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,6 +242,7 @@ pub fn add_app(
|
||||
scope: vec![scope],
|
||||
group_id,
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
order,
|
||||
};
|
||||
cfg.apps.push(new.clone());
|
||||
@@ -361,3 +394,203 @@ pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String
|
||||
state.config.lock().unwrap().settings.theme = theme;
|
||||
state.persist()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- hidden elements
|
||||
|
||||
/// Replaces an app's hidden selectors and re-applies them without a reload.
|
||||
#[tauri::command]
|
||||
pub fn set_hidden(
|
||||
app_id: String,
|
||||
hidden: Vec<String>,
|
||||
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.hidden = hidden.clone();
|
||||
}
|
||||
state.persist()?;
|
||||
webviews::push_hidden(&app, &app_id, &hidden);
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
/// Starts the in-page element picker, for reaching something a right-click
|
||||
/// cannot land on cleanly.
|
||||
#[tauri::command]
|
||||
pub fn pick_hidden(app_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("window.__workAppPick && window.__workAppPick()")
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Permission state, and what a notification raised straight from Rust does.
|
||||
///
|
||||
/// Splits the chain in two: if this succeeds and the page test does not, the
|
||||
/// shim is at fault; if this fails, macOS never granted permission.
|
||||
#[tauri::command]
|
||||
pub fn notification_status(app: AppHandle) -> String {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
let state = match app.notification().permission_state() {
|
||||
Ok(s) => format!("{s:?}"),
|
||||
Err(e) => format!("unknown ({e})"),
|
||||
};
|
||||
let raised = match app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Work")
|
||||
.body("Notifications are working.")
|
||||
.show()
|
||||
{
|
||||
Ok(()) => "raised".to_string(),
|
||||
Err(e) => format!("failed: {e}"),
|
||||
};
|
||||
let app_state = app.state::<AppState>();
|
||||
let diag = app_state.diag.lock().unwrap().clone();
|
||||
let last = app_state.last_notification.lock().unwrap().clone();
|
||||
let page = if diag.is_empty() { "not run yet".into() } else { diag };
|
||||
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
||||
format!("permission: {state} · direct: {raised} · page: {page} · from page: {from_page}")
|
||||
}
|
||||
|
||||
/// Asks macOS for notification permission, once, at startup.
|
||||
pub fn ensure_notification_permission(app: &AppHandle) {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let granted = matches!(
|
||||
app.notification().permission_state(),
|
||||
Ok(tauri_plugin_notification::PermissionState::Granted)
|
||||
);
|
||||
if !granted {
|
||||
if let Err(e) = app.notification().request_permission() {
|
||||
eprintln!("notification permission was refused: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires a notification the way a page would.
|
||||
///
|
||||
/// Deliberately routed through the injected shim rather than raised directly:
|
||||
/// the thing worth testing is the whole chain — page API, sentinel, Rust, and
|
||||
/// macOS — not whether this process can show a notification.
|
||||
#[tauri::command]
|
||||
pub fn test_notification(app_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"))?;
|
||||
// The probe reports what it found before raising anything, so a
|
||||
// notification that never appears still says why.
|
||||
wv.eval(
|
||||
r#"(function () {
|
||||
var kind = typeof window.Notification;
|
||||
var shim = !!(window.Notification && window.Notification.__work);
|
||||
var err = '';
|
||||
try {
|
||||
new Notification('Test notification',
|
||||
{ body: 'If you can see this, pages can reach you.' });
|
||||
} catch (e) { err = String(e); }
|
||||
if (window.__workAppSend) {
|
||||
window.__workAppSend('diag', { api: kind, shim: shim ? 'yes' : 'no', err: err });
|
||||
}
|
||||
})();"#,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- 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."
|
||||
));
|
||||
}
|
||||
|
||||
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)]
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+31
-13
@@ -29,6 +29,10 @@ pub struct App {
|
||||
pub group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_agent: Option<String>,
|
||||
/// CSS selectors this app hides on every page. Chosen by right-clicking
|
||||
/// the thing you never want to see again.
|
||||
#[serde(default)]
|
||||
pub hidden: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
}
|
||||
@@ -167,8 +171,11 @@ pub fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// First-run contents: enough apps, across enough groups, that grouping and
|
||||
/// cross-app link routing can both be seen working without typing anything.
|
||||
/// First-run contents: the tools this was built for.
|
||||
///
|
||||
/// Each scope is the exact host, so a link from Gmail to Drive switches rather
|
||||
/// than being swallowed by whichever Google app happens to be showing. The
|
||||
/// `/u/N/` paths pin the second Google account, which is the one that matters.
|
||||
pub fn seed() -> Config {
|
||||
let mk = |name: &str, url: &str, group: &str, order: i32| App {
|
||||
id: new_id(),
|
||||
@@ -177,22 +184,20 @@ pub fn seed() -> Config {
|
||||
scope: default_scope(url).into_iter().collect(),
|
||||
group_id: Some(group.into()),
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
order,
|
||||
};
|
||||
Config {
|
||||
version: 1,
|
||||
groups: vec![
|
||||
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 0 },
|
||||
Group { id: "g-dev".into(), name: "Dev".into(), collapsed: false, order: 1 },
|
||||
Group { id: "g-ref".into(), name: "Reference".into(), collapsed: false, order: 2 },
|
||||
Group { id: "g-work".into(), name: "Work".into(), collapsed: false, order: 0 },
|
||||
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 1 },
|
||||
],
|
||||
apps: vec![
|
||||
mk("Google", "https://www.google.com", "g-google", 0),
|
||||
mk("YouTube", "https://www.youtube.com", "g-google", 1),
|
||||
mk("GitHub", "https://github.com", "g-dev", 0),
|
||||
mk("Hacker News", "https://news.ycombinator.com", "g-dev", 1),
|
||||
mk("Wikipedia", "https://en.wikipedia.org", "g-ref", 0),
|
||||
mk("MDN", "https://developer.mozilla.org", "g-ref", 1),
|
||||
mk("Odoo", "https://example.odoo.com/web", "g-work", 0),
|
||||
mk("Gmail", "https://mail.google.com/mail/u/N/", "g-google", 0),
|
||||
mk("Drive", "https://drive.google.com/drive/u/N/my-drive", "g-google", 1),
|
||||
mk("Chat", "https://chat.google.com/u/N/app/home", "g-google", 2),
|
||||
],
|
||||
settings: Settings::default(),
|
||||
}
|
||||
@@ -240,10 +245,23 @@ mod tests {
|
||||
assert_eq!(normalize_url("http://intranet.local"), "http://intranet.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_scopes_are_exact_hosts_that_cannot_swallow_each_other() {
|
||||
// The whole point of exact hosts: a Drive link inside Gmail must match
|
||||
// Drive, not Gmail. A shared `google.com` scope would break that.
|
||||
let cfg = seed();
|
||||
let scopes: Vec<String> = cfg.apps.iter().flat_map(|a| a.scopes()).collect();
|
||||
assert!(scopes.contains(&"mail.google.com".to_string()));
|
||||
assert!(scopes.contains(&"drive.google.com".to_string()));
|
||||
assert!(scopes.contains(&"chat.google.com".to_string()));
|
||||
assert!(scopes.contains(&"example.odoo.com".to_string()));
|
||||
assert!(!scopes.contains(&"google.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_apps_all_carry_a_scope() {
|
||||
let cfg = seed();
|
||||
assert_eq!(cfg.apps.len(), 6);
|
||||
assert_eq!(cfg.apps.len(), 4);
|
||||
assert!(cfg.apps.iter().all(|a| !a.scopes().is_empty()));
|
||||
}
|
||||
|
||||
@@ -251,7 +269,7 @@ mod tests {
|
||||
fn ordering_follows_group_then_position() {
|
||||
let cfg = seed();
|
||||
let names: Vec<&str> = cfg.ordered().iter().map(|a| a.name.as_str()).collect();
|
||||
assert_eq!(names, ["Google", "YouTube", "GitHub", "Hacker News", "Wikipedia", "MDN"]);
|
||||
assert_eq!(names, ["Odoo", "Gmail", "Drive", "Chat"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! 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())?
|
||||
}
|
||||
@@ -40,6 +40,9 @@ pub struct Browser {
|
||||
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>,
|
||||
@@ -64,25 +67,37 @@ pub fn chromium_browsers() -> Vec<(&'static str, &'static str, std::path::PathBu
|
||||
]
|
||||
}
|
||||
|
||||
/// The browsers this machine actually has, for the Settings picker.
|
||||
/// 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 out: Vec<Browser> = chromium_browsers()
|
||||
let mut found: Vec<(Browser, Option<std::time::SystemTime>)> = 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(),
|
||||
.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();
|
||||
|
||||
out.push(Browser {
|
||||
id: "firefox".into(),
|
||||
label: "Firefox".into(),
|
||||
available: firefox::find_cookie_db().is_some(),
|
||||
});
|
||||
found.push((
|
||||
Browser { id: "firefox".into(), label: "Firefox".into(), available: firefox::find_cookie_db().is_some() },
|
||||
last_used(firefox::find_cookie_db()),
|
||||
));
|
||||
|
||||
out.retain(|b| b.available);
|
||||
out
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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
|
||||
* menu on the page. Configuration arrives as `window.__WORKAPP`, written
|
||||
* immediately above this by Rust.
|
||||
*
|
||||
* Everything talks back over a made-up URL scheme that `on_navigation` answers
|
||||
* and cancels. Deliberately not Tauri IPC, which would mean handing the page
|
||||
* the ability to call into the app.
|
||||
*/
|
||||
(function () {
|
||||
if (window.__workAppReady) return;
|
||||
window.__workAppReady = true;
|
||||
|
||||
var CFG = window.__WORKAPP || { scopes: [], hidden: [], name: '' };
|
||||
var queue = [];
|
||||
var sending = false;
|
||||
|
||||
/* Assignments to location are cancelled by the navigation delegate, but two
|
||||
in the same tick would lose one — so they go out one at a time. */
|
||||
function drain() {
|
||||
if (sending || !queue.length) return;
|
||||
sending = true;
|
||||
var url = queue.shift();
|
||||
try { window.location.href = url; } catch (e) {}
|
||||
setTimeout(function () { sending = false; drain(); }, 0);
|
||||
}
|
||||
|
||||
function send(kind, data) {
|
||||
var q = Object.keys(data)
|
||||
.map(function (k) { return k + '=' + encodeURIComponent(data[k]); })
|
||||
.join('&');
|
||||
queue.push('workapp-' + kind + ':/?' + q);
|
||||
drain();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ routing */
|
||||
|
||||
function abs(href) {
|
||||
try { return new URL(href, document.baseURI).href; } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function inScope(u) {
|
||||
try {
|
||||
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
|
||||
return CFG.scopes.some(function (s) {
|
||||
s = String(s).replace(/^www\./, '').toLowerCase();
|
||||
return h === s || h.endsWith('.' + s);
|
||||
});
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (picking) return;
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href');
|
||||
if (!href || href.charAt(0) === '#') return;
|
||||
if (/^(javascript|blob|data):/i.test(href)) return;
|
||||
var u = abs(href);
|
||||
if (!u) return;
|
||||
|
||||
if (inScope(u)) {
|
||||
/* A new-tab link has nowhere to go in a tabless app, so it takes over
|
||||
this view rather than being swallowed. */
|
||||
if (a.target === '_blank') { e.preventDefault(); window.location.href = u; }
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
send('route', { u: u });
|
||||
}, true);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
window.open = function (url) {
|
||||
if (!url) return null;
|
||||
var u = abs(url);
|
||||
if (!u) return null;
|
||||
if (inScope(u)) { window.location.href = u; return null; }
|
||||
send('route', { u: u });
|
||||
return null;
|
||||
};
|
||||
void nativeOpen;
|
||||
|
||||
/* ------------------------------------------------------ hiding elements */
|
||||
|
||||
var STYLE_ID = '__workapp_hidden';
|
||||
|
||||
/* The injected configuration is a snapshot from when this view was built, so
|
||||
a selector added since would vanish on reload. The page's own storage
|
||||
carries the current list across loads, and Rust rewrites it on every
|
||||
navigation — so the rule is in place before the first paint rather than
|
||||
arriving after the thing has already flashed on screen. */
|
||||
function stored() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STYLE_ID);
|
||||
var list = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(list) ? list : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
var hidden = stored() || (CFG.hidden || []).slice();
|
||||
|
||||
function applyHidden() {
|
||||
var css = hidden.length
|
||||
? hidden.join(',\n') + ' { display: none !important; }'
|
||||
: '';
|
||||
var el = document.getElementById(STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = STYLE_ID;
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
if (el.textContent !== css) el.textContent = css;
|
||||
try { localStorage.setItem(STYLE_ID, JSON.stringify(hidden)); } catch (e) {}
|
||||
}
|
||||
|
||||
/* Single-page apps rewrite <head>, which takes the rule with it. */
|
||||
function watch() {
|
||||
applyHidden();
|
||||
try {
|
||||
new MutationObserver(function () {
|
||||
if (!document.getElementById(STYLE_ID)) applyHidden();
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A selector for one element, preferring things likely to survive a reload.
|
||||
*
|
||||
* An id wins outright. Otherwise it climbs, taking up to two stable-looking
|
||||
* classes per level and falling back to position. Framework classes that are
|
||||
* hashed or numeric are skipped, since those change on every deploy.
|
||||
*/
|
||||
function selectorFor(el) {
|
||||
function idOf(n) {
|
||||
var id = n.getAttribute && n.getAttribute('id');
|
||||
return id && /^[A-Za-z][\w-]*$/.test(id) ? '#' + CSS.escape(id) : null;
|
||||
}
|
||||
if (idOf(el)) return idOf(el);
|
||||
|
||||
var parts = [];
|
||||
var node = el;
|
||||
while (node && node.nodeType === 1 && parts.length < 5) {
|
||||
if (node.tagName === 'BODY' || node.tagName === 'HTML') break;
|
||||
var id = idOf(node);
|
||||
if (id) { parts.unshift(id); break; }
|
||||
|
||||
var part = node.tagName.toLowerCase();
|
||||
var classes = (node.getAttribute('class') || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(function (c) {
|
||||
return c && c.length < 32 && /^[A-Za-z_-][\w-]*$/.test(c) && !/\d{3,}/.test(c);
|
||||
})
|
||||
.slice(0, 2);
|
||||
|
||||
if (classes.length) {
|
||||
part += '.' + classes.map(function (c) { return CSS.escape(c); }).join('.');
|
||||
} else {
|
||||
var p = node.parentElement;
|
||||
if (p) {
|
||||
var same = Array.prototype.filter.call(p.children, function (c) {
|
||||
return c.tagName === node.tagName;
|
||||
});
|
||||
if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
|
||||
}
|
||||
}
|
||||
parts.unshift(part);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return parts.join(' ') || el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
function hide(el) {
|
||||
if (!el || el === document.body || el === document.documentElement) return;
|
||||
var sel = selectorFor(el);
|
||||
try { if (!document.querySelector(sel)) return; } catch (e) { return; }
|
||||
if (hidden.indexOf(sel) === -1) hidden.push(sel);
|
||||
applyHidden();
|
||||
send('hide', { s: sel });
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- element picker */
|
||||
|
||||
var picking = false;
|
||||
var marker = null;
|
||||
var lastTarget = null;
|
||||
|
||||
function ensureMarker() {
|
||||
if (marker) return marker;
|
||||
marker = document.createElement('div');
|
||||
marker.style.cssText = [
|
||||
'position:fixed', 'z-index:2147483646', 'pointer-events:none',
|
||||
'border:2px solid #0ea5e9', 'background:rgba(14,165,233,0.18)',
|
||||
'border-radius:3px', 'transition:all 60ms ease-out'
|
||||
].join(';');
|
||||
document.documentElement.appendChild(marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function onPickMove(e) {
|
||||
var el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!el || el === marker) return;
|
||||
lastTarget = el;
|
||||
var r = el.getBoundingClientRect();
|
||||
var m = ensureMarker();
|
||||
m.style.top = r.top + 'px';
|
||||
m.style.left = r.left + 'px';
|
||||
m.style.width = r.width + 'px';
|
||||
m.style.height = r.height + 'px';
|
||||
}
|
||||
|
||||
function onPickKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); stopPicking(); }
|
||||
/* Arrow up widens the selection to the parent, for when the thing you
|
||||
want is the container rather than the text you can point at. */
|
||||
if (e.key === 'ArrowUp' && lastTarget && lastTarget.parentElement) {
|
||||
e.preventDefault();
|
||||
lastTarget = lastTarget.parentElement;
|
||||
var r = lastTarget.getBoundingClientRect();
|
||||
var m = ensureMarker();
|
||||
m.style.top = r.top + 'px'; m.style.left = r.left + 'px';
|
||||
m.style.width = r.width + 'px'; m.style.height = r.height + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function onPickClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var el = lastTarget || document.elementFromPoint(e.clientX, e.clientY);
|
||||
stopPicking();
|
||||
hide(el);
|
||||
}
|
||||
|
||||
function startPicking() {
|
||||
if (picking) return;
|
||||
picking = true;
|
||||
ensureMarker().style.display = 'block';
|
||||
document.addEventListener('mousemove', onPickMove, true);
|
||||
document.addEventListener('click', onPickClick, true);
|
||||
document.addEventListener('keydown', onPickKey, true);
|
||||
}
|
||||
|
||||
function stopPicking() {
|
||||
picking = false;
|
||||
if (marker) marker.style.display = 'none';
|
||||
document.removeEventListener('mousemove', onPickMove, true);
|
||||
document.removeEventListener('click', onPickClick, true);
|
||||
document.removeEventListener('keydown', onPickKey, true);
|
||||
}
|
||||
|
||||
/* Reachable from the shell: starting the picker, and reporting back. */
|
||||
window.__workAppPick = startPicking;
|
||||
window.__workAppSend = send;
|
||||
|
||||
/* ------------------------------------------------------- context menu */
|
||||
|
||||
var menu = null;
|
||||
|
||||
function closeMenu() {
|
||||
if (menu) { menu.remove(); menu = null; }
|
||||
}
|
||||
|
||||
function openMenu(x, y, target) {
|
||||
closeMenu();
|
||||
menu = document.createElement('div');
|
||||
menu.style.cssText = [
|
||||
'position:fixed', 'z-index:2147483647', 'min-width:210px',
|
||||
'padding:5px', 'border-radius:10px',
|
||||
'background:#ffffff', 'color:#1e293b',
|
||||
'border:1px solid #cbd5e1',
|
||||
'box-shadow:0 12px 28px rgba(2,6,23,0.22)',
|
||||
'font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
||||
'left:' + x + 'px', 'top:' + y + 'px'
|
||||
].join(';');
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
menu.style.background = '#0f172b';
|
||||
menu.style.color = '#e2e8f0';
|
||||
menu.style.borderColor = '#334155';
|
||||
}
|
||||
|
||||
[
|
||||
['Hide this element', function () { hide(target); }],
|
||||
['Pick an element to hide…', startPicking],
|
||||
['Manage hidden elements…', function () { send('manage', { x: '1' }); }]
|
||||
].forEach(function (item) {
|
||||
var b = document.createElement('div');
|
||||
b.textContent = item[0];
|
||||
b.style.cssText = 'padding:6px 10px;border-radius:6px;cursor:pointer;white-space:nowrap';
|
||||
b.addEventListener('mouseenter', function () { b.style.background = 'rgba(14,165,233,0.15)'; });
|
||||
b.addEventListener('mouseleave', function () { b.style.background = 'transparent'; });
|
||||
b.addEventListener('click', function (ev) {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
closeMenu();
|
||||
item[1]();
|
||||
}, true);
|
||||
menu.appendChild(b);
|
||||
});
|
||||
|
||||
document.documentElement.appendChild(menu);
|
||||
/* Keep it on screen when the click was near an edge. */
|
||||
var r = menu.getBoundingClientRect();
|
||||
if (r.right > innerWidth) menu.style.left = Math.max(4, innerWidth - r.width - 6) + 'px';
|
||||
if (r.bottom > innerHeight) menu.style.top = Math.max(4, innerHeight - r.height - 6) + 'px';
|
||||
}
|
||||
|
||||
document.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY, e.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (menu && !menu.contains(e.target)) closeMenu();
|
||||
}, true);
|
||||
|
||||
/* ------------------------------------------------------- notifications */
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- start */
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', watch);
|
||||
applyHidden();
|
||||
} else {
|
||||
watch();
|
||||
}
|
||||
})();
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod routing;
|
||||
pub mod cookies;
|
||||
pub mod webviews;
|
||||
|
||||
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
|
||||
@@ -50,9 +51,14 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.menu(build_menu)
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
let state = commands::build_state(&app.handle().clone())?;
|
||||
app.manage(state);
|
||||
|
||||
// Asked for at startup rather than at the first notification, so
|
||||
// the prompt does not arrive attached to someone else's message.
|
||||
commands::ensure_notification_permission(&app.handle().clone());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -75,6 +81,12 @@ pub fn run() {
|
||||
commands::delete_group,
|
||||
commands::set_nav_collapsed,
|
||||
commands::set_theme,
|
||||
commands::set_hidden,
|
||||
commands::pick_hidden,
|
||||
commands::test_notification,
|
||||
commands::notification_status,
|
||||
commands::list_browsers,
|
||||
commands::pair_browser,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -65,6 +65,12 @@ pub fn host_matches(host: &str, scope: &str) -> bool {
|
||||
host == scope || host.ends_with(&format!(".{scope}"))
|
||||
}
|
||||
|
||||
/// The sign-in hosts, for the cookie import — a session for a tool is no use
|
||||
/// without the identity provider's cookie that vouches for it.
|
||||
pub fn identity_providers() -> &'static [&'static str] {
|
||||
IDENTITY_PROVIDERS
|
||||
}
|
||||
|
||||
fn is_identity_provider(host: &str) -> bool {
|
||||
IDENTITY_PROVIDERS.iter().any(|p| host_matches(host, p))
|
||||
}
|
||||
|
||||
+238
-109
@@ -5,23 +5,25 @@
|
||||
//! and anything the shell wants to draw over an app (a dialog) requires hiding
|
||||
//! the app first.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
|
||||
webview::{NewWindowResponse, WebviewBuilder},
|
||||
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::{App, Config};
|
||||
use crate::routing::{self, AppScope, Decision};
|
||||
|
||||
/// Scheme the injected interceptor uses to hand a URL back for a decision.
|
||||
/// Scheme prefix the injected script uses to talk back.
|
||||
///
|
||||
/// A made-up scheme rather than Tauri IPC: IPC to a remote origin means
|
||||
/// granting google.com the ability to call into this app, and routing a link
|
||||
/// does not need anything that dangerous. `on_navigation` sees this, answers
|
||||
/// it, and cancels the navigation — so nothing ever loads.
|
||||
const ROUTE_SCHEME: &str = "workapp-route";
|
||||
/// granting google.com the ability to call into this app, and none of these
|
||||
/// messages need anything that dangerous. `on_navigation` answers them and
|
||||
/// cancels the navigation, so nothing ever loads.
|
||||
const SCHEME_PREFIX: &str = "workapp-";
|
||||
|
||||
pub fn label_for(app_id: &str) -> String {
|
||||
format!("app-{app_id}")
|
||||
@@ -41,86 +43,40 @@ pub struct UrlEvent {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// The click interceptor, specialised for one app's scope.
|
||||
///
|
||||
/// It only decides *intent*: a link the user clicked that leaves this app's
|
||||
/// hosts is prevented and handed to Rust. Redirects, form posts and OAuth
|
||||
/// bounces are untouched, which is what keeps sign-in flows alive.
|
||||
fn interceptor_script(scopes: &[String]) -> String {
|
||||
let json = serde_json::to_string(scopes).unwrap_or_else(|_| "[]".into());
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HiddenEvent {
|
||||
pub app_id: String,
|
||||
pub selector: String,
|
||||
}
|
||||
|
||||
/// The page script, with this app's configuration written above it.
|
||||
fn script_for(app: &App) -> String {
|
||||
let cfg = serde_json::json!({
|
||||
"scopes": app.scopes(),
|
||||
"hidden": app.hidden,
|
||||
"name": app.name,
|
||||
});
|
||||
format!(
|
||||
r#"(function () {{
|
||||
if (window.__workAppRouter) return;
|
||||
window.__workAppRouter = true;
|
||||
var SCOPES = {json};
|
||||
|
||||
function abs(href) {{ try {{ return new URL(href, document.baseURI).href; }} catch (e) {{ return null; }} }}
|
||||
function inScope(u) {{
|
||||
try {{
|
||||
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
|
||||
return SCOPES.some(function (s) {{
|
||||
s = String(s).replace(/^www\./, '').toLowerCase();
|
||||
return h === s || h.endsWith('.' + s);
|
||||
}});
|
||||
}} catch (e) {{ return false; }}
|
||||
}}
|
||||
function ask(u) {{
|
||||
try {{ window.location.href = '{scheme}:/?u=' + encodeURIComponent(u); }} catch (e) {{}}
|
||||
}}
|
||||
|
||||
document.addEventListener('click', function (e) {{
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href');
|
||||
if (!href || href.charAt(0) === '#') return;
|
||||
if (/^(javascript|blob|data):/i.test(href)) return;
|
||||
var u = abs(href);
|
||||
if (!u) return;
|
||||
|
||||
if (inScope(u)) {{
|
||||
// Our own host. A new-tab link has nowhere to go in a tabless app, so
|
||||
// it takes over this view rather than being swallowed.
|
||||
if (a.target === '_blank') {{ e.preventDefault(); window.location.href = u; }}
|
||||
return;
|
||||
}}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ask(u);
|
||||
}}, true);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
window.open = function (url) {{
|
||||
if (!url) return null;
|
||||
var u = abs(url);
|
||||
if (!u) return null;
|
||||
if (inScope(u)) {{ window.location.href = u; return null; }}
|
||||
ask(u);
|
||||
return null;
|
||||
}};
|
||||
void nativeOpen;
|
||||
}})();"#,
|
||||
json = json,
|
||||
scheme = ROUTE_SCHEME
|
||||
"window.__WORKAPP = {};\n{}",
|
||||
cfg,
|
||||
include_str!("inject.js")
|
||||
)
|
||||
}
|
||||
|
||||
/// Pulls the URL back out of a `workapp-route:/?u=…` sentinel.
|
||||
pub fn route_target(url: &Url) -> Option<String> {
|
||||
if url.scheme() != ROUTE_SCHEME {
|
||||
return None;
|
||||
}
|
||||
url.query_pairs()
|
||||
.find(|(k, _)| k == "u")
|
||||
.map(|(_, v)| v.to_string())
|
||||
/// Splits a sentinel URL into its kind and query parameters.
|
||||
pub fn sentinel(url: &Url) -> Option<(String, HashMap<String, String>)> {
|
||||
let kind = url.scheme().strip_prefix(SCHEME_PREFIX)?.to_string();
|
||||
let params = url
|
||||
.query_pairs()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
Some((kind, params))
|
||||
}
|
||||
|
||||
/// Acts on a decision. Called off the navigation delegate, never on it.
|
||||
fn apply(handle: &AppHandle, from: &str, decision: Decision) {
|
||||
/// Acts on a routing decision. Called off the navigation delegate, never on it.
|
||||
fn apply(handle: &AppHandle, decision: Decision) {
|
||||
match decision {
|
||||
// Only an identity provider reaches here, and the click that produced
|
||||
// it was already cancelled — so this view has to be sent there.
|
||||
Decision::Stay => {}
|
||||
Decision::Switch { app_id, url } => {
|
||||
let _ = handle.emit("switch-app", SwitchEvent { app_id, url });
|
||||
@@ -129,31 +85,130 @@ fn apply(handle: &AppHandle, from: &str, decision: Decision) {
|
||||
let _ = tauri_plugin_opener::open_url(url, None::<&str>);
|
||||
}
|
||||
}
|
||||
let _ = from;
|
||||
}
|
||||
|
||||
/// Handles a sentinel URL: decide, then act without blocking the delegate.
|
||||
fn handle_route(handle: &AppHandle, from: &str, target: String, scopes: Vec<AppScope>) {
|
||||
/// Answers one message from the page, without blocking the delegate.
|
||||
fn handle_sentinel(
|
||||
handle: &AppHandle,
|
||||
from: &str,
|
||||
kind: String,
|
||||
params: HashMap<String, String>,
|
||||
scopes: Vec<AppScope>,
|
||||
) {
|
||||
let handle = handle.clone();
|
||||
let from = from.to_string();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match routing::decide(&target, Some(&from), &scopes) {
|
||||
Decision::Stay => {
|
||||
// The click was cancelled to ask the question, so completing it
|
||||
// is now this side's job.
|
||||
if let Some(wv) = handle.get_webview(&label_for(&from)) {
|
||||
if let Ok(u) = Url::parse(&target) {
|
||||
let _ = wv.navigate(u);
|
||||
match kind.as_str() {
|
||||
"route" => {
|
||||
let Some(target) = params.get("u") else { return };
|
||||
match routing::decide(target, Some(&from), &scopes) {
|
||||
// Only an identity provider reaches here, and the click was
|
||||
// already cancelled to ask the question — so completing it
|
||||
// is now this side's job.
|
||||
Decision::Stay => {
|
||||
if let Some(wv) = handle.get_webview(&label_for(&from)) {
|
||||
if let Ok(u) = Url::parse(target) {
|
||||
let _ = wv.navigate(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => apply(&handle, other),
|
||||
}
|
||||
}
|
||||
other => apply(&handle, &from, other),
|
||||
|
||||
"hide" => {
|
||||
let Some(selector) = params.get("s") else { return };
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
if state.add_hidden(&from, selector).is_ok() {
|
||||
let _ = handle.emit(
|
||||
"hidden-added",
|
||||
HiddenEvent { app_id: from.clone(), selector: selector.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
"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}");
|
||||
}
|
||||
|
||||
// Reports what the page found, over the same channel a real
|
||||
// notification uses — so a silent failure says which half broke.
|
||||
"diag" => {
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
let mut parts: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
parts.sort();
|
||||
*state.diag.lock().unwrap() = parts.join(" ");
|
||||
}
|
||||
|
||||
"manage" => {
|
||||
let _ = handle.emit("manage-hidden", from.clone());
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// view after the fact. It is the only navigation gesture the app has, now
|
||||
/// that there is no toolbar carrying arrows.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn enable_swipe_navigation(handle: &AppHandle, app_id: &str) {
|
||||
use objc2_web_kit::WKWebView;
|
||||
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
|
||||
let _ = wv.with_webview(|platform| unsafe {
|
||||
let ptr = platform.inner() as *const WKWebView;
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
(*ptr).setAllowsBackForwardNavigationGestures(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn enable_swipe_navigation(_: &AppHandle, _: &str) {}
|
||||
|
||||
/// Builds the child webview for one app and parks it in the stage rect.
|
||||
pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64, f64)) -> Result<(), String> {
|
||||
pub fn create(
|
||||
handle: &AppHandle,
|
||||
app: &App,
|
||||
cfg: &Config,
|
||||
stage: (f64, f64, f64, f64),
|
||||
) -> Result<(), String> {
|
||||
let window = handle
|
||||
.get_window("main")
|
||||
.ok_or_else(|| "main window is gone".to_string())?;
|
||||
@@ -170,15 +225,18 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
|
||||
let win_id = id.clone();
|
||||
let win_scopes = scopes.clone();
|
||||
|
||||
let load_handle = handle.clone();
|
||||
let load_id = id.clone();
|
||||
|
||||
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
|
||||
.user_agent(&app.ua())
|
||||
.initialization_script(interceptor_script(&app.scopes()))
|
||||
.initialization_script(script_for(app))
|
||||
.on_navigation(move |url| {
|
||||
// The sentinel is a question, not a destination: answer it and
|
||||
// A sentinel is a question, not a destination: answer it and
|
||||
// cancel. Everything else is allowed — a strict filter here would
|
||||
// break every OAuth redirect chain.
|
||||
if let Some(target) = route_target(url) {
|
||||
handle_route(&nav_handle, &nav_id, target, nav_scopes.clone());
|
||||
if let Some((kind, params)) = sentinel(url) {
|
||||
handle_sentinel(&nav_handle, &nav_id, kind, params, nav_scopes.clone());
|
||||
return false;
|
||||
}
|
||||
let _ = nav_handle.emit(
|
||||
@@ -189,16 +247,27 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
|
||||
})
|
||||
.on_new_window(move |url, _features| {
|
||||
// Nothing may open a window of its own; the same rules apply.
|
||||
let decision = routing::decide(url.as_str(), Some(&win_id), &win_scopes);
|
||||
match decision {
|
||||
match routing::decide(url.as_str(), Some(&win_id), &win_scopes) {
|
||||
Decision::Stay => {
|
||||
if let Some(wv) = win_handle.get_webview(&label_for(&win_id)) {
|
||||
let _ = wv.navigate(url);
|
||||
}
|
||||
}
|
||||
other => apply(&win_handle, &win_id, other),
|
||||
other => apply(&win_handle, other),
|
||||
}
|
||||
NewWindowResponse::Deny
|
||||
})
|
||||
// The script carries a snapshot of the hidden list from when the view
|
||||
// was built, so anything chosen since would come back on reload. This
|
||||
// re-asserts the real list on every navigation.
|
||||
.on_page_load(move |_wv, _payload| {
|
||||
let state = load_handle.state::<crate::commands::AppState>();
|
||||
let hidden = state
|
||||
.cfg()
|
||||
.app(&load_id)
|
||||
.map(|a| a.hidden.clone())
|
||||
.unwrap_or_default();
|
||||
push_hidden(&load_handle, &load_id, &hidden);
|
||||
});
|
||||
|
||||
window
|
||||
@@ -209,8 +278,10 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Created hidden. `show` is what puts one on screen, so startup does not
|
||||
// flash every app in turn as they are built.
|
||||
enable_swipe_navigation(handle, &id);
|
||||
|
||||
// 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)) {
|
||||
let _ = wv.hide();
|
||||
}
|
||||
@@ -218,7 +289,12 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
|
||||
}
|
||||
|
||||
/// Shows one app and hides the rest, sizing it to the stage.
|
||||
pub fn show_only(handle: &AppHandle, app_id: Option<&str>, cfg: &Config, stage: (f64, f64, f64, f64)) {
|
||||
pub fn show_only(
|
||||
handle: &AppHandle,
|
||||
app_id: Option<&str>,
|
||||
cfg: &Config,
|
||||
stage: (f64, f64, f64, f64),
|
||||
) {
|
||||
for app in &cfg.apps {
|
||||
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
||||
if Some(app.id.as_str()) == app_id {
|
||||
@@ -254,26 +330,79 @@ pub fn destroy(handle: &AppHandle, app_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-applies an app's hidden selectors without a reload.
|
||||
///
|
||||
/// The script owns the stylesheet, so changing the list from Settings is a
|
||||
/// message to the page rather than a rebuild of the view.
|
||||
pub fn push_hidden(handle: &AppHandle, app_id: &str, hidden: &[String]) {
|
||||
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
|
||||
let json = serde_json::to_string(hidden).unwrap_or_else(|_| "[]".into());
|
||||
let script = format!(
|
||||
r#"(function(){{
|
||||
var css = {json}.length ? {json}.join(',\n') + ' {{ display: none !important; }}' : '';
|
||||
var el = document.getElementById('__workapp_hidden');
|
||||
if (!el) {{
|
||||
el = document.createElement('style');
|
||||
el.id = '__workapp_hidden';
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}}
|
||||
el.textContent = css;
|
||||
try {{ localStorage.setItem('__workapp_hidden', JSON.stringify({json})); }} catch (e) {{}}
|
||||
}})();"#
|
||||
);
|
||||
let _ = wv.eval(&script);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn route_target_round_trips_a_url_with_a_query() {
|
||||
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1%26c%3D2").unwrap();
|
||||
assert_eq!(route_target(&u).unwrap(), "https://x.com/a?b=1&c=2");
|
||||
fn sentinel_splits_kind_from_parameters() {
|
||||
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1").unwrap();
|
||||
let (kind, params) = sentinel(&u).unwrap();
|
||||
assert_eq!(kind, "route");
|
||||
assert_eq!(params["u"], "https://x.com/a?b=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_reads_a_notification() {
|
||||
let u = Url::parse("workapp-notify:/?t=New%20mail&b=From%20Sam&a=Gmail").unwrap();
|
||||
let (kind, params) = sentinel(&u).unwrap();
|
||||
assert_eq!(kind, "notify");
|
||||
assert_eq!(params["t"], "New mail");
|
||||
assert_eq!(params["b"], "From Sam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_reads_a_hide_selector() {
|
||||
let u = Url::parse("workapp-hide:/?s=%23promo%20.banner").unwrap();
|
||||
let (kind, params) = sentinel(&u).unwrap();
|
||||
assert_eq!(kind, "hide");
|
||||
assert_eq!(params["s"], "#promo .banner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_urls_are_not_sentinels() {
|
||||
let u = Url::parse("https://github.com/?u=x").unwrap();
|
||||
assert!(route_target(&u).is_none());
|
||||
assert!(sentinel(&Url::parse("https://github.com/?u=x").unwrap()).is_none());
|
||||
assert!(sentinel(&Url::parse("mailto:a@b.com").unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_carries_the_apps_own_scope() {
|
||||
let s = interceptor_script(&["mail.google.com".into()]);
|
||||
fn the_script_carries_this_apps_own_configuration() {
|
||||
let app = App {
|
||||
id: "a".into(),
|
||||
name: "Gmail".into(),
|
||||
url: "https://mail.google.com".into(),
|
||||
scope: vec!["mail.google.com".into()],
|
||||
group_id: None,
|
||||
user_agent: None,
|
||||
hidden: vec![".ad".into()],
|
||||
order: 0,
|
||||
};
|
||||
let s = script_for(&app);
|
||||
assert!(s.contains("mail.google.com"));
|
||||
assert!(s.contains("workapp-route:/?u="));
|
||||
assert!(s.contains(".ad"));
|
||||
assert!(s.contains("__workAppReady"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user