From 256dcc8f8ea5cccca0017592c1e1a4960dce476d Mon Sep 17 00:00:00 2001 From: Matt Warner Date: Tue, 2 Jun 2026 21:37:58 -0400 Subject: [PATCH 1/5] Add PyQt6 GUI frontend (vrgb-gui) A desktop frontend that imports the vrgb CLI as a module and drives the keyboard in-process (shared HID protocol + config logic, no duplication). Device I/O runs on a worker thread; persisting actions fall back to pkexec when the vrgb group is not yet active in the session. Features: - HS color wheel + value slider, hex entry, preset swatches - Live throttled preview, persisted on release - Brightness slider, power toggle, firmware/autonomous toggle - OEM rainbow toggle (auto-disabled on unsupported device mappings) - Profile save/load/delete - System-tray applet (quick on/off, brightness, profiles); close-to-tray Adds install-gui.sh, vrgb-gui.desktop, and README docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 37 +- install-gui.sh | 67 ++++ vrgb-gui.desktop | 11 + vrgb-gui.py | 927 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1039 insertions(+), 3 deletions(-) create mode 100755 install-gui.sh create mode 100644 vrgb-gui.desktop create mode 100755 vrgb-gui.py diff --git a/README.md b/README.md index 30ed883..b2fdf91 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,36 @@ Use the KDE autostart option in the installer (or set it manually) to reapply yo +## Graphical Interface (vrgb-gui) + +A PyQt6 desktop frontend is included. It is a thin GUI over the CLI: it imports +`vrgb` as a module and drives the keyboard in-process, so the HID protocol and +config logic are shared with the command line — no duplicated device code. + +**Features** + +- HS color wheel + value slider, hex entry, and preset swatches +- Live preview while you drag (throttled), persisted on release +- Brightness slider (0–100%) and a power on/off toggle +- Firmware/autonomous mode toggle +- OEM rainbow toggle (auto-disabled on device mappings that do not support it) +- Profile manager (save / load / delete) +- System-tray applet: quick on/off, brightness, and profile loading; closing the + window hides it to the tray +- Falls back to a Polkit (`pkexec`) password prompt if the `vrgb` group is not yet + active in your session (i.e. before the first logout/login after install) + +**Install (after `./install.sh`)** + + chmod +x install-gui.sh + ./install-gui.sh + +Requires `PyQt6` (`sudo dnf install python3-pyqt6` on Fedora). Launch it from your +application menu (search "VRGB") or run `vrgb-gui`. Start the tray on login with +the installer's autostart option, or run `vrgb-gui --tray`. + + + ## Command List Show Current Status @@ -297,9 +327,10 @@ Removes: ## Future Development - expanded ASUS hardware compatibility -- simple GUI frontend -- color picker / brightness control -- profile management +- ~~simple GUI frontend~~ — added (`vrgb-gui`, PyQt6) +- ~~color picker / brightness control~~ — added +- ~~profile management~~ — added (CLI + GUI) +- packaged distribution (RPM / Flatpak) With future updates in mind, this project will aim to continue to be as efficient and lightweight as possible. diff --git a/install-gui.sh b/install-gui.sh new file mode 100755 index 0000000..9a937ef --- /dev/null +++ b/install-gui.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -e + +# Run from the script's own directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "VRGB GUI Installer" +echo "------------------" + +# 1. Require the vrgb CLI (the GUI imports it as a module + uses it for pkexec) +if [ ! -x /usr/local/bin/vrgb ]; then + echo "The 'vrgb' CLI is not installed at /usr/local/bin/vrgb." + echo "Run ./install.sh first, then re-run this script." + exit 1 +fi + +# 2. Check PyQt6 +if ! python3 -c "import PyQt6.QtWidgets" 2>/dev/null; then + echo "PyQt6 is missing. Install it with one of:" + echo " sudo dnf install python3-pyqt6 # Fedora" + echo " pip install --user PyQt6" + exit 1 +fi + +echo "[1/4] Installing GUI to /usr/local/bin/vrgb-gui ..." +sudo install -m 755 vrgb-gui.py /usr/local/bin/vrgb-gui + +echo "[2/4] Installing icon ..." +if [ -f assets/vrgblogodark.png ]; then + sudo install -m 644 assets/vrgblogodark.png /usr/share/pixmaps/vrgb.png + # Also drop into the hicolor theme so menus/tray resolve "vrgb" + sudo install -d /usr/share/icons/hicolor/256x256/apps + sudo install -m 644 assets/vrgblogodark.png /usr/share/icons/hicolor/256x256/apps/vrgb.png +fi + +echo "[3/4] Installing desktop launcher ..." +sudo install -m 644 vrgb-gui.desktop /usr/share/applications/vrgb-gui.desktop +sudo update-desktop-database /usr/share/applications 2>/dev/null || true +sudo gtk-update-icon-cache -f /usr/share/icons/hicolor 2>/dev/null || true + +echo "[4/4] Optional: start the GUI (tray) automatically on login" +read -p "Add login autostart for the tray? (y/n): " AUTOSTART +if [[ "$AUTOSTART" == "y" || "$AUTOSTART" == "Y" ]]; then + mkdir -p ~/.config/autostart + cat > ~/.config/autostart/vrgb-gui.desktop < 0 else 0.0 + ang = math.atan2(dy, dx) + if ang < 0: + ang += 2.0 * math.pi + self._h = ang / (2.0 * math.pi) + self.update() + self.hs_changed.emit(self._h, self._s) + + def mousePressEvent(self, e): + self._pick(e.position()) + + def mouseMoveEvent(self, e): + if e.buttons() & Qt.MouseButton.LeftButton: + self._pick(e.position()) + + def mouseReleaseEvent(self, _e): + self.released.emit() + + +# ---------------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------------- + +PRESETS = [ + ("Red", "ff0000"), ("Orange", "ff6a00"), ("Yellow", "ffd400"), + ("Green", "00ff44"), ("Cyan", "00e5ff"), ("Blue", "0066ff"), + ("Purple", "aa00ff"), ("Magenta", "ff00aa"), ("White", "ffffff"), +] + + +def make_logo_icon(): + for cand in ("/usr/share/pixmaps/vrgb.png", + str(Path(__file__).resolve().parent / "assets" / "vrgblogodark.png")): + if os.path.exists(cand): + ic = QIcon(cand) + if not ic.isNull(): + return ic + # Fallback: a small rainbow ring pixmap + pm = QPixmap(64, 64) + pm.fill(Qt.GlobalColor.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + grad = QConicalGradient(32, 32, 0) + for i in range(0, 361, 30): + grad.setColorAt(i / 360.0, QColor.fromHsvF((i % 360) / 360.0, 1.0, 1.0)) + p.setPen(QPen(QBrush(grad), 10)) + p.drawEllipse(10, 10, 44, 44) + p.end() + return QIcon(pm) + + +# ---------------------------------------------------------------------------- +# Main window +# ---------------------------------------------------------------------------- + +class MainWindow(QMainWindow): + def __init__(self, worker: DeviceWorker, mod): + super().__init__() + self.worker = worker + self.mod = mod + self._suppress = False # block feedback loops while we set widgets + self._interacting = False # user dragging color + self._devinfo = None + + self.setWindowTitle("VRGB — Keyboard RGB") + self.setWindowIcon(make_logo_icon()) + + cfg = mod.load_config() + self._color = QColor("#" + cfg.get("color", "aa00ff")) + self._percent = int(cfg.get("percent", 100)) + + self._build_ui() + self._wire_worker() + + # Throttle timer for live preview during drags + self._preview = QTimer(self) + self._preview.setSingleShot(True) + self._preview.setInterval(45) + self._preview.timeout.connect(self._emit_preview) + self._pending = None # ("color"/"brightness", ...) + + self._load_from_cfg(cfg) + self.worker.submit("detect") + + # -- UI construction -- + def _build_ui(self): + central = QWidget() + root = QVBoxLayout(central) + root.setContentsMargins(14, 12, 14, 12) + root.setSpacing(10) + + # Status line + self.status_lbl = QLabel("Detecting keyboard…") + self.status_lbl.setStyleSheet("color:#999;") + root.addWidget(self.status_lbl) + + # --- Color row: wheel + value slider + swatch/hex --- + color_box = QGroupBox("Color") + cgrid = QGridLayout(color_box) + + self.wheel = ColorWheel() + cgrid.addWidget(self.wheel, 0, 0, 4, 1) + + self.value_slider = QSlider(Qt.Orientation.Vertical) + self.value_slider.setRange(0, 100) + self.value_slider.setValue(100) + self.value_slider.setToolTip("Value (color lightness)") + cgrid.addWidget(self.value_slider, 0, 1, 4, 1) + + self.swatch = QFrame() + self.swatch.setMinimumSize(64, 64) + self.swatch.setFrameShape(QFrame.Shape.StyledPanel) + cgrid.addWidget(self.swatch, 0, 2) + + cgrid.addWidget(QLabel("Hex"), 1, 2) + self.hex_edit = QLineEdit() + self.hex_edit.setMaxLength(7) + self.hex_edit.setPlaceholderText("#rrggbb") + cgrid.addWidget(self.hex_edit, 2, 2) + + # preset swatches + preset_row = QHBoxLayout() + for name, hexc in PRESETS: + b = QPushButton() + b.setFixedSize(24, 24) + b.setToolTip(name) + b.setStyleSheet(f"background:#{hexc}; border:1px solid #444; border-radius:4px;") + b.clicked.connect(lambda _=False, h=hexc: self._apply_hex("#" + h, commit=True)) + preset_row.addWidget(b) + preset_row.addStretch(1) + cgrid.addLayout(preset_row, 4, 0, 1, 3) + + root.addWidget(color_box) + + # --- Brightness + power --- + bbox = QGroupBox("Brightness & power") + bl = QGridLayout(bbox) + self.power_btn = QPushButton("Power") + self.power_btn.setCheckable(True) + self.power_btn.setMinimumWidth(90) + bl.addWidget(self.power_btn, 0, 0, 2, 1) + + bl.addWidget(QLabel("Brightness"), 0, 1) + self.bright_slider = QSlider(Qt.Orientation.Horizontal) + self.bright_slider.setRange(0, 100) + self.bright_slider.setValue(self._percent) + bl.addWidget(self.bright_slider, 0, 2) + self.bright_lbl = QLabel(f"{self._percent}%") + self.bright_lbl.setMinimumWidth(40) + bl.addWidget(self.bright_lbl, 0, 3) + + self.auto_chk = QCheckBox("Firmware / autonomous mode") + self.auto_chk.setToolTip("Hand control back to the keyboard firmware") + bl.addWidget(self.auto_chk, 1, 1, 1, 2) + + self.rainbow_chk = QCheckBox("OEM rainbow") + bl.addWidget(self.rainbow_chk, 1, 3) + + root.addWidget(bbox) + + # --- Profiles --- + pbox = QGroupBox("Profiles") + pl = QGridLayout(pbox) + self.profile_list = QListWidget() + self.profile_list.setMaximumHeight(110) + pl.addWidget(self.profile_list, 0, 0, 4, 1) + self.btn_load = QPushButton("Load") + self.btn_save = QPushButton("Save current…") + self.btn_delete = QPushButton("Delete") + pl.addWidget(self.btn_save, 0, 1) + pl.addWidget(self.btn_load, 1, 1) + pl.addWidget(self.btn_delete, 2, 1) + root.addWidget(pbox) + + self.setCentralWidget(central) + + # Signal wiring + self.wheel.hs_changed.connect(self._on_wheel) + self.wheel.released.connect(self._commit_color) + self.value_slider.valueChanged.connect(self._on_value) + self.value_slider.sliderReleased.connect(self._commit_color) + self.hex_edit.editingFinished.connect(lambda: self._apply_hex(self.hex_edit.text(), commit=True)) + self.bright_slider.valueChanged.connect(self._on_brightness) + self.bright_slider.sliderReleased.connect(self._commit_brightness) + self.power_btn.clicked.connect(self._on_power) + self.auto_chk.toggled.connect(self._on_auto) + self.rainbow_chk.toggled.connect(self._on_rainbow) + self.btn_save.clicked.connect(self._profile_save) + self.btn_load.clicked.connect(self._profile_load) + self.btn_delete.clicked.connect(self._profile_delete) + self.profile_list.itemDoubleClicked.connect(lambda _i: self._profile_load()) + + def _wire_worker(self): + self.worker.op_done.connect(self._on_op_done) + self.worker.device_status.connect(self._on_device_status) + self.worker.config_updated.connect(self._on_config_updated) + + # -- config <-> widgets -- + def _load_from_cfg(self, cfg): + self._suppress = True + self._color = QColor("#" + cfg.get("color", "aa00ff")) + self._percent = int(cfg.get("percent", 100)) + h, s, v, _ = self._color.getHsvF() + h = max(h, 0.0) + self.wheel.set_hsv(h, s, v) + self.value_slider.setValue(int(v * 100)) + self.hex_edit.setText(self._color.name()) + self._update_swatch() + self.bright_slider.setValue(self._percent) + self.bright_lbl.setText(f"{self._percent}%") + self.power_btn.setChecked(self._percent > 0) + self.power_btn.setText("On" if self._percent > 0 else "Off") + self.auto_chk.setChecked(bool(cfg.get("autonomous", False))) + self._reload_profiles(cfg) + self._suppress = False + + def _reload_profiles(self, cfg): + self.profile_list.clear() + for name in sorted(cfg.get("profiles", {})): + self.profile_list.addItem(QListWidgetItem(name)) + + def _update_swatch(self): + self.swatch.setStyleSheet( + f"background:{self._color.name()}; border:1px solid #333; border-radius:6px;" + ) + + def _current_hex(self): + return self._color.name().lstrip("#") + + # -- interaction handlers -- + def _on_wheel(self, h, s): + if self._suppress: + return + v = self.value_slider.value() / 100.0 + self._color = QColor.fromHsvF(h, s, v) + self._sync_color_widgets(update_wheel=False) + self._queue_preview("color") + + def _on_value(self, val): + if self._suppress: + return + h, s, _v, _a = self._color.getHsvF() + self._color = QColor.fromHsvF(max(h, 0.0), s, val / 100.0) + self.wheel.set_value(val / 100.0) + self._sync_color_widgets(update_wheel=False) + self._queue_preview("color") + + def _apply_hex(self, text, commit=False): + text = text.strip() + if not text.startswith("#"): + text = "#" + text + col = QColor(text) + if not col.isValid(): + return + self._color = col + self._sync_color_widgets(update_wheel=True) + if commit: + self._commit_color() + + def _sync_color_widgets(self, update_wheel): + self._suppress = True + if update_wheel: + h, s, v, _ = self._color.getHsvF() + self.wheel.set_hsv(max(h, 0.0), s, v) + self.value_slider.setValue(int(v * 100)) + self.hex_edit.setText(self._color.name()) + self._update_swatch() + self._suppress = False + + def _queue_preview(self, kind): + self._interacting = True + if kind == "color": + self._pending = ("color", self._current_hex(), self._percent) + else: + self._pending = ("brightness", self.bright_slider.value()) + if not self._preview.isActive(): + self._preview.start() + + def _emit_preview(self): + if not self._pending: + return + kind = self._pending[0] + if kind == "color": + _, hexc, pct = self._pending + self.worker.submit("color", hexc, pct, False) + else: + _, pct = self._pending + self.worker.submit("brightness", pct, False) + self._pending = None + + def _commit_color(self): + self._preview.stop() + self._pending = None + self._interacting = False + self.worker.submit("color", self._current_hex(), self._percent, True) + + def _on_brightness(self, val): + if self._suppress: + return + self.bright_lbl.setText(f"{val}%") + self._percent = val + self.power_btn.setChecked(val > 0) + self.power_btn.setText("On" if val > 0 else "Off") + self._queue_preview("brightness") + + def _commit_brightness(self): + self._preview.stop() + self._pending = None + self.worker.submit("brightness", self.bright_slider.value(), True) + + def _on_power(self, checked): + if self._suppress: + return + self.power_btn.setText("On" if checked else "Off") + self.worker.submit("power", bool(checked)) + + def _on_auto(self, checked): + if self._suppress: + return + self.worker.submit("auto", bool(checked)) + + def _on_rainbow(self, checked): + if self._suppress: + return + self.worker.submit("rainbow", bool(checked)) + + # -- profiles -- + def _profile_save(self): + name, ok = QInputDialog.getText(self, "Save profile", "Profile name:") + if ok and name.strip(): + self.worker.submit("profile_save", name.strip()) + + def _profile_load(self): + item = self.profile_list.currentItem() + if item: + self.worker.submit("profile_load", item.text()) + + def _profile_delete(self): + item = self.profile_list.currentItem() + if not item: + return + if QMessageBox.question(self, "Delete profile", f"Delete '{item.text()}'?") \ + == QMessageBox.StandardButton.Yes: + self.worker.submit("profile_delete", item.text()) + + # -- worker callbacks -- + def _on_op_done(self, op, ok, msg): + self.status_lbl.setStyleSheet("color:#5c5;" if ok else "color:#d55;") + self.status_lbl.setText(("✓ " if ok else "✗ ") + msg) + + def _on_device_status(self, devinfo, err): + self._devinfo = devinfo + if devinfo: + rainbow_ok = bool(devinfo.get("rainbow_supported", False)) + self.rainbow_chk.setEnabled(rainbow_ok) + if not rainbow_ok: + self.rainbow_chk.setToolTip( + "OEM rainbow is not supported on this device mapping " + f"({devinfo.get('hid_id', '')})" + ) + self.status_lbl.setStyleSheet("color:#5c5;") + self.status_lbl.setText(f"● {devinfo.get('model', 'keyboard')} · {devinfo.get('path','')}") + else: + self.rainbow_chk.setEnabled(False) + self.status_lbl.setStyleSheet("color:#d55;") + self.status_lbl.setText("✗ " + (err or "Keyboard not found")) + + def _on_config_updated(self, cfg): + # Refresh non-interactive widgets; never yank the wheel mid-drag. + if self._interacting: + self._reload_profiles(cfg) + return + self._load_from_cfg(cfg) + + +# ---------------------------------------------------------------------------- +# Tray +# ---------------------------------------------------------------------------- + +class Tray(QSystemTrayIcon): + def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): + super().__init__(make_logo_icon()) + self.window = window + self.worker = worker + self.app = app + self.setToolTip("VRGB — keyboard RGB") + + menu = QMenu() + self.act_show = QAction("Show / hide window") + self.act_show.triggered.connect(self._toggle_window) + menu.addAction(self.act_show) + menu.addSeparator() + + self.act_on = QAction("Turn on") + self.act_on.triggered.connect(lambda: self.worker.submit("power", True)) + self.act_off = QAction("Turn off") + self.act_off.triggered.connect(lambda: self.worker.submit("power", False)) + menu.addAction(self.act_on) + menu.addAction(self.act_off) + + bmenu = menu.addMenu("Brightness") + for pct in (25, 50, 75, 100): + a = QAction(f"{pct}%", self) + a.triggered.connect(lambda _=False, p=pct: self.worker.submit("brightness", p, True)) + bmenu.addAction(a) + + self.profiles_menu = menu.addMenu("Profiles") + self._rebuild_profiles(window.mod.load_config()) + worker.config_updated.connect(self._rebuild_profiles) + + menu.addSeparator() + act_quit = QAction("Quit") + act_quit.triggered.connect(self._quit) + menu.addAction(act_quit) + + self.setContextMenu(menu) + self.activated.connect(self._on_activated) + + def _rebuild_profiles(self, cfg): + self.profiles_menu.clear() + names = sorted(cfg.get("profiles", {})) + if not names: + a = QAction("(none saved)", self) + a.setEnabled(False) + self.profiles_menu.addAction(a) + return + for name in names: + a = QAction(name, self) + a.triggered.connect(lambda _=False, n=name: self.worker.submit("profile_load", n)) + self.profiles_menu.addAction(a) + + def _on_activated(self, reason): + if reason == QSystemTrayIcon.ActivationReason.Trigger: + self._toggle_window() + + def _toggle_window(self): + if self.window.isVisible() and not self.window.isMinimized(): + self.window.hide() + else: + self.window.showNormal() + self.window.raise_() + self.window.activateWindow() + + def _quit(self): + self.worker.stop() + self.worker.wait(2000) + self.app.quit() + + +# ---------------------------------------------------------------------------- +# Entry point +# ---------------------------------------------------------------------------- + +def main(): + try: + mod, core_path = load_core() + except FileNotFoundError as exc: + app = QApplication(sys.argv) + QMessageBox.critical(None, "VRGB GUI", str(exc)) + return 1 + + app = QApplication(sys.argv) + app.setApplicationName("VRGB GUI") + app.setWindowIcon(make_logo_icon()) + app.setQuitOnLastWindowClosed(False) # live in the tray + + worker = DeviceWorker(mod, core_path) + worker.start() + + window = MainWindow(worker, mod) + + tray_ok = QSystemTrayIcon.isSystemTrayAvailable() + tray = Tray(window, worker, app) if tray_ok else None + if tray: + tray.show() + + # Hide-to-tray on window close (if a tray exists); otherwise closing quits. + if tray: + def close_event(e): + e.ignore() + window.hide() + tray.showMessage("VRGB", "Still running in the tray.", + QSystemTrayIcon.MessageIcon.Information, 2000) + window.closeEvent = close_event + + # Start with the window shown unless launched with --tray + if "--tray" not in sys.argv: + window.show() + elif not tray: + window.show() + + rc = app.exec() + worker.stop() + worker.wait(2000) + return rc + + +if __name__ == "__main__": + sys.exit(main()) From 659eeb3c4c38eb995136684509b73e197468a26f Mon Sep 17 00:00:00 2001 From: Matt Warner Date: Tue, 2 Jun 2026 21:51:57 -0400 Subject: [PATCH 2/5] Harden GUI + fix 13 review findings Critical: - pkexec only ever runs the root-owned /usr/local/bin/vrgb; never a user-writable script (removes a local privesc primitive) - vrgb.py get_real_home() now honors PKEXEC_UID, so a pkexec'd CLI writes the invoking user's ~/.config/vrgb instead of /root's - parent all tray QActions to self so 'Quit' is not garbage-collected High: - clear _interacting on brightness commit (UI refresh no longer freezes) - only set quitOnLastWindowClosed(False) when a tray exists, else closing the window exits instead of orphaning a headless process - track + kill the pkexec subprocess on shutdown; add a 120s timeout; drain the worker (wait/terminate) before quitting so the QThread isn't destroyed while running Medium/low: - stop mutating process-global sys.stdout/stderr from the worker thread - process the op queue strictly FIFO (drop racy _coalesce reordering) - brightness applies the on-screen color (slider routes through the color path) - gate all device-dependent widgets on detection - 'Save current' commits on-screen state before snapshotting - emit deep-copied config snapshots across the thread boundary - clamp hue for gray colors; guard zero-radius wheel geometry Co-Authored-By: Claude Opus 4.8 (1M context) --- vrgb-gui.py | 525 ++++++++++++++++++++++++++-------------------------- vrgb.py | 11 ++ 2 files changed, 273 insertions(+), 263 deletions(-) diff --git a/vrgb-gui.py b/vrgb-gui.py index 3aa97e5..c8fb561 100755 --- a/vrgb-gui.py +++ b/vrgb-gui.py @@ -13,22 +13,25 @@ Polkit and runs as root. Live "preview" drags are best-effort and silently skipped in that case (to avoid password spam) until the group is active. + The pkexec fallback ONLY ever runs the root-owned /usr/local/bin/vrgb binary; it + never runs a user-writable script as root. The CLI honours PKEXEC_UID so the + elevated run reads/writes the invoking user's ~/.config/vrgb, not /root's. + State (color / brightness / profiles / autonomous) lives in ~/.config/vrgb/config.json, read through the imported module's load_config(). """ import sys -import io import os import math +import copy import queue import subprocess -import contextlib import importlib.util import importlib.machinery from pathlib import Path -from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject, QSize, QPointF +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QPointF from PyQt6.QtGui import ( QColor, QConicalGradient, @@ -39,7 +42,6 @@ QIcon, QPixmap, QAction, - QGuiApplication, ) from PyQt6.QtWidgets import ( QApplication, @@ -72,7 +74,7 @@ def _core_candidates(): here = Path(__file__).resolve().parent return [ - Path("/usr/local/bin/vrgb"), # installed CLI (preferred; also used for pkexec) + Path("/usr/local/bin/vrgb"), # installed CLI (preferred) here / "vrgb.py", # running from the repo checkout ] @@ -91,10 +93,23 @@ def load_core(): ) -# Prefer the installed root-owned binary for pkexec; fall back to whatever we imported. -def pkexec_target(core_path): - installed = Path("/usr/local/bin/vrgb") - return str(installed if installed.exists() else core_path) +def pkexec_target(): + """A SAFE root-owned binary to run under pkexec, or None. + + Running a user-writable file as root is a local privilege-escalation primitive, + so we require /usr/local/bin/vrgb to exist, be owned by root, and not be group- + or world-writable. We never fall back to the (user-owned) repo checkout. + """ + p = Path("/usr/local/bin/vrgb") + try: + st = p.stat() + except OSError: + return None + if st.st_uid != 0: + return None + if st.st_mode & 0o022: # group/other writable -> unsafe + return None + return str(p) # ---------------------------------------------------------------------------- @@ -102,20 +117,18 @@ def pkexec_target(core_path): # ---------------------------------------------------------------------------- class DeviceWorker(QThread): - # op name, ok, human message - op_done = pyqtSignal(str, bool, str) - # devinfo dict or None, error message - device_status = pyqtSignal(object, str) - # fresh config dict after a state-changing op - config_updated = pyqtSignal(dict) - - def __init__(self, mod, core_path, parent=None): + op_done = pyqtSignal(str, bool, str) # op name, ok, human message + device_status = pyqtSignal(object, str) # devinfo dict or None, error message + config_updated = pyqtSignal(dict) # fresh config snapshot + + def __init__(self, mod, parent=None): super().__init__(parent) self.mod = mod - self.pkexec_bin = pkexec_target(core_path) + self.pkexec_bin = pkexec_target() self.q: "queue.Queue" = queue.Queue() self._devinfo = None self._running = True + self._proc = None # tracked pkexec subprocess, for shutdown # -- public API (called from the UI thread) -- def submit(self, op, *args): @@ -123,6 +136,12 @@ def submit(self, op, *args): def stop(self): self._running = False + p = self._proc + if p is not None and p.poll() is None: + try: + p.kill() + except Exception: + pass self.q.put(("quit", ())) # -- internals (run on the worker thread) -- @@ -134,40 +153,19 @@ def run(self): continue if op == "quit": break - # Coalesce a backlog of preview color/brightness ops to the newest. - op, args = self._coalesce(op, args) try: self._dispatch(op, args) except Exception as exc: # never let the worker die self.op_done.emit(op, False, f"{type(exc).__name__}: {exc}") - def _coalesce(self, op, args): - # If newer same-kind preview ops are queued, skip ahead to the last one. - if op in ("color", "brightness"): - try: - while True: - nxt_op, nxt_args = self.q.get_nowait() - if nxt_op == op and not (nxt_args and nxt_args[-1]): # persist == False - op, args = nxt_op, nxt_args - else: - # put it back at the front by handling after; simplest: requeue - self.q.put((nxt_op, nxt_args)) - break - except queue.Empty: - pass - return op, args - def _ensure_device(self): if self._devinfo is not None: return self._devinfo - buf = io.StringIO() try: - with contextlib.redirect_stderr(buf), contextlib.redirect_stdout(io.StringIO()): - self._devinfo = self.mod.find_device() + self._devinfo = self.mod.find_device() self.device_status.emit(self._devinfo, "") except SystemExit: - msg = buf.getvalue().strip() or "VRGB keyboard not found" - self.device_status.emit(None, msg) + self.device_status.emit(None, "VRGB keyboard not found") raise return self._devinfo @@ -175,180 +173,178 @@ def _cfg(self): return self.mod.load_config() def _emit_cfg(self, cfg): - self.config_updated.emit(dict(cfg)) + # Emit an owned snapshot (own the nested profiles dict too) so the GUI + # thread never iterates a dict the worker might later mutate. + snap = dict(cfg) + snap["profiles"] = copy.deepcopy(cfg.get("profiles", {})) + self.config_updated.emit(snap) def _run_cli(self, cli_args): """Privileged fallback via pkexec (Polkit GUI password prompt).""" - res = subprocess.run( + if not self.pkexec_bin: + raise RuntimeError( + "Privileged fallback unavailable: install the vrgb CLI to " + "/usr/local/bin (run ./install.sh), then log out and back in." + ) + self._proc = subprocess.Popen( ["pkexec", self.pkexec_bin, *cli_args], - capture_output=True, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) - if res.returncode != 0: - err = (res.stderr or res.stdout or "pkexec failed").strip() - raise RuntimeError(err) - + try: + out, err = self._proc.communicate(timeout=120) + rc = self._proc.returncode + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.communicate() + raise RuntimeError("pkexec timed out waiting for authorization") + finally: + self._proc = None + if rc != 0: + raise RuntimeError((err or out or "pkexec failed").strip()) + + # -- per-op handlers -- def _dispatch(self, op, args): mod = self.mod - - if op == "detect": - try: - self._ensure_device() - except SystemExit: - pass + handler = getattr(self, f"_op_{op}", None) + if handler is None: + self.op_done.emit(op, False, f"unknown op '{op}'") return + handler(mod, *args) - if op == "color": - hexcol, percent, persist = args - try: - dev = self._ensure_device() - except SystemExit: - return - try: - if persist: - cfg = self._cfg() - with contextlib.redirect_stdout(io.StringIO()): - mod.cmd_set(cfg, dev, hexcol, str(percent)) - self._emit_cfg(cfg) - self.op_done.emit("color", True, f"Set #{hexcol} @ {percent}%") - else: - r, g, b = mod.hex_to_rgb(hexcol) - intensity = mod.percent_to_intensity(percent) - mod.set_firmware_mode(dev, False) - mod.set_color(dev, r, g, b, intensity) - except PermissionError: - if persist: - self._run_cli(["set", hexcol, str(percent)]) - self._emit_cfg(self._cfg()) - self.op_done.emit("color", True, f"Set #{hexcol} @ {percent}% (pkexec)") - # preview: silently skip when not permitted - return + def _op_detect(self, mod): + try: + self._ensure_device() + except SystemExit: + pass - if op == "brightness": - percent, persist = args - try: - dev = self._ensure_device() - except SystemExit: - return - try: - if persist: - cfg = self._cfg() - with contextlib.redirect_stdout(io.StringIO()): - mod.cmd_brightness(cfg, dev, str(percent)) - self._emit_cfg(cfg) - self.op_done.emit("brightness", True, f"Brightness {percent}%") - else: - cfg = self._cfg() - r, g, b = mod.hex_to_rgb(cfg["color"]) - intensity = mod.percent_to_intensity(percent) - mod.set_firmware_mode(dev, False) - mod.set_color(dev, r, g, b, intensity) - except PermissionError: - if persist: - self._run_cli(["brightness", str(percent)]) - self._emit_cfg(self._cfg()) - self.op_done.emit("brightness", True, f"Brightness {percent}% (pkexec)") + def _op_color(self, mod, hexcol, percent, persist): + try: + dev = self._ensure_device() + except SystemExit: return - - if op == "power": - (on,) = args - try: - dev = self._ensure_device() - except SystemExit: - return - cfg = self._cfg() - try: - with contextlib.redirect_stdout(io.StringIO()): - if on: - mod.cmd_restore(cfg, dev) - else: - mod.cmd_off(cfg, dev) + try: + if persist: + cfg = self._cfg() + mod.cmd_set(cfg, dev, hexcol, str(percent)) self._emit_cfg(cfg) - self.op_done.emit("power", True, "On" if on else "Off") - except PermissionError: - self._run_cli(["restore" if on else "off"]) + self.op_done.emit("color", True, f"Set #{hexcol} @ {percent}%") + else: + r, g, b = mod.hex_to_rgb(hexcol) + intensity = mod.percent_to_intensity(percent) + mod.set_firmware_mode(dev, False) + mod.set_color(dev, r, g, b, intensity) + except PermissionError: + if persist: + self._run_cli(["set", hexcol, str(percent)]) self._emit_cfg(self._cfg()) - self.op_done.emit("power", True, ("On" if on else "Off") + " (pkexec)") - return + self.op_done.emit("color", True, f"Set #{hexcol} @ {percent}% (pkexec)") + # preview: silently skip when not permitted - if op == "auto": - (on,) = args - try: - dev = self._ensure_device() - except SystemExit: - return - cfg = self._cfg() - try: - with contextlib.redirect_stdout(io.StringIO()): - mod.cmd_auto(cfg, dev, "on" if on else "off") - self._emit_cfg(cfg) - self.op_done.emit("auto", True, "Firmware mode " + ("on" if on else "off")) - except PermissionError: - self._run_cli(["auto", "on" if on else "off"]) - self._emit_cfg(self._cfg()) - self.op_done.emit("auto", True, "Firmware mode " + ("on" if on else "off") + " (pkexec)") + def _op_brightness(self, mod, percent, persist): + # Used by the tray (no on-screen color picker): keep the saved color. + try: + dev = self._ensure_device() + except SystemExit: return - - if op == "rainbow": - (on,) = args - try: - dev = self._ensure_device() - except SystemExit: - return - cfg = self._cfg() - try: - with contextlib.redirect_stdout(io.StringIO()): - mod.cmd_rainbow(cfg, dev, "on" if on else "off") + try: + if persist: + cfg = self._cfg() + mod.cmd_brightness(cfg, dev, str(percent)) self._emit_cfg(cfg) - self.op_done.emit("rainbow", True, "Rainbow " + ("on" if on else "off")) - except PermissionError: - # rainbow needs root for the ASUS WMI debugfs regardless of the vrgb group - self._run_cli(["rainbow", "on" if on else "off"]) + self.op_done.emit("brightness", True, f"Brightness {percent}%") + else: + cfg = self._cfg() + r, g, b = mod.hex_to_rgb(cfg["color"]) + intensity = mod.percent_to_intensity(percent) + mod.set_firmware_mode(dev, False) + mod.set_color(dev, r, g, b, intensity) + except PermissionError: + if persist: + self._run_cli(["brightness", str(percent)]) self._emit_cfg(self._cfg()) - self.op_done.emit("rainbow", True, "Rainbow " + ("on" if on else "off") + " (pkexec)") - except SystemExit: - self.op_done.emit("rainbow", False, "OEM rainbow not supported on this device") - return + self.op_done.emit("brightness", True, f"Brightness {percent}% (pkexec)") - if op == "profile_save": - (name,) = args - cfg = self._cfg() - with contextlib.redirect_stdout(io.StringIO()): - mod.cmd_profile_save(cfg, name) + def _op_power(self, mod, on): + try: + dev = self._ensure_device() + except SystemExit: + return + cfg = self._cfg() + try: + if on: + mod.cmd_restore(cfg, dev) + else: + mod.cmd_off(cfg, dev) self._emit_cfg(cfg) - self.op_done.emit("profile_save", True, f"Saved profile '{name}'") + self.op_done.emit("power", True, "On" if on else "Off") + except PermissionError: + self._run_cli(["restore" if on else "off"]) + self._emit_cfg(self._cfg()) + self.op_done.emit("power", True, ("On" if on else "Off") + " (pkexec)") + + def _op_auto(self, mod, on): + try: + dev = self._ensure_device() + except SystemExit: return + cfg = self._cfg() + try: + mod.cmd_auto(cfg, dev, "on" if on else "off") + self._emit_cfg(cfg) + self.op_done.emit("auto", True, "Firmware mode " + ("on" if on else "off")) + except PermissionError: + self._run_cli(["auto", "on" if on else "off"]) + self._emit_cfg(self._cfg()) + self.op_done.emit("auto", True, "Firmware mode " + ("on" if on else "off") + " (pkexec)") - if op == "profile_delete": - (name,) = args - cfg = self._cfg() - try: - with contextlib.redirect_stdout(io.StringIO()): - mod.cmd_profile_delete(cfg, name) - self._emit_cfg(cfg) - self.op_done.emit("profile_delete", True, f"Deleted profile '{name}'") - except SystemExit: - self.op_done.emit("profile_delete", False, f"Profile '{name}' not found") + def _op_rainbow(self, mod, on): + try: + dev = self._ensure_device() + except SystemExit: return + cfg = self._cfg() + try: + mod.cmd_rainbow(cfg, dev, "on" if on else "off") + self._emit_cfg(cfg) + self.op_done.emit("rainbow", True, "Rainbow " + ("on" if on else "off")) + except PermissionError: + self._run_cli(["rainbow", "on" if on else "off"]) + self._emit_cfg(self._cfg()) + self.op_done.emit("rainbow", True, "Rainbow " + ("on" if on else "off") + " (pkexec)") + except SystemExit: + self.op_done.emit("rainbow", False, "OEM rainbow not supported on this device") - if op == "profile_load": - (name,) = args - try: - dev = self._ensure_device() - except SystemExit: - return - cfg = self._cfg() - try: - with contextlib.redirect_stdout(io.StringIO()): - mod.cmd_profile_load(cfg, dev, name) - self._emit_cfg(cfg) - self.op_done.emit("profile_load", True, f"Loaded profile '{name}'") - except PermissionError: - self._run_cli(["profile", "load", name]) - self._emit_cfg(self._cfg()) - self.op_done.emit("profile_load", True, f"Loaded profile '{name}' (pkexec)") - except SystemExit: - self.op_done.emit("profile_load", False, f"Profile '{name}' not found") + def _op_profile_save(self, mod, name): + cfg = self._cfg() + mod.cmd_profile_save(cfg, name) + self._emit_cfg(cfg) + self.op_done.emit("profile_save", True, f"Saved profile '{name}'") + + def _op_profile_delete(self, mod, name): + cfg = self._cfg() + try: + mod.cmd_profile_delete(cfg, name) + self._emit_cfg(cfg) + self.op_done.emit("profile_delete", True, f"Deleted profile '{name}'") + except SystemExit: + self.op_done.emit("profile_delete", False, f"Profile '{name}' not found") + + def _op_profile_load(self, mod, name): + try: + dev = self._ensure_device() + except SystemExit: return + cfg = self._cfg() + try: + mod.cmd_profile_load(cfg, dev, name) + self._emit_cfg(cfg) + self.op_done.emit("profile_load", True, f"Loaded profile '{name}'") + except PermissionError: + self._run_cli(["profile", "load", name]) + self._emit_cfg(self._cfg()) + self.op_done.emit("profile_load", True, f"Loaded profile '{name}' (pkexec)") + except SystemExit: + self.op_done.emit("profile_load", False, f"Profile '{name}' not found") # ---------------------------------------------------------------------------- @@ -387,30 +383,28 @@ def paintEvent(self, _evt): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) cx, cy, r = self._geom() - rect_center = QPointF(cx, cy) + if r <= 0: + return + center = QPointF(cx, cy) - # Hue ring via conical gradient - hue_grad = QConicalGradient(rect_center, 0.0) + hue_grad = QConicalGradient(center, 0.0) for i in range(0, 361, 30): hue_grad.setColorAt(i / 360.0, QColor.fromHsvF((i % 360) / 360.0, 1.0, 1.0)) p.setPen(Qt.PenStyle.NoPen) p.setBrush(QBrush(hue_grad)) - p.drawEllipse(rect_center, r, r) + p.drawEllipse(center, r, r) - # Saturation: white center fading out - sat_grad = QRadialGradient(rect_center, r) + sat_grad = QRadialGradient(center, r) sat_grad.setColorAt(0.0, QColor(255, 255, 255, 255)) sat_grad.setColorAt(1.0, QColor(255, 255, 255, 0)) p.setBrush(QBrush(sat_grad)) - p.drawEllipse(rect_center, r, r) + p.drawEllipse(center, r, r) - # Darken the whole wheel to reflect current Value if self._value < 1.0: shade = int((1.0 - self._value) * 255) p.setBrush(QColor(0, 0, 0, shade)) - p.drawEllipse(rect_center, r, r) + p.drawEllipse(center, r, r) - # Selector marker angle = self._h * 2.0 * math.pi dist = self._s * r mx = cx + dist * math.cos(angle) @@ -423,10 +417,12 @@ def paintEvent(self, _evt): def _pick(self, pos): cx, cy, r = self._geom() + if r <= 0: + return dx = pos.x() - cx dy = cy - pos.y() dist = math.hypot(dx, dy) - self._s = min(1.0, dist / r) if r > 0 else 0.0 + self._s = min(1.0, dist / r) ang = math.atan2(dy, dx) if ang < 0: ang += 2.0 * math.pi @@ -435,14 +431,16 @@ def _pick(self, pos): self.hs_changed.emit(self._h, self._s) def mousePressEvent(self, e): - self._pick(e.position()) + if e.button() == Qt.MouseButton.LeftButton: + self._pick(e.position()) def mouseMoveEvent(self, e): if e.buttons() & Qt.MouseButton.LeftButton: self._pick(e.position()) - def mouseReleaseEvent(self, _e): - self.released.emit() + def mouseReleaseEvent(self, e): + if e.button() == Qt.MouseButton.LeftButton: + self.released.emit() # ---------------------------------------------------------------------------- @@ -463,7 +461,6 @@ def make_logo_icon(): ic = QIcon(cand) if not ic.isNull(): return ic - # Fallback: a small rainbow ring pixmap pm = QPixmap(64, 64) pm.fill(Qt.GlobalColor.transparent) p = QPainter(pm) @@ -487,8 +484,10 @@ def __init__(self, worker: DeviceWorker, mod): self.worker = worker self.mod = mod self._suppress = False # block feedback loops while we set widgets - self._interacting = False # user dragging color + self._interacting = False # user dragging a control self._devinfo = None + self._preset_btns = [] + self._dev_widgets = [] self.setWindowTitle("VRGB — Keyboard RGB") self.setWindowIcon(make_logo_icon()) @@ -500,12 +499,11 @@ def __init__(self, worker: DeviceWorker, mod): self._build_ui() self._wire_worker() - # Throttle timer for live preview during drags self._preview = QTimer(self) self._preview.setSingleShot(True) self._preview.setInterval(45) self._preview.timeout.connect(self._emit_preview) - self._pending = None # ("color"/"brightness", ...) + self._pending = None # ("color", hex, percent) self._load_from_cfg(cfg) self.worker.submit("detect") @@ -517,12 +515,10 @@ def _build_ui(self): root.setContentsMargins(14, 12, 14, 12) root.setSpacing(10) - # Status line self.status_lbl = QLabel("Detecting keyboard…") self.status_lbl.setStyleSheet("color:#999;") root.addWidget(self.status_lbl) - # --- Color row: wheel + value slider + swatch/hex --- color_box = QGroupBox("Color") cgrid = QGridLayout(color_box) @@ -546,7 +542,6 @@ def _build_ui(self): self.hex_edit.setPlaceholderText("#rrggbb") cgrid.addWidget(self.hex_edit, 2, 2) - # preset swatches preset_row = QHBoxLayout() for name, hexc in PRESETS: b = QPushButton() @@ -555,12 +550,12 @@ def _build_ui(self): b.setStyleSheet(f"background:#{hexc}; border:1px solid #444; border-radius:4px;") b.clicked.connect(lambda _=False, h=hexc: self._apply_hex("#" + h, commit=True)) preset_row.addWidget(b) + self._preset_btns.append(b) preset_row.addStretch(1) cgrid.addLayout(preset_row, 4, 0, 1, 3) root.addWidget(color_box) - # --- Brightness + power --- bbox = QGroupBox("Brightness & power") bl = QGridLayout(bbox) self.power_btn = QPushButton("Power") @@ -586,7 +581,6 @@ def _build_ui(self): root.addWidget(bbox) - # --- Profiles --- pbox = QGroupBox("Profiles") pl = QGridLayout(pbox) self.profile_list = QListWidget() @@ -602,6 +596,12 @@ def _build_ui(self): self.setCentralWidget(central) + # Device-dependent widgets, gated on detection + self._dev_widgets = [ + self.wheel, self.value_slider, self.hex_edit, + self.bright_slider, self.power_btn, self.auto_chk, + ] + self._preset_btns + # Signal wiring self.wheel.hs_changed.connect(self._on_wheel) self.wheel.released.connect(self._commit_color) @@ -609,7 +609,7 @@ def _build_ui(self): self.value_slider.sliderReleased.connect(self._commit_color) self.hex_edit.editingFinished.connect(lambda: self._apply_hex(self.hex_edit.text(), commit=True)) self.bright_slider.valueChanged.connect(self._on_brightness) - self.bright_slider.sliderReleased.connect(self._commit_brightness) + self.bright_slider.sliderReleased.connect(self._commit_color) self.power_btn.clicked.connect(self._on_power) self.auto_chk.toggled.connect(self._on_auto) self.rainbow_chk.toggled.connect(self._on_rainbow) @@ -629,7 +629,7 @@ def _load_from_cfg(self, cfg): self._color = QColor("#" + cfg.get("color", "aa00ff")) self._percent = int(cfg.get("percent", 100)) h, s, v, _ = self._color.getHsvF() - h = max(h, 0.0) + h = max(h, 0.0) # gray -> hue is -1; clamp self.wheel.set_hsv(h, s, v) self.value_slider.setValue(int(v * 100)) self.hex_edit.setText(self._color.name()) @@ -662,7 +662,7 @@ def _on_wheel(self, h, s): v = self.value_slider.value() / 100.0 self._color = QColor.fromHsvF(h, s, v) self._sync_color_widgets(update_wheel=False) - self._queue_preview("color") + self._queue_preview() def _on_value(self, val): if self._suppress: @@ -671,7 +671,7 @@ def _on_value(self, val): self._color = QColor.fromHsvF(max(h, 0.0), s, val / 100.0) self.wheel.set_value(val / 100.0) self._sync_color_widgets(update_wheel=False) - self._queue_preview("color") + self._queue_preview() def _apply_hex(self, text, commit=False): text = text.strip() @@ -695,25 +695,17 @@ def _sync_color_widgets(self, update_wheel): self._update_swatch() self._suppress = False - def _queue_preview(self, kind): + def _queue_preview(self): self._interacting = True - if kind == "color": - self._pending = ("color", self._current_hex(), self._percent) - else: - self._pending = ("brightness", self.bright_slider.value()) + self._pending = (self._current_hex(), self._percent) if not self._preview.isActive(): self._preview.start() def _emit_preview(self): if not self._pending: return - kind = self._pending[0] - if kind == "color": - _, hexc, pct = self._pending - self.worker.submit("color", hexc, pct, False) - else: - _, pct = self._pending - self.worker.submit("brightness", pct, False) + hexc, pct = self._pending + self.worker.submit("color", hexc, pct, False) self._pending = None def _commit_color(self): @@ -729,12 +721,8 @@ def _on_brightness(self, val): self._percent = val self.power_btn.setChecked(val > 0) self.power_btn.setText("On" if val > 0 else "Off") - self._queue_preview("brightness") - - def _commit_brightness(self): - self._preview.stop() - self._pending = None - self.worker.submit("brightness", self.bright_slider.value(), True) + # Brightness applies the on-screen color (not the last saved one). + self._queue_preview() def _on_power(self, checked): if self._suppress: @@ -756,6 +744,8 @@ def _on_rainbow(self, checked): def _profile_save(self): name, ok = QInputDialog.getText(self, "Save profile", "Profile name:") if ok and name.strip(): + # Persist the on-screen color/brightness first so the snapshot matches. + self._commit_color() self.worker.submit("profile_save", name.strip()) def _profile_load(self): @@ -778,7 +768,10 @@ def _on_op_done(self, op, ok, msg): def _on_device_status(self, devinfo, err): self._devinfo = devinfo - if devinfo: + present = devinfo is not None + for w in self._dev_widgets: + w.setEnabled(present) + if present: rainbow_ok = bool(devinfo.get("rainbow_supported", False)) self.rainbow_chk.setEnabled(rainbow_ok) if not rainbow_ok: @@ -787,14 +780,16 @@ def _on_device_status(self, devinfo, err): f"({devinfo.get('hid_id', '')})" ) self.status_lbl.setStyleSheet("color:#5c5;") - self.status_lbl.setText(f"● {devinfo.get('model', 'keyboard')} · {devinfo.get('path','')}") + self.status_lbl.setText( + f"● {devinfo.get('model', 'keyboard')} · {devinfo.get('path', '')}" + ) else: self.rainbow_chk.setEnabled(False) self.status_lbl.setStyleSheet("color:#d55;") self.status_lbl.setText("✗ " + (err or "Keyboard not found")) def _on_config_updated(self, cfg): - # Refresh non-interactive widgets; never yank the wheel mid-drag. + # Never yank the wheel/sliders mid-drag; just refresh the profile list. if self._interacting: self._reload_profiles(cfg) return @@ -814,14 +809,15 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): self.setToolTip("VRGB — keyboard RGB") menu = QMenu() - self.act_show = QAction("Show / hide window") + # Parent every QAction to `self` so none are garbage-collected. + self.act_show = QAction("Show / hide window", self) self.act_show.triggered.connect(self._toggle_window) menu.addAction(self.act_show) menu.addSeparator() - self.act_on = QAction("Turn on") + self.act_on = QAction("Turn on", self) self.act_on.triggered.connect(lambda: self.worker.submit("power", True)) - self.act_off = QAction("Turn off") + self.act_off = QAction("Turn off", self) self.act_off.triggered.connect(lambda: self.worker.submit("power", False)) menu.addAction(self.act_on) menu.addAction(self.act_off) @@ -837,9 +833,9 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): worker.config_updated.connect(self._rebuild_profiles) menu.addSeparator() - act_quit = QAction("Quit") - act_quit.triggered.connect(self._quit) - menu.addAction(act_quit) + self.act_quit = QAction("Quit", self) + self.act_quit.triggered.connect(self._quit) + menu.addAction(self.act_quit) self.setContextMenu(menu) self.activated.connect(self._on_activated) @@ -871,7 +867,11 @@ def _toggle_window(self): def _quit(self): self.worker.stop() - self.worker.wait(2000) + # Drain the worker fully (it may be inside a pkexec dialog) before quitting + # so the QThread is never destroyed while running. + if not self.worker.wait(3000): + self.worker.terminate() + self.worker.wait(1000) self.app.quit() @@ -880,46 +880,45 @@ def _quit(self): # ---------------------------------------------------------------------------- def main(): + app = QApplication(sys.argv) + app.setApplicationName("VRGB GUI") + app.setWindowIcon(make_logo_icon()) + try: - mod, core_path = load_core() + mod, _core_path = load_core() except FileNotFoundError as exc: - app = QApplication(sys.argv) QMessageBox.critical(None, "VRGB GUI", str(exc)) return 1 - app = QApplication(sys.argv) - app.setApplicationName("VRGB GUI") - app.setWindowIcon(make_logo_icon()) - app.setQuitOnLastWindowClosed(False) # live in the tray - - worker = DeviceWorker(mod, core_path) + worker = DeviceWorker(mod) worker.start() window = MainWindow(worker, mod) - tray_ok = QSystemTrayIcon.isSystemTrayAvailable() - tray = Tray(window, worker, app) if tray_ok else None + tray = Tray(window, worker, app) if QSystemTrayIcon.isSystemTrayAvailable() else None if tray: + # Live in the tray: closing the window only hides it. + app.setQuitOnLastWindowClosed(False) tray.show() - # Hide-to-tray on window close (if a tray exists); otherwise closing quits. - if tray: def close_event(e): e.ignore() window.hide() tray.showMessage("VRGB", "Still running in the tray.", QSystemTrayIcon.MessageIcon.Information, 2000) window.closeEvent = close_event + # else: no tray -> default quit-on-last-window-closed (True) so closing exits. - # Start with the window shown unless launched with --tray - if "--tray" not in sys.argv: - window.show() - elif not tray: + if "--tray" in sys.argv and tray: + pass # start hidden to the tray + else: window.show() rc = app.exec() worker.stop() - worker.wait(2000) + if not worker.wait(3000): + worker.terminate() + worker.wait(1000) return rc diff --git a/vrgb.py b/vrgb.py index c1fcc1d..040a5bd 100755 --- a/vrgb.py +++ b/vrgb.py @@ -27,12 +27,23 @@ def HIDIOCSFEATURE(length: int) -> int: def get_real_home() -> Path: + # Resolve the invoking user's home even when elevated, so config lives under + # the real user's ~/.config and not /root. sudo sets SUDO_USER; pkexec scrubs + # the environment but sets PKEXEC_UID. sudo_user = os.environ.get("SUDO_USER") if sudo_user: try: return Path(pwd.getpwnam(sudo_user).pw_dir) except KeyError: pass + + pkexec_uid = os.environ.get("PKEXEC_UID") + if pkexec_uid: + try: + return Path(pwd.getpwuid(int(pkexec_uid)).pw_dir) + except (KeyError, ValueError): + pass + return Path.home() From ab757a668e162365820699000d5305d63eaeafe9 Mon Sep 17 00:00:00 2001 From: Matt Warner Date: Tue, 2 Jun 2026 22:09:11 -0400 Subject: [PATCH 3/5] Tie the GUI brightness slider to the FN+F4/F3 keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard has two brightness layers that multiply: the firmware backlight (FN keys -> asus::kbd_backlight, 0..max) and vrgb's HID intensity byte. They were independent, so the slider and the FN keys ignored each other. The slider now exposes a single unified brightness B (0..100%), decomposed into a firmware step F and HID intensity I such that (F/max)*(I/255) == B — smooth and no double-dimming. Firmware writes go through logind SetBrightness (passwordless for the active session). A 300ms poll watches the firmware level so FN+F4/F3 move the slider and the HID intensity too, with a settle window to ignore self-induced changes. Degrades to pure-HID brightness when the LED node / logind is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 +- vrgb-gui.py | 215 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 168 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b2fdf91..dc99699 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,12 @@ config logic are shared with the command line — no duplicated device code. - HS color wheel + value slider, hex entry, and preset swatches - Live preview while you drag (throttled), persisted on release -- Brightness slider (0–100%) and a power on/off toggle +- Unified brightness slider (0–100%) that is **tied to the FN+F4 / FN+F3 keys**: it + decomposes brightness into the firmware backlight step (`asus::kbd_backlight`, set + via logind) and vrgb's HID intensity so the two layers never double-dim, and it + polls the firmware level so the hardware keys move the slider too. Falls back to + pure-HID brightness if the LED node / logind is unavailable. +- A power on/off toggle - Firmware/autonomous mode toggle - OEM rainbow toggle (auto-disabled on device mappings that do not support it) - Profile manager (save / load / delete) diff --git a/vrgb-gui.py b/vrgb-gui.py index c8fb561..eb09ae5 100755 --- a/vrgb-gui.py +++ b/vrgb-gui.py @@ -17,7 +17,18 @@ never runs a user-writable script as root. The CLI honours PKEXEC_UID so the elevated run reads/writes the invoking user's ~/.config/vrgb, not /root's. -State (color / brightness / profiles / autonomous) lives in ~/.config/vrgb/config.json, +Unified brightness: + The ASUS keyboard has TWO brightness layers that multiply: + * the firmware backlight (FN+F4 / FN+F3) -> /sys/class/leds/asus::kbd_backlight, + a coarse 0..max (max=3) level set via logind (passwordless for the active session); + * vrgb's HID "intensity" byte (0..255), the fine per-color scaling. + The slider exposes a single unified brightness B (0..100%). It is decomposed into a + firmware step F and an HID intensity I such that (F/max) * (I/255) == B, so the two + layers never double-dim. The firmware level is polled, so FN+F4/F3 move the slider + (and the HID intensity) too. If the LED node or logind is unavailable the GUI falls + back to pure-HID brightness (B == I). + +State (color / intensity / profiles / autonomous) lives in ~/.config/vrgb/config.json, read through the imported module's load_config(). """ @@ -25,6 +36,7 @@ import os import math import copy +import time import queue import subprocess import importlib.util @@ -112,6 +124,51 @@ def pkexec_target(): return str(p) +# ---------------------------------------------------------------------------- +# Firmware keyboard backlight (FN+F4 / FN+F3) via logind +# ---------------------------------------------------------------------------- + +class KbdBacklight: + """Reads/writes /sys/class/leds/asus::kbd_backlight. + + Reads come straight from sysfs (world-readable). Writes go through logind's + SetBrightness, which Polkit allows for the active local session without a + password. Returns gracefully degraded values when the node is absent. + """ + + PATH = Path("/sys/class/leds/asus::kbd_backlight") + LED_NAME = "asus::kbd_backlight" + + def __init__(self): + self.available = self.PATH.exists() + self.max = (self._read_int("max_brightness") or 0) if self.available else 0 + if self.max <= 0: + self.available = False + + def _read_int(self, name): + try: + return int((self.PATH / name).read_text().strip()) + except (OSError, ValueError): + return None + + def level(self): + return self._read_int("brightness") + + @staticmethod + def set_level(level): + try: + subprocess.run( + ["busctl", "call", "org.freedesktop.login1", + "/org/freedesktop/login1/session/auto", + "org.freedesktop.login1.Session", "SetBrightness", "ssu", + "leds", KbdBacklight.LED_NAME, str(int(level))], + check=True, capture_output=True, text=True, timeout=10, + ) + return True + except Exception: + return False + + # ---------------------------------------------------------------------------- # Worker thread: serializes all device I/O off the UI thread # ---------------------------------------------------------------------------- @@ -173,8 +230,6 @@ def _cfg(self): return self.mod.load_config() def _emit_cfg(self, cfg): - # Emit an owned snapshot (own the nested profiles dict too) so the GUI - # thread never iterates a dict the worker might later mutate. snap = dict(cfg) snap["profiles"] = copy.deepcopy(cfg.get("profiles", {})) self.config_updated.emit(snap) @@ -204,12 +259,11 @@ def _run_cli(self, cli_args): # -- per-op handlers -- def _dispatch(self, op, args): - mod = self.mod handler = getattr(self, f"_op_{op}", None) if handler is None: self.op_done.emit(op, False, f"unknown op '{op}'") return - handler(mod, *args) + handler(self.mod, *args) def _op_detect(self, mod): try: @@ -217,6 +271,10 @@ def _op_detect(self, mod): except SystemExit: pass + def _op_fwlevel(self, mod, level): + if not KbdBacklight.set_level(level): + self.op_done.emit("fwlevel", False, "Could not set keyboard backlight level") + def _op_color(self, mod, hexcol, percent, persist): try: dev = self._ensure_device() @@ -227,7 +285,7 @@ def _op_color(self, mod, hexcol, percent, persist): cfg = self._cfg() mod.cmd_set(cfg, dev, hexcol, str(percent)) self._emit_cfg(cfg) - self.op_done.emit("color", True, f"Set #{hexcol} @ {percent}%") + self.op_done.emit("color", True, f"Set #{hexcol}") else: r, g, b = mod.hex_to_rgb(hexcol) intensity = mod.percent_to_intensity(percent) @@ -237,32 +295,7 @@ def _op_color(self, mod, hexcol, percent, persist): if persist: self._run_cli(["set", hexcol, str(percent)]) self._emit_cfg(self._cfg()) - self.op_done.emit("color", True, f"Set #{hexcol} @ {percent}% (pkexec)") - # preview: silently skip when not permitted - - def _op_brightness(self, mod, percent, persist): - # Used by the tray (no on-screen color picker): keep the saved color. - try: - dev = self._ensure_device() - except SystemExit: - return - try: - if persist: - cfg = self._cfg() - mod.cmd_brightness(cfg, dev, str(percent)) - self._emit_cfg(cfg) - self.op_done.emit("brightness", True, f"Brightness {percent}%") - else: - cfg = self._cfg() - r, g, b = mod.hex_to_rgb(cfg["color"]) - intensity = mod.percent_to_intensity(percent) - mod.set_firmware_mode(dev, False) - mod.set_color(dev, r, g, b, intensity) - except PermissionError: - if persist: - self._run_cli(["brightness", str(percent)]) - self._emit_cfg(self._cfg()) - self.op_done.emit("brightness", True, f"Brightness {percent}% (pkexec)") + self.op_done.emit("color", True, f"Set #{hexcol} (pkexec)") def _op_power(self, mod, on): try: @@ -483,18 +516,24 @@ def __init__(self, worker: DeviceWorker, mod): super().__init__() self.worker = worker self.mod = mod + self.kbd = KbdBacklight() self._suppress = False # block feedback loops while we set widgets self._interacting = False # user dragging a control self._devinfo = None self._preset_btns = [] self._dev_widgets = [] + # Unified-brightness state + self._brightness_b = 100 # slider value (unified 0..100) + self._percent = 100 # HID intensity % sent to vrgb + self._expected_fw = self.kbd.level() if self.kbd.available else None + self._fw_ignore_until = 0.0 # monotonic deadline to ignore self-induced fw changes + self.setWindowTitle("VRGB — Keyboard RGB") self.setWindowIcon(make_logo_icon()) cfg = mod.load_config() self._color = QColor("#" + cfg.get("color", "aa00ff")) - self._percent = int(cfg.get("percent", 100)) self._build_ui() self._wire_worker() @@ -503,11 +542,42 @@ def __init__(self, worker: DeviceWorker, mod): self._preview.setSingleShot(True) self._preview.setInterval(45) self._preview.timeout.connect(self._emit_preview) - self._pending = None # ("color", hex, percent) + self._pending = None # (hex, hid_percent) self._load_from_cfg(cfg) + + # Poll the firmware backlight so FN+F4/F3 move the slider too. + if self.kbd.available: + self._fw_timer = QTimer(self) + self._fw_timer.setInterval(300) + self._fw_timer.timeout.connect(self._poll_firmware) + self._fw_timer.start() + self.worker.submit("detect") + # ---- unified brightness math ---- + def _levels_for(self, b): + """Unified brightness B (0..100) -> (firmware_level | None, hid_percent).""" + b = max(0, min(100, int(round(b)))) + if not self.kbd.available: + return None, b # pure HID + if b <= 0: + return 0, 0 + maxf = self.kbd.max + f = max(1, math.ceil(b / 100.0 * maxf)) # smallest fw step that can reach b + i = min(100, int(round(b * maxf / f))) # HID fills the gap: (f/maxf)*(i/100)=b/100 + return f, i + + def _b_from(self, fw_level, hid_percent): + if not self.kbd.available or fw_level is None: + return int(round(hid_percent)) + return int(round(fw_level * hid_percent / self.kbd.max)) + + def _set_firmware(self, level): + self._expected_fw = level + self._fw_ignore_until = time.monotonic() + 0.6 + self.worker.submit("fwlevel", level) + # -- UI construction -- def _build_ui(self): central = QWidget() @@ -566,9 +636,11 @@ def _build_ui(self): bl.addWidget(QLabel("Brightness"), 0, 1) self.bright_slider = QSlider(Qt.Orientation.Horizontal) self.bright_slider.setRange(0, 100) - self.bright_slider.setValue(self._percent) + self.bright_slider.setValue(self._brightness_b) + if self.kbd.available: + self.bright_slider.setToolTip("Unified brightness — also moves with FN+F4 / FN+F3") bl.addWidget(self.bright_slider, 0, 2) - self.bright_lbl = QLabel(f"{self._percent}%") + self.bright_lbl = QLabel(f"{self._brightness_b}%") self.bright_lbl.setMinimumWidth(40) bl.addWidget(self.bright_lbl, 0, 3) @@ -596,7 +668,6 @@ def _build_ui(self): self.setCentralWidget(central) - # Device-dependent widgets, gated on detection self._dev_widgets = [ self.wheel, self.value_slider, self.hex_edit, self.bright_slider, self.power_btn, self.auto_chk, @@ -627,17 +698,25 @@ def _wire_worker(self): def _load_from_cfg(self, cfg): self._suppress = True self._color = QColor("#" + cfg.get("color", "aa00ff")) - self._percent = int(cfg.get("percent", 100)) + self._percent = int(cfg.get("percent", 100)) # HID intensity % + + fw = self.kbd.level() if self.kbd.available else None + if self.kbd.available and fw is None: + fw = self.kbd.max + if fw is not None: + self._expected_fw = fw + self._brightness_b = self._b_from(fw, self._percent) + h, s, v, _ = self._color.getHsvF() h = max(h, 0.0) # gray -> hue is -1; clamp self.wheel.set_hsv(h, s, v) self.value_slider.setValue(int(v * 100)) self.hex_edit.setText(self._color.name()) self._update_swatch() - self.bright_slider.setValue(self._percent) - self.bright_lbl.setText(f"{self._percent}%") - self.power_btn.setChecked(self._percent > 0) - self.power_btn.setText("On" if self._percent > 0 else "Off") + self.bright_slider.setValue(self._brightness_b) + self.bright_lbl.setText(f"{self._brightness_b}%") + self.power_btn.setChecked(self._brightness_b > 0) + self.power_btn.setText("On" if self._brightness_b > 0 else "Off") self.auto_chk.setChecked(bool(cfg.get("autonomous", False))) self._reload_profiles(cfg) self._suppress = False @@ -717,13 +796,27 @@ def _commit_color(self): def _on_brightness(self, val): if self._suppress: return + # val is the unified brightness B; decompose into firmware step + HID intensity. + self._brightness_b = val + fw, hid = self._levels_for(val) + self._percent = hid self.bright_lbl.setText(f"{val}%") - self._percent = val self.power_btn.setChecked(val > 0) self.power_btn.setText("On" if val > 0 else "Off") - # Brightness applies the on-screen color (not the last saved one). + if fw is not None: + self._set_firmware(fw) self._queue_preview() + def set_brightness_external(self, b): + """Set unified brightness from the tray (or anywhere) and persist it.""" + b = max(0, min(100, int(b))) + if self.bright_slider.value() == b: + # value unchanged -> valueChanged won't fire; apply directly + self._on_brightness(b) + else: + self.bright_slider.setValue(b) # fires _on_brightness + self._commit_color() + def _on_power(self, checked): if self._suppress: return @@ -740,12 +833,34 @@ def _on_rainbow(self, checked): return self.worker.submit("rainbow", bool(checked)) + # -- firmware (FN+F4/F3) polling -- + def _poll_firmware(self): + if not self.kbd.available or self._interacting: + return + if time.monotonic() < self._fw_ignore_until: + return + cur = self.kbd.level() + if cur is None or cur == self._expected_fw: + return + # FN key changed the firmware level -> mirror it into the slider + HID. + self._expected_fw = cur + b = int(round(cur * 100 / self.kbd.max)) + self._suppress = True + self._brightness_b = b + self._percent = 100 # FN snaps the HID sub-step to full + self.bright_slider.setValue(b) + self.bright_lbl.setText(f"{b}%") + self.power_btn.setChecked(b > 0) + self.power_btn.setText("On" if b > 0 else "Off") + self._suppress = False + # Persist the matching color/intensity (keeps the current color). + self.worker.submit("color", self._current_hex(), self._percent, True) + # -- profiles -- def _profile_save(self): name, ok = QInputDialog.getText(self, "Save profile", "Profile name:") if ok and name.strip(): - # Persist the on-screen color/brightness first so the snapshot matches. - self._commit_color() + self._commit_color() # persist on-screen state first self.worker.submit("profile_save", name.strip()) def _profile_load(self): @@ -789,7 +904,6 @@ def _on_device_status(self, devinfo, err): self.status_lbl.setText("✗ " + (err or "Keyboard not found")) def _on_config_updated(self, cfg): - # Never yank the wheel/sliders mid-drag; just refresh the profile list. if self._interacting: self._reload_profiles(cfg) return @@ -809,7 +923,6 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): self.setToolTip("VRGB — keyboard RGB") menu = QMenu() - # Parent every QAction to `self` so none are garbage-collected. self.act_show = QAction("Show / hide window", self) self.act_show.triggered.connect(self._toggle_window) menu.addAction(self.act_show) @@ -825,7 +938,7 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): bmenu = menu.addMenu("Brightness") for pct in (25, 50, 75, 100): a = QAction(f"{pct}%", self) - a.triggered.connect(lambda _=False, p=pct: self.worker.submit("brightness", p, True)) + a.triggered.connect(lambda _=False, p=pct: self.window.set_brightness_external(p)) bmenu.addAction(a) self.profiles_menu = menu.addMenu("Profiles") @@ -867,8 +980,6 @@ def _toggle_window(self): def _quit(self): self.worker.stop() - # Drain the worker fully (it may be inside a pkexec dialog) before quitting - # so the QThread is never destroyed while running. if not self.worker.wait(3000): self.worker.terminate() self.worker.wait(1000) @@ -897,7 +1008,6 @@ def main(): tray = Tray(window, worker, app) if QSystemTrayIcon.isSystemTrayAvailable() else None if tray: - # Live in the tray: closing the window only hides it. app.setQuitOnLastWindowClosed(False) tray.show() @@ -907,7 +1017,6 @@ def close_event(e): tray.showMessage("VRGB", "Still running in the tray.", QSystemTrayIcon.MessageIcon.Information, 2000) window.closeEvent = close_event - # else: no tray -> default quit-on-last-window-closed (True) so closing exits. if "--tray" in sys.argv and tray: pass # start hidden to the tray From f5aa5a63d01b5ecfe6c9cd0760933c1c79b12bff Mon Sep 17 00:00:00 2001 From: Matt Warner Date: Tue, 2 Jun 2026 22:18:23 -0400 Subject: [PATCH 4/5] Add in-GUI autostart toggles + tray brightness slider & color picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'Start at login' section in the window with two checkboxes that create/remove the user XDG autostart entries (restore lighting / start tray), reflecting their current state — so autostart is opt-in per user, not forced by the installer. - Tray menu now embeds a live brightness slider and a color-swatch row plus a 'More colors…' QColorDialog. They drive the main window's controls, reusing the firmware decomposition, preview throttling and persistence. The tray slider re-syncs to the current brightness each time the menu opens. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- vrgb-gui.py | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dc99699..a7d97e8 100644 --- a/README.md +++ b/README.md @@ -188,8 +188,10 @@ config logic are shared with the command line — no duplicated device code. - Firmware/autonomous mode toggle - OEM rainbow toggle (auto-disabled on device mappings that do not support it) - Profile manager (save / load / delete) -- System-tray applet: quick on/off, brightness, and profile loading; closing the - window hides it to the tray +- "Start at login" toggles (restore lighting / start tray) managed from inside the app +- System-tray applet with an embedded brightness slider, a color-swatch row, a + "More colors…" dialog, on/off, and profile loading; closing the window hides it + to the tray - Falls back to a Polkit (`pkexec`) password prompt if the `vrgb` group is not yet active in your session (i.e. before the first logout/login after install) diff --git a/vrgb-gui.py b/vrgb-gui.py index eb09ae5..49953f8 100755 --- a/vrgb-gui.py +++ b/vrgb-gui.py @@ -76,6 +76,8 @@ QMenu, QSizePolicy, QFrame, + QWidgetAction, + QColorDialog, ) @@ -169,6 +171,78 @@ def set_level(level): return False +# ---------------------------------------------------------------------------- +# Login autostart (~/.config/autostart) — user-managed, no root needed +# ---------------------------------------------------------------------------- + +class Autostart: + """Create/remove the per-user XDG autostart .desktop entries. + + Two independent entries: + * 'restore' -> reapply the saved lighting at login (`vrgb restore`) + * 'tray' -> start the GUI minimised to the tray (`vrgb-gui --tray`) + """ + + DIR = Path.home() / ".config" / "autostart" + ENTRIES = { + "restore": { + "file": "vrgb.desktop", + "name": "VRGB Restore", + "comment": "Restore keyboard RGB state on login", + "exec": "/usr/local/bin/vrgb restore", + "icon": "vrgb", + }, + "tray": { + "file": "vrgb-gui.desktop", + "name": "VRGB (tray)", + "comment": "Keyboard RGB control tray applet", + "exec": "/usr/local/bin/vrgb-gui --tray", + "icon": "vrgb", + }, + } + + @classmethod + def path(cls, key): + return cls.DIR / cls.ENTRIES[key]["file"] + + @classmethod + def is_enabled(cls, key): + p = cls.path(key) + if not p.exists(): + return False + try: + low = p.read_text(errors="ignore").lower() + except OSError: + return False + if "hidden=true" in low: + return False + if "x-gnome-autostart-enabled=false" in low: + return False + return True + + @classmethod + def set_enabled(cls, key, enabled): + p = cls.path(key) + if enabled: + e = cls.ENTRIES[key] + cls.DIR.mkdir(parents=True, exist_ok=True) + p.write_text( + "[Desktop Entry]\n" + "Type=Application\n" + f"Name={e['name']}\n" + f"Comment={e['comment']}\n" + f"Exec={e['exec']}\n" + f"Icon={e['icon']}\n" + "Terminal=false\n" + "X-GNOME-Autostart-enabled=true\n" + ) + else: + try: + p.unlink() + except FileNotFoundError: + pass + + # ---------------------------------------------------------------------------- # Worker thread: serializes all device I/O off the UI thread # ---------------------------------------------------------------------------- @@ -545,6 +619,7 @@ def __init__(self, worker: DeviceWorker, mod): self._pending = None # (hex, hid_percent) self._load_from_cfg(cfg) + self._load_autostart_state() # Poll the firmware backlight so FN+F4/F3 move the slider too. if self.kbd.available: @@ -666,6 +741,16 @@ def _build_ui(self): pl.addWidget(self.btn_delete, 2, 1) root.addWidget(pbox) + sbox = QGroupBox("Start at login") + sgl = QVBoxLayout(sbox) + self.auto_restore_chk = QCheckBox("Restore my lighting at login") + self.auto_restore_chk.setToolTip("Runs `vrgb restore` on login (color can reset on a full power cycle)") + self.auto_tray_chk = QCheckBox("Start the tray icon at login") + self.auto_tray_chk.setToolTip("Launches this app minimised to the system tray on login") + sgl.addWidget(self.auto_restore_chk) + sgl.addWidget(self.auto_tray_chk) + root.addWidget(sbox) + self.setCentralWidget(central) self._dev_widgets = [ @@ -688,6 +773,8 @@ def _build_ui(self): self.btn_load.clicked.connect(self._profile_load) self.btn_delete.clicked.connect(self._profile_delete) self.profile_list.itemDoubleClicked.connect(lambda _i: self._profile_load()) + self.auto_restore_chk.toggled.connect(lambda on: self._toggle_autostart("restore", on)) + self.auto_tray_chk.toggled.connect(lambda on: self._toggle_autostart("tray", on)) def _wire_worker(self): self.worker.op_done.connect(self._on_op_done) @@ -833,6 +920,29 @@ def _on_rainbow(self, checked): return self.worker.submit("rainbow", bool(checked)) + # -- autostart -- + def _load_autostart_state(self): + self._suppress = True + self.auto_restore_chk.setChecked(Autostart.is_enabled("restore")) + self.auto_tray_chk.setChecked(Autostart.is_enabled("tray")) + self._suppress = False + + def _toggle_autostart(self, key, on): + if self._suppress: + return + try: + Autostart.set_enabled(key, on) + ok = True + except OSError as exc: + ok = False + err = str(exc) + label = "login restore" if key == "restore" else "tray autostart" + self.status_lbl.setStyleSheet("color:#5c5;" if ok else "color:#d55;") + self.status_lbl.setText( + (f"{'Enabled' if on else 'Disabled'} {label}") if ok + else f"✗ autostart: {err}" + ) + # -- firmware (FN+F4/F3) polling -- def _poll_firmware(self): if not self.kbd.available or self._interacting: @@ -920,6 +1030,7 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): self.window = window self.worker = worker self.app = app + self._tray_suppress = False self.setToolTip("VRGB — keyboard RGB") menu = QMenu() @@ -928,18 +1039,25 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): menu.addAction(self.act_show) menu.addSeparator() + # Embedded brightness slider + menu.addAction(self._caption("Brightness")) + menu.addAction(self._make_brightness_action()) + self.act_on = QAction("Turn on", self) self.act_on.triggered.connect(lambda: self.worker.submit("power", True)) self.act_off = QAction("Turn off", self) self.act_off.triggered.connect(lambda: self.worker.submit("power", False)) menu.addAction(self.act_on) menu.addAction(self.act_off) + menu.addSeparator() - bmenu = menu.addMenu("Brightness") - for pct in (25, 50, 75, 100): - a = QAction(f"{pct}%", self) - a.triggered.connect(lambda _=False, p=pct: self.window.set_brightness_external(p)) - bmenu.addAction(a) + # Embedded color picker (preset swatches + full dialog) + menu.addAction(self._caption("Color")) + menu.addAction(self._make_color_action()) + self.act_more = QAction("More colors…", self) + self.act_more.triggered.connect(self._pick_color) + menu.addAction(self.act_more) + menu.addSeparator() self.profiles_menu = menu.addMenu("Profiles") self._rebuild_profiles(window.mod.load_config()) @@ -950,9 +1068,76 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): self.act_quit.triggered.connect(self._quit) menu.addAction(self.act_quit) + menu.aboutToShow.connect(self._sync_controls) self.setContextMenu(menu) self.activated.connect(self._on_activated) + # -- embedded-widget builders -- + def _caption(self, text): + a = QWidgetAction(self) + lbl = QLabel(f" {text}") + lbl.setStyleSheet("color:#888; font-size:11px; padding:2px 0 0 0;") + a.setDefaultWidget(lbl) + a.setEnabled(False) + return a + + def _make_brightness_action(self): + a = QWidgetAction(self) + w = QWidget() + lay = QHBoxLayout(w) + lay.setContentsMargins(12, 2, 10, 6) + self.b_slider = QSlider(Qt.Orientation.Horizontal) + self.b_slider.setRange(0, 100) + self.b_slider.setMinimumWidth(170) + self.b_slider.valueChanged.connect(self._on_tray_brightness) + self.b_slider.sliderReleased.connect(self._commit_tray_brightness) + self.b_value = QLabel("--%") + self.b_value.setMinimumWidth(36) + lay.addWidget(self.b_slider) + lay.addWidget(self.b_value) + a.setDefaultWidget(w) + return a + + def _make_color_action(self): + a = QWidgetAction(self) + w = QWidget() + lay = QHBoxLayout(w) + lay.setContentsMargins(12, 2, 10, 6) + lay.setSpacing(4) + for name, hexc in PRESETS: + btn = QPushButton() + btn.setFixedSize(20, 20) + btn.setToolTip(name) + btn.setStyleSheet(f"background:#{hexc}; border:1px solid #444; border-radius:3px;") + btn.clicked.connect(lambda _=False, h=hexc: self.window._apply_hex("#" + h, commit=True)) + lay.addWidget(btn) + lay.addStretch(1) + a.setDefaultWidget(w) + return a + + # -- embedded-widget behavior -- + def _sync_controls(self): + self._tray_suppress = True + b = int(self.window._brightness_b) + self.b_slider.setValue(b) + self.b_value.setText(f"{b}%") + self._tray_suppress = False + + def _on_tray_brightness(self, v): + self.b_value.setText(f"{v}%") + if self._tray_suppress: + return + # Drive the main window slider, reusing its firmware decomposition + preview. + self.window.bright_slider.setValue(v) + + def _commit_tray_brightness(self): + self.window._commit_color() + + def _pick_color(self): + col = QColorDialog.getColor(self.window._color, self.window, "Pick keyboard color") + if col.isValid(): + self.window._apply_hex(col.name(), commit=True) + def _rebuild_profiles(self, cfg): self.profiles_menu.clear() names = sorted(cfg.get("profiles", {})) From 11bb2afe28dec9e4d4ce7536063a4bf832865d14 Mon Sep 17 00:00:00 2001 From: Matt Warner Date: Tue, 2 Jun 2026 22:23:53 -0400 Subject: [PATCH 5/5] Fix tray controls for KDE: use submenus instead of embedded widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KDE renders system-tray menus over DBusMenu, which silently drops QWidgetAction embedded widgets — the brightness slider, color swatch row and captions showed up blank. Replace them with standard, DBusMenu-renderable items: - Brightness submenu of discrete steps (10/25/50/75/100%), exclusive + checkable, with the step nearest the current brightness ticked on open. - Color submenu of preset swatches (colored QIcon) plus a 'More colors…' dialog. Both still route through the main window's controls (set_brightness_external / _apply_hex), so the firmware decomposition, preview and persistence are reused. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 ++-- vrgb-gui.py | 104 ++++++++++++++++++---------------------------------- 2 files changed, 40 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index a7d97e8..3c24a1f 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,11 @@ config logic are shared with the command line — no duplicated device code. - OEM rainbow toggle (auto-disabled on device mappings that do not support it) - Profile manager (save / load / delete) - "Start at login" toggles (restore lighting / start tray) managed from inside the app -- System-tray applet with an embedded brightness slider, a color-swatch row, a - "More colors…" dialog, on/off, and profile loading; closing the window hides it - to the tray +- System-tray applet: on/off, a Brightness submenu (discrete steps, current one + ticked), a Color submenu (preset swatches + a "More colors…" dialog), and profile + loading; closing the window hides it to the tray. (The tray uses submenus rather + than embedded widgets because KDE renders tray menus over DBusMenu, which does not + support embedded slider/widget items.) - Falls back to a Polkit (`pkexec`) password prompt if the `vrgb` group is not yet active in your session (i.e. before the first logout/login after install) diff --git a/vrgb-gui.py b/vrgb-gui.py index 49953f8..9f15689 100755 --- a/vrgb-gui.py +++ b/vrgb-gui.py @@ -54,6 +54,7 @@ QIcon, QPixmap, QAction, + QActionGroup, ) from PyQt6.QtWidgets import ( QApplication, @@ -581,6 +582,12 @@ def make_logo_icon(): return QIcon(pm) +def swatch_icon(hexc): + pm = QPixmap(16, 16) + pm.fill(QColor("#" + hexc)) + return QIcon(pm) + + # ---------------------------------------------------------------------------- # Main window # ---------------------------------------------------------------------------- @@ -1039,26 +1046,39 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): menu.addAction(self.act_show) menu.addSeparator() - # Embedded brightness slider - menu.addAction(self._caption("Brightness")) - menu.addAction(self._make_brightness_action()) - self.act_on = QAction("Turn on", self) self.act_on.triggered.connect(lambda: self.worker.submit("power", True)) self.act_off = QAction("Turn off", self) self.act_off.triggered.connect(lambda: self.worker.submit("power", False)) menu.addAction(self.act_on) menu.addAction(self.act_off) - menu.addSeparator() - # Embedded color picker (preset swatches + full dialog) - menu.addAction(self._caption("Color")) - menu.addAction(self._make_color_action()) + # Brightness as a submenu of discrete steps. KDE's tray menu is rendered over + # DBusMenu, which cannot host an embedded QSlider widget — only plain items. + self.bright_menu = menu.addMenu("Brightness") + self._bright_group = QActionGroup(self) + self._bright_group.setExclusive(True) + self._bright_actions = [] + for pct in (10, 25, 50, 75, 100): + a = QAction(f"{pct}%", self) + a.setCheckable(True) + a.triggered.connect(lambda _=False, p=pct: self.window.set_brightness_external(p)) + self._bright_group.addAction(a) + self.bright_menu.addAction(a) + self._bright_actions.append((pct, a)) + + # Color as a submenu of preset swatches (colored icons) + the full dialog. + self.color_menu = menu.addMenu("Color") + for name, hexc in PRESETS: + a = QAction(swatch_icon(hexc), name, self) + a.triggered.connect(lambda _=False, h=hexc: self.window._apply_hex("#" + h, commit=True)) + self.color_menu.addAction(a) + self.color_menu.addSeparator() self.act_more = QAction("More colors…", self) self.act_more.triggered.connect(self._pick_color) - menu.addAction(self.act_more) - menu.addSeparator() + self.color_menu.addAction(self.act_more) + menu.addSeparator() self.profiles_menu = menu.addMenu("Profiles") self._rebuild_profiles(window.mod.load_config()) worker.config_updated.connect(self._rebuild_profiles) @@ -1072,66 +1092,12 @@ def __init__(self, window: MainWindow, worker: DeviceWorker, app: QApplication): self.setContextMenu(menu) self.activated.connect(self._on_activated) - # -- embedded-widget builders -- - def _caption(self, text): - a = QWidgetAction(self) - lbl = QLabel(f" {text}") - lbl.setStyleSheet("color:#888; font-size:11px; padding:2px 0 0 0;") - a.setDefaultWidget(lbl) - a.setEnabled(False) - return a - - def _make_brightness_action(self): - a = QWidgetAction(self) - w = QWidget() - lay = QHBoxLayout(w) - lay.setContentsMargins(12, 2, 10, 6) - self.b_slider = QSlider(Qt.Orientation.Horizontal) - self.b_slider.setRange(0, 100) - self.b_slider.setMinimumWidth(170) - self.b_slider.valueChanged.connect(self._on_tray_brightness) - self.b_slider.sliderReleased.connect(self._commit_tray_brightness) - self.b_value = QLabel("--%") - self.b_value.setMinimumWidth(36) - lay.addWidget(self.b_slider) - lay.addWidget(self.b_value) - a.setDefaultWidget(w) - return a - - def _make_color_action(self): - a = QWidgetAction(self) - w = QWidget() - lay = QHBoxLayout(w) - lay.setContentsMargins(12, 2, 10, 6) - lay.setSpacing(4) - for name, hexc in PRESETS: - btn = QPushButton() - btn.setFixedSize(20, 20) - btn.setToolTip(name) - btn.setStyleSheet(f"background:#{hexc}; border:1px solid #444; border-radius:3px;") - btn.clicked.connect(lambda _=False, h=hexc: self.window._apply_hex("#" + h, commit=True)) - lay.addWidget(btn) - lay.addStretch(1) - a.setDefaultWidget(w) - return a - - # -- embedded-widget behavior -- def _sync_controls(self): - self._tray_suppress = True - b = int(self.window._brightness_b) - self.b_slider.setValue(b) - self.b_value.setText(f"{b}%") - self._tray_suppress = False - - def _on_tray_brightness(self, v): - self.b_value.setText(f"{v}%") - if self._tray_suppress: - return - # Drive the main window slider, reusing its firmware decomposition + preview. - self.window.bright_slider.setValue(v) - - def _commit_tray_brightness(self): - self.window._commit_color() + # Tick the brightness step nearest the current unified brightness. + b = self.window._brightness_b + nearest = min(self._bright_actions, key=lambda pa: abs(pa[0] - b))[1] + for _pct, a in self._bright_actions: + a.setChecked(a is nearest) def _pick_color(self): col = QColorDialog.getColor(self.window._color, self.window, "Pick keyboard color")