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:
2026-09-01 12:17:31 +02:00
parent e369c82774
commit ff4a0c6bc4
44 changed files with 2492 additions and 419 deletions
+233
View File
@@ -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));
}
}