From 2c2be21fefe673aa78ad6f788750dbf0350cd570 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Wed, 11 Feb 2026 16:14:31 -0500 Subject: [PATCH] [gnome] Add new extensions --- .../extension.js | 89 + .../isMatch.js | 36 + .../metadata.json | 15 + .../prefs.js | 143 ++ .../schemas/gschemas.compiled | Bin 0 -> 496 bytes ...ions.junk-notification-cleaner.gschema.xml | 25 + .../extension.js | 245 +++ .../metadata.json | 16 + .../prefs.js | 248 +++ .../schemas/gschemas.compiled | Bin 0 -> 836 bytes ...s.notification-banner-reloaded.gschema.xml | 53 + .../utils.js | 9 + .../extension.js | 1704 +++++++++++++++++ .../metadata.json | 18 + .../prefs.js | 672 +++++++ .../schemas/gschemas.compiled | Bin 0 -> 1770 bytes ...e.shell.extensions.quicklaunch.gschema.xml | 142 ++ .../stylesheet.css | 131 ++ .../gnome-shell-extension-screen-rotate.mo | Bin 0 -> 4542 bytes .../snapshell@unbl.ink/extension.js | 262 +++ .../snapshell@unbl.ink/metadata.json | 7 + .../extensions/snapshell@unbl.ink/prefs.js | 200 ++ .../schemas/gschemas.compiled | Bin 0 -> 465 bytes ...ome.shell.extensions.snapshell.gschema.xml | 31 + .../extensions/vpn-toggler@rheddes.nl | 1 + 25 files changed, 4047 insertions(+) create mode 100644 gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/extension.js create mode 100644 gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/isMatch.js create mode 100644 gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/metadata.json create mode 100644 gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/prefs.js create mode 100644 gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/schemas/gschemas.compiled create mode 100644 gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/schemas/org.gnome.shell.extensions.junk-notification-cleaner.gschema.xml create mode 100644 gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/extension.js create mode 100644 gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/metadata.json create mode 100644 gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/prefs.js create mode 100644 gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/schemas/gschemas.compiled create mode 100644 gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/schemas/org.gnome.shell.extensions.notification-banner-reloaded.gschema.xml create mode 100644 gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/utils.js create mode 100644 gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/extension.js create mode 100644 gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/metadata.json create mode 100644 gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/prefs.js create mode 100644 gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/schemas/gschemas.compiled create mode 100644 gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/schemas/org.gnome.shell.extensions.quicklaunch.gschema.xml create mode 100644 gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/stylesheet.css create mode 100644 gnome/.local/share/gnome-shell/extensions/screen-rotate@shyzus.github.io/locale/ru/LC_MESSAGES/gnome-shell-extension-screen-rotate.mo create mode 100644 gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/extension.js create mode 100644 gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/metadata.json create mode 100644 gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/prefs.js create mode 100644 gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/schemas/gschemas.compiled create mode 100644 gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/schemas/org.gnome.shell.extensions.snapshell.gschema.xml create mode 120000 gnome/.local/share/gnome-shell/extensions/vpn-toggler@rheddes.nl diff --git a/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/extension.js b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/extension.js new file mode 100644 index 0000000..727b06d --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/extension.js @@ -0,0 +1,89 @@ +import * as Main from "resource:///org/gnome/shell/ui/main.js"; +import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; +import { isMatch } from "./isMatch.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(([_, value]) => value) + .map(([label, value]) => `${label}: '${value}'`); + return `${name}(${labels.join(", ")})`; +} +function getWindowLabel(window) { + return getObjectLabel("Window", { + ["Title"]: window.title, + ["WMClass"]: window.wmClass, + ["GTKAppId"]: window.gtkApplicationId, + ["SandboxedAppId"]: window.get_sandboxed_app_id(), + }); +} +function getSourceLabel(source) { + return getObjectLabel("Source", { + ["Title"]: source.title, + ["Icon"]: source.icon?.to_string(), + }); +} +export default class JunkNotificationCleaner extends Extension { + focusListenerId = null; + closeListenerId = null; + settings = null; + log(level, message) { + let minLevel = this.settings.get_string("log-level"); + const levels = Object.values(LogLevel); + if (!levels.includes(minLevel)) + minLevel = LogLevel.INFO; + if (levels.indexOf(level) >= levels.indexOf(minLevel)) { + log(`[${this.metadata.uuid}][${level}] ${message}`); + } + } + clearNotificationsForApp(window, event) { + const windowLabel = getWindowLabel(window); + this.log(LogLevel.DEBUG, `${windowLabel}: received ${event}`); + const excludedApps = this.settings.get_strv("excluded-apps"); + for (const wmClassPattern of excludedApps) { + if (new RegExp(wmClassPattern).test(window.wmClass)) { + this.log(LogLevel.DEBUG, `${windowLabel}: excluded by '${wmClassPattern}'`); + return; + } + } + for (const source of Main.messageTray.getSources()) { + const sourceLabel = getSourceLabel(source); + for (const notification of [...source.notifications]) { + this.log(LogLevel.DEBUG, `${windowLabel}: ${sourceLabel}: found ${notification.isTransient ? "transient" : "persistent"} notification${notification.title ? `: ${notification.title}` : ""}`); + if (isMatch(window, source) && !notification.isTransient) { + notification.destroy(); + this.log(LogLevel.INFO, `${windowLabel}: ${sourceLabel}: removed notification${notification.title ? `: ${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() { + if (this.focusListenerId !== null) { + global.display.disconnect(this.focusListenerId); + } + if (this.closeListenerId !== null) { + global.window_manager.disconnect(this.closeListenerId); + } + if (this.settings) { + this.settings = null; + } + } +} diff --git a/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/isMatch.js b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/isMatch.js new file mode 100644 index 0000000..b96d150 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/isMatch.js @@ -0,0 +1,36 @@ +export function isMatch(window, source) { + if (source.icon) { + const icon = source.icon.to_string(); + if ( + // 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) { + return true; + } + // Snap apps have icon paths like /snap/firefox/6638/default256.png + const snapAppName = icon?.match(/^\/snap\/([^/]+)\//)?.at(1); + // Snap sandboxed ids use format appname_appname (firefox_firefox) + if (snapAppName) { + if (window.get_sandboxed_app_id() === `${snapAppName}_${snapAppName}`) { + return true; + } + } + } + if (source.title) { + if ( + // Proton Mail Bridge: title matches window title + source.title === window.title || + // Extract app name from composite title (isMatch.ts - junk-notification-cleaner - Cursor) + source.title === window.title?.match(/^.+ (-|\|) (.+)$/)?.[2] || + // Thunderbird: title matches window manager class (thunderbird) + source.title === window.wmClass || + // Discord snap: title duplicated matches sandboxed app id (discord_discord) + `${source.title}_${source.title}` === window.get_sandboxed_app_id()) { + return true; + } + } + return false; +} diff --git a/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/metadata.json b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/metadata.json new file mode 100644 index 0000000..279cd61 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/metadata.json @@ -0,0 +1,15 @@ +{ + "_generated": "Generated by SweetTooth, do not edit", + "description": "Delete notifications for an application when its window is focused or closed.", + "name": "Junk Notification Cleaner", + "settings-schema": "org.gnome.shell.extensions.junk-notification-cleaner", + "shell-version": [ + "46", + "47", + "48", + "49" + ], + "url": "https://github.com/murar8/junk-notification-cleaner", + "uuid": "junk-notification-cleaner@murar8.github.com", + "version": 6 +} \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/prefs.js b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/prefs.js new file mode 100644 index 0000000..23d7ceb --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/prefs.js @@ -0,0 +1,143 @@ +import Adw from "gi://Adw"; +import Gio from "gi://Gio"; +import Gtk from "gi://Gtk"; +import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js"; +const LOG_LEVELS = ["debug", "info", "warn", "error"]; +export default class JunkNotificationCleanerPreferences extends ExtensionPreferences { + async fillPreferencesWindow(window) { + const settings = this.getSettings(); + const page = new Adw.PreferencesPage(); + page.set_title("Settings"); + page.set_icon_name("preferences-system-symbolic"); + window.add(page); + const generalGroup = new Adw.PreferencesGroup(); + generalGroup.set_title("General Settings"); + page.add(generalGroup); + const focusRow = new Adw.ActionRow({ + title: "Delete on Focus", + subtitle: "Delete notifications when an application window is focused.", + }); + const focusSwitch = new Gtk.Switch({ + active: settings.get_boolean("delete-on-focus"), + valign: Gtk.Align.CENTER, + }); + settings.bind("delete-on-focus", focusSwitch, "active", Gio.SettingsBindFlags.DEFAULT); + focusRow.add_suffix(focusSwitch); + generalGroup.add(focusRow); + const closeRow = new Adw.ActionRow({ + title: "Delete on Close", + subtitle: "Delete notifications when an application window is closed.", + }); + const closeSwitch = new Gtk.Switch({ + active: settings.get_boolean("delete-on-close"), + valign: Gtk.Align.CENTER, + }); + settings.bind("delete-on-close", closeSwitch, "active", Gio.SettingsBindFlags.DEFAULT); + closeRow.add_suffix(closeSwitch); + generalGroup.add(closeRow); + const debugGroup = new Adw.PreferencesGroup(); + debugGroup.set_title("Logging"); + page.add(debugGroup); + const logLevelRow = new Adw.ActionRow({ + title: "Log Level", + subtitle: "Set the logging level for troubleshooting notification matching.", + }); + const logLevelDropdown = new Gtk.DropDown({ + model: Gtk.StringList.new(LOG_LEVELS), + valign: Gtk.Align.CENTER, + }); + let currentLogLevel = settings.get_string("log-level") || "info"; + const currentIndex = LOG_LEVELS.indexOf(currentLogLevel); + if (currentIndex !== -1) { + logLevelDropdown.set_selected(currentIndex); + } + logLevelDropdown.connect("notify::selected", () => { + const selectedIndex = logLevelDropdown.get_selected(); + settings.set_string("log-level", LOG_LEVELS[selectedIndex]); + }); + logLevelRow.add_suffix(logLevelDropdown); + debugGroup.add(logLevelRow); + const excludedGroup = new Adw.PreferencesGroup(); + excludedGroup.set_title("Excluded WM Classes"); + excludedGroup.set_description([ + "Window Manager Classes whose notifications will not be automatically deleted.", + "Will be matched against the wm_class property of the window, supports ECMAScript regular expressions.", + ].join("\n")); + page.add(excludedGroup); + const excludedBox = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + margin_top: 8, + margin_bottom: 8, + margin_start: 8, + margin_end: 8, + spacing: 8, + }); + const excludedApps = settings.get_strv("excluded-apps"); + const listBox = new Gtk.ListBox({ + selection_mode: Gtk.SelectionMode.NONE, + css_classes: ["boxed-list"], + }); + excludedBox.append(listBox); + for (const app of excludedApps) { + this.addExcludedAppRow(app, listBox, settings); + } + const addBox = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 8, + margin_top: 8, + }); + const entry = new Gtk.Entry({ + placeholder_text: "Enter WM Class regex (e.g. .*firefox.*)", + hexpand: true, + }); + const addButton = new Gtk.Button({ + label: "Add", + css_classes: ["suggested-action"], + }); + addButton.connect("clicked", () => { + const text = entry.get_text().trim(); + if (!text) + return; + const currentApps = settings.get_strv("excluded-apps"); + if (currentApps.includes(text)) + return; + settings.set_strv("excluded-apps", [...currentApps, text]); + this.addExcludedAppRow(text, listBox, settings); + entry.set_text(""); + }); + addBox.append(entry); + addBox.append(addButton); + excludedBox.append(addBox); + excludedGroup.add(excludedBox); + } + addExcludedAppRow(app, listBox, settings) { + const row = new Gtk.ListBoxRow(); + const box = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 8, + margin_top: 8, + margin_bottom: 8, + margin_start: 8, + margin_end: 8, + }); + const label = new Gtk.Label({ + label: app, + hexpand: true, + xalign: 0, + }); + const removeButton = new Gtk.Button({ + icon_name: "user-trash-symbolic", + tooltip_text: "Remove", + }); + removeButton.connect("clicked", () => { + const currentApps = settings.get_strv("excluded-apps"); + const newApps = currentApps.filter((a) => a !== app); + settings.set_strv("excluded-apps", newApps); + listBox.remove(row); + }); + box.append(label); + box.append(removeButton); + row.set_child(box); + listBox.append(row); + } +} diff --git a/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/schemas/gschemas.compiled b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/schemas/gschemas.compiled new file mode 100644 index 0000000000000000000000000000000000000000..72cf47b225a4149f113d9816f08b3967939148c2 GIT binary patch literal 496 zcmZ{gF-rqM5QR6<1VWHhf++Aa*y%rWWilysiE;(IykL)F+iN8S5#@@z4uoUZ* zf}MpT_!BIGe?Z^6oM_{~<9ob?nVs3%*TYQPLRGZ}9xQxNud*8W@Y!yXd|OxB>Kxoe z&{$RK3-!TAwWG*6h5QiApqtKfjX8Y>`2?Io7f-D^b9&~N z;1x8Rp0&9PeFOP5_(ostNY5SLLGCG&#Eg2<3w54*KCuGryT;Cpae2u2_mJ_whm4hCv!5HXQ~7o98ziT4 AWB>pF literal 0 HcmV?d00001 diff --git a/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/schemas/org.gnome.shell.extensions.junk-notification-cleaner.gschema.xml b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/schemas/org.gnome.shell.extensions.junk-notification-cleaner.gschema.xml new file mode 100644 index 0000000..cb75d93 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/junk-notification-cleaner@murar8.github.com/schemas/org.gnome.shell.extensions.junk-notification-cleaner.gschema.xml @@ -0,0 +1,25 @@ + + + + + true + Delete notifications when focusing app + Delete notifications when an application window is focused. + + + true + Delete notifications when closing app + Delete notifications when an application window is closed. + + + [] + Excluded applications + List of application regex patterns for which notifications should not be automatically deleted. + + + 'info' + Log level + Set the logging level for troubleshooting notification matching issues. + + + \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/extension.js b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/extension.js new file mode 100644 index 0000000..4916ba9 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/extension.js @@ -0,0 +1,245 @@ +/* extension.js + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import Clutter from 'gi://Clutter'; + + +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import { MessageTray } from 'resource:///org/gnome/shell/ui/messageTray.js'; +import * as Utils from './utils.js'; + +const BannerBin = Main.messageTray._bannerBin; + +/* Imports necessary by the code pulled in from messageTray.js */ +import { State, Urgency } from 'resource:///org/gnome/shell/ui/messageTray.js'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import St from 'gi://St'; +import * as Calendar from 'resource:///org/gnome/shell/ui/calendar.js' +import * as MessageList from 'resource:///org/gnome/shell/ui/messageList.js' + + +const NOTIFICATION_TIMEOUT = 4000; +const HIDE_TIMEOUT = 200; +const LONGER_HIDE_TIMEOUT = 600; +const IDLE_TIME = 1000; + +let ANIMATION_TIME = 200; +let ANIMATION_DIRECTION = 2; +let ANCHOR_VERTICAL = 0; +let ANCHOR_HORIZONTAL = 2; +let PADDING_VERTICAL = 0; +let PADDING_HORIZONTAL = 0; +let ALWAYS_MINIMIZE = 0; + +function patcher(obj, live, method, original, patch) { + const body = eval(`${obj}.prototype.${method}.toString()`); + const newBody = body.replace(original, patch).replace(method + "(", "function(") + eval(`${obj}.prototype.${method} = ${newBody}`); + eval(`${live}.${method} = ${newBody}`); +} + +const getMessageTraySize = () => { + const { width, height } = Main.layoutManager.getWorkAreaForMonitor(global.display.get_primary_monitor()); + return {width, height}; +} + +const originalShow = MessageTray.prototype._showNotification; +const originalHide = MessageTray.prototype._hideNotification; +const originalUpdateShowing = MessageTray.prototype._updateShowingNotification; + +function calcTarget(self) { + let x = 0, y = 0; + switch (ANCHOR_HORIZONTAL) { + case 0: // left + x = 0 + PADDING_HORIZONTAL; + break; + case 1: // right + x = getMessageTraySize().width - self._banner.width - PADDING_HORIZONTAL; + break; + case 2: // center + x = (getMessageTraySize().width - self._banner.width) / 2.0; + break; + } + switch (ANCHOR_VERTICAL) { + case 0: // top + y = 0 + PADDING_VERTICAL; + break; + case 1: // bottom + y = getMessageTraySize().height - self._banner.height - PADDING_VERTICAL; + break; + case 2: // center + y = (getMessageTraySize().height - self._banner.height) / 2.0; + break; + } + return { x, y } +} + +function calcHide(self) { + let { x, y } = calcTarget(self) + switch (ANIMATION_DIRECTION) { + case 0: // from left + x = -self._banner.width; + break; + case 1: // from right + x = getMessageTraySize().width; + break; + case 2: // from top + y = -self._banner.height + break; + case 3: // from bottom + y = getMessageTraySize().height + break; + } + return { x, y } +} + +function calcStart(self) { + const { x, y } = calcHide(self); + self._bannerBin.x = x; + self._bannerBin.y = y; + + + // if banner is not expanded and anchored to the bottom + // it won't have enough vertical space to expand + // in such case, move it up enough to fit the expanded banner + if (!self._banner.expanded && ANCHOR_VERTICAL == 1) { + const unexpandedHeight = self._banner.height + // expand without animation to measure height + self._banner.expand(false); + const expandedDifference = self._banner.height - unexpandedHeight; + // go back to unexpanded + self._banner.unexpand(false); + + // move up when needed + self._banner.connect('expanded', () => { + self._bannerBin.ease({ + y: self._bannerBin.y - expandedDifference, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + } +} + +// each of the methods has hardcoded values for showing banners +// at the top. instead of rewriting whole functions, just patch the +// relevant lines +const patches = [ + { + "obj": "MessageTray", "method": "_updateShowingNotification", + "live": "Main.messageTray", + "original": 'y: 0', + "patch": '...calcTarget(this)' + }, + { + "obj": "MessageTray", "method": "_showNotification", + "live": "Main.messageTray", + "original": 'this._bannerBin.y = -this._banner.height', + "patch": 'calcStart(this)' + }, + { + "obj": "MessageTray", "method": "_hideNotification", + "live": "Main.messageTray", + "original": 'y: -this._bannerBin.height', + "patch": '...calcHide(this)' + } +]; + +const always_minimize_patch = { + obj: "MessageTray", + live: "Main.messageTray", + method: "_updateShowingNotification", + original: "this._expandBanner(true)", + patch: "// always minimized setting enabled by notification-banner-reloaded ... this._expandBanner(true)", +}; + +export default class NotificationExtension extends Extension { + constructor(metadata) { + super(metadata); + this._previous_y_align = BannerBin.get_y_align(); + this._previous_x_align = BannerBin.get_x_align(); + } + + _loadSettings() { + this._settings = this.getSettings(); + this._settingsChangedId = this._settings.connect('changed', this._onSettingsChange.bind(this)); + this._fetchSettings(); + } + + _fetchSettings() { + ANCHOR_VERTICAL = this._settings.get_int(Utils.PrefFields.ANCHOR_VERTICAL); + ANCHOR_HORIZONTAL = this._settings.get_int(Utils.PrefFields.ANCHOR_HORIZONTAL); + PADDING_VERTICAL = this._settings.get_int(Utils.PrefFields.PADDING_VERTICAL); + PADDING_HORIZONTAL = this._settings.get_int(Utils.PrefFields.PADDING_HORIZONTAL); + ANIMATION_DIRECTION = this._settings.get_int(Utils.PrefFields.ANIMATION_DIRECTION); + ANIMATION_TIME = this._settings.get_int(Utils.PrefFields.ANIMATION_TIME); + ALWAYS_MINIMIZE = this._settings.get_int(Utils.PrefFields.ALWAYS_MINIMIZE); + } + + _onSettingsChange() { + this._fetchSettings(); + this.enable(); + } + + enable() { + this._loadSettings(); + // generally alignment can be controller with START/CENTER/END + // but CENTER and END are problematic to implement animations with + // (especially x -> END and animations from left/right) + // all positions will then be calculated in relation to the top-left + // corner (START/START). + let x_align = Clutter.ActorAlign.START; + let y_align = Clutter.ActorAlign.START; + BannerBin.set_x_align(x_align); + BannerBin.set_y_align(y_align); + this.restore(); + for (const { obj, live, method, original, patch } of patches) { + patcher(obj, live, method, original, patch) + } + + if (ALWAYS_MINIMIZE) { + const { obj, live, method, original, patch } = always_minimize_patch; + patcher(obj, live, method, original, patch); + } + } + + disable() { + BannerBin.set_x_align(this._previous_x_align); + BannerBin.set_y_align(this._previous_y_align); + BannerBin.x = 0 + BannerBin.y = 0 + this.restore() + if (this._settingsChangedId) { + this._settings.disconnect(this._settingsChangedId); + this._settingsChangedId = null; + } + this._settings = null; + + } + + restore() { + MessageTray.prototype._hideNotification = originalHide; + MessageTray.prototype._showNotification = originalShow; + MessageTray.prototype._updateShowingNotification = originalUpdateShowing; + } +} diff --git a/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/metadata.json b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/metadata.json new file mode 100644 index 0000000..f8ecd87 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/metadata.json @@ -0,0 +1,16 @@ +{ + "_generated": "Generated by SweetTooth, do not edit", + "description": "Configure notification banner position and animation to your liking.\nVersion 9: Gnome 45 changes by mannjani@github\nVersion 10: mannjani@github added a test button inside prefs", + "name": "Notification Banner Reloaded", + "settings-schema": "org.gnome.shell.extensions.notification-banner-reloaded", + "shell-version": [ + "45", + "46", + "47", + "48", + "49" + ], + "url": "https://github.com/marcinjakubowski/notification-position-reloaded", + "uuid": "notification-banner-reloaded@marcinjakubowski.github.com", + "version": 17 +} \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/prefs.js b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/prefs.js new file mode 100644 index 0000000..250ebcf --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/prefs.js @@ -0,0 +1,248 @@ +/* extension.js + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +/* + Swaths of pref related code borrowed from Clipboard Indicator, an amazing extension + https://github.com/Tudmotu/gnome-shell-extension-clipboard-indicator + https://extensions.gnome.org/extension/779/clipboard-indicator/ +*/ +import GObject from 'gi://GObject'; +import Gtk from 'gi://Gtk'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import * as Utils from './utils.js'; + +import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +export default class NotificationExtensionPreferences extends ExtensionPreferences { + constructor(metadata) { + super(metadata); + } + + getPreferencesWidget() { + let frame = new Gtk.Box(); + let widget = new Preferences(this.getSettings()); + frame.append(widget.main); + if (frame.show_all) + frame.show_all(); + return frame; + } +} + +class Preferences{ + constructor(settings) { + this.settings = settings + this.main = new Gtk.Grid({ + margin_top: 10, + margin_bottom: 10, + margin_start: 10, + margin_end: 10, + row_spacing: 12, + column_spacing: 18, + column_homogeneous: false, + row_homogeneous: false + }); + + this.paddingHorizontal = new Gtk.SpinButton({ + adjustment: new Gtk.Adjustment({ + lower: 0, + upper: 1000, + step_increment: 1 + }) + }); + this.paddingVertical = new Gtk.SpinButton({ + adjustment: new Gtk.Adjustment({ + lower: 0, + upper: 1000, + step_increment: 1 + }) + }); + this.animationTime = new Gtk.SpinButton({ + adjustment: new Gtk.Adjustment({ + lower: 100, + upper: 5000, + step_increment: 100 + }) + }); + this.anchorHorizontal = new Gtk.ComboBox({ + model: this._create_options([ _('Left'), _('Right'), _('Center') ]) + }); + this.anchorVertical = new Gtk.ComboBox({ + model: this._create_options([ _('Top'), _('Bottom'), _('Center') ]) + }); + this.animationDirection = new Gtk.ComboBox({ + model: this._create_options([ _('Slide from Left'), _('Slide from Right'), _('Slide from Top'), _('Slide from Bottom')]) + }); + this.alwaysMinimize = new Gtk.ComboBox({ + model: this._create_options([ _('false'), _('true')]) + }); + this.testButton = new Gtk.Button({ label: _("Test") }); + this.testButton.connect('clicked', () => { + const notification = new GLib.Variant('(susssasa{sv}i)', [ + 'Notification Banner Reloaded', + 0, + 'dialog-information-symbolic', + 'Notification Banner Reloaded', + 'Test', + [], + {}, + -1, + ]); + Gio.DBus.session.call( + 'org.freedesktop.Notifications', + '/org/freedesktop/Notifications', + 'org.freedesktop.Notifications', + 'Notify', + notification, + null, + Gio.DBusCallFlags.NONE, + -1, + null, + null); + }); + + let rendererText = new Gtk.CellRendererText(); + for (const widget of [this.anchorHorizontal, this.anchorVertical, this.animationDirection, this.alwaysMinimize]) { + widget.pack_start(rendererText, false); + widget.add_attribute(rendererText, "text", 0); + } + + let anchorHorizontalLabel = new Gtk.Label({ + label: _("Horizontal Position"), + hexpand: true, + halign: Gtk.Align.START + }); + let anchorVerticalLabel = new Gtk.Label({ + label: _("Vertical Position"), + hexpand: true, + halign: Gtk.Align.START + }); + let paddingHorizontalLabel = new Gtk.Label({ + label: _("Horizontal Padding"), + hexpand: true, + halign: Gtk.Align.START + }); + let paddingVerticalLabel = new Gtk.Label({ + label: _("Vertical Padding"), + hexpand: true, + halign: Gtk.Align.START + }); + let animationDirectionLabel = new Gtk.Label({ + label: _("Animation Direction"), + hexpand: true, + halign: Gtk.Align.START + }); + let animationTimeLabel = new Gtk.Label({ + label: _("Animation Time (ms)"), + hexpand: true, + halign: Gtk.Align.START + }); + let alwaysMinimizeLabel = new Gtk.Label({ + label: _("Always Minimize"), + hexpand: true, + halign: Gtk.Align.START + }); + + const testButtonLabel = new Gtk.Label({ + label: _("Send Test Notification"), + hexpand: true, + halign: Gtk.Align.START + }); + + const addRow = ((main) => { + let row = 0; + return (label, input) => { + let inputWidget = input; + + if (input instanceof Gtk.Switch) { + inputWidget = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL,}); + addBox(inputWidget, input); + } + + if (label) { + main.attach(label, 0, row, 1, 1); + main.attach(inputWidget, 1, row, 1, 1); + } + else { + main.attach(inputWidget, 0, row, 2, 1); + } + + row++; + }; + })(this.main); + + addRow(anchorHorizontalLabel, this.anchorHorizontal); + addRow(anchorVerticalLabel, this.anchorVertical); + addRow(paddingHorizontalLabel, this.paddingHorizontal); + addRow(paddingVerticalLabel, this.paddingVertical); + addRow(animationDirectionLabel, this.animationDirection); + addRow(animationTimeLabel, this.animationTime); + addRow(alwaysMinimizeLabel, this.alwaysMinimize); + addRow(testButtonLabel, this.testButton); + + settings.bind(Utils.PrefFields.ANCHOR_HORIZONTAL, this.anchorHorizontal, 'active', Gio.SettingsBindFlags.DEFAULT); + settings.bind(Utils.PrefFields.ANCHOR_VERTICAL, this.anchorVertical, 'active', Gio.SettingsBindFlags.DEFAULT); + settings.bind(Utils.PrefFields.ANIMATION_DIRECTION, this.animationDirection, 'active', Gio.SettingsBindFlags.DEFAULT); + settings.bind(Utils.PrefFields.ALWAYS_MINIMIZE, this.alwaysMinimize, 'active', Gio.SettingsBindFlags.DEFAULT); + + this._bindSpinWithThrottle(this.paddingHorizontal, Utils.PrefFields.PADDING_HORIZONTAL); + this._bindSpinWithThrottle(this.paddingVertical, Utils.PrefFields.PADDING_VERTICAL); + this._bindSpinWithThrottle(this.animationTime, Utils.PrefFields.ANIMATION_TIME); + } + + _bindSpinWithThrottle(spinButton, key) { + /*- Load value from settings -*/ + spinButton.value = this.settings.get_int(key); + let timeoutId = 0; + const updateSettings = () => { + const newVal = Math.round(spinButton.value); + if (this.settings.get_int(key) !== newVal) { + this.settings.set_int(key, newVal); + } + timeoutId = 0; + return GLib.SOURCE_REMOVE; + }; + + spinButton.connect('value-changed', () => { + if (timeoutId) { + GLib.source_remove(timeoutId); + } + timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, updateSettings); + }); + + /*- If the user update elsewhere, we update spin value -*/ + this.settings.connect(`changed::${key}`, () => { + const val = this.settings.get_int(key); + if (Math.round(spinButton.value) !== val) { + spinButton.value = val; + } + }); + } + + _create_options(opts) { + let options = opts.map(function (v) { return { name: v }}); + let liststore = new Gtk.ListStore(); + liststore.set_column_types([GObject.TYPE_STRING]) + for (let i = 0; i < options.length; i++ ) { + let option = options[i]; + let iter = liststore.append(); + liststore.set (iter, [0], [option.name]); + } + return liststore; + } +}; diff --git a/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/schemas/gschemas.compiled b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/schemas/gschemas.compiled new file mode 100644 index 0000000000000000000000000000000000000000..53f6b1d407c0d96109001d948f27ee73ca46611e GIT binary patch literal 836 zcmaJLBT~F1^oez3jPSe zrGt}$I0}L~IOz}2bCT4C7JP7WPEIcO-FtJ_Hf5lti9~u9z}tzo`ctnDytDhXxHST19n+#NuEF=f-O=$FYogAynL+ca1y9-Q(TdKc09l;9n6ntHl5S literal 0 HcmV?d00001 diff --git a/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/schemas/org.gnome.shell.extensions.notification-banner-reloaded.gschema.xml b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/schemas/org.gnome.shell.extensions.notification-banner-reloaded.gschema.xml new file mode 100644 index 0000000..48097e1 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/schemas/org.gnome.shell.extensions.notification-banner-reloaded.gschema.xml @@ -0,0 +1,53 @@ + + + + + 200 + Duration of the show/hide animation + Duration of the show/hide animation, in milliseconds + + + + + 0 + Vertical position of the banner + + + + + + 2 + Horizontal position of the banner + + + + + + 0 + Vertical padding of the banner + + + + + + 0 + Horizontal padding of the banner + + + + + + 2 + Animation direction + + + + + 0 + Always minimized notifications by default (ignores urgency of notification) + + + + + diff --git a/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/utils.js b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/utils.js new file mode 100644 index 0000000..3771286 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/notification-banner-reloaded@marcinjakubowski.github.com/utils.js @@ -0,0 +1,9 @@ +export const PrefFields = { + ANCHOR_VERTICAL : 'anchor-vertical', + ANCHOR_HORIZONTAL : 'anchor-horizontal', + PADDING_VERTICAL : 'padding-vertical', + PADDING_HORIZONTAL : 'padding-horizontal', + ANIMATION_TIME : 'animation-time', + ANIMATION_DIRECTION : 'animation-direction', + ALWAYS_MINIMIZE : 'always-minimized', +}; diff --git a/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/extension.js b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/extension.js new file mode 100644 index 0000000..9f58988 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/extension.js @@ -0,0 +1,1704 @@ +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Gio from 'gi://Gio'; +import Clutter from 'gi://Clutter'; +import Shell from 'gi://Shell'; +import GLib from 'gi://GLib'; +import { Extension, gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const AppPinner = GObject.registerClass( + class AppPinner extends PanelMenu.Button { + + // 1. Costruttore e inizializzazione + _init(settings) { + super._init(0.0, _('App Pinner')); + this._settings = settings; + this._destroyed = false; + this._pendingApps = new Set(); + this._settingsHandler = []; + this._longPressTimeoutId = null; + + this._windowTracker = Shell.WindowTracker.get_default(); + this._windowTracker.connect('tracked-windows-changed', this._updateRunningIndicators.bind(this)); + + this._settingsHandler.push( + this._settings.connect('changed::show-in-panel', () => this._updateVisibility()), + this._settings.connect('changed::sort-alphabetically', () => this._refreshUI()), + this._settings.connect('changed::show-pin-icon', () => this._updatePinIconVisibility()) + ); + + this._appSystem = Shell.AppSystem.get_default(); + this._runningTracker = new Set(); + this._appStateChangedId = this._appSystem.connect('app-state-changed', + () => this._updateRunningIndicators()); + + // Contenitore principale + this._mainContainer = new St.BoxLayout({ + style_class: 'panel-button', + vertical: false, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + reactive: true, + track_hover: true + }); + this.add_child(this._mainContainer); + + // Icona del menu + this._menuIcon = new St.Icon({ + icon_name: 'view-pin-symbolic', + style_class: 'system-status-icon app-pinner-menu-icon', + icon_size: 15 + }); + this._mainContainer.add_child(this._menuIcon); + + // Contenitore icone pinnate + this._pinnedIconsBox = new St.BoxLayout({ + style_class: 'app-pinner-icons', + vertical: false, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, + y_expand: true + }); + this._mainContainer.add_child(this._pinnedIconsBox); + + this._updateIconsSpacing(); + + // Connessioni impostazioni + this._settingsHandler.push( + this._settings.connect('changed::icon-size', () => this._refreshUI()), + this._settings.connect('changed::spacing', () => { + this._updateIconsSpacing(); + this._pinnedIconsBox.queue_relayout(); + }), + this._settings.connect('changed::enable-labels', () => this._refreshUI()), + this._settings.connect('changed::pinned-apps', () => this._refreshUI()), + this._settings.connect('changed::indicator-color', () => { + this._updateIndicatorColor(); + this._updateRunningIndicators(); + }) + ); + + + this.menu.actor.connect('button-press-event', (actor, event) => { + const target = event.get_source(); + + if (!this.menu.actor.contains(target)) { + this.menu.close(); + } + }); + + this._buildMenu(); + this._refreshUI(); + + this._logindProxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.system, + g_interface_name: 'org.freedesktop.login1.Manager', + g_object_path: '/org/freedesktop/login1', + g_name: 'org.freedesktop.login1' + }); + + this._logindProxy.init_async(GLib.PRIORITY_DEFAULT, null, (proxy, res) => { + try { + this._logindProxy.init_finish(res); + this._logindId = this._logindProxy.connectSignal('PrepareForSleep', + this._handleSleepSignal.bind(this)); + } catch (e) { + console.error('Errore inizializzazione proxy login1:', e.message); + } + }); + + this._appStateChangedId = this._appSystem.connect('app-state-changed', () => { + this._addTimeout(100, () => { + this._updateRunningIndicators(); + return GLib.SOURCE_REMOVE; + }); + }); + + this._settingsHandler.push( + this._settings.connect('changed::custom-links', () => this._refreshUI()) + ); + + this._timeoutIds = new Set(); + + } + + _updateVisibility() { + const showInPanel = this._settings.get_boolean('show-in-panel'); + this._pinnedIconsBox.visible = showInPanel; + this._updatePinIconVisibility(); + this._pinnedIconsBox.queue_relayout(); + this.queue_relayout(); + } + + _updatePinIconVisibility() { + const showPinIcon = this._settings.get_boolean('show-pin-icon'); + const hasPinnedApps = this._settings.get_strv('pinned-apps').length > 0; + const noPinnedApps = !hasPinnedApps; + + const iconSize = noPinnedApps ? 18 : 18; + this._menuIcon.icon_size = iconSize; + + this._menuIcon.opacity = (noPinnedApps || showPinIcon) ? 255 : 0; + this._menuIcon.reactive = (noPinnedApps || showPinIcon); + this._menuIcon.set_width(showPinIcon ? -1 : iconSize); + + this.queue_relayout(); + } + + _addTimeout(interval, callback) { + const sourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => { + const result = callback(); + this._timeoutIds.delete(sourceId); + return result; + }); + this._timeoutIds.add(sourceId); + return sourceId; + } + + // 2. Metodi lifecycle e gestione stato + destroy() { + if (this._destroyed) return; + this._destroyed = true; + + if (this._logindId && this._logindProxy) { + this._logindProxy.disconnect(this._logindId); + this._logindProxy = null; + } + + this._settingsHandler?.forEach(h => this._settings.disconnect(h)); + this._settingsHandler = null; + + this._pinnedIconsBox?.destroy(); + this._searchInput?.destroy(); + + if (this._appStateChangedId) { + this._appSystem.disconnect(this._appStateChangedId); + this._appStateChangedId = null; + } + + if (this._timeoutIds) { + this._timeoutIds.forEach(id => GLib.Source.remove(id)); + this._timeoutIds.clear(); + } + + if (this._longPressTimeoutId !== null) { + GLib.Source.remove(this._longPressTimeoutId); + this._timeoutIds.delete(this._longPressTimeoutId); + this._longPressTimeoutId = null; + } + + super.destroy(); + } + + // 3. Costruzione UI principale + _buildMenu() { + this.menu.removeAll(); + + // Barra di ricerca + const searchEntry = new PopupMenu.PopupBaseMenuItem(); + const searchBox = new St.BoxLayout({ + vertical: false, + style_class: 'search-box', + x_expand: true, + x_align: Clutter.ActorAlign.FILL + }); + + this._searchInput = new St.Entry({ + hint_text: _('Search applications...'), + can_focus: true, + x_expand: true + }); + + searchBox.add_child(this._searchInput); + searchEntry.actor.add_child(searchBox); + this.menu.addMenuItem(searchEntry); + + // Sezione risultati + this._resultsSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._resultsSection); + + // Sezione pinnati + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._pinnedSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._pinnedSection); + + this._searchInput.clutter_text.connect('text-changed', () => this._updateSearch()); + + // Sezione aggiungi link + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + const linkSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(linkSection); + + const linkBox = new St.BoxLayout({ + vertical: false, + style_class: 'app-pinner-link-box', + x_expand: true, + margin_top: 6, + margin_bottom: 6 + }); + this._linkInput = new St.Entry({ + hint_text: _('Enter URL...'), + can_focus: true, + style_class: 'app-pinner-link-input' + }); + + const addLinkBtn = new St.Button({ + style_class: 'app-pinner-add-link-btn', + label: _('Add'), + can_focus: true, + x_align: Clutter.ActorAlign.END + }); + + addLinkBtn.connect('clicked', () => { + const url = this._linkInput.get_text().trim(); + if (this._isValidUrl(url)) { + this._pinLink(url); + this._linkInput.set_text(''); + this.menu.close(); + } + }); + + linkBox.add_child(this._linkInput); + linkBox.add_child(addLinkBtn); + linkSection.actor.add_child(linkBox); + + this._mainContainer.reactive = true; + this._mainContainer.connect('button-press-event', (actor, event) => { + if (event.get_button() === Clutter.BUTTON_PRIMARY) { + this.menu.toggle(); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + }); + + this.menu.actor.connect('button-press-event', (actor, event) => { + const target = event.get_source(); + if (!this.menu.actor.contains(target)) { + this.menu.close(); + } + }); + } + + _addPinnedIcon(appId) { + + if (appId.startsWith('link://')) { + const url = appId.replace('link://', ''); + return this._addLinkIcon(url); + } + + + const app = Gio.DesktopAppInfo.new(appId + '.desktop') || + Gio.DesktopAppInfo.new(appId); + + if (!app) { + const altPaths = [ + GLib.build_filenamev([GLib.get_home_dir(), '.local', 'share', 'applications', `${appId}.desktop`]), + `/var/lib/flatpak/exports/share/applications/${appId}.desktop`, + `${GLib.get_home_dir()}/.local/share/flatpak/exports/share/applications/${appId}.desktop` + ]; + + for (const path of altPaths) { + if (GLib.file_test(path, GLib.FileTest.EXISTS)) { + const altApp = Gio.DesktopAppInfo.new_from_filename(path); + if (altApp) { + app = altApp; + break; + } + } + } + + if (!app) { + console.error(`[CRITICAL] App not found in any location: ${appId}`); + return; + } + } + + const showLabels = this._settings.get_boolean('enable-labels'); + + const iconBox = new St.BoxLayout({ + vertical: true, + style_class: 'app-pinner-icon-box', + y_expand: true + }); + + const iconContainer = new St.Bin({ + style_class: 'app-pinner-icon-container', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + + const iconButton = new St.Button({ + child: new St.Icon({ + gicon: app.get_icon(), + icon_size: this._settings.get_int('icon-size') + }), + style_class: 'app-pinner-icon', + reactive: true, + hover: true, + track_hover: true + }); + + const runningIndicator = new St.Widget({ + style_class: 'app-pinner-running-indicator', + visible: false, + reactive: false, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.START, + x_expand: false, + y_expand: false, + translation_x: +9, + translation_y: -9 + }); + + this._updateIndicatorColor(runningIndicator); + + this._settings.connect('changed::indicator-color', + () => this._updateIndicatorColor(runningIndicator)); + + + iconContainer.add_child(iconButton); + iconContainer.add_child(runningIndicator); + + iconBox.runningIndicator = runningIndicator; + + let pressStartTime = 0; + this._longPressTimeoutId = null; + let isLongPress = false; + + iconButton.connect('button-press-event', (actor, event) => { + + if (this._longPressTimeoutId !== null) { + GLib.Source.remove(this._longPressTimeoutId); + this._timeoutIds.delete(this._longPressTimeoutId); + this._longPressTimeoutId = null; + } + + pressStartTime = Date.now(); + isLongPress = false; + + this._longPressTimeoutId = this._addTimeout(500, () => { + isLongPress = true; + this._animateAndMoveToEnd(appId, iconBox); + this._longPressTimeoutId = null; + return GLib.SOURCE_REMOVE; + }); + + actor.ease({ + scale_x: 0.8, + scale_y: 0.8, + duration: 200 + }); + return Clutter.EVENT_PROPAGATE; + }); + + iconButton.connect('button-release-event', (actor, event) => { + if (this._longPressTimeoutId !== null) { + GLib.Source.remove(this._longPressTimeoutId); + this._timeoutIds.delete(this._longPressTimeoutId); + this._longPressTimeoutId = null; + } + + actor.ease({ + scale_x: 1.0, + scale_y: 1.0, + duration: 200 + }); + return Clutter.EVENT_PROPAGATE; + }); + + iconButton.connect('clicked', () => { + if (!isLongPress) { + this._launchApp(appId); + } + isLongPress = false; + }); + + iconBox.add_child(iconContainer); + + if (showLabels) { + iconBox.add_child(new St.Label({ + text: app.get_name(), + style_class: 'app-pinner-label' + })); + } + + this._pinnedIconsBox.add_child(iconBox); + iconBox.appId = appId; + + this._updateRunningIndicator(appId, runningIndicator); + + return iconBox; + } + + _addLinkIcon(url) { + const appId = `link://${url}`; + const iconSize = this._settings.get_int('icon-size'); + const showLabels = this._settings.get_boolean('enable-labels'); + + const iconBox = new St.BoxLayout({ + vertical: true, + style_class: 'app-pinner-icon-box' + }); + + const iconButton = new St.Button({ + child: new St.Icon({ + icon_name: 'emblem-web-symbolic', + icon_size: iconSize, + style_class: 'app-pinner-link-icon' + }), + style_class: 'app-pinner-icon', + reactive: true, + hover: true, + track_hover: true + }); + + iconButton.connect('notify::hover', () => { + iconButton.style = iconButton.hover + ? 'background: rgba(255,255,255,0.1); border-radius: 6px;' + : ''; + }); + + let pressStartTime = 0; + this._longPressTimeoutId = null; + let isLongPress = false; + + iconButton.connect('button-press-event', (actor, event) => { + + pressStartTime = Date.now(); + isLongPress = false; + + this._longPressTimeoutId = this._addTimeout(500, () => { + isLongPress = true; + this._animateAndMoveToEnd(appId, iconBox); + return GLib.SOURCE_REMOVE; + }); + + actor.ease({ + scale_x: 0.8, + scale_y: 0.8, + duration: 200 + }); + + return Clutter.EVENT_PROPAGATE; + }); + + iconButton.connect('button-release-event', (actor, event) => { + if (this._longPressTimeoutId !== null) { + GLib.Source.remove(this._longPressTimeoutId); + this._timeoutIds.delete(this._longPressTimeoutId); + this._longPressTimeoutId = null; + } + + actor.ease({ + scale_x: 1.0, + scale_y: 1.0, + duration: 200 + }); + + return Clutter.EVENT_PROPAGATE; + }); + + iconButton.connect('clicked', () => { + if (!isLongPress) { + Gio.AppInfo.launch_default_for_uri(url, null); + } + isLongPress = false; + }); + + const container = new St.Bin(); + container.add_child(iconButton); + iconBox.add_child(container); + + if (showLabels) { + const label = new St.Label({ + text: this._shortenUrl(url), + style_class: 'app-pinner-label', + x_align: Clutter.ActorAlign.CENTER + }); + iconBox.add_child(label); + } + + iconBox.appId = appId; + this._pinnedIconsBox.add_child(iconBox); + + return iconBox; + } + + _checkVisibility() { + if (this._destroyed) return; + this._updateVisibility(); + this._addTimeout(2000, () => { + this._checkVisibility(); + return GLib.SOURCE_REMOVE; + }); + } + + // 4. Gestione interazioni utente + _launchApp(appId) { + + if (appId.startsWith('link://')) { + const url = appId.replace('link://', ''); + Gio.AppInfo.launch_default_for_uri(url, null); + return; + } + try { + const appSystem = Shell.AppSystem.get_default(); + let app = appSystem.lookup_app(`${appId}.desktop`) || appSystem.lookup_app(appId); + + if (!app) { + const desktopApp = Gio.DesktopAppInfo.new(`${appId}.desktop`); + if (desktopApp) { + app = new Shell.App({ desktop_app_info: desktopApp }); + } + if (!app) { + console.error(`[ERROR] Application ${appId} not found`); + return; + } + } + + this._pendingApps.add(appId); + + app.activate(); + + this._forceImmediateUpdate(appId); + + this._addTimeout(1500, () => { + this._pendingApps.delete(appId); + this._updateRunningIndicators(); + this._addTimeout(500, () => { + this._updateRunningIndicators(); + return GLib.SOURCE_REMOVE; + }); + return GLib.SOURCE_REMOVE; + }); + + } catch (e) { + this._pendingApps.delete(appId); + } + + this._pendingApps.add(appId); + this._updateRunningIndicators(); + } + + _handleSleepSignal(sender, signalName, params) { + const [isSleeping] = params.deepUnpack(); + if (!isSleeping) { + this._addTimeout(1000, () => { + this._updateVisibility(); + this._forceFullRefresh(); + return GLib.SOURCE_REMOVE; + }); + } + } + + // 5. Gestione stato applicazioni + _updateRunningIndicators() { + const children = this._pinnedIconsBox.get_children(); + + children.forEach(iconBox => { + const appId = iconBox.appId; + if (!appId || appId.startsWith('link://')) return; + this._updateRunningIndicator(appId, iconBox.runningIndicator); + }); + } + + _updateRunningIndicator(appId, indicator) { + let isRunning = false; + + if (this._pendingApps.has(appId)) { + isRunning = true; + } else { + const app = this._findAppById(appId); + if (app) { + const state = app.get_state(); + const hasWindows = app.get_windows().length > 0; + const isInAppSystem = this._appSystem.get_running().some(a => a.get_id() === app.get_id()); + + isRunning = state === Shell.AppState.RUNNING || hasWindows || isInAppSystem; + + } + } + + if (indicator.visible !== isRunning) { + indicator.visible = isRunning; + + if (isRunning) { + indicator.ease({ + scale_x: 1.2, + scale_y: 1.2, + opacity: 200, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + indicator.ease({ + scale_x: 1.0, + scale_y: 1.0, + opacity: 255, + duration: 300 + }); + } + }); + } else { + indicator.set_scale(1.0, 1.0); + indicator.opacity = 255; + } + } + + if (!isRunning && this._pendingApps.has(appId)) { + this._pendingApps.delete(appId); + } + } + + // 6. Gestione impostazioni e aggiornamenti UI + _refreshUI() { + if (this._destroyed) return; + + const schema = this._settings.settings_schema; + const key = schema.get_key('icon-size'); + const range = key.get_range(); + const [minSize, maxIconSize] = range.deep_unpack(); + + let pinnedApps = this._settings.get_strv('pinned-apps'); + + if (this._settings.get_boolean('sort-alphabetically') && pinnedApps.length > 0) { + const sortedApps = [...pinnedApps].sort((a, b) => { + const nameA = this._getAppOrLinkName(a).toLowerCase(); + const nameB = this._getAppOrLinkName(b).toLowerCase(); + return nameA.localeCompare(nameB); + }); + + if (!arraysEqual(pinnedApps, sortedApps)) { + const handler = this._settings.connect('changed::pinned-apps', () => { }); + this._settings.set_strv('pinned-apps', sortedApps); + this._settings.disconnect(handler); + return; + } + } + + const spacing = this._settings.get_int('spacing'); + const totalItems = pinnedApps.length; + + const containerWidth = (maxIconSize * totalItems) + (spacing * Math.max(0, totalItems - 1)); + + this._mainContainer.set_style(` + min-width: ${containerWidth}px; + padding: 0 ${spacing}px; + `); + + this._pinnedIconsBox.destroy_all_children(); + + pinnedApps.forEach(appId => { + const app = Gio.DesktopAppInfo.new(`${appId}.desktop`) || Gio.DesktopAppInfo.new(appId); + if (app) { + this._addPinnedIcon(appId); + } else if (appId.startsWith('link://')) { + this._addPinnedIcon(appId); + } + }); + + this._pinnedSection.box.destroy_all_children(); + pinnedApps.forEach(appId => this._addMenuPinnedItem(appId)); + + this._updatePinIconVisibility(); + } + + _updateIconsSpacing() { + const spacing = Math.min(20, Math.max(0, this._settings.get_int('spacing'))); + this._pinnedIconsBox.set_style(`spacing: ${spacing}px;`); + } + + // 7. Gestione elementi pinnati + _pinApp(app) { + + const current = this._settings.get_strv('pinned-apps'); + if (current.length >= 10) { + this._showMaxItemsError(); + return; + } + + const rawAppId = app.get_id(); + const appId = rawAppId.replace(/\.desktop$/i, ''); + + if (!current.includes(appId)) { + const updated = [...current, appId]; + this._settings.set_strv('pinned-apps', updated); + } + } + + _unpinApp(appId) { + if (appId.startsWith('link://')) { + const url = appId.replace('link://', ''); + const links = this._settings.get_strv('custom-links') + .filter(l => l !== url); + this._settings.set_strv('custom-links', links); + } + + const startupApps = this._settings.get_strv('startup-apps') + .filter(id => id !== appId); + this._settings.set_strv('startup-apps', startupApps); + + const pinnedApps = this._settings.get_strv('pinned-apps') + .filter(id => id !== appId); + this._settings.set_strv('pinned-apps', pinnedApps); + } + + _isPinned(appId) { + const cleanAppId = this._sanitizeAppId(appId); + return this._settings.get_strv('pinned-apps').includes(cleanAppId); + } + + // 8. Funzioni helper e utilità + _isValidUrl(url) { + try { + new URL(url); + return true; + } catch { + return url.startsWith('http') || url.startsWith('ftp'); + } + } + + _sortApps(a, b, query) { + const aName = a.get_name().toLowerCase(); + const bName = b.get_name().toLowerCase(); + + if (aName === query) return -1; + if (bName === query) return 1; + + const aStart = aName.startsWith(query); + const bStart = bName.startsWith(query); + if (aStart !== bStart) return aStart ? -1 : 1; + + return aName.length - bName.length; + } + + _handleWakeupEvent() { + this._addTimeout(2000, () => { + this._updateRunningIndicators(); + this._refreshUI(); + return GLib.SOURCE_REMOVE; + }); + } + + _pinLink(url) { + + const pinnedApps = this._settings.get_strv('pinned-apps'); + if (pinnedApps.length >= 10) { + this._showMaxItemsError(); + return; + } + + if (!url) return; + if (!url.includes('://')) url = `http://${url}`; + + const links = this._settings.get_strv('custom-links'); + + if (!links.includes(url)) { + const newLinks = [...links, url]; + const newPinnedApps = [...pinnedApps, `link://${url}`]; + + this._settings.set_strv('custom-links', newLinks); + this._settings.set_strv('pinned-apps', newPinnedApps); + this._refreshUI(); + } + } + + async _updateSearch() { + this._resultsSection.box.destroy_all_children(); + const query = this._searchInput.get_text().trim().toLowerCase(); + if (!query) return; + + const iconSize = this._settings.get_int('icon-size'); + const appSys = Shell.AppSystem.get_default(); + + const results = appSys.get_installed() + .filter(app => { + const appId = this._sanitizeAppId(app.get_id()); + return !this._isPinned(appId) && + this._matchQuery(query, app.get_name().toLowerCase()); + }) + .sort((a, b) => this._sortApps(a, b, query)) + .slice(0, 10); + + if (results.length === 0) { + this._resultsSection.box.add_child( + new PopupMenu.PopupMenuItem(_('No results found')) + ); + return; + } + + results.forEach(app => { + const item = new PopupMenu.PopupMenuItem(app.get_name()); + const icon = new St.Icon({ + gicon: app.get_icon(), + icon_size: iconSize + }); + item.insert_child_at_index(icon, 0); + + if (this._settings.get_strv('pinned-apps').length >= 10) { + item.setSensitive(false); + item.label.text = _("Max items reached - unpin something first"); + } + + item.connect('activate', () => { + this._pinApp(app); + + this._searchInput.set_text(''); + this._updateSearch(); + + this.menu.close(); + + }); + + this._resultsSection.box.add_child(item); + }); + + this.menu.actor.show_all(); + this.menu.actor.queue_redraw(); + + } + + _sanitizeAppId(appId) { + const sanitized = appId + .replace(/\.desktop$/i, '') + .replace(/^application:\/\//i, ''); + return sanitized; + } + + _matchQuery(query, appName) { + const keywords = appName.split(/[\s-]/); + return appName.includes(query) || + keywords.some(k => k.startsWith(query)) || + this._fuzzyMatch(query, appName); + } + + _fuzzyMatch(pattern, str) { + let patternIndex = 0; + for (const char of str.toLowerCase()) { + if (char === pattern[patternIndex]) { + if (++patternIndex === pattern.length) return true; + } + } + return false; + } + + _getIndicatorForApp(appId) { + const iconBox = this._pinnedIconsBox.get_children().find(b => b.appId === appId); + return iconBox ? iconBox.runningIndicator : null; + } + + _forceImmediateUpdate(appId) { + const indicator = this._getIndicatorForApp(appId); + if (indicator) { + indicator.visible = true; + indicator.opacity = 0; + indicator.set_scale(0.5, 0.5); + indicator.ease({ + opacity: 255, + scale_x: 1, + scale_y: 1, + duration: 300, + mode: Clutter.AnimationMode.EASE_OUT_QUAD + }); + } + this._updateRunningIndicators(); + } + + _shortenUrl(url) { + try { + const parsed = new URL(url); + return parsed.hostname.replace(/^www\./i, ''); + } catch { + return url.substring(0, 15) + '...'; + } + } + + _forceFullRefresh() { + this._runningTracker = new Set( + this._appSystem.get_running().map(app => this._sanitizeAppId(app.get_id())) + ); + + this._pinnedIconsBox.get_children().forEach(iconBox => { + this._updateRunningIndicator(iconBox.appId, iconBox.runningIndicator); + }); + + this._addTimeout(1000, () => { + this._appSystem = Shell.AppSystem.get_default(); + this._updateRunningIndicators(); + return GLib.SOURCE_REMOVE; + }); + } + + _findAppById(appId) { + let app = this._appSystem.lookup_app(appId) || + this._appSystem.lookup_app(`${appId}.desktop`); + + if (!app) { + const gioApp = Gio.DesktopAppInfo.new(`${appId}.desktop`) || + Gio.DesktopAppInfo.new(appId); + + if (gioApp) { + app = this._appSystem.lookup_app(gioApp.get_id()); + } + } + + return app; + } + + _refreshUIWithEffect() { + this._pinnedIconsBox.get_children().forEach((child, index) => { + child.ease({ + opacity: 0, + scale_x: 0.5, + scale_y: 0.5, + duration: 300, + delay: index * 30, + onComplete: () => child.destroy() + }); + }); + + this._addTimeout(300, () => { + this._settings.get_strv('pinned-apps').forEach(appId => + this._addPinnedIcon(appId) + ); + return GLib.SOURCE_REMOVE; + }); + } + + _animateAndMoveToEnd(appId, iconBox) { + const currentApps = this._settings.get_strv('pinned-apps'); + const currentIndex = currentApps.indexOf(appId); + + if (currentIndex === currentApps.length - 1) { + iconBox.ease({ + scale_x: 1.2, + scale_y: 1.2, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + iconBox.ease({ + scale_x: 1.0, + scale_y: 1.0, + duration: 300, + mode: Clutter.AnimationMode.EASE_OUT_BOUNCE + }); + } + }); + return; + } + + iconBox.ease({ + scale_x: 1.2, + scale_y: 1.2, + opacity: 0, + duration: 300, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + const updatedApps = this._settings.get_strv('pinned-apps'); + const newIndex = updatedApps.indexOf(appId); + + if (newIndex === updatedApps.length - 1) return; + + const newOrder = [ + ...updatedApps.slice(0, newIndex), + ...updatedApps.slice(newIndex + 1), + updatedApps[newIndex] + ]; + + this._settings.set_strv('pinned-apps', newOrder); + } + }); + } + + _updateIndicatorColor(indicator) { + const color = this._settings.get_string('indicator-color'); + indicator.set_style(`background-color: ${color};`); + } + + _getUrlName(url) { + try { + if (!url.match(/^[a-zA-Z]+:\/\//)) { + url = 'http://' + url; + } + + const parsed = new URL(url); + let hostname = parsed.hostname; + + hostname = hostname.replace(/^www\./i, ''); + + const specialCases = { + 'youtube.com': 'YouTube', + 'google.com': 'Google', + 'github.com': 'GitHub', + }; + + return specialCases[hostname.toLowerCase()] || + hostname.split('.')[0].charAt(0).toUpperCase() + + hostname.split('.')[0].slice(1); + } catch (e) { + return url.replace(/^https?:\/\//, '') + .replace(/^www\./i, '') + .split('/')[0]; + } + } + + _getAppOrLinkName(appId) { + if (appId.startsWith('link://')) { + return this._getUrlName(appId.replace('link://', '')); + } else { + const app = Gio.DesktopAppInfo.new(`${appId}.desktop`) || Gio.DesktopAppInfo.new(appId); + return app ? app.get_name() : appId; + } + } + + + _addMenuPinnedItem(appId) { + if (appId.startsWith('link://')) { + const url = appId.replace('link://', ''); + const item = new PopupMenu.PopupMenuItem(url); + + const icon = new St.Icon({ + icon_name: 'emblem-web-symbolic', + icon_size: this._settings.get_int('icon-size'), + style_class: 'app-pinner-link-icon' + }); + item.insert_child_at_index(icon, 0); + + const spacer = new St.BoxLayout({ x_expand: true }); + item.actor.add_child(spacer); + + const removeBtn = new St.Button({ + child: new St.Label({ text: '×' }), + style_class: 'app-pinner-remove-btn' + }); + + item.connect('activate', () => { + Gio.AppInfo.launch_default_for_uri(url, null); + this.menu.close(); + }); + + removeBtn.connect('clicked', () => { + this._unpinApp(`link://${url}`); + }); + + item.add_child(removeBtn); + this._pinnedSection.box.add_child(item); + } else { + const app = Gio.DesktopAppInfo.new(`${appId}.desktop`); + if (!app) { + console.error(`Applicazione non trovata nel menu: ${appId}`); + return; + } + + const item = new PopupMenu.PopupMenuItem(app.get_name()); + const icon = new St.Icon({ + gicon: app.get_icon(), + icon_size: this._settings.get_int('icon-size') + }); + item.insert_child_at_index(icon, 0); + + const spacer = new St.BoxLayout({ x_expand: true }); + item.actor.add_child(spacer); + + const removeBtn = new St.Button({ + child: new St.Label({ + text: '×', + style_class: 'app-pinner-remove-label' + }), + style_class: 'app-pinner-remove-btn' + }); + + removeBtn.connect('button-press-event', (actor, event) => { + this._unpinApp(appId); + this.menu.close(); + this.menu.open(); + return Clutter.EVENT_STOP; + }); + + item.connect('activate', () => { + this._launchApp(appId); + this.menu.close(); + }); + + item.add_child(removeBtn); + this._pinnedSection.box.add_child(item); + } + } + }); + +function arraysEqual(a, b) { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +export default class AppPinnerExtension extends Extension { + + constructor(metadata) { + super(metadata); + this._timeoutIds = new Set(); + this._settingsHandler = []; + } + + _addTimeout(interval, callback) { + const sourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => { + const result = callback(); + this._timeoutIds.delete(sourceId); + return result; + }); + this._timeoutIds.add(sourceId); + return sourceId; + } + + _dbusImpl = null; + + // 1. Lifecycle dell'estensione + enable() { + + this._settingsHandler = []; + + this._settings = this.getSettings(); + + this._validateSettings(); + + this._settingsHandler.push( + this._settings.connect('changed::show-in-panel', () => { + if (this._indicator) { + this._indicator._updateVisibility(); + } + }), + this._settings.connect('changed::show-pin-icon', () => { + if (this._indicator) { + this._indicator._updatePinIconVisibility(); + } + }) + ); + + this._sessionConnection = Gio.DBus.session; + this._sessionWatcher = this._sessionConnection.watch_name( + 'org.gnome.Shell', + Gio.BusNameWatcherFlags.NONE, + () => { }, + () => this.disable() + ); + + const dbusInterface = ` + + + + + + + `; + + if (!this._dbusImpl) { + try { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(dbusInterface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Extensions/AppPinner'); + } catch (e) { + console.error('Errore registrazione D-Bus:', e.message); + } + } + + if (!this._settings.settings_schema.get_key('show-in-panel')) { + this._settings.set_boolean('show-in-panel', true); + } + + this._positionHandler = this._settings.connect( + 'changed::position-in-panel', + () => this._safeRecreateIndicator() + ); + + this._keybindings = []; + for (let i = 1; i <= 10; i++) { + const shortcut = this._settings.get_string(`shortcut-${i}`); + if (shortcut) { + this._addKeybinding(i); + } + } + + this._shortcutHandlers = []; + for (let i = 1; i <= 10; i++) { + const handlerId = this._settings.connect(`changed::shortcut-${i}`, () => this._updateKeybinding(i)); + this._shortcutHandlers.push(handlerId); + } + + this._safeRecreateIndicator(); + + this._settings.connect('changed::startup-apps', () => this._syncAutostart()); + this._syncAutostart(); + + this._startupChangedId = this._settings.connect( + 'changed::startup-apps', + () => { + this._addTimeout(500, () => { + console.log("[DEBUG] Startup apps changed!"); + this._syncAutostart(); + return GLib.SOURCE_REMOVE; + }); + } + ); + + this._settings.connect('changed::pinned-apps', () => { + this._cleanOrphanedStartupApps(); + this._syncAutostart(); + }); + + this._settings.connect('changed::startup-apps', () => this._syncAutostart()); + + this._cleanOrphanedStartupApps(); + this._syncAutostart(); + + this._settings.connect('changed::pinned-apps', () => { + this._cleanOrphanedStartupApps(); + this._syncAutostart(); + }); + + this._addTimeout(2000, () => { + this._syncAutostart(); + return GLib.SOURCE_REMOVE; + }); + + this._indicator._checkVisibility(); + + } + + disable() { + + if (this._sessionWatcherId) { + this._sessionConnection.unwatch_name(this._sessionWatcherId); + this._sessionWatcherId = null; + } + + if (this._dbusImpl) { + try { + this._dbusImpl.unexport(); + } catch (e) { + console.error('DBus unexport error:', e); + } + this._dbusImpl = null; + } + + + for (let i = 1; i <= 10; i++) { + this._removeKeybinding(i); + } + + const mediaKeysSettings = new Gio.Settings({ + schema_id: 'org.gnome.settings-daemon.plugins.media-keys' + }); + + const currentPaths = mediaKeysSettings.get_strv('custom-keybindings'); + const newPaths = currentPaths.filter(p => !p.includes('app-pinner-')); + mediaKeysSettings.set_strv('custom-keybindings', newPaths); + + if (this._positionHandler) { + this._settings.disconnect(this._positionHandler); + this._positionHandler = null; + } + + this._keybindings.forEach(key => Main.wm.removeKeybinding(key)); + this._keybindings = []; + + if (this._indicator) { + const parent = this._indicator.get_parent(); + if (parent) { + parent.remove_child(this._indicator); + } + this._indicator.destroy(); + this._indicator = null; + } + + if (this._timeoutIds) { + this._timeoutIds.forEach(id => GLib.Source.remove(id)); + this._timeoutIds.clear(); + } + + this._settings.disconnect(this._shortcutHandler); + + const autostartDir = GLib.build_filenamev([GLib.get_user_config_dir(), 'autostart']); + const dir = Gio.File.new_for_path(autostartDir); + + const enumerator = dir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); + let fileInfo; + while ((fileInfo = enumerator.next_file(null)) !== null) { + const name = fileInfo.get_name(); + if (name.startsWith('app-pinner-')) { + GLib.unlink(`${autostartDir}/${name}`); + } + } + + if (this._startupChangedId) { + this._settings.disconnect(this._startupChangedId); + } + + try { + const enumerator = dir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); + let fileInfo; + while ((fileInfo = enumerator.next_file(null)) !== null) { + const name = fileInfo.get_name(); + if (name.startsWith('app-pinner-')) { + GLib.unlink(`${autostartDir}/${name}`); + } + } + } catch (e) { + console.error('Errore pulizia autostart:', e); + } + + if (this._settingsHandler) { + this._settingsHandler.forEach(handler => { + if (handler) { + this._settings.disconnect(handler); + } + }); + this._settingsHandler = null; + } + + if (this._indicator && this._indicator._checkVisibility) { + GLib.Source.remove(this._indicator._checkVisibilityTimeout); + } + + this._settings = null + } + + _updateKeybinding(position) { + const shortcut = this._settings.get_string(`shortcut-${position}`); + if (shortcut) { + this._addKeybinding(position); + } else { + this._removeKeybinding(position); + } + } + + // 2. Gestione D-Bus e keybindings + _addKeybinding(position) { + const key = `shortcut-${position}`; + const shortcut = this._settings.get_string(key); + + if (!shortcut) return; + + const customPath = `/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/app-pinner-${position}/`; + + const customSettings = new Gio.Settings({ + schema_id: 'org.gnome.settings-daemon.plugins.media-keys.custom-keybinding', + path: customPath + }); + + customSettings.set_string('name', `App Pinner Position ${position}`); + customSettings.set_string('command', `dbus-send --session --type=method_call --dest=org.gnome.Shell /org/gnome/Shell/Extensions/AppPinner org.gnome.Shell.Extensions.AppPinner.LaunchPosition uint32:${position}`); + customSettings.set_string('binding', shortcut); + + const mediaKeysSettings = new Gio.Settings({ + schema_id: 'org.gnome.settings-daemon.plugins.media-keys' + }); + + const currentPaths = mediaKeysSettings.get_strv('custom-keybindings'); + if (!currentPaths.includes(customPath)) { + mediaKeysSettings.set_strv('custom-keybindings', [...currentPaths, customPath]); + } + } + + LaunchPosition(position) { + this._launchAppByPosition(position); + } + + _launchAppByPosition(position) { + + const apps = this._settings.get_strv('pinned-apps'); + + if (apps.length === 0) return; + + const index = position - 1; + if (index < 0 || index >= apps.length) return; + + const appId = apps[index]; + + if (appId.startsWith('link://')) { + if (!this._indicator) return; + + this._indicator._launchApp(appId); + return; + } + + const appSys = Shell.AppSystem.get_default(); + let app = appSys.lookup_app(`${appId}.desktop`) || appSys.lookup_app(appId); + + if (!app) { + const gioApp = Gio.DesktopAppInfo.new(`${appId}.desktop`) || Gio.DesktopAppInfo.new(appId); + + if (!gioApp) { + console.error(`[ERROR] App ${appId} non trovata in nessun formato`); + return; + } + } else { + } + + this._indicator._launchApp(appId); + } + + // 3. Gestione autostart + _syncAutostart() { + const autostartDir = GLib.build_filenamev([GLib.get_user_config_dir(), 'autostart']); + const startupApps = this._settings.get_strv('startup-apps'); + + if (!GLib.file_test(autostartDir, GLib.FileTest.IS_DIR)) { + GLib.mkdir_with_parents(autostartDir, 0o755); + } + + const validFiles = new Map(); + + startupApps.forEach(entry => { + if (entry.startsWith('link://')) { + const url = entry.replace('link://', ''); + const sanitized = this._sanitizeForFilename(url); + const fileName = `app-pinner-link-${sanitized}.desktop`; + validFiles.set(fileName, true); + + const content = [ + '[Desktop Entry]', + 'Type=Application', + `Name=Link ${url}`, + `Exec=xdg-open "${url}"`, + 'Icon=emblem-web-symbolic', + 'X-GNOME-Autostart-enabled=true', + 'X-GNOME-Autostart-Delay=2', + 'NoDisplay=false', + '' + ].join('\n'); + + try { + GLib.file_set_contents(`${autostartDir}/${fileName}`, content); + GLib.chmod(`${autostartDir}/${fileName}`, 0o644); + } catch (e) { + console.error(`Errore scrittura link ${url}:`, e.message); + } + } else { + const appInfo = this._getAppInfo(entry); + if (!appInfo) { + console.error(`[ERROR] App non trovata: ${entry}`); + return; + } + + const desktopId = appInfo.get_id() || `${entry}.desktop`; + const sanitizedId = this._sanitizeForFilename(desktopId); + const fileName = `app-pinner-${sanitizedId}.desktop`; + validFiles.set(fileName, true); + + const execCommand = appInfo.get_string('Exec'); + if (!execCommand) { + console.error(`[ERROR] Comando non trovato per ${entry}`); + return; + } + + const desktopContent = [ + '[Desktop Entry]', + 'Type=Application', + `Name=${appInfo.get_name()} (App Pinner)`, + `Exec=${execCommand}`, + 'X-GNOME-Autostart-enabled=true', + 'X-GNOME-Autostart-Delay=2', + 'Icon=' + (appInfo.get_icon() || ''), + 'Comment=Avviato automaticamente da App Pinner', + 'NoDisplay=false', + '' + ].join('\n'); + + try { + GLib.file_set_contents(`${autostartDir}/${fileName}`, desktopContent); + GLib.chmod(`${autostartDir}/${fileName}`, 0o644); + } catch (e) { + console.error(`Errore scrittura app ${entry}:`, e.message); + } + } + }); + + const dir = Gio.File.new_for_path(autostartDir); + try { + const enumerator = dir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); + let fileInfo; + while ((fileInfo = enumerator.next_file(null))) { + const fileName = fileInfo.get_name(); + if (fileName.startsWith('app-pinner-') && !validFiles.has(fileName)) { + GLib.unlink(`${autostartDir}/${fileName}`); + } + } + } catch (e) { + console.error('Errore durante la pulizia dei file:', e.message); + } + } + + // 4. Funzioni helper e validazione + _validateSettings() { + const currentPos = this._settings.get_string('position-in-panel'); + if (!['left', 'right'].includes(currentPos)) { + this._settings.set_string('position-in-panel', 'right'); + } + } + + _getAppInfo(appId) { + console.log(`[DEBUG] Getting app info for: ${appId}`); + + const appSys = Shell.AppSystem.get_default(); + const allApps = appSys.get_installed(); + const foundApp = allApps.find(app => + app.get_id().toLowerCase() === appId.toLowerCase() + ); + + if (foundApp) { + return Gio.DesktopAppInfo.new(foundApp.get_id()); + } + + const flatpakPaths = [ + '/var/lib/flatpak/exports/share/applications/', + `${GLib.get_home_dir()}/.local/share/flatpak/exports/share/applications/` + ]; + + for (const path of flatpakPaths) { + const fullPath = `${path}${appId}.desktop`; + if (GLib.file_test(fullPath, GLib.FileTest.EXISTS)) { + return Gio.DesktopAppInfo.new_from_filename(fullPath); + } + } + + const dataDirs = GLib.get_system_data_dirs(); + for (const dataDir of dataDirs) { + const appPath = `${dataDir}/applications/${appId}.desktop`; + if (GLib.file_test(appPath, GLib.FileTest.EXISTS)) { + return Gio.DesktopAppInfo.new_from_filename(appPath); + } + } + + return null; + } + + _sanitizeForFilename(appId) { + return appId + .replace(/\.desktop$/i, '') + .replace(/[^a-zA-Z0-9-]/g, '_') + .replace(/_+/g, '_') + .substring(0, 50); + } + + _cleanOrphanedStartupApps() { + const pinnedApps = this._settings.get_strv('pinned-apps'); + const customLinks = this._settings.get_strv('custom-links'); + const startupApps = this._settings.get_strv('startup-apps'); + + const validEntries = new Set([ + ...pinnedApps, + ...customLinks.map(url => `link://${url}`) + ]); + + const cleaned = startupApps.filter(appId => + validEntries.has(appId) || + customLinks.includes(appId.replace('link://', '')) + ); + + if (!arraysEqual(cleaned, startupApps)) { + this._settings.set_strv('startup-apps', cleaned); + } + } + + _removeKeybinding(position) { + const customPath = `/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/app-pinner-${position}/`; + + const mediaKeysSettings = new Gio.Settings({ + schema_id: 'org.gnome.settings-daemon.plugins.media-keys' + }); + + const currentPaths = mediaKeysSettings.get_strv('custom-keybindings'); + if (currentPaths.includes(customPath)) { + const newPaths = currentPaths.filter(p => p !== customPath); + mediaKeysSettings.set_strv('custom-keybindings', newPaths); + + const customSettings = new Gio.Settings({ + schema_id: 'org.gnome.settings-daemon.plugins.media-keys.custom-keybinding', + path: customPath + }); + customSettings.reset('name'); + customSettings.reset('command'); + customSettings.reset('binding'); + } + } + + _showMaxItemsError() { + Main.notifyError( + _("Maximum items reached"), + _("You can pin up to 10 items maximum") + ); + } + + _validateAccelerator(accelerator) { + if (!accelerator) return false; + + try { + const [key, mods] = Clutter.accelerator_parse(accelerator); + return key !== 0; + } catch (e) { + return false; + } + } + + _safeRecreateIndicator() { + const iconSize = this._settings.get_int('icon-size'); + const spacing = this._settings.get_int('spacing'); + const labels = this._settings.get_boolean('enable-labels'); + + this._recreateIndicator(); + + this._settings.set_int('icon-size', iconSize); + this._settings.set_int('spacing', spacing); + this._settings.set_boolean('enable-labels', labels); + } + + _recreateIndicator() { + if (this._indicator) { + this._indicator.destroy(); + this._indicator = null; + } + + this._indicator = new AppPinner(this._settings); + const position = this._settings.get_string('position-in-panel'); + + if (this._indicator.get_parent()) { + this._indicator.get_parent().remove_child(this._indicator); + } + + switch (position) { + case 'left': + Main.panel._leftBox.insert_child_at_index(this._indicator, 0); + break; + + case 'center': + if (Main.panel._centerBox) { + Main.panel._centerBox.add_child(this._indicator); + } else { + Main.panel._centerBox = new St.BoxLayout(); + Main.panel._centerBox.x_align = Clutter.ActorAlign.CENTER; + Main.panel.insert_child_at_index(Main.panel._centerBox, 1); + Main.panel._centerBox.add_child(this._indicator); + } + break; + + case 'right': + const dateMenu = Main.panel.statusArea?.dateMenu; + let targetIndex = 0; + if (dateMenu?.actor) { + const children = Main.panel._rightBox.get_children(); + const dateMenuIndex = children.indexOf(dateMenu.actor); + targetIndex = dateMenuIndex !== -1 ? dateMenuIndex + 1 : 0; + } + Main.panel._rightBox.insert_child_at_index(this._indicator, targetIndex); + break; + } + + this._indicator.set_style_class_name( + `app-pinner-position-${position}` + ); + + Main.panel.queue_relayout(); + Main.panel.menuManager.addMenu(this._indicator.menu); + } +} \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/metadata.json b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/metadata.json new file mode 100644 index 0000000..9655af5 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/metadata.json @@ -0,0 +1,18 @@ +{ + "_generated": "Generated by SweetTooth, do not edit", + "creator": "comitanigiacomo", + "description": "Supercharge your GNOME workflow with this sleek top-bar dock:\n\n ~ 1-Click Access - Launch apps/websites instantly\n ~ Full Customization - Resize icons, adjust spacing, toggle labels\n ~ Live Activity Indicators - Color-coded dots for running apps\n ~ Smart Search - Find+pin apps directly from the menu\n ~ 10 Keyboard Shortcuts - Instant launches with custom keybinds\n ~ Web Links Support - Pin frequently visited URLs\n ~ Auto-Start Essentials - Launch critical apps on login\n ~ Flexible Positioning - Left/Center/Right panel placement\n ~ Press-to-Reorder - Intuitive visual organization\n ~ Smart Limiter - Focus on what matters (max 10 items)", + "name": "quick-launch", + "settings-schema": "org.gnome.shell.extensions.app-pinner", + "shell-version": [ + "45", + "46", + "47", + "48", + "49" + ], + "stylesheet": "stylesheet.css", + "url": "https://github.com/comitanigiacomo/quicklaunch", + "uuid": "quicklaunch@comitanigiacomo.github.com", + "version": 15 +} \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/prefs.js b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/prefs.js new file mode 100644 index 0000000..9ba3f1b --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/prefs.js @@ -0,0 +1,672 @@ +import Gtk from 'gi://Gtk'; +import Adw from 'gi://Adw'; +import Gio from 'gi://Gio'; +import Gdk from 'gi://Gdk'; +import GLib from 'gi://GLib'; +import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +export default class AppPinnerExtensionPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings('org.gnome.shell.extensions.app-pinner'); + + window.set_search_enabled(true); + + // Pagina Aspetto + const appearancePage = new Adw.PreferencesPage({ + title: 'Appearance', + icon_name: 'applications-graphics-symbolic' + }); + this._buildAppearancePage(appearancePage, settings); + window.add(appearancePage); + + // Pagina Scorciatoie + const shortcutsPage = new Adw.PreferencesPage({ + title: 'Shortcuts', + icon_name: 'preferences-desktop-keyboard-symbolic' + }); + this._buildShortcutsPage(shortcutsPage, settings); + window.add(shortcutsPage); + + // Pagina Avvio Automatico + const startupPage = new Adw.PreferencesPage({ + title: 'Startup Apps', + icon_name: 'system-run-symbolic' + }); + this._buildStartupPage(startupPage, settings); + window.add(startupPage); + + // Pagina About + const aboutPage = new Adw.PreferencesPage({ + title: 'About', + icon_name: 'dialog-information-symbolic' + }); + this._buildAboutPage(aboutPage); + window.add(aboutPage); + } + + _buildAppearancePage(page, settings) { + const group = new Adw.PreferencesGroup({ + title: 'Panel Customization', + description: 'Adjust how pinned apps appear in your top panel' + }); + + const iconSizeRow = new Adw.SpinRow({ + title: 'Icon Size', + subtitle: 'Recommended between 16-24 pixels for optimal panel integration', + adjustment: new Gtk.Adjustment({ + value: settings.get_int('icon-size'), + lower: 16, + upper: 24, + step_increment: 1 + }) + }); + settings.bind('icon-size', iconSizeRow, 'value', Gio.SettingsBindFlags.DEFAULT); + group.add(iconSizeRow); + + const positionRow = new Adw.ComboRow({ + title: 'Panel Position', + subtitle: 'Choose where to show the pinned apps in your top panel', + model: new Gtk.StringList({ strings: ['left', 'center', 'right'] }) + }); + + positionRow.connect('notify::selected', () => { + const selectedString = positionRow.model.get_string(positionRow.selected); + settings.set_string('position-in-panel', selectedString); + }); + + const currentPos = settings.get_string('position-in-panel'); + positionRow.selected = ['left', 'center', 'right'].indexOf(currentPos); + + group.add(positionRow); + + const spacingRow = new Adw.SpinRow({ + title: 'Icon Spacing', + subtitle: 'Space between app icons in pixels', + adjustment: new Gtk.Adjustment({ + value: settings.get_int('spacing'), + lower: 0, + upper: 20, + step_increment: 1 + }) + }); + settings.bind('spacing', spacingRow, 'value', Gio.SettingsBindFlags.DEFAULT); + group.add(spacingRow); + + const colorRow = new Adw.ActionRow({ + title: 'Indicator Color', + subtitle: 'Choose color for running apps indicator' + }); + + const colorButton = new Gtk.ColorButton({ + valign: Gtk.Align.CENTER, + use_alpha: true + }); + + const initialColor = new Gdk.RGBA(); + initialColor.parse(settings.get_string('indicator-color')); + colorButton.set_rgba(initialColor); + + colorButton.connect('color-set', (btn) => { + const color = btn.get_rgba().to_string(); + settings.set_string('indicator-color', color); + }); + + colorRow.add_suffix(colorButton); + colorRow.set_activatable_widget(colorButton); + group.add(colorRow); + + const sortToggle = new Adw.SwitchRow({ + title: 'Sort Alphabetically', + subtitle: 'Automatically sort apps by name', + active: settings.get_boolean('sort-alphabetically') + }); + settings.bind('sort-alphabetically', sortToggle, 'active', Gio.SettingsBindFlags.DEFAULT); + group.add(sortToggle); + + const visibilityGroup = new Adw.PreferencesGroup({ + title: 'Visibility Options', + description: 'Control what elements are displayed in the panel' + }); + + const panelToggle = new Adw.SwitchRow({ + title: 'Show in Top Panel', + subtitle: 'Display pinned apps directly in the panel', + active: settings.get_boolean('show-in-panel') + }); + settings.bind('show-in-panel', panelToggle, 'active', Gio.SettingsBindFlags.DEFAULT); + visibilityGroup.add(panelToggle); + + const pinIconToggle = new Adw.SwitchRow({ + title: 'Show Pin Icon', + subtitle: 'Display the pin icon when apps are pinned', + active: settings.get_boolean('show-pin-icon') + }); + settings.bind('show-pin-icon', pinIconToggle, 'active', Gio.SettingsBindFlags.DEFAULT); + visibilityGroup.add(pinIconToggle); + + page.add(visibilityGroup); + + page.add(group); + } + + _buildShortcutsPage(page, settings) { + const group = new Adw.PreferencesGroup({ + title: 'Keyboard Shortcuts', + description: 'Assign custom keyboard shortcuts to launch your pinned apps directly.\nShortcuts are position-based and will follow your current app order.' + }); + + const listBox = new Gtk.ListBox({ + selection_mode: Gtk.SelectionMode.NONE, + css_classes: ['shortcut-list'] + }); + + const updateList = () => { + let child = listBox.get_first_child(); + while (child) { + const next = child.get_next_sibling(); + listBox.remove(child); + child = next; + } + + const pinnedApps = settings.get_strv('pinned-apps'); + if (pinnedApps.length === 0) { + const emptyRow = new Adw.ActionRow({ + title: 'No Pinned Apps', + subtitle: 'Pin apps from the main menu to enable shortcuts', + css_classes: ['dim-label'] + }); + listBox.append(emptyRow); + return; + } + + pinnedApps.forEach((appId, index) => { + const position = index + 1; + const appName = this._getAppName(appId) || 'Unknown Application'; + + const row = new Adw.ActionRow({ + title: `Position ${position}`, + subtitle: appName, + css_classes: ['shortcut-row'] + }); + + const entry = new Gtk.Entry({ + text: this._acceleratorToLabel(settings.get_string(`shortcut-${position}`)), + editable: false, + width_chars: 20, + css_classes: ['shortcut-entry'], + placeholder_text: 'Click to set shortcut', + tooltip_text: `Press any key combination for ${appName}` + }); + + entry.connect('notify::has-focus', (widget) => { + if (!widget.hasFocus) { + widget.set_selection(0, 0); + widget.set_position(-1); + } + }); + + const controller = new Gtk.EventControllerKey(); + controller.connect('key-pressed', (_, keyval, _keycode, state) => { + if (keyval === Gdk.KEY_Escape) { + entry.grab_focus_away(); + return Gdk.EVENT_STOP; + } + + const isModifier = [ + Gdk.KEY_Control_L, Gdk.KEY_Control_R, + Gdk.KEY_Shift_L, Gdk.KEY_Shift_R, + Gdk.KEY_Alt_L, Gdk.KEY_Alt_R, + Gdk.KEY_Super_L, Gdk.KEY_Super_R + ].includes(keyval); + + if (isModifier) return Gdk.EVENT_STOP; + + const modifiers = state & Gtk.accelerator_get_default_mod_mask(); + const accelerator = Gtk.accelerator_name(keyval, modifiers); + + entry.text = this._acceleratorToLabel(accelerator); + settings.set_string(`shortcut-${position}`, accelerator); + return Gdk.EVENT_STOP; + }); + + entry.add_controller(controller); + + const clearButton = new Gtk.Button({ + icon_name: 'edit-clear-symbolic', + tooltip_text: 'Clear shortcut' + }); + + clearButton.connect('clicked', () => { + entry.text = 'Disabled'; + settings.set_string(`shortcut-${position}`, ''); + }); + + row.add_suffix(entry); + row.add_suffix(clearButton); + listBox.append(row); + }); + }; + + updateList(); + + settings.connect('changed::pinned-apps', () => { + updateList(); + }); + + const scrolledWindow = new Gtk.ScrolledWindow({ + height_request: 300, + child: listBox + }); + + group.add(scrolledWindow); + page.add(group); + } + + _buildStartupPage(page, settings) { + const group = new Adw.PreferencesGroup({ + title: 'Launch at Startup', + description: 'Select applications to launch automatically when you log in' + }); + + const listStore = new Gtk.StringList(); + const updateList = () => { + const pinned = settings.get_strv('pinned-apps'); + listStore.splice(0, listStore.get_n_items(), pinned); + }; + + updateList(); + settings.connect('changed::pinned-apps', updateList); + + const factory = new Gtk.SignalListItemFactory(); + factory.connect('setup', (_, listItem) => { + const row = new Adw.ActionRow(); + const icon = new Gtk.Image({ icon_size: Gtk.IconSize.LARGE, margin_end: 12 }); + row.add_prefix(icon); + + const toggle = new Gtk.Switch({ + valign: Gtk.Align.CENTER, + halign: Gtk.Align.END + }); + row.add_suffix(toggle); + + row._icon = icon; + row._toggle = toggle; + listItem.set_child(row); + }); + + factory.connect('bind', (_, listItem) => { + const appId = listItem.get_item().string; + const row = listItem.get_child(); + const toggle = row._toggle; + + const startupApps = settings.get_strv('startup-apps'); + toggle.set_active(startupApps.includes(appId)); + + toggle.connect('notify::active', (sw) => { + const newStartup = sw.active + ? [...settings.get_strv('startup-apps'), appId] + : settings.get_strv('startup-apps').filter(id => id !== appId); + settings.set_strv('startup-apps', newStartup); + }); + + const appInfo = this._getAppInfo(appId); + row.set_title(appInfo?.get_name() || appId); + row._icon.set_from_gicon(appInfo?.get_icon()); + }); + + const listView = new Gtk.ListView({ + model: new Gtk.SingleSelection({ model: listStore }), + factory: factory + }); + + const scrolledWindow = new Gtk.ScrolledWindow({ + height_request: 300, + child: listView + }); + group.add(scrolledWindow); + page.add(group); + } + + _buildAboutPage(page) { + const group = new Adw.PreferencesGroup({ + title: this.metadata.name, + description: 'A modern application pinner and quick launcher for GNOME Shell' + }); + + const versionRow = new Adw.ActionRow({ + title: 'Version' + }); + const versionLabel = new Gtk.Label({ + label: `v${this.metadata.version.toString()}`, + css_classes: ['dim-label'], + xalign: 0 + }); + versionRow.add_suffix(versionLabel); + group.add(versionRow); + + const authorRow = new Adw.ActionRow({ + title: 'Developer' + }); + + const githubButton = new Gtk.Button({ + label: this.metadata.creator, + css_classes: ['flat', 'suggested-action'], + tooltip_text: 'Open GitHub profile', + margin_top: 6, + margin_bottom: 6 + }); + + githubButton.connect('clicked', () => { + Gio.AppInfo.launch_default_for_uri('https://github.com/comitanigiacomo', null); + }); + + authorRow.add_suffix(githubButton); + authorRow.activatable_widget = githubButton; + group.add(authorRow); + + const repoRow = new Adw.ActionRow({ + title: 'Repository' + }); + + const repoButton = new Gtk.Button({ + label: 'Source Code', + css_classes: ['flat', 'suggested-action'], + tooltip_text: 'Open GitHub repository', + margin_top: 6, + margin_bottom: 6 + }); + + repoButton.connect('clicked', () => { + if (this.metadata.url) { + Gio.AppInfo.launch_default_for_uri(this.metadata.url, null); + } + }); + + repoRow.add_suffix(repoButton); + repoRow.activatable_widget = repoButton; + group.add(repoRow); + + const licenseRow = new Adw.ActionRow({ + title: 'License' + }); + + const licenseButton = new Gtk.Button({ + label: 'GPLv3', + css_classes: ['flat', 'suggested-action'], + tooltip_text: 'View license details', + margin_top: 6, + margin_bottom: 6 + }); + + licenseButton.connect('clicked', () => { + Gio.AppInfo.launch_default_for_uri('https://github.com/comitanigiacomo/quicklaunch?tab=GPL-3.0-1-ov-file', null); + }); + + licenseRow.add_suffix(licenseButton); + licenseRow.activatable_widget = licenseButton; + group.add(licenseRow); + + const descriptionLabel = new Gtk.Label({ + label: ` + + ● 🚀 One-Click Access: Launch apps/links directly from panel\n + ● ⌨️ Custom Shortcuts: Configurable keyboard combinations\n + ● 🔴 Live Indicators: Real-time running app status\n + ● 🌐 Web Links: Open URLs in default browser\n + ● ⚡ Auto-Start: Launch critical apps at login\n + ● 🔧 Smart Reordering: long-press to organize\n + ● 🔍 Instant Search: Find/pin apps from dropdown\n\n + Optimized for power users - Seamless GNOME integration + ` + .replace(/ {8}/g, '') + .trim(), + wrap: true, + xalign: 0.5, + halign: Gtk.Align.CENTER, + hexpand: true + }); + + const descGroup = new Adw.PreferencesGroup(); + descGroup.add(descriptionLabel); + + page.add(group); + page.add(descGroup); + } + + _acceleratorToLabel(accel) { + if (!accel) return 'Disabled'; + + try { + const [success, keyval, mods] = Gtk.accelerator_parse(accel); + return success ? Gtk.accelerator_get_label(keyval, mods) : 'Invalid'; + } catch (e) { + return 'Error'; + } + } + + _labelToAccelerator(label) { + let [success, keyval, mods] = Gtk.accelerator_parse(label); + + if (!success || keyval === 0) { + const custom = this._parseCustomShortcut(label); + [success, keyval, mods] = Gtk.accelerator_parse(custom); + } + + if (success && keyval !== 0) { + return Gtk.accelerator_name(keyval, mods); + } + return ''; + } + + _configureShortcut(window, settings, key, entry) { + + const dialog = new Gtk.Dialog({ + title: 'Set Shortcut', + transient_for: window, + modal: true, + use_header_bar: true + }); + + let accelerator = settings.get_string(key); + const label = new Gtk.Label({ + label: 'Press desired key combination...\nCurrent: ' + this._acceleratorToLabel(accelerator), + margin: 12 + }); + + const controller = new Gtk.EventControllerKey(); + controller.connect('key-pressed', (_, keyval, _keycode, state) => { + try { + const mask = state & Gtk.accelerator_get_default_mod_mask(); + + const validMods = mask & ( + Gdk.ModifierType.SHIFT_MASK | + Gdk.ModifierType.CONTROL_MASK | + Gdk.ModifierType.ALT_MASK | + Gdk.ModifierType.SUPER_MASK + ); + + if (keyval === Gdk.KEY_Shift_L || keyval === Gdk.KEY_Shift_R || + keyval === Gdk.KEY_Control_L || keyval === Gdk.KEY_Control_R || + keyval === Gdk.KEY_Alt_L || keyval === Gdk.KEY_Alt_R || + keyval === Gdk.KEY_Super_L || keyval === Gdk.KEY_Super_R) { + return Gdk.EVENT_STOP; + } + + accelerator = Gtk.accelerator_name(keyval, validMods); + label.label = 'New shortcut: ' + this._acceleratorToLabel(accelerator); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + dialog.queue_draw(); + return GLib.SOURCE_REMOVE; + }); + + } catch (e) { + console.error('Error processing key:', e); + } + return Gdk.EVENT_STOP; + }); + + dialog.add_controller(controller); + + const resetButton = new Gtk.Button({ + label: 'Reset', + margin_end: 12 + }); + resetButton.connect('clicked', () => { + accelerator = ''; + label.label = 'Shortcut cleared!'; + }); + + dialog.get_content_area().append(label); + dialog.get_header_bar().pack_end(resetButton); + + dialog.add_button('Cancel', Gtk.ResponseType.CANCEL); + dialog.add_button('Save', Gtk.ResponseType.OK); + + dialog.connect('response', (_, response) => { + if (response === Gtk.ResponseType.OK) { + try { + const [success] = Gtk.accelerator_parse(accelerator); + if (success) { + settings.set_string(key, accelerator); + entry.text = this._acceleratorToLabel(accelerator); + } else { + this._showErrorDialog('Invalid shortcut combination'); + } + } catch (e) { + this._showErrorDialog(`Error saving shortcut: ${e.message}`); + } + } + dialog.destroy(); + }); + + dialog.present(); + } + + _showErrorDialog(message) { + const dialog = new Gtk.MessageDialog({ + transient_for: this._window, + modal: true, + message_type: Gtk.MessageType.ERROR, + buttons: Gtk.ButtonsType.OK, + text: message + }); + dialog.connect('response', () => dialog.destroy()); + dialog.present(); + } + + _parseCustomShortcut(text) { + try { + const normalized = text + .replace(//g, '+') + .toLowerCase(); + + const parts = normalized.split('+'); + let mods = 0; + let keyval = 0; + + parts.forEach(part => { + const mod = Gtk.accelerator_parse_modifier(part.trim()); + if (mod) { + mods |= mod; + } else { + keyval = Gtk.accelerator_parse_key(part.trim(), 0); + } + }); + + return Gtk.accelerator_name(keyval, mods); + } catch (e) { + return ''; + } + } + + _validateAccelerator(accel) { + try { + const [success, keyval] = Gtk.accelerator_parse(accel); + return success && keyval !== 0; + } catch (e) { + return false; + } + } + + _getAppInfo(appId) { + const variants = [ + appId, + `${appId}.desktop`, + `${appId.replace(/-/g, '')}.desktop`, + appId.toLowerCase(), + `${appId.toLowerCase()}.desktop` + ]; + + const searchPaths = [ + GLib.get_user_data_dir() + '/applications', + '/usr/share/applications', + '/usr/local/share/applications' + ]; + + for (const variant of variants) { + const appInfo = Gio.DesktopAppInfo.new(variant); + if (appInfo) return appInfo; + + for (const path of searchPaths) { + const fullPath = `${path}/${variant}`; + if (GLib.file_test(fullPath, GLib.FileTest.EXISTS)) { + return Gio.DesktopAppInfo.new_from_filename(fullPath); + } + } + } + return null; + } + + _getAppDisplayInfo(appId) { + let appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`); + + if (!appInfo) { + const cleanId = appId.replace(/^application:\/\//, ''); + appInfo = Gio.DesktopAppInfo.new(`${cleanId}.desktop`); + } + + if (!appInfo) { + const shellApp = Shell.AppSystem.get_default().lookup_app(appId); + if (shellApp) { + return { + name: shellApp.get_name(), + icon: shellApp.get_icon() + }; + } + } + + if (!appInfo) { + const variations = [ + appId.toLowerCase(), + appId.replace(/-/g, ''), + `${appId}.desktop`, + `${appId.toLowerCase()}.desktop` + ]; + + for (const variant of variations) { + appInfo = Gio.DesktopAppInfo.new(variant); + if (appInfo) break; + } + } + + return { + name: appInfo ? appInfo.get_name() : appId, + icon: appInfo ? appInfo.get_icon() : null + }; + } + + _getAppName(appId) { + const appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`); + return appInfo ? appInfo.get_name() : appId; + } + + _sanitizeAppId(appId) { + return appId + .replace(/^application:\/\//, '') + .replace(/\.desktop$/i, '') + .replace(/\s+/g, '-') + .toLowerCase(); + } +} \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/schemas/gschemas.compiled b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/schemas/gschemas.compiled new file mode 100644 index 0000000000000000000000000000000000000000..b8764405b80087254aa092918b51ad918c77656c GIT binary patch literal 1770 zcmZ8iNoW*76m6Gr%VON)5)eY-jMy{dzUAg2f*u0miXJMR&PSnsfWE4Gl2)KZF zP{C+4iF$Fl2ws971mhu|6%+)~gNW!sjR%SERrMq>4R7A7H@|G-TFzP02uXPT5W0qyj0Q<#UI;Z@Cn-24?lxepo za#iU0Dol$Yumeviw}?1A$u)-cd+c{Mkccq{T)|xEdBA*NL5wOeQ3EUl>VU<-QeXwZ zIjsU#1MH`Qwu`^(*$2m4W1X}fgP#Czue1d8sjK0SfJcFEW8OOY)C=K%0DlEOf9lK8 zr>=v~!6$&$*UgvdQ!{@(xPkx0Ye{Wc`>nO$Uf{*ytCtv0y%_QR;2~i2$Ay{nshR&N zco>MHp6!4H7`IG&pEPr<{$?bxTLKLQ>F?!-Pd{WswEfZCNl z4bqz9`Hsd<;L*!&eI}^cFAqfm7>M&wGfy3O888_8)b!Van}7}8yQ9ocJr8~sybq{; z_~03RYVOw&@Cjfj&O^;Sd2j)^8~fDsd%$OaU)e@Ie(L$~d%*+1y*Qql@sGhzfahn< zR$C+|mCUQm$BxiXAe1YOtNVe(k>)bv9GMzh4qgecMp=tE z%DV1YkJSL{t`?}&;S%WSb%^7rAJ#8xnRkU#3M0Q@ce&+u&O+rX6XU$3C@Cjz3*{9= zg!@Ck!*K_~K`5GfjPYHf>)lFv$#T;3QmItnhaTVhZ-zxLm*=JD@MS#X((OchTx-Ww zk-3>n+XRo#!6GKhqLfO7elfCz4DzDgjj-fWmUR~XU|bz;rzlB8OzzL*UARX`rzE*c z=?*vSiu}N8(7T6SC`L{xvN!5n*f4!Vc9=9=hlz+y>}N`Q^R)IB(~cKadaA3koYQ(T z2V)}d)@gaRnRY?+ROZ4x<2rNCxUW;z*;S%ly0*lcNOWh;m6_3Y22L684%6njwnOif vYwl>~<=u@zP0c$ox}EfyCmo0=uNzhd3!Bj&nT-C(WF|i{naPh#2F3IbqAy-l literal 0 HcmV?d00001 diff --git a/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/schemas/org.gnome.shell.extensions.quicklaunch.gschema.xml b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/schemas/org.gnome.shell.extensions.quicklaunch.gschema.xml new file mode 100644 index 0000000..0d536af --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/schemas/org.gnome.shell.extensions.quicklaunch.gschema.xml @@ -0,0 +1,142 @@ + + + + + + [] + Custom keybindings paths + + + + true + Show pin icon in panel + Whether to show the pin icon in the panel when there are pinned items + + + + + [] + Pinned Applications + + + + + 24 + + Icon size in pixels + + + + true + Show apps in panel + Whether to display pinned apps in the top panel + + + + false + Always show menu icon + Keep the menu icon visible even when panel apps are hidden + + + + 'right' + + + + + + Panel position + Posizione nella barra superiore + + + + 6 + + Spacing between icons + + + + false + Show application labels + + + + + true + Enable launch animation + + + + + 10 + + Maximum apps to display + + + + ['org.gnome.Calculator.desktop'] + Default suggested apps + + + + + '' + Shortcut for position 1 + + + '' + Shortcut for position 2 + + + '' + Shortcut for position 3 + + + '' + Shortcut for position 4 + + + '' + Shortcut for position 5 + + + '' + Shortcut for position 6 + + + '' + Shortcut for position 7 + + + '' + Shortcut for position 8 + + + '' + Shortcut for position 9 + + + '' + Shortcut for position 10 + + + [] + Startup Applications + List of application IDs to launch at system startup. + + + '#00ff00' + Indicator color + Color for running application indicator + + + [] + Custom pinned links + + + false + Sort apps alphabetically + If enabled, pinned apps will be sorted by name. + + + \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/stylesheet.css b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/stylesheet.css new file mode 100644 index 0000000..460356b --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/quicklaunch@comitanigiacomo.github.com/stylesheet.css @@ -0,0 +1,131 @@ +.app-pinner-icons { + height: 100%; + padding: 0 2px; +} + +.app-pinner-icon-box { + height: 100%; + padding: 2px 0; + margin: auto 0; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.app-pinner-icon { + display: flex !important; + align-items: center; + justify-content: center; + padding: 0 !important; + margin: auto; + border-radius: 6px; + transition: all 0.2s ease-in-out; +} + +.app-pinner-link-icon { + margin: auto; + -st-icon-style: symbolic; +} + +.app-pinner-running-indicator { + background-color: #ff0000; + border-radius: 4px; + width: 8px; + height: 8px; + position: absolute; + top: 3px; + right: 3px; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + z-index: 1000; +} + +.app-pinner-label { + max-width: 100px; + text-overflow: ellipsis; + font-size: 0.8em; + margin-top: 2px; +} + +.app-pinner-remove-btn { + font-size: 18px; + background-color: transparent; + border-radius: 100px; + min-width: 18px; + min-height: 18px; + padding: 2px; + display: flex; + align-items: right; + justify-content: right; + transition: background-color 0.2s ease-in-out; +} + +.app-pinner-remove-btn:hover { + background-color: rgba(255, 0, 0, 0.8); +} + +.app-pinner-remove-label { + font-size: 16px; + font-weight: bold; + color: white; + text-align: center; +} + +.search-box { + width: 200px; + padding: 8px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.app-pinner-link-input { + min-width: 200px; + padding: 6px 12px; + border-radius: 6px; + background-color: rgba(0,0,0,0.3); + color: #ffffff; + border: 1px solid rgba(255,255,255,0.2); + font-size: 13px; +} + +.app-pinner-link-input:focus { + border-color: #3584e4; + box-shadow: inset 0 0 0 1px #3584e4; +} + +.app-pinner-add-link-btn { + background-color: rgba(78, 166, 78, 0.8); + border-radius: 8px; + min-width: 32px; + min-height: 32px; + padding: 6px; + margin-left: 6px; + transition: all 0.2s ease-in-out; +} + +.app-pinner-add-link-btn:hover { + background-color: rgba(98, 196, 98, 0.9); + transform: scale(1.05); +} + +.app-pinner-add-link-btn:active { + background-color: rgba(68, 146, 68, 0.9); +} + +.app-pinner-icon-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; +} + +.app-pinner-link-box { + margin: 8px; + padding: 6px; + background-color: rgba(255,255,255,0.05); + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); +} \ No newline at end of file diff --git a/gnome/.local/share/gnome-shell/extensions/screen-rotate@shyzus.github.io/locale/ru/LC_MESSAGES/gnome-shell-extension-screen-rotate.mo b/gnome/.local/share/gnome-shell/extensions/screen-rotate@shyzus.github.io/locale/ru/LC_MESSAGES/gnome-shell-extension-screen-rotate.mo new file mode 100644 index 0000000000000000000000000000000000000000..6df96773e1116a0552892d86bafa077bace59d2b GIT binary patch literal 4542 zcmcJRTWlOx8OIN#u@Pj1+)b!AwdOlOdLCL>_~Y+LWtRiiib)(@Bk7|NJzZ!!tXn?-b?I- zmon1&Kj)n9{{7E4{`2j({7~`S!|#*)4!%vP&wyWkJAZfzyOg>UEQ0R`$3T($3iws< zo8X7RH^JM%e}E~t>y~^y2Y!I{^WYue%iyQMRq*5Bo8WHnU*NmI+iq3r-QaHUgJ3WC z32+}Me#XJuKm&^16nr0e4wU#_1m6L^*4Y0Q_)*qt;2!Xg;N9TA!TZ5G5$*#=!H<9e z_#Ai^wDG)CsROKcgCaKx7QkhwEM>iQ>8^#2LQ;2j)(348%8gMS4L_yvl-A6x=o1OEo%TAil}Rq!S7 zFepuv{yzp%p5`Mx7JrmgeT<)QMdFoM#Ex)59_hg@ep35S@$2O$kK`hca8Vx0XY24G zkWlO+e34v*XASz=eW?fKY(BKaa;Le`c)16JhW3FTiq=|gX;3(CA8iJzdfW@lq&qdF z!$=<?N#X_7SdDnGJLMZ^EO3s8iJN#~&^jCqft_AB42EnOm}U)aci1Ch}%Ykm%E1GD8qvEWwoB*pQ&dK_0o? zE{9QM%89O|MnaN^D_&gl-8llngPsl3ATbrO;OZa@oK}2o#_*b$Anqv0A<3#;ro9xl z2F=1g=2dWy@7_mJqOip^z4~jZS3cROr#cdxGEt&uu=tIT9DE%$da!jQ=U%z};BhlI z6}nMH9o;Hw>rG31HB(V^bD|tkdS^?#)zq=JdNrdRZ}dd>9W~QQziO?Id)1lt4LNTe z+f$V%r*fB#R28<{OQpl<>DVM{VkSJT$0i<^>bL6NCww9D{&tlvvhDS6x3r*AGwWgB81cIJL`xFz^|K51|!QQ@3iIN#o9z^zk%~JvV4>mvof&96NGsv=wx5 zpwRPqE+w*pid5rcLCG2Ia8As43|Or@W+R z+)pFdcMgToY+TYo%`W5Ny$5vuI@}lBTPzF@7xjJjX?ZQ&JzOm6&(RaaSwA#9v~_fF zc%Qsljl#iVK|l3Wu7XUVADtpPL;VMO)B`%($d8}&tv`}O(-U7xQn;8+fS zn{95lXCp$+>Rrw*)fco4euLNVklR_Ti}DpMsSz&*CEJzOzJyOwcs+x{Is1NPKxN-= z2HnWzGNH_4Z7EydR;8ad!^BfZSyc8a{-q<7roNC}k$j0JTg`LLD@Eg0D2Fu2*0UoZ z$uBptLRqAI7bF2n$H4|0%eKnoY3(|gUa|+axJ!s!A~O=VrI0!j4SlzPu4DSLq)#B2 zyCf~)U>%be)DHZ}RVnToZw)n-{j5Eiol;m-*-u2A*0y~>Me!<;ozK~F)dqN-tp$DK z_3XRZGBJ}mzb4IYtgGxLY zhHSr=Oe48V(vejf+U)tZy2un*?luVepg}&A+AGe8=>Pxud<&9+yB#f+nbzLYw<7p% zKAIivS|V5@6KcUuoW{Hq);>e8y5F8xg}YkDRjx~GR~oaabHK=_Vzd5j%gv4U=_e7( z*p$J@f!va9ypc`3Fetfftw1f0w#I0Six}V&#`q9t11kHu&Eh&CUSeY443{>g)#fn2 z@%sP%pX>(TjQo?9$AF=0_>gZAv%2lmVx7u&v{}vU8e}K-RZ@_OHij<$zWrtX2mJRY vmqHT^_6STWF0*`*f8}I+TX&6r21@q((CuQ#mAQ!lG<31M13Qkk54!&Vm}U>y literal 0 HcmV?d00001 diff --git a/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/extension.js b/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/extension.js new file mode 100644 index 0000000..517f2c6 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/extension.js @@ -0,0 +1,262 @@ +import GObject from "gi://GObject"; +import GLib from "gi://GLib"; +import Soup from "gi://Soup"; + +import * as Main from "resource:///org/gnome/shell/ui/main.js"; +import * as QuickSettings from "resource:///org/gnome/shell/ui/quickSettings.js"; +import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; + +function clamp01(x) { + return Math.max(0, Math.min(1, x)); +} + +function jsonRpc(method, params = {}) { + return JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }); +} + +async function postJson(session, url, bodyStr) { + const msg = Soup.Message.new("POST", url); + msg.request_headers.append("Content-Type", "application/json"); + + // Soup 3: request body as GLib.Bytes + msg.set_request_body_from_bytes( + "application/json", + new GLib.Bytes(bodyStr), + ); + + const bytes = await session.send_and_read_async( + msg, + GLib.PRIORITY_DEFAULT, + null, + ); + const text = new TextDecoder().decode(bytes.get_data()); + return JSON.parse(text); +} + +function connectedClients(group) { + return (group?.clients ?? []).filter((c) => c?.connected); +} + +function connectedClientIds(group) { + return connectedClients(group) + .map((c) => c.id) + .filter(Boolean); +} + +function avgConnectedVolumePercent(group) { + const percents = connectedClients(group) + .map((c) => c?.config?.volume?.percent) + .filter((p) => typeof p === "number"); + + if (!percents.length) return null; + + return percents.reduce((a, b) => a + b, 0) / percents.length; +} + +function groupLabel(group) { + const base = + group?.name && group.name.trim() + ? group.name.trim() + : (group?.stream_id ?? "Group"); + + const connected = connectedClients(group).length; + const total = group?.clients?.length ?? 0; + + return `${base} (${connected}/${total})`; +} + +const SnapcastSlider = GObject.registerClass( + class SnapcastSlider extends QuickSettings.QuickSlider { + constructor(extension) { + super({ + title: "Snapcast", + iconName: "audio-volume-high-symbolic", + }); + + this._ext = extension; + this._settings = extension.getSettings(); + this._session = new Soup.Session(); + + this._pollId = 0; + this._debounceId = 0; + this._sliderChangedId = 0; + this._settingsChangedId = 0; + + this._isUpdatingFromServer = false; + this._groupClientIds = []; + + // Load settings + this._reloadSettings(); + + // React to settings changes (server-url, group-id, poll-seconds) + this._settingsChangedId = this._settings.connect("changed", () => { + this._reloadSettings(); + this._restartPolling(); + this._pollOnce().catch(logError); + }); + + // Slider change -> debounce -> set group volume + this._sliderChangedId = this.slider.connect("notify::value", () => { + if (this._isUpdatingFromServer) return; + this._debouncedSetVolume(); + }); + + // Start polling + initial sync + this._restartPolling(); + this._pollOnce().catch(logError); + } + + _reloadSettings() { + // You’ll define these keys in your gschema: + // - server-url (string) + // - group-id (string) + // - poll-seconds (uint) + this._url = this._settings.get_string("server-url"); + this._groupId = this._settings.get_string("group-id"); + this._pollSeconds = this._settings.get_uint("poll-seconds"); + + // Sensible guards + if (!this._pollSeconds || this._pollSeconds < 2) + this._pollSeconds = 5; + } + + _restartPolling() { + if (this._pollId) { + GLib.source_remove(this._pollId); + this._pollId = 0; + } + + this._pollId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + this._pollSeconds, + () => { + this._pollOnce().catch(logError); + return GLib.SOURCE_CONTINUE; + }, + ); + } + + _debouncedSetVolume() { + if (this._debounceId) { + GLib.source_remove(this._debounceId); + this._debounceId = 0; + } + + // Wait a beat so dragging doesn’t fire 100 requests/sec + this._debounceId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + 150, + () => { + this._debounceId = 0; + this._setGroupVolumeFromSlider().catch(logError); + return GLib.SOURCE_REMOVE; + }, + ); + } + + async _pollOnce() { + // If not configured yet + if (!this._url) { + this.title = "Snapcast — set server URL"; + this._groupClientIds = []; + return; + } + if (!this._groupId) { + this.title = "Snapcast — select a group"; + this._groupClientIds = []; + return; + } + + // Pull all status in one request + const resp = await postJson( + this._session, + this._url, + jsonRpc("Server.GetStatus", {}), + ); + const groups = resp?.result?.server?.groups ?? []; + const group = groups.find((g) => g.id === this._groupId); + + if (!group) { + this.title = "Snapcast — group not found"; + this._groupClientIds = []; + return; + } + + // Only connected clients + this._groupClientIds = connectedClientIds(group); + + // Update title with something human-readable + this.title = `Snapcast — ${groupLabel(group)}`; + + // Slider position = average of connected clients’ volumes + const avg = avgConnectedVolumePercent(group); + if (typeof avg === "number") { + const v = clamp01(avg / 100); + + this._isUpdatingFromServer = true; + try { + if (Math.abs(this.slider.value - v) > 0.01) + this.slider.value = v; + } finally { + this._isUpdatingFromServer = false; + } + } + } + + async _setGroupVolumeFromSlider() { + if (!this._groupClientIds.length) return; + + const percent = Math.round(clamp01(this.slider.value) * 100); + + // Fan out: set volume for each connected client in the group + await Promise.allSettled( + this._groupClientIds.map((id) => { + const body = jsonRpc("Client.SetVolume", { + id, + volume: { percent }, + }); + return postJson(this._session, this._url, body); + }), + ); + } + + destroy() { + if (this._pollId) GLib.source_remove(this._pollId); + if (this._debounceId) GLib.source_remove(this._debounceId); + if (this._sliderChangedId) + this.slider.disconnect(this._sliderChangedId); + if (this._settingsChangedId) + this._settings.disconnect(this._settingsChangedId); + + super.destroy(); + } + }, +); + +const SnapcastIndicator = GObject.registerClass( + class SnapcastIndicator extends QuickSettings.SystemIndicator { + constructor(extension) { + super(); + this.quickSettingsItems.push(new SnapcastSlider(extension)); + } + + destroy() { + this.quickSettingsItems.forEach((item) => item.destroy()); + super.destroy(); + } + }, +); + +export default class SnapshellExtension extends Extension { + enable() { + this._indicator = new SnapcastIndicator(this); + Main.panel.statusArea.quickSettings.addExternalIndicator( + this._indicator, + ); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/metadata.json b/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/metadata.json new file mode 100644 index 0000000..b4b7295 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "snapshell@unbl.ink", + "name": "Snapshell", + "description": "Quick Settings volume slider for a Snapcast group (connected clients only).", + "shell-version": ["45", "46", "47", "48", "49", "50", "51"], + "version": 1 +} diff --git a/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/prefs.js b/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/prefs.js new file mode 100644 index 0000000..f676390 --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/prefs.js @@ -0,0 +1,200 @@ +import Adw from "gi://Adw"; +import Gio from "gi://Gio"; +import GLib from "gi://GLib"; +import Gtk from "gi://Gtk"; +import Soup from "gi://Soup"; + +import { ExtensionPreferences } from "resource:///org/gnome/shell/extensions/prefs.js"; + +function jsonRpc(method, params = {}) { + return JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }); +} + +async function postJson(session, url, bodyStr) { + const msg = Soup.Message.new("POST", url); + msg.request_headers.append("Content-Type", "application/json"); + msg.set_request_body_from_bytes( + "application/json", + new GLib.Bytes(bodyStr), + ); + + const bytes = await session.send_and_read_async( + msg, + GLib.PRIORITY_DEFAULT, + null, + ); + const text = new TextDecoder().decode(bytes.get_data()); + return JSON.parse(text); +} + +function connectedCount(group) { + return (group?.clients ?? []).filter((c) => c?.connected).length; +} + +function groupLabel(group) { + const base = + group?.name && group.name.trim() + ? group.name.trim() + : (group?.stream_id ?? "Group"); + + const connected = connectedCount(group); + const total = group?.clients?.length ?? 0; + + return `${base} (${connected}/${total})`; +} + +export default class SnapshellPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings( + "org.gnome.shell.extensions.snapshell", + ); + const session = new Soup.Session(); + + // --- UI models --- + const groupNames = new Gtk.StringList(); + let groupIds = []; // parallel array: index -> group id + + // --- Page --- + const page = new Adw.PreferencesPage({ + title: "Snapshell", + icon_name: "audio-volume-high-symbolic", + }); + window.add(page); + + // --- Group: Connection --- + const connectionGroup = new Adw.PreferencesGroup({ + title: "Connection", + description: "Configure Snapserver JSON-RPC endpoint.", + }); + page.add(connectionGroup); + + const urlRow = new Adw.EntryRow({ + title: "Server URL", + text: settings.get_string("server-url"), + }); + urlRow.set_tooltip_text("Example: https://snap.unbl.ink/jsonrpc"); + connectionGroup.add(urlRow); + + urlRow.connect("notify::text", () => { + settings.set_string("server-url", urlRow.text.trim()); + }); + + // --- Group: Target Group --- + const targetGroup = new Adw.PreferencesGroup({ + title: "Target group", + description: + "Choose which Snapcast group the Quick Settings slider controls.", + }); + page.add(targetGroup); + + const groupRow = new Adw.ComboRow({ + title: "Snapcast group", + model: groupNames, + }); + targetGroup.add(groupRow); + + const refreshRow = new Adw.ActionRow({ + title: "Refresh groups", + subtitle: "Fetch groups from Server.GetStatus", + }); + const refreshBtn = new Gtk.Button({ label: "Refresh" }); + refreshRow.add_suffix(refreshBtn); + refreshRow.activatable_widget = refreshBtn; + targetGroup.add(refreshRow); + + // --- Group: Behavior --- + const behaviorGroup = new Adw.PreferencesGroup({ + title: "Behavior", + }); + page.add(behaviorGroup); + + const pollRow = new Adw.SpinRow({ + title: "Poll interval (seconds)", + adjustment: new Gtk.Adjustment({ + lower: 2, + upper: 60, + step_increment: 1, + page_increment: 5, + value: settings.get_uint("poll-seconds") || 5, + }), + }); + behaviorGroup.add(pollRow); + + pollRow.connect("notify::value", () => { + settings.set_uint("poll-seconds", pollRow.value); + }); + + // --- Helpers to load + sync groups --- + const setStatus = (text) => { + refreshRow.subtitle = text; + }; + + const clearGroups = () => { + // Remove all items from StringList + while (groupNames.get_n_items() > 0) groupNames.remove(0); + groupIds = []; + }; + + const syncComboSelectionFromSettings = () => { + const selectedId = settings.get_string("group-id"); + const idx = groupIds.indexOf(selectedId); + groupRow.selected = idx >= 0 ? idx : Gtk.INVALID_LIST_POSITION; + }; + + const fetchGroups = async () => { + const url = settings.get_string("server-url").trim(); + if (!url) { + clearGroups(); + setStatus("Set Server URL first."); + return; + } + + setStatus("Fetching…"); + try { + const resp = await postJson( + session, + url, + jsonRpc("Server.GetStatus", {}), + ); + const groups = resp?.result?.server?.groups ?? []; + + clearGroups(); + + // Populate list + for (const g of groups) { + groupNames.append(groupLabel(g)); + groupIds.push(g.id); + } + + setStatus(`Loaded ${groups.length} group(s).`); + syncComboSelectionFromSettings(); + } catch (e) { + clearGroups(); + setStatus(`Failed: ${e?.message ?? e}`); + } + }; + + // When user picks a row, store its group id + groupRow.connect("notify::selected", () => { + const idx = groupRow.selected; + if (idx === Gtk.INVALID_LIST_POSITION) return; + const id = groupIds[idx]; + if (id) settings.set_string("group-id", id); + }); + + // Refresh button + refreshBtn.connect("clicked", () => { + fetchGroups(); + }); + + // If URL changes, offer fresh fetch (don’t auto-spam) + urlRow.connect("notify::text", () => { + setStatus("URL changed. Click Refresh."); + clearGroups(); + syncComboSelectionFromSettings(); + }); + + // Initial load + fetchGroups(); + } +} diff --git a/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/schemas/gschemas.compiled b/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/schemas/gschemas.compiled new file mode 100644 index 0000000000000000000000000000000000000000..bfcdbc69fafe1f8f1ea52682d5f9fb2ab130b324 GIT binary patch literal 465 zcmYLFJ5Iwu6nuOl5JW=aCrS#6q}bh%ki_8vfRq}gpfS!SHke(@Yey*{qUHoN2+ACT zE6^fw1PV?7^PEIHc|4=>`0c)TcIl^)Z!((K0k}8QmF0yAF6|NhqGsQ^Mdk|r_viG5 zW2}$poF2iuOGJ3crb9V2_D)L|>Nu8qoN41CYn*h(PYM&UN(nD6{)f+8oujf0GpTTdy`@>|DXJTD~KLvjP z!_?-9i2B^!KW<%XFYtwKv<9Mi((y#i3;LqrwL61)t=??6n;5}WIgDBXcm}7_kxoUP z#_SS>S(dm{rFd|eo9kFc=2qP}Ytker8YDK3h0}pGeaH7fRr0D#{49i2Sf(nLsfxX+ M|78|EXC}`51Ct + + + + + 'https://snap.unbl.ink/jsonrpc' + Snapcast JSON-RPC URL + + HTTP(S) endpoint for Snapserver JSON-RPC. + + + + + 'a16be8ca-e36f-5498-08cd-3879f2025775' + Snapcast group id + + UUID of the Snapcast group to control by default. + + + + + 5 + Polling interval (seconds) + + How often to refresh Server.GetStatus to update slider position. + + + + + diff --git a/gnome/.local/share/gnome-shell/extensions/vpn-toggler@rheddes.nl b/gnome/.local/share/gnome-shell/extensions/vpn-toggler@rheddes.nl new file mode 120000 index 0000000..7ab241b --- /dev/null +++ b/gnome/.local/share/gnome-shell/extensions/vpn-toggler@rheddes.nl @@ -0,0 +1 @@ +/home/powellc/src/github.com/Rheddes/vpn-toggler \ No newline at end of file