v1.0.18
[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.ymlnow setsfail-on-vuln: true, so any CVE found inrequirements-dev.txtorbackend/cf-worker/package-lock.jsonfails CI and blocks the merge. The six known Wrangler-3.x findings (one moderateesbuild, five medium-to-highundici) were resolved by bumping the worker to Wrangler^4.0.0(4.92 at upgrade time), which ships cleanesbuild0.27.x andminiflare4.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 (lxmlGHSA-vfmq-68hx-4jfw, fixed in 6.1.0) flowing throughcyclonedx-bomwas pinned away vialxml>=6.1.0inrequirements-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 anosv-scanner.tomlignore entry, not by flippingfail-on-vulnback globally. CLAUDE.md,docs/WINDOWS.md, anddocs/WHITEPAPER.mdupdated.
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.pyafter granting Accessibility in System Settings. New platform layer (src/platform/macos.py) drivesMacOSKeySynthesizervia QuartzCGEventCreateKeyboardEvent+CGEventPostToPidwith pid-targeted delivery. The pid target is the key fix for focus theft: Qt 6 dropped theQt.Tool→NSPanelmapping, so neitherNSApplicationActivationPolicyAccessorynorWindowDoesNotAcceptFocusprevents click-activation onQQuickWindow'sQNSWindow. Posting to a specific pid sidesteps frontmost state entirely. Cold-start seeding walks the parent process tree viaos.getppid()+ps -o ppid=until it lands on a pidNSRunningApplicationrecognises, so first-typed keystrokes don't vanish into the OSK itself during the gap before the user activates a real target.NSWorkspaceDidActivateApplicationNotificationobserver tracks subsequent app switches (primary signal); the bridge's 250 ms foreground-window poll also callsset_target_pidon macOS for defence-in-depth. App-level wiring: accessory activation policy removes the Dock icon and Cmd+Tab entry,_apply_macos_window_flagssetsNSFloatingWindowLevel+CanJoinAllSpaces | Transient | FullScreenAuxiliary+hidesOnDeactivate=NO,_icon_pathis now per-platform (.icnson macOS,.icoon Windows,.pngon Linux). Password-field auto-detection via_MacOSAXDetectorwalksNSWorkspace.frontmostApplication()→AXUIElementCreateApplication(pid)→kAXFocusedUIElementAttribute→AXSecureTextFieldsubrole/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 becausekAXFocusedUIElementAttributeonAXUIElementCreateSystemWide()returnskAXErrorCannotComplete(-25204) on macOS 14/15. Verified across CocoaNSSecureTextField(System Settings, Keychain), WebKit<input type=password>(Safari), and Chromium password fields. Documented constraint: macOS Secure Event Input is enabled per-app byNSSecureTextField, so the system shell (System Settings, Keychain, FileVault, login window,sudoin 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/) mirrorsbuild/linux/with PyInstallerBUNDLE()producingAlpha-OSK.app(com.okstudio1.alpha-osk,LSMinimumSystemVersion 11.0,LSUIElement false,NSHighResolutionCapable true), lockfile + CycloneDX SBOM, optional--dmgviahdiutil; not yet exercised end-to-end.alpha-osk.icnsis a 10-size multi-resolution icon generated fromlogo-2048.pngviasips+iconutil(regen recipe indocs/MACOS.md). Stubbed for later phases: code signing & notarization (Phase 3), auto-update (Homebrew tap first, Sparkle later). pyobjc requirements gated viasys_platform == "darwin"so Linux/Windows resolves are unchanged.scripts/mac_keysend_diag.pyis 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 indocs/MACOS.md; CLAUDE.md "Things to Watch Out For" entry covers theCGEventPostToPid+ Qt 6 /NSPanelhistory 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_sbomrunspython -m cyclonedx_py environment --of JSON --sv 1.6 --output-reproducibleagainst the build venv on every build (always, even on--skip-build) and writesrelease/Alpha-OSK-Setup-{version}-sbom.cyclonedx.json(Windows) orrelease/Alpha-OSK-{version}-linux-sbom.cyclonedx.json(Linux) alongside the plaintext lockfile already shipped. About 100 KB, 80 components for the Python side.--output-reproduciblestrips time / random fields so two builds of the same env produce byte-identical SBOMs (diffs stay noise-free). Soft-fails (warning, no abort) ifcyclonedx-bomisn't in the venv; production builds pull it in via the newcyclonedx-bom>=7.0.0pin inrequirements-dev.txt. Worker side:npm run sbomcalls@cyclonedx/cyclonedx-npmto emitcf-worker-sbom.cyclonedx.json(~470 KB, 209 components — npm dep trees are deeper), and a newpredeployscript chains it before everywrangler deployso every worker deploy has a fresh SBOM. Worker SBOM is gitignored (regenerable from the committedpackage-lock.jsonany time). New CI jobosv-scanin.github/workflows/ci.ymlpinned togoogle/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 viagh release create. Full rationale + maintenance notes indocs/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_lockfileandbuild/linux/build.py::freeze_lockfilerunpip freeze --allagainst the build venv on every build and write the result torelease/Alpha-OSK-Setup-{version}-requirements.lock.txt(Windows) orrelease/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-buildis 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 viacyclonedx-bom+osv-scanneris documented indocs/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 ingh release create. backend/cf-worker/package-lock.jsoncommitted. The cf-worker shipped without a lockfile, so local dev and CI could resolve different Wrangler / TypeScript /@cloudflare/workers-typesversions. Rannpm install --package-lock-onlyto generate it, addednode_modules/to.gitignoreso 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 localwrangler dev --localdev 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.
pressSpecialKeyauto-released Ctrl, Alt, and Win at the end of its modifier-cleanup block but Shift was missing from that block, so_shift_activestayed True andhold_modifier("shift")'s OS-side Shift-down stayed in place after the chord. The character-key path in_press_charalready handled this correctly. Fix mirrors that block inpressSpecialKey(Python state flip +release_modifier("shift")+_update_layer()+shiftActiveChangedemit, gated onnot _caps_lock_activeto 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 intests/test_keyboard_bridge.py::TestModifierscovering Shift/Alt/Win auto-release after special key plusshiftActiveChangedsignal 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 verifyis called withcapture_output=True(to suppress its stdout from the GUI parent) but the call was missingcreationflags=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()runsverify_file()for bothalpha-osk.exeand 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 inupdater._verify_signatureandplatform/windows.create_shortcut; 1.0.17:check.py/run.pycapability probes). Fixed insign.py::verify_file; same fix applied opportunistically tobuild/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_filewithcapture_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.jsonschema (usedid+word_countfields 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_predictorwrites viamax(),disable_packonly 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_packslot, 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 bygetAvailablePacks()rather than the old hardcoded[{id: "medical", ...}, ...]Repeater), with an empty-state explainer when nothing is imported.VocabularyPack.get_info()now includes theidfield (the directory name) so QML can drive enable/disable from the same dict the list iterates over. Tests: removed theTestRealPacksparametrised class (tested data files that no longer exist); shrunkTestPackHybridIntegrationfrom 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 existingkeyboard.resetContext()slot to wipe_current_word/_sentence_buffer/_context_bufferand re-emit empty predictions, with a 1.4 s "Context cleared" toast for feedback (mirrorseditSavedToast'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
currentViewstring with five siblingColumnLayouts gated onvisible: 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'sonVisibleChangedcallssettingsPanel.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.qmlis the same component, just re-parented.
Fixed
- Clicking a prediction after typing a punctuation prefix no longer eats the punctuation. Reported as: typing
*helthen clicking the "hello" pill deleted the asterisk. Root cause: the asterisk accumulated into_current_word(it wasn't in the word-boundary set), sopressPredictionsaw a typed prefix of*hel."hello".startswith("*hel")is False, so the suffix-only insertion path was skipped, the bridge fell through toreplace_text(len("*hel"), "hello "), and Shift+Left-selected 4 chars (including the asterisk) before overwriting. Extended the word-internal boundary set in_press_charto cover prefix-punctuation chars that real typing produces:*,@,#,$,%,&,+,=,~,^,|,",`. These now reset_current_wordthe 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 likekey=value. Apostrophe and underscore are deliberately excluded so contractions (don't) and snake_case identifiers stay as single tokens. Two regression tests intests/test_keyboard_bridge.py:test_prefix_punctuation_does_not_pollute_current_wordwalks each new boundary char, andtest_asterisk_prefix_prediction_click_keeps_asteriskmirrors 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) andrun.py::check_dependencies(PySide6 import check withcapture_output=True) invoked console-subsystempython.exewithoutcreationflags=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 passcreationflags=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 incheck.py, the keyboard launch inrun_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_vocabby +5 (same magnitude the engine already applies on prediction-click reinforcement), and records the boost in a newNgramPredictor.preferred: Dict[str, int]counter so the boost can be surfaced and rolled back later. Persisted inngram_model.jsonnext todispreference/blacklist. New "Boosted Words" card on the Model Visualization Dashboard tab (Settings → Tools → Language Model Visualization → Dashboard) lists boosted words as green tags labelledword (+N); clicking a tag calls the newkeyboard.unprefer(word)slot which decrementsunigrams/user_vocab/_user_total/total_wordsby the cumulative boost amount, capped at the currentuser_vocabcount 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: newmarkGoodSuggestion/unpreferslots; newpreferredCountandpreferred(top 20 by boost) keys ingetVisualizationData()'s stats payload. HybridPredictor forwardersmark_good_suggestion/unpreferkeep 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.mddocuments 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.ymlopens 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, newon_installer_launchingcallback param ondownload_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_splashorchestrator + new_new_exe_readysingle-shot helper). Splash colours match the in-app toast (#1e3354on#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_headlesspath 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_relauncherpasses--target-exe sys.executable(a python interpreter), and_new_exe_readywaits for the target's mtime to advance past parent-death — whichpython.exenever does, so the splash sat for the full 180 s_NEW_EXE_TIMEOUT_Swindow 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 detectspython/pythonwbasenames 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 atalpha-osk.exe) is unaffected. - Spatial fuzzy model now covers the digit row. Added 1-9 and 0 to
QWERTY_POSITIONSat row -1, vertically aligned with the qwerty row (5 above t, 6 above y, etc.). Before this, digits returned{digit: 1.0}fromSpatialKeyModel.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— pinnedactions/checkout@v4andactions/setup-python@v5to commit SHAs (resolves zizmorunpinned-usesfinding; tag references can be force-moved by a compromised maintainer), addedpermissions: contents: readat workflow + per-job (default GITHUB_TOKEN is read-write to everything), andpersist-credentials: falseon every checkout step (prevents credential leakage through artifacts; CI never pushes back). (2)requirements-dev.txt— pinned transitivePygments>=2.20.0to 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 beforeurllib.request.urlopenso a misconfigured build that pointedDEFAULT_ENDPOINTathttp://orfile://would fail-closed withURLErrorrather 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, andupdater.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_signatureandsrc/platform/windows.py::create_shortcutinvokedpowershell.exeviasubprocess.runwithoutcreationflags=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 passcreationflags=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 fromadd_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 useDETACHED_PROCESS | CREATE_NEW_PROCESS_GROUPand aren't affected.