commit bbfc1f165bf7d0ac90371ab421d05d53b90f9403 Author: pongsn Date: Mon Jun 29 02:23:11 2026 -0500 Initial release: GPU mode tray app for Lenovo Legion PyQt6 system tray app for switching between Hybrid and NVIDIA-only GPU modes via lenovolegionlinux (legion_cli). Includes polkit policy for passwordless switching, KDE autostart entry, and install script. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67a4543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..519b790 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# gpu-mode-tray + +A lightweight KDE system tray application for switching GPU modes on Lenovo Legion laptops running the [lenovolegionlinux](https://github.com/johnfanv2/LenovoLegionLinux) driver. + +## Features + +- Shows current GPU mode in the system tray (chip icon: red/green split for Hybrid, solid green for NVIDIA only) +- Switch between **Hybrid** (AMD iGPU drives internal display, NVIDIA exposes HDMI) and **NVIDIA only** (dGPU drives everything) with one click +- No password prompt for the active desktop session (polkit `allow_active: yes`) +- Tracks pending mode switches before reboot — shows an orange badge on the icon and allows cancelling the switch +- Triggers a reboot prompt after switching; also offers "Reboot now" from the tray menu +- Installs itself as a KDE autostart entry + +## Compatibility + +| Component | Requirement | +|-----------|-------------| +| Hardware | Lenovo Legion laptop with MUX switch support via lenovolegionlinux | +| Init system | systemd (for `systemctl reboot`) | +| Desktop | Any DE with system tray support (KDE, XFCE, …). GNOME on Wayland requires the AppIndicator extension | +| Autostart | XDG-compliant DEs (`~/.config/autostart/`) | + +Not Arch or KDE specific — works on any systemd-based distro (Ubuntu, Fedora, openSUSE, Debian, etc.) as long as the lenovolegionlinux driver is installed. + +## Tested on + +- **Lenovo Legion 5 Pro Gen 7** (BIOS JUCN66WW) +- AMD Radeon 680M (iGPU) + NVIDIA GeForce RTX 3070 Ti Laptop GPU +- KDE Plasma 6.6 on Wayland, kernel 6.18, Manjaro Linux + +> **Note:** This machine requires `force=1` for the legion_laptop module and the `--donotexpecthwmon` flag for `legion_cli`. Other Legion models with full hwmon support may work without these workarounds — the commands would need adjusting in `main.py` and the sudoers/polkit config. + +## Dependencies + +- Python 3.8+ +- PyQt6 + - Arch/Manjaro: `sudo pacman -S python-pyqt6` + - Ubuntu/Debian: `sudo apt install python3-pyqt6` + - Fedora: `sudo dnf install python3-qt6` + - Or via pip: `pip install pyqt6` +- [lenovolegionlinux](https://github.com/johnfanv2/LenovoLegionLinux) driver (`legion_cli` must be in `$PATH`) +- polkit (`pkexec`) + +## Installation + +```bash +git clone https://github.com/pongsn/gpu-mode-tray.git +cd gpu-mode-tray +sudo bash install.sh +``` + +`install.sh` will: +1. Install the polkit policy to `/usr/share/polkit-1/actions/` (enables passwordless switching for the active session) +2. Install the app to `/usr/local/bin/gpu-mode-tray` +3. Install the autostart entry to `~/.config/autostart/` (starts on next login) + +## Usage + +```bash +gpu-mode-tray & +``` + +Right-click the tray icon to switch GPU mode. A reboot is required for any mode change to take effect — this is a hardware MUX limitation, same as on Windows. diff --git a/com.lenovolegion.gpumodetray.policy b/com.lenovolegion.gpumodetray.policy new file mode 100644 index 0000000..da2c124 --- /dev/null +++ b/com.lenovolegion.gpumodetray.policy @@ -0,0 +1,17 @@ + + + + + Switch GPU mode (Hybrid / NVIDIA only) + Authentication is required to switch the GPU mode. A reboot will be needed. + + auth_admin + auth_admin + yes + + /usr/bin/legion_cli + true + + diff --git a/gpu-mode-tray.desktop b/gpu-mode-tray.desktop new file mode 100644 index 0000000..d1b5bfb --- /dev/null +++ b/gpu-mode-tray.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=GPU Mode Tray +Comment=GPU mode switcher for Lenovo Legion +Exec=/usr/local/bin/gpu-mode-tray +Icon=preferences-desktop-display +X-KDE-autostart-after=panel diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..427c3ff --- /dev/null +++ b/install.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +POLICY_FILE="com.lenovolegion.gpumodetray.policy" +POLICY_DST="/usr/share/polkit-1/actions/$POLICY_FILE" +APP_DST="/usr/local/bin/gpu-mode-tray" +AUTOSTART_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/autostart" +DESKTOP_FILE="gpu-mode-tray.desktop" + +echo "Installing polkit policy -> $POLICY_DST" +sudo install -m 644 "$POLICY_FILE" "$POLICY_DST" + +echo "Installing app -> $APP_DST" +sudo install -m 755 main.py "$APP_DST" + +echo "Installing autostart entry -> $AUTOSTART_DIR/$DESKTOP_FILE" +mkdir -p "$AUTOSTART_DIR" +install -m 644 "$DESKTOP_FILE" "$AUTOSTART_DIR/$DESKTOP_FILE" + +echo "Done. Run now with: gpu-mode-tray" +echo "Will autostart on next login." diff --git a/main.py b/main.py new file mode 100644 index 0000000..f2ce247 --- /dev/null +++ b/main.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +import sys +import subprocess +from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QMessageBox +from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QAction, QPen +from PyQt6.QtCore import Qt, QTimer, QRect + +MODE_LABELS = { + 'hybrid': 'Hybrid (AMD + NVIDIA)', + 'dgpu': 'NVIDIA only', + 'unknown': 'Unknown', +} + +SWITCH_LABELS = { + 'hybrid': 'Switch to Hybrid (AMD + NVIDIA)', + 'dgpu': 'Switch to NVIDIA only', +} + +AMD_RED = QColor('#EF5350') +NV_GREEN = QColor('#66BB6A') +GREY = QColor('#9E9E9E') +BADGE = QColor('#FFA726') + + +def get_mode(): + try: + r = subprocess.run( + ['legion_cli', '--donotexpecthwmon', 'hybrid-mode-status'], + capture_output=True, text=True, timeout=5, + ) + out = r.stdout + r.stderr + if 'True' in out: + return 'hybrid' + if 'False' in out: + return 'dgpu' + except Exception: + pass + return 'unknown' + + +def switch_mode(target): + cmd = 'hybrid-mode-enable' if target == 'hybrid' else 'hybrid-mode-disable' + r = subprocess.run( + ['pkexec', '/usr/bin/legion_cli', '--donotexpecthwmon', cmd], + capture_output=True, text=True, timeout=30, + ) + return r.returncode == 0 + + +def _draw_chip(painter, color, clip=None): + if clip: + painter.save() + painter.setClipRect(clip) + pen = QPen(color, 1.5, Qt.PenStyle.SolidLine, + Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + # Body + painter.drawRoundedRect(5, 5, 12, 12, 2, 2) + # Left pins + painter.drawRect(2, 8, 3, 2) + painter.drawRect(2, 12, 3, 2) + # Right pins + painter.drawRect(17, 8, 3, 2) + painter.drawRect(17, 12, 3, 2) + # Top pins + painter.drawRect(8, 2, 2, 3) + painter.drawRect(12, 2, 2, 3) + # Bottom pins + painter.drawRect(8, 17, 2, 3) + painter.drawRect(12, 17, 2, 3) + if clip: + painter.restore() + + +def make_icon(mode, pending=False): + size = 22 + px = QPixmap(size, size) + px.fill(Qt.GlobalColor.transparent) + p = QPainter(px) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + if mode == 'hybrid': + _draw_chip(p, AMD_RED, QRect(0, 0, 11, size)) + _draw_chip(p, NV_GREEN, QRect(11, 0, 11, size)) + elif mode == 'dgpu': + _draw_chip(p, NV_GREEN) + else: + _draw_chip(p, GREY) + + if pending: + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(BADGE) + p.drawEllipse(15, 0, 6, 6) + + p.end() + return QIcon(px) + + +class GpuModeTray: + def __init__(self): + self.app = QApplication(sys.argv) + self.app.setQuitOnLastWindowClosed(False) + + self.active_mode = get_mode() + self.pending_mode = None + self.tray = QSystemTrayIcon(make_icon(self.active_mode)) + self._build_menu() + self.tray.setContextMenu(self.menu) + self.tray.show() + + self.timer = QTimer() + self.timer.timeout.connect(self._refresh) + self.timer.start(30_000) + + def _build_menu(self): + self.menu = QMenu() + + self.status_item = QAction() + self.status_item.setEnabled(False) + self.menu.addAction(self.status_item) + self.menu.addSeparator() + + self.hybrid_action = QAction() + self.hybrid_action.triggered.connect(lambda: self._request_switch('hybrid')) + self.menu.addAction(self.hybrid_action) + + self.dgpu_action = QAction() + self.dgpu_action.triggered.connect(lambda: self._request_switch('dgpu')) + self.menu.addAction(self.dgpu_action) + + self.sep_pending = self.menu.addSeparator() + self.cancel_action = QAction() + self.cancel_action.triggered.connect(self._cancel_switch) + self.menu.addAction(self.cancel_action) + + self.reboot_action = QAction('Reboot now') + self.reboot_action.triggered.connect(lambda: subprocess.run(['systemctl', 'reboot'])) + self.menu.addAction(self.reboot_action) + + self.menu.addSeparator() + self.exit_action = QAction('Exit') + self.exit_action.triggered.connect(self.app.quit) + self.menu.addAction(self.exit_action) + + self._sync_ui() + + def _sync_ui(self): + pending = self.pending_mode is not None + active_label = MODE_LABELS[self.active_mode] + + if pending: + pending_label = MODE_LABELS[self.pending_mode] + self.status_item.setText(f'GPU mode: {active_label}') + self.tray.setToolTip(f'GPU mode: {active_label} → {pending_label} (pending reboot)') + else: + self.status_item.setText(f'GPU mode: {active_label}') + self.tray.setToolTip(f'GPU mode: {active_label}') + + self.tray.setIcon(make_icon(self.active_mode, pending=pending)) + + # Switch action labels and enabled state + for key, action in [('hybrid', self.hybrid_action), ('dgpu', self.dgpu_action)]: + if pending and key == self.pending_mode: + action.setText(f'{MODE_LABELS[key]} (pending reboot)') + action.setEnabled(False) + elif pending: + action.setText(SWITCH_LABELS[key]) + action.setEnabled(False) + else: + action.setText(SWITCH_LABELS[key]) + action.setEnabled(self.active_mode != key) + + self.sep_pending.setVisible(pending) + self.cancel_action.setVisible(pending) + self.reboot_action.setVisible(pending) + if pending: + self.cancel_action.setText(f'Cancel switch to {MODE_LABELS[self.pending_mode]}') + + def _refresh(self): + mode = get_mode() + if mode != self.active_mode: + self.active_mode = mode + self.pending_mode = None + self._sync_ui() + + def _request_switch(self, target): + label = MODE_LABELS[target] + if QMessageBox.question( + None, + 'Switch GPU mode', + f'Switch to {label}?

