param([switch]$Purge,[switch]$DryRun,[switch]$LibraryOnly) $ErrorActionPreference='Stop' $ProgressPreference='SilentlyContinue' function Get-MapleUninstallEnvironment { if($env:OS -ne 'Windows_NT'){throw 'This uninstaller requires Windows.'} $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) if(!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Open PowerShell as Administrator, then run this command again.'} return @{programFiles=[Environment]::GetFolderPath('ProgramFiles');programData=[Environment]::GetFolderPath('CommonApplicationData');windows=[Environment]::GetFolderPath('Windows')} } function Normalize-MapleUninstallPath([string]$Path) { if($Path -notmatch '^[A-Za-z]:\\' -or $Path.Substring(2).Contains(':')){throw 'Uninstall path must be a local absolute path.'} $full=[IO.Path]::GetFullPath($Path).TrimEnd('\') if($full.Length -le 3 -or !$full.Equals($Path.TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)){throw 'Unsafe or noncanonical uninstall path.'} return $full } function Test-MapleUninstallWithin([string]$Root,[string]$Path) { try{return (Normalize-MapleUninstallPath $Path).StartsWith((Normalize-MapleUninstallPath $Root)+'\',[StringComparison]::OrdinalIgnoreCase)}catch{return $false} } function Get-MapleUninstallHash([string]$Path) { $stream=[IO.File]::OpenRead($Path);$hasher=[Security.Cryptography.SHA256]::Create() try{return [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-','').ToLowerInvariant()}finally{$hasher.Dispose();$stream.Dispose()} } function Assert-MapleUninstallSafePath([string]$Path,[switch]$Tree) { $full=Normalize-MapleUninstallPath $Path $ancestor=$full while($ancestor -and $ancestor.Length -gt 3){ if(Test-Path -LiteralPath $ancestor){ $item=Get-Item -LiteralPath $ancestor -Force if($item.Attributes -band [IO.FileAttributes]::ReparsePoint){throw ('Reparse point blocks uninstall: '+$ancestor)} } $ancestor=Split-Path -Parent $ancestor } if($Tree -and (Test-Path -LiteralPath $full -PathType Container)){ $queue=[Collections.Generic.Stack[string]]::new();$queue.Push($full) while($queue.Count){ foreach($child in Get-ChildItem -LiteralPath $queue.Pop() -Force){ if(!(Test-MapleUninstallWithin $full $child.FullName) -or ($child.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw ('Unsafe child blocks uninstall: '+$child.FullName)} if($child.PSIsContainer){$queue.Push($child.FullName)} } } } } function Get-MapleUninstallProfiles { $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach($key in Get-ChildItem -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'){ $entry=Get-ItemProperty -LiteralPath $key.PSPath if(!$entry.ProfileImagePath){continue} $path=Normalize-MapleUninstallPath ([Environment]::ExpandEnvironmentVariables([string]$entry.ProfileImagePath)) if($seen.Add($path) -and (Test-Path -LiteralPath $path -PathType Container)){@{sid=$key.PSChildName;path=$path}} } } function Get-MapleUninstallTasks { @(Get-ScheduledTask | Where-Object {$_.TaskName -in @('MapleMCPTray','MapleMCPAgent','MapleMCPUpdater','MapleMCPCredentialRotation','MapleMCPAgentCanary028','MapleRemoteTray','MapleRemoteAgent','MapleRemoteUpdater')}) } function Get-MapleUninstallNativeService([string[]]$Roots) { $service=Get-CimInstance Win32_Service -Filter "Name='MapleMCP.Agent'" -ErrorAction SilentlyContinue if(!$service){return $null} $raw=[string]$service.PathName;$exe='' if($raw -match '^\s*"([^"]+)"\s+run(?:\s|$)'){$exe=$Matches[1]} elseif($raw -match '^\s*(\S+)\s+run(?:\s|$)'){$exe=$Matches[1]} else{throw 'MapleMCP.Agent service command line is not lifecycle-owned.'} $exe=Normalize-MapleUninstallPath $exe $owners=@($Roots|Where-Object {Test-MapleUninstallWithin $_ $exe}) if($owners.Count -ne 1){throw 'MapleMCP.Agent service executable is outside supported installation roots.'} $relative=$exe.Substring($owners[0].TrimEnd('\').Length+1) if($relative -notmatch '^agent\\releases\\[a-f0-9]{64}\\MapleMCPAgent\.exe$'){throw 'MapleMCP.Agent service executable is outside the immutable Go host release layout.'} if([string]$service.StartName -notin @('LocalSystem','NT AUTHORITY\SYSTEM')){throw 'MapleMCP.Agent service identity is not lifecycle-owned.'} return @{name='MapleMCP.Agent';path=$exe;root=$owners[0];pathName=$raw;state=[string]$service.State;pid=[int64]$service.ProcessId;startName=[string]$service.StartName} } function Assert-MapleUninstallNativeServiceUnchanged([object]$Expected,[string[]]$Roots) { $current=Get-MapleUninstallNativeService $Roots if(($null -eq $Expected) -ne ($null -eq $current)){throw 'MapleMCP.Agent service changed after uninstall preflight.'} if($Expected -and (($current|ConvertTo-Json -Compress) -cne ($Expected|ConvertTo-Json -Compress))){throw 'MapleMCP.Agent service changed after uninstall preflight.'} return $current } function Remove-MapleUninstallNativeService([object]$Service) { if(!$Service){return} $current=Get-CimInstance Win32_Service -Filter "Name='MapleMCP.Agent'" -ErrorAction SilentlyContinue if(!$current -or [string]$current.PathName -cne [string]$Service.pathName){throw 'MapleMCP.Agent service changed before removal.'} Stop-Service -Name 'MapleMCP.Agent' -Force -ErrorAction SilentlyContinue $deadline=[DateTime]::UtcNow.AddSeconds(20) while((Get-Service -Name 'MapleMCP.Agent' -ErrorAction SilentlyContinue).Status -ne 'Stopped' -and [DateTime]::UtcNow -lt $deadline){Start-Sleep -Milliseconds 200} $remaining=Get-Service -Name 'MapleMCP.Agent' -ErrorAction SilentlyContinue if($remaining -and $remaining.Status -ne 'Stopped'){throw 'MapleMCP.Agent service did not stop before uninstall.'} $output=@(& $Service.path unregister-service 2>&1) if($LASTEXITCODE -ne 0){throw ('MapleMCP.Agent service removal failed: '+($output -join ' '))} if(Get-CimInstance Win32_Service -Filter "Name='MapleMCP.Agent'" -ErrorAction SilentlyContinue){throw 'MapleMCP.Agent service registration remains.'} } function Assert-MapleUninstallTasks([object[]]$Tasks,[string[]]$Roots,[string]$Windows) { foreach($task in $Tasks){ if($task.TaskPath -cne '\' -or @($task.Actions).Count -ne 1){throw ('Ambiguous Maple task: '+$task.TaskName)} $action=$task.Actions[0];$path=[string]$action.Execute $owned=@($Roots|Where-Object {Test-MapleUninstallWithin $_ $path}).Count -gt 0 if(!$owned -and $path.Equals((Join-Path $Windows 'System32\WindowsPowerShell\v1.0\powershell.exe'),[StringComparison]::OrdinalIgnoreCase)){ $match=[regex]::Match([string]$action.Arguments,'(?:^|\s)-File\s+(?:"(?[^"]+)"|(?\S+))',[Text.RegularExpressions.RegexOptions]::IgnoreCase) $owned=$match.Success -and @($Roots|Where-Object {Test-MapleUninstallWithin $_ $match.Groups['file'].Value}).Count -gt 0 } if(!$owned){throw ('Maple task points outside supported installation roots: '+$task.TaskName)} } } function Assert-MapleUninstallSteadyState([string]$Root) { if(Test-Path -LiteralPath (Join-Path $Root 'installation-preparation.json')){throw 'An unfinished installation must be recovered before uninstall.'} foreach($name in @('update-state.json','pro-promotion.json')){ $p=Join-Path $Root $name if(Test-Path -LiteralPath $p){ $j=Get-Content -Raw -LiteralPath $p|ConvertFrom-Json if($j.pending -or $j.status -eq 'pending'){throw 'An unfinished update or Pro promotion must be recovered before uninstall.'} } } } function Get-MapleUninstallRetainedReceipt([object]$Plan) { if($Plan.purge){return $null} $pairs=@();for($i=0;$i -lt $Plan.installRoots.Count;$i++){if($Plan.presence[$Plan.installRoots[$i]] -and $Plan.presence[$Plan.dataRoots[$i]]){$pairs+=@(@{root=$Plan.installRoots[$i];data=$Plan.dataRoots[$i];brand=(Split-Path -Leaf $Plan.installRoots[$i])})}} if($pairs.Count -ne 1){return $null} $pair=$pairs[0];$agent=@($Plan.tasks|Where-Object {$_.TaskName -in @('MapleMCPAgent','MapleRemoteAgent')});$native=$Plan.nativeService if(($agent.Count + $(if($native){1}else{0})) -ne 1){return $null} if($native -and !([string]$native.root).Equals($pair.root,[StringComparison]::OrdinalIgnoreCase)){return $null} $config=Join-Path $pair.data 'agent.json';$statePath=Join-Path $pair.data 'update-state.json' try{$cfg=Get-Content -LiteralPath $config -Raw|ConvertFrom-Json;$state=Get-Content -LiteralPath $statePath -Raw|ConvertFrom-Json}catch{return $null} if(!$cfg.device_id -or !$cfg.device_token -or !$state.active -or $state.pending){return $null} $directory=[string]$state.active.directory if($directory -notmatch '^releases/[1-9][0-9]*-[a-f0-9]{16}$'){return $null} $slot=Join-Path $pair.root $directory.Replace('/','\');if(!(Test-MapleUninstallWithin $pair.root $slot)){throw 'Active release escaped the installation root.'} $markerPath=@('.maplemcp-release.json','.mapleremote-release.json'|ForEach-Object {Join-Path $slot $_}|Where-Object {Test-Path -LiteralPath $_}|Select-Object -First 1) if(!$markerPath){return $null};Assert-MapleUninstallSafePath $slot -Tree $marker=Get-Content -LiteralPath $markerPath -Raw|ConvertFrom-Json if([string]$marker.version -cne [string]$state.active.version -or $marker.sequence -ne $state.active.sequence -or [string]$marker.sha256 -notmatch '^[a-f0-9]{64}$' -or !$marker.sha256.StartsWith($directory.Substring($directory.LastIndexOf('-')+1))){return $null} if($native){$edition=if([string]$cfg.authority_profile -ceq 'system_full_send'){'pro'}else{'standard'};$ownershipEvidence=$native} else{$edition=if(([string]$agent[0].Actions[0].Execute).Equals((Join-Path $pair.root 'GodMode-MMCP.exe'),[StringComparison]::OrdinalIgnoreCase)){'pro'}elseif(([string]$agent[0].Actions[0].Execute).Equals((Join-Path $pair.root 'MapleMCPAgentHost.exe'),[StringComparison]::OrdinalIgnoreCase) -or ([string]$agent[0].Actions[0].Execute).Equals((Join-Path $pair.root 'MapleRemoteAgentHost.exe'),[StringComparison]::OrdinalIgnoreCase)){'standard'}else{throw 'Supported agent task host is unknown.'};$ownershipEvidence=$agent[0]} if($edition -eq 'pro' -and ($cfg.authority_profile -cne 'system_full_send' -or [string]$cfg.enrollment_sha256 -notmatch '^[a-f0-9]{64}$')){return $null} if($edition -eq 'standard' -and $cfg.authority_profile -and $cfg.authority_profile -cne 'standard_user'){return $null} $checkpoint=$state.checkpoint if($null -ne $checkpoint -and ($checkpoint.sequence -lt 1 -or [string]$checkpoint.payloadSha256 -notmatch '^[a-f0-9]{64}$')){throw 'Retained journal checkpoint is invalid.'} return @{schema=1;kind='ordinary-uninstall';brand=$pair.brand;programFilesRoot=$pair.root;programDataRoot=$pair.data;configurationSha256=(Get-MapleUninstallHash $config);updateStateSha256=(Get-MapleUninstallHash $statePath);installation=@{edition=$edition;version=[string]$marker.version;componentSha256=([string]$marker.sha256).ToLowerInvariant();journal=@{activeSequence=[long]$state.active.sequence;highWater=[long]$state.highWater;checkpoint=$checkpoint};taskSha256=([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes(($ownershipEvidence|ConvertTo-Json -Depth 8 -Compress))))).Replace('-','').ToLowerInvariant())}} } function Write-MapleUninstallRetainedReceipt([object]$Receipt) { if($null -eq $Receipt){return} $path=Join-Path $Receipt.programDataRoot 'retained-uninstall.json';if(Test-Path -LiteralPath $path){throw 'A retained uninstall receipt already exists and requires recovery review.'} $temporary=$path+'.tmp-'+[Guid]::NewGuid().ToString('N');$json=$Receipt|ConvertTo-Json -Depth 16 -Compress $expected=([BitConverter]::ToString(([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($json)))).Replace('-','').ToLowerInvariant()) try{[IO.File]::WriteAllText($temporary,$json,[Text.UTF8Encoding]::new($false));[IO.File]::Move($temporary,$path)}finally{if(Test-Path -LiteralPath $temporary){Remove-Item -LiteralPath $temporary -Force}} if((Get-MapleUninstallHash $path) -ne $expected){throw 'Retained uninstall receipt publication failed.'} } function Get-MapleUninstallPlan([switch]$Purge) { $environment=Get-MapleUninstallEnvironment $installs=@('MapleMCP','MapleRemote'|ForEach-Object {Normalize-MapleUninstallPath (Join-Path $environment.programFiles $_)}) $data=@('MapleMCP','MapleRemote'|ForEach-Object {Normalize-MapleUninstallPath (Join-Path $environment.programData $_)}) $shortcuts=@('MapleMCP','MapleRemote'|ForEach-Object {Normalize-MapleUninstallPath (Join-Path $environment.programData ('Microsoft\Windows\Start Menu\Programs\'+$_))}) $profiles=@(Get-MapleUninstallProfiles);$browser=@() foreach($profile in $profiles){ Assert-MapleUninstallSafePath (Join-Path $profile.path 'NTUSER.DAT') Assert-MapleUninstallSafePath (Join-Path $profile.path 'AppData\Local\MapleMCP\BrowserCompanion\native-messaging\ca.maplevibe.maplemcp.browser.json') } if($Purge){ foreach($profile in $profiles){ foreach($relative in @('AppData\Local\MapleMCP\BrowserCompanion','AppData\Local\MapleMCP\Browser','AppData\Local\MapleMCP\agent-browser','AppData\Local\MapleMCP\browser-companion.json','AppData\Local\MapleRemote\BrowserCompanion','AppData\Local\MapleRemote\Browser','AppData\Local\MapleRemote\agent-browser','AppData\Local\MapleRemote\browser-companion.json')){ $target=Normalize-MapleUninstallPath (Join-Path $profile.path $relative) if(!(Test-MapleUninstallWithin $profile.path $target)){throw 'User cleanup escaped its registered profile.'} $browser+=$target } Assert-MapleUninstallSafePath (Join-Path $profile.path 'NTUSER.DAT') } } foreach($path in @($installs+$data+$shortcuts+$browser)){Assert-MapleUninstallSafePath $path -Tree} foreach($root in $data){ Assert-MapleUninstallSteadyState $root if(Test-Path -LiteralPath (Join-Path $root 'updater-operation-lock')){throw 'An existing updater operation lock requires recovery before uninstall.'} } $tasks=@(Get-MapleUninstallTasks);Assert-MapleUninstallTasks $tasks $installs $environment.windows $nativeService=Get-MapleUninstallNativeService $installs $keys=@();$registryBindings=@{} foreach($view in @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')){ foreach($name in @('MapleMCP','GodModeMMCP','{96A8F293-4A2B-4B93-9E7F-32D526139A28}_is1')){ $key=Join-Path $view $name if(Test-Path -LiteralPath $key){ $registration=Get-ItemProperty -LiteralPath $key if($registration.InstallLocation -and @($installs|Where-Object {$_.Equals(([string]$registration.InstallLocation).TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)}).Count -eq 0){throw 'Nondefault registered installation requires explicit review.'} $keys+=$key $registryBindings[$key]=$registration|Select-Object InstallLocation,UninstallString,DisplayName|ConvertTo-Json -Compress } } } # A preserved enrollment must stay byte-exact during ordinary uninstall. $preserved=@{} if(!$Purge){foreach($root in $data){foreach($name in @('agent.json','update-state.json','pro-promotion.json','pro-install.json')){$p=Join-Path $root $name;if(Test-Path -LiteralPath $p){$preserved[$p]=Get-MapleUninstallHash $p}}}} $presence=@{};foreach($path in @($installs+$data+$shortcuts+$browser)){$presence[$path]=[bool](Test-Path -LiteralPath $path)} $plan=@{schema=2;purge=[bool]$Purge;installRoots=$installs;dataRoots=$data;shortcuts=$shortcuts;browserPaths=$browser;profiles=$profiles;tasks=$tasks;nativeService=$nativeService;registryKeys=$keys;registryBindings=$registryBindings;preservedHashes=$preserved;presence=$presence;windows=$environment.windows} if(!$Purge){$plan.retainedReceipt=Get-MapleUninstallRetainedReceipt $plan} return $plan } function Enter-MapleUninstallLock([string]$Root) { if(-not ('MapleUninstallLockNative' -as [type])){ Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;public static class MapleUninstallLockNative{[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)]public static extern bool CreateDirectory(string path,IntPtr security);}' } $directory=Join-Path $Root 'updater-operation-lock' if(![MapleUninstallLockNative]::CreateDirectory($directory,[IntPtr]::Zero)){throw 'Shared updater operation lock is busy; nothing may be deleted.'} $owner=@{schema=1;pid=$PID;token=[Guid]::NewGuid().ToString('D');operation='uninstall';startedAt=[DateTime]::UtcNow.ToString('o')} # A failed publication deliberately leaves recovery evidence. & icacls.exe $directory /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F'|Out-Null if($LASTEXITCODE -ne 0){throw 'Could not protect uninstall operation lock.'} $owner|ConvertTo-Json -Compress|Set-Content -LiteralPath (Join-Path $directory 'owner.json') -Encoding UTF8 return @{directory=$directory;owner=$owner} } function Assert-MapleUninstallLock([object]$Lock) { $owner=Get-Content -Raw -LiteralPath (Join-Path $Lock.directory 'owner.json')|ConvertFrom-Json if($owner.pid -ne $PID -or $owner.token -cne $Lock.owner.token){throw 'Uninstall lock ownership changed; recovery evidence retained.'} } function Exit-MapleUninstallLock([object]$Lock) { Assert-MapleUninstallLock $Lock Remove-Item -LiteralPath (Join-Path $Lock.directory 'owner.json') -Force [IO.Directory]::Delete($Lock.directory,$false) } function Get-MapleUninstallProcesses([string[]]$Roots) { @(Get-CimInstance Win32_Process | Where-Object {$p=[string]$_.ExecutablePath;$p -and @($Roots|Where-Object {Test-MapleUninstallWithin $_ $p}).Count -gt 0}) } function Stop-MapleUninstallProcess([object]$Process) { # Re-read the PID and executable to avoid terminating a reused PID. $current=Get-CimInstance Win32_Process -Filter ('ProcessId='+[int]$Process.ProcessId) if(!$current){return} if($current.CreationDate -ne $Process.CreationDate -or $current.ExecutablePath -cne $Process.ExecutablePath){throw 'Process identity changed before uninstall termination.'} $result=Invoke-CimMethod -InputObject $current -MethodName Terminate if($result.ReturnValue -ne 0){throw ('Could not stop product process PID'+$Process.ProcessId)} } function Remove-MapleUninstallPath([string]$Path) { Assert-MapleUninstallSafePath $Path -Tree if(Test-Path -LiteralPath $Path){Remove-Item -LiteralPath $Path -Recurse -Force} if(Test-Path -LiteralPath $Path){throw ('Uninstall residue remains: '+$Path)} } function Open-MapleUninstallUserHive([object]$Profile) { if($Profile.sid -notmatch '^S-1-[0-9-]+$'){throw 'Invalid registered user SID.'} $path='Registry::HKEY_USERS\'+$Profile.sid if(Test-Path -LiteralPath $path){return @{path=$path;mounted=$false;name=''}} $file=Join-Path $Profile.path 'NTUSER.DAT' if(!(Test-Path -LiteralPath $file)){return $null} Assert-MapleUninstallSafePath $file $name='MapleMCPUninstall-'+[Guid]::NewGuid().ToString('N') & reg.exe load ('HKU\'+$name) $file|Out-Null if($LASTEXITCODE -ne 0){throw 'Cannot load a registered user hive; purge is incomplete.'} return @{path=('Registry::HKEY_USERS\'+$name);mounted=$true;name=$name} } function Close-MapleUninstallUserHive([object]$Hive) { if($Hive.mounted){[GC]::Collect();[GC]::WaitForPendingFinalizers();& reg.exe unload ('HKU\'+$Hive.name)|Out-Null;if($LASTEXITCODE -ne 0){throw 'Temporary user hive could not be unloaded.'}} } function Remove-MapleUninstallBrowserEnvironment([object]$Profile) { $hive=Open-MapleUninstallUserHive $Profile if(!$hive){return} try{ $path=Join-Path $hive.path 'Environment' if(Test-Path -LiteralPath $path){ $names=@((Get-ItemProperty -LiteralPath $path).PSObject.Properties.Name|Where-Object {$_ -eq 'MVRC_REMOTE_CAPABILITIES_JSON' -or $_ -like 'MVRC_BROWSER_COMPANION_*' -or $_ -like 'MVRC_AGENT_BROWSER_*'}) foreach($name in $names){Remove-ItemProperty -LiteralPath $path -Name $name} $after=(Get-ItemProperty -LiteralPath $path).PSObject.Properties.Name if(@($names|Where-Object {$after -contains $_}).Count){throw 'Product browser environment residue remains.'} } }finally{Close-MapleUninstallUserHive $hive} } function Remove-MapleUninstallBrowserNativeMessaging([object]$Profile,[string[]]$InstallRoots) { $hostName='ca.maplevibe.maplemcp.browser' $manifest=Normalize-MapleUninstallPath (Join-Path $Profile.path ('AppData\Local\MapleMCP\BrowserCompanion\native-messaging\'+$hostName+'.json')) Assert-MapleUninstallSafePath $manifest $ownedManifest=$false;$manifestHash='' if(Test-Path -LiteralPath $manifest -PathType Leaf){ $parsed=Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json $ownedManifest=[string]$parsed.name -ceq $hostName -and @($InstallRoots|Where-Object {([string]$parsed.path).Equals((Join-Path $_ 'MapleMCPBrowserNativeHost.exe'),[StringComparison]::OrdinalIgnoreCase)}).Count -eq 1 if($ownedManifest){$manifestHash=Get-MapleUninstallHash $manifest} } $hive=Open-MapleUninstallUserHive $Profile if(!$hive){return} try{ foreach($relative in @('Software\Google\Chrome\NativeMessagingHosts\','Software\Microsoft\Edge\NativeMessagingHosts\')){ $key=Join-Path $hive.path ($relative+$hostName) if(!(Test-Path -LiteralPath $key)){continue} [string]$actual=(Get-Item -LiteralPath $key).GetValue('') if([string]::IsNullOrWhiteSpace($actual)){continue} try{$bound=(Normalize-MapleUninstallPath $actual).Equals($manifest,[StringComparison]::OrdinalIgnoreCase)}catch{$bound=$false} if(!$bound){continue} if((Test-Path -LiteralPath $manifest) -and !$ownedManifest){throw 'Browser Native Messaging manifest is not MapleMCP-owned.'} if($ownedManifest -and (Get-MapleUninstallHash $manifest) -cne $manifestHash){throw 'Browser Native Messaging manifest changed during uninstall.'} Remove-Item -LiteralPath $key -Recurse -Force if(Test-Path -LiteralPath $key){throw 'Owned Browser Native Messaging registration remains.'} } if($ownedManifest){ if((Get-MapleUninstallHash $manifest) -cne $manifestHash){throw 'Browser Native Messaging manifest changed during uninstall.'} Remove-Item -LiteralPath $manifest -Force } }finally{Close-MapleUninstallUserHive $hive} } function Get-MapleUninstallControllerProcess([int]$ProcessId) {Get-CimInstance Win32_Process -Filter ('ProcessId='+$ProcessId)} function Assert-MapleUninstallIndependentController([string[]]$Roots) { $ancestorId=$PID;$seen=[Collections.Generic.HashSet[int]]::new() while($ancestorId -gt 0 -and $seen.Add($ancestorId)){ $process=Get-MapleUninstallControllerProcess $ancestorId if(!$process){if($ancestorId -eq $PID){throw 'Cannot verify the uninstaller controller process.'};break} if(@($Roots|Where-Object {Test-MapleUninstallWithin $_ ([string]$process.ExecutablePath)}).Count){throw 'Run uninstall from an independent elevated PowerShell, not from the MapleMCP agent or its managed process.'} $ancestorId=[int]$process.ParentProcessId } } function Invoke-MapleUninstall([object]$Plan) { $locks=@() try{ Assert-MapleUninstallIndependentController $Plan.installRoots foreach($path in $Plan.presence.Keys){if([bool](Test-Path -LiteralPath $path) -ne $Plan.presence[$path]){throw 'Installation paths changed after uninstall preflight.'}} foreach($root in $Plan.dataRoots){if(Test-Path -LiteralPath $root){$locks+=Enter-MapleUninstallLock $root}} foreach($root in $Plan.dataRoots){Assert-MapleUninstallSteadyState $root} foreach($path in $Plan.preservedHashes.Keys){if((Get-MapleUninstallHash $path) -cne $Plan.preservedHashes[$path]){throw 'Enrollment changed after uninstall preflight.'}} foreach($key in $Plan.registryBindings.Keys){ if(!(Test-Path -LiteralPath $key) -or ((Get-ItemProperty -LiteralPath $key|Select-Object InstallLocation,UninstallString,DisplayName|ConvertTo-Json -Compress) -cne $Plan.registryBindings[$key])){throw 'Uninstall registration changed after preflight.'} } # Detect task changes after preflight, before stopping anything. $current=@(Get-MapleUninstallTasks);Assert-MapleUninstallTasks $current $Plan.installRoots $Plan.windows if(($current|Select-Object TaskName,TaskPath,Actions|ConvertTo-Json -Depth 6 -Compress) -cne ($Plan.tasks|Select-Object TaskName,TaskPath,Actions|ConvertTo-Json -Depth 6 -Compress)){throw 'Tasks changed after uninstall preflight.'} $currentNative=Assert-MapleUninstallNativeServiceUnchanged $Plan.nativeService $Plan.installRoots if(!$Plan.purge){Write-MapleUninstallRetainedReceipt $Plan.retainedReceipt} foreach($task in $current){ Stop-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath Unregister-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -Confirm:$false } Remove-MapleUninstallNativeService $currentNative foreach($process in Get-MapleUninstallProcesses $Plan.installRoots){Stop-MapleUninstallProcess $process} $deadline=[DateTime]::UtcNow.AddSeconds(15) do{$remaining=@(Get-MapleUninstallProcesses $Plan.installRoots);if(!$remaining.Count){break};Start-Sleep -Milliseconds 150}while([DateTime]::UtcNow -lt $deadline) if($remaining.Count -or @(Get-MapleUninstallTasks).Count){throw 'Product tasks or processes remain; file removal stopped.'} foreach($lock in $locks){Assert-MapleUninstallLock $lock} foreach($profile in $Plan.profiles){Remove-MapleUninstallBrowserNativeMessaging $profile $Plan.installRoots} foreach($path in @($Plan.installRoots+$Plan.shortcuts)){ if($Plan.presence[$path]){Remove-MapleUninstallPath $path} elseif(Test-Path -LiteralPath $path){throw 'A new installation appeared during uninstall.'} } foreach($key in $Plan.registryKeys){Remove-Item -LiteralPath $key -Recurse -Force;if(Test-Path -LiteralPath $key){throw 'Uninstall registry residue remains.'}} if($Plan.purge){ foreach($path in $Plan.browserPaths){Remove-MapleUninstallPath $path} foreach($profile in $Plan.profiles){Remove-MapleUninstallBrowserEnvironment $profile} foreach($root in $Plan.dataRoots){ if(Test-Path -LiteralPath $root){ if(!$Plan.presence[$root]){throw 'A new data root appeared during uninstall.'} Assert-MapleUninstallSafePath $root -Tree foreach($child in Get-ChildItem -LiteralPath $root -Force){if($child.Name -cne 'updater-operation-lock'){Remove-MapleUninstallPath $child.FullName}} } } }else{ foreach($path in $Plan.preservedHashes.Keys){if(!(Test-Path -LiteralPath $path) -or (Get-MapleUninstallHash $path) -cne $Plan.preservedHashes[$path]){throw 'Preserved enrollment or journal changed during uninstall.'}} } }finally{ foreach($lock in $locks){Exit-MapleUninstallLock $lock} } if($Plan.purge){foreach($root in $Plan.dataRoots){if(Test-Path -LiteralPath $root){Assert-MapleUninstallSafePath $root -Tree;[IO.Directory]::Delete($root,$false)};if(Test-Path -LiteralPath $root){throw 'Data root remains; purge incomplete.'}}} $targets=@($Plan.installRoots+$Plan.shortcuts) if($Plan.purge){$targets+=@($Plan.dataRoots+$Plan.browserPaths)} $residue=@($targets|Where-Object {Test-Path -LiteralPath $_}) if($residue.Count -or @(Get-MapleUninstallTasks).Count -or @(Get-MapleUninstallProcesses $Plan.installRoots).Count -or (Get-CimInstance Win32_Service -Filter "Name='MapleMCP.Agent'" -ErrorAction SilentlyContinue)){throw 'Independent final residue check failed.'} return @{schema=2;status='removed';purged=[bool]$Plan.purge;localEnrollmentPreserved=(!$Plan.purge);retainedReinstallReceipt=($null -ne $Plan.retainedReceipt);removedRoots=$Plan.installRoots;checkedProfiles=@($Plan.profiles|ForEach-Object {$_.sid});serverEnrollmentRevoked=$false;externalRecoveryArchivesPreserved=$true} } if(!$LibraryOnly){ $plan=Get-MapleUninstallPlan -Purge:$Purge if($DryRun){ @{schema=2;status='dry-run';purge=[bool]$Purge;installRoots=$plan.installRoots;dataRoots=$plan.dataRoots;browserPaths=$plan.browserPaths;profileSids=@($plan.profiles|ForEach-Object {$_.sid});tasks=@($plan.tasks|Select-Object TaskName,TaskPath);nativeService=$plan.nativeService;serverEnrollmentRevoked=$false;externalRecoveryArchivesPreserved=$true}|ConvertTo-Json -Depth 6 }else{Invoke-MapleUninstall $plan|ConvertTo-Json -Depth 6} }