mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(windows): add installer failure diagnostics and manual install entry
This commit is contained in:
@@ -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/);
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user