From 732e081ee447967327e6e0b3a15c307f8036269e Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 9 Oct 2026 00:48:26 +0800 Subject: [PATCH] fix(windows): add installer failure diagnostics and manual install entry --- .../tableStructureEditorState.test.ts | 2 +- scripts/windows-installer.integration.mjs | 3 + scripts/windows-installer.test.mjs | 180 +++++++++++++++-- src-tauri/windows/nsis/installer.nsi | 184 ++++++++++++++++-- 4 files changed, 339 insertions(+), 30 deletions(-) diff --git a/packages/app-tests/tableStructureEditorState.test.ts b/packages/app-tests/tableStructureEditorState.test.ts index 2203ebf702..05fea34c6d 100644 --- a/packages/app-tests/tableStructureEditorState.test.ts +++ b/packages/app-tests/tableStructureEditorState.test.ts @@ -119,7 +119,7 @@ test("creates editable column drafts from column metadata", () => { ); }); -test("preserves MySQL generated expressions in original column metadata", () => { +test("preserves MySQL generated expressions in editable and original column metadata", () => { const expression = "GENERATED ALWAYS AS (`price` * `quantity`) STORED"; const [draft] = createColumnDrafts( [ diff --git a/scripts/windows-installer.integration.mjs b/scripts/windows-installer.integration.mjs index 7f396de789..e1296043e5 100644 --- a/scripts/windows-installer.integration.mjs +++ b/scripts/windows-installer.integration.mjs @@ -79,6 +79,7 @@ SectionEnd !macro NSIS_HOOK_PREINSTALL System::Call 'shell32::IsUserAnAdmin() i .r0' WriteINIStr "${report}" "installer" "admin" "$0" + WriteINIStr "${report}" "installer" "elevation" "$DbxElevationCode" WriteINIStr "${report}" "installer" "cmdline" "$CMDLINE" WriteINIStr "${report}" "installer" "language" "$LANGUAGE" WriteINIStr "${report}" "installer" "update" "$UpdateMode" @@ -152,6 +153,7 @@ SectionEnd console.log(output); assert.match(output,/\[driver\]\r?\nadmin=0/); assert.match(output,/\[installer\]\r?\nadmin=1/); + assert.match(output,/elevation=2/); assert.match(output,/complete=1/); assert.match(output,/update=1/); assert.match(output,/passive=1/); @@ -169,6 +171,7 @@ SectionEnd run('installer',['/S','/UPDATE','/NS','/R','/ARGS','"writable update argument"',`/D=${writableTarget}`]); await waitFor(()=>fs.existsSync(report)&&fs.readFileSync(report,'utf8').includes('version=new')); const writableOutput=fs.readFileSync(report,'utf8'); + assert.match(writableOutput,/elevation=0/); console.log(writableOutput); assert.match(writableOutput,/\[installer\]\r?\nadmin=0/); assert.doesNotMatch(writableOutput,/\/DBX_ELEVATED/); diff --git a/scripts/windows-installer.test.mjs b/scripts/windows-installer.test.mjs index 267ce5e056..024a0763e2 100644 --- a/scripts/windows-installer.test.mjs +++ b/scripts/windows-installer.test.mjs @@ -5,14 +5,24 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { test } from 'node:test' -const compiler = process.env.NSIS_MAKENSIS || path.join(process.env.LOCALAPPDATA || '', 'tauri', 'NSIS', 'makensis.exe') -const available = process.platform === 'win32' && existsSync(compiler) +const compiler = process.env.NSIS_MAKENSIS || (process.platform === 'win32' + ? path.join(process.env.LOCALAPPDATA || '', 'tauri', 'NSIS', 'makensis.exe') + : 'makensis') +let compilerAvailable = false +try { + execFileSync(compiler, [process.platform === 'win32' ? '/VERSION' : '-VERSION'], { timeout: 5000 }) + compilerAvailable = true +} catch {} +const available = process.platform === 'win32' && compilerAvailable +const template = readFileSync(new URL('../src-tauri/windows/nsis/installer.nsi', import.meta.url), 'utf8') +const diagnosticVariables = template.match(/^Var Dbx.*$/gm).join('\n') +const diagnostics = template.slice(template.indexOf('AllowSkipFiles off'), template.indexOf('; Probe without truncating')) +const elevationInit = template.slice(template.indexOf('Function .onInit\n') + 'Function .onInit\n'.length, template.indexOf(' \u0024{GetOptions} $CMDLINE "/P" $PassiveMode')) +const languages = ['English', 'SimpChinese', 'TradChinese'].map(language => `!insertmacro MUI_LANGUAGE "${language}"`).join('\n') test('Windows installer access checks and elevation handoff', { skip: !available }, () => { const dir = mkdtempSync(path.join(tmpdir(), 'dbx installer test ')) - const template = readFileSync(new URL('../src-tauri/windows/nsis/installer.nsi', import.meta.url), 'utf8') const helper = template.match(/Function DbxEnsureInstallAccess\r?\n[\s\S]*?FunctionEnd/)[0] - const elevationInit = template.slice(template.indexOf('Function .onInit\n') + 'Function .onInit\n'.length, template.indexOf(' \u0024{GetOptions} $CMDLINE "/P" $PassiveMode')) assert.ok(elevationInit.includes('/DBX_PROFILE=')) const launch = `ExecShell "runas" "$EXEPATH" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR'` assert.ok(helper.includes(launch)) @@ -29,19 +39,20 @@ test('Windows installer access checks and elevation handoff', { skip: !available const binary = path.join(destination, 'dbx.exe') writeFileSync(binary, 'original executable contents') const script = `Unicode true +XPStyle on !include MUI2.nsh !include FileFunc.nsh !define MAINBINARYNAME "dbx" +!define VERSION "1.0.1" Name "DBX access probe" OutFile "${exe}" RequestExecutionLevel user SilentInstall silent -Var DbxElevated +${diagnosticVariables} Var DbxTestCancel Var DbxTestHandle -!insertmacro MUI_LANGUAGE "English" -LangString dbxElevationFailed \u0024{LANG_ENGLISH} "Elevation cancelled" -LangString dbxElevationUserMismatch \u0024{LANG_ENGLISH} "Different user" +${languages} +${diagnostics} ${fixtureHelper} Function .onInit ${elevationInit} @@ -61,6 +72,9 @@ ${elevationInit} \u0024{IfNot} \u0024{Errors} StrCpy $DbxTestCancel 1 \u0024{EndIf} +FunctionEnd +Section + ; NSIS applies the selected language after .onInit returns. StrCpy $0 "register zero" StrCpy $1 "register one" StrCpy $2 "register two" @@ -69,10 +83,11 @@ ${elevationInit} WriteINIStr "$EXEDIR\\result.ini" "probe" "registers" "$0|$1|$2|$3" WriteINIStr "$EXEDIR\\result.ini" "probe" "destination" "$INSTDIR" WriteINIStr "$EXEDIR\\result.ini" "probe" "language" "$LANGUAGE" + WriteINIStr "$EXEDIR\\result.ini" "probe" "elevation" "$DbxElevationCode" + WriteINIStr "$EXEDIR\\result.ini" "probe" "fileError" "$DbxFileProbeError" + WriteINIStr "$EXEDIR\\result.ini" "probe" "folderError" "$DbxDirectoryProbeError" SetErrorLevel 0 Quit -FunctionEnd -Section SectionEnd ` const source = path.join(dir, 'probe.nsi') @@ -80,9 +95,9 @@ SectionEnd try { // Also compile the unmodified production launch instruction and init code. writeFileSync(source, script.replace(fixtureHelper, helper)) - execFileSync(compiler, ['/V2', source], { encoding: 'utf8', timeout: 30_000 }) + execFileSync(compiler, ['/V2', '/INPUTCHARSET', 'UTF8', source], { encoding: 'utf8', timeout: 30_000 }) writeFileSync(source, script) - execFileSync(compiler, ['/V2', source], { encoding: 'utf8', timeout: 30_000 }) + execFileSync(compiler, ['/V2', '/INPUTCHARSET', 'UTF8', source], { encoding: 'utf8', timeout: 30_000 }) const run = (args, target = destination) => { rmSync(result, { force: true }) let status = 0 @@ -98,11 +113,16 @@ SectionEnd assert.equal(writable.status, 0, writable.output) assert.match(writable.output, /register zero\|register one\|register two\|register three/) assert.doesNotMatch(writable.output, /handoff/) + assert.match(writable.output, /elevation=0/) + assert.match(writable.output, /fileError=0/) + assert.match(writable.output, /folderError=0/) assert.equal(readFileSync(binary, 'utf8'), 'original executable contents') const locked = run(['/TEST_LOCK']) assert.equal(locked.status, 0) assert.doesNotMatch(locked.output, /handoff/) + assert.match(locked.output, /fileError=32/) + assert.match(locked.output, /folderError=0/) assert.equal(readFileSync(binary, 'utf8'), 'original executable contents') const nested = run([], path.join(destination, 'new', 'nested DBX')) @@ -132,6 +152,7 @@ SectionEnd const elevated = run(['/TEST_ELEVATED']) assert.equal(elevated.status, 0) assert.doesNotMatch(elevated.output, /handoff/) + assert.match(elevated.output, /elevation=3/) assert.match(elevated.output, /register zero\|register one\|register two\|register three/) const wrongUser = run(['/DBX_ELEVATED', '/DBX_LANG=1033', '/DBX_PROFILE="different Windows account"']) @@ -149,3 +170,138 @@ SectionEnd rmSync(dir, { recursive: true, force: true }) } }) + +// Compile the actual diagnostic strings and write policy on any host with NSIS. +// On Windows also execute a locked-file extraction: it must stop before the +// completion marker, and its expanded dialog must contain usable diagnostics. +test('Installer write failure diagnostics compile', { skip: !compilerAvailable }, async (t) => { + const dir = mkdtempSync(path.join(tmpdir(), 'dbx write failure ')) + const payload = path.join(dir, 'payload.txt') + const source = path.join(dir, 'failure.nsi') + const exe = path.join(dir, 'failure.exe') + const target = path.join(dir, 'destination') + const report = path.join(dir, 'result.ini') + mkdirSync(target) + writeFileSync(payload, 'new executable contents') + // Simulate only the user's Manual installation selection and Explorer launch. + // Keep the production extraction policy, dialog code and exit handler intact. + const fixtureDiagnostics = diagnostics + .replace('Function DbxShowWriteError', 'Function DbxShowWriteErrorActual') + .replace(' Call DbxShowWriteError\n', ' Call DbxShowWriteError\n WriteINIStr "$EXEDIR\\result.ini" "trace" "action" "$DbxWriteFailureAction"\n') + .replace(`ExecShell "open" "$WINDIR\\explorer.exe" '/select,"$EXEPATH"'`, + `WriteINIStr "$EXEDIR\\result.ini" "manual" "args" '/select,"$EXEPATH"'`) + const dialogSelection = `Function DbxShowWriteError + \u0024{If} $TestManual = 1 + StrCpy $DbxWriteFailureAction \u0024{DBX_WRITE_MANUAL} + \u0024{ElseIf} $TestRetry = 1 + StrCpy $TestRetry 0 + System::Call 'kernel32::CloseHandle(p $TestHandle)' + StrCpy $DbxWriteFailureAction \u0024{DBX_WRITE_RETRY} + \u0024{Else} + Call DbxShowWriteErrorActual + \u0024{EndIf} +FunctionEnd` + const script = `Unicode true +XPStyle on +!include MUI2.nsh +!include FileFunc.nsh +!define VERSION "1.0.1" +!define MAINBINARYNAME "dbx" +Name "DBX required file write test" +OutFile "${exe}" +RequestExecutionLevel user +SilentInstall silent +${diagnosticVariables} +Var TestHandle +Var TestManual +Var TestRetry +${languages} +${fixtureDiagnostics} +${dialogSelection} +${template.match(/Function DbxEnsureInstallAccess\r?\n[\s\S]*?FunctionEnd/)[0]} +Function .onInit +${elevationInit} + \u0024{GetOptions} $CMDLINE "/LANG=" $0 + StrCpy $LANGUAGE $0 + \u0024{GetOptions} $CMDLINE "/LOCK" $0 + \u0024{IfNot} \u0024{Errors} + System::Call 'kernel32::CreateFileW(w "$INSTDIR\\dbx.exe", i 0x80000000, i 0, p 0, i 3, i 0, p 0) p .s' + Pop $TestHandle + \u0024{EndIf} + \u0024{GetOptions} $CMDLINE "/MANUAL" $0 + \u0024{IfNot} \u0024{Errors} + StrCpy $TestManual 1 + \u0024{EndIf} + \u0024{GetOptions} $CMDLINE "/RETRY" $0 + \u0024{IfNot} \u0024{Errors} + StrCpy $TestRetry 1 + \u0024{EndIf} + Call DbxEnsureInstallAccess + \u0024{GetOptions} $CMDLINE "/READONLY" $0 + \u0024{IfNot} \u0024{Errors} + SetFileAttributes "$INSTDIR\\dbx.exe" READONLY + \u0024{EndIf} +FunctionEnd +Section + SetOutPath $INSTDIR + ; NSIS applies the selected language after .onInit returns. + Call DbxUpdateElevationStatus + StrCpy $0 "$INSTDIR\\dbx.exe" + WriteINIStr "$EXEDIR\\result.ini" "diagnostics" "dialog" "$(dbxFileWriteErrorNoIgnore)" + !insertmacro DbxExtractFile "/oname=dbx.exe" "${payload}" "$INSTDIR\\dbx.exe" + WriteINIStr "$EXEDIR\\result.ini" "install" "complete" "1" +SectionEnd +` + try { + writeFileSync(source, script) + const flag = process.platform === 'win32' ? '/' : '-' + const compilation = execFileSync(compiler, [`${flag}V2`, `${flag}INPUTCHARSET`, 'UTF8', source], { encoding: 'utf8', timeout: 30_000 }) + assert.doesNotMatch(compilation, /unknown variable|unknown constant|unknown identifier/i) + await t.test('Windows locked-file extraction stops and reports diagnostics', { skip: !available }, () => { + for (const [language, diagnosticLabel, instruction] of [ + ['1033', 'Diagnostic code:', 'Please run the installer as administrator.'], + ['2052', '诊断码:', '请以管理员权限运行安装程序。'], + ['1028', '診斷碼:', '請以系統管理員權限執行安裝程式。'], + ]) { + for (const action of ['writable', 'readonly', 'retry', 'cancel', 'manual']) { + t.diagnostic(`Windows extraction: language=${language}, action=${action}`) + const locked = ['retry', 'cancel', 'manual'].includes(action) + writeFileSync(path.join(target, 'dbx.exe'), 'original executable contents') + // Win32 INI writes otherwise use the runner's ANSI code page. + writeFileSync(report, '\ufeff', 'utf16le') + let exitCode = 0 + try { + execFileSync(exe, ['/S', `/LANG=${language}`, ...(locked ? ['/LOCK'] : []), ...(action === 'manual' ? ['/MANUAL'] : []), ...(action === 'retry' ? ['/RETRY'] : []), ...(action === 'readonly' ? ['/READONLY'] : []), `/D=${target}`], { timeout: 15_000, windowsVerbatimArguments: true, argv0: `"${exe}"` }) + } catch (error) { + if (typeof error.status !== 'number') { + const diagnostic = existsSync(report) ? readFileSync(report, 'utf16le') : 'No fixture report' + throw new Error(`language=${language}, action=${action}: ${error.message}\n${diagnostic}`, { cause: error }) + } + exitCode = error.status + } + const output = readFileSync(report, 'utf16le') + assert.doesNotMatch(output, /Installer:|安装包:|安裝套件:/) + assert.ok(output.includes(target), output) + assert.ok(output.includes(diagnosticLabel), output) + assert.match(output, new RegExp(`A[01]-E[01]-F${locked ? 32 : 0}-D0`)) + assert.ok(output.includes(instruction), output) + assert.ok(output.includes('1.0.1'), output) + assert.doesNotMatch(output, /Program Files|\$Dbx|\$EXEPATH|\$INSTDIR/) + if (action === 'cancel' || action === 'manual') { + assert.equal(exitCode, 2, 'A failed required-file write must stop installation') + assert.doesNotMatch(output, /complete=1/) + if (action === 'manual') assert.ok(output.includes(`args=/select,"${exe}"`), output) + else assert.doesNotMatch(output, /\[manual\]/) + assert.equal(readFileSync(path.join(target, 'dbx.exe'), 'utf8'), 'original executable contents') + } else { + assert.equal(exitCode, 0) + assert.match(output, /complete=1/) + assert.equal(readFileSync(path.join(target, 'dbx.exe'), 'utf8'), 'new executable contents') + } + } + } + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src-tauri/windows/nsis/installer.nsi b/src-tauri/windows/nsis/installer.nsi index 8b62ab4e71..d024ea66c2 100644 --- a/src-tauri/windows/nsis/installer.nsi +++ b/src-tauri/windows/nsis/installer.nsi @@ -1,4 +1,5 @@ Unicode true +XPStyle on ManifestDPIAware true ManifestSupportedOS all ; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) @@ -96,6 +97,12 @@ Var NoShortcutMode Var WixMode Var OldMainBinaryName Var DbxElevated +Var DbxIsAdmin +Var DbxElevationCode +Var DbxFileProbeError +Var DbxDirectoryProbeError +Var DbxFailedFile +Var DbxWriteFailureAction Name "${PRODUCTNAME}" BrandingText "${COPYRIGHT}" @@ -499,18 +506,33 @@ FunctionEnd !include "{{this}}" {{/each}} -; Keep the standard write-error actions while explaining how to upgrade legacy -; installations restored from Program Files without changing the install mode. -LangString dbxFileWriteError ${LANG_ENGLISH} "Error opening file for writing:$\r$\n$\r$\n$0$\r$\n$\r$\nIf you are upgrading DBX installed under Program Files, abort this installation, right-click the installer, and select Run as administrator.$\r$\n$\r$\nClick Abort to stop the installation,$\r$\nRetry to try again, or$\r$\nIgnore to skip this file." -LangString dbxFileWriteErrorNoIgnore ${LANG_ENGLISH} "Error opening file for writing:$\r$\n$\r$\n$0$\r$\n$\r$\nIf you are upgrading DBX installed under Program Files, cancel this installation, right-click the installer, and select Run as administrator.$\r$\n$\r$\nClick Retry to try again, or$\r$\nCancel to stop the installation." -LangString dbxFileWriteError ${LANG_SIMPCHINESE} "无法打开要写入的文件:$\r$\n$\r$\n$0$\r$\n$\r$\n如果正在升级安装于 Program Files 的 DBX,请中止本次安装,然后右键单击安装程序并选择“以管理员身份运行”。$\r$\n$\r$\n单击“中止”停止安装,$\r$\n单击“重试”再次尝试,或$\r$\n单击“忽略”跳过此文件。" -LangString dbxFileWriteErrorNoIgnore ${LANG_SIMPCHINESE} "无法打开要写入的文件:$\r$\n$\r$\n$0$\r$\n$\r$\n如果正在升级安装于 Program Files 的 DBX,请取消本次安装,然后右键单击安装程序并选择“以管理员身份运行”。$\r$\n$\r$\n单击“重试”再次尝试,或$\r$\n单击“取消”停止安装。" -LangString dbxFileWriteError ${LANG_TRADCHINESE} "無法開啟要寫入的檔案:$\r$\n$\r$\n$0$\r$\n$\r$\n如果正在升級安裝於 Program Files 的 DBX,請中止本次安裝,然後以滑鼠右鍵按一下安裝程式並選擇「以系統管理員身分執行」。$\r$\n$\r$\n按一下「中止」以停止安裝,$\r$\n按一下「重試」以再次嘗試,或$\r$\n按一下「忽略」以略過此檔案。" -LangString dbxFileWriteErrorNoIgnore ${LANG_TRADCHINESE} "無法開啟要寫入的檔案:$\r$\n$\r$\n$0$\r$\n$\r$\n如果正在升級安裝於 Program Files 的 DBX,請取消本次安裝,然後以滑鼠右鍵按一下安裝程式並選擇「以系統管理員身分執行」。$\r$\n$\r$\n按一下「重試」以再次嘗試,或$\r$\n按一下「取消」以停止安裝。" +; Required application files must never be skipped after a write failure. +AllowSkipFiles off +!define DBX_WRITE_CANCEL 2 +!define DBX_WRITE_RETRY 4 +!define DBX_WRITE_MANUAL 1001 +LangString dbxInstallDiagnostics ${LANG_ENGLISH} "Version: ${VERSION}$\r$\nDestination: $INSTDIR$\r$\nDiagnostic code: A$DbxIsAdmin-E$DbxElevationCode-F$DbxFileProbeError-D$DbxDirectoryProbeError" +LangString dbxInstallDiagnostics ${LANG_SIMPCHINESE} "版本:${VERSION}$\r$\n安装目录:$INSTDIR$\r$\n诊断码:A$DbxIsAdmin-E$DbxElevationCode-F$DbxFileProbeError-D$DbxDirectoryProbeError" +LangString dbxInstallDiagnostics ${LANG_TRADCHINESE} "版本:${VERSION}$\r$\n安裝目錄:$INSTDIR$\r$\n診斷碼:A$DbxIsAdmin-E$DbxElevationCode-F$DbxFileProbeError-D$DbxDirectoryProbeError" +LangString dbxFileWriteErrorNoIgnore ${LANG_ENGLISH} "Unable to write this file:$\r$\n$0$\r$\n$\r$\n$(dbxInstallDiagnostics)$\r$\n$\r$\nPlease run the installer as administrator." +LangString dbxFileWriteErrorNoIgnore ${LANG_SIMPCHINESE} "无法写入文件:$\r$\n$0$\r$\n$\r$\n$(dbxInstallDiagnostics)$\r$\n$\r$\n请以管理员权限运行安装程序。" +LangString dbxFileWriteErrorNoIgnore ${LANG_TRADCHINESE} "無法寫入檔案:$\r$\n$0$\r$\n$\r$\n$(dbxInstallDiagnostics)$\r$\n$\r$\n請以系統管理員權限執行安裝程式。" +LangString dbxFileWriteTitle ${LANG_ENGLISH} "Unable to write an installation file" +LangString dbxFileWriteTitle ${LANG_SIMPCHINESE} "无法写入安装文件" +LangString dbxFileWriteTitle ${LANG_TRADCHINESE} "無法寫入安裝檔案" +LangString dbxManualInstall ${LANG_ENGLISH} "Manual installation" +LangString dbxManualInstall ${LANG_SIMPCHINESE} "手动安装" +LangString dbxManualInstall ${LANG_TRADCHINESE} "手動安裝" +LangString dbxFileWriteError ${LANG_ENGLISH} "$DbxFailedFile$\r$\n$\r$\n$(dbxInstallDiagnostics)$\r$\n$\r$\nPlease run the installer as administrator." +LangString dbxFileWriteError ${LANG_SIMPCHINESE} "$DbxFailedFile$\r$\n$\r$\n$(dbxInstallDiagnostics)$\r$\n$\r$\n请以管理员权限运行安装程序。" +LangString dbxFileWriteError ${LANG_TRADCHINESE} "$DbxFailedFile$\r$\n$\r$\n$(dbxInstallDiagnostics)$\r$\n$\r$\n請以系統管理員權限執行安裝程式。" +LangString dbxManualInstallFallback ${LANG_ENGLISH} "Yes: open the installer folder and exit. No: retry. Cancel: stop installation." +LangString dbxManualInstallFallback ${LANG_SIMPCHINESE} "“是”:打开安装包目录并退出;“否”:重试;“取消”:停止安装。" +LangString dbxManualInstallFallback ${LANG_TRADCHINESE} "「是」:開啟安裝套件目錄並結束;「否」:重試;「取消」:停止安裝。" LangString dbxWin7InstallerRequired ${LANG_ENGLISH} "This installer does not support Windows 7 or Windows Server 2012 R2.$\r$\n$\r$\nPlease use the dedicated Windows 7 / Server 2012 R2 package instead.$\r$\n$\r$\nOpen the download now?" LangString dbxWin7InstallerRequired ${LANG_SIMPCHINESE} "此安装包不支持 Windows 7 或 Windows Server 2012 R2。$\r$\n$\r$\n请改用 Windows 7 / Server 2012 R2 专用包。$\r$\n$\r$\n是否立即打开下载地址?" LangString dbxWin7InstallerRequired ${LANG_TRADCHINESE} "此安裝套件不支援 Windows 7 或 Windows Server 2012 R2。$\r$\n$\r$\n請改用 Windows 7 / Server 2012 R2 專用套件。$\r$\n$\r$\n是否立即開啟下載網址?" -FileErrorText "$(dbxFileWriteError)" "$(dbxFileWriteErrorNoIgnore)" +FileErrorText "$(dbxFileWriteErrorNoIgnore)" "$(dbxFileWriteErrorNoIgnore)" LangString dbxElevationFailed ${LANG_ENGLISH} "Administrator permission is required to install DBX in this folder. Permission was denied or the elevated installer could not be started." LangString dbxElevationFailed ${LANG_SIMPCHINESE} "安装到此目录需要管理员权限。授权已被拒绝,或无法启动提权后的安装程序。" @@ -519,6 +541,119 @@ LangString dbxElevationUserMismatch ${LANG_ENGLISH} "Please approve elevation us LangString dbxElevationUserMismatch ${LANG_SIMPCHINESE} "请使用启动安装程序的同一个 Windows 账户授权提权,以确保 DBX 的安装信息和快捷方式保留在正确的用户配置中。" LangString dbxElevationUserMismatch ${LANG_TRADCHINESE} "請使用啟動安裝程式的同一個 Windows 帳戶授權提升權限,以確保 DBX 的安裝資訊和捷徑保留在正確的使用者設定中。" +Function DbxUpdateElevationStatus + ; Compact support code: A=admin, E=elevation state, F/D=access prechecks. + ; E: 0=not requested, 1=already admin, 2=elevated, 3=unconfirmed, + ; 4=launch denied/failed, 5=profile mismatch. NA means not checked. + System::Call 'shell32::IsUserAnAdmin() i .s' + Pop $DbxIsAdmin + ${If} $DbxElevated = 1 + ${If} $DbxIsAdmin != 0 + StrCpy $DbxElevationCode 2 + ${Else} + StrCpy $DbxElevationCode 3 + ${EndIf} + ${ElseIf} $DbxIsAdmin != 0 + StrCpy $DbxElevationCode 1 + ${Else} + StrCpy $DbxElevationCode 0 + ${EndIf} +FunctionEnd + +; Keep extraction failures under our control so a third action can open the +; installer folder without ever continuing past a failed required-file write. +!macro DbxExtractFile OPTIONS SOURCE DESTINATION + StrCpy $DbxFailedFile "${DESTINATION}" + Call DbxUpdateElevationStatus + ${Do} + Call DbxPrepareFileWrite + SetOverwrite try + ClearErrors + File ${OPTIONS} "${SOURCE}" + SetOverwrite on + ${IfNot} ${Errors} + ${ExitDo} + ${EndIf} + Call DbxShowWriteError + ${If} $DbxWriteFailureAction = ${DBX_WRITE_RETRY} + ${Continue} + ${ElseIf} $DbxWriteFailureAction = ${DBX_WRITE_MANUAL} + Call DbxManualInstall + ${EndIf} + SetErrorLevel 2 + Quit + ${Loop} +!macroend + +Function DbxPrepareFileWrite + Push $0 + Push $1 + ; Preserve SetOverwrite on's handling of read-only files when using try to + ; capture errors. Keep every other existing file attribute intact. + System::Call 'kernel32::GetFileAttributesW(w "$DbxFailedFile") i .r0' + ${If} $0 != -1 + IntOp $1 $0 & 1 + ${If} $1 != 0 + IntOp $0 $0 & 0xFFFFFFFE + System::Call 'kernel32::SetFileAttributesW(w "$DbxFailedFile", i r0)' + ${EndIf} + ${EndIf} + Pop $1 + Pop $0 +FunctionEnd + +Function DbxShowWriteError + Push $0 + Push $1 + Push $2 + Push $3 + StrCpy $DbxWriteFailureAction ${DBX_WRITE_CANCEL} + ; Unattended failures stop installation without opening Explorer or a dialog. + ${If} ${Silent} + Goto dbx_write_dialog_done + ${EndIf} + + ; NSIS uses a 32-bit stub even for x64/arm64 application payloads. + ; TASKDIALOGCONFIG is 96 bytes; use the Windows Retry/Cancel buttons plus + ; a localized custom Manual installation button. Cancel is the default. + ; Size to content so the default narrow layout does not shorten diagnostic paths. + System::Call '*(i ${DBX_WRITE_MANUAL}, w "$(dbxManualInstall)") p .r1' + System::Call '*(i 96, p $HWNDPARENT, p 0, i 0x1001008, i 0x18, w "$(^Name)", p 65534, w "$(dbxFileWriteTitle)", w "$(dbxFileWriteError)", i 1, p r1, i 2, i 0, p 0, i 0, p 0, p 0, p 0, p 0, p 0, p 0, p 0, p 0, i 0) p .r0' + StrCpy $3 -1 + System::Call 'comctl32::TaskDialogIndirect(p r0, *i .r2, p 0, p 0) i .r3' + System::Free $0 + System::Free $1 + ${If} $3 = 0 + StrCpy $DbxWriteFailureAction $2 + ${Else} + ; A safe fallback if the system cannot create a task dialog. + MessageBox MB_YESNOCANCEL|MB_ICONSTOP|MB_DEFBUTTON3 "$(dbxFileWriteError)$\r$\n$\r$\n$(dbxManualInstallFallback)" /SD IDCANCEL IDYES dbx_write_manual IDNO dbx_write_retry + Goto dbx_write_dialog_done + dbx_write_manual: + StrCpy $DbxWriteFailureAction ${DBX_WRITE_MANUAL} + Goto dbx_write_dialog_done + dbx_write_retry: + StrCpy $DbxWriteFailureAction ${DBX_WRITE_RETRY} + ${EndIf} + + dbx_write_dialog_done: + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +Function DbxManualInstall + ClearErrors + ExecShell "open" "$WINDIR\explorer.exe" '/select,"$EXEPATH"' + ${If} ${Errors} + ExecShell "open" "$EXEDIR" + ${EndIf} + ; This is a failed installation, never a successful update or a skipped file. + SetErrorLevel 2 + Quit +FunctionEnd + ; Probe without truncating the old executable. Only ACCESS_DENIED requests UAC; ; sharing violations still go through CheckIfAppIsRunning and normal retry UI. Function DbxEnsureInstallAccess @@ -526,10 +661,15 @@ Function DbxEnsureInstallAccess Push $1 Push $2 Push $3 + Call DbxUpdateElevationStatus + StrCpy $DbxFileProbeError "NA" + StrCpy $DbxDirectoryProbeError "NA" ${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" System::Call 'kernel32::CreateFileW(w "$INSTDIR\${MAINBINARYNAME}.exe", i 0x40000000, i 7, p 0, i 3, i 0, p 0) p .r0 ?e' Pop $1 + StrCpy $DbxFileProbeError $1 ${If} $0 != -1 + StrCpy $DbxFileProbeError 0 System::Call 'kernel32::CloseHandle(p r0)' ${ElseIf} $1 = 5 Goto dbx_elevate @@ -550,7 +690,9 @@ Function DbxEnsureInstallAccess ${EndIf} System::Call 'kernel32::GetTempFileNameW(w r2, w "dbx", i 0, w .r3) i .r0 ?e' Pop $1 + StrCpy $DbxDirectoryProbeError $1 ${If} $0 != 0 + StrCpy $DbxDirectoryProbeError 0 Delete "$3" ${ElseIf} $1 = 5 Goto dbx_elevate @@ -571,7 +713,8 @@ Function DbxEnsureInstallAccess ClearErrors ExecShell "runas" "$EXEPATH" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR' ${If} ${Errors} - MessageBox MB_OK|MB_ICONSTOP "$(dbxElevationFailed)" /SD IDOK + StrCpy $DbxElevationCode 4 + MessageBox MB_OK|MB_ICONSTOP "$(dbxElevationFailed)$\r$\n$\r$\n$(dbxInstallDiagnostics)" /SD IDOK SetErrorLevel 740 Pop $3 Pop $2 @@ -599,7 +742,11 @@ Function .onInit ; entered in the UAC credential dialog. ${GetOptions} $CMDLINE "/DBX_PROFILE=" $0 ${If} $0 != $PROFILE - MessageBox MB_OK|MB_ICONSTOP "$(dbxElevationUserMismatch)" /SD IDOK + StrCpy $DbxFileProbeError "NA" + StrCpy $DbxDirectoryProbeError "NA" + Call DbxUpdateElevationStatus + StrCpy $DbxElevationCode 5 + MessageBox MB_OK|MB_ICONSTOP "$(dbxElevationUserMismatch)$\r$\n$\r$\n$(dbxInstallDiagnostics)" /SD IDOK SetErrorLevel 740 Quit ${EndIf} @@ -705,7 +852,7 @@ Section WebView2 ; installer so an existing stale runtime is upgraded without network access. !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" - File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" + !insertmacro DbxExtractFile '"/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe"' "${WEBVIEW2INSTALLERPATH}" "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" DetailPrint "$(installingWebview2)" ExecWait '"$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" ${WEBVIEW2INSTALLERARGS} /install' $1 Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" @@ -754,7 +901,7 @@ Section WebView2 !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" - File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" + !insertmacro DbxExtractFile '"/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe"' "${WEBVIEW2BOOTSTRAPPERPATH}" "$TEMP\MicrosoftEdgeWebview2Setup.exe" DetailPrint "$(installingWebview2)" StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" Goto install_webview2 @@ -815,22 +962,25 @@ Section Install !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + Call DbxUpdateElevationStatus + DetailPrint "$(dbxInstallDiagnostics)" + ; Copy main executable - File "${MAINBINARYSRCPATH}" + !insertmacro DbxExtractFile "" "${MAINBINARYSRCPATH}" "$INSTDIR\${MAINBINARYNAME}.exe" ; MSVC links WebView2Loader statically, while GNU builds may emit a DLL next to the binary. - File /nonfatal /a "/oname=WebView2Loader.dll" "${WEBVIEW2LOADERSRCPATH}" + !insertmacro DbxExtractFile "/nonfatal /a /oname=WebView2Loader.dll" "${WEBVIEW2LOADERSRCPATH}" "$INSTDIR\WebView2Loader.dll" ; Copy resources {{#each resources_dirs}} CreateDirectory "$INSTDIR\\{{this}}" {{/each}} {{#each resources}} - File /a "/oname={{this.[1]}}" "{{no-escape @key}}" + !insertmacro DbxExtractFile '/a "/oname={{this.[1]}}"' "{{no-escape @key}}" "$INSTDIR\{{this.[1]}}" {{/each}} ; Copy external binaries {{#each binaries}} - File /a "/oname={{this}}" "{{no-escape @key}}" + !insertmacro DbxExtractFile '/a "/oname={{this}}"' "{{no-escape @key}}" "$INSTDIR\{{this}}" {{/each}} ; Create file associations