diff --git a/dbus-monitor.sh b/dbus-monitor.sh new file mode 100755 index 00000000..d816b93a --- /dev/null +++ b/dbus-monitor.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Usage: +# dbus-monitor.sh +# dbus-monitor.sh --profile # table like tab separated output +# dbus-monitor.sh --signals # only show signals +# dbus-monitor.sh FILTER # see https://dbus.freedesktop.org/doc/dbus-specification.html#message-bus-routing-match-rules +# dbus-monitor.sh type=signal + +set -euo pipefail + +ADDITIONAL_ARGS=() +FILTER=("path=/org/github/PaperWM") + +script_name=$(basename "${BASH_SOURCE[0]}") + +usage() { + cat </dev/null; } | tail -n +5 +} + +join_by() { + local IFS="$1" + shift + echo "$*" +} + +main "$@" diff --git a/dbus-send.sh b/dbus-send.sh new file mode 100755 index 00000000..fdc4c635 --- /dev/null +++ b/dbus-send.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_name=$(basename "${BASH_SOURCE[0]}") + +usage() { + cat <&2 + usage + exit 1 + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + action) + shift + trigger_action "$1" + return + ;; + list-actions) + shift + list_actions + return + ;; + -h|--help) + usage + exit + ;; + *) + echo "ERROR: Unknown command: $1" >&2 + usage + exit 1 + ;; + esac + done +} + +trigger_action() { + METHOD=TriggerAction call "string:$1" +} + +list_actions() { + local result + result=$(METHOD=ListActions call) + while IFS=' ' read -ra words; do + for word in "${words[@]}"; do + case "${word}" in + array|\[|\]) + ;; + *) + echo "${word}" + ;; + esac + done + done <<< "$result" +} + +send() { + local args + args=( --dest=org.github.PaperWM ) + if [[ -n "${OPTIONS:-}" ]]; then + args+=( "${OPTIONS}" ) + fi + args+=( + /org/github/PaperWM + "org.github.PaperWM.${METHOD}" + "$@" + ) + + set -x + dbus-send "${args[@]}" + { set +x; } &> /dev/null +} + +call() { + OPTIONS=--print-reply=literal send "$@" +} + +global_rematch() { + local s=$1 regex=$2 + while [[ $s =~ $regex ]]; do + echo "${BASH_REMATCH[1]}" + s=${s#*"${BASH_REMATCH[1]}"} + done +} + +join_by() { + local IFS="$1" + shift + echo "$*" +} + +main "$@" diff --git a/dbus.js b/dbus.js new file mode 100644 index 00000000..a87aad98 --- /dev/null +++ b/dbus.js @@ -0,0 +1,261 @@ +// Ressources: +// - https://gjs.guide/guides/gio/dbus.html#introduction-to-d-bus +// - https://dbus.freedesktop.org/doc/dbus-api-design.html +// - https://gitlab.gnome.org/GNOME/gnome-shell/-/tree/main/data/dbus-interfaces +// - https://gjs-docs.gnome.org/gio20~2.0/gio.bus_own_name +// - https://docs.gtk.org/glib/gvariant-format-strings.html (dbus does not support maybe/nullable types) +// - https://docs.gtk.org/glib/gvariant-text.html +// - https://dbus.freedesktop.org/doc/dbus-specification.html +// - https://dbus.freedesktop.org/doc/dbus-specification.html#type-system +// - https://www.baeldung.com/linux/dbus +// - D-Spy, dbus-monitor, dbus-send + +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import { Tiling, Keybindings, Utils } from './imports.js'; +import * as Imports from './imports.js'; + +// used as the well known name and as the interface name in DBus +const DBUS_NAME = "org.github.PaperWM"; +const DBUS_PATH = "/org/github/PaperWM" +const DBUS_INTERFACE_DIR = "./dbus"; + +let serviceInstance = null; +let dbusConnection = null; +let dbusOwnerId; + +export function enable() { + console.debug(`#PaperWM: Registering DBus interface: ${DBUS_NAME} on path ${DBUS_PATH}`); + try { + dbusOwnerId = Gio.bus_own_name( + Gio.BusType.SESSION, + DBUS_NAME, + Gio.BusNameOwnerFlags.DO_NOT_QUEUE, + onBusAcquired, + onNameAcquired, + null, + ); + console.debug(`#PaperWM: dbusOwnerId=${dbusOwnerId}`); + } catch (e) { + console.error("#PaperWM: Failed to own DBus name.") + console.error(e); + } +} + +export function disable() { + try { + Gio.bus_unown_name(dbusOwnerId); + } catch (e) { + console.error("#PaperWM: Failed to unown DBus name.") + console.error(e); + } + + dbusConnection = null; + serviceInstance.destroy(); + serviceInstance = null; +} + +/** + * Invoked when a connection to a message bus has been obtained. + * + * If there is a client waiting for the well-known name to appear on the bus, + * you probably want to export your interfaces here. This way the interfaces + * are ready to be used when the client is notified the name has been owned. + * + * @param {Gio.DBusConnection} connection - the connection to a message bus + * @param {string} name - the name that is requested to be owned + */ +function onBusAcquired(connection, name) { + console.log(`${name}: connection acquired`); + dbusConnection = connection; + + serviceInstance = new PaperWMService(); + exportDBusObject("org.github.PaperWM", serviceInstance, DBUS_PATH); +} + +function exportDBusObject(interfaceName, object, dbusPath) { + const xmlPath = DBUS_INTERFACE_DIR + "/" + interfaceName + ".xml"; + const uri = GLib.uri_resolve_relative(import.meta.url, xmlPath, GLib.UriFlags.NONE); + const file = Gio.File.new_for_uri(uri); + const [success, xmlContent, _etag] = file.load_contents(null); + if (!success) { + throw Error("Failed to read dbus interface definition from xml file."); + } + const xmlString = new TextDecoder("utf-8").decode(xmlContent); + + // Create the class instance, then the D-Bus object + const exportedObject = Gio.DBusExportedObject.wrapJSObject(xmlString, object); + + // Assign the exported object to the property the class expects, then export + object._impl = exportedObject; + exportedObject.export(dbusConnection, dbusPath); +} + +/** + * Invoked when the name is acquired. + * + * On the other hand, if you were using something like GDBusObjectManager to + * watch for interfaces, you could export your interfaces here. + * + * @param {Gio.DBusConnection} connection - the connection that acquired the name + * @param {string} name - the name being owned + */ +function onNameAcquired(connection, name) { + // console.log(`${name}: name acquired`); +} + +// find window by id +// global.get_window_actors().find(w => w.get_meta_window().get_id() == the_id) + + +class PaperWMService { + // NOTE: this._impl is set to the exported DBus service before any of the + // methods are called. + + constructor() { + this.undos = new Array(); + + this.signals = new Utils.Signals(); + + // Need to use this signal as space::window-added does not contain + // e.g. wm_class. + this.signals.connect( + Tiling.spaces, "window-first-frame", + (_spaces, metaWindow) => { + const space = Tiling.spaces.spaceOfWindow(metaWindow); + const data = { + wm_class: metaWindow.wm_class, + title: metaWindow.title, + + workspace_index: space.index, + index: space.indexOf(metaWindow), + row: space.rowOf(metaWindow), + floating: Tiling.isFloating(metaWindow), + scratch: Tiling.isScratch(metaWindow), + transient: Tiling.isTransient(metaWindow), + tiled: Tiling.isTiled(metaWindow), + }; + this._impl.emit_signal( + 'WindowAdded', + new GLib.Variant('(s)', [JSON.stringify(data)])); + } + ); + } + + destroy() { + this.signals.destroy(); + } + + // Properties + get DebugTrace() { + if (this._debugTrace === undefined) + return false; + + return this._debugTrace; + } + + set DebugTrace(value) { + if (this._debugTrace === value) + return; + + this._debugTrace = value; + this._impl.emit_property_changed('DebugTrace', + GLib.Variant.new_boolean(this.DebugTrace)); + } + + // Spaces + ListSpaces() { + return [...Tiling.spaces.values()].map(ws => encode_space(ws)); + } + + GetSpace(index) { + return encode_space(get_space(index)); + } + + ActivateSpace(index) { + get_space(index).activate(); + } + + // Actions + ListActions() { + return Keybindings.getAllMutterNames(); + } + + TriggerAction(name) { + const action = Keybindings.byMutterName(name); + const binding = { + get_name: () => name, + get_mask: () => 0, + is_reversed: () => false, + }; + action.keyHandler(global.display, global.display.get_focus_window(), binding); + } + + /** + * Eval `input` as a function. + * + * If the function returns a functions it will be registered and can later be + * called using `UndoEval`. If the function returns something else it will + * be returned as a string. Most likely you want to manually create a string + * to get the correct representation. + * + * Usage (over DBus): + * + * ``` + * const [undoId, _output] = Eval('console.log("hi"); global.x = 1; () => { global.x = undefined; };'); + * UndoEval(undoId); + * + * const [_undoId, output] = Eval('console.log("hi"); global.x = 1; return "something";'); + * // assert output == "something"; + * ``` + */ + Eval(input) { + console.debug(`Service.Eval() invoked with '${input}'`); + + const f = new Function(input); + const undoF = f(Imports); + const undoIndex = this.undos.length; + if (typeof undoF === "function") { + this.undos.push(undoF); + return new GLib.Variant("(is)", [undoIndex, ""]); + } else { + this.undos.push(null); + return new GLib.Variant("(is)", [undoIndex, undoF]); + } + } + + UndoEval(undoId) { + if (undoId >= this.undos.length) { + throw new Error("Invalid undoId."); + } + const undoF = this.undos[undoId]; + if (undoF === null) { + // already used or no undo function registered + throw new Error("Invalid undoId."); + } + undoF(); + this.undos[undoId] = null; + } +} + + +function encode_space(ws) { + console.log(typeof ws); + console.log(ws.constructor.name); + Utils.prettyPrintToLog(ws); + const out = { + index: ws.index, + name: ws.name, + // TODO more useful information + }; + return JSON.stringify(out); +} + +function get_space(index) { + const ws = Tiling.spaces.spaceOfIndex(index); + if (ws === undefined) { + throw new Error(`No space with index ${index}`); + } + return ws; +} diff --git a/dbus/org.github.PaperWM.xml b/dbus/org.github.PaperWM.xml new file mode 100644 index 00000000..e1cbef80 --- /dev/null +++ b/dbus/org.github.PaperWM.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extension.js b/extension.js index 91164d2d..34cbfdcc 100644 --- a/extension.js +++ b/extension.js @@ -8,7 +8,7 @@ import * as Util from 'resource:///org/gnome/shell/misc/util.js'; import { Utils, Settings, Gestures, Keybindings, LiveAltTab, Navigator, - Stackoverlay, Scratch, Workspace, Tiling, Topbar, Patches, App + Stackoverlay, Scratch, Workspace, Tiling, Topbar, Patches, App, DBus } from './imports.js'; import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; @@ -50,7 +50,7 @@ export default class PaperWM extends Extension { modules = [ Utils, Settings, Patches, Gestures, Keybindings, LiveAltTab, Navigator, Stackoverlay, Scratch, - Workspace, Tiling, Topbar, App, + Workspace, Tiling, Topbar, App, DBus ]; #userStylesheet = null; diff --git a/imports.js b/imports.js index 7bf7ec92..b79342f0 100644 --- a/imports.js +++ b/imports.js @@ -16,3 +16,4 @@ export * as Tiling from './tiling.js'; export * as Topbar from './topbar.js'; export * as Utils from './utils.js'; export * as Workspace from './workspace.js'; +export * as DBus from './dbus.js'; diff --git a/keybindings.js b/keybindings.js index 7a396ee6..3b6eeb6a 100644 --- a/keybindings.js +++ b/keybindings.js @@ -275,6 +275,10 @@ export function byMutterName(name) { return nameMap[name]; } +export function getAllMutterNames() { + return Object.keys(nameMap); +} + export function byId(mutterId) { return actionIdMap[mutterId]; } diff --git a/tiling.js b/tiling.js index 2b17221d..7fc78717 100644 --- a/tiling.js +++ b/tiling.js @@ -1081,6 +1081,9 @@ export class Space extends Array { rowOf(metaWindow) { let column = this[this.indexOf(metaWindow)]; + if (column === undefined) { + return -1; + } return column.indexOf(metaWindow); } @@ -2893,6 +2896,7 @@ export const Spaces = class Spaces extends Map { signals.connectOneShot(actor, 'first-frame', () => { allocateClone(metaWindow); insertWindow(metaWindow, { existing: false }); + this.emit("window-first-frame", metaWindow); }); } diff --git a/utils.js b/utils.js index a8d1429d..7d094867 100644 --- a/utils.js +++ b/utils.js @@ -45,12 +45,32 @@ export function print_stacktrace(error) { console.error(`JS ERROR: ${error}\n ${trace.join('\n')}`); } +// taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value +function getCircularReplacer() { + const ancestors = []; + return function (key, value) { + if (typeof value !== "object" || value === null) { + return value; + } + // `this` is the object that value is contained in, + // i.e., its direct parent. + while (ancestors.length > 0 && ancestors.at(-1) !== this) { + ancestors.pop(); + } + if (ancestors.includes(value)) { + return "[Circular]"; + } + ancestors.push(value); + return value; + }; +} + /** * Pretty prints args using JSON.stringify. * @param {...any} arugs */ export function prettyPrintToLog(...args) { - console.log(args.map(v => JSON.stringify(v, null), 2)); + console.log(args.map(v => JSON.stringify(v, getCircularReplacer()), 2)); } export function framestr(rect) {