From 9bc4e2ddb1f68af4b21c7eb408075401fc9719e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 31 Aug 2026 11:16:35 +0200 Subject: [PATCH 1/3] feat: make Steam startup and logging opt-in - Add a persisted, disabled-by-default Startup -> Start Steam on startup tray toggle; avoid dispatching the steam:// startup restore URL when Steam is absent unless opted in - Keep logging disabled by default and ensure View Log restores the disabled runtime state after creating its marker - Add startup-setting tests and document the tray options --- README.md | 6 ++- windows/appsettings.py | 4 ++ windows/tests/appsettings/test_appsettings.py | 6 +++ windows/tests/tray/test_tray_launcher.py | 28 +++++++++++ windows/tray/__init__.py | 2 + windows/tray/app.py | 49 ++++++++++++------- windows/tray/launcher.py | 24 +++++++-- windows/tray/menu.py | 5 ++ 8 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 windows/tests/tray/test_tray_launcher.py diff --git a/README.md b/README.md index 7ad636b..009a621 100755 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ It is a Windows-only fork of [SteamlessKeyboard](https://github.com/PietPetGit/S ## Requirements - Windows 10 or 11 (64-bit) -- [Steam](https://store.steampowered.com), running, with Steam Input available — DualTouch waits for it on launch +- [Steam](https://store.steampowered.com), with Steam Input available — DualTouch waits for it unless “Start Steam on startup” is enabled in the tray - Python 3.10+ if running from source (not needed for the prebuilt exe) ## Quick Start @@ -64,7 +64,7 @@ cursor-helper name (so the two processes are distinguishable in Task Manager); keep it too. - Settings: `%APPDATA%\DualTouch\settings.json` (auto-created with defaults; a legacy file next to the exe is migrated automatically) -- Logs: `%APPDATA%\DualTouch\dualtouch.log` +- Logs: `%APPDATA%\DualTouch\dualtouch.log` (created only when logging is enabled) **From source** @@ -98,6 +98,8 @@ menus), or by hand-editing `settings.json` for finer control. | `steam_kbd_layer` | `true` | Dispatch the `forceinputappid` keyboard layer while open | | `block_sc_hid` | `false` | Open the Steam Controller HID exclusively (Steam can't read it) | | `start_with_windows` | `false` | Launch at logon (elevated scheduled task, no UAC prompt) | +| `start_steam_on_startup` | `false` | Start Steam when DualTouch starts if it is not already running; tray: Startup → Start Steam on startup | +| `logging_enabled` | `false` | Write diagnostics to `dualtouch.log`; tray: Startup → Enable Logging | ## Building diff --git a/windows/appsettings.py b/windows/appsettings.py index 20903c2..2538090 100755 --- a/windows/appsettings.py +++ b/windows/appsettings.py @@ -26,6 +26,10 @@ # Write dualtouch.log (tray "Startup -> Enable Logging"). Off by default: # the log is for diagnosing problems, not for everyday runs. "logging_enabled": False, + # Startup cleanup uses steam://forceinputappid/0 to clear a stale Steam + # Input override. If Steam is not already running, handling that URL can + # launch Steam, so make that launch an explicit opt-in from the tray. + "start_steam_on_startup": False, # Steam keyboard-layer switching: when enabled (default), the OSK asks # Steam Input to switch to the "DualTouch Keyboard Layer" shortcut's # controller config (LB/RB muted, everything else default) while the diff --git a/windows/tests/appsettings/test_appsettings.py b/windows/tests/appsettings/test_appsettings.py index 0cb2564..2d8e1b4 100644 --- a/windows/tests/appsettings/test_appsettings.py +++ b/windows/tests/appsettings/test_appsettings.py @@ -96,6 +96,12 @@ def test_missing_settings_file_falls_back_to_defaults(tmpdir): ) +def test_startup_steam_and_logging_are_disabled_by_default(tmpdir): + s = _load_from(tmpdir) + assert s["start_steam_on_startup"] is False + assert s["logging_enabled"] is False + + def test_known_keys_coerce_to_default_types(tmpdir): # A hand-edited bool stored as 0/1 must come back as a real bool, and a # bogus numeric string must not crash the loader. diff --git a/windows/tests/tray/test_tray_launcher.py b/windows/tests/tray/test_tray_launcher.py new file mode 100644 index 0000000..5eea7cd --- /dev/null +++ b/windows/tests/tray/test_tray_launcher.py @@ -0,0 +1,28 @@ +"""Tests for launcher startup side effects.""" + +from tray.launcher import _should_dispatch_startup_restore + + +def test_startup_restore_does_not_launch_steam_by_default(): + assert _should_dispatch_startup_restore({}, steam_running=False) is False + assert ( + _should_dispatch_startup_restore( + {"start_steam_on_startup": False}, steam_running=False + ) + is False + ) + + +def test_startup_restore_keeps_running_steam_healthy(): + # The setting controls launching Steam, not whether an already-running + # client's stale forced appid is restored. + assert _should_dispatch_startup_restore({}, steam_running=True) is True + + +def test_startup_restore_can_opt_into_launching_steam(): + assert ( + _should_dispatch_startup_restore( + {"start_steam_on_startup": True}, steam_running=False + ) + is True + ) diff --git a/windows/tray/__init__.py b/windows/tray/__init__.py index d39fbcb..80a1e57 100755 --- a/windows/tray/__init__.py +++ b/windows/tray/__init__.py @@ -6,6 +6,8 @@ * Requires Steam: nothing runs until the Steam client is up, and the launcher waits for it (the keyboard layer, the watcher and the OSK all assume Steam Input is present — there is no standalone/no-Steam mode). + Steam is not started automatically unless the tray's "Start Steam on + startup" option is enabled. * Watches the Steam Controller for the Steam+X chord and brings up the on-screen keyboard in-process (no subprocess startup cost). The app keeps working alongside Steam; Steam Input may still hold the controller and diff --git a/windows/tray/app.py b/windows/tray/app.py index 90dc2f8..605b3b2 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -255,6 +255,9 @@ def __init__(self): def is_start_with_windows_checked(self, item): return self.settings["start_with_windows"] + def is_start_steam_on_startup_checked(self, item): + return self.settings.get("start_steam_on_startup", False) + # Skin submenu: one radio item per bundled skin. pystray needs a distinct # checked-predicate and action per name, so we build small closures. def is_skin_checked(self, name): @@ -365,6 +368,12 @@ def toggle_start_with_windows(self, icon, item): self.settings["start_with_windows"] = not item.checked _save_settings(self.settings) _apply_autostart(self.settings["start_with_windows"]) + self._refresh_menu() + + def toggle_start_steam_on_startup(self, icon, item): + self.settings["start_steam_on_startup"] = not item.checked + _save_settings(self.settings) + self._refresh_menu() def toggle_logging(self, icon, item): self.settings["logging_enabled"] = not item.checked @@ -377,24 +386,30 @@ def is_logging_checked(self, item): def view_log(self, icon, item): """Tray "View Log": open dualtouch.log with the default handler. - If logging is off or the file doesn't exist yet, enable logging for - THIS call only (write a marker so the file exists) — never persist - logging_enabled, so the tray toggle stays exactly as the user set it. - Viewing a log must not silently flip the user's logging preference.""" - if not applog_is_logging_enabled(): - set_logging_enabled(True) # in-memory only; not saved - action, path = resolve_log_action() - if action != "open": - # Logging is on (this call) but the file was never written (e.g. - # no activity yet) — write a marker so there is something to open. - applog_log_line("tray", "Log opened via tray View Log") + If logging is off or the file doesn't exist yet, enable logging only + long enough to write a marker so there is something to open. Never + persist or leave logging_enabled enabled as a side effect of viewing + the log; the tray preference remains exactly as the user set it.""" + was_enabled = applog_is_logging_enabled() + try: + if not was_enabled: + set_logging_enabled(True) # in-memory only; not saved action, path = resolve_log_action() - if action == "open": - try: - os.startfile(path) - return - except Exception as e: - print(f"view log open failed: {e!r}") + if action != "open": + # Logging is on (this call) but the file was never written + # (e.g. no activity yet) — write a marker so there is + # something to open. + applog_log_line("tray", "Log opened via tray View Log") + action, path = resolve_log_action() + if action == "open": + try: + os.startfile(path) + return + except Exception as e: + print(f"view log open failed: {e!r}") + finally: + if not was_enabled: + set_logging_enabled(False) # --- Live Steam Controller settings (tray "Steam Controller" submenu) --- # Every toggle/radio below saves to settings.json AND republishes to the diff --git a/windows/tray/launcher.py b/windows/tray/launcher.py index 2b2bb17..cfcc4f3 100755 --- a/windows/tray/launcher.py +++ b/windows/tray/launcher.py @@ -53,6 +53,19 @@ def _verify_force_appid(steam_path, appid): return True +def _should_dispatch_startup_restore(settings, steam_running): + """Return whether startup may dispatch the Steam restore URL. + + The restore URL is harmless when Steam is already running, but handing a + steam:// URL to Windows when it is not can start Steam. Keep that side + effect opt-in while preserving startup cleanup for an already-running + Steam client. + """ + return bool(steam_running) or bool( + settings.get("start_steam_on_startup", False) + ) + + class _LauncherMixin: # Attributes provided by the composed tray App (declared here so static # tooling knows the mixin's contract — see tray/app.py __init__). @@ -292,13 +305,16 @@ def _launcher_loop(self): # keyboard-layer appid FORCED (crash / killed exe — the force # lives in the Steam client, so restarting Steam clears it but # restarting DualTouch alone does not). /0 restores auto config - # so the controller works right away. Idempotent when nothing - # is forced; no-op when Steam is down (its runtime state - # cleared with the client anyway). + # so the controller works right away. Do not dispatch the URL + # while Steam is down unless the user explicitly opted into + # starting Steam on startup: the steam:// handler would + # otherwise launch Steam as a side effect. if not self._startup_appid_zeroed: self._startup_appid_zeroed = True try: - if ssc.force_appid(0): + if _should_dispatch_startup_restore( + self.settings, _steam_running() + ) and ssc.force_appid(0): _log( "steam input: startup — restored auto config (/0)" ) diff --git a/windows/tray/menu.py b/windows/tray/menu.py index 89b913e..125a03b 100755 --- a/windows/tray/menu.py +++ b/windows/tray/menu.py @@ -187,6 +187,11 @@ def build_menu(app): app.toggle_start_with_windows, checked=app.is_start_with_windows_checked, ), + pystray.MenuItem( + "Start Steam on startup", + app.toggle_start_steam_on_startup, + checked=app.is_start_steam_on_startup_checked, + ), pystray.MenuItem( "Enable Logging", app.toggle_logging, From 2bf37a0343e78c1551ecb73d983731e2bb7a0e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 31 Aug 2026 11:41:08 +0200 Subject: [PATCH 2/3] fix: allow clean Windows shutdown - Approve Windows session-end queries from the tray window so shutdown and restart are not blocked by DualTouch - Run controller, cursor, and tray cleanup once when Windows commits to ending the session - Add tray session-end tests --- windows/tests/tray/test_tray_session.py | 18 +++++++++++++ windows/tray/app.py | 30 +++++++++++++++++++--- windows/tray/icon.py | 34 +++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 windows/tests/tray/test_tray_session.py diff --git a/windows/tests/tray/test_tray_session.py b/windows/tests/tray/test_tray_session.py new file mode 100644 index 0000000..50bf700 --- /dev/null +++ b/windows/tests/tray/test_tray_session.py @@ -0,0 +1,18 @@ +"""Tests for Windows shutdown/session-end tray handling.""" + +from tray.icon import SessionAwareIcon + + +def test_query_end_session_is_approved(): + assert SessionAwareIcon._on_query_end_session(None, 0, 0) == 1 + + +def test_end_session_invokes_cleanup_callback_only_when_committed(): + calls = [] + icon = type("IconStub", (), {})() + icon._on_session_end_callback = lambda received: calls.append(received) + + assert SessionAwareIcon._on_end_session(icon, 0, 0) == 0 + assert calls == [] + assert SessionAwareIcon._on_end_session(icon, 1, 0) == 0 + assert calls == [icon] diff --git a/windows/tray/app.py b/windows/tray/app.py index 605b3b2..c8afe23 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -5,7 +5,6 @@ from contextlib import suppress from typing import Any -import pystray import steam_shortcut as ssc from applog import is_logging_enabled as applog_is_logging_enabled from applog import log_line as applog_log_line @@ -27,7 +26,7 @@ from .battery import _BatteryMixin from .helpers import _apply_autostart -from .icon import _load_icon_image +from .icon import SessionAwareIcon, _load_icon_image from .launcher import _LauncherMixin from .menu import build_menu from .steam import _SteamLayerMixin @@ -157,6 +156,11 @@ def __init__(self): self._publish_diacritics() self._stop_event = threading.Event() + # Windows may deliver WM_ENDSESSION at the same time as a tray Exit + # click. Make the cleanup path one-shot so controller/cursor teardown + # and icon.stop() cannot race each other. + self._shutdown_lock = threading.Lock() + self._shutdown_started = False # Steam-required cache for _should_abort_sc (2s TTL — the per-frame # abort check must not call psutil on every HID report). self._steam_ok_at = 0.0 @@ -557,7 +561,12 @@ def is_diacritic_locale_checked(self, locale): self.settings.get("diacritic_locale", "auto") == locale ) - def exit_app(self, icon, item): + def _shutdown(self, icon): + """Stop the tray and background work for user or Windows shutdown.""" + with self._shutdown_lock: + if self._shutdown_started: + return + self._shutdown_started = True self._stop_event.set() # Wake any event-idle background threads so they observe the stop. self._steam_watch_wake.set() @@ -588,6 +597,13 @@ def exit_app(self, icon, item): ).start() icon.stop() + def on_windows_session_end(self, icon): + """Callback from the tray window after Windows accepts shutdown.""" + self._shutdown(icon) + + def exit_app(self, icon, item): + self._shutdown(icon) + def _notify(self, title, message): icon = self._icon_ref if icon is None: @@ -616,7 +632,13 @@ def main(): menu = build_menu(app) - icon = pystray.Icon("SteamControllerKeyboard", image, "DualTouch", menu) + icon = SessionAwareIcon( + "SteamControllerKeyboard", + image, + "DualTouch", + menu, + on_session_end=app.on_windows_session_end, + ) app._icon_ref = icon def setup(icon): diff --git a/windows/tray/icon.py b/windows/tray/icon.py index 096375a..467a487 100755 --- a/windows/tray/icon.py +++ b/windows/tray/icon.py @@ -3,9 +3,43 @@ import ctypes import os +import pystray from applog import _bundle_dir from PIL import Image +# Windows sends these messages to every top-level window during logoff, +# restart, and shutdown. pystray's dispatcher returns 0 for messages it does +# not know, which Windows treats as a veto for WM_QUERYENDSESSION. +WM_QUERYENDSESSION = 0x0011 +WM_ENDSESSION = 0x0016 + + +class SessionAwareIcon(pystray.Icon): + """Tray icon that cooperates with Windows session shutdown. + + The project only uses pystray's Windows backend, but keeping the handler + registration conditional makes this class harmless if another backend is + selected for headless tests or source inspection. + """ + + def __init__(self, *args, on_session_end=None, **kwargs): + self._on_session_end_callback = on_session_end + super().__init__(*args, **kwargs) + handlers = getattr(self, "_message_handlers", None) + if isinstance(handlers, dict): + handlers[WM_QUERYENDSESSION] = self._on_query_end_session + handlers[WM_ENDSESSION] = self._on_end_session + + def _on_query_end_session(self, wparam, lparam): + """Approve Windows session termination immediately.""" + return 1 + + def _on_end_session(self, wparam, lparam): + """Start application cleanup after Windows commits to termination.""" + if wparam and self._on_session_end_callback is not None: + self._on_session_end_callback(self) + return 0 + def _load_icon_image(): # Prefer the multi-resolution app_icon.ico (hand-tuned per size, so From 901963b7f2d542de8f744213b459b979ee41b564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 31 Aug 2026 12:10:43 +0200 Subject: [PATCH 3/3] chore: satisfy pre-commit typing checks - Add typed scaffolding for the tray session-end callback tests - Keep shutdown coverage independent of a live tray icon --- windows/tests/tray/test_tray_session.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/windows/tests/tray/test_tray_session.py b/windows/tests/tray/test_tray_session.py index 50bf700..83db555 100644 --- a/windows/tests/tray/test_tray_session.py +++ b/windows/tests/tray/test_tray_session.py @@ -1,16 +1,27 @@ """Tests for Windows shutdown/session-end tray handling.""" +from collections.abc import Callable +from typing import cast + from tray.icon import SessionAwareIcon +class _IconStub: + _on_session_end_callback: Callable[[SessionAwareIcon], None] | None = None + + def test_query_end_session_is_approved(): - assert SessionAwareIcon._on_query_end_session(None, 0, 0) == 1 + icon = cast(SessionAwareIcon, _IconStub()) + assert SessionAwareIcon._on_query_end_session(icon, 0, 0) == 1 def test_end_session_invokes_cleanup_callback_only_when_committed(): calls = [] - icon = type("IconStub", (), {})() - icon._on_session_end_callback = lambda received: calls.append(received) + icon_stub = _IconStub() + icon_stub._on_session_end_callback = lambda received: calls.append( + received + ) + icon = cast(SessionAwareIcon, icon_stub) assert SessionAwareIcon._on_end_session(icon, 0, 0) == 0 assert calls == []