diff --git a/Makefile b/Makefile index 95ff7ad..fe079cc 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,15 @@ run: python -m sims.main --config ./example-stylix-dev.yaml + +# Talk to the running sims daemon over DBus. +# Usage: make cli list +# make cli finder +# make cli screenrec stop +# make cli ARGS="list --json" # for flags (make eats leading dashes) +ifeq (cli,$(firstword $(MAKECMDGOALS))) + CLI_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) + $(eval $(CLI_ARGS):;@:) +endif + +cli: + @python -m sims.cli $(if $(ARGS),$(ARGS),$(CLI_ARGS)) diff --git a/flake.nix b/flake.nix index 770788b..8559d54 100644 --- a/flake.nix +++ b/flake.nix @@ -32,13 +32,9 @@ { formatter = pkgs.nixfmt-rfc-style; devShells.default = pkgs.callPackage ./nix/shell.nix { inherit pkgs; }; - packages = { + packages = rec { default = pkgs.callPackage ./nix/derivation.nix { inherit (pkgs) lib python3Packages; }; - sims-cli = pkgs.writeShellApplication { - name = "sims-cli"; - runtimeInputs = [ pkgs.dbus ]; - text = builtins.readFile ./scripts/sims-cli.sh; - }; + sims = default; }; apps.default = { type = "app"; diff --git a/nix/derivation.nix b/nix/derivation.nix index b1067a8..13d7d72 100644 --- a/nix/derivation.nix +++ b/nix/derivation.nix @@ -15,6 +15,7 @@ notmuch, khal, emacs, + dbus, ... }: @@ -64,6 +65,8 @@ python3Packages.buildPythonApplication { mkdir -p $out/bin cp scripts/launcher.py $out/bin/sims chmod +x $out/bin/sims + cp scripts/cli_launcher.py $out/bin/sims-cli + chmod +x $out/bin/sims-cli runHook postInstall @@ -71,7 +74,7 @@ python3Packages.buildPythonApplication { preFixup = '' makeWrapperArgs+=("''${gappsWrapperArgs[@]}") - makeWrapperArgs+=(--prefix PATH : ${lib.makeBinPath [ khal notmuch emacs ]}) + makeWrapperArgs+=(--prefix PATH : ${lib.makeBinPath [ khal notmuch emacs dbus ]}) ''; passthru = { diff --git a/scripts/cli_launcher.py b/scripts/cli_launcher.py new file mode 100644 index 0000000..dd04904 --- /dev/null +++ b/scripts/cli_launcher.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +import os +import sys + +script_dir = os.path.dirname(os.path.abspath(__file__)) +site_packages_dir = os.path.join( + script_dir, + os.pardir, + "lib", + f"python{sys.version_info.major}.{sys.version_info.minor}", + "site-packages", +) + +if site_packages_dir not in sys.path: + sys.path.insert(0, site_packages_dir) + +from sims.cli import main + +sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__)) +sys.exit(main()) diff --git a/scripts/sims-cli.sh b/scripts/sims-cli.sh deleted file mode 100755 index 9b44654..0000000 --- a/scripts/sims-cli.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# sims-cli — talk to the running `sims` status bar over DBus. -set -euo pipefail - -DEST=org.Fabric.fabric.sims -OBJ=/org/Fabric/fabric -IFACE=org.Fabric.fabric - -invoke() { - dbus-send --session --print-reply --dest="$DEST" "$OBJ" \ - "$IFACE.InvokeAction" "string:$1" "array:string:" >/dev/null -} - -list_actions() { - dbus-send --session --print-reply --dest="$DEST" "$OBJ" \ - org.freedesktop.DBus.Properties.Get \ - "string:$IFACE" string:Actions -} - -usage() { - cat <<'EOF' >&2 -usage: sims-cli [args] - finder open window finder - apps open application launcher - power open power menu - screenshot open screenshot menu - notmuch-refresh refresh unread mail count - screenrec menu open screenrec menu (auto-detects state) - screenrec start-monitor start recording the focused monitor - screenrec start-region start recording a slurp-selected region - screenrec stop stop active recording - list list registered actions -EOF - exit 2 -} - -case "${1:-}" in - finder) invoke open-finder ;; - apps) invoke open-app-launcher ;; - power) invoke open-power-menu ;; - screenshot) invoke open-screenshot-menu ;; - notmuch-refresh) invoke refresh-notmuch ;; - screenrec) - case "${2:-}" in - menu) invoke open-screenrec-menu ;; - start-monitor) invoke screenrec-start-monitor ;; - start-region) invoke screenrec-start-region ;; - stop) invoke screenrec-stop ;; - *) usage ;; - esac ;; - list) list_actions ;; - *) usage ;; -esac diff --git a/sims/cli.py b/sims/cli.py new file mode 100644 index 0000000..0634963 --- /dev/null +++ b/sims/cli.py @@ -0,0 +1,132 @@ +"""sims-cli — talk to the running sims status bar over DBus.""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Callable + +DEST = "org.Fabric.fabric.sims" +OBJ = "/org/Fabric/fabric" +IFACE = "org.Fabric.fabric" + + +def _dbus_send(*args: str) -> str: + proc = subprocess.run( + ["dbus-send", "--session", "--print-reply", f"--dest={DEST}", OBJ, *args], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + sys.stderr.write(proc.stderr) + sys.exit(proc.returncode) + return proc.stdout + + +def invoke_action(action: str) -> None: + _dbus_send(f"{IFACE}.InvokeAction", f"string:{action}", "array:string:") + + +_ACTION_RE = re.compile(r'dict entry\(\s*string "([^"]+)"') + + +def list_actions() -> list[str]: + out = _dbus_send( + "org.freedesktop.DBus.Properties.Get", + f"string:{IFACE}", + "string:Actions", + ) + return _ACTION_RE.findall(out) + + +@dataclass +class Command: + name: str + help: str + run: Callable[[argparse.Namespace], None] + + +def _action(name: str) -> Callable[[argparse.Namespace], None]: + return lambda _ns: invoke_action(name) + + +COMMANDS: list[Command] = [ + Command("finder", "open window finder", _action("open-finder")), + Command("apps", "open application launcher", _action("open-app-launcher")), + Command("power", "open power menu", _action("open-power-menu")), + Command("screenshot", "open screenshot menu", _action("open-screenshot-menu")), + Command("notmuch-refresh", "refresh unread mail count", _action("refresh-notmuch")), +] + + +def _cmd_screenrec(ns: argparse.Namespace) -> None: + mapping = { + "menu": "open-screenrec-menu", + "start-monitor": "screenrec-start-monitor", + "start-region": "screenrec-start-region", + "stop": "screenrec-stop", + } + invoke_action(mapping[ns.screenrec_cmd]) + + +def _cmd_list(ns: argparse.Namespace) -> None: + actions = list_actions() + if ns.json: + json.dump(actions, sys.stdout, indent=2) + sys.stdout.write("\n") + else: + for a in actions: + print(a) + + +def _cmd_invoke(ns: argparse.Namespace) -> None: + invoke_action(ns.action) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="sims-cli", + description="Talk to the running sims status bar over DBus.", + ) + sub = parser.add_subparsers(dest="cmd", required=True, metavar="COMMAND") + + for cmd in COMMANDS: + p = sub.add_parser(cmd.name, help=cmd.help) + p.set_defaults(func=cmd.run) + + rec = sub.add_parser("screenrec", help="screen recording controls") + rec_sub = rec.add_subparsers(dest="screenrec_cmd", required=True, metavar="ACTION") + for sub_name, sub_help in [ + ("menu", "open screenrec menu (auto-detects state)"), + ("start-monitor", "start recording the focused monitor"), + ("start-region", "start recording a slurp-selected region"), + ("stop", "stop active recording"), + ]: + rec_sub.add_parser(sub_name, help=sub_help) + rec.set_defaults(func=_cmd_screenrec) + + lst = sub.add_parser("list", help="list registered actions") + lst.add_argument("--json", action="store_true", help="emit JSON array") + lst.set_defaults(func=_cmd_list) + + inv = sub.add_parser( + "invoke", + help="invoke a raw action by name (escape hatch for actions without a dedicated subcommand)", + ) + inv.add_argument("action", help="dbus action name, e.g. open-finder") + inv.set_defaults(func=_cmd_invoke) + + return parser + + +def main() -> None: + parser = build_parser() + ns = parser.parse_args() + ns.func(ns) + + +if __name__ == "__main__": + main()