import * as Main from "resource:///org/gnome/shell/ui/main.js"; import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; import Shell from "gi://Shell"; import { NotificationApplicationPolicy } from "resource:///org/gnome/shell/ui/messageTray.js"; export var LogLevel; (function (LogLevel) { LogLevel["DEBUG"] = "debug"; LogLevel["INFO"] = "info"; LogLevel["WARN"] = "warn"; LogLevel["ERROR"] = "error"; })(LogLevel || (LogLevel = {})); function getObjectLabel(name, values) { const labels = Object.entries(values) .filter((entry) => !!entry[1]) .map(([label, value]) => `${label}: '${value}'`); return `${name}(${labels.join(", ")})`; } function getWindowLabel(window, app) { return getObjectLabel("Window", { Title: window.title, AppId: app?.id ?? null, GtkAppId: window.gtkApplicationId, WmClass: window.wmClass, SandboxedAppId: window.get_sandboxed_app_id(), }); } function getSourceLabel(source) { return getObjectLabel("Source", { Title: source.title, Icon: source.icon?.to_string() ?? null, PolicyId: source.policy instanceof NotificationApplicationPolicy ? source.policy.id : null, }); } // libnotify clients without a desktop-entry hint set source.icon to an // app-identifying string. Compare it against window-side identifiers. function matchByIcon(icon, window) { return ( // Ghostty deb: icon matches GTK app id (com.mitchellh.ghostty) icon === window.gtkApplicationId || // Slack Flatpak: icon matches sandboxed app id (com.slack.Slack) icon === window.get_sandboxed_app_id() || // Firefox deb: icon matches window manager class (firefox) icon === window.wmClass); } // Snap apps expose icon paths like /snap/firefox/6638/default256.png; their // sandboxed ids often duplicate the snap name (firefox_firefox). function matchBySnapIcon(icon, window) { const snap = /^\/snap\/([^/]+)\//.exec(icon)?.[1]; if (!snap) return false; return window.get_sandboxed_app_id() === `${snap}_${snap}`; } function matchByTitle(title, window) { if (window.title == null) return false; return ( // Proton Mail Bridge: title matches window title title === window.title || // Extract app name from composite title separated by " - " or " | ". // `^.+` is greedy, so the rightmost separator wins: // "foo.ts - project - Cursor" -> "Cursor", "doc | App" -> "App". title === /^.+ (-|\|) (.+)$/.exec(window.title)?.[2] || // Thunderbird: title matches window manager class (thunderbird) title === window.wmClass || // Discord snap: title duplicated matches sandboxed app id (discord_discord) `${title}_${title}` === window.get_sandboxed_app_id()); } // Prefer authoritative identifiers (policy id, source icon) when present. // If an icon is set, do not fall back to title matching; title is a // last-ditch heuristic reserved for sources that expose neither. function sourceMatchesApp(source, window, appId) { if (source.policy instanceof NotificationApplicationPolicy) { return source.policy.id === appId; } const icon = source.icon?.to_string(); if (icon) return matchByIcon(icon, window) || matchBySnapIcon(icon, window); if (source.title) return matchByTitle(source.title, window); return false; } export default class JunkNotificationCleaner extends Extension { focusListenerId = null; closeListenerId = null; settings = null; windowTracker = Shell.WindowTracker.get_default(); log(level, message) { // gschema enum maps debug=0, info=1, warn=2, error=3, matching the // declaration order of LogLevel; compare ints directly. const minLevelIdx = this.settings?.get_enum("log-level") ?? 1; if (Object.values(LogLevel).indexOf(level) >= minLevelIdx) { log(`[${this.metadata.uuid}][${level}] ${message}`); } } clearNotificationsForApp(window, event) { const app = this.windowTracker.get_window_app(window); const windowLabel = getWindowLabel(window, app); this.log(LogLevel.DEBUG, `${windowLabel}: received ${event}`); const settings = this.settings; if (!settings) { this.log(LogLevel.ERROR, `${windowLabel}: settings not initialized`); return; } if (!app) { this.log(LogLevel.DEBUG, `${windowLabel}: no app associated with window`); return; } // Shell.App.id is the desktop filename (e.g. "org.gnome.Nautilus.desktop"); // NotificationApplicationPolicy.id stores the same id without the // ".desktop" suffix, so normalize before comparison. const appId = app.id.replace(/\.desktop$/, ""); const excludedApps = settings.get_strv("excluded-apps"); if (excludedApps.includes(appId)) { this.log(LogLevel.DEBUG, `${windowLabel}: excluded by app id '${appId}'`); return; } for (const source of Main.messageTray.getSources()) { const label = `${windowLabel}: ${getSourceLabel(source)}`; const matches = sourceMatchesApp(source, window, appId); for (const notification of [...source.notifications]) { const title = notification.title ?? "(untitled notification)"; const kind = notification.isTransient ? "transient" : "persistent"; this.log(LogLevel.DEBUG, `${label}: found ${kind} notification: ${title}`); if (notification.isTransient) continue; if (matches) { notification.destroy(); this.log(LogLevel.INFO, `${label}: removed notification: ${title}`); } } } } enable() { this.settings = this.getSettings(); this.focusListenerId = global.display.connect("notify::focus-window", ({ focusWindow }) => { if (this.settings?.get_boolean("delete-on-focus") && focusWindow) { this.clearNotificationsForApp(focusWindow, "focus"); } }); this.closeListenerId = global.window_manager.connect("destroy", (_, { metaWindow }) => { if (this.settings?.get_boolean("delete-on-close") && metaWindow) { this.clearNotificationsForApp(metaWindow, "close"); } }); } disable() { this.settings = null; if (this.focusListenerId !== null) { global.display.disconnect(this.focusListenerId); this.focusListenerId = null; } if (this.closeListenerId !== null) { global.window_manager.disconnect(this.closeListenerId); this.closeListenerId = null; } } }