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()