diff --git a/builds/installer.nsh b/builds/installer.nsh index bb8b51510..2d5b5dafb 100644 --- a/builds/installer.nsh +++ b/builds/installer.nsh @@ -329,3 +329,41 @@ FunctionEnd ; Data choices were applied and verified before application removal. !macroend + +; Shell plugins may change the shell context. Never rely on that context for +; the final registration cleanup of this per-user installation. Keep a live +; installation registered, and do not remove another installation's entry. +!macro POSNIC_VERIFY_REMOVAL + IfFileExists "$INSTDIR\${APP_EXECUTABLE_FILENAME}" posnic_removal_failed + ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation" + ${If} $0 != "" + ${AndIf} $0 != $INSTDIR + Goto posnic_removal_failed + ${EndIf} + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCT_NAME}" + ${If} $0 == "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCT_NAME}" + ${EndIf} + DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}" + DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}" + ClearErrors + EnumRegValue $0 HKCU "${UNINSTALL_REGISTRY_KEY}" 0 + IfErrors 0 posnic_removal_failed + ClearErrors + EnumRegValue $0 HKCU "${INSTALL_REGISTRY_KEY}" 0 + IfErrors posnic_removal_verified posnic_removal_failed + posnic_removal_failed: + SetErrorLevel 1 + Abort "Removal is incomplete. Close the application and run the installer to repair it, then retry removal." + posnic_removal_verified: + DetailPrint "Application removal and Windows registration verified." +!macroend + +; Runs after electron-builder's file, shortcut and registration cleanup. A +; failure must not reach the successful finish page. +!macro customUnInstallSection + Section "un.Verify removal" + !insertmacro check64BitAndSetRegView + !insertmacro POSNIC_VERIFY_REMOVAL + SectionEnd +!macroend diff --git a/tests/uninstall-registration-windows.test.js b/tests/uninstall-registration-windows.test.js new file mode 100644 index 000000000..2989b9618 --- /dev/null +++ b/tests/uninstall-registration-windows.test.js @@ -0,0 +1,68 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const compiler = process.env.NSIS_MAKENSIS || path.join(process.env.LOCALAPPDATA || '', 'electron-builder/Cache/nsis/nsis-3.0.4.1/Bin/makensis.exe'); +const available = process.platform === 'win32' && fs.existsSync(compiler); +const source = fs.readFileSync(path.join(__dirname, '../builds/installer.nsh'), 'utf8'); +const macro = source.match(/!macro POSNIC_VERIFY_REMOVAL\r?\n[\s\S]*?!macroend/)[0]; + +// Execute the production cleanup macro against disposable registry keys only. +// This harness never installs or uninstalls a real application. +test('Windows registration finalizer handles stale, absent, live and unrelated installations', { skip: !available }, t => { + for (const scenario of ['stale', 'absent', 'live', 'other']) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'posnic-registration-test-')); + const key = 'HKCU\\Software\\PosnicRemovalTests\\' + path.basename(dir); + const runKey = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; + const nsisKey = key.replace(/^HKCU\\/, ''); + const run = (file, args) => spawnSync(file, args, { encoding: 'utf8', windowsHide: true }); + t.after(() => { + assert.ok(key.startsWith('HKCU\\Software\\PosnicRemovalTests\\posnic-registration-test-')); + run('reg.exe', ['delete', key, '/f', '/reg:64']); + run('reg.exe', ['delete', runKey, '/v', path.basename(dir), '/f', '/reg:64']); + assert.equal(path.dirname(dir), os.tmpdir()); + assert.ok(path.basename(dir).startsWith('posnic-registration-test-')); + fs.rmSync(dir, { recursive: true, force: true }); + }); + const exe = path.join(dir, 'check.exe'); + const nsi = path.join(dir, 'check.nsi'); + fs.writeFileSync(nsi, `Unicode true +!include LogicLib.nsh +Name "Registration cleanup test" +OutFile "${exe}" +RequestExecutionLevel user +SilentInstall silent +!define APP_EXECUTABLE_FILENAME "fixture.exe" +!define PRODUCT_NAME "${path.basename(dir)}" +!define INSTALL_REGISTRY_KEY "${nsisKey}\\Install" +!define UNINSTALL_REGISTRY_KEY "${nsisKey}\\Uninstall" +${macro} +Section + SetRegView 64 + StrCpy $INSTDIR "${dir}" + WriteRegStr HKCU "Software\\Microsoft\\Windows\\CurrentVersion\\Run" "\${PRODUCT_NAME}" "$INSTDIR\\fixture.exe" + ${scenario !== 'absent' ? `WriteRegStr HKCU "\${INSTALL_REGISTRY_KEY}" InstallLocation "${scenario === 'other' ? dir + '-other' : dir}" + WriteRegStr HKCU "\${UNINSTALL_REGISTRY_KEY}" DisplayName "Fixture"` : ''} + ${scenario === 'live' ? 'FileOpen $0 "$INSTDIR\\fixture.exe" w\n FileClose $0' : ''} + ; Simulate a shell plugin leaving a different context behind. + SetShellVarContext all + !insertmacro POSNIC_VERIFY_REMOVAL +SectionEnd +`); + const compiled = run(compiler, ['/V2', nsi]); + assert.equal(compiled.status, 0, compiled.stdout + compiled.stderr); + const result = run(exe, []); + const shouldKeep = scenario === 'live' || scenario === 'other'; + assert.equal(result.status, shouldKeep ? 1 : 0, scenario + ': ' + result.stderr); + for (const child of ['Install', 'Uninstall']) { + const query = run('reg.exe', ['query', key + '\\' + child, '/reg:64']); + assert.equal(query.status === 0, shouldKeep, scenario + ': ' + child); + } + const startup = run('reg.exe', ['query', runKey, '/v', path.basename(dir), '/reg:64']); + assert.equal(startup.status === 0, shouldKeep, scenario + ': startup'); + } +});