A reboot is required for the change to take effect.', + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) != QMessageBox.StandardButton.Ok: + return + + if not switch_mode(target): + QMessageBox.warning(None, 'GPU mode', 'Failed to switch GPU mode.\nCheck polkit authentication.') + return + + self.pending_mode = target + self._sync_ui() + + self.tray.showMessage( + 'GPU mode switch pending', + f'Will switch to {label} after reboot. Use the tray menu to cancel.', + QSystemTrayIcon.MessageIcon.Information, + 6000, + ) + + if QMessageBox.question( + None, + 'Reboot required', + f'GPU mode will switch to {label} after reboot.

Reboot now?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) == QMessageBox.StandardButton.Yes: + subprocess.run(['systemctl', 'reboot']) + + def _cancel_switch(self): + if not self.pending_mode: + return + if not switch_mode(self.active_mode): + QMessageBox.warning(None, 'GPU mode', 'Failed to revert GPU mode.\nCheck polkit authentication.') + return + self.pending_mode = None + self._sync_ui() + self.tray.showMessage( + 'GPU mode switch cancelled', + 'No changes will be applied on reboot.', + QSystemTrayIcon.MessageIcon.Information, + 4000, + ) + + def run(self): + sys.exit(self.app.exec()) + + +if __name__ == '__main__': + GpuModeTray().run()