#!/usr/bin/python3
# sg-settingsctl -- the native half of Settings (sg-shell's SystemSettings):
# what a Windows program cannot reach on its own -- the sound server, the
# Bluetooth daemon, the compositor's outputs and colour temperature, the idle
# timers, and apt's view of pending updates. Everything here runs as the
# signed-in user; root's work (the time zone, the computer name, fetching
# updates) goes through sg-admind, never here.
#
# Wine gives a Windows program no pipes to a native one, so the caller passes
# --out FILE and reads the answer from it (as sg-dictate --mics does). The
# last line of every answer is OK or ERROR <kind> <message>; kind is one of
# invalid (exit 2), unsupported (4), notfound (5), failed (1).
#
#   sg-settingsctl sound
#       SINK|SOURCE <name>\t<default yes|no>\t<volume %>\t<muted yes|no>\t<description>
#   sg-settingsctl sound default sink|source NAME
#   sg-settingsctl sound volume sink|source NAME PERCENT      (0-150)
#   sg-settingsctl sound mute sink|source NAME yes|no
#   sg-settingsctl sound chime      the volume chime, on the default output
#   sg-settingsctl sound step up|down [--chime]   the default output 5 points
#       up or down (up unmutes), as the volume keys do: VOLUME <percent>
#   sg-settingsctl sound mute-toggle   the default output: MUTED yes|no
#   sg-settingsctl media           the session's media players (MPRIS)
#       PLAYER <name>\t<Playing|Paused|Stopped>\t<title>\t<artist>
#   sg-settingsctl media play-pause|next|previous|stop|play|pause
#       the media keys: to the one playing, else the one last controlled,
#       else one paused: PLAYER <name>
#   sg-settingsctl bluetooth
#       BLUETOOTH yes|no, POWERED yes|no,
#       DEVICE <mac>\t<connected yes|no>\t<paired yes|no>\t<name>
#   sg-settingsctl bluetooth power on|off
#   sg-settingsctl bluetooth connect|disconnect|remove|pair MAC
#   sg-settingsctl bluetooth scan [SECONDS]            (then lists, as above)
#   sg-settingsctl display
#       OUTPUT <name>\t<WxH@Hz>\t<scale>\t<description>
#       MODE <name>\t<WxH@Hz>\t<current yes|no>\t<preferred yes|no>
#   sg-settingsctl display mode OUTPUT WxH[@Hz]
#   sg-settingsctl display scale OUTPUT SCALE                  (1-3, steps of .25)
#       (both kept for that monitor, in the user's settings.json)
#   sg-settingsctl display restore     the kept resolution and scale, at session start
#       RESTORED <name>\tmode|scale\t<value>
#   sg-settingsctl nightlight [on|off] [--temp K]
#       NIGHTLIGHT on|off\t<K>\t<available yes|no>\t<running yes|no>
#   sg-settingsctl power [--screen MIN] [--sleep MIN]         (0 = never)
#       POWER <screen min>\t<sleep min>\t<available yes|no>\t<running yes|no>
#   sg-settingsctl sleep-caps
#       CAN suspend|hibernate <logind: yes|no|challenge|na>
#   sg-settingsctl sleep [suspend|hibernate]    lock, then systemctl suspend|hibernate
#   sg-settingsctl shutdown poweroff|reboot   when the session's programs have closed
#                                     (wineboot --end-session), systemctl poweroff|reboot
#   sg-settingsctl updates
#       UPDATE <package>\t<installed version>\t<new version>, STAGED yes|no,
#       DOWNLOAD <package>\t<bytes>\t<bytes downloaded>, DOWNLOADING yes|no
#   sg-settingsctl updates progress   only the DOWNLOAD/DOWNLOADING/STAGED lines (fast)
#   sg-settingsctl session-start      apply night light and the idle timers
#                                     (swayidle also locks before any sleep)
#   sg-settingsctl look [dark|light RRGGBB]   Linux programs follow the user's mode
#       LOOK <dark|light>\t<RRGGBB>          and accent: GTK 3/4's settings.ini and
#                                     gtk.css (libadwaita's accent), the
#                                     desktop's color-scheme and accent-color
#                                     (gsettings; the portal tells GTK 4, Qt 6
#                                     and browsers); kept, and applied again
#                                     at session start
#   sg-settingsctl lockscreen
#       LOCKSCREEN <picture published yes|no>\t<shown on sign-in yes|no>
#   sg-settingsctl lockscreen picture FILE | default
#   sg-settingsctl lockscreen signin yes|no
#   sg-settingsctl printers          CUPS's queues, for Settings' Printers & scanners
#       QUEUE <name>\t<make and model>\t<device-uri>\t<idle|printing|stopped>\t<description>
#       (the Windows printers are winspool's; these are the same printers'
#       queues Linux programs print to, grouped under them by sg-shell)
#
# Settings are the user's: ~/.config/stained-glass/settings.json.
#
# Copyright (C) 2026 Stained Glass OS contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

import json
import os
import re
import shutil
import signal
import subprocess
import sys

EXIT = {"invalid": 2, "unsupported": 4, "notfound": 5, "failed": 1}


class Fail(Exception):
    def __init__(self, kind, msg):
        super().__init__(msg)
        self.kind = kind


# ---- plumbing ---------------------------------------------------------------------------

def tool(name):
    """The tool's path: SG_SETTINGSCTL_TOOLS first (the gate's stand-ins), then
    PATH, then the usual places -- a native program started by Wine may carry
    the Windows PATH, not ours."""
    for d in filter(None, [os.environ.get("SG_SETTINGSCTL_TOOLS")]):
        p = os.path.join(d, name)
        if os.access(p, os.X_OK):
            return p
    found = shutil.which(name)
    if found:
        return found
    for d in ("/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin", "/usr/libexec/stained-glass"):
        p = os.path.join(d, name)
        if os.access(p, os.X_OK):
            return p
    return None


def run(argv, timeout=15, check=True):
    exe = tool(argv[0])
    if not exe:
        raise Fail("unsupported", "%s is not installed" % argv[0])
    env = dict(os.environ, LC_ALL="C", LANG="C")
    try:
        r = subprocess.run([exe] + argv[1:], capture_output=True, text=True, timeout=timeout,
                           env=env, stdin=subprocess.DEVNULL)
    except subprocess.TimeoutExpired:
        raise Fail("failed", "%s did not answer" % argv[0])
    except OSError as e:
        raise Fail("failed", "%s: %s" % (argv[0], e))
    if check and r.returncode != 0:
        err = (r.stderr or r.stdout).strip().splitlines()
        raise Fail("failed", "%s: %s" % (argv[0], err[-1] if err else "exit %d" % r.returncode))
    return r.stdout


def config_path():
    base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
    return os.path.join(base, "stained-glass", "settings.json")


def load_config():
    try:
        with open(config_path(), encoding="utf-8") as f:
            c = json.load(f)
            return c if isinstance(c, dict) else {}
    except (OSError, ValueError):
        return {}


def save_config(c):
    path = config_path()
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(c, f, indent=1, sort_keys=True)
    os.replace(tmp, path)


def runtime_dir():
    d = os.environ.get("XDG_RUNTIME_DIR")
    if d and os.path.isdir(d):
        return d
    d = os.path.join("/tmp", "sg-settings-%d" % os.getuid())
    os.makedirs(d, mode=0o700, exist_ok=True)
    return d


def pid_alive(name):
    """The pid in <runtime>/<name>.pid if that process is still ours and alive."""
    try:
        with open(os.path.join(runtime_dir(), name + ".pid")) as f:
            pid = int(f.read().strip())
        os.kill(pid, 0)
        with open("/proc/%d/status" % pid) as f:
            for line in f:
                if line.startswith("Uid:") and int(line.split()[1]) != os.getuid():
                    return 0
        return pid
    except (OSError, ValueError):
        return 0


def stop(name):
    pid = pid_alive(name)
    if pid:
        # the helper's whole group (start() makes it a session of its own):
        # device-sounds-run's "udevadm monitor" outlived it before, one more
        # each time the session started again
        try:
            if os.environ.get("SG_MUTANT_STOP_LEAVES_CHILDREN"):
                raise OSError
            os.killpg(pid, signal.SIGTERM)
        except OSError:
            try:
                os.kill(pid, signal.SIGTERM)
            except OSError:
                pass
    try:
        os.unlink(os.path.join(runtime_dir(), name + ".pid"))
    except OSError:
        pass


def start(name, argv):
    """Start a long-running helper, detached, and remember its pid."""
    stop(name)
    exe = tool(argv[0])
    if not exe:
        raise Fail("unsupported", "%s is not installed" % argv[0])
    p = subprocess.Popen([exe] + argv[1:], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                         stderr=subprocess.DEVNULL, start_new_session=True)
    with open(os.path.join(runtime_dir(), name + ".pid"), "w") as f:
        f.write("%d\n" % p.pid)


def yn(v):
    return "yes" if v else "no"


def one_of(v, allowed, what):
    if v not in allowed:
        raise Fail("invalid", "%s must be one of %s" % (what, ", ".join(allowed)))
    return v


def integer(v, lo, hi, what):
    if not re.fullmatch(r"\d{1,5}", v or "") or not lo <= int(v) <= hi:
        raise Fail("invalid", "%s must be a number from %d to %d" % (what, lo, hi))
    return int(v)


def clean(s):
    return re.sub(r"[\t\r\n]", " ", str(s))


# ---- sound (PipeWire, through pactl) ------------------------------------------------------

DEVICE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}")


def device_name(v):
    if not DEVICE_NAME.fullmatch(v or ""):
        raise Fail("invalid", "not a device name")
    return v


def percent_of(dev):
    vols = []
    for ch in (dev.get("volume") or {}).values():
        m = re.match(r"\s*(\d+)%", str(ch.get("value_percent", "")))
        if m:
            vols.append(int(m.group(1)))
    return max(vols) if vols else 0


