Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions builds/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 68 additions & 0 deletions tests/uninstall-registration-windows.test.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
Loading