Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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**

Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions windows/appsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions windows/tests/appsettings/test_appsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions windows/tests/tray/test_tray_launcher.py
Original file line number Diff line number Diff line change
@@ -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
)
29 changes: 29 additions & 0 deletions windows/tests/tray/test_tray_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""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():
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_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 == []
assert SessionAwareIcon._on_end_session(icon, 1, 0) == 0
assert calls == [icon]
2 changes: 2 additions & 0 deletions windows/tray/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 58 additions & 21 deletions windows/tray/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -255,6 +259,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):
Expand Down Expand Up @@ -365,6 +372,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
Expand All @@ -377,24 +390,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
Expand Down Expand Up @@ -542,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()
Expand Down Expand Up @@ -573,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:
Expand Down Expand Up @@ -601,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):
Expand Down
34 changes: 34 additions & 0 deletions windows/tray/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions windows/tray/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__).
Expand Down Expand Up @@ -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)"
)
Expand Down
5 changes: 5 additions & 0 deletions windows/tray/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down