diff --git a/README.md b/README.md index ce9f750f..0ad70dc7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ and run it. It always points at the latest release. - Adds the WebView2 runtime if Settings needs it. - Settings, profiles and logs stay in `%LOCALAPPDATA%\Wind`, also after an uninstall unless you choose otherwise. +- Works with screen readers: setup switches to plain, standard pages when a screen reader (NVDA, + JAWS or Narrator) or high contrast is on. To ask for them yourself, run `Wind-Setup-x64.exe /ACCESSIBLE`. The installer is not signed, so SmartScreen warns on first run: choose **More info**, then **Run anyway**. Some browsers and work computers block the download. Setup signs Wind locally for your PC, diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 94a83588..b3f8cea0 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -14,6 +14,8 @@ install/uninstall round trip (`tools\installer_check.ps1`). - [ ] Upgrade while Wind runs zoomed: no "file in use" error, the OS cursor is visible afterwards. Upgrade with Settings open: WindConfig closes. - [ ] Uninstall keeping settings: `magnifier.ini` survives. Uninstall removing settings: it does not. - [ ] At 100% and 225% DPI the setup window is sharp and its hit targets line up. +- [ ] Accessible setup (`/ACCESSIBLE`, or with NVDA running): stock pages with a title bar; NVDA reads each page, Tab reaches every button and box, Space ticks the licence box, Install shows the log, and Finish starts an unelevated Wind only when "Open Wind now" is ticked. +- [ ] The picture's licence screen: Space ticks the box; Enter before ticking shows the hint message box. - [ ] Without UIAccess Wind refuses to start: a plain `build.bat` `build\Wind.exe` (no `WIND_ALLOW_NO_UIACCESS`) shows the "Wind can't start" dialog telling you to run the installer again, then exits with no tray icon and no magnifier. ## Desktop zoom diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index 72718fd0..c9b2bddb 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -118,6 +118,12 @@ and local signing are described in `installer/README.md`; the design spec is [../specs/2026-08-20-installer-design.md](../specs/2026-08-20-installer-design.md). There is no install-location chooser, because UIAccess is granted only in a secure location. +The picture has no controls a screen reader or the keyboard can use, so setup also carries an +accessible plain mode of stock MUI pages (`installer/plain.nsh`, issue #513). `.onInit` picks it on +`/ACCESSIBLE`, a running NVDA, JAWS or Narrator, or high contrast, never under `/S` (not +`SPI_GETSCREENREADER`: other apps leave it set); both page +sets are listed and each skips itself when the other runs. Details in `installer/README.md`. + **Elevation rules** (`installer/app.nsh`): 1. **HKCU and `%LOCALAPPDATA%` belong to the wrong user under elevation.** An elevated process's diff --git a/installer/README.md b/installer/README.md index 42007c21..637e0a76 100644 --- a/installer/README.md +++ b/installer/README.md @@ -3,7 +3,8 @@ Wind's installer is NSIS with a custom-drawn UI: a looping video with the screens composited over it. NSIS cannot play video, so every tick it decodes a JPEG frame through GDI+, alpha-blends the screen's overlay on top and copies the result into one static control; the same tick hit-tests the -pointer. There are no native buttons. +pointer. There are no native buttons, so setup also carries an accessible plain mode built from stock +pages (see [Accessible setup](#accessible-setup)). Wind installs per-machine and elevated, and there is no install-location screen: UIAccess is granted only to a signed binary in a secure location, so an install elsewhere could not get it, and Wind refuses to start without it. @@ -18,6 +19,7 @@ The elevation rules (HKLM autostart, launching through `explorer.exe`, stopping | `wind.nsi` | Entry point: metadata, page order, install and uninstall sections | | `app.nsh` | Wind-specific logic: quitting a running Wind, WebView2, autostart, launching | | `screens.nsh` | The five screens and what each click does | +| `plain.nsh` | The accessible plain mode: stock MUI pages for screen readers and the keyboard | | `kit.nsh` | The frameless window: size, DPI, GDI+, unpacking | | `video.nsh` | The player: decode, composite, hover, clicks, dragging | | `over.html` | The foreground: type, buttons, caption. The only place copy lives | @@ -69,6 +71,33 @@ and opens it through `explorer.exe`, so the viewer is not elevated (an elevated `$PLUGINSDIR` to Administrators). A silent install skips the page; `LICENSE.txt` is installed next to `Wind.exe` either way. +## Accessible setup + +The picture is pixels: a screen reader finds nothing to name on it and the keyboard has nothing to +focus (issue #513). So `wind.nsi` lists two page sets and `.onInit` (`WIND_PICK_PLAIN` in `kit.nsh`) +picks one per run. The plain set is stock NSIS: MUI's welcome page, MUI's licence page with the full +`LICENSE` text and a real "I accept" checkbox, an nsDialogs page with the install folder as a label +and a "Start Wind when I sign in" checkbox, the stock install page with its status line and log +shown, and MUI's finish page with "Open Wind now" (launched through `explorer.exe`, like the picture) +and "Create desktop shortcut". NVDA, JAWS and Narrator read these, and Tab, Space and Enter work. + +Setup runs plain when any of these holds: + +- `/ACCESSIBLE` is on the command line (any case): `Wind-Setup-x64-.exe /ACCESSIBLE`. +- A screen reader is running: NVDA (its `wxWindowClassNR` / `NVDA` window, or `nvda.exe`), JAWS + (`jfw.exe`) or Narrator (`Narrator.exe`). The system flag `SPI_GETSCREENREADER` is deliberately + not used: other apps leave it set with no screen reader running. +- High contrast is on (the art ignores the user's colours). + +A silent install (`/S`) never runs plain mode and behaves exactly as before. Plain mode skips +everything the picture needs: the frameless window, the media unpack, GDI+ and the timer, the hidden +log and the autoclose. Each plain MUI page has `windPlainOnly` as its PRE function, and each picture +creator aborts when `$Plain` is 1, so NSIS walks past the other set in both directions. + +The picture itself answers one key: Space ticks the licence box, and pressing Enter on the licence +screen before ticking it shows a message box saying so and pointing at `/ACCESSIBLE`, rather than +doing nothing. + ## Editing the overlay Edit `over.html`, then regenerate both overlay sets straight into `media//o`: diff --git a/installer/kit.nsh b/installer/kit.nsh index e3fc97ba..a1779ee6 100644 --- a/installer/kit.nsh +++ b/installer/kit.nsh @@ -30,6 +30,50 @@ Var ArtDir Var Dialog Var Gdip ; GDI+ token, started once and never stopped: setup outlives it Var Font +Var Plain ; 1: the accessible stock pages (plain.nsh) instead of the picture + +; ---- accessible plain mode (issue #513) ---------------------------------------------------- +; The picture has no controls a screen reader can name and no keyboard operation, so setup +; also carries a plain set of stock MUI pages (plain.nsh) and picks one set at startup. Plain +; when any of these holds: +; /ACCESSIBLE on the command line (GetOptions is case-insensitive) +; a screen reader is running: NVDA's own window exists (the FindWindow nvda.exe uses to find +; a running copy), or tasklist shows nvda.exe, jfw.exe (JAWS) or Narrator.exe +; high contrast is on: the art is fixed colours and ignores the user's scheme +; Never under /S: a silent install shows no pages, and keeping $Plain at 0 there keeps every +; silent code path exactly as it was. +!macro WIND_PICK_PLAIN + StrCpy $Plain 0 + ${IfNot} ${Silent} + ${GetParameters} $0 + ClearErrors + ${GetOptions} $0 "/ACCESSIBLE" $1 + ${IfNot} ${Errors} + StrCpy $Plain 1 + ${EndIf} + ; A running screen reader. Not SPI_GETSCREENREADER: other apps leave that flag set with no + ; screen reader running, which would hide the animated UI from sighted users (#513). + FindWindow $1 "wxWindowClassNR" "NVDA" + ${If} $1 <> 0 + StrCpy $Plain 1 + ${Else} + nsExec::Exec 'cmd /c tasklist /NH | findstr /I /B "nvda.exe jfw.exe Narrator.exe"' + Pop $1 + ${If} $1 == 0 + StrCpy $Plain 1 + ${EndIf} + ${EndIf} + ; HIGHCONTRAST { cbSize, dwFlags, lpszDefaultScheme }, 12 bytes in this 32-bit process + System::Call '*(i 12, i 0, p 0) p .r0' + System::Call 'user32::SystemParametersInfoW(i 0x42, i 12, p r0, i 0)' + System::Call '*$0(i, i .r1)' + System::Free $0 + IntOp $1 $1 & 1 ; HCF_HIGHCONTRASTON + ${If} $1 <> 0 + StrCpy $Plain 1 + ${EndIf} + ${EndIf} +!macroend !macro HIDE_CHILD ID GetDlgItem $0 $HWNDPARENT ${ID} @@ -49,6 +93,12 @@ Var Font !define MUI_CUSTOMFUNCTION_GUIINIT windGuiInit Function windGuiInit + ; Plain mode keeps NSIS's own window as it is: frame, caption, header and buttons are what + ; a screen reader and the keyboard use. None of the picture's setup below is needed. + ${If} $Plain = 1 + Return + ${EndIf} + ; --- what are we drawing at? ------------------------------------------------------------ System::Call 'user32::GetDpiForSystem() i .r0 ? e' Pop $1 diff --git a/installer/plain.nsh b/installer/plain.nsh new file mode 100644 index 00000000..4be85d66 --- /dev/null +++ b/installer/plain.nsh @@ -0,0 +1,92 @@ +; +; Wind setup, the accessible plain pages (issue #513). +; +; The picture (kit.nsh, video.nsh, screens.nsh) is pixels: a screen reader finds nothing to name +; on it and the keyboard has nothing to focus. These pages are the stock alternative, real +; dialog controls with text, tab order and default buttons: MUI's welcome, licence (with a real +; checkbox), install and finish pages, plus one nsDialogs page for the autostart choice. They +; offer exactly what the picture offers. kit.nsh's WIND_PICK_PLAIN decides which set runs; +; wind.nsi lists both, each set skipping itself when the other is chosen. +; +; Included after the page list, because the finish page's show function reads +; $mui.FinishPage.Run, which MUI only declares when that page is inserted. +; + +Var PlainAutostart ; the setup page's checkbox + +; PRE for every MUI page that belongs to plain mode only: skip it when the picture runs. +Function windPlainOnly + ${If} $Plain <> 1 + Abort + ${EndIf} +FunctionEnd + +; ---- setup: where Wind goes, and autostart ----------------------------------- +; The path is shown, not chosen, for the reason screens.nsh gives: UIAccess is only granted in a +; secure location. +Function windPlainSetupCreate + ${If} ${Silent} + ${OrIf} $Plain <> 1 + Abort + ${EndIf} + !insertmacro MUI_HEADER_TEXT "Where Wind goes" "Wind installs to Program Files." + nsDialogs::Create 1018 + Pop $0 + ${If} $0 == error + Abort + ${EndIf} + ${NSD_CreateLabel} 0 0 100% 30u "Wind installs for everyone on this PC. Windows only grants the access its shortcuts and desktop zoom need to apps in a protected folder, so the folder cannot be changed." + Pop $0 + ${NSD_CreateLabel} 0 36u 100% 12u "Install folder: $INSTDIR" + Pop $0 + ${NSD_CreateCheckbox} 0 60u 100% 12u "&Start Wind when I sign in" + Pop $PlainAutostart + ${If} $WantAutostart = 1 + ${NSD_Check} $PlainAutostart + ${EndIf} + ; Back runs no leave function, so the box writes its choice as it changes, or Back and + ; Next again would show the old state. + ${NSD_OnClick} $PlainAutostart windPlainSetupClick + ; NSIS only names the button Install on its own pages; this one starts the install too + GetDlgItem $0 $HWNDPARENT 1 + SendMessage $0 ${WM_SETTEXT} 0 "STR:$(^InstallBtn)" + ; focus on the one choice, so a screen reader announces it as the page opens + ${NSD_SetFocus} $PlainAutostart + nsDialogs::Show +FunctionEnd + +; The box's click handler: nsDialogs pushes the control, so it is popped here. +Function windPlainSetupClick + Pop $0 + Call windPlainSetupLeave +FunctionEnd + +; Read before the section runs, which applies it (WIND_APPLY_AUTOSTART), as on the picture. +Function windPlainSetupLeave + ${NSD_GetState} $PlainAutostart $0 + ${If} $0 = ${BST_CHECKED} + StrCpy $WantAutostart 1 + ${Else} + StrCpy $WantAutostart 0 + ${EndIf} +FunctionEnd + +; ---- finish ------------------------------------------------------------------ +; "Open Wind now" starts ticked, unless local signing failed: that Wind has no UIAccess and +; cannot start (the section has already said so and cleared $RunAfter), as on the picture. +Function windPlainFinishShow + ${If} $RunAfter <> 1 + ${NSD_Uncheck} $mui.FinishPage.Run + ${EndIf} +FunctionEnd + +; MUI calls these on Finish when their box is ticked. Through explorer.exe, never Exec, so +; Wind does not inherit setup's admin token (app.nsh). +Function windPlainRun + !insertmacro WIND_LAUNCH_DEELEVATED +FunctionEnd + +Function windPlainDesktop + SetShellVarContext all + CreateShortcut "$DESKTOP\Wind.lnk" "$INSTDIR\Wind.exe" +FunctionEnd diff --git a/installer/screens.nsh b/installer/screens.nsh index 9ef4da39..8763647e 100644 --- a/installer/screens.nsh +++ b/installer/screens.nsh @@ -5,6 +5,8 @@ ; differs is $Screen, which picks the overlay and decides what the clicks mean. There is not a ; single button control in this file. ; +; In plain mode (kit.nsh, plain.nsh, issue #513) every page here skips itself. +; ; Prism has a "where it goes" page with a Browse button. Wind does not, and the omission is ; deliberate: UIAccess is only granted to a signed binary in a secure location, so an install ; to D:\Apps\Wind would silently disable the elevated-window shortcuts and the desktop @@ -19,6 +21,11 @@ Function windPageStart ${If} ${Silent} Abort ${EndIf} + ; Plain mode (kit.nsh, plain.nsh) shows the stock pages instead; skipping here skips every + ; picture screen that runs through this. + ${If} $Plain = 1 + Abort + ${EndIf} ; Back runs no leave function, so the page we came from may still hold its canvas: the ; bitmaps, the DC and the GDI+ images, one of which keeps its PNG locked. Free it here so ; nothing leaks and the next unpack can write (issue #263). Idempotent after a Next. @@ -47,6 +54,7 @@ FunctionEnd ; ---- 1. welcome -------------------------------------------------------------- Function windWelcomeCreate ${If} ${Silent} + ${OrIf} $Plain = 1 Abort ${EndIf} ; once per run: Back returns here, and the art is already unpacked @@ -70,6 +78,11 @@ Function windLicenceCreate ${EndIf} StrCpy $Screen 4 Call windPageStart + ; NSIS leaves the keyboard focus on its Next button, hidden but still live. Space ticks the + ; box (WindInput), and the same Space would then click that Next on key-up and leave the + ; page the moment the box is ticked (or show the hint after an untick). The canvas is a + ; static, which ignores keys; Enter still reaches Next through the dialog's default button. + System::Call 'user32::SetFocus(p $Canvas)' nsDialogs::Show FunctionEnd @@ -81,6 +94,10 @@ Function windLicenceLeave ; staying on this page: undo what the click set up for leaving it StrCpy $Leaving 0 !insertmacro WIND_THAW + ; Not silently (issue #513): someone on the keyboard pressed Enter and got nothing. A + ; message box is a real dialog, so it is also the one thing on this screen a screen + ; reader can read. Space ticks the box (WindInput). + MessageBox MB_ICONINFORMATION|MB_OK "To continue, accept the licence agreement: press Space or click the box.$\r$\n$\r$\nFor a setup that works with a screen reader and the keyboard, close Setup and run it again with /ACCESSIBLE." Abort ${EndIf} Call windPageLeave @@ -125,7 +142,9 @@ FunctionEnd ; script while files are being written. This screen draws one frame and hands the motion to ; the progress bar, which Windows paints for us. Function windCopyShow + ; Plain mode keeps the stock page: status line, log and buttons, all readable. ${If} ${Silent} + ${OrIf} $Plain = 1 Return ${EndIf} FindWindow $R4 "#32770" "" $HWNDPARENT diff --git a/installer/video.nsh b/installer/video.nsh index 7188fb0e..c7fa43ac 100644 --- a/installer/video.nsh +++ b/installer/video.nsh @@ -47,6 +47,7 @@ Var OverImg Var BackImg ; the shade and caption scrim, loaded once per page Var Hot ; control under the pointer Var WasDown +Var SpaceDown ; Space's state last tick, so one press ticks the licence box once Var Leaving ; an action is posted; this page is finished Var BoxOn ; the two states of a checkbox, stamped rather than baked Var BoxOff @@ -161,6 +162,7 @@ Function WindCanvas ; assume the button may still be down from the click that brought us here, so the next page ; needs a real release before it accepts anything StrCpy $WasDown 1 + StrCpy $SpaceDown 1 FunctionEnd Function WindCanvasFree @@ -369,6 +371,28 @@ Function WindInput ${Else} StrCpy $WasDown 1 ${EndIf} + + ; The one key this picture answers (issue #513): Space ticks the licence box, so the + ; keyboard is not stuck on the one screen that cannot be passed without it. Edge-triggered + ; on the high bit like the click, and only while setup is the foreground window ($2). + ${If} $Screen = 4 + System::Call 'user32::GetAsyncKeyState(i 0x20) i .r1' + IntOp $3 $1 & 0x8000 + ${If} $3 <> 0 + ${AndIf} $SpaceDown = 0 + ${AndIf} $2 = $HWNDPARENT + ${If} $Accepted = 1 + StrCpy $Accepted 0 + ${Else} + StrCpy $Accepted 1 + ${EndIf} + ${EndIf} + ${If} $3 = 0 + StrCpy $SpaceDown 0 + ${Else} + StrCpy $SpaceDown 1 + ${EndIf} + ${EndIf} FunctionEnd ; Post, never send. SendMessage would run the whole page change inside this callback, and NSIS diff --git a/installer/wind.nsi b/installer/wind.nsi index d20a5a22..ee03ee48 100644 --- a/installer/wind.nsi +++ b/installer/wind.nsi @@ -71,16 +71,51 @@ VIAddVersionKey "LegalCopyright" "Copyright (c) 2026 ${PUBLISHER}" !include "screens.nsh" -; Welcome, licence, setup, copying, done. Only the copying page is MUI's, because it is the one that +; Two page sets, one per run (issue #513). WIND_PICK_PLAIN in .onInit chooses: the accessible +; stock pages (plain.nsh) for a screen reader, high contrast or /ACCESSIBLE, otherwise the +; picture. Each page skips itself when its set is not the one running (the plain MUI pages +; through windPlainOnly as their PRE, the picture's creators through their own $Plain check), +; and NSIS walks past a skipped page in either direction, so Back works in both sets. +; +; Plain: welcome, licence, setup. +!define MUI_PAGE_CUSTOMFUNCTION_PRE windPlainOnly +!define MUI_WELCOMEPAGE_TEXT "Setup will install Wind, a fullscreen magnifier that keeps tracking the mouse, even when a game hides it.$\r$\n$\r$\nWind installs for everyone on this PC.$\r$\n$\r$\nClick Next to continue." +!insertmacro MUI_PAGE_WELCOME +!define MUI_PAGE_CUSTOMFUNCTION_PRE windPlainOnly +!define MUI_LICENSEPAGE_CHECKBOX +!insertmacro MUI_PAGE_LICENSE "..\LICENSE" +Page custom windPlainSetupCreate windPlainSetupLeave + +; The picture: welcome, licence, setup. Only its copying page is MUI's, because it is the one that ; has to be driven by the section; the SHOW define has to sit immediately before it, which is ; the only way to hand that page a show function without an earlier page swallowing it. Page custom windWelcomeCreate windPageLeave Page custom windLicenceCreate windLicenceLeave Page custom windSetupCreate windPageLeave + +; Copying, shared: windCopyShow dresses it as the picture, and leaves it stock in plain mode. !define MUI_PAGE_CUSTOMFUNCTION_SHOW windCopyShow !insertmacro MUI_PAGE_INSTFILES + +; Done. Plain: MUI's finish page, its two boxes standing in for the picture's. NOAUTOCLOSE, or +; MUI's own init would SetAutoClose true for both sets and walk past the install log; the +; picture's section sets autoclose itself. +!define MUI_PAGE_CUSTOMFUNCTION_PRE windPlainOnly +!define MUI_PAGE_CUSTOMFUNCTION_SHOW windPlainFinishShow +!define MUI_FINISHPAGE_NOAUTOCLOSE +!define MUI_FINISHPAGE_RUN +!define MUI_FINISHPAGE_RUN_TEXT "&Open Wind now" +!define MUI_FINISHPAGE_RUN_FUNCTION windPlainRun +!define MUI_FINISHPAGE_SHOWREADME +!define MUI_FINISHPAGE_SHOWREADME_TEXT "Create &desktop shortcut" +!define MUI_FINISHPAGE_SHOWREADME_NOTCHECKED +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION windPlainDesktop +!insertmacro MUI_PAGE_FINISH +; The picture. Page custom windDoneCreate windDoneLeave +!include "plain.nsh" + !insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_LANGUAGE "English" @@ -88,6 +123,11 @@ Page custom windDoneCreate windDoneLeave ; Setup writes nothing to %LOCALAPPDATA%\Wind: the app seeds and owns that directory, ; including magnifier.ini, which it resolves through wind::ResolveIniPath(). Section "Wind" SEC_WIND + ; Plain mode (issue #513) shows the log as it goes: NVDA reads each new line, and the + ; picture's hidden log is the reason it was silent. + ${If} $Plain = 1 + SetDetailsView show + ${EndIf} !insertmacro WIND_QUIT_RUNNING SetOutPath "$INSTDIR" @@ -173,12 +213,15 @@ Section "Wind" SEC_WIND !insertmacro WIND_APPLY_AUTOSTART ; The done screen is walked to by autoclose: the Next button it would otherwise wait for has - ; been hidden since .onGUIInit. - SetAutoClose true - ; That walk is a page swap no click started, so freeze here as a click would (issue #263); - ; the done screen thaws after its first frame. Last, so a failure above never leaves it on. - ${IfNot} ${Silent} - !insertmacro WIND_FREEZE + ; been hidden since .onGUIInit. Plain mode keeps its Next button and its log, so it stops + ; here and waits for the user, like any stock installer. + ${If} $Plain <> 1 + SetAutoClose true + ; That walk is a page swap no click started, so freeze here as a click would (issue #263); + ; the done screen thaws after its first frame. Last, so a failure above never leaves it on. + ${IfNot} ${Silent} + !insertmacro WIND_FREEZE + ${EndIf} ${EndIf} SectionEnd @@ -252,6 +295,8 @@ Function .onInit StrCpy $SignFailed 0 ; the licence starts unaccepted; only the licence screen's box sets it StrCpy $Accepted 0 + ; which page set runs: the picture, or the accessible stock pages (kit.nsh) + !insertmacro WIND_PICK_PLAIN FunctionEnd Function un.onInit