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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user