From f2f5a2a2d836287a6cf76d2c50e4176884279ca4 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 20:21:49 +0530 Subject: [PATCH 1/3] Remove automatic recovery copying from uninstall --- builds/installer.nsh | 8 +++---- builds/setup-data.ps1 | 34 ++++----------------------- tests/setup-uninstall-windows.test.js | 23 ++++-------------- 3 files changed, 13 insertions(+), 52 deletions(-) diff --git a/builds/installer.nsh b/builds/installer.nsh index d000a723a..0c47b33f2 100644 --- a/builds/installer.nsh +++ b/builds/installer.nsh @@ -259,7 +259,7 @@ Function un.PosnicDataPage ${NSD_CreateCheckbox} 0 112u 100% 20u "Keep saved backups" Pop $POSNIC_BACKUPS_CHECK ${NSD_SetState} $POSNIC_BACKUPS_CHECK $POSNIC_KEEP_BACKUPS - ${NSD_CreateLabel} 0 145u 100% 36u "Cloud data will not be deleted. Before removing business data, a verified recovery copy is saved separately in Documents." + ${NSD_CreateLabel} 0 145u 100% 36u "Cloud data will not be deleted. Unchecked data is permanently removed from this computer. No extra backup is created." Pop $0 nsDialogs::Show FunctionEnd @@ -272,7 +272,7 @@ Function un.PosnicDataLeave ${If} $POSNIC_KEEP_BUSINESS == 1 StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: kept" ${Else} - StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: removed locally; recovery copy kept in Documents\Posnic-Recovery" + StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: permanently removed from this computer" ${EndIf} ${If} $POSNIC_KEEP_HARDWARE == 1 StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nHardware settings: kept" @@ -289,7 +289,7 @@ Function un.PosnicDataLeave ${AndIf} $POSNIC_KEEP_BACKUPS == 1 Return ${EndIf} - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "Apply these choices?$\r$\n$POSNIC_REMOVAL_SUMMARY$\r$\n$\r$\nSelected local data will be removed. Continue?" /SD IDNO IDYES +2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "Apply these choices?$\r$\n$POSNIC_REMOVAL_SUMMARY$\r$\n$\r$\nUnchecked local data will be permanently removed. No extra backup will be created. Continue?" /SD IDNO IDYES +2 Abort FunctionEnd @@ -330,7 +330,7 @@ FunctionEnd DeleteRegValue HKCU "Software\Posnic" "Start Menu Folder" DeleteRegKey /ifempty HKCU "Software\Posnic" - ; Data choices were applied and verified before application removal. + ; Data choices were applied before application removal. !macroend ; Shell plugins may change the shell context. Never rely on that context for diff --git a/builds/setup-data.ps1 b/builds/setup-data.ps1 index 15bf61330..4b06a8603 100644 --- a/builds/setup-data.ps1 +++ b/builds/setup-data.ps1 @@ -24,29 +24,6 @@ function Assert-DirectChild([string]$Parent, [string]$Child) { } return $resolvedChild } -function Get-RecoveryHash([string]$File) { - $stream = [IO.File]::OpenRead((Get-ExtendedPath $File)) - $digest = [Security.Cryptography.SHA256]::Create() - try { return [BitConverter]::ToString($digest.ComputeHash($stream)) } - finally { $stream.Dispose(); $digest.Dispose() } -} -function Copy-VerifiedTree([string]$Source, [string]$Destination) { - [IO.Directory]::CreateDirectory((Get-ExtendedPath $Destination)) | Out-Null - $directory = New-Object IO.DirectoryInfo (Get-ExtendedPath $Source) - foreach ($entry in $directory.EnumerateFileSystemInfos()) { - if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Recovery cannot follow linked folders.' } - # These database lock handles are held exclusively by this helper. - if ($entry.Name -in @('mongod.lock', 'WiredTiger.lock')) { continue } - $target = Join-Path $Destination $entry.Name - if ($entry.Attributes -band [IO.FileAttributes]::Directory) { Copy-VerifiedTree $entry.FullName $target } - else { - [IO.File]::Copy((Get-ExtendedPath $entry.FullName), (Get-ExtendedPath $target), $false) - if ((Get-RecoveryHash $entry.FullName) -ne (Get-RecoveryHash $target)) { - throw 'Recovery verification failed. Existing data was not removed.' - } - } - } -} function Assert-UnlinkedTree([IO.FileSystemInfo]$Entry) { if ($Entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked folders cannot be deleted here.' } if ($Entry.Attributes -band [IO.FileAttributes]::Directory) { @@ -82,14 +59,13 @@ try { $documents = [Environment]::GetFolderPath('MyDocuments') $DataRoot = Assert-DirectChild $env:APPDATA $DataRoot $BackupsRoot = Assert-DirectChild $documents $BackupsRoot - $RecoveryRoot = Assert-DirectChild $documents $RecoveryRoot foreach ($running in Get-Process) { try { $runningPath = $running.Path } catch { continue } if ($AppExecutable -and $runningPath -and $runningPath -eq $AppExecutable) { throw 'The application is still running. Close it before removing data.' } } - if ($RecoveryRoot -eq $BackupsRoot -or $DataRoot -eq $BackupsRoot) { throw 'Recovery must be separate from deleted folders.' } + if ($DataRoot -eq $BackupsRoot) { throw 'Data and backup folders must be separate.' } $hardwareFiles = Get-Content -LiteralPath $HardwareManifest -Raw | ConvertFrom-Json foreach ($name in $hardwareFiles) { if ([IO.Path]::GetFileName($name) -ne $name) { throw 'Invalid hardware manifest.' } @@ -99,12 +75,10 @@ try { $lockFile = Join-Path $DataRoot $name if (Test-Path -LiteralPath $lockFile) { $locks += [IO.File]::Open($lockFile, 'Open', 'ReadWrite', 'None') } } - $copyRoot = Join-Path $RecoveryRoot ('recovery-' + [Guid]::NewGuid().ToString('N')) - Copy-VerifiedTree $DataRoot $copyRoot - [IO.File]::WriteAllText((Join-Path $copyRoot 'recovery-info.txt'), 'Verified local recovery copy. Not restored or uploaded automatically. Restore only with the original Windows account and compatible application version.') + # Validate paths before applying the user's explicit deletion choices. + Assert-UnlinkedTree (New-Object IO.DirectoryInfo (Get-ExtendedPath $DataRoot)) foreach ($lock in $locks) { $lock.Dispose() }; $locks = @() - # Leave selected hardware files in place; remove all other entries only - # after the entire recovery copy has passed hash verification. + # No automatic recovery copy: saved backups are controlled independently. foreach ($entry in Get-ChildItem -LiteralPath $DataRoot -Force) { if ($KeepHardware -eq '1' -and $entry.Name -in $hardwareFiles) { continue } if ($KeepBackups -eq '1' -and $entry.Name -eq 'backups') { continue } diff --git a/tests/setup-uninstall-windows.test.js b/tests/setup-uninstall-windows.test.js index 771bfd5b9..3fd754cc1 100644 --- a/tests/setup-uninstall-windows.test.js +++ b/tests/setup-uninstall-windows.test.js @@ -36,7 +36,7 @@ function fixture(t) { { encoding: 'utf8', windowsHide: true }); } }; } -test('Windows uninstaller handles all eight keep combinations and protects unsynced data', { skip: !windows }, t => { +test('Windows uninstaller handles all eight keep combinations without creating extra backups', { skip: !windows }, t => { for (const business of [0, 1]) for (const hardware of [0, 1]) for (const backups of [0, 1]) { const f = fixture(t), result = f.run(business, hardware, backups); assert.equal(result.status, 0, result.stdout + result.stderr); @@ -44,11 +44,7 @@ test('Windows uninstaller handles all eight keep combinations and protects unsyn assert.equal(fs.existsSync(path.join(f.data, 'preferences.json')), Boolean(hardware)); assert.equal(fs.existsSync(path.join(f.backups, 'saved.json')), Boolean(backups)); assert.equal(fs.existsSync(path.join(f.data, 'backups/fallback.json')), Boolean(backups)); - if (!business) { - const archive = path.join(f.recovery, fs.readdirSync(f.recovery)[0]); - assert.equal(fs.readFileSync(path.join(archive, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale'); - assert.equal(fs.readFileSync(path.join(archive, 'preferences.json'), 'utf8'), 'printer settings'); - } + assert.equal(fs.existsSync(f.recovery), false); } }); @@ -72,15 +68,7 @@ test('Windows helper refuses a host that cannot inspect 64-bit applications', { assert.equal(fs.existsSync(f.recovery), false); assert.equal(fs.readFileSync(path.join(f.data, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale'); }); -test('Windows recovery failure stops before deleting business data or backups', { skip: !windows }, t => { - const f = fixture(t); - fs.writeFileSync(f.recovery, 'blocked destination'); - assert.notEqual(f.run(0, 0, 0).status, 0); - assert.equal(fs.readFileSync(path.join(f.data, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale'); - assert.equal(fs.readFileSync(path.join(f.backups, 'saved.json'), 'utf8'), 'old backup'); -}); - -test('Windows recovery copies and verifies paths beyond MAX_PATH before removal', { skip: !windows }, t => { +test('Windows uninstaller removes long paths while retaining hardware without extra copying', { skip: !windows }, t => { const f = fixture(t); const relative = path.join('api-runtime', 'current', 'node_modules', 'nested-runtime-module-'.repeat(4), 'generated-types-'.repeat(4), 'long-generated-type-name.js'); const original = path.join(f.data, relative); @@ -90,13 +78,12 @@ test('Windows recovery copies and verifies paths beyond MAX_PATH before removal' assert.ok(original.length > 260); const result = f.run(0, 1, 1); assert.equal(result.status, 0, result.stdout + result.stderr); - const archive = path.join(f.recovery, fs.readdirSync(f.recovery)[0]); - assert.equal(fs.readFileSync(path.join(archive, relative), 'utf8'), 'long path recovery fixture'); + assert.equal(fs.existsSync(f.recovery), false); assert.equal(fs.existsSync(original), false); assert.equal(fs.readFileSync(path.join(f.data, 'preferences.json'), 'utf8'), 'printer settings'); }); -test('Windows recovery refuses linked directories without deleting business data', { skip: !windows }, t => { +test('Windows uninstaller refuses linked directories without deleting business data', { skip: !windows }, t => { const f = fixture(t); fs.symlinkSync(f.backups, path.join(f.data, 'linked-backups'), 'junction'); assert.notEqual(f.run(0, 0, 0).status, 0); From be961429832381acea58aae920401fa39a5f1a1e Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 20:24:59 +0530 Subject: [PATCH 2/3] Restore visible live uninstall file details --- builds/installer.nsh | 15 ++++++++++++--- builds/setup-data.ps1 | 18 ++++++++++++++++-- tests/setup-uninstall-windows.test.js | 2 ++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/builds/installer.nsh b/builds/installer.nsh index 0c47b33f2..10fd392a9 100644 --- a/builds/installer.nsh +++ b/builds/installer.nsh @@ -49,6 +49,13 @@ SetDatablockOptimize on ShowInstDetails show ShowUninstDetails show +; common.nsh is loaded after this include and sets nevershow. Override it +; in the late header hook, after electron-builder has declared its pages. +!macro customHeader + ShowInstDetails show + ShowUninstDetails show +!macroend + Var POSNIC_REPAIR_MODE Var POSNIC_START_MENU_FOLDER Var POSNIC_KEEP_BUSINESS @@ -302,11 +309,13 @@ Function un.PosnicApplyData ${If} ${RunningX64} StrCpy $POSNIC_POWERSHELL "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" ${EndIf} - nsExec::ExecToStack '"$POSNIC_POWERSHELL" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\setup-data.ps1" -DataRoot "$APPDATA\posnic" -BackupsRoot "$DOCUMENTS\Posnic-Backups" -RecoveryRoot "$DOCUMENTS\Posnic-Recovery" -AppExecutable "$INSTDIR\${APP_EXECUTABLE_FILENAME}" -HardwareManifest "$INSTDIR\resources\setup-hardware-files.json" -KeepBusiness $POSNIC_KEEP_BUSINESS -KeepHardware $POSNIC_KEEP_HARDWARE -KeepBackups $POSNIC_KEEP_BACKUPS' + SetDetailsView show + SetDetailsPrint both + DetailPrint "Applying your data choices..." + nsExec::ExecToLog '"$POSNIC_POWERSHELL" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\setup-data.ps1" -DataRoot "$APPDATA\posnic" -BackupsRoot "$DOCUMENTS\Posnic-Backups" -RecoveryRoot "$DOCUMENTS\Posnic-Recovery" -AppExecutable "$INSTDIR\${APP_EXECUTABLE_FILENAME}" -HardwareManifest "$INSTDIR\resources\setup-hardware-files.json" -KeepBusiness $POSNIC_KEEP_BUSINESS -KeepHardware $POSNIC_KEEP_HARDWARE -KeepBackups $POSNIC_KEEP_BACKUPS' Pop $0 - Pop $1 ${If} $0 != 0 - MessageBox MB_OK|MB_ICONSTOP "Removal stopped.$\r$\n$1" /SD IDOK + MessageBox MB_OK|MB_ICONSTOP "Removal stopped. See the details above for the error. Your selected cleanup may be incomplete." /SD IDOK Abort ${EndIf} FunctionEnd diff --git a/builds/setup-data.ps1 b/builds/setup-data.ps1 index 4b06a8603..9701ab409 100644 --- a/builds/setup-data.ps1 +++ b/builds/setup-data.ps1 @@ -8,6 +8,15 @@ param( ) $ErrorActionPreference = 'Stop' +# Stream the current filename without flooding the UI for every small file. +$script:progressClock = [Diagnostics.Stopwatch]::StartNew() +function Write-RemovalProgress([string]$Message, [switch]$Force) { + if ($Force -or $script:progressClock.ElapsedMilliseconds -ge 100) { + Write-Output $Message + $script:progressClock.Restart() + } +} + function Get-ExtendedPath([string]$File) { if ($File.StartsWith('\\?\')) { return $File } $full = [IO.Path]::GetFullPath($File) @@ -35,10 +44,12 @@ function Remove-UnlinkedTree([IO.DirectoryInfo]$Directory) { if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked folders cannot be deleted here.' } if ($entry.Attributes -band [IO.FileAttributes]::Directory) { Remove-UnlinkedTree $entry } else { + Write-RemovalProgress ('Deleting: ' + $entry.FullName) $entry.IsReadOnly = $false $entry.Delete() } } + Write-RemovalProgress ('Removing folder: ' + $Directory.FullName) $Directory.Delete() } function Remove-CheckedTree([string]$Root) { @@ -46,6 +57,7 @@ function Remove-CheckedTree([string]$Root) { # Validate the entire tree, then use extended paths for the actual removal. $extended = Get-ExtendedPath $Root if (![IO.Directory]::Exists($extended)) { return } + Write-RemovalProgress ('Removing folder: ' + $Root) -Force $directory = New-Object IO.DirectoryInfo $extended Assert-UnlinkedTree $directory Remove-UnlinkedTree $directory @@ -56,6 +68,7 @@ try { if ([Environment]::Is64BitOperatingSystem -and ![Environment]::Is64BitProcess) { throw 'Use the native 64-bit PowerShell host so running applications can be checked safely.' } + Write-RemovalProgress 'Checking that the application is closed...' -Force $documents = [Environment]::GetFolderPath('MyDocuments') $DataRoot = Assert-DirectChild $env:APPDATA $DataRoot $BackupsRoot = Assert-DirectChild $documents $BackupsRoot @@ -75,6 +88,7 @@ try { $lockFile = Join-Path $DataRoot $name if (Test-Path -LiteralPath $lockFile) { $locks += [IO.File]::Open($lockFile, 'Open', 'ReadWrite', 'None') } } + Write-RemovalProgress 'Preparing selected local data for removal...' -Force # Validate paths before applying the user's explicit deletion choices. Assert-UnlinkedTree (New-Object IO.DirectoryInfo (Get-ExtendedPath $DataRoot)) foreach ($lock in $locks) { $lock.Dispose() }; $locks = @() @@ -83,12 +97,12 @@ try { if ($KeepHardware -eq '1' -and $entry.Name -in $hardwareFiles) { continue } if ($KeepBackups -eq '1' -and $entry.Name -eq 'backups') { continue } if ($entry.PSIsContainer) { Remove-CheckedTree $entry.FullName } - else { Remove-Item -LiteralPath $entry.FullName -Force } + else { Write-RemovalProgress ('Deleting: ' + $entry.Name); Remove-Item -LiteralPath $entry.FullName -Force } } } elseif ($KeepHardware -eq '0') { foreach ($name in $hardwareFiles) { $target = Join-Path $DataRoot $name - if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Force } + if (Test-Path -LiteralPath $target) { Write-RemovalProgress ('Deleting hardware settings: ' + $name) -Force; Remove-Item -LiteralPath $target -Force } } } if ($KeepBackups -eq '0') { diff --git a/tests/setup-uninstall-windows.test.js b/tests/setup-uninstall-windows.test.js index 3fd754cc1..96eff7fb3 100644 --- a/tests/setup-uninstall-windows.test.js +++ b/tests/setup-uninstall-windows.test.js @@ -40,6 +40,8 @@ test('Windows uninstaller handles all eight keep combinations without creating e for (const business of [0, 1]) for (const hardware of [0, 1]) for (const backups of [0, 1]) { const f = fixture(t), result = f.run(business, hardware, backups); assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /Checking that the application is closed/); + if (!business) assert.match(result.stdout, /Removing folder: .*mongodb/); assert.equal(fs.existsSync(path.join(f.data, 'mongodb/data/sales.wt')), Boolean(business)); assert.equal(fs.existsSync(path.join(f.data, 'preferences.json')), Boolean(hardware)); assert.equal(fs.existsSync(path.join(f.backups, 'saved.json')), Boolean(backups)); From 02e644b3f5244dae071ada6e81bb0eea8a936e50 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 20:28:26 +0530 Subject: [PATCH 3/3] Reduce per-file overhead in uninstall cleanup --- builds/setup-data.ps1 | 62 ++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/builds/setup-data.ps1 b/builds/setup-data.ps1 index 9701ab409..b6d0acb0b 100644 --- a/builds/setup-data.ps1 +++ b/builds/setup-data.ps1 @@ -33,33 +33,59 @@ function Assert-DirectChild([string]$Parent, [string]$Child) { } return $resolvedChild } -function Assert-UnlinkedTree([IO.FileSystemInfo]$Entry) { - if ($Entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked folders cannot be deleted here.' } - if ($Entry.Attributes -band [IO.FileAttributes]::Directory) { - foreach ($child in $Entry.EnumerateFileSystemInfos()) { Assert-UnlinkedTree $child } +# Keep the per-file loop in compiled code: PowerShell recursion and pipeline +# calls are disproportionately expensive for node_modules' many tiny files. +Add-Type -TypeDefinition @" +using System; +using System.IO; +using System.Diagnostics; +public static class PosnicRemoval { + private static readonly Stopwatch progress = Stopwatch.StartNew(); + private static void Report(string action, string path) { + if (progress.ElapsedMilliseconds < 100) return; + Console.WriteLine(action + path); + progress.Restart(); } -} -function Remove-UnlinkedTree([IO.DirectoryInfo]$Directory) { - foreach ($entry in $Directory.EnumerateFileSystemInfos()) { - if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked folders cannot be deleted here.' } - if ($entry.Attributes -band [IO.FileAttributes]::Directory) { Remove-UnlinkedTree $entry } - else { - Write-RemovalProgress ('Deleting: ' + $entry.FullName) - $entry.IsReadOnly = $false - $entry.Delete() + public static void Validate(FileSystemInfo entry) { + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("Linked folders cannot be deleted here."); + var directory = entry as DirectoryInfo; + if (directory != null) + foreach (var child in directory.EnumerateFileSystemInfos()) Validate(child); + } + public static void Remove(DirectoryInfo directory) { + foreach (var entry in directory.EnumerateFileSystemInfos()) { + // Check again during removal; never follow a link introduced later. + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("Linked folders cannot be deleted here."); + var childDirectory = entry as DirectoryInfo; + if (childDirectory != null) Remove(childDirectory); + else { + Report("Deleting: ", entry.FullName); + if ((entry.Attributes & FileAttributes.ReadOnly) != 0) + entry.Attributes &= ~FileAttributes.ReadOnly; + entry.Delete(); + } } + Report("Removing folder: ", directory.FullName); + directory.Delete(); } - Write-RemovalProgress ('Removing folder: ' + $Directory.FullName) - $Directory.Delete() } -function Remove-CheckedTree([string]$Root) { +"@ +function Assert-UnlinkedTree([IO.FileSystemInfo]$Entry) { + [PosnicRemoval]::Validate($Entry) +} +function Remove-UnlinkedTree([IO.DirectoryInfo]$Directory) { + [PosnicRemoval]::Remove($Directory) +} +function Remove-CheckedTree([string]$Root, [switch]$AlreadyChecked) { # Windows PowerShell's provider cannot reliably remove paths over MAX_PATH. # Validate the entire tree, then use extended paths for the actual removal. $extended = Get-ExtendedPath $Root if (![IO.Directory]::Exists($extended)) { return } Write-RemovalProgress ('Removing folder: ' + $Root) -Force $directory = New-Object IO.DirectoryInfo $extended - Assert-UnlinkedTree $directory + if (!$AlreadyChecked) { Assert-UnlinkedTree $directory } Remove-UnlinkedTree $directory } if ($LibraryOnly) { return } @@ -96,7 +122,7 @@ try { foreach ($entry in Get-ChildItem -LiteralPath $DataRoot -Force) { if ($KeepHardware -eq '1' -and $entry.Name -in $hardwareFiles) { continue } if ($KeepBackups -eq '1' -and $entry.Name -eq 'backups') { continue } - if ($entry.PSIsContainer) { Remove-CheckedTree $entry.FullName } + if ($entry.PSIsContainer) { Remove-CheckedTree $entry.FullName -AlreadyChecked } else { Write-RemovalProgress ('Deleting: ' + $entry.Name); Remove-Item -LiteralPath $entry.FullName -Force } } } elseif ($KeepHardware -eq '0') {