diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec5163e..8040779 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 @@ -29,24 +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\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 + # 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 -Path 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/README.md b/README.md index 3cca72e..48ffc62 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/dist/DualTouch-windows-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/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/build.py b/windows/build.py index 65feba4..4a364c4 100755 --- a/windows/build.py +++ b/windows/build.py @@ -1,102 +1,456 @@ -"""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(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}") + 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__": 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 frozen else set() + 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/installer/DualTouch.iss b/windows/installer/DualTouch.iss new file mode 100755 index 0000000..f523581 --- /dev/null +++ b/windows/installer/DualTouch.iss @@ -0,0 +1,272 @@ +; ============================================================================ +; 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 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 +; ============================================================================ + +#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} + +; 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 + +; x64 only. +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +; 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 + +; 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=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%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 udało się zamknąć DualTouch. Zamknij go ręcznie i uruchom instalator ponownie. +en.RemoveSettings=Also remove settings and logs (%APPDATA%\DualTouch) +pl.RemoveSettings=Usuń także ustawienia i dzienniki (%APPDATA%\DualTouch) + +[Tasks] +Name: "startmenu"; Description: "{cm:StartMenuShortcut}"; GroupDescription: "{cm:AdditionalIcons}" +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 +; 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}"; IconFilename: "{app}\{#MyAppExeName}"; Tasks: startmenu +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"; Tasks: startmenu +; Desktop (optional). +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{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 0000000..e40f11c Binary files /dev/null and b/windows/installer/wizard-banner.bmp differ diff --git a/windows/installer/wizard-small.bmp b/windows/installer/wizard-small.bmp new file mode 100755 index 0000000..f6e59f5 Binary files /dev/null and b/windows/installer/wizard-small.bmp differ 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..f9cd4e5 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,15 +107,13 @@ def _shortcut_exe(): def _shortcut_startdir(): """Working directory for the shortcut — the exe's own directory.""" - if getattr(sys, "frozen", False): - return f'"{_exe_dir()}"' return f'"{_exe_dir()}"' 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/app.py b/windows/tray/app.py index 41f52cc..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 @@ -484,7 +487,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 +494,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/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/tray/launcher.py b/windows/tray/launcher.py index cbc1376..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,14 +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).""" - if self._persistent_sc is not None: - with suppress(Exception): - self._persistent_sc.close() + 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() # background threads ---------------------------------------------------- @@ -345,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, 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