The report was that a backgrounded site dies slowly and stops fetching. It did, for three separate reasons, and the first two fixes each traded one failure for another: Hiding inactive views lets WebKit suspend them, so nothing is hidden any more - every view keeps its size and stays in the window, and the active one is ordered on top. Telling a background page it was hidden then made Gmail throttle its own syncing; its unread count sat unchanged for two and a half minutes. That spoof is gone. Neither was enough on its own: a background page keeps its timers but loses the connection its updates arrive on, and WKWebView has no equivalent of Electron's backgroundThrottling. Background apps are now poked every 45s with the events a page uses to catch up after you return to a tab - it fetches without losing a half-written reply. Notifications now come from the unread count in the title rather than the site's notification code, which Gmail will not run while it believes you are looking at it. Measured: Gmail behind Odoo went 155 -> 157 untouched and raised "Gmail / 1 new". Also adds a background-app probe to Settings, which is what turned this from guesswork into measurement, and drops the nav's blocked-element count and the hide-element button.
723 lines
24 KiB
Rust
723 lines
24 KiB
Rust
//! The command surface. The shell owns "which app is active"; Rust owns the
|
|
//! webviews and the file on disk.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
|
|
use tauri::{AppHandle, Emitter, Manager, State};
|
|
use url::Url;
|
|
|
|
use crate::config::{self, App, Config, Group};
|
|
use crate::webviews;
|
|
|
|
/// Stage rect in logical pixels: x, y, width, height.
|
|
pub type Stage = (f64, f64, f64, f64);
|
|
|
|
pub struct AppState {
|
|
pub dir: PathBuf,
|
|
pub config: Mutex<Config>,
|
|
pub active: Mutex<Option<String>>,
|
|
pub stage: Mutex<Stage>,
|
|
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
|
|
pub booted: Mutex<bool>,
|
|
/// What each app's page last reported about itself, keyed by app id.
|
|
pub diag: Mutex<std::collections::HashMap<String, String>>,
|
|
/// The last notification a page raised, and what macOS did with it.
|
|
pub last_notification: Mutex<String>,
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn cfg(&self) -> Config {
|
|
self.config.lock().unwrap().clone()
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
|
let dir = handle
|
|
.path()
|
|
.app_config_dir()
|
|
.map_err(|e| format!("no config directory: {e}"))?;
|
|
let config = config::load(&dir)?;
|
|
Ok(AppState {
|
|
dir,
|
|
config: Mutex::new(config),
|
|
active: Mutex::new(None),
|
|
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
|
booted: Mutex::new(false),
|
|
diag: Mutex::new(std::collections::HashMap::new()),
|
|
last_notification: Mutex::new(String::new()),
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_config(state: State<'_, AppState>) -> Config {
|
|
state.cfg()
|
|
}
|
|
|
|
/// Records the rect the shell has left for an app, and resizes the one showing.
|
|
#[tauri::command]
|
|
pub fn set_stage(
|
|
x: f64,
|
|
y: f64,
|
|
width: f64,
|
|
height: f64,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) {
|
|
let stage = (x, y, width.max(0.0), height.max(0.0));
|
|
*state.stage.lock().unwrap() = stage;
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::set_stage(&app, active.as_deref(), stage);
|
|
}
|
|
|
|
/// Creates every app's webview: the active one first, the rest staggered.
|
|
///
|
|
/// Called by the shell once it has measured the stage, so the views are born
|
|
/// at the right size instead of being built against a guess and corrected.
|
|
#[tauri::command]
|
|
pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
|
{
|
|
let mut booted = state.booted.lock().unwrap();
|
|
if *booted {
|
|
return Ok(());
|
|
}
|
|
*booted = true;
|
|
}
|
|
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
let active = state.active.lock().unwrap().clone();
|
|
|
|
let first = active
|
|
.clone()
|
|
.or_else(|| cfg.ordered().first().map(|a| a.id.clone()));
|
|
|
|
if let Some(id) = &first {
|
|
if let Some(a) = cfg.app(id) {
|
|
webviews::create(&app, a, &cfg, stage)?;
|
|
*state.active.lock().unwrap() = Some(id.clone());
|
|
webviews::show_only(&app, Some(id), &cfg, stage);
|
|
}
|
|
}
|
|
|
|
// The rest follow with a gap, so launching does not fire a dozen
|
|
// simultaneous page loads at the network and the CPU.
|
|
let rest: Vec<App> = cfg
|
|
.ordered()
|
|
.into_iter()
|
|
.filter(|a| Some(&a.id) != first.as_ref())
|
|
.cloned()
|
|
.collect();
|
|
|
|
tauri::async_runtime::spawn(async move {
|
|
for a in rest {
|
|
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
|
let state = app.state::<AppState>();
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
|
|
eprintln!("could not create webview for {}: {e}", a.name);
|
|
}
|
|
// Each new view is added on top of its siblings, so the app being
|
|
// looked at has to be put back in front after every one.
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
*state.active.lock().unwrap() = Some(app_id.clone());
|
|
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
|
}
|
|
|
|
/// Hides every app, so a dialog is not painted over by a native view.
|
|
#[tauri::command]
|
|
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
|
let stage = *state.stage.lock().unwrap();
|
|
webviews::hide_all(&app, &state.cfg(), stage);
|
|
}
|
|
|
|
/// Puts the active app back after a dialog closes.
|
|
#[tauri::command]
|
|
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn navigate_app(app_id: String, url: String, app: AppHandle) -> Result<(), String> {
|
|
let wv = app
|
|
.get_webview(&webviews::label_for(&app_id))
|
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
|
let url = Url::parse(&url).map_err(|e| e.to_string())?;
|
|
wv.navigate(url).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Back, forward and reload, run inside the app's own page.
|
|
#[tauri::command]
|
|
pub fn history_go(app_id: String, delta: i32, app: AppHandle) -> Result<(), String> {
|
|
let wv = app
|
|
.get_webview(&webviews::label_for(&app_id))
|
|
.ok_or_else(|| format!("{app_id} has no webview"))?;
|
|
let script = if delta == 0 {
|
|
"location.reload()".to_string()
|
|
} else {
|
|
format!("history.go({delta})")
|
|
};
|
|
wv.eval(&script).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn current_url(app_id: String, app: AppHandle) -> Option<String> {
|
|
app.get_webview(&webviews::label_for(&app_id))
|
|
.and_then(|wv| wv.url().ok())
|
|
.map(|u| u.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn open_external(url: String) -> Result<(), String> {
|
|
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string())
|
|
}
|
|
|
|
// ---------------------------------------------------------------- apps
|
|
|
|
#[tauri::command]
|
|
pub fn add_app(
|
|
name: String,
|
|
url: String,
|
|
group_id: Option<String>,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
let url = config::normalize_url(&url);
|
|
let scope = config::default_scope(&url)
|
|
.ok_or_else(|| format!("{url} is not a URL this can open"))?;
|
|
|
|
let new = {
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let order = cfg
|
|
.apps
|
|
.iter()
|
|
.filter(|a| a.group_id == group_id)
|
|
.map(|a| a.order)
|
|
.max()
|
|
.map_or(0, |m| m + 1);
|
|
let new = App {
|
|
id: config::new_id(),
|
|
name: name.trim().to_string(),
|
|
url,
|
|
scope: vec![scope],
|
|
group_id,
|
|
user_agent: None,
|
|
hidden: Vec::new(),
|
|
zoom: 1.0,
|
|
order,
|
|
};
|
|
cfg.apps.push(new.clone());
|
|
new
|
|
};
|
|
state.persist()?;
|
|
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
if *state.booted.lock().unwrap() {
|
|
webviews::create(&app, &new, &cfg, stage)?;
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
|
}
|
|
Ok(cfg)
|
|
}
|
|
|
|
/// Applies an edit. A change to url, scope or user agent rebuilds the webview,
|
|
/// since none of the three can be altered on a live one.
|
|
#[tauri::command]
|
|
pub fn update_app(
|
|
updated: App,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
let rebuild = {
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let existing = cfg
|
|
.apps
|
|
.iter_mut()
|
|
.find(|a| a.id == updated.id)
|
|
.ok_or_else(|| format!("no app {}", updated.id))?;
|
|
let rebuild = existing.url != updated.url
|
|
|| existing.scope != updated.scope
|
|
|| existing.user_agent != updated.user_agent;
|
|
*existing = updated.clone();
|
|
rebuild
|
|
};
|
|
state.persist()?;
|
|
|
|
let cfg = state.cfg();
|
|
let stage = *state.stage.lock().unwrap();
|
|
if rebuild && *state.booted.lock().unwrap() {
|
|
webviews::destroy(&app, &updated.id);
|
|
webviews::create(&app, &updated, &cfg, stage)?;
|
|
let active = state.active.lock().unwrap().clone();
|
|
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
|
}
|
|
Ok(cfg)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_app(
|
|
app_id: String,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
cfg.apps.retain(|a| a.id != app_id);
|
|
}
|
|
state.persist()?;
|
|
webviews::destroy(&app, &app_id);
|
|
|
|
let mut active = state.active.lock().unwrap();
|
|
if active.as_deref() == Some(app_id.as_str()) {
|
|
*active = None;
|
|
}
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
/// Rewrites group membership and position in one go, for a drag.
|
|
#[tauri::command]
|
|
pub fn reorder_apps(
|
|
ordering: Vec<(String, Option<String>, i32)>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
for (id, group_id, order) in ordering {
|
|
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == id) {
|
|
a.group_id = group_id;
|
|
a.order = order;
|
|
}
|
|
}
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
// -------------------------------------------------------------- groups
|
|
|
|
#[tauri::command]
|
|
pub fn add_group(name: String, state: State<'_, AppState>) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let order = cfg.groups.iter().map(|g| g.order).max().map_or(0, |m| m + 1);
|
|
cfg.groups.push(Group {
|
|
id: config::new_id(),
|
|
name: name.trim().to_string(),
|
|
collapsed: false,
|
|
order,
|
|
});
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_group(updated: Group, state: State<'_, AppState>) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
let g = cfg
|
|
.groups
|
|
.iter_mut()
|
|
.find(|g| g.id == updated.id)
|
|
.ok_or_else(|| format!("no group {}", updated.id))?;
|
|
*g = updated;
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
/// Removes a group. Its apps survive, ungrouped — deleting a folder should
|
|
/// never be a way to lose the things inside it by accident.
|
|
#[tauri::command]
|
|
pub fn delete_group(group_id: String, state: State<'_, AppState>) -> Result<Config, String> {
|
|
{
|
|
let mut cfg = state.config.lock().unwrap();
|
|
cfg.groups.retain(|g| g.id != group_id);
|
|
for a in cfg.apps.iter_mut() {
|
|
if a.group_id.as_deref() == Some(group_id.as_str()) {
|
|
a.group_id = None;
|
|
}
|
|
}
|
|
}
|
|
state.persist()?;
|
|
Ok(state.cfg())
|
|
}
|
|
|
|
// ------------------------------------------------------------ settings
|
|
|
|
#[tauri::command]
|
|
pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<(), String> {
|
|
state.config.lock().unwrap().settings.nav_collapsed = collapsed;
|
|
state.persist()
|
|
}
|
|
|
|
#[tauri::command]
|
|
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())
|
|
}
|
|
|
|
/// 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 last = app_state.last_notification.lock().unwrap().clone();
|
|
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
|
format!("permission: {state} · direct: {raised} · from page: {from_page}")
|
|
}
|
|
|
|
/// Asks macOS for notification permission, and claims this app's identity.
|
|
///
|
|
/// The identity has to be set explicitly. Left alone, mac-notification-sys
|
|
/// looks up an application literally named "use_default", fails, and falls back
|
|
/// to `com.apple.Finder` — so every notification arrives as Finder, wearing
|
|
/// Finder's alert style, which is why they land silently in Notification Centre
|
|
/// instead of on screen. It is registered before the first notification because
|
|
/// the crate's own lazy fallback runs once and cannot be corrected afterwards.
|
|
pub fn ensure_notification_permission(app: &AppHandle) {
|
|
use tauri_plugin_notification::NotificationExt;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let bundle = app.config().identifier.clone();
|
|
if let Err(e) = mac_notification_sys::set_application(&bundle) {
|
|
eprintln!("could not claim {bundle} for notifications: {e}");
|
|
}
|
|
present_notifications_while_frontmost();
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
// ----------------------------------------------------------------- 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.
|
|
///
|
|
/// 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 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())
|
|
}
|
|
|
|
/// Makes macOS show a banner even when this app is the frontmost one.
|
|
///
|
|
/// `NSUserNotificationCenter` suppresses the banner whenever the posting
|
|
/// application is in front, delivering it silently to Notification Centre
|
|
/// instead, unless the delegate says otherwise. That default is wrong here: an
|
|
/// app showing Odoo is still frontmost when Gmail — a hidden webview in the
|
|
/// same window — has something to say, and that notification is the entire
|
|
/// point of keeping the other apps loaded.
|
|
///
|
|
/// The delegate belongs to mac-notification-sys and implements only delivery
|
|
/// and activation, so the missing method is added to its class at runtime.
|
|
#[cfg(target_os = "macos")]
|
|
fn present_notifications_while_frontmost() {
|
|
use objc2::ffi::class_addMethod;
|
|
use objc2::runtime::{AnyClass, AnyObject, Bool, Sel};
|
|
|
|
extern "C" fn should_present(
|
|
_this: *mut AnyObject,
|
|
_cmd: Sel,
|
|
_center: *mut AnyObject,
|
|
_notification: *mut AnyObject,
|
|
) -> Bool {
|
|
Bool::YES
|
|
}
|
|
|
|
let Some(class) = AnyClass::get(c"NotificationCenterDelegate") else {
|
|
eprintln!("notification delegate class is missing; banners will stay silent");
|
|
return;
|
|
};
|
|
|
|
let added = unsafe {
|
|
class_addMethod(
|
|
class as *const AnyClass as *mut AnyClass,
|
|
objc2::sel!(userNotificationCenter:shouldPresentNotification:),
|
|
std::mem::transmute::<*const (), unsafe extern "C-unwind" fn()>(
|
|
should_present as *const (),
|
|
),
|
|
c"c@:@@".as_ptr(),
|
|
)
|
|
};
|
|
if !added.as_bool() {
|
|
eprintln!("could not force banner presentation; notifications may stay silent");
|
|
}
|
|
}
|
|
|
|
/// 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()");
|
|
}
|
|
}
|
|
|
|
/// Asks every app's page what it currently believes about itself.
|
|
///
|
|
/// Built because guessing was not working: it separates "the page is asleep"
|
|
/// from "the page is awake and thinks it is on screen" from "the page is awake,
|
|
/// knows it is hidden, and simply has nothing to say".
|
|
#[tauri::command]
|
|
pub fn probe_apps(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
|
state.diag.lock().unwrap().clear();
|
|
for a in state.cfg().apps {
|
|
let Some(wv) = app.get_webview(&webviews::label_for(&a.id)) else { continue };
|
|
let _ = wv.eval(
|
|
r#"(function () {
|
|
if (!window.__workAppSend) return;
|
|
var st = window.__workAppState ? window.__workAppState() : {};
|
|
window.__workAppSend('diag', {
|
|
title: String(document.title || '').slice(0, 40),
|
|
active: String(st.active),
|
|
seen: String(st.seen),
|
|
queued: String(st.queued) + '/' + String(st.sending)
|
|
});
|
|
})();"#,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// What every app reported, from the newest run only.
|
|
#[tauri::command]
|
|
pub fn app_reports(state: State<'_, AppState>) -> Vec<(String, String)> {
|
|
let cfg = state.cfg();
|
|
let diag = state.diag.lock().unwrap();
|
|
cfg.apps
|
|
.iter()
|
|
.map(|a| {
|
|
let report = diag
|
|
.get(&a.id)
|
|
.cloned()
|
|
.unwrap_or_else(|| "no reply — page is asleep or gone".into());
|
|
(a.name.clone(), report)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// -------------------------------------------------- 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"))?;
|
|
let escaped = notification_id.replace('\\', "\\\\").replace('\'', "\\'");
|
|
wv.eval(&format!(
|
|
"window.__workAppNotifyClick && window.__workAppNotifyClick('{escaped}')"
|
|
))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Brings the window forward when a notification is clicked.
|
|
#[tauri::command]
|
|
pub fn focus_window(app: AppHandle) {
|
|
if let Some(w) = app.get_window("main") {
|
|
let _ = w.unminimize();
|
|
let _ = w.show();
|
|
let _ = w.set_focus();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
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);
|
|
}
|
|
}
|