fix(windows): add installer failure diagnostics and manual install entry

This commit is contained in:
zipg
2026-10-09 00:48:26 +08:00
committed by GitHub
parent 7c5e9dcda0
commit 732e081ee4
4 changed files with 339 additions and 30 deletions
@@ -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/);
+168 -12
View File
@@ -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 })
}
})