// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; const GObject = imports.gi.GObject; const Gtk = imports.gi.Gtk; const Shell = imports.gi.Shell; const Signals = imports.signals; // Use __ () and N__() for the extension gettext domain, and reuse // the shell domain with the default _() and N_() const Gettext = imports.gettext.domain('dashtodock'); const __ = Gettext.gettext; const N__ = function(e) { return e }; const Me = imports.misc.extensionUtils.getCurrentExtension(); const Docking = Me.imports.docking; const Utils = Me.imports.utils; const FILE_MANAGER_DESKTOP_APP_ID = 'org.gnome.Nautilus.desktop'; const TRASH_URI = 'trash://'; const UPDATE_TRASH_DELAY = 500; const NautilusFileOperations2Interface = '\ \ \ \ \ \ \ '; const NautilusFileOperations2ProxyInterface = Gio.DBusProxy.makeProxyWrapper(NautilusFileOperations2Interface); function makeNautilusFileOperationsProxy() { const proxy = new NautilusFileOperations2ProxyInterface( Gio.DBus.session, 'org.gnome.Nautilus', '/org/gnome/Nautilus/FileOperations2', (_p, error) => { if (error) logError(error, 'Error connecting to Nautilus'); } ); proxy.platformData = params => { const defaultParams = { parentHandle: '', timestamp: global.get_current_time(), windowPosition: 'center', }; const { parentHandle, timestamp, windowPosition } = { ...defaultParams, ...params, }; return { 'parent-handle': new GLib.Variant('s', parentHandle), 'timestamp': new GLib.Variant('u', timestamp), 'window-position': new GLib.Variant('s', windowPosition), }; }; return proxy; } function wrapWindowsBackedApp(shellApp) { if (shellApp._dtdData) throw new Error('%s has been already wrapped'.format(shellApp)); shellApp._dtdData = { windows: [], methodInjections: new Utils.InjectionsHandler(), propertyInjections: new Utils.PropertyInjectionsHandler(), destroy: function () { this.windows = []; this.methodInjections.destroy(); this.propertyInjections.destroy(); } }; const m = (...args) => shellApp._dtdData.methodInjections.add(shellApp, ...args); const p = (...args) => shellApp._dtdData.propertyInjections.add(shellApp, ...args); shellApp._mi = m; shellApp._pi = p; m('get_state', () => shellApp.get_windows().length ? Shell.AppState.RUNNING : Shell.AppState.STOPPED); p('state', { get: () => shellApp.get_state() }); m('get_windows', () => shellApp._dtdData.windows); m('get_n_windows', () => shellApp.get_windows().length); m('get_pids', () => shellApp.get_windows().reduce((pids, w) => { if (w.get_pid() > 0 && !pids.includes(w.get_pid())) pids.push(w.get_pid()); return pids; }, [])); m('is_on_workspace', (_om, workspace) => shellApp.get_windows().some(w => w.get_workspace() === workspace)); m('request_quit', () => shellApp.get_windows().filter(w => w.can_close()).forEach(w => w.delete(global.get_current_time()))); shellApp._updateWindows = function () { throw new GObject.NotImplementedError(`_updateWindows in ${this.constructor.name}`); }; let updateWindowsIdle = GLib.idle_add(GLib.DEFAULT_PRIORITY, () => { shellApp._updateWindows(); updateWindowsIdle = undefined; return GLib.SOURCE_REMOVE; }); const windowTracker = Shell.WindowTracker.get_default(); shellApp._checkFocused = function () { if (this.get_windows().some(w => w.has_focus())) { this.isFocused = true; windowTracker.notify('focus-app'); } else if (this.isFocused) { this.isFocused = false; windowTracker.notify('focus-app'); } } shellApp._checkFocused(); const focusWindowNotifyId = global.display.connect('notify::focus-window', () => shellApp._checkFocused()); // Re-implements shell_app_activate_window for generic activation and alt-tab support m('activate_window', function (_om, window, timestamp) { if (!window) [window] = this.get_windows(); else if (!this.get_windows().includes(window)) return; const currentWorkspace = global.workspace_manager.get_active_workspace(); const workspace = window.get_workspace(); const sameWorkspaceWindows = this.get_windows().filter(w => w.get_workspace() === workspace); sameWorkspaceWindows.forEach(w => w.raise()); if (workspace !== currentWorkspace) workspace.activate_with_focus(window, timestamp); else window.activate(timestamp); }); // Re-implements shell_app_activate_full for generic activation and dash support m('activate_full', function (_om, workspace, timestamp) { if (!timestamp) timestamp = global.get_current_time(); switch (this.state) { case Shell.AppState.STOPPED: try { this.launch(timestamp, workspace, Shell.AppLaunchGpu.APP_PREF); } catch (e) { global.notify_error(__("Failed to launch ā€œ%sā€".format( this.get_name())), e.message); } break; case Shell.AppState.RUNNING: this.activate_window(null, timestamp); break; } }); m('activate', () => shellApp.activate_full(-1, 0)); m('compare', (_om, other) => shellAppCompare(shellApp, other)); shellApp.destroy = function() { global.display.disconnect(focusWindowNotifyId); updateWindowsIdle && GLib.source_remove(updateWindowsIdle); this._dtdData.destroy(); this._dtdData = undefined; this.destroy = undefined; } return shellApp; } // We can't inherit from Shell.App as it's a final type, so let's patch it function makeLocationApp(params) { if (!params.location) throw new TypeError('Invalid location'); location = params.location; delete params.location; const shellApp = new Shell.App(params); wrapWindowsBackedApp(shellApp); shellApp.appInfo.customId = 'location:%s'.format(location); Object.defineProperties(shellApp, { location: { value: location }, isTrash: { value: location.startsWith(TRASH_URI) }, }); shellApp._mi('toString', defaultToString => '[LocationApp - %s]'.format(defaultToString.call(shellApp))); // FIXME: We need to add a new API to Nautilus to open new windows shellApp._mi('can_open_new_window', () => false); const { fm1Client } = Docking.DockManager.getDefault(); shellApp._updateWindows = function () { const oldState = this.state; const oldWindows = this.get_windows(); this._dtdData.windows = fm1Client.getWindows(this.location); if (this.get_windows().length !== oldWindows.length || this.get_windows().some((win, index) => win !== oldWindows[index])) this.emit('windows-changed'); if (oldState !== this.state) { Shell.AppSystem.get_default().emit('app-state-changed', this); this.notify('state'); this._checkFocused(); } }; const windowsChangedId = fm1Client.connect('windows-changed', () => shellApp._updateWindows()); const parentDestroy = shellApp.destroy; shellApp.destroy = function () { fm1Client.disconnect(windowsChangedId); parentDestroy.call(this); } return shellApp; } function getFileManagerApp() { return Shell.AppSystem.get_default().lookup_app(FILE_MANAGER_DESKTOP_APP_ID); } function wrapWindowsManagerApp() { const fileManagerApp = getFileManagerApp(); if (!fileManagerApp) return null; if (fileManagerApp._dtdData) return fileManagerApp; const originalGetWindows = fileManagerApp.get_windows; wrapWindowsBackedApp(fileManagerApp); const { fm1Client } = Docking.DockManager.getDefault(); const windowsChangedId = fileManagerApp.connect('windows-changed', () => fileManagerApp._updateWindows()); const fm1WindowsChangedId = fm1Client.connect('windows-changed', () => fileManagerApp._updateWindows()); fileManagerApp._updateWindows = function () { const oldState = this.state; const oldWindows = this.get_windows(); const locationWindows = []; getRunningApps().forEach(a => locationWindows.push(...a.get_windows())); this._dtdData.windows = originalGetWindows.call(this).filter(w => !locationWindows.includes(w)); if (this.get_windows().length !== oldWindows.length || this.get_windows().some((win, index) => win !== oldWindows[index])) { this.block_signal_handler(windowsChangedId); this.emit('windows-changed'); this.unblock_signal_handler(windowsChangedId); } if (oldState !== this.state) { Shell.AppSystem.get_default().emit('app-state-changed', this); this.notify('state'); this._checkFocused(); } }; fileManagerApp._mi('toString', defaultToString => '[FileManagerApp - %s]'.format(defaultToString.call(fileManagerApp))); const parentDestroy = fileManagerApp.destroy; fileManagerApp.destroy = function () { fileManagerApp.disconnect(windowsChangedId); fm1Client.disconnect(fm1WindowsChangedId); parentDestroy.call(this); } return fileManagerApp; } function unWrapWindowsManagerApp() { const fileManagerApp = getFileManagerApp(); if (!fileManagerApp || !fileManagerApp._dtdData) return; fileManagerApp.destroy(); } // Re-implements shell_app_compare so that can be used to resort running apps function shellAppCompare(app, other) { if (app.state !== other.state) { if (app.state === Shell.AppState.RUNNING) return -1; return 1; } const windows = app.get_windows(); const otherWindows = other.get_windows(); const isMinimized = windows => !windows.some(w => w.showing_on_its_workspace()); const otherMinimized = isMinimized(otherWindows); if (isMinimized(windows) != otherMinimized) { if (otherMinimized) return -1; return 1; } if (app.state === Shell.AppState.RUNNING) { if (windows.length && !otherWindows.length) return -1; else if (!windows.length && otherWindows.length) return 1; const lastUserTime = windows => Math.max(...windows.map(w => w.get_user_time())); return lastUserTime(otherWindows) - lastUserTime(windows); } return 0; } /** * This class maintains a Shell.App representing the Trash and keeps it * up-to-date as the trash fills and is emptied over time. */ var Trash = class DashToDock_Trash { _promisified = false; static initPromises() { if (Trash._promisified) return; Gio._promisify(Gio.FileEnumerator.prototype, 'close_async', 'close_finish'); Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async', 'next_files_finish'); Gio._promisify(Gio.file_new_for_uri(TRASH_URI).constructor.prototype, 'enumerate_children_async', 'enumerate_children_finish'); Trash._promisified = true; } constructor() { Trash.initPromises(); this._cancellable = new Gio.Cancellable(); this._file = Gio.file_new_for_uri(TRASH_URI); try { this._monitor = this._file.monitor_directory(0, this._cancellable); this._signalId = this._monitor.connect( 'changed', this._onTrashChange.bind(this) ); } catch (e) { if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) return; logError(e, 'Impossible to monitor trash'); } this._empty = true; this._schedUpdateId = 0; this._updateTrash(); } destroy() { this._cancellable.cancel(); this._cancellable = null; this._monitor?.disconnect(this._signalId); this._monitor = null; this._file = null; this._trashApp?.destroy(); } _onTrashChange() { if (this._schedUpdateId) { GLib.source_remove(this._schedUpdateId); } this._schedUpdateId = GLib.timeout_add( GLib.PRIORITY_LOW, UPDATE_TRASH_DELAY, () => { this._schedUpdateId = 0; this._updateTrash(); return GLib.SOURCE_REMOVE; }); } async _updateTrash() { try { const priority = GLib.PRIORITY_LOW; const cancellable = this._cancellable; const childrenEnumerator = await this._file.enumerate_children_async( Gio.FILE_ATTRIBUTE_STANDARD_TYPE, Gio.FileQueryInfoFlags.NONE, priority, cancellable); const children = await childrenEnumerator.next_files_async(1, priority, cancellable); this._empty = !children.length; this._ensureApp(); await childrenEnumerator.close_async(priority, null); } catch (e) { if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) logError(e, 'Impossible to enumerate trash children'); } } _ensureApp() { if (this._trashApp == null || this._lastEmpty !== this._empty) { let trashKeys = new GLib.KeyFile(); trashKeys.set_string('Desktop Entry', 'Name', __('Trash')); trashKeys.set_string('Desktop Entry', 'Icon', this._empty ? 'user-trash' : 'user-trash-full'); trashKeys.set_string('Desktop Entry', 'Type', 'Application'); trashKeys.set_string('Desktop Entry', 'Exec', 'gio open %s'.format(TRASH_URI)); trashKeys.set_string('Desktop Entry', 'StartupNotify', 'false'); if (!this._empty) { trashKeys.set_string('Desktop Entry', 'Actions', 'empty-trash;'); trashKeys.set_string('Desktop Action empty-trash', 'Name', __('Empty Trash')); trashKeys.set_string('Desktop Action empty-trash', 'Exec', 'true'); } let trashAppInfo = Gio.DesktopAppInfo.new_from_keyfile(trashKeys); this._trashApp?.destroy(); this._trashApp = makeLocationApp({ location: TRASH_URI + '/', appInfo: trashAppInfo, }); if (!this._empty) { this._trashApp._mi('launch_action', (launchAction, actionName, timestamp, ...args) => { if (actionName === 'empty-trash') { const nautilus = makeNautilusFileOperationsProxy(); const askConfirmation = true; nautilus.EmptyTrashRemote(askConfirmation, nautilus.platformData({ timestamp }), (_p, error) => { if (error) logError(error, 'Empty trash failed'); }); return; } return launchAction.call(this, actionName, timestamp, ...args); }); } this._lastEmpty = this._empty; this.emit('changed'); } } getApp() { this._ensureApp(); return this._trashApp; } } Signals.addSignalMethods(Trash.prototype); /** * This class maintains Shell.App representations for removable devices * plugged into the system, and keeps the list of Apps up-to-date as * devices come and go and are mounted and unmounted. */ var Removables = class DashToDock_Removables { constructor() { this._signalsHandler = new Utils.GlobalSignalsHandler(); this._monitor = Gio.VolumeMonitor.get(); this._volumeApps = [] this._mountApps = [] this._monitor.get_volumes().forEach( (volume) => { this._onVolumeAdded(this._monitor, volume); } ); this._monitor.get_mounts().forEach( (mount) => { this._onMountAdded(this._monitor, mount); } ); this._signalsHandler.add([ this._monitor, 'mount-added', this._onMountAdded.bind(this) ], [ this._monitor, 'mount-removed', this._onMountRemoved.bind(this) ], [ this._monitor, 'volume-added', this._onVolumeAdded.bind(this) ], [ this._monitor, 'volume-removed', this._onVolumeRemoved.bind(this) ]); } destroy() { this._signalsHandler.destroy(); this._monitor.run_dispose(); } _getWorkingIconName(icon) { if (icon instanceof Gio.EmblemedIcon) { icon = icon.get_icon(); } if (icon instanceof Gio.ThemedIcon) { const { iconTheme } = Docking.DockManager.getDefault(); let names = icon.get_names(); for (let i = 0; i < names.length; i++) { let iconName = names[i]; if (iconTheme.has_icon(iconName)) { return iconName; } } return ''; } else { return icon.to_string(); } } _onVolumeAdded(monitor, volume) { if (!volume.can_mount()) { return; } if (volume.get_identifier('class') == 'network') { return; } let activationRoot = volume.get_activation_root(); if (!activationRoot) { // Can't offer to mount a device if we don't know // where to mount it. // These devices are usually ejectable so you // don't normally unmount them anyway. return; } let escapedUri = activationRoot.get_uri() let uri = GLib.uri_unescape_string(escapedUri, null); let volumeKeys = new GLib.KeyFile(); volumeKeys.set_string('Desktop Entry', 'Name', volume.get_name()); volumeKeys.set_string('Desktop Entry', 'Icon', this._getWorkingIconName(volume.get_icon())); volumeKeys.set_string('Desktop Entry', 'Type', 'Application'); volumeKeys.set_string('Desktop Entry', 'Exec', 'gio open "' + uri + '"'); volumeKeys.set_string('Desktop Entry', 'StartupNotify', 'false'); volumeKeys.set_string('Desktop Entry', 'Actions', 'mount;'); volumeKeys.set_string('Desktop Action mount', 'Name', __('Mount')); volumeKeys.set_string('Desktop Action mount', 'Exec', 'gio mount "' + uri + '"'); let volumeAppInfo = Gio.DesktopAppInfo.new_from_keyfile(volumeKeys); const volumeApp = makeLocationApp({ location: escapedUri, appInfo: volumeAppInfo, }); this._volumeApps.push(volumeApp); this.emit('changed'); } _onVolumeRemoved(monitor, volume) { for (let i = 0; i < this._volumeApps.length; i++) { let app = this._volumeApps[i]; if (app.get_name() == volume.get_name()) { const [volumeApp] = this._volumeApps.splice(i, 1); volumeApp.destroy(); } } this.emit('changed'); } _onMountAdded(monitor, mount) { // Filter out uninteresting mounts if (!mount.can_eject() && !mount.can_unmount()) return; if (mount.is_shadowed()) return; let volume = mount.get_volume(); if (!volume || volume.get_identifier('class') == 'network') { return; } const escapedUri = mount.get_default_location().get_uri() let uri = GLib.uri_unescape_string(escapedUri, null); let mountKeys = new GLib.KeyFile(); mountKeys.set_string('Desktop Entry', 'Name', mount.get_name()); mountKeys.set_string('Desktop Entry', 'Icon', this._getWorkingIconName(volume.get_icon())); mountKeys.set_string('Desktop Entry', 'Type', 'Application'); mountKeys.set_string('Desktop Entry', 'Exec', 'gio open "' + uri + '"'); mountKeys.set_string('Desktop Entry', 'StartupNotify', 'false'); mountKeys.set_string('Desktop Entry', 'Actions', 'unmount;'); if (mount.can_eject()) { mountKeys.set_string('Desktop Action unmount', 'Name', __('Eject')); mountKeys.set_string('Desktop Action unmount', 'Exec', 'gio mount -e "' + uri + '"'); } else { mountKeys.set_string('Desktop Entry', 'Actions', 'unmount;'); mountKeys.set_string('Desktop Action unmount', 'Name', __('Unmount')); mountKeys.set_string('Desktop Action unmount', 'Exec', 'gio mount -u "' + uri + '"'); } let mountAppInfo = Gio.DesktopAppInfo.new_from_keyfile(mountKeys); const mountApp = makeLocationApp({ appInfo: mountAppInfo, location: escapedUri, }); this._mountApps.push(mountApp); this.emit('changed'); } _onMountRemoved(monitor, mount) { for (let i = 0; i < this._mountApps.length; i++) { let app = this._mountApps[i]; if (app.get_name() == mount.get_name()) { const [mountApp] = this._mountApps.splice(i, 1); mountApp.destroy(); } } this.emit('changed'); } getApps() { // When we have both a volume app and a mount app, we prefer // the mount app. let apps = new Map(); this._volumeApps.map(function(app) { apps.set(app.get_name(), app); }); this._mountApps.map(function(app) { apps.set(app.get_name(), app); }); return [...apps.values()]; } } Signals.addSignalMethods(Removables.prototype); function getRunningApps() { const dockManager = Docking.DockManager.getDefault(); const locationApps = []; if (dockManager.removables) locationApps.push(...dockManager.removables.getApps()); if (dockManager.trash) locationApps.push(dockManager.trash.getApp()); return locationApps.filter(a => a.state === Shell.AppState.RUNNING); }