diff --git a/build/installer.nsh b/build/installer.nsh index 09b0e7b..19d6f5a 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -5,53 +5,107 @@ Var DshDirectoryPage Var DshDirectoryEdit + Var DshDirectorySearchAfter + Var DshDirectoryAttached Var DshDirectoryNormalizationActive - ; MUI invokes this after the assisted installer's directory page is ready. - ; Normalize a selected drive root immediately so the page does not reject it - ; before electron-builder's later install-time sanitization can run. + ; electron-builder declares its install-mode page before the directory page. + ; MUI consumes this callback on that first page, so use it to start a short + ; polling timer and attach to the directory edit control once that page exists. !define MUI_PAGE_CUSTOMFUNCTION_SHOW DshDirectoryPageShow Function DshDirectoryPageShow - FindWindow $DshDirectoryPage "#32770" "" $HWNDPARENT + ${NSD_CreateTimer} DshAttachDirectoryPage 50 + FunctionEnd + + Function DshAttachDirectoryPage + ${If} $DshDirectoryAttached == "1" + System::Call 'USER32::IsWindowVisible(p $DshDirectoryPage)i.r0' + ${If} $0 == 0 + ; The user can navigate back to the install-mode page and then return. + ; Keep the timer alive so the directory page can be attached again. + StrCpy $DshDirectoryAttached "0" + StrCpy $DshDirectoryPage 0 + StrCpy $DshDirectoryEdit 0 + ${Else} + ; Keep polling while the directory page is visible. This also catches a + ; manually entered parent path as soon as the edit control loses focus. + Call DshNormalizeSelectedDirectory + Return + ${EndIf} + ${EndIf} + + StrCpy $DshDirectorySearchAfter 0 + + DshFindDirectoryPage: + FindWindow $DshDirectoryPage "#32770" "" $HWNDPARENT $DshDirectorySearchAfter + + ${If} $DshDirectoryPage == 0 + Return + ${EndIf} + GetDlgItem $DshDirectoryEdit $DshDirectoryPage 1019 + + ${If} $DshDirectoryEdit == 0 + ; MUI keeps earlier custom pages as hidden child dialogs. Continue until + ; the child containing the actual directory edit control is found. + StrCpy $DshDirectorySearchAfter $DshDirectoryPage + Goto DshFindDirectoryPage + ${EndIf} + + System::Call 'USER32::IsWindowVisible(p $DshDirectoryPage)i.r0' + ${If} $0 == 0 + StrCpy $DshDirectorySearchAfter $DshDirectoryPage + Goto DshFindDirectoryPage + ${EndIf} + ${NSD_OnChange} $DshDirectoryEdit DshDirectoryChanged - Call DshNormalizeDriveRoot + StrCpy $DshDirectoryAttached "1" + Call DshNormalizeSelectedDirectory FunctionEnd Function DshDirectoryChanged Pop $0 - Call DshNormalizeDriveRoot + Call DshNormalizeSelectedDirectory FunctionEnd - Function DshNormalizeDriveRoot + Function DshNormalizeSelectedDirectory ${If} $DshDirectoryNormalizationActive == "1" Return ${EndIf} + ; A Browse selection updates the edit while focus remains on the Browse + ; button. Do not rewrite the path character-by-character when the user is + ; typing directly into the edit control. + System::Call 'USER32::GetFocus()p.r4' + ${If} $4 == $DshDirectoryEdit + Return + ${EndIf} + ${NSD_GetText} $DshDirectoryEdit $0 - StrLen $1 $0 - ; Accept both forms produced by typing or the Windows folder picker: - ; "D:" and "D:\". Any non-root directory is left untouched. - ${If} $1 == 2 - StrCpy $2 $0 1 1 - ${If} $2 != ":" - Return - ${EndIf} - StrCpy $3 "$0\${APP_FILENAME}" - ${ElseIf} $1 == 3 - StrCpy $2 $0 1 1 - ${If} $2 != ":" - Return - ${EndIf} - StrCpy $2 $0 1 2 - ${If} $2 != "\" + ${If} $0 == "" + Return + ${EndIf} + + ; Keep the default path and an already-normalized custom path unchanged. + StrLen $1 "${APP_FILENAME}" + StrLen $2 $0 + ${If} $2 >= $1 + IntOp $4 $2 - $1 + StrCpy $3 $0 $1 $4 + ${If} $3 == "${APP_FILENAME}" Return ${EndIf} + ${EndIf} + + ; The directory picker returns the selected parent directory. Make the + ; application subdirectory visible immediately for every custom location. + StrCpy $1 $0 1 -1 + ${If} $1 == "\" StrCpy $3 "$0${APP_FILENAME}" ${Else} - Return + StrCpy $3 "$0\${APP_FILENAME}" ${EndIf} StrCpy $DshDirectoryNormalizationActive "1" diff --git a/test/release.test.ts b/test/release.test.ts index 98ce71c..bd988c2 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -76,14 +76,25 @@ describe('GitHub release contract', () => { expect(packageJson.build.portable).toBeUndefined() }) - it('turns a selected Windows drive root into an application directory', async () => { + it('shows the application subdirectory for every custom Windows install location', async () => { const installer = await readFile( path.join(projectRoot, 'build', 'installer.nsh'), 'utf8' ) expect(installer).toContain('!define MUI_PAGE_CUSTOMFUNCTION_SHOW DshDirectoryPageShow') + expect(installer).toContain('${NSD_CreateTimer} DshAttachDirectoryPage 50') + expect(installer).toContain( + 'FindWindow $DshDirectoryPage "#32770" "" $HWNDPARENT $DshDirectorySearchAfter' + ) + expect(installer).toContain('Goto DshFindDirectoryPage') + expect(installer).toContain('StrCpy $DshDirectoryAttached "0"') expect(installer).toContain('${NSD_OnChange} $DshDirectoryEdit DshDirectoryChanged') + expect(installer).toContain( + "System::Call 'USER32::IsWindowVisible(p $DshDirectoryPage)i.r0'" + ) + expect(installer).toContain("System::Call 'USER32::GetFocus()p.r4'") + expect(installer).toContain('StrLen $1 "${APP_FILENAME}"') expect(installer).toContain('StrCpy $3 "$0\\${APP_FILENAME}"') expect(installer).toContain('StrCpy $3 "$0${APP_FILENAME}"') expect(installer).toContain('${NSD_SetText} $DshDirectoryEdit $3')