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); } }