def sound(args):
    if not args:
        out = []
        for kind, what in (("SINK", "sinks"), ("SOURCE", "sources")):
            try:
                devs = json.loads(run(["pactl", "--format=json", "list", what]) or "[]")
            except ValueError:
                raise Fail("failed", "pactl gave an answer that is not JSON")
            default = run(["pactl", "get-default-" + what[:-1]], check=False).strip()
            for d in devs:
                name = d.get("name", "")
                props = d.get("properties") or {}
                # an output's monitor is not a microphone
                if kind == "SOURCE" and (props.get("device.class") == "monitor" or name.endswith(".monitor")):
                    continue
                out.append("%s %s\t%s\t%d\t%s\t%s" % (kind, clean(name), yn(name == default), percent_of(d),
                                                      yn(d.get("mute")), clean(d.get("description") or name)))
        return out
    verb = args[0]
    if verb == "default" and len(args) == 3:
        kind = one_of(args[1], ("sink", "source"), "the device kind")
        run(["pactl", "set-default-" + kind, device_name(args[2])])
    elif verb == "volume" and len(args) == 4:
        kind = one_of(args[1], ("sink", "source"), "the device kind")
        pct = integer(args[3], 0, 150, "the volume")
        run(["pactl", "set-%s-volume" % kind, device_name(args[2]), "%d%%" % pct])
    elif verb == "mute" and len(args) == 4:
        kind = one_of(args[1], ("sink", "source"), "the device kind")
        on = one_of(args[3], ("yes", "no"), "mute")
        run(["pactl", "set-%s-mute" % kind, device_name(args[2]), "1" if on == "yes" else "0"])
    elif verb == "chime" and len(args) == 1:
        chime()
    elif verb == "step" and len(args) in (2, 3):
        up = one_of(args[1], ("up", "down"), "the direction") == "up"
        if len(args) == 3 and args[2] != "--chime":
            raise Fail("invalid", "usage: sound step up|down [--chime]")
        sink = run(["pactl", "get-default-sink"]).strip()
        try:
            devs = json.loads(run(["pactl", "--format=json", "list", "sinks"]) or "[]")
        except ValueError:
            raise Fail("failed", "pactl gave an answer that is not JSON")
        dev = next((d for d in devs if d.get("name") == sink), None)
        if dev is None:
            raise Fail("notfound", "no sound output")
        now = percent_of(dev)
        # to the next multiple of 5 up or down (37 -> 40 or 35)
        pct = min(100, (now // STEP + 1) * STEP) if up else max(0, (now - 1) // STEP * STEP)
        run(["pactl", "set-sink-volume", sink, "%d%%" % pct])
        if up and dev.get("mute"):
            run(["pactl", "set-sink-mute", sink, "0"])
        if len(args) == 3 and (up or not dev.get("mute")):
            chime()
        return ["VOLUME %d" % pct]
    elif verb == "mute-toggle" and len(args) == 1:
        sink = run(["pactl", "get-default-sink"]).strip()
        run(["pactl", "set-sink-mute", sink, "toggle"])
        muted = run(["pactl", "get-sink-mute", sink], check=False).strip().endswith("yes")
        return ["MUTED %s" % yn(muted)]
    else:
        raise Fail("invalid", "usage: sound [default|volume|mute|chime|step|mute-toggle ...]")
    return []


STEP = 5
CHIME = os.environ.get("SG_CHIME", "/usr/share/sounds/stained-glass/volume-change.wav")


def chime():
    """The volume chime, on the default output, not waited for: at the new
    volume, so the person hears where it is (David 2026-10-02)."""
    play(CHIME)


def play(path):
    """A sound on the default output, not waited for."""
    if not os.path.exists(path):
        return
    for player in (["pw-play", "--media-role", "Notification"], ["paplay"], ["aplay", "-q"]):
        exe = tool(player[0])
        if exe:
            subprocess.Popen([exe] + player[1:] + [path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                             stderr=subprocess.DEVNULL, start_new_session=True)
            return


# ---- the device sounds (David 2026-10-07: "a sound effect on device attach, like
# Windows"): a USB device plugged in or taken out, as udev tells it; one sound
# per device (not per interface), and none while they are turned off. ----------

SOUNDS = os.environ.get("SG_SOUNDS_DIR") or "/usr/share/sounds/stained-glass"


def devsounds_on(c):
    return (c.get("devicesounds") or {}).get("on", True)


def device_sounds(args):
    """device-sounds: DEVICESOUNDS on|off; device-sounds on|off."""
    c = load_config()
    if not args:
        return ["DEVICESOUNDS %s" % ("on" if devsounds_on(c) else "off")]
    if len(args) == 1:
        one_of(args[0], ("on", "off"), "device sounds")
        c["devicesounds"] = {"on": args[0] == "on"}
        save_config(c)
        return []
    raise Fail("invalid", "usage: device-sounds [on|off]")


def device_sounds_run(args):
    """Runs for the session: udev's USB events, a sound for each device that
    comes or goes."""
    exe = tool("udevadm")
    if not exe:
        raise Fail("unsupported", "udevadm is not installed")
    p = subprocess.Popen([exe, "monitor", "--udev", "--subsystem-match=usb", "--property"],
                         stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, text=True)
    event = {}
    for line in p.stdout:
        line = line.rstrip("\n")
        if "=" in line and not line.startswith(" "):
            k, v = line.split("=", 1)
            event[k] = v
            continue
        if line.strip() or not event:
            continue
        # a whole event (a blank line ends it)
        action, kind = event.get("ACTION"), event.get("DEVTYPE")
        event = {}
        if kind != "usb_device" and os.environ.get("SG_MUTANT_DEVSOUND_EVERY_INTERFACE") != "1":
            continue
        if action not in ("add", "remove") or not devsounds_on(load_config()):
            continue
        play(os.path.join(SOUNDS, "device-connect.wav" if action == "add" else "device-disconnect.wav"))
    return []


# ---- Bluetooth (bluez, through bluetoothctl) -----------------------------------------------

MAC = re.compile(r"[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}")


def bt_adapters():
    """The kernel's Bluetooth adapters (hci0, ...)."""
    d = os.environ.get("SG_SETTINGSCTL_BTSYS") or "/sys/class/bluetooth"
    try:
        return [n for n in os.listdir(d) if n.startswith("hci")]
    except OSError:
        return []


def bluetooth(args):
    if not tool("bluetoothctl"):
        if not args:
            return ["BLUETOOTH no", "POWERED no"]
        raise Fail("unsupported", "Bluetooth is not installed")
    if not args:
        # No adapter: say so at once. bluetoothctl would wait for a bluetoothd
        # that never starts without one (its unit needs /sys/class/bluetooth)
        # -- 15 s with Settings' Devices page frozen.
        if not bt_adapters():
            return ["BLUETOOTH no", "POWERED no"]
        show = run(["bluetoothctl", "show"], timeout=6, check=False)
        if "Controller" not in show:
            return ["BLUETOOTH no", "POWERED no"]
        out = ["BLUETOOTH yes", "POWERED %s" % yn(re.search(r"Powered:\s*yes", show))]
        for line in run(["bluetoothctl", "devices"], check=False).splitlines():
            m = re.match(r"Device (\S+) (.*)", line.strip())
            if not m or not MAC.fullmatch(m.group(1)):
                continue
            info = run(["bluetoothctl", "info", m.group(1)], check=False)
            out.append("DEVICE %s\t%s\t%s\t%s" % (m.group(1), yn(re.search(r"Connected:\s*yes", info)),
                                                   yn(re.search(r"Paired:\s*yes", info)), clean(m.group(2))))
        return out
    if args[0] == "power" and len(args) == 2:
        run(["bluetoothctl", "power", one_of(args[1], ("on", "off"), "power")])
    elif args[0] == "scan" and len(args) <= 2:
        # look for devices for a while, then list everything bluez now knows
        secs = integer(args[1], 1, 30, "seconds") if len(args) == 2 else 8
        run(["bluetoothctl", "--timeout", str(secs), "scan", "on"], timeout=secs + 15, check=False)
        return bluetooth([])
    elif args[0] == "pair" and len(args) == 2:
        if not MAC.fullmatch(args[1]):
            raise Fail("invalid", "not a device address")
        run(["bluetoothctl", "pair", args[1]], timeout=60)
        run(["bluetoothctl", "trust", args[1]], check=False)
        run(["bluetoothctl", "connect", args[1]], timeout=30, check=False)
    elif args[0] in ("connect", "disconnect", "remove") and len(args) == 2:
        if not MAC.fullmatch(args[1]):
            raise Fail("invalid", "not a device address")
        run(["bluetoothctl", args[0], args[1]], timeout=30)
    else:
        raise Fail("invalid", "usage: bluetooth [power on|off | scan [SECONDS] | pair|connect|disconnect|remove MAC]")
    return []


# ---- display (the compositor's outputs, through wlr-randr) ----------------------------------

OUTPUT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
MODE = re.compile(r"(\d{3,5})x(\d{3,5})(?:@(\d{1,3}(?:\.\d{1,3})?))?")


def fmt_mode(m):
    return "%dx%d@%s" % (m.get("width", 0), m.get("height", 0),
                         ("%.2f" % m["refresh"]).rstrip("0").rstrip(".") if m.get("refresh") else "0")


def outputs():
    if not os.environ.get("WAYLAND_DISPLAY"):
        raise Fail("unsupported", "the display is not the compositor's")
    try:
        return json.loads(run(["wlr-randr", "--json"]) or "[]")
    except ValueError:
        raise Fail("failed", "wlr-randr gave an answer that is not JSON")


def display(args):
    if not args:
        out = []
        for o in outputs():
            name = o.get("name", "")
            cur = next((m for m in o.get("modes", []) if m.get("current")), {})
            out.append("OUTPUT %s\t%s\t%s\t%s" % (clean(name), fmt_mode(cur) if cur else "off",
                                                  o.get("scale", 1), clean(o.get("description") or name)))
            for m in o.get("modes", []):
                out.append("MODE %s\t%s\t%s\t%s" % (clean(name), fmt_mode(m), yn(m.get("current")),
                                                    yn(m.get("preferred"))))
            out.append("TRANSFORM %s\t%s" % (clean(name), transform_of(o)))
        return out
    if len(args) == 3 and args[0] in ("mode", "scale", "transform"):
        if not OUTPUT_NAME.fullmatch(args[1]):
            raise Fail("invalid", "not an output name")
        if args[1] not in [o.get("name") for o in outputs()]:
            raise Fail("notfound", "no output %s" % args[1])
        if args[0] == "transform":
            one_of(args[2], TRANSFORMS, "orientation")
            run(["wlr-randr", "--output", args[1], "--transform", args[2]])
        elif args[0] == "mode":
            m = MODE.fullmatch(args[2])
            if not m:
                raise Fail("invalid", "a mode is WIDTHxHEIGHT[@HZ]")
            o = next(o for o in outputs() if o.get("name") == args[1])
            run(["wlr-randr", "--output", args[1], "--mode", exact_mode(o, m) or args[2]])
        else:
            if not re.fullmatch(r"[123](\.(0|00|25|5|50|75))?", args[2]):
                raise Fail("invalid", "the scale is 1 to 3 in steps of 0.25")
            run(["wlr-randr", "--output", args[1], "--scale", args[2]])
        # kept for this monitor, as Windows keeps each monitor's settings, and
        # put back when the next session starts (display restore)
        o = next(o for o in outputs() if o.get("name") == args[1])
        c = load_config()
        c.setdefault("display", {}).setdefault(monitor_key(o), {})[args[0]] = args[2]
        save_config(c)
        return []
    if args == ["restore"]:
        return display_restore()
    raise Fail("invalid", "usage: display [mode OUTPUT WxH[@HZ] | scale OUTPUT S | transform OUTPUT T | restore]")


def exact_mode(o, m):
    """The output's own spelling of a mode (WxH@74.992): the list shows 74.99,
    and wlr-randr refuses a rate that is not exactly the monitor's ("unknown
    mode" -- Settings could not pick such a mode). None when not offered."""
    w, h = int(m.group(1)), int(m.group(2))
    hz = float(m.group(3)) if m.group(3) else None
    fits = [x for x in o.get("modes", []) if x.get("width") == w and x.get("height") == h
            and (hz is None or abs(float(x.get("refresh") or 0) - hz) < 0.5)]
    if not fits:
        return None
    best = min(fits, key=lambda x: abs(float(x.get("refresh") or 0) - (hz or float(x.get("refresh") or 0))))
    return "%dx%d@%.3f" % (w, h, float(best.get("refresh") or 0)) if best.get("refresh") else "%dx%d" % (w, h)


def monitor_key(o):
    """A monitor, not the port it is on: make, model and serial when the
    compositor knows them, else the output's name."""
    parts = [str(o.get(k) or "").strip() for k in ("make", "model", "serial")]
    return "/".join(parts) if any(parts) else "output:%s" % o.get("name", "")


def display_restore():
    """The resolution and scale the user chose for each connected monitor,
    where the monitor still offers them (a session starts at the monitor's
    preferred mode otherwise: David, 1080p set in Settings was gone after a
    reboot)."""
    saved = load_config().get("display", {})
    out = []
    for o in outputs():
        want = saved.get(monitor_key(o))
        if not isinstance(want, dict):
            continue
        name = o.get("name", "")
        m = MODE.fullmatch(str(want.get("mode", "")))
        if m:
            w, h = int(m.group(1)), int(m.group(2))
            hz = float(m.group(3)) if m.group(3) else None
            fits = [x for x in o.get("modes", []) if x.get("width") == w and x.get("height") == h
                    and (hz is None or abs(float(x.get("refresh") or 0) - hz) < 0.5)]
            cur = next((x for x in o.get("modes", []) if x.get("current")), {})
            if fits and not (cur.get("width") == w and cur.get("height") == h
                             and (hz is None or abs(float(cur.get("refresh") or 0) - hz) < 0.5)):
                run(["wlr-randr", "--output", name, "--mode", exact_mode(o, m) or want["mode"]])
                out.append("RESTORED %s\tmode\t%s" % (clean(name), want["mode"]))
        s = str(want.get("scale", ""))
        if re.fullmatch(r"[123](\.(0|00|25|5|50|75))?", s) and abs(float(o.get("scale") or 1) - float(s)) > 0.01:
            run(["wlr-randr", "--output", name, "--scale", s])
            out.append("RESTORED %s\tscale\t%s" % (clean(name), s))
        t = str(want.get("transform", ""))
        if t in TRANSFORMS and transform_of(o) != t:
            run(["wlr-randr", "--output", name, "--transform", t])
            out.append("RESTORED %s\ttransform\t%s" % (clean(name), t))
    return out


# ---- screen rotation (David 2026-10-07: "a tablet can auto rotate, or turn it off
# in Settings"): the compositor's output transform, which the touch screen and the
# pen follow (they are mapped to the output) and Wine's desktop follows as it
# follows any new size (sg_desktop_follow). The accelerometer comes through
# iio-sensor-proxy (monitor-sensor); "autorotate" turns the built-in screen
# while the rotation lock is off. -----------------------------------------------------

TRANSFORMS = ("normal", "90", "180", "270")
# iio-sensor-proxy's orientation -> the transform that keeps the picture upright,
# as GNOME maps them: left side up (turned clockwise) is Windows' Portrait
ORIENTATION = {"normal": "normal", "left-up": "90", "bottom-up": "180", "right-up": "270"}
PANEL = re.compile(r"(eDP|DSI|LVDS)-")


def transform_of(o):
    t = str(o.get("transform") or "normal")
    return t if t in TRANSFORMS else "normal"


def panel_output(outs):
    """The built-in screen, which turns with the PC: eDP, DSI or LVDS, or the
    only output there is."""
    for o in outs:
        if PANEL.match(o.get("name", "")):
            return o
    return outs[0] if len(outs) == 1 else None


def has_accelerometer():
    try:
        out = run(["busctl", "get-property", "--system", "net.hadess.SensorProxy", "/net/hadess/SensorProxy",
                   "net.hadess.SensorProxy", "HasAccelerometer"], timeout=5)
    except Fail:
        return False
    return out.strip() == "b true"


def rotation_locked(c):
    return bool((c.get("rotation") or {}).get("lock"))


def orientation_file():
    return os.path.join(runtime_dir(), "sg-orientation")


def apply_orientation(orient):
    """Turn the built-in screen to keep the picture upright."""
    if orient not in ORIENTATION:
        return
    o = panel_output(outputs())
    if o and transform_of(o) != ORIENTATION[orient]:
        run(["wlr-randr", "--output", o["name"], "--transform", ORIENTATION[orient]])


def rotation(args):
    """rotation: ROTATION lock sensor; rotation lock yes|no."""
    c = load_config()
    if not args:
        return ["ROTATION %s\t%s" % (yn(rotation_locked(c)), yn(has_accelerometer()))]
    if len(args) == 2 and args[0] == "lock":
        one_of(args[1], ("yes", "no"), "lock")
        c.setdefault("rotation", {})["lock"] = args[1] == "yes"
        save_config(c)
        if args[1] == "no":
            # unlocked: the screen takes the way the PC is held now
            try:
                with open(orientation_file()) as f:
                    apply_orientation(f.read().strip())
            except OSError:
                pass
        return []
    raise Fail("invalid", "usage: rotation [lock yes|no]")


ORIENT_LINE = re.compile(r"orientation(?: changed)?: ([a-z-]+)")


def autorotate(args):
    """Runs for the session: monitor-sensor's orientation, kept for an unlock,
    turns the built-in screen while the rotation lock is off."""
    exe = tool("monitor-sensor")
    if not exe:
        raise Fail("unsupported", "monitor-sensor is not installed")
    p = subprocess.Popen([exe, "--accel"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                         stdin=subprocess.DEVNULL, text=True)
    for line in p.stdout:
        m = ORIENT_LINE.search(line)
        if not m or m.group(1) not in ORIENTATION:
            continue
        try:
            with open(orientation_file() + ".tmp", "w") as f:
                f.write(m.group(1) + "\n")
            os.replace(orientation_file() + ".tmp", orientation_file())
        except OSError:
            pass
        if not rotation_locked(load_config()) or os.environ.get("SG_MUTANT_ROTATION_IGNORES_LOCK") == "1":
            try:
                apply_orientation(m.group(1))
            except Fail:
                pass
    return []


def start_autorotate():
    if tool("monitor-sensor") and has_accelerometer():
        start("sg-autorotate", [os.path.abspath(sys.argv[0]), "autorotate"])


# ---- night light (the compositor's gamma, through wlsunset) --------------------------------

def nightlight_state(c):
    n = c.get("nightlight") or {}
    return bool(n.get("on")), int(n.get("temp", 4000))


def apply_nightlight(c):
    on, temp = nightlight_state(c)
    if on and os.environ.get("WAYLAND_DISPLAY") and tool("wlsunset"):
        # a fixed temperature: the same low and high, day and night
        start("sg-nightlight", ["wlsunset", "-t", str(temp), "-T", str(temp + 1)])
    else:
        stop("sg-nightlight")


def nightlight(args):
    c = load_config()
    on, temp = nightlight_state(c)
    i = 0
    changed = False
    while i < len(args):
        a = args[i]
        if a in ("on", "off"):
            on = a == "on"
            changed = True
        elif a == "--temp" and i + 1 < len(args):
            temp = integer(args[i + 1], 1900, 6500, "the colour temperature")
            changed = True
            i += 1
        else:
            raise Fail("invalid", "usage: nightlight [on|off] [--temp K]")
        i += 1
    if changed:
        c["nightlight"] = {"on": on, "temp": temp}
        save_config(c)
        apply_nightlight(c)
    avail = bool(tool("wlsunset")) and bool(os.environ.get("WAYLAND_DISPLAY"))
    return ["NIGHTLIGHT %s\t%d\t%s\t%s" % ("on" if on else "off", temp, yn(avail), yn(pid_alive("sg-nightlight")))]


# ---- power and sleep (the compositor's idle notifications, through swayidle) ----------------

def power_state(c):
    p = c.get("power") or {}
    return int(p.get("screen", 10)), int(p.get("sleep", 0))


def autologon_account():
    """This account signs in automatically when the PC starts (Settings >
    Accounts > Kiosk; sg-admind keeps it in /etc/stained-glass/autologon.conf)."""
    try:
        import pwd
        with open(os.environ.get("SG_AUTOLOGON_CONF", "/etc/stained-glass/autologon.conf")) as f:
            for line in f:
                if line.startswith("user="):
                    return line.strip()[5:] == pwd.getpwuid(os.getuid()).pw_name
    except (OSError, KeyError):
        pass
    return False


def lock_command():
    """The command that locks this session (sg-lockctl to the compositor's
    control socket), or None outside a Stained Glass session -- and for the
    account that signs in automatically: anyone who turns the PC on is in it
    anyway, and a kiosk tablet must not wake to a password nobody there
    knows (its screen still turns off and the PC still sleeps)."""
    if autologon_account() and os.environ.get("SG_MUTANT_AUTOLOGON_LOCKS") != "1":
        return None
    lockctl = tool("sg-lockctl")
    if lockctl and os.environ.get("SG_LOCK_CONTROL"):
        return "%s LOCK" % lockctl
    return None


def apply_power(c):
    """swayidle runs for the whole session when it can: the screen and sleep
    timeouts Settings chose (the screen going off locks first), and --
    whatever they are -- the lock before any
    sleep (lid, Start's Sleep, the timer), since Windows asks for the password
    when the PC wakes. -w makes logind wait for the lock before sleeping."""
    screen, sleep = power_state(c)
    if not os.environ.get("WAYLAND_DISPLAY") or not tool("swayidle"):
        stop("sg-idle")
        return
    argv = ["swayidle", "-w"]
    lock = lock_command()
    if screen and tool("wlopm"):
        # the screen going dark locks the session first, so that waking it
        # asks for the password (David 2026-10-01: a laptop's display went
        # black and was not locked)
        off = "wlopm --off '*'"
        if lock:
            off = "%s; %s" % (lock, off)
        argv += ["timeout", str(screen * 60), off, "resume", "wlopm --on '*'"]
    if sleep:
        argv += ["timeout", str(sleep * 60), "systemctl suspend"]
    if lock:
        argv += ["before-sleep", lock]
        if tool("wlopm"):
            argv += ["after-resume", "wlopm --on '*'"]
    if len(argv) > 2:
        start("sg-idle", argv)
    else:
        stop("sg-idle")


def power(args):
    c = load_config()
    screen, sleep = power_state(c)
    changed = False
    i = 0
    while i < len(args):
        if args[i] in ("--screen", "--sleep") and i + 1 < len(args):
            v = integer(args[i + 1], 0, 1440, "minutes")
            if args[i] == "--screen":
                screen = v
            else:
                sleep = v
            changed = True
            i += 2
        else:
            raise Fail("invalid", "usage: power [--screen MIN] [--sleep MIN]")
    if changed:
        c["power"] = {"screen": screen, "sleep": sleep}
        save_config(c)
        apply_power(c)
    avail = bool(tool("swayidle")) and bool(os.environ.get("WAYLAND_DISPLAY"))
    return ["POWER %d\t%d\t%s\t%s" % (screen, sleep, yn(avail), yn(pid_alive("sg-idle")))]


# ---- sleep and hibernate (logind, which asks polkit) ------------------------------------

def logind_can(what):
    """logind's answer to Can<What>: yes, no, challenge (polkit would ask for a
    password -- no agent runs in our session, so Start treats it as no) or na."""
    text = run(["busctl", "call", "org.freedesktop.login1", "/org/freedesktop/login1",
                "org.freedesktop.login1.Manager", "Can" + what], timeout=10, check=False)
    m = re.match(r's\s+"(\w+)"', text.strip())
    return m.group(1) if m else "na"


def sleep_caps(args):
    if args:
        raise Fail("invalid", "usage: sleep-caps")
    return ["CAN suspend %s" % logind_can("Suspend"), "CAN hibernate %s" % logind_can("Hibernate")]


def sleep_now(args):
    """Start's Sleep/Hibernate: lock first, then ask logind (through systemctl;
    polkit decides -- an inactive or remote session is refused)."""
    if len(args) > 1 or (args and args[0] not in ("suspend", "hibernate")):
        raise Fail("invalid", "usage: sleep [suspend|hibernate]")
    verb = args[0] if args else "suspend"
    lock = lock_command()
    if lock:
        run(lock.split(" "), timeout=10, check=False)
    run(["systemctl", verb], timeout=30)
    return []


# ---- the lock screen's picture (published for the lock service; see sg-lockd) ------------------
#
# The lock screen is drawn by the machine account, which cannot read the
# user's files and must not read a path the user names. So the user's choice
# is copied, as the user, into a drop directory (1733: anyone may create, no
# one may list or touch another's file): <user> the picture, <user>.signin
# "1"/"0". sg-lockd re-checks everything (owner, type, size) before using it.

LOCKPIC_MAX = 32 << 20


def lockpic_dir():
    return os.environ.get("SG_LOCKSCREEN_DIR") or "/var/lib/stained-glass/lockscreen"


def is_picture(head):
    return (head[:3] == b"\xff\xd8\xff" or head[:8] == b"\x89PNG\r\n\x1a\n" or head[:2] == b"BM"
            or head[:6] in (b"GIF87a", b"GIF89a"))


def publish(name, data):
    import tempfile
    d = lockpic_dir()
    fd, tmp = tempfile.mkstemp(dir=d, prefix="." + name + ".")
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
        os.chmod(tmp, 0o644)
        os.replace(tmp, os.path.join(d, name))
    except OSError as e:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise Fail("failed", "could not publish the lock screen picture: %s" % e.strerror)


def lockscreen(args):
    import pwd
    user = pwd.getpwuid(os.getuid()).pw_name
    d = lockpic_dir()
    if not os.path.isdir(d):
        raise Fail("unsupported", "no lock screen directory (%s)" % d)
    if len(args) == 2 and args[0] == "picture" and args[1] == "default":
        try:
            os.unlink(os.path.join(d, user))
        except FileNotFoundError:
            pass
        except OSError as e:
            raise Fail("failed", e.strerror)
    elif len(args) == 2 and args[0] == "picture":
        try:
            fd = os.open(args[1], os.O_RDONLY | os.O_NONBLOCK)
        except OSError:
            raise Fail("notfound", "cannot open %s" % args[1])
        with os.fdopen(fd, "rb") as f:
            st = os.fstat(f.fileno())
            import stat
            if not stat.S_ISREG(st.st_mode) or st.st_size <= 0 or st.st_size > LOCKPIC_MAX:
                raise Fail("invalid", "not a picture file of at most 32 MB")
            data = f.read(LOCKPIC_MAX + 1)
        if len(data) > LOCKPIC_MAX or not is_picture(data[:8]):
            raise Fail("invalid", "not a JPEG, PNG, BMP or GIF picture")
        publish(user, data)
    elif len(args) == 2 and args[0] == "signin":
        one_of(args[1], ("yes", "no"), "signin")
        publish(user + ".signin", b"1" if args[1] == "yes" else b"0")
    elif args:
        raise Fail("invalid", "usage: lockscreen [picture FILE|default] [signin yes|no]")
    signin = True
    try:
        with open(os.path.join(d, user + ".signin"), "rb") as f:
            signin = f.read(1) != b"0"
    except OSError:
        pass
    return ["LOCKSCREEN %s\t%s" % (yn(os.path.isfile(os.path.join(d, user))), yn(signin))]


# ---- shut down and restart (ExitWindowsEx, wine-sg 0241) ------------------------------------

def ending_sessions():
    """This user's `wineboot --end-session` processes: the Windows side of a
    shut down, closing the session's programs (they may ask to save)."""
    me, found = os.getuid(), []
    for pid in os.listdir("/proc"):
        if not pid.isdigit():
            continue
        try:
            if os.stat("/proc/" + pid).st_uid != me:
                continue
            with open("/proc/%s/cmdline" % pid, "rb") as f:
                argv = f.read().split(b"\0")
        except OSError:
            continue
        # Wine shows the Windows path as argv[0]: ...\wineboot.exe
        exe = argv[0].replace(b"\\", b"/").rsplit(b"/", 1)[-1].lower()
        if exe == b"wineboot.exe" and b"--end-session" in argv[1:]:
            found.append(int(pid))
    return found


def shutdown_now(args):
    """Start's Shut down / Restart (any program's ExitWindowsEx): wait while the
    session's programs close -- wineboot --end-session, at most SG_SHUTDOWN_WAIT
    seconds -- then ask logind through systemctl. Polkit decides; sg-session's
    rule lets a signed-in local user shut down, as Windows lets any user shut
    down a workstation."""
    import time
    if len(args) != 1 or args[0] not in ("poweroff", "reboot"):
        raise Fail("invalid", "usage: shutdown poweroff|reboot")
    limit = float(os.environ.get("SG_SHUTDOWN_WAIT", "30"))
    start = time.time()
    time.sleep(0.5)                  # ExitWindowsEx starts wineboot first; let it show up
    while ending_sessions() and time.time() - start < limit:
        time.sleep(0.25)
    # --no-wall: no "The system will reboot now!" on the console under the
    # shutdown splash (polkit lets the person at the PC turn the message off)
    run(["systemctl", "--no-wall", args[0]], timeout=60)
    return []


# ---- updates (apt's cache; fetching is sg-admind's update-check) ----------------------------

def update_progress():
    """What sg-update-prepare is downloading, per package (it publishes this:
    apt's partial/ is root's). DOWNLOAD name<TAB>bytes<TAB>bytes-here."""
    state = os.environ.get("SG_UPDATE_STATE", "/run/sg-update")
    out, sizes = [], {}
    try:
        with open(os.path.join(state, "progress"), encoding="utf-8") as f:
            for line in f:
                p = line.split()
                if len(p) == 3 and p[1].isdigit() and p[2].isdigit():
                    name = p[0].split("_", 1)[0]            # name_version_arch.deb
                    size, done = sizes.get(name, (0, 0))
                    sizes[name] = (size + int(p[1]), done + min(int(p[2]), int(p[1])))
    except OSError:
        pass
    for name in sorted(sizes):
        out.append("DOWNLOAD %s\t%d\t%d" % (name, sizes[name][0], sizes[name][1]))
    try:
        with open(os.path.join(state, "state"), encoding="utf-8") as f:
            st = f.read().strip()
    except OSError:
        st = ""
    out.append("DOWNLOADING %s" % yn(st == "downloading"))
    return out


def updates(args):
    if args == ["progress"]:
        return update_progress() + ["STAGED %s" % yn(os.path.lexists(os.environ.get("SG_SYSTEM_UPDATE", "/system-update")))]
    if args == ["check"]:
        # anyone at the PC may check (config/polkit/50-stained-glass-updates.rules)
        run(["systemctl", "start", "--no-block", "sg-update-prepare.service"], timeout=30)
        return []
    if args:
        raise Fail("invalid", "usage: updates [check|progress]")
    out = []
    progress = update_progress()
    if "DOWNLOADING yes" in progress and not os.environ.get("SG_MUTANT_UPDATES_APT_WHILE_DOWNLOADING"):
        # apt's lists and cache are the download's while it runs: asking apt
        # waited for the whole download, and Settings with it (David,
        # 2026-10-07). The download's own plan says what is coming.
        out += planned_updates()
    else:
        text = run(["apt", "list", "--upgradable"], timeout=60, check=False)
        for line in text.splitlines():
            # name/suite new-version arch [upgradable from: old-version]
            m = re.match(r"([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from: ([^\]]+)\]", line)
            if m:
                out.append("UPDATE %s\t%s\t%s" % (m.group(1), m.group(3), m.group(2)))
    out += progress
    out.append("STAGED %s" % yn(os.path.lexists(os.environ.get("SG_SYSTEM_UPDATE", "/system-update"))))
    out += update_problems()
    return out


def planned_updates():
    """UPDATE lines from the plan sg-update-prepare publishes (FILE SIZE, a
    file being name_version_arch.deb with the version URL-quoted), each one's
    installed version read from dpkg's own database -- no apt, no lock."""
    import urllib.parse
    state = os.environ.get("SG_UPDATE_STATE", "/run/sg-update")
    out, seen = [], set()
    try:
        with open(os.path.join(state, "plan"), encoding="utf-8") as f:
            lines = f.read().splitlines()
    except OSError:
        return out
    for line in lines:
        p = line.split()
        if not p or not p[0].endswith(".deb") or p[0].count("_") < 2:
            continue
        name, ver = p[0][:-4].split("_")[:2]
        ver = urllib.parse.unquote(ver)
        if name in seen or not re.fullmatch(r"[a-z0-9][a-z0-9.+-]*", name):
            continue
        seen.add(name)
        old = run(["dpkg-query", "-W", "-f=${Version}", name], timeout=10, check=False).strip()
        out.append("UPDATE %s\t%s\t%s" % (name, clean(old) or "-", clean(ver)))
    return out


def update_problems():
    """the package sources the last check could not use, and why (published by
    sg-update-prepare: HOST<TAB>reason): PROBLEM host<TAB>reason"""
    out = []
    try:
        with open(os.path.join(os.environ.get("SG_UPDATE_STATE", "/run/sg-update"), "problems"), encoding="utf-8") as f:
            for line in f:
                p = line.rstrip("\n").split("\t", 1)
                if len(p) == 2 and p[0] and p[1]:
                    out.append("PROBLEM %s\t%s" % (p[0][:200], p[1][:200]))
    except OSError:
        pass
    return out[:8]


# the desktop's named accents (org.gnome.desktop.interface accent-color), for
# the nearest to the user's own colour
ACCENTS = {"blue": (0x35, 0x84, 0xe4), "teal": (0x21, 0x90, 0xa4), "green": (0x3a, 0x94, 0x4a),
           "yellow": (0xc8, 0x88, 0x00), "orange": (0xed, 0x5b, 0x00), "red": (0xe6, 0x2d, 0x42),
           "pink": (0xd5, 0x61, 0x99), "purple": (0x91, 0x41, 0xac), "slate": (0x6f, 0x83, 0x96)}


def nearest_accent(hexrgb):
    r, g, b = int(hexrgb[0:2], 16), int(hexrgb[2:4], 16), int(hexrgb[4:6], 16)
    return min(ACCENTS, key=lambda n: sum((a - c) ** 2 for a, c in zip(ACCENTS[n], (r, g, b))))


def set_ini(path, key, value):
    """KEY=VALUE in the file's [Settings] group, the rest of it kept"""
    try:
        with open(path, encoding="utf-8") as f:
            lines = f.read().splitlines()
    except OSError:
        lines = []
    out, group, done = [], None, False
    for line in lines:
        m = re.match(r"\s*\[(.*)\]\s*$", line)
        if m:
            if group == "Settings" and not done:
                out.append("%s=%s" % (key, value))
                done = True
            group = m.group(1)
        elif group == "Settings" and re.match(r"\s*%s\s*=" % re.escape(key), line):
            if not done:
                out.append("%s=%s" % (key, value))
                done = True
            continue
        out.append(line)
    if not done:
        if group != "Settings":
            if "[Settings]" not in lines:
                out.append("[Settings]")
        out.append("%s=%s" % (key, value))
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write("\n".join(out) + "\n")
    os.replace(tmp, path)


CSS_BEGIN = "/* Stained Glass: the accent colour (sg-settingsctl look) -- kept up to date, edit outside this block */"
CSS_END = "/* Stained Glass: end of the accent colour */"


def accent_css(accent, dark):
    """libadwaita's accent (GTK 4 apps such as GNOME's): the desktop portal
    here reports no accent, so libadwaita stayed blue whatever the user chose
    -- its variables (1.6) and named colours (older) say it instead. On dark,
    text in the accent is lightened, as libadwaita does."""
    r, g, b = int(accent[0:2], 16), int(accent[2:4], 16), int(accent[4:6], 16)
    fg = "#%02x%02x%02x" % tuple(int(v + (255 - v) * 0.35) for v in (r, g, b)) if dark else "#" + accent
    return "\n".join([CSS_BEGIN,
                      ":root { --accent-bg-color: #%s; --accent-color: %s; }" % (accent, fg),
                      "@define-color accent_bg_color #%s;" % accent,
                      "@define-color accent_color %s;" % fg,
                      CSS_END])


# libadwaita's window buttons and corners as every other window's (Wine's:
# square 30 px buttons, Close red under the pointer, square corners) -- it
# ignores themes, not the user's gtk.css (GTK 4 only: GTK 3 has the theme)
WINDOW_CSS = "\n".join([
    "windowcontrols > button { border-radius: 0; min-width: 30px; min-height: 30px; margin: 0; padding: 0;"
    " background: none; box-shadow: none; }",
    "windowcontrols > button > image { background: none; border-radius: 0; padding: 0; box-shadow: none; }",
    "windowcontrols > button:hover { background-color: alpha(currentColor, 0.08); }",
    "windowcontrols > button.close:hover { background-color: #e81123; color: white; }",
    "window.csd, window.csd > .titlebar, window.csd headerbar { border-radius: 0; }",
    ":root { --window-radius: 0px; }"])


def look_css(accent, dark, gtk):
    block = accent_css(accent, dark)
    if gtk != "gtk-4.0":
        # :root and its variables are GTK 4's: GTK 3 stopped at them ("Invalid
        # name of pseudo-class") and warned in every program it started
        block = "\n".join(l for l in block.split("\n") if not l.startswith(":root"))
    if gtk == "gtk-4.0":
        block = block.replace("\n" + CSS_END, "\n" + WINDOW_CSS + "\n" + CSS_END)
    return block


def set_css_block(path, block):
    """our block in the user's gtk.css, the rest of it kept (none: removed)"""
    try:
        with open(path, encoding="utf-8") as f:
            text = f.read()
    except OSError:
        text = ""
    m = re.search(re.escape(CSS_BEGIN) + r".*?" + re.escape(CSS_END) + r"\n?", text, re.S)
    rest = (text[:m.start()] + text[m.end():]) if m else text
    if not block:
        if not m:
            return
        out = rest
    else:
        out = block + "\n" + rest
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(out)
    os.replace(tmp, path)


DEFAULT_ACCENT = "7030c0"   # Stained Glass purple, as sg-gtk-theme's


def apply_look(c):
    look = c.get("look")
    if not isinstance(look, dict) or look.get("mode") not in ("dark", "light"):
        # never chosen: libadwaita's accent our purple too (the theme's own
        # colour), the rest left as it is
        base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
        for gtk in ("gtk-3.0", "gtk-4.0"):
            set_css_block(os.path.join(base, gtk, "gtk.css"), look_css(DEFAULT_ACCENT, False, gtk))
        return
    dark = look["mode"] == "dark"
    base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
    accent = look.get("accent", "")
    valid = re.fullmatch(r"[0-9a-fA-F]{6}", accent)
    for gtk in ("gtk-3.0", "gtk-4.0"):
        set_ini(os.path.join(base, gtk, "settings.ini"), "gtk-application-prefer-dark-theme", "true" if dark else "false")
        set_css_block(os.path.join(base, gtk, "gtk.css"), look_css(accent.lower(), dark, gtk) if valid else "")
    try:
        run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark" if dark else "default"])
        accent = look.get("accent", "")
        # accent-color is newer than some desktops' schemas: only where it is known
        if re.fullmatch(r"[0-9a-fA-F]{6}", accent) and \
                "accent-color" in run(["gsettings", "list-keys", "org.gnome.desktop.interface"], check=False).split():
            run(["gsettings", "set", "org.gnome.desktop.interface", "accent-color", nearest_accent(accent)])
    except Fail:
        pass   # no gsettings: GTK's own files still say it


def look(args):
    c = load_config()
    if args:
        if len(args) != 2:
            raise Fail("invalid", "usage: look [dark|light RRGGBB]")
        mode = one_of(args[0], ("dark", "light"), "mode")
        if not re.fullmatch(r"[0-9a-fA-F]{6}", args[1]):
            raise Fail("invalid", "the accent is six hex digits")
        c["look"] = {"mode": mode, "accent": args[1].lower()}
        save_config(c)
        apply_look(c)
    lk = c.get("look") or {}
    return ["LOOK %s\t%s" % (lk.get("mode", "light"), lk.get("accent", ""))]


def display_scale(args):
    """Linux programs at the display scale Settings just set (sg-shell's
    scale_write, after LogPixels): GTK, Qt and the pointer follow at once
    (lib/sg-display-scale: the session's XSETTINGS manager, the X
    resources, the work area above the taskbar)."""
    if len(args) != 1:
        raise Fail("invalid", "usage: display-scale PERCENT")
    pc = integer(args[0], 100, 500, "scale")
    script = os.path.join(os.environ.get("SG_LIB", "/usr/lib/stained-glass"), "sg-display-scale")
    if not os.path.exists(script):
        raise Fail("unsupported", "sg-display-scale is not installed")
    # on its own: Settings waits for this answer, and Qt's windows are
    # resized a moment after the toolkits take the new scale
    try:
        subprocess.Popen(["sh", script, str(pc)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                         stderr=subprocess.DEVNULL, start_new_session=True)
    except OSError as e:
        raise Fail("failed", "sg-display-scale: %s" % e)
    return ["SCALE %d" % pc]


def session_start(args):
    c = load_config()
    for f in (apply_nightlight, apply_power, apply_look):
        try:
            f(c)
        except Fail:
            pass
    try:
        start_autorotate()
    except Fail:
        pass
    if tool("udevadm"):
        try:
            start("sg-devsounds", [os.path.abspath(sys.argv[0]), "device-sounds-run"])
        except Fail:
            pass
    return []


def lpoptions_values(text):
    """lpoptions' key=value list: a value with spaces is in single quotes, but
    a quote inside it is not escaped ('DYMO LabelWriter 550 (maker's
    driver)'), so a value runs to the next " key=" rather than to a quote."""
    keys = list(re.finditer(r"(?:^|\s)([a-z][a-z0-9-]*)=", text.strip()))
    text = text.strip()
    values = {}
    for i, m in enumerate(keys):
        end = keys[i + 1].start() if i + 1 < len(keys) else len(text)
        v = text[m.end():end].strip()
        if v.startswith("'") and v.rfind("'") > 0:
            v = v[1:v.rfind("'")]       # a key with no value may follow ("printer-location")
        else:
            v = re.split(r"(?<!\\)\s", v)[0].replace("\\ ", " ")
        values[m.group(1)] = v
    return values


def printers(args):
    """CUPS's queues: what Linux programs print to (lpstat, lpoptions)."""
    if args:
        raise Fail("invalid", "usage: printers")
    uris, states, out = {}, {}, []
    for line in run(["lpstat", "-v"], check=False).splitlines():
        m = re.match(r"device for (\S+): (.*)$", line.strip())
        if m:
            uris[m.group(1)] = m.group(2).strip()
    for line in run(["lpstat", "-p"], check=False).splitlines():
        m = re.match(r"printer (\S+) (.*)$", line.strip())
        if not m:
            continue
        rest = m.group(2)
        states[m.group(1)] = ("stopped" if "disabled" in rest else
                              "printing" if "printing" in rest else "idle")
    for name in sorted(uris):
        try:
            opts = lpoptions_values(run(["lpoptions", "-p", name], check=False))
        except Fail:
            opts = {}
        out.append("QUEUE %s\t%s\t%s\t%s\t%s" % (clean(name), clean(opts.get("printer-make-and-model", "")),
                                                 clean(uris[name]), states.get(name, "idle"),
                                                 clean(opts.get("printer-info", ""))))
    return out


MPRIS = "org.mpris.MediaPlayer2."
MEDIA_VERBS = {"play-pause": "PlayPause", "next": "Next", "previous": "Previous",
               "stop": "Stop", "play": "Play", "pause": "Pause"}


def busctl(*args):
    """busctl on the user's session bus, its answer as JSON"""
    r = subprocess.run(["busctl", "--user", "--json=short", "--timeout=2"] + list(args),
                       capture_output=True, text=True, timeout=10)
    if r.returncode != 0:
        raise Fail("failed", r.stderr.strip() or "busctl failed")
    try:
        return json.loads(r.stdout) if r.stdout.strip() else None
    except ValueError:
        raise Fail("failed", "busctl gave an answer that is not JSON")


def media_players():
    """The session's media players (MPRIS: Linux programs, and Windows ones
    through wine-sg's transport-controls bridge): [(name, status, title, artist)]."""
    names = busctl("call", "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
                   "ListNames")["data"][0]
    out = []
    for name in sorted(n for n in names if n.startswith(MPRIS)):
        try:
            props = busctl("call", name, "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties",
                           "GetAll", "s", "org.mpris.MediaPlayer2.Player")["data"][0]
        except (Fail, KeyError, IndexError, TypeError):
            continue        # gone, or not answering: not one to send keys to
        status = (props.get("PlaybackStatus") or {}).get("data", "Stopped")
        meta = (props.get("Metadata") or {}).get("data") or {}
        title = (meta.get("xesam:title") or {}).get("data", "")
        artist = (meta.get("xesam:artist") or {}).get("data") or []
        out.append((name, status, str(title), ", ".join(artist) if isinstance(artist, list) else str(artist)))
    return out


def media_last_file():
    return os.path.join(runtime_dir(), "sg-media-last")


def media_target(players):
    """The player the media keys mean, as Windows picks the session: the one
    playing; else the one last controlled, if it is still here; else one paused;
    else the first."""
    playing = [p for p in players if p[1] == "Playing"]
    if playing:
        return playing[0][0]
    try:
        with open(media_last_file()) as f:
            last = f.read().strip()
    except OSError:
        last = ""
    if os.environ.get("SG_MUTANT_MEDIA_FIRST") != "1":
        if any(p[0] == last for p in players):
            return last
        paused = [p for p in players if p[1] == "Paused"]
        if paused:
            return paused[0][0]
    return players[0][0] if players else None


def media(args):
    """The media keys: Play/Pause, Next, Previous and Stop for the session's
    player (sg-volume hears them from the shell hook)."""
    if not shutil.which("busctl") or not (os.environ.get("DBUS_SESSION_BUS_ADDRESS")
                                          or os.path.exists(os.path.join(runtime_dir(), "bus"))):
        raise Fail("unsupported", "no session bus")
    players = media_players()
    if not args:
        return ["PLAYER %s\t%s\t%s\t%s" % (clean(n[len(MPRIS):]), s, clean(t), clean(a))
                for n, s, t, a in players]
    if len(args) != 1 or args[0] not in MEDIA_VERBS:
        raise Fail("invalid", "usage: media [" + "|".join(MEDIA_VERBS) + "]")
    target = media_target(players)
    if target is None:
        raise Fail("notfound", "no media player")
    busctl("call", target, "/org/mpris/MediaPlayer2", "org.mpris.MediaPlayer2.Player", MEDIA_VERBS[args[0]])
    try:
        with open(media_last_file(), "w") as f:
            f.write(target + "\n")
    except OSError:
        pass
    return ["PLAYER %s" % clean(target[len(MPRIS):])]


COMMANDS = {"sound": sound, "media": media, "bluetooth": bluetooth, "display": display, "nightlight": nightlight,
            "power": power, "updates": updates, "session-start": session_start,
            "sleep-caps": sleep_caps, "sleep": sleep_now, "shutdown": shutdown_now,
            "lockscreen": lockscreen, "look": look, "display-scale": display_scale,
            "printers": printers, "rotation": rotation, "autorotate": autorotate,
            "device-sounds": device_sounds, "device-sounds-run": device_sounds_run}


def main(argv):
    out_path = None
    if "--out" in argv:
        i = argv.index("--out")
        if i + 1 >= len(argv):
            print("ERROR invalid --out needs a file")
            return 2
        out_path = argv[i + 1]
        argv = argv[:i] + argv[i + 2:]
    lines, code = [], 0
    if not argv or argv[0] not in COMMANDS:
        lines, code = ["ERROR invalid usage: sg-settingsctl " + "|".join(sorted(COMMANDS)) + " ..."], 2
    else:
        try:
            lines = COMMANDS[argv[0]](argv[1:]) + ["OK"]
        except Fail as e:
            lines, code = ["ERROR %s %s" % (e.kind, clean(e))], EXIT.get(e.kind, 1)
    text = "".join(line + "\n" for line in lines)
    if out_path:
        tmp = out_path + ".part"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(text)
        os.replace(tmp, out_path)
    else:
        sys.stdout.write(text)
    return code


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
