Harden Windows 11 support: paste/tray/notification fixes + admin-free Run-key autostart - #44
Closed
sumgup0 wants to merge 29 commits into
Closed
Harden Windows 11 support: paste/tray/notification fixes + admin-free Run-key autostart#44sumgup0 wants to merge 29 commits into
sumgup0 wants to merge 29 commits into
Conversation
read_clipboard_text/write_clipboard_text call GetClipboardData, GlobalAlloc, and GlobalLock through ctypes without declaring return types. ctypes then assumes a 32-bit int return and truncates the 64-bit HANDLE/pointer these functions hand back; the truncated, sign-extended pointer faults the instant it is dereferenced (GlobalLock -> wstring_at). So on 64-bit Windows every clipboard read crashes with an access violation and every write fails with "GlobalLock failed". WHY this shipped undetected: the clipboard is the only copy mechanism on Windows, so this breaks the whole dictation->paste flow there - but Linux (wl-copy/xclip) and macOS (pbcopy) use subprocesses and never touch this code. The unit tests mock ctypes wholesale, and the windows-latest CI job only *imports* this module; nothing ever executed a real clipboard call. Fix: declare argtypes/restype so handles stay pointer-sized. Add live round-trip tests that drive the real OS clipboard (skipped off-Windows, so they execute on the windows-latest CI job) - the coverage that would have caught this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
send_ctrl_v_paste builds an INPUT array whose union declared only the KEYBDINPUT member. On x64 that makes sizeof(INPUT) 32 bytes, but the real Win32 INPUT is 40 (its largest union member, MOUSEINPUT, is bigger). SendInput validates cbSize against the 40-byte ABI layout, so it rejected every call with ERROR_INVALID_PARAMETER (87), returned 0, and injected no keystrokes - send_ctrl_v_paste then raised "SendInput returned 0, expected 4". WHY it mattered and stayed hidden: this is the keystroke half of paste, so combined with the clipboard crash the dictation->paste flow could not work at all on 64-bit Windows. The mocked unit test fakes SendInput returning 4, so it asserted the call was made but never that the OS accepted the struct; CI's smoke step only imports the module. Fix: include MOUSEINPUT (and HARDWAREINPUT) in the union so sizeof(INPUT) matches the ABI. Add a deterministic struct-size test (guards the contract on every CI platform) and a live test that actually calls SendInput on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clipboard fixes declared argtypes/restype on ctypes.windll.user32 and
.kernel32. ctypes.windll is a process-wide cache: it hands back one shared
function object per name, so reconfiguring GetClipboardData/SendInput
there mutates state any other code in the process sees. That is a
latent foot-gun (a library that also touches those functions with
different expectations could be affected).
Bind private ctypes.WinDLL("user32"/"kernel32") handles and configure the
prototypes once on them, so our declarations stay local to this module.
While here, drop the WWORD monkey-patch on the ctypes.wintypes module -
the same "mutate a shared module" anti-pattern - since WWORD was only an
alias of WORD.
The new test pins ctypes.windll's GetClipboardData.restype and asserts
our clipboard calls leave it untouched; the existing live round-trip and
SendInput tests prove behavior is unchanged by the refactor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Task Scheduler service command was built from sys.executable, which on Windows is python.exe - a console (CUI) subsystem binary. The install task runs at logon with an InteractiveToken in the user's session, so the console subsystem gives that process its own console window that flashes (or lingers) at every sign-in. (PE subsystem: python.exe = 3/console, pythonw.exe = 2/GUI.) Prefer pythonw.exe (the GUI-subsystem interpreter shipped next to python.exe) for the baked service command on Windows. The swap is gated to win32 so systemd and launchd keep the real interpreter, and falls back to sys.executable if pythonw.exe is somehow absent. Nothing is lost: Task Scheduler never captured the service's stdout anyway. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
windows_service_state decided "active" by scanning schtasks /Query output for a line "Status: Running". Every label and value schtasks prints is translated to the Windows display language, so on any non-English Windows the value is not "Running" and the task is reported inactive even while it is running - making `transclip status` and `doctor` wrong for those users. Read PowerShell's Get-ScheduledTask State instead. Its .value__ is the numeric MSFT_ScheduledTask enum (Ready=3, Running=4), which is identical in every locale. schtasks is still used only for existence/detail, where the exit code (not the text) is what matters. Tests drive the locale case (localized schtasks text + numeric "4" must report active) and the Ready-not-Running boundary. Verified live that the PowerShell query is valid and returns None when the task is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two ways the tray could crash, both from unguarded lookups:
1. The "Set hotkey" dialog persisted whatever text the user typed and
then registered it. An unparseable binding makes keyboard.add_hotkey
raise ValueError mid-registration - after the bad value was already
saved, so it would keep failing on restart. Add is_valid_hotkey()
(keyboard.parse_hotkey) and reject invalid input before persisting.
The decision is extracted into _apply_hotkey_selection so it is unit
tested without the Tk dialog.
2. build_image looked up the icon color with dict[name], which raises
KeyError if a health state outside {recording, ready, offline} is ever
introduced. build_image runs in the pystray loop, so that would take
the tray down. Extract health_icon_color() with a "gray" fallback.
Tests: is_valid_hotkey accept/reject/missing-keyboard (cross-platform via
an injected keyboard module); selection logic persist/reject/blank; and
the icon color fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paste injects Ctrl+V with SendInput. Windows UIPI blocks a normal-integrity process from injecting input into a window owned by an elevated (administrator) process, and - unlike a wrong struct size - it fails silently: the transcript is copied but nothing is pasted, with no error. This is inherent to running unelevated and cannot be reliably detected, so the right move is to tell the user what is happening. Add an informational doctor check (windows_elevated_paste) explaining the symptom and the fix (match elevation), and expand the README Windows permissions section. Informational only (required=False); off Windows it reports "not checked". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tray's "Set hotkey" Tk dialog runs in a DPI-unaware process, so on a high-DPI or multi-monitor display Windows bitmap-stretches it and it looks blurry. Microsoft's recommended mode for desktop apps is Per-Monitor-v2 (Win10 1703+). Add set_dpi_awareness() in a new desktop/win32_app.py: it tries SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2), then falls back to shcore SetProcessDpiAwareness, then legacy SetProcessDPIAware, returning the mode applied. The fallback order is unit tested cross-platform via injected setters; a Windows-gated live test confirms the process actually reports per-monitor awareness. Called first thing in run_windows_tray, before any window exists (awareness can only be set pre-window). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nothing stopped a second `transclip tray` from starting. Because the Windows hotkey is a global low-level keyboard hook (not an exclusive RegisterHotKey), a second tray installs a second hook and the toggle fires twice on one keypress - the double-trigger failure mode. Add acquire_single_instance()/release_single_instance() in win32_app.py using a per-session named mutex: CreateMutexW returns a handle either way but sets GetLastError to ERROR_ALREADY_EXISTS when the name was already taken, so the second caller gets None. (kernel32 is bound as a private WinDLL with use_last_error so get_last_error is reliable.) The tray exits cleanly with a message if another instance holds the mutex; it is held for the process lifetime and released on exit. Tested: same-process second acquisition is blocked, the name is reusable after release, and acquire is a no-op off Windows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without an explicit AppUserModelID the Windows shell derives an implicit identity from the host binary (python.exe/pythonw.exe). That groups the tray under "Python" on the taskbar and misattributes any notifications - and a stable AUMID is the prerequisite for attributing toast notifications to TransClip (W4). Add set_app_user_model_id() (shell32 SetCurrentProcessExplicitAppUserModelID) with the product constant APP_USER_MODEL_ID = "com.paulbrav.TransClip", and call it at tray startup before any window exists. A Windows-gated live test sets the id and reads it back via GetCurrentProcessExplicitAppUserModelID; off Windows it degrades to False. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace reliance on legacy tray balloon tips with real Action Center toasts, the current Windows mechanism. New desktop/notifications.py: - toast_xml(): pure ToastGeneric payload builder (XML-escaped). - windows_toast(): best-effort show via winrt ToastNotificationManager. Uses create_toast_notifier_with_id (the AppUserModelID overload required for unpackaged apps; the no-arg variant is packaged-only). Degrades to False when the winrt packages are absent or off Windows - never raises. - register_toast_app_id(): registers the AUMID DisplayName under HKCU so toasts attribute to TransClip and persist in Action Center (needed on Windows 10; Windows 11 is more lenient). The tray registers the AUMID at startup and confirms a hotkey change with a toast. winrt-* packages are added to the windows-ui extra (win32 only), so the Linux/macOS legs are unaffected. Also release the single-instance mutex on every run_windows_tray exit (not just process exit) so the guard is re-entrant - this is correct on its own and keeps the tray runnable more than once per test process. The winrt classes are imported behind a one-line _winrt_toast_api() seam, so the show path and the missing-winrt path are both unit tested with fakes (no real toast fired in tests); a manual smoke test confirmed a real toast is delivered end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
File the Build 2026 on-device Windows Speech Recognition API as a roadmap item: a zero-download, no-GPU ASR backend option for Windows 11, opt-in via asr_backend. Records why it is deferred (new backend + preview API, not a cheap fix) and what adopting it would take (engine contract, winrt-* extra, doctor check, Windows eval gate). Linked from the README Windows section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clipboard is a single global resource: another process (a clipboard manager, browser, Office, Teams, RDP) can hold it open for a few milliseconds, so OpenClipboard fails intermittently. read/write previously gave up on the first failure, which on a real desktop surfaces as a paste that fails to copy the transcript or fails to restore the prior clipboard, with a bare "OpenClipboard failed". This contention could not be reproduced in the sandbox (so it was initially deferred), but a real Windows desktop reproduced it: the live clipboard round-trip test errored once under concurrent clipboard activity, then passed in isolation. Add _open_clipboard(): up to 10 attempts with a 15 ms backoff before failing. read_clipboard_text/write_clipboard_text route through it, so the live tests inherit the resilience and no longer flake. The retry loop is unit tested with an injected OpenClipboard sequence and sleep (retries then succeeds; raises after exhausting attempts) - deterministic and cross-platform. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause (found via systematic debugging on a real Windows host): validate_shell_command treated any nonzero exit from `bash -n` as a syntax error and reported the command "invalid Bash". But on a common Windows setup the resolved bash is C:\Windows\System32\bash.exe - the WSL legacy launcher - which relays into the *default* distro. When that distro is Docker Desktop's bashless `docker-desktop` (the only distro registered), bash never executes: `execvpe(/bin/bash) failed`, exit 1. So a valid spoken command like "ls -la" was rejected with a WSL relay error. bash exits 2 from `bash -n` only for an actual syntax error; any other nonzero means bash could not run the check, not a verdict on the command. Treat exit 2 as the syntax-error case (unchanged) and any other nonzero as "bash non-functional" - skip validation and pass the command through for review, mirroring the existing bash-not-found path. Record bash_nonfunctional in metadata. This conflated "bash failed to execute" with "command is invalid"; the fix distinguishes them. Verified on the affected host: the live validator now returns ok for "ls -la" with bash_nonfunctional=True, and the full suite passes (it previously failed only on this environment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`transclip tray` crashed immediately building the menu: ValueError(<function ...set_model>) from pystray's _assert_action. Root cause (systematic debugging): pystray computes action.__code__.co_argcount and raises ValueError for any action taking more than 2 positional parameters. The win32 sink bound per-row loop variables with the default-argument idiom - set_model(_icon, _item, model_id=row.model_id, backend=row.backend) and copy_history(_icon, _item, value=full_text) - which inflates co_argcount to 4 and 3, so pystray rejected both. The model submenu always has items, so the tray could never start on Windows; the history submenu would crash once any transcript existed. Like the clipboard/SendInput bugs, this is a Windows-only (pystray) path the Linux/macOS author never ran, and the existing tray tests inject a fake pystray that does not enforce the arg-count contract, so it passed mocked tests while crashing live. Bind the per-row values in a factory method instead, so the handler takes only (icon, item): _set_model_action / _copy_history_action. Add Windows-gated tests that build both submenus with the REAL pystray (the coverage that was missing). Verified the live crash path now builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the menu built, the first health refresh at startup crashed: AttributeError: property 'text' of 'MenuItem' object has no setter. Root cause (systematic debugging): the shared RefDrivenMenuView refreshes the live menu by mutating items - setattr(item, "text"/"enabled", ...) - which works for GTK (Gtk.MenuItem) and macOS (NSMenuItem) but not pystray, whose MenuItem.text/.enabled are read-only. pystray's idiom is dynamic items: text/enabled are callables re-evaluated on each render, repainted via icon.update_menu(). The existing win32 test injected a *mutable* fake MenuItem, so it passed while the real tray could never refresh. Back each dynamic win32 item with a small mutable _ItemState holder and give the MenuItem callable text/enabled that read it; store the holder in menu_refs (so the unchanged setattr lambdas now mutate the holder). Add an optional on_updated hook to RefDrivenMenuView (default no-op, so GTK/macOS are untouched) that the win32 adapter wires to icon.update_menu(). Tests build status/action/model items with the REAL pystray and assert labels/enabled update after creation; the full-flow test's fake MenuItem is made faithful (immutable, evaluates callables) and now asserts the repaint fires. Verified the live build+refresh path end to end with real pystray. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A full menu refresh mutates ~6 items, and the win32 adapter repaints by calling icon.update_menu() on each RefDrivenMenuView setter, so one health refresh triggered ~7 native menu rebuilds. Harmless but wasteful. Add RefDrivenMenuView.batch(): inside it the repaint hook is deferred and fired exactly once on exit (re-entrant via depth save/restore). Declare batch() on the TrayMenuView protocol and wrap apply_menu_snapshot's mutations in it. The win32 run_windows_tray refresh now repaints twice (once for the snapshot batch, once for the separate history rebuild) instead of seven times - one repaint per logical refresh. No behavior change for GTK/macOS: their on_updated hook is the default no-op, so batching just defers and fires a no-op. Verified against real pystray (7 -> 2 update_menu calls); full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tray only signalled recording state by the icon colour; there was no notification when recording started or stopped. Toast on both, covering the whole "recording on/off" story: - started -> "Recording... press the hotkey again to stop" - stopped -> "Transcribed and pasted" - stopped but paste blocked (UIPI / elevated target) -> "Transcript copied - press Ctrl+V to paste" (otherwise a blocked paste is silent) - failed -> "Recording failed: <reason>" recording_toast_message() builds the body (pure, fully unit tested); _notify_toggle() gates on the new recording_notifications setting (default on, so it can be silenced) and fires windows_toast. Both toggle paths - the global hotkey and the tray menu item - are wired through it. Windows-only: Linux/macOS do not call _notify_toggle, so the new setting is inert there (no behaviour change, full suite incl. GTK/macOS green). Verified a real toast end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking OK/Cancel/minimize/close in the Set-hotkey dialog did nothing; the only way out was the tray icon's Close. Root cause: pystray runs its Win32 message loop on the main thread and invokes the menu action synchronously inside that loop, after making its own hidden window the foreground window (SetForegroundWindow). The tkinter dialog was therefore created nested inside pystray's message pump and could never take the foreground, so it rendered but never serviced input - every click was dead. Replace simpledialog.askstring with a small custom dialog run on a dedicated thread, so it gets a clean message queue and its own mainloop; topmost + focus_force make it interactive, and OK/Cancel/Enter/Escape/ window-close are all wired. The menu callback blocks on join() so it stays modal. Pure GUI glue (not unit-testable headlessly) - verify live. Also type icon_holder as dict[str, Any] so ty no longer flags the icon.update_menu()/.stop()/.icon accesses (6 -> 3 diagnostics). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend tray notifications to the actions that silently restart the dictation service - Start/Restart service, toggle Model cleanup, and switching the ASR model - since the few seconds of unavailability is otherwise invisible. Each fires a heads-up toast. Rename the just-added recording_notifications setting to tray_notifications: one switch (default on) now gates every tray toast - recording start/stop, the service/model changes above, and the existing hotkey-set confirmation. _notify_action() does the gated toast; the service menu callbacks and the model-switch callback are wrapped through it. Windows-only: Linux/macOS never call these, so the setting is inert there (no behaviour change). Verified config round-trip and a live toast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Instead of only typing the binding, a Record keys button captures the pressed combination via keyboard.read_hotkey(), which returns the canonical chord string (e.g. "ctrl+shift+space") - already in the format add_hotkey/is_valid_hotkey expect, so it drops straight into the existing validate/apply path with no translation. read_hotkey() blocks, so it runs on a worker thread and the result is filled into the entry via tkinter after() polling (keeping all Tk calls on the dialog thread). During capture the live global hotkey is paused (_capture_hotkey: pause -> read -> resume, with resume always running even on failure) so the keys pressed for capture cannot also fire the current action; the entry is disabled and OK/Cancel/Enter/Escape are inert so the press cannot close the dialog or type into the field. _capture_hotkey is unit tested (pause/read/resume order; always resumes and returns None on read failure). The dialog button is GUI glue - verify live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking "Record keys" and ending the capture raised KeyError and left the dialog frozen - no button worked, only the tray Close. Two compounding bugs: 1. Double-stop. The capture's pause called the hotkey's stop() but left the handle in hotkey_holder, so resume (restart_hotkey) called stop() again on the already-removed handle; keyboard.remove_hotkey raises KeyError on a hotkey that is already gone. 2. That crash froze the dialog: the capture worker thread died before resetting recording["active"], so the entry stayed disabled and OK/Cancel/close stayed inert (they no-op while recording). Fixes: - start_windows_hotkey's stop() is now idempotent (suppresses the KeyError from removing an already-removed hotkey), so calling it twice is safe. - pause clears hotkey_holder["stop"] so resume re-registers cleanly rather than double-removing. - the capture worker resets recording["active"] in a finally, so any failure in capture() can never lock the dialog again. Regression test drives pause -> capture -> resume against a keyboard that raises on double-remove, asserting no crash and exactly one live hotkey. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closing the Set-hotkey dialog (OK or Cancel) aborted the whole tray with "Tcl_AsyncDelete: async handler deleted by the wrong thread". The dialog runs on a dedicated thread, so its Tk interpreter's async handler is created on that thread. tkinter widgets form reference cycles, so when the dialog thread exits the interpreter is not freed by refcount; the main thread's next garbage-collection pass frees it instead - deleting the async handler from a thread other than the one that created it, which Tcl treats as a fatal error. Run gc.collect() on the dialog thread once the dialog has closed (after run()'s widgets are out of scope and root.destroy() has cleared tkinter's _default_root global), so the interpreter is finalized on the thread that created it. GUI/threading glue - verify live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`transclip install` failed with "Access is denied" for a non-elevated user: creating a task in the Task Scheduler root folder requires admin, and even an ONLOGON task via Register-ScheduledTask needs elevation. A dictation tray should install without a UAC prompt. Switch Windows autostart to the per-user Run key (HKCU\Software\Microsoft\Windows\CurrentVersion\Run), which the logon shell launches at interactive sign-in and which is writable by the user's own token -- no elevation required. - install: write the service command line to the Run value (winreg) and start the service immediately, detached and window-less, so status is "active" without waiting for the next sign-in. - uninstall: stop the running service (PowerShell, matched by its `*transclip*...*serve*` command line) and delete the Run value. - status: installed = Run value present; active = a matching service process is running. Command-line matching is locale-independent, unlike schtasks' localized status string. - start/stop/restart: Popen-detached / PowerShell Stop-Process. winreg is imported lazily inside three small seams so the module still imports on Linux CI; cross-platform tests patch those seams and a win32-gated test exercises the real registry round-trip under a throwaway value name. Drops the now-dead Task Scheduler XML builder and TASK_SCHEDULER_NAME, and updates the doctor service-manager message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Run-key status/stop logic finds live service processes with a PowerShell pipeline matching `$_.CommandLine -like '*transclip*' -and -like '*serve*'`. `Get-CimInstance Win32_Process` enumerates the querying PowerShell process too, and its command line contains those very literals (they are in the `-Command` text) -- so the query matched itself: - status reported a phantom `active=True; 1 service process running` after uninstall, while health correctly showed the server was dead. - stop tried to `Stop-Process` a PID from the same enumeration that had already exited, printing "Cannot find a process with PID ...". Constrain the match to the interpreter image (`$_.Name -like 'python*'`) so powershell.exe no longer matches, and add `-ErrorAction SilentlyContinue` so an enumerate-then-kill race stays quiet. Live check: the old filter counts 1 (itself) with nothing running, the new filter counts 0. Also drop the stale "Task Scheduler service installed" line from the Windows hotkey setup message now that autostart is a logon Run-key entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_windows_tray_fails_clearly_without_pystray asserts run_tray exits 1 when pystray is absent, but it never mocked the single-instance mutex guard that run_windows_tray checks first. On a dev host where a real TransClip tray is running, the mutex is held, so run_tray prints "already running" and exits 0 before reaching the pystray check -- the test failed only when the app happened to be open. CI, with no tray running, always passed and hid the order dependency the guard introduced. Mock acquire_single_instance/release_single_instance so the test deterministically reaches the path it asserts, regardless of host state. Production ordering is intentionally unchanged: if a tray is already running, pystray is by definition installed, so exit 0 is correct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the windows-fixes review (no behavior change to the daemon or tray runtime): - daemon/common.py: the _service_python docstring still said "logon Task Scheduler task"; autostart is the HKCU Run key now -> "logon autostart entry". - tests/test_tray_win32.py: gate PystrayMenuSinkActionTests on pystray availability, not just sys.platform, so a Windows host without the windows-ui extra skips it instead of erroring. The other win32 tray classes import pystray lazily and do not need the guard. - tray/win32.py: document that on_hotkey runs on the keyboard hook thread (not the pystray loop) and why the cross-thread menu-state writes are safe. - README/CONTEXT: `install` registers an HKCU Run-key autostart entry, not a Task Scheduler task; pin the Windows CUDA wheel to cu128 (Blackwell / RTX 50-series needs it; older GPUs can use cu124/cu126). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The INPUT-family structs used wintypes.DWORD/LONG, which alias c_ulong/ c_long. Those are 4 bytes on Windows (LLP64) but 8 bytes on LP64 (Linux/ macOS), so sizeof(INPUT) was 40 on Windows but 56 elsewhere -- failing test_input_struct_matches_win32_abi_size (sizeof == 40) on the Linux and macOS CI runners while passing on Windows. Define the structs with fixed-width ctypes types (c_uint16/c_uint32/ c_int32), which have identical sizes on every platform. The Win32 layout is byte-for-byte unchanged (on Windows c_ulong == c_uint32 == 4, so SendInput still sees the same 40-byte struct and the Windows tests stay green) and sizeof(INPUT) is now 40 everywhere. Note: the defect is only observable on LP64 -- on Windows c_uint32 *is* c_ulong (same class object), so no Windows-only test can distinguish the two; the existing behavioral sizeof==40 test is the cross-platform guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mypy was green on master but went red on this branch: 4 `Name "ctypes.WinDLL" is not defined [name-defined]` errors on the Linux runner, because typeshed gates ctypes.WinDLL behind sys.platform == 'win32' and the WinDLL type annotations are evaluated cross-platform. Add `# type: ignore[name-defined]` on the four annotation sites (the WinDLL *calls* are attr-defined, already disabled in the mypy config); warn_unused_ignores is false, so the ignores are harmless on Windows. ty's informational lane was also full of platform-API false positives (winreg, ctypes.WinDLL, winrt) because it assumed the Linux host platform. Set `python-platform = "all"` so ty makes no single-OS assumption -- the correct setting for a tri-platform project. This drops ty from 57 to 26 diagnostics, removing the win32/macOS platform noise while keeping the genuine type findings. Both lanes are continue-on-error/informational; this stops the PR from adding noise to them. Verified: mypy --platform linux is now "Success: no issues"; ty check reports 26 with no win32 platform-API files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paulbrav
pushed a commit
that referenced
this pull request
Jun 14, 2026
…ge, debloat
Review follow-ups on top of sumgup0's windows-fixes work:
Blockers
- daemon/windows.py: install AND uninstall now best-effort clear the pre-Run-key
Task Scheduler task ("TransClip"). Without this, an in-place Windows upgrader
kept the old logon task AND the new HKCU Run-key entry -> the service started
twice at sign-in (the serve path has no single-instance mutex), and uninstall
orphaned the task. Tests assert both install and uninstall issue schtasks /Delete.
- tests: the live SendInput paste test is gated behind TRANSCLIP_LIVE_PASTE_TESTS
so it no longer runs on the blocking windows-latest CI gate, where a locked or
contended desktop can make SendInput insert <4 events and flake red. The
deterministic sizeof(INPUT)==40 assertion still guards the struct-size fix; the
live test stays for manual real-desktop validation.
Coverage / robustness
- Un-skip the four pure-Python win32 tray test classes (toast routing, invalid-
hotkey rejection, health-icon fallback). They mock all win32 deps and now run
on every platform's CI instead of only the windows-latest lane.
- is_valid_hotkey: catch any parse failure, not just ValueError, so a malformed
binding can never crash the "Set hotkey" apply path it exists to protect.
Debloat / cross-platform hygiene
- Drop docs/windows-roadmap.md (56 lines of speculative not-started roadmap) and
its README pointer; out of scope for a hardening change.
- check_windows_elevated_paste is appended only on Windows, so the Linux/macOS
doctor output no longer carries a "not checked off Windows" advisory line.
Validated on Linux: ruff clean, ty 25 (not grown), 444 tests pass. Windows-runtime
paths (Win32 interop, tray, toasts, autostart) rely on the windows-latest CI job.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paulbrav
added a commit
that referenced
this pull request
Jun 14, 2026
Owner
|
Superseded by #48, which merged your work (thanks @sumgup0!) rebased onto current master + review follow-ups: legacy Task Scheduler migration (prevents double service-start on in-place upgrades), the live SendInput test gated off the blocking CI lane, the pure-Python win32 tray tests un-skipped so they run on every platform, and some debloat. All Windows CI green. In master as of |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens the existing Windows support so the tray, paste, notifications, and
service-install paths actually work on real Windows 11 hardware. Most of these
were latent bugs that never surfaced in CI because the unit tests mocked the OS
boundary (ctypes / pystray / registry); they only appear against the real Win32
APIs. Every change is Windows-only or platform-gated and does not alter
Linux/macOS behaviour.
Validated end-to-end on Windows 11 (NVIDIA RTX 5090): dictate -> cleanup -> paste
into Notepad, tray menu + global hotkey, and
install/status/uninstall.What's fixed
Win32 interop (the core "it crashes" bugs)
c_intrestype andtruncated handles -> declare explicit
argtypes/restypefor every call.SendInputpaste silently failed: theINPUTunion omittedMOUSEINPUT/HARDWAREINPUT, sosizeof(INPUT)didn't match the x64 ABI.WinDLL(use_last_error=True)handle instead of mutating theshared
ctypes.windllcache.OpenClipboardunder transient contention.System tray (pystray)
loop binding inflated
co_argcount) -> factory-built 2-arg handlers.MenuItem.text/enabledare read-only -> drive labels from backingstate +
icon.update_menu(); coalesce repaints into one per refresh.AppUserModelID for taskbar grouping + notification attribution.
pystray loop), with a "Record keys" capture; fixes for the capture crash and a
Tcl_AsyncDeleteon dialog close.the event loop.
Notifications
on/off and service/model toasts under a single
tray_notificationssetting.Service install / autostart
pythonw.exeso it doesn't flash a console window at logon.a root-folder scheduled task requires admin, so
installfailed with "Access isdenied" for a normal user. The Run key is writable by the user's own token (no
UAC):
installregisters it and starts the service;uninstallstops it andremoves the value;
statusmatches the running service by command line(locale-independent, unlike parsing
schtasksoutput).Doctor / docs / misc
elevated window) in
doctorand the README.docs/windows-roadmap.mdfor a future on-device Windows Speech backend.bashexists but can't execute (e.g.a broken WSL relay), instead of rejecting all shell commands.
Testing
ruff check+compileall+python -m tests: 436 tests pass, includingWin32-gated tests that exercise the real registry, named mutex, AUMID,
SendInput, and pystray rather than mocks.
tyclean on the changed modules.set/capture, toasts.
Notes
hardening (+ Granite on CUDA), not a new ASR backend.