/* extension.js - Strata extension lifecycle. */ import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import Meta from 'gi://Meta'; import Shell from 'gi://Shell'; import St from 'gi://St'; import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import { StrataProxy, BUS_NAME, OBJECT_PATH } from './dbus.js'; import { StrataPanel } from './ui/panel.js'; import { logError } from './util.js'; export default class StrataExtension extends Extension { /** @type {Gio.Subprocess | null} */ _daemon = null; _daemonSpawnTime = 0; _daemonRestartAttempts = 0; _shuttingDown = false; _daemonRestartTimerId = null; /** True while a GetNameOwner check before spawning is in flight, so an * overlapping respawn attempt can't spawn a second daemon. */ _spawnPending = false; /** @type {StrataPanel | null} */ _panel = null; /** @type {object | null} Gio.DBusProxy instance */ _proxy = null; _itemAddedId = null; _itemDeletedId = null; _historyClearedId = null; /** @type {string} WM class of the currently focused app (lower-cased) */ _currentFocusedApp = ''; /** @type {object | null} PanelMenu.Button indicator */ _indicator = null; /** @type {number | null} Debounce timer for clipboard reads */ _clipboardDebounceId = null; /** True while a transfer_async is in flight - prevents concurrent transfers. */ _clipboardTransferPending = false; /** @type {string[]} Apps to exclude from history */ _excludedApps = []; _pendingSignalId = null; /** @type {Set} Pending GLib.idle_add source IDs to flush on disable. */ _idleSources = new Set(); enable() { this._shuttingDown = false; this._daemonRestartAttempts = 0; this._settings = this.getSettings(); this._excludedApps = this._readExcluded(); this._readSizeLimits(); this._settings.connectObject( 'changed::excluded-apps', () => { this._excludedApps = this._readExcluded(); }, 'changed::max-history', () => { this._readSizeLimits(); this._pushConfig(); }, 'changed::max-text-mb', () => { this._readSizeLimits(); this._pushConfig(); }, 'changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); }, this); this._addIndicator(); this._spawnDaemon(); this._connectProxy(); this._connectFocusTracking(); this._connectClipboardMonitor(); this._registerShortcut(); } disable() { // Signal the watchdog NOT to respawn on exit. this._shuttingDown = true; if (this._daemonRestartTimerId !== null) { GLib.Source.remove(this._daemonRestartTimerId); this._daemonRestartTimerId = null; } this._unregisterShortcut(); this._disconnectClipboardMonitor(); this._disconnectFocusTracking(); this._disconnectSignals(); this._clearIdleSources(); this._proxy?.disconnectObject(this); this._settings?.disconnectObject(this); this._panel?.destroy(); this._panel = null; this._indicator?.disconnectObject(this); this._indicator?.destroy(); this._indicator = null; this._stopDaemon(); this._proxy = null; this._settings = null; } _addIndicator() { // dontCreateMenu = true so PanelMenu.Button doesn't open an empty popup. this._indicator = new PanelMenu.Button(0.0, 'Strata', true); const icon = new St.Icon({ icon_name: 'edit-paste-symbolic', style_class: 'system-status-icon', }); this._indicator.add_child(icon); this._indicator.connectObject('button-press-event', () => { this._panel?.toggle(); return false; // EVENT_PROPAGATE }, this); Main.panel.addToStatusArea('strata', this._indicator); } _connectClipboardMonitor() { global.display.get_selection().connectObject('owner-changed', (_sel, type) => { if (type !== Meta.SelectionType.SELECTION_CLIPBOARD) return; this._scheduleClipboardRead(); }, this); } _disconnectClipboardMonitor() { // Cancel any pending debounce timer and reset the in-flight guard so // the next enable() cycle starts clean. if (this._clipboardDebounceId !== null) { GLib.Source.remove(this._clipboardDebounceId); this._clipboardDebounceId = null; } this._clipboardTransferPending = false; global.display.get_selection().disconnectObject(this); } /** Debounce entry point - coalesces rapid clipboard changes (e.g. from * apps that write clipboard multiple times per operation). */ _scheduleClipboardRead() { if (this._clipboardDebounceId !== null) { GLib.Source.remove(this._clipboardDebounceId); this._clipboardDebounceId = null; } this._clipboardDebounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => { this._clipboardDebounceId = null; this._readClipboard(); return GLib.SOURCE_REMOVE; }); } _readClipboard() { if (this._clipboardTransferPending) return; const selection = global.display.get_selection(); const mimes = selection.get_mimetypes(Meta.SelectionType.SELECTION_CLIPBOARD); // Password managers (KeePassXC, KeePass, some Bitwarden builds) mark // copied secrets with this hint mime. Honoring it lets users keep // their passwords out of clipboard history. if (mimes.includes('x-kde-passwordManagerHint')) return; if (this._isExcluded(this._currentFocusedApp)) return; const mime = this._pickMime(mimes); if (!mime) return; this._clipboardTransferPending = true; const outputStream = Gio.MemoryOutputStream.new_resizable(); selection.transfer_async( Meta.SelectionType.SELECTION_CLIPBOARD, mime, -1, outputStream, null, (_obj, result) => { this._clipboardTransferPending = false; try { selection.transfer_finish(result); outputStream.close(null); const bytes = outputStream.steal_as_bytes(); const size = bytes.get_size(); if (size === 0) return; if (size > (mime.startsWith('image/') ? this._maxImageBytes : this._maxTextBytes)) return; this._proxy?.SubmitItemRemote(mime, bytes.get_data(), () => {}); } catch (e) { logError('Clipboard read error', e); } } ); } /** Pick the best MIME type to store from the offered list (mirrors Rust pick_mime). */ _pickMime(mimes) { const PREFERRED = [ 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/bmp', 'image/tiff', 'image/x-icon', // Plain text (UTF-8 preferred, then locale, then X11 legacy aliases). // Ranked above rich-text so editors that expose both text/plain and // text/html give us raw source, not styled markup. 'text/plain;charset=utf-8', 'UTF8_STRING', 'text/plain', 'STRING', 'TEXT', // Rich text -- only reached when no plain-text offer is available. 'text/html', 'text/rtf', 'application/rtf', 'text/markdown', // File-manager copy/cut payloads (URI list, not bytes). 'x-special/gnome-copied-files', 'x-special/nautilus-clipboard', 'application/x-kde-cutselection', 'text/uri-list', ]; for (const want of PREFERRED) if (mimes.includes(want)) return want; // Allowlist only: see pick_mime in the daemon. Unknown mime types are skipped. return null; } _spawnDaemon() { if (this._shuttingDown) return; // Coalesce overlapping spawn attempts: the async GetNameOwner check below // can still be in flight when the backoff timer fires _spawnDaemon again. // Without this guard both could call _doSpawnDaemon and spawn two daemons. if (this._spawnPending) return; this._spawnPending = true; // Check if a daemon is already running (e.g. via systemd user service). // If the D-Bus name is already owned we must not spawn a second instance. Gio.DBus.session.call( 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'GetNameOwner', new GLib.Variant('(s)', [BUS_NAME]), null, Gio.DBusCallFlags.NONE, 2000, null, (_conn, result) => { this._spawnPending = false; if (this._shuttingDown) return; try { _conn.call_finish(result); // Name already owned (e.g. systemd user service) - don't spawn a second instance. } catch { this._doSpawnDaemon(); } } ); } _doSpawnDaemon() { if (this._shuttingDown) return; const daemonPath = GLib.find_program_in_path('strata-daemon'); if (!daemonPath) { logError('strata-daemon not found in PATH. Install the strata-daemon package or place the binary in your PATH.'); this._notifyDaemonMissing(); return; } try { this._daemon = new Gio.Subprocess({ argv: [daemonPath], flags: Gio.SubprocessFlags.NONE, }); this._daemon.init(null); this._daemonSpawnTime = GLib.get_monotonic_time() / 1000; // ms this._daemon.wait_async(null, (proc) => this._onDaemonExited(proc)); } catch (e) { logError('Failed to spawn daemon', e); this._scheduleDaemonRestart(); } } _notifyDaemonMissing() { try { const source = new MessageTray.Source({ title: 'Strata', icon: new St.Icon({ icon_name: 'edit-paste-symbolic' }), }); Main.messageTray.add(source); const notification = new MessageTray.Notification({ source, title: 'Strata: daemon not installed', body: 'Install the strata-daemon package. See the project page for instructions.', urgency: MessageTray.Urgency.HIGH, }); source.addNotification(notification); } catch { // Message tray may not be available (e.g. during early startup) - already logged above. } } _onDaemonExited(proc) { // Ignore exits from anything that is not the current daemon: a stop // initiated by _stopDaemon (which nulls _daemon), or a stray subprocess // from an overlapping spawn. Otherwise we'd misattribute its exit. if (!this._daemon || proc !== this._daemon) return; const exit = proc.get_exit_status(); const lifetimeMs = (GLib.get_monotonic_time() / 1000) - this._daemonSpawnTime; this._daemon = null; if (this._shuttingDown) return; // Reset attempt counter if the daemon ran long enough to be considered healthy. if (lifetimeMs >= 5000) { this._daemonRestartAttempts = 0; } this._daemonRestartAttempts++; logError(`daemon exited with status ${exit} after ${Math.round(lifetimeMs)}ms (restart attempt ${this._daemonRestartAttempts})`); if (this._daemonRestartAttempts > 5) { logError('Daemon crashed 5 times in rapid succession - giving up. Disable and re-enable the extension to retry.'); return; } this._scheduleDaemonRestart(); } _scheduleDaemonRestart() { if (this._shuttingDown) return; if (this._daemonRestartTimerId !== null) { GLib.Source.remove(this._daemonRestartTimerId); this._daemonRestartTimerId = null; } // Exponential backoff: 1s, 2s, 4s, 8s, 16s const backoffMs = 1000 * Math.pow(2, Math.max(0, this._daemonRestartAttempts - 1)); this._daemonRestartTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, backoffMs, () => { this._daemonRestartTimerId = null; this._spawnDaemon(); return GLib.SOURCE_REMOVE; }); } _stopDaemon() { if (!this._daemon) return; const daemon = this._daemon; this._daemon = null; try { daemon.send_signal(15); } catch { /* already exited */ } // SIGTERM } _connectProxy() { try { this._proxy = new StrataProxy( Gio.DBus.session, BUS_NAME, OBJECT_PATH, (proxy, error) => { if (this._shuttingDown || proxy !== this._proxy) return; if (error) { logError('D-Bus proxy error', error); return; } this._connectSignals(); this._panel = new StrataPanel(proxy, this._settings); // Push config now if the daemon is already up, and // again on every owner transition so a respawned // daemon picks up the latest values. this._pushConfig(); proxy.connectObject('notify::g-name-owner', () => { if (proxy.g_name_owner) this._pushConfig(); }, this); } ); } catch (e) { logError('Failed to create D-Bus proxy', e); } } /** Read size limits from GSettings into instance fields (bytes). */ _readSizeLimits() { this._maxHistory = this._settings.get_int('max-history'); this._maxTextBytes = this._settings.get_int('max-text-mb') * 1024 * 1024; this._maxImageBytes = this._settings.get_int('max-image-mb') * 1024 * 1024; } /** Push runtime limits to the daemon. Safe to call before the proxy * is ready or after the daemon has gone away. */ _pushConfig() { this._proxy?.SetConfigRemote( this._maxHistory, this._maxTextBytes, this._maxImageBytes, () => {} ); } _connectSignals() { this._itemAddedId = this._proxy.connectSignal('ItemAdded', (_p, _sender, [id, mimeType, preview]) => this._onItemAdded(id, mimeType, preview)); this._itemDeletedId = this._proxy.connectSignal('ItemDeleted', (_p, _sender, [id]) => { try { const cachePath = `${GLib.get_user_cache_dir()}/strata/thumbnails/${id}.png`; GLib.unlink(cachePath); } catch { /* no cached thumbnail for this id */ } this._addIdleSource(() => this._panel?.removeItem(id)); }); this._historyClearedId = this._proxy.connectSignal('HistoryCleared', () => { this._clearThumbnailCache(); this._addIdleSource(() => this._panel?.clearItems()); }); } /** Delete every cached thumbnail without blocking the compositor: * enumerate and unlink asynchronously in batches. */ _clearThumbnailCache() { const dir = Gio.File.new_for_path( `${GLib.get_user_cache_dir()}/strata/thumbnails`); dir.enumerate_children_async( 'standard::name', Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null, (d, res) => { let en; try { en = d.enumerate_children_finish(res); } catch { return; // dir missing or unreadable } const step = () => { en.next_files_async(32, GLib.PRIORITY_DEFAULT, null, (e, r) => { let infos; try { infos = e.next_files_finish(r); } catch { infos = []; } if (infos.length === 0) { e.close_async(GLib.PRIORITY_DEFAULT, null, () => {}); return; } for (const info of infos) { e.get_child(info).delete_async( GLib.PRIORITY_DEFAULT, null, (f, rr) => { try { f.delete_finish(rr); } catch { /* best-effort */ } }); } step(); }); }; step(); }); } _disconnectSignals() { if (this._itemAddedId && this._proxy) { this._proxy.disconnectSignal(this._itemAddedId); this._itemAddedId = null; } if (this._itemDeletedId && this._proxy) { this._proxy.disconnectSignal(this._itemDeletedId); this._itemDeletedId = null; } if (this._historyClearedId && this._proxy) { this._proxy.disconnectSignal(this._historyClearedId); this._historyClearedId = null; } if (this._pendingSignalId !== null) { GLib.Source.remove(this._pendingSignalId); this._pendingSignalId = null; } } /** Schedule a one-shot idle callback whose source ID is tracked so disable() * can drop pending work instead of leaking a closure on `this`. */ _addIdleSource(callback) { const id = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { this._idleSources.delete(id); callback(); return GLib.SOURCE_REMOVE; }); this._idleSources.add(id); return id; } _clearIdleSources() { for (const id of this._idleSources) GLib.Source.remove(id); this._idleSources.clear(); } _onItemAdded(id, mimeType, preview) { // Debounce: if the daemon emits a burst, coalesce into one update. if (this._pendingSignalId !== null) { GLib.Source.remove(this._pendingSignalId); this._pendingSignalId = null; } this._pendingSignalId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => { this._pendingSignalId = null; this._addIdleSource(() => this._panel?.prependItem(id, mimeType, preview)); return GLib.SOURCE_REMOVE; }); } _readExcluded() { return this._settings.get_strv('excluded-apps').map(s => s.toLowerCase()); } _isExcluded(appClass) { if (!appClass) return false; return this._excludedApps.some(ex => appClass.includes(ex)); } _connectFocusTracking() { global.display.connectObject('notify::focus-window', () => { const win = global.display.focus_window; this._currentFocusedApp = (win?.get_wm_class() ?? '').toLowerCase(); }, this); } _disconnectFocusTracking() { global.display.disconnectObject(this); } _registerShortcut() { // POPUP is included so the shortcut still fires while the panel's own // modal grab is active (pushModal sets actionMode POPUP). Without it the // second press is swallowed by the grab and toggle() never runs, so the // panel could open but not close via the shortcut. Main.wm.addKeybinding( 'keyboard-shortcut', this._settings, Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, () => this._panel?.toggle() ); } _unregisterShortcut() { Main.wm.removeKeybinding('keyboard-shortcut'); } }