mirror of
https://github.com/edu4rdshl/Strata.git
synced 2026-07-18 07:34:45 +00:00
Gio.Subprocess.send_signal returns void and is race-free, so it never throws.
528 lines
20 KiB
JavaScript
528 lines
20 KiB
JavaScript
/* 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<number>} 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;
|
|
daemon.send_signal(15); // 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');
|
|
}
|
|
}
|