Skip to content

v1.0.18

Choose a tag to compare

@owenpkent owenpkent released this 16 May 21:20

[1.0.18] — 2026-05-16

Headlines: initial macOS support (the keyboard runs natively on macOS 11+ with key synthesis, AXUIElement password detection, focus-theft fixes for Qt 6), Sticky Shift no longer "keeps holding" after Shift+Tab and other special-key chords, scorecard exception list for two repo-posture findings deemed not load-bearing for a solo-dev accessibility tool, plus the OSV-Scanner CI gate, CycloneDX SBOM emission, and the audit-driven hardening pass that were sitting in Unreleased.

Changed

  • OSV-Scanner CI job promoted from advisory to merge-gating. .github/workflows/ci.yml now sets fail-on-vuln: true, so any CVE found in requirements-dev.txt or backend/cf-worker/package-lock.json fails CI and blocks the merge. The six known Wrangler-3.x findings (one moderate esbuild, five medium-to-high undici) were resolved by bumping the worker to Wrangler ^4.0.0 (4.92 at upgrade time), which ships clean esbuild 0.27.x and miniflare 4.x; the worker source needed zero code changes (the upgrade is breaking for legacy classic Workers and [env] config patterns, neither of which we use — it's a textbook ESM-module / D1-binding worker). One transitive Python advisory (lxml GHSA-vfmq-68hx-4jfw, fixed in 6.1.0) flowing through cyclonedx-bom was pinned away via lxml>=6.1.0 in requirements-dev.txt. SARIF upload to the Security tab remains off (upload-sarif: false) because the source repo is private and GitHub Advanced Security is not enabled; findings surface in the job's annotations / summary. Future advisories that genuinely cannot be fixed before the next push should be quarantined with an osv-scanner.toml ignore entry, not by flipping fail-on-vuln back globally. CLAUDE.md, docs/WINDOWS.md, and docs/WHITEPAPER.md updated.

Added

  • macOS support (Phase 1 + Phase 4). The keyboard now launches on macOS 11+, types into other apps, and respects Cocoa conventions for a non-activating system tool. Run from source with python run.py after granting Accessibility in System Settings. New platform layer (src/platform/macos.py) drives MacOSKeySynthesizer via Quartz CGEventCreateKeyboardEvent + CGEventPostToPid with pid-targeted delivery. The pid target is the key fix for focus theft: Qt 6 dropped the Qt.ToolNSPanel mapping, so neither NSApplicationActivationPolicyAccessory nor WindowDoesNotAcceptFocus prevents click-activation on QQuickWindow's QNSWindow. Posting to a specific pid sidesteps frontmost state entirely. Cold-start seeding walks the parent process tree via os.getppid() + ps -o ppid= until it lands on a pid NSRunningApplication recognises, so first-typed keystrokes don't vanish into the OSK itself during the gap before the user activates a real target. NSWorkspaceDidActivateApplicationNotification observer tracks subsequent app switches (primary signal); the bridge's 250 ms foreground-window poll also calls set_target_pid on macOS for defence-in-depth. App-level wiring: accessory activation policy removes the Dock icon and Cmd+Tab entry, _apply_macos_window_flags sets NSFloatingWindowLevel + CanJoinAllSpaces | Transient | FullScreenAuxiliary + hidesOnDeactivate=NO, _icon_path is now per-platform (.icns on macOS, .ico on Windows, .png on Linux). Password-field auto-detection via _MacOSAXDetector walks NSWorkspace.frontmostApplication()AXUIElementCreateApplication(pid)kAXFocusedUIElementAttributeAXSecureTextField subrole/role, pid-keyed cache, same dual-trigger pattern as Windows/Linux (200 ms poll + per-keystroke synchronous check rate-limited to 50 ms). Routed via the frontmost app rather than the system-wide element because kAXFocusedUIElementAttribute on AXUIElementCreateSystemWide() returns kAXErrorCannotComplete (-25204) on macOS 14/15. Verified across Cocoa NSSecureTextField (System Settings, Keychain), WebKit <input type=password> (Safari), and Chromium password fields. Documented constraint: macOS Secure Event Input is enabled per-app by NSSecureTextField, so the system shell (System Settings, Keychain, FileVault, login window, sudo in Terminal) blocks third-party OSKs from typing into password fields; web <input type=password> in Safari/Chrome, many app login fields, and password-manager autofill all work or bypass the path entirely. Apple's built-in Accessibility Keyboard bypasses SEI via a privileged path not available to third-party apps. Build pipeline (build/macos/) mirrors build/linux/ with PyInstaller BUNDLE() producing Alpha-OSK.app (com.okstudio1.alpha-osk, LSMinimumSystemVersion 11.0, LSUIElement false, NSHighResolutionCapable true), lockfile + CycloneDX SBOM, optional --dmg via hdiutil; not yet exercised end-to-end. alpha-osk.icns is a 10-size multi-resolution icon generated from logo-2048.png via sips + iconutil (regen recipe in docs/MACOS.md). Stubbed for later phases: code signing & notarization (Phase 3), auto-update (Homebrew tap first, Sparkle later). pyobjc requirements gated via sys_platform == "darwin" so Linux/Windows resolves are unchanged. scripts/mac_keysend_diag.py is a standalone Quartz key-send sanity check used to isolate Quartz from the Qt UI layer during focus-theft debugging. Full plan, design decisions, phase punch list, and per-category SEI breakdown in docs/MACOS.md; CLAUDE.md "Things to Watch Out For" entry covers the CGEventPostToPid + Qt 6 / NSPanel history so the pattern isn't accidentally simplified away.
  • CycloneDX 1.6 SBOM emitted at release time + CI-time OSV-Scanner CVE check. Builds the structured supply-chain document that procurement reviews, federal contracts (Executive Order 14028), and the upcoming EU Cyber Resilience Act all expect. build/{windows,linux}/build.py::emit_sbom runs python -m cyclonedx_py environment --of JSON --sv 1.6 --output-reproducible against the build venv on every build (always, even on --skip-build) and writes release/Alpha-OSK-Setup-{version}-sbom.cyclonedx.json (Windows) or release/Alpha-OSK-{version}-linux-sbom.cyclonedx.json (Linux) alongside the plaintext lockfile already shipped. About 100 KB, 80 components for the Python side. --output-reproducible strips time / random fields so two builds of the same env produce byte-identical SBOMs (diffs stay noise-free). Soft-fails (warning, no abort) if cyclonedx-bom isn't in the venv; production builds pull it in via the new cyclonedx-bom>=7.0.0 pin in requirements-dev.txt. Worker side: npm run sbom calls @cyclonedx/cyclonedx-npm to emit cf-worker-sbom.cyclonedx.json (~470 KB, 209 components — npm dep trees are deeper), and a new predeploy script chains it before every wrangler deploy so every worker deploy has a fresh SBOM. Worker SBOM is gitignored (regenerable from the committed package-lock.json any time). New CI job osv-scan in .github/workflows/ci.yml pinned to google/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 (v2.3.8) reads both lockfiles (requirements-dev.txt + backend/cf-worker/package-lock.json) and queries the OSV database on every push and PR (see the Changed entry above for the merge-gating posture and how the initial known noise was resolved). Release-checklist step 7 (docs/WINDOWS.md) now uploads the SBOM alongside the installer and lockfile via gh release create. Full rationale + maintenance notes in docs/WINDOWS.md § Dependency Lockfile & SBOM and CLAUDE.md § Dependency Lockfile & SBOM. Closes gap #6 from WHITEPAPER §8.2.
  • Release-time Python dependency lockfile. build/windows/build.py::freeze_lockfile and build/linux/build.py::freeze_lockfile run pip freeze --all against the build venv on every build and write the result to release/Alpha-OSK-Setup-{version}-requirements.lock.txt (Windows) or release/Alpha-OSK-{version}-linux-requirements.lock.txt (Linux), with a short header naming the version. Runs even on --skip-build (since bumping the version is what --skip-build is for, and the lockfile name encodes the version). PyInstaller bundles whatever pip resolved at build time, so until now we literally could not answer "what version of urllib3 shipped in 1.0.16?" — the lockfile fixes that with one text file and zero new build dependencies. Not a CycloneDX/SPDX SBOM (no licenses, no purls, no signed statements); the proper SBOM upgrade path via cyclonedx-bom + osv-scanner is documented in docs/WINDOWS.md § Dependency Lockfile for when it's actually needed (hospital procurement, federal supply-chain audit per Executive Order 14028). Release-checklist step 7 (docs/WINDOWS.md) now uploads the lockfile asset alongside the installer in gh release create.
  • backend/cf-worker/package-lock.json committed. The cf-worker shipped without a lockfile, so local dev and CI could resolve different Wrangler / TypeScript / @cloudflare/workers-types versions. Ran npm install --package-lock-only to generate it, added node_modules/ to .gitignore so the lockfile stays the source of truth. Generating the lockfile incidentally surfaced six dev-only CVEs flowing through Wrangler 3.x (one moderate esbuild, five medium-to-high undici) — these affected only the local wrangler dev --local dev server, not the deployed worker on Cloudflare's edge runtime where those packages don't exist. Resolved in the same release by the Wrangler 4.x bump documented in the Changed entry above. Exactly what a lockfile is for: making transitive-dep CVEs visible.

Fixed

  • Sticky Shift no longer "keeps holding" after pressing a special key. Reported as: clicking Shift then Tab fired the chord correctly but Shift visually stayed on and every subsequent click was also under Shift until the user tapped Shift again to toggle off. pressSpecialKey auto-released Ctrl, Alt, and Win at the end of its modifier-cleanup block but Shift was missing from that block, so _shift_active stayed True and hold_modifier("shift")'s OS-side Shift-down stayed in place after the chord. The character-key path in _press_char already handled this correctly. Fix mirrors that block in pressSpecialKey (Python state flip + release_modifier("shift") + _update_layer() + shiftActiveChanged emit, gated on not _caps_lock_active to match _press_char), so chord and character keys now behave identically and match the Windows on-screen keyboard's one-shot Shift behaviour. Same fix also covers Alt+Tab, Ctrl+Tab (worked before but coverage was incomplete), and Win+arrow chords on special keys. Four regression tests in tests/test_keyboard_bridge.py::TestModifiers covering Shift/Alt/Win auto-release after special key plus shiftActiveChanged signal emission. CLAUDE.md "Things to Watch Out For" gains an entry noting the two parallel auto-release blocks (_press_char + pressSpecialKey) and what regresses if they drift.
  • Two blank cmd windows at the end of every Windows release build. Regression in build/windows/sign.py::verify_file: signtool verify is called with capture_output=True (to suppress its stdout from the GUI parent) but the call was missing creationflags=CREATE_NO_WINDOW. From a GUI parent with no inherited console (Cursor / VS Code task runner, Explorer launch, post-elevation re-spawn), Windows allocates a fresh empty console per invocation. verify_build() runs verify_file() for both alpha-osk.exe and the installer, so the symptom was exactly two blank windows at the end of every build. Same defect class as the prior round (1.0.16: PowerShell calls in updater._verify_signature and platform/windows.create_shortcut; 1.0.17: check.py / run.py capability probes). Fixed in sign.py::verify_file; same fix applied opportunistically to build/windows/build.py::check_pyinstaller (python -m PyInstaller --version) and ::check_certificate (certutil -store) which had the same defect but ran at build start where the windows often flashed too quickly to notice. Visible-output sites (build_pyinstaller's actual PyInstaller run, build_nsis_installer's makensis call, sign.py::sign_file with capture_output=False) are deliberately untouched — their output is supposed to stream to the terminal. CLAUDE.md Things to Watch Out For now lists every known-fixed site so a future audit doesn't have to grep for them.

Removed

  • All six built-in vocabulary packs (medical, programming, academic, gaming, business, nsfw). Each was 200-400 words, ~30-40× smaller than the base Google 10K + supplement. The engine's organic learning bumps a word's score by +5 every time the user accepts a pill, so a real domain user types past the pack's coverage in their first hour and from then on the seed list saves nothing. Sourcing real domain vocabularies (SNOMED-grade for medical, full API surface for programming) is its own project and runs into licensing issues; curated 300-word lists were strictly worse than no shipped packs at all. They were also drifting in maintenance — the NSFW pack had a different pack.json schema (used id + word_count fields no other pack used, version as string instead of int) and shipped no n-grams while the others all had ~100 each. Bonus: there was an open correctness bug where disabling a pack didn't undo the predictor injection (apply_to_predictor writes via max(), disable_pack only clears the pack's own copy), so just trying a pack permanently polluted the predictor until next process restart. Removing the built-ins makes that bug invisible to all but a tiny audience (users who import their own pack and then disable without restarting). The import infrastructure (PackManager, import_vocabulary_pack slot, the QML Import button + folder picker, the security hardening for .. traversal and symlinks) is preserved — power users who have a real domain wordlist can drop a folder in. The Vocabulary Packs section in Settings now shows toggles for any imported packs (driven dynamically by getAvailablePacks() rather than the old hardcoded [{id: "medical", ...}, ...] Repeater), with an empty-state explainer when nothing is imported. VocabularyPack.get_info() now includes the id field (the directory name) so QML can drive enable/disable from the same dict the list iterates over. Tests: removed the TestRealPacks parametrised class (tested data files that no longer exist); shrunk TestPackHybridIntegration from four tests to two (the two that don't depend on a built-in pack); the 28-test suite still passes. CLAUDE.md gets a new "Vocabulary Packs" section explaining the import-only design and how to re-introduce a built-in pack if a future release wants to.

Added

  • "Clear Suggestion Context" button in the title bar (⟲). The auto context-reset on app switch fires from _check_foreground_window's 250 ms hwnd poll, so it misses transitions that keep the same hwnd: tab changes inside a browser, focus changes between child windows of the same parent app, anything that swaps document context without changing the foreground window. Reported as "sometimes the suggestions don't clear as much as I want." New ⟲ button in the title bar (between the privacy toggle and the settings gear) calls the existing keyboard.resetContext() slot to wipe _current_word / _sentence_buffer / _context_buffer and re-emit empty predictions, with a 1.4 s "Context cleared" toast for feedback (mirrors editSavedToast's pattern). The slot was already in the bridge for "explicit user action" — this just gives it a UI surface.
  • Hover tooltips on every title bar button. Update (↓), privacy mode (Learning/Paused), clear context (⟲), settings (⚙), minimize, and close all show a 400 ms-delay tooltip on hover (matches the existing prediction-pill truncation tooltip in Main.qml). The privacy-mode tooltip is dynamic — "Pause learning from typing" when active, "Resume learning from typing" when paused — so the click action is communicated alongside the current state on the button face. The icons alone weren't self-explanatory; this is the cheapest way to make every button discoverable without adding visible labels.

Changed

  • Settings panel refactored from a single long scroll into a four-category drill-down. The previous panel was 13 stacked sections in one Flickable (~1300 lines), and finding the right toggle meant scrolling past everything else. New structure: a home grid with four colour-coded category cards (Appearance, Smart Typing, Your Language Model, Data & Privacy); clicking a card swaps the body to that category's sub-view with a back arrow (‹) in the header. Category mapping: Appearance gets Panels (renamed from "Layout"), Keyboard Layout, Theme, and Sound & Opacity (renamed from "Appearance" since the parent category took the name); Smart Typing gets Suggestions, Suggestion Engine, and Input; Your Language Model gets a top "Open Dashboard →" tile plus Vocabulary Packs and Prediction Model; Data & Privacy gets a top "Help & Shortcuts" tile plus Privacy, Updates, and Developer. State is held in a single currentView string with five sibling ColumnLayouts gated on visible: unifiedSettings.currentView === "<id>"; only one renders at a time. Scroll position resets to the top on every view change so a sub-view never opens mid-section. Settings window's onVisibleChanged calls settingsPanel.resetToHome() so re-opening always lands on the home grid (not whatever sub-page the user last visited — that would read as "the menu changed"). The old "Tools" section was split: the visualization button became the top tile of Your Language Model, the help button became the top tile of Data & Privacy. CLAUDE.md's "Settings Panel Structure" section documents the full where-each-section-lives table and updated "Adding a New Setting" steps.
  • Privacy mode icon replaced with a "Learning" / "Paused" text label. The previous play-triangle / pause-bars Canvas was a media-player metaphor that read as "is something playing" rather than "is the keyboard learning from me" — reported as confusing. The Canvas is gone; the button now shows the literal state in text ("Learning" in neutral grey when active, "Paused" in red text + red border + dark red background when off). Both labels are -ing/-ed state words (not imperatives) so the button face consistently shows what's happening, while the hover tooltip (unchanged) says what clicking will do — eliminating the label-vs-action ambiguity. Button width fixed at 62 px for the longer "Learning" label so toggling doesn't reflow the title bar; drag-area right margin bumped from 155 → 230 to account for the wider button row (this title-bar button row also grew by the new ⟲ button above).

Changed

  • Analytics moved from the top of Settings into the renamed "Your Language Model" submenu (Settings → Tools). Settings opens for config changes far more often than for stats, so leading with the Analytics dashboard meant scrolling past it every time. The submenu previously called "Language Model Visualization" is now "Your Language Model" (window title and Tools button both updated), and the Analytics dashboard is embedded at the top of its Dashboard tab. Same data, same controls, alongside the rest of the user's typing data (vocabulary, top words, boosted/suppressed lists) instead of greeting the user before any config. No behaviour change in the dashboard itself — AnalyticsDashboard.qml is the same component, just re-parented.

Fixed

  • Clicking a prediction after typing a punctuation prefix no longer eats the punctuation. Reported as: typing *hel then clicking the "hello" pill deleted the asterisk. Root cause: the asterisk accumulated into _current_word (it wasn't in the word-boundary set), so pressPrediction saw a typed prefix of *hel. "hello".startswith("*hel") is False, so the suffix-only insertion path was skipped, the bridge fell through to replace_text(len("*hel"), "hello "), and Shift+Left-selected 4 chars (including the asterisk) before overwriting. Extended the word-internal boundary set in _press_char to cover prefix-punctuation chars that real typing produces: *, @, #, $, %, &, +, =, ~, ^, |, ", `. These now reset _current_word the same way hyphen and slash do (the char stays on screen, predictions track the trailing word alone). Same fix covers @user, #tag, $var, markdown **bold**, quoted "hello, backtick `code, and operator-prefix tokens like key=value. Apostrophe and underscore are deliberately excluded so contractions (don't) and snake_case identifiers stay as single tokens. Two regression tests in tests/test_keyboard_bridge.py: test_prefix_punctuation_does_not_pollute_current_word walks each new boundary char, and test_asterisk_prefix_prediction_click_keeps_asterisk mirrors the user's exact flow (asserts no BackSpace, no replace_text, suffix-only "lo " sent).
  • Silent dev-script subprocess calls no longer pop empty console windows. check.py::_have_module (capability probe for ruff / mypy / pytest, output piped to DEVNULL) and run.py::check_dependencies (PySide6 import check with capture_output=True) invoked console-subsystem python.exe without creationflags=CREATE_NO_WINDOW. When the parent has no inherited console (Cursor / VS Code task runner, Explorer launch, post-elevation re-spawn), Windows allocates a fresh blank console for each child — exactly the "completely empty/blank" popups the user reported and had to close manually. Both call sites now pass creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) (portable: 0 on POSIX, 0x08000000 on Windows). The visible-output sites (pip install, the actual ruff / mypy / pytest runs in check.py, the keyboard launch in run_keyboard) are deliberately untouched so their output still streams to the terminal.

Added

  • "Show more" on the prediction-pill right-click menu, paired with a Boosted Words dashboard section. The existing menu surfaced "Show less" (downweight) and "Remove" (blacklist) but had no positive counterpart, so a user who wanted a particular spelling or domain word to surface sooner had no on-keyboard way to nudge it. New green ▲ "Show more" row sits between Edit and Show less; one click clears any prior dispreference, bumps unigrams / user_vocab by +5 (same magnitude the engine already applies on prediction-click reinforcement), and records the boost in a new NgramPredictor.preferred: Dict[str, int] counter so the boost can be surfaced and rolled back later. Persisted in ngram_model.json next to dispreference / blacklist. New "Boosted Words" card on the Model Visualization Dashboard tab (Settings → Tools → Language Model Visualization → Dashboard) lists boosted words as green tags labelled word (+N); clicking a tag calls the new keyboard.unprefer(word) slot which decrements unigrams / user_vocab / _user_total / total_words by the cumulative boost amount, capped at the current user_vocab count so a word that was also organically learned keeps its organic count after the boost is removed. The card hides when nothing is boosted, same pattern as the Suppressed Words card. Bridge surface: new markGoodSuggestion / unprefer slots; new preferredCount and preferred (top 20 by boost) keys in getVisualizationData()'s stats payload. HybridPredictor forwarders mark_good_suggestion / unprefer keep external callers off the n-gram internals.
  • Repo governance files (LICENSE, SECURITY.md, Dependabot config). MIT LICENSE at repo root makes the OSS grant explicit (it was missing entirely before, which made the repo source-available-by-accident rather than open-source-by-intent). SECURITY.md documents private vulnerability disclosure: GitHub private advisories on the public releases repo, owenpkent@gmail.com fallback, scope (Python source, QML, build pipelines, telemetry worker) and out-of-scope (anything requiring physical access to an unlocked machine, third-party deps). .github/dependabot.yml opens weekly PRs for pip + github-actions ecosystems, with patch/minor pip updates grouped to keep noise down. All three were flagged by the security audit's scorecard pass; adding them flips the project's Dependency-Update-Tool, License, and Security-Policy scores from 0 → 10.
  • Auto-update progress indicator (T40). The 1.0.17 update made the auto-update path actually relaunch the OSK after install, but the user reported the resulting ~30 s gap between "keyboard disappears" and "keyboard reappears" still felt broken. Two new layers cover that gap: (1) a pre-update toast in the live OSK ("Installing v1.0.X. The keyboard will disappear briefly and come back.") fired immediately before the installer launches and dwells for 1.8 s so the user knows what's about to happen. New KeyboardBridge.updateInstallHandoffPending(version) signal, new on_installer_launching callback param on download_and_install. (2) A small splash window owned by the relauncher process itself, frameless and always-on-top, with phase-aware text ("Waiting for the installer to finish…" → "Installing files…" → "Launching the new keyboard…" → "Done!"). The relauncher's polling logic was refactored from blocking sleep loops into a QTimer state machine so the splash stays painted between checks (_run_with_splash orchestrator + new _new_exe_ready single-shot helper). Splash colours match the in-app toast (#1e3354 on #4a8eff) so it visually belongs to Alpha-OSK rather than looking like a stray system dialog. Failure paths show "Find Alpha-OSK in your Start Menu" for 6 s instead of vanishing silently. If the splash fails to start at all (PySide6 import error, no display server), the relauncher falls back to the existing headless path — better to relaunch silently than to leave the user with nothing. Production callers pass --show-splash; tests don't, so the existing _run_headless path is still exercised.
  • Splash dismiss button + dev-mode short-circuit. Got immediate real-world feedback: a dev-mode test left the splash stuck at "Installing files…" because dev-mode's _spawn_relauncher passes --target-exe sys.executable (a python interpreter), and _new_exe_ready waits for the target's mtime to advance past parent-death — which python.exe never does, so the splash sat for the full 180 s _NEW_EXE_TIMEOUT_S window with no escape. Two fixes: a small ✕ in the top-right corner of the splash that hides the window without aborting the relaunch (the user is dismissing the visual, not the work — polling continues invisibly so the new OSK still launches when ready), and a new _is_dev_target() check that detects python / pythonw basenames and routes those straight to headless. The dev-mode skip is gated only on the target-exe shape, so a real production install (which always points at alpha-osk.exe) is unaffected.
  • Spatial fuzzy model now covers the digit row. Added 1-9 and 0 to QWERTY_POSITIONS at row -1, vertically aligned with the qwerty row (5 above t, 6 above y, etc.). Before this, digits returned {digit: 1.0} from SpatialKeyModel.get_key_probabilities, so off-by-one-row mistypes between letter and digit ("h3llo" instead of "hello", "t6st" instead of "test") were never recoverable by the fuzzy candidate generator. After: the spatial path treats digits as near-neighbours of the letter directly below them, so the candidate generator can substitute and surface the right word. Same-row digit-to-digit nearness (4↔5↔6) falls out of the existing distance metric for free.

Changed

  • Audit-driven hardening pass. Driven by the multi-tool security audit (CodeQL / semgrep / bandit / osv-scanner / scorecard / zizmor). Touches: (1) .github/workflows/ci.yml — pinned actions/checkout@v4 and actions/setup-python@v5 to commit SHAs (resolves zizmor unpinned-uses finding; tag references can be force-moved by a compromised maintainer), added permissions: contents: read at workflow + per-job (default GITHUB_TOKEN is read-write to everything), and persist-credentials: false on every checkout step (prevents credential leakage through artifacts; CI never pushes back). (2) requirements-dev.txt — pinned transitive Pygments>=2.20.0 to dodge the ReDoS vulnerabilities (CVE-2022-40896 and CVE-2026-4539) that osv-scanner found in older versions pytest could otherwise pull in. (3) src/telemetry.py::_default_submit_fn — added an HTTPS scheme + hostname check before urllib.request.urlopen so a misconfigured build that pointed DEFAULT_ENDPOINT at http:// or file:// would fail-closed with URLError rather than silently leak telemetry over an unauthenticated channel. (4) Explanatory comments on the eleven empty-except blocks across _update_relauncher.py, keyboard_app.py, keyboard_bridge.py, password_detect.py, and updater.py — all are intentional best-effort cleanup (COM teardown, OS-shutdown chmod, log-init failure, transient stat races, AllowSetForegroundWindow probe) where raising would make worse outcomes than swallowing; the comments document why and turn each site into a CodeQL-compliant rule pass.

Fixed

  • PowerShell subprocess calls no longer pop empty cmd windows. src/updater.py::_verify_signature and src/platform/windows.py::create_shortcut invoked powershell.exe via subprocess.run without creationflags=CREATE_NO_WINDOW, so each call popped a brief PowerShell console window. On hosts where the parent process has no attached console (frozen GUI build, Qt application without a parent terminal), some of those windows would persist instead of flashing and the user had to close them manually. Both call sites now pass creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) (portable: 0 on POSIX, 0x08000000 on Windows). The auto-update signature verification path runs on every install, and the shortcut creation path runs from add_to_startup / create_start_menu_shortcut / create_desktop_shortcut, so this affected anyone exercising the auto-updater or a fresh install. Detached-process spawns (_spawn_relauncher, _launch_new_osk, the non-Windows _launch_installer) already use DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP and aren't affected.