From d37c22336d384ed34cfb420a0027077aec15c846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 20:24:27 +0200 Subject: [PATCH 1/6] fix: never run HID teardown on the Exit menu thread; make close idempotent - Exit could freeze for seconds writing haptic-stop reports to a wedged dongle before icon.stop(); teardown now runs best-effort on a daemon thread - _close_persistent_sc snapshot-and-clears first so the exit thread and the stopping launcher can't double-close --- windows/tray/app.py | 10 +++++++++- windows/tray/launcher.py | 11 +++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/windows/tray/app.py b/windows/tray/app.py index 41f52cc..f66056d 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -484,7 +484,6 @@ def exit_app(self, icon, item): # Alt held at the OS level. with suppress(Exception): self._chord.release_alt() - self._close_persistent_sc() # Un-hide the system cursors on exit in case the OSK was open # when the user quit (the close-path restore already covers a # normal close; this covers quitting while the OSK is up). @@ -492,6 +491,15 @@ def exit_app(self, icon, item): import cursor_ctrl cursor_ctrl.force_restore_cursor() + # Device teardown writes haptic-stop HID reports that can stall for + # seconds on a wedged dongle; never run it on the tray menu thread or + # Exit looks frozen. Best-effort on a daemon thread instead — if the + # process exits first, the OS reclaims the handles anyway. + threading.Thread( + target=self._close_persistent_sc, + name="exit-teardown", + daemon=True, + ).start() icon.stop() def _notify(self, title, message): diff --git a/windows/tray/launcher.py b/windows/tray/launcher.py index cbc1376..19b1489 100755 --- a/windows/tray/launcher.py +++ b/windows/tray/launcher.py @@ -194,13 +194,16 @@ def _dispatch_steam_restore(self, restore_hwnd): def _close_persistent_sc(self): """Release the persisted SteamController and its HID handle. Called on app exit only — the handle is deliberately kept open for the whole - session otherwise (see launcher_thread).""" - if self._persistent_sc is not None: - with suppress(Exception): - self._persistent_sc.close() + session otherwise (see launcher_thread). Thread-safe: snapshot-and- + clear first so exactly one caller (menu-thread daemon or the stopping + launcher_thread) performs the close.""" + sc = self._persistent_sc + if sc is not None: self._persistent_sc = None self._persistent_sc_passive = None self._persistent_sc_exclusive = None + with suppress(Exception): + sc.close() # background threads ---------------------------------------------------- From 13439956bc7bd8e486e13f7b995159600eaa4f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 20:24:32 +0200 Subject: [PATCH 2/6] feat: Nuitka onedir release build with Inno installer and named helper exe - onefile always ran as a bootloader parent+child pair and lingered deleting its temp dir at exit; onedir gives one process per invocation and an instant start - helper ships as DualTouch-cursor-helper.exe with its own FileDescription so Task Manager distinguishes tray from daemon - build.py --installer compiles Installer/DualTouch.iss: admin install, gruvbox dark wizard, closes running instances, uninstall removes both scheduled tasks (+optional settings) - exe version stamped from git tags; CI zips the whole dist folder --- .github/workflows/build.yml | 10 +- .gitignore | 3 + README.md | 24 +- requirements.txt | 4 +- windows/DualTouch-windows.spec | 39 --- windows/Installer/DualTouch.iss | 267 +++++++++++++++ windows/Installer/wizard-banner.bmp | Bin 0 -> 154542 bytes windows/Installer/wizard-small.bmp | Bin 0 -> 9798 bytes windows/build.py | 482 ++++++++++++++++++++++++---- 9 files changed, 718 insertions(+), 111 deletions(-) delete mode 100755 windows/DualTouch-windows.spec create mode 100755 windows/Installer/DualTouch.iss create mode 100755 windows/Installer/wizard-banner.bmp create mode 100755 windows/Installer/wizard-small.bmp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec5163e..e57f4a8 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,10 +13,13 @@ jobs: windows: name: Windows release package runs-on: windows-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 + with: + # Full history so build.py can read the latest tag for the exe version. + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -34,8 +37,9 @@ jobs: - name: Package Windows release shell: pwsh run: | - New-Item -ItemType Directory -Force -Path release\DualTouch-windows | Out-Null - Copy-Item -LiteralPath windows\dist\DualTouch-windows.exe -Destination release\DualTouch-windows\ + New-Item -ItemType Directory -Force -Path release | Out-Null + # onedir build: ship the whole dist folder (exe + _internal) as-is. + Copy-Item -LiteralPath windows\dist\DualTouch-windows -Destination release\DualTouch-windows -Recurse Copy-Item -LiteralPath README.md,LICENSE -Destination release\DualTouch-windows\ Compress-Archive -Path release\DualTouch-windows -DestinationPath DualTouch-windows.zip -Force diff --git a/.gitignore b/.gitignore index 34c7e4f..4c2e9be 100755 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ env/ build/ dist/ +# —— Inno Setup installer output —— +windows/Installer/Output/ + # —— Editor / OS / agent scratch —— AGENTS.md CLAUDE.md diff --git a/README.md b/README.md index 3cca72e..01e6d3a 100755 --- a/README.md +++ b/README.md @@ -56,7 +56,12 @@ It is a Windows-only fork of [SteamlessKeyboard](https://github.com/PietPetGit/S **Release build** -Run `DualTouch-windows.exe` (it will request elevation on start). +Run `DualTouch-windows.exe` from the extracted `DualTouch-windows` +folder (it will request elevation on start) — the whole folder ships +together, so keep all of its files side by side. The +`DualTouch-cursor-helper.exe` next to it is the same binary under its +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` @@ -95,10 +100,23 @@ menus), or by hand-editing `settings.json` for finer control. ```sh cd windows -python build.py +python build.py # folder distribution +python build.py --installer # + Windows installer (needs Inno Setup 6) ``` -Output: `windows/dist/DualTouch-windows.exe`. +Output: `windows/dist/DualTouch-windows/` (a standalone folder +distribution — `DualTouch-windows.exe`, the `DualTouch-cursor-helper.exe` +copy, and everything they need; ship the whole folder together). + +With `--installer`, an Inno Setup 6 setup is compiled to +`windows/Installer/Output/DualTouch-Setup-.exe` +([Inno Setup](https://jrsoftware.org/isdl.php) must be installed or +`ISCC.exe` on PATH). + +Releases are compiled with [Nuitka](https://nuitka.net): all Python is +turned into machine code, so the bundle contains no Python sources or +bytecode. The first build on a machine without a C compiler auto-downloads +MinGW and takes several minutes; later builds are faster. ## Troubleshooting diff --git a/requirements.txt b/requirements.txt index 122681c..6c06860 100755 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,6 @@ vdf PyGObject<3.52 ; sys_platform == "linux" # gi / AppIndicator tray backend (linux/tray_linux.py) # ---- build tool (used by CI to produce the release binaries) ---- -pyinstaller +# Nuitka compiles the whole app to machine code: release bundles ship no +# Python sources or bytecode (see windows/build.py). +nuitka>=2.4 diff --git a/windows/DualTouch-windows.spec b/windows/DualTouch-windows.spec deleted file mode 100755 index 74e8043..0000000 --- a/windows/DualTouch-windows.spec +++ /dev/null @@ -1,39 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - - -a = Analysis( - ['tray/__main__.py'], - pathex=[], - binaries=[('sdl3w/dll/SDL3.dll', 'sdl3w/dll'), ('sdl3w/dll/SDL3_ttf.dll', 'sdl3w/dll')], - datas=[('data', 'data')], - hiddenimports=['pystray._win32', 'pynput.keyboard._win32', 'pynput.mouse._win32', 'PIL._tkinter_finder', 'sdl3w'], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name='DualTouch-windows', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=['data/images/app_icon.ico'], -) diff --git a/windows/Installer/DualTouch.iss b/windows/Installer/DualTouch.iss new file mode 100755 index 0000000..f1a013e --- /dev/null +++ b/windows/Installer/DualTouch.iss @@ -0,0 +1,267 @@ +; ============================================================================ +; DualTouch - Installer (Inno Setup 6) +; Forced-dark wizard with a Gruvbox background (#282828), Start menu / +; desktop shortcuts, graceful shutdown of a running instance before +; installing, and a clean uninstall (stops processes, removes the two +; scheduled tasks, optional removal of %APPDATA%\DualTouch settings). +; +; HOW TO COMPILE: +; 1) Build the app first: cd windows && python build.py +; (produces dist\DualTouch-windows\) +; 2) Install Inno Setup 6 -> https://jrsoftware.org/isdl.php +; (6.7+ required for the native dark wizard styling used here) +; 3) Compile this script with Inno Setup (F9), or simply run: +; python build.py --installer +; The finished setup exits to Installer\Output\DualTouch-Setup-x.y.z.exe +; +; If you change version/paths, edit only the #define block below. +; The version override comes from build.py: /DAPP_VERSION=x.y.z +; ============================================================================ + +#ifndef APP_VERSION +#define APP_VERSION "0.0.0" +#endif + +#define MyAppName "DualTouch" +#define MyAppVersion APP_VERSION +#define MyAppPublisher "mateuszklysz" +#define MyAppURL "https://github.com/mateuszklysz/dualtouch" +#define MyAppExeName "DualTouch-windows.exe" +#define HelperExeName "DualTouch-cursor-helper.exe" + +; Folder produced by build.py (relative to this .iss). +#define SourceDir "..\dist\DualTouch-windows" +#define AppIcon "..\data\images\app_icon.ico" + +[Setup] +AppId={{6B2F4C8A-91D3-4E57-A0C8-3D9E1F4B7C2D} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL}/releases +VersionInfoVersion={#MyAppVersion} +VersionInfoProductVersion={#MyAppVersion} +VersionInfoCompany={#MyAppPublisher} +VersionInfoDescription={#MyAppName} Setup + +; Per-machine install into Program Files (the app runs elevated by design, +; so an admin setup matches its nature). The dialog override still allows +; choosing per-user at setup time; under lowest rights {autopf} resolves to +; %LOCALAPPDATA%\Programs instead. +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +DefaultGroupName={#MyAppName} +UninstallDisplayName={#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} + +; No Restart Manager prompt - we stop the tray/helper ourselves in code. +CloseApplications=no + +; x64 only. +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +; Output. +OutputDir=Output +OutputBaseFilename=DualTouch-Setup-{#MyAppVersion} +SetupIconFile={#AppIcon} +Compression=lzma2/ultra64 +SolidCompression=yes + +; Wizard look: native dark styling with a Gruvbox page background. The two +; BMPs are generated from data/images/icon.png on the same background and +; committed next to this script. +WizardStyle=modern dark +WizardBackColor=#282828 +WizardImageBackColor=#282828 +WizardSizePercent=110 +ShowLanguageDialog=yes +UsePreviousLanguage=no +DisableWelcomePage=no +WizardImageFile=wizard-banner.bmp +WizardSmallImageFile=wizard-small.bmp + +[Languages] +Name: "en"; MessagesFile: "compiler:Default.isl" +#if FileExists(CompilerPath + "\Languages\Polish.isl") +Name: "pl"; MessagesFile: "compiler:Languages\Polish.isl" +#endif + +[CustomMessages] +en.StartMenuShortcut=Create a Start menu shortcut +pl.StartMenuShortcut=Utwrz skrt w menu Start +en.CloseRunningText=DualTouch is still running.%n%nClose it so the installer can continue? +pl.CloseRunningText=DualTouch jest nadal uruchomiony.%n%nZamkn go, aby instalator mg kontynuowa? +en.StillRunning=DualTouch could not be stopped. Close it manually and run the setup again. +pl.StillRunning=Nie udao si zamkn DualTouch. Zamkn je rcznie i uruchom instalator ponownie. +en.RemoveSettings=Also remove settings and logs (%APPDATA%\DualTouch) +pl.RemoveSettings=Usuw take ustawienia i dzienniki (%APPDATA%\DualTouch) + +[Tasks] +Name: "startmenu"; Description: "{cm:StartMenuShortcut}"; GroupDescription: "{cm:AdditionalIcons}" +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce + +; NOTE: no "start with Windows" shortcut here on purpose - DualTouch manages +; its own autostart scheduled task from the tray menu (Startup -> Start with +; Windows), which must point at the installed exe non-elevated first. + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion + +[Icons] +; Start menu (created unless the user unticks the task). +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startmenu +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"; Tasks: startmenu +; Desktop (optional). +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +; Launch after install (checkbox ticked by default). The exe self-elevates +; on its own, so no runas flags are needed here. +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent + +[Code] +// --------------------------------------------------------------------------- +// Running-instance handling +// --------------------------------------------------------------------------- + +function IsProcessRunning(ImageName: String): Boolean; +var + Locator, Service, Processes: Variant; +begin + Result := False; + try + Locator := CreateOleObject('WbemScripting.SWbemLocator'); + Service := Locator.ConnectServer('.', 'root\CIMV2', '', ''); + Processes := Service.ExecQuery( + Format('SELECT ProcessId FROM Win32_Process WHERE Name="%s"', [ImageName])); + Result := Processes.Count > 0; + except + // WMI unavailable - assume not running rather than blocking setup. + end; +end; + +function AnyComponentRunning: Boolean; +begin + Result := IsProcessRunning('{#MyAppExeName}') or IsProcessRunning('{#HelperExeName}'); +end; + +procedure KillProcesses; +var + RC: Integer; +begin + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM "{#MyAppExeName}"', '', + SW_HIDE, ewWaitUntilTerminated, RC); + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM "{#HelperExeName}"', '', + SW_HIDE, ewWaitUntilTerminated, RC); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ''; + if AnyComponentRunning then + begin + if MsgBox(ExpandConstant('{cm:CloseRunningText}'), mbConfirmation, MB_YESNO) = IDYES then + begin + KillProcesses; + Sleep(700); + end; + if AnyComponentRunning then + Result := ExpandConstant('{cm:StillRunning}'); + end; +end; + +var + RemoveSettingsWithApp: Boolean; + +function InitializeUninstall: Boolean; +var + OptionsForm: TSetupForm; + DescriptionLabel: TNewStaticText; + RemoveSettingsCheck: TNewCheckBox; + OkButton: TNewButton; + CancelButton: TNewButton; +begin + RemoveSettingsWithApp := False; + + OptionsForm := CreateCustomForm(ScaleX(460), ScaleY(150), True, False); + try + OptionsForm.Caption := 'Uninstall ' + '{#MyAppName}'; + OptionsForm.Position := poScreenCenter; + + DescriptionLabel := TNewStaticText.Create(OptionsForm); + DescriptionLabel.Parent := OptionsForm; + DescriptionLabel.Left := ScaleX(20); + DescriptionLabel.Top := ScaleX(20); + DescriptionLabel.Width := ScaleX(420); + DescriptionLabel.Caption := + 'The tray app and its cursor-helper daemon will be stopped, and both' + #13#10 + + 'scheduled tasks (cursor helper, optional autostart) will be removed.'; + + RemoveSettingsCheck := TNewCheckBox.Create(OptionsForm); + RemoveSettingsCheck.Parent := OptionsForm; + RemoveSettingsCheck.Left := ScaleX(20); + RemoveSettingsCheck.Top := ScaleY(62); + RemoveSettingsCheck.Width := ScaleX(420); + RemoveSettingsCheck.Checked := False; + RemoveSettingsCheck.Caption := ExpandConstant('{cm:RemoveSettings}'); + + OkButton := TNewButton.Create(OptionsForm); + OkButton.Parent := OptionsForm; + OkButton.Caption := SetupMessage(msgButtonOK); + OkButton.ModalResult := mrOk; + OkButton.Default := True; + OkButton.Left := ScaleX(360); + OkButton.Top := ScaleY(104); + + CancelButton := TNewButton.Create(OptionsForm); + CancelButton.Parent := OptionsForm; + CancelButton.Caption := SetupMessage(msgButtonCancel); + CancelButton.ModalResult := mrCancel; + CancelButton.Cancel := True; + CancelButton.Left := ScaleX(250); + CancelButton.Top := ScaleY(104); + + Result := OptionsForm.ShowModal = mrOk; + RemoveSettingsWithApp := Result and RemoveSettingsCheck.Checked; + finally + OptionsForm.Free; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + RC: Integer; +begin + case CurUninstallStep of + usUninstall: + // Before files are deleted. + KillProcesses; + usPostUninstall: + begin + // Both tasks re-register themselves pointing wherever the exe lives, + // but they must not survive an uninstall. + Exec(ExpandConstant('{sys}\schtasks.exe'), + '/Delete /TN "DualTouchCursor" /F', '', SW_HIDE, ewWaitUntilTerminated, RC); + Exec(ExpandConstant('{sys}\schtasks.exe'), + '/Delete /TN "DualTouchAutostart" /F', '', SW_HIDE, ewWaitUntilTerminated, RC); + if RemoveSettingsWithApp then + DelTree(ExpandConstant('{userappdata}\DualTouch'), True, True, True); + end; + end; +end; + +// ============================================================================ +// NOTES +// - Default install is per-machine ({autopf} -> Program Files under +// PrivilegesRequired=admin); the dialog override allows per-user instead. +// - The app itself runs elevated by design (it types into elevated games), +// which is why neither a shortcut nor the launched exe needs extra rights. +// - Settings live in %APPDATA%\DualTouch and survive uninstall unless the +// checkbox in the uninstall dialog is ticked. +// ============================================================================ diff --git a/windows/Installer/wizard-banner.bmp b/windows/Installer/wizard-banner.bmp new file mode 100755 index 0000000000000000000000000000000000000000..e40f11c8b8e5a93b1075cf20f271d96f9612d3c4 GIT binary patch literal 154542 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 T{(FAB%Xw#Gao5lPTR;B;0Je>I literal 0 HcmV?d00001 diff --git a/windows/build.py b/windows/build.py index 65feba4..9639b93 100755 --- a/windows/build.py +++ b/windows/build.py @@ -1,102 +1,454 @@ -"""Build script for the portable DualTouch Steam Controller Keyboard EXE. +"""Build script for the DualTouch release binaries (Nuitka-compiled). Run: - python build.py + python build.py # folder distribution + python build.py --installer # + Inno Setup installer (needs ISCC) -Produces `dist/DualTouch-windows.exe`. Uses the prebuilt -`data/images/app_icon.ico` (multi-resolution) directly as the EXE icon -and bundles the data/ folder as PyInstaller datas. The output is a -single-file, no-console exe suitable for dropping anywhere. +Produces `dist/DualTouch-windows/` — a standalone folder distribution +whose entry point is `dist/DualTouch-windows/DualTouch-windows.exe`, +plus a `DualTouch-cursor-helper.exe` copy of the same compiled program. +Task Manager labels processes by their exe's FileDescription, so the +copy carries its own ("DualTouch Cursor Helper") — without that, the +tray and the cursor-helper daemon are indistinguishable there. Ship +the whole folder together (zip it as-is). + +All Python is compiled to machine code by Nuitka: the release contains +no .py sources and no .pyc bytecode, only native binaries plus data. + +The first build on a machine without MSVC auto-downloads a MinGW +toolchain (--assume-yes-for-downloads) and takes several minutes; later +builds are faster. """ +import ctypes import glob import os +import re import shutil +import struct import subprocess import sys +from ctypes import wintypes PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) ENTRY = os.path.join("tray", "__main__.py") OUTPUT_NAME = "DualTouch-windows" -# Paths are relative to PROJECT_DIR (the cwd PyInstaller runs in), so the -# regenerated .spec keeps relative paths and the build works from any checkout. +HELPER_EXE_NAME = "DualTouch-cursor-helper.exe" +DIST_DIR = os.path.join(PROJECT_DIR, "dist") +OUT_DIR = os.path.join(DIST_DIR, OUTPUT_NAME) APP_ICON_ICO = os.path.join("data", "images", "app_icon.ico") -DATA_DIR = os.path.join("data") + +MAIN_FILE_DESCRIPTION = "DualTouch Steam Controller Keyboard" +HELPER_FILE_DESCRIPTION = "DualTouch Cursor Helper" + +INSTALLER_DIR = os.path.join(PROJECT_DIR, "Installer") +ISS_PATH = os.path.join(INSTALLER_DIR, "DualTouch.iss") + + +def _app_version(): + """Latest git tag (v-prefixed tags welcome) as the exe version, or 0.0.0 + when building outside a tagged checkout (CI uses fetch-depth: 0).""" + try: + tag = subprocess.check_output( + ["git", "describe", "--tags", "--abbrev=0"], + cwd=PROJECT_DIR, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except Exception: + return "0.0.0" + m = re.match(r"v?(\d[\w.\-]*)", tag) + return m.group(1) if m else "0.0.0" -def _check_icon(): +def _preflight(): + try: + import nuitka # noqa: F401 + except ImportError as e: + raise SystemExit( + "nuitka missing — run: pip install -r requirements.txt" + ) from e if not os.path.isfile(APP_ICON_ICO): raise SystemExit(f"app icon not found: {APP_ICON_ICO}") + sdl_dll_dir = os.path.join("sdl3w", "dll") + if not glob.glob(os.path.join(sdl_dll_dir, "*.dll")): + raise SystemExit(f"no SDL3 DLLs found in {sdl_dll_dir}") + print(f"version: {_app_version()}") print(f"exe icon: {APP_ICON_ICO}") -def _run_pyinstaller(): - # `data;data` tells PyInstaller to drop the data/ folder into the bundle - # rooted at "data". On Windows the separator in --add-data is ";". +def _clean_previous(): + for stale in [OUT_DIR] + glob.glob(os.path.join(DIST_DIR, "*.dist")) + glob.glob( + os.path.join(DIST_DIR, "*.build") + ): + if os.path.isdir(stale): + shutil.rmtree(stale, ignore_errors=True) + # Legacy single-file exe from the old PyInstaller --onefile builds. + legacy_exe = os.path.join(DIST_DIR, f"{OUTPUT_NAME}.exe") + if os.path.isfile(legacy_exe): + os.remove(legacy_exe) + + +# --- helper-exe identity ---------------------------------------------------- +# Task Manager displays a process under its exe's FileDescription, falling +# back to the file name only when none exists. Nuitka stamps every binary +# with the same description, so the cursor-helper copy gets its version +# resource rewritten here (Begin/Update/EndUpdateResource) to keep the two +# processes distinguishable. + + +def _ver_block(key, value=b"", vtype=0, children=b""): + """One VS_VERSIONINFO-style block, lengths patched in after layout.""" + buf = bytearray(struct.pack("/sdl3w/dll (see sdl3w/_loader.py). cmd = [ sys.executable, "-m", - "PyInstaller", - "--noconfirm", - "--clean", - "--onefile", - "--windowed", - # NOT --uac-admin: the exe must be able to run NON-elevated too, so the - # same binary can act as the cursor helper (launched by a scheduled task - # for the session-cursor hide/show, which requires normal integrity). - # The tray self-elevates at runtime (ShellExecute runas) only for the - # main UI; --cursor-helper mode stays de-elevated. See tray.py __main__ - # and cursor_ctrl.py. - "--name", - OUTPUT_NAME, - "--icon", - APP_ICON_ICO, - "--add-data", - f"{DATA_DIR};data", - # pystray uses platform-specific backends loaded at runtime; pyinstaller - # doesn't always pick them up unless we name them explicitly. - "--hidden-import", - "pystray._win32", - "--hidden-import", - "pynput.keyboard._win32", - "--hidden-import", - "pynput.mouse._win32", - "--hidden-import", - "PIL._tkinter_finder", - # Our hand-rolled SDL3 binding is imported transitively (triton.screen - # -> sdl3w); name it explicitly so PyInstaller always bundles it. - "--hidden-import", - "sdl3w", + "nuitka", + "--standalone", + # Auto-fetch MinGW when no C compiler exists (first local build). + "--assume-yes-for-downloads", + "--windows-console-mode=disable", + # No UAC manifest on purpose: the same binary must run NON-elevated as + # the cursor helper (scheduled task); the tray self-elevates at runtime. + f"--windows-icon-from-ico={APP_ICON_ICO}", + "--windows-product-name=DualTouch", + f"--windows-product-version={version}", + f"--windows-file-version={version}", + f"--windows-file-description={MAIN_FILE_DESCRIPTION}", + f"--output-filename={OUTPUT_NAME}.exe", + f"--output-dir={DIST_DIR}", + "--include-data-dir=data=data", + # --include-data-dir silently skips .dll files (Nuitka classifies them + # as code), so name the vendored SDL3 DLLs with the file-pattern form, + # which does not suffix-filter. + "--include-data-files=sdl3w/dll/*.dll=sdl3w/dll/", + # pystray/pynput pick platform backends at runtime; name them so the + # static analysis always keeps them (same list the PyInstaller build used). + "--include-module=pystray._win32", + "--include-module=pynput.keyboard._win32", + "--include-module=pynput.mouse._win32", + "--include-module=PIL._tkinter_finder", + "--include-package=sdl3w", + ENTRY, ] - - # sdl3w loads the vendored SDL3 DLLs at import time, searching - # /sdl3w/dll first (see sdl3w/_loader.py). Ship the pinned SDL3 - # family (SDL3.dll + SDL3_ttf.dll) into that same path inside the EXE. - sdl_dll_dir = os.path.join("sdl3w", "dll") - sdl_dlls = glob.glob(os.path.join(sdl_dll_dir, "*.dll")) - if not sdl_dlls: - raise SystemExit(f"no SDL3 DLLs found in {sdl_dll_dir}") - for dll in sdl_dlls: - cmd += ["--add-binary", f"{dll};sdl3w/dll"] - - cmd.append(ENTRY) print("running:", " ".join(cmd)) subprocess.check_call(cmd, cwd=PROJECT_DIR) -def _cleanup(): - # Trim PyInstaller's intermediate artifacts; keep dist/ and the .spec. - build_dir = os.path.join(PROJECT_DIR, "build") - if os.path.isdir(build_dir): +def _assemble_output(): + dist_dirs = glob.glob(os.path.join(DIST_DIR, "*.dist")) + if len(dist_dirs) != 1: + raise SystemExit(f"expected exactly one Nuitka .dist folder, got {dist_dirs}") + if os.path.exists(OUT_DIR): + shutil.rmtree(OUT_DIR, ignore_errors=True) + os.rename(dist_dirs[0], OUT_DIR) + # Drop Nuitka's intermediate C-code build tree next to the dist folder. + for build_dir in glob.glob(os.path.join(DIST_DIR, "*.build")): shutil.rmtree(build_dir, ignore_errors=True) + out_exe = os.path.join(OUT_DIR, f"{OUTPUT_NAME}.exe") + if not os.path.isfile(out_exe): + raise SystemExit(f"expected output missing: {out_exe}") + for marker in ( + os.path.join(OUT_DIR, "data", "images"), + os.path.join(OUT_DIR, "sdl3w", "dll", "SDL3.dll"), + ): + if not os.path.exists(marker): + raise SystemExit(f"bundled resource missing: {marker}") + # Same compiled program under the cursor-helper name, but with its own + # version resource so Task Manager shows "DualTouch Cursor Helper" + # instead of the tray's description for both processes. + helper_exe = os.path.join(OUT_DIR, HELPER_EXE_NAME) + shutil.copyfile(out_exe, helper_exe) + _set_file_description( + helper_exe, + file_description=HELPER_FILE_DESCRIPTION, + original_filename=HELPER_EXE_NAME, + internal_name=HELPER_FILE_DESCRIPTION, + ) + + print(f"\nbuilt: {out_exe}") + print(f"helper copy: {helper_exe}") + print(f"ship the whole folder: dist/{OUTPUT_NAME}/") + + +def _find_iscc(): + """Locate Inno Setup 6's ISCC.exe without hardcoded paths: PATH first, + then the standard per-machine and per-user install roots from the + environment.""" + found = shutil.which("iscc") + if found: + return found + candidates = [] + for env in ("ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): + base = os.environ.get(env) + if base: + candidates.append(os.path.join(base, "Inno Setup 6", "ISCC.exe")) + local = os.environ.get("LOCALAPPDATA") + if local: + # Per-user installs land under %LOCALAPPDATA%\Programs. + candidates.append( + os.path.join(local, "Programs", "Inno Setup 6", "ISCC.exe") + ) + candidates.append(os.path.join(local, "Inno Setup 6", "ISCC.exe")) + return next((c for c in candidates if os.path.isfile(c)), None) + + +def _build_installer(): + """Compile Installer/DualTouch.iss with Inno Setup 6 (ISCC), passing the + git-tag version through /DAPP_VERSION for the setup's version metadata.""" + iscc = _find_iscc() + if not iscc: + raise SystemExit( + "--installer: Inno Setup 6 not found — install it from " + "https://jrsoftware.org/isdl.php (or add ISCC.exe to PATH)" + ) + version = _app_version() + subprocess.run( + [iscc, f"/DAPP_VERSION={version}", "/Q", ISS_PATH], + cwd=PROJECT_DIR, + check=True, + ) + out = os.path.join(INSTALLER_DIR, "Output", f"DualTouch-Setup-{version}.exe") + print(f"installer: {out}") + def main(argv=None): - _check_icon() - _run_pyinstaller() - _cleanup() - out = os.path.join(PROJECT_DIR, "dist", f"{OUTPUT_NAME}.exe") - print(f"\nbuilt: {out}") + want_installer = "--installer" in (sys.argv[1:] if argv is None else argv) + _preflight() + _clean_previous() + _run_nuitka() + _assemble_output() + if want_installer: + _build_installer() if __name__ == "__main__": From 3de97db7d58b05e5e4a923bc09de0ebbd56e5944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 20:24:38 +0200 Subject: [PATCH 3/6] fix: detect Nuitka freezes via __compiled__; real exe paths; crash log - Nuitka doesn't set sys.frozen, so every frozen branch took the source path and self-elevation targeted a nonexistent python.exe -> silent exit(0) - _exe_path resolves via GetModuleFileNameW (sys.executable lies under Nuitka) - windowed exes write unhandled tracebacks to %APPDATA%\DualTouch\crash.log instead of dying silently - helper trust accepts either image name; copy-freshness compares ProductVersion, not size (the description-patched helper differs by 512 bytes) --- windows/applog.py | 30 ++++++++-- windows/autostart.py | 10 +++- windows/cursor_ctrl.py | 108 ++++++++++++++++++++++++++++++----- windows/cursor_helper.py | 60 ++++++++++++------- windows/sdl3w/_loader.py | 7 ++- windows/steam_shortcut.py | 23 ++++++-- windows/tests/test_applog.py | 28 +++++++++ windows/tray/__init__.py | 2 +- windows/tray/__main__.py | 34 ++++++++++- windows/tray/helpers.py | 17 +++++- windows/triton/resources.py | 2 +- 11 files changed, 265 insertions(+), 56 deletions(-) diff --git a/windows/applog.py b/windows/applog.py index 799f1f9..a91d527 100755 --- a/windows/applog.py +++ b/windows/applog.py @@ -15,23 +15,41 @@ def _is_frozen(): - return getattr(sys, "frozen", False) + """True when running from a compiled distribution. PyInstaller sets + sys.frozen; Nuitka does not — its marker is the module-level + __compiled__ attribute present in every module it compiles.""" + return getattr(sys, "frozen", False) or "__compiled__" in globals() + + +def _exe_path(): + """Path of the binary the user actually launched. + + Nuitka standalone reports sys.executable as '\\python.exe' even + though no such file exists in the output folder, so anything that + persists, relaunches or advertises a path must ask Windows for our own + process image instead (GetModuleFileNameW(NULL)).""" + if _is_frozen(): + import ctypes + + buf = ctypes.create_unicode_buffer(1024) + if ctypes.windll.kernel32.GetModuleFileNameW(None, buf, 1024): + return os.path.normpath(buf.value) + return os.path.abspath(sys.executable) def _bundle_dir(): """Directory containing read-only bundled resources (data/, glyphs).""" if _is_frozen(): - # PyInstaller sets sys._MEIPASS at bootstrap; not in typeshed stubs. - return getattr( - sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)) - ) + # Compiled (Nuitka standalone) builds keep every resource next to the + # executable, so the exe dir IS the bundle dir. + return os.path.dirname(_exe_path()) return os.path.dirname(os.path.abspath(__file__)) def _exe_dir(): """Directory we treat as the install location (for portable settings).""" if _is_frozen(): - return os.path.dirname(os.path.abspath(sys.executable)) + return os.path.dirname(_exe_path()) return os.path.dirname(os.path.abspath(__file__)) diff --git a/windows/autostart.py b/windows/autostart.py index 5343cc7..13585ed 100755 --- a/windows/autostart.py +++ b/windows/autostart.py @@ -35,7 +35,9 @@ def _is_frozen(): - return getattr(sys, "frozen", False) + # applog-style detection: Nuitka doesn't set sys.frozen (PyInstaller + # does); its marker is the compiled module's __compiled__ attribute. + return getattr(sys, "frozen", False) or "__compiled__" in globals() def _startup_dir(): @@ -67,7 +69,11 @@ def _command(): """(executable, arguments) the scheduled task should run. Frozen: the EXE itself. From source: the current interpreter running the tray package.""" if _is_frozen(): - return os.path.abspath(sys.executable), "" + # applog._exe_path, not sys.executable: Nuitka standalone reports a + # python.exe that doesn't exist in the dist folder. + from applog import _exe_path + + return os.path.abspath(_exe_path()), "" return os.path.abspath(sys.executable), "-m tray" diff --git a/windows/cursor_ctrl.py b/windows/cursor_ctrl.py index 6c26d98..a7fb078 100755 --- a/windows/cursor_ctrl.py +++ b/windows/cursor_ctrl.py @@ -6,18 +6,29 @@ fails -- verified at runtime). The reliable path is to run a NON-elevated helper in the interactive session. -Mechanism (single exe, no second binary): - 1. A Windows scheduled task "DualTouchCursor" re-invokes THIS exe with - --cursor-helper. A scheduled task starts at the interactive user's - integrity level (non-elevated) regardless of how the registering - process was elevated. +Mechanism (one binary, two exe names): + 1. A Windows scheduled task "DualTouchCursor" re-invokes the + DualTouch exe with --cursor-helper. A scheduled task starts at the + interactive user's integrity level (non-elevated) regardless of how + the registering process was elevated. 2. To hide/show, we write a marker file (hide|show) and trigger the task via `schtasks /Run`. The re-invoked exe (in --cursor-helper mode) reads the marker, does the cursor work, and removes it. + +The daemon runs from "DualTouch-cursor-helper.exe" — build.py ships a +copy of the tray exe under that name, carrying its own FileDescription +resource ("DualTouch Cursor Helper") so Task Manager shows which process +is which: "DualTouch-windows.exe" is the tray, +"DualTouch-cursor-helper.exe" is the non-elevated cursor daemon. If the +copy can't be maintained (read-only folder), the task falls back to +re-invoking the tray exe itself. """ +import ctypes import os import secrets +import shutil +import struct import subprocess import sys from contextlib import suppress @@ -26,11 +37,12 @@ _TASK_NAME = "DualTouchCursor" _MARKER_NAME = "cursor_action.txt" -# The helper is THIS SAME exe, re-invoked with --cursor-helper. The +# The helper is THIS SAME binary, re-invoked with --cursor-helper. The # scheduled task runs it non-elevated (a scheduled task starts at the # interactive user's integrity level, not the elevated tray's), so the # same binary that runs the tray can also manipulate the session cursors. _HELPER_FLAG = "--cursor-helper" +_HELPER_EXE_NAME = "DualTouch-cursor-helper.exe" # Per-session marker authentication token. Generated ONCE per process. A # same-user process could otherwise write "hide|1" (PID 1 = System, always @@ -48,8 +60,77 @@ def _marker_path(): return os.path.join(user_data_dir(), _MARKER_NAME) +def _product_version(path): + """ProductVersion string of an exe via the version API, or None when it + has no readable VERSIONINFO.""" + try: + ver = ctypes.WinDLL("version") + size = ver.GetFileVersionInfoSizeW(path, None) + if not size: + return None + buf = ctypes.create_string_buffer(size) + if not ver.GetFileVersionInfoW(path, 0, size, buf): + return None + ptr = ctypes.c_void_p() + n = ctypes.c_uint() + if not ver.VerQueryValueW( + buf, "\\VarFileInfo\\Translation", ctypes.byref(ptr), ctypes.byref(n) + ) or ptr.value is None: + return None + langid, codepage = struct.unpack_from( + " +Usage (via schtasks /Run): DualTouch-cursor-helper.exe --cursor-helper --daemon --token """ import ctypes @@ -59,8 +60,10 @@ _BASE = user_data_dir() except Exception: - if getattr(sys, "frozen", False): - _BASE = os.path.dirname(os.path.abspath(sys.executable)) + if getattr(sys, "frozen", False) or "__compiled__" in globals(): + from applog import _exe_path + + _BASE = os.path.dirname(_exe_path()) else: _BASE = os.path.dirname(os.path.abspath(__file__)) _MARKER = os.path.join(_BASE, "cursor_action.txt") @@ -291,13 +294,31 @@ def _daemon_loop(): time.sleep(0.02) +# Image basenames a marker's tray PID may run when frozen: the renamed +# helper copy and the tray exe are byte-identical builds of the same +# binary, so either name is "our own process". +_TRAY_IMAGE_NAMES = ("dualtouch-windows.exe", "dualtouch-cursor-helper.exe") + + +def _allowed_image_names(frozen=None): + """The set of image basenames a marker's PID may run. Frozen: any + DualTouch exe name (tray or helper copy). Source run: both ends are + python.exe, so the interpreter's own basename is the only entry.""" + if frozen is None: + frozen = getattr(sys, "frozen", False) or "__compiled__" in globals() + names: set = set(_TRAY_IMAGE_NAMES) + if not frozen: + names.add(os.path.basename(sys.executable).lower()) + return names + + def _pid_is_trusted(pid): - """True only if `pid` is a live process running the SAME binary as this - helper (the tray's python/DualTouch exe). A marker is honored only when - it names its own owner process — a random PID, PID 1 (System), or a - dead PID is refused even if the token somehow matched. psutil reads the - process table WITHOUT a handle, so it works across the elevation - boundary (the tray runs elevated; we don't).""" + """True only if `pid` is a live process running a DualTouch exe (the + tray's or the helper's own image — same binary, two names). A marker is + honored only when it names its own owner process — a random PID, PID 1 + (System), or a dead PID is refused even if the token somehow matched. + psutil reads the process table WITHOUT a handle, so it works across the + elevation boundary (the tray runs elevated; we don't).""" if pid <= 1: return False try: @@ -306,8 +327,7 @@ def _pid_is_trusted(pid): p = psutil.Process(int(pid)) if not p.is_running(): return False - name = (p.name() or "").lower() - return name == os.path.basename(sys.executable).lower() + return (p.name() or "").lower() in _allowed_image_names() except Exception: return False # unknown: fail closed (don't honor the marker) diff --git a/windows/sdl3w/_loader.py b/windows/sdl3w/_loader.py index 53cb66c..76eb8fc 100755 --- a/windows/sdl3w/_loader.py +++ b/windows/sdl3w/_loader.py @@ -4,7 +4,7 @@ from source (windows/sdl3w/dll) and when frozen by PyInstaller (the DLLs are added to the bundle root / a sdl3w/dll subdir via build.py --add-binary). -Hand-rolled rather than depending on PySDL3 so the shipped onefile carries its +Hand-rolled rather than depending on PySDL3 so the shipped bundle carries its own pinned SDL3 (no runtime download) and we control exactly what's bound. """ @@ -28,8 +28,9 @@ def _candidate_dirs(): """Directories to search for the SDL3 DLLs, most-specific first.""" dirs = [] - # PyInstaller onefile extracts to sys._MEIPASS; build.py drops the DLLs both - # at the bundle root and under sdl3w/dll, so check both. + # Compiled (Nuitka standalone) builds place the bundle next to the exe; + # build.py drops the DLLs both at the bundle root and under sdl3w/dll, + # so check both. sys._MEIPASS (PyInstaller layouts) is honored too. meipass = getattr(sys, "_MEIPASS", None) if meipass: dirs.append(os.path.join(meipass, "sdl3w", "dll")) diff --git a/windows/steam_shortcut.py b/windows/steam_shortcut.py index 04681b1..92ce0ea 100755 --- a/windows/steam_shortcut.py +++ b/windows/steam_shortcut.py @@ -73,11 +73,19 @@ _STEAM_REG_VALUE = "SteamPath" +def _is_frozen(): + # Nuitka doesn't set sys.frozen (PyInstaller does); its marker is the + # compiled module's __compiled__ attribute. + return getattr(sys, "frozen", False) or "__compiled__" in globals() + + def _exe_dir(): """Directory treated as the install location (portable settings live here, next to the exe/script — same convention as tray.py).""" - if getattr(sys, "frozen", False): - return os.path.dirname(os.path.abspath(sys.executable)) + if _is_frozen(): + from applog import _exe_path + + return os.path.dirname(_exe_path()) return os.path.dirname(os.path.abspath(__file__)) @@ -86,8 +94,11 @@ def _shortcut_exe(): from the RUNNING process (the frozen exe's own path, or python launching the tray package in source runs) — so the library entry targets the real app wherever it was launched from, instead of a hardcoded dummy.""" - if getattr(sys, "frozen", False): - return f'"{os.path.abspath(sys.executable)}"' + if _is_frozen(): + # applog._exe_path: sys.executable lies under Nuitka standalone. + from applog import _exe_path + + return f'"{_exe_path()}"' # Source run: Steam's Exe field must be an executable; point it at the # interpreter and carry the tray package in LaunchOptions (see # _shortcut_launch_options). @@ -96,7 +107,7 @@ def _shortcut_exe(): def _shortcut_startdir(): """Working directory for the shortcut — the exe's own directory.""" - if getattr(sys, "frozen", False): + if _is_frozen(): return f'"{_exe_dir()}"' return f'"{_exe_dir()}"' @@ -104,7 +115,7 @@ def _shortcut_startdir(): def _shortcut_launch_options(): """LaunchOptions for the shortcut: in source runs Steam must start the tray package via the interpreter; frozen builds need nothing.""" - if getattr(sys, "frozen", False): + if _is_frozen(): return "" return "-m tray" diff --git a/windows/tests/test_applog.py b/windows/tests/test_applog.py index e12a6a4..bcf35a7 100755 --- a/windows/tests/test_applog.py +++ b/windows/tests/test_applog.py @@ -205,3 +205,31 @@ def test_parse_marker_rejects_malformed(tmpdir): assert ch._parse_marker(f"hide|abc|{tok}", tok) is None # non-int pid assert ch._parse_marker(f"frobnicate|1234|{tok}", tok) is None assert ch._parse_marker(f"HIDE|1234|{tok}", tok) is None + + +# --- cursor helper PID trust (renamed-helper-copy support) ------------------- +# The daemon runs from DualTouch-cursor-helper.exe while the tray runs as +# DualTouch-windows.exe, so _pid_is_trusted must accept BOTH image names +# (same binary, two copies), not just the helper's own basename. + +def test_allowed_image_names_frozen_pair(tmpdir): + ch = _cursor_helper(tmpdir) + names = ch._allowed_image_names(frozen=True) + assert "dualtouch-windows.exe" in names + assert "dualtouch-cursor-helper.exe" in names + assert "python.exe" not in names + + +def test_allowed_image_names_source_run_is_interpreter(tmpdir): + ch = _cursor_helper(tmpdir) + names = ch._allowed_image_names(frozen=False) + assert os.path.basename(sys.executable).lower() in names + + +def test_pid_trust_accepts_own_process(tmpdir): + ch = _cursor_helper(tmpdir) + # This test process itself (python.exe in a source checkout) must be a + # trusted marker owner; degenerate pids stay refused. + assert ch._pid_is_trusted(os.getpid()) is True + assert ch._pid_is_trusted(1) is False + assert ch._pid_is_trusted(0) is False diff --git a/windows/tray/__init__.py b/windows/tray/__init__.py index 53a6b6a..d39fbcb 100755 --- a/windows/tray/__init__.py +++ b/windows/tray/__init__.py @@ -21,7 +21,7 @@ # IMPORTANT: TRITON_DATA must be set before importing triton.* — triton.resources # captures its env-var search path at import time. os.environ["TRITON_DATA"] = os.path.join(_bundle_dir(), "data") -# (SDL3 DLLs are located by sdl3w/_loader.py via sys._MEIPASS — no env var needed.) +# (SDL3 DLLs are located by sdl3w/_loader.py next to the bundle — no env var needed.) from .app import App, main # noqa: E402 from .helpers import _relaunch_elevated # noqa: E402 diff --git a/windows/tray/__main__.py b/windows/tray/__main__.py index b83c293..0119dd0 100755 --- a/windows/tray/__main__.py +++ b/windows/tray/__main__.py @@ -1,9 +1,36 @@ +import os import sys from tray import _relaunch_elevated, main from tray.helpers import _create_tray_mutex, _tray_mutex_held +def _trace_base(): + try: + from applog import user_data_dir + + return user_data_dir() + except Exception: + return os.path.join( + os.environ.get("APPDATA", os.path.expanduser("~")), "DualTouch" + ) + + +def _write_crash_log(): + """Persist the active exception for post-mortem. A windowed exe has no + stderr, so without this any startup crash is completely invisible.""" + import traceback + + text = traceback.format_exc() + try: + base = _trace_base() + os.makedirs(base, exist_ok=True) + with open(os.path.join(base, "crash.log"), "a", encoding="utf-8") as f: + f.write(text.rstrip() + "\n\n") + except OSError: + pass + + def _run(): # The scheduled task (DualTouchCursor) re-invokes THIS exe with # --cursor-helper to manipulate the interactive session cursors @@ -36,13 +63,18 @@ def _run(): _relaunch_elevated() sys.exit(0) # ALWAYS exit the parent; never run main() here except Exception: - pass + # A failed elevation check used to vanish silently (windowed exe); + # leave evidence, then keep the original fall-through behavior. + _write_crash_log() mutex = _create_tray_mutex() if mutex is None: sys.exit(0) # another tray already owns the mutex try: main() + except BaseException: + _write_crash_log() + raise finally: import ctypes diff --git a/windows/tray/helpers.py b/windows/tray/helpers.py index 011f962..f06eb3e 100755 --- a/windows/tray/helpers.py +++ b/windows/tray/helpers.py @@ -304,6 +304,8 @@ def _relaunch_elevated(): import ctypes from ctypes import wintypes + from applog import _exe_path + shell32 = ctypes.windll.shell32 shell32.ShellExecuteW.restype = wintypes.HINSTANCE shell32.ShellExecuteW.argtypes = [ @@ -314,9 +316,18 @@ def _relaunch_elevated(): wintypes.LPCWSTR, ctypes.c_int, ] - res = shell32.ShellExecuteW( - None, "runas", sys.executable, " ".join(sys.argv[1:]), None, 1 - ) + # Frozen: relaunch the real binary (sys.executable lies under + # Nuitka standalone — see applog._exe_path). Source: re-run the + # interpreter with the tray package. + from applog import _is_frozen + + if _is_frozen(): + target = _exe_path() + params = " ".join(sys.argv[1:]) + else: + target = sys.executable + params = (" ".join(["-m", "tray"] + sys.argv[1:])).strip() + res = shell32.ShellExecuteW(None, "runas", target, params, None, 1) return int(res) > 32 except Exception: return False diff --git a/windows/triton/resources.py b/windows/triton/resources.py index d2e9cbb..6774c9d 100755 --- a/windows/triton/resources.py +++ b/windows/triton/resources.py @@ -5,7 +5,7 @@ # os.pathsep, not ":" — Windows drive letters contain a colon, so splitting on # ":" turned "C:\...\data" into ("C", "\...\data") and the drive-relative # second element only worked by accident when cwd was on the same drive as -# TRITON_DATA (it broke in the frozen exe, whose onefile temp dir is on C:). +# TRITON_DATA (it broke in the frozen exe, whose bundle dir is on C:). env_search_paths = ( tuple(_search_path_env_str.split(os.pathsep)) if _search_path_env_str is not None From 2c30f1c7688d8bc995c955c184bf06ce64e42902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 20:42:08 +0200 Subject: [PATCH 4/6] fix: compile the setup into dist; fresh shortcut tasks; ship it in CI - setup lands next to the app folder in windows/dist instead of Installer/Output - desktop-icon task can no longer be silently remembered unchecked across reinstalls; shortcuts carry an explicit icon reference - CI builds with --installer and attaches clearly named assets: DualTouch-windows-portable.zip and DualTouch-windows-setup-*.exe --- .github/workflows/build.yml | 16 +++++++++++----- .gitignore | 3 --- README.md | 2 +- windows/Installer/DualTouch.iss | 19 ++++++++++++------- windows/build.py | 2 +- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e57f4a8..08f69e8 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,25 +32,31 @@ jobs: - name: Build Windows binaries working-directory: windows - run: python build.py + run: python build.py --installer - name: Package Windows release shell: pwsh run: | New-Item -ItemType Directory -Force -Path release | Out-Null - # onedir build: ship the whole dist folder (exe + _internal) as-is. + # Nuitka onedir build: ship the whole dist folder (exe + helper + data) as-is. Copy-Item -LiteralPath windows\dist\DualTouch-windows -Destination release\DualTouch-windows -Recurse Copy-Item -LiteralPath README.md,LICENSE -Destination release\DualTouch-windows\ - Compress-Archive -Path release\DualTouch-windows -DestinationPath DualTouch-windows.zip -Force + Compress-Archive -Path release\DualTouch-windows -DestinationPath DualTouch-windows-portable.zip -Force + # Inno Setup installer as its own release asset (built into windows\dist). + Copy-Item -LiteralPath windows\dist\DualTouch-windows-setup-*.exe -Destination . - name: Upload Windows artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: DualTouch-windows - path: DualTouch-windows.zip + path: | + DualTouch-windows-portable.zip + DualTouch-windows-setup-*.exe - name: Attach Windows artifact to tagged release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 #v3.0.2 with: - files: DualTouch-windows.zip + files: | + DualTouch-windows-portable.zip + DualTouch-windows-setup-*.exe diff --git a/.gitignore b/.gitignore index 4c2e9be..34c7e4f 100755 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,6 @@ env/ build/ dist/ -# —— Inno Setup installer output —— -windows/Installer/Output/ - # —— Editor / OS / agent scratch —— AGENTS.md CLAUDE.md diff --git a/README.md b/README.md index 01e6d3a..48ffc62 100755 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ distribution — `DualTouch-windows.exe`, the `DualTouch-cursor-helper.exe` copy, and everything they need; ship the whole folder together). With `--installer`, an Inno Setup 6 setup is compiled to -`windows/Installer/Output/DualTouch-Setup-.exe` +`windows/dist/DualTouch-windows-setup-.exe` ([Inno Setup](https://jrsoftware.org/isdl.php) must be installed or `ISCC.exe` on PATH). diff --git a/windows/Installer/DualTouch.iss b/windows/Installer/DualTouch.iss index f1a013e..f8d485d 100755 --- a/windows/Installer/DualTouch.iss +++ b/windows/Installer/DualTouch.iss @@ -12,7 +12,7 @@ ; (6.7+ required for the native dark wizard styling used here) ; 3) Compile this script with Inno Setup (F9), or simply run: ; python build.py --installer -; The finished setup exits to Installer\Output\DualTouch-Setup-x.y.z.exe +; The finished setup exits to dist\DualTouch-Setup-x.y.z.exe ; ; If you change version/paths, edit only the #define block below. ; The version override comes from build.py: /DAPP_VERSION=x.y.z @@ -59,6 +59,10 @@ DefaultGroupName={#MyAppName} UninstallDisplayName={#MyAppName} UninstallDisplayIcon={app}\{#MyAppExeName} +; Fresh task defaults every run - never resurrect a stale remembered +; selection (a previous unchecked "desktop icon" would otherwise stick). +UsePreviousTasks=no + ; No Restart Manager prompt - we stop the tray/helper ourselves in code. CloseApplications=no @@ -66,9 +70,10 @@ CloseApplications=no ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible -; Output. -OutputDir=Output -OutputBaseFilename=DualTouch-Setup-{#MyAppVersion} +; Output. The finished setup lands in the shared dist/ folder, next to +; the built app folder. +OutputDir=..\dist +OutputBaseFilename=DualTouch-windows-setup-{#MyAppVersion} SetupIconFile={#AppIcon} Compression=lzma2/ultra64 SolidCompression=yes @@ -104,7 +109,7 @@ pl.RemoveSettings=Usuw take ustawienia i dzienniki (%APPDATA%\DualTouch) [Tasks] Name: "startmenu"; Description: "{cm:StartMenuShortcut}"; GroupDescription: "{cm:AdditionalIcons}" -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" ; NOTE: no "start with Windows" shortcut here on purpose - DualTouch manages ; its own autostart scheduled task from the tray menu (Startup -> Start with @@ -115,10 +120,10 @@ Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdi [Icons] ; Start menu (created unless the user unticks the task). -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startmenu +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppExeName}"; Tasks: startmenu Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"; Tasks: startmenu ; Desktop (optional). -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] ; Launch after install (checkbox ticked by default). The exe self-elevates diff --git a/windows/build.py b/windows/build.py index 9639b93..2997b90 100755 --- a/windows/build.py +++ b/windows/build.py @@ -437,7 +437,7 @@ def _build_installer(): cwd=PROJECT_DIR, check=True, ) - out = os.path.join(INSTALLER_DIR, "Output", f"DualTouch-Setup-{version}.exe") + out = os.path.join(DIST_DIR, f"DualTouch-windows-setup-{version}.exe") print(f"installer: {out}") From 2ad8d0d6cfd9fc3eb9b3bff515da53d2bc83f600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 21:04:50 +0200 Subject: [PATCH 5/6] fix: review follow-ups: CI glob, close race, helper trust, Polish strings - CI used Copy-Item -LiteralPath on a glob, which never matches: the release step would have failed on every tag - guard _close_persistent_sc with a lock and route the launcher rebuild through it so concurrent exit/launcher teardown can't double-close - source runs now trust only the interpreter image name; DualTouch exe names count when frozen - restore Polish installer diacritics and save the .iss as UTF-8 with BOM - verify ISCC produced the expected setup before reporting success --- .github/workflows/build.yml | 2 +- windows/Installer/DualTouch.iss | 10 +++++----- windows/build.py | 2 ++ windows/cursor_helper.py | 2 +- windows/steam_shortcut.py | 2 -- windows/tray/app.py | 3 +++ windows/tray/launcher.py | 22 +++++++++++----------- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 08f69e8..8040779 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,7 +43,7 @@ jobs: Copy-Item -LiteralPath README.md,LICENSE -Destination release\DualTouch-windows\ Compress-Archive -Path release\DualTouch-windows -DestinationPath DualTouch-windows-portable.zip -Force # Inno Setup installer as its own release asset (built into windows\dist). - Copy-Item -LiteralPath windows\dist\DualTouch-windows-setup-*.exe -Destination . + Copy-Item -Path windows\dist\DualTouch-windows-setup-*.exe -Destination . - name: Upload Windows artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 diff --git a/windows/Installer/DualTouch.iss b/windows/Installer/DualTouch.iss index f8d485d..f523581 100755 --- a/windows/Installer/DualTouch.iss +++ b/windows/Installer/DualTouch.iss @@ -1,4 +1,4 @@ -; ============================================================================ +; ============================================================================ ; DualTouch - Installer (Inno Setup 6) ; Forced-dark wizard with a Gruvbox background (#282828), Start menu / ; desktop shortcuts, graceful shutdown of a running instance before @@ -99,13 +99,13 @@ Name: "pl"; MessagesFile: "compiler:Languages\Polish.isl" [CustomMessages] en.StartMenuShortcut=Create a Start menu shortcut -pl.StartMenuShortcut=Utwrz skrt w menu Start +pl.StartMenuShortcut=Utwórz skrót w menu Start en.CloseRunningText=DualTouch is still running.%n%nClose it so the installer can continue? -pl.CloseRunningText=DualTouch jest nadal uruchomiony.%n%nZamkn go, aby instalator mg kontynuowa? +pl.CloseRunningText=DualTouch jest nadal uruchomiony.%n%nZamknij go, aby instalator mógł kontynuować? en.StillRunning=DualTouch could not be stopped. Close it manually and run the setup again. -pl.StillRunning=Nie udao si zamkn DualTouch. Zamkn je rcznie i uruchom instalator ponownie. +pl.StillRunning=Nie udało się zamknąć DualTouch. Zamknij go ręcznie i uruchom instalator ponownie. en.RemoveSettings=Also remove settings and logs (%APPDATA%\DualTouch) -pl.RemoveSettings=Usuw take ustawienia i dzienniki (%APPDATA%\DualTouch) +pl.RemoveSettings=Usuń także ustawienia i dzienniki (%APPDATA%\DualTouch) [Tasks] Name: "startmenu"; Description: "{cm:StartMenuShortcut}"; GroupDescription: "{cm:AdditionalIcons}" diff --git a/windows/build.py b/windows/build.py index 2997b90..98c8ff8 100755 --- a/windows/build.py +++ b/windows/build.py @@ -438,6 +438,8 @@ def _build_installer(): check=True, ) out = os.path.join(DIST_DIR, f"DualTouch-windows-setup-{version}.exe") + if not os.path.isfile(out): + raise SystemExit(f"installer missing after ISCC run: {out}") print(f"installer: {out}") diff --git a/windows/cursor_helper.py b/windows/cursor_helper.py index b20348f..a5fc1ed 100755 --- a/windows/cursor_helper.py +++ b/windows/cursor_helper.py @@ -306,7 +306,7 @@ def _allowed_image_names(frozen=None): python.exe, so the interpreter's own basename is the only entry.""" if frozen is None: frozen = getattr(sys, "frozen", False) or "__compiled__" in globals() - names: set = set(_TRAY_IMAGE_NAMES) + names: set = set(_TRAY_IMAGE_NAMES) if frozen else set() if not frozen: names.add(os.path.basename(sys.executable).lower()) return names diff --git a/windows/steam_shortcut.py b/windows/steam_shortcut.py index 92ce0ea..f9cd4e5 100755 --- a/windows/steam_shortcut.py +++ b/windows/steam_shortcut.py @@ -107,8 +107,6 @@ def _shortcut_exe(): def _shortcut_startdir(): """Working directory for the shortcut — the exe's own directory.""" - if _is_frozen(): - return f'"{_exe_dir()}"' return f'"{_exe_dir()}"' diff --git a/windows/tray/app.py b/windows/tray/app.py index f66056d..7d24836 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -155,6 +155,9 @@ def __init__(self): self._persistent_sc = None self._persistent_sc_passive = None self._persistent_sc_exclusive = None + # Serializes _close_persistent_sc against the launcher thread (which + # also closes/rebuilds the persistent controller). + self._persistent_sc_lock = threading.Lock() # Open-keyboard request plumbing: _open_kbd_event asks # launcher_thread to open the on-screen keyboard (tray menu); # _launcher_wake wakes the launcher out of its reconnect backoff so the diff --git a/windows/tray/launcher.py b/windows/tray/launcher.py index 19b1489..2b2bb17 100755 --- a/windows/tray/launcher.py +++ b/windows/tray/launcher.py @@ -63,6 +63,7 @@ class _LauncherMixin: _chord: _ChordState _current_sc: SteamController | None _persistent_sc: SteamController | None + _persistent_sc_lock: threading.Lock _notify: Callable[[str, str], None] _refresh_menu: Callable[[], None] _icon_ref: Any @@ -193,17 +194,18 @@ def _dispatch_steam_restore(self, restore_hwnd): def _close_persistent_sc(self): """Release the persisted SteamController and its HID handle. Called - on app exit only — the handle is deliberately kept open for the whole - session otherwise (see launcher_thread). Thread-safe: snapshot-and- - clear first so exactly one caller (menu-thread daemon or the stopping - launcher_thread) performs the close.""" - sc = self._persistent_sc - if sc is not None: + on app exit (best-effort daemon thread) and by the launcher thread + itself — locked + snapshot-and-clear so exactly one caller performs + the close even when both race.""" + with self._persistent_sc_lock: + sc = self._persistent_sc + if sc is None: + return self._persistent_sc = None self._persistent_sc_passive = None self._persistent_sc_exclusive = None - with suppress(Exception): - sc.close() + with suppress(Exception): + sc.close() # background threads ---------------------------------------------------- @@ -348,9 +350,7 @@ def _launcher_loop(self): or not self._persistent_sc.opened ) if need_rebuild: - if self._persistent_sc is not None: - self._persistent_sc.close() - self._persistent_sc = None + self._close_persistent_sc() sc = SteamController( callback=watcher.on_input, passive=passive_flag, From a875717a6a5b435077ebe843264216be1c8a9405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 22:01:54 +0200 Subject: [PATCH 6/6] chore: rename Installer dir to installer --- windows/build.py | 4 ++-- windows/{Installer => installer}/DualTouch.iss | 0 windows/{Installer => installer}/wizard-banner.bmp | Bin windows/{Installer => installer}/wizard-small.bmp | Bin 4 files changed, 2 insertions(+), 2 deletions(-) rename windows/{Installer => installer}/DualTouch.iss (100%) rename windows/{Installer => installer}/wizard-banner.bmp (100%) rename windows/{Installer => installer}/wizard-small.bmp (100%) diff --git a/windows/build.py b/windows/build.py index 98c8ff8..4a364c4 100755 --- a/windows/build.py +++ b/windows/build.py @@ -41,7 +41,7 @@ MAIN_FILE_DESCRIPTION = "DualTouch Steam Controller Keyboard" HELPER_FILE_DESCRIPTION = "DualTouch Cursor Helper" -INSTALLER_DIR = os.path.join(PROJECT_DIR, "Installer") +INSTALLER_DIR = os.path.join(PROJECT_DIR, "installer") ISS_PATH = os.path.join(INSTALLER_DIR, "DualTouch.iss") @@ -423,7 +423,7 @@ def _find_iscc(): def _build_installer(): - """Compile Installer/DualTouch.iss with Inno Setup 6 (ISCC), passing the + """Compile installer/DualTouch.iss with Inno Setup 6 (ISCC), passing the git-tag version through /DAPP_VERSION for the setup's version metadata.""" iscc = _find_iscc() if not iscc: diff --git a/windows/Installer/DualTouch.iss b/windows/installer/DualTouch.iss similarity index 100% rename from windows/Installer/DualTouch.iss rename to windows/installer/DualTouch.iss diff --git a/windows/Installer/wizard-banner.bmp b/windows/installer/wizard-banner.bmp similarity index 100% rename from windows/Installer/wizard-banner.bmp rename to windows/installer/wizard-banner.bmp diff --git a/windows/Installer/wizard-small.bmp b/windows/installer/wizard-small.bmp similarity index 100% rename from windows/Installer/wizard-small.bmp rename to windows/installer/wizard-small.bmp