Unread counts, a window margin, blurred dialogs, softer group names
Each rise in an app's unread count while you are elsewhere adds to a tally beside its name, and a dot on its icon in the rail. Looking at the app is the only thing that clears it. The window has no frame of its own, so the shell now keeps a 6px margin in a normal window - the only part of it that is not a web page, and so the only place left to grab. Full screen gives the room back. Dialogs get the app blurred behind them. An app's webview paints above the shell, so it has to be moved aside before a dialog can be seen at all, and once moved there is nothing left to blur - a still taken on the way out is the only way to keep the background there. It runs on a blocking worker: its completion handler is on the main thread, and waiting there deadlocks until the timeout and returns nothing. Settings is much larger, the cog moved to the foot of the nav, group names read as names rather than shouted headings, and "Refresh icons" bumps a version every favicon URL carries, for the ones that cache wrong.
This commit is contained in:
Generated
+25
@@ -2205,9 +2205,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2227,6 +2235,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -2287,6 +2296,19 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4868,8 +4890,11 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
name = "work-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"mac-notification-sys",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-web-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -25,7 +25,10 @@ uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences"] }
|
||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "block2"] }
|
||||
objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError"] }
|
||||
block2 = "0.6"
|
||||
# Notifications are raised here rather than through the plugin, which offers no
|
||||
# way to learn that one was clicked.
|
||||
mac-notification-sys = "0.6"
|
||||
|
||||
@@ -24,6 +24,10 @@ pub struct AppState {
|
||||
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>,
|
||||
/// How much has arrived in each app since you last looked at it.
|
||||
pub unread: Mutex<std::collections::HashMap<String, u32>>,
|
||||
/// Size of the last dialog backdrop still, or why there wasn't one.
|
||||
pub last_snapshot: Mutex<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -67,6 +71,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
booted: Mutex::new(false),
|
||||
diag: Mutex::new(std::collections::HashMap::new()),
|
||||
last_notification: Mutex::new(String::new()),
|
||||
unread: Mutex::new(std::collections::HashMap::new()),
|
||||
last_snapshot: Mutex::new(String::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,9 +161,67 @@ pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
*state.active.lock().unwrap() = Some(app_id.clone());
|
||||
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
||||
|
||||
// Looking at an app is what clears its count. Nothing else does.
|
||||
state.unread.lock().unwrap().remove(&app_id);
|
||||
let _ = app.emit("unread-changed", unread_list(&state));
|
||||
}
|
||||
|
||||
/// Every app's count, in the shape the nav wants.
|
||||
pub fn unread_list(state: &AppState) -> Vec<(String, u32)> {
|
||||
state
|
||||
.unread
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unread_counts(state: State<'_, AppState>) -> Vec<(String, u32)> {
|
||||
unread_list(&state)
|
||||
}
|
||||
|
||||
/// Bumps the version every favicon URL carries, so a wrong one is refetched.
|
||||
#[tauri::command]
|
||||
pub fn refresh_favicons(state: State<'_, AppState>) -> Result<Config, String> {
|
||||
{
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
cfg.settings.favicon_version = cfg.settings.favicon_version.wrapping_add(1);
|
||||
}
|
||||
state.persist()?;
|
||||
Ok(state.cfg())
|
||||
}
|
||||
|
||||
/// Hides every app, so a dialog is not painted over by a native view.
|
||||
/// A still of the app on screen, taken before a dialog covers it.
|
||||
///
|
||||
/// Async, and the wait happens on a blocking worker: the snapshot's completion
|
||||
/// handler runs on the main thread, so waiting for it *on* the main thread
|
||||
/// deadlocks until the timeout and returns nothing every time.
|
||||
#[tauri::command]
|
||||
pub async fn stage_snapshot(app: AppHandle) -> Option<String> {
|
||||
let id = {
|
||||
let state = app.state::<AppState>();
|
||||
let active = state.active.lock().unwrap();
|
||||
active.clone()?
|
||||
};
|
||||
let handle = app.clone();
|
||||
let shot = tauri::async_runtime::spawn_blocking(move || webviews::snapshot(&app, &id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// Recorded so the diagnostic can say whether the still was taken at all,
|
||||
// rather than leaving a flat backdrop to be interpreted by eye.
|
||||
*handle.state::<AppState>().last_snapshot.lock().unwrap() = match &shot {
|
||||
Some(d) => format!("{} bytes", d.len()),
|
||||
None => "none".into(),
|
||||
};
|
||||
shot
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
||||
let stage = *state.stage.lock().unwrap();
|
||||
@@ -443,7 +507,9 @@ pub fn notification_status(app: AppHandle) -> String {
|
||||
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}")
|
||||
let shot = app_state.last_snapshot.lock().unwrap().clone();
|
||||
let shot = if shot.is_empty() { "not taken".into() } else { shot };
|
||||
format!("permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot}")
|
||||
}
|
||||
|
||||
/// Asks macOS for notification permission, and claims this app's identity.
|
||||
|
||||
@@ -77,6 +77,9 @@ pub struct Settings {
|
||||
pub nav_collapsed: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
/// Bumped to defeat a cached favicon that came back wrong.
|
||||
#[serde(default)]
|
||||
pub favicon_version: u32,
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
@@ -88,6 +91,7 @@ impl Default for Settings {
|
||||
Self {
|
||||
nav_collapsed: false,
|
||||
theme: default_theme(),
|
||||
favicon_version: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ pub fn run() {
|
||||
commands::set_stage,
|
||||
commands::set_active,
|
||||
commands::hide_stage,
|
||||
commands::stage_snapshot,
|
||||
commands::show_stage,
|
||||
commands::navigate_app,
|
||||
commands::history_go,
|
||||
@@ -103,6 +104,8 @@ pub fn run() {
|
||||
commands::delete_group,
|
||||
commands::set_nav_collapsed,
|
||||
commands::set_theme,
|
||||
commands::unread_counts,
|
||||
commands::refresh_favicons,
|
||||
commands::focus_window,
|
||||
commands::notification_click,
|
||||
commands::set_zoom,
|
||||
|
||||
@@ -170,6 +170,15 @@ fn handle_sentinel(
|
||||
.map(|a| a.name.clone())
|
||||
.unwrap_or_else(|| "Work".into());
|
||||
let n: u32 = delta.parse().unwrap_or(1);
|
||||
|
||||
{
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
let mut counts = state.unread.lock().unwrap();
|
||||
*counts.entry(from.clone()).or_insert(0) += n;
|
||||
}
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
let _ = handle.emit("unread-changed", crate::commands::unread_list(&state));
|
||||
|
||||
let title = if n == 1 { "1 new".to_string() } else { format!("{n} new") };
|
||||
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
||||
}
|
||||
@@ -340,6 +349,76 @@ fn keep_running_while_covered(handle: &AppHandle, app_id: &str) {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn keep_running_while_covered(_: &AppHandle, _: &str) {}
|
||||
|
||||
/// A still of one app, as a PNG data URL, for the shell to blur behind a dialog.
|
||||
///
|
||||
/// A dialog is drawn by the shell webview, and every app's webview is a native
|
||||
/// view that paints above it — so an app has to be moved out of the way before
|
||||
/// a dialog can be seen at all, and once it is moved there is nothing left to
|
||||
/// blur. A still taken on the way out is the only way to keep the background
|
||||
/// there. Deliberately small: it is going behind a blur.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn snapshot(handle: &AppHandle, app_id: &str) -> Option<String> {
|
||||
use block2::RcBlock;
|
||||
use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage};
|
||||
use objc2_foundation::{
|
||||
MainThreadMarker, NSDataBase64EncodingOptions, NSDictionary, NSError, NSNumber,
|
||||
};
|
||||
use objc2_web_kit::{WKSnapshotConfiguration, WKWebView};
|
||||
|
||||
let wv = handle.get_webview(&label_for(app_id))?;
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<String>>();
|
||||
|
||||
let sent = wv.with_webview(move |platform| unsafe {
|
||||
let ptr = platform.inner() as *const WKWebView;
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
let _ = tx.send(None);
|
||||
return;
|
||||
};
|
||||
if ptr.is_null() {
|
||||
let _ = tx.send(None);
|
||||
return;
|
||||
}
|
||||
let webview: &WKWebView = &*ptr;
|
||||
|
||||
let config = WKSnapshotConfiguration::new(mtm);
|
||||
config.setSnapshotWidth(Some(&NSNumber::new_f64(640.0)));
|
||||
|
||||
let handler = RcBlock::new(move |image: *mut NSImage, _error: *mut NSError| {
|
||||
let encoded = (|| {
|
||||
let image = image.as_ref()?;
|
||||
let tiff = image.TIFFRepresentation()?;
|
||||
let rep = NSBitmapImageRep::imageRepWithData(&tiff)?;
|
||||
let png = rep.representationUsingType_properties(
|
||||
NSBitmapImageFileType::PNG,
|
||||
&NSDictionary::new(),
|
||||
)?;
|
||||
Some(
|
||||
png.base64EncodedStringWithOptions(NSDataBase64EncodingOptions::empty())
|
||||
.to_string(),
|
||||
)
|
||||
})();
|
||||
let _ = tx.send(encoded);
|
||||
});
|
||||
|
||||
webview.takeSnapshotWithConfiguration_completionHandler(Some(&config), &handler);
|
||||
});
|
||||
|
||||
if sent.is_err() {
|
||||
return None;
|
||||
}
|
||||
// A snapshot that takes longer than this is not worth making someone wait
|
||||
// for; the dialog opens over a plain backdrop instead.
|
||||
rx.recv_timeout(std::time::Duration::from_millis(2500))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|b64| format!("data:image/png;base64,{b64}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn snapshot(_: &AppHandle, _: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user