diff --git a/lib/solaar/game_profile_detector.py b/lib/solaar/game_profile_detector.py new file mode 100644 index 00000000..87388c88 --- /dev/null +++ b/lib/solaar/game_profile_detector.py @@ -0,0 +1,164 @@ +import json +import logging +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import psutil + +CONFIG_PATH = Path.home() / ".config" / "solaar" / "game_dpi_profiles.json" +POLL_SECONDS = 1.0 +DEBOUNCE_REQUIRED = 2 +COOLDOWN_SECONDS = 5.0 +PROFILE_CHOICES = {f"profile {i}": f"Profile {i}" for i in range(1, 6)} + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("solaar-game-profile-detector") + + +def _load_config(): + try: + return json.loads(CONFIG_PATH.read_text()) + except Exception: + return {} + + +def _alive_pids(pids): + alive = set() + for pid in pids: + if psutil.pid_exists(pid): + alive.add(pid) + return alive + + +def _iter_processes(): + for proc in psutil.process_iter(["pid", "name", "cmdline"]): + try: + info = proc.info + name = (info.get("name") or "").lower() + cmdline = [part.lower() for part in (info.get("cmdline") or [])] + yield info.get("pid"), name, cmdline + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + +def _match_alias(alias, proc_name, cmdline): + alias = alias.lower() + joined = " ".join(cmdline) + if alias == "minecraft": + return proc_name == "java" and "mojang" in joined + if alias == proc_name: + return True + if alias in proc_name: + return True + return alias in joined + + +def _find_target(config): + for device_name, device_cfg in config.items(): + profiles = device_cfg.get("profiles", []) + for profile in profiles: + if profile.get("is_default"): + continue + aliases = profile.get("aliases", []) + if not aliases: + continue + matched_pids = set() + for pid, proc_name, cmdline in _iter_processes(): + if any(_match_alias(alias, proc_name, cmdline) for alias in aliases): + matched_pids.add(pid) + if matched_pids: + return { + "device_name": device_name, + "profile_name": profile.get("name") or profile.get("onboard_profile"), + "onboard_profile": PROFILE_CHOICES.get(profile.get("onboard_profile", "").lower(), profile.get("onboard_profile", "Profile 1")), + "matched_pids": matched_pids, + } + return None + + +def _default_for_device(config, device_name): + profiles = config.get(device_name, {}).get("profiles", []) + for profile in profiles: + if profile.get("is_default"): + return { + "device_name": device_name, + "profile_name": profile.get("name") or profile.get("onboard_profile"), + "onboard_profile": PROFILE_CHOICES.get(profile.get("onboard_profile", "").lower(), profile.get("onboard_profile", "Profile 1")), + } + return None + + +def _solaar_cmd(): + return shutil.which("solaar") or str(Path.home() / ".local" / "bin" / "solaar") + + +def _apply_profile(binding): + cmd = [_solaar_cmd(), "config", binding["device_name"], "onboard_profiles", binding["onboard_profile"]] + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def main(): + last_applied = None + active_game = None + last_switch = 0.0 + pending_key = None + pending_hits = 0 + + while True: + config = _load_config() + now = time.monotonic() + + if active_game is not None: + alive = _alive_pids(active_game.get("matched_pids", set())) + if alive: + active_game["matched_pids"] = alive + time.sleep(POLL_SECONDS) + continue + default_binding = _default_for_device(config, active_game["device_name"]) + if default_binding and default_binding != last_applied and now - last_switch >= COOLDOWN_SECONDS: + _apply_profile(default_binding) + logger.info(f"reverted {default_binding['device_name']} to {default_binding['onboard_profile']}") + last_applied = default_binding + last_switch = now + active_game = None + pending_key = None + pending_hits = 0 + time.sleep(POLL_SECONDS) + continue + + target = _find_target(config) + if target is None: + pending_key = None + pending_hits = 0 + time.sleep(POLL_SECONDS) + continue + + key = (target["device_name"], target["onboard_profile"]) + if key == pending_key: + pending_hits += 1 + else: + pending_key = key + pending_hits = 1 + + if pending_hits >= DEBOUNCE_REQUIRED and target != last_applied and now - last_switch >= COOLDOWN_SECONDS: + _apply_profile(target) + logger.info(f"activated {target['device_name']} -> {target['onboard_profile']} for {target['profile_name']}") + active_game = target + last_applied = { + "device_name": target["device_name"], + "profile_name": target["profile_name"], + "onboard_profile": target["onboard_profile"], + } + last_switch = now + pending_key = None + pending_hits = 0 + + time.sleep(POLL_SECONDS) + + +if __name__ == "__main__": + main() diff --git a/lib/solaar/ui/game_profiles.py b/lib/solaar/ui/game_profiles.py new file mode 100644 index 00000000..2fbd3760 --- /dev/null +++ b/lib/solaar/ui/game_profiles.py @@ -0,0 +1,255 @@ +import json +import subprocess +import sys +from pathlib import Path + +import gi + +from solaar.i18n import _ + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +CONFIG_PATH = Path.home() / ".config" / "solaar" / "game_dpi_profiles.json" +SERVICE_PATH = Path.home() / ".config" / "systemd" / "user" / "solaar-game-profile-detector.service" +PROFILE_CHOICES = [f"Profile {i}" for i in range(1, 6)] +SERVICE_TEMPLATE = """[Unit] +Description=Solaar Game Profile Detector +After=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/solaar-game-profile-detector +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target +""" + + +def _run_systemctl(*args): + return subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True) + + +def _load_config(): + try: + return json.loads(CONFIG_PATH.read_text()) + except Exception: + return {} + + +def _save_config(data): + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def _device_key(device): + return getattr(device, "name", None) or getattr(device, "codename", None) or "Unknown Device" + + +def _service_status_text(): + active = _run_systemctl("is-active", "solaar-game-profile-detector.service") + enabled = _run_systemctl("is-enabled", "solaar-game-profile-detector.service") + return f"{_('Status')}: {active.stdout.strip() or active.stderr.strip() or 'unknown'} / {_('Autostart')}: {enabled.stdout.strip() or enabled.stderr.strip() or 'disabled'}" + + +def _service_enabled(): + result = _run_systemctl("is-enabled", "solaar-game-profile-detector.service") + return result.returncode == 0 and result.stdout.strip() == "enabled" + + +class _GameProfilesDialog(Gtk.Dialog): + def __init__(self, parent, device): + super().__init__(title=_("Game DPI Profiles"), transient_for=parent, flags=0) + self.device = device + self.set_modal(True) + self.set_default_size(760, 420) + self.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) + self.add_button(_("Save"), Gtk.ResponseType.OK) + + area = self.get_content_area() + area.set_spacing(10) + area.set_margin_top(10) + area.set_margin_bottom(10) + area.set_margin_start(10) + area.set_margin_end(10) + + intro = Gtk.Label(label=_("Bind running games to onboard mouse profiles. Default profile is restored when the matched game exits.")) + intro.set_xalign(0) + intro.set_line_wrap(True) + area.pack_start(intro, False, False, 0) + + service_frame = Gtk.Frame(label=_("Background detector")) + service_box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 8) + service_box.set_border_width(8) + self.status_label = Gtk.Label(label=_service_status_text()) + self.status_label.set_xalign(0) + service_box.pack_start(self.status_label, False, False, 0) + + self.autostart = Gtk.CheckButton(label=_("Start detector on login")) + self.autostart.set_active(_service_enabled()) + service_box.pack_start(self.autostart, False, False, 0) + + btn_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6) + for label, callback in [ + (_("Install/repair service"), self._install_service), + (_("Start detector"), self._start_service), + (_("Stop detector"), self._stop_service), + (_("Refresh status"), self._refresh_status), + ]: + btn = Gtk.Button(label=label) + btn.connect("clicked", callback) + btn_box.pack_start(btn, False, False, 0) + service_box.pack_start(btn_box, False, False, 0) + service_frame.add(service_box) + area.pack_start(service_frame, False, False, 0) + + self.store = Gtk.ListStore(str, str, str, bool) + self._load_rows() + + tree = Gtk.TreeView(model=self.store) + tree.set_hexpand(True) + tree.set_vexpand(True) + self.tree = tree + + name_renderer = Gtk.CellRendererText() + name_renderer.set_property("editable", True) + name_renderer.connect("edited", self._on_name_edited) + tree.append_column(Gtk.TreeViewColumn(_("Name"), name_renderer, text=0)) + + profile_renderer = Gtk.CellRendererCombo() + profile_renderer.set_property("editable", True) + profile_renderer.set_property("has-entry", False) + combo_model = Gtk.ListStore(str) + for choice in PROFILE_CHOICES: + combo_model.append([choice]) + profile_renderer.set_property("model", combo_model) + profile_renderer.set_property("text-column", 0) + profile_renderer.connect("edited", self._on_profile_edited) + tree.append_column(Gtk.TreeViewColumn(_("Onboard profile"), profile_renderer, text=1)) + + aliases_renderer = Gtk.CellRendererText() + aliases_renderer.set_property("editable", True) + aliases_renderer.connect("edited", self._on_aliases_edited) + tree.append_column(Gtk.TreeViewColumn(_("Game aliases"), aliases_renderer, text=2)) + + default_renderer = Gtk.CellRendererToggle() + default_renderer.connect("toggled", self._on_default_toggled) + tree.append_column(Gtk.TreeViewColumn(_("Desktop default"), default_renderer, active=3)) + + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroller.add(tree) + area.pack_start(scroller, True, True, 0) + + row_buttons = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6) + add_btn = Gtk.Button(label=_("Add binding")) + add_btn.connect("clicked", self._add_row) + remove_btn = Gtk.Button(label=_("Remove selected")) + remove_btn.connect("clicked", self._remove_selected) + row_buttons.pack_start(add_btn, False, False, 0) + row_buttons.pack_start(remove_btn, False, False, 0) + area.pack_start(row_buttons, False, False, 0) + + self.show_all() + + def _device_config(self): + data = _load_config() + key = _device_key(self.device) + return data, key, data.get(key, {}) + + def _load_rows(self): + self.store.clear() + _data, _key, device_cfg = self._device_config() + profiles = device_cfg.get("profiles", []) + if not profiles: + self.store.append([_("Desktop"), "Profile 1", "", True]) + self.store.append([_("Minecraft"), "Profile 2", "minecraft", False]) + return + for profile in profiles: + self.store.append([ + profile.get("name", ""), + profile.get("onboard_profile", "Profile 1"), + ",".join(profile.get("aliases", [])), + bool(profile.get("is_default", False)), + ]) + + def _refresh_status(self, *_args): + self.status_label.set_text(_service_status_text()) + self.autostart.set_active(_service_enabled()) + + def _install_service(self, *_args): + SERVICE_PATH.parent.mkdir(parents=True, exist_ok=True) + SERVICE_PATH.write_text(SERVICE_TEMPLATE) + _run_systemctl("daemon-reload") + self._refresh_status() + + def _start_service(self, *_args): + self._apply_autostart_choice() + _run_systemctl("start", "solaar-game-profile-detector.service") + self._refresh_status() + + def _stop_service(self, *_args): + _run_systemctl("stop", "solaar-game-profile-detector.service") + self._refresh_status() + + def _apply_autostart_choice(self): + if self.autostart.get_active(): + _run_systemctl("enable", "solaar-game-profile-detector.service") + else: + _run_systemctl("disable", "solaar-game-profile-detector.service") + + def _add_row(self, *_args): + self.store.append([_("New profile"), "Profile 1", "", False]) + + def _remove_selected(self, *_args): + selection = self.tree.get_selection() + model, treeiter = selection.get_selected() + if treeiter is not None: + model.remove(treeiter) + + def _on_name_edited(self, _renderer, path, text): + self.store[path][0] = text + + def _on_profile_edited(self, _renderer, path, text): + self.store[path][1] = text + + def _on_aliases_edited(self, _renderer, path, text): + self.store[path][2] = text + + def _on_default_toggled(self, _renderer, path): + for idx, row in enumerate(self.store): + row[3] = str(idx) == path + + def save(self): + self._apply_autostart_choice() + data, key, _device_cfg = self._device_config() + profiles = [] + has_default = False + for row in self.store: + name = row[0].strip() + onboard_profile = row[1].strip() or "Profile 1" + aliases = [a.strip().lower() for a in row[2].split(",") if a.strip()] + is_default = bool(row[3]) + has_default = has_default or is_default + profiles.append({ + "name": name or onboard_profile, + "onboard_profile": onboard_profile, + "aliases": aliases, + "is_default": is_default, + }) + if profiles and not has_default: + profiles[0]["is_default"] = True + data[key] = {"profiles": profiles} + _save_config(data) + + +def show(parent, device): + if device is None: + return + dialog = _GameProfilesDialog(parent, device) + response = dialog.run() + if response == Gtk.ResponseType.OK: + dialog.save() + dialog.destroy() diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py index a9f117e1..e1119aee 100644 --- a/lib/solaar/ui/window.py +++ b/lib/solaar/ui/window.py @@ -34,6 +34,7 @@ from solaar.i18n import ngettext from . import action from . import config_panel from . import diversion_rules +from . import game_profiles from . import icons from .about import about from .common import ui_async @@ -327,6 +328,14 @@ def _create_window_layout(): bottom_buttons_box.add(quit_button) about_button = _new_button(_("About %s") % NAME, "help-about", _SMALL_BUTTON_ICON_SIZE, clicked=about.show) bottom_buttons_box.add(about_button) + game_profiles_button = _new_button( + _("Game DPI Profiles"), + "applications-games", + _SMALL_BUTTON_ICON_SIZE, + tooltip=_("Configure automatic onboard profile switching for games"), + clicked=lambda *_trigger: game_profiles.show(_window, _find_selected_device()), + ) + bottom_buttons_box.add(game_profiles_button) diversion_button = _new_button( _("Rule Editor"), "", _SMALL_BUTTON_ICON_SIZE, clicked=lambda *_trigger: diversion_rules.show_window(_model) ) @@ -378,6 +387,16 @@ def _find_selected_device_id(): return _model.get_value(item, Column.PATH), _model.get_value(item, Column.NUMBER) +def _device_supports_game_profiles(device): + if not device or getattr(device, "kind", None) is None: + return False + settings = getattr(device, "settings", None) or [] + for setting in settings: + if getattr(setting, "name", None) == "onboard_profiles": + return True + return False + + # triggered by changing selection in the tree def _device_selected(selection): model, item = selection.get_selected() @@ -755,6 +774,14 @@ def _update_info_panel(device, full=False): _details.set_visible(False) _info.set_visible(False) _empty.set_visible(True) + for child in _window.get_children(): + vbox = child + if hasattr(vbox, "get_children"): + for sub in vbox.get_children(): + if isinstance(sub, Gtk.HButtonBox): + for btn in sub.get_children(): + if btn.get_tooltip_text() == _("Configure automatic onboard profile switching for games"): + btn.set_sensitive(False) return # a receiver must be valid @@ -777,6 +804,25 @@ def _update_info_panel(device, full=False): _info._title.set_sensitive(is_online) _update_device_panel(device, _info._device, _info._buttons, full) + for child in _window.get_children(): + pass + button = None + for child in _window.get_children(): + vbox = child + if hasattr(vbox, "get_children"): + for sub in vbox.get_children(): + if isinstance(sub, Gtk.HButtonBox): + for btn in sub.get_children(): + if btn.get_tooltip_text() == _("Configure automatic onboard profile switching for games"): + button = btn + break + if button: + break + if button: + break + if button is not None: + button.set_sensitive(_device_supports_game_profiles(device)) + _empty.set_visible(False) _info.set_visible(True) diff --git a/setup.py b/setup.py index 4f0405e4..d8809d30 100755 --- a/setup.py +++ b/setup.py @@ -92,6 +92,7 @@ setup( entry_points={ "console_scripts": [ "solaar = solaar.gtk:main", + "solaar-game-profile-detector = solaar.game_profile_detector:main", ], }, ) diff --git a/share/systemd/user/solaar-game-profile-detector.service b/share/systemd/user/solaar-game-profile-detector.service new file mode 100644 index 00000000..21bb7a9c --- /dev/null +++ b/share/systemd/user/solaar-game-profile-detector.service @@ -0,0 +1,12 @@ +[Unit] +Description=Solaar Game Profile Detector +After=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/solaar-game-profile-detector +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target