Show each site's own icon; restore the native title bar
Both previous attempts asked a question about a domain, and a domain does not know which product it is serving. Google's favicon service returned a marketing site's icon for anything behind a login and nothing for a private host; Simple Icons returned one flat brand mark where the real one is multicoloured and, for Gmail, carries the unread count. The page already holds the answer - fetched, authenticated, current. The injected script now reads link[rel~="icon"] and reports the best one: largest declared sizes wins, an Apple touch icon counts as 180, and an .ico is penalised as usually the 16px tab icon. It rechecks on the same tick as the unread count, which is when a site like Gmail redraws its icon with a badge. The URL is stored, so the nav is right at launch rather than blank until every page has loaded. The custom frame is gone with it: ordinary macOS title bar, traffic lights where every other window puts them, and the bar following the app's Light/Dark choice through set_theme. A window that behaves like a window beats one that looks bespoke.
This commit is contained in:
@@ -41,6 +41,11 @@ impl AppState {
|
||||
config::save(&self.dir, &self.config.lock().unwrap())
|
||||
}
|
||||
|
||||
/// Same, for the sentinel handlers that live outside this module.
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
self.persist()
|
||||
}
|
||||
|
||||
/// Records a selector chosen by right-clicking it in the page.
|
||||
pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> {
|
||||
{
|
||||
@@ -298,6 +303,7 @@ pub fn add_app(
|
||||
group_id,
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
icon: None,
|
||||
zoom: 1.0,
|
||||
order,
|
||||
};
|
||||
@@ -448,11 +454,27 @@ pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.config.lock().unwrap().settings.theme = theme;
|
||||
pub fn set_theme(theme: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.config.lock().unwrap().settings.theme = theme.clone();
|
||||
apply_window_theme(&app, &theme);
|
||||
state.persist()
|
||||
}
|
||||
|
||||
/// Puts the window's own title bar in the same light or dark as the app.
|
||||
///
|
||||
/// `None` hands it back to the system, which is what "System" means — the bar
|
||||
/// then follows the OS the way every other window does.
|
||||
pub fn apply_window_theme(app: &AppHandle, theme: &str) {
|
||||
let wanted = match theme {
|
||||
"light" => Some(tauri::Theme::Light),
|
||||
"dark" => Some(tauri::Theme::Dark),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(w) = app.get_window("main") {
|
||||
let _ = w.set_theme(wanted);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- hidden elements
|
||||
|
||||
/// Replaces an app's hidden selectors and re-applies them without a reload.
|
||||
|
||||
@@ -33,6 +33,10 @@ pub struct App {
|
||||
/// the thing you never want to see again.
|
||||
#[serde(default)]
|
||||
pub hidden: Vec<String>,
|
||||
/// The icon this app's own page last reported, kept so the nav is right
|
||||
/// from the moment it opens rather than once every page has loaded.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// Page zoom, remembered per app: a dense ERP and a mail client do not
|
||||
/// want the same size.
|
||||
#[serde(default = "default_zoom")]
|
||||
@@ -187,6 +191,7 @@ pub fn seed() -> Config {
|
||||
group_id: Some(group.into()),
|
||||
user_agent: None,
|
||||
hidden: Vec::new(),
|
||||
icon: None,
|
||||
zoom: 1.0,
|
||||
order,
|
||||
};
|
||||
|
||||
@@ -445,6 +445,57 @@
|
||||
});
|
||||
} catch (e) { window.Notification = WorkNotification; }
|
||||
|
||||
/* ---------------------------------------------------------- the icon */
|
||||
|
||||
/* The site's own icon, read off the page it is on.
|
||||
|
||||
An icon service asked about a domain can only guess, and it guesses badly:
|
||||
it gets a marketing site's icon for a tool that lives behind a login, and
|
||||
nothing at all for a private host. The page knows — it is carrying the
|
||||
answer in its head, already fetched, already authenticated. Gmail even
|
||||
redraws it with the unread count on it.
|
||||
|
||||
Rechecked on the same tick as the count, because that is exactly when a
|
||||
site like Gmail swaps it. */
|
||||
var lastIcon = null;
|
||||
|
||||
function bestIcon() {
|
||||
var links = document.querySelectorAll(
|
||||
'link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
|
||||
);
|
||||
var best = null;
|
||||
var bestScore = -1;
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
var href = links[i].getAttribute('href');
|
||||
if (!href) continue;
|
||||
|
||||
/* Biggest declared size wins; an .ico is a last resort because it is
|
||||
usually the 16px one drawn for a browser tab in 2005. */
|
||||
var sizes = links[i].getAttribute('sizes') || '';
|
||||
var size = parseInt((/(\d+)/.exec(sizes) || [])[1] || '0', 10);
|
||||
var rel = (links[i].getAttribute('rel') || '').toLowerCase();
|
||||
var score = size || (rel.indexOf('apple') !== -1 ? 180 : 32);
|
||||
if (/\.ico(\?|$)/i.test(href)) score -= 24;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = href;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return best ? new URL(best, document.baseURI).href : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkIcon() {
|
||||
var icon = bestIcon();
|
||||
if (!icon || icon === lastIcon) return;
|
||||
lastIcon = icon;
|
||||
send('icon', { u: icon });
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- unread counting */
|
||||
|
||||
/* Sites put their unread count in the title — "Inbox (12)", "(3) Chat".
|
||||
@@ -491,6 +542,8 @@
|
||||
}
|
||||
|
||||
setInterval(checkUnread, 4000);
|
||||
setInterval(checkIcon, 4000);
|
||||
checkIcon();
|
||||
setInterval(poke, 45000);
|
||||
|
||||
/* ------------------------------------------------------------- start */
|
||||
|
||||
@@ -81,6 +81,9 @@ pub fn run() {
|
||||
// Asked for at startup rather than at the first notification, so
|
||||
// the prompt does not arrive attached to someone else's message.
|
||||
commands::ensure_notification_permission(&app.handle().clone());
|
||||
|
||||
let theme = app.state::<commands::AppState>().cfg().settings.theme;
|
||||
commands::apply_window_theme(&app.handle().clone(), &theme);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
||||
@@ -52,6 +52,13 @@ pub struct NotificationClick {
|
||||
pub notification_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IconEvent {
|
||||
pub app_id: String,
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HiddenEvent {
|
||||
@@ -183,6 +190,31 @@ fn handle_sentinel(
|
||||
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
||||
}
|
||||
|
||||
// The site told us its own icon. Stored rather than merely shown,
|
||||
// so the nav is right at launch instead of blank until every page
|
||||
// has finished loading.
|
||||
"icon" => {
|
||||
let Some(url) = params.get("u") else { return };
|
||||
let state = handle.state::<crate::commands::AppState>();
|
||||
let changed = {
|
||||
let mut cfg = state.config.lock().unwrap();
|
||||
match cfg.apps.iter_mut().find(|a| a.id == from) {
|
||||
Some(a) if a.icon.as_deref() != Some(url.as_str()) => {
|
||||
a.icon = Some(url.clone());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
let _ = state.save();
|
||||
let _ = handle.emit(
|
||||
"icon-changed",
|
||||
IconEvent { app_id: from.clone(), icon: url.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
"manage" => {
|
||||
let _ = handle.emit("manage-hidden", from.clone());
|
||||
}
|
||||
@@ -722,6 +754,7 @@ mod tests {
|
||||
group_id: None,
|
||||
user_agent: None,
|
||||
hidden: vec![".ad".into()],
|
||||
icon: None,
|
||||
zoom: 1.0,
|
||||
order: 0,
|
||||
};
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
"height": 900,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 26, "y": 24 }
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": { "csp": null }
|
||||
|
||||
Reference in New Issue
Block a user