Skip to content

Harden Windows 11 support: paste/tray/notification fixes + admin-free Run-key autostart - #44

Closed
sumgup0 wants to merge 29 commits into
paulbrav:masterfrom
sumgup0:windows-fixes
Closed

Harden Windows 11 support: paste/tray/notification fixes + admin-free Run-key autostart#44
sumgup0 wants to merge 29 commits into
paulbrav:masterfrom
sumgup0:windows-fixes

Conversation

@sumgup0

@sumgup0 sumgup0 commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

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)

  • Clipboard access crashed on 64-bit: ctypes used the default c_int restype and
    truncated handles -> declare explicit argtypes/restype for every call.
  • SendInput paste silently failed: the INPUT union omitted
    MOUSEINPUT/HARDWAREINPUT, so sizeof(INPUT) didn't match the x64 ABI.
  • Use a private WinDLL(use_last_error=True) handle instead of mutating the
    shared ctypes.windll cache.
  • Retry OpenClipboard under transient contention.

System tray (pystray)

  • pystray rejects menu-action callbacks with >2 positional args (a default-arg
    loop binding inflated co_argcount) -> factory-built 2-arg handlers.
  • pystray MenuItem.text/enabled are read-only -> drive labels from backing
    state + icon.update_menu(); coalesce repaints into one per refresh.
  • Per-monitor-v2 DPI awareness, a single-instance named mutex, and an explicit
    AppUserModelID for taskbar grouping + notification attribution.
  • Set-hotkey dialog runs on its own Tk thread (it was unresponsive nested in the
    pystray loop), with a "Record keys" capture; fixes for the capture crash and a
    Tcl_AsyncDelete on dialog close.
  • Reject invalid hotkey strings and unknown health-icon states before they reach
    the event loop.

Notifications

  • Modern toast notifications via the WinRT projection (pywinrt) - recording
    on/off and service/model toasts under a single tray_notifications setting.

Service install / autostart

  • Run the service via pythonw.exe so it doesn't flash a console window at logon.
  • Autostart via the per-user HKCU Run key instead of Task Scheduler. Creating
    a root-folder scheduled task requires admin, so install failed with "Access is
    denied" for a normal user. The Run key is writable by the user's own token (no
    UAC): install registers it and starts the service; uninstall stops it and
    removes the value; status matches the running service by command line
    (locale-independent, unlike parsing schtasks output).

Doctor / docs / misc

  • Document the UIPI limitation (a normal-integrity app can't paste into an
    elevated window) in doctor and the README.
  • docs/windows-roadmap.md for a future on-device Windows Speech backend.
  • Skip shell-command syntax validation when bash exists 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, including
    Win32-gated tests that exercise the real registry, named mutex, AUMID,
    SendInput, and pystray rather than mocks.
  • ty clean on the changed modules.
  • Manual: install/status/uninstall, mic dictation -> paste, tray hotkey
    set/capture, toasts.

Notes

  • Complementary to the OpenVINO Windows backend - this is daemon/tray/paste
    hardening (+ Granite on CUDA), not a new ASR backend.
  • Independent of the NAR-on-Windows PR.

sumgup0 and others added 29 commits June 13, 2026 22:49
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
Win32 interop/tray/toast fixes + admin-free HKCU Run-key autostart, with legacy Task Scheduler migration (no double-start on upgrade), CI-safe paste test, wider win32 test coverage, and debloat. Supersedes #44 (orig by @sumgup0).
@paulbrav

Copy link
Copy Markdown
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 ba25cb0. Closing this in favor of the merged version.

@paulbrav paulbrav closed this Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants