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 <noreply@anthropic.com>
This commit is contained in:
commit
bbfc1f165b
6 changed files with 351 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
63
README.md
Normal file
63
README.md
Normal file
|
|
@ -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.
|
||||||
17
com.lenovolegion.gpumodetray.policy
Normal file
17
com.lenovolegion.gpumodetray.policy
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE policyconfig PUBLIC
|
||||||
|
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||||
|
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||||
|
<policyconfig>
|
||||||
|
<action id="com.lenovolegion.gpumodetray.switch">
|
||||||
|
<description>Switch GPU mode (Hybrid / NVIDIA only)</description>
|
||||||
|
<message>Authentication is required to switch the GPU mode. A reboot will be needed.</message>
|
||||||
|
<defaults>
|
||||||
|
<allow_any>auth_admin</allow_any>
|
||||||
|
<allow_inactive>auth_admin</allow_inactive>
|
||||||
|
<allow_active>yes</allow_active>
|
||||||
|
</defaults>
|
||||||
|
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/legion_cli</annotate>
|
||||||
|
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
|
||||||
|
</action>
|
||||||
|
</policyconfig>
|
||||||
7
gpu-mode-tray.desktop
Normal file
7
gpu-mode-tray.desktop
Normal file
|
|
@ -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
|
||||||
21
install.sh
Normal file
21
install.sh
Normal file
|
|
@ -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."
|
||||||
239
main.py
Normal file
239
main.py
Normal file
|
|
@ -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 <b>{label}</b>?<br><br>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 <b>{label}</b> after reboot.<br><br>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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue