Keep background apps alive, and notify from their unread count
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.
This commit is contained in:
@@ -204,6 +204,44 @@ wrong at first:
|
|||||||
|
|
||||||
Service-worker push is **not** covered — only notifications a page raises while it is open.
|
Service-worker push is **not** covered — only notifications a page raises while it is open.
|
||||||
|
|
||||||
|
## Keeping background apps alive
|
||||||
|
|
||||||
|
The app is only worth having if a tool you are not looking at still tells you something
|
||||||
|
arrived. Getting there took three wrong turns, and each one is why the code looks as it
|
||||||
|
does:
|
||||||
|
|
||||||
|
1. **Hiding the inactive views.** The obvious way to switch apps, and it kills them:
|
||||||
|
WebKit reads a hidden `NSView` as a page that is not visible, throttles its timers and
|
||||||
|
eventually suspends it. Now nothing is hidden — every view keeps its full size and stays
|
||||||
|
in the window, and the active one is simply ordered on top. Sibling views covering each
|
||||||
|
other is not something WebKit tracks.
|
||||||
|
|
||||||
|
2. **Telling the page it was hidden.** The idea was that a page believing itself hidden
|
||||||
|
would notify rather than stay quiet. It backfired: told it was hidden, Gmail throttled
|
||||||
|
its *own* syncing, and its unread count sat unchanged for two and a half minutes. The
|
||||||
|
spoof is gone. Pages are told nothing about visibility.
|
||||||
|
|
||||||
|
3. **Assuming that was enough.** It was not. A backgrounded page keeps running its timers
|
||||||
|
but loses the long-lived connection its updates arrive on, and WKWebView offers no
|
||||||
|
equivalent of Electron's `backgroundThrottling: false`. So every background app is
|
||||||
|
**poked every 45 seconds** with the events a page uses to catch up when you return to a
|
||||||
|
tab. It fetches as if you had just looked at it, and keeps its state — unlike a reload,
|
||||||
|
which would throw away a half-written reply.
|
||||||
|
|
||||||
|
**Measured end to end:** Gmail behind Odoo went from 155 to 157 unread without being
|
||||||
|
touched, and the app raised `Gmail / 1 new`.
|
||||||
|
|
||||||
|
### Unread counting
|
||||||
|
|
||||||
|
Notifications do not come from the site's own notification code, because Gmail declines to
|
||||||
|
raise one while it believes you are looking at it — and with the visibility spoof gone, it
|
||||||
|
always believes that. They come from the **unread count in the title**: `Inbox (12)`,
|
||||||
|
`(3) Chat`. Every one of these tools publishes it, it cannot be switched off, and it never
|
||||||
|
moves into a service worker this app cannot reach. A rise while the app is not the one on
|
||||||
|
screen raises a banner; a fall is you reading things, and is not news.
|
||||||
|
|
||||||
|
The site's own notifications still work when they fire — both paths feed the same channel.
|
||||||
|
|
||||||
## Zoom
|
## Zoom
|
||||||
|
|
||||||
Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a
|
Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a
|
||||||
|
|||||||
+55
-20
@@ -20,8 +20,8 @@ pub struct AppState {
|
|||||||
pub stage: Mutex<Stage>,
|
pub stage: Mutex<Stage>,
|
||||||
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
|
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
|
||||||
pub booted: Mutex<bool>,
|
pub booted: Mutex<bool>,
|
||||||
/// What the last page probe reported, for the notification diagnostic.
|
/// What each app's page last reported about itself, keyed by app id.
|
||||||
pub diag: Mutex<String>,
|
pub diag: Mutex<std::collections::HashMap<String, String>>,
|
||||||
/// The last notification a page raised, and what macOS did with it.
|
/// The last notification a page raised, and what macOS did with it.
|
||||||
pub last_notification: Mutex<String>,
|
pub last_notification: Mutex<String>,
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
|||||||
active: Mutex::new(None),
|
active: Mutex::new(None),
|
||||||
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
||||||
booted: Mutex::new(false),
|
booted: Mutex::new(false),
|
||||||
diag: Mutex::new(String::new()),
|
diag: Mutex::new(std::collections::HashMap::new()),
|
||||||
last_notification: Mutex::new(String::new()),
|
last_notification: Mutex::new(String::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,10 @@ pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), Strin
|
|||||||
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
|
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
|
||||||
eprintln!("could not create webview for {}: {e}", a.name);
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +160,8 @@ pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
|
|||||||
/// Hides every app, so a dialog is not painted over by a native view.
|
/// Hides every app, so a dialog is not painted over by a native view.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
||||||
webviews::hide_all(&app, &state.cfg());
|
let stage = *state.stage.lock().unwrap();
|
||||||
|
webviews::hide_all(&app, &state.cfg(), stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts the active app back after a dialog closes.
|
/// Puts the active app back after a dialog closes.
|
||||||
@@ -246,6 +251,8 @@ pub fn add_app(
|
|||||||
let stage = *state.stage.lock().unwrap();
|
let stage = *state.stage.lock().unwrap();
|
||||||
if *state.booted.lock().unwrap() {
|
if *state.booted.lock().unwrap() {
|
||||||
webviews::create(&app, &new, &cfg, stage)?;
|
webviews::create(&app, &new, &cfg, stage)?;
|
||||||
|
let active = state.active.lock().unwrap().clone();
|
||||||
|
webviews::show_only(&app, active.as_deref(), &cfg, stage);
|
||||||
}
|
}
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
@@ -411,17 +418,6 @@ pub fn set_hidden(
|
|||||||
Ok(state.cfg())
|
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.
|
/// 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
|
/// Splits the chain in two: if this succeeds and the page test does not, the
|
||||||
@@ -446,12 +442,8 @@ pub fn notification_status(app: AppHandle) -> String {
|
|||||||
};
|
};
|
||||||
let app_state = app.state::<AppState>();
|
let app_state = app.state::<AppState>();
|
||||||
let last = app_state.last_notification.lock().unwrap().clone();
|
let last = app_state.last_notification.lock().unwrap().clone();
|
||||||
let diag = app_state.diag.lock().unwrap().clone();
|
|
||||||
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
||||||
// The probe's own report, which distinguishes "the page never ran" from
|
format!("permission: {state} · direct: {raised} · from page: {from_page}")
|
||||||
// "the page ran and the message did not arrive".
|
|
||||||
let probe = if diag.is_empty() { "no reply".into() } else { diag };
|
|
||||||
format!("permission: {state} · direct: {raised} · from page: {from_page} · probe: {probe}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks macOS for notification permission, and claims this app's identity.
|
/// Asks macOS for notification permission, and claims this app's identity.
|
||||||
@@ -632,6 +624,49 @@ pub fn reload_active(app: &AppHandle) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// -------------------------------------------------- notification clicks
|
||||||
|
|
||||||
/// Runs the page's own click handler for a notification it raised.
|
/// Runs the page's own click handler for a notification it raised.
|
||||||
|
|||||||
@@ -37,6 +37,29 @@
|
|||||||
drain();
|
drain();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------- background tabs */
|
||||||
|
|
||||||
|
/* Which app is on screen. Deliberately *not* wired to the page's own idea of
|
||||||
|
visibility.
|
||||||
|
|
||||||
|
Two earlier attempts failed, and both failures are the reason this is
|
||||||
|
shaped the way it is. Hiding the view lets WebKit suspend the page, and a
|
||||||
|
suspended tool stops fetching. Leaving the view alone but telling the page
|
||||||
|
it is hidden makes the page throttle its own syncing instead — measured on
|
||||||
|
Gmail, whose unread count sat unchanged for two and a half minutes.
|
||||||
|
|
||||||
|
So the page is told nothing at all. It believes it is on screen, keeps its
|
||||||
|
connection, and keeps counting. What it will not do is raise a
|
||||||
|
notification, since as far as it knows you are looking straight at it —
|
||||||
|
and that is the job the unread watcher below picks up. */
|
||||||
|
var active = true;
|
||||||
|
window.__workAppSetVisible = function (v) { active = !!v; };
|
||||||
|
/* Readable from the probe, so "it never fired" can be told apart from
|
||||||
|
"it fired and the message never left". */
|
||||||
|
window.__workAppState = function () {
|
||||||
|
return { active: active, queued: queue.length, sending: sending, seen: lastCount };
|
||||||
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------ routing */
|
/* ------------------------------------------------------------ routing */
|
||||||
|
|
||||||
function abs(href) {
|
function abs(href) {
|
||||||
@@ -422,6 +445,54 @@
|
|||||||
});
|
});
|
||||||
} catch (e) { window.Notification = WorkNotification; }
|
} catch (e) { window.Notification = WorkNotification; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------- unread counting */
|
||||||
|
|
||||||
|
/* Sites put their unread count in the title — "Inbox (12)", "(3) Chat".
|
||||||
|
It is the one signal they all share, and unlike their notification code it
|
||||||
|
cannot be switched off, gated behind a heuristic about whether you are
|
||||||
|
looking, or moved into a service worker this app cannot reach.
|
||||||
|
|
||||||
|
Gmail, measured: left believing it is on screen it keeps fetching and its
|
||||||
|
title tracks the count, but it will not raise a notification while it
|
||||||
|
thinks you are looking. The title does not care what it thinks. */
|
||||||
|
var lastCount = null;
|
||||||
|
|
||||||
|
function titleCount() {
|
||||||
|
var m = /\((\d+)\)/.exec(document.title || '');
|
||||||
|
return m ? parseInt(m[1], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUnread() {
|
||||||
|
var n = titleCount();
|
||||||
|
if (lastCount === null) { lastCount = n; return; }
|
||||||
|
/* Only while nobody is looking, and only on a rise — a count going down is
|
||||||
|
you reading things, which is not news. */
|
||||||
|
if (n > lastCount && !active) {
|
||||||
|
send('unread', { n: String(n), d: String(n - lastCount) });
|
||||||
|
}
|
||||||
|
lastCount = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gmail's long-lived connection does not survive being in the background in
|
||||||
|
WKWebView, and there is no public switch for that — Electron has
|
||||||
|
backgroundThrottling, WebKit has nothing. Measured: a backgrounded Gmail
|
||||||
|
sat on the same unread count for minutes while its timers kept running, so
|
||||||
|
it is the connection that goes, not the page.
|
||||||
|
|
||||||
|
What a page does respond to is the event it uses to catch up when you come
|
||||||
|
back to a tab. Sending that on a timer makes a background app fetch as if
|
||||||
|
you had just looked at it, which costs nothing and keeps its state — unlike
|
||||||
|
a reload, which would throw away a half-written reply. */
|
||||||
|
function poke() {
|
||||||
|
if (active) return;
|
||||||
|
try { document.dispatchEvent(new Event('visibilitychange')); } catch (e) {}
|
||||||
|
try { window.dispatchEvent(new Event('focus')); } catch (e) {}
|
||||||
|
try { window.dispatchEvent(new Event('online')); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(checkUnread, 4000);
|
||||||
|
setInterval(poke, 45000);
|
||||||
|
|
||||||
/* ------------------------------------------------------------- start */
|
/* ------------------------------------------------------------- start */
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -107,9 +107,10 @@ pub fn run() {
|
|||||||
commands::notification_click,
|
commands::notification_click,
|
||||||
commands::set_zoom,
|
commands::set_zoom,
|
||||||
commands::set_hidden,
|
commands::set_hidden,
|
||||||
commands::pick_hidden,
|
|
||||||
commands::test_notification,
|
commands::test_notification,
|
||||||
commands::notification_status,
|
commands::notification_status,
|
||||||
|
commands::probe_apps,
|
||||||
|
commands::app_reports,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+135
-17
@@ -154,7 +154,24 @@ fn handle_sentinel(
|
|||||||
.map(|(k, v)| format!("{k}={v}"))
|
.map(|(k, v)| format!("{k}={v}"))
|
||||||
.collect();
|
.collect();
|
||||||
parts.sort();
|
parts.sort();
|
||||||
*state.diag.lock().unwrap() = parts.join(" ");
|
state.diag.lock().unwrap().insert(from.clone(), parts.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The site's own title said something arrived while nobody was
|
||||||
|
// looking. Used because a site may decline to raise a notification
|
||||||
|
// itself, but its unread count never lies.
|
||||||
|
"unread" => {
|
||||||
|
let total = params.get("n").cloned().unwrap_or_default();
|
||||||
|
let delta = params.get("d").cloned().unwrap_or_default();
|
||||||
|
let name = handle
|
||||||
|
.state::<crate::commands::AppState>()
|
||||||
|
.cfg()
|
||||||
|
.app(&from)
|
||||||
|
.map(|a| a.name.clone())
|
||||||
|
.unwrap_or_else(|| "Work".into());
|
||||||
|
let n: u32 = delta.parse().unwrap_or(1);
|
||||||
|
let title = if n == 1 { "1 new".to_string() } else { format!("{n} new") };
|
||||||
|
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
"manage" => {
|
"manage" => {
|
||||||
@@ -256,6 +273,73 @@ fn notify(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raises one app's view above its siblings, and keeps every view alive.
|
||||||
|
///
|
||||||
|
/// The obvious way to switch apps is to hide the others, and it is wrong.
|
||||||
|
/// WebKit treats a hidden `NSView` as a page that is not visible: it throttles
|
||||||
|
/// timers, stops polling, and eventually suspends the page altogether. A tool
|
||||||
|
/// left in the background then quietly stops fetching, and its notifications
|
||||||
|
/// never arrive — which is the whole reason the other apps stay loaded.
|
||||||
|
///
|
||||||
|
/// So nothing is hidden. Every view keeps its full size and stays in the view
|
||||||
|
/// hierarchy; the active one is simply ordered on top. Sibling views covering
|
||||||
|
/// each other is not something WebKit tracks, so all of them keep running.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn raise_to_front(handle: &AppHandle, app_id: &str) {
|
||||||
|
use objc2::runtime::AnyObject;
|
||||||
|
|
||||||
|
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
|
||||||
|
let _ = wv.with_webview(|platform| unsafe {
|
||||||
|
let view = platform.inner() as *mut AnyObject;
|
||||||
|
if view.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let superview: *mut AnyObject = objc2::msg_send![view, superview];
|
||||||
|
if superview.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// NSWindowAbove, relative to nil: straight to the top of the stack.
|
||||||
|
let above: isize = 1;
|
||||||
|
let nil: *mut AnyObject = std::ptr::null_mut();
|
||||||
|
let _: () = objc2::msg_send![
|
||||||
|
superview,
|
||||||
|
addSubview: view,
|
||||||
|
positioned: above,
|
||||||
|
relativeTo: nil,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn raise_to_front(_: &AppHandle, _: &str) {}
|
||||||
|
|
||||||
|
/// Stops WebKit throttling the page when the window is covered by another app.
|
||||||
|
///
|
||||||
|
/// Window occlusion is the one kind of hiding WebKit does track, and it applies
|
||||||
|
/// to every view in the window at once — so a Work window buried behind a
|
||||||
|
/// browser would stop fetching mail. Private API, hence the check first: if a
|
||||||
|
/// future WebKit drops it, notifications degrade rather than the app breaking.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn keep_running_while_covered(handle: &AppHandle, app_id: &str) {
|
||||||
|
use objc2::runtime::{AnyObject, Bool};
|
||||||
|
|
||||||
|
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
|
||||||
|
let _ = wv.with_webview(|platform| unsafe {
|
||||||
|
let view = platform.inner() as *mut AnyObject;
|
||||||
|
if view.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sel = objc2::sel!(_setWindowOcclusionDetectionEnabled:);
|
||||||
|
let responds: Bool = objc2::msg_send![view, respondsToSelector: sel];
|
||||||
|
if responds.as_bool() {
|
||||||
|
let _: () = objc2::msg_send![view, _setWindowOcclusionDetectionEnabled: false];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn keep_running_while_covered(_: &AppHandle, _: &str) {}
|
||||||
|
|
||||||
/// Turns on WKWebView's two-finger back and forward swipes.
|
/// 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
|
/// wry supports it but Tauri does not expose it, so it is set on the native
|
||||||
@@ -344,6 +428,14 @@ pub fn create(
|
|||||||
.map(|a| a.hidden.clone())
|
.map(|a| a.hidden.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
push_hidden(&load_handle, &load_id, &hidden);
|
push_hidden(&load_handle, &load_id, &hidden);
|
||||||
|
|
||||||
|
// A fresh page starts out believing it is visible, and the call
|
||||||
|
// that said otherwise was made before it had loaded — so a
|
||||||
|
// background app would come back thinking it is on screen, and
|
||||||
|
// stay quiet about everything that arrived.
|
||||||
|
let active = state.active.lock().unwrap().clone();
|
||||||
|
let is_active = active.as_deref() == Some(load_id.as_str());
|
||||||
|
set_page_visibility(&load_handle, &load_id, is_active);
|
||||||
});
|
});
|
||||||
|
|
||||||
window
|
window
|
||||||
@@ -355,20 +447,26 @@ pub fn create(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
enable_swipe_navigation(handle, &id);
|
enable_swipe_navigation(handle, &id);
|
||||||
|
keep_running_while_covered(handle, &id);
|
||||||
|
|
||||||
if let Some(wv) = handle.get_webview(&label_for(&id)) {
|
if let Some(wv) = handle.get_webview(&label_for(&id)) {
|
||||||
let _ = wv.set_zoom(app.zoom);
|
let _ = wv.set_zoom(app.zoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Created hidden. `show_only` is what puts one on screen, so startup does
|
|
||||||
// not flash every app in turn as they are built.
|
|
||||||
if let Some(wv) = handle.get_webview(&label_for(&id)) {
|
|
||||||
let _ = wv.hide();
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows one app and hides the rest, sizing it to the stage.
|
/// Tells a page whether it is the one being looked at.
|
||||||
|
///
|
||||||
|
/// Separate from the view's real visibility on purpose — see the note in
|
||||||
|
/// `inject.js`. The view never hides; only the page's own idea of it changes.
|
||||||
|
fn set_page_visibility(handle: &AppHandle, app_id: &str, visible: bool) {
|
||||||
|
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
|
||||||
|
let _ = wv.eval(&format!(
|
||||||
|
"window.__workAppSetVisible && window.__workAppSetVisible({visible})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brings one app to the front. Every other app stays live behind it.
|
||||||
pub fn show_only(
|
pub fn show_only(
|
||||||
handle: &AppHandle,
|
handle: &AppHandle,
|
||||||
app_id: Option<&str>,
|
app_id: Option<&str>,
|
||||||
@@ -377,34 +475,54 @@ pub fn show_only(
|
|||||||
) {
|
) {
|
||||||
for app in &cfg.apps {
|
for app in &cfg.apps {
|
||||||
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
||||||
if Some(app.id.as_str()) == app_id {
|
|
||||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||||
let _ = wv.set_zoom(app.zoom);
|
let _ = wv.set_zoom(app.zoom);
|
||||||
let _ = wv.show();
|
let _ = wv.show();
|
||||||
} else {
|
set_page_visibility(handle, &app.id, Some(app.id.as_str()) == app_id);
|
||||||
let _ = wv.hide();
|
|
||||||
}
|
}
|
||||||
|
if let Some(id) = app_id {
|
||||||
|
raise_to_front(handle, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-sizes whichever app is showing. Called on every layout change.
|
/// Re-sizes every app to the stage. Called on every layout change.
|
||||||
|
///
|
||||||
|
/// All of them, not just the visible one: they are all really on screen now,
|
||||||
|
/// stacked, so one left at a stale size would show around the edges of the
|
||||||
|
/// active app the moment the window grew.
|
||||||
pub fn set_stage(handle: &AppHandle, active: Option<&str>, stage: (f64, f64, f64, f64)) {
|
pub fn set_stage(handle: &AppHandle, active: Option<&str>, stage: (f64, f64, f64, f64)) {
|
||||||
let Some(id) = active else { return };
|
let cfg = handle.state::<crate::commands::AppState>().cfg();
|
||||||
let Some(wv) = handle.get_webview(&label_for(id)) else { return };
|
for app in &cfg.apps {
|
||||||
|
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
||||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||||
|
}
|
||||||
|
if let Some(id) = active {
|
||||||
|
raise_to_front(handle, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hides every app webview, so the shell can draw over the whole window.
|
/// Clears the stage so the shell can draw a dialog over the whole window.
|
||||||
pub fn hide_all(handle: &AppHandle, cfg: &Config) {
|
///
|
||||||
|
/// Parked below the window rather than hidden. Hiding is what WebKit reads as
|
||||||
|
/// "not visible", and it would suspend every app for as long as Settings stayed
|
||||||
|
/// open — long enough to miss mail. A view moved out of the window is still a
|
||||||
|
/// visible view as far as WebKit is concerned; it simply is not on screen.
|
||||||
|
pub fn hide_all(handle: &AppHandle, cfg: &Config, stage: (f64, f64, f64, f64)) {
|
||||||
for app in &cfg.apps {
|
for app in &cfg.apps {
|
||||||
if let Some(wv) = handle.get_webview(&label_for(&app.id)) {
|
if let Some(wv) = handle.get_webview(&label_for(&app.id)) {
|
||||||
let _ = wv.hide();
|
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1 + PARKED_OFFSET));
|
||||||
}
|
}
|
||||||
|
// Nothing is on screen behind a dialog, so nothing should think it is.
|
||||||
|
set_page_visibility(handle, &app.id, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Far enough below the window that nothing shows, near enough that no
|
||||||
|
/// coordinate system objects to it.
|
||||||
|
const PARKED_OFFSET: f64 = 10_000.0;
|
||||||
|
|
||||||
pub fn destroy(handle: &AppHandle, app_id: &str) {
|
pub fn destroy(handle: &AppHandle, app_id: &str) {
|
||||||
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
|
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
|
||||||
let _ = wv.close();
|
let _ = wv.close();
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ export default function App() {
|
|||||||
onBack={() => activeId && api.historyGo(activeId, -1)}
|
onBack={() => activeId && api.historyGo(activeId, -1)}
|
||||||
onForward={() => activeId && api.historyGo(activeId, 1)}
|
onForward={() => activeId && api.historyGo(activeId, 1)}
|
||||||
onReload={() => activeId && api.historyGo(activeId, 0)}
|
onReload={() => activeId && api.historyGo(activeId, 0)}
|
||||||
onPick={() => activeId && api.pickHidden(activeId)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* The hole an app's native webview is positioned into. It stays empty
|
{/* The hole an app's native webview is positioned into. It stays empty
|
||||||
|
|||||||
+2
-1
@@ -38,7 +38,6 @@ export const setTheme = (theme: string) => invoke<void>("set_theme", { theme });
|
|||||||
|
|
||||||
export const setHidden = (appId: string, hidden: string[]) =>
|
export const setHidden = (appId: string, hidden: string[]) =>
|
||||||
invoke<Config>("set_hidden", { appId, hidden });
|
invoke<Config>("set_hidden", { appId, hidden });
|
||||||
export const pickHidden = (appId: string) => invoke<void>("pick_hidden", { appId });
|
|
||||||
|
|
||||||
export const testNotification = (appId: string) =>
|
export const testNotification = (appId: string) =>
|
||||||
invoke<void>("test_notification", { appId });
|
invoke<void>("test_notification", { appId });
|
||||||
@@ -49,3 +48,5 @@ export const setZoom = (appId: string, zoom: number) =>
|
|||||||
export const notificationClick = (appId: string, notificationId: string) =>
|
export const notificationClick = (appId: string, notificationId: string) =>
|
||||||
invoke<void>("notification_click", { appId, notificationId });
|
invoke<void>("notification_click", { appId, notificationId });
|
||||||
export const focusWindow = () => invoke<void>("focus_window");
|
export const focusWindow = () => invoke<void>("focus_window");
|
||||||
|
export const probeApps = () => invoke<void>("probe_apps");
|
||||||
|
export const appReports = () => invoke<[string, string][]>("app_reports");
|
||||||
|
|||||||
+11
-14
@@ -1,5 +1,5 @@
|
|||||||
import type { Config, Group, WorkApp } from "../types";
|
import type { Config, Group, WorkApp } from "../types";
|
||||||
import { Back, Cog, Collapse, EyeOff, Forward, Reload } from "./icons";
|
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
|
||||||
import { Favicon, HEADING, ICON_CHROME } from "./ui";
|
import { Favicon, HEADING, ICON_CHROME } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,7 +13,6 @@ interface Props {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onForward: () => void;
|
onForward: () => void;
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
onPick: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +29,7 @@ export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
|||||||
export default function Nav({
|
export default function Nav({
|
||||||
config, activeId, collapsed,
|
config, activeId, collapsed,
|
||||||
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
||||||
onBack, onForward, onReload, onPick,
|
onBack, onForward, onReload,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||||
const inGroup = (id: string | null) =>
|
const inGroup = (id: string | null) =>
|
||||||
@@ -42,8 +41,9 @@ export default function Nav({
|
|||||||
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
|
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
|
||||||
"dark:border-slate-800 dark:bg-slate-900";
|
"dark:border-slate-800 dark:bg-slate-900";
|
||||||
|
|
||||||
/* Back, forward, reload, hide-an-element and settings, in that order —
|
/* Back, forward, reload and settings. Nothing hides an element here — that
|
||||||
navigation first because it is what gets reached for most. */
|
lives on the right-click menu, where you are already pointing at the thing
|
||||||
|
you want gone. */
|
||||||
const controls = (
|
const controls = (
|
||||||
<>
|
<>
|
||||||
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
|
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
|
||||||
@@ -55,14 +55,6 @@ export default function Nav({
|
|||||||
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
|
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
|
||||||
<Reload />
|
<Reload />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={onPick}
|
|
||||||
disabled={disabled}
|
|
||||||
title="Hide an element on this page — or right-click it"
|
|
||||||
className={ICON_CHROME}
|
|
||||||
>
|
|
||||||
<EyeOff />
|
|
||||||
</button>
|
|
||||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||||
<Cog />
|
<Cog />
|
||||||
</button>
|
</button>
|
||||||
@@ -95,8 +87,13 @@ export default function Nav({
|
|||||||
return (
|
return (
|
||||||
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
||||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
|
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
|
||||||
|
{/* Only settings and the expander survive the rail. Back and forward
|
||||||
|
are a two-finger swipe, reload is ⌘R, and five buttons across 72px
|
||||||
|
is clutter standing in for a toolbar nobody asked for. */}
|
||||||
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
|
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
|
||||||
<div className="flex flex-wrap justify-center gap-0.5 px-1">{controls}</div>
|
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||||
|
<Cog />
|
||||||
|
</button>
|
||||||
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
||||||
<Collapse open={false} />
|
<Collapse open={false} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function Settings({
|
|||||||
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
|
||||||
|
|
||||||
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
|
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
|
||||||
|
const [reports, setReports] = useState<[string, string][]>([]);
|
||||||
|
|
||||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
@@ -272,7 +273,27 @@ export default function Settings({
|
|||||||
>
|
>
|
||||||
Check permission
|
Check permission
|
||||||
</button>
|
</button>
|
||||||
<span className={HELP}>Clicking one opens the message it is about.</span>
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await api.probeApps();
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
setReports(await api.appReports());
|
||||||
|
}}
|
||||||
|
className={BTN}
|
||||||
|
>
|
||||||
|
Check background apps
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reports.length > 0 && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{reports.map(([name, report]) => (
|
||||||
|
<p key={name} className={`${HELP} font-mono`}>
|
||||||
|
<span className="font-semibold">{name}</span> · {report}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hidden">
|
||||||
</div>
|
</div>
|
||||||
{notifyStatus && (
|
{notifyStatus && (
|
||||||
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
|
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ export const Reload = (p: Props) => <S d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" {..
|
|||||||
export const External = (p: Props) => (
|
export const External = (p: Props) => (
|
||||||
<S d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" {...p} />
|
<S d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" {...p} />
|
||||||
);
|
);
|
||||||
export const EyeOff = (p: Props) => (
|
|
||||||
<S d="M3 3l18 18M10.6 10.6a2 2 0 002.8 2.8M9.4 5.4A9.5 9.5 0 0112 5c5 0 9 4.5 9 7a11 11 0 01-2.4 3.5M6.2 6.9C4 8.3 3 10.4 3 12c0 2.5 4 7 9 7a9.6 9.6 0 004-.85" {...p} />
|
|
||||||
);
|
|
||||||
export const Trash = ({ className = "size-3.5" }: Props) => (
|
export const Trash = ({ className = "size-3.5" }: Props) => (
|
||||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
|
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
|
||||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|||||||
Reference in New Issue
Block